mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-08-15 10:34:35 +08:00
fix(music):优化音乐识别缓存管理UI
This commit is contained in:
397
src/components/cache/MusicRecognitionCachePanel.vue
vendored
397
src/components/cache/MusicRecognitionCachePanel.vue
vendored
@@ -1,51 +1,38 @@
|
||||
<script setup lang="ts">
|
||||
import { useToast } from 'vue-toastification'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import api from '@/api'
|
||||
import type { ApiResponse, MusicRecognitionCacheData, MusicRecognitionCacheItem } from '@/api/types'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
import type { MusicRecognitionCacheItem } from '@/api/types'
|
||||
import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue'
|
||||
import { useGlobalSettingsStore } from '@/stores'
|
||||
import { getDisplayImageUrl } from '@/utils/imageUtils'
|
||||
|
||||
type RecognitionStatusFilter = 'all' | 'recognized' | 'unrecognized'
|
||||
type InfiniteScrollStatus = 'ok' | 'empty' | 'loading' | 'error'
|
||||
|
||||
const MOBILE_CACHE_PAGE_SIZE = 20
|
||||
const MUSIC_CACHE_ENDPOINT = 'music/cache'
|
||||
|
||||
// 纯展示组件:数据加载与删除请求由父级识别缓存面板统一负责
|
||||
const props = defineProps<{
|
||||
// 已按搜索与状态条件过滤的音乐识别缓存
|
||||
items: MusicRecognitionCacheItem[]
|
||||
loading: boolean
|
||||
selectedItems: string[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:selectedItems', value: string[]): void
|
||||
(e: 'delete', key: string): void
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const display = useDisplay()
|
||||
const createConfirm = useConfirm()
|
||||
const $toast = useToast()
|
||||
const globalSettingsStore = useGlobalSettingsStore()
|
||||
|
||||
const isMobile = computed(() => display.smAndDown.value)
|
||||
const recognitionSourceName = computed(() => t('setting.cache.recognitionSource.musicbrainz'))
|
||||
const recognitionIdLabel = computed(() => t('setting.cache.musicbrainzId'))
|
||||
const recognitionFilterPlaceholder = computed(() =>
|
||||
t('setting.cache.filterRecognitionCache', { source: recognitionSourceName.value }),
|
||||
)
|
||||
const loading = ref(false)
|
||||
const searchFilter = ref('')
|
||||
const statusFilter = ref<RecognitionStatusFilter>('all')
|
||||
const selectedItems = ref<string[]>([])
|
||||
const cacheData = ref<MusicRecognitionCacheData>({
|
||||
count: 0,
|
||||
recognized: 0,
|
||||
unrecognized: 0,
|
||||
data: [],
|
||||
})
|
||||
|
||||
const mobileVisibleCount = ref(MOBILE_CACHE_PAGE_SIZE)
|
||||
const mobileInfiniteKey = ref(0)
|
||||
let cacheLoadRequestId = 0
|
||||
|
||||
const statusOptions = computed(() => [
|
||||
{ title: t('setting.cache.allStatuses'), value: 'all' },
|
||||
{ title: t('setting.cache.recognizedOnly'), value: 'recognized' },
|
||||
{ title: t('setting.cache.unrecognizedOnly'), value: 'unrecognized' },
|
||||
])
|
||||
|
||||
const tableHeaders = computed(() => [
|
||||
{ title: '', key: 'data-table-select', sortable: false, width: '48px' },
|
||||
@@ -57,22 +44,8 @@ const tableHeaders = computed(() => [
|
||||
{ title: t('setting.cache.actions'), key: 'actions', sortable: false, width: '72px' },
|
||||
])
|
||||
|
||||
const filteredData = computed(() => {
|
||||
const keyword = searchFilter.value.trim().toLowerCase()
|
||||
return cacheData.value.data.filter(item => {
|
||||
const matchesKeyword =
|
||||
!keyword ||
|
||||
[item.key, item.title, item.album, item.media_id, getArtistText(item), String(item.year ?? '')].some(value =>
|
||||
(value || '').toLowerCase().includes(keyword),
|
||||
)
|
||||
const matchesStatus =
|
||||
statusFilter.value === 'all' || (statusFilter.value === 'recognized' ? isRecognized(item) : !isRecognized(item))
|
||||
return matchesKeyword && matchesStatus
|
||||
})
|
||||
})
|
||||
|
||||
const mobileVisibleData = computed(() => filteredData.value.slice(0, mobileVisibleCount.value))
|
||||
const mobileHasMore = computed(() => mobileVisibleData.value.length < filteredData.value.length)
|
||||
const mobileVisibleData = computed(() => props.items.slice(0, mobileVisibleCount.value))
|
||||
const mobileHasMore = computed(() => mobileVisibleData.value.length < props.items.length)
|
||||
|
||||
/** 重置移动端分页,让筛选或刷新后的识别缓存从第一页开始展示。 */
|
||||
function resetMobilePagination() {
|
||||
@@ -82,7 +55,7 @@ function resetMobilePagination() {
|
||||
|
||||
/** 追加移动端下一页识别缓存,并由虚拟滚动限制实际渲染节点。 */
|
||||
function loadMoreMobileCache({ done }: { done: (status: InfiniteScrollStatus) => void }) {
|
||||
if (loading.value) {
|
||||
if (props.loading) {
|
||||
done('ok')
|
||||
return
|
||||
}
|
||||
@@ -92,104 +65,13 @@ function loadMoreMobileCache({ done }: { done: (status: InfiniteScrollStatus) =>
|
||||
return
|
||||
}
|
||||
|
||||
mobileVisibleCount.value = Math.min(mobileVisibleCount.value + MOBILE_CACHE_PAGE_SIZE, filteredData.value.length)
|
||||
mobileVisibleCount.value = Math.min(mobileVisibleCount.value + MOBILE_CACHE_PAGE_SIZE, props.items.length)
|
||||
done(mobileHasMore.value ? 'ok' : 'empty')
|
||||
}
|
||||
|
||||
/** 加载音乐识别缓存列表。 */
|
||||
async function loadCacheData(showSuccess = false) {
|
||||
const requestId = ++cacheLoadRequestId
|
||||
try {
|
||||
loading.value = true
|
||||
const response = (await api.get(MUSIC_CACHE_ENDPOINT)) as unknown as ApiResponse<MusicRecognitionCacheData>
|
||||
if (requestId !== cacheLoadRequestId) return
|
||||
const responseData = response.data ?? {
|
||||
count: 0,
|
||||
recognized: 0,
|
||||
unrecognized: 0,
|
||||
data: [],
|
||||
}
|
||||
cacheData.value = {
|
||||
...responseData,
|
||||
data: (responseData.data ?? []).map(item => ({ ...item })),
|
||||
}
|
||||
selectedItems.value = selectedItems.value.filter(key => cacheData.value.data.some(item => item.key === key))
|
||||
resetMobilePagination()
|
||||
if (showSuccess) $toast.success(t('setting.cache.listRefreshSuccess'))
|
||||
} catch (error) {
|
||||
if (requestId !== cacheLoadRequestId) return
|
||||
console.error(error)
|
||||
$toast.error(t('setting.cache.loadFailed'))
|
||||
} finally {
|
||||
if (requestId === cacheLoadRequestId) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 清空全部音乐识别缓存。 */
|
||||
async function clearAllCache() {
|
||||
const confirmed = await createConfirm({
|
||||
type: 'warn',
|
||||
title: t('common.confirm'),
|
||||
content: t('setting.cache.recognitionClearConfirm', { source: recognitionSourceName.value }),
|
||||
})
|
||||
if (!confirmed) return
|
||||
|
||||
try {
|
||||
loading.value = true
|
||||
const response = (await api.delete(MUSIC_CACHE_ENDPOINT)) as unknown as ApiResponse
|
||||
if (!response.success) throw new Error(response.message)
|
||||
$toast.success(response.message || t('setting.cache.clearSuccess'))
|
||||
await loadCacheData()
|
||||
selectedItems.value = []
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
$toast.error(t('setting.cache.clearFailed'))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 请求接口删除指定音乐识别缓存。 */
|
||||
async function deleteCacheItem(key: string) {
|
||||
const response = (await api.delete(`${MUSIC_CACHE_ENDPOINT}/${encodeURIComponent(key)}`)) as unknown as ApiResponse
|
||||
if (!response.success) throw new Error(response.message)
|
||||
}
|
||||
|
||||
/** 删除桌面端表格中选中的识别缓存。 */
|
||||
async function deleteSelectedItems() {
|
||||
if (selectedItems.value.length === 0) {
|
||||
$toast.warning(t('setting.cache.selectDeleteWarning'))
|
||||
return
|
||||
}
|
||||
|
||||
const deleteCount = selectedItems.value.length
|
||||
try {
|
||||
loading.value = true
|
||||
await Promise.all(selectedItems.value.map(deleteCacheItem))
|
||||
$toast.success(t('setting.cache.deleteSelectedSuccess', { count: deleteCount }))
|
||||
await loadCacheData()
|
||||
selectedItems.value = []
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
$toast.error(t('setting.cache.deleteSelectedFailed'))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 删除单条识别缓存。 */
|
||||
async function deleteSingleItem(item: MusicRecognitionCacheItem) {
|
||||
try {
|
||||
loading.value = true
|
||||
await deleteCacheItem(item.key)
|
||||
$toast.success(t('setting.cache.deleteSuccess'))
|
||||
await loadCacheData()
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
$toast.error(t('setting.cache.deleteFailed'))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
/** 更新桌面端表格选中项。 */
|
||||
function updateSelectedItems(value: unknown) {
|
||||
emit('update:selectedItems', (value as string[]) ?? [])
|
||||
}
|
||||
|
||||
/** 获取音乐识别缓存封面的可展示地址。 */
|
||||
@@ -232,103 +114,11 @@ function getRecognitionStatusLabel(item: MusicRecognitionCacheItem): string {
|
||||
return isRecognized(item) ? t('setting.cache.recognized') : t('setting.cache.unrecognized')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void loadCacheData()
|
||||
})
|
||||
|
||||
watch([searchFilter, statusFilter], () => {
|
||||
resetMobilePagination()
|
||||
})
|
||||
watch(() => props.items, resetMobilePagination)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="music-cache-panel">
|
||||
<div class="cache-panel-toolbar">
|
||||
<div class="cache-panel-stats">
|
||||
<div class="cache-panel-stat cache-panel-stat--primary">
|
||||
<VIcon icon="mdi-database-outline" :size="isMobile ? 32 : 22" />
|
||||
<div>
|
||||
<strong>{{ cacheData.count }}</strong>
|
||||
<span>{{ t('setting.cache.totalCount') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cache-panel-stat cache-panel-stat--success">
|
||||
<VIcon icon="mdi-check-decagram-outline" :size="isMobile ? 32 : 22" />
|
||||
<div>
|
||||
<strong>{{ cacheData.recognized }}</strong>
|
||||
<span>{{ t('setting.cache.recognized') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cache-panel-stat cache-panel-stat--warning">
|
||||
<VIcon icon="mdi-help-circle-outline" :size="isMobile ? 32 : 22" />
|
||||
<div>
|
||||
<strong>{{ cacheData.unrecognized }}</strong>
|
||||
<span>{{ t('setting.cache.unrecognized') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!isMobile" class="cache-panel-actions">
|
||||
<VBtn icon variant="text" color="primary" :loading="loading" @click="loadCacheData(true)">
|
||||
<VIcon icon="mdi-refresh" />
|
||||
<VTooltip activator="parent" location="bottom">{{ t('setting.cache.refreshList') }}</VTooltip>
|
||||
</VBtn>
|
||||
<VBtn
|
||||
icon
|
||||
variant="text"
|
||||
color="warning"
|
||||
:disabled="selectedItems.length === 0"
|
||||
:loading="loading"
|
||||
@click="deleteSelectedItems"
|
||||
>
|
||||
<VIcon icon="mdi-delete-sweep-outline" />
|
||||
<VTooltip activator="parent" location="bottom">
|
||||
{{ t('setting.cache.deleteSelected') }} ({{ selectedItems.length }})
|
||||
</VTooltip>
|
||||
</VBtn>
|
||||
<VBtn icon variant="text" color="error" :loading="loading" @click="clearAllCache">
|
||||
<VIcon icon="mdi-delete-variant" />
|
||||
<VTooltip activator="parent" location="bottom">{{ t('setting.cache.clearAll') }}</VTooltip>
|
||||
</VBtn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cache-panel-filters">
|
||||
<VTextField
|
||||
v-model="searchFilter"
|
||||
class="cache-panel-filter"
|
||||
:label="isMobile ? undefined : recognitionFilterPlaceholder"
|
||||
:placeholder="isMobile ? recognitionFilterPlaceholder : undefined"
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
variant="outlined"
|
||||
:density="isMobile ? 'comfortable' : 'compact'"
|
||||
:single-line="isMobile"
|
||||
clearable
|
||||
hide-details
|
||||
/>
|
||||
<VSelect
|
||||
v-model="statusFilter"
|
||||
class="cache-panel-filter"
|
||||
:label="isMobile ? undefined : t('setting.cache.recognitionStatus')"
|
||||
:placeholder="isMobile ? t('setting.cache.recognitionStatus') : undefined"
|
||||
:items="statusOptions"
|
||||
prepend-inner-icon="mdi-list-status"
|
||||
variant="outlined"
|
||||
:density="isMobile ? 'comfortable' : 'compact'"
|
||||
:single-line="isMobile"
|
||||
hide-details
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="isMobile" class="cache-panel-mobile-actions">
|
||||
<VBtn variant="tonal" color="primary" :loading="loading" prepend-icon="mdi-refresh" @click="loadCacheData(true)">
|
||||
{{ t('setting.cache.refresh') }}
|
||||
</VBtn>
|
||||
<VBtn variant="tonal" color="error" :loading="loading" prepend-icon="mdi-delete-variant" @click="clearAllCache">
|
||||
{{ t('setting.cache.clearAll') }}
|
||||
</VBtn>
|
||||
</div>
|
||||
|
||||
<template v-if="isMobile">
|
||||
<VInfiniteScroll
|
||||
v-if="mobileVisibleData.length > 0 || loading"
|
||||
@@ -384,7 +174,7 @@ watch([searchFilter, statusFilter], () => {
|
||||
variant="text"
|
||||
color="error"
|
||||
:aria-label="t('common.delete')"
|
||||
@click="deleteSingleItem(item)"
|
||||
@click="emit('delete', item.key)"
|
||||
>
|
||||
<VIcon icon="mdi-delete-outline" size="20" />
|
||||
</VBtn>
|
||||
@@ -402,10 +192,10 @@ watch([searchFilter, statusFilter], () => {
|
||||
|
||||
<VDataTable
|
||||
v-else
|
||||
v-model="selectedItems"
|
||||
:model-value="selectedItems"
|
||||
class="music-cache-table"
|
||||
:headers="tableHeaders"
|
||||
:items="filteredData"
|
||||
:items="items"
|
||||
:loading="loading"
|
||||
item-value="key"
|
||||
show-select
|
||||
@@ -413,6 +203,7 @@ watch([searchFilter, statusFilter], () => {
|
||||
:items-per-page-text="t('common.itemsPerPage')"
|
||||
:no-data-text="t('common.noDataText')"
|
||||
:loading-text="t('common.loadingText')"
|
||||
@update:model-value="updateSelectedItems"
|
||||
>
|
||||
<template #item.poster="{ item }">
|
||||
<div class="music-cache-table__cover rounded-md">
|
||||
@@ -457,7 +248,7 @@ watch([searchFilter, statusFilter], () => {
|
||||
variant="text"
|
||||
color="error"
|
||||
:aria-label="t('common.delete')"
|
||||
@click="deleteSingleItem(item)"
|
||||
@click="emit('delete', item.key)"
|
||||
>
|
||||
<VIcon icon="mdi-delete-outline" size="18" />
|
||||
<VTooltip activator="parent" location="start">{{ t('common.delete') }}</VTooltip>
|
||||
@@ -482,93 +273,9 @@ watch([searchFilter, statusFilter], () => {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
min-block-size: 0;
|
||||
}
|
||||
|
||||
.cache-panel-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.cache-panel-stats {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.cache-panel-stat {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border: var(--app-surface-border);
|
||||
border-radius: var(--app-surface-radius);
|
||||
background: var(--app-grouped-list-background);
|
||||
box-shadow: var(--app-surface-shadow);
|
||||
gap: 10px;
|
||||
min-block-size: 58px;
|
||||
min-inline-size: 126px;
|
||||
padding-block: 10px;
|
||||
padding-inline: 14px;
|
||||
}
|
||||
|
||||
.cache-panel-stat strong,
|
||||
.cache-panel-stat span {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.cache-panel-stat strong {
|
||||
color: rgba(var(--v-theme-on-surface), 0.9);
|
||||
font-size: 18px;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.cache-panel-stat span {
|
||||
color: rgba(var(--v-theme-on-surface), 0.58);
|
||||
font-size: 12px;
|
||||
margin-block-start: 3px;
|
||||
}
|
||||
|
||||
.cache-panel-stat--primary {
|
||||
color: rgb(var(--v-theme-primary));
|
||||
}
|
||||
|
||||
.cache-panel-stat--success {
|
||||
color: rgb(var(--v-theme-success));
|
||||
}
|
||||
|
||||
.cache-panel-stat--warning {
|
||||
color: rgb(var(--v-theme-warning));
|
||||
}
|
||||
|
||||
.cache-panel-actions {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.cache-panel-filters {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(180px, 0.35fr);
|
||||
}
|
||||
|
||||
.cache-panel-filters :deep(.v-field) {
|
||||
border-radius: var(--app-field-radius);
|
||||
background: var(--app-grouped-list-background);
|
||||
}
|
||||
|
||||
.cache-panel-mobile-actions {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.cache-panel-mobile-actions :deep(.v-btn) {
|
||||
min-block-size: 44px;
|
||||
}
|
||||
|
||||
.music-cache-table {
|
||||
overflow: hidden;
|
||||
border: var(--app-surface-border);
|
||||
@@ -652,54 +359,6 @@ watch([searchFilter, statusFilter], () => {
|
||||
}
|
||||
|
||||
@media (width <= 959.98px) {
|
||||
.cache-panel-toolbar {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.cache-panel-stats {
|
||||
display: grid;
|
||||
flex: 1 1 auto;
|
||||
gap: 12px;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.cache-panel-stat {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
padding: 18px;
|
||||
gap: 14px;
|
||||
min-block-size: 92px;
|
||||
min-inline-size: 0;
|
||||
}
|
||||
|
||||
.cache-panel-stat strong {
|
||||
font-size: 28px;
|
||||
font-weight: 800;
|
||||
line-height: 1.05;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cache-panel-stat span {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
margin-block-start: 8px;
|
||||
}
|
||||
|
||||
.cache-panel-filters {
|
||||
gap: 10px;
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.cache-panel-filter :deep(.v-field__outline) {
|
||||
color: rgba(var(--v-theme-on-surface), 0.18);
|
||||
}
|
||||
|
||||
.cache-panel-filter :deep(.v-field__input) {
|
||||
color: rgba(var(--v-theme-on-surface), 0.72);
|
||||
font-size: 16px;
|
||||
min-block-size: 54px;
|
||||
}
|
||||
|
||||
.music-cache-mobile-scroll {
|
||||
overflow: visible !important;
|
||||
min-block-size: 20rem;
|
||||
|
||||
424
src/components/cache/RecognitionCachePanel.vue
vendored
424
src/components/cache/RecognitionCachePanel.vue
vendored
@@ -3,7 +3,13 @@ import { useToast } from 'vue-toastification'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import api from '@/api'
|
||||
import type { ApiResponse, RecognitionCacheData, RecognitionCacheItem } from '@/api/types'
|
||||
import type {
|
||||
ApiResponse,
|
||||
MusicRecognitionCacheData,
|
||||
MusicRecognitionCacheItem,
|
||||
RecognitionCacheData,
|
||||
RecognitionCacheItem,
|
||||
} from '@/api/types'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue'
|
||||
import MusicRecognitionCachePanel from '@/components/cache/MusicRecognitionCachePanel.vue'
|
||||
@@ -11,11 +17,12 @@ import { useGlobalSettingsStore } from '@/stores'
|
||||
import { getDisplayImageUrl } from '@/utils/imageUtils'
|
||||
|
||||
type RecognitionStatusFilter = 'all' | 'recognized' | 'unrecognized'
|
||||
type RecognitionCategory = 'media' | 'music'
|
||||
type RecognitionTypeFilter = 'all' | 'media' | 'music'
|
||||
type InfiniteScrollStatus = 'ok' | 'empty' | 'loading' | 'error'
|
||||
|
||||
const MOBILE_CACHE_PAGE_SIZE = 20
|
||||
const RECOGNITION_CACHE_ENDPOINT = 'tmdb/cache'
|
||||
const TMDB_CACHE_ENDPOINT = 'tmdb/cache'
|
||||
const MUSIC_CACHE_ENDPOINT = 'music/cache'
|
||||
|
||||
const { t } = useI18n()
|
||||
const display = useDisplay()
|
||||
@@ -25,16 +32,16 @@ const globalSettingsStore = useGlobalSettingsStore()
|
||||
|
||||
const isMobile = computed(() => display.smAndDown.value)
|
||||
const recognitionSourceName = computed(() => t('setting.cache.recognitionSource.themoviedb'))
|
||||
const musicSourceName = computed(() => t('setting.cache.recognitionSource.musicbrainz'))
|
||||
const recognitionIdLabel = computed(() => t('setting.cache.tmdbId'))
|
||||
const recognitionFilterPlaceholder = computed(() =>
|
||||
t('setting.cache.filterRecognitionCache', { source: recognitionSourceName.value }),
|
||||
)
|
||||
const recognitionFilterPlaceholder = computed(() => t('setting.cache.filterAllRecognitionCache'))
|
||||
const loading = ref(false)
|
||||
const searchFilter = ref('')
|
||||
const statusFilter = ref<RecognitionStatusFilter>('all')
|
||||
// 类型筛选:全部 / 影视(TMDB)/ 音乐(MusicBrainz),切换后对应表格切换显示
|
||||
const typeFilter = ref<RecognitionTypeFilter>('all')
|
||||
const selectedItems = ref<string[]>([])
|
||||
// 识别缓存分类:影视(TMDB)与音乐(MusicBrainz)
|
||||
const recognitionCategory = ref<RecognitionCategory>('media')
|
||||
const selectedMusicItems = ref<string[]>([])
|
||||
const cacheData = ref<RecognitionCacheData>({
|
||||
count: 0,
|
||||
recognized: 0,
|
||||
@@ -43,6 +50,12 @@ const cacheData = ref<RecognitionCacheData>({
|
||||
shared_recognize_enabled: false,
|
||||
data: [],
|
||||
})
|
||||
const musicCacheData = ref<MusicRecognitionCacheData>({
|
||||
count: 0,
|
||||
recognized: 0,
|
||||
unrecognized: 0,
|
||||
data: [],
|
||||
})
|
||||
const mobileVisibleCount = ref(MOBILE_CACHE_PAGE_SIZE)
|
||||
const mobileInfiniteKey = ref(0)
|
||||
let cacheLoadRequestId = 0
|
||||
@@ -53,6 +66,21 @@ const statusOptions = computed(() => [
|
||||
{ title: t('setting.cache.unrecognizedOnly'), value: 'unrecognized' },
|
||||
])
|
||||
|
||||
const typeOptions = computed(() => [
|
||||
{ title: t('setting.cache.recognitionTypeOptions.all'), value: 'all' },
|
||||
{ title: t('setting.cache.recognitionTypeOptions.media'), value: 'media' },
|
||||
{ title: t('setting.cache.recognitionTypeOptions.music'), value: 'music' },
|
||||
])
|
||||
|
||||
// 影视与音乐缓存统计汇总展示
|
||||
const totalCount = computed(() => cacheData.value.count + musicCacheData.value.count)
|
||||
const recognizedCount = computed(() => cacheData.value.recognized + musicCacheData.value.recognized)
|
||||
const unrecognizedCount = computed(() => cacheData.value.unrecognized + musicCacheData.value.unrecognized)
|
||||
const totalSelectedCount = computed(() => selectedItems.value.length + selectedMusicItems.value.length)
|
||||
|
||||
const showMediaSection = computed(() => typeFilter.value !== 'music')
|
||||
const showMusicSection = computed(() => typeFilter.value !== 'media')
|
||||
|
||||
const tableHeaders = computed(() => [
|
||||
{ title: '', key: 'data-table-select', sortable: false, width: '48px' },
|
||||
{ title: t('setting.cache.poster'), key: 'poster', sortable: false, width: '76px' },
|
||||
@@ -75,6 +103,21 @@ const filteredData = computed(() => {
|
||||
})
|
||||
})
|
||||
|
||||
const filteredMusicData = computed(() => {
|
||||
const keyword = searchFilter.value.trim().toLowerCase()
|
||||
return musicCacheData.value.data.filter(item => {
|
||||
const matchesKeyword =
|
||||
!keyword ||
|
||||
[item.key, item.title, item.album, item.media_id, getMusicArtistText(item), String(item.year ?? '')].some(value =>
|
||||
(value || '').toLowerCase().includes(keyword),
|
||||
)
|
||||
const matchesStatus =
|
||||
statusFilter.value === 'all' ||
|
||||
(statusFilter.value === 'recognized' ? isMusicRecognized(item) : !isMusicRecognized(item))
|
||||
return matchesKeyword && matchesStatus
|
||||
})
|
||||
})
|
||||
|
||||
const mobileVisibleData = computed(() => filteredData.value.slice(0, mobileVisibleCount.value))
|
||||
const mobileHasMore = computed(() => mobileVisibleData.value.length < filteredData.value.length)
|
||||
|
||||
@@ -100,12 +143,15 @@ function loadMoreMobileCache({ done }: { done: (status: InfiniteScrollStatus) =>
|
||||
done(mobileHasMore.value ? 'ok' : 'empty')
|
||||
}
|
||||
|
||||
/** 加载 TMDB 主识别缓存列表。 */
|
||||
/** 并行加载影视(TMDB)与音乐(MusicBrainz)识别缓存。 */
|
||||
async function loadCacheData(showSuccess = false) {
|
||||
const requestId = ++cacheLoadRequestId
|
||||
try {
|
||||
loading.value = true
|
||||
const response = (await api.get(RECOGNITION_CACHE_ENDPOINT)) as unknown as ApiResponse<RecognitionCacheData>
|
||||
const [response, musicResponse] = (await Promise.all([
|
||||
api.get(TMDB_CACHE_ENDPOINT),
|
||||
api.get(MUSIC_CACHE_ENDPOINT),
|
||||
])) as unknown as [ApiResponse<RecognitionCacheData>, ApiResponse<MusicRecognitionCacheData>]
|
||||
if (requestId !== cacheLoadRequestId) return
|
||||
const responseData = response.data ?? {
|
||||
count: 0,
|
||||
@@ -121,7 +167,15 @@ async function loadCacheData(showSuccess = false) {
|
||||
shared_recognize_enabled: responseData.shared_recognize_enabled ?? false,
|
||||
data: responseData.data.map(item => ({ ...item, recognition_id: getRecognitionId(item) })),
|
||||
}
|
||||
const musicData = musicResponse.data ?? { count: 0, recognized: 0, unrecognized: 0, data: [] }
|
||||
musicCacheData.value = {
|
||||
...musicData,
|
||||
data: (musicData.data ?? []).map(item => ({ ...item })),
|
||||
}
|
||||
selectedItems.value = selectedItems.value.filter(key => cacheData.value.data.some(item => item.key === key))
|
||||
selectedMusicItems.value = selectedMusicItems.value.filter(key =>
|
||||
musicCacheData.value.data.some(item => item.key === key),
|
||||
)
|
||||
resetMobilePagination()
|
||||
if (showSuccess) $toast.success(t('setting.cache.listRefreshSuccess'))
|
||||
} catch (error) {
|
||||
@@ -133,22 +187,34 @@ async function loadCacheData(showSuccess = false) {
|
||||
}
|
||||
}
|
||||
|
||||
/** 清空全部 TMDB 主识别缓存。 */
|
||||
/** 清空当前类型筛选范围内的识别缓存,全部类型时影视与音乐一并清空。 */
|
||||
async function clearAllCache() {
|
||||
const clearTargets: string[] = []
|
||||
if (typeFilter.value !== 'music') clearTargets.push(TMDB_CACHE_ENDPOINT)
|
||||
if (typeFilter.value !== 'media') clearTargets.push(MUSIC_CACHE_ENDPOINT)
|
||||
const content =
|
||||
typeFilter.value === 'all'
|
||||
? t('setting.cache.recognitionClearAllConfirm')
|
||||
: t('setting.cache.recognitionClearConfirm', {
|
||||
source: typeFilter.value === 'media' ? recognitionSourceName.value : musicSourceName.value,
|
||||
})
|
||||
const confirmed = await createConfirm({
|
||||
type: 'warn',
|
||||
title: t('common.confirm'),
|
||||
content: t('setting.cache.recognitionClearConfirm', { source: recognitionSourceName.value }),
|
||||
content,
|
||||
})
|
||||
if (!confirmed) return
|
||||
|
||||
try {
|
||||
loading.value = true
|
||||
const response = (await api.delete(RECOGNITION_CACHE_ENDPOINT)) as unknown as ApiResponse
|
||||
if (!response.success) throw new Error(response.message)
|
||||
$toast.success(response.message || t('setting.cache.clearSuccess'))
|
||||
const responses = (await Promise.all(
|
||||
clearTargets.map(endpoint => api.delete(endpoint)),
|
||||
)) as unknown as ApiResponse[]
|
||||
if (responses.some(item => !item.success)) throw new Error(responses.find(item => !item.success)?.message)
|
||||
$toast.success(t('setting.cache.clearSuccess'))
|
||||
await loadCacheData()
|
||||
selectedItems.value = []
|
||||
selectedMusicItems.value = []
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
$toast.error(t('setting.cache.clearFailed'))
|
||||
@@ -157,28 +223,36 @@ async function clearAllCache() {
|
||||
}
|
||||
}
|
||||
|
||||
/** 请求 TMDB 接口删除指定识别缓存。 */
|
||||
/** 请求接口删除指定影视识别缓存。 */
|
||||
async function deleteCacheItem(key: string) {
|
||||
const response = (await api.delete(
|
||||
`${RECOGNITION_CACHE_ENDPOINT}/${encodeURIComponent(key)}`,
|
||||
)) as unknown as ApiResponse
|
||||
const response = (await api.delete(`${TMDB_CACHE_ENDPOINT}/${encodeURIComponent(key)}`)) as unknown as ApiResponse
|
||||
if (!response.success) throw new Error(response.message)
|
||||
}
|
||||
|
||||
/** 删除桌面端表格中选中的识别缓存。 */
|
||||
/** 请求接口删除指定音乐识别缓存。 */
|
||||
async function deleteMusicCacheItem(key: string) {
|
||||
const response = (await api.delete(`${MUSIC_CACHE_ENDPOINT}/${encodeURIComponent(key)}`)) as unknown as ApiResponse
|
||||
if (!response.success) throw new Error(response.message)
|
||||
}
|
||||
|
||||
/** 删除两个表格中选中的识别缓存。 */
|
||||
async function deleteSelectedItems() {
|
||||
if (selectedItems.value.length === 0) {
|
||||
if (totalSelectedCount.value === 0) {
|
||||
$toast.warning(t('setting.cache.selectDeleteWarning'))
|
||||
return
|
||||
}
|
||||
|
||||
const deleteCount = selectedItems.value.length
|
||||
const deleteCount = totalSelectedCount.value
|
||||
try {
|
||||
loading.value = true
|
||||
await Promise.all(selectedItems.value.map(deleteCacheItem))
|
||||
await Promise.all([
|
||||
...selectedItems.value.map(deleteCacheItem),
|
||||
...selectedMusicItems.value.map(deleteMusicCacheItem),
|
||||
])
|
||||
$toast.success(t('setting.cache.deleteSelectedSuccess', { count: deleteCount }))
|
||||
await loadCacheData()
|
||||
selectedItems.value = []
|
||||
selectedMusicItems.value = []
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
$toast.error(t('setting.cache.deleteSelectedFailed'))
|
||||
@@ -187,7 +261,7 @@ async function deleteSelectedItems() {
|
||||
}
|
||||
}
|
||||
|
||||
/** 删除单条识别缓存。 */
|
||||
/** 删除单条影视识别缓存。 */
|
||||
async function deleteSingleItem(item: RecognitionCacheItem) {
|
||||
try {
|
||||
loading.value = true
|
||||
@@ -202,6 +276,21 @@ async function deleteSingleItem(item: RecognitionCacheItem) {
|
||||
}
|
||||
}
|
||||
|
||||
/** 删除单条音乐识别缓存,由音乐表格组件上抛事件触发。 */
|
||||
async function deleteMusicItem(key: string) {
|
||||
try {
|
||||
loading.value = true
|
||||
await deleteMusicCacheItem(key)
|
||||
$toast.success(t('setting.cache.deleteSuccess'))
|
||||
await loadCacheData()
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
$toast.error(t('setting.cache.deleteFailed'))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 获取识别缓存海报的可展示地址。 */
|
||||
function getPosterUrl(item: RecognitionCacheItem): string {
|
||||
if (!item.poster_path) return ''
|
||||
@@ -216,12 +305,22 @@ function getRecognitionId(item: RecognitionCacheItem): string {
|
||||
return item.tmdb_id ? String(item.tmdb_id) : ''
|
||||
}
|
||||
|
||||
/** 判断识别缓存条目是否包含有效媒体 ID。 */
|
||||
/** 判断影视识别缓存条目是否包含有效媒体 ID。 */
|
||||
function isRecognized(item: RecognitionCacheItem): boolean {
|
||||
const recognitionId = getRecognitionId(item)
|
||||
return Boolean(recognitionId && recognitionId !== '0')
|
||||
}
|
||||
|
||||
/** 判断音乐识别缓存条目是否包含有效媒体 ID。 */
|
||||
function isMusicRecognized(item: MusicRecognitionCacheItem): boolean {
|
||||
return Boolean(item.media_id)
|
||||
}
|
||||
|
||||
/** 获取音乐缓存条目的艺术家展示文本,参与关键词搜索。 */
|
||||
function getMusicArtistText(item: MusicRecognitionCacheItem): string {
|
||||
return (item.artists || []).join(' / ')
|
||||
}
|
||||
|
||||
/** 获取移动端识别缓存卡片的稳定渲染 key。 */
|
||||
function getRecognitionCacheItemKey(item: RecognitionCacheItem): string {
|
||||
return item.key
|
||||
@@ -250,136 +349,122 @@ onMounted(() => {
|
||||
void loadCacheData()
|
||||
})
|
||||
|
||||
watch([searchFilter, statusFilter], () => {
|
||||
watch([searchFilter, statusFilter, typeFilter], () => {
|
||||
resetMobilePagination()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="recognition-cache-panel">
|
||||
<div class="recognition-cache-categories">
|
||||
<VBtnToggle
|
||||
v-model="recognitionCategory"
|
||||
mandatory
|
||||
divided
|
||||
density="comfortable"
|
||||
variant="text"
|
||||
color="primary"
|
||||
class="recognition-cache-categories__switcher"
|
||||
:aria-label="t('setting.cache.recognitionCategoryLabel')"
|
||||
>
|
||||
<VBtn value="media" prepend-icon="mdi-movie-search-outline">
|
||||
{{ t('setting.cache.recognitionCategory.media') }}
|
||||
<div class="cache-panel-toolbar">
|
||||
<div class="cache-panel-stats">
|
||||
<div class="cache-panel-stat cache-panel-stat--primary">
|
||||
<VIcon icon="mdi-database-outline" :size="isMobile ? 32 : 22" />
|
||||
<div>
|
||||
<strong>{{ totalCount }}</strong>
|
||||
<span>{{ t('setting.cache.totalCount') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cache-panel-stat cache-panel-stat--success">
|
||||
<VIcon icon="mdi-check-decagram-outline" :size="isMobile ? 32 : 22" />
|
||||
<div>
|
||||
<strong>{{ recognizedCount }}</strong>
|
||||
<span>{{ t('setting.cache.recognized') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!isMobile || cacheData.shared_recognize_enabled" class="cache-panel-stat cache-panel-stat--warning">
|
||||
<VIcon icon="mdi-help-circle-outline" size="22" />
|
||||
<div>
|
||||
<strong>{{ unrecognizedCount }}</strong>
|
||||
<span>{{ t('setting.cache.unrecognized') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="cacheData.shared_recognize_enabled" class="cache-panel-stat cache-panel-stat--info">
|
||||
<VIcon icon="mdi-cloud-check-outline" :size="isMobile ? 32 : 22" />
|
||||
<div>
|
||||
<strong>{{ cacheData.shared_recognized }}</strong>
|
||||
<span>{{ t('setting.cache.sharedRecognized') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!isMobile" class="cache-panel-actions">
|
||||
<VBtn icon variant="text" color="primary" :loading="loading" @click="loadCacheData(true)">
|
||||
<VIcon icon="mdi-refresh" />
|
||||
<VTooltip activator="parent" location="bottom">{{ t('setting.cache.refreshList') }}</VTooltip>
|
||||
</VBtn>
|
||||
<VBtn value="music" prepend-icon="mdi-music-note-outline">
|
||||
{{ t('setting.cache.recognitionCategory.music') }}
|
||||
<VBtn
|
||||
icon
|
||||
variant="text"
|
||||
color="warning"
|
||||
:disabled="totalSelectedCount === 0"
|
||||
:loading="loading"
|
||||
@click="deleteSelectedItems"
|
||||
>
|
||||
<VIcon icon="mdi-delete-sweep-outline" />
|
||||
<VTooltip activator="parent" location="bottom">
|
||||
{{ t('setting.cache.deleteSelected') }} ({{ totalSelectedCount }})
|
||||
</VTooltip>
|
||||
</VBtn>
|
||||
</VBtnToggle>
|
||||
<VBtn icon variant="text" color="error" :loading="loading" @click="clearAllCache">
|
||||
<VIcon icon="mdi-delete-variant" />
|
||||
<VTooltip activator="parent" location="bottom">{{ t('setting.cache.clearAll') }}</VTooltip>
|
||||
</VBtn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MusicRecognitionCachePanel v-if="recognitionCategory === 'music'" />
|
||||
<div class="cache-panel-filters">
|
||||
<VTextField
|
||||
v-model="searchFilter"
|
||||
class="cache-panel-filter"
|
||||
:label="isMobile ? undefined : recognitionFilterPlaceholder"
|
||||
:placeholder="isMobile ? recognitionFilterPlaceholder : undefined"
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
variant="outlined"
|
||||
:density="isMobile ? 'comfortable' : 'compact'"
|
||||
:single-line="isMobile"
|
||||
clearable
|
||||
hide-details
|
||||
/>
|
||||
<VSelect
|
||||
v-model="typeFilter"
|
||||
class="cache-panel-filter"
|
||||
:label="isMobile ? undefined : t('setting.cache.recognitionType')"
|
||||
:placeholder="isMobile ? t('setting.cache.recognitionType') : undefined"
|
||||
:items="typeOptions"
|
||||
prepend-inner-icon="mdi-shape-outline"
|
||||
variant="outlined"
|
||||
:density="isMobile ? 'comfortable' : 'compact'"
|
||||
:single-line="isMobile"
|
||||
hide-details
|
||||
/>
|
||||
<VSelect
|
||||
v-model="statusFilter"
|
||||
class="cache-panel-filter"
|
||||
:label="isMobile ? undefined : t('setting.cache.recognitionStatus')"
|
||||
:placeholder="isMobile ? t('setting.cache.recognitionStatus') : undefined"
|
||||
:items="statusOptions"
|
||||
prepend-inner-icon="mdi-list-status"
|
||||
variant="outlined"
|
||||
:density="isMobile ? 'comfortable' : 'compact'"
|
||||
:single-line="isMobile"
|
||||
hide-details
|
||||
/>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="cache-panel-toolbar">
|
||||
<div class="cache-panel-stats">
|
||||
<div class="cache-panel-stat cache-panel-stat--primary">
|
||||
<VIcon icon="mdi-database-outline" :size="isMobile ? 32 : 22" />
|
||||
<div>
|
||||
<strong>{{ cacheData.count }}</strong>
|
||||
<span>{{ t('setting.cache.totalCount') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cache-panel-stat cache-panel-stat--success">
|
||||
<VIcon icon="mdi-check-decagram-outline" :size="isMobile ? 32 : 22" />
|
||||
<div>
|
||||
<strong>{{ cacheData.recognized }}</strong>
|
||||
<span>{{ t('setting.cache.recognized') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="!isMobile || cacheData.shared_recognize_enabled"
|
||||
class="cache-panel-stat cache-panel-stat--warning"
|
||||
>
|
||||
<VIcon icon="mdi-help-circle-outline" size="22" />
|
||||
<div>
|
||||
<strong>{{ cacheData.unrecognized }}</strong>
|
||||
<span>{{ t('setting.cache.unrecognized') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="cacheData.shared_recognize_enabled" class="cache-panel-stat cache-panel-stat--info">
|
||||
<VIcon icon="mdi-cloud-check-outline" :size="isMobile ? 32 : 22" />
|
||||
<div>
|
||||
<strong>{{ cacheData.shared_recognized }}</strong>
|
||||
<span>{{ t('setting.cache.sharedRecognized') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="isMobile" class="cache-panel-mobile-actions">
|
||||
<VBtn variant="tonal" color="primary" :loading="loading" prepend-icon="mdi-refresh" @click="loadCacheData(true)">
|
||||
{{ t('setting.cache.refresh') }}
|
||||
</VBtn>
|
||||
<VBtn variant="tonal" color="error" :loading="loading" prepend-icon="mdi-delete-variant" @click="clearAllCache">
|
||||
{{ t('setting.cache.clearAll') }}
|
||||
</VBtn>
|
||||
</div>
|
||||
|
||||
<div v-if="!isMobile" class="cache-panel-actions">
|
||||
<VBtn icon variant="text" color="primary" :loading="loading" @click="loadCacheData(true)">
|
||||
<VIcon icon="mdi-refresh" />
|
||||
<VTooltip activator="parent" location="bottom">{{ t('setting.cache.refreshList') }}</VTooltip>
|
||||
</VBtn>
|
||||
<VBtn
|
||||
icon
|
||||
variant="text"
|
||||
color="warning"
|
||||
:disabled="selectedItems.length === 0"
|
||||
:loading="loading"
|
||||
@click="deleteSelectedItems"
|
||||
>
|
||||
<VIcon icon="mdi-delete-sweep-outline" />
|
||||
<VTooltip activator="parent" location="bottom">
|
||||
{{ t('setting.cache.deleteSelected') }} ({{ selectedItems.length }})
|
||||
</VTooltip>
|
||||
</VBtn>
|
||||
<VBtn icon variant="text" color="error" :loading="loading" @click="clearAllCache">
|
||||
<VIcon icon="mdi-delete-variant" />
|
||||
<VTooltip activator="parent" location="bottom">{{ t('setting.cache.clearAll') }}</VTooltip>
|
||||
</VBtn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cache-panel-filters">
|
||||
<VTextField
|
||||
v-model="searchFilter"
|
||||
class="cache-panel-filter"
|
||||
:label="isMobile ? undefined : recognitionFilterPlaceholder"
|
||||
:placeholder="isMobile ? recognitionFilterPlaceholder : undefined"
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
variant="outlined"
|
||||
:density="isMobile ? 'comfortable' : 'compact'"
|
||||
:single-line="isMobile"
|
||||
clearable
|
||||
hide-details
|
||||
/>
|
||||
<VSelect
|
||||
v-model="statusFilter"
|
||||
class="cache-panel-filter"
|
||||
:label="isMobile ? undefined : t('setting.cache.recognitionStatus')"
|
||||
:placeholder="isMobile ? t('setting.cache.recognitionStatus') : undefined"
|
||||
:items="statusOptions"
|
||||
prepend-inner-icon="mdi-list-status"
|
||||
variant="outlined"
|
||||
:density="isMobile ? 'comfortable' : 'compact'"
|
||||
:single-line="isMobile"
|
||||
hide-details
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="isMobile" class="cache-panel-mobile-actions">
|
||||
<VBtn
|
||||
variant="tonal"
|
||||
color="primary"
|
||||
:loading="loading"
|
||||
prepend-icon="mdi-refresh"
|
||||
@click="loadCacheData(true)"
|
||||
>
|
||||
{{ t('setting.cache.refresh') }}
|
||||
</VBtn>
|
||||
<VBtn variant="tonal" color="error" :loading="loading" prepend-icon="mdi-delete-variant" @click="clearAllCache">
|
||||
{{ t('setting.cache.clearAll') }}
|
||||
</VBtn>
|
||||
<template v-if="showMediaSection">
|
||||
<div v-if="typeFilter === 'all'" class="recognition-cache-section-title">
|
||||
<VIcon icon="mdi-movie-search-outline" size="18" />
|
||||
<span>{{ recognitionSourceName }}</span>
|
||||
</div>
|
||||
|
||||
<template v-if="isMobile">
|
||||
@@ -515,6 +600,21 @@ watch([searchFilter, statusFilter], () => {
|
||||
</template>
|
||||
</VDataTable>
|
||||
</template>
|
||||
|
||||
<template v-if="showMusicSection">
|
||||
<div v-if="typeFilter === 'all'" class="recognition-cache-section-title">
|
||||
<VIcon icon="mdi-music-note-outline" size="18" />
|
||||
<span>{{ musicSourceName }}</span>
|
||||
</div>
|
||||
|
||||
<MusicRecognitionCachePanel
|
||||
:items="filteredMusicData"
|
||||
:loading="loading"
|
||||
:selected-items="selectedMusicItems"
|
||||
@update:selected-items="selectedMusicItems = $event"
|
||||
@delete="deleteMusicItem"
|
||||
/>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -531,23 +631,14 @@ watch([searchFilter, statusFilter], () => {
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.recognition-cache-categories {
|
||||
.recognition-cache-section-title {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.recognition-cache-categories__switcher {
|
||||
overflow: hidden;
|
||||
border: var(--app-surface-border);
|
||||
border-radius: var(--app-control-radius);
|
||||
backdrop-filter: var(--app-grouped-list-backdrop-filter);
|
||||
background: var(--app-grouped-list-background);
|
||||
box-shadow: var(--app-surface-shadow);
|
||||
}
|
||||
|
||||
.recognition-cache-categories__switcher :deep(.v-btn) {
|
||||
min-inline-size: 150px;
|
||||
align-items: center;
|
||||
color: rgba(var(--v-theme-on-surface), 0.72);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
gap: 8px;
|
||||
margin-block-start: 4px;
|
||||
}
|
||||
|
||||
.cache-panel-toolbar {
|
||||
@@ -619,7 +710,7 @@ watch([searchFilter, statusFilter], () => {
|
||||
.cache-panel-filters {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(180px, 0.35fr);
|
||||
grid-template-columns: minmax(0, 1fr) minmax(150px, 0.28fr) minmax(150px, 0.28fr);
|
||||
}
|
||||
|
||||
.cache-panel-filters :deep(.v-field) {
|
||||
@@ -720,19 +811,6 @@ watch([searchFilter, statusFilter], () => {
|
||||
padding-inline: 16px;
|
||||
}
|
||||
|
||||
.recognition-cache-categories {
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.recognition-cache-categories__switcher {
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.recognition-cache-categories__switcher :deep(.v-btn) {
|
||||
flex: 1 1 0;
|
||||
min-inline-size: 0;
|
||||
}
|
||||
|
||||
.cache-panel-toolbar {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
@@ -1,66 +1,49 @@
|
||||
import MusicRecognitionCachePanel from '@/components/cache/MusicRecognitionCachePanel.vue'
|
||||
import { screen, waitFor } from '@testing-library/vue'
|
||||
import type { MusicRecognitionCacheItem } from '@/api/types'
|
||||
import { screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { renderWithProviders } from '@tests/support/render'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
apiGet: vi.fn(),
|
||||
apiDelete: vi.fn(),
|
||||
toastError: vi.fn(),
|
||||
toastSuccess: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api', () => ({
|
||||
default: {
|
||||
get: mocks.apiGet,
|
||||
delete: mocks.apiDelete,
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('vue-toastification', () => ({
|
||||
useToast: () => ({
|
||||
error: mocks.toastError,
|
||||
success: mocks.toastSuccess,
|
||||
}),
|
||||
}))
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const cacheKey = '[音乐]晴天-周杰伦-叶惠美-2003'
|
||||
|
||||
function mockMusicCacheData() {
|
||||
mocks.apiGet.mockResolvedValue({
|
||||
data: {
|
||||
count: 2,
|
||||
recognized: 1,
|
||||
unrecognized: 1,
|
||||
data: [
|
||||
{
|
||||
key: cacheKey,
|
||||
media_id: 'rec-1',
|
||||
title: '晴天',
|
||||
artists: ['周杰伦'],
|
||||
album: '叶惠美',
|
||||
year: 2003,
|
||||
music_type: 'recording',
|
||||
cover_url: '',
|
||||
},
|
||||
{
|
||||
key: '[音乐]未知曲目--None-None',
|
||||
media_id: '',
|
||||
title: '未知曲目',
|
||||
artists: [],
|
||||
album: '',
|
||||
year: '',
|
||||
music_type: 'recording',
|
||||
cover_url: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
const musicItems: MusicRecognitionCacheItem[] = [
|
||||
{
|
||||
key: cacheKey,
|
||||
media_id: 'rec-1',
|
||||
title: '晴天',
|
||||
artists: ['周杰伦'],
|
||||
album: '叶惠美',
|
||||
year: 2003,
|
||||
music_type: 'recording',
|
||||
cover_url: '',
|
||||
},
|
||||
{
|
||||
key: '[音乐]未知曲目--None-None',
|
||||
media_id: '',
|
||||
title: '未知曲目',
|
||||
artists: [],
|
||||
album: '',
|
||||
year: '',
|
||||
music_type: 'recording',
|
||||
cover_url: '',
|
||||
},
|
||||
]
|
||||
|
||||
interface MusicPanelProps {
|
||||
items?: MusicRecognitionCacheItem[]
|
||||
loading?: boolean
|
||||
selectedItems?: string[]
|
||||
}
|
||||
|
||||
async function renderMusicRecognitionCachePanel() {
|
||||
function renderMusicPanel(props: MusicPanelProps = {}) {
|
||||
return renderWithProviders(MusicRecognitionCachePanel, {
|
||||
props: {
|
||||
items: musicItems,
|
||||
loading: false,
|
||||
selectedItems: [],
|
||||
...props,
|
||||
},
|
||||
initialState: {
|
||||
globalSettings: {
|
||||
data: {
|
||||
@@ -73,54 +56,33 @@ async function renderMusicRecognitionCachePanel() {
|
||||
})
|
||||
}
|
||||
|
||||
describe('MusicRecognitionCachePanel', () => {
|
||||
beforeEach(() => {
|
||||
mocks.apiGet.mockReset()
|
||||
mocks.apiDelete.mockReset()
|
||||
mocks.toastError.mockReset()
|
||||
mocks.toastSuccess.mockReset()
|
||||
})
|
||||
describe('MusicRecognitionCachePanel table section', () => {
|
||||
it('renders music cache items provided through props', async () => {
|
||||
await renderMusicPanel()
|
||||
|
||||
it('loads music recognition cache from the music endpoint', async () => {
|
||||
mockMusicCacheData()
|
||||
|
||||
await renderMusicRecognitionCachePanel()
|
||||
|
||||
await waitFor(() => expect(mocks.apiGet).toHaveBeenCalledWith('music/cache'))
|
||||
expect(await screen.findByText('晴天')).toBeInTheDocument()
|
||||
expect(screen.getByText('周杰伦')).toBeInTheDocument()
|
||||
expect(screen.getByText('未知曲目')).toBeInTheDocument()
|
||||
expect(screen.getByText('MusicBrainz ID')).toBeInTheDocument()
|
||||
expect(screen.getByText('已识别')).toBeInTheDocument()
|
||||
expect(screen.getByText('未识别')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows empty hint when music cache is empty', async () => {
|
||||
mocks.apiGet.mockResolvedValue({
|
||||
data: {
|
||||
count: 0,
|
||||
recognized: 0,
|
||||
unrecognized: 0,
|
||||
data: [],
|
||||
},
|
||||
})
|
||||
it('shows empty hint when no items are provided', async () => {
|
||||
await renderMusicPanel({ items: [] })
|
||||
|
||||
await renderMusicRecognitionCachePanel()
|
||||
|
||||
await waitFor(() => expect(mocks.apiGet).toHaveBeenCalledWith('music/cache'))
|
||||
expect(await screen.findByText('暂无 MusicBrainz 识别缓存')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('deletes a music cache item through the encoded endpoint', async () => {
|
||||
mockMusicCacheData()
|
||||
mocks.apiDelete.mockResolvedValue({ success: true, message: '音乐识别缓存删除成功' })
|
||||
|
||||
await renderMusicRecognitionCachePanel()
|
||||
it('emits delete with the cache key when a row delete button is clicked', async () => {
|
||||
const { emitted } = await renderMusicPanel()
|
||||
await screen.findByText('晴天')
|
||||
|
||||
const user = userEvent.setup()
|
||||
const deleteButtons = screen.getAllByRole('button', { name: '删除' })
|
||||
await user.click(deleteButtons[0])
|
||||
|
||||
await waitFor(() => expect(mocks.apiDelete).toHaveBeenCalledWith(`music/cache/${encodeURIComponent(cacheKey)}`))
|
||||
expect(mocks.toastSuccess).toHaveBeenCalled()
|
||||
expect(emitted('delete')).toBeTruthy()
|
||||
expect(emitted('delete')?.[0]).toEqual([cacheKey])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -22,6 +22,22 @@ vi.mock('vue-toastification', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
const emptyMusicResponse = {
|
||||
data: {
|
||||
count: 0,
|
||||
recognized: 0,
|
||||
unrecognized: 0,
|
||||
data: [],
|
||||
},
|
||||
}
|
||||
|
||||
/** 按请求端点分别返回 TMDB 与音乐识别缓存数据。 */
|
||||
function mockCacheApis(tmdbData: Record<string, unknown>) {
|
||||
mocks.apiGet.mockImplementation((url: string) =>
|
||||
Promise.resolve(url === 'music/cache' ? emptyMusicResponse : { data: tmdbData }),
|
||||
)
|
||||
}
|
||||
|
||||
async function renderRecognitionCachePanel(recognitionSource = 'themoviedb') {
|
||||
return renderWithProviders(RecognitionCachePanel, {
|
||||
initialState: {
|
||||
@@ -44,15 +60,13 @@ describe('RecognitionCachePanel shared recognition statistics', () => {
|
||||
})
|
||||
|
||||
it('shows the persisted shared recognition count when sharing is enabled', async () => {
|
||||
mocks.apiGet.mockResolvedValue({
|
||||
data: {
|
||||
count: 12,
|
||||
recognized: 9,
|
||||
unrecognized: 3,
|
||||
shared_recognized: 27,
|
||||
shared_recognize_enabled: true,
|
||||
data: [],
|
||||
},
|
||||
mockCacheApis({
|
||||
count: 12,
|
||||
recognized: 9,
|
||||
unrecognized: 3,
|
||||
shared_recognized: 27,
|
||||
shared_recognize_enabled: true,
|
||||
data: [],
|
||||
})
|
||||
|
||||
await renderRecognitionCachePanel()
|
||||
@@ -63,15 +77,13 @@ describe('RecognitionCachePanel shared recognition statistics', () => {
|
||||
})
|
||||
|
||||
it('hides shared recognition statistics when sharing is disabled', async () => {
|
||||
mocks.apiGet.mockResolvedValue({
|
||||
data: {
|
||||
count: 12,
|
||||
recognized: 9,
|
||||
unrecognized: 3,
|
||||
shared_recognized: 27,
|
||||
shared_recognize_enabled: false,
|
||||
data: [],
|
||||
},
|
||||
mockCacheApis({
|
||||
count: 12,
|
||||
recognized: 9,
|
||||
unrecognized: 3,
|
||||
shared_recognized: 27,
|
||||
shared_recognize_enabled: false,
|
||||
data: [],
|
||||
})
|
||||
|
||||
await renderRecognitionCachePanel()
|
||||
@@ -81,15 +93,13 @@ describe('RecognitionCachePanel shared recognition statistics', () => {
|
||||
})
|
||||
|
||||
it('loads TMDB cache even when Douban is selected as the recognition source', async () => {
|
||||
mocks.apiGet.mockResolvedValue({
|
||||
data: {
|
||||
count: 0,
|
||||
recognized: 0,
|
||||
unrecognized: 0,
|
||||
shared_recognized: 0,
|
||||
shared_recognize_enabled: false,
|
||||
data: [],
|
||||
},
|
||||
mockCacheApis({
|
||||
count: 0,
|
||||
recognized: 0,
|
||||
unrecognized: 0,
|
||||
shared_recognized: 0,
|
||||
shared_recognize_enabled: false,
|
||||
data: [],
|
||||
})
|
||||
|
||||
await renderRecognitionCachePanel('douban')
|
||||
@@ -97,3 +107,61 @@ describe('RecognitionCachePanel shared recognition statistics', () => {
|
||||
await waitFor(() => expect(mocks.apiGet).toHaveBeenCalledWith('tmdb/cache'))
|
||||
})
|
||||
})
|
||||
|
||||
describe('RecognitionCachePanel unified movie/TV and music management', () => {
|
||||
beforeEach(() => {
|
||||
mocks.apiGet.mockReset()
|
||||
mocks.toastError.mockReset()
|
||||
mocks.toastSuccess.mockReset()
|
||||
})
|
||||
|
||||
it('loads both cache sources and aggregates the statistics', async () => {
|
||||
mocks.apiGet.mockImplementation((url: string) =>
|
||||
Promise.resolve(
|
||||
url === 'music/cache'
|
||||
? {
|
||||
data: {
|
||||
count: 3,
|
||||
recognized: 2,
|
||||
unrecognized: 1,
|
||||
data: [
|
||||
{
|
||||
key: '[音乐]晴天-周杰伦-叶惠美-2003',
|
||||
media_id: 'rec-1',
|
||||
title: '晴天',
|
||||
artists: ['周杰伦'],
|
||||
album: '叶惠美',
|
||||
year: 2003,
|
||||
music_type: 'recording',
|
||||
cover_url: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
: {
|
||||
data: {
|
||||
count: 12,
|
||||
recognized: 9,
|
||||
unrecognized: 3,
|
||||
shared_recognized: 0,
|
||||
shared_recognize_enabled: false,
|
||||
data: [],
|
||||
},
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
await renderRecognitionCachePanel()
|
||||
|
||||
await waitFor(() => expect(mocks.apiGet).toHaveBeenCalledWith('tmdb/cache'))
|
||||
await waitFor(() => expect(mocks.apiGet).toHaveBeenCalledWith('music/cache'))
|
||||
// 总条数 12 + 3,已识别 9 + 2,未识别 3 + 1
|
||||
expect(await screen.findByText('15')).toBeInTheDocument()
|
||||
expect(screen.getByText('11')).toBeInTheDocument()
|
||||
expect(screen.getByText('4')).toBeInTheDocument()
|
||||
// 音乐条目直接展示在统一面板中
|
||||
expect(screen.getByText('晴天')).toBeInTheDocument()
|
||||
// 类型筛选下拉框默认展示“全部”
|
||||
expect(screen.getByText('全部')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2703,8 +2703,9 @@ export default {
|
||||
cacheType: 'Cache Type',
|
||||
torrentCache: 'Resource Cache',
|
||||
recognitionCache: 'Recognition Cache',
|
||||
recognitionCategoryLabel: 'Recognition Cache Category',
|
||||
recognitionCategory: {
|
||||
recognitionType: 'Type',
|
||||
recognitionTypeOptions: {
|
||||
all: 'All',
|
||||
media: 'Movie/TV',
|
||||
music: 'Music',
|
||||
},
|
||||
@@ -2718,6 +2719,7 @@ export default {
|
||||
filterByTitle: 'Filter by Title',
|
||||
filterBySite: 'Filter by Site',
|
||||
filterRecognitionCache: 'Search cache key, title, or {source} ID',
|
||||
filterAllRecognitionCache: 'Search cache key, title, artist, or recognition ID',
|
||||
selectSite: 'Select Site',
|
||||
loadingMore: 'Loading...',
|
||||
refresh: 'Refresh Cache',
|
||||
@@ -2785,6 +2787,7 @@ export default {
|
||||
},
|
||||
clearConfirm: 'Are you sure you want to clear all cache?',
|
||||
recognitionClearConfirm: 'Clear all {source} recognition cache?',
|
||||
recognitionClearAllConfirm: 'Clear all recognition cache (movie/TV and music)?',
|
||||
tmdbClearConfirm: 'Clear all TheMovieDb recognition cache?',
|
||||
recognitionSource: {
|
||||
themoviedb: 'TheMovieDb',
|
||||
|
||||
@@ -2652,8 +2652,9 @@ export default {
|
||||
cacheType: '缓存类型',
|
||||
torrentCache: '资源缓存',
|
||||
recognitionCache: '识别缓存',
|
||||
recognitionCategoryLabel: '识别缓存分类',
|
||||
recognitionCategory: {
|
||||
recognitionType: '类型',
|
||||
recognitionTypeOptions: {
|
||||
all: '全部',
|
||||
media: '电影/电视剧',
|
||||
music: '音乐',
|
||||
},
|
||||
@@ -2667,6 +2668,7 @@ export default {
|
||||
filterByTitle: '按标题筛选',
|
||||
filterBySite: '按站点筛选',
|
||||
filterRecognitionCache: '搜索缓存键、标题或 {source} ID',
|
||||
filterAllRecognitionCache: '搜索缓存键、标题、艺术家或识别 ID',
|
||||
selectSite: '选择站点',
|
||||
loadingMore: '加载中...',
|
||||
refresh: '刷新缓存',
|
||||
@@ -2734,6 +2736,7 @@ export default {
|
||||
},
|
||||
clearConfirm: '确认清空所有缓存吗?',
|
||||
recognitionClearConfirm: '确认清空全部 {source} 识别缓存吗?',
|
||||
recognitionClearAllConfirm: '确认清空全部识别缓存(影视与音乐)吗?',
|
||||
tmdbClearConfirm: '确认清空全部 TheMovieDb 识别缓存吗?',
|
||||
recognitionSource: {
|
||||
themoviedb: 'TheMovieDb',
|
||||
|
||||
@@ -2651,8 +2651,9 @@ export default {
|
||||
cacheType: '緩存類型',
|
||||
torrentCache: '資源緩存',
|
||||
recognitionCache: '識別緩存',
|
||||
recognitionCategoryLabel: '識別緩存分類',
|
||||
recognitionCategory: {
|
||||
recognitionType: '類型',
|
||||
recognitionTypeOptions: {
|
||||
all: '全部',
|
||||
media: '電影/電視劇',
|
||||
music: '音樂',
|
||||
},
|
||||
@@ -2666,6 +2667,7 @@ export default {
|
||||
filterByTitle: '按標題篩選',
|
||||
filterBySite: '按站點篩選',
|
||||
filterRecognitionCache: '搜索緩存鍵、標題或 {source} ID',
|
||||
filterAllRecognitionCache: '搜索緩存鍵、標題、藝術家或識別 ID',
|
||||
selectSite: '選擇站點',
|
||||
loadingMore: '加載中...',
|
||||
refresh: '刷新緩存',
|
||||
@@ -2733,6 +2735,7 @@ export default {
|
||||
},
|
||||
clearConfirm: '確認清空所有緩存嗎?',
|
||||
recognitionClearConfirm: '確認清空全部 {source} 識別緩存嗎?',
|
||||
recognitionClearAllConfirm: '確認清空全部識別緩存(影視與音樂)嗎?',
|
||||
tmdbClearConfirm: '確認清空全部 TheMovieDb 識別緩存嗎?',
|
||||
recognitionSource: {
|
||||
themoviedb: 'TheMovieDb',
|
||||
|
||||
Reference in New Issue
Block a user