mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-09 01:36:45 +08:00
feat(classification): clarify rule values and category destinations
This commit is contained in:
@@ -236,6 +236,8 @@ export interface ClassificationFieldDefinition {
|
||||
operators: ClassificationOperator[]
|
||||
media_types: ClassificationMediaType[]
|
||||
options: ClassificationFieldOption[]
|
||||
/** 按来源区分的候选,保持原始值和大小写。 */
|
||||
source_options?: Record<string, ClassificationFieldOption[]>
|
||||
allow_custom_values: boolean
|
||||
source_support: Record<string, ClassificationSourceSupport>
|
||||
selectable?: boolean
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import type { ClassificationCategory, ClassificationMediaType } from '@/api/mediaClassificationTypes'
|
||||
import { formatClassificationCategoryOptionTitle } from '@/utils/mediaClassification'
|
||||
import { classificationCategoryDisplayName, formatClassificationCategoryOptionTitle } from '@/utils/mediaClassification'
|
||||
|
||||
/** 分类树编辑器输入属性。 */
|
||||
interface ClassificationCategoryEditorProps {
|
||||
@@ -461,7 +461,10 @@ function updateFallback(mediaType: ClassificationMediaType, categoryId: string |
|
||||
>
|
||||
<div class="classification-category-summary">
|
||||
<div class="classification-category-title-line">
|
||||
<strong>{{ category.name }}</strong>
|
||||
<strong>{{ classificationCategoryDisplayName(category) }}</strong>
|
||||
<VChip v-if="fallbacks[category.media_type] === category.id" size="small" color="primary" variant="tonal"
|
||||
>默认分类</VChip
|
||||
>
|
||||
<VChip size="small" :color="category.enabled ? 'success' : undefined" variant="tonal">
|
||||
{{
|
||||
category.enabled
|
||||
@@ -470,7 +473,9 @@ function updateFallback(mediaType: ClassificationMediaType, categoryId: string |
|
||||
}}
|
||||
</VChip>
|
||||
</div>
|
||||
<code class="classification-category-id">{{ category.id }}</code>
|
||||
<p v-if="classificationCategoryDisplayName(category) !== category.name" class="classification-category-id">
|
||||
旧版迁移时额外创建的备用目录;只有被规则、默认分类或目录设置选中时才会使用。
|
||||
</p>
|
||||
<ol
|
||||
class="classification-category-path"
|
||||
:aria-label="t('setting.classification.category.pathAria', { name: category.name })"
|
||||
|
||||
@@ -37,6 +37,7 @@ const emit = defineEmits<{
|
||||
type ConditionNodeKind = 'condition' | 'all' | 'any' | 'not'
|
||||
type ValueControlKind = 'none' | 'range' | 'list' | 'boolean' | 'number' | 'select' | 'text'
|
||||
|
||||
/** 当前规则所选来源的字段可用性提示。 */
|
||||
interface SourceSupportHint {
|
||||
source: string
|
||||
support: Extract<ClassificationSourceSupport, 'partial' | 'unavailable'>
|
||||
@@ -145,7 +146,12 @@ const fieldItems = computed(() =>
|
||||
.sort((left, right) => fieldOrder(left) - fieldOrder(right))
|
||||
.filter(field => field.selectable !== false || field.id === selectedCondition.value?.field)
|
||||
.map(field => ({
|
||||
title: field.group ? `${field.group} · ${field.label}` : field.label,
|
||||
title:
|
||||
field.selectable === false
|
||||
? `${field.label.replace('(旧规则)', '')}(兼容字段)`
|
||||
: field.group
|
||||
? `${field.group} · ${field.label}`
|
||||
: field.label,
|
||||
value: field.id,
|
||||
props: { disabled: field.selectable === false },
|
||||
})),
|
||||
@@ -183,20 +189,60 @@ const operatorItems = computed(() =>
|
||||
})),
|
||||
)
|
||||
|
||||
const optionItems = computed(() =>
|
||||
(selectedDefinition.value?.options ?? []).map(option => ({
|
||||
title: option.label,
|
||||
/** 合并通用字典与已选来源候选,来源切换只改变建议,不清空已有值。 */
|
||||
const optionItems = computed(() => {
|
||||
const definition = selectedDefinition.value
|
||||
const options = new Map<ClassificationFactScalar, { title: string; value: ClassificationFactScalar }>()
|
||||
for (const option of definition?.options ?? []) {
|
||||
options.set(option.value, {
|
||||
title: option.label === String(option.value) ? option.label : `${option.label} · ${option.value}`,
|
||||
value: option.value,
|
||||
})),
|
||||
)
|
||||
})
|
||||
}
|
||||
if (definition?.id === 'identity.media_source') {
|
||||
for (const source of props.sourceOptions)
|
||||
options.set(source.value, { title: `${source.title} · ${source.value}`, value: source.value })
|
||||
}
|
||||
for (const [source, candidates] of Object.entries(definition?.source_options ?? {})) {
|
||||
if (props.sources.length && !props.sources.includes(source)) continue
|
||||
const sourceName = props.sourceOptions.find(option => option.value === source)?.title ?? source
|
||||
for (const option of candidates) {
|
||||
const previous = options.get(option.value)
|
||||
const title = option.label === String(option.value) ? option.label : `${option.label} · ${option.value}`
|
||||
options.set(option.value, {
|
||||
title: previous ? `${previous.title} / ${sourceName}` : `${title} · ${sourceName}`,
|
||||
value: option.value,
|
||||
})
|
||||
}
|
||||
}
|
||||
return [...options.values()]
|
||||
})
|
||||
|
||||
/** 字典之外的历史值仍显示并原样保存,不能因切换来源或选项升级而丢失。 */
|
||||
const catalogListSelection = computed(() =>
|
||||
optionItems.value.filter(option => listValue.value.some(value => Object.is(value, option.value))),
|
||||
listValue.value.map(
|
||||
value => optionItems.value.find(option => Object.is(value, option.value)) ?? { title: String(value), value },
|
||||
),
|
||||
)
|
||||
const catalogScalarSelection = computed(
|
||||
() =>
|
||||
optionItems.value.find(option => Object.is(option.value, scalarValue.value)) ??
|
||||
(scalarValue.value === undefined || scalarValue.value === null
|
||||
? null
|
||||
: { title: String(scalarValue.value), value: scalarValue.value }),
|
||||
)
|
||||
|
||||
const catalogScalarSelection = computed(
|
||||
() => optionItems.value.find(option => Object.is(option.value, scalarValue.value)) ?? null,
|
||||
)
|
||||
/** 明确区分可搜索字典与没有封闭枚举的来源原值。 */
|
||||
const valueHint = computed(() => {
|
||||
if (NO_VALUE_OPERATORS.has(selectedCondition.value?.operator as ClassificationOperator)) return ''
|
||||
if (optionItems.value.length)
|
||||
return selectedDefinition.value?.allow_custom_values
|
||||
? '搜索名称或代码选择;列表外的来源原值也可输入,按回车确认。'
|
||||
: '搜索名称或代码,可直接选择条件值。'
|
||||
return ['string', 'string_list'].includes(selectedDefinition.value?.value_type ?? '')
|
||||
? '此字段没有固定字典,请按媒体预览中的原值填写;多个值逐个输入并按回车确认。'
|
||||
: ''
|
||||
})
|
||||
|
||||
const sourceSupportHints = computed<SourceSupportHint[]>(() => {
|
||||
const definition = selectedDefinition.value
|
||||
@@ -233,7 +279,7 @@ const valueControlKind = computed<ValueControlKind>(() => {
|
||||
if (LIST_VALUE_OPERATORS.has(condition.operator) || definition.value_type === 'string_list') return 'list'
|
||||
if (definition.value_type === 'boolean') return 'boolean'
|
||||
if (['integer', 'number', 'year'].includes(definition.value_type)) return 'number'
|
||||
if (definition.value_type === 'enum' || definition.options.length > 0) return 'select'
|
||||
if (definition.value_type === 'enum' || optionItems.value.length > 0) return 'select'
|
||||
return 'text'
|
||||
})
|
||||
|
||||
@@ -257,12 +303,14 @@ const scalarValue = computed(() => {
|
||||
|
||||
const booleanValue = computed(() => scalarValue.value === true)
|
||||
const numericStep = computed(() => (selectedDefinition.value?.value_type === 'number' ? 'any' : 1))
|
||||
const usesCatalogSelect = computed(
|
||||
() => (selectedDefinition.value?.options.length ?? 0) > 0 && !selectedDefinition.value?.allow_custom_values,
|
||||
)
|
||||
const usesCatalogSelect = computed(() => optionItems.value.length > 0 && !selectedDefinition.value?.allow_custom_values)
|
||||
// Vuetify 会从对象 items 推断 return-object;这里仅收窄模板泛型,运行时仍传递原始标量。
|
||||
const comboboxListModel = computed(() => listValue.value as never[])
|
||||
const comboboxScalarModel = computed(() => scalarValue.value as never)
|
||||
const comboboxListModel = computed(
|
||||
() => (optionItems.value.length ? catalogListSelection.value : listValue.value) as never[],
|
||||
)
|
||||
const comboboxScalarModel = computed(
|
||||
() => (optionItems.value.length ? catalogScalarSelection.value : scalarValue.value) as never,
|
||||
)
|
||||
|
||||
/** 根据字段值类型把控件输出转换为分类条件允许的标量。 */
|
||||
function normalizeScalarValue(value: unknown, definition: ClassificationFieldDefinition): ClassificationFactScalar {
|
||||
@@ -291,7 +339,8 @@ function createDefaultValue(
|
||||
if (definition.value_type === 'integer' || definition.value_type === 'number' || definition.value_type === 'year') {
|
||||
return null
|
||||
}
|
||||
return definition.options[0]?.value ?? ''
|
||||
// 开放字段的候选只是录入建议,不能把第一个语言或国家自动变成匹配条件。
|
||||
return definition.value_type === 'enum' && !definition.allow_custom_values ? (definition.options[0]?.value ?? '') : ''
|
||||
}
|
||||
|
||||
/** 由字段目录的首个可用字段构造叶子,不在前端臆造字段或操作符。 */
|
||||
@@ -356,7 +405,7 @@ function updateScalarValue(value: unknown): void {
|
||||
const condition = selectedCondition.value
|
||||
const definition = selectedDefinition.value
|
||||
if (!condition || !definition) return
|
||||
updateNode({ ...condition, value: normalizeScalarValue(value, definition) })
|
||||
updateNode({ ...condition, value: normalizeScalarValue(catalogOptionValue(value), definition) })
|
||||
}
|
||||
|
||||
/** 写入成员列表,数字字段会把控件字符串转换为 number。 */
|
||||
@@ -367,7 +416,7 @@ function updateListValue(value: unknown): void {
|
||||
|
||||
const values = Array.isArray(value) ? value : value === null || value === undefined ? [] : [value]
|
||||
const normalized = values
|
||||
.map(item => normalizeScalarValue(item, definition))
|
||||
.map(item => normalizeScalarValue(catalogOptionValue(item), definition))
|
||||
.filter((item): item is Exclude<ClassificationFactScalar, null> => item !== null)
|
||||
updateNode({ ...condition, value: normalized })
|
||||
}
|
||||
@@ -534,11 +583,12 @@ function removeChild(index: number): void {
|
||||
</div>
|
||||
|
||||
<template v-else-if="valueControlKind === 'list'">
|
||||
<VSelect
|
||||
<VAutocomplete
|
||||
v-if="usesCatalogSelect"
|
||||
:model-value="catalogListSelection"
|
||||
:items="optionItems"
|
||||
return-object
|
||||
placeholder="搜索或输入条件值"
|
||||
label="条件值"
|
||||
aria-label="条件值列表"
|
||||
multiple
|
||||
@@ -555,6 +605,8 @@ function removeChild(index: number): void {
|
||||
v-else
|
||||
:model-value="comboboxListModel"
|
||||
:items="optionItems"
|
||||
return-object
|
||||
placeholder="搜索或输入条件值"
|
||||
label="条件值"
|
||||
aria-label="条件值列表"
|
||||
multiple
|
||||
@@ -598,11 +650,12 @@ function removeChild(index: number): void {
|
||||
/>
|
||||
|
||||
<template v-else-if="valueControlKind === 'select'">
|
||||
<VSelect
|
||||
<VAutocomplete
|
||||
v-if="usesCatalogSelect"
|
||||
:model-value="catalogScalarSelection"
|
||||
:items="optionItems"
|
||||
return-object
|
||||
placeholder="搜索或输入条件值"
|
||||
label="条件值"
|
||||
aria-label="条件值"
|
||||
variant="outlined"
|
||||
@@ -616,6 +669,8 @@ function removeChild(index: number): void {
|
||||
v-else
|
||||
:model-value="comboboxScalarModel"
|
||||
:items="optionItems"
|
||||
return-object
|
||||
placeholder="搜索或输入条件值"
|
||||
label="条件值"
|
||||
aria-label="条件值"
|
||||
variant="outlined"
|
||||
@@ -640,6 +695,17 @@ function removeChild(index: number): void {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-if="selectedDefinition?.description"
|
||||
class="classification-condition-builder__node-hint"
|
||||
data-testid="field-description"
|
||||
>
|
||||
{{ selectedDefinition.description }}
|
||||
</p>
|
||||
<p v-if="valueHint" class="classification-condition-builder__node-hint" data-testid="value-hint">
|
||||
{{ valueHint }}
|
||||
</p>
|
||||
|
||||
<p class="classification-condition-builder__source-scope-note" data-testid="source-scope-note">
|
||||
{{ sourceScopeNote }}
|
||||
</p>
|
||||
@@ -651,8 +717,10 @@ function removeChild(index: number): void {
|
||||
>
|
||||
<VIcon icon="mdi-history" size="16" />
|
||||
<span>
|
||||
此字段只保留旧规则的原始匹配。
|
||||
<template v-if="replacementDefinition">建议改用“{{ replacementDefinition.label }}”。</template>
|
||||
这条条件从旧分类配置迁移而来,继续按原来的方式匹配,不需要重新配置。
|
||||
<template v-if="replacementDefinition"
|
||||
>新增条件请选“{{ replacementDefinition.label }}”;更换字段可能改变匹配结果,请先预览。</template
|
||||
>
|
||||
</span>
|
||||
</p>
|
||||
|
||||
|
||||
@@ -114,7 +114,7 @@ function categoryItems(rule: ClassificationRule) {
|
||||
return props.categories
|
||||
.filter(category => selectedMediaTypes.size === 0 || selectedMediaTypes.has(category.media_type))
|
||||
.map(category => ({
|
||||
title: `${formatClassificationCategoryOptionTitle(category)}${category.enabled ? '' : '(已停用)'}`,
|
||||
title: `${formatClassificationCategoryOptionTitle(category, { includeMediaType: rule.media_types.length !== 1 })}${category.enabled ? '' : '(已停用)'}`,
|
||||
value: category.id,
|
||||
props: { disabled: !category.enabled },
|
||||
}))
|
||||
@@ -272,7 +272,7 @@ function sourceSummary(rule: ClassificationRule): string {
|
||||
function targetSummary(rule: ClassificationRule): string {
|
||||
if (rule.kind === 'label') return rule.target.labels.length ? `标签 ${rule.target.labels.join('、')}` : '未设置标签'
|
||||
const category = props.categories.find(item => item.id === rule.target.category_id)
|
||||
return category?.name ?? '未设置分类'
|
||||
return category ? formatClassificationCategoryOptionTitle(category) : '未设置分类'
|
||||
}
|
||||
|
||||
const sourceItems = computed(() => {
|
||||
@@ -509,13 +509,13 @@ watch(
|
||||
</div>
|
||||
|
||||
<div class="classification-rule-target">
|
||||
<div class="classification-rule-section-title">规则输出</div>
|
||||
<div class="classification-rule-section-title">命中后执行</div>
|
||||
<div class="classification-rule-grid">
|
||||
<VSelect
|
||||
v-if="rule.kind === 'category'"
|
||||
:model-value="rule.target.category_id"
|
||||
:items="categoryItems(rule)"
|
||||
label="分类目标"
|
||||
label="归入分类"
|
||||
clearable
|
||||
density="compact"
|
||||
hide-details="auto"
|
||||
|
||||
@@ -50,23 +50,23 @@ async function renderEditor(
|
||||
}
|
||||
|
||||
describe('ClassificationCategoryEditor', () => {
|
||||
it('按电影、电视剧和音乐分段展示分类编号与多级路径', async () => {
|
||||
it('按电影、电视剧和音乐分段展示分类名称与多级路径,内部编号留在编辑表单', async () => {
|
||||
const user = userEvent.setup()
|
||||
await renderEditor()
|
||||
|
||||
expect(screen.getByText('科幻电影')).toBeInTheDocument()
|
||||
expect(screen.getByText('movie.scifi')).toBeInTheDocument()
|
||||
expect(screen.queryByText('movie.scifi')).not.toBeInTheDocument()
|
||||
expect(document.querySelector('.classification-media-segments')).toHaveClass('v-btn-group--density-compact')
|
||||
expect(screen.getByRole('list', { name: '科幻电影分类路径' })).toHaveTextContent('电影科幻')
|
||||
expect(screen.queryByText('纪录剧集')).not.toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '电视剧' }))
|
||||
expect(screen.getByText('纪录剧集')).toBeInTheDocument()
|
||||
expect(screen.getByText('tv.documentary')).toBeInTheDocument()
|
||||
expect(screen.queryByText('tv.documentary')).not.toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '音乐' }))
|
||||
expect(screen.getByText('无损音乐')).toBeInTheDocument()
|
||||
expect(screen.getByText('music.lossless')).toBeInTheDocument()
|
||||
expect(screen.queryByText('music.lossless')).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('list', { name: '无损音乐分类路径' })).toHaveTextContent('音乐专辑无损')
|
||||
})
|
||||
|
||||
@@ -137,7 +137,7 @@ describe('ClassificationCategoryEditor', () => {
|
||||
expect(deleteButton).toBeDisabled()
|
||||
expect(deleteButton).toHaveAttribute('aria-describedby', protection.id)
|
||||
expect(protection).toHaveTextContent('已被分类规则引用')
|
||||
expect(protection).toHaveTextContent('已设为电影全局兜底分类')
|
||||
expect(protection).toHaveTextContent('已设为电影默认分类')
|
||||
|
||||
await user.click(deleteButton)
|
||||
expect(events.updateCategories).not.toHaveBeenCalled()
|
||||
@@ -184,7 +184,7 @@ describe('ClassificationCategoryEditor', () => {
|
||||
const { events } = await renderEditor()
|
||||
|
||||
await user.click(screen.getByRole('combobox', { name: '音乐默认分类' }))
|
||||
await user.click(await screen.findByRole('option', { name: '无损音乐 · 音乐 / 专辑 / 无损' }))
|
||||
await user.click(await screen.findByRole('option', { name: '无损音乐 · 路径:音乐 / 专辑 / 无损' }))
|
||||
|
||||
await waitFor(() => expect(events.updateFallbacks).toHaveBeenCalledWith({ 音乐: 'music.lossless' }))
|
||||
})
|
||||
@@ -197,7 +197,7 @@ describe('ClassificationCategoryEditor', () => {
|
||||
|
||||
await user.click(screen.getByRole('combobox', { name: '电视剧默认分类' }))
|
||||
|
||||
expect(await screen.findByRole('option', { name: '未分类 · 通用' })).toBeInTheDocument()
|
||||
expect(await screen.findByRole('option', { name: '备用未分类 · 路径:未分类 / 通用' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('option', { name: '未分类 · 未分类 / 通用' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
ClassificationSourceOption,
|
||||
} from '@/api/mediaClassificationTypes'
|
||||
import ClassificationConditionBuilder from '@/components/classification/ClassificationConditionBuilder.vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { fireEvent, screen, within } from '@testing-library/vue'
|
||||
import { renderWithProviders } from '@tests/support/render'
|
||||
import { Fragment, defineComponent, h, inject, provide, type InjectionKey, type PropType } from 'vue'
|
||||
@@ -298,7 +299,7 @@ describe('ClassificationConditionBuilder', () => {
|
||||
expect(fieldSelect).toHaveAttribute('aria-label', '条件字段')
|
||||
expect(within(fieldSelect).getByRole('button', { name: '媒体 · 年份' })).toBeInTheDocument()
|
||||
expect(within(fieldSelect).queryByRole('button', { name: '音乐 · 音乐标签' })).not.toBeInTheDocument()
|
||||
expect(within(fieldSelect).queryByRole('button', { name: '旧规则 · 风格(旧规则)' })).not.toBeInTheDocument()
|
||||
expect(within(fieldSelect).queryByRole('button', { name: '风格(兼容字段)' })).not.toBeInTheDocument()
|
||||
|
||||
const operatorSelect = screen.getByTestId('operator-select')
|
||||
expect(operatorSelect).toHaveAttribute('aria-label', '条件操作符')
|
||||
@@ -325,10 +326,12 @@ describe('ClassificationConditionBuilder', () => {
|
||||
})
|
||||
|
||||
expect(
|
||||
within(screen.getByTestId('field-select')).getByRole('button', { name: '旧规则 · 风格(旧规则)' }),
|
||||
within(screen.getByTestId('field-select')).getByRole('button', { name: '风格(兼容字段)' }),
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByTestId('retired-field-hint')).toHaveTextContent('此字段只保留旧规则的原始匹配。')
|
||||
expect(screen.getByTestId('retired-field-hint')).toHaveTextContent('建议改用“风格”')
|
||||
expect(screen.getByTestId('retired-field-hint')).toHaveTextContent(
|
||||
'这条条件从旧分类配置迁移而来,继续按原来的方式匹配,不需要重新配置。',
|
||||
)
|
||||
expect(screen.getByTestId('retired-field-hint')).toHaveTextContent('新增条件请选“风格”')
|
||||
})
|
||||
|
||||
it('按 string、enum、integer、number、year、string_list、boolean 和无值操作符输出类型化值', async () => {
|
||||
@@ -497,3 +500,101 @@ describe('ClassificationConditionBuilder', () => {
|
||||
expect(sources).toEqual(snapshots.sources)
|
||||
})
|
||||
})
|
||||
|
||||
describe('分类字典的真实输入控件', () => {
|
||||
it('搜索中文国家名后保存代码,并保留目录外的历史值', async () => {
|
||||
const user = userEvent.setup()
|
||||
const definition = fieldDefinition('media.countries', '原产国家/地区', 'string_list', ['contains_any'])
|
||||
definition.options = [
|
||||
{ value: 'JP', label: '日本' },
|
||||
{ value: 'KR', label: '韩国' },
|
||||
]
|
||||
const result = await renderWithProviders(ClassificationConditionBuilder, {
|
||||
props: {
|
||||
...defaultProps,
|
||||
fields: [definition],
|
||||
modelValue: { field: definition.id, operator: 'contains_any', value: ['历史原值'] },
|
||||
},
|
||||
})
|
||||
await user.type(screen.getByRole('textbox', { name: '条件值列表' }), '日本')
|
||||
await user.click(await screen.findByRole('option', { name: '日本 · JP' }))
|
||||
expect((result.emitted()['update:modelValue'] as unknown[][]).at(-1)?.[0]).toEqual({
|
||||
field: 'media.countries',
|
||||
operator: 'contains_any',
|
||||
value: ['历史原值', 'JP'],
|
||||
})
|
||||
})
|
||||
|
||||
it('来源切换刷新候选但不修改已有值,选择候选保存来源的原始文本', async () => {
|
||||
const user = userEvent.setup()
|
||||
const definition = fieldDefinition('media.genre_names', '来源风格', 'string_list', ['contains_any'])
|
||||
definition.source_options = {
|
||||
douban: [{ value: '动画', label: '动画' }],
|
||||
anilist: [{ value: 'Action', label: '动作' }],
|
||||
}
|
||||
const modelValue: ClassificationConditionNode = {
|
||||
field: definition.id,
|
||||
operator: 'contains_any',
|
||||
value: ['自定义风格'],
|
||||
}
|
||||
const result = await renderWithProviders(ClassificationConditionBuilder, {
|
||||
props: { ...defaultProps, fields: [definition], sources: ['douban'], modelValue },
|
||||
})
|
||||
await user.click(screen.getByRole('textbox', { name: '条件值列表' }))
|
||||
expect(await screen.findByRole('option', { name: '动画 · 豆瓣' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('option', { name: '动作 · Action · anilist' })).not.toBeInTheDocument()
|
||||
await user.keyboard('{Escape}')
|
||||
await result.rerender({ sources: ['anilist'] })
|
||||
expect(result.emitted()['update:modelValue']).toBeUndefined()
|
||||
await user.click(screen.getByRole('textbox', { name: '条件值列表' }))
|
||||
await user.click(await screen.findByRole('option', { name: '动作 · Action · anilist' }))
|
||||
expect((result.emitted()['update:modelValue'] as unknown[][]).at(-1)?.[0]).toEqual({
|
||||
...modelValue,
|
||||
value: ['自定义风格', 'Action'],
|
||||
})
|
||||
})
|
||||
|
||||
it('单选语言保存语言代码,开放字段仍可输入字典外原值', async () => {
|
||||
const user = userEvent.setup()
|
||||
const definition = fieldDefinition('media.language', '原始语言', 'string', ['equals'])
|
||||
definition.options = [{ value: 'ja', label: '日语' }]
|
||||
const result = await renderWithProviders(ClassificationConditionBuilder, {
|
||||
props: {
|
||||
...defaultProps,
|
||||
fields: [definition],
|
||||
modelValue: { field: definition.id, operator: 'equals', value: '' },
|
||||
},
|
||||
})
|
||||
await user.type(screen.getByRole('textbox', { name: '条件值' }), '日语')
|
||||
await user.click(await screen.findByRole('option', { name: '日语 · ja' }))
|
||||
expect((result.emitted()['update:modelValue'] as unknown[][]).at(-1)?.[0]).toEqual({
|
||||
field: definition.id,
|
||||
operator: 'equals',
|
||||
value: 'ja',
|
||||
})
|
||||
await result.rerender({ modelValue: { field: definition.id, operator: 'equals', value: '' } })
|
||||
const input = screen.getByRole('textbox', { name: '条件值' })
|
||||
await user.clear(input)
|
||||
await user.type(input, '未知语言{Enter}{Tab}')
|
||||
expect((result.emitted()['update:modelValue'] as unknown[][]).at(-1)?.[0]).toEqual({
|
||||
field: definition.id,
|
||||
operator: 'equals',
|
||||
value: '未知语言',
|
||||
})
|
||||
})
|
||||
|
||||
it('新增开放字段条件时保持空值,不把字典首项误设为匹配条件', async () => {
|
||||
const definition = fieldDefinition('media.language', '原始语言', 'string', ['equals'])
|
||||
definition.options = [{ value: 'ja', label: '日语' }]
|
||||
const result = await renderWithProviders(ClassificationConditionBuilder, {
|
||||
props: {
|
||||
...defaultProps,
|
||||
fields: [definition],
|
||||
modelValue: { field: definition.id, operator: 'equals', value: '' },
|
||||
},
|
||||
})
|
||||
|
||||
expect(screen.getByRole('textbox', { name: '条件值' })).toHaveValue('')
|
||||
expect(result.emitted()['update:modelValue']).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -170,9 +170,9 @@ describe('ClassificationImpactPanel', () => {
|
||||
}
|
||||
|
||||
expect(screen.getByRole('note')).toHaveTextContent('未展示的记录不能据此判断是否发生变化')
|
||||
expect(screen.getByLabelText('当前规则分类计数')).toHaveTextContent('科幻电影 · 电影 / 科幻70')
|
||||
expect(screen.getByLabelText('待发布规则分类计数')).toHaveTextContent('华语电影 · 电影 / 华语1')
|
||||
expect(screen.getByLabelText('待发布规则分类计数')).toHaveTextContent('无损音乐 · 音乐 / 专辑 / 无损30')
|
||||
expect(screen.getByLabelText('当前规则分类计数')).toHaveTextContent('科幻电影 · 路径:电影 / 科幻70')
|
||||
expect(screen.getByLabelText('待发布规则分类计数')).toHaveTextContent('华语电影 · 路径:电影 / 华语1')
|
||||
expect(screen.getByLabelText('待发布规则分类计数')).toHaveTextContent('无损音乐 · 路径:音乐 / 专辑 / 无损30')
|
||||
})
|
||||
|
||||
it('按媒体类型和来源展示分组,并完整呈现有限变化示例的前后结果', async () => {
|
||||
@@ -189,13 +189,13 @@ describe('ClassificationImpactPanel', () => {
|
||||
expect(within(example).getByRole('list', { name: '变化字段' })).toHaveTextContent('分类变化分类路径命中规则')
|
||||
|
||||
const previous = within(example).getByRole('region', { name: '变化示例 1 的活动策略结果' })
|
||||
expect(previous).toHaveTextContent('科幻电影 · 电影 / 科幻')
|
||||
expect(previous).toHaveTextContent('科幻电影 · 路径:电影 / 科幻')
|
||||
expect(previous).toHaveTextContent('电影 / 科幻')
|
||||
expect(previous).toHaveTextContent('规则命中')
|
||||
expect(previous).toHaveTextContent('完整')
|
||||
|
||||
const candidate = within(example).getByRole('region', { name: '变化示例 1 的候选策略结果' })
|
||||
expect(candidate).toHaveTextContent('华语电影 · 电影 / 华语')
|
||||
expect(candidate).toHaveTextContent('华语电影 · 路径:电影 / 华语')
|
||||
expect(candidate).toHaveTextContent('电影 / 华语')
|
||||
expect(candidate).toHaveTextContent('全局默认分类')
|
||||
expect(candidate).toHaveTextContent('媒体信息不完整')
|
||||
|
||||
@@ -169,8 +169,8 @@ describe('ClassificationPreviewPanel', () => {
|
||||
it('只显示人类可读的分类、规则来源和字段名称', async () => {
|
||||
await renderPanel({ result: createEvaluation() })
|
||||
|
||||
expect(screen.getByRole('region', { name: '规则建议分类' })).toHaveTextContent('科幻电影 · 电影 / 科幻')
|
||||
expect(screen.getByRole('region', { name: '生效分类' })).toHaveTextContent('精选电影 · 电影 / 精选')
|
||||
expect(screen.getByRole('region', { name: '规则建议分类' })).toHaveTextContent('科幻电影 · 路径:电影 / 科幻')
|
||||
expect(screen.getByRole('region', { name: '生效分类' })).toHaveTextContent('精选电影 · 路径:电影 / 精选')
|
||||
expect(screen.getByRole('region', { name: '标签' })).toHaveTextContent('经典')
|
||||
expect(screen.getByRole('region', { name: '警告' })).toHaveTextContent('当前媒体没有内容分级')
|
||||
expect(screen.getByRole('region', { name: '警告' })).toHaveTextContent('内容分级')
|
||||
|
||||
@@ -251,8 +251,8 @@ describe('ClassificationRuleEditor', () => {
|
||||
const editor = await renderEditor([createRule()])
|
||||
|
||||
await user.click(screen.getByLabelText('分类目标 电影规则'))
|
||||
expect(await screen.findByRole('option', { name: '华语电影 · 电影 / 华语' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('option', { name: '摇滚专辑 · 音乐 / 摇滚' })).not.toBeInTheDocument()
|
||||
expect(await screen.findByRole('option', { name: '华语电影 · 路径:电影 / 华语' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('option', { name: '摇滚专辑 · 路径:音乐 / 摇滚' })).not.toBeInTheDocument()
|
||||
await user.keyboard('{Escape}')
|
||||
|
||||
await selectOption('媒体类型 电影规则', '音乐')
|
||||
@@ -262,7 +262,7 @@ describe('ClassificationRuleEditor', () => {
|
||||
expect(editor.latestRules()[0]?.media_types).toEqual(['音乐'])
|
||||
expect(editor.latestRules()[0]?.target.category_id).toBeNull()
|
||||
|
||||
await selectOption('分类目标 电影规则', '摇滚专辑 · 音乐 / 摇滚')
|
||||
await selectOption('分类目标 电影规则', '摇滚专辑 · 路径:音乐 / 摇滚')
|
||||
expect(editor.latestRules()[0]?.target.category_id).toBe('music-rock')
|
||||
})
|
||||
|
||||
|
||||
@@ -2039,7 +2039,7 @@ export default {
|
||||
},
|
||||
rules: {
|
||||
title: '2. 编写规则',
|
||||
body: '在“规则”中从上到下设置条件。系统先检查规则限定的媒体类型和数据来源,再读取这条媒体记录实际提供的信息。选择多个来源表示任意一个来源都可以命中,系统不会把多个来源的信息拼在一起;第一条符合条件的规则生效,都不符合时使用媒体类型默认分类。',
|
||||
body: '在“规则”中从上到下设置条件。国家、语言和风格可搜索名称选择,系统自动保存对应代码;“来源风格”的候选随数据源变化,没有固定字典的字段按媒体预览中的原值填写。带“兼容字段”的条件来自旧配置,保留原来的匹配方式。系统先检查规则限定的媒体类型和数据来源,再读取这条媒体记录实际提供的信息。选择多个来源表示任意一个来源都可以命中,系统不会把多个来源的信息拼在一起;第一条符合条件的规则生效,都不符合时使用媒体类型默认分类。',
|
||||
},
|
||||
preview: {
|
||||
title: '3. 预览分类结果',
|
||||
@@ -2061,7 +2061,7 @@ export default {
|
||||
},
|
||||
category: {
|
||||
title: '分类树',
|
||||
description: '分类路径最多 {count} 级,规则和目录设置会跟随分类编号保存。',
|
||||
description: '分类是规则命中或未命中后归入的目录。名称便于识别,路径决定目录层级,最多 {count} 级。',
|
||||
add: '新增{mediaType}分类',
|
||||
mediaTypeSegments: '分类媒体类型',
|
||||
editTitle: '编辑分类',
|
||||
@@ -2086,13 +2086,13 @@ export default {
|
||||
edit: '编辑分类“{name}”',
|
||||
empty: '暂无{mediaType}分类',
|
||||
fallbackTitle: '未匹配时的默认分类',
|
||||
fallbackHint: '没有规则匹配时,按媒体类型使用这里设置的分类。',
|
||||
fallbackHint: '只有所有分类规则都未命中时,才归入这里选择的分类。它与规则中的“归入分类”使用同一份分类列表。',
|
||||
fallbackFor: '{mediaType}默认分类',
|
||||
pathRequired: '分类路径不能为空',
|
||||
pathEmptySegment: '分类路径不能包含空层级',
|
||||
pathTooDeep: '分类路径最多支持 {count} 级',
|
||||
ruleReference: '已被分类规则引用',
|
||||
globalFallbackReference: '已设为{mediaTypes}全局兜底分类',
|
||||
globalFallbackReference: '已设为{mediaTypes}默认分类',
|
||||
directoryReference: '已被目录配置引用:{directories}',
|
||||
listSeparator: '、',
|
||||
reasonSeparator: ';',
|
||||
|
||||
@@ -3,6 +3,13 @@ import { formatClassificationCategoryOptionTitle, normalizeClassificationPolicy
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
describe('formatClassificationCategoryOptionTitle', () => {
|
||||
it('distinguishes identical category names by media type without changing custom category names', () => {
|
||||
const category = { id: 'custom', name: '未分类', path: ['未分类', '通用'], media_type: '电影' as const }
|
||||
expect(formatClassificationCategoryOptionTitle(category, { includeMediaType: true })).toBe(
|
||||
'电影 · 未分类 · 路径:未分类 / 通用',
|
||||
)
|
||||
})
|
||||
|
||||
it('omits a path that is identical to the category name', () => {
|
||||
expect(formatClassificationCategoryOptionTitle({ id: 'movie.base', name: '电影', path: ['电影'] })).toBe('电影')
|
||||
})
|
||||
@@ -14,27 +21,27 @@ describe('formatClassificationCategoryOptionTitle', () => {
|
||||
name: '动画',
|
||||
path: ['电影', '动画'],
|
||||
}),
|
||||
).toBe('动画 · 电影')
|
||||
).toBe('动画 · 路径:电影 / 动画')
|
||||
})
|
||||
|
||||
it('removes a repeated category name from the end of a hierarchical path', () => {
|
||||
it('preserves the complete directory path when the name repeats', () => {
|
||||
expect(
|
||||
formatClassificationCategoryOptionTitle({
|
||||
id: 'movie.china',
|
||||
name: '华语电影',
|
||||
path: ['电影', '华语电影'],
|
||||
}),
|
||||
).toBe('华语电影 · 电影')
|
||||
).toBe('华语电影 · 路径:电影 / 华语电影')
|
||||
})
|
||||
|
||||
it('removes a repeated category name from any path segment', () => {
|
||||
it('explains the generated spare category without altering its path', () => {
|
||||
expect(
|
||||
formatClassificationCategoryOptionTitle({
|
||||
id: 'tv.uncategorized',
|
||||
name: '未分类',
|
||||
path: ['未分类', '通用'],
|
||||
}),
|
||||
).toBe('未分类 · 通用')
|
||||
).toBe('备用未分类 · 路径:未分类 / 通用')
|
||||
})
|
||||
|
||||
it('can preserve a caller-specific path separator and stable ID', () => {
|
||||
@@ -43,7 +50,7 @@ describe('formatClassificationCategoryOptionTitle', () => {
|
||||
{ id: 'movie.animation', name: '动画', path: ['电影', '动画'] },
|
||||
{ includeId: true, pathSeparator: '/' },
|
||||
),
|
||||
).toBe('动画 · 电影 · movie.animation')
|
||||
).toBe('动画 · 路径:电影/动画 · movie.animation')
|
||||
})
|
||||
|
||||
it('uses the configured label only when the category has no path', () => {
|
||||
|
||||
@@ -12,21 +12,35 @@ interface ClassificationCategoryOptionTitleOptions {
|
||||
emptyPathLabel?: string
|
||||
includeId?: boolean
|
||||
pathSeparator?: string
|
||||
includeMediaType?: boolean
|
||||
}
|
||||
|
||||
/** 生成分类选择器标题,避免分类名与路径中的同名段重复显示。 */
|
||||
/** 生成分类标题:同名单层路径省略,多级路径完整标注,跨媒体类型选项增加类型。 */
|
||||
export function formatClassificationCategoryOptionTitle(
|
||||
category: Pick<ClassificationCategory, 'name' | 'path' | 'id'>,
|
||||
category: Pick<ClassificationCategory, 'name' | 'path' | 'id'> & Partial<Pick<ClassificationCategory, 'media_type'>>,
|
||||
options: ClassificationCategoryOptionTitleOptions = {},
|
||||
): string {
|
||||
const pathSegments = category.path.filter(segment => segment !== category.name)
|
||||
const path = pathSegments.join(options.pathSeparator ?? ' / ')
|
||||
const displayPath = path || (category.path.length ? '' : (options.emptyPathLabel ?? ''))
|
||||
const parts = [category.name, displayPath]
|
||||
const path = category.path.join(options.pathSeparator ?? ' / ')
|
||||
const name = classificationCategoryDisplayName(category)
|
||||
const displayPath = path && path !== name ? `路径:${path}` : !path ? (options.emptyPathLabel ?? '') : ''
|
||||
const parts = [options.includeMediaType ? category.media_type : '', name, displayPath]
|
||||
if (options.includeId) parts.push(category.id)
|
||||
return parts.filter(Boolean).join(' · ')
|
||||
}
|
||||
|
||||
/** 仅解释早期迁移生成的备用目录,保持用户命名、稳定编号和实际路径不变。 */
|
||||
export function classificationCategoryDisplayName(
|
||||
category: Pick<ClassificationCategory, 'id' | 'name' | 'path'>,
|
||||
): string {
|
||||
return /^(movie|tv|music)\.uncategorized$/.test(category.id) &&
|
||||
category.name === '未分类' &&
|
||||
category.path.length === 2 &&
|
||||
category.path[0] === '未分类' &&
|
||||
category.path[1] === '通用'
|
||||
? '备用未分类'
|
||||
: category.name
|
||||
}
|
||||
|
||||
/** 根据规则媒体类型生成一个明确、可直接编辑的默认条件。 */
|
||||
export function createClassificationTypeCondition(
|
||||
mediaTypes: readonly ClassificationMediaType[],
|
||||
|
||||
@@ -218,6 +218,12 @@ const editorFields = computed<ClassificationFieldDefinition[]>(() =>
|
||||
media_types: [...field.media_types],
|
||||
operators: [...field.operators],
|
||||
options: field.options.map(option => ({ ...option })),
|
||||
source_options: Object.fromEntries(
|
||||
Object.entries(field.source_options ?? {}).map(([source, options]) => [
|
||||
source,
|
||||
options.map(option => ({ ...option })),
|
||||
]),
|
||||
),
|
||||
source_support: { ...field.source_support },
|
||||
}
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user