fix(classification): avoid duplicate category labels

This commit is contained in:
jxxghp
2026-09-03 17:54:37 +08:00
parent 2b643cfd36
commit a5ffdb27e7
7 changed files with 67 additions and 5 deletions
+2 -1
View File
@@ -2,6 +2,7 @@
import type { StorageConf, TransferDirectoryConf } from '@/api/types'
import type { ClassificationCategory } from '@/api/mediaClassification'
import { manageStorage } from '@/api/manage'
import { formatClassificationCategoryOptionTitle } from '@/utils/mediaClassification'
import { nextTick } from 'vue'
import { useI18n } from 'vue-i18n'
import { storageRemoteDict } from '@/api/constants'
@@ -228,7 +229,7 @@ const categoryItems = computed(() => [
...props.categories
.filter(category => category.enabled && category.media_type === props.directory.media_type)
.map(category => ({
title: `${category.name} · ${categoryPath(category)} · ${category.id}`,
title: formatClassificationCategoryOptionTitle(category, { includeId: true, pathSeparator: '/' }),
value: category.id,
})),
])
@@ -11,6 +11,7 @@ vi.mock('@/api/manage', () => ({
}))
const categories: ClassificationCategory[] = [
{ id: 'movie.base', media_type: '电影', name: '电影', path: ['电影'], enabled: true, labels: [] },
{ id: 'movie.animation', media_type: '电影', name: '动画', path: ['电影', '动画'], enabled: true, labels: [] },
{ id: 'movie.disabled', media_type: '电影', name: '停用', path: ['电影', '停用'], enabled: false, labels: [] },
{ id: 'tv.animation', media_type: '电视剧', name: '动画', path: ['电视剧', '动画'], enabled: true, labels: [] },
@@ -57,6 +58,7 @@ describe('DirectoryCard classification reference', () => {
const categorySelect = within(screen.getByTestId('directory-category-select')).getByRole('combobox')
await user.click(categorySelect)
expect(await screen.findByRole('option', { name: '电影 · movie.base' })).toBeInTheDocument()
expect(await screen.findByRole('option', { name: '动画 · 电影/动画 · movie.animation' })).toBeInTheDocument()
expect(screen.queryByRole('option', { name: /movie.disabled/ })).not.toBeInTheDocument()
expect(screen.queryByRole('option', { name: /tv.animation/ })).not.toBeInTheDocument()
@@ -1,5 +1,6 @@
<script setup lang="ts">
import type { ClassificationCategory, ClassificationMediaType } from '@/api/mediaClassificationTypes'
import { formatClassificationCategoryOptionTitle } from '@/utils/mediaClassification'
/** 分类树编辑器输入属性。 */
interface ClassificationCategoryEditorProps {
@@ -141,8 +142,9 @@ function isCategoryProtected(categoryId: string): boolean {
/** 为 fallback 选择器生成不带内部稳定 ID 的可读标题。 */
function fallbackItemTitle(category: ClassificationCategory): string {
const path = category.path.length ? category.path.join(' / ') : t('setting.classification.category.pathUnset')
return `${category.name} · ${path}`
return formatClassificationCategoryOptionTitle(category, {
emptyPathLabel: t('setting.classification.category.pathUnset'),
})
}
/** 返回指定媒体类型可选的稳定分类 ID 列表。 */
@@ -0,0 +1,36 @@
import { formatClassificationCategoryOptionTitle } from '@/utils/mediaClassification'
import { describe, expect, it } from 'vitest'
describe('formatClassificationCategoryOptionTitle', () => {
it('omits a path that is identical to the category name', () => {
expect(formatClassificationCategoryOptionTitle({ id: 'movie.base', name: '电影', path: ['电影'] })).toBe('电影')
})
it('keeps hierarchical paths that add information to the category name', () => {
expect(
formatClassificationCategoryOptionTitle({
id: 'movie.animation',
name: '动画',
path: ['电影', '动画'],
}),
).toBe('动画 · 电影 / 动画')
})
it('can preserve a caller-specific path separator and stable ID', () => {
expect(
formatClassificationCategoryOptionTitle(
{ id: 'movie.animation', name: '动画', path: ['电影', '动画'] },
{ includeId: true, pathSeparator: '/' },
),
).toBe('动画 · 电影/动画 · movie.animation')
})
it('uses the configured label only when the category has no path', () => {
expect(
formatClassificationCategoryOptionTitle(
{ id: 'movie.unset', name: '未分类', path: [] },
{ emptyPathLabel: '未设置路径' },
),
).toBe('未分类 · 未设置路径')
})
})
+20
View File
@@ -0,0 +1,20 @@
import type { ClassificationCategory } from '@/api/mediaClassificationTypes'
/** 分类下拉标题的可选显示配置。 */
interface ClassificationCategoryOptionTitleOptions {
emptyPathLabel?: string
includeId?: boolean
pathSeparator?: string
}
/** 生成分类选择器标题,避免分类名与完全相同的路径重复显示。 */
export function formatClassificationCategoryOptionTitle(
category: Pick<ClassificationCategory, 'name' | 'path' | 'id'>,
options: ClassificationCategoryOptionTitleOptions = {},
): string {
const path = category.path.join(options.pathSeparator ?? ' / ')
const displayPath = path && path !== category.name ? path : path ? '' : (options.emptyPathLabel ?? '')
const parts = [category.name, displayPath]
if (options.includeId) parts.push(category.id)
return parts.filter(Boolean).join(' · ')
}
@@ -21,6 +21,7 @@ import ClassificationPolicyControlPanel from '@/components/classification/Classi
import ClassificationPreviewPanel from '@/components/classification/ClassificationPreviewPanel.vue'
import ClassificationRuleEditor from '@/components/classification/ClassificationRuleEditor.vue'
import { useMediaClassification } from '@/composables/useMediaClassification'
import { formatClassificationCategoryOptionTitle } from '@/utils/mediaClassification'
import { cloneDeep, isEqual } from 'lodash-es'
import { useToast } from 'vue-toastification'
@@ -222,7 +223,7 @@ function fallbackCategoryOptions(mediaType: ClassificationMediaType) {
return (draftPolicy.value?.categories ?? [])
.filter(category => category.enabled && category.media_type === mediaType)
.map(category => ({
title: category.path.length ? `${category.name} · ${category.path.join(' / ')}` : category.name,
title: formatClassificationCategoryOptionTitle(category),
value: category.id,
}))
}
@@ -444,7 +444,7 @@ describe('AccountSettingClassification', () => {
name: 'musicbrainz 的电影来源兜底',
})
await user.click(musicbrainzFallback)
await user.click(await screen.findByRole('option', { name: '电影 · 电影' }))
await user.click(await screen.findByRole('option', { name: '电影' }))
const state = mocks.useMediaClassification.mock.results[0].value
expect(state.draftPolicy.value.source_fallbacks.musicbrainz.).toBe('movie.base')