mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-09 01:36:45 +08:00
fix(classification): open rules from directory settings
This commit is contained in:
@@ -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
@@ -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
@@ -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
@@ -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: '保存後由服務端回填當前規範路徑',
|
||||
|
||||
@@ -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' })]),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user