mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-08-15 02:29:31 +08:00
feat(music): 音乐订阅刷新与识别缓存
This commit is contained in:
@@ -2191,6 +2191,38 @@ export interface RecognitionCacheData {
|
||||
data: RecognitionCacheItem[]
|
||||
}
|
||||
|
||||
// 音乐识别缓存项
|
||||
export interface MusicRecognitionCacheItem {
|
||||
// 缓存键
|
||||
key: string
|
||||
// MusicBrainz ID,空字符串表示未识别
|
||||
media_id?: string
|
||||
// 识别后的曲名/专辑名
|
||||
title: string
|
||||
// 艺术家列表
|
||||
artists?: string[]
|
||||
// 所属专辑
|
||||
album?: string
|
||||
// 发行年份
|
||||
year?: string | number
|
||||
// 音乐实体类型:recording/album/artist
|
||||
music_type?: string
|
||||
// 封面图片地址
|
||||
cover_url?: string
|
||||
}
|
||||
|
||||
// 音乐识别缓存数据
|
||||
export interface MusicRecognitionCacheData {
|
||||
// 缓存总数
|
||||
count: number
|
||||
// 已识别数量
|
||||
recognized: number
|
||||
// 未识别数量
|
||||
unrecognized: number
|
||||
// 缓存数据
|
||||
data: MusicRecognitionCacheItem[]
|
||||
}
|
||||
|
||||
// 订阅分享统计
|
||||
export interface SubscribeShareStatistics {
|
||||
// 分享人
|
||||
|
||||
778
src/components/cache/MusicRecognitionCachePanel.vue
vendored
Normal file
778
src/components/cache/MusicRecognitionCachePanel.vue
vendored
Normal file
@@ -0,0 +1,778 @@
|
||||
<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 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 { 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' },
|
||||
{ title: t('setting.cache.poster'), key: 'poster', sortable: false, width: '76px' },
|
||||
{ title: t('setting.cache.cacheKey'), key: 'key', sortable: true },
|
||||
{ title: t('setting.cache.recognitionResult'), key: 'result', sortable: false, width: '240px' },
|
||||
{ title: recognitionIdLabel.value, key: 'media_id', sortable: true, width: '140px' },
|
||||
{ title: t('setting.cache.recognitionStatus'), key: 'status', sortable: true, width: '120px' },
|
||||
{ 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)
|
||||
|
||||
/** 重置移动端分页,让筛选或刷新后的识别缓存从第一页开始展示。 */
|
||||
function resetMobilePagination() {
|
||||
mobileVisibleCount.value = MOBILE_CACHE_PAGE_SIZE
|
||||
mobileInfiniteKey.value++
|
||||
}
|
||||
|
||||
/** 追加移动端下一页识别缓存,并由虚拟滚动限制实际渲染节点。 */
|
||||
function loadMoreMobileCache({ done }: { done: (status: InfiniteScrollStatus) => void }) {
|
||||
if (loading.value) {
|
||||
done('ok')
|
||||
return
|
||||
}
|
||||
|
||||
if (!mobileHasMore.value) {
|
||||
done('empty')
|
||||
return
|
||||
}
|
||||
|
||||
mobileVisibleCount.value = Math.min(mobileVisibleCount.value + MOBILE_CACHE_PAGE_SIZE, filteredData.value.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 getCoverUrl(item: MusicRecognitionCacheItem): string {
|
||||
if (!item.cover_url) return ''
|
||||
return getDisplayImageUrl(item.cover_url, globalSettingsStore.globalSettings.GLOBAL_IMAGE_CACHE)
|
||||
}
|
||||
|
||||
/** 获取艺术家展示文本。 */
|
||||
function getArtistText(item: MusicRecognitionCacheItem): string {
|
||||
return (item.artists || []).join(' / ')
|
||||
}
|
||||
|
||||
/** 判断识别缓存条目是否包含有效媒体 ID。 */
|
||||
function isRecognized(item: MusicRecognitionCacheItem): boolean {
|
||||
return Boolean(item.media_id)
|
||||
}
|
||||
|
||||
/** 获取移动端识别缓存卡片的稳定渲染 key。 */
|
||||
function getMusicCacheItemKey(item: MusicRecognitionCacheItem): string {
|
||||
return item.key
|
||||
}
|
||||
|
||||
/** 获取音乐实体类型的本地化名称。 */
|
||||
function getMusicTypeLabel(musicType?: string): string {
|
||||
if (musicType === 'album') return t('setting.cache.musicType.album')
|
||||
if (musicType === 'artist') return t('setting.cache.musicType.artist')
|
||||
return t('setting.cache.musicType.recording')
|
||||
}
|
||||
|
||||
/** 获取音乐实体类型对应的主题颜色。 */
|
||||
function getMusicTypeColor(musicType?: string): string {
|
||||
if (musicType === 'album') return 'success'
|
||||
if (musicType === 'artist') return 'info'
|
||||
return 'primary'
|
||||
}
|
||||
|
||||
/** 获取识别状态的本地化名称。 */
|
||||
function getRecognitionStatusLabel(item: MusicRecognitionCacheItem): string {
|
||||
return isRecognized(item) ? t('setting.cache.recognized') : t('setting.cache.unrecognized')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void loadCacheData()
|
||||
})
|
||||
|
||||
watch([searchFilter, statusFilter], () => {
|
||||
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"
|
||||
:key="mobileInfiniteKey"
|
||||
mode="intersect"
|
||||
side="end"
|
||||
:items="mobileVisibleData"
|
||||
class="music-cache-mobile-scroll"
|
||||
@load="loadMoreMobileCache"
|
||||
>
|
||||
<template #loading>
|
||||
<div class="cache-panel-load-state">
|
||||
<VProgressCircular indeterminate color="primary" size="22" width="3" />
|
||||
<span>{{ t('setting.cache.loadingMore') }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #empty />
|
||||
|
||||
<ProgressiveCardGrid
|
||||
v-if="mobileVisibleData.length > 0"
|
||||
:items="mobileVisibleData"
|
||||
:columns="1"
|
||||
:gap="10"
|
||||
:estimated-item-height="152"
|
||||
:overscan-rows="5"
|
||||
:get-item-key="getMusicCacheItemKey"
|
||||
>
|
||||
<template #default="{ item }">
|
||||
<article class="music-cache-mobile-item">
|
||||
<div class="music-cache-cover rounded-md">
|
||||
<VImg v-if="getCoverUrl(item)" :src="getCoverUrl(item)" :alt="item.title || item.key" cover />
|
||||
<VIcon v-else icon="mdi-album-outline" size="28" />
|
||||
</div>
|
||||
|
||||
<div class="music-cache-mobile-item__content">
|
||||
<div class="music-cache-mobile-item__title">
|
||||
{{ item.title || t('setting.cache.unrecognized') }}
|
||||
</div>
|
||||
<div class="music-cache-mobile-item__meta">
|
||||
<VChip size="x-small" variant="tonal" :color="getMusicTypeColor(item.music_type)">
|
||||
{{ getMusicTypeLabel(item.music_type) }}
|
||||
</VChip>
|
||||
<span v-if="getArtistText(item)">{{ getArtistText(item) }}</span>
|
||||
<span v-if="item.year">{{ item.year }}</span>
|
||||
</div>
|
||||
<div class="music-cache-mobile-item__key">{{ item.key }}</div>
|
||||
</div>
|
||||
|
||||
<VBtn
|
||||
icon
|
||||
size="small"
|
||||
variant="text"
|
||||
color="error"
|
||||
:aria-label="t('common.delete')"
|
||||
@click="deleteSingleItem(item)"
|
||||
>
|
||||
<VIcon icon="mdi-delete-outline" size="20" />
|
||||
</VBtn>
|
||||
</article>
|
||||
</template>
|
||||
</ProgressiveCardGrid>
|
||||
</VInfiniteScroll>
|
||||
|
||||
<div v-else class="cache-panel-empty">
|
||||
<VIcon icon="mdi-database-search-outline" size="42" />
|
||||
<strong>{{ t('setting.cache.noRecognitionCache', { source: recognitionSourceName }) }}</strong>
|
||||
<span>{{ t('setting.cache.noRecognitionCacheHint') }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<VDataTable
|
||||
v-else
|
||||
v-model="selectedItems"
|
||||
class="music-cache-table"
|
||||
:headers="tableHeaders"
|
||||
:items="filteredData"
|
||||
:loading="loading"
|
||||
item-value="key"
|
||||
show-select
|
||||
hover
|
||||
:items-per-page-text="t('common.itemsPerPage')"
|
||||
:no-data-text="t('common.noDataText')"
|
||||
:loading-text="t('common.loadingText')"
|
||||
>
|
||||
<template #item.poster="{ item }">
|
||||
<div class="music-cache-table__cover rounded-md">
|
||||
<VImg v-if="getCoverUrl(item)" :src="getCoverUrl(item)" :alt="item.title || item.key" cover />
|
||||
<VIcon v-else icon="mdi-album-outline" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #item.key="{ item }">
|
||||
<div class="music-cache-table__key">{{ item.key }}</div>
|
||||
</template>
|
||||
|
||||
<template #item.result="{ item }">
|
||||
<div class="music-cache-result">
|
||||
<strong>{{ item.title || t('setting.cache.unrecognized') }}</strong>
|
||||
<span v-if="getArtistText(item)">{{ getArtistText(item) }}</span>
|
||||
<span v-if="item.album">{{ item.album }}</span>
|
||||
<span v-if="item.year">{{ item.year }}</span>
|
||||
<VChip size="x-small" variant="tonal" :color="getMusicTypeColor(item.music_type)">
|
||||
{{ getMusicTypeLabel(item.music_type) }}
|
||||
</VChip>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #item.media_id="{ item }">
|
||||
<span v-if="isRecognized(item)" class="font-weight-medium music-cache-table__media-id">{{
|
||||
item.media_id
|
||||
}}</span>
|
||||
<span v-else class="text-medium-emphasis">-</span>
|
||||
</template>
|
||||
|
||||
<template #item.status="{ item }">
|
||||
<VChip size="small" variant="tonal" :color="isRecognized(item) ? 'success' : 'warning'">
|
||||
{{ getRecognitionStatusLabel(item) }}
|
||||
</VChip>
|
||||
</template>
|
||||
|
||||
<template #item.actions="{ item }">
|
||||
<VBtn
|
||||
icon
|
||||
size="small"
|
||||
variant="text"
|
||||
color="error"
|
||||
:aria-label="t('common.delete')"
|
||||
@click="deleteSingleItem(item)"
|
||||
>
|
||||
<VIcon icon="mdi-delete-outline" size="18" />
|
||||
<VTooltip activator="parent" location="start">{{ t('common.delete') }}</VTooltip>
|
||||
</VBtn>
|
||||
</template>
|
||||
|
||||
<template #no-data>
|
||||
<div class="cache-panel-empty">
|
||||
<VIcon icon="mdi-database-search-outline" size="42" />
|
||||
<strong>{{ t('setting.cache.noRecognitionCache', { source: recognitionSourceName }) }}</strong>
|
||||
<span>{{ t('setting.cache.noRecognitionCacheHint') }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</VDataTable>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* stylelint-disable selector-pseudo-class-no-unknown */
|
||||
|
||||
.music-cache-panel {
|
||||
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);
|
||||
border-radius: var(--app-surface-radius);
|
||||
box-shadow: var(--app-surface-shadow);
|
||||
}
|
||||
|
||||
.music-cache-table__cover,
|
||||
.music-cache-cover {
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(var(--v-theme-on-surface), 0.06);
|
||||
color: rgba(var(--v-theme-on-surface), 0.36);
|
||||
}
|
||||
|
||||
.music-cache-table__cover {
|
||||
block-size: 62px;
|
||||
inline-size: 62px;
|
||||
margin-block: 4px;
|
||||
}
|
||||
|
||||
.music-cache-table__cover :deep(.v-img),
|
||||
.music-cache-cover :deep(.v-img) {
|
||||
block-size: 100%;
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.music-cache-table__key {
|
||||
color: rgba(var(--v-theme-on-surface), 0.68);
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
max-inline-size: 30rem;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.music-cache-table__media-id {
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.music-cache-result {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.music-cache-result strong {
|
||||
color: rgba(var(--v-theme-on-surface), 0.88);
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.music-cache-result span {
|
||||
color: rgba(var(--v-theme-on-surface), 0.56);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.cache-panel-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
color: rgba(var(--v-theme-on-surface), 0.48);
|
||||
gap: 8px;
|
||||
min-block-size: 14rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.cache-panel-empty strong {
|
||||
color: rgba(var(--v-theme-on-surface), 0.78);
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.cache-panel-empty span {
|
||||
font-size: 13px;
|
||||
max-inline-size: 30rem;
|
||||
}
|
||||
|
||||
@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;
|
||||
}
|
||||
|
||||
.music-cache-mobile-scroll :deep(.v-infinite-scroll__container),
|
||||
.music-cache-mobile-scroll :deep(.progressive-card-grid),
|
||||
.music-cache-mobile-scroll :deep(.progressive-card-grid__track) {
|
||||
overflow: visible !important;
|
||||
}
|
||||
|
||||
.music-cache-mobile-scroll :deep(.v-infinite-scroll__side) {
|
||||
padding-block: 14px 2px;
|
||||
}
|
||||
|
||||
.cache-panel-load-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: rgba(var(--v-theme-on-surface), 0.58);
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
gap: 8px;
|
||||
min-block-size: 70px;
|
||||
}
|
||||
|
||||
.music-cache-mobile-item {
|
||||
display: grid;
|
||||
align-items: start;
|
||||
padding: 12px;
|
||||
border: var(--app-surface-border);
|
||||
border-radius: var(--app-surface-radius);
|
||||
backdrop-filter: var(--app-grouped-list-backdrop-filter);
|
||||
background: var(--app-grouped-list-background);
|
||||
box-shadow: var(--app-surface-shadow);
|
||||
gap: 12px;
|
||||
grid-template-columns: 62px minmax(0, 1fr) 36px;
|
||||
}
|
||||
|
||||
.music-cache-cover {
|
||||
block-size: 62px;
|
||||
inline-size: 62px;
|
||||
}
|
||||
|
||||
.music-cache-mobile-item__content {
|
||||
min-inline-size: 0;
|
||||
}
|
||||
|
||||
.music-cache-mobile-item__title {
|
||||
color: rgba(var(--v-theme-on-surface), 0.9);
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.music-cache-mobile-item__meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
color: rgba(var(--v-theme-on-surface), 0.58);
|
||||
font-size: 12px;
|
||||
gap: 7px;
|
||||
margin-block-start: 7px;
|
||||
}
|
||||
|
||||
.music-cache-mobile-item__key {
|
||||
color: rgba(var(--v-theme-on-surface), 0.48);
|
||||
font-family: monospace;
|
||||
font-size: 11px;
|
||||
line-height: 1.35;
|
||||
margin-block-start: 8px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
503
src/components/cache/RecognitionCachePanel.vue
vendored
503
src/components/cache/RecognitionCachePanel.vue
vendored
@@ -6,10 +6,12 @@ import api from '@/api'
|
||||
import type { ApiResponse, RecognitionCacheData, RecognitionCacheItem } from '@/api/types'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue'
|
||||
import MusicRecognitionCachePanel from '@/components/cache/MusicRecognitionCachePanel.vue'
|
||||
import { useGlobalSettingsStore } from '@/stores'
|
||||
import { getDisplayImageUrl } from '@/utils/imageUtils'
|
||||
|
||||
type RecognitionStatusFilter = 'all' | 'recognized' | 'unrecognized'
|
||||
type RecognitionCategory = 'media' | 'music'
|
||||
type InfiniteScrollStatus = 'ok' | 'empty' | 'loading' | 'error'
|
||||
|
||||
const MOBILE_CACHE_PAGE_SIZE = 20
|
||||
@@ -31,6 +33,8 @@ const loading = ref(false)
|
||||
const searchFilter = ref('')
|
||||
const statusFilter = ref<RecognitionStatusFilter>('all')
|
||||
const selectedItems = ref<string[]>([])
|
||||
// 识别缓存分类:影视(TMDB)与音乐(MusicBrainz)
|
||||
const recognitionCategory = ref<RecognitionCategory>('media')
|
||||
const cacheData = ref<RecognitionCacheData>({
|
||||
count: 0,
|
||||
recognized: 0,
|
||||
@@ -253,231 +257,264 @@ watch([searchFilter, statusFilter], () => {
|
||||
|
||||
<template>
|
||||
<section class="recognition-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 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-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"
|
||||
:key="mobileInfiniteKey"
|
||||
mode="intersect"
|
||||
side="end"
|
||||
:items="mobileVisibleData"
|
||||
class="recognition-cache-mobile-scroll"
|
||||
@load="loadMoreMobileCache"
|
||||
<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')"
|
||||
>
|
||||
<template #loading>
|
||||
<div class="cache-panel-load-state">
|
||||
<VProgressCircular indeterminate color="primary" size="22" width="3" />
|
||||
<span>{{ t('setting.cache.loadingMore') }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #empty />
|
||||
|
||||
<ProgressiveCardGrid
|
||||
v-if="mobileVisibleData.length > 0"
|
||||
:items="mobileVisibleData"
|
||||
:columns="1"
|
||||
:gap="10"
|
||||
:estimated-item-height="152"
|
||||
:overscan-rows="5"
|
||||
:get-item-key="getRecognitionCacheItemKey"
|
||||
>
|
||||
<template #default="{ item }">
|
||||
<article class="recognition-cache-mobile-item">
|
||||
<div class="recognition-cache-poster rounded-md">
|
||||
<VImg v-if="getPosterUrl(item)" :src="getPosterUrl(item)" :alt="item.title || item.key" cover />
|
||||
<VIcon v-else icon="mdi-image-off-outline" size="28" />
|
||||
</div>
|
||||
|
||||
<div class="recognition-cache-mobile-item__content">
|
||||
<div class="recognition-cache-mobile-item__title">
|
||||
{{ item.title || t('setting.cache.unrecognized') }}
|
||||
</div>
|
||||
<div class="recognition-cache-mobile-item__meta">
|
||||
<VChip size="x-small" variant="tonal" :color="getMediaTypeColor(item.media_type)">
|
||||
{{ getMediaTypeLabel(item.media_type) }}
|
||||
</VChip>
|
||||
<span v-if="item.year">{{ item.year }}</span>
|
||||
<span v-if="isRecognized(item)">{{ recognitionIdLabel }} #{{ getRecognitionId(item) }}</span>
|
||||
</div>
|
||||
<div class="recognition-cache-mobile-item__key">{{ item.key }}</div>
|
||||
</div>
|
||||
|
||||
<VBtn
|
||||
icon
|
||||
size="small"
|
||||
variant="text"
|
||||
color="error"
|
||||
:aria-label="t('common.delete')"
|
||||
@click="deleteSingleItem(item)"
|
||||
>
|
||||
<VIcon icon="mdi-delete-outline" size="20" />
|
||||
</VBtn>
|
||||
</article>
|
||||
</template>
|
||||
</ProgressiveCardGrid>
|
||||
</VInfiniteScroll>
|
||||
|
||||
<div v-else class="cache-panel-empty">
|
||||
<VIcon icon="mdi-database-search-outline" size="42" />
|
||||
<strong>{{ t('setting.cache.noRecognitionCache', { source: recognitionSourceName }) }}</strong>
|
||||
<span>{{ t('setting.cache.noRecognitionCacheHint') }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<VDataTable
|
||||
v-else
|
||||
v-model="selectedItems"
|
||||
class="recognition-cache-table"
|
||||
:headers="tableHeaders"
|
||||
:items="filteredData"
|
||||
:loading="loading"
|
||||
item-value="key"
|
||||
show-select
|
||||
hover
|
||||
:items-per-page-text="t('common.itemsPerPage')"
|
||||
:no-data-text="t('common.noDataText')"
|
||||
:loading-text="t('common.loadingText')"
|
||||
>
|
||||
<template #item.poster="{ item }">
|
||||
<div class="recognition-cache-table__poster rounded-md">
|
||||
<VImg v-if="getPosterUrl(item)" :src="getPosterUrl(item)" :alt="item.title || item.key" cover />
|
||||
<VIcon v-else icon="mdi-image-off-outline" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #item.key="{ item }">
|
||||
<div class="recognition-cache-table__key">{{ item.key }}</div>
|
||||
</template>
|
||||
|
||||
<template #item.result="{ item }">
|
||||
<div class="recognition-cache-result">
|
||||
<strong>{{ item.title || t('setting.cache.unrecognized') }}</strong>
|
||||
<span v-if="item.year">{{ item.year }}</span>
|
||||
<VChip size="x-small" variant="tonal" :color="getMediaTypeColor(item.media_type)">
|
||||
{{ getMediaTypeLabel(item.media_type) }}
|
||||
</VChip>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #item.recognition_id="{ item }">
|
||||
<span v-if="isRecognized(item)" class="font-weight-medium">#{{ getRecognitionId(item) }}</span>
|
||||
<span v-else class="text-medium-emphasis">-</span>
|
||||
</template>
|
||||
|
||||
<template #item.status="{ item }">
|
||||
<VChip size="small" variant="tonal" :color="isRecognized(item) ? 'success' : 'warning'">
|
||||
{{ getRecognitionStatusLabel(item) }}
|
||||
</VChip>
|
||||
</template>
|
||||
|
||||
<template #item.actions="{ item }">
|
||||
<VBtn icon size="small" variant="text" color="error" @click="deleteSingleItem(item)">
|
||||
<VIcon icon="mdi-delete-outline" size="18" />
|
||||
<VTooltip activator="parent" location="start">{{ t('common.delete') }}</VTooltip>
|
||||
<VBtn value="media" prepend-icon="mdi-movie-search-outline">
|
||||
{{ t('setting.cache.recognitionCategory.media') }}
|
||||
</VBtn>
|
||||
</template>
|
||||
<VBtn value="music" prepend-icon="mdi-music-note-outline">
|
||||
{{ t('setting.cache.recognitionCategory.music') }}
|
||||
</VBtn>
|
||||
</VBtnToggle>
|
||||
</div>
|
||||
|
||||
<template #no-data>
|
||||
<div class="cache-panel-empty">
|
||||
<MusicRecognitionCachePanel v-if="recognitionCategory === 'music'" />
|
||||
|
||||
<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-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"
|
||||
:key="mobileInfiniteKey"
|
||||
mode="intersect"
|
||||
side="end"
|
||||
:items="mobileVisibleData"
|
||||
class="recognition-cache-mobile-scroll"
|
||||
@load="loadMoreMobileCache"
|
||||
>
|
||||
<template #loading>
|
||||
<div class="cache-panel-load-state">
|
||||
<VProgressCircular indeterminate color="primary" size="22" width="3" />
|
||||
<span>{{ t('setting.cache.loadingMore') }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #empty />
|
||||
|
||||
<ProgressiveCardGrid
|
||||
v-if="mobileVisibleData.length > 0"
|
||||
:items="mobileVisibleData"
|
||||
:columns="1"
|
||||
:gap="10"
|
||||
:estimated-item-height="152"
|
||||
:overscan-rows="5"
|
||||
:get-item-key="getRecognitionCacheItemKey"
|
||||
>
|
||||
<template #default="{ item }">
|
||||
<article class="recognition-cache-mobile-item">
|
||||
<div class="recognition-cache-poster rounded-md">
|
||||
<VImg v-if="getPosterUrl(item)" :src="getPosterUrl(item)" :alt="item.title || item.key" cover />
|
||||
<VIcon v-else icon="mdi-image-off-outline" size="28" />
|
||||
</div>
|
||||
|
||||
<div class="recognition-cache-mobile-item__content">
|
||||
<div class="recognition-cache-mobile-item__title">
|
||||
{{ item.title || t('setting.cache.unrecognized') }}
|
||||
</div>
|
||||
<div class="recognition-cache-mobile-item__meta">
|
||||
<VChip size="x-small" variant="tonal" :color="getMediaTypeColor(item.media_type)">
|
||||
{{ getMediaTypeLabel(item.media_type) }}
|
||||
</VChip>
|
||||
<span v-if="item.year">{{ item.year }}</span>
|
||||
<span v-if="isRecognized(item)">{{ recognitionIdLabel }} #{{ getRecognitionId(item) }}</span>
|
||||
</div>
|
||||
<div class="recognition-cache-mobile-item__key">{{ item.key }}</div>
|
||||
</div>
|
||||
|
||||
<VBtn
|
||||
icon
|
||||
size="small"
|
||||
variant="text"
|
||||
color="error"
|
||||
:aria-label="t('common.delete')"
|
||||
@click="deleteSingleItem(item)"
|
||||
>
|
||||
<VIcon icon="mdi-delete-outline" size="20" />
|
||||
</VBtn>
|
||||
</article>
|
||||
</template>
|
||||
</ProgressiveCardGrid>
|
||||
</VInfiniteScroll>
|
||||
|
||||
<div v-else class="cache-panel-empty">
|
||||
<VIcon icon="mdi-database-search-outline" size="42" />
|
||||
<strong>{{ t('setting.cache.noRecognitionCache', { source: recognitionSourceName }) }}</strong>
|
||||
<span>{{ t('setting.cache.noRecognitionCacheHint') }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</VDataTable>
|
||||
|
||||
<VDataTable
|
||||
v-else
|
||||
v-model="selectedItems"
|
||||
class="recognition-cache-table"
|
||||
:headers="tableHeaders"
|
||||
:items="filteredData"
|
||||
:loading="loading"
|
||||
item-value="key"
|
||||
show-select
|
||||
hover
|
||||
:items-per-page-text="t('common.itemsPerPage')"
|
||||
:no-data-text="t('common.noDataText')"
|
||||
:loading-text="t('common.loadingText')"
|
||||
>
|
||||
<template #item.poster="{ item }">
|
||||
<div class="recognition-cache-table__poster rounded-md">
|
||||
<VImg v-if="getPosterUrl(item)" :src="getPosterUrl(item)" :alt="item.title || item.key" cover />
|
||||
<VIcon v-else icon="mdi-image-off-outline" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #item.key="{ item }">
|
||||
<div class="recognition-cache-table__key">{{ item.key }}</div>
|
||||
</template>
|
||||
|
||||
<template #item.result="{ item }">
|
||||
<div class="recognition-cache-result">
|
||||
<strong>{{ item.title || t('setting.cache.unrecognized') }}</strong>
|
||||
<span v-if="item.year">{{ item.year }}</span>
|
||||
<VChip size="x-small" variant="tonal" :color="getMediaTypeColor(item.media_type)">
|
||||
{{ getMediaTypeLabel(item.media_type) }}
|
||||
</VChip>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #item.recognition_id="{ item }">
|
||||
<span v-if="isRecognized(item)" class="font-weight-medium">#{{ getRecognitionId(item) }}</span>
|
||||
<span v-else class="text-medium-emphasis">-</span>
|
||||
</template>
|
||||
|
||||
<template #item.status="{ item }">
|
||||
<VChip size="small" variant="tonal" :color="isRecognized(item) ? 'success' : 'warning'">
|
||||
{{ getRecognitionStatusLabel(item) }}
|
||||
</VChip>
|
||||
</template>
|
||||
|
||||
<template #item.actions="{ item }">
|
||||
<VBtn icon size="small" variant="text" color="error" @click="deleteSingleItem(item)">
|
||||
<VIcon icon="mdi-delete-outline" size="18" />
|
||||
<VTooltip activator="parent" location="start">{{ t('common.delete') }}</VTooltip>
|
||||
</VBtn>
|
||||
</template>
|
||||
|
||||
<template #no-data>
|
||||
<div class="cache-panel-empty">
|
||||
<VIcon icon="mdi-database-search-outline" size="42" />
|
||||
<strong>{{ t('setting.cache.noRecognitionCache', { source: recognitionSourceName }) }}</strong>
|
||||
<span>{{ t('setting.cache.noRecognitionCacheHint') }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</VDataTable>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -494,6 +531,25 @@ watch([searchFilter, statusFilter], () => {
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.recognition-cache-categories {
|
||||
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;
|
||||
}
|
||||
|
||||
.cache-panel-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -664,6 +720,19 @@ 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;
|
||||
}
|
||||
|
||||
126
src/components/cache/__tests__/MusicRecognitionCachePanel.spec.ts
vendored
Normal file
126
src/components/cache/__tests__/MusicRecognitionCachePanel.spec.ts
vendored
Normal file
@@ -0,0 +1,126 @@
|
||||
import MusicRecognitionCachePanel from '@/components/cache/MusicRecognitionCachePanel.vue'
|
||||
import { screen, waitFor } 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,
|
||||
}),
|
||||
}))
|
||||
|
||||
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: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function renderMusicRecognitionCachePanel() {
|
||||
return renderWithProviders(MusicRecognitionCachePanel, {
|
||||
initialState: {
|
||||
globalSettings: {
|
||||
data: {
|
||||
GLOBAL_IMAGE_CACHE: false,
|
||||
RECOGNIZE_SOURCE: 'musicbrainz',
|
||||
TMDB_IMAGE_DOMAIN: 'image.tmdb.org',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('MusicRecognitionCachePanel', () => {
|
||||
beforeEach(() => {
|
||||
mocks.apiGet.mockReset()
|
||||
mocks.apiDelete.mockReset()
|
||||
mocks.toastError.mockReset()
|
||||
mocks.toastSuccess.mockReset()
|
||||
})
|
||||
|
||||
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()
|
||||
})
|
||||
|
||||
it('shows empty hint when music cache is empty', async () => {
|
||||
mocks.apiGet.mockResolvedValue({
|
||||
data: {
|
||||
count: 0,
|
||||
recognized: 0,
|
||||
unrecognized: 0,
|
||||
data: [],
|
||||
},
|
||||
})
|
||||
|
||||
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()
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -2703,6 +2703,11 @@ export default {
|
||||
cacheType: 'Cache Type',
|
||||
torrentCache: 'Resource Cache',
|
||||
recognitionCache: 'Recognition Cache',
|
||||
recognitionCategoryLabel: 'Recognition Cache Category',
|
||||
recognitionCategory: {
|
||||
media: 'Movie/TV',
|
||||
music: 'Music',
|
||||
},
|
||||
totalCount: 'Total Count',
|
||||
siteCount: 'Site Count',
|
||||
recognized: 'Recognized',
|
||||
@@ -2736,6 +2741,7 @@ export default {
|
||||
poster: 'Poster',
|
||||
cacheKey: 'Recognition Request',
|
||||
tmdbId: 'TMDB ID',
|
||||
musicbrainzId: 'MusicBrainz ID',
|
||||
doubanId: 'Douban ID',
|
||||
torrentTitle: 'Title',
|
||||
site: 'Site',
|
||||
@@ -2769,8 +2775,14 @@ export default {
|
||||
mediaType: {
|
||||
movie: 'Movie',
|
||||
tv: 'TV Show',
|
||||
music: 'Music',
|
||||
unknown: 'Unknown',
|
||||
},
|
||||
musicType: {
|
||||
recording: 'Single',
|
||||
album: 'Album',
|
||||
artist: 'Artist',
|
||||
},
|
||||
clearConfirm: 'Are you sure you want to clear all cache?',
|
||||
recognitionClearConfirm: 'Clear all {source} recognition cache?',
|
||||
tmdbClearConfirm: 'Clear all TheMovieDb recognition cache?',
|
||||
|
||||
@@ -2652,6 +2652,11 @@ export default {
|
||||
cacheType: '缓存类型',
|
||||
torrentCache: '资源缓存',
|
||||
recognitionCache: '识别缓存',
|
||||
recognitionCategoryLabel: '识别缓存分类',
|
||||
recognitionCategory: {
|
||||
media: '电影/电视剧',
|
||||
music: '音乐',
|
||||
},
|
||||
totalCount: '总条数',
|
||||
siteCount: '站点数',
|
||||
recognized: '已识别',
|
||||
@@ -2685,6 +2690,7 @@ export default {
|
||||
poster: '海报',
|
||||
cacheKey: '识别请求',
|
||||
tmdbId: 'TMDB ID',
|
||||
musicbrainzId: 'MusicBrainz ID',
|
||||
doubanId: '豆瓣 ID',
|
||||
torrentTitle: '标题',
|
||||
site: '站点',
|
||||
@@ -2718,8 +2724,14 @@ export default {
|
||||
mediaType: {
|
||||
movie: '电影',
|
||||
tv: '电视剧',
|
||||
music: '音乐',
|
||||
unknown: '未知',
|
||||
},
|
||||
musicType: {
|
||||
recording: '单曲',
|
||||
album: '专辑',
|
||||
artist: '艺术家',
|
||||
},
|
||||
clearConfirm: '确认清空所有缓存吗?',
|
||||
recognitionClearConfirm: '确认清空全部 {source} 识别缓存吗?',
|
||||
tmdbClearConfirm: '确认清空全部 TheMovieDb 识别缓存吗?',
|
||||
|
||||
@@ -2651,6 +2651,11 @@ export default {
|
||||
cacheType: '緩存類型',
|
||||
torrentCache: '資源緩存',
|
||||
recognitionCache: '識別緩存',
|
||||
recognitionCategoryLabel: '識別緩存分類',
|
||||
recognitionCategory: {
|
||||
media: '電影/電視劇',
|
||||
music: '音樂',
|
||||
},
|
||||
totalCount: '總條數',
|
||||
siteCount: '站點數',
|
||||
recognized: '已識別',
|
||||
@@ -2684,6 +2689,7 @@ export default {
|
||||
poster: '海報',
|
||||
cacheKey: '識別請求',
|
||||
tmdbId: 'TMDB ID',
|
||||
musicbrainzId: 'MusicBrainz ID',
|
||||
doubanId: '豆瓣 ID',
|
||||
torrentTitle: '標題',
|
||||
site: '站點',
|
||||
@@ -2717,8 +2723,14 @@ export default {
|
||||
mediaType: {
|
||||
movie: '電影',
|
||||
tv: '電視劇',
|
||||
music: '音樂',
|
||||
unknown: '未知',
|
||||
},
|
||||
musicType: {
|
||||
recording: '單曲',
|
||||
album: '專輯',
|
||||
artist: '藝術家',
|
||||
},
|
||||
clearConfirm: '確認清空所有緩存嗎?',
|
||||
recognitionClearConfirm: '確認清空全部 {source} 識別緩存嗎?',
|
||||
tmdbClearConfirm: '確認清空全部 TheMovieDb 識別緩存嗎?',
|
||||
|
||||
Reference in New Issue
Block a user