fix(classification): open rules from directory settings

This commit is contained in:
jxxghp
2026-09-04 07:16:00 +08:00
parent 1f3f759833
commit 565c79288d
20 changed files with 436 additions and 329 deletions
+1 -1
View File
@@ -229,7 +229,7 @@ const categoryItems = computed(() => [
...props.categories
.filter(category => category.enabled && category.media_type === props.directory.media_type)
.map(category => ({
title: formatClassificationCategoryOptionTitle(category, { includeId: true, pathSeparator: '/' }),
title: formatClassificationCategoryOptionTitle(category, { pathSeparator: '/' }),
value: category.id,
})),
])
@@ -58,11 +58,11 @@ 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()
expect(screen.queryByRole('option', { name: /music.live/ })).not.toBeInTheDocument()
expect(await screen.findByRole('option', { name: '电影' })).toBeInTheDocument()
expect(await screen.findByRole('option', { name: '动画 · 电影' })).toBeInTheDocument()
expect(screen.queryByRole('option', { name: /停用/ })).not.toBeInTheDocument()
expect(screen.queryByRole('option', { name: /电视动画|tv.animation/ })).not.toBeInTheDocument()
expect(screen.queryByRole('option', { name: /现场|music.live/ })).not.toBeInTheDocument()
})
it('clears both the stable id and path snapshot when the media type changes', async () => {
@@ -14,6 +14,7 @@ import type {
ClassificationSelection,
ClassificationSourceSupport,
} from '@/api/mediaClassificationTypes'
import { formatClassificationCategoryOptionTitle } from '@/utils/mediaClassification'
defineOptions({ name: 'ClassificationPreviewPanel' })
@@ -262,17 +263,22 @@ function sourceSupportHint(field: ClassificationFieldDefinition): string | null
return key ? t(key) : null
}
/** 将分类选择解析为名称路径和稳定 ID。 */
/** 将分类选择转换为不重复内部编号的可读名称路径。 */
function selectionTitle(selection: ClassificationSelection | null | undefined): string {
if (!selection?.category_id) return t('setting.classification.preview.selection.unmatched')
const category = categoryMap.value.get(selection.category_id)
const path = selection.category_path.length ? selection.category_path : (category?.path ?? [])
const name = category?.name ?? t('setting.classification.preview.selection.unknown')
return t('setting.classification.preview.selection.summary', {
name,
path: path.length ? path.join(' / ') : t('setting.classification.preview.selection.unsetPath'),
return formatClassificationCategoryOptionTitle(
{
id: selection.category_id,
})
name,
path,
},
{
emptyPathLabel: t('setting.classification.preview.selection.unsetPath'),
},
)
}
/** 将选择来源转换为界面可读文本。 */
@@ -10,6 +10,7 @@ import type {
ClassificationRule,
ClassificationRuleKind,
} from '@/api/mediaClassificationTypes'
import { formatClassificationCategoryOptionTitle } from '@/utils/mediaClassification'
import ClassificationConditionBuilder from './ClassificationConditionBuilder.vue'
const MEDIA_TYPES: ClassificationMediaType[] = ['电影', '电视剧', '音乐']
@@ -127,7 +128,7 @@ function categoryItems(rule: ClassificationRule) {
return props.categories
.filter(category => selectedMediaTypes.size === 0 || selectedMediaTypes.has(category.media_type))
.map(category => ({
title: `${category.path.join(' / ')}${category.enabled ? '' : '(已停用)'}`,
title: `${formatClassificationCategoryOptionTitle(category)}${category.enabled ? '' : '(已停用)'}`,
value: category.id,
props: { disabled: !category.enabled },
}))
@@ -443,10 +444,10 @@ watch(
/>
<VTextField
:model-value="rule.id"
label="稳定 ID"
label="规则编号"
density="compact"
hide-details="auto"
:aria-label="`规则 ID ${index + 1}`"
:aria-label="`规则编号 ${index + 1}`"
@update:model-value="value => updateRule(index, { id: value })"
/>
<VBtnToggle
@@ -50,7 +50,7 @@ async function renderEditor(
}
describe('ClassificationCategoryEditor', () => {
it('按电影、电视剧和音乐分段展示稳定 ID 与多级路径', async () => {
it('按电影、电视剧和音乐分段展示分类编号与多级路径', async () => {
const user = userEvent.setup()
await renderEditor()
@@ -69,14 +69,14 @@ describe('ClassificationCategoryEditor', () => {
expect(screen.getByRole('list', { name: '无损音乐分类路径' })).toHaveTextContent('音乐专辑无损')
})
it('新建时录入稳定 ID,并编辑既有分类的名称、路径、媒体类型和启停状态', async () => {
it('新建时录入分类编号,并编辑既有分类的名称、路径、媒体类型和启停状态', async () => {
const user = userEvent.setup()
const { events } = await renderEditor()
await user.click(screen.getByRole('button', { name: '音乐' }))
await user.click(screen.getByRole('button', { name: '新增音乐分类' }))
await user.type(screen.getByRole('textbox', { name: /分类名称/ }), '现场专辑')
await user.type(screen.getByRole('textbox', { name: /稳定 ID/ }), 'music.live')
await user.type(screen.getByRole('textbox', { name: /分类编号/ }), 'music.live')
await user.type(screen.getByRole('textbox', { name: /分类路径/ }), '音乐/专辑/现场')
await user.click(screen.getByRole('button', { name: '保存分类' }))
@@ -98,7 +98,7 @@ describe('ClassificationCategoryEditor', () => {
await user.click(screen.getByRole('button', { name: '电影' }))
await user.click(screen.getByRole('button', { name: '编辑分类“科幻电影”' }))
const nameInput = screen.getByRole('textbox', { name: /分类名称/ })
const idInput = screen.getByRole('textbox', { name: /稳定 ID/ })
const idInput = screen.getByRole('textbox', { name: /分类编号/ })
const pathInput = screen.getByRole('textbox', { name: /分类路径/ })
expect(idInput).toHaveValue('movie.scifi')
expect(idInput).toHaveAttribute('readonly')
@@ -178,11 +178,11 @@ describe('ClassificationCategoryEditor', () => {
})
})
it('fallback 选择器提交稳定分类 ID 而不是名称或路径', async () => {
it('默认分类选择器提交分类编号而不是名称或路径', async () => {
const user = userEvent.setup()
const { events } = await renderEditor()
await user.click(screen.getByRole('combobox', { name: '音乐回退分类' }))
await user.click(screen.getByRole('combobox', { name: '音乐默认分类' }))
await user.click(await screen.findByRole('option', { name: '无损音乐 · 音乐 / 专辑 / 无损' }))
await waitFor(() => expect(events.updateFallbacks).toHaveBeenCalledWith({ : 'music.lossless' }))
@@ -194,7 +194,7 @@ describe('ClassificationCategoryEditor', () => {
await user.click(screen.getByRole('button', { name: '新增电影分类' }))
await user.type(screen.getByRole('textbox', { name: /分类名称/ }), '过深分类')
await user.type(screen.getByRole('textbox', { name: /稳定 ID/ }), 'movie.deep')
await user.type(screen.getByRole('textbox', { name: /分类编号/ }), 'movie.deep')
await user.type(screen.getByRole('textbox', { name: /分类路径/ }), '电影/地区/华语')
await user.click(screen.getByRole('button', { name: '保存分类' }))
@@ -203,7 +203,7 @@ describe('ClassificationCategoryEditor', () => {
expect(businessError).toHaveAttribute('role', 'alert')
expect(businessError.id).not.toBe('')
expect(screen.getByRole('region', { name: '新增分类' })).toHaveAttribute('aria-describedby', businessError.id)
expect(screen.getByRole('textbox', { name: /稳定 ID/ })).toHaveValue('movie.deep')
expect(screen.getByRole('textbox', { name: /分类编号/ })).toHaveValue('movie.deep')
expect(events.updateCategories).not.toHaveBeenCalled()
})
})
@@ -125,9 +125,9 @@ describe('ClassificationImpactPanel', () => {
const analysis = createAnalysis()
await renderPanel({ analysis })
expect(screen.getByText('sample_source: recent_history近期下载与整理历史)')).toBeInTheDocument()
expect(screen.getByText('活动 revision 7')).toBeInTheDocument()
expect(screen.getByText('候选 revision 8')).toBeInTheDocument()
expect(screen.getByText('样本来源:近期下载与整理记录')).toBeInTheDocument()
expect(screen.getByText('当前版本 7')).toBeInTheDocument()
expect(screen.getByText('待发布版本 8')).toBeInTheDocument()
const expectedMetrics: Record<string, string> = {
requested_limit: '100',
@@ -165,7 +165,7 @@ describe('ClassificationImpactPanel', () => {
expect(screen.getByText('返回 1 条,共检测到 3 条变化')).toBeInTheDocument()
const example = screen.getByRole('article', { name: '变化示例 1:流浪地球' })
expect(example).toHaveTextContent('themoviedb:movie-1')
expect(within(example).getByRole('list', { name: '变化字段' })).toHaveTextContent('分类 ID分类路径命中规则')
expect(within(example).getByRole('list', { name: '变化字段' })).toHaveTextContent('分类编号分类路径命中规则')
const previous = within(example).getByRole('region', { name: '变化示例 1 的活动策略结果' })
expect(previous).toHaveTextContent('movie.scifi')
@@ -177,7 +177,7 @@ describe('ClassificationImpactPanel', () => {
expect(candidate).toHaveTextContent('movie.china')
expect(candidate).toHaveTextContent('电影 / 华语')
expect(candidate).toHaveTextContent('source_fallback')
expect(candidate).toHaveTextContent('事实不完整')
expect(candidate).toHaveTextContent('媒体信息不完整')
expect(screen.getByRole('alert')).toHaveTextContent('近期历史仅保留有限事实')
})
@@ -196,7 +196,7 @@ describe('ClassificationImpactPanel', () => {
}),
})
expect(screen.getByText('sample_source: request(请求内显式事实)')).toBeInTheDocument()
expect(screen.getByText('样本来源:本次输入的媒体信息')).toBeInTheDocument()
expect(screen.queryByRole('note')).not.toBeInTheDocument()
expect(screen.getByText('本次样本没有可展示的媒体类型与来源分组。')).toBeInTheDocument()
expect(screen.getByText('有限样本内未返回分类变化示例。')).toBeInTheDocument()
@@ -104,7 +104,7 @@ describe('ClassificationPreviewPanel', () => {
const result = await renderPanel()
const sourceInput = screen.getByRole('textbox', { name: '媒体来源' })
const mediaIdInput = screen.getByRole('textbox', { name: '媒体 ID' })
const mediaIdInput = screen.getByRole('textbox', { name: '媒体编号' })
await user.type(sourceInput, 'plugin.example')
await user.type(mediaIdInput, 'release-42')
await user.type(screen.getByRole('spinbutton', { name: '发行年份' }), '2024')
@@ -121,8 +121,8 @@ describe('ClassificationPreviewPanel', () => {
await user.click(await screen.findByRole('option', { name: '专辑' }))
await user.type(screen.getByRole('combobox', { name: '音乐标签' }), 'ambient{Enter}')
await user.type(screen.getByRole('textbox', { name: '来源地区组' }), 'east-asia')
await user.click(screen.getByRole('button', { name: '活动策略' }))
await user.click(screen.getByRole('button', { name: '执行事实预览' }))
await user.click(screen.getByRole('button', { name: '已发布的规则' }))
await user.click(screen.getByRole('button', { name: '预览分类结果' }))
await waitFor(() => expect(result.emitted()['request-preview']).toHaveLength(1))
const previewEvents = result.emitted()['request-preview'] as unknown[][] | undefined
@@ -214,10 +214,8 @@ describe('ClassificationPreviewPanel', () => {
expect(screen.getByText('部分完成')).toBeInTheDocument()
expect(screen.getByText('18')).toBeInTheDocument()
expect(screen.getByRole('region', { name: '推荐分类' })).toHaveTextContent('科幻电影 · 电影 / 科幻 · movie.scifi')
expect(screen.getByRole('region', { name: '生效分类' })).toHaveTextContent(
'生效电影 · 电影 / 精选 · movie.effective',
)
expect(screen.getByRole('region', { name: '规则建议分类' })).toHaveTextContent('科幻电影 · 电影 / 科幻')
expect(screen.getByRole('region', { name: '生效分类' })).toHaveTextContent('生效电影 · 电影 / 精选')
expect(screen.getByRole('region', { name: '标签' })).toHaveTextContent('经典高分')
expect(screen.getByRole('region', { name: '警告' })).toHaveTextContent('missing_field:来源未提供内容分级')
expect(screen.getByRole('region', { name: '警告' })).toHaveTextContent('facts.media.content_rating')
@@ -237,12 +235,12 @@ describe('ClassificationPreviewPanel', () => {
const user = userEvent.setup()
const result = await renderPanel()
await user.click(screen.getByRole('button', { name: '执行事实预览' }))
await user.click(screen.getByRole('button', { name: '预览分类结果' }))
expect(screen.getByRole('alert')).toHaveTextContent('媒体来源不能为空')
expect(result.emitted()['request-preview']).toBeUndefined()
await result.rerender({ fields, categories, result: null, loading: true })
expect(screen.getByRole('button', { name: '执行事实预览' })).toBeDisabled()
expect(screen.getByRole('progressbar', { name: '正在执行事实预览' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: '预览分类结果' })).toBeDisabled()
expect(screen.getByRole('progressbar', { name: '正在预览分类结果' })).toBeInTheDocument()
})
})
@@ -194,7 +194,7 @@ describe('ClassificationRuleEditor', () => {
const editor = await renderEditor([createRule()])
await fireEvent.update(screen.getByLabelText('规则名称 1'), '音乐来源规则')
await fireEvent.update(screen.getByLabelText('规则 ID 1'), 'rule-music-source')
await fireEvent.update(screen.getByLabelText('规则编号 1'), 'rule-music-source')
await user.click(screen.getByRole('checkbox', { name: '启用规则 音乐来源规则' }))
await selectOption('媒体类型 音乐来源规则', '音乐')
await selectOption('数据来源 音乐来源规则', 'musicbrainz')
@@ -215,8 +215,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('媒体类型 电影规则', '音乐')
@@ -226,7 +226,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')
})
+74 -68
View File
@@ -570,10 +570,6 @@ export default {
title: 'Storage & Directories',
description: 'Download directory, media library directory, organization, scraping',
},
classification: {
title: 'Auto Classification',
description: 'Category trees, source scopes, and combined rules for movies, TV shows, and music',
},
site: {
title: 'Sites',
description: 'Site synchronization, site data refresh, site reset',
@@ -1935,12 +1931,12 @@ export default {
setting: {
classification: {
title: 'Media Auto Classification',
description: 'Configure shared movie, TV, and music rules with stable category IDs and dynamic fields.',
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: 'Active revision {revision}',
revision: 'Current version {revision}',
unsaved: 'Unsaved changes',
loading: 'Loading the classification policy and field catalog...',
loadFailed: 'Failed to load the classification policy',
@@ -1951,32 +1947,41 @@ export default {
validationRequestFailed: 'Draft validation request failed',
validationIssues: '{count} validation issues',
draftReset: 'Restored the active policy',
enrichmentTitle: 'Classification Fact Sources',
enrichmentTitle: 'Classification Information Sources',
enrichmentHint:
'Call registered sources only for missing standard facts referenced by active rules. Enrichment never overwrites primary facts or changes media identity.',
enrichmentModeLabel: 'Cross-source enrichment',
enrichmentPrimaryOnly: 'Primary Source Only',
enrichmentMissing: 'Fill Missing Facts',
sourceFallbacks: 'Source-specific Fallbacks',
sourceFallbacksHint: 'Used only when a source-specific rule does not match; stores stable category IDs.',
'Ask registered sources only when a rule needs missing media details. Additional information never overwrites the primary source or changes the matched media.',
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} fallbacks, {count} configured',
sourceFallbackConfigured: '{count} configured',
sourceFallbackEmpty: 'Not configured',
sourceFallbackFor: '{mediaType} fallback for {source}',
analysisTitle: 'Validation, Preview, and Version Control',
analysisHint: 'Inspect fact matches, estimate recent-sample impact, and review history before publishing.',
previewTab: 'Fact Preview',
sourceFallbackPanel: '{source} default categories, {count} set',
sourceFallbackConfigured: '{count} set',
sourceFallbackEmpty: 'Not set',
sourceFallbackFor: 'Default {mediaType} category for {source}',
sourceNames: {
imdb: 'IMDb',
tvdb: 'TVDB',
bilibili: 'Bilibili',
mangguodiscover: 'Mango TV',
migu: 'Migu Video',
tencentvideodiscover: 'Tencent Video',
iqiyi: 'iQIYI',
},
analysisTitle: 'Check, Preview, and Publish',
analysisHint: 'Review category matches, estimate changes, and check version history before publishing.',
previewTab: 'Result Preview',
impactTab: 'Impact Analysis',
publishTab: 'Publish & History',
previewFailed: 'Classification preview request failed',
impactFailed: 'Classification impact analysis failed',
publishSucceeded: 'Classification policy published as revision {revision}',
publishSucceeded: 'Classification rules published as version {revision}',
publishFailed: 'Classification policy publish failed',
remoteReloaded: 'Reloaded the active server policy',
remoteReloadFailed: 'Failed to reload the server policy',
historyFailed: 'Failed to load classification policy history',
rollbackSucceeded: 'Revision {source} was restored and published as revision {revision}',
rollbackSucceeded: 'Version {source} was restored and published as version {revision}',
rollbackFailed: 'Classification policy rollback failed',
directoryReferencesUnavailable: 'Directory references unavailable',
directoryReferencesUnavailableHint:
@@ -1984,7 +1989,7 @@ export default {
category: {
title: 'Category Tree',
description:
'Paths support up to {count} levels. Rules, fallbacks, and directories always reference stable IDs.',
'Paths support up to {count} levels. Rules and directory settings are saved with the category number.',
add: 'Add {mediaType} category',
mediaTypeSegments: 'Category media type',
editTitle: 'Edit Category',
@@ -1992,10 +1997,10 @@ export default {
cancelEdit: 'Cancel category edit',
save: 'Save category',
name: 'Category Name',
stableId: 'Stable ID',
stableId: 'Category Number',
existingIdHint:
'Stable IDs cannot be changed after creation because rules, fallbacks, directories, and history reference them',
newIdHint: 'Rules, fallbacks, directories, and history will reference this value after creation',
'The category number cannot be changed after creation because rules, defaults, directory settings, and history use it to identify this category',
newIdHint: 'Rules, defaults, directory settings, and history will use this number after creation',
path: 'Category Path',
pathHint: 'Separate levels with /. Maximum {count} levels',
mediaType: 'Media Type',
@@ -2010,9 +2015,9 @@ export default {
pathUnset: 'No path set',
edit: 'Edit category "{name}"',
empty: 'No {mediaType} categories',
fallbackTitle: 'Fallback Categories',
fallbackHint: 'Select a category by media type when no rule matches. The stored value is a stable ID.',
fallbackFor: '{mediaType} fallback category',
fallbackTitle: 'Default Categories When Nothing Matches',
fallbackHint: 'Choose the category to use for each media type when no rule matches.',
fallbackFor: 'Default {mediaType} category',
pathRequired: 'Category path is required',
pathEmptySegment: 'Category path cannot contain an empty level',
pathTooDeep: 'Category path supports at most {count} levels',
@@ -2028,51 +2033,51 @@ export default {
editingStatus: 'Editing category "{name}"',
cancelledStatus: 'Category editing cancelled',
nameRequired: 'Category name is required',
idRequired: 'Stable ID is required',
idDuplicate: 'Stable ID "{id}" already exists',
idRequired: 'Category number is required',
idDuplicate: 'Category number "{id}" already exists',
protectedMutationBlocked: 'A referenced category cannot be disabled or moved to another media type',
updatedStatus: 'Updated category "{name}"',
deletedStatus: 'Deleted category "{name}"',
fallbackUpdatedStatus: 'Updated the {mediaType} fallback category',
fallbackClearedStatus: 'Cleared the {mediaType} fallback category',
fallbackUpdatedStatus: 'Updated the default {mediaType} category',
fallbackClearedStatus: 'Cleared the default {mediaType} category',
},
preview: {
title: 'Fact Preview and Match Explanation',
title: 'Category Result Preview and Match Details',
description:
'Build facts from stable media identity and the field catalog. Previewing does not change the active policy or source identity.',
run: 'Run Fact Preview',
modeLabel: 'Preview Policy',
draftPolicy: 'Draft Policy',
activePolicy: 'Active Policy',
factsTitle: 'Preview Facts',
'Enter media details to see where the current rules place it. Previewing does not change the published rules.',
run: 'Preview Category Result',
modeLabel: 'Rules to use',
draftPolicy: 'Rules being edited',
activePolicy: 'Published rules',
factsTitle: 'Media Details',
mediaSource: 'Media Source',
mediaSourcePlaceholder: 'For example, themoviedb or musicbrainz',
mediaId: 'Media ID',
mediaIdPlaceholder: 'Stable ID within the source',
mediaId: 'Media Number',
mediaIdPlaceholder: 'Enter the number used by the data source',
mediaType: 'Media Type',
noEditableFields: 'This media type has no editable fields.',
resultTitle: 'Preview Result',
loading: 'Running fact preview',
emptyResult: 'Enter facts and run the preview to inspect the match explanation.',
loading: 'Previewing the category result',
emptyResult: 'Enter media details and preview to inspect the match details.',
status: 'Status',
policyRevision: 'Policy Revision',
recommended: 'Recommended Category',
policyRevision: 'Rules Version',
recommended: 'Rule Recommendation',
effective: 'Effective Category',
rule: 'Rule',
rule: 'Matched Rule',
none: 'None',
source: 'Source',
labels: 'Labels',
warnings: 'Warnings',
warningField: 'Field {field}',
warningSource: 'Source {source}',
trace: 'Rule Match Explanation',
noRules: 'No rules were evaluated.',
trace: 'Rule Match Details',
noRules: 'No rules were checked.',
matched: 'Matched',
notMatched: 'Not Matched',
traceTableAria: 'Condition match explanation for rule {rule}',
conditionMatched: 'Condition matched',
conditionNotMatched: 'Condition not matched',
noConditionTrace: 'This rule has no condition trace.',
noConditionTrace: 'There are no match details for this rule.',
root: 'Root',
missing: 'Not Provided',
mediaTypes: {
@@ -2086,9 +2091,9 @@ export default {
no: 'No',
},
support: {
partial: 'Partially supported by the current source',
unavailable: 'Unavailable from the current source',
extension: 'Provided by the current source extension',
partial: 'The current source provides only part of this information',
unavailable: 'The current source does not provide this information',
extension: 'Added by the current source',
},
groups: {
sourceExtension: 'Source Extensions',
@@ -2106,8 +2111,8 @@ export default {
},
selectionSource: {
automatic: 'Rule Match',
sourceFallback: 'Source Fallback',
fallback: 'Global Fallback',
sourceFallback: 'Source Default Category',
fallback: 'Global Default Category',
},
states: {
complete: 'Complete',
@@ -2121,15 +2126,15 @@ export default {
operator: 'Operator',
expected: 'Expected',
actual: 'Actual',
factSource: 'Fact Source',
path: 'Path',
factSource: 'Information Source',
path: 'Category Path',
},
},
impact: {
title: 'Impact Analysis',
description:
'Compare the active policy with the current draft using a limited sample. This is not an exact full-library count.',
sampleSource: 'sample_source: {source} ({label})',
sampleSource: 'Sample source: {label}',
sampleLimit: 'Maximum Samples',
exampleLimit: 'Change Example Limit',
analyzeAria: 'Analyze the impact of the current classification draft',
@@ -2138,8 +2143,8 @@ export default {
'Compares at most 200 samples and returns up to 50 change examples. Analysis is read-only and does not move files or modify history.',
loading: 'Generating a bounded-sample estimate…',
metadataAria: 'Impact analysis metadata',
baselineRevision: 'Active revision {revision}',
candidateRevision: 'Candidate revision {revision}',
baselineRevision: 'Current version {revision}',
candidateRevision: 'Pending version {revision}',
sampledAt: 'Sampled at {time}',
overviewTitle: 'Bounded Estimate Summary',
truncated:
@@ -2172,21 +2177,21 @@ export default {
yes: 'Yes',
no: 'No',
sampleSources: {
request: 'Facts explicitly supplied in the request',
recentHistory: 'Recent download and organization history',
request: 'Media details entered for this preview',
recentHistory: 'Recent download and organization records',
},
states: {
complete: 'Complete',
partial: 'Incomplete Facts',
notEvaluated: 'Not Evaluated',
partial: 'Incomplete Media Details',
notEvaluated: 'Not Checked',
invalidPolicy: 'Invalid Policy',
},
changedFields: {
categoryId: 'Category ID',
categoryId: 'Category Number',
categoryPath: 'Category Path',
ruleId: 'Matched Rule',
labels: 'Output Labels',
state: 'Evaluation State',
state: 'Check Status',
},
metrics: {
requestedLimit: 'Requested Limit',
@@ -2196,11 +2201,11 @@ export default {
sampleCount: 'Valid Samples',
changedCount: 'Changed',
unchangedCount: 'Unchanged',
categoryChangedCount: 'Stable Category Changes',
categoryChangedCount: 'Category Changes',
pathOnlyChangedCount: 'Path-only Changes',
ruleChangedOnlyCount: 'Matched Rule-only Changes',
becameFallbackCount: 'Changed to Fallback Category',
partialCount: 'Incomplete Facts',
partialCount: 'Incomplete Media Details',
degradedCount: 'Candidate Result Regressions',
},
columns: {
@@ -3145,6 +3150,7 @@ export default {
storageSaveSuccess: 'Storage settings saved successfully',
storageSaveFailed: 'Failed to save storage settings!',
classification: {
manage: 'Automatic Classification Rules',
categoryLabel: 'Fixed Category',
snapshotLabel: 'Category Path Snapshot',
snapshotPending: 'The server will fill the current canonical path after saving',
+75 -68
View File
@@ -554,10 +554,6 @@ export default {
title: '存储 & 目录',
description: '下载目录、媒体库目录、整理、刮削',
},
classification: {
title: '自动分类',
description: '电影、电视剧和音乐的分类树、数据源范围与组合规则',
},
site: {
title: '站点',
description: '站点同步、站点数据刷新、站点重置',
@@ -1916,12 +1912,12 @@ export default {
setting: {
classification: {
title: '媒体自动分类',
description: '使用稳定分类 ID 和动态字段,为电影、电视剧和音乐置统一规则。',
description: '按分类名称和媒体信息,为电影、电视剧和音乐置统一的自动分类规则。',
workspaceCategories: '分类树',
workspaceRules: '规则',
workspaceSources: '来源',
workspaceReview: '验证发布',
revision: '活动版本 {revision}',
revision: '当前版本 {revision}',
unsaved: '有未保存修改',
loading: '正在加载分类策略和字段目录...',
loadFailed: '分类策略加载失败',
@@ -1932,37 +1928,47 @@ export default {
validationRequestFailed: '草稿校验请求失败',
validationIssues: '{count} 个校验问题',
draftReset: '已恢复当前活动策略',
enrichmentTitle: '分类事实来源',
enrichmentHint: '仅在活动规则引用的标准事实缺失时调用已登记数据源;补充结果不会覆盖主来源事实或改变媒体身份。',
enrichmentModeLabel: '跨来源补充',
enrichmentPrimaryOnly: '仅主来源',
enrichmentMissing: '补充缺失事实',
sourceFallbacks: '数据源专用兜底',
sourceFallbacksHint: '仅在对应数据源规则未命中时使用,保存稳定分类 ID。',
enrichmentTitle: '分类信息来源',
enrichmentHint:
'只有规则需要的媒体信息缺失时,才会向已登记的数据源补充;补充结果不会覆盖主要来源的信息,也不会把媒体识别成另一条记录。',
enrichmentModeLabel: '是否补充缺少的信息',
enrichmentPrimaryOnly: '只使用主要来源',
enrichmentMissing: '补充缺少的信息',
sourceFallbacks: '按数据源设置默认分类',
sourceFallbacksHint: '当某个数据源没有匹配到规则时,使用这里设置的默认分类。',
source: '数据源',
sourceFallbackPanel: '{source} 来源兜底,已置 {count} 项',
sourceFallbackConfigured: '已置 {count} 项',
sourceFallbackEmpty: '未置',
sourceFallbackFor: '{source} 的{mediaType}来源兜底',
analysisTitle: '验证、预览与版本控制',
analysisHint: '在发布前检查事实命中、估算近期样本影响并审阅版本历史。',
previewTab: '事实预览',
sourceFallbackPanel: '{source} 默认分类,已置 {count} 项',
sourceFallbackConfigured: '已置 {count} 项',
sourceFallbackEmpty: '未置',
sourceFallbackFor: '{source} 的{mediaType}默认分类',
sourceNames: {
imdb: 'IMDb',
tvdb: 'TVDB',
bilibili: '哔哩哔哩',
mangguodiscover: '芒果TV',
migu: '咪咕视频',
tencentvideodiscover: '腾讯视频',
iqiyi: '爱奇艺',
},
analysisTitle: '检查、预览与发布',
analysisHint: '发布前查看分类匹配结果、变更影响并确认历史版本。',
previewTab: '结果预览',
impactTab: '影响分析',
publishTab: '发布与历史',
previewFailed: '分类预览请求失败',
impactFailed: '分类影响分析失败',
publishSucceeded: '分类策略已发布为 revision {revision}',
publishSucceeded: '分类规则已发布为 {revision}',
publishFailed: '分类策略发布失败',
remoteReloaded: '已重新加载服务端活动策略',
remoteReloadFailed: '重新加载服务端策略失败',
historyFailed: '分类策略历史加载失败',
rollbackSucceeded: 'revision {source} 已回滚并发布为 revision {revision}',
rollbackSucceeded: ' {source} 版已恢复,并发布为第 {revision}',
rollbackFailed: '分类策略回滚失败',
directoryReferencesUnavailable: '目录引用加载失败',
directoryReferencesUnavailableHint: '当前无法展示完整目录引用保护;服务端仍会在校验和发布时阻止无效变更。',
category: {
title: '分类树',
description: '分类路径最多 {count} 级,规则、兜底和目录配置始终引用稳定 ID。',
description: '分类路径最多 {count} 级,规则和目录设置会跟随分类编号保存。',
add: '新增{mediaType}分类',
mediaTypeSegments: '分类媒体类型',
editTitle: '编辑分类',
@@ -1970,9 +1976,9 @@ export default {
cancelEdit: '取消分类编辑',
save: '保存分类',
name: '分类名称',
stableId: '稳定 ID',
existingIdHint: '稳定 ID 创建后不修改,规则、兜底、目录和历史快照会持续引用该值',
newIdHint: '创建后规则、兜底、目录和历史快照引用',
stableId: '分类编号',
existingIdHint: '分类编号创建后不修改,规则、默认分类、目录设置和历史版本会用它识别这个分类',
newIdHint: '创建后规则、默认分类、目录设置和历史版本会使用这个编号',
path: '分类路径',
pathHint: '使用 / 分隔层级,最多 {count} 级',
mediaType: '媒体类型',
@@ -1986,9 +1992,9 @@ export default {
pathUnset: '未设置路径',
edit: '编辑分类“{name}”',
empty: '暂无{mediaType}分类',
fallbackTitle: '回退分类',
fallbackHint: '未命中规则时按媒体类型选择分类,保存值为稳定 ID。',
fallbackFor: '{mediaType}回退分类',
fallbackTitle: '未匹配时的默认分类',
fallbackHint: '没有规则匹配时,按媒体类型使用这里设置的分类。',
fallbackFor: '{mediaType}默认分类',
pathRequired: '分类路径不能为空',
pathEmptySegment: '分类路径不能包含空层级',
pathTooDeep: '分类路径最多支持 {count} 级',
@@ -2004,50 +2010,50 @@ export default {
editingStatus: '正在编辑分类“{name}”',
cancelledStatus: '已取消分类编辑',
nameRequired: '分类名称不能为空',
idRequired: '稳定 ID 不能为空',
idDuplicate: '稳定 ID “{id}” 已存在',
idRequired: '分类编号不能为空',
idDuplicate: '分类编号“{id}”已存在',
protectedMutationBlocked: '被引用的分类不能停用或改变媒体类型,请先清理引用',
updatedStatus: '已更新分类“{name}”',
deletedStatus: '已删除分类“{name}”',
fallbackUpdatedStatus: '已更新{mediaType}回退分类',
fallbackClearedStatus: '已清除{mediaType}回退分类',
fallbackUpdatedStatus: '已更新{mediaType}默认分类',
fallbackClearedStatus: '已清除{mediaType}默认分类',
},
preview: {
title: '事实预览与命中解释',
description: '使用稳定媒体身份和字段目录构造事实,预览不会修改活动策略或来源身份。',
run: '执行事实预览',
modeLabel: '预览策略',
draftPolicy: '草稿策略',
activePolicy: '活动策略',
factsTitle: '预览事实',
title: '分类结果预览与匹配说明',
description: '填写媒体信息,预览规则会将它分到哪里。预览不会修改当前已发布的规则。',
run: '预览分类结果',
modeLabel: '使用哪套规则',
draftPolicy: '当前编辑中的规则',
activePolicy: '已发布的规则',
factsTitle: '媒体信息',
mediaSource: '媒体来源',
mediaSourcePlaceholder: '例如 themoviedb、musicbrainz',
mediaId: '媒体 ID',
mediaIdPlaceholder: '来源内稳定 ID',
mediaId: '媒体编号',
mediaIdPlaceholder: '填写数据源中的媒体编号',
mediaType: '媒体类型',
noEditableFields: '当前媒体类型没有可编辑字段。',
resultTitle: '预览结果',
loading: '正在执行事实预览',
emptyResult: '填写事实并执行预览后查看命中解释。',
loading: '正在预览分类结果',
emptyResult: '填写媒体信息并预览后查看匹配说明。',
status: '状态',
policyRevision: '策略 revision',
recommended: '推荐分类',
policyRevision: '规则版本',
recommended: '规则建议分类',
effective: '生效分类',
rule: '规则',
rule: '匹配规则',
none: '无',
source: '来源',
labels: '标签',
warnings: '警告',
warningField: '字段 {field}',
warningSource: '来源 {source}',
trace: '规则命中解释',
noRules: '没有执行任何规则。',
trace: '规则匹配说明',
noRules: '没有检查任何规则。',
matched: '命中',
notMatched: '未命中',
traceTableAria: '规则 {rule} 的条件命中解释',
conditionMatched: '条件命中',
conditionNotMatched: '条件未命中',
noConditionTrace: '该规则没有条件轨迹。',
noConditionTrace: '该规则没有可显示的匹配信息。',
root: '根',
missing: '未提供',
mediaTypes: {
@@ -2061,9 +2067,9 @@ export default {
no: '否',
},
support: {
partial: '当前来源部分支持',
unavailable: '当前来源不可用',
extension: '由当前来源扩展提供',
partial: '当前数据源只提供部分信息',
unavailable: '当前数据源不提供这项信息',
extension: '由当前数据源补充',
},
groups: {
sourceExtension: '来源扩展',
@@ -2081,8 +2087,8 @@ export default {
},
selectionSource: {
automatic: '规则命中',
sourceFallback: '来源兜底',
fallback: '全局兜底',
sourceFallback: '数据源默认分类',
fallback: '全局默认分类',
},
states: {
complete: '完整',
@@ -2096,14 +2102,14 @@ export default {
operator: '操作符',
expected: 'Expected',
actual: 'Actual',
factSource: '事实来源',
path: 'Path',
factSource: '信息来源',
path: '分类路径',
},
},
impact: {
title: '影响分析',
description: '使用有限样本比较活动策略与当前草稿,不代表全库精确统计。',
sampleSource: 'sample_source: {source}{label}',
sampleSource: '样本来源:{label}',
sampleLimit: '最大样本数',
exampleLimit: '变化示例上限',
analyzeAria: '分析当前分类草稿影响',
@@ -2111,8 +2117,8 @@ export default {
scope: '最多比较 200 条样本并返回 50 条变化示例;分析仅执行只读求值,不移动文件或修改历史。',
loading: '正在生成有界样本估算…',
metadataAria: '影响分析元数据',
baselineRevision: '活动 revision {revision}',
candidateRevision: '候选 revision {revision}',
baselineRevision: '当前版本 {revision}',
candidateRevision: '待发布版本 {revision}',
sampledAt: '采样于 {time}',
overviewTitle: '有界估算汇总',
truncated: '本次结果因样本扫描范围或变化示例上限而截断,未展示的记录不应推断为无变化。',
@@ -2143,21 +2149,21 @@ export default {
yes: '是',
no: '否',
sampleSources: {
request: '请求内显式事实',
recentHistory: '近期下载与整理历史',
request: '本次输入的媒体信息',
recentHistory: '近期下载与整理记录',
},
states: {
complete: '完整',
partial: '事实不完整',
notEvaluated: '未求值',
partial: '媒体信息不完整',
notEvaluated: '未检查',
invalidPolicy: '策略无效',
},
changedFields: {
categoryId: '分类 ID',
categoryId: '分类编号',
categoryPath: '分类路径',
ruleId: '命中规则',
labels: '输出标签',
state: '求值状态',
state: '检查状态',
},
metrics: {
requestedLimit: '请求上限',
@@ -2167,11 +2173,11 @@ export default {
sampleCount: '有效样本',
changedCount: '发生变化',
unchangedCount: '保持不变',
categoryChangedCount: '稳定分类变化',
categoryChangedCount: '分类变化',
pathOnlyChangedCount: '仅路径变化',
ruleChangedOnlyCount: '仅命中规则变化',
becameFallbackCount: '转为兜底分类',
partialCount: '存在不完整事实',
partialCount: '存在不完整媒体信息',
degradedCount: '候选结果降级',
},
columns: {
@@ -3069,6 +3075,7 @@ export default {
storageSaveSuccess: '存储设置保存成功',
storageSaveFailed: '存储设置保存失败!',
classification: {
manage: '自动分类策略',
categoryLabel: '固定分类',
snapshotLabel: '分类路径快照',
snapshotPending: '保存后由服务端回填当前规范路径',
+73 -66
View File
@@ -555,10 +555,6 @@ export default {
title: '存儲 & 目錄',
description: '下載目錄、媒體庫目錄、整理、刮削',
},
classification: {
title: '自動分類',
description: '電影、電視劇和音樂的分類樹、資料源範圍與組合規則',
},
site: {
title: '站點',
description: '站點同步、站點數據刷新、站點重置',
@@ -1916,12 +1912,12 @@ export default {
setting: {
classification: {
title: '媒體自動分類',
description: '使用穩定分類 ID 和動態欄位,為電影、電視劇和音樂配置統一規則。',
description: '按分類名稱和媒體資訊,為電影、電視劇和音樂設定統一的自動分類規則。',
workspaceCategories: '分類樹',
workspaceRules: '規則',
workspaceSources: '來源',
workspaceReview: '驗證發佈',
revision: '活動版本 {revision}',
revision: '目前版本 {revision}',
unsaved: '有未儲存修改',
loading: '正在載入分類策略和欄位目錄...',
loadFailed: '分類策略載入失敗',
@@ -1932,37 +1928,47 @@ export default {
validationRequestFailed: '草稿校驗請求失敗',
validationIssues: '{count} 個校驗問題',
draftReset: '已恢復目前活動策略',
enrichmentTitle: '分類事實來源',
enrichmentHint: '僅在活動規則引用的標準事實缺失時呼叫已登記資料源;補充結果不會覆蓋主來源事實或改變媒體身份。',
enrichmentModeLabel: '跨來源補充',
enrichmentPrimaryOnly: '僅主來源',
enrichmentMissing: '補充缺失事實',
sourceFallbacks: '資料源專用兜底',
sourceFallbacksHint: '僅在對應資料源規則未命中時使用,儲存穩定分類 ID。',
enrichmentTitle: '分類資訊來源',
enrichmentHint:
'只有規則需要的媒體資訊缺失時,才會向已登記的資料源補充;補充結果不會覆蓋主要來源的資訊,也不會把媒體識別成另一筆記錄。',
enrichmentModeLabel: '是否補充缺少的資訊',
enrichmentPrimaryOnly: '只使用主要來源',
enrichmentMissing: '補充缺少的資訊',
sourceFallbacks: '按資料源設定預設分類',
sourceFallbacksHint: '當某個資料源沒有命中規則時,使用這裡設定的預設分類。',
source: '資料源',
sourceFallbackPanel: '{source} 來源兜底,已設定 {count} 項',
sourceFallbackPanel: '{source} 預設分類,已設定 {count} 項',
sourceFallbackConfigured: '已設定 {count} 項',
sourceFallbackEmpty: '未設定',
sourceFallbackFor: '{source} 的{mediaType}來源兜底',
analysisTitle: '驗證、預覽與版本控制',
analysisHint: '在發佈前檢查事實命中、估算近期樣本影響並審閱版本歷史。',
previewTab: '事實預覽',
sourceFallbackFor: '{source} 的{mediaType}預設分類',
sourceNames: {
imdb: 'IMDb',
tvdb: 'TVDB',
bilibili: '哔哩哔哩',
mangguodiscover: '芒果TV',
migu: '咪咕視頻',
tencentvideodiscover: '騰訊視頻',
iqiyi: '愛奇藝',
},
analysisTitle: '檢查、預覽與發佈',
analysisHint: '發佈前查看分類命中結果、變更影響並確認歷史版本。',
previewTab: '結果預覽',
impactTab: '影響分析',
publishTab: '發佈與歷史',
previewFailed: '分類預覽請求失敗',
impactFailed: '分類影響分析失敗',
publishSucceeded: '分類策略已發佈為 revision {revision}',
publishSucceeded: '分類規則已發佈為 {revision}',
publishFailed: '分類策略發佈失敗',
remoteReloaded: '已重新載入服務端活動策略',
remoteReloadFailed: '重新載入服務端策略失敗',
historyFailed: '分類策略歷史載入失敗',
rollbackSucceeded: 'revision {source} 已回滾並發佈為 revision {revision}',
rollbackSucceeded: ' {source} 版已恢復,並發佈為第 {revision}',
rollbackFailed: '分類策略回滾失敗',
directoryReferencesUnavailable: '目錄引用載入失敗',
directoryReferencesUnavailableHint: '目前無法顯示完整目錄引用保護;服務端仍會在校驗和發佈時阻止無效變更。',
category: {
title: '分類樹',
description: '分類路徑最多 {count} 級,規則、兜底和目錄配置始終引用穩定 ID。',
description: '分類路徑最多 {count} 級,規則和目錄設定會跟隨分類編號儲存。',
add: '新增{mediaType}分類',
mediaTypeSegments: '分類媒體類型',
editTitle: '編輯分類',
@@ -1970,9 +1976,9 @@ export default {
cancelEdit: '取消分類編輯',
save: '儲存分類',
name: '分類名稱',
stableId: '穩定 ID',
existingIdHint: '穩定 ID 建立後不修改,規則、兜底、目錄和歷史快照會持續引用該值',
newIdHint: '建立後規則、兜底、目錄和歷史快照引用',
stableId: '分類編號',
existingIdHint: '分類編號建立後不修改,規則、預設分類、目錄設定和歷史版本會用它識別這個分類',
newIdHint: '建立後規則、預設分類、目錄設定和歷史版本會使用這個編號',
path: '分類路徑',
pathHint: '使用 / 分隔層級,最多 {count} 級',
mediaType: '媒體類型',
@@ -1986,9 +1992,9 @@ export default {
pathUnset: '未設置路徑',
edit: '編輯分類「{name}」',
empty: '暫無{mediaType}分類',
fallbackTitle: '回退分類',
fallbackHint: '未命中規則時按媒體類型選擇分類,儲存值為穩定 ID。',
fallbackFor: '{mediaType}回退分類',
fallbackTitle: '未命中時的預設分類',
fallbackHint: '沒有規則命中時,按媒體類型使用這裡設定的分類。',
fallbackFor: '{mediaType}預設分類',
pathRequired: '分類路徑不能為空',
pathEmptySegment: '分類路徑不能包含空層級',
pathTooDeep: '分類路徑最多支援 {count} 級',
@@ -2004,50 +2010,50 @@ export default {
editingStatus: '正在編輯分類「{name}」',
cancelledStatus: '已取消分類編輯',
nameRequired: '分類名稱不能為空',
idRequired: '穩定 ID 不能為空',
idDuplicate: '穩定 ID 「{id}」已存在',
idRequired: '分類編號不能為空',
idDuplicate: '分類編號「{id}」已存在',
protectedMutationBlocked: '被引用的分類不能停用或改變媒體類型,請先清理引用',
updatedStatus: '已更新分類「{name}」',
deletedStatus: '已刪除分類「{name}」',
fallbackUpdatedStatus: '已更新{mediaType}回退分類',
fallbackClearedStatus: '已清除{mediaType}回退分類',
fallbackUpdatedStatus: '已更新{mediaType}預設分類',
fallbackClearedStatus: '已清除{mediaType}預設分類',
},
preview: {
title: '事實預覽與命中解釋',
description: '使用穩定媒體身分和欄位目錄建構事實,預覽不會修改活動策略或來源身分。',
run: '執行事實預覽',
modeLabel: '預覽策略',
draftPolicy: '草稿策略',
activePolicy: '活動策略',
factsTitle: '預覽事實',
title: '分類結果預覽與命中說明',
description: '填寫媒體資訊,預覽規則會將它分到哪裡。預覽不會修改目前已發佈的規則。',
run: '預覽分類結果',
modeLabel: '使用哪套規則',
draftPolicy: '目前編輯中的規則',
activePolicy: '已發佈的規則',
factsTitle: '媒體資訊',
mediaSource: '媒體來源',
mediaSourcePlaceholder: '例如 themoviedb、musicbrainz',
mediaId: '媒體 ID',
mediaIdPlaceholder: '來源內穩定 ID',
mediaId: '媒體編號',
mediaIdPlaceholder: '填寫資料源中的媒體編號',
mediaType: '媒體類型',
noEditableFields: '目前媒體類型沒有可編輯欄位。',
resultTitle: '預覽結果',
loading: '正在執行事實預覽',
emptyResult: '填寫事實並執行預覽後查看命中解釋。',
loading: '正在預覽分類結果',
emptyResult: '填寫媒體資訊並預覽後查看命中說明。',
status: '狀態',
policyRevision: '策略 revision',
recommended: '建議分類',
policyRevision: '規則版本',
recommended: '規則建議分類',
effective: '生效分類',
rule: '規則',
rule: '命中規則',
none: '無',
source: '來源',
labels: '標籤',
warnings: '警告',
warningField: '欄位 {field}',
warningSource: '來源 {source}',
trace: '規則命中解釋',
noRules: '沒有執行任何規則。',
trace: '規則命中說明',
noRules: '沒有檢查任何規則。',
matched: '命中',
notMatched: '未命中',
traceTableAria: '規則 {rule} 的條件命中解釋',
conditionMatched: '條件命中',
conditionNotMatched: '條件未命中',
noConditionTrace: '此規則沒有條件軌跡。',
noConditionTrace: '此規則沒有可顯示的命中資訊。',
root: '根',
missing: '未提供',
mediaTypes: {
@@ -2061,9 +2067,9 @@ export default {
no: '否',
},
support: {
partial: '目前來源僅部分支援',
unavailable: '目前來源不可用',
extension: '由目前來源擴充提供',
partial: '目前資料源只提供部分資訊',
unavailable: '目前資料源不提供這項資訊',
extension: '由目前資料源補充',
},
groups: {
sourceExtension: '來源擴充',
@@ -2081,8 +2087,8 @@ export default {
},
selectionSource: {
automatic: '規則命中',
sourceFallback: '來源備援',
fallback: '全域備援',
sourceFallback: '資料源預設分類',
fallback: '全域預設分類',
},
states: {
complete: '完整',
@@ -2096,14 +2102,14 @@ export default {
operator: '運算子',
expected: 'Expected',
actual: 'Actual',
factSource: '事實來源',
path: 'Path',
factSource: '資訊來源',
path: '分類路徑',
},
},
impact: {
title: '影響分析',
description: '使用有限樣本比較活動策略與目前草稿,不代表完整媒體庫的精確統計。',
sampleSource: 'sample_source: {source}{label}',
sampleSource: '樣本來源:{label}',
sampleLimit: '最大樣本數',
exampleLimit: '變更範例上限',
analyzeAria: '分析目前分類草稿的影響',
@@ -2111,8 +2117,8 @@ export default {
scope: '最多比較 200 筆樣本並傳回 50 筆變更範例;分析只會執行唯讀求值,不會移動檔案或修改歷史。',
loading: '正在產生有界樣本估算…',
metadataAria: '影響分析中繼資料',
baselineRevision: '活動 revision {revision}',
candidateRevision: '候選 revision {revision}',
baselineRevision: '目前版本 {revision}',
candidateRevision: '待發佈版本 {revision}',
sampledAt: '取樣於 {time}',
overviewTitle: '有界估算摘要',
truncated: '本次結果因樣本掃描範圍或變更範例上限而截斷,不應將未顯示的記錄推斷為沒有變更。',
@@ -2143,21 +2149,21 @@ export default {
yes: '是',
no: '否',
sampleSources: {
request: '請求內明確提供的事實',
recentHistory: '近期下載與整理歷史',
request: '本次輸入的媒體資訊',
recentHistory: '近期下載與整理記錄',
},
states: {
complete: '完整',
partial: '事實不完整',
notEvaluated: '未求值',
partial: '媒體資訊不完整',
notEvaluated: '未檢查',
invalidPolicy: '策略無效',
},
changedFields: {
categoryId: '分類 ID',
categoryId: '分類編號',
categoryPath: '分類路徑',
ruleId: '命中規則',
labels: '輸出標籤',
state: '求值狀態',
state: '檢查狀態',
},
metrics: {
requestedLimit: '請求上限',
@@ -2167,11 +2173,11 @@ export default {
sampleCount: '有效樣本',
changedCount: '發生變更',
unchangedCount: '維持不變',
categoryChangedCount: '穩定分類變更',
categoryChangedCount: '分類變更',
pathOnlyChangedCount: '僅路徑變更',
ruleChangedOnlyCount: '僅命中規則變更',
becameFallbackCount: '轉為備援分類',
partialCount: '存在不完整事實',
partialCount: '存在不完整媒體資訊',
degradedCount: '候選結果降級',
},
columns: {
@@ -3069,6 +3075,7 @@ export default {
storageSaveSuccess: '存儲設置保存成功',
storageSaveFailed: '存儲設置保存失敗!',
classification: {
manage: '自動分類策略',
categoryLabel: '固定分類',
snapshotLabel: '分類路徑快照',
snapshotPending: '保存後由服務端回填當前規範路徑',
+4 -5
View File
@@ -24,7 +24,6 @@ vi.mock('@/router/i18n-menu', () => ({
getSettingTabs: () => [
{ title: '系统', icon: 'mdi-server-network', tab: 'system' },
{ title: '目录', icon: 'mdi-folder', tab: 'directory' },
{ title: '分类', icon: 'mdi-file-tree', tab: 'classification' },
],
}))
@@ -65,10 +64,10 @@ describe('setting page', () => {
expect(activeTab.value).toBe('directory')
mocks.route.query.tab = 'classification'
await waitFor(() => expect(activeTab.value).toBe('classification'))
await waitFor(() => expect(activeTab.value).toBe('directory'))
mocks.route.query.tab = 'missing'
await waitFor(() => expect(activeTab.value).toBe('classification'))
await waitFor(() => expect(activeTab.value).toBe('directory'))
})
it('无效初始标签回退到第一个设置页', async () => {
@@ -78,10 +77,10 @@ describe('setting page', () => {
await waitFor(() => expect(registeredActiveTab().value).toBe('system'))
})
it('注册包含自动分类入口的设置标签,并保持标签值与窗口一致', async () => {
it('不再把自动分类注册为设置页一级标签', async () => {
await renderSettingPage()
expect(registeredSettingTabs()).toEqual(
expect(registeredSettingTabs()).not.toEqual(
expect.arrayContaining([expect.objectContaining({ tab: 'classification' })]),
)
})
-4
View File
@@ -12,9 +12,6 @@ const settingTabs = computed(() => getSettingTabs(t))
//
const AccountSettingSystem = defineAsyncComponent(() => import('@/views/setting/AccountSettingSystem.vue'))
const AccountSettingDirectory = defineAsyncComponent(() => import('@/views/setting/AccountSettingDirectory.vue'))
const AccountSettingClassification = defineAsyncComponent(
() => import('@/views/setting/AccountSettingClassification.vue'),
)
const AccountSettingSite = defineAsyncComponent(() => import('@/views/setting/AccountSettingSite.vue'))
const AccountSettingRule = defineAsyncComponent(() => import('@/views/setting/AccountSettingRule.vue'))
const AccountSettingSearch = defineAsyncComponent(() => import('@/views/setting/AccountSettingSearch.vue'))
@@ -26,7 +23,6 @@ const visitedTabs = ref(new Set<string>())
const settingTabComponents = [
{ value: 'system', component: AccountSettingSystem },
{ value: 'directory', component: AccountSettingDirectory },
{ value: 'classification', component: AccountSettingClassification },
{ value: 'site', component: AccountSettingSite },
{ value: 'rule', component: AccountSettingRule },
{ value: 'search', component: AccountSettingSearch },
-6
View File
@@ -211,12 +211,6 @@ export function getSettingTabs(t: Composer['t']): NavMenuTabItem[] {
tab: 'directory',
description: t('settingTabs.directory.description'),
},
{
title: t('settingTabs.classification.title'),
icon: 'mdi-file-tree',
tab: 'classification',
description: t('settingTabs.classification.description'),
},
{
title: t('settingTabs.site.title'),
icon: 'mdi-web',
@@ -13,7 +13,17 @@ describe('formatClassificationCategoryOptionTitle', () => {
name: '动画',
path: ['电影', '动画'],
}),
).toBe('动画 · 电影 / 动画')
).toBe('动画 · 电影')
})
it('removes a repeated category name from the end of a hierarchical path', () => {
expect(
formatClassificationCategoryOptionTitle({
id: 'movie.china',
name: '华语电影',
path: ['电影', '华语电影'],
}),
).toBe('华语电影 · 电影')
})
it('can preserve a caller-specific path separator and stable ID', () => {
@@ -22,7 +32,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', () => {
+5 -3
View File
@@ -7,13 +7,15 @@ interface ClassificationCategoryOptionTitleOptions {
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 pathSegments = [...category.path]
while (pathSegments[pathSegments.length - 1] === category.name) pathSegments.pop()
const path = pathSegments.join(options.pathSeparator ?? ' / ')
const displayPath = path || (category.path.length ? '' : (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 { useMediaSources } from '@/composables/useMediaSources'
import { formatClassificationCategoryOptionTitle } from '@/utils/mediaClassification'
import { cloneDeep, isEqual } from 'lodash-es'
import { useToast } from 'vue-toastification'
@@ -40,10 +41,13 @@ interface ClassificationImpactRequestEvent {
const props = withDefaults(
defineProps<{
active?: boolean
showClose?: boolean
}>(),
{ active: true },
{ active: true, showClose: false },
)
const emit = defineEmits<{ close: [] }>()
const { t } = useI18n()
const toast = useToast()
const initialized = ref(false)
@@ -58,6 +62,22 @@ 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',
bangumi: 'setting.cache.recognitionSource.bangumi',
anilist: 'setting.cache.recognitionSource.anilist',
imdb: 'setting.classification.sourceNames.imdb',
tvdb: 'setting.classification.sourceNames.tvdb',
musicbrainz: 'setting.cache.recognitionSource.musicbrainz',
theaudiodb: 'setting.cache.recognitionSource.theaudiodb',
doubanmusic: 'setting.cache.recognitionSource.doubanmusic',
bilibili: 'setting.classification.sourceNames.bilibili',
mangguodiscover: 'setting.classification.sourceNames.mangguodiscover',
migu: 'setting.classification.sourceNames.migu',
tencentvideodiscover: 'setting.classification.sourceNames.tencentvideodiscover',
iqiyi: 'setting.classification.sourceNames.iqiyi',
}
const {
activeRevision,
@@ -87,6 +107,7 @@ const {
rollback,
validateDraft,
} = useMediaClassification()
const { catalog: mediaSourceCatalog } = useMediaSources()
/** 服务端校验结果是否仍对应当前未发布草稿。 */
const validationIsCurrent = computed(
@@ -150,6 +171,14 @@ const availableSources = computed(() => {
return [...sources].sort((left, right) => left.localeCompare(right))
})
/** 将来源标识转换为后端注册名称,并为内置来源提供本地化兜底。 */
function sourceDisplayName(source: string): string {
const registeredSource = mediaSourceCatalog.value.find(item => item.media_source === source)
if (registeredSource?.name?.trim()) return registeredSource.name.trim()
const labelKey = builtinSourceLabelKeys[source]
return labelKey ? t(labelKey) : source
}
/** 按条件树顺序提取当前策略实际引用的字段 ID。 */
function collectConditionFieldIds(node: ClassificationConditionNode): string[] {
if ('field' in node) return [node.field]
@@ -228,11 +257,14 @@ function fallbackCategoryOptions(mediaType: ClassificationMediaType) {
}))
}
/** 将来源兜底标签和有界浮层参数传给 VSelect。 */
/** 将来源默认分类标签和有界浮层参数传给 VSelect。 */
function sourceFallbackMenuProps(source: string, mediaType: ClassificationMediaType) {
return {
activatorProps: {
'aria-label': t('setting.classification.sourceFallbackFor', { source, mediaType }),
'aria-label': t('setting.classification.sourceFallbackFor', {
source: sourceDisplayName(source),
mediaType,
}),
},
contentClass: 'classification-source-fallback-menu',
maxHeight: 280,
@@ -456,7 +488,7 @@ watch(analysisTab, tab => {
</script>
<template>
<VCard class="classification-settings" variant="flat">
<VCard class="classification-settings" :class="{ 'classification-settings--dialog': showClose }" variant="flat">
<VCardItem>
<template #prepend>
<VAvatar color="primary" variant="tonal" size="40">
@@ -466,6 +498,7 @@ watch(analysisTab, tab => {
<VCardTitle>{{ t('setting.classification.title') }}</VCardTitle>
<VCardSubtitle>{{ t('setting.classification.description') }}</VCardSubtitle>
<template #append>
<div class="classification-settings__header-actions">
<div class="classification-settings__status">
<VChip size="small" variant="tonal" prepend-icon="mdi-source-branch">
{{ t('setting.classification.revision', { revision: activeRevision }) }}
@@ -474,6 +507,14 @@ watch(analysisTab, tab => {
{{ t('setting.classification.unsaved') }}
</VChip>
</div>
<VBtn
v-if="showClose"
icon="mdi-close"
variant="text"
:aria-label="t('common.close')"
@click="emit('close')"
/>
</div>
</template>
</VCardItem>
@@ -603,14 +644,14 @@ watch(analysisTab, tab => {
class="classification-settings__source-title"
:aria-label="
t('setting.classification.sourceFallbackPanel', {
source,
source: sourceDisplayName(source),
count: sourceFallbackCount(source),
})
"
>
<span class="classification-settings__source-name">
<VIcon icon="mdi-database-outline" size="18" />
<code>{{ source }}</code>
<span>{{ sourceDisplayName(source) }}</span>
</span>
<VChip size="x-small" variant="tonal">
{{
@@ -630,7 +671,12 @@ watch(analysisTab, tab => {
:model-value="draftPolicy.source_fallbacks[source]?.[mediaType] ?? null"
:items="fallbackCategoryOptions(mediaType)"
:label="mediaType"
:aria-label="t('setting.classification.sourceFallbackFor', { source, mediaType })"
:aria-label="
t('setting.classification.sourceFallbackFor', {
source: sourceDisplayName(source),
mediaType,
})
"
:menu-props="sourceFallbackMenuProps(source, mediaType)"
density="compact"
variant="outlined"
@@ -764,6 +810,16 @@ watch(analysisTab, tab => {
background: transparent;
}
.classification-settings--dialog {
block-size: 100%;
}
.classification-settings__header-actions {
display: flex;
align-items: flex-start;
gap: 0.5rem;
}
.classification-settings__status {
display: flex;
flex-wrap: wrap;
+18 -6
View File
@@ -12,12 +12,9 @@ import { storageAttributes } from '@/api/constants'
import { useSilentSettingRefresh } from '@/composables/useSilentSettingRefresh'
import { configureAceEditorPadding } from '@/utils/aceEditor'
import { useMediaClassification } from '@/composables/useMediaClassification'
import { useRoute, useRouter } from 'vue-router'
const { t } = useI18n()
const { global: globalTheme } = useTheme()
const route = useRoute()
const router = useRouter()
const props = defineProps({
active: {
@@ -28,6 +25,9 @@ const props = defineProps({
//
const Draggable = defineAsyncComponent(() => import('vuedraggable').then(module => module.default))
const AccountSettingClassification = defineAsyncComponent(() =>
import('@/views/setting/AccountSettingClassification.vue').then(module => module.default),
)
//
const directories = ref<TransferDirectoryConf[]>([])
@@ -36,6 +36,8 @@ const directories = ref<TransferDirectoryConf[]>([])
const storages = ref<StorageConf[]>([])
const { activePolicy, refreshPolicy } = useMediaClassification()
const classificationDialogOpen = ref(false)
const classificationDialogMounted = ref(false)
//
const mediaCategories = computed<ClassificationCategory[]>(() =>
@@ -98,9 +100,10 @@ const renameEditorOptions = {
showGutter: true,
}
/** 切换到统一自动分类设置页,并保留当前路由的其它查询参数。 */
/** 打开目录页内的全屏自动分类编辑器,并保留已经打开过的草稿状态。 */
function openClassificationSettings(): void {
void router.push({ query: { ...route.query, tab: 'classification' } })
classificationDialogMounted.value = true
classificationDialogOpen.value = true
}
const movieRenameFormat = computed({
@@ -458,7 +461,7 @@ useSilentSettingRefresh(loadPageData, {
</VBtn>
<VSpacer />
<VBtn color="info" variant="tonal" prepend-icon="mdi-file-tree" @click="openClassificationSettings">
{{ t('settingTabs.classification.title') }}
{{ t('setting.directory.classification.manage') }}
</VBtn>
</div>
</VForm>
@@ -578,6 +581,15 @@ useSilentSettingRefresh(loadPageData, {
</VCard>
</VCol>
</VRow>
<VDialog v-model="classificationDialogOpen" fullscreen scrollable class="classification-settings-dialog">
<AccountSettingClassification
v-if="classificationDialogMounted"
:active="classificationDialogOpen"
show-close
@close="classificationDialogOpen = false"
/>
</VDialog>
</template>
<style scoped>
@@ -400,7 +400,7 @@ describe('AccountSettingClassification', () => {
const state = mocks.useMediaClassification.mock.results[0].value
expect(state.draftPolicy.value.enrichment_mode).toBe('primary_only')
await user.click(screen.getByRole('button', { name: '补充缺失事实' }))
await user.click(screen.getByRole('button', { name: '补充缺少的信息' }))
expect(state.draftPolicy.value.enrichment_mode).toBe('enrich_missing')
expect(state.isDirty.value).toBe(true)
@@ -436,12 +436,12 @@ describe('AccountSettingClassification', () => {
await openWorkspace('来源')
await user.click(
screen.getByRole('button', {
name: 'musicbrainz 来源兜底,已置 0 项',
name: 'MusicBrainz 默认分类,已置 0 项',
}),
)
const musicbrainzFallback = screen.getByRole('combobox', {
name: 'musicbrainz 的电影来源兜底',
name: 'MusicBrainz 的电影默认分类',
})
await user.click(musicbrainzFallback)
await user.click(await screen.findByRole('option', { name: '电影' }))
@@ -455,13 +455,13 @@ describe('AccountSettingClassification', () => {
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()
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()
await user.click(screen.getByRole('button', { name: 'TheMovieDb 默认分类,已置 1 项' }))
expect(screen.queryByRole('combobox', { name: 'MusicBrainz 的电影默认分类' })).not.toBeInTheDocument()
expect(screen.getByRole('combobox', { name: 'TheMovieDb 的电影默认分类' })).toBeVisible()
})
it('maps fact preview modes and bounded impact options to the composable', async () => {
@@ -568,7 +568,7 @@ describe('AccountSettingClassification', () => {
await user.click(screen.getByRole('button', { name: 'control-publish' }))
await waitFor(() => expect(mocks.publishDraft).toHaveBeenCalledTimes(1))
expect(mocks.toastSuccess).toHaveBeenCalledWith('分类策略已发布为 revision 7')
expect(mocks.toastSuccess).toHaveBeenLastCalledWith('分类规则已发布为第 7 版')
mocks.refreshPolicy.mockClear()
mocks.analyzeImpact.mockClear()
@@ -583,6 +583,6 @@ describe('AccountSettingClassification', () => {
await screen.findByRole('region', { name: 'policy-control-panel' })
await user.click(screen.getByRole('button', { name: 'control-rollback' }))
await waitFor(() => expect(mocks.rollback).toHaveBeenCalledWith(3))
expect(mocks.toastSuccess).toHaveBeenCalledWith('revision 3 已回滚并发布为 revision 7')
expect(mocks.toastSuccess).toHaveBeenLastCalledWith('第 3 版已恢复,并发布为第 7 版')
})
})
@@ -10,7 +10,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
apiGet: vi.fn(),
apiPost: vi.fn(),
routerPush: vi.fn(),
toastError: vi.fn(),
toastSuccess: vi.fn(),
useSilentSettingRefresh: vi.fn(),
@@ -29,15 +28,6 @@ vi.mock('@/composables/useSilentSettingRefresh', () => ({
useSilentSettingRefresh: mocks.useSilentSettingRefresh,
}))
vi.mock('vue-router', async importOriginal => {
const actual = await importOriginal<typeof import('vue-router')>()
return {
...actual,
useRoute: () => ({ query: { section: 'directories' } }),
useRouter: () => ({ push: mocks.routerPush }),
}
})
vi.mock('@/components/cards/DirectoryCard.vue', async () => {
const { defineComponent } = await import('vue')
return {
@@ -83,6 +73,35 @@ vi.mock('@/components/cards/StorageCard.vue', async () => {
}
})
vi.mock('@/views/setting/AccountSettingClassification.vue', async () => {
const { defineComponent } = await import('vue')
return {
default: defineComponent({
name: 'AccountSettingClassificationStub',
props: {
active: { type: Boolean, default: false },
showClose: { type: Boolean, default: false },
},
emits: ['close'],
template: `
<section v-if="active" aria-label="classification-dialog-content">
<button v-if="showClose" aria-label="关闭自动分类" @click="$emit('close')">close</button>
</section>
`,
}),
}
})
const DialogStub = defineComponent({
name: 'VDialogStub',
props: {
modelValue: { type: Boolean, default: false },
fullscreen: { type: Boolean, default: false },
},
template:
'<div v-if="modelValue" data-testid="classification-dialog" :data-fullscreen="String(fullscreen)"><slot /></div>',
})
vi.mock('vuedraggable', async () => {
const { defineComponent, h } = await import('vue')
return {
@@ -210,7 +229,7 @@ function mockLoadedSettings(
async function renderDirectorySettings() {
return renderWithProviders(AccountSettingDirectory, {
global: { stubs: { VAceEditor: AceEditorStub } },
global: { stubs: { VAceEditor: AceEditorStub, VDialog: DialogStub } },
})
}
@@ -229,7 +248,6 @@ describe('AccountSettingDirectory', () => {
vi.spyOn(console, 'log').mockImplementation(() => {})
mocks.apiGet.mockReset()
mocks.apiPost.mockReset()
mocks.routerPush.mockReset().mockResolvedValue(undefined)
mocks.toastError.mockReset()
mocks.toastSuccess.mockReset()
mocks.useSilentSettingRefresh.mockReset()
@@ -244,6 +262,7 @@ describe('AccountSettingDirectory', () => {
await waitFor(() => expect(screen.getByTestId('category-count-目录1')).toHaveTextContent('2'))
expect(mocks.apiGet).toHaveBeenCalledWith('media/classification/policy')
expect(screen.getByRole('checkbox', { name: '挂载盘删除空目录' })).toBeChecked()
expect(screen.getByRole('button', { name: '自动分类策略' })).toBeInTheDocument()
expect(getRenameEditors().map(input => (input as HTMLTextAreaElement).value)).toEqual([
'{{ title }}',
'{{ artist }}',
@@ -256,6 +275,21 @@ describe('AccountSettingDirectory', () => {
expect(refreshOptions.active.value).toBe(false)
})
it('从目录页打开全屏自动分类弹窗并支持关闭', async () => {
const user = userEvent.setup()
await renderDirectorySettings()
await screen.findByText('目录1')
await user.click(screen.getByRole('button', { name: '自动分类策略' }))
const dialog = screen.getByTestId('classification-dialog')
expect(dialog).toHaveAttribute('data-fullscreen', 'true')
expect(screen.getByRole('region', { name: 'classification-dialog-content' })).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: '关闭自动分类' }))
expect(screen.queryByTestId('classification-dialog')).not.toBeInTheDocument()
})
it('adds a non-conflicting directory name, updates paths, and saves current priority order', async () => {
const user = userEvent.setup()
await renderDirectorySettings()
@@ -429,25 +463,4 @@ describe('AccountSettingDirectory', () => {
await user.click(getCard('整理 & 刮削').getByRole('button', { name: '保存' }))
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith('整理选项设置保存失败!'))
})
it('opens unified classification settings and reloads storage data after a card completes', async () => {
const user = userEvent.setup()
await renderDirectorySettings()
await screen.findByText('本地存储')
const directoryCard = getCard('目录')
await user.click(directoryCard.getByRole('button', { name: '自动分类' }))
expect(mocks.routerPush).toHaveBeenCalledWith({
query: { section: 'directories', tab: 'classification' },
})
const initialStorageLoads = mocks.apiGet.mock.calls.filter(
([url]) => url === 'system/setting/public/Storages',
).length
await user.click(screen.getByRole('button', { name: 'reload-本地存储' }))
await waitFor(() => {
expect(mocks.apiGet.mock.calls.filter(([url]) => url === 'system/setting/public/Storages')).toHaveLength(
initialStorageLoads + 1,
)
})
})
})