From b67c6ee427a5c2f8b20ed9d9df5d9f58eb971c08 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Thu, 3 Sep 2026 11:44:36 +0800 Subject: [PATCH] feat(classification): add visual rule editor --- eslint-suppressions.json | 20 +- src/api/__tests__/mediaClassification.spec.ts | 185 ++++ src/api/mediaClassification.ts | 134 +++ src/api/mediaClassificationTypes.ts | 375 +++++++ src/api/types.ts | 56 +- src/components/cards/DirectoryCard.vue | 169 +++- src/components/cards/MediaInfoCard.vue | 4 +- src/components/cards/MusicCard.vue | 2 +- .../cards/__tests__/DirectoryCard.spec.ts | 130 +++ .../cards/__tests__/MediaInfoCard.spec.ts | 2 +- .../ClassificationCategoryEditor.vue | 764 +++++++++++++++ .../ClassificationConditionBuilder.vue | 760 +++++++++++++++ .../ClassificationImpactPanel.vue | 890 +++++++++++++++++ .../ClassificationPolicyControlPanel.vue | 682 +++++++++++++ .../ClassificationPreviewPanel.vue | 919 ++++++++++++++++++ .../ClassificationRuleEditor.vue | 635 ++++++++++++ .../ClassificationCategoryEditor.spec.ts | 209 ++++ .../ClassificationConditionBuilder.spec.ts | 453 +++++++++ .../ClassificationImpactPanel.spec.ts | 205 ++++ .../ClassificationPolicyControlPanel.spec.ts | 246 +++++ .../ClassificationPreviewPanel.spec.ts | 248 +++++ .../ClassificationRuleEditor.spec.ts | 254 +++++ src/components/dialog/CategoryEditDialog.vue | 652 ------------- .../__tests__/CategoryEditDialog.spec.ts | 62 -- .../__tests__/useMediaClassification.spec.ts | 345 +++++++ src/composables/useMediaClassification.ts | 350 +++++++ src/locales/en-US.ts | 376 ++++++- src/locales/zh-CN.ts | 361 ++++++- src/locales/zh-TW.ts | 361 ++++++- src/pages/__tests__/music-album.spec.ts | 2 +- src/pages/__tests__/music-detail.spec.ts | 2 +- src/pages/__tests__/music.spec.ts | 6 +- src/pages/__tests__/setting.spec.ts | 73 ++ src/pages/setting.vue | 24 +- src/router/i18n-menu.ts | 6 + src/views/discover/MusicAlbumView.vue | 4 +- src/views/discover/MusicDetailView.vue | 2 +- .../setting/AccountSettingClassification.vue | 802 +++++++++++++++ src/views/setting/AccountSettingDirectory.vue | 99 +- .../AccountSettingClassification.spec.ts | 497 ++++++++++ .../__tests__/AccountSettingDirectory.spec.ts | 138 ++- src/views/system/NameTestView.vue | 5 +- .../system/__tests__/NameTestView.spec.ts | 2 +- 43 files changed, 10634 insertions(+), 877 deletions(-) create mode 100644 src/api/__tests__/mediaClassification.spec.ts create mode 100644 src/api/mediaClassification.ts create mode 100644 src/api/mediaClassificationTypes.ts create mode 100644 src/components/cards/__tests__/DirectoryCard.spec.ts create mode 100644 src/components/classification/ClassificationCategoryEditor.vue create mode 100644 src/components/classification/ClassificationConditionBuilder.vue create mode 100644 src/components/classification/ClassificationImpactPanel.vue create mode 100644 src/components/classification/ClassificationPolicyControlPanel.vue create mode 100644 src/components/classification/ClassificationPreviewPanel.vue create mode 100644 src/components/classification/ClassificationRuleEditor.vue create mode 100644 src/components/classification/__tests__/ClassificationCategoryEditor.spec.ts create mode 100644 src/components/classification/__tests__/ClassificationConditionBuilder.spec.ts create mode 100644 src/components/classification/__tests__/ClassificationImpactPanel.spec.ts create mode 100644 src/components/classification/__tests__/ClassificationPolicyControlPanel.spec.ts create mode 100644 src/components/classification/__tests__/ClassificationPreviewPanel.spec.ts create mode 100644 src/components/classification/__tests__/ClassificationRuleEditor.spec.ts delete mode 100644 src/components/dialog/CategoryEditDialog.vue delete mode 100644 src/components/dialog/__tests__/CategoryEditDialog.spec.ts create mode 100644 src/composables/__tests__/useMediaClassification.spec.ts create mode 100644 src/composables/useMediaClassification.ts create mode 100644 src/pages/__tests__/setting.spec.ts create mode 100644 src/views/setting/AccountSettingClassification.vue create mode 100644 src/views/setting/__tests__/AccountSettingClassification.spec.ts diff --git a/eslint-suppressions.json b/eslint-suppressions.json index fd6cc7b7..cb9b35de 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -100,9 +100,6 @@ } }, "src/components/cards/DirectoryCard.vue": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - }, "sonarjs/super-linear-regex": { "count": 1 }, @@ -176,14 +173,6 @@ "count": 1 } }, - "src/components/dialog/CategoryEditDialog.vue": { - "@typescript-eslint/ban-ts-comment": { - "count": 16 - }, - "@typescript-eslint/no-explicit-any": { - "count": 5 - } - }, "src/components/dialog/ContentToggleSettingsDialog.vue": { "@typescript-eslint/no-explicit-any": { "count": 1 @@ -615,11 +604,6 @@ "count": 2 } }, - "src/pages/setting.vue": { - "@typescript-eslint/no-unused-vars": { - "count": 1 - } - }, "src/plugins/i18n.ts": { "@typescript-eslint/no-explicit-any": { "count": 2 @@ -787,7 +771,7 @@ }, "src/views/setting/AccountSettingDirectory.vue": { "@typescript-eslint/no-explicit-any": { - "count": 4 + "count": 3 }, "sonarjs/super-linear-regex": { "count": 1 @@ -893,4 +877,4 @@ "count": 1 } } -} \ No newline at end of file +} diff --git a/src/api/__tests__/mediaClassification.spec.ts b/src/api/__tests__/mediaClassification.spec.ts new file mode 100644 index 00000000..cf4b7afa --- /dev/null +++ b/src/api/__tests__/mediaClassification.spec.ts @@ -0,0 +1,185 @@ +import { AxiosHeaders, type AxiosResponse, type InternalAxiosRequestConfig } from 'axios' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ApiRequestError } from '@/api/client' +import type { ApiResponse } from '@/api/types' +import { + analyzeClassificationImpact, + getClassificationFields, + getClassificationHistory, + getClassificationPolicy, + getClassificationRevisionConflict, + getClassificationValidationFailure, + previewClassificationPolicy, + publishClassificationPolicy, + rollbackClassificationPolicy, + validateClassificationPolicy, + type ClassificationFacts, + type ClassificationPolicy, + type ClassificationRevisionConflict, + type ClassificationValidationResult, +} from '@/api/mediaClassification' + +const mocks = vi.hoisted(() => ({ + get: vi.fn(), + post: vi.fn(), + put: vi.fn(), +})) + +vi.mock('@/api', () => ({ + default: mocks, +})) + +function createPolicy(revision = 1): ClassificationPolicy { + return { + schema_version: 2, + revision, + mode: 'first_match', + enrichment_mode: 'primary_only', + categories: [{ id: 'movie', media_type: '电影', name: '电影', path: ['电影'], enabled: true, labels: [] }], + rules: [ + { + id: 'movie-rule', + name: '电影规则', + kind: 'category', + enabled: true, + priority: 0, + media_types: ['电影'], + sources: [], + when: { field: 'media.type', operator: 'equals', value: '电影' }, + target: { category_id: 'movie', labels: [] }, + }, + ], + fallbacks: { 电影: 'movie' }, + source_fallbacks: {}, + field_aliases: {}, + updated_at: '2026-09-02T00:00:00Z', + } +} + +function createFacts(): ClassificationFacts { + return { + identity: { media_source: 'themoviedb', media_id: '1' }, + media: { type: '电影', title: '示例电影' }, + extensions: {}, + field_sources: {}, + } +} + +function createHttpError(status: number, payload: ApiResponse): ApiRequestError> { + const config = { headers: new AxiosHeaders() } as InternalAxiosRequestConfig + const response: AxiosResponse> = { + config, + data: payload, + headers: new AxiosHeaders(), + status, + statusText: String(status), + } + return new ApiRequestError(payload.message, { payload, response }) +} + +describe('media classification API', () => { + beforeEach(() => { + mocks.get.mockReset() + mocks.post.mockReset() + mocks.put.mockReset() + }) + + it('覆盖 policy、fields、history 三个读取端点', async () => { + mocks.get.mockResolvedValue({ ok: true }) + + await getClassificationPolicy() + await getClassificationFields() + await getClassificationHistory() + + expect(mocks.get.mock.calls).toEqual([ + ['media/classification/policy', { feedback: 'silent' }], + ['media/classification/fields', { feedback: 'silent' }], + ['media/classification/history', { feedback: 'silent' }], + ]) + }) + + it('覆盖发布、校验、预览、影响和回滚写端点', async () => { + const policy = createPolicy() + const facts = createFacts() + mocks.put.mockResolvedValue(policy) + mocks.post.mockResolvedValue({ ok: true }) + + await publishClassificationPolicy({ expected_revision: 1, policy }) + await validateClassificationPolicy({ policy }) + await previewClassificationPolicy({ input: { kind: 'facts', facts }, policy }) + await analyzeClassificationImpact({ + expected_revision: 1, + policy, + sample_limit: 30, + example_limit: 5, + samples: [facts], + }) + await rollbackClassificationPolicy(7, { expected_revision: 8 }) + + expect(mocks.put).toHaveBeenCalledWith( + 'media/classification/policy', + { expected_revision: 1, policy }, + { feedback: 'silent' }, + ) + expect(mocks.post.mock.calls).toEqual([ + ['media/classification/validate', { policy }, { feedback: 'silent' }], + ['media/classification/preview', { input: { kind: 'facts', facts }, policy }, { feedback: 'silent' }], + [ + 'media/classification/impact', + { expected_revision: 1, policy, sample_limit: 30, example_limit: 5, samples: [facts] }, + { feedback: 'silent' }, + ], + ['media/classification/rollback/7', { expected_revision: 8 }, { feedback: 'silent' }], + ]) + }) + + it('从 409 错误中保留结构化 revision 冲突 data', () => { + const data: ClassificationRevisionConflict = { + code: 'classification_revision_conflict', + expected_revision: 3, + current_revision: 4, + } + const error = createHttpError(409, { success: false, message: 'revision 冲突', data }) + + expect(getClassificationRevisionConflict(error)).toEqual(data) + expect(getClassificationValidationFailure(error)).toBeNull() + }) + + it('从 422 错误中保留完整校验路径和问题代码', () => { + const data: ClassificationValidationResult = { + valid: false, + issues: [ + { + severity: 'error', + code: 'unknown_field', + message: '字段不存在', + path: ['rules', 0, 'when', 'field'], + }, + ], + } + const error = createHttpError(422, { success: false, message: '校验失败', data }) + + expect(getClassificationValidationFailure(error)).toEqual(data) + expect(getClassificationRevisionConflict(error)).toBeNull() + }) + + it('拒绝错误状态或非标准 envelope 中的伪结构化数据', () => { + const data: ClassificationRevisionConflict = { + code: 'classification_revision_conflict', + expected_revision: 1, + current_revision: 2, + } + + expect( + getClassificationRevisionConflict(createHttpError(422, { success: false, message: '错误状态', data })), + ).toBeNull() + expect( + getClassificationRevisionConflict( + new ApiRequestError('坏响应', { + payload: { message: '缺少标准 envelope', data }, + response: { status: 409 } as AxiosResponse, + }), + ), + ).toBeNull() + }) +}) diff --git a/src/api/mediaClassification.ts b/src/api/mediaClassification.ts new file mode 100644 index 00000000..2f254396 --- /dev/null +++ b/src/api/mediaClassification.ts @@ -0,0 +1,134 @@ +import api from '@/api' +import type { ApiResponse } from '@/api/types' +import { ApiRequestError, isApiResponse } from './client' +import type { + ClassificationEvaluation, + ClassificationFieldCatalog, + ClassificationImpactAnalysis, + ClassificationImpactRequest, + ClassificationPolicy, + ClassificationPolicyHistory, + ClassificationPolicyPublishRequest, + ClassificationPolicyRollbackRequest, + ClassificationPolicyRollbackResult, + ClassificationPolicyValidateRequest, + ClassificationPreviewRequest, + ClassificationRevisionConflict, + ClassificationValidationResult, +} from './mediaClassificationTypes' + +const CLASSIFICATION_API_BASE = 'media/classification' + +/** 读取当前活动分类策略。 */ +export function getClassificationPolicy(): Promise { + return api.get(`${CLASSIFICATION_API_BASE}/policy`, { feedback: 'silent' }) +} + +/** 以 CAS revision 校验并发布完整分类策略。 */ +export function publishClassificationPolicy( + request: ClassificationPolicyPublishRequest, +): Promise { + return api.put( + `${CLASSIFICATION_API_BASE}/policy`, + request, + { feedback: 'silent' }, + ) +} + +/** 读取动态字段能力目录和服务端编辑限制。 */ +export function getClassificationFields(): Promise { + return api.get(`${CLASSIFICATION_API_BASE}/fields`, { feedback: 'silent' }) +} + +/** 使用与发布相同的规则校验完整策略草稿。 */ +export function validateClassificationPolicy( + request: ClassificationPolicyValidateRequest, +): Promise { + return api.post( + `${CLASSIFICATION_API_BASE}/validate`, + request, + { feedback: 'silent' }, + ) +} + +/** 对显式事实执行活动策略或未发布草稿并返回命中解释。 */ +export function previewClassificationPolicy(request: ClassificationPreviewRequest): Promise { + return api.post( + `${CLASSIFICATION_API_BASE}/preview`, + request, + { feedback: 'silent' }, + ) +} + +/** 估算未发布草稿对显式或近期历史样本的影响。 */ +export function analyzeClassificationImpact( + request: ClassificationImpactRequest, +): Promise { + return api.post( + `${CLASSIFICATION_API_BASE}/impact`, + request, + { feedback: 'silent' }, + ) +} + +/** 读取当前 revision 及最近的历史策略快照。 */ +export function getClassificationHistory(): Promise { + return api.get(`${CLASSIFICATION_API_BASE}/history`, { feedback: 'silent' }) +} + +/** 将指定历史策略内容发布为新的单调 revision。 */ +export function rollbackClassificationPolicy( + revision: number, + request: ClassificationPolicyRollbackRequest, +): Promise { + return api.post< + ClassificationPolicyRollbackResult, + ClassificationPolicyRollbackResult, + ClassificationPolicyRollbackRequest + >(`${CLASSIFICATION_API_BASE}/rollback/${encodeURIComponent(revision)}`, request, { feedback: 'silent' }) +} + +/** 从指定 HTTP 状态的标准错误 envelope 中安全提取结构化 data。 */ +function getStructuredErrorData(error: unknown, status: number, guard: (value: unknown) => value is T): T | null { + if (!(error instanceof ApiRequestError) || error.status !== status) return null + + const payload = error.payload ?? error.response?.data + if (!isApiResponse(payload) || !guard(payload.data)) return null + return payload.data +} + +/** 判断未知值是否是 revision 冲突详情。 */ +function isRevisionConflict(value: unknown): value is ClassificationRevisionConflict { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false + const record = value as Record + return ( + record.code === 'classification_revision_conflict' && + typeof record.expected_revision === 'number' && + typeof record.current_revision === 'number' + ) +} + +/** 判断未知值是否是完整的策略校验结果。 */ +function isValidationResult(value: unknown): value is ClassificationValidationResult { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false + const record = value as Record + return typeof record.valid === 'boolean' && Array.isArray(record.issues) +} + +/** 从 409 ApiRequestError 中读取并保留 revision 冲突详情。 */ +export function getClassificationRevisionConflict(error: unknown): ClassificationRevisionConflict | null { + return getStructuredErrorData(error, 409, isRevisionConflict) +} + +/** 从 422 ApiRequestError 中读取并保留完整字段路径校验结果。 */ +export function getClassificationValidationFailure(error: unknown): ClassificationValidationResult | null { + return getStructuredErrorData(error, 422, isValidationResult) +} + +/** 分类接口的 409 标准错误 envelope。 */ +export type ClassificationRevisionConflictEnvelope = ApiResponse + +/** 分类接口的 422 标准错误 envelope。 */ +export type ClassificationValidationFailureEnvelope = ApiResponse + +export type * from './mediaClassificationTypes' diff --git a/src/api/mediaClassificationTypes.ts b/src/api/mediaClassificationTypes.ts new file mode 100644 index 00000000..c4185a3a --- /dev/null +++ b/src/api/mediaClassificationTypes.ts @@ -0,0 +1,375 @@ +/** 分类体系支持的媒体类型。 */ +export type ClassificationMediaType = '电影' | '电视剧' | '音乐' + +/** 分类规则输出类型。 */ +export type ClassificationRuleKind = 'category' | 'label' + +/** 主分类规则求值模式。 */ +export type ClassificationPolicyMode = 'first_match' + +/** 分类前是否允许已登记数据源补充缺失的标准事实。 */ +export type ClassificationEnrichmentMode = 'primary_only' | 'enrich_missing' + +/** 数据源对分类字段的支持等级。 */ +export type ClassificationSourceSupport = 'native' | 'derived' | 'partial' | 'extension' | 'unavailable' + +/** 条件叶子支持的操作符。 */ +export type ClassificationOperator = + | 'equals' + | 'not_equals' + | 'in' + | 'not_in' + | 'contains' + | 'starts_with' + | 'ends_with' + | 'gt' + | 'gte' + | 'lt' + | 'lte' + | 'between' + | 'contains_any' + | 'contains_all' + | 'contains_none' + | 'is_true' + | 'is_false' + | 'exists' + | 'not_exists' + +/** 分类事实允许的 JSON 标量。 */ +export type ClassificationFactScalar = string | number | boolean | null + +/** 分类事实和条件值允许的形状。 */ +export type ClassificationFactValue = ClassificationFactScalar | ClassificationFactScalar[] + +/** 动态字段对应的输入控件和值语义。 */ +export type ClassificationFieldValueType = 'string' | 'enum' | 'integer' | 'number' | 'year' | 'string_list' | 'boolean' + +/** 分类结果的求值状态。 */ +export type ClassificationResultState = 'complete' | 'partial' | 'not_evaluated' | 'invalid_policy' + +/** 稳定分类定义。 */ +export interface ClassificationCategory { + id: string + media_type: ClassificationMediaType + name: string + path: string[] + enabled: boolean + labels: string[] +} + +/** 条件树叶子节点。 */ +export interface ClassificationCondition { + field: string + operator: ClassificationOperator + value?: ClassificationFactValue +} + +/** 递归条件组,每个节点只能声明 all、any、not 中的一种。 */ +export interface ClassificationConditionGroup { + all?: ClassificationConditionNode[] | null + any?: ClassificationConditionNode[] | null + not?: ClassificationConditionNode | null +} + +/** 条件树节点。 */ +export type ClassificationConditionNode = ClassificationCondition | ClassificationConditionGroup + +/** 规则命中后的主分类和标签输出。 */ +export interface ClassificationTarget { + category_id?: string | null + labels: string[] +} + +/** 全局有序的分类规则。 */ +export interface ClassificationRule { + id: string + name: string + kind: ClassificationRuleKind + enabled: boolean + priority: number + media_types: ClassificationMediaType[] + sources: string[] + when: ClassificationConditionNode + target: ClassificationTarget +} + +/** 可版本化发布的完整分类策略。 */ +export interface ClassificationPolicy { + schema_version: 2 + revision: number + mode: ClassificationPolicyMode + enrichment_mode: ClassificationEnrichmentMode + categories: ClassificationCategory[] + rules: ClassificationRule[] + fallbacks: Partial> + source_fallbacks: Record>> + field_aliases: Record> + updated_at?: string | null +} + +/** 活动策略及其有界历史快照。 */ +export interface ClassificationPolicyState { + active: ClassificationPolicy + history: ClassificationPolicy[] +} + +/** 分类事实中的稳定媒体身份。 */ +export interface ClassificationIdentityFacts { + media_source: string + media_id: string +} + +/** 电影、电视剧和音乐共享的标准事实。 */ +export interface ClassificationMediaFacts { + type: ClassificationMediaType + title?: string | null + year?: number | null + language?: string | null + countries?: string[] | null + genre_keys?: string[] | null + genre_names?: string[] | null + adult?: boolean | null + runtime?: number | null + content_rating?: string | null + companies?: string[] | null + networks?: string[] | null +} + +/** 音乐实体专用标准事实。 */ +export interface ClassificationMusicFacts { + entity_type?: string | null + album_type?: string | null + secondary_types?: string[] | null + genres?: string[] | null + tags?: string[] | null + artists?: string[] | null + artist_country?: string | null + release_status?: string | null +} + +/** 单个标准事实的可信数据源和提供者来源。 */ +export interface ClassificationFactSource { + media_source: string + provider_id: string + provider_name: string +} + +/** 规则求值器使用的完整标准事实。 */ +export interface ClassificationFacts { + identity: ClassificationIdentityFacts + media: ClassificationMediaFacts + music?: ClassificationMusicFacts | null + extensions: Record> + field_sources: Record +} + +/** 推荐或最终生效的分类选择快照。 */ +export interface ClassificationSelection { + category_id?: string | null + category_path: string[] + rule_id?: string | null + source?: string | null +} + +/** 分类求值产生的推荐、生效结果和状态。 */ +export interface ClassificationResult { + recommended?: ClassificationSelection | null + effective?: ClassificationSelection | null + labels: string[] + policy_revision: number + state: ClassificationResultState +} + +/** 单个条件叶子的求值记录。 */ +export interface ClassificationConditionTrace { + field: string + operator: ClassificationOperator + expected?: ClassificationFactValue + actual?: ClassificationFactValue + matched: boolean + path: (string | number)[] + source?: ClassificationFactSource | null +} + +/** 单条规则及其条件求值记录。 */ +export interface ClassificationRuleTrace { + rule_id: string + matched: boolean + conditions: ClassificationConditionTrace[] +} + +/** 预览中的结构化事实缺失或来源提示。 */ +export interface ClassificationEvaluationWarning { + code: string + message: string + path: (string | number)[] + field?: string | null + source?: string | null +} + +/** 分类预览的事实、结果和命中解释。 */ +export interface ClassificationEvaluation { + facts: ClassificationFacts + result: ClassificationResult + trace: ClassificationRuleTrace[] + warnings: ClassificationEvaluationWarning[] +} + +/** 动态枚举字段的稳定值和显示文本。 */ +export interface ClassificationFieldOption { + value: ClassificationFactScalar + label: string +} + +/** 动态条件编辑器使用的字段能力目录项。 */ +export interface ClassificationFieldDefinition { + id: string + label: string + group: string + description?: string | null + value_type: ClassificationFieldValueType + operators: ClassificationOperator[] + media_types: ClassificationMediaType[] + options: ClassificationFieldOption[] + allow_custom_values: boolean + source_support: Record +} + +/** 策略编辑器必须遵守的服务端结构限制。 */ +export interface ClassificationPolicyLimits { + max_category_depth: number + max_category_segment_length: number + max_category_path_length: number + max_condition_depth: number + max_conditions_per_rule: number + max_rules: number + max_total_conditions: number +} + +/** 标准字段、扩展字段和服务端限制目录。 */ +export interface ClassificationFieldCatalog { + fields: ClassificationFieldDefinition[] + limits: ClassificationPolicyLimits +} + +/** 单条策略校验错误或警告。 */ +export interface ClassificationValidationIssue { + severity: 'error' | 'warning' + code: string + message: string + path: (string | number)[] +} + +/** 完整策略校验结果。 */ +export interface ClassificationValidationResult { + valid: boolean + issues: ClassificationValidationIssue[] +} + +/** CAS 发布完整策略的请求。 */ +export interface ClassificationPolicyPublishRequest { + expected_revision: number + policy: ClassificationPolicy +} + +/** 回滚历史策略的 CAS 请求。 */ +export interface ClassificationPolicyRollbackRequest { + expected_revision: number +} + +/** 仅校验完整策略草稿的请求。 */ +export interface ClassificationPolicyValidateRequest { + policy: ClassificationPolicy +} + +/** 由调用方直接提供标准事实的预览输入。 */ +export interface ClassificationFactsPreviewInput { + kind: 'facts' + facts: ClassificationFacts +} + +/** 当前后端支持的可判别预览输入。 */ +export type ClassificationPreviewInput = ClassificationFactsPreviewInput + +/** 使用活动策略或未发布草稿执行预览的请求。 */ +export interface ClassificationPreviewRequest { + input: ClassificationPreviewInput + policy?: ClassificationPolicy | null +} + +/** 比较活动策略与草稿的有界影响分析请求。 */ +export interface ClassificationImpactRequest { + expected_revision: number + policy: ClassificationPolicy + sample_limit?: number + example_limit?: number + samples?: ClassificationFacts[] +} + +/** 单个样本在两版策略之间的分类变化。 */ +export interface ClassificationImpactChange { + identity: ClassificationIdentityFacts + media_type: ClassificationMediaType + title?: string | null + changed_fields: string[] + previous: ClassificationResult + candidate: ClassificationResult +} + +/** 按媒体类型和数据源聚合的影响统计。 */ +export interface ClassificationImpactGroup { + media_type: ClassificationMediaType + media_source: string + sampled: number + changed: number + degraded: number +} + +/** 有边界的近期样本分类影响统计。 */ +export interface ClassificationImpactAnalysis { + estimated: true + sampled_at: string + sample_source: 'request' | 'recent_history' + baseline_revision: number + candidate_revision: number + requested_limit: number + scanned_count: number + skipped_count: number + truncated: boolean + sample_count: number + changed_count: number + unchanged_count: number + category_changed_count: number + path_only_changed_count: number + rule_changed_only_count: number + became_fallback_count: number + partial_count: number + degraded_count: number + previous_categories: Record + candidate_categories: Record + groups: ClassificationImpactGroup[] + changes: ClassificationImpactChange[] + warnings: string[] +} + +/** 发布、影响分析或回滚遇到的 revision 冲突。 */ +export interface ClassificationRevisionConflict { + code: 'classification_revision_conflict' + expected_revision: number + current_revision: number +} + +/** 当前 revision 与有界历史完整策略列表。 */ +export interface ClassificationPolicyHistory { + active_revision: number + items: ClassificationPolicy[] +} + +/** 历史内容被发布为新 revision 后的结果。 */ +export interface ClassificationPolicyRollbackResult { + restored_from_revision: number + policy: ClassificationPolicy +} + +/** 分类接口可保留在错误 envelope 中的结构化业务数据。 */ +export type ClassificationStructuredErrorData = ClassificationRevisionConflict | ClassificationValidationResult diff --git a/src/api/types.ts b/src/api/types.ts index 49ca1a39..5ac06ed5 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -58,6 +58,23 @@ export interface SubscriptionBatchStatus { can_cancel: boolean } +/** 自动分类中的单个推荐或生效选择快照。 */ +export interface MediaClassificationSelection { + category_id?: string + category_path?: string[] + rule_id?: string + source?: string +} + +/** 媒体对象携带的分类结果快照。 */ +export interface MediaClassificationResult { + recommended?: MediaClassificationSelection + effective?: MediaClassificationSelection + labels?: string[] + policy_revision?: number + state?: 'complete' | 'partial' | 'not_evaluated' | 'invalid_policy' +} + // 手动刮削选项 export interface ManualScrapeOptions { // 媒体数据源 @@ -459,7 +476,13 @@ export interface MediaInfo { vote_average?: number // 描述 overview?: string - // 二级分类 + // 媒体库目录分类 + library_category?: string + // 数据源提供的描述性分类 + metadata_category?: string + // 本次分类结果快照 + classification?: MediaClassificationResult + // 媒体库目录分类兼容字段 category?: string // 详情页面 detail_link?: string @@ -535,6 +558,14 @@ export interface MediaInfo { album_id?: string // 专辑主类型:Album、EP、Single 等 album_type?: string + // 专辑副类型:Live、Compilation、Soundtrack 等 + secondary_types?: string[] + // 音乐标签 + tags?: string[] + // 艺术家国家或地区 + artist_country?: string + // 发行状态 + release_status?: string // 发行版本 version?: string // 音轨号 @@ -634,7 +665,13 @@ export interface MusicAlbumInfo { genres?: string[] // 标签 tags?: string[] + // 媒体库目录分类 + library_category?: string // 主类型与副类型组合文本 + metadata_category?: string + // 本次分类结果快照 + classification?: MediaClassificationResult + // 媒体库目录分类兼容字段 category?: string // 10 分制评分 rating?: number @@ -1943,6 +1980,8 @@ export interface TransferDirectoryConf { media_type?: string // 适用媒体类别 media_category?: string + // 适用媒体类别稳定 ID;media_category 仅保存服务端规范化路径快照 + media_category_id?: string | null // 下载类型子目录 download_type_folder?: boolean // 下载类别子目录 @@ -2478,18 +2517,3 @@ export interface ApiResponse { message: string data: T | null } - -// 分类规则 -export interface CategoryRule { - genre_ids?: string - original_language?: string - production_countries?: string - origin_country?: string - release_year?: string -} - -// 分类配置 -export interface CategoryConfig { - movie?: { [key: string]: CategoryRule } - tv?: { [key: string]: CategoryRule } -} diff --git a/src/components/cards/DirectoryCard.vue b/src/components/cards/DirectoryCard.vue index 922ee081..4ffc4c75 100644 --- a/src/components/cards/DirectoryCard.vue +++ b/src/components/cards/DirectoryCard.vue @@ -1,5 +1,6 @@ + + + + diff --git a/src/components/classification/ClassificationConditionBuilder.vue b/src/components/classification/ClassificationConditionBuilder.vue new file mode 100644 index 00000000..4c507bd7 --- /dev/null +++ b/src/components/classification/ClassificationConditionBuilder.vue @@ -0,0 +1,760 @@ + + + + + diff --git a/src/components/classification/ClassificationImpactPanel.vue b/src/components/classification/ClassificationImpactPanel.vue new file mode 100644 index 00000000..4769787f --- /dev/null +++ b/src/components/classification/ClassificationImpactPanel.vue @@ -0,0 +1,890 @@ + + + + + diff --git a/src/components/classification/ClassificationPolicyControlPanel.vue b/src/components/classification/ClassificationPolicyControlPanel.vue new file mode 100644 index 00000000..6f66ae89 --- /dev/null +++ b/src/components/classification/ClassificationPolicyControlPanel.vue @@ -0,0 +1,682 @@ + + + + + diff --git a/src/components/classification/ClassificationPreviewPanel.vue b/src/components/classification/ClassificationPreviewPanel.vue new file mode 100644 index 00000000..47d02ac2 --- /dev/null +++ b/src/components/classification/ClassificationPreviewPanel.vue @@ -0,0 +1,919 @@ + + + + + diff --git a/src/components/classification/ClassificationRuleEditor.vue b/src/components/classification/ClassificationRuleEditor.vue new file mode 100644 index 00000000..01a8de38 --- /dev/null +++ b/src/components/classification/ClassificationRuleEditor.vue @@ -0,0 +1,635 @@ + + + + + diff --git a/src/components/classification/__tests__/ClassificationCategoryEditor.spec.ts b/src/components/classification/__tests__/ClassificationCategoryEditor.spec.ts new file mode 100644 index 00000000..cc995a28 --- /dev/null +++ b/src/components/classification/__tests__/ClassificationCategoryEditor.spec.ts @@ -0,0 +1,209 @@ +import type { ClassificationCategory, ClassificationMediaType } from '@/api/mediaClassificationTypes' +import ClassificationCategoryEditor from '@/components/classification/ClassificationCategoryEditor.vue' +import { screen, waitFor, within } from '@testing-library/vue' +import userEvent from '@testing-library/user-event' +import { renderWithProviders } from '@tests/support/render' +import { describe, expect, it, vi } from 'vitest' + +/** 创建分类树编辑器测试使用的稳定分类。 */ +function createCategory( + id: string, + mediaType: ClassificationMediaType, + name: string, + path: string[], +): ClassificationCategory { + return { id, media_type: mediaType, name, path, enabled: true, labels: [] } +} + +const categories: ClassificationCategory[] = [ + createCategory('movie.scifi', '电影', '科幻电影', ['电影', '科幻']), + createCategory('tv.documentary', '电视剧', '纪录剧集', ['电视剧', '纪录']), + createCategory('music.lossless', '音乐', '无损音乐', ['音乐', '专辑', '无损']), +] + +/** 渲染编辑器并记录双向绑定事件。 */ +async function renderEditor( + overrides: { + categories?: ClassificationCategory[] + fallbacks?: Partial> + maxDepth?: number + referencedCategoryIds?: string[] + directoryReferences?: Array<{ categoryId: string; directoryNames: string[] }> + } = {}, +) { + const events = { + updateCategories: vi.fn(), + updateFallbacks: vi.fn(), + } + const result = await renderWithProviders(ClassificationCategoryEditor, { + props: { + categories: overrides.categories ?? categories, + fallbacks: overrides.fallbacks ?? {}, + maxDepth: overrides.maxDepth, + referencedCategoryIds: overrides.referencedCategoryIds, + directoryReferences: overrides.directoryReferences, + 'onUpdate:categories': events.updateCategories, + 'onUpdate:fallbacks': events.updateFallbacks, + }, + }) + return { ...result, events } +} + +describe('ClassificationCategoryEditor', () => { + it('按电影、电视剧和音乐分段展示稳定 ID 与多级路径', async () => { + const user = userEvent.setup() + await renderEditor() + + expect(screen.getByText('科幻电影')).toBeInTheDocument() + expect(screen.getByText('movie.scifi')).toBeInTheDocument() + expect(screen.getByRole('list', { name: '科幻电影分类路径' })).toHaveTextContent('电影科幻') + expect(screen.queryByText('纪录剧集')).not.toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: '电视剧' })) + expect(screen.getByText('纪录剧集')).toBeInTheDocument() + expect(screen.getByText('tv.documentary')).toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: '音乐' })) + expect(screen.getByText('无损音乐')).toBeInTheDocument() + expect(screen.getByText('music.lossless')).toBeInTheDocument() + expect(screen.getByRole('list', { name: '无损音乐分类路径' })).toHaveTextContent('音乐专辑无损') + }) + + it('新建时录入稳定 ID,并编辑既有分类的名称、路径、媒体类型和启停状态', 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: /分类路径/ }), '音乐/专辑/现场') + await user.click(screen.getByRole('button', { name: '保存分类' })) + + expect(events.updateCategories).toHaveBeenCalledOnce() + expect(events.updateCategories.mock.calls[0][0]).toEqual([ + ...categories, + { + id: 'music.live', + media_type: '音乐', + name: '现场专辑', + path: ['音乐', '专辑', '现场'], + enabled: true, + labels: [], + }, + ]) + expect(events.updateCategories.mock.calls[0][0]).not.toBe(categories) + expect(events.updateCategories.mock.calls[0][0][0].path).not.toBe(categories[0].path) + + 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 pathInput = screen.getByRole('textbox', { name: /分类路径/ }) + expect(idInput).toHaveValue('movie.scifi') + expect(idInput).toHaveAttribute('readonly') + await user.clear(nameInput) + await user.type(nameInput, '科幻剧集') + await user.clear(pathInput) + await user.type(pathInput, '电视剧/科幻') + await user.click(screen.getByRole('combobox', { name: '媒体类型' })) + await user.click(await screen.findByRole('option', { name: '电视剧' })) + await user.click(screen.getByRole('checkbox', { name: '启用分类' })) + await user.click(screen.getByRole('button', { name: '保存分类' })) + + expect(events.updateCategories).toHaveBeenCalledTimes(2) + expect(events.updateCategories.mock.calls[1][0][0]).toEqual({ + id: 'movie.scifi', + media_type: '电视剧', + name: '科幻剧集', + path: ['电视剧', '科幻'], + enabled: false, + labels: [], + }) + }) + + it('阻止删除规则或 fallback 引用的分类并给出可访问原因', async () => { + const user = userEvent.setup() + const { events } = await renderEditor({ + fallbacks: { 电影: 'movie.scifi' }, + referencedCategoryIds: ['movie.scifi'], + }) + + const row = screen.getByText('科幻电影').closest('[data-category-id="movie.scifi"]') + expect(row).not.toBeNull() + const deleteButton = within(row as HTMLElement).getByRole('button', { name: /不能删除“科幻电影”/ }) + const protection = within(row as HTMLElement).getByRole('note') + expect(deleteButton).toBeDisabled() + expect(deleteButton).toHaveAttribute('aria-describedby', protection.id) + expect(protection).toHaveTextContent('已被分类规则或来源兜底引用') + expect(protection).toHaveTextContent('已设为电影全局兜底分类') + + await user.click(deleteButton) + expect(events.updateCategories).not.toHaveBeenCalled() + }) + + it('目录引用保护媒体类型和启停状态,但允许修改名称与路径', async () => { + const user = userEvent.setup() + const { events } = await renderEditor({ + directoryReferences: [{ categoryId: 'movie.scifi', directoryNames: ['电影主目录', '归档目录'] }], + }) + + const row = screen.getByText('科幻电影').closest('[data-category-id="movie.scifi"]') + expect(row).not.toBeNull() + expect(within(row as HTMLElement).getByRole('note')).toHaveTextContent('已被目录配置引用:电影主目录、归档目录') + expect(within(row as HTMLElement).getByRole('button', { name: /不能删除“科幻电影”/ })).toBeDisabled() + + await user.click(within(row as HTMLElement).getByRole('button', { name: '编辑分类“科幻电影”' })) + const mediaType = screen.getByRole('combobox', { name: '媒体类型' }) + const enabled = screen.getByRole('checkbox', { name: '启用分类' }) + expect(mediaType).toHaveClass('v-field--disabled') + expect(enabled).toBeDisabled() + expect(screen.getByText('此分类正在被引用')).toBeInTheDocument() + + const nameInput = screen.getByRole('textbox', { name: /分类名称/ }) + const pathInput = screen.getByRole('textbox', { name: /分类路径/ }) + await user.clear(nameInput) + await user.type(nameInput, '科幻电影新版') + await user.clear(pathInput) + await user.type(pathInput, '电影/科幻/新版') + await user.click(screen.getByRole('button', { name: '保存分类' })) + + expect(events.updateCategories).toHaveBeenCalledOnce() + expect(events.updateCategories.mock.calls[0][0][0]).toMatchObject({ + id: 'movie.scifi', + media_type: '电影', + name: '科幻电影新版', + path: ['电影', '科幻', '新版'], + enabled: true, + }) + }) + + it('fallback 选择器提交稳定分类 ID 而不是名称或路径', async () => { + const user = userEvent.setup() + const { events } = await renderEditor() + + await user.click(screen.getByRole('combobox', { name: '音乐回退分类' })) + await user.click(await screen.findByRole('option', { name: /无损音乐.*music.lossless/ })) + + await waitFor(() => expect(events.updateFallbacks).toHaveBeenCalledWith({ 音乐: 'music.lossless' })) + }) + + it('路径超过最大深度时保留草稿并拒绝发出分类更新', async () => { + const user = userEvent.setup() + const { events } = await renderEditor({ maxDepth: 2 }) + + 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: /分类路径/ }), '电影/地区/华语') + await user.click(screen.getByRole('button', { name: '保存分类' })) + + const businessError = screen.getByTestId('classification-category-error') + expect(businessError).toHaveTextContent('分类路径最多支持 2 级') + 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(events.updateCategories).not.toHaveBeenCalled() + }) +}) diff --git a/src/components/classification/__tests__/ClassificationConditionBuilder.spec.ts b/src/components/classification/__tests__/ClassificationConditionBuilder.spec.ts new file mode 100644 index 00000000..810c6685 --- /dev/null +++ b/src/components/classification/__tests__/ClassificationConditionBuilder.spec.ts @@ -0,0 +1,453 @@ +import type { + ClassificationConditionNode, + ClassificationFieldDefinition, + ClassificationFieldValueType, + ClassificationMediaType, + ClassificationOperator, +} from '@/api/mediaClassificationTypes' +import ClassificationConditionBuilder from '@/components/classification/ClassificationConditionBuilder.vue' +import { fireEvent, screen, within } from '@testing-library/vue' +import { renderWithProviders } from '@tests/support/render' +import { Fragment, defineComponent, h, inject, provide, type InjectionKey, type PropType } from 'vue' +import { describe, expect, it } from 'vitest' + +type ToggleHandler = (value: unknown) => void + +const toggleHandlerKey: InjectionKey = Symbol('classification-condition-toggle') + +/** 把 Vuetify item 统一转换为测试可点击的标题和值。 */ +function normalizeItem(item: unknown): { title: string; value: unknown } { + if (typeof item === 'object' && item !== null && 'value' in item) { + const record = item as { title?: unknown; value: unknown } + return { title: String(record.title ?? record.value), value: record.value } + } + return { title: String(item), value: item } +} + +const SelectStub = defineComponent({ + name: 'VSelect', + inheritAttrs: false, + props: { + disabled: Boolean, + items: { type: Array as PropType, default: () => [] }, + label: String, + modelValue: { type: null as unknown as PropType, default: undefined }, + multiple: Boolean, + returnObject: Boolean, + }, + emits: ['update:modelValue'], + setup(props, { attrs, emit }) { + /** 模拟单选或多选,并保留目录选项的原始 JSON 类型。 */ + function selectValue(item: unknown): void { + if (props.disabled) return + const normalized = normalizeItem(item) + const value = props.returnObject ? item : normalized.value + if (!props.multiple) { + emit('update:modelValue', value) + return + } + const current = Array.isArray(props.modelValue) ? props.modelValue : [] + const exists = current.some(item => Object.is(item, value)) + emit('update:modelValue', exists ? current.filter(item => !Object.is(item, value)) : [...current, value]) + } + + return () => + h('fieldset', { ...attrs, 'aria-label': attrs['aria-label'] ?? props.label }, [ + h('legend', props.label), + ...props.items.map(item => { + const normalized = normalizeItem(item) + return h( + 'button', + { + type: 'button', + disabled: props.disabled, + onClick: () => selectValue(item), + }, + normalized.title, + ) + }), + ]) + }, +}) + +const ComboboxStub = defineComponent({ + name: 'VCombobox', + inheritAttrs: false, + props: { + label: String, + modelValue: { type: null as unknown as PropType, default: undefined }, + multiple: Boolean, + }, + emits: ['update:modelValue'], + setup(props, { attrs, emit }) { + /** 把任意 modelValue 转换为测试输入框可显示的字符串。 */ + function displayValue(value: unknown): string { + if (Array.isArray(value)) return value.map(item => String(item)).join(',') + return value === null || value === undefined ? '' : String(value) + } + + /** 将测试输入按控件是否多选转换为字符串或成员数组。 */ + function updateValue(event: Event): void { + const rawValue = (event.target as HTMLInputElement).value + emit( + 'update:modelValue', + props.multiple + ? rawValue + .split(',') + .map(item => item.trim()) + .filter(Boolean) + : rawValue, + ) + } + + return () => + h('label', { ...attrs }, [ + h('span', props.label), + h('input', { + 'aria-label': attrs['aria-label'] ?? props.label, + 'value': displayValue(props.modelValue), + onInput: updateValue, + }), + ]) + }, +}) + +const TextFieldStub = defineComponent({ + name: 'VTextField', + inheritAttrs: false, + props: { + label: String, + modelValue: { type: null as unknown as PropType, default: undefined }, + type: { type: String, default: 'text' }, + }, + emits: ['update:modelValue'], + setup(props, { attrs, emit }) { + return () => + h('label', { ...attrs }, [ + h('span', props.label), + h('input', { + 'aria-label': props.label, + 'type': props.type, + 'value': props.modelValue ?? '', + onInput: (event: Event) => emit('update:modelValue', (event.target as HTMLInputElement).value), + }), + ]) + }, +}) + +const ButtonToggleStub = defineComponent({ + name: 'VBtnToggle', + inheritAttrs: false, + props: { + disabled: Boolean, + }, + emits: ['update:modelValue'], + setup(props, { attrs, emit, slots }) { + /** 向内部按钮提供与 VBtnToggle 等价的值更新入口。 */ + provide(toggleHandlerKey, value => { + if (!props.disabled) emit('update:modelValue', value) + }) + return () => h('div', { ...attrs, role: 'group' }, slots.default?.()) + }, +}) + +const ButtonStub = defineComponent({ + name: 'VBtn', + inheritAttrs: false, + props: { + disabled: Boolean, + icon: { type: [String, Boolean] as PropType, default: false }, + value: { default: undefined }, + }, + emits: ['click'], + setup(props, { attrs, emit, slots }) { + const updateToggle = inject(toggleHandlerKey, undefined) + + /** 同时模拟普通图标按钮和分段控件按钮的点击行为。 */ + function click(event: MouseEvent): void { + if (props.disabled) return + if (props.value !== undefined) updateToggle?.(props.value) + emit('click', event) + } + + return () => + h( + 'button', + { ...attrs, type: 'button', disabled: props.disabled, onClick: click }, + slots.default?.() ?? (typeof props.icon === 'string' ? props.icon : ''), + ) + }, +}) + +const TooltipStub = defineComponent({ + name: 'VTooltip', + inheritAttrs: false, + props: { text: String }, + setup(props, { slots }) { + return () => h(Fragment, [slots.activator?.({ props: { title: props.text } }), slots.default?.()]) + }, +}) + +const PassThroughStub = defineComponent({ + inheritAttrs: false, + setup(_, { attrs, slots }) { + return () => h('div', attrs, slots.default?.()) + }, +}) + +const componentStubs = { + VAlert: PassThroughStub, + VBtn: ButtonStub, + VBtnToggle: ButtonToggleStub, + VChip: PassThroughStub, + VCombobox: ComboboxStub, + VIcon: PassThroughStub, + VSelect: SelectStub, + VTextField: TextFieldStub, + VTooltip: TooltipStub, +} + +/** 构造覆盖指定值类型的动态字段目录项。 */ +function fieldDefinition( + id: string, + label: string, + valueType: ClassificationFieldValueType, + operators: ClassificationOperator[], + options: ClassificationFieldDefinition['options'] = [], + mediaTypes: ClassificationMediaType[] = ['电影'], + allowCustomValues = true, +): ClassificationFieldDefinition { + return { + id, + label, + group: id.startsWith('music.') ? '音乐' : '媒体', + description: `${label}说明`, + value_type: valueType, + operators, + media_types: mediaTypes, + options, + allow_custom_values: allowCustomValues, + source_support: { + douban: 'unavailable', + musicbrainz: 'native', + themoviedb: 'partial', + }, + } +} + +const fields: ClassificationFieldDefinition[] = [ + fieldDefinition('media.title', '标题', 'string', ['equals', 'starts_with', 'exists']), + fieldDefinition( + 'media.rating', + '分级', + 'enum', + ['equals', 'not_equals', 'in'], + [ + { label: 'PG', value: 'PG' }, + { label: 'R', value: 'R' }, + ], + ['电影'], + false, + ), + fieldDefinition('media.runtime', '时长', 'integer', ['gt', 'between']), + fieldDefinition('media.score', '评分', 'number', ['gte', 'between']), + fieldDefinition('media.year', '年份', 'year', ['gte', 'between', 'exists'], [], ['电影', '电视剧']), + fieldDefinition('media.genres', '类型', 'string_list', ['contains_any', 'contains_all', 'exists']), + fieldDefinition('media.adult', '成人内容', 'boolean', ['equals', 'is_true', 'is_false', 'exists']), + fieldDefinition('music.tags', '音乐标签', 'string_list', ['contains_any'], [], ['音乐']), +] + +const defaultProps = { + fields, + mediaTypes: ['电影'] as ClassificationMediaType[], + sources: ['themoviedb', 'douban'], +} + +/** 渲染带生产插件和轻量输入控件的条件构建器。 */ +async function renderBuilder(modelValue: ClassificationConditionNode, overrides: Record = {}) { + return renderWithProviders(ClassificationConditionBuilder, { + props: { ...defaultProps, modelValue, ...overrides }, + global: { stubs: componentStubs }, + }) +} + +/** 读取最近一次受控节点更新。 */ +function latestModel(result: Awaited>): ClassificationConditionNode { + const updates = result.emitted()['update:modelValue'] as unknown[][] | undefined + return updates?.at(-1)?.[0] as ClassificationConditionNode +} + +describe('ClassificationConditionBuilder', () => { + it('按媒体类型过滤动态字段,并只展示字段目录声明的操作符', async () => { + const result = await renderBuilder({ field: 'media.year', operator: 'gte', value: 2000 }) + + const fieldSelect = screen.getByTestId('field-select') + expect(fieldSelect).toHaveAttribute('aria-label', '条件字段') + expect(within(fieldSelect).getByRole('button', { name: '媒体 · 年份' })).toBeInTheDocument() + expect(within(fieldSelect).queryByRole('button', { name: '音乐 · 音乐标签' })).not.toBeInTheDocument() + + const operatorSelect = screen.getByTestId('operator-select') + expect(operatorSelect).toHaveAttribute('aria-label', '条件操作符') + expect(within(operatorSelect).getByRole('button', { name: '大于等于' })).toBeInTheDocument() + expect(within(operatorSelect).getByRole('button', { name: '介于' })).toBeInTheDocument() + expect(within(operatorSelect).getByRole('button', { name: '存在' })).toBeInTheDocument() + expect(within(operatorSelect).queryByRole('button', { name: '等于' })).not.toBeInTheDocument() + + await fireEvent.click(within(fieldSelect).getByRole('button', { name: '媒体 · 分级' })) + expect(latestModel(result)).toEqual({ field: 'media.rating', operator: 'equals', value: 'PG' }) + + await result.rerender({ ...defaultProps, modelValue: latestModel(result) }) + expect(within(screen.getByTestId('operator-select')).getByRole('button', { name: '不等于' })).toBeInTheDocument() + expect( + within(screen.getByTestId('operator-select')).queryByRole('button', { name: '介于' }), + ).not.toBeInTheDocument() + }) + + it('按 string、enum、integer、number、year、string_list、boolean 和无值操作符输出类型化值', async () => { + const result = await renderBuilder({ field: 'media.title', operator: 'equals', value: '旧标题' }) + + await fireEvent.update(within(screen.getByTestId('text-value-input')).getByRole('textbox'), '新标题') + expect(latestModel(result)).toEqual({ field: 'media.title', operator: 'equals', value: '新标题' }) + + await result.rerender({ ...defaultProps, modelValue: { field: 'media.rating', operator: 'equals', value: 'PG' } }) + expect(screen.getByTestId('select-value-input')).toHaveAttribute('aria-label', '条件值') + await fireEvent.click(within(screen.getByTestId('select-value-input')).getByRole('button', { name: 'R' })) + expect(latestModel(result)).toEqual({ field: 'media.rating', operator: 'equals', value: 'R' }) + + await result.rerender({ ...defaultProps, modelValue: { field: 'media.runtime', operator: 'gt', value: 90 } }) + await fireEvent.update(within(screen.getByTestId('number-value-input')).getByRole('spinbutton'), '120') + expect(latestModel(result)).toEqual({ field: 'media.runtime', operator: 'gt', value: 120 }) + + await result.rerender({ ...defaultProps, modelValue: { field: 'media.score', operator: 'gte', value: 7.5 } }) + await fireEvent.update(within(screen.getByTestId('number-value-input')).getByRole('spinbutton'), '8.25') + expect(latestModel(result)).toEqual({ field: 'media.score', operator: 'gte', value: 8.25 }) + + await result.rerender({ + ...defaultProps, + modelValue: { field: 'media.year', operator: 'between', value: [1990, 2020] }, + }) + await fireEvent.update(within(screen.getByTestId('range-start')).getByRole('spinbutton'), '2001') + expect(latestModel(result)).toEqual({ field: 'media.year', operator: 'between', value: [2001, 2020] }) + + await result.rerender({ + ...defaultProps, + modelValue: { field: 'media.genres', operator: 'contains_any', value: ['剧情'] }, + }) + expect(within(screen.getByTestId('list-value-input')).getByLabelText('条件值列表')).toBeInTheDocument() + await fireEvent.update(within(screen.getByTestId('list-value-input')).getByRole('textbox'), '动画, 家庭') + expect(latestModel(result)).toEqual({ + field: 'media.genres', + operator: 'contains_any', + value: ['动画', '家庭'], + }) + + await result.rerender({ ...defaultProps, modelValue: { field: 'media.adult', operator: 'equals', value: true } }) + await fireEvent.click(within(screen.getByTestId('boolean-value-input')).getByRole('button', { name: '否' })) + expect(latestModel(result)).toEqual({ field: 'media.adult', operator: 'equals', value: false }) + + await fireEvent.click(within(screen.getByTestId('operator-select')).getByRole('button', { name: '为真' })) + expect(latestModel(result)).toEqual({ field: 'media.adult', operator: 'is_true' }) + await result.rerender({ ...defaultProps, modelValue: latestModel(result) }) + expect(screen.getByTestId('no-value')).toHaveTextContent('此操作符无需值') + expect(screen.queryByTestId('number-value-input')).not.toBeInTheDocument() + }) + + it('支持递归切换组类型、更新、新增和删除子条件', async () => { + const initial: ClassificationConditionNode = { + all: [ + { field: 'media.title', operator: 'equals', value: '原标题' }, + { field: 'media.year', operator: 'gte', value: 2020 }, + ], + } + const result = await renderBuilder(initial) + + const root = result.container.querySelector('section[data-depth="0"]') + expect(root).not.toBeNull() + expect(result.container.querySelectorAll('section[data-depth="1"]')).toHaveLength(2) + + const firstChild = result.container.querySelector('section[data-depth="1"]') + expect(firstChild).not.toBeNull() + await fireEvent.update(within(firstChild as HTMLElement).getByRole('textbox'), '新标题') + expect(latestModel(result)).toEqual({ + all: [ + { field: 'media.title', operator: 'equals', value: '新标题' }, + { field: 'media.year', operator: 'gte', value: 2020 }, + ], + }) + + await result.rerender({ ...defaultProps, modelValue: latestModel(result) }) + await fireEvent.click(within(root as HTMLElement).getByRole('button', { name: '新增子条件' })) + expect((latestModel(result) as { all: ClassificationConditionNode[] }).all).toHaveLength(3) + + await result.rerender({ ...defaultProps, modelValue: latestModel(result) }) + const deleteButtons = within(root as HTMLElement).getAllByRole('button', { name: /删除子条件/ }) + expect(deleteButtons).toHaveLength(3) + await fireEvent.click(deleteButtons[1]) + expect((latestModel(result) as { all: ClassificationConditionNode[] }).all).toHaveLength(2) + + await result.rerender({ ...defaultProps, modelValue: latestModel(result) }) + await fireEvent.click(within(root as HTMLElement).getAllByRole('button', { name: '任一' })[0]) + expect(latestModel(result)).toHaveProperty('any') + + await result.rerender({ ...defaultProps, modelValue: latestModel(result) }) + await fireEvent.click(within(root as HTMLElement).getAllByRole('button', { name: '非' })[0]) + expect(latestModel(result)).toHaveProperty('not') + }) + + it('达到 maxDepth 后禁止继续切换为条件组', async () => { + const result = await renderBuilder( + { field: 'media.title', operator: 'equals', value: '标题' }, + { depth: 2, maxDepth: 2 }, + ) + + expect(screen.getByTestId('depth-limit')).toHaveTextContent('已达最大组深度') + for (const label of ['全部', '任一', '非']) { + expect(screen.getByRole('button', { name: label })).toBeDisabled() + } + + await fireEvent.click(screen.getByRole('button', { name: '全部' })) + expect(result.emitted()['update:modelValue']).toBeUndefined() + }) + + it('仅显示所选来源中的 partial 和 unavailable 支持提示', async () => { + const result = await renderBuilder({ field: 'media.title', operator: 'equals', value: '标题' }) + + const hints = screen.getByTestId('source-support-hints') + expect(hints).toHaveTextContent('themoviedb:部分支持') + expect(hints).toHaveTextContent('douban:不可用') + expect(hints).not.toHaveTextContent('musicbrainz') + + await result.rerender({ + ...defaultProps, + modelValue: { field: 'media.title', operator: 'equals', value: '标题' }, + sources: ['musicbrainz'], + }) + expect(screen.queryByTestId('source-support-hints')).not.toBeInTheDocument() + }) + + it('所有编辑都通过 emit 返回新节点且不改写输入 props', async () => { + const modelValue: ClassificationConditionNode = { + all: [ + { field: 'media.title', operator: 'equals', value: '原标题' }, + { field: 'media.year', operator: 'gte', value: 2020 }, + ], + } + const mediaTypes: ClassificationMediaType[] = ['电影'] + const sources = ['themoviedb', 'douban'] + const snapshots = { + fields: structuredClone(fields), + mediaTypes: structuredClone(mediaTypes), + modelValue: structuredClone(modelValue), + sources: structuredClone(sources), + } + const result = await renderBuilder(modelValue, { fields, mediaTypes, sources }) + + await fireEvent.click(screen.getByRole('button', { name: '新增子条件' })) + + expect(latestModel(result)).not.toBe(modelValue) + expect(modelValue).toEqual(snapshots.modelValue) + expect(fields).toEqual(snapshots.fields) + expect(mediaTypes).toEqual(snapshots.mediaTypes) + expect(sources).toEqual(snapshots.sources) + }) +}) diff --git a/src/components/classification/__tests__/ClassificationImpactPanel.spec.ts b/src/components/classification/__tests__/ClassificationImpactPanel.spec.ts new file mode 100644 index 00000000..a4647ac2 --- /dev/null +++ b/src/components/classification/__tests__/ClassificationImpactPanel.spec.ts @@ -0,0 +1,205 @@ +import type { ClassificationImpactAnalysis, ClassificationResult } from '@/api/mediaClassificationTypes' +import ClassificationImpactPanel from '@/components/classification/ClassificationImpactPanel.vue' +import userEvent from '@testing-library/user-event' +import { fireEvent, screen, within } from '@testing-library/vue' +import { renderWithProviders } from '@tests/support/render' +import { describe, expect, it } from 'vitest' + +/** 构造影响示例中的分类结果。 */ +function createResult( + revision: number, + categoryId: string, + categoryPath: string[], + ruleId: string, + source: string, + state: ClassificationResult['state'] = 'complete', +): ClassificationResult { + return { + recommended: { category_id: categoryId, category_path: categoryPath, rule_id: ruleId, source }, + effective: null, + labels: [], + policy_revision: revision, + state, + } +} + +/** 构造覆盖统计、分组、示例和警告的完整影响分析。 */ +function createAnalysis(overrides: Partial = {}): ClassificationImpactAnalysis { + return { + estimated: true, + sampled_at: '2026-09-02T08:30:00Z', + sample_source: 'recent_history', + baseline_revision: 7, + candidate_revision: 8, + requested_limit: 100, + scanned_count: 128, + skipped_count: 8, + truncated: true, + sample_count: 100, + changed_count: 3, + unchanged_count: 97, + category_changed_count: 1, + path_only_changed_count: 1, + rule_changed_only_count: 1, + became_fallback_count: 1, + partial_count: 2, + degraded_count: 1, + previous_categories: { 'movie.scifi': 70, 'music.lossless': 30 }, + candidate_categories: { 'movie.china': 1, 'movie.scifi': 69, 'music.lossless': 30 }, + groups: [ + { media_type: '电影', media_source: 'themoviedb', sampled: 70, changed: 2, degraded: 1 }, + { media_type: '音乐', media_source: 'musicbrainz', sampled: 30, changed: 1, degraded: 0 }, + ], + changes: [ + { + identity: { media_source: 'themoviedb', media_id: 'movie-1' }, + media_type: '电影', + title: '流浪地球', + changed_fields: ['category_id', 'category_path', 'rule_id'], + previous: createResult(7, 'movie.scifi', ['电影', '科幻'], 'rule-scifi', 'rule'), + candidate: createResult(8, 'movie.china', ['电影', '华语'], 'rule-china', 'source_fallback', 'partial'), + }, + ], + warnings: ['近期历史仅保留有限事实,结果只反映当前可用字段。'], + ...overrides, + } +} + +/** 渲染影响分析面板。 */ +async function renderPanel( + overrides: { + analysis?: ClassificationImpactAnalysis | null + loading?: boolean + disabled?: boolean + } = {}, +) { + return renderWithProviders(ClassificationImpactPanel, { + props: { + analysis: overrides.analysis ?? null, + loading: overrides.loading ?? false, + disabled: overrides.disabled ?? false, + }, + }) +} + +describe('ClassificationImpactPanel', () => { + it('明确说明有界估算,并携带规范化后的样本和示例上限触发分析', async () => { + const user = userEvent.setup() + const panel = await renderPanel() + + expect(screen.getByRole('region', { name: '影响分析' })).toHaveAttribute('aria-busy', 'false') + expect(screen.getByText('使用有限样本比较活动策略与当前草稿,不代表全库精确统计。')).toBeInTheDocument() + expect(screen.getByText(/结果将始终以有界样本估算展示/)).toBeInTheDocument() + expect(screen.getByRole('spinbutton', { name: '最大样本数' })).toHaveValue(100) + expect(screen.getByRole('spinbutton', { name: '变化示例上限' })).toHaveValue(20) + + await fireEvent.update(screen.getByRole('spinbutton', { name: '最大样本数' }), '260.8') + await fireEvent.update(screen.getByRole('spinbutton', { name: '变化示例上限' }), '-4') + await user.click(screen.getByRole('button', { name: '分析当前分类草稿影响' })) + + expect(panel.emitted().analyze).toEqual([[{ sampleLimit: 200, exampleLimit: 0 }]]) + expect(screen.getByRole('spinbutton', { name: '最大样本数' })).toHaveValue(200) + expect(screen.getByRole('spinbutton', { name: '变化示例上限' })).toHaveValue(0) + }) + + it('在加载或外部禁用时锁定参数和触发按钮', async () => { + const user = userEvent.setup() + const panel = await renderPanel({ loading: true }) + const region = screen.getByRole('region', { name: '影响分析' }) + const analyzeButton = screen.getByRole('button', { name: '分析当前分类草稿影响' }) + + expect(region).toHaveAttribute('aria-busy', 'true') + expect(screen.getByRole('status')).toHaveTextContent('正在生成有界样本估算') + expect(screen.getByRole('spinbutton', { name: '最大样本数' })).toBeDisabled() + expect(analyzeButton).toBeDisabled() + await user.click(analyzeButton) + expect(panel.emitted().analyze).toBeUndefined() + + await panel.rerender({ analysis: null, loading: false, disabled: true }) + expect(region).toHaveAttribute('aria-busy', 'false') + expect(screen.getByRole('spinbutton', { name: '变化示例上限' })).toBeDisabled() + expect(analyzeButton).toBeDisabled() + }) + + it('展示有界样本统计、变化类型、前后分类计数和截断语义', async () => { + 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() + + const expectedMetrics: Record = { + requested_limit: '100', + scanned_count: '128', + skipped_count: '8', + truncated: '是', + sample_count: '100', + changed_count: '3', + unchanged_count: '97', + category_changed_count: '1', + path_only_changed_count: '1', + rule_changed_only_count: '1', + became_fallback_count: '1', + partial_count: '2', + degraded_count: '1', + } + for (const [key, value] of Object.entries(expectedMetrics)) { + expect(screen.getByTestId(`impact-metric-${key}`)).toHaveTextContent(value) + } + + expect(screen.getByRole('note')).toHaveTextContent('未展示的记录不应推断为无变化') + expect(screen.getByLabelText('活动策略分类计数')).toHaveTextContent('movie.scifi70') + expect(screen.getByLabelText('候选策略分类计数')).toHaveTextContent('movie.china1') + expect(screen.getByLabelText('候选策略分类计数')).toHaveTextContent('music.lossless30') + }) + + it('按媒体类型和来源展示分组,并完整呈现有限变化示例的前后结果', async () => { + await renderPanel({ analysis: createAnalysis() }) + + const groupTable = screen.getByRole('region', { name: '媒体类型与来源影响分组表' }) + expect(groupTable).toHaveAttribute('tabindex', '0') + expect(within(groupTable).getByRole('row', { name: '电影 themoviedb 70 2 1' })).toBeInTheDocument() + expect(within(groupTable).getByRole('row', { name: '音乐 musicbrainz 30 1 0' })).toBeInTheDocument() + + 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分类路径命中规则') + + const previous = within(example).getByRole('region', { name: '变化示例 1 的活动策略结果' }) + expect(previous).toHaveTextContent('movie.scifi') + expect(previous).toHaveTextContent('电影 / 科幻') + expect(previous).toHaveTextContent('rule-scifi') + expect(previous).toHaveTextContent('完整') + + const candidate = within(example).getByRole('region', { name: '变化示例 1 的候选策略结果' }) + expect(candidate).toHaveTextContent('movie.china') + expect(candidate).toHaveTextContent('电影 / 华语') + expect(candidate).toHaveTextContent('source_fallback') + expect(candidate).toHaveTextContent('事实不完整') + + expect(screen.getByRole('alert')).toHaveTextContent('近期历史仅保留有限事实') + }) + + it('区分显式请求样本来源,并为无分组和无变化结果提供清晰空状态', async () => { + await renderPanel({ + analysis: createAnalysis({ + sample_source: 'request', + truncated: false, + sample_count: 1, + changed_count: 0, + unchanged_count: 1, + groups: [], + changes: [], + warnings: [], + }), + }) + + expect(screen.getByText('sample_source: request(请求内显式事实)')).toBeInTheDocument() + expect(screen.queryByRole('note')).not.toBeInTheDocument() + expect(screen.getByText('本次样本没有可展示的媒体类型与来源分组。')).toBeInTheDocument() + expect(screen.getByText('有限样本内未返回分类变化示例。')).toBeInTheDocument() + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + }) +}) diff --git a/src/components/classification/__tests__/ClassificationPolicyControlPanel.spec.ts b/src/components/classification/__tests__/ClassificationPolicyControlPanel.spec.ts new file mode 100644 index 00000000..6587d7dc --- /dev/null +++ b/src/components/classification/__tests__/ClassificationPolicyControlPanel.spec.ts @@ -0,0 +1,246 @@ +import type { + ClassificationImpactAnalysis, + ClassificationPolicy, + ClassificationPolicyHistory, + ClassificationRevisionConflict, + ClassificationValidationResult, +} from '@/api/mediaClassificationTypes' +import ClassificationPolicyControlPanel from '@/components/classification/ClassificationPolicyControlPanel.vue' +import userEvent from '@testing-library/user-event' +import { screen, within } from '@testing-library/vue' +import { renderWithProviders } from '@tests/support/render' +import { beforeEach, describe, expect, it } from 'vitest' + +/** 创建可计数的历史策略快照。 */ +function createPolicy(revision: number, categoryCount: number, ruleCount: number): ClassificationPolicy { + return { + schema_version: 2, + revision, + mode: 'first_match', + enrichment_mode: 'primary_only', + categories: Array.from({ length: categoryCount }, (_, index) => ({ + id: 'category-' + revision + '-' + index, + media_type: '电影' as const, + name: '分类 ' + (index + 1), + path: ['电影', '分类 ' + (index + 1)], + enabled: true, + labels: [], + })), + rules: Array.from({ length: ruleCount }, (_, index) => ({ + id: 'rule-' + revision + '-' + index, + name: '规则 ' + (index + 1), + kind: 'category' as const, + enabled: true, + priority: index, + media_types: ['电影' as const], + sources: [], + when: { all: [] }, + target: { category_id: categoryCount ? 'category-' + revision + '-0' : null, labels: [] }, + })), + fallbacks: {}, + source_fallbacks: {}, + field_aliases: {}, + updated_at: revision === 6 ? '2026-09-01T08:30:00+08:00' : '2026-08-31T08:30:00+08:00', + } +} + +/** 创建与当前活动 revision 对齐的影响分析。 */ +function createImpact(baselineRevision = 7, sampledAt = '2026-09-02T09:30:00+08:00'): ClassificationImpactAnalysis { + return { + estimated: true, + sampled_at: sampledAt, + sample_source: 'recent_history', + baseline_revision: baselineRevision, + candidate_revision: baselineRevision, + requested_limit: 100, + scanned_count: 24, + skipped_count: 0, + truncated: false, + sample_count: 24, + changed_count: 5, + unchanged_count: 19, + category_changed_count: 3, + path_only_changed_count: 1, + rule_changed_only_count: 1, + became_fallback_count: 0, + partial_count: 0, + degraded_count: 1, + previous_categories: {}, + candidate_categories: {}, + groups: [], + changes: [], + warnings: [], + } +} + +const validResult: ClassificationValidationResult = { valid: true, issues: [] } + +/** 使用常规空闲状态渲染控制面板,单个用例只覆盖必要差异。 */ +async function renderPanel(overrides: Partial['$props']> = {}) { + return renderWithProviders(ClassificationPolicyControlPanel, { + props: { + activeRevision: 7, + isDirty: true, + validationResult: null, + conflict: null, + history: null, + ...overrides, + }, + }) +} + +describe('ClassificationPolicyControlPanel', () => { + beforeEach(() => { + document.body.innerHTML = '' + }) + + it('仅在当前校验、当前影响分析和人工审阅全部完成后允许发布', async () => { + const user = userEvent.setup() + const panel = await renderPanel() + const publish = screen.getByRole('button', { name: '发布分类策略新版本' }) + + expect(publish).toBeDisabled() + expect(screen.getByText('当前草稿尚未通过服务端校验')).toBeInTheDocument() + expect(screen.getByText('需要对当前草稿执行最新影响分析')).toBeInTheDocument() + expect(screen.getByText('尚未确认审阅影响分析')).toBeInTheDocument() + + await panel.rerender({ + activeRevision: 7, + isDirty: true, + validationResult: validResult, + validationIsCurrent: true, + impactResult: createImpact(), + impactIsCurrent: true, + conflict: null, + history: null, + }) + + expect(publish).toBeDisabled() + await user.click(screen.getByRole('checkbox', { name: '我已审阅最新影响分析,并确认可以发布' })) + expect(publish).toBeEnabled() + await user.click(publish) + + expect(panel.emitted().publish).toHaveLength(1) + expect(screen.getByText('当前草稿已通过服务端校验')).toBeInTheDocument() + expect(screen.getByText('影响分析基于当前 revision 7')).toBeInTheDocument() + }) + + it('草稿门禁操作发出独立事件,并在影响分析变旧后撤销审阅确认', async () => { + const user = userEvent.setup() + const panel = await renderPanel({ + validationResult: validResult, + validationIsCurrent: true, + impactResult: createImpact(), + impactIsCurrent: true, + }) + + await user.click(screen.getByRole('button', { name: '校验当前草稿' })) + await user.click(screen.getByRole('button', { name: '分析草稿影响' })) + const review = screen.getByRole('checkbox', { name: '我已审阅最新影响分析,并确认可以发布' }) + await user.click(review) + expect(review).toBeChecked() + + await panel.rerender({ + activeRevision: 8, + isDirty: true, + validationResult: validResult, + validationIsCurrent: true, + impactResult: createImpact(7), + impactIsCurrent: false, + conflict: null, + history: null, + }) + + expect(panel.emitted().validate).toHaveLength(1) + expect(panel.emitted().analyze).toHaveLength(1) + expect(review).not.toBeChecked() + expect(review).toBeDisabled() + expect(screen.getByText('该结果已过期,请重新执行影响分析。')).toBeInTheDocument() + expect(screen.getByRole('button', { name: '发布分类策略新版本' })).toBeDisabled() + }) + + it('冲突明确展示 expected/current revision,并保留草稿后按顺序重新分析', async () => { + const user = userEvent.setup() + const conflict: ClassificationRevisionConflict = { + code: 'classification_revision_conflict', + expected_revision: 7, + current_revision: 9, + } + const panel = await renderPanel({ + validationResult: validResult, + validationIsCurrent: true, + impactResult: createImpact(), + impactIsCurrent: true, + conflict, + }) + + const alert = screen.getByText('检测到 revision 冲突').closest('[role="alert"]') + expect(alert).not.toBeNull() + expect(alert).toHaveTextContent('本地操作基于 revision 7') + expect(alert).toHaveTextContent('服务端当前为 revision 9') + expect(alert).toHaveTextContent('本地草稿已保留') + + await user.click(screen.getByRole('button', { name: '重新加载远端状态' })) + await user.click(screen.getByRole('button', { name: '保留草稿并重新分析' })) + + expect(panel.emitted().refresh).toHaveLength(1) + expect(panel.emitted()['keep-draft']).toHaveLength(1) + expect(panel.emitted().analyze).toBeUndefined() + expect(screen.getByRole('button', { name: '发布分类策略新版本' })).toBeDisabled() + }) + + it('历史版本展示 revision、时间和规模,并将所选 revision 作为 CAS 回滚目标发出', async () => { + const user = userEvent.setup() + const history: ClassificationPolicyHistory = { + active_revision: 7, + items: [createPolicy(5, 3, 4), createPolicy(6, 2, 7)], + } + const panel = await renderPanel({ history }) + + const revision6 = screen.getByTestId('classification-history-revision-6') + const revision5 = screen.getByTestId('classification-history-revision-5') + expect(revision6.compareDocumentPosition(revision5) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy() + expect(within(revision5).getByText('revision 5')).toBeInTheDocument() + expect(within(revision5).getByText('3 个分类')).toBeInTheDocument() + expect(within(revision5).getByText('4 条规则')).toBeInTheDocument() + expect(within(revision5).getByText(/2026/)).toBeInTheDocument() + expect(screen.getByText(/回滚不会改写或删除旧版本/)).toBeInTheDocument() + expect(screen.getByText(/创建一个新 revision/)).toBeInTheDocument() + + const rollback = screen.getByRole('button', { name: '将所选历史版本发布为新版本' }) + expect(rollback).toBeDisabled() + await user.click(screen.getByRole('radio', { name: /选择 revision 5,3 个分类,4 条规则/ })) + expect(rollback).toBeEnabled() + expect(rollback).toHaveTextContent('将 revision 5 回滚为新版本') + await user.click(rollback) + + expect(panel.emitted().rollback).toEqual([[5]]) + }) + + it('加载和写入状态禁用竞争操作,并允许独立刷新历史', async () => { + const user = userEvent.setup() + const panel = await renderPanel({ + history: { active_revision: 7, items: [createPolicy(6, 1, 1)] }, + analyzingImpact: true, + }) + + expect(screen.getByRole('button', { name: '校验当前草稿' })).toBeDisabled() + expect(screen.getByRole('button', { name: '分析草稿影响' })).toHaveAttribute('aria-busy', 'true') + expect(screen.getByRole('button', { name: '刷新版本历史' })).toBeDisabled() + await user.click(screen.getByRole('radio', { name: /选择 revision 6/ })) + expect(screen.getByRole('button', { name: '将所选历史版本发布为新版本' })).toBeDisabled() + + await panel.rerender({ + activeRevision: 7, + isDirty: true, + validationResult: null, + conflict: null, + history: null, + loadingHistory: false, + analyzingImpact: false, + }) + await user.click(screen.getByRole('button', { name: '刷新版本历史' })) + expect(panel.emitted()['load-history']).toHaveLength(1) + expect(screen.getByText('尚未加载版本历史')).toBeInTheDocument() + }) +}) diff --git a/src/components/classification/__tests__/ClassificationPreviewPanel.spec.ts b/src/components/classification/__tests__/ClassificationPreviewPanel.spec.ts new file mode 100644 index 00000000..31d131c3 --- /dev/null +++ b/src/components/classification/__tests__/ClassificationPreviewPanel.spec.ts @@ -0,0 +1,248 @@ +import type { + ClassificationCategory, + ClassificationEvaluation, + ClassificationFieldDefinition, +} from '@/api/mediaClassificationTypes' +import ClassificationPreviewPanel from '@/components/classification/ClassificationPreviewPanel.vue' +import { screen, waitFor, within } from '@testing-library/vue' +import userEvent from '@testing-library/user-event' +import { renderWithProviders } from '@tests/support/render' +import { describe, expect, it } from 'vitest' + +/** 构造事实预览测试使用的动态字段。 */ +function field( + definition: Partial & + Pick, +): ClassificationFieldDefinition { + return { + group: '共享字段', + description: `${definition.label}说明`, + operators: ['equals'], + media_types: ['电影', '电视剧', '音乐'], + options: [], + allow_custom_values: true, + source_support: {}, + ...definition, + } +} + +const fields: ClassificationFieldDefinition[] = [ + field({ id: 'identity.media_source', label: '媒体来源目录字段', value_type: 'string' }), + field({ + id: 'media.type', + label: '媒体类型目录字段', + value_type: 'enum', + options: [ + { label: '电影', value: '电影' }, + { label: '电视剧', value: '电视剧' }, + { label: '音乐', value: '音乐' }, + ], + }), + field({ id: 'media.year', label: '发行年份', value_type: 'year' }), + field({ + id: 'music.entity_type', + label: '音乐实体类型', + group: '音乐', + value_type: 'enum', + media_types: ['音乐'], + options: [ + { label: '专辑', value: 'album' }, + { label: '单曲', value: 'recording' }, + ], + allow_custom_values: false, + }), + field({ + id: 'music.tags', + label: '音乐标签', + group: '音乐', + value_type: 'string_list', + media_types: ['音乐'], + }), + field({ + id: 'extensions.plugin.example.region_group', + label: '来源地区组', + group: '来源扩展', + value_type: 'string', + source_support: { 'plugin.example': 'extension' }, + }), +] + +const categories: ClassificationCategory[] = [ + { + id: 'movie.scifi', + media_type: '电影', + name: '科幻电影', + path: ['电影', '科幻'], + enabled: true, + labels: [], + }, + { + id: 'movie.effective', + media_type: '电影', + name: '生效电影', + path: ['电影', '精选'], + enabled: true, + labels: [], + }, +] + +/** 渲染事实预览组件并允许覆盖外部求值状态。 */ +async function renderPanel(overrides: { result?: ClassificationEvaluation | null; loading?: boolean } = {}) { + return renderWithProviders(ClassificationPreviewPanel, { + props: { + fields, + categories, + result: overrides.result ?? null, + loading: overrides.loading ?? false, + }, + }) +} + +describe('ClassificationPreviewPanel', () => { + it('按字段目录编辑音乐与扩展事实,并在切换活动策略时保持稳定来源身份', async () => { + const user = userEvent.setup() + const result = await renderPanel() + + const sourceInput = screen.getByRole('textbox', { name: '媒体来源' }) + const mediaIdInput = screen.getByRole('textbox', { name: '媒体 ID' }) + await user.type(sourceInput, 'plugin.example') + await user.type(mediaIdInput, 'release-42') + await user.type(screen.getByRole('spinbutton', { name: '发行年份' }), '2024') + + expect(screen.queryByRole('combobox', { name: '音乐实体类型' })).not.toBeInTheDocument() + expect(screen.queryByText('媒体来源目录字段')).not.toBeInTheDocument() + expect(screen.queryByText('媒体类型目录字段')).not.toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: '音乐' })) + expect(sourceInput).toHaveValue('plugin.example') + expect(mediaIdInput).toHaveValue('release-42') + + await user.click(screen.getByRole('combobox', { name: '音乐实体类型' })) + 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 waitFor(() => expect(result.emitted()['request-preview']).toHaveLength(1)) + const previewEvents = result.emitted()['request-preview'] as unknown[][] | undefined + expect(previewEvents?.[0]?.[0]).toEqual({ + input: { + kind: 'facts', + facts: { + identity: { media_source: 'plugin.example', media_id: 'release-42' }, + media: { type: '音乐', year: 2024 }, + music: { entity_type: 'album', tags: ['ambient'] }, + extensions: { 'plugin.example': { region_group: 'east-asia' } }, + field_sources: {}, + }, + }, + policyMode: 'active', + }) + }) + + it('展示推荐与生效分类、状态、revision、警告及逐条件 expected/actual/path', async () => { + const evaluation: ClassificationEvaluation = { + facts: { + identity: { media_source: 'themoviedb', media_id: '550' }, + media: { type: '电影', year: 1999 }, + extensions: {}, + field_sources: { + 'media.year': { + media_source: 'themoviedb', + provider_id: 'host:themoviedb', + provider_name: 'TheMovieDb', + }, + }, + }, + result: { + recommended: { + category_id: 'movie.scifi', + category_path: ['电影', '科幻'], + rule_id: 'rule.scifi', + source: 'automatic', + }, + effective: { + category_id: 'movie.effective', + category_path: ['电影', '精选'], + rule_id: null, + source: 'fallback', + }, + labels: ['经典', '高分'], + policy_revision: 18, + state: 'partial', + }, + warnings: [ + { + code: 'missing_field', + message: '来源未提供内容分级', + path: ['facts', 'media', 'content_rating'], + field: 'media.content_rating', + source: 'themoviedb', + }, + ], + trace: [ + { + rule_id: 'rule.scifi', + matched: true, + conditions: [ + { + field: 'media.year', + operator: 'gte', + expected: 1990, + actual: 1999, + matched: true, + path: ['rules', 0, 'when', 'all', 0], + source: { + media_source: 'themoviedb', + provider_id: 'host:themoviedb', + provider_name: 'TheMovieDb', + }, + }, + { + field: 'media.content_rating', + operator: 'exists', + matched: false, + path: ['rules', 0, 'when', 'all', 1], + }, + ], + }, + ], + } + + await renderPanel({ result: evaluation }) + + 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('missing_field:来源未提供内容分级') + expect(screen.getByRole('region', { name: '警告' })).toHaveTextContent('facts.media.content_rating') + + const traceTable = screen.getByRole('table', { name: '规则 rule.scifi 的条件命中解释' }) + expect(within(traceTable).getByText('1990')).toBeInTheDocument() + expect(within(traceTable).getByText('1999')).toBeInTheDocument() + expect(within(traceTable).getByText('TheMovieDb · themoviedb')).toHaveAttribute('title', 'host:themoviedb') + expect(within(traceTable).getAllByText('未提供')).toHaveLength(3) + expect(within(traceTable).getAllByText('media.content_rating')).toHaveLength(1) + expect(within(traceTable).getByText('rules[0].when.all[1]')).toBeInTheDocument() + expect(within(traceTable).getByLabelText('条件命中')).toBeInTheDocument() + expect(within(traceTable).getByLabelText('条件未命中')).toBeInTheDocument() + }) + + it('缺少稳定身份时拒绝发出请求,并在加载期间禁用重复预览', async () => { + const user = userEvent.setup() + const result = await renderPanel() + + 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() + }) +}) diff --git a/src/components/classification/__tests__/ClassificationRuleEditor.spec.ts b/src/components/classification/__tests__/ClassificationRuleEditor.spec.ts new file mode 100644 index 00000000..9a254314 --- /dev/null +++ b/src/components/classification/__tests__/ClassificationRuleEditor.spec.ts @@ -0,0 +1,254 @@ +import type { + ClassificationCategory, + ClassificationFieldDefinition, + ClassificationRule, +} from '@/api/mediaClassificationTypes' +import ClassificationRuleEditor from '@/components/classification/ClassificationRuleEditor.vue' +import userEvent from '@testing-library/user-event' +import { fireEvent, screen, within } from '@testing-library/vue' +import { renderWithProviders } from '@tests/support/render' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('vuedraggable', async () => { + const { defineComponent, h } = await import('vue') + return { + default: defineComponent({ + name: 'DraggableStub', + props: { modelValue: { type: Array, default: () => [] } }, + emits: ['update:modelValue'], + setup(props, { emit, slots }) { + /** 模拟拖拽结束后由 vuedraggable 提交的新顺序。 */ + const reverse = () => emit('update:modelValue', [...props.modelValue].reverse()) + return () => + h('div', { 'data-testid': 'rule-draggable' }, [ + h('button', { 'aria-label': '模拟拖拽反转', type: 'button', onClick: reverse }, 'reverse'), + ...(props.modelValue as ClassificationRule[]).map((element, index) => slots.item?.({ element, index })), + ]) + }, + }), + } +}) + +vi.mock('@/components/classification/ClassificationConditionBuilder.vue', async () => { + const { defineComponent, h } = await import('vue') + return { + default: defineComponent({ + name: 'ClassificationConditionBuilderStub', + props: { + modelValue: { type: Object, required: true }, + fields: { type: Array, required: true }, + mediaTypes: { type: Array, required: true }, + sources: { type: Array, required: true }, + maxDepth: { type: Number, required: true }, + }, + emits: ['update:modelValue'], + setup(props) { + return () => + h( + 'output', + { 'aria-label': '条件构建器状态' }, + JSON.stringify({ + maxDepth: props.maxDepth, + mediaTypes: props.mediaTypes, + sources: props.sources, + }), + ) + }, + }), + } +}) + +const categories: ClassificationCategory[] = [ + { id: 'movie-cn', media_type: '电影', name: '华语电影', path: ['电影', '华语'], enabled: true, labels: [] }, + { id: 'tv-animation', media_type: '电视剧', name: '动画剧集', path: ['电视剧', '动画'], enabled: true, labels: [] }, + { id: 'music-rock', media_type: '音乐', name: '摇滚专辑', path: ['音乐', '摇滚'], enabled: true, labels: [] }, +] + +const fields: ClassificationFieldDefinition[] = [ + { + id: 'media.genre_names', + label: '类型', + group: '媒体', + value_type: 'string_list', + operators: ['contains_any'], + media_types: ['电影', '电视剧', '音乐'], + options: [], + allow_custom_values: true, + source_support: { themoviedb: 'native', musicbrainz: 'partial' }, + }, +] + +/** 创建测试规则,覆盖分类规则和标签规则共用的数据结构。 */ +function createRule(overrides: Partial = {}): ClassificationRule { + return { + id: 'rule-movie', + name: '电影规则', + kind: 'category', + enabled: true, + priority: 0, + media_types: ['电影'], + sources: [], + when: { all: [] }, + target: { category_id: 'movie-cn', labels: [] }, + ...overrides, + } +} + +/** 渲染规则编辑器并返回最近一次提交的规则数组。 */ +async function renderEditor(rules: ClassificationRule[], options: { maxRules?: number } = {}) { + const result = await renderWithProviders(ClassificationRuleEditor, { + props: { + rules, + categories, + fields, + maxConditionDepth: 6, + ...options, + }, + }) + await screen.findByTestId('rule-draggable') + + return { + ...result, + latestRules: () => { + const events = (result.emitted()['update:rules'] ?? []) as unknown[][] + return events.at(-1)?.[0] as ClassificationRule[] + }, + } +} + +/** 打开 Vuetify 下拉框并选择一个选项。 */ +async function selectOption(label: string, option: string) { + const user = userEvent.setup() + const visibleOption = screen.queryByRole('option', { name: option }) + if (visibleOption) { + await user.click(visibleOption) + return + } + + await user.click(screen.getByLabelText(label)) + await user.click(await screen.findByRole('option', { name: option })) +} + +describe('ClassificationRuleEditor', () => { + beforeEach(() => { + document.body.innerHTML = '' + }) + + it('新增、复制和删除规则,并为副本生成独立稳定 ID', async () => { + const user = userEvent.setup() + const editor = await renderEditor([createRule()]) + + await user.click(screen.getByRole('button', { name: '新增分类规则' })) + expect(editor.latestRules()).toEqual([ + expect.objectContaining({ id: 'rule-movie', priority: 0 }), + expect.objectContaining({ id: 'rule-2', name: '新规则 2', priority: 1 }), + ]) + + await user.click(screen.getByRole('button', { name: '复制规则 电影规则' })) + expect(editor.latestRules()).toEqual([ + expect.objectContaining({ id: 'rule-movie', priority: 0 }), + expect.objectContaining({ id: 'rule-movie-copy', name: '电影规则 副本', priority: 1 }), + expect.objectContaining({ id: 'rule-2', priority: 2 }), + ]) + + await user.click(screen.getByRole('button', { name: '删除规则 电影规则 副本' })) + expect(editor.latestRules().map(rule => [rule.id, rule.priority])).toEqual([ + ['rule-movie', 0], + ['rule-2', 1], + ]) + }) + + it('通过上下按钮和拖拽重排,并始终按顺序重算 priority', async () => { + const user = userEvent.setup() + const editor = await renderEditor([ + createRule(), + createRule({ id: 'rule-music', name: '音乐规则', priority: 1, media_types: ['音乐'] }), + createRule({ id: 'rule-tv', name: '剧集规则', priority: 2, media_types: ['电视剧'] }), + ]) + + await user.click(screen.getByRole('button', { name: '下移规则 电影规则' })) + expect(editor.latestRules().map(rule => [rule.id, rule.priority])).toEqual([ + ['rule-music', 0], + ['rule-movie', 1], + ['rule-tv', 2], + ]) + + await user.click(screen.getByRole('button', { name: '模拟拖拽反转' })) + expect(editor.latestRules().map(rule => [rule.id, rule.priority])).toEqual([ + ['rule-tv', 0], + ['rule-movie', 1], + ['rule-music', 2], + ]) + }) + + it('编辑名称、稳定 ID、启停状态、媒体类型和来源', async () => { + const user = userEvent.setup() + const editor = await renderEditor([createRule()]) + + await fireEvent.update(screen.getByLabelText('规则名称 1'), '音乐来源规则') + await fireEvent.update(screen.getByLabelText('规则 ID 1'), 'rule-music-source') + await user.click(screen.getByRole('checkbox', { name: '启用规则 音乐来源规则' })) + await selectOption('媒体类型 音乐来源规则', '音乐') + await selectOption('数据来源 音乐来源规则', 'musicbrainz') + + expect(editor.latestRules()[0]).toEqual( + expect.objectContaining({ + id: 'rule-music-source', + name: '音乐来源规则', + enabled: false, + media_types: ['电影', '音乐'], + sources: ['musicbrainz'], + }), + ) + }) + + it('按媒体类型过滤分类目标,并在目标失效时自动清空', async () => { + const user = userEvent.setup() + const editor = await renderEditor([createRule()]) + + await user.click(screen.getByLabelText('分类目标 电影规则')) + expect(await screen.findByRole('option', { name: '电影 / 华语' })).toBeInTheDocument() + expect(screen.queryByRole('option', { name: '音乐 / 摇滚' })).not.toBeInTheDocument() + await user.keyboard('{Escape}') + + await selectOption('媒体类型 电影规则', '音乐') + expect(editor.latestRules()[0]?.target.category_id).toBe('movie-cn') + + await selectOption('媒体类型 电影规则', '电影') + expect(editor.latestRules()[0]?.media_types).toEqual(['音乐']) + expect(editor.latestRules()[0]?.target.category_id).toBeNull() + + await selectOption('分类目标 电影规则', '音乐 / 摇滚') + expect(editor.latestRules()[0]?.target.category_id).toBe('music-rock') + }) + + it('切换为标签规则后清理分类目标并编辑标签输出', async () => { + const user = userEvent.setup() + const editor = await renderEditor([createRule()]) + + const ruleRegion = within(screen.getByRole('article', { name: '规则 1:电影规则' })) + await user.click(ruleRegion.getByRole('button', { name: '标签' })) + expect(editor.latestRules()[0]).toEqual( + expect.objectContaining({ kind: 'label', target: { category_id: null, labels: [] } }), + ) + expect(screen.queryByLabelText('分类目标 电影规则')).not.toBeInTheDocument() + + const labelInput = screen.getByLabelText('标签输出 电影规则') + await user.click(labelInput) + await user.type(labelInput, '演唱会{Enter}') + expect(editor.latestRules()[0]?.target.labels).toEqual(['演唱会']) + }) + + it('达到 maxRules 后禁用新增和复制且不产生额外规则', async () => { + const user = userEvent.setup() + const editor = await renderEditor([createRule()], { maxRules: 1 }) + const addButton = screen.getByRole('button', { name: '新增分类规则' }) + const copyButton = screen.getByRole('button', { name: '复制规则 电影规则' }) + + expect(addButton).toBeDisabled() + expect(copyButton).toBeDisabled() + await user.click(addButton) + await user.click(copyButton) + expect(editor.emitted()['update:rules']).toBeUndefined() + }) +}) diff --git a/src/components/dialog/CategoryEditDialog.vue b/src/components/dialog/CategoryEditDialog.vue deleted file mode 100644 index 416ba59b..00000000 --- a/src/components/dialog/CategoryEditDialog.vue +++ /dev/null @@ -1,652 +0,0 @@ - - - - - diff --git a/src/components/dialog/__tests__/CategoryEditDialog.spec.ts b/src/components/dialog/__tests__/CategoryEditDialog.spec.ts deleted file mode 100644 index db1dff41..00000000 --- a/src/components/dialog/__tests__/CategoryEditDialog.spec.ts +++ /dev/null @@ -1,62 +0,0 @@ -import DialogCloseBtn from '@/@core/components/DialogCloseBtn.vue' -import CategoryEditDialog from '@/components/dialog/CategoryEditDialog.vue' -import { screen, waitFor } from '@testing-library/vue' -import { renderWithProviders } from '@tests/support/render' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const mocks = vi.hoisted(() => ({ - apiGet: vi.fn(), - toastError: vi.fn(), -})) - -vi.mock('@/api', () => ({ - default: { - get: mocks.apiGet, - post: vi.fn(), - }, -})) - -vi.mock('vue-toastification', () => ({ - useToast: () => ({ error: mocks.toastError, success: vi.fn() }), -})) - -vi.mock('vuedraggable', async () => { - const { defineComponent, h } = await import('vue') - return { - default: defineComponent({ - props: { modelValue: { type: Array, default: () => [] } }, - setup(props, { slots }) { - return () => - h( - 'div', - props.modelValue.map((element, index) => slots.item?.({ element, index })), - ) - }, - }), - } -}) - -describe('CategoryEditDialog data client contract', () => { - beforeEach(() => { - mocks.apiGet.mockReset() - mocks.toastError.mockReset() - }) - - it('parses the unwrapped category configuration returned by the default API client', async () => { - mocks.apiGet.mockResolvedValue({ - movie: { - '电影直返分类': { genre_ids: '28', original_language: 'zh', production_countries: 'CN' }, - }, - tv: {}, - }) - - await renderWithProviders(CategoryEditDialog, { - global: { components: { VDialogCloseBtn: DialogCloseBtn } }, - props: { modelValue: true }, - }) - - await waitFor(() => expect(mocks.apiGet).toHaveBeenCalledWith('media/category/config', { feedback: 'silent' })) - expect(await screen.findByDisplayValue('电影直返分类')).toBeInTheDocument() - expect(mocks.toastError).not.toHaveBeenCalled() - }) -}) diff --git a/src/composables/__tests__/useMediaClassification.spec.ts b/src/composables/__tests__/useMediaClassification.spec.ts new file mode 100644 index 00000000..c23caabf --- /dev/null +++ b/src/composables/__tests__/useMediaClassification.spec.ts @@ -0,0 +1,345 @@ +import { AxiosHeaders, type AxiosResponse, type InternalAxiosRequestConfig } from 'axios' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ApiRequestError } from '@/api/client' +import type { ApiResponse } from '@/api/types' +import type { + ClassificationEvaluation, + ClassificationFacts, + ClassificationFieldCatalog, + ClassificationImpactAnalysis, + ClassificationPolicy, + ClassificationPolicyHistory, + ClassificationRevisionConflict, + ClassificationValidationResult, +} from '@/api/mediaClassification' +import { clearMediaClassificationFieldCatalogCache, useMediaClassification } from '@/composables/useMediaClassification' + +const mocks = vi.hoisted(() => ({ + analyzeImpact: vi.fn(), + getFields: vi.fn(), + getHistory: vi.fn(), + getPolicy: vi.fn(), + preview: vi.fn(), + publish: vi.fn(), + rollback: vi.fn(), + validate: vi.fn(), +})) + +vi.mock('@/api/mediaClassification', async importOriginal => ({ + ...(await importOriginal()), + analyzeClassificationImpact: (...args: unknown[]) => mocks.analyzeImpact(...args), + getClassificationFields: (...args: unknown[]) => mocks.getFields(...args), + getClassificationHistory: (...args: unknown[]) => mocks.getHistory(...args), + getClassificationPolicy: (...args: unknown[]) => mocks.getPolicy(...args), + previewClassificationPolicy: (...args: unknown[]) => mocks.preview(...args), + publishClassificationPolicy: (...args: unknown[]) => mocks.publish(...args), + rollbackClassificationPolicy: (...args: unknown[]) => mocks.rollback(...args), + validateClassificationPolicy: (...args: unknown[]) => mocks.validate(...args), +})) + +function createPolicy(revision = 1, name = '电影'): ClassificationPolicy { + return { + schema_version: 2, + revision, + mode: 'first_match', + enrichment_mode: 'primary_only', + categories: [{ id: 'movie', media_type: '电影', name, path: [name], enabled: true, labels: [] }], + rules: [ + { + id: 'movie-rule', + name: '电影规则', + kind: 'category', + enabled: true, + priority: 0, + media_types: ['电影'], + sources: [], + when: { field: 'media.type', operator: 'equals', value: '电影' }, + target: { category_id: 'movie', labels: [] }, + }, + ], + fallbacks: { 电影: 'movie' }, + source_fallbacks: {}, + field_aliases: {}, + updated_at: `2026-09-02T00:00:0${revision}Z`, + } +} + +function createFacts(): ClassificationFacts { + return { + identity: { media_source: 'themoviedb', media_id: '1' }, + media: { type: '电影', title: '示例电影' }, + extensions: {}, + field_sources: {}, + } +} + +function createFieldCatalog(label = '媒体类型'): ClassificationFieldCatalog { + return { + fields: [ + { + id: 'media.type', + label, + group: '通用', + value_type: 'enum', + operators: ['equals'], + media_types: ['电影', '电视剧', '音乐'], + options: [{ value: '电影', label: '电影' }], + allow_custom_values: false, + source_support: { themoviedb: 'native' }, + }, + ], + limits: { + max_category_depth: 4, + max_category_segment_length: 80, + max_category_path_length: 240, + max_condition_depth: 6, + max_conditions_per_rule: 50, + max_rules: 200, + max_total_conditions: 1000, + }, + } +} + +function createHttpError(status: number, payload: ApiResponse): ApiRequestError> { + const config = { headers: new AxiosHeaders() } as InternalAxiosRequestConfig + const response: AxiosResponse> = { + config, + data: payload, + headers: new AxiosHeaders(), + status, + statusText: String(status), + } + return new ApiRequestError(payload.message, { payload, response }) +} + +describe('useMediaClassification', () => { + beforeEach(() => { + clearMediaClassificationFieldCatalogCache() + for (const mock of Object.values(mocks)) mock.mockReset() + }) + + it('深拷贝活动快照和草稿,并在刷新时保留未保存编辑', async () => { + const first = createPolicy(1) + const second = createPolicy(2, '新电影') + mocks.getPolicy.mockResolvedValueOnce(first).mockResolvedValueOnce(second) + const classification = useMediaClassification() + + await classification.refreshPolicy() + expect(classification.activePolicy.value).toEqual(first) + expect(classification.draftPolicy.value).toEqual(first) + expect(classification.activePolicy.value).not.toBe(classification.draftPolicy.value) + expect(classification.activePolicy.value?.categories[0]).not.toBe(classification.draftPolicy.value?.categories[0]) + + classification.draftPolicy.value!.categories[0].name = '本地编辑' + expect(classification.isDirty.value).toBe(true) + expect(classification.activePolicy.value?.categories[0].name).toBe('电影') + + await classification.refreshPolicy() + expect(classification.activePolicy.value).toEqual(second) + expect(classification.draftPolicy.value?.categories[0].name).toBe('本地编辑') + expect(classification.isDirty.value).toBe(true) + }) + + it('草稿干净时随刷新同步,并可显式重置本地编辑', async () => { + mocks.getPolicy.mockResolvedValueOnce(createPolicy(1)).mockResolvedValueOnce(createPolicy(2, '新版')) + const classification = useMediaClassification() + + await classification.refreshPolicy() + await classification.refreshPolicy() + expect(classification.draftPolicy.value).toEqual(createPolicy(2, '新版')) + + classification.draftPolicy.value!.categories[0].name = '临时名称' + classification.resetDraft() + expect(classification.draftPolicy.value).toEqual(classification.activePolicy.value) + expect(classification.draftPolicy.value).not.toBe(classification.activePolicy.value) + expect(classification.isDirty.value).toBe(false) + }) + + it('跨 composable 实例缓存字段目录,并支持强制刷新', async () => { + mocks.getFields.mockResolvedValueOnce(createFieldCatalog()).mockResolvedValueOnce(createFieldCatalog('新目录')) + const first = useMediaClassification() + const second = useMediaClassification() + + await expect(first.loadFields()).resolves.toEqual(createFieldCatalog()) + await expect(second.loadFields()).resolves.toEqual(createFieldCatalog()) + expect(mocks.getFields).toHaveBeenCalledTimes(1) + expect(first.fieldCatalog.value).not.toBe(second.fieldCatalog.value) + + await expect(second.loadFields(true)).resolves.toEqual(createFieldCatalog('新目录')) + expect(mocks.getFields).toHaveBeenCalledTimes(2) + expect(second.fieldCatalog.value?.fields[0].label).toBe('新目录') + }) + + it('以当前草稿和活动 revision 驱动校验、预览、影响及历史查询', async () => { + const policy = createPolicy(4) + const facts = createFacts() + const validation: ClassificationValidationResult = { valid: true, issues: [] } + const evaluation: ClassificationEvaluation = { + facts, + result: { recommended: null, effective: null, labels: [], policy_revision: 4, state: 'complete' }, + trace: [], + warnings: [], + } + const impact: ClassificationImpactAnalysis = { + estimated: true, + sampled_at: '2026-09-02T00:00:00Z', + sample_source: 'request', + baseline_revision: 4, + candidate_revision: 5, + requested_limit: 10, + scanned_count: 1, + skipped_count: 0, + truncated: false, + sample_count: 1, + changed_count: 0, + unchanged_count: 1, + category_changed_count: 0, + path_only_changed_count: 0, + rule_changed_only_count: 0, + became_fallback_count: 0, + partial_count: 0, + degraded_count: 0, + previous_categories: { movie: 1 }, + candidate_categories: { movie: 1 }, + groups: [], + changes: [], + warnings: [], + } + const history: ClassificationPolicyHistory = { active_revision: 4, items: [createPolicy(3)] } + mocks.getPolicy.mockResolvedValue(policy) + mocks.validate.mockResolvedValue(validation) + mocks.preview.mockResolvedValue(evaluation) + mocks.analyzeImpact.mockResolvedValue(impact) + mocks.getHistory.mockResolvedValue(history) + const classification = useMediaClassification() + await classification.refreshPolicy() + + await expect(classification.validateDraft()).resolves.toEqual(validation) + await expect(classification.preview({ kind: 'facts', facts })).resolves.toEqual(evaluation) + await expect(classification.preview({ kind: 'facts', facts }, { policy: undefined })).resolves.toEqual(evaluation) + await expect(classification.preview({ kind: 'facts', facts }, { policy: null })).resolves.toEqual(evaluation) + await expect(classification.analyzeImpact({ sampleLimit: 10, exampleLimit: 2, samples: [facts] })).resolves.toEqual( + impact, + ) + await expect(classification.loadHistory()).resolves.toEqual(history) + + expect(mocks.validate).toHaveBeenCalledWith({ policy }) + expect(mocks.preview).toHaveBeenNthCalledWith(1, { input: { kind: 'facts', facts }, policy }) + expect(mocks.preview).toHaveBeenNthCalledWith(2, { input: { kind: 'facts', facts }, policy }) + expect(mocks.preview).toHaveBeenNthCalledWith(3, { input: { kind: 'facts', facts } }) + expect(mocks.analyzeImpact).toHaveBeenCalledWith({ + expected_revision: 4, + policy, + sample_limit: 10, + example_limit: 2, + samples: [facts], + }) + expect(classification.validationResult.value).toEqual(validation) + expect(classification.previewResult.value).toEqual(evaluation) + expect(classification.impactResult.value).toEqual(impact) + expect(classification.history.value).toEqual(history) + }) + + it('保留发布冲突和 422 校验 data,成功发布后重建干净草稿', async () => { + const initial = createPolicy(1) + const published = createPolicy(2, '已发布') + const conflict: ClassificationRevisionConflict = { + code: 'classification_revision_conflict', + expected_revision: 1, + current_revision: 2, + } + const conflictError = createHttpError(409, { success: false, message: 'revision 冲突', data: conflict }) + const validation: ClassificationValidationResult = { + valid: false, + issues: [ + { + severity: 'error', + code: 'unknown_field', + message: '字段不存在', + path: ['rules', 0, 'when', 'field'], + }, + ], + } + const validationError = createHttpError(422, { success: false, message: '校验失败', data: validation }) + mocks.getPolicy.mockResolvedValue(initial) + mocks.publish.mockRejectedValueOnce(conflictError).mockResolvedValueOnce(published) + mocks.validate.mockRejectedValueOnce(validationError) + const classification = useMediaClassification() + await classification.refreshPolicy() + classification.draftPolicy.value!.categories[0].name = '本地编辑' + + await expect(classification.publishDraft()).rejects.toBe(conflictError) + expect(classification.conflict.value).toEqual(conflict) + expect(classification.draftPolicy.value?.categories[0].name).toBe('本地编辑') + + await expect(classification.validateDraft()).rejects.toBe(validationError) + expect(classification.validationResult.value).toEqual(validation) + + const publishedDraft = JSON.parse(JSON.stringify(classification.draftPolicy.value)) as ClassificationPolicy + await expect(classification.publishDraft()).resolves.toEqual(published) + expect(mocks.publish).toHaveBeenLastCalledWith({ expected_revision: 1, policy: publishedDraft }) + expect(classification.activePolicy.value).toEqual(published) + expect(classification.draftPolicy.value).toEqual(published) + expect(classification.draftPolicy.value).not.toBe(classification.activePolicy.value) + expect(classification.isDirty.value).toBe(false) + expect(classification.conflict.value).toBeNull() + }) + + it('回滚更新活动快照但不覆盖已有未保存草稿', async () => { + const initial = createPolicy(5) + const rolledBack = createPolicy(6, '回滚版本') + const facts = createFacts() + const evaluation: ClassificationEvaluation = { + facts, + result: { recommended: null, effective: null, labels: [], policy_revision: 5, state: 'complete' }, + trace: [], + warnings: [], + } + const impact = { + estimated: true, + sampled_at: '2026-09-02T00:00:00Z', + sample_source: 'request', + baseline_revision: 5, + candidate_revision: 6, + requested_limit: 1, + scanned_count: 1, + skipped_count: 0, + truncated: false, + sample_count: 1, + changed_count: 0, + unchanged_count: 1, + category_changed_count: 0, + path_only_changed_count: 0, + rule_changed_only_count: 0, + became_fallback_count: 0, + partial_count: 0, + degraded_count: 0, + previous_categories: {}, + candidate_categories: {}, + groups: [], + changes: [], + warnings: [], + } satisfies ClassificationImpactAnalysis + mocks.getPolicy.mockResolvedValue(initial) + mocks.preview.mockResolvedValue(evaluation) + mocks.analyzeImpact.mockResolvedValue(impact) + mocks.rollback.mockResolvedValue({ restored_from_revision: 2, policy: rolledBack }) + const classification = useMediaClassification() + await classification.refreshPolicy() + await classification.preview({ kind: 'facts', facts }) + await classification.analyzeImpact({ samples: [facts] }) + classification.draftPolicy.value!.categories[0].name = '待发布草稿' + + await expect(classification.rollback(2)).resolves.toEqual({ + restored_from_revision: 2, + policy: rolledBack, + }) + + expect(mocks.rollback).toHaveBeenCalledWith(2, { expected_revision: 5 }) + expect(classification.activePolicy.value).toEqual(rolledBack) + expect(classification.draftPolicy.value?.categories[0].name).toBe('待发布草稿') + expect(classification.isDirty.value).toBe(true) + expect(classification.previewResult.value).toBeNull() + expect(classification.impactResult.value).toBeNull() + }) +}) diff --git a/src/composables/useMediaClassification.ts b/src/composables/useMediaClassification.ts new file mode 100644 index 00000000..76b33f7b --- /dev/null +++ b/src/composables/useMediaClassification.ts @@ -0,0 +1,350 @@ +import { computed, readonly, ref } from 'vue' +import { cloneDeep, isEqual } from 'lodash-es' +import { + analyzeClassificationImpact, + getClassificationFields, + getClassificationHistory, + getClassificationPolicy, + getClassificationRevisionConflict, + getClassificationValidationFailure, + previewClassificationPolicy, + publishClassificationPolicy, + rollbackClassificationPolicy, + validateClassificationPolicy, + type ClassificationEvaluation, + type ClassificationFacts, + type ClassificationFieldCatalog, + type ClassificationImpactAnalysis, + type ClassificationPolicy, + type ClassificationPolicyHistory, + type ClassificationPolicyRollbackResult, + type ClassificationPreviewInput, + type ClassificationRevisionConflict, + type ClassificationValidationResult, +} from '@/api/mediaClassification' + +/** 分类影响分析的可选采样参数。 */ +export interface ClassificationImpactOptions { + policy?: ClassificationPolicy + sampleLimit?: number + exampleLimit?: number + samples?: ClassificationFacts[] +} + +/** 分类预览可显式选择草稿策略或活动策略。 */ +export interface ClassificationPreviewOptions { + /** undefined 使用当前草稿,null 使用服务端活动策略。 */ + policy?: ClassificationPolicy | null +} + +let fieldCatalogCache: ClassificationFieldCatalog | null = null +let fieldCatalogPromise: Promise | null = null +let fieldCatalogEpoch = 0 + +/** 清除共享字段目录缓存,供插件字段注册变化或测试隔离时显式刷新。 */ +export function clearMediaClassificationFieldCatalogCache(): void { + fieldCatalogEpoch += 1 + fieldCatalogCache = null + fieldCatalogPromise = null +} + +/** 读取共享字段目录,并隔离每个调用方拿到的可变对象。 */ +async function resolveFieldCatalog(force: boolean): Promise { + if (!force && fieldCatalogCache) return cloneDeep(fieldCatalogCache) + if (!force && fieldCatalogPromise) return cloneDeep(await fieldCatalogPromise) + + if (force) clearMediaClassificationFieldCatalogCache() + const requestEpoch = fieldCatalogEpoch + const request = getClassificationFields() + .then(catalog => { + const snapshot = cloneDeep(catalog) + if (requestEpoch === fieldCatalogEpoch) fieldCatalogCache = snapshot + return cloneDeep(snapshot) + }) + .finally(() => { + if (fieldCatalogPromise === request) fieldCatalogPromise = null + }) + + fieldCatalogPromise = request + return request +} + +/** + * 管理媒体分类策略的活动快照、可编辑草稿和全部只读分析操作。 + * + * 活动策略始终与草稿深拷贝隔离;刷新只在草稿干净时同步草稿,避免后台刷新覆盖未保存编辑。 + */ +export function useMediaClassification() { + const activePolicyState = ref(null) + const draftPolicy = ref(null) + const fieldCatalogState = ref(null) + const historyState = ref(null) + const validationState = ref(null) + const previewState = ref(null) + const impactState = ref(null) + const conflictState = ref(null) + const lastError = ref(null) + + const loadingPolicy = ref(false) + const loadingFields = ref(false) + const loadingHistory = ref(false) + const validating = ref(false) + const previewing = ref(false) + const analyzingImpact = ref(false) + const publishing = ref(false) + const rollingBack = ref(false) + + const activeRevision = computed(() => activePolicyState.value?.revision ?? 0) + const isDirty = computed(() => { + if (!draftPolicy.value) return false + if (!activePolicyState.value) return true + return !isEqual(draftPolicy.value, activePolicyState.value) + }) + + /** 返回当前草稿;未加载策略时拒绝执行依赖草稿的操作。 */ + function requireDraft(): ClassificationPolicy { + if (!draftPolicy.value) throw new Error('分类策略草稿尚未加载') + return draftPolicy.value + } + + /** 返回活动策略;未加载策略时拒绝 CAS 写操作。 */ + function requireActive(): ClassificationPolicy { + if (!activePolicyState.value) throw new Error('活动分类策略尚未加载') + return activePolicyState.value + } + + /** 记录结构化冲突或校验错误,同时保留原始异常供 UI 决定提示方式。 */ + function captureError(error: unknown): void { + lastError.value = error + const conflict = getClassificationRevisionConflict(error) + if (conflict) conflictState.value = cloneDeep(conflict) + const validation = getClassificationValidationFailure(error) + if (validation) validationState.value = cloneDeep(validation) + } + + /** 应用服务端活动快照,并按调用语义决定是否同步草稿。 */ + function applyActivePolicy(policy: ClassificationPolicy, preserveDirtyDraft: boolean): void { + activePolicyState.value = cloneDeep(policy) + if (!preserveDirtyDraft || !draftPolicy.value) draftPolicy.value = cloneDeep(policy) + } + + /** 刷新活动策略;响应到达时若草稿已脏则只更新活动快照。 */ + async function refreshPolicy(): Promise { + loadingPolicy.value = true + lastError.value = null + try { + const policy = await getClassificationPolicy() + const preserveDirtyDraft = isDirty.value + applyActivePolicy(policy, preserveDirtyDraft) + conflictState.value = null + return cloneDeep(policy) + } catch (error) { + captureError(error) + throw error + } finally { + loadingPolicy.value = false + } + } + + /** 读取字段目录;默认复用跨组件缓存,force=true 时重新请求。 */ + async function loadFields(force = false): Promise { + loadingFields.value = true + lastError.value = null + try { + const catalog = await resolveFieldCatalog(force) + fieldCatalogState.value = cloneDeep(catalog) + return cloneDeep(catalog) + } catch (error) { + captureError(error) + throw error + } finally { + loadingFields.value = false + } + } + + /** 读取有界策略历史。 */ + async function loadHistory(): Promise { + loadingHistory.value = true + lastError.value = null + try { + const history = await getClassificationHistory() + historyState.value = cloneDeep(history) + return cloneDeep(history) + } catch (error) { + captureError(error) + throw error + } finally { + loadingHistory.value = false + } + } + + /** 使用服务端真实字段目录校验指定策略或当前草稿。 */ + async function validateDraft(policy: ClassificationPolicy = requireDraft()): Promise { + validating.value = true + lastError.value = null + validationState.value = null + try { + const result = await validateClassificationPolicy({ policy: cloneDeep(policy) }) + validationState.value = cloneDeep(result) + return cloneDeep(result) + } catch (error) { + captureError(error) + throw error + } finally { + validating.value = false + } + } + + /** 对显式事实执行当前草稿预览;policy=null 时预览服务端活动策略。 */ + async function preview( + input: ClassificationPreviewInput, + options: ClassificationPreviewOptions = {}, + ): Promise { + previewing.value = true + lastError.value = null + previewState.value = null + try { + const selectedPolicy = options.policy === null ? null : (options.policy ?? requireDraft()) + const result = await previewClassificationPolicy({ + input: cloneDeep(input), + ...(selectedPolicy ? { policy: cloneDeep(selectedPolicy) } : {}), + }) + previewState.value = cloneDeep(result) + return cloneDeep(result) + } catch (error) { + captureError(error) + throw error + } finally { + previewing.value = false + } + } + + /** 比较活动 revision 和当前草稿,并保存有界影响分析结果。 */ + async function analyzeImpact(options: ClassificationImpactOptions = {}): Promise { + analyzingImpact.value = true + lastError.value = null + impactState.value = null + conflictState.value = null + try { + const active = requireActive() + const policy = options.policy ?? requireDraft() + const result = await analyzeClassificationImpact({ + expected_revision: active.revision, + policy: cloneDeep(policy), + ...(options.sampleLimit === undefined ? {} : { sample_limit: options.sampleLimit }), + ...(options.exampleLimit === undefined ? {} : { example_limit: options.exampleLimit }), + ...(options.samples === undefined ? {} : { samples: cloneDeep(options.samples) }), + }) + impactState.value = cloneDeep(result) + return cloneDeep(result) + } catch (error) { + captureError(error) + throw error + } finally { + analyzingImpact.value = false + } + } + + /** 以活动 revision 发布当前草稿,并用服务端返回的新版本重建快照和草稿。 */ + async function publishDraft(): Promise { + publishing.value = true + lastError.value = null + conflictState.value = null + validationState.value = null + try { + const active = requireActive() + const policy = await publishClassificationPolicy({ + expected_revision: active.revision, + policy: cloneDeep(requireDraft()), + }) + applyActivePolicy(policy, false) + historyState.value = null + previewState.value = null + impactState.value = null + return cloneDeep(policy) + } catch (error) { + captureError(error) + throw error + } finally { + publishing.value = false + } + } + + /** 回滚历史内容为新 revision;已有未保存草稿不会被回滚响应覆盖。 */ + async function rollback(revision: number): Promise { + rollingBack.value = true + lastError.value = null + conflictState.value = null + validationState.value = null + try { + const active = requireActive() + const preserveDirtyDraft = isDirty.value + const result = await rollbackClassificationPolicy(revision, { + expected_revision: active.revision, + }) + applyActivePolicy(result.policy, preserveDirtyDraft) + historyState.value = null + previewState.value = null + impactState.value = null + return cloneDeep(result) + } catch (error) { + captureError(error) + throw error + } finally { + rollingBack.value = false + } + } + + /** 用指定策略替换可编辑草稿,不修改活动快照。 */ + function replaceDraft(policy: ClassificationPolicy): void { + draftPolicy.value = cloneDeep(policy) + validationState.value = null + previewState.value = null + impactState.value = null + conflictState.value = null + } + + /** 放弃未保存编辑并从当前活动快照重建草稿。 */ + function resetDraft(): void { + const active = requireActive() + replaceDraft(active) + } + + /** 并行加载首屏必需的活动策略和动态字段目录。 */ + async function initialize(): Promise { + await Promise.all([refreshPolicy(), loadFields()]) + } + + return { + activePolicy: readonly(activePolicyState), + draftPolicy, + fieldCatalog: readonly(fieldCatalogState), + history: readonly(historyState), + validationResult: readonly(validationState), + previewResult: readonly(previewState), + impactResult: readonly(impactState), + conflict: readonly(conflictState), + lastError: readonly(lastError), + activeRevision, + isDirty, + loadingPolicy: readonly(loadingPolicy), + loadingFields: readonly(loadingFields), + loadingHistory: readonly(loadingHistory), + validating: readonly(validating), + previewing: readonly(previewing), + analyzingImpact: readonly(analyzingImpact), + publishing: readonly(publishing), + rollingBack: readonly(rollingBack), + initialize, + refreshPolicy, + loadFields, + loadHistory, + validateDraft, + preview, + analyzeImpact, + publishDraft, + rollback, + replaceDraft, + resetDraft, + } +} diff --git a/src/locales/en-US.ts b/src/locales/en-US.ts index 5b793907..22fcacf4 100644 --- a/src/locales/en-US.ts +++ b/src/locales/en-US.ts @@ -567,6 +567,10 @@ 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', @@ -1926,6 +1930,346 @@ export default { }, }, setting: { + classification: { + title: 'Media Auto Classification', + description: 'Configure shared movie, TV, and music rules with stable category IDs and dynamic fields.', + revision: 'Active revision {revision}', + unsaved: 'Unsaved changes', + loading: 'Loading the classification policy and field catalog...', + loadFailed: 'Failed to load the classification policy', + validateDraft: 'Validate Draft', + discardDraft: 'Discard Changes', + validationPassed: 'Draft validation passed', + validationFailed: 'Draft has {count} issues', + validationRequestFailed: 'Draft validation request failed', + validationIssues: '{count} validation issues', + draftReset: 'Restored the active policy', + enrichmentTitle: 'Classification Fact 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.', + source: 'Source', + 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', + impactTab: 'Impact Analysis', + publishTab: 'Publish & History', + previewFailed: 'Classification preview request failed', + impactFailed: 'Classification impact analysis failed', + publishSucceeded: 'Classification policy published as revision {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}', + rollbackFailed: 'Classification policy rollback failed', + directoryReferencesUnavailable: 'Directory references unavailable', + directoryReferencesUnavailableHint: + 'Complete directory reference protection cannot be shown. The server will still reject invalid validation or publish requests.', + category: { + title: 'Category Tree', + description: + 'Paths support up to {count} levels. Rules, fallbacks, and directories always reference stable IDs.', + add: 'Add {mediaType} category', + mediaTypeSegments: 'Category media type', + editTitle: 'Edit Category', + addTitle: 'Add Category', + cancelEdit: 'Cancel category edit', + save: 'Save category', + name: 'Category Name', + stableId: 'Stable ID', + 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', + path: 'Category Path', + pathHint: 'Separate levels with /. Maximum {count} levels', + mediaType: 'Media Type', + enabled: 'Enable Category', + protectedEditTitle: 'This category is referenced', + protectedEditHint: + 'You can change its name and path, but references must be cleared before disabling, changing media type, or deleting it.', + listAria: '{mediaType} category list', + enabledState: 'Enabled', + disabledState: 'Disabled', + pathAria: 'Category path for {name}', + 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', + pathRequired: 'Category path is required', + pathEmptySegment: 'Category path cannot contain an empty level', + pathTooDeep: 'Category path supports at most {count} levels', + ruleReference: 'Referenced by a classification rule or source fallback', + globalFallbackReference: 'Used as the global fallback for {mediaTypes}', + directoryReference: 'Referenced by directories: {directories}', + listSeparator: ', ', + reasonSeparator: '; ', + deleteBlocked: 'Cannot delete "{name}": {reasons}', + delete: 'Delete category "{name}"', + protectedHint: 'Category "{name}" is protected: {reasons}', + addingStatus: 'Adding a {mediaType} category', + editingStatus: 'Editing category "{name}"', + cancelledStatus: 'Category editing cancelled', + nameRequired: 'Category name is required', + idRequired: 'Stable ID is required', + idDuplicate: 'Stable ID "{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', + }, + preview: { + title: 'Fact Preview and Match Explanation', + 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', + mediaSource: 'Media Source', + mediaSourcePlaceholder: 'For example, themoviedb or musicbrainz', + mediaId: 'Media ID', + mediaIdPlaceholder: 'Stable ID within the 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.', + status: 'Status', + policyRevision: 'Policy Revision', + recommended: 'Recommended Category', + effective: 'Effective Category', + rule: 'Rule', + none: 'None', + source: 'Source', + labels: 'Labels', + warnings: 'Warnings', + warningField: 'Field {field}', + warningSource: 'Source {source}', + trace: 'Rule Match Explanation', + noRules: 'No rules were evaluated.', + 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.', + root: 'Root', + missing: 'Not Provided', + mediaTypes: { + movie: 'Movie', + tv: 'TV Show', + music: 'Music', + }, + boolean: { + unset: 'Not Set', + yes: 'Yes', + no: 'No', + }, + support: { + partial: 'Partially supported by the current source', + unavailable: 'Unavailable from the current source', + extension: 'Provided by the current source extension', + }, + groups: { + sourceExtension: 'Source Extensions', + shared: 'Shared Fields', + }, + validation: { + mediaSourceRequired: 'Media source is required', + mediaIdRequired: 'Media ID is required', + }, + selection: { + unmatched: 'No Category Matched', + unknown: 'Unknown Category', + unsetPath: 'Path Not Set', + summary: '{name} · {path} · {id}', + }, + selectionSource: { + automatic: 'Rule Match', + sourceFallback: 'Source Fallback', + fallback: 'Global Fallback', + }, + states: { + complete: 'Complete', + partial: 'Partially Complete', + notEvaluated: 'Not Evaluated', + invalidPolicy: 'Invalid Policy', + }, + columns: { + result: 'Result', + field: 'Field', + operator: 'Operator', + expected: 'Expected', + actual: 'Actual', + factSource: 'Fact Source', + path: '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})', + sampleLimit: 'Maximum Samples', + exampleLimit: 'Change Example Limit', + analyzeAria: 'Analyze the impact of the current classification draft', + analyze: 'Analyze', + scope: + '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}', + sampledAt: 'Sampled at {time}', + overviewTitle: 'Bounded Estimate Summary', + truncated: + 'This result was truncated by the sample scan range or change example limit. Records not shown must not be assumed unchanged.', + changeTypesTitle: 'Classification Change Types', + categoriesTitle: 'Category Count Comparison', + previousCategoriesAria: 'Active policy category counts', + candidateCategoriesAria: 'Candidate policy category counts', + emptyCategoryCounts: 'No category counts', + groupsTitle: 'Grouped by Media Type and Source', + groupsAria: 'Impact groups by media type and source', + emptyGroups: 'This sample has no media type and source groups to display.', + examplesTitle: 'Limited Change Examples', + exampleSummary: '{returned} returned; {changed} changes detected in total', + examplesAria: 'Limited change example list', + exampleAria: 'Change example {index}: {title}', + untitledMedia: 'Untitled Media', + changedFieldsAria: 'Changed fields', + previousResultAria: 'Active policy result for change example {index}', + candidateResultAria: 'Candidate policy result for change example {index}', + previous: 'Active Policy', + candidate: 'Candidate Policy', + emptyExamples: 'No classification change examples were returned from the limited sample.', + warningsTitle: 'Estimate Warnings', + emptyAnalysis: + 'No impact analysis has been generated. Results are always presented as bounded-sample estimates.', + uncategorized: 'Uncategorized', + noCategoryPath: 'No Category Path', + none: 'None', + yes: 'Yes', + no: 'No', + sampleSources: { + request: 'Facts explicitly supplied in the request', + recentHistory: 'Recent download and organization history', + }, + states: { + complete: 'Complete', + partial: 'Incomplete Facts', + notEvaluated: 'Not Evaluated', + invalidPolicy: 'Invalid Policy', + }, + changedFields: { + categoryId: 'Category ID', + categoryPath: 'Category Path', + ruleId: 'Matched Rule', + labels: 'Output Labels', + state: 'Evaluation State', + }, + metrics: { + requestedLimit: 'Requested Limit', + scannedCount: 'Records Scanned', + skippedCount: 'Records Skipped', + truncated: 'Result Truncated', + sampleCount: 'Valid Samples', + changedCount: 'Changed', + unchangedCount: 'Unchanged', + categoryChangedCount: 'Stable Category Changes', + pathOnlyChangedCount: 'Path-only Changes', + ruleChangedOnlyCount: 'Matched Rule-only Changes', + becameFallbackCount: 'Changed to Fallback Category', + partialCount: 'Incomplete Facts', + degradedCount: 'Candidate Result Regressions', + }, + columns: { + mediaType: 'Media Type', + source: 'Data Source', + sample: 'Samples', + changed: 'Changed', + degraded: 'Regressed', + }, + resultFields: { + category: 'Category', + path: 'Path', + rule: 'Rule', + source: 'Source', + state: 'State', + }, + }, + control: { + title: 'Version Publishing and History', + description: + 'Publishing and rollback both use the active revision for CAS. Local drafts are not overwritten automatically when a conflict occurs.', + policyStatusAria: 'Current policy status', + unpublishedChanges: 'Unpublished Changes', + conflictTitle: 'Revision Conflict Detected', + conflictDescription: + 'The local operation is based on revision {expected}, while the server is currently at revision {current}. The local draft has been preserved. Synchronize the remote state and rerun the analysis.', + reloadRemote: 'Reload Remote State', + keepDraft: 'Keep Draft and Reanalyze', + publishTitle: 'Publish New Version', + publishDescription: + 'Server validation and impact analysis must both match the current draft. You must also confirm that you reviewed the analysis before publishing.', + validateDraftAria: 'Validate the current draft', + serverValidation: 'Server Validation', + analyzeDraftAria: 'Analyze draft impact', + impactAnalysis: 'Impact Analysis', + publishRequirementsAria: 'Publishing prerequisites', + latestImpactAria: 'Latest impact analysis summary', + sample: 'Samples', + classificationChanges: 'Classification Changes', + degraded: 'Regressions', + analysisTime: 'Analyzed at: {time}', + impactExpired: 'This result is stale. Run impact analysis again.', + reviewConfirmation: 'I reviewed the latest impact analysis and confirm that this draft can be published', + publishAria: 'Publish a new classification policy version', + publish: 'Publish New Version', + historyTitle: 'Version History', + historyDescription: + 'After you select historical content, the server will run CAS against revision {revision} and create a new revision.', + refreshHistory: 'Refresh Version History', + loadingHistory: 'Loading version history', + historyNotLoaded: 'Version history has not been loaded', + historyEmpty: 'No historical versions are available for rollback', + selectHistoryLegend: 'Select a historical version to restore', + selectHistoryAria: 'Select revision {revision}, with {categories} categories and {rules} rules', + updatedAtUnknown: 'Update time unknown', + categoryCount: '{count} categories', + ruleCount: '{count} rules', + rollbackNotice: + 'Rollback does not rewrite or delete old versions. The selected historical content is published through CAS as a new revision.', + rollbackAria: 'Publish the selected historical version as a new version', + selectBeforeRollback: 'Select a Historical Version to Roll Back', + rollbackRevision: 'Roll Back Revision {revision} as a New Version', + publishingStatus: 'Publishing the classification policy based on revision {revision}', + keepDraftStatus: 'Local draft preserved; reanalyzing impact', + rollbackStatus: 'Publishing revision {revision} as a new version', + requirements: { + dirtyPassed: 'The draft has unpublished changes', + dirtyPending: 'Modify the classification policy draft first', + validationPassed: 'The current draft passed server validation', + validationPending: 'The current draft has not passed server validation', + impactPassed: 'Impact analysis is based on current revision {revision}', + impactPending: 'Run a current impact analysis for this draft', + reviewPassed: 'Impact analysis review confirmed', + reviewPending: 'Impact analysis review not confirmed', + conflictPending: 'Resolve the revision conflict first', + conflictPassed: 'No revision conflict is present', + }, + }, + }, about: { title: 'About MoviePilot', softwareVersion: 'Software Version', @@ -2790,25 +3134,19 @@ export default { defaultDirName: 'Directory', storageSaveSuccess: 'Storage settings saved successfully', storageSaveFailed: 'Failed to save storage settings!', - }, - category: { - title: 'Category Policy', - subtitle: 'Configure media auto-categorization rules by type, language, region, etc.', - movie: 'Movies', - tv: 'TV Shows', - name: 'Category Name (Directory)', - genre: 'Genre', - language: 'Language', - languagePlaceholder: 'e.g., en,fr,zh (comma separated)', - country: 'Country/Region', - countryPlaceholder: 'e.g., US,CN,JP', - year: 'Year', - yearPlaceholder: 'e.g., 2023, 2020-2024', - addMovie: 'Add Movie Category', - addTv: 'Add TV Category', - saveSuccess: 'Category policy saved successfully', - loadFailed: 'Failed to load category configuration', - saveFailed: 'Save failed: {message}', + classification: { + categoryLabel: 'Fixed Category', + snapshotLabel: 'Category Path Snapshot', + snapshotPending: 'The server will fill the current canonical path after saving', + categoryMissing: 'Category ID {id} does not exist. Select another category.', + categoryDisabled: 'Category ID {id} is disabled. Select another category.', + mediaTypeMismatch: + 'The category media type is {categoryType}, which does not match the directory type {directoryType}.', + legacyAmbiguous: 'Legacy category path {path} matches multiple categories. Select one manually.', + legacyUnresolved: 'Legacy category path {path} does not match the current policy. Select another category.', + saveBlocked: 'One or more directory category references are invalid. Fix them before saving.', + loadFailed: 'Failed to load the classification policy. Directory categories cannot be edited.', + }, }, rule: { customRules: 'Custom Rules', diff --git a/src/locales/zh-CN.ts b/src/locales/zh-CN.ts index afb6fd8b..cd7e5143 100644 --- a/src/locales/zh-CN.ts +++ b/src/locales/zh-CN.ts @@ -551,6 +551,10 @@ export default { title: '存储 & 目录', description: '下载目录、媒体库目录、整理、刮削', }, + classification: { + title: '自动分类', + description: '电影、电视剧和音乐的分类树、数据源范围与组合规则', + }, site: { title: '站点', description: '站点同步、站点数据刷新、站点重置', @@ -1907,6 +1911,332 @@ export default { }, }, setting: { + classification: { + title: '媒体自动分类', + description: '使用稳定分类 ID 和动态字段,为电影、电视剧和音乐配置统一规则。', + revision: '活动版本 {revision}', + unsaved: '有未保存修改', + loading: '正在加载分类策略和字段目录...', + loadFailed: '分类策略加载失败', + validateDraft: '校验草稿', + discardDraft: '放弃修改', + validationPassed: '草稿校验通过', + validationFailed: '草稿存在 {count} 个问题', + validationRequestFailed: '草稿校验请求失败', + validationIssues: '{count} 个校验问题', + draftReset: '已恢复当前活动策略', + enrichmentTitle: '分类事实来源', + enrichmentHint: '仅在活动规则引用的标准事实缺失时调用已登记数据源;补充结果不会覆盖主来源事实或改变媒体身份。', + enrichmentModeLabel: '跨来源补充', + enrichmentPrimaryOnly: '仅主来源', + enrichmentMissing: '补充缺失事实', + sourceFallbacks: '数据源专用兜底', + sourceFallbacksHint: '仅在对应数据源规则未命中时使用,保存稳定分类 ID。', + source: '数据源', + sourceFallbackFor: '{source} 的{mediaType}来源兜底', + analysisTitle: '验证、预览与版本控制', + analysisHint: '在发布前检查事实命中、估算近期样本影响并审阅版本历史。', + previewTab: '事实预览', + impactTab: '影响分析', + publishTab: '发布与历史', + previewFailed: '分类预览请求失败', + impactFailed: '分类影响分析失败', + publishSucceeded: '分类策略已发布为 revision {revision}', + publishFailed: '分类策略发布失败', + remoteReloaded: '已重新加载服务端活动策略', + remoteReloadFailed: '重新加载服务端策略失败', + historyFailed: '分类策略历史加载失败', + rollbackSucceeded: 'revision {source} 已回滚并发布为 revision {revision}', + rollbackFailed: '分类策略回滚失败', + directoryReferencesUnavailable: '目录引用加载失败', + directoryReferencesUnavailableHint: '当前无法展示完整目录引用保护;服务端仍会在校验和发布时阻止无效变更。', + category: { + title: '分类树', + description: '分类路径最多 {count} 级,规则、兜底和目录配置始终引用稳定 ID。', + add: '新增{mediaType}分类', + mediaTypeSegments: '分类媒体类型', + editTitle: '编辑分类', + addTitle: '新增分类', + cancelEdit: '取消分类编辑', + save: '保存分类', + name: '分类名称', + stableId: '稳定 ID', + existingIdHint: '稳定 ID 创建后不可修改,规则、兜底、目录和历史快照会持续引用该值', + newIdHint: '创建后由规则、兜底、目录和历史快照引用', + path: '分类路径', + pathHint: '使用 / 分隔层级,最多 {count} 级', + mediaType: '媒体类型', + enabled: '启用分类', + protectedEditTitle: '此分类正在被引用', + protectedEditHint: '可修改名称和路径,但必须先清理引用才能停用、改变媒体类型或删除分类。', + listAria: '{mediaType}分类列表', + enabledState: '已启用', + disabledState: '已停用', + pathAria: '{name}分类路径', + pathUnset: '未设置路径', + edit: '编辑分类“{name}”', + empty: '暂无{mediaType}分类', + fallbackTitle: '回退分类', + fallbackHint: '未命中规则时按媒体类型选择分类,保存值为稳定 ID。', + fallbackFor: '{mediaType}回退分类', + pathRequired: '分类路径不能为空', + pathEmptySegment: '分类路径不能包含空层级', + pathTooDeep: '分类路径最多支持 {count} 级', + ruleReference: '已被分类规则或来源兜底引用', + globalFallbackReference: '已设为{mediaTypes}全局兜底分类', + directoryReference: '已被目录配置引用:{directories}', + listSeparator: '、', + reasonSeparator: ';', + deleteBlocked: '不能删除“{name}”:{reasons}', + delete: '删除分类“{name}”', + protectedHint: '分类“{name}”受引用保护:{reasons}', + addingStatus: '正在新增{mediaType}分类', + editingStatus: '正在编辑分类“{name}”', + cancelledStatus: '已取消分类编辑', + nameRequired: '分类名称不能为空', + idRequired: '稳定 ID 不能为空', + idDuplicate: '稳定 ID “{id}” 已存在', + protectedMutationBlocked: '被引用的分类不能停用或改变媒体类型,请先清理引用', + updatedStatus: '已更新分类“{name}”', + deletedStatus: '已删除分类“{name}”', + fallbackUpdatedStatus: '已更新{mediaType}回退分类', + fallbackClearedStatus: '已清除{mediaType}回退分类', + }, + preview: { + title: '事实预览与命中解释', + description: '使用稳定媒体身份和字段目录构造事实,预览不会修改活动策略或来源身份。', + run: '执行事实预览', + modeLabel: '预览策略', + draftPolicy: '草稿策略', + activePolicy: '活动策略', + factsTitle: '预览事实', + mediaSource: '媒体来源', + mediaSourcePlaceholder: '例如 themoviedb、musicbrainz', + mediaId: '媒体 ID', + mediaIdPlaceholder: '来源内稳定 ID', + mediaType: '媒体类型', + noEditableFields: '当前媒体类型没有可编辑字段。', + resultTitle: '预览结果', + loading: '正在执行事实预览', + emptyResult: '填写事实并执行预览后查看命中解释。', + status: '状态', + policyRevision: '策略 revision', + recommended: '推荐分类', + effective: '生效分类', + rule: '规则', + none: '无', + source: '来源', + labels: '标签', + warnings: '警告', + warningField: '字段 {field}', + warningSource: '来源 {source}', + trace: '规则命中解释', + noRules: '没有执行任何规则。', + matched: '命中', + notMatched: '未命中', + traceTableAria: '规则 {rule} 的条件命中解释', + conditionMatched: '条件命中', + conditionNotMatched: '条件未命中', + noConditionTrace: '该规则没有条件轨迹。', + root: '根', + missing: '未提供', + mediaTypes: { + movie: '电影', + tv: '电视剧', + music: '音乐', + }, + boolean: { + unset: '未设置', + yes: '是', + no: '否', + }, + support: { + partial: '当前来源部分支持', + unavailable: '当前来源不可用', + extension: '由当前来源扩展提供', + }, + groups: { + sourceExtension: '来源扩展', + shared: '共享字段', + }, + validation: { + mediaSourceRequired: '媒体来源不能为空', + mediaIdRequired: '媒体 ID 不能为空', + }, + selection: { + unmatched: '未命中分类', + unknown: '未知分类', + unsetPath: '未设置路径', + summary: '{name} · {path} · {id}', + }, + selectionSource: { + automatic: '规则命中', + sourceFallback: '来源兜底', + fallback: '全局兜底', + }, + states: { + complete: '完整', + partial: '部分完成', + notEvaluated: '未求值', + invalidPolicy: '策略无效', + }, + columns: { + result: '结果', + field: '字段', + operator: '操作符', + expected: 'Expected', + actual: 'Actual', + factSource: '事实来源', + path: 'Path', + }, + }, + impact: { + title: '影响分析', + description: '使用有限样本比较活动策略与当前草稿,不代表全库精确统计。', + sampleSource: 'sample_source: {source}({label})', + sampleLimit: '最大样本数', + exampleLimit: '变化示例上限', + analyzeAria: '分析当前分类草稿影响', + analyze: '开始分析', + scope: '最多比较 200 条样本并返回 50 条变化示例;分析仅执行只读求值,不移动文件或修改历史。', + loading: '正在生成有界样本估算…', + metadataAria: '影响分析元数据', + baselineRevision: '活动 revision {revision}', + candidateRevision: '候选 revision {revision}', + sampledAt: '采样于 {time}', + overviewTitle: '有界估算汇总', + truncated: '本次结果因样本扫描范围或变化示例上限而截断,未展示的记录不应推断为无变化。', + changeTypesTitle: '分类变化类型', + categoriesTitle: '分类计数对比', + previousCategoriesAria: '活动策略分类计数', + candidateCategoriesAria: '候选策略分类计数', + emptyCategoryCounts: '无分类计数', + groupsTitle: '按媒体类型与来源分组', + groupsAria: '媒体类型与来源影响分组表', + emptyGroups: '本次样本没有可展示的媒体类型与来源分组。', + examplesTitle: '有限变化示例', + exampleSummary: '返回 {returned} 条,共检测到 {changed} 条变化', + examplesAria: '有限变化示例列表', + exampleAria: '变化示例 {index}:{title}', + untitledMedia: '未命名媒体', + changedFieldsAria: '变化字段', + previousResultAria: '变化示例 {index} 的活动策略结果', + candidateResultAria: '变化示例 {index} 的候选策略结果', + previous: '活动策略', + candidate: '候选策略', + emptyExamples: '有限样本内未返回分类变化示例。', + warningsTitle: '估算警告', + emptyAnalysis: '尚未生成影响分析。结果将始终以有界样本估算展示。', + uncategorized: '未分类', + noCategoryPath: '无分类路径', + none: '无', + yes: '是', + no: '否', + sampleSources: { + request: '请求内显式事实', + recentHistory: '近期下载与整理历史', + }, + states: { + complete: '完整', + partial: '事实不完整', + notEvaluated: '未求值', + invalidPolicy: '策略无效', + }, + changedFields: { + categoryId: '分类 ID', + categoryPath: '分类路径', + ruleId: '命中规则', + labels: '输出标签', + state: '求值状态', + }, + metrics: { + requestedLimit: '请求上限', + scannedCount: '扫描记录', + skippedCount: '跳过记录', + truncated: '结果截断', + sampleCount: '有效样本', + changedCount: '发生变化', + unchangedCount: '保持不变', + categoryChangedCount: '稳定分类变化', + pathOnlyChangedCount: '仅路径变化', + ruleChangedOnlyCount: '仅命中规则变化', + becameFallbackCount: '转为兜底分类', + partialCount: '存在不完整事实', + degradedCount: '候选结果降级', + }, + columns: { + mediaType: '媒体类型', + source: '数据来源', + sample: '样本', + changed: '变化', + degraded: '降级', + }, + resultFields: { + category: '分类', + path: '路径', + rule: '规则', + source: '来源', + state: '状态', + }, + }, + control: { + title: '版本发布与历史', + description: '发布与回滚均使用当前活动 revision 执行 CAS,冲突时本地草稿不会被自动覆盖。', + policyStatusAria: '当前策略状态', + unpublishedChanges: '有未发布修改', + conflictTitle: '检测到 revision 冲突', + conflictDescription: + '本地操作基于 revision {expected},服务端当前为 revision {current}。本地草稿已保留,请先同步远端状态并重新分析。', + reloadRemote: '重新加载远端状态', + keepDraft: '保留草稿并重新分析', + publishTitle: '发布新版本', + publishDescription: '服务端校验和影响分析都必须对应当前草稿,发布前还需人工确认已审阅分析结果。', + validateDraftAria: '校验当前草稿', + serverValidation: '服务端校验', + analyzeDraftAria: '分析草稿影响', + impactAnalysis: '影响分析', + publishRequirementsAria: '发布前置条件', + latestImpactAria: '最近一次影响分析摘要', + sample: '样本', + classificationChanges: '分类变化', + degraded: '降级', + analysisTime: '分析时间:{time}', + impactExpired: '该结果已过期,请重新执行影响分析。', + reviewConfirmation: '我已审阅最新影响分析,并确认可以发布', + publishAria: '发布分类策略新版本', + publish: '发布新版本', + historyTitle: '版本历史', + historyDescription: '选择历史内容后,服务端会以 revision {revision} 执行 CAS,并创建一个新 revision。', + refreshHistory: '刷新版本历史', + loadingHistory: '正在加载版本历史', + historyNotLoaded: '尚未加载版本历史', + historyEmpty: '暂无可回滚的历史版本', + selectHistoryLegend: '选择要恢复的历史版本', + selectHistoryAria: '选择 revision {revision},{categories} 个分类,{rules} 条规则', + updatedAtUnknown: '更新时间未知', + categoryCount: '{count} 个分类', + ruleCount: '{count} 条规则', + rollbackNotice: '回滚不会改写或删除旧版本;所选历史内容会通过 CAS 发布为一个全新的 revision。', + rollbackAria: '将所选历史版本发布为新版本', + selectBeforeRollback: '选择历史版本后回滚', + rollbackRevision: '将 revision {revision} 回滚为新版本', + publishingStatus: '正在发布基于 revision {revision} 的分类策略', + keepDraftStatus: '已保留本地草稿,正在重新分析影响', + rollbackStatus: '正在将 revision {revision} 发布为新版本', + requirements: { + dirtyPassed: '存在待发布的草稿修改', + dirtyPending: '请先修改分类策略草稿', + validationPassed: '当前草稿已通过服务端校验', + validationPending: '当前草稿尚未通过服务端校验', + impactPassed: '影响分析基于当前 revision {revision}', + impactPending: '需要对当前草稿执行最新影响分析', + reviewPassed: '已确认审阅影响分析', + reviewPending: '尚未确认审阅影响分析', + conflictPending: '请先处理 revision 冲突', + conflictPassed: '当前没有 revision 冲突', + }, + }, + }, about: { title: '关于 MoviePilot', softwareVersion: '软件版本', @@ -2728,25 +3058,18 @@ export default { defaultDirName: '目录', storageSaveSuccess: '存储设置保存成功', storageSaveFailed: '存储设置保存失败!', - }, - category: { - title: '分类策略', - subtitle: '配置媒体自动分类规则,按类型、语言、地区等条件自动归类', - movie: '电影 (Movie)', - tv: '电视剧 (TV)', - name: '分类名称 (目录名)', - genre: '内容类型 (Genre)', - language: '语种 (Language)', - languagePlaceholder: '如: zh,cn,en (使用逗号分隔)', - country: '国家/地区 (Country)', - countryPlaceholder: '如: US,CN,JP', - year: '年份 (Year)', - yearPlaceholder: '如: 2023, 2020-2024', - addMovie: '添加电影分类', - addTv: '添加电视剧分类', - saveSuccess: '分类策略保存成功', - loadFailed: '加载分类配置失败', - saveFailed: '保存失败: {message}', + classification: { + categoryLabel: '固定分类', + snapshotLabel: '分类路径快照', + snapshotPending: '保存后由服务端回填当前规范路径', + categoryMissing: '分类 ID {id} 不存在,请重新选择。', + categoryDisabled: '分类 ID {id} 已停用,请重新选择。', + mediaTypeMismatch: '分类媒体类型为 {categoryType},与目录媒体类型 {directoryType} 不一致。', + legacyAmbiguous: '旧分类路径 {path} 匹配到多个分类,无法自动绑定,请手动选择。', + legacyUnresolved: '旧分类路径 {path} 无法匹配当前策略,保存前请重新选择。', + saveBlocked: '目录中存在无效或失效的分类引用,请修复后再保存。', + loadFailed: '分类策略加载失败,目录分类暂不可编辑。', + }, }, rule: { customRules: '自定义规则', diff --git a/src/locales/zh-TW.ts b/src/locales/zh-TW.ts index 0f39c584..32259e36 100644 --- a/src/locales/zh-TW.ts +++ b/src/locales/zh-TW.ts @@ -552,6 +552,10 @@ export default { title: '存儲 & 目錄', description: '下載目錄、媒體庫目錄、整理、刮削', }, + classification: { + title: '自動分類', + description: '電影、電視劇和音樂的分類樹、資料源範圍與組合規則', + }, site: { title: '站點', description: '站點同步、站點數據刷新、站點重置', @@ -1907,6 +1911,332 @@ export default { }, }, setting: { + classification: { + title: '媒體自動分類', + description: '使用穩定分類 ID 和動態欄位,為電影、電視劇和音樂配置統一規則。', + revision: '活動版本 {revision}', + unsaved: '有未儲存修改', + loading: '正在載入分類策略和欄位目錄...', + loadFailed: '分類策略載入失敗', + validateDraft: '校驗草稿', + discardDraft: '放棄修改', + validationPassed: '草稿校驗通過', + validationFailed: '草稿存在 {count} 個問題', + validationRequestFailed: '草稿校驗請求失敗', + validationIssues: '{count} 個校驗問題', + draftReset: '已恢復目前活動策略', + enrichmentTitle: '分類事實來源', + enrichmentHint: '僅在活動規則引用的標準事實缺失時呼叫已登記資料源;補充結果不會覆蓋主來源事實或改變媒體身份。', + enrichmentModeLabel: '跨來源補充', + enrichmentPrimaryOnly: '僅主來源', + enrichmentMissing: '補充缺失事實', + sourceFallbacks: '資料源專用兜底', + sourceFallbacksHint: '僅在對應資料源規則未命中時使用,儲存穩定分類 ID。', + source: '資料源', + sourceFallbackFor: '{source} 的{mediaType}來源兜底', + analysisTitle: '驗證、預覽與版本控制', + analysisHint: '在發佈前檢查事實命中、估算近期樣本影響並審閱版本歷史。', + previewTab: '事實預覽', + impactTab: '影響分析', + publishTab: '發佈與歷史', + previewFailed: '分類預覽請求失敗', + impactFailed: '分類影響分析失敗', + publishSucceeded: '分類策略已發佈為 revision {revision}', + publishFailed: '分類策略發佈失敗', + remoteReloaded: '已重新載入服務端活動策略', + remoteReloadFailed: '重新載入服務端策略失敗', + historyFailed: '分類策略歷史載入失敗', + rollbackSucceeded: 'revision {source} 已回滾並發佈為 revision {revision}', + rollbackFailed: '分類策略回滾失敗', + directoryReferencesUnavailable: '目錄引用載入失敗', + directoryReferencesUnavailableHint: '目前無法顯示完整目錄引用保護;服務端仍會在校驗和發佈時阻止無效變更。', + category: { + title: '分類樹', + description: '分類路徑最多 {count} 級,規則、兜底和目錄配置始終引用穩定 ID。', + add: '新增{mediaType}分類', + mediaTypeSegments: '分類媒體類型', + editTitle: '編輯分類', + addTitle: '新增分類', + cancelEdit: '取消分類編輯', + save: '儲存分類', + name: '分類名稱', + stableId: '穩定 ID', + existingIdHint: '穩定 ID 建立後不可修改,規則、兜底、目錄和歷史快照會持續引用該值', + newIdHint: '建立後由規則、兜底、目錄和歷史快照引用', + path: '分類路徑', + pathHint: '使用 / 分隔層級,最多 {count} 級', + mediaType: '媒體類型', + enabled: '啟用分類', + protectedEditTitle: '此分類正在被引用', + protectedEditHint: '可修改名稱和路徑,但必須先清理引用才能停用、改變媒體類型或刪除分類。', + listAria: '{mediaType}分類列表', + enabledState: '已啟用', + disabledState: '已停用', + pathAria: '{name}分類路徑', + pathUnset: '未設置路徑', + edit: '編輯分類「{name}」', + empty: '暫無{mediaType}分類', + fallbackTitle: '回退分類', + fallbackHint: '未命中規則時按媒體類型選擇分類,儲存值為穩定 ID。', + fallbackFor: '{mediaType}回退分類', + pathRequired: '分類路徑不能為空', + pathEmptySegment: '分類路徑不能包含空層級', + pathTooDeep: '分類路徑最多支援 {count} 級', + ruleReference: '已被分類規則或來源兜底引用', + globalFallbackReference: '已設為{mediaTypes}全域兜底分類', + directoryReference: '已被目錄配置引用:{directories}', + listSeparator: '、', + reasonSeparator: ';', + deleteBlocked: '不能刪除「{name}」:{reasons}', + delete: '刪除分類「{name}」', + protectedHint: '分類「{name}」受引用保護:{reasons}', + addingStatus: '正在新增{mediaType}分類', + editingStatus: '正在編輯分類「{name}」', + cancelledStatus: '已取消分類編輯', + nameRequired: '分類名稱不能為空', + idRequired: '穩定 ID 不能為空', + idDuplicate: '穩定 ID 「{id}」已存在', + protectedMutationBlocked: '被引用的分類不能停用或改變媒體類型,請先清理引用', + updatedStatus: '已更新分類「{name}」', + deletedStatus: '已刪除分類「{name}」', + fallbackUpdatedStatus: '已更新{mediaType}回退分類', + fallbackClearedStatus: '已清除{mediaType}回退分類', + }, + preview: { + title: '事實預覽與命中解釋', + description: '使用穩定媒體身分和欄位目錄建構事實,預覽不會修改活動策略或來源身分。', + run: '執行事實預覽', + modeLabel: '預覽策略', + draftPolicy: '草稿策略', + activePolicy: '活動策略', + factsTitle: '預覽事實', + mediaSource: '媒體來源', + mediaSourcePlaceholder: '例如 themoviedb、musicbrainz', + mediaId: '媒體 ID', + mediaIdPlaceholder: '來源內穩定 ID', + mediaType: '媒體類型', + noEditableFields: '目前媒體類型沒有可編輯欄位。', + resultTitle: '預覽結果', + loading: '正在執行事實預覽', + emptyResult: '填寫事實並執行預覽後查看命中解釋。', + status: '狀態', + policyRevision: '策略 revision', + recommended: '建議分類', + effective: '生效分類', + rule: '規則', + none: '無', + source: '來源', + labels: '標籤', + warnings: '警告', + warningField: '欄位 {field}', + warningSource: '來源 {source}', + trace: '規則命中解釋', + noRules: '沒有執行任何規則。', + matched: '命中', + notMatched: '未命中', + traceTableAria: '規則 {rule} 的條件命中解釋', + conditionMatched: '條件命中', + conditionNotMatched: '條件未命中', + noConditionTrace: '此規則沒有條件軌跡。', + root: '根', + missing: '未提供', + mediaTypes: { + movie: '電影', + tv: '電視劇', + music: '音樂', + }, + boolean: { + unset: '未設定', + yes: '是', + no: '否', + }, + support: { + partial: '目前來源僅部分支援', + unavailable: '目前來源不可用', + extension: '由目前來源擴充提供', + }, + groups: { + sourceExtension: '來源擴充', + shared: '共用欄位', + }, + validation: { + mediaSourceRequired: '媒體來源不可為空', + mediaIdRequired: '媒體 ID 不可為空', + }, + selection: { + unmatched: '未命中分類', + unknown: '未知分類', + unsetPath: '未設定路徑', + summary: '{name} · {path} · {id}', + }, + selectionSource: { + automatic: '規則命中', + sourceFallback: '來源備援', + fallback: '全域備援', + }, + states: { + complete: '完整', + partial: '部分完成', + notEvaluated: '未求值', + invalidPolicy: '策略無效', + }, + columns: { + result: '結果', + field: '欄位', + operator: '運算子', + expected: 'Expected', + actual: 'Actual', + factSource: '事實來源', + path: 'Path', + }, + }, + impact: { + title: '影響分析', + description: '使用有限樣本比較活動策略與目前草稿,不代表完整媒體庫的精確統計。', + sampleSource: 'sample_source: {source}({label})', + sampleLimit: '最大樣本數', + exampleLimit: '變更範例上限', + analyzeAria: '分析目前分類草稿的影響', + analyze: '開始分析', + scope: '最多比較 200 筆樣本並傳回 50 筆變更範例;分析只會執行唯讀求值,不會移動檔案或修改歷史。', + loading: '正在產生有界樣本估算…', + metadataAria: '影響分析中繼資料', + baselineRevision: '活動 revision {revision}', + candidateRevision: '候選 revision {revision}', + sampledAt: '取樣於 {time}', + overviewTitle: '有界估算摘要', + truncated: '本次結果因樣本掃描範圍或變更範例上限而截斷,不應將未顯示的記錄推斷為沒有變更。', + changeTypesTitle: '分類變更類型', + categoriesTitle: '分類計數比較', + previousCategoriesAria: '活動策略分類計數', + candidateCategoriesAria: '候選策略分類計數', + emptyCategoryCounts: '沒有分類計數', + groupsTitle: '依媒體類型與來源分組', + groupsAria: '媒體類型與來源影響分組表', + emptyGroups: '本次樣本沒有可顯示的媒體類型與來源分組。', + examplesTitle: '有限變更範例', + exampleSummary: '傳回 {returned} 筆,共偵測到 {changed} 筆變更', + examplesAria: '有限變更範例清單', + exampleAria: '變更範例 {index}:{title}', + untitledMedia: '未命名媒體', + changedFieldsAria: '變更欄位', + previousResultAria: '變更範例 {index} 的活動策略結果', + candidateResultAria: '變更範例 {index} 的候選策略結果', + previous: '活動策略', + candidate: '候選策略', + emptyExamples: '有限樣本內未傳回分類變更範例。', + warningsTitle: '估算警告', + emptyAnalysis: '尚未產生影響分析。結果一律以有界樣本估算呈現。', + uncategorized: '未分類', + noCategoryPath: '沒有分類路徑', + none: '無', + yes: '是', + no: '否', + sampleSources: { + request: '請求內明確提供的事實', + recentHistory: '近期下載與整理歷史', + }, + states: { + complete: '完整', + partial: '事實不完整', + notEvaluated: '未求值', + invalidPolicy: '策略無效', + }, + changedFields: { + categoryId: '分類 ID', + categoryPath: '分類路徑', + ruleId: '命中規則', + labels: '輸出標籤', + state: '求值狀態', + }, + metrics: { + requestedLimit: '請求上限', + scannedCount: '掃描記錄', + skippedCount: '略過記錄', + truncated: '結果截斷', + sampleCount: '有效樣本', + changedCount: '發生變更', + unchangedCount: '維持不變', + categoryChangedCount: '穩定分類變更', + pathOnlyChangedCount: '僅路徑變更', + ruleChangedOnlyCount: '僅命中規則變更', + becameFallbackCount: '轉為備援分類', + partialCount: '存在不完整事實', + degradedCount: '候選結果降級', + }, + columns: { + mediaType: '媒體類型', + source: '資料來源', + sample: '樣本', + changed: '變更', + degraded: '降級', + }, + resultFields: { + category: '分類', + path: '路徑', + rule: '規則', + source: '來源', + state: '狀態', + }, + }, + control: { + title: '版本發佈與歷史', + description: '發佈與回滾都會使用目前活動 revision 執行 CAS;發生衝突時,不會自動覆寫本機草稿。', + policyStatusAria: '目前策略狀態', + unpublishedChanges: '有尚未發佈的修改', + conflictTitle: '偵測到 revision 衝突', + conflictDescription: + '本機操作以 revision {expected} 為基準,伺服器目前為 revision {current}。本機草稿已保留,請先同步遠端狀態並重新分析。', + reloadRemote: '重新載入遠端狀態', + keepDraft: '保留草稿並重新分析', + publishTitle: '發佈新版本', + publishDescription: '伺服器驗證和影響分析都必須對應目前草稿,發佈前還需要人工確認已審閱分析結果。', + validateDraftAria: '驗證目前草稿', + serverValidation: '伺服器驗證', + analyzeDraftAria: '分析草稿影響', + impactAnalysis: '影響分析', + publishRequirementsAria: '發佈前置條件', + latestImpactAria: '最近一次影響分析摘要', + sample: '樣本', + classificationChanges: '分類變更', + degraded: '降級', + analysisTime: '分析時間:{time}', + impactExpired: '此結果已過期,請重新執行影響分析。', + reviewConfirmation: '我已審閱最新影響分析,並確認可以發佈', + publishAria: '發佈分類策略新版本', + publish: '發佈新版本', + historyTitle: '版本歷史', + historyDescription: '選擇歷史內容後,伺服器會以 revision {revision} 執行 CAS,並建立一個新 revision。', + refreshHistory: '重新整理版本歷史', + loadingHistory: '正在載入版本歷史', + historyNotLoaded: '尚未載入版本歷史', + historyEmpty: '目前沒有可回滾的歷史版本', + selectHistoryLegend: '選擇要還原的歷史版本', + selectHistoryAria: '選擇 revision {revision},{categories} 個分類,{rules} 條規則', + updatedAtUnknown: '更新時間未知', + categoryCount: '{count} 個分類', + ruleCount: '{count} 條規則', + rollbackNotice: '回滾不會改寫或刪除舊版本;所選歷史內容會透過 CAS 發佈為全新的 revision。', + rollbackAria: '將所選歷史版本發佈為新版本', + selectBeforeRollback: '選擇歷史版本後回滾', + rollbackRevision: '將 revision {revision} 回滾為新版本', + publishingStatus: '正在發佈以 revision {revision} 為基準的分類策略', + keepDraftStatus: '已保留本機草稿,正在重新分析影響', + rollbackStatus: '正在將 revision {revision} 發佈為新版本', + requirements: { + dirtyPassed: '存在待發佈的草稿修改', + dirtyPending: '請先修改分類策略草稿', + validationPassed: '目前草稿已通過伺服器驗證', + validationPending: '目前草稿尚未通過伺服器驗證', + impactPassed: '影響分析以目前 revision {revision} 為基準', + impactPending: '需要對目前草稿執行最新影響分析', + reviewPassed: '已確認審閱影響分析', + reviewPending: '尚未確認審閱影響分析', + conflictPending: '請先處理 revision 衝突', + conflictPassed: '目前沒有 revision 衝突', + }, + }, + }, about: { title: '關於 MoviePilot', softwareVersion: '軟件版本', @@ -2728,25 +3058,18 @@ export default { defaultDirName: '目錄', storageSaveSuccess: '存儲設置保存成功', storageSaveFailed: '存儲設置保存失敗!', - }, - category: { - title: '分類策略', - subtitle: '配置媒體自動分類規則,按類型、語言、地區等條件自動歸類', - movie: '電影 (Movie)', - tv: '電視劇 (TV)', - name: '分類名稱 (目錄名)', - genre: '內容類型 (Genre)', - language: '語種 (Language)', - languagePlaceholder: '如: zh,cn,en (使用逗號分隔)', - country: '國家/地區 (Country)', - countryPlaceholder: '如: US,CN,JP', - year: '年份 (Year)', - yearPlaceholder: '如: 2023, 2020-2024', - addMovie: '添加電影分類', - addTv: '添加電視劇分類', - saveSuccess: '分類策略保存成功', - loadFailed: '加載分類配置失敗', - saveFailed: '保存失敗: {message}', + classification: { + categoryLabel: '固定分類', + snapshotLabel: '分類路徑快照', + snapshotPending: '保存後由服務端回填當前規範路徑', + categoryMissing: '分類 ID {id} 不存在,請重新選擇。', + categoryDisabled: '分類 ID {id} 已停用,請重新選擇。', + mediaTypeMismatch: '分類媒體類型為 {categoryType},與目錄媒體類型 {directoryType} 不一致。', + legacyAmbiguous: '舊分類路徑 {path} 匹配到多個分類,無法自動綁定,請手動選擇。', + legacyUnresolved: '舊分類路徑 {path} 無法匹配當前策略,保存前請重新選擇。', + saveBlocked: '目錄中存在無效或失效的分類引用,請修復後再保存。', + loadFailed: '分類策略加載失敗,目錄分類暫不可編輯。', + }, }, rule: { customRules: '自定義規則', diff --git a/src/pages/__tests__/music-album.spec.ts b/src/pages/__tests__/music-album.spec.ts index 4a984f9e..5aee7fa1 100644 --- a/src/pages/__tests__/music-album.spec.ts +++ b/src/pages/__tests__/music-album.spec.ts @@ -42,7 +42,7 @@ const album = { artist: 'Queen', artists: ['Queen'], artist_ids: [artistId], - category: 'Album', + metadata_category: 'Album', cover_url: `https://coverartarchive.org/release-group/${albumId}/front-500`, duration: 2580, genres: ['rock', 'art rock'], diff --git a/src/pages/__tests__/music-detail.spec.ts b/src/pages/__tests__/music-detail.spec.ts index 13ac30c0..ede85e93 100644 --- a/src/pages/__tests__/music-detail.spec.ts +++ b/src/pages/__tests__/music-detail.spec.ts @@ -49,7 +49,7 @@ const recording = { artist: '周杰伦', artists: ['周杰伦'], artist_ids: [artistId], - category: 'Album', + metadata_category: 'Album', cover_url: `https://coverartarchive.org/release-group/${albumId}/front-500`, duration: 269, genres: ['mandopop'], diff --git a/src/pages/__tests__/music.spec.ts b/src/pages/__tests__/music.spec.ts index 7095c02c..59b37e7c 100644 --- a/src/pages/__tests__/music.spec.ts +++ b/src/pages/__tests__/music.spec.ts @@ -52,7 +52,7 @@ const musicResult = { artist: '周杰伦', artists: ['周杰伦'], artist_ids: [artistId], - category: 'Album', + metadata_category: 'Album', duration: 269, media_id: recordingId, music_type: 'recording', @@ -70,7 +70,7 @@ const albumResult = { artist: '周杰伦', artists: ['周杰伦'], artist_ids: [artistId], - category: 'Album', + metadata_category: 'Album', media_id: secondAlbumId, music_type: 'album', release_date: '2004-08-03', @@ -81,7 +81,7 @@ const albumResult = { } const artistResult = { - category: 'Person', + metadata_category: 'Person', media_id: artistId, music_type: 'artist', media_source: 'musicbrainz', diff --git a/src/pages/__tests__/setting.spec.ts b/src/pages/__tests__/setting.spec.ts new file mode 100644 index 00000000..4cd3db9f --- /dev/null +++ b/src/pages/__tests__/setting.spec.ts @@ -0,0 +1,73 @@ +import SettingPage from '@/pages/setting.vue' +import { waitFor } from '@testing-library/vue' +import { renderWithProviders } from '@tests/support/render' +import type { Ref } from 'vue' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + registerHeaderTab: vi.fn(), + route: null as unknown as { query: { tab: string | string[] | undefined } }, +})) + +vi.mock('vue-router', async importOriginal => { + const actual = await importOriginal() + const { reactive } = await import('vue') + mocks.route = reactive({ query: { tab: 'directory' as string | string[] | undefined } }) + return { ...actual, useRoute: () => mocks.route } +}) + +vi.mock('@/composables/useDynamicHeaderTab', () => ({ + useDynamicHeaderTab: () => ({ registerHeaderTab: mocks.registerHeaderTab }), +})) + +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' }, + ], +})) + +/** 返回设置页注册到页头的活动标签响应式引用。 */ +function registeredActiveTab(): Ref { + const registration = mocks.registerHeaderTab.mock.calls[0]?.[0] as { modelValue?: Ref } | undefined + expect(registration?.modelValue).toBeDefined() + return registration!.modelValue! +} + +/** 渲染设置页框架但不实例化各异步设置面板。 */ +async function renderSettingPage() { + return renderWithProviders(SettingPage, { + global: { + stubs: { + VWindow: { template: '
' }, + }, + }, + }) +} + +describe('setting page', () => { + beforeEach(() => { + mocks.registerHeaderTab.mockReset() + mocks.route.query.tab = 'directory' + }) + + it('响应有效的 route.query.tab 变化并忽略未知标签', async () => { + await renderSettingPage() + const activeTab = registeredActiveTab() + expect(activeTab.value).toBe('directory') + + mocks.route.query.tab = 'classification' + await waitFor(() => expect(activeTab.value).toBe('classification')) + + mocks.route.query.tab = 'missing' + await waitFor(() => expect(activeTab.value).toBe('classification')) + }) + + it('无效初始标签回退到第一个设置页', async () => { + mocks.route.query.tab = 'missing' + await renderSettingPage() + + await waitFor(() => expect(registeredActiveTab().value).toBe('system')) + }) +}) diff --git a/src/pages/setting.vue b/src/pages/setting.vue index d1f0f248..201c7398 100644 --- a/src/pages/setting.vue +++ b/src/pages/setting.vue @@ -1,6 +1,5 @@