mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-07 00:36:41 +08:00
Enhance name recognition results with source metadata
This commit is contained in:
@@ -63,6 +63,10 @@ const bodyClasses = computed(() => [
|
|||||||
},
|
},
|
||||||
])
|
])
|
||||||
|
|
||||||
|
// 允许工具视图在执行后续操作前主动关闭外层弹窗。
|
||||||
|
function closeDialog() {
|
||||||
|
visible.value = false
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -77,7 +81,7 @@ const bodyClasses = computed(() => [
|
|||||||
</VCardItem>
|
</VCardItem>
|
||||||
<VDivider />
|
<VDivider />
|
||||||
<VCardText :class="bodyClasses">
|
<VCardText :class="bodyClasses">
|
||||||
<Component :is="props.view" v-bind="props.viewProps" />
|
<Component :is="props.view" v-bind="props.viewProps" @close="closeDialog" />
|
||||||
</VCardText>
|
</VCardText>
|
||||||
</VCard>
|
</VCard>
|
||||||
</VDialog>
|
</VDialog>
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import ShortcutToolDialog from '@/components/dialog/ShortcutToolDialog.vue'
|
||||||
|
import { screen } from '@testing-library/vue'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
|
import { renderWithProviders } from '@tests/support/render'
|
||||||
|
import { defineComponent, markRaw } from 'vue'
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
const ToolView = defineComponent({
|
||||||
|
emits: ['close'],
|
||||||
|
template: '<button type="button" @click="$emit(\'close\')">关闭工具</button>',
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('ShortcutToolDialog', () => {
|
||||||
|
it('closes the outer dialog when the active tool requests it', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
const result = await renderWithProviders(ShortcutToolDialog, {
|
||||||
|
props: {
|
||||||
|
modelValue: true,
|
||||||
|
title: '测试工具',
|
||||||
|
view: markRaw(ToolView),
|
||||||
|
},
|
||||||
|
global: {
|
||||||
|
stubs: {
|
||||||
|
VDialogCloseBtn: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: '关闭工具' }))
|
||||||
|
|
||||||
|
expect(result.emitted()['update:modelValue']).toEqual([[false]])
|
||||||
|
expect(result.emitted().close).toEqual([[]])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1625,9 +1625,13 @@ export default {
|
|||||||
title: 'Meta Info',
|
title: 'Meta Info',
|
||||||
caption: 'Parsed name, episodes, and resource terms',
|
caption: 'Parsed name, episodes, and resource terms',
|
||||||
},
|
},
|
||||||
|
source: {
|
||||||
|
title: 'Recognition Source',
|
||||||
|
caption: 'The media source matched by this recognition',
|
||||||
|
},
|
||||||
media: {
|
media: {
|
||||||
title: 'Media Match',
|
title: 'Media ID',
|
||||||
caption: 'Final matched media source',
|
caption: 'The native media ID from the matched source',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1614,9 +1614,13 @@ export default {
|
|||||||
title: '元信息',
|
title: '元信息',
|
||||||
caption: '解析出的名称、季集和资源信息',
|
caption: '解析出的名称、季集和资源信息',
|
||||||
},
|
},
|
||||||
|
source: {
|
||||||
|
title: '识别数据源',
|
||||||
|
caption: '本次识别匹配到的媒体数据源',
|
||||||
|
},
|
||||||
media: {
|
media: {
|
||||||
title: '媒体匹配',
|
title: '媒体 ID',
|
||||||
caption: '最终匹配到的媒体数据源',
|
caption: '最终匹配到的媒体数据源原生 ID',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1613,9 +1613,13 @@ export default {
|
|||||||
title: '元資訊',
|
title: '元資訊',
|
||||||
caption: '解析出的名稱、季集和資源資訊',
|
caption: '解析出的名稱、季集和資源資訊',
|
||||||
},
|
},
|
||||||
|
source: {
|
||||||
|
title: '識別資料源',
|
||||||
|
caption: '本次識別匹配到的媒體資料源',
|
||||||
|
},
|
||||||
media: {
|
media: {
|
||||||
title: '媒體匹配',
|
title: '媒體 ID',
|
||||||
caption: '最終匹配到的媒體資料源',
|
caption: '最終匹配到的媒體資料源原生 ID',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
import { resolve } from 'node:path'
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
describe('text-moviepilot theme color', () => {
|
||||||
|
it('derives its gradient from the active Vuetify primary color', () => {
|
||||||
|
const commonStyles = readFileSync(resolve(process.cwd(), 'src/styles/common.scss'), 'utf8')
|
||||||
|
const textMoviePilotRule = commonStyles.match(/\.text-moviepilot\s*\{(?<rule>[\s\S]*?)\n\}/)?.groups?.rule
|
||||||
|
|
||||||
|
expect(textMoviePilotRule).toContain('var(--v-theme-primary)')
|
||||||
|
expect(textMoviePilotRule).not.toMatch(/#818cf8|#c084fc/i)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1377,12 +1377,8 @@ html[data-theme='transparent'].transparent-glass-realtime .v-theme--transparent
|
|||||||
// 文本样式
|
// 文本样式
|
||||||
.text-moviepilot {
|
.text-moviepilot {
|
||||||
background-clip: text;
|
background-clip: text;
|
||||||
background-image: linear-gradient(to bottom right, var(--tw-gradient-stops));
|
background-image: linear-gradient(to bottom right, rgba(var(--v-theme-primary), 0.72), rgb(var(--v-theme-primary)));
|
||||||
color: transparent;
|
color: transparent;
|
||||||
|
|
||||||
--tw-gradient-from: #818cf8;
|
|
||||||
--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to);
|
|
||||||
--tw-gradient-to: #c084fc;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 登录页与主框架共用的品牌文字效果。
|
// 登录页与主框架共用的品牌文字效果。
|
||||||
|
|||||||
@@ -1,20 +1,55 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, reactive, ref } from 'vue'
|
import { computed, nextTick, reactive, ref } from 'vue'
|
||||||
import { useToast } from 'vue-toastification'
|
import { useToast } from 'vue-toastification'
|
||||||
import { requiredValidator } from '@/@validators'
|
import { requiredValidator } from '@/@validators'
|
||||||
import api from '@/api'
|
import api from '@/api'
|
||||||
import type { Context, MediaDataSource, MediaInfo } from '@/api/types'
|
import type { Context, MediaDataSource, MediaInfo } from '@/api/types'
|
||||||
import { getMediaSubscribeId } from '@/composables/useMediaSubscribe'
|
import { getMediaSubscribeId, getMediaSubscribeIdentity } from '@/composables/useMediaSubscribe'
|
||||||
import router from '@/router'
|
import router from '@/router'
|
||||||
import { useGlobalSettingsStore } from '@/stores'
|
import { useGlobalSettingsStore } from '@/stores'
|
||||||
|
import { getLogoUrl } from '@/utils/imageUtils'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
interface PipelineStep {
|
interface PipelineStep {
|
||||||
icon: string
|
icon: string
|
||||||
|
identity?: MediaIdentity
|
||||||
|
source?: MediaSourceDisplay
|
||||||
title: string
|
title: string
|
||||||
value: string
|
value: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface MediaIdentity {
|
||||||
|
id: string
|
||||||
|
link?: string
|
||||||
|
source: string
|
||||||
|
sourceKey: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MediaSourceDisplay {
|
||||||
|
icon?: string
|
||||||
|
image?: string
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const MEDIA_SOURCE_LABELS: Record<string, string> = {
|
||||||
|
anilist: 'AniList',
|
||||||
|
bangumi: 'Bangumi',
|
||||||
|
douban: 'Douban',
|
||||||
|
themoviedb: 'TheMovieDb',
|
||||||
|
}
|
||||||
|
|
||||||
|
const MEDIA_SOURCE_LOGOS: Record<string, string> = {
|
||||||
|
bangumi: getLogoUrl('bangumi'),
|
||||||
|
douban: getLogoUrl('douban'),
|
||||||
|
themoviedb: getLogoUrl('tmdb'),
|
||||||
|
}
|
||||||
|
|
||||||
|
const NAME_TEST_TITLE_HISTORY_KEY = 'MP_NAME_TEST_TITLE_HISTORY'
|
||||||
|
const NAME_TEST_TITLE_HISTORY_LIMIT = 5
|
||||||
|
|
||||||
|
const emit = defineEmits<{ close: [] }>()
|
||||||
|
|
||||||
// 国际化
|
// 国际化
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const globalSettingsStore = useGlobalSettingsStore()
|
const globalSettingsStore = useGlobalSettingsStore()
|
||||||
@@ -40,12 +75,47 @@ const nameTestResult = ref<Context>()
|
|||||||
|
|
||||||
// 名称识别表单
|
// 名称识别表单
|
||||||
const nameTestForm = reactive({
|
const nameTestForm = reactive({
|
||||||
title: '',
|
title: null,
|
||||||
subtitle: '',
|
subtitle: null,
|
||||||
customWords: '',
|
customWords: null,
|
||||||
source: getDefaultMediaSource(),
|
source: getDefaultMediaSource(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/** 从本地存储读取最近使用的识别标题。 */
|
||||||
|
function loadTitleHistory() {
|
||||||
|
try {
|
||||||
|
const storedHistory: unknown = JSON.parse(localStorage.getItem(NAME_TEST_TITLE_HISTORY_KEY) || '[]')
|
||||||
|
if (!Array.isArray(storedHistory)) return []
|
||||||
|
|
||||||
|
return storedHistory
|
||||||
|
.filter((title): title is string => typeof title === 'string' && Boolean(title.trim()))
|
||||||
|
.map(title => title.trim())
|
||||||
|
.filter((title, index, titles) => titles.indexOf(title) === index)
|
||||||
|
.slice(0, NAME_TEST_TITLE_HISTORY_LIMIT)
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const nameTestTitleHistory = ref<string[]>(loadTitleHistory())
|
||||||
|
|
||||||
|
/** 将本次提交的标题移到历史记录首位,并只保留最新五条。 */
|
||||||
|
function saveTitleHistory(title: string) {
|
||||||
|
const normalizedTitle = title.trim()
|
||||||
|
if (!normalizedTitle) return
|
||||||
|
|
||||||
|
nameTestTitleHistory.value = [
|
||||||
|
normalizedTitle,
|
||||||
|
...nameTestTitleHistory.value.filter(historyTitle => historyTitle !== normalizedTitle),
|
||||||
|
].slice(0, NAME_TEST_TITLE_HISTORY_LIMIT)
|
||||||
|
|
||||||
|
try {
|
||||||
|
localStorage.setItem(NAME_TEST_TITLE_HISTORY_KEY, JSON.stringify(nameTestTitleHistory.value))
|
||||||
|
} catch {
|
||||||
|
// 本地存储不可用时仍继续执行本次识别。
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 识别按钮状态
|
// 识别按钮状态
|
||||||
const nameTestLoading = ref(false)
|
const nameTestLoading = ref(false)
|
||||||
|
|
||||||
@@ -91,17 +161,53 @@ const canViewMediaDetail = computed(() =>
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
/** 生成识别结果中的数据源原生ID摘要,并兼容旧接口字段。 */
|
/** 生成媒体源官方详情页地址。 */
|
||||||
function getMediaIdentityLabel(media?: MediaInfo) {
|
function getMediaOfficialLink(media: MediaInfo, source: string, mediaId: string) {
|
||||||
if (!media) return t('nameTest.unrecognized')
|
const encodedId = encodeURIComponent(mediaId)
|
||||||
if (media.media_id) return `${media.source || media.mediaid_prefix} ${media.media_id}`
|
|
||||||
if (media.tmdb_id) return `TMDB ${media.tmdb_id}`
|
switch (source) {
|
||||||
if (media.douban_id) return `Douban ${media.douban_id}`
|
case 'themoviedb': {
|
||||||
if (media.bangumi_id) return `Bangumi ${media.bangumi_id}`
|
const mediaType = media.type?.trim().toLowerCase()
|
||||||
if (media.anilist_id) return `AniList ${media.anilist_id}`
|
return `https://www.themoviedb.org/${mediaType === '电影' || mediaType === 'movie' ? 'movie' : 'tv'}/${encodedId}`
|
||||||
return media.title || t('nameTest.unrecognized')
|
}
|
||||||
|
case 'douban':
|
||||||
|
return `https://movie.douban.com/subject/${encodedId}`
|
||||||
|
case 'bangumi':
|
||||||
|
return `https://bgm.tv/subject/${encodedId}`
|
||||||
|
case 'anilist':
|
||||||
|
return `https://anilist.co/anime/${encodedId}`
|
||||||
|
default:
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 生成识别结果中的数据源原生 ID,并兼容旧接口字段。 */
|
||||||
|
function getMediaIdentity(media?: MediaInfo): MediaIdentity | undefined {
|
||||||
|
if (!media) return undefined
|
||||||
|
|
||||||
|
const identity = getMediaSubscribeIdentity(media)
|
||||||
|
if (!identity) return undefined
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: identity.mediaId,
|
||||||
|
link: getMediaOfficialLink(media, identity.source, identity.mediaId),
|
||||||
|
source: MEDIA_SOURCE_LABELS[identity.source] || identity.source,
|
||||||
|
sourceKey: identity.source,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const mediaIdentity = computed(() => getMediaIdentity(mediaInfo.value))
|
||||||
|
const recognizedMediaSource = computed<MediaSourceDisplay>(() => {
|
||||||
|
const sourceKey = mediaIdentity.value?.sourceKey || nameTestForm.source
|
||||||
|
|
||||||
|
return {
|
||||||
|
icon: sourceKey === 'anilist' ? 'mdi-alpha-a-circle' : undefined,
|
||||||
|
image: MEDIA_SOURCE_LOGOS[sourceKey],
|
||||||
|
key: sourceKey,
|
||||||
|
label: mediaIdentity.value?.source || MEDIA_SOURCE_LABELS[sourceKey] || sourceKey,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const pipelineSteps = computed<PipelineStep[]>(() => [
|
const pipelineSteps = computed<PipelineStep[]>(() => [
|
||||||
{
|
{
|
||||||
icon: 'mdi-file-document-outline',
|
icon: 'mdi-file-document-outline',
|
||||||
@@ -117,9 +223,16 @@ const pipelineSteps = computed<PipelineStep[]>(() => [
|
|||||||
.join(' · ') || '-',
|
.join(' · ') || '-',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: 'mdi-movie-search-outline',
|
icon: 'mdi-database-search-outline',
|
||||||
|
source: recognizedMediaSource.value,
|
||||||
|
title: t('nameTest.steps.source.title'),
|
||||||
|
value: recognizedMediaSource.value.label,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: 'mdi-identifier',
|
||||||
|
identity: mediaIdentity.value,
|
||||||
title: t('nameTest.steps.media.title'),
|
title: t('nameTest.steps.media.title'),
|
||||||
value: getMediaIdentityLabel(mediaInfo.value),
|
value: mediaIdentity.value?.id || t('nameTest.unrecognized'),
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
|
|
||||||
@@ -129,11 +242,11 @@ function getPosterImage(url = '') {
|
|||||||
return url.replace('original', 'w500')
|
return url.replace('original', 'w500')
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 跳转查看当前识别结果匹配到的媒体详情。 */
|
/** 关闭识别测试弹窗后,跳转查看当前识别结果匹配到的媒体详情。 */
|
||||||
function viewMediaDetail() {
|
async function viewMediaDetail() {
|
||||||
if (!canViewMediaDetail.value || !mediaInfo.value) return
|
if (!canViewMediaDetail.value || !mediaInfo.value) return
|
||||||
|
|
||||||
router.push({
|
const target = {
|
||||||
path: '/media',
|
path: '/media',
|
||||||
query: {
|
query: {
|
||||||
mediaid: getMediaSubscribeId(mediaInfo.value),
|
mediaid: getMediaSubscribeId(mediaInfo.value),
|
||||||
@@ -141,12 +254,20 @@ function viewMediaDetail() {
|
|||||||
year: mediaInfo.value.year,
|
year: mediaInfo.value.year,
|
||||||
type: mediaInfo.value.type,
|
type: mediaInfo.value.type,
|
||||||
},
|
},
|
||||||
})
|
}
|
||||||
|
|
||||||
|
emit('close')
|
||||||
|
await nextTick()
|
||||||
|
await router.push(target)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 调用媒体识别接口并刷新解析工作台,输入的识别词会临时应用于本次识别测试。 */
|
/** 调用媒体识别接口并刷新解析工作台,输入的识别词会临时应用于本次识别测试。 */
|
||||||
async function nameTest() {
|
async function nameTest() {
|
||||||
if (!nameTestForm.title) return
|
const normalizedTitle = nameTestForm.title?.trim() || ''
|
||||||
|
if (!normalizedTitle) return
|
||||||
|
|
||||||
|
nameTestForm.title = normalizedTitle
|
||||||
|
saveTitleHistory(normalizedTitle)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
nameTestLoading.value = true
|
nameTestLoading.value = true
|
||||||
@@ -157,7 +278,7 @@ async function nameTest() {
|
|||||||
params: {
|
params: {
|
||||||
title: nameTestForm.title,
|
title: nameTestForm.title,
|
||||||
subtitle: nameTestForm.subtitle,
|
subtitle: nameTestForm.subtitle,
|
||||||
custom_words: nameTestForm.customWords || undefined,
|
custom_words: nameTestForm.customWords?.trim() || undefined,
|
||||||
source: nameTestForm.source,
|
source: nameTestForm.source,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -180,7 +301,7 @@ function parseCustomWordLines(text: string) {
|
|||||||
async function saveCustomWords() {
|
async function saveCustomWords() {
|
||||||
if (savingCustomWords.value) return
|
if (savingCustomWords.value) return
|
||||||
|
|
||||||
const newLines = parseCustomWordLines(nameTestForm.customWords)
|
const newLines = parseCustomWordLines(nameTestForm.customWords || '')
|
||||||
if (!newLines.length) return
|
if (!newLines.length) return
|
||||||
|
|
||||||
savingCustomWords.value = true
|
savingCustomWords.value = true
|
||||||
@@ -216,8 +337,9 @@ async function saveCustomWords() {
|
|||||||
<VForm validate-on="submit lazy" @submit.prevent="nameTest">
|
<VForm validate-on="submit lazy" @submit.prevent="nameTest">
|
||||||
<VRow class="shortcut-form">
|
<VRow class="shortcut-form">
|
||||||
<VCol cols="12" class="shortcut-form-col">
|
<VCol cols="12" class="shortcut-form-col">
|
||||||
<VTextField
|
<VCombobox
|
||||||
v-model="nameTestForm.title"
|
v-model="nameTestForm.title"
|
||||||
|
:items="nameTestTitleHistory"
|
||||||
:label="t('nameTest.title')"
|
:label="t('nameTest.title')"
|
||||||
:hint="t('nameTest.titleHint')"
|
:hint="t('nameTest.titleHint')"
|
||||||
persistent-hint
|
persistent-hint
|
||||||
@@ -263,7 +385,7 @@ async function saveCustomWords() {
|
|||||||
size="small"
|
size="small"
|
||||||
variant="tonal"
|
variant="tonal"
|
||||||
color="primary"
|
color="primary"
|
||||||
:disabled="!nameTestForm.customWords.trim()"
|
:disabled="!nameTestForm.customWords?.trim()"
|
||||||
:loading="savingCustomWords"
|
:loading="savingCustomWords"
|
||||||
@click="saveCustomWords"
|
@click="saveCustomWords"
|
||||||
>
|
>
|
||||||
@@ -359,7 +481,42 @@ async function saveCustomWords() {
|
|||||||
{{ step.title }}
|
{{ step.title }}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-body-2 font-weight-medium pipeline-value">
|
<div class="text-body-2 font-weight-medium pipeline-value">
|
||||||
{{ step.value }}
|
<span
|
||||||
|
v-if="step.source"
|
||||||
|
class="media-source-display"
|
||||||
|
:aria-label="step.source.label"
|
||||||
|
:data-source="step.source.key"
|
||||||
|
:title="step.source.label"
|
||||||
|
data-testid="recognition-source"
|
||||||
|
>
|
||||||
|
<VImg
|
||||||
|
v-if="step.source.image"
|
||||||
|
class="media-source-logo"
|
||||||
|
:src="step.source.image"
|
||||||
|
:alt="step.source.label"
|
||||||
|
/>
|
||||||
|
<VIcon
|
||||||
|
v-else-if="step.source.icon"
|
||||||
|
class="media-source-logo"
|
||||||
|
color="#02a9ff"
|
||||||
|
:icon="step.source.icon"
|
||||||
|
/>
|
||||||
|
<span>{{ step.source.label }}</span>
|
||||||
|
</span>
|
||||||
|
<template v-else-if="step.identity">
|
||||||
|
<a
|
||||||
|
v-if="step.identity.link"
|
||||||
|
class="media-id-link"
|
||||||
|
:href="step.identity.link"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
@click.stop
|
||||||
|
>
|
||||||
|
{{ step.identity.id }}
|
||||||
|
</a>
|
||||||
|
<span v-else>{{ step.identity.id }}</span>
|
||||||
|
</template>
|
||||||
|
<template v-else>{{ step.value }}</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -534,6 +691,24 @@ async function saveCustomWords() {
|
|||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.media-id-link {
|
||||||
|
color: rgb(var(--v-theme-primary));
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 0.15em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-source-display {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-source-logo {
|
||||||
|
flex: 0 0 1.4rem;
|
||||||
|
block-size: 1.4rem;
|
||||||
|
inline-size: 1.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
.applied-words {
|
.applied-words {
|
||||||
display: grid;
|
display: grid;
|
||||||
border-block-start: var(--app-surface-border);
|
border-block-start: var(--app-surface-border);
|
||||||
|
|||||||
@@ -0,0 +1,206 @@
|
|||||||
|
import NameTestView from '@/views/system/NameTestView.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(),
|
||||||
|
apiPost: vi.fn(),
|
||||||
|
routerPush: vi.fn(),
|
||||||
|
toastError: vi.fn(),
|
||||||
|
toastSuccess: vi.fn(),
|
||||||
|
toastWarning: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/api', () => ({
|
||||||
|
default: {
|
||||||
|
get: mocks.apiGet,
|
||||||
|
post: mocks.apiPost,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/router', () => ({
|
||||||
|
default: {
|
||||||
|
push: mocks.routerPush,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('vue-toastification', () => ({
|
||||||
|
useToast: () => ({
|
||||||
|
error: mocks.toastError,
|
||||||
|
success: mocks.toastSuccess,
|
||||||
|
warning: mocks.toastWarning,
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
interface RecognizedMedia {
|
||||||
|
media_id: string
|
||||||
|
source: string
|
||||||
|
title: string
|
||||||
|
type: string
|
||||||
|
year: string
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderRecognizedMedia(media: RecognizedMedia, onClose = vi.fn()) {
|
||||||
|
mocks.apiGet.mockResolvedValueOnce({
|
||||||
|
media_info: media,
|
||||||
|
meta_info: {
|
||||||
|
apply_words: [],
|
||||||
|
name: media.title,
|
||||||
|
org_string: 'Test.Release',
|
||||||
|
},
|
||||||
|
torrent_info: {},
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await renderWithProviders(NameTestView, {
|
||||||
|
attrs: { onClose },
|
||||||
|
initialState: {
|
||||||
|
globalSettings: {
|
||||||
|
data: { RECOGNIZE_SOURCE: 'themoviedb' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const user = userEvent.setup()
|
||||||
|
|
||||||
|
await user.type(screen.getByLabelText('标题'), 'Test.Release')
|
||||||
|
await user.click(screen.getByRole('button', { name: '识别' }))
|
||||||
|
await screen.findByRole('link', { name: media.media_id })
|
||||||
|
|
||||||
|
return { ...result, onClose, user }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('NameTestView media identity', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
localStorage.clear()
|
||||||
|
mocks.routerPush.mockResolvedValue(undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[
|
||||||
|
'TheMovieDb',
|
||||||
|
{ media_id: '271016', source: 'themoviedb', title: '测试剧集', type: '电视剧', year: '2026' },
|
||||||
|
'https://www.themoviedb.org/tv/271016',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'Douban',
|
||||||
|
{ media_id: '1295644', source: 'douban', title: '测试电影', type: '电影', year: '1994' },
|
||||||
|
'https://movie.douban.com/subject/1295644',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'Bangumi',
|
||||||
|
{ media_id: '485', source: 'bangumi', title: '测试动画', type: '电视剧', year: '2026' },
|
||||||
|
'https://bgm.tv/subject/485',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'AniList',
|
||||||
|
{ media_id: '154587', source: 'anilist', title: '测试番剧', type: '电视剧', year: '2026' },
|
||||||
|
'https://anilist.co/anime/154587',
|
||||||
|
],
|
||||||
|
])('formats %s and links its native media ID', async (sourceLabel, media, expectedLink) => {
|
||||||
|
await renderRecognizedMedia(media)
|
||||||
|
|
||||||
|
const mediaIdLink = screen.getByRole('link', { name: media.media_id })
|
||||||
|
expect(mediaIdLink).toHaveAttribute('href', expectedLink)
|
||||||
|
expect(mediaIdLink).toHaveAttribute('target', '_blank')
|
||||||
|
expect(mediaIdLink).toHaveAttribute('rel', 'noopener noreferrer')
|
||||||
|
expect(mediaIdLink.closest('.pipeline-step')).toHaveTextContent(`媒体 ID${media.media_id}`)
|
||||||
|
expect(mediaIdLink.closest('.pipeline-step')).not.toHaveTextContent(sourceLabel)
|
||||||
|
const sourceDisplay = screen.getByTestId('recognition-source')
|
||||||
|
expect(sourceDisplay).toHaveAttribute('data-source', media.source)
|
||||||
|
expect(sourceDisplay).toHaveAccessibleName(sourceLabel)
|
||||||
|
expect(sourceDisplay.closest('.pipeline-step')).toHaveTextContent(`识别数据源${sourceLabel}`)
|
||||||
|
expect(sourceDisplay.querySelector('.media-source-logo')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('closes the recognition dialog before navigating to the media detail', async () => {
|
||||||
|
const eventOrder: string[] = []
|
||||||
|
const onClose = vi.fn(() => eventOrder.push('close'))
|
||||||
|
mocks.routerPush.mockImplementation(async () => {
|
||||||
|
eventOrder.push('push')
|
||||||
|
})
|
||||||
|
const media = {
|
||||||
|
media_id: '271016',
|
||||||
|
source: 'themoviedb',
|
||||||
|
title: '测试剧集',
|
||||||
|
type: '电视剧',
|
||||||
|
year: '2026',
|
||||||
|
}
|
||||||
|
const { user } = await renderRecognizedMedia(media, onClose)
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: '查看详情' }))
|
||||||
|
|
||||||
|
expect(eventOrder).toEqual(['close', 'push'])
|
||||||
|
expect(onClose).toHaveBeenCalledOnce()
|
||||||
|
expect(mocks.routerPush).toHaveBeenCalledWith({
|
||||||
|
path: '/media',
|
||||||
|
query: {
|
||||||
|
mediaid: 'tmdb:271016',
|
||||||
|
title: '测试剧集',
|
||||||
|
type: '电视剧',
|
||||||
|
year: '2026',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('persists the five most recent unique titles and restores them in the combobox', async () => {
|
||||||
|
mocks.apiGet.mockImplementation(async (_endpoint: string, options: { params: { title: string } }) => ({
|
||||||
|
media_info: {
|
||||||
|
media_id: '271016',
|
||||||
|
source: 'themoviedb',
|
||||||
|
title: options.params.title,
|
||||||
|
type: '电视剧',
|
||||||
|
year: '2026',
|
||||||
|
},
|
||||||
|
meta_info: {
|
||||||
|
apply_words: [],
|
||||||
|
name: options.params.title,
|
||||||
|
org_string: options.params.title,
|
||||||
|
},
|
||||||
|
torrent_info: {},
|
||||||
|
}))
|
||||||
|
const result = await renderWithProviders(NameTestView, {
|
||||||
|
initialState: {
|
||||||
|
globalSettings: {
|
||||||
|
data: { RECOGNIZE_SOURCE: 'themoviedb' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const user = userEvent.setup()
|
||||||
|
const titleInput = screen.getByLabelText('标题')
|
||||||
|
const submittedTitles = ['标题一', '标题二', '标题三', '标题四', '标题五', '标题六', '标题三']
|
||||||
|
|
||||||
|
for (const [index, title] of submittedTitles.entries()) {
|
||||||
|
await user.clear(titleInput)
|
||||||
|
await user.type(titleInput, ` ${title} `)
|
||||||
|
await user.click(screen.getByRole('button', { name: index === 0 ? '识别' : '重新识别' }))
|
||||||
|
await waitFor(() => expect(mocks.apiGet).toHaveBeenCalledTimes(index + 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(JSON.parse(localStorage.getItem('MP_NAME_TEST_TITLE_HISTORY') || '[]')).toEqual([
|
||||||
|
'标题三',
|
||||||
|
'标题六',
|
||||||
|
'标题五',
|
||||||
|
'标题四',
|
||||||
|
'标题二',
|
||||||
|
])
|
||||||
|
|
||||||
|
result.unmount()
|
||||||
|
await renderWithProviders(NameTestView, {
|
||||||
|
initialState: {
|
||||||
|
globalSettings: {
|
||||||
|
data: { RECOGNIZE_SOURCE: 'themoviedb' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const restoredInput = screen.getByLabelText('标题')
|
||||||
|
await user.click(restoredInput)
|
||||||
|
const historyOptions = await screen.findAllByRole('option')
|
||||||
|
expect(historyOptions.map(option => option.textContent)).toEqual(['标题三', '标题六', '标题五', '标题四', '标题二'])
|
||||||
|
await user.click(screen.getByRole('option', { name: '标题六' }))
|
||||||
|
|
||||||
|
expect(restoredInput).toHaveValue('标题六')
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user