fix(classification): polish policy editor controls

This commit is contained in:
jxxghp
2026-09-04 22:38:03 +08:00
parent 79ab32b0d3
commit d64b0ad8d5
24 changed files with 351 additions and 423 deletions
@@ -50,7 +50,6 @@ function createPolicy(revision = 1): ClassificationPolicy {
},
],
fallbacks: { : 'movie' },
source_fallbacks: {},
field_aliases: {},
updated_at: '2026-09-02T00:00:00Z',
}
+6 -1
View File
@@ -102,7 +102,6 @@ export interface ClassificationPolicy {
categories: ClassificationCategory[]
rules: ClassificationRule[]
fallbacks: Partial<Record<ClassificationMediaType, string>>
source_fallbacks: Record<string, Partial<Record<ClassificationMediaType, string>>>
field_aliases: Record<string, Record<string, string>>
updated_at?: string | null
}
@@ -221,6 +220,12 @@ export interface ClassificationFieldOption {
label: string
}
/** 数据源选择器使用的稳定值和人类可读名称。 */
export interface ClassificationSourceOption {
value: string
title: string
}
/** 动态条件编辑器使用的字段能力目录项。 */
export interface ClassificationFieldDefinition {
id: string
@@ -90,7 +90,7 @@ function parseCategoryPath(pathText: string): ParsedCategoryPath {
return { path, error: null }
}
/** 返回分类被规则、来源兜底和各媒体类型全局兜底引用的具体原因。 */
/** 返回分类被规则和各媒体类型全局兜底引用的具体原因。 */
function categoryReferenceReasons(categoryId: string): string[] {
const reasons: string[] = []
if (referencedIds.value.has(categoryId)) reasons.push(t('setting.classification.category.ruleReference'))
@@ -641,6 +641,18 @@ function updateFallback(mediaType: ClassificationMediaType, categoryId: string |
gap: 14px;
}
.classification-category-form-grid {
align-items: start;
}
.classification-category-form-grid > :deep(.v-input) {
align-self: start;
}
.classification-category-form-grid > :deep(.v-input .v-field) {
min-block-size: var(--v-input-control-height, 56px);
}
.classification-category-error {
margin: 0;
color: rgb(var(--v-theme-error));
@@ -777,10 +789,7 @@ function updateFallback(mediaType: ClassificationMediaType, categoryId: string |
--v-field-border-opacity: 0.72;
}
:global(html[data-theme='glass'] .classification-category-dialog .classification-category-form .v-field__overlay),
:global(
html[data-theme='glass'] .classification-category-dialog .classification-category-form .v-selection-control__wrapper
) {
:global(html[data-theme='glass'] .classification-category-dialog .classification-category-form .v-field__overlay) {
background-color: var(--glass-control) !important;
}
@@ -790,13 +799,6 @@ function updateFallback(mediaType: ClassificationMediaType, categoryId: string |
.classification-category-form
.v-field--focused
.v-field__overlay
),
:global(
html[data-theme='glass']
.classification-category-dialog
.classification-category-form
.v-selection-control--dirty
.v-selection-control__wrapper
) {
background-color: var(--glass-control-prominent) !important;
}
@@ -826,12 +828,8 @@ function updateFallback(mediaType: ClassificationMediaType, categoryId: string |
--v-field-border-opacity: 0.72;
}
:global(html[data-theme='transparent'] .classification-category-dialog .classification-category-form .v-field__overlay),
:global(
html[data-theme='transparent']
.classification-category-dialog
.classification-category-form
.v-selection-control__wrapper
html[data-theme='transparent'] .classification-category-dialog .classification-category-form .v-field__overlay
) {
background-color: rgba(var(--v-theme-surface), var(--transparent-opacity)) !important;
}
@@ -842,13 +840,6 @@ function updateFallback(mediaType: ClassificationMediaType, categoryId: string |
.classification-category-form
.v-field--focused
.v-field__overlay
),
:global(
html[data-theme='transparent']
.classification-category-dialog
.classification-category-form
.v-selection-control--dirty
.v-selection-control__wrapper
) {
background-color: rgba(var(--v-theme-primary), 0.14) !important;
}
@@ -7,6 +7,7 @@ import type {
ClassificationFieldDefinition,
ClassificationMediaType,
ClassificationOperator,
ClassificationSourceOption,
ClassificationSourceSupport,
} from '@/api/mediaClassificationTypes'
@@ -18,12 +19,14 @@ const props = withDefaults(
fields: readonly ClassificationFieldDefinition[]
mediaTypes: readonly ClassificationMediaType[]
sources: readonly string[]
sourceOptions?: readonly ClassificationSourceOption[]
depth?: number
maxDepth?: number
}>(),
{
depth: 0,
maxDepth: 3,
sourceOptions: () => [],
},
)
@@ -55,9 +58,16 @@ const NODE_KIND_ITEMS: ReadonlyArray<{ title: string; value: ConditionNodeKind }
{ title: '条件', value: 'condition' },
{ title: '全部', value: 'all' },
{ title: '任一', value: 'any' },
{ title: '', value: 'not' },
{ title: '排除', value: 'not' },
]
const NODE_KIND_HINTS: Record<ConditionNodeKind, string> = {
condition: '单个条件只判断一个字段。选择“全部”或“任一”后,可以添加同级条件。',
all: '全部满足:所有条件都满足时,规则才会命中。',
any: '任一满足:只要有一个条件满足,规则就会命中。',
not: '排除:括号内条件满足时,规则不会命中。',
}
const OPERATOR_LABELS: Record<ClassificationOperator, string> = {
equals: '等于',
not_equals: '不等于',
@@ -144,6 +154,7 @@ const fieldItems = computed(() =>
const nodeKind = computed(() => getNodeKind(props.modelValue))
const groupChildren = computed(() => getGroupChildren(props.modelValue))
const canUseGroup = computed(() => props.depth < props.maxDepth)
const nodeKindHint = computed(() => NODE_KIND_HINTS[nodeKind.value])
const canAddChild = computed(
() =>
nodeKind.value !== 'condition' &&
@@ -204,6 +215,16 @@ const sourceSupportHints = computed<SourceSupportHint[]>(() => {
return hints
})
/** 把规则来源限制解释成用户可理解的匹配范围。 */
const sourceScopeNote = computed(() => {
if (props.sources.length === 0) return '未限定数据来源:这条规则适用于所有来源。'
const names = [...new Set(props.sources)].map(source => {
const option = props.sourceOptions.find(item => item.value === source)
return option?.title ?? source
})
return `已选数据来源:${names.join('、')}。多个来源表示任意一个来源,不会合并多条媒体信息;字段按当前媒体的实际来源读取。`
})
const valueControlKind = computed<ValueControlKind>(() => {
const condition = selectedCondition.value
const definition = selectedDefinition.value
@@ -438,6 +459,10 @@ function removeChild(index: number): void {
</VChip>
</div>
<p class="classification-condition-builder__node-hint" data-testid="node-kind-hint">
{{ nodeKindHint }}
</p>
<template v-if="nodeKind === 'condition'">
<VAlert v-if="availableFields.length === 0" type="warning" variant="tonal" density="compact" class="mt-3">
当前媒体类型没有共同可用的条件字段
@@ -615,6 +640,10 @@ function removeChild(index: number): void {
</div>
</div>
<p class="classification-condition-builder__source-scope-note" data-testid="source-scope-note">
{{ sourceScopeNote }}
</p>
<p
v-if="selectedDefinition?.selectable === false"
class="classification-condition-builder__retired-field"
@@ -642,7 +671,7 @@ function removeChild(index: number): void {
size="small"
variant="tonal"
>
{{ hint.source }}{{ hint.label }}
{{ props.sourceOptions.find(item => item.value === hint.source)?.title ?? hint.source }}{{ hint.label }}
</VChip>
</div>
</template>
@@ -654,6 +683,7 @@ function removeChild(index: number): void {
:fields="props.fields"
:media-types="props.mediaTypes"
:sources="props.sources"
:source-options="props.sourceOptions"
:depth="props.depth + 1"
:max-depth="props.maxDepth"
@update:model-value="updateChild(index, $event)"
@@ -680,18 +710,20 @@ function removeChild(index: number): void {
</div>
<div class="classification-condition-builder__group-actions">
<VTooltip :text="canAddChild ? '新增子条件' : '没有可用字段或 not 已有子条件'" location="top">
<VTooltip :text="canAddChild ? '添加同级条件' : '没有可用字段,或排除已经有条件'" location="top">
<template #activator="{ props: tooltipProps }">
<VBtn
v-bind="tooltipProps"
icon="mdi-plus"
prepend-icon="mdi-plus"
variant="tonal"
color="primary"
size="small"
aria-label="新增子条件"
aria-label="添加条件"
:disabled="!canAddChild"
@click="addChild"
/>
>
添加条件
</VBtn>
</template>
</VTooltip>
</div>
@@ -703,15 +735,15 @@ function removeChild(index: number): void {
.classification-condition-builder {
min-inline-size: 0;
padding: 12px;
border: 1px solid var(--classification-border, rgba(var(--v-border-color), var(--v-border-opacity)));
border: 1px solid var(--classification-border, rgba(var(--v-theme-on-surface), 0.16));
border-radius: 8px;
background: var(--classification-panel, rgba(var(--v-theme-surface-variant), 0.12));
background: var(--classification-panel, rgba(var(--v-theme-on-surface), 0.04));
}
.classification-condition-builder:not([data-depth='0']) {
padding: 8px 0 8px 10px;
border: 0;
border-inline-start: 2px solid var(--classification-border, rgba(var(--v-border-color), var(--v-border-opacity)));
border-inline-start: 2px solid var(--classification-border, rgba(var(--v-theme-on-surface), 0.16));
border-radius: 0;
background: transparent;
}
@@ -740,6 +772,14 @@ function removeChild(index: number): void {
letter-spacing: 0;
}
.classification-condition-builder__node-hint,
.classification-condition-builder__source-scope-note {
margin: 8px 0 0;
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
font-size: 0.75rem;
line-height: 1.5;
}
.classification-condition-builder__leaf {
display: grid;
grid-template-columns: minmax(180px, 1.15fr) minmax(150px, 0.85fr) minmax(220px, 1.4fr);
@@ -794,7 +834,7 @@ function removeChild(index: number): void {
}
.classification-condition-builder__group-actions {
justify-content: flex-end;
justify-content: flex-start;
}
:global(.classification-field-menu .v-list-item-title),
@@ -21,7 +21,6 @@ const visible = computed({
const sections = computed(() => [
{ icon: 'mdi-file-tree-outline', key: 'categories' },
{ icon: 'mdi-filter-cog-outline', key: 'rules' },
{ icon: 'mdi-database-sync-outline', key: 'sources' },
{ icon: 'mdi-play-box-outline', key: 'preview' },
{ icon: 'mdi-chart-box-outline', key: 'impact' },
{ icon: 'mdi-check-decagram-outline', key: 'publish' },
@@ -217,7 +217,6 @@ function resultSource(result: ClassificationResult): string {
const source = resultSelection(result)?.source
const labels: Record<string, string> = {
automatic: t('setting.classification.preview.selectionSource.automatic'),
source_fallback: t('setting.classification.preview.selectionSource.sourceFallback'),
fallback: t('setting.classification.preview.selectionSource.fallback'),
}
return source ? (labels[source] ?? source) : t('setting.classification.impact.none')
@@ -561,7 +561,7 @@ watch(
gap: 8px;
padding: 12px;
border-radius: 8px;
background: rgba(var(--v-theme-surface-variant), 0.24);
background: var(--classification-control, rgba(var(--v-theme-on-surface), 0.08));
}
.classification-policy-control__metrics {
@@ -196,7 +196,6 @@ function selectionTitle(selection: ClassificationSelection | null | undefined):
function selectionSourceLabel(source: string | null | undefined): string {
const labels: Record<string, string> = {
automatic: t('setting.classification.preview.selectionSource.automatic'),
source_fallback: t('setting.classification.preview.selectionSource.sourceFallback'),
fallback: t('setting.classification.preview.selectionSource.fallback'),
}
return source ? (labels[source] ?? source) : t('setting.classification.preview.missing')
@@ -757,7 +756,7 @@ function factSourceLabel(source: ClassificationFactSource | null | undefined): s
overflow-y: auto;
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
border-radius: 8px;
background: rgba(var(--v-theme-surface-variant), 0.12);
background: var(--classification-panel, rgba(var(--v-theme-on-surface), 0.04));
}
.classification-preview__search-results :deep(.v-list-item) {
@@ -1,16 +1,18 @@
<script lang="ts" setup>
import type {
ClassificationCategory,
ClassificationCondition,
ClassificationConditionGroup,
ClassificationConditionNode,
ClassificationFactValue,
ClassificationFieldDefinition,
ClassificationMediaType,
ClassificationRule,
ClassificationRuleKind,
ClassificationSourceOption,
} from '@/api/mediaClassificationTypes'
import { formatClassificationCategoryOptionTitle } from '@/utils/mediaClassification'
import {
createClassificationTypeCondition,
formatClassificationCategoryOptionTitle,
normalizeClassificationConditionNode,
} from '@/utils/mediaClassification'
import ClassificationConditionBuilder from './ClassificationConditionBuilder.vue'
const MEDIA_TYPES: ClassificationMediaType[] = ['电影', '电视剧', '音乐']
@@ -28,10 +30,12 @@ const props = withDefaults(
rules: ClassificationRule[]
categories: ClassificationCategory[]
fields: readonly ClassificationFieldDefinition[]
sourceOptions?: readonly ClassificationSourceOption[]
maxRules?: number
maxConditionDepth?: number
}>(),
{
sourceOptions: () => [],
maxRules: DEFAULT_MAX_RULES,
maxConditionDepth: DEFAULT_MAX_CONDITION_DEPTH,
},
@@ -46,28 +50,9 @@ const Draggable = defineAsyncComponent(() => import('vuedraggable').then(module
const draftRules = ref<ClassificationRule[]>([])
const expandedRuleId = ref<string | null>(null)
/** 复制条件值,保留标量和列表的原始数据形状。 */
function cloneConditionValue(value: ClassificationFactValue | undefined): ClassificationFactValue | undefined {
return Array.isArray(value) ? [...value] : value
}
/** 按条件联合类型递归复制,避免 Vue 响应式代理进入 structuredClone。 */
function cloneCondition(node: ClassificationConditionNode): ClassificationConditionNode {
if ('field' in node) {
const condition = node as ClassificationCondition
return {
field: condition.field,
operator: condition.operator,
...(condition.value === undefined ? {} : { value: cloneConditionValue(condition.value) }),
}
}
const group = node as ClassificationConditionGroup
// API null any/not
if (group.all !== undefined && group.all !== null) return { all: group.all.map(cloneCondition) }
if (group.any !== undefined && group.any !== null) return { any: group.any.map(cloneCondition) }
if (group.not !== undefined && group.not !== null) return { not: cloneCondition(group.not) }
return {}
return normalizeClassificationConditionNode(node)
}
/** 深拷贝规则,隔离父级策略草稿和编辑器内部的临时修改。 */
@@ -76,7 +61,7 @@ function cloneRule(rule: ClassificationRule): ClassificationRule {
...rule,
media_types: [...rule.media_types],
sources: [...rule.sources],
when: cloneCondition(rule.when),
when: normalizeClassificationConditionNode(rule.when, createClassificationTypeCondition(rule.media_types)),
target: {
category_id: rule.target.category_id ?? null,
labels: [...rule.target.labels],
@@ -167,7 +152,7 @@ function addRule() {
priority: draftRules.value.length,
media_types: mediaTypes,
sources: [],
when: { all: [] },
when: createClassificationTypeCondition(mediaTypes),
target: {
category_id: defaultCategory?.id ?? null,
labels: [],
@@ -276,7 +261,11 @@ function mediaTypeSummary(rule: ClassificationRule): string {
/** 将来源限制压缩为可扫描的规则摘要。 */
function sourceSummary(rule: ClassificationRule): string {
return rule.sources.length ? rule.sources.join('、') : '全部来源'
return rule.sources.length
? rule.sources
.map(source => props.sourceOptions.find(option => option.value === source)?.title ?? source)
.join('、')
: '全部来源'
}
/** 返回规则输出的人类可读摘要,不暴露稳定 ID 作为首要信息。 */
@@ -286,11 +275,16 @@ function targetSummary(rule: ClassificationRule): string {
return category?.name ?? '未设置分类'
}
const sourceItems = computed(() =>
[...new Set(props.fields.flatMap(field => Object.keys(field.source_support)))].sort((left, right) =>
left.localeCompare(right),
),
)
const sourceItems = computed(() => {
const knownOptions = new Map(props.sourceOptions.map(option => [option.value, option.title]))
const sourceIds = new Set([
...props.sourceOptions.map(option => option.value),
...props.fields.flatMap(field => Object.keys(field.source_support)),
])
return [...sourceIds]
.sort((left, right) => (knownOptions.get(left) ?? left).localeCompare(knownOptions.get(right) ?? right))
.map(value => ({ title: knownOptions.get(value) ?? value, value }))
})
const orderedRules = computed({
get: () => draftRules.value,
@@ -345,14 +339,17 @@ watch(
:aria-label="`规则 ${index + 1}${rule.name || rule.id}`"
>
<div class="classification-rule-head">
<IconBtn
<VBtn
class="classification-rule-drag cursor-move"
icon="mdi-drag-vertical"
data-testid="classification-rule-drag"
icon
variant="text"
color="secondary"
:aria-label="`拖拽排序规则 ${rule.name || rule.id}`"
>
<VIcon icon="mdi-drag-vertical" size="20" />
<VTooltip activator="parent" location="top">拖拽排序</VTooltip>
</IconBtn>
</VBtn>
<button
class="classification-rule-summary"
@@ -505,6 +502,7 @@ watch(
:fields="fields"
:media-types="rule.media_types"
:sources="rule.sources"
:source-options="sourceOptions"
:max-depth="maxConditionDepth"
@update:model-value="value => updateCondition(index, value)"
/>
@@ -639,7 +637,7 @@ watch(
.classification-rule-summary:hover,
.classification-rule-summary:focus-visible {
background: var(--classification-control, rgba(var(--v-theme-surface-variant), 0.24));
background: var(--classification-control, rgba(var(--v-theme-on-surface), 0.08));
}
.classification-rule-summary:focus-visible {
@@ -136,7 +136,7 @@ describe('ClassificationCategoryEditor', () => {
const protection = within(row as HTMLElement).getByRole('note')
expect(deleteButton).toBeDisabled()
expect(deleteButton).toHaveAttribute('aria-describedby', protection.id)
expect(protection).toHaveTextContent('已被分类规则或来源兜底引用')
expect(protection).toHaveTextContent('已被分类规则引用')
expect(protection).toHaveTextContent('已设为电影全局兜底分类')
await user.click(deleteButton)
@@ -4,6 +4,7 @@ import type {
ClassificationFieldValueType,
ClassificationMediaType,
ClassificationOperator,
ClassificationSourceOption,
} from '@/api/mediaClassificationTypes'
import ClassificationConditionBuilder from '@/components/classification/ClassificationConditionBuilder.vue'
import { fireEvent, screen, within } from '@testing-library/vue'
@@ -268,6 +269,11 @@ const defaultProps = {
fields,
mediaTypes: ['电影'] as ClassificationMediaType[],
sources: ['themoviedb', 'douban'],
sourceOptions: [
{ value: 'themoviedb', title: 'TheMovieDB' },
{ value: 'douban', title: '豆瓣' },
{ value: 'musicbrainz', title: 'MusicBrainz' },
] as ClassificationSourceOption[],
}
/** 渲染带生产插件和轻量输入控件的条件构建器。 */
@@ -398,7 +404,7 @@ describe('ClassificationConditionBuilder', () => {
})
await result.rerender({ ...defaultProps, modelValue: latestModel(result) })
await fireEvent.click(within(root as HTMLElement).getByRole('button', { name: '新增子条件' }))
await fireEvent.click(within(root as HTMLElement).getByRole('button', { name: '添加条件' }))
expect((latestModel(result) as { all: ClassificationConditionNode[] }).all).toHaveLength(3)
await result.rerender({ ...defaultProps, modelValue: latestModel(result) })
@@ -412,10 +418,27 @@ describe('ClassificationConditionBuilder', () => {
expect(latestModel(result)).toHaveProperty('any')
await result.rerender({ ...defaultProps, modelValue: latestModel(result) })
await fireEvent.click(within(root as HTMLElement).getAllByRole('button', { name: '' })[0])
await fireEvent.click(within(root as HTMLElement).getAllByRole('button', { name: '排除' })[0])
expect(latestModel(result)).toHaveProperty('not')
})
it('重复点击当前条件组不会继续嵌套,并能添加同级条件', async () => {
const initial: ClassificationConditionNode = { field: 'media.title', operator: 'equals', value: '标题' }
const result = await renderBuilder(initial)
await fireEvent.click(screen.getAllByRole('button', { name: '全部' })[0])
const group = latestModel(result)
expect(group).toEqual({ all: [initial] })
await result.rerender({ ...defaultProps, modelValue: group })
const updateCount = (result.emitted()['update:modelValue'] ?? []).length
await fireEvent.click(screen.getAllByRole('button', { name: '全部' })[0])
expect((result.emitted()['update:modelValue'] ?? []).length).toBe(updateCount)
await fireEvent.click(screen.getByRole('button', { name: '添加条件' }))
expect((latestModel(result) as { all: ClassificationConditionNode[] }).all).toHaveLength(2)
})
it('达到 maxDepth 后禁止继续切换为条件组', async () => {
const result = await renderBuilder(
{ field: 'media.title', operator: 'equals', value: '标题' },
@@ -423,7 +446,7 @@ describe('ClassificationConditionBuilder', () => {
)
expect(screen.getByTestId('depth-limit')).toHaveTextContent('已达最大组深度')
for (const label of ['全部', '任一', '']) {
for (const label of ['全部', '任一', '排除']) {
expect(screen.getByRole('button', { name: label })).toBeDisabled()
}
@@ -435,9 +458,10 @@ describe('ClassificationConditionBuilder', () => {
const result = await renderBuilder({ field: 'media.title', operator: 'equals', value: '标题' })
const hints = screen.getByTestId('source-support-hints')
expect(hints).toHaveTextContent('themoviedb:部分支持')
expect(hints).toHaveTextContent('douban:不可用')
expect(hints).toHaveTextContent('TheMovieDB:部分支持')
expect(hints).toHaveTextContent('豆瓣:不可用')
expect(hints).not.toHaveTextContent('musicbrainz')
expect(screen.getByTestId('source-scope-note')).toHaveTextContent('多个来源表示任意一个来源')
await result.rerender({
...defaultProps,
@@ -464,7 +488,7 @@ describe('ClassificationConditionBuilder', () => {
}
const result = await renderBuilder(modelValue, { fields, mediaTypes, sources })
await fireEvent.click(screen.getByRole('button', { name: '新增子条件' }))
await fireEvent.click(screen.getByRole('button', { name: '添加条件' }))
expect(latestModel(result)).not.toBe(modelValue)
expect(modelValue).toEqual(snapshots.modelValue)
@@ -58,7 +58,7 @@ function createAnalysis(overrides: Partial<ClassificationImpactAnalysis> = {}):
title: '流浪地球',
changed_fields: ['category_id', 'category_path', 'rule_id'],
previous: createResult(7, 'movie.scifi', ['电影', '科幻'], 'rule-scifi', 'rule'),
candidate: createResult(8, 'movie.china', ['电影', '华语'], 'rule-china', 'source_fallback', 'partial'),
candidate: createResult(8, 'movie.china', ['电影', '华语'], 'rule-china', 'fallback', 'partial'),
},
],
warnings: ['有 2 条记录无法获取完整媒体信息,未纳入比较。'],
@@ -197,7 +197,7 @@ describe('ClassificationImpactPanel', () => {
const candidate = within(example).getByRole('region', { name: '变化示例 1 的候选策略结果' })
expect(candidate).toHaveTextContent('华语电影 · 电影 / 华语')
expect(candidate).toHaveTextContent('电影 / 华语')
expect(candidate).toHaveTextContent('数据源默认分类')
expect(candidate).toHaveTextContent('全局默认分类')
expect(candidate).toHaveTextContent('媒体信息不完整')
expect(screen.getByRole('alert')).toHaveTextContent('无法获取完整媒体信息')
@@ -38,7 +38,6 @@ function createPolicy(revision: number, categoryCount: number, ruleCount: number
target: { category_id: categoryCount ? 'category-' + revision + '-0' : null, labels: [] },
})),
fallbacks: {},
source_fallbacks: {},
field_aliases: {},
updated_at: revision === 6 ? '2026-09-01T08:30:00+08:00' : '2026-08-31T08:30:00+08:00',
}
@@ -2,6 +2,7 @@ import type {
ClassificationCategory,
ClassificationFieldDefinition,
ClassificationRule,
ClassificationSourceOption,
} from '@/api/mediaClassificationTypes'
import ClassificationRuleEditor from '@/components/classification/ClassificationRuleEditor.vue'
import userEvent from '@testing-library/user-event'
@@ -78,6 +79,11 @@ const fields: ClassificationFieldDefinition[] = [
},
]
const sourceOptions: ClassificationSourceOption[] = [
{ value: 'themoviedb', title: 'TheMovieDB' },
{ value: 'musicbrainz', title: 'MusicBrainz' },
]
/** 创建测试规则,覆盖分类规则和标签规则共用的数据结构。 */
function createRule(overrides: Partial<ClassificationRule> = {}): ClassificationRule {
return {
@@ -101,6 +107,7 @@ async function renderEditor(rules: ClassificationRule[], options: { maxRules?: n
rules,
categories,
fields,
sourceOptions,
maxConditionDepth: 6,
...options,
},
@@ -189,6 +196,16 @@ describe('ClassificationRuleEditor', () => {
])
})
it('显示可拖拽图标,并为新增规则生成媒体类型条件', async () => {
const user = userEvent.setup()
const editor = await renderEditor([createRule()])
expect(screen.getAllByTestId('classification-rule-drag')).toHaveLength(1)
await user.click(screen.getByRole('button', { name: '新增分类规则' }))
expect(editor.latestRules()[1]?.when).toEqual({ field: 'media.type', operator: 'equals', value: '电影' })
})
it('编辑名称、稳定 ID、启停状态、媒体类型和来源', async () => {
const user = userEvent.setup()
const editor = await renderEditor([createRule()])
@@ -197,7 +214,7 @@ describe('ClassificationRuleEditor', () => {
await fireEvent.update(screen.getByLabelText('规则编号 1'), 'rule-music-source')
await user.click(screen.getByRole('checkbox', { name: '启用规则 音乐来源规则' }))
await selectOption('媒体类型 音乐来源规则', '音乐')
await selectOption('数据来源 音乐来源规则', 'musicbrainz')
await selectOption('数据来源 音乐来源规则', 'MusicBrainz')
expect(editor.latestRules()[0]).toEqual(
expect.objectContaining({
@@ -58,7 +58,6 @@ function createPolicy(revision = 1, name = '电影'): ClassificationPolicy {
},
],
fallbacks: { : 'movie' },
source_fallbacks: {},
field_aliases: {},
updated_at: `2026-09-02T00:00:0${revision}Z`,
}
+10 -8
View File
@@ -22,6 +22,7 @@ import {
type ClassificationRevisionConflict,
type ClassificationValidationResult,
} from '@/api/mediaClassification'
import { normalizeClassificationPolicy } from '@/utils/mediaClassification'
/** 分类影响分析的可选采样参数。 */
export interface ClassificationImpactOptions {
@@ -124,8 +125,9 @@ export function useMediaClassification() {
/** 应用服务端活动快照,并按调用语义决定是否同步草稿。 */
function applyActivePolicy(policy: ClassificationPolicy, preserveDirtyDraft: boolean): void {
activePolicyState.value = cloneDeep(policy)
if (!preserveDirtyDraft || !draftPolicy.value) draftPolicy.value = cloneDeep(policy)
const normalizedPolicy = normalizeClassificationPolicy(policy)
activePolicyState.value = cloneDeep(normalizedPolicy)
if (!preserveDirtyDraft || !draftPolicy.value) draftPolicy.value = cloneDeep(normalizedPolicy)
}
/** 刷新活动策略;响应到达时若草稿已脏则只更新活动快照。 */
@@ -137,7 +139,7 @@ export function useMediaClassification() {
const preserveDirtyDraft = isDirty.value
applyActivePolicy(policy, preserveDirtyDraft)
conflictState.value = null
return cloneDeep(policy)
return normalizeClassificationPolicy(policy)
} catch (error) {
captureError(error)
throw error
@@ -184,7 +186,7 @@ export function useMediaClassification() {
lastError.value = null
validationState.value = null
try {
const result = await validateClassificationPolicy({ policy: cloneDeep(policy) })
const result = await validateClassificationPolicy({ policy: normalizeClassificationPolicy(policy) })
validationState.value = cloneDeep(result)
return cloneDeep(result)
} catch (error) {
@@ -207,7 +209,7 @@ export function useMediaClassification() {
const selectedPolicy = options.policy === null ? null : (options.policy ?? requireDraft())
const result = await previewClassificationPolicy({
input: cloneDeep(input),
...(selectedPolicy ? { policy: cloneDeep(selectedPolicy) } : {}),
...(selectedPolicy ? { policy: normalizeClassificationPolicy(selectedPolicy) } : {}),
})
previewState.value = cloneDeep(result)
return cloneDeep(result)
@@ -230,7 +232,7 @@ export function useMediaClassification() {
const policy = options.policy ?? requireDraft()
const result = await analyzeClassificationImpact({
expected_revision: active.revision,
policy: cloneDeep(policy),
policy: normalizeClassificationPolicy(policy),
...(options.sampleLimit === undefined ? {} : { sample_limit: options.sampleLimit }),
...(options.exampleLimit === undefined ? {} : { example_limit: options.exampleLimit }),
...(options.samples === undefined ? {} : { samples: cloneDeep(options.samples) }),
@@ -255,7 +257,7 @@ export function useMediaClassification() {
const active = requireActive()
const policy = await publishClassificationPolicy({
expected_revision: active.revision,
policy: cloneDeep(requireDraft()),
policy: normalizeClassificationPolicy(requireDraft()),
})
applyActivePolicy(policy, false)
historyState.value = null
@@ -297,7 +299,7 @@ export function useMediaClassification() {
/** 用指定策略替换可编辑草稿,不修改活动快照。 */
function replaceDraft(policy: ClassificationPolicy): void {
draftPolicy.value = cloneDeep(policy)
draftPolicy.value = normalizeClassificationPolicy(policy)
validationState.value = null
previewState.value = null
impactState.value = null
+6 -19
View File
@@ -1941,7 +1941,6 @@ export default {
description: 'Set shared automatic rules for movies, TV shows, and music using category names and media details.',
workspaceCategories: 'Categories',
workspaceRules: 'Rules',
workspaceSources: 'Sources',
workspaceReview: 'Review',
revision: 'Current version {revision}',
unsaved: 'Unsaved changes',
@@ -1960,13 +1959,6 @@ export default {
enrichmentModeLabel: 'Fill missing information',
enrichmentPrimaryOnly: 'Primary source only',
enrichmentMissing: 'Fill missing information',
sourceFallbacks: 'Default categories by source',
sourceFallbacksHint: 'Used when no rule matches for a data source.',
source: 'Source',
sourceFallbackPanel: '{source} default categories, {count} set',
sourceFallbackConfigured: '{count} set',
sourceFallbackEmpty: 'Not set',
sourceFallbackFor: 'Default {mediaType} category for {source}',
sourceNames: {
imdb: 'IMDb',
tvdb: 'TVDB',
@@ -2005,26 +1997,22 @@ export default {
},
rules: {
title: '2. Write rules',
body: 'Set conditions in order under Rules. The first matching rule from top to bottom is used. When no rule matches, the source default or global default category is used.',
},
sources: {
title: '3. Set source defaults',
body: 'Under Sources, set a default category for each data source and media type. These defaults are used only when no rule produces a match.',
body: 'Set conditions from top to bottom under Rules. The system first checks the media types and data sources allowed by the rule, then reads details from the actual media record. Selecting multiple sources means any one of them may match; information from different sources is never merged. The first matching rule wins, and the media-type default category is used when none matches.',
},
preview: {
title: '4. Preview a result',
title: '3. Preview a result',
body: 'Under Result Preview, search for a keyword and select a media item. The preview uses the title, year, genres, countries, and music details from the selected result; nothing needs to be entered by hand.',
},
impact: {
title: '5. Review the impact',
title: '4. Review the impact',
body: 'The system reads recent download and organization records, retrieves their complete media details using the recorded source and number, and compares the current rules with the pending rules. Records whose details cannot be retrieved are counted separately, not treated as unchanged.',
},
publish: {
title: '6. Validate and publish',
title: '5. Validate and publish',
body: 'Validate the draft, run the impact analysis, and review the result. Both checks must match the current draft before publishing. Publishing saves classification settings only; it does not move files.',
},
history: {
title: '7. Review history and roll back',
title: '6. Review history and roll back',
body: 'Each publication creates a new version. You can inspect an earlier configuration and roll it back. A rollback also creates a new version and keeps the existing history.',
},
},
@@ -2064,7 +2052,7 @@ export default {
pathRequired: 'Category path is required',
pathEmptySegment: 'Category path cannot contain an empty level',
pathTooDeep: 'Category path supports at most {count} levels',
ruleReference: 'Referenced by a classification rule or source fallback',
ruleReference: 'Referenced by a classification rule',
globalFallbackReference: 'Used as the global fallback for {mediaTypes}',
directoryReference: 'Referenced by directories: {directories}',
listSeparator: ', ',
@@ -2176,7 +2164,6 @@ export default {
},
selectionSource: {
automatic: 'Rule Match',
sourceFallback: 'Source Default Category',
fallback: 'Global Default Category',
},
states: {
+6 -19
View File
@@ -1920,7 +1920,6 @@ export default {
description: '按分类名称和媒体信息,为电影、电视剧和音乐设置统一的自动分类规则。',
workspaceCategories: '分类树',
workspaceRules: '规则',
workspaceSources: '来源',
workspaceReview: '验证发布',
revision: '当前版本 {revision}',
unsaved: '有未保存修改',
@@ -1939,13 +1938,6 @@ export default {
enrichmentModeLabel: '是否补充缺少的信息',
enrichmentPrimaryOnly: '只使用主要来源',
enrichmentMissing: '补充缺少的信息',
sourceFallbacks: '按数据源设置默认分类',
sourceFallbacksHint: '当某个数据源没有匹配到规则时,使用这里设置的默认分类。',
source: '数据源',
sourceFallbackPanel: '{source} 默认分类,已设置 {count} 项',
sourceFallbackConfigured: '已设置 {count} 项',
sourceFallbackEmpty: '未设置',
sourceFallbackFor: '{source} 的{mediaType}默认分类',
sourceNames: {
imdb: 'IMDb',
tvdb: 'TVDB',
@@ -1983,26 +1975,22 @@ export default {
},
rules: {
title: '2. 编写规则',
body: '在“规则”中按顺序设置条件。系统从上到下使用第一条符合条件的规则;没有规则符合时,再使用数据源默认分类或全局默认分类。',
},
sources: {
title: '3. 设置数据源默认分类',
body: '在“来源”中为每个数据源和媒体类型设置默认分类。只有规则没有匹配结果时,系统才会使用这里的设置。',
body: '在“规则”中从上到下设置条件。系统先检查规则限定的媒体类型和数据来源,再读取这条媒体记录实际提供的信息。选择多个来源表示任意一个来源都可以命中,系统不会把多个来源的信息拼在一起;第一条符合条件的规则生效,都不符合时使用媒体类型默认分类。',
},
preview: {
title: '4. 预览分类结果',
title: '3. 预览分类结果',
body: '在“结果预览”中输入关键词搜索并选择一条媒体信息。预览会直接使用搜索结果里的标题、年份、风格、国家和音乐信息,不需要手工填写编号或字段。',
},
impact: {
title: '5. 查看影响范围',
title: '4. 查看影响范围',
body: '系统会读取近期下载和整理记录,按照记录中的数据源和编号重新获取完整媒体信息,再比较当前规则和待发布规则。无法获取详情的记录会单独统计,不会被当成没有变化。',
},
publish: {
title: '6. 校验并发布',
title: '5. 校验并发布',
body: '先校验草稿,再运行影响分析并检查结果。两者都对应当前草稿后才能发布;发布只保存分类配置,不会移动文件。',
},
history: {
title: '7. 查看历史和回退',
title: '6. 查看历史和回退',
body: '每次发布都会产生新版本。你可以查看以前的配置并回退;回退也会产生一个新版本,原有历史不会被覆盖。',
},
},
@@ -2039,7 +2027,7 @@ export default {
pathRequired: '分类路径不能为空',
pathEmptySegment: '分类路径不能包含空层级',
pathTooDeep: '分类路径最多支持 {count} 级',
ruleReference: '已被分类规则或来源兜底引用',
ruleReference: '已被分类规则引用',
globalFallbackReference: '已设为{mediaTypes}全局兜底分类',
directoryReference: '已被目录配置引用:{directories}',
listSeparator: '、',
@@ -2149,7 +2137,6 @@ export default {
},
selectionSource: {
automatic: '规则命中',
sourceFallback: '数据源默认分类',
fallback: '全局默认分类',
},
states: {
+6 -19
View File
@@ -1920,7 +1920,6 @@ export default {
description: '按分類名稱和媒體資訊,為電影、電視劇和音樂設定統一的自動分類規則。',
workspaceCategories: '分類樹',
workspaceRules: '規則',
workspaceSources: '來源',
workspaceReview: '驗證發佈',
revision: '目前版本 {revision}',
unsaved: '有未儲存修改',
@@ -1939,13 +1938,6 @@ export default {
enrichmentModeLabel: '是否補充缺少的資訊',
enrichmentPrimaryOnly: '只使用主要來源',
enrichmentMissing: '補充缺少的資訊',
sourceFallbacks: '按資料源設定預設分類',
sourceFallbacksHint: '當某個資料源沒有命中規則時,使用這裡設定的預設分類。',
source: '資料源',
sourceFallbackPanel: '{source} 預設分類,已設定 {count} 項',
sourceFallbackConfigured: '已設定 {count} 項',
sourceFallbackEmpty: '未設定',
sourceFallbackFor: '{source} 的{mediaType}預設分類',
sourceNames: {
imdb: 'IMDb',
tvdb: 'TVDB',
@@ -1983,26 +1975,22 @@ export default {
},
rules: {
title: '2. 編寫規則',
body: '在「規則」中依順序設定條件。系統會由上到下使用第一條符合條件的規則;沒有規則符合時,再使用資料源預設分類或全域預設分類。',
},
sources: {
title: '3. 設定資料源預設分類',
body: '在「來源」中為每個資料源和媒體類型設定預設分類。只有規則沒有命中結果時,系統才會使用這裡的設定。',
body: '在「規則」中由上到下設定條件。系統先檢查規則限定的媒體類型和資料源,再讀取這筆媒體記錄實際提供的資訊。選擇多個資料源表示任一資料源都可以命中,系統不會把多個來源的資訊拼在一起;第一條符合條件的規則生效,都不符合時使用媒體類型預設分類。',
},
preview: {
title: '4. 預覽分類結果',
title: '3. 預覽分類結果',
body: '在「結果預覽」中輸入關鍵字搜尋並選擇一筆媒體資訊。預覽會直接使用搜尋結果中的標題、年份、風格、國家和音樂資訊,不需要手動填寫編號或欄位。',
},
impact: {
title: '5. 查看影響範圍',
title: '4. 查看影響範圍',
body: '系統會讀取近期下載和整理記錄,依照記錄中的資料源和編號重新取得完整媒體資訊,再比較目前規則和待發佈規則。無法取得詳情的記錄會單獨統計,不會被當成沒有變更。',
},
publish: {
title: '6. 檢查並發佈',
title: '5. 檢查並發佈',
body: '先檢查草稿,再執行影響分析並查看結果。兩者都對應目前草稿後才能發佈;發佈只會儲存分類設定,不會移動檔案。',
},
history: {
title: '7. 查看歷史和回退',
title: '6. 查看歷史和回退',
body: '每次發佈都會產生新版本。你可以查看以前的設定並回退;回退也會產生新版本,原有歷史不會被覆蓋。',
},
},
@@ -2039,7 +2027,7 @@ export default {
pathRequired: '分類路徑不能為空',
pathEmptySegment: '分類路徑不能包含空層級',
pathTooDeep: '分類路徑最多支援 {count} 級',
ruleReference: '已被分類規則或來源兜底引用',
ruleReference: '已被分類規則引用',
globalFallbackReference: '已設為{mediaTypes}全域兜底分類',
directoryReference: '已被目錄配置引用:{directories}',
listSeparator: '、',
@@ -2149,7 +2137,6 @@ export default {
},
selectionSource: {
automatic: '規則命中',
sourceFallback: '資料源預設分類',
fallback: '全域預設分類',
},
states: {
@@ -1,4 +1,5 @@
import { formatClassificationCategoryOptionTitle } from '@/utils/mediaClassification'
import type { ClassificationPolicy } from '@/api/mediaClassificationTypes'
import { formatClassificationCategoryOptionTitle, normalizeClassificationPolicy } from '@/utils/mediaClassification'
import { describe, expect, it } from 'vitest'
describe('formatClassificationCategoryOptionTitle', () => {
@@ -43,4 +44,35 @@ describe('formatClassificationCategoryOptionTitle', () => {
),
).toBe('未分类 · 未设置路径')
})
it('does not send the removed source default field and repairs empty legacy conditions', () => {
const policy = {
schema_version: 2,
revision: 1,
mode: 'first_match',
enrichment_mode: 'primary_only',
categories: [],
rules: [
{
id: 'movie-rule',
name: '电影规则',
kind: 'category',
enabled: true,
priority: 0,
media_types: ['电影'],
sources: [],
when: { all: null },
target: { category_id: null, labels: [] },
},
],
fallbacks: {},
field_aliases: {},
source_fallbacks: { themoviedb: { : 'movie' } },
} as unknown as ClassificationPolicy & { source_fallbacks: unknown }
const normalized = normalizeClassificationPolicy(policy)
expect(normalized).not.toHaveProperty('source_fallbacks')
expect(normalized.rules[0].when).toEqual({ field: 'media.type', operator: 'equals', value: '电影' })
})
})
+94 -1
View File
@@ -1,4 +1,11 @@
import type { ClassificationCategory } from '@/api/mediaClassificationTypes'
import type {
ClassificationCategory,
ClassificationCondition,
ClassificationConditionNode,
ClassificationFactValue,
ClassificationMediaType,
ClassificationPolicy,
} from '@/api/mediaClassificationTypes'
/** 分类下拉标题的可选显示配置。 */
interface ClassificationCategoryOptionTitleOptions {
@@ -20,3 +27,89 @@ export function formatClassificationCategoryOptionTitle(
if (options.includeId) parts.push(category.id)
return parts.filter(Boolean).join(' · ')
}
/** 根据规则媒体类型生成一个明确、可直接编辑的默认条件。 */
export function createClassificationTypeCondition(
mediaTypes: readonly ClassificationMediaType[],
): ClassificationConditionNode {
const selectedMediaTypes = [...new Set(mediaTypes)]
if (selectedMediaTypes.length === 1) {
return { field: 'media.type', operator: 'equals', value: selectedMediaTypes[0] }
}
if (selectedMediaTypes.length > 1) {
return {
any: selectedMediaTypes.map(type => ({ field: 'media.type', operator: 'equals', value: type })),
}
}
return { field: 'media.type', operator: 'exists' }
}
/** 复制条件值,避免请求体继续携带 Vue 响应式数组。 */
function cloneClassificationConditionValue(
value: ClassificationFactValue | undefined,
): ClassificationFactValue | undefined {
return Array.isArray(value) ? [...value] : value
}
/** 判断未知条件节点是否为字段条件叶子。 */
function isClassificationCondition(value: unknown): value is ClassificationCondition {
return Boolean(value && typeof value === 'object' && 'field' in value && 'operator' in value)
}
/** 清理条件组中的显式 null 分支,避免服务端把兼容空值解析成非法条件组。 */
export function normalizeClassificationConditionNode(
node: ClassificationConditionNode,
fallback?: ClassificationConditionNode,
): ClassificationConditionNode {
if (isClassificationCondition(node)) {
return {
field: node.field,
operator: node.operator,
...(node.value === undefined ? {} : { value: cloneClassificationConditionValue(node.value) }),
}
}
const group = node && typeof node === 'object' ? node : null
if (group && Array.isArray(group.all)) {
return { all: group.all.map(child => normalizeClassificationConditionNode(child)) }
}
if (group && Array.isArray(group.any)) {
return { any: group.any.map(child => normalizeClassificationConditionNode(child)) }
}
if (group && group.not !== undefined && group.not !== null) {
return { not: normalizeClassificationConditionNode(group.not) }
}
if (fallback) return normalizeClassificationConditionNode(fallback)
return { all: [] }
}
/** 生成可安全提交的策略副本,并把旧的空条件转换为规则媒体类型条件。 */
export function normalizeClassificationPolicy(policy: ClassificationPolicy): ClassificationPolicy {
// 旧版本曾生成来源默认分类;新策略只允许按媒体类型设置默认分类,不能把废弃字段发回服务端。
const normalizedPolicy = { ...policy } as ClassificationPolicy & { source_fallbacks?: unknown }
delete normalizedPolicy.source_fallbacks
return {
...normalizedPolicy,
categories: policy.categories.map(category => ({
...category,
path: [...category.path],
labels: [...category.labels],
})),
rules: policy.rules.map(rule => ({
...rule,
media_types: [...rule.media_types],
sources: [...rule.sources],
when: normalizeClassificationConditionNode(rule.when, createClassificationTypeCondition(rule.media_types)),
target: {
...rule.target,
labels: [...rule.target.labels],
},
})),
fallbacks: { ...policy.fallbacks },
field_aliases: Object.fromEntries(
Object.entries(policy.field_aliases).map(([field, aliases]) => [field, { ...aliases }]),
),
}
}
@@ -13,6 +13,7 @@ import type {
ClassificationPolicyHistory,
ClassificationPreviewInput,
ClassificationRule,
ClassificationSourceOption,
ClassificationValidationResult,
} from '@/api/mediaClassificationTypes'
import ClassificationCategoryEditor from '@/components/classification/ClassificationCategoryEditor.vue'
@@ -23,7 +24,7 @@ import ClassificationPreviewPanel from '@/components/classification/Classificati
import ClassificationRuleEditor from '@/components/classification/ClassificationRuleEditor.vue'
import { useMediaClassification } from '@/composables/useMediaClassification'
import { useMediaSources } from '@/composables/useMediaSources'
import { formatClassificationCategoryOptionTitle } from '@/utils/mediaClassification'
import { normalizeClassificationPolicy } from '@/utils/mediaClassification'
import { cloneDeep, isEqual } from 'lodash-es'
import { useToast } from 'vue-toastification'
@@ -56,14 +57,12 @@ const initializing = ref(false)
const loadError = ref(false)
const directoryReferencesUnavailable = ref(false)
const directories = ref<TransferDirectoryConf[]>([])
const workspaceTab = ref<'categories' | 'rules' | 'sources' | 'review'>('categories')
const workspaceTab = ref<'categories' | 'rules' | 'review'>('categories')
const analysisTab = ref<'preview' | 'impact' | 'publish'>('preview')
const helpDialog = ref(false)
const expandedSource = ref<string | null>(null)
const validatedDraftSnapshot = ref<ClassificationPolicy | null>(null)
const analyzedDraftSnapshot = ref<ClassificationPolicy | null>(null)
const lastImpactOptions = ref<ClassificationImpactRequestEvent>({ sampleLimit: 100, exampleLimit: 20 })
const mediaTypes: ClassificationMediaType[] = ['电影', '电视剧', '音乐']
const builtinSourceLabelKeys: Record<string, string> = {
themoviedb: 'setting.cache.recognitionSource.themoviedb',
douban: 'setting.cache.recognitionSource.douban',
@@ -130,7 +129,7 @@ const impactIsCurrent = computed(
isEqual(draftPolicy.value, analyzedDraftSnapshot.value),
)
/** 汇总规则和来源兜底引用;全局兜底由分类树按媒体类型单独判断。 */
/** 汇总规则引用;全局兜底由分类树按媒体类型单独判断。 */
const referencedCategoryIds = computed(() => {
const policy = draftPolicy.value
if (!policy) return []
@@ -139,11 +138,6 @@ const referencedCategoryIds = computed(() => {
for (const rule of policy.rules) {
if (rule.target.category_id) references.add(rule.target.category_id)
}
for (const sourceFallbacks of Object.values(policy.source_fallbacks)) {
for (const categoryId of Object.values(sourceFallbacks)) {
if (categoryId) references.add(categoryId)
}
}
return [...references]
})
@@ -163,16 +157,6 @@ const directoryCategoryReferences = computed(() => {
}))
})
/** 从动态字段支持表汇总当前可配置的内置和插件来源。 */
const availableSources = computed(() => {
const sources = new Set<string>()
for (const field of fieldCatalog.value?.fields ?? []) {
for (const source of Object.keys(field.source_support)) sources.add(source)
}
for (const source of Object.keys(draftPolicy.value?.source_fallbacks ?? {})) sources.add(source)
return [...sources].sort((left, right) => left.localeCompare(right))
})
/** 将来源标识转换为后端注册名称,并为内置来源提供本地化兜底。 */
function sourceDisplayName(source: string): string {
const registeredSource = mediaSourceCatalog.value.find(item => item.media_source === source)
@@ -181,6 +165,20 @@ function sourceDisplayName(source: string): string {
return labelKey ? t(labelKey) : t('setting.classification.preview.unknownSource')
}
/** 汇总规则和字段目录中的来源,并为规则选择器提供稳定值与可读名称。 */
const classificationSourceOptions = computed<ClassificationSourceOption[]>(() => {
const sourceIds = new Set<string>(mediaSourceCatalog.value.map(source => source.media_source))
for (const field of fieldCatalog.value?.fields ?? []) {
for (const source of Object.keys(field.source_support)) sourceIds.add(source)
}
for (const rule of draftPolicy.value?.rules ?? []) {
for (const source of rule.sources) sourceIds.add(source)
}
return [...sourceIds]
.sort((left, right) => sourceDisplayName(left).localeCompare(sourceDisplayName(right)))
.map(value => ({ value, title: sourceDisplayName(value) }))
})
/** 按条件树顺序提取当前策略实际引用的字段 ID。 */
function collectConditionFieldIds(node: ClassificationConditionNode): string[] {
if ('field' in node) return [node.field]
@@ -249,37 +247,6 @@ const historySnapshot = computed<ClassificationPolicyHistory | null>(() =>
history.value ? mutableApiSnapshot<ClassificationPolicyHistory>(history.value) : null,
)
/** 返回指定媒体类型可作为来源兜底的启用分类。 */
function fallbackCategoryOptions(mediaType: ClassificationMediaType) {
return (draftPolicy.value?.categories ?? [])
.filter(category => category.enabled && category.media_type === mediaType)
.map(category => ({
title: formatClassificationCategoryOptionTitle(category),
value: category.id,
}))
}
/** 将来源默认分类标签和有界浮层参数传给 VSelect。 */
function sourceFallbackMenuProps(source: string, mediaType: ClassificationMediaType) {
return {
activatorProps: {
'aria-label': t('setting.classification.sourceFallbackFor', {
source: sourceDisplayName(source),
mediaType,
}),
},
contentClass: 'classification-source-fallback-menu',
maxHeight: 280,
location: 'bottom start' as const,
offset: 4,
}
}
/** 返回来源已配置的媒体类型数量,折叠状态下仍能快速识别有效配置。 */
function sourceFallbackCount(source: string): number {
return Object.values(draftPolicy.value?.source_fallbacks[source] ?? {}).filter(Boolean).length
}
/** 标签首次激活时加载策略与动态字段,失败后允许用户显式重试。 */
async function ensureInitialized(force = false): Promise<void> {
if (!props.active || initializing.value || (initialized.value && !force)) return
@@ -335,27 +302,15 @@ function updateRules(rules: ClassificationRule[]): void {
draftPolicy.value = { ...draftPolicy.value, rules }
}
/** 更新单个来源和媒体类型的稳定兜底引用,空值会清理无用来源节点。 */
function updateSourceFallback(source: string, mediaType: ClassificationMediaType, categoryId: string | null): void {
if (!draftPolicy.value) return
const sourceFallbacks = Object.fromEntries(
Object.entries(draftPolicy.value.source_fallbacks).map(([sourceId, values]) => [sourceId, { ...values }]),
)
const sourceValues = { ...(sourceFallbacks[source] ?? {}) }
if (categoryId) sourceValues[mediaType] = categoryId
else delete sourceValues[mediaType]
if (Object.keys(sourceValues).length) sourceFallbacks[source] = sourceValues
else delete sourceFallbacks[source]
draftPolicy.value = { ...draftPolicy.value, source_fallbacks: sourceFallbacks }
}
/** 通过服务端真实字段目录校验当前草稿,并保留结构化问题供页面展示。 */
async function validateCurrentDraft(): Promise<void> {
if (!draftPolicy.value) return
const requestedPolicy = cloneDeep(draftPolicy.value)
const requestedPolicy = normalizeClassificationPolicy(draftPolicy.value)
const requestedSnapshot = cloneDeep(requestedPolicy)
draftPolicy.value = requestedPolicy
try {
const result = await validateDraft(requestedPolicy)
validatedDraftSnapshot.value = result.valid ? requestedPolicy : null
validatedDraftSnapshot.value = result.valid ? requestedSnapshot : null
if (result.valid) toast.success(t('setting.classification.validationPassed'))
else toast.error(t('setting.classification.validationFailed', { count: result.issues.length }))
} catch (error) {
@@ -379,14 +334,16 @@ async function previewFacts(request: ClassificationPreviewRequestEvent): Promise
async function analyzeCurrentDraft(options: ClassificationImpactRequestEvent = lastImpactOptions.value): Promise<void> {
if (!draftPolicy.value) return
lastImpactOptions.value = { ...options }
const requestedPolicy = cloneDeep(draftPolicy.value)
const requestedPolicy = normalizeClassificationPolicy(draftPolicy.value)
const requestedSnapshot = cloneDeep(requestedPolicy)
draftPolicy.value = requestedPolicy
try {
await analyzeImpact({
policy: requestedPolicy,
sampleLimit: options.sampleLimit,
exampleLimit: options.exampleLimit,
})
analyzedDraftSnapshot.value = requestedPolicy
analyzedDraftSnapshot.value = requestedSnapshot
workspaceTab.value = 'review'
analysisTab.value = 'impact'
} catch (error) {
@@ -575,9 +532,6 @@ watch(analysisTab, tab => {
<VTab value="rules" prepend-icon="mdi-filter-cog-outline">{{
t('setting.classification.workspaceRules')
}}</VTab>
<VTab value="sources" prepend-icon="mdi-database-sync-outline">{{
t('setting.classification.workspaceSources')
}}</VTab>
<VTab value="review" prepend-icon="mdi-check-decagram-outline">{{
t('setting.classification.workspaceReview')
}}</VTab>
@@ -631,6 +585,7 @@ watch(analysisTab, tab => {
:rules="draftPolicy.rules"
:categories="draftPolicy.categories"
:fields="editorFields"
:source-options="classificationSourceOptions"
:max-rules="fieldCatalog.limits.max_rules"
:max-condition-depth="fieldCatalog.limits.max_condition_depth"
@update:rules="updateRules"
@@ -638,72 +593,6 @@ watch(analysisTab, tab => {
</section>
</VWindowItem>
<VWindowItem value="sources">
<section class="classification-settings__panel classification-settings__source-fallbacks">
<div class="classification-settings__section-heading">
<div>
<h3>{{ t('setting.classification.sourceFallbacks') }}</h3>
<p>{{ t('setting.classification.sourceFallbacksHint') }}</p>
</div>
<span class="classification-settings__count">{{ availableSources.length }}</span>
</div>
<VExpansionPanels
v-model="expandedSource"
class="classification-settings__source-list"
variant="accordion"
>
<VExpansionPanel v-for="source in availableSources" :key="source" :value="source">
<VExpansionPanelTitle
class="classification-settings__source-title"
:aria-label="
t('setting.classification.sourceFallbackPanel', {
source: sourceDisplayName(source),
count: sourceFallbackCount(source),
})
"
>
<span class="classification-settings__source-name">
<VIcon icon="mdi-database-outline" size="18" />
<span>{{ sourceDisplayName(source) }}</span>
</span>
<VChip size="x-small" variant="tonal">
{{
sourceFallbackCount(source)
? t('setting.classification.sourceFallbackConfigured', {
count: sourceFallbackCount(source),
})
: t('setting.classification.sourceFallbackEmpty')
}}
</VChip>
</VExpansionPanelTitle>
<VExpansionPanelText>
<div class="classification-settings__source-options">
<VSelect
v-for="mediaType in mediaTypes"
:key="mediaType"
:model-value="draftPolicy.source_fallbacks[source]?.[mediaType] ?? null"
:items="fallbackCategoryOptions(mediaType)"
:label="mediaType"
:aria-label="
t('setting.classification.sourceFallbackFor', {
source: sourceDisplayName(source),
mediaType,
})
"
:menu-props="sourceFallbackMenuProps(source, mediaType)"
density="compact"
variant="outlined"
hide-details
clearable
@update:model-value="updateSourceFallback(source, mediaType, $event)"
/>
</div>
</VExpansionPanelText>
</VExpansionPanel>
</VExpansionPanels>
</section>
</VWindowItem>
<VWindowItem value="review">
<section
class="classification-settings__panel classification-settings__analysis"
@@ -817,10 +706,10 @@ watch(analysisTab, tab => {
<style scoped>
.classification-settings {
--classification-border: rgba(var(--v-border-color), var(--v-border-opacity));
--classification-panel: rgba(var(--v-theme-surface-variant), 0.12);
--classification-border: rgba(var(--v-theme-on-surface), 0.16);
--classification-panel: rgba(var(--v-theme-on-surface), 0.04);
--classification-panel-raised: rgb(var(--v-theme-surface));
--classification-control: rgba(var(--v-theme-surface-variant), 0.24);
--classification-control: rgba(var(--v-theme-on-surface), 0.08);
overflow: hidden;
background: transparent;
@@ -881,7 +770,7 @@ watch(analysisTab, tab => {
.classification-settings__analysis-tabs :deep(.v-slide-group__content) {
display: grid;
inline-size: 100%;
grid-template-columns: repeat(4, minmax(0, 1fr));
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.classification-settings__analysis-tabs :deep(.v-slide-group__content) {
@@ -971,75 +860,6 @@ watch(analysisTab, tab => {
margin-block-start: 0.25rem;
}
.classification-settings__count {
display: inline-grid;
min-inline-size: 28px;
block-size: 28px;
place-items: center;
border: 1px solid var(--classification-border);
border-radius: 50%;
color: rgba(var(--v-theme-on-surface), 0.7);
font-size: 0.8125rem;
}
.classification-settings__source-list {
overflow: hidden;
border: 0;
border-radius: 0;
background: transparent;
box-shadow: none;
}
.classification-settings__source-list :deep(.v-expansion-panel) {
border-block-end: 1px solid var(--classification-border);
background: transparent;
}
.classification-settings__source-list :deep(.v-expansion-panel:last-child) {
border-block-end: 0;
}
.classification-settings__source-list :deep(.v-expansion-panel-title) {
color: rgb(var(--v-theme-on-surface));
}
.classification-settings__source-list :deep(.v-expansion-panel-text__wrapper) {
padding: 12px 14px 14px;
}
.classification-settings__source-title {
min-block-size: 52px;
padding: 8px 14px;
}
.classification-settings__source-title :deep(.v-expansion-panel-title__overlay) {
background: var(--classification-control);
}
.classification-settings__source-title :deep(.v-expansion-panel-title__icon) {
margin-inline-start: 10px;
}
.classification-settings__source-name {
display: flex;
flex: 1 1 auto;
align-items: center;
gap: 8px;
min-inline-size: 0;
}
.classification-settings__source-name code {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.classification-settings__source-options {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
}
.classification-settings__issues {
display: grid;
gap: 0.4rem;
@@ -1079,22 +899,6 @@ watch(analysisTab, tab => {
box-shadow: var(--glass-control-shadow);
}
:global(html[data-theme='glass'] .classification-settings__source-list) {
border: 0 !important;
border-radius: 0;
background-color: transparent !important;
background-image: none !important;
box-shadow: none !important;
}
:global(html[data-theme='glass'] .classification-settings__source-list .v-expansion-panel) {
border-color: var(--glass-border) !important;
background-color: transparent !important;
background-image: none !important;
box-shadow: none !important;
}
:global(.classification-source-fallback-menu),
:global(.classification-category-menu),
:global(.classification-preview-menu),
:global(.classification-condition-menu),
@@ -1109,7 +913,6 @@ watch(analysisTab, tab => {
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.24);
}
:global(.classification-source-fallback-menu .v-list),
:global(.classification-category-menu .v-list),
:global(.classification-preview-menu .v-list),
:global(.classification-condition-menu .v-list),
@@ -1119,7 +922,6 @@ watch(analysisTab, tab => {
padding-block: 4px;
}
:global(html[data-theme='glass'] .classification-source-fallback-menu),
:global(html[data-theme='glass'] .classification-category-menu),
:global(html[data-theme='glass'] .classification-preview-menu),
:global(html[data-theme='glass'] .classification-condition-menu),
@@ -1180,10 +982,6 @@ watch(analysisTab, tab => {
inline-size: 100%;
}
.classification-settings__source-options {
grid-template-columns: minmax(0, 1fr);
}
.classification-settings__actions {
align-items: stretch;
flex-direction: column-reverse;
@@ -180,7 +180,6 @@ function createPolicy(): ClassificationPolicy {
},
],
fallbacks: { : 'movie.base' },
source_fallbacks: { themoviedb: { : 'movie.base' } },
field_aliases: {},
}
}
@@ -339,7 +338,7 @@ describe('AccountSettingClassification', () => {
})
/** 切换一级工作区,模拟移动端按需展示大型编辑面板。 */
async function openWorkspace(name: '分类树' | '规则' | '来源' | '验证发布'): Promise<void> {
async function openWorkspace(name: '分类树' | '规则' | '验证发布'): Promise<void> {
const user = userEvent.setup()
await user.click(await screen.findByRole('tab', { name }))
}
@@ -382,7 +381,6 @@ describe('AccountSettingClassification', () => {
expect(state.draftPolicy.value.categories[0].name).toBe('新电影')
expect(state.draftPolicy.value.fallbacks.).toBe('movie.new')
expect(state.draftPolicy.value.rules[0].name).toBe('新规则')
expect(state.draftPolicy.value.source_fallbacks.themoviedb.).toBe('movie.base')
expect(screen.getByLabelText('category-references')).toHaveTextContent('movie.base')
expect(screen.getByLabelText('directory-references')).toHaveTextContent('movie.base')
expect(screen.getByLabelText('directory-references')).toHaveTextContent('电影目录')
@@ -463,38 +461,14 @@ describe('AccountSettingClassification', () => {
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith('请求参数不正确'))
})
it('updates source fallbacks through stable category IDs', async () => {
const user = userEvent.setup()
it('只显示分类树、规则和验证发布三个工作区标签', async () => {
await renderWithProviders(AccountSettingClassification)
await openWorkspace('来源')
await user.click(
screen.getByRole('button', {
name: 'MusicBrainz 默认分类,已设置 0 项',
}),
)
await screen.findByRole('region', { name: 'category-editor' })
const musicbrainzFallback = screen.getByRole('combobox', {
name: 'MusicBrainz 的电影默认分类',
})
await user.click(musicbrainzFallback)
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')
})
it('keeps source fallbacks collapsed and opens one source at a time', async () => {
const user = userEvent.setup()
await renderWithProviders(AccountSettingClassification)
await openWorkspace('来源')
expect(screen.queryByRole('combobox', { name: 'MusicBrainz 的电影默认分类' })).not.toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'MusicBrainz 默认分类,已设置 0 项' }))
expect(screen.getByRole('combobox', { name: 'MusicBrainz 的电影默认分类' })).toBeVisible()
await user.click(screen.getByRole('button', { name: 'TheMovieDb 默认分类,已设置 1 项' }))
expect(screen.queryByRole('combobox', { name: 'MusicBrainz 的电影默认分类' })).not.toBeInTheDocument()
expect(screen.getByRole('combobox', { name: 'TheMovieDb 的电影默认分类' })).toBeVisible()
expect(screen.getByRole('tab', { name: '分类树' })).toBeInTheDocument()
expect(screen.getByRole('tab', { name: '规则' })).toBeInTheDocument()
expect(screen.getByRole('tab', { name: '验证发布' })).toBeInTheDocument()
expect(screen.queryByRole('tab', { name: '来源' })).not.toBeInTheDocument()
})
it('maps fact preview modes and bounded impact options to the composable', async () => {
@@ -178,7 +178,6 @@ const classificationPolicyFixture = {
categories: classificationCategories,
rules: [],
fallbacks: {},
source_fallbacks: {},
field_aliases: {},
}