mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-10 10:16:44 +08:00
feat(classification): add visual rule editor
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<T>(status: number, payload: ApiResponse<T>): ApiRequestError<ApiResponse<T>> {
|
||||
const config = { headers: new AxiosHeaders() } as InternalAxiosRequestConfig
|
||||
const response: AxiosResponse<ApiResponse<T>> = {
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -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<ClassificationPolicy> {
|
||||
return api.get(`${CLASSIFICATION_API_BASE}/policy`, { feedback: 'silent' })
|
||||
}
|
||||
|
||||
/** 以 CAS revision 校验并发布完整分类策略。 */
|
||||
export function publishClassificationPolicy(
|
||||
request: ClassificationPolicyPublishRequest,
|
||||
): Promise<ClassificationPolicy> {
|
||||
return api.put<ClassificationPolicy, ClassificationPolicy, ClassificationPolicyPublishRequest>(
|
||||
`${CLASSIFICATION_API_BASE}/policy`,
|
||||
request,
|
||||
{ feedback: 'silent' },
|
||||
)
|
||||
}
|
||||
|
||||
/** 读取动态字段能力目录和服务端编辑限制。 */
|
||||
export function getClassificationFields(): Promise<ClassificationFieldCatalog> {
|
||||
return api.get(`${CLASSIFICATION_API_BASE}/fields`, { feedback: 'silent' })
|
||||
}
|
||||
|
||||
/** 使用与发布相同的规则校验完整策略草稿。 */
|
||||
export function validateClassificationPolicy(
|
||||
request: ClassificationPolicyValidateRequest,
|
||||
): Promise<ClassificationValidationResult> {
|
||||
return api.post<ClassificationValidationResult, ClassificationValidationResult, ClassificationPolicyValidateRequest>(
|
||||
`${CLASSIFICATION_API_BASE}/validate`,
|
||||
request,
|
||||
{ feedback: 'silent' },
|
||||
)
|
||||
}
|
||||
|
||||
/** 对显式事实执行活动策略或未发布草稿并返回命中解释。 */
|
||||
export function previewClassificationPolicy(request: ClassificationPreviewRequest): Promise<ClassificationEvaluation> {
|
||||
return api.post<ClassificationEvaluation, ClassificationEvaluation, ClassificationPreviewRequest>(
|
||||
`${CLASSIFICATION_API_BASE}/preview`,
|
||||
request,
|
||||
{ feedback: 'silent' },
|
||||
)
|
||||
}
|
||||
|
||||
/** 估算未发布草稿对显式或近期历史样本的影响。 */
|
||||
export function analyzeClassificationImpact(
|
||||
request: ClassificationImpactRequest,
|
||||
): Promise<ClassificationImpactAnalysis> {
|
||||
return api.post<ClassificationImpactAnalysis, ClassificationImpactAnalysis, ClassificationImpactRequest>(
|
||||
`${CLASSIFICATION_API_BASE}/impact`,
|
||||
request,
|
||||
{ feedback: 'silent' },
|
||||
)
|
||||
}
|
||||
|
||||
/** 读取当前 revision 及最近的历史策略快照。 */
|
||||
export function getClassificationHistory(): Promise<ClassificationPolicyHistory> {
|
||||
return api.get(`${CLASSIFICATION_API_BASE}/history`, { feedback: 'silent' })
|
||||
}
|
||||
|
||||
/** 将指定历史策略内容发布为新的单调 revision。 */
|
||||
export function rollbackClassificationPolicy(
|
||||
revision: number,
|
||||
request: ClassificationPolicyRollbackRequest,
|
||||
): Promise<ClassificationPolicyRollbackResult> {
|
||||
return api.post<
|
||||
ClassificationPolicyRollbackResult,
|
||||
ClassificationPolicyRollbackResult,
|
||||
ClassificationPolicyRollbackRequest
|
||||
>(`${CLASSIFICATION_API_BASE}/rollback/${encodeURIComponent(revision)}`, request, { feedback: 'silent' })
|
||||
}
|
||||
|
||||
/** 从指定 HTTP 状态的标准错误 envelope 中安全提取结构化 data。 */
|
||||
function getStructuredErrorData<T>(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<T>(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<string, unknown>
|
||||
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<string, unknown>
|
||||
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<ClassificationRevisionConflict>
|
||||
|
||||
/** 分类接口的 422 标准错误 envelope。 */
|
||||
export type ClassificationValidationFailureEnvelope = ApiResponse<ClassificationValidationResult>
|
||||
|
||||
export type * from './mediaClassificationTypes'
|
||||
@@ -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<Record<ClassificationMediaType, string>>
|
||||
source_fallbacks: Record<string, Partial<Record<ClassificationMediaType, string>>>
|
||||
field_aliases: Record<string, Record<string, string>>
|
||||
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<string, Record<string, ClassificationFactValue>>
|
||||
field_sources: Record<string, ClassificationFactSource>
|
||||
}
|
||||
|
||||
/** 推荐或最终生效的分类选择快照。 */
|
||||
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<string, ClassificationSourceSupport>
|
||||
}
|
||||
|
||||
/** 策略编辑器必须遵守的服务端结构限制。 */
|
||||
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<string, number>
|
||||
candidate_categories: Record<string, number>
|
||||
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
|
||||
+40
-16
@@ -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<T = unknown> {
|
||||
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 }
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts" setup>
|
||||
import type { StorageConf, TransferDirectoryConf } from '@/api/types'
|
||||
import type { ClassificationCategory } from '@/api/mediaClassification'
|
||||
import { manageStorage } from '@/api/manage'
|
||||
import { nextTick } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
@@ -29,8 +30,8 @@ const props = defineProps({
|
||||
required: true, // 必填参数
|
||||
},
|
||||
categories: {
|
||||
type: Object as PropType<{ [key: string]: any }>,
|
||||
required: true,
|
||||
type: Array as PropType<readonly ClassificationCategory[]>,
|
||||
default: () => [],
|
||||
},
|
||||
storages: {
|
||||
type: Array as PropType<StorageConf[]>,
|
||||
@@ -216,13 +217,114 @@ function onClose() {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
// 根据选中的媒体类型,获取对应的媒体类别
|
||||
const getCategories = computed(() => {
|
||||
const default_value = [{ title: t('common.all'), value: '' }]
|
||||
if (!props.categories || !props.categories[props.directory?.media_type ?? '']) return default_value
|
||||
return default_value.concat(props.categories[props.directory.media_type ?? ''])
|
||||
/** 返回分类的服务端规范路径文本。 */
|
||||
function categoryPath(category: ClassificationCategory): string {
|
||||
return category.path.join('/')
|
||||
}
|
||||
|
||||
// 目录只能选择当前媒体类型下仍启用的稳定分类。
|
||||
const categoryItems = computed(() => [
|
||||
{ title: t('common.all'), value: '' },
|
||||
...props.categories
|
||||
.filter(category => category.enabled && category.media_type === props.directory.media_type)
|
||||
.map(category => ({
|
||||
title: `${category.name} · ${categoryPath(category)} · ${category.id}`,
|
||||
value: category.id,
|
||||
})),
|
||||
])
|
||||
|
||||
const selectedCategory = computed(() => {
|
||||
const categoryId = props.directory.media_category_id?.trim()
|
||||
if (!categoryId) return undefined
|
||||
return props.categories.find(category => category.id === categoryId)
|
||||
})
|
||||
|
||||
const legacyCategoryMatches = computed(() => {
|
||||
if (props.directory.media_category_id || !props.directory.media_category || !props.directory.media_type) return []
|
||||
return props.categories.filter(
|
||||
category =>
|
||||
category.media_type === props.directory.media_type && categoryPath(category) === props.directory.media_category,
|
||||
)
|
||||
})
|
||||
|
||||
// 无法自动迁移的旧路径保留原值;稳定 ID 的失效状态则必须显式修复。
|
||||
const categoryDiagnostic = computed(() => {
|
||||
const categoryId = props.directory.media_category_id?.trim()
|
||||
if (categoryId) {
|
||||
if (!selectedCategory.value) {
|
||||
return {
|
||||
type: 'error' as const,
|
||||
message: t('setting.directory.classification.categoryMissing', { id: categoryId }),
|
||||
}
|
||||
}
|
||||
if (!selectedCategory.value.enabled) {
|
||||
return {
|
||||
type: 'error' as const,
|
||||
message: t('setting.directory.classification.categoryDisabled', { id: categoryId }),
|
||||
}
|
||||
}
|
||||
if (!props.directory.media_type || selectedCategory.value.media_type !== props.directory.media_type) {
|
||||
return {
|
||||
type: 'error' as const,
|
||||
message: t('setting.directory.classification.mediaTypeMismatch', {
|
||||
categoryType: selectedCategory.value.media_type,
|
||||
directoryType: props.directory.media_type || t('common.all'),
|
||||
}),
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
if (!props.directory.media_category) return null
|
||||
if (legacyCategoryMatches.value.length > 1) {
|
||||
return {
|
||||
type: 'warning' as const,
|
||||
message: t('setting.directory.classification.legacyAmbiguous', { path: props.directory.media_category }),
|
||||
}
|
||||
}
|
||||
if (legacyCategoryMatches.value.length === 0) {
|
||||
return {
|
||||
type: 'warning' as const,
|
||||
message: t('setting.directory.classification.legacyUnresolved', { path: props.directory.media_category }),
|
||||
}
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
/** 切换媒体类型时清除不再具备类型语义的稳定 ID 和路径快照。 */
|
||||
function onMediaTypeChanged(value: string | null): void {
|
||||
// 该卡片沿用现有约定,直接维护父页面传入的目录草稿。
|
||||
// eslint-disable-next-line vue/no-mutating-props
|
||||
props.directory.media_type = value ?? ''
|
||||
props.directory.media_category_id = null
|
||||
props.directory.media_category = ''
|
||||
}
|
||||
|
||||
/** 更新稳定分类选择;路径快照等待保存后的服务端规范化回读。 */
|
||||
function onMediaCategoryChanged(value: string | null): void {
|
||||
const nextCategoryId = value?.trim() || null
|
||||
if (nextCategoryId === (props.directory.media_category_id?.trim() || null)) return
|
||||
// eslint-disable-next-line vue/no-mutating-props
|
||||
props.directory.media_category_id = nextCategoryId
|
||||
props.directory.media_category = ''
|
||||
}
|
||||
|
||||
// 旧路径只按同媒体类型的完整规范路径唯一精确匹配,不进行名称、末级或模糊推断。
|
||||
watch(
|
||||
[
|
||||
() => props.directory.media_category_id,
|
||||
() => props.directory.media_category,
|
||||
() => props.directory.media_type,
|
||||
() => props.categories,
|
||||
],
|
||||
() => {
|
||||
if (props.directory.media_category_id || legacyCategoryMatches.value.length !== 1) return
|
||||
// eslint-disable-next-line vue/no-mutating-props
|
||||
props.directory.media_category_id = legacyCategoryMatches.value[0].id
|
||||
},
|
||||
{ immediate: true, deep: true },
|
||||
)
|
||||
|
||||
// 监听 资源存储与媒体库储存 变化,重新加载整理方式下拉字典
|
||||
watch(
|
||||
[() => props.directory.library_storage, () => props.directory.storage],
|
||||
@@ -245,13 +347,13 @@ watch(
|
||||
|
||||
// 媒体类别和类型变更非空时,将按类型分类和按类别分类置为false
|
||||
watch(
|
||||
[() => props.directory.media_type, () => props.directory.media_category],
|
||||
([newMediaType, newMediaCategory], [oldMediaType, oldMediaCategory]) => {
|
||||
[() => props.directory.media_type, () => props.directory.media_category_id],
|
||||
([newMediaType, newMediaCategoryId], [oldMediaType, oldMediaCategoryId]) => {
|
||||
if (newMediaType && newMediaType !== oldMediaType) {
|
||||
props.directory.download_type_folder = false
|
||||
props.directory.library_type_folder = false
|
||||
}
|
||||
if (newMediaCategory && newMediaCategory !== oldMediaCategory) {
|
||||
if (newMediaCategoryId && newMediaCategoryId !== oldMediaCategoryId) {
|
||||
props.directory.download_category_folder = false
|
||||
props.directory.library_category_folder = false
|
||||
}
|
||||
@@ -295,25 +397,52 @@ watch(
|
||||
<VCardText v-if="!isCollapsed">
|
||||
<VForm>
|
||||
<VRow>
|
||||
<VCol cols="6">
|
||||
<VCol cols="12" sm="6">
|
||||
<VAutocomplete
|
||||
v-model="props.directory.media_type"
|
||||
:model-value="props.directory.media_type"
|
||||
variant="underlined"
|
||||
:items="typeItems"
|
||||
:label="t('directory.mediaType')"
|
||||
mobile-control-width="65%"
|
||||
@update:modelValue="props.directory.media_category = ''"
|
||||
@update:model-value="onMediaTypeChanged"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="6">
|
||||
<VCol cols="12" sm="6">
|
||||
<VAutocomplete
|
||||
v-model="props.directory.media_category"
|
||||
data-testid="directory-category-select"
|
||||
:model-value="props.directory.media_category_id || ''"
|
||||
variant="underlined"
|
||||
:items="getCategories"
|
||||
:label="t('directory.mediaCategory')"
|
||||
:items="categoryItems"
|
||||
:label="t('setting.directory.classification.categoryLabel')"
|
||||
mobile-control-width="65%"
|
||||
@update:model-value="onMediaCategoryChanged"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VTextField
|
||||
data-testid="directory-category-path"
|
||||
:model-value="props.directory.media_category || ''"
|
||||
variant="underlined"
|
||||
:label="t('setting.directory.classification.snapshotLabel')"
|
||||
:hint="
|
||||
props.directory.media_category_id && !props.directory.media_category
|
||||
? t('setting.directory.classification.snapshotPending')
|
||||
: undefined
|
||||
"
|
||||
persistent-hint
|
||||
readonly
|
||||
/>
|
||||
<VAlert
|
||||
v-if="categoryDiagnostic"
|
||||
data-testid="directory-category-diagnostic"
|
||||
:type="categoryDiagnostic.type"
|
||||
variant="tonal"
|
||||
density="compact"
|
||||
class="mt-2"
|
||||
>
|
||||
{{ categoryDiagnostic.message }}
|
||||
</VAlert>
|
||||
</VCol>
|
||||
<VCol cols="4">
|
||||
<VAutocomplete
|
||||
v-model="props.directory.storage"
|
||||
@@ -446,7 +575,11 @@ watch(
|
||||
</VCardText>
|
||||
<VCardActions class="text-center py-0">
|
||||
<VSpacer />
|
||||
<VBtn :icon="isCollapsed ? 'mdi-chevron-down' : 'mdi-chevron-up'" @click.stop="isCollapsed = !isCollapsed" />
|
||||
<VBtn
|
||||
data-testid="directory-card-toggle"
|
||||
:icon="isCollapsed ? 'mdi-chevron-down' : 'mdi-chevron-up'"
|
||||
@click.stop="isCollapsed = !isCollapsed"
|
||||
/>
|
||||
<VSpacer />
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
|
||||
@@ -224,11 +224,11 @@ watch(musicCover, () => {
|
||||
</VChip>
|
||||
<!-- 音乐分类 -->
|
||||
<VChip
|
||||
v-if="isMusic && (context?.media_info?.category || context?.media_info?.album_type)"
|
||||
v-if="isMusic && (context?.media_info?.metadata_category || context?.media_info?.album_type)"
|
||||
variant="elevated"
|
||||
class="me-1 mb-1 text-white bg-purple-500"
|
||||
>
|
||||
{{ context?.media_info?.category || context?.media_info?.album_type }}
|
||||
{{ context?.media_info?.metadata_category || context?.media_info?.album_type }}
|
||||
</VChip>
|
||||
<!-- 风格 -->
|
||||
<VChip
|
||||
|
||||
@@ -63,7 +63,7 @@ const entityMeta = computed(() => {
|
||||
// 卡片只展示标准音乐模型中已映射的稳定字段
|
||||
const metaItems = computed(() => {
|
||||
const items: { hideOnNarrow?: boolean; icon: string; label: string }[] = []
|
||||
const category = props.music?.category || props.music?.album_type
|
||||
const category = props.music?.metadata_category || props.music?.album_type
|
||||
if (category) items.push({ hideOnNarrow: true, icon: 'mdi-label-outline', label: category })
|
||||
const releaseDate = props.music?.release_date || props.music?.year?.toString()
|
||||
if (releaseDate) items.push({ icon: 'mdi-calendar-blank-outline', label: releaseDate })
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import type { ClassificationCategory } from '@/api/mediaClassification'
|
||||
import type { TransferDirectoryConf } from '@/api/types'
|
||||
import DirectoryCard from '@/components/cards/DirectoryCard.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'
|
||||
|
||||
vi.mock('@/api/manage', () => ({
|
||||
manageStorage: vi.fn(),
|
||||
}))
|
||||
|
||||
const categories: ClassificationCategory[] = [
|
||||
{ id: 'movie.animation', media_type: '电影', name: '动画', path: ['电影', '动画'], enabled: true, labels: [] },
|
||||
{ id: 'movie.disabled', media_type: '电影', name: '停用', path: ['电影', '停用'], enabled: false, labels: [] },
|
||||
{ id: 'tv.animation', media_type: '电视剧', name: '动画', path: ['电视剧', '动画'], enabled: true, labels: [] },
|
||||
{ id: 'music.live', media_type: '音乐', name: '现场', path: ['音乐', '现场'], enabled: true, labels: [] },
|
||||
]
|
||||
|
||||
/** 创建可观察组件原地更新结果的目录配置。 */
|
||||
function createDirectory(overrides: Partial<TransferDirectoryConf> = {}): TransferDirectoryConf {
|
||||
return {
|
||||
name: '测试目录',
|
||||
priority: 0,
|
||||
storage: 'local',
|
||||
monitor_type: '',
|
||||
media_type: '电影',
|
||||
media_category: '',
|
||||
media_category_id: null,
|
||||
transfer_type: '',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
/** 渲染并展开目录卡片,返回被组件直接维护的目录对象。 */
|
||||
async function renderExpandedDirectory(
|
||||
overrides: Partial<TransferDirectoryConf> = {},
|
||||
availableCategories: ClassificationCategory[] = categories,
|
||||
) {
|
||||
const directory = createDirectory(overrides)
|
||||
await renderWithProviders(DirectoryCard, {
|
||||
props: {
|
||||
directory,
|
||||
categories: availableCategories,
|
||||
storages: [{ name: '本地', type: 'local', config: {} }],
|
||||
},
|
||||
})
|
||||
await userEvent.setup().click(screen.getByTestId('directory-card-toggle'))
|
||||
return directory
|
||||
}
|
||||
|
||||
describe('DirectoryCard classification reference', () => {
|
||||
it('only lists enabled categories for the selected media type', async () => {
|
||||
const user = userEvent.setup()
|
||||
await renderExpandedDirectory()
|
||||
|
||||
const categorySelect = within(screen.getByTestId('directory-category-select')).getByRole('combobox')
|
||||
await user.click(categorySelect)
|
||||
|
||||
expect(await screen.findByRole('option', { name: '动画 · 电影/动画 · movie.animation' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('option', { name: /movie.disabled/ })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('option', { name: /tv.animation/ })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('option', { name: /music.live/ })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('clears both the stable id and path snapshot when the media type changes', async () => {
|
||||
const user = userEvent.setup()
|
||||
const directory = await renderExpandedDirectory({
|
||||
media_category_id: 'movie.animation',
|
||||
media_category: '电影/动画',
|
||||
})
|
||||
|
||||
await user.click(screen.getByRole('textbox', { name: '媒体类型' }))
|
||||
await user.click(await screen.findByRole('option', { name: '音乐' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(directory.media_type).toBe('音乐')
|
||||
expect(directory.media_category_id).toBeNull()
|
||||
expect(directory.media_category).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
it('binds a legacy path only when the same-media-type full path has one exact match', async () => {
|
||||
const directory = await renderExpandedDirectory({ media_category: '电影/动画' })
|
||||
|
||||
await waitFor(() => expect(directory.media_category_id).toBe('movie.animation'))
|
||||
expect((screen.getByTestId('directory-category-path').querySelector('input') as HTMLInputElement).value).toBe(
|
||||
'电影/动画',
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps ambiguous or non-exact legacy paths readable and exposes diagnostics', async () => {
|
||||
const duplicatePathCategories = [
|
||||
...categories,
|
||||
{
|
||||
id: 'movie.animation-copy',
|
||||
media_type: '电影' as const,
|
||||
name: '动画副本',
|
||||
path: ['电影', '动画'],
|
||||
enabled: true,
|
||||
labels: [],
|
||||
},
|
||||
]
|
||||
const ambiguous = await renderExpandedDirectory({ media_category: '电影/动画' }, duplicatePathCategories)
|
||||
|
||||
expect(ambiguous.media_category_id).toBeNull()
|
||||
expect(screen.getByTestId('directory-category-diagnostic')).toHaveTextContent('匹配到多个分类')
|
||||
|
||||
const { unmount } = await renderWithProviders(DirectoryCard, {
|
||||
props: {
|
||||
directory: createDirectory({ media_category: '动画' }),
|
||||
categories,
|
||||
storages: [{ name: '本地', type: 'local', config: {} }],
|
||||
},
|
||||
})
|
||||
await userEvent.setup().click(screen.getAllByTestId('directory-card-toggle').at(-1)!)
|
||||
expect(screen.getAllByTestId('directory-category-diagnostic').at(-1)).toHaveTextContent('无法匹配当前策略')
|
||||
unmount()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['missing id', { media_category_id: 'movie.missing' }, '不存在'],
|
||||
['disabled id', { media_category_id: 'movie.disabled' }, '已停用'],
|
||||
['media type mismatch', { media_category_id: 'tv.animation' }, '不一致'],
|
||||
])('diagnoses an invalid stable reference: %s', async (_name, overrides, message) => {
|
||||
await renderExpandedDirectory(overrides)
|
||||
|
||||
expect(screen.getByTestId('directory-category-diagnostic')).toHaveTextContent(message)
|
||||
})
|
||||
})
|
||||
@@ -24,7 +24,7 @@ describe('MediaInfoCard', () => {
|
||||
media_info: {
|
||||
album: '完美的一天',
|
||||
artist: '孙燕姿',
|
||||
category: 'Album',
|
||||
metadata_category: 'Album',
|
||||
cover_url: 'https://coverartarchive.org/release-group/album-1/front-500',
|
||||
duration: 221,
|
||||
genres: ['华语流行'],
|
||||
|
||||
@@ -0,0 +1,764 @@
|
||||
<script setup lang="ts">
|
||||
import type { ClassificationCategory, ClassificationMediaType } from '@/api/mediaClassificationTypes'
|
||||
|
||||
/** 分类树编辑器输入属性。 */
|
||||
interface ClassificationCategoryEditorProps {
|
||||
categories: ClassificationCategory[]
|
||||
fallbacks: Partial<Record<ClassificationMediaType, string>>
|
||||
referencedCategoryIds?: string[]
|
||||
directoryReferences?: Array<{ categoryId: string; directoryNames: string[] }>
|
||||
maxDepth?: number
|
||||
}
|
||||
|
||||
/** 分类表单在新增和编辑期间使用的本地草稿。 */
|
||||
interface ClassificationCategoryDraft {
|
||||
originalId: string | null
|
||||
id: string
|
||||
mediaType: ClassificationMediaType
|
||||
name: string
|
||||
pathText: string
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
/** 路径输入解析结果,错误信息由同一可访问状态区展示。 */
|
||||
interface ParsedCategoryPath {
|
||||
path: string[]
|
||||
error: string | null
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<ClassificationCategoryEditorProps>(), {
|
||||
referencedCategoryIds: () => [],
|
||||
directoryReferences: () => [],
|
||||
maxDepth: 4,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:categories': [categories: ClassificationCategory[]]
|
||||
'update:fallbacks': [fallbacks: Partial<Record<ClassificationMediaType, string>>]
|
||||
}>()
|
||||
|
||||
const mediaTypes: ReadonlyArray<{ icon: string; label: ClassificationMediaType }> = [
|
||||
{ label: '电影', icon: 'mdi-movie-open-outline' },
|
||||
{ label: '电视剧', icon: 'mdi-television-classic' },
|
||||
{ label: '音乐', icon: 'mdi-music-note-outline' },
|
||||
]
|
||||
|
||||
const { t } = useI18n()
|
||||
const activeMediaType = ref<ClassificationMediaType>('电影')
|
||||
const draft = ref<ClassificationCategoryDraft | null>(null)
|
||||
const validationMessage = ref('')
|
||||
const statusMessage = ref('')
|
||||
const validationErrorId = `classification-category-error-${useId()}`
|
||||
|
||||
const effectiveMaxDepth = computed(() => Math.max(1, Math.trunc(props.maxDepth)))
|
||||
const visibleCategories = computed(() =>
|
||||
props.categories.filter(category => category.media_type === activeMediaType.value),
|
||||
)
|
||||
const referencedIds = computed(() => new Set(props.referencedCategoryIds))
|
||||
const directoryReferenceMap = computed(
|
||||
() => new Map(props.directoryReferences.map(reference => [reference.categoryId, reference.directoryNames])),
|
||||
)
|
||||
const draftReferenceReasons = computed(() =>
|
||||
draft.value?.originalId ? categoryReferenceReasons(draft.value.originalId) : [],
|
||||
)
|
||||
|
||||
/** 克隆分类数组,避免子组件把父级草稿中的路径或标签数组变为共享引用。 */
|
||||
function cloneCategories(categories: ClassificationCategory[]): ClassificationCategory[] {
|
||||
return categories.map(category => ({
|
||||
...category,
|
||||
labels: [...category.labels],
|
||||
path: [...category.path],
|
||||
}))
|
||||
}
|
||||
|
||||
/** 将用户输入拆成明确的多级路径,并拒绝空层级。 */
|
||||
function parseCategoryPath(pathText: string): ParsedCategoryPath {
|
||||
const trimmedPath = pathText.trim()
|
||||
if (!trimmedPath) return { path: [], error: t('setting.classification.category.pathRequired') }
|
||||
|
||||
const path = trimmedPath.split('/').map(segment => segment.trim())
|
||||
if (path.some(segment => !segment)) {
|
||||
return { path: [], error: t('setting.classification.category.pathEmptySegment') }
|
||||
}
|
||||
if (path.length > effectiveMaxDepth.value) {
|
||||
return {
|
||||
path: [],
|
||||
error: t('setting.classification.category.pathTooDeep', { count: effectiveMaxDepth.value }),
|
||||
}
|
||||
}
|
||||
return { path, error: null }
|
||||
}
|
||||
|
||||
/** 返回分类被规则、来源兜底和各媒体类型全局兜底引用的具体原因。 */
|
||||
function categoryReferenceReasons(categoryId: string): string[] {
|
||||
const reasons: string[] = []
|
||||
if (referencedIds.value.has(categoryId)) reasons.push(t('setting.classification.category.ruleReference'))
|
||||
|
||||
const fallbackTypes = mediaTypes
|
||||
.map(item => item.label)
|
||||
.filter(mediaType => props.fallbacks[mediaType] === categoryId)
|
||||
if (fallbackTypes.length) {
|
||||
reasons.push(
|
||||
t('setting.classification.category.globalFallbackReference', {
|
||||
mediaTypes: fallbackTypes.join(t('setting.classification.category.listSeparator')),
|
||||
}),
|
||||
)
|
||||
}
|
||||
const directoryNames = directoryReferenceMap.value.get(categoryId) ?? []
|
||||
if (directoryNames.length) {
|
||||
reasons.push(
|
||||
t('setting.classification.category.directoryReference', {
|
||||
directories: directoryNames.join(t('setting.classification.category.listSeparator')),
|
||||
}),
|
||||
)
|
||||
}
|
||||
return reasons
|
||||
}
|
||||
|
||||
/** 生成删除操作及可访问提示共用的完整保护文案。 */
|
||||
function deletionHint(category: ClassificationCategory): string {
|
||||
const reasons = categoryReferenceReasons(category.id)
|
||||
return reasons.length
|
||||
? t('setting.classification.category.deleteBlocked', {
|
||||
name: category.name,
|
||||
reasons: reasons.join(t('setting.classification.category.reasonSeparator')),
|
||||
})
|
||||
: t('setting.classification.category.delete', { name: category.name })
|
||||
}
|
||||
|
||||
/** 生成编辑表单和列表共用的引用保护说明。 */
|
||||
function protectionHint(category: ClassificationCategory): string {
|
||||
return t('setting.classification.category.protectedHint', {
|
||||
name: category.name,
|
||||
reasons: categoryReferenceReasons(category.id).join(t('setting.classification.category.reasonSeparator')),
|
||||
})
|
||||
}
|
||||
|
||||
/** 判断分类当前是否受规则或 fallback 引用保护。 */
|
||||
function isCategoryProtected(categoryId: string): boolean {
|
||||
return categoryReferenceReasons(categoryId).length > 0
|
||||
}
|
||||
|
||||
/** 为稳定 ID fallback 选择器生成可辨识的标题。 */
|
||||
function fallbackItemTitle(category: ClassificationCategory): string {
|
||||
const path = category.path.length ? category.path.join(' / ') : t('setting.classification.category.pathUnset')
|
||||
return `${category.name} · ${path} · ${category.id}`
|
||||
}
|
||||
|
||||
/** 返回指定媒体类型可选的稳定分类 ID 列表。 */
|
||||
function fallbackItems(mediaType: ClassificationMediaType): ClassificationCategory[] {
|
||||
return props.categories.filter(category => category.media_type === mediaType)
|
||||
}
|
||||
|
||||
/** 将业务标签传给 VSelect 的真实 combobox 激活元素。 */
|
||||
function comboboxMenuProps(label: string): { activatorProps: { 'aria-label': string } } {
|
||||
return { activatorProps: { 'aria-label': label } }
|
||||
}
|
||||
|
||||
/** 开始新增当前分段的分类。 */
|
||||
function startAddCategory(): void {
|
||||
draft.value = {
|
||||
originalId: null,
|
||||
id: '',
|
||||
mediaType: activeMediaType.value,
|
||||
name: '',
|
||||
pathText: '',
|
||||
enabled: true,
|
||||
}
|
||||
validationMessage.value = ''
|
||||
statusMessage.value = t('setting.classification.category.addingStatus', { mediaType: activeMediaType.value })
|
||||
}
|
||||
|
||||
/** 将现有分类复制到本地表单,保存前不修改父级数据。 */
|
||||
function startEditCategory(category: ClassificationCategory): void {
|
||||
activeMediaType.value = category.media_type
|
||||
draft.value = {
|
||||
originalId: category.id,
|
||||
id: category.id,
|
||||
mediaType: category.media_type,
|
||||
name: category.name,
|
||||
pathText: category.path.join('/'),
|
||||
enabled: category.enabled,
|
||||
}
|
||||
validationMessage.value = ''
|
||||
statusMessage.value = t('setting.classification.category.editingStatus', { name: category.name })
|
||||
}
|
||||
|
||||
/** 取消当前新增或编辑,并清除表单错误。 */
|
||||
function cancelEdit(): void {
|
||||
draft.value = null
|
||||
validationMessage.value = ''
|
||||
statusMessage.value = t('setting.classification.category.cancelledStatus')
|
||||
}
|
||||
|
||||
/** 校验并提交分类草稿;既有分类始终沿用创建时的稳定 ID。 */
|
||||
function saveDraft(): void {
|
||||
const currentDraft = draft.value
|
||||
if (!currentDraft) return
|
||||
|
||||
const id = currentDraft.originalId ?? currentDraft.id.trim()
|
||||
const name = currentDraft.name.trim()
|
||||
if (!name) {
|
||||
validationMessage.value = t('setting.classification.category.nameRequired')
|
||||
return
|
||||
}
|
||||
if (!id) {
|
||||
validationMessage.value = t('setting.classification.category.idRequired')
|
||||
return
|
||||
}
|
||||
if (props.categories.some(category => category.id === id && category.id !== currentDraft.originalId)) {
|
||||
validationMessage.value = t('setting.classification.category.idDuplicate', { id })
|
||||
return
|
||||
}
|
||||
|
||||
const originalCategory = currentDraft.originalId
|
||||
? props.categories.find(category => category.id === currentDraft.originalId)
|
||||
: null
|
||||
if (
|
||||
originalCategory &&
|
||||
isCategoryProtected(originalCategory.id) &&
|
||||
(currentDraft.mediaType !== originalCategory.media_type || (originalCategory.enabled && !currentDraft.enabled))
|
||||
) {
|
||||
validationMessage.value = t('setting.classification.category.protectedMutationBlocked')
|
||||
return
|
||||
}
|
||||
|
||||
const parsedPath = parseCategoryPath(currentDraft.pathText)
|
||||
if (parsedPath.error) {
|
||||
validationMessage.value = parsedPath.error
|
||||
return
|
||||
}
|
||||
|
||||
const nextCategory: ClassificationCategory = {
|
||||
id,
|
||||
media_type: currentDraft.mediaType,
|
||||
name,
|
||||
path: parsedPath.path,
|
||||
enabled: currentDraft.enabled,
|
||||
labels: currentDraft.originalId
|
||||
? [...(props.categories.find(category => category.id === currentDraft.originalId)?.labels ?? [])]
|
||||
: [],
|
||||
}
|
||||
const nextCategories = cloneCategories(props.categories)
|
||||
if (currentDraft.originalId) {
|
||||
const index = nextCategories.findIndex(category => category.id === currentDraft.originalId)
|
||||
if (index >= 0) nextCategories.splice(index, 1, nextCategory)
|
||||
else nextCategories.push(nextCategory)
|
||||
} else {
|
||||
nextCategories.push(nextCategory)
|
||||
}
|
||||
|
||||
emit('update:categories', nextCategories)
|
||||
activeMediaType.value = nextCategory.media_type
|
||||
draft.value = null
|
||||
validationMessage.value = ''
|
||||
statusMessage.value = t('setting.classification.category.updatedStatus', { name: nextCategory.name })
|
||||
}
|
||||
|
||||
/** 删除未被引用的分类;受保护分类只更新明确提示,不发出变更事件。 */
|
||||
function removeCategory(category: ClassificationCategory): void {
|
||||
const hint = deletionHint(category)
|
||||
if (isCategoryProtected(category.id)) {
|
||||
statusMessage.value = hint
|
||||
return
|
||||
}
|
||||
|
||||
emit('update:categories', cloneCategories(props.categories.filter(item => item.id !== category.id)))
|
||||
if (draft.value?.originalId === category.id) draft.value = null
|
||||
validationMessage.value = ''
|
||||
statusMessage.value = t('setting.classification.category.deletedStatus', { name: category.name })
|
||||
}
|
||||
|
||||
/** 使用稳定分类 ID 更新指定媒体类型的 fallback。 */
|
||||
function updateFallback(mediaType: ClassificationMediaType, categoryId: string | null): void {
|
||||
const nextFallbacks = { ...props.fallbacks }
|
||||
if (categoryId) nextFallbacks[mediaType] = categoryId
|
||||
else delete nextFallbacks[mediaType]
|
||||
emit('update:fallbacks', nextFallbacks)
|
||||
statusMessage.value = categoryId
|
||||
? t('setting.classification.category.fallbackUpdatedStatus', { mediaType })
|
||||
: t('setting.classification.category.fallbackClearedStatus', { mediaType })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="classification-category-editor" aria-labelledby="classification-category-title">
|
||||
<header class="classification-category-header">
|
||||
<div class="classification-category-heading">
|
||||
<h2 id="classification-category-title">{{ t('setting.classification.category.title') }}</h2>
|
||||
<p>{{ t('setting.classification.category.description', { count: effectiveMaxDepth }) }}</p>
|
||||
</div>
|
||||
|
||||
<VBtn
|
||||
icon
|
||||
color="primary"
|
||||
variant="tonal"
|
||||
:aria-label="t('setting.classification.category.add', { mediaType: activeMediaType })"
|
||||
@click="startAddCategory"
|
||||
>
|
||||
<VIcon icon="mdi-plus" />
|
||||
<VTooltip activator="parent" location="top">
|
||||
{{ t('setting.classification.category.add', { mediaType: activeMediaType }) }}
|
||||
</VTooltip>
|
||||
</VBtn>
|
||||
</header>
|
||||
|
||||
<VBtnToggle
|
||||
v-model="activeMediaType"
|
||||
mandatory
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
class="classification-media-segments"
|
||||
:aria-label="t('setting.classification.category.mediaTypeSegments')"
|
||||
>
|
||||
<VBtn v-for="item in mediaTypes" :key="item.label" :value="item.label">
|
||||
<VIcon :icon="item.icon" start />
|
||||
{{ item.label }}
|
||||
</VBtn>
|
||||
</VBtnToggle>
|
||||
|
||||
<p class="sr-only" role="status" aria-live="polite">{{ statusMessage }}</p>
|
||||
|
||||
<section
|
||||
v-if="draft"
|
||||
class="classification-category-form"
|
||||
aria-labelledby="classification-category-form-title"
|
||||
:aria-describedby="validationMessage ? validationErrorId : undefined"
|
||||
>
|
||||
<div class="classification-category-form-header">
|
||||
<h3 id="classification-category-form-title">
|
||||
{{
|
||||
draft.originalId
|
||||
? t('setting.classification.category.editTitle')
|
||||
: t('setting.classification.category.addTitle')
|
||||
}}
|
||||
</h3>
|
||||
<div class="classification-category-form-actions">
|
||||
<VBtn icon variant="text" :aria-label="t('setting.classification.category.cancelEdit')" @click="cancelEdit">
|
||||
<VIcon icon="mdi-close" />
|
||||
<VTooltip activator="parent" location="top">
|
||||
{{ t('setting.classification.category.cancelEdit') }}
|
||||
</VTooltip>
|
||||
</VBtn>
|
||||
<VBtn
|
||||
icon
|
||||
color="primary"
|
||||
variant="tonal"
|
||||
:aria-label="t('setting.classification.category.save')"
|
||||
@click="saveDraft"
|
||||
>
|
||||
<VIcon icon="mdi-content-save-outline" />
|
||||
<VTooltip activator="parent" location="top">
|
||||
{{ t('setting.classification.category.save') }}
|
||||
</VTooltip>
|
||||
</VBtn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="classification-category-form-grid">
|
||||
<VTextField
|
||||
v-model="draft.name"
|
||||
:label="t('setting.classification.category.name')"
|
||||
hide-details="auto"
|
||||
required
|
||||
/>
|
||||
<VTextField
|
||||
v-model="draft.id"
|
||||
:label="t('setting.classification.category.stableId')"
|
||||
:hint="
|
||||
draft.originalId
|
||||
? t('setting.classification.category.existingIdHint')
|
||||
: t('setting.classification.category.newIdHint')
|
||||
"
|
||||
persistent-hint
|
||||
:readonly="draft.originalId !== null"
|
||||
required
|
||||
/>
|
||||
<VTextField
|
||||
v-model="draft.pathText"
|
||||
:label="t('setting.classification.category.path')"
|
||||
:hint="t('setting.classification.category.pathHint', { count: effectiveMaxDepth })"
|
||||
persistent-hint
|
||||
required
|
||||
/>
|
||||
<VSelect
|
||||
v-model="draft.mediaType"
|
||||
:label="t('setting.classification.category.mediaType')"
|
||||
:aria-label="t('setting.classification.category.mediaType')"
|
||||
:menu-props="comboboxMenuProps(t('setting.classification.category.mediaType'))"
|
||||
:items="mediaTypes"
|
||||
item-title="label"
|
||||
item-value="label"
|
||||
hide-details="auto"
|
||||
:disabled="draftReferenceReasons.length > 0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<VSwitch
|
||||
v-model="draft.enabled"
|
||||
:label="t('setting.classification.category.enabled')"
|
||||
color="primary"
|
||||
hide-details
|
||||
:disabled="draftReferenceReasons.length > 0 && draft.enabled"
|
||||
/>
|
||||
<VAlert
|
||||
v-if="draftReferenceReasons.length"
|
||||
type="warning"
|
||||
variant="tonal"
|
||||
density="compact"
|
||||
:title="t('setting.classification.category.protectedEditTitle')"
|
||||
>
|
||||
{{ t('setting.classification.category.protectedEditHint') }}
|
||||
<ul class="classification-category-reference-list">
|
||||
<li v-for="reason in draftReferenceReasons" :key="reason">{{ reason }}</li>
|
||||
</ul>
|
||||
</VAlert>
|
||||
<p
|
||||
v-if="validationMessage"
|
||||
:id="validationErrorId"
|
||||
class="classification-category-error"
|
||||
role="alert"
|
||||
data-testid="classification-category-error"
|
||||
>
|
||||
{{ validationMessage }}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<div
|
||||
class="classification-category-list"
|
||||
:aria-label="t('setting.classification.category.listAria', { mediaType: activeMediaType })"
|
||||
>
|
||||
<article
|
||||
v-for="category in visibleCategories"
|
||||
:key="category.id"
|
||||
class="classification-category-row"
|
||||
:data-category-id="category.id"
|
||||
>
|
||||
<div class="classification-category-summary">
|
||||
<div class="classification-category-title-line">
|
||||
<strong>{{ category.name }}</strong>
|
||||
<VChip size="small" :color="category.enabled ? 'success' : undefined" variant="tonal">
|
||||
{{
|
||||
category.enabled
|
||||
? t('setting.classification.category.enabledState')
|
||||
: t('setting.classification.category.disabledState')
|
||||
}}
|
||||
</VChip>
|
||||
</div>
|
||||
<code class="classification-category-id">{{ category.id }}</code>
|
||||
<ol
|
||||
class="classification-category-path"
|
||||
:aria-label="t('setting.classification.category.pathAria', { name: category.name })"
|
||||
>
|
||||
<li v-for="(segment, index) in category.path" :key="`${category.id}-${index}`">
|
||||
<VIcon v-if="index > 0" icon="mdi-chevron-right" size="16" aria-hidden="true" />
|
||||
<span>{{ segment }}</span>
|
||||
</li>
|
||||
<li v-if="category.path.length === 0" class="classification-category-path-empty">
|
||||
{{ t('setting.classification.category.pathUnset') }}
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<p
|
||||
v-if="isCategoryProtected(category.id)"
|
||||
:id="`classification-delete-protection-${category.id}`"
|
||||
class="classification-category-protection"
|
||||
role="note"
|
||||
>
|
||||
<VIcon icon="mdi-lock-outline" size="16" aria-hidden="true" />
|
||||
{{ protectionHint(category) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="classification-category-actions">
|
||||
<VBtn
|
||||
icon
|
||||
variant="text"
|
||||
:aria-label="t('setting.classification.category.edit', { name: category.name })"
|
||||
@click="startEditCategory(category)"
|
||||
>
|
||||
<VIcon icon="mdi-pencil-outline" />
|
||||
<VTooltip activator="parent" location="top">
|
||||
{{ t('setting.classification.category.editTitle') }}
|
||||
</VTooltip>
|
||||
</VBtn>
|
||||
<span
|
||||
class="classification-delete-activator"
|
||||
:tabindex="isCategoryProtected(category.id) ? 0 : -1"
|
||||
:aria-label="isCategoryProtected(category.id) ? deletionHint(category) : undefined"
|
||||
>
|
||||
<VBtn
|
||||
icon
|
||||
color="error"
|
||||
variant="text"
|
||||
:disabled="isCategoryProtected(category.id)"
|
||||
:aria-label="deletionHint(category)"
|
||||
:aria-describedby="
|
||||
isCategoryProtected(category.id) ? `classification-delete-protection-${category.id}` : undefined
|
||||
"
|
||||
@click="removeCategory(category)"
|
||||
>
|
||||
<VIcon icon="mdi-delete-outline" />
|
||||
<VTooltip v-if="!isCategoryProtected(category.id)" activator="parent" location="top">
|
||||
{{ deletionHint(category) }}
|
||||
</VTooltip>
|
||||
</VBtn>
|
||||
<VTooltip v-if="isCategoryProtected(category.id)" activator="parent" location="top">
|
||||
{{ deletionHint(category) }}
|
||||
</VTooltip>
|
||||
</span>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<div v-if="visibleCategories.length === 0" class="classification-category-empty" role="status">
|
||||
<VIcon icon="mdi-folder-outline" size="32" />
|
||||
<span>{{ t('setting.classification.category.empty', { mediaType: activeMediaType }) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="classification-fallbacks" aria-labelledby="classification-fallback-title">
|
||||
<div>
|
||||
<h3 id="classification-fallback-title">{{ t('setting.classification.category.fallbackTitle') }}</h3>
|
||||
<p>{{ t('setting.classification.category.fallbackHint') }}</p>
|
||||
</div>
|
||||
<div class="classification-fallback-grid">
|
||||
<VSelect
|
||||
v-for="item in mediaTypes"
|
||||
:key="item.label"
|
||||
:model-value="fallbacks[item.label] ?? null"
|
||||
:label="t('setting.classification.category.fallbackFor', { mediaType: item.label })"
|
||||
:aria-label="t('setting.classification.category.fallbackFor', { mediaType: item.label })"
|
||||
:menu-props="comboboxMenuProps(t('setting.classification.category.fallbackFor', { mediaType: item.label }))"
|
||||
:items="fallbackItems(item.label)"
|
||||
:item-title="fallbackItemTitle"
|
||||
item-value="id"
|
||||
clearable
|
||||
hide-details="auto"
|
||||
@update:model-value="updateFallback(item.label, $event)"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.classification-category-editor {
|
||||
display: grid;
|
||||
gap: 20px;
|
||||
inline-size: 100%;
|
||||
min-inline-size: 0;
|
||||
}
|
||||
|
||||
.classification-category-header,
|
||||
.classification-category-form-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.classification-category-heading,
|
||||
.classification-fallbacks > div:first-child {
|
||||
min-inline-size: 0;
|
||||
}
|
||||
|
||||
.classification-category-heading h2,
|
||||
.classification-category-form h3,
|
||||
.classification-fallbacks h3 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
line-height: 1.4;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.classification-category-heading p,
|
||||
.classification-fallbacks p {
|
||||
margin: 4px 0 0;
|
||||
color: rgb(var(--v-theme-on-surface-variant));
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.classification-media-segments {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
inline-size: 100%;
|
||||
block-size: auto;
|
||||
}
|
||||
|
||||
.classification-media-segments :deep(.v-btn) {
|
||||
min-inline-size: 0;
|
||||
}
|
||||
|
||||
.classification-category-form {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||
border-radius: 8px;
|
||||
background: rgba(var(--v-theme-surface-variant), 0.18);
|
||||
}
|
||||
|
||||
.classification-category-form-actions,
|
||||
.classification-category-actions {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.classification-category-form-grid,
|
||||
.classification-fallback-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.classification-category-error {
|
||||
margin: 0;
|
||||
color: rgb(var(--v-theme-error));
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.classification-category-reference-list {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
margin: 8px 0 0;
|
||||
padding-inline-start: 20px;
|
||||
}
|
||||
|
||||
.classification-category-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
min-inline-size: 0;
|
||||
}
|
||||
|
||||
.classification-category-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
min-inline-size: 0;
|
||||
padding: 14px 12px 14px 16px;
|
||||
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.classification-category-summary {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
min-inline-size: 0;
|
||||
}
|
||||
|
||||
.classification-category-title-line {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.classification-category-id {
|
||||
overflow-wrap: anywhere;
|
||||
color: rgb(var(--v-theme-on-surface-variant));
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.classification-category-path {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
min-inline-size: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.classification-category-path li {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-inline-size: 0;
|
||||
}
|
||||
|
||||
.classification-category-path span {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.classification-category-path-empty {
|
||||
color: rgb(var(--v-theme-on-surface-variant));
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.classification-category-protection {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
margin: 2px 0 0;
|
||||
color: rgb(var(--v-theme-warning));
|
||||
font-size: 0.8125rem;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.classification-delete-activator {
|
||||
display: inline-flex;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.classification-delete-activator:focus-visible {
|
||||
outline: 2px solid rgb(var(--v-theme-primary));
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.classification-category-empty {
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
gap: 6px;
|
||||
padding: 28px 16px;
|
||||
border-block: 1px dashed rgba(var(--v-border-color), var(--v-border-opacity));
|
||||
color: rgb(var(--v-theme-on-surface-variant));
|
||||
}
|
||||
|
||||
.classification-fallbacks {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
padding-block-start: 4px;
|
||||
border-block-start: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||
}
|
||||
|
||||
.classification-fallback-grid {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
overflow: hidden;
|
||||
inline-size: 1px;
|
||||
block-size: 1px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
margin: -1px;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.classification-category-form-grid,
|
||||
.classification-fallback-grid {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.classification-media-segments :deep(.v-btn) {
|
||||
padding-inline: 8px;
|
||||
}
|
||||
|
||||
.classification-category-row {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.classification-category-actions {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,760 @@
|
||||
<script setup lang="ts">
|
||||
import type {
|
||||
ClassificationCondition,
|
||||
ClassificationConditionNode,
|
||||
ClassificationFactScalar,
|
||||
ClassificationFactValue,
|
||||
ClassificationFieldDefinition,
|
||||
ClassificationMediaType,
|
||||
ClassificationOperator,
|
||||
ClassificationSourceSupport,
|
||||
} from '@/api/mediaClassificationTypes'
|
||||
|
||||
defineOptions({ name: 'ClassificationConditionBuilder' })
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue: ClassificationConditionNode
|
||||
fields: readonly ClassificationFieldDefinition[]
|
||||
mediaTypes: readonly ClassificationMediaType[]
|
||||
sources: readonly string[]
|
||||
depth?: number
|
||||
maxDepth?: number
|
||||
}>(),
|
||||
{
|
||||
depth: 0,
|
||||
maxDepth: 3,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'update:modelValue', value: ClassificationConditionNode): void
|
||||
}>()
|
||||
|
||||
type ConditionNodeKind = 'condition' | 'all' | 'any' | 'not'
|
||||
type ValueControlKind = 'none' | 'range' | 'list' | 'boolean' | 'number' | 'select' | 'text'
|
||||
|
||||
interface SourceSupportHint {
|
||||
source: string
|
||||
support: Extract<ClassificationSourceSupport, 'partial' | 'unavailable'>
|
||||
label: string
|
||||
color: 'warning' | 'error'
|
||||
icon: string
|
||||
}
|
||||
|
||||
const NO_VALUE_OPERATORS = new Set<ClassificationOperator>(['is_true', 'is_false', 'exists', 'not_exists'])
|
||||
const LIST_VALUE_OPERATORS = new Set<ClassificationOperator>([
|
||||
'in',
|
||||
'not_in',
|
||||
'contains_any',
|
||||
'contains_all',
|
||||
'contains_none',
|
||||
])
|
||||
|
||||
const NODE_KIND_ITEMS: ReadonlyArray<{ title: string; value: ConditionNodeKind }> = [
|
||||
{ title: '条件', value: 'condition' },
|
||||
{ title: '全部', value: 'all' },
|
||||
{ title: '任一', value: 'any' },
|
||||
{ title: '非', value: 'not' },
|
||||
]
|
||||
|
||||
const OPERATOR_LABELS: Record<ClassificationOperator, string> = {
|
||||
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: '不存在',
|
||||
}
|
||||
|
||||
/** 判断节点是否为字段条件叶子。 */
|
||||
function isCondition(node: ClassificationConditionNode): node is ClassificationCondition {
|
||||
return 'field' in node && 'operator' in node
|
||||
}
|
||||
|
||||
/** 读取当前节点类型,并兼容服务端允许的显式 null 组字段。 */
|
||||
function getNodeKind(node: ClassificationConditionNode): ConditionNodeKind {
|
||||
if (isCondition(node)) return 'condition'
|
||||
if (node.all !== undefined && node.all !== null) return 'all'
|
||||
if (node.any !== undefined && node.any !== null) return 'any'
|
||||
return 'not'
|
||||
}
|
||||
|
||||
/** 返回当前条件组的子节点快照,避免后续编辑直接改写 props。 */
|
||||
function getGroupChildren(node: ClassificationConditionNode): ClassificationConditionNode[] {
|
||||
if (isCondition(node)) return []
|
||||
if (node.all !== undefined && node.all !== null) return [...node.all]
|
||||
if (node.any !== undefined && node.any !== null) return [...node.any]
|
||||
return node.not ? [node.not] : []
|
||||
}
|
||||
|
||||
/** 判断字段是否同时适用于规则当前选择的全部媒体类型。 */
|
||||
function supportsSelectedMediaTypes(field: ClassificationFieldDefinition): boolean {
|
||||
return props.mediaTypes.length === 0 || props.mediaTypes.every(mediaType => field.media_types.includes(mediaType))
|
||||
}
|
||||
|
||||
const availableFields = computed(() => props.fields.filter(supportsSelectedMediaTypes))
|
||||
|
||||
const fieldItems = computed(() =>
|
||||
availableFields.value.map(field => ({
|
||||
title: field.group ? `${field.group} · ${field.label}` : field.label,
|
||||
value: field.id,
|
||||
})),
|
||||
)
|
||||
|
||||
const nodeKind = computed(() => getNodeKind(props.modelValue))
|
||||
const groupChildren = computed(() => getGroupChildren(props.modelValue))
|
||||
const canUseGroup = computed(() => props.depth < props.maxDepth)
|
||||
const canAddChild = computed(
|
||||
() =>
|
||||
nodeKind.value !== 'condition' &&
|
||||
availableFields.value.length > 0 &&
|
||||
(nodeKind.value !== 'not' || groupChildren.value.length === 0),
|
||||
)
|
||||
|
||||
const selectedCondition = computed<ClassificationCondition | null>(() =>
|
||||
isCondition(props.modelValue) ? props.modelValue : null,
|
||||
)
|
||||
|
||||
const selectedDefinition = computed(() => {
|
||||
const fieldId = selectedCondition.value?.field
|
||||
return fieldId ? availableFields.value.find(field => field.id === fieldId) : undefined
|
||||
})
|
||||
|
||||
const operatorItems = computed(() =>
|
||||
(selectedDefinition.value?.operators ?? []).map(operator => ({
|
||||
title: OPERATOR_LABELS[operator],
|
||||
value: operator,
|
||||
})),
|
||||
)
|
||||
|
||||
const optionItems = computed(() =>
|
||||
(selectedDefinition.value?.options ?? []).map(option => ({
|
||||
title: option.label,
|
||||
value: option.value,
|
||||
})),
|
||||
)
|
||||
|
||||
const catalogListSelection = computed(() =>
|
||||
optionItems.value.filter(option => listValue.value.some(value => Object.is(value, option.value))),
|
||||
)
|
||||
|
||||
const catalogScalarSelection = computed(
|
||||
() => optionItems.value.find(option => Object.is(option.value, scalarValue.value)) ?? null,
|
||||
)
|
||||
|
||||
const sourceSupportHints = computed<SourceSupportHint[]>(() => {
|
||||
const definition = selectedDefinition.value
|
||||
if (!definition) return []
|
||||
|
||||
const hints: SourceSupportHint[] = []
|
||||
for (const source of new Set(props.sources)) {
|
||||
const support = definition.source_support[source]
|
||||
if (support === 'partial') {
|
||||
hints.push({ source, support, label: '部分支持', color: 'warning', icon: 'mdi-alert-outline' })
|
||||
}
|
||||
if (support === 'unavailable') {
|
||||
hints.push({ source, support, label: '不可用', color: 'error', icon: 'mdi-database-off-outline' })
|
||||
}
|
||||
}
|
||||
return hints
|
||||
})
|
||||
|
||||
const valueControlKind = computed<ValueControlKind>(() => {
|
||||
const condition = selectedCondition.value
|
||||
const definition = selectedDefinition.value
|
||||
if (!condition || !definition || NO_VALUE_OPERATORS.has(condition.operator)) return 'none'
|
||||
if (condition.operator === 'between') return 'range'
|
||||
if (LIST_VALUE_OPERATORS.has(condition.operator) || definition.value_type === 'string_list') return 'list'
|
||||
if (definition.value_type === 'boolean') return 'boolean'
|
||||
if (['integer', 'number', 'year'].includes(definition.value_type)) return 'number'
|
||||
if (definition.value_type === 'enum' || definition.options.length > 0) return 'select'
|
||||
return 'text'
|
||||
})
|
||||
|
||||
const listValue = computed<ClassificationFactScalar[]>(() => {
|
||||
const value = selectedCondition.value?.value
|
||||
return Array.isArray(value) ? [...value] : value === undefined ? [] : [value]
|
||||
})
|
||||
|
||||
const rangeValue = computed<[number | null, number | null]>(() => {
|
||||
const value = selectedCondition.value?.value
|
||||
if (Array.isArray(value) && value.length === 2) {
|
||||
return [typeof value[0] === 'number' ? value[0] : null, typeof value[1] === 'number' ? value[1] : null]
|
||||
}
|
||||
return [null, null]
|
||||
})
|
||||
|
||||
const scalarValue = computed(() => {
|
||||
const value = selectedCondition.value?.value
|
||||
return Array.isArray(value) ? undefined : value
|
||||
})
|
||||
|
||||
const booleanValue = computed(() => scalarValue.value === true)
|
||||
const numericStep = computed(() => (selectedDefinition.value?.value_type === 'number' ? 'any' : 1))
|
||||
const usesCatalogSelect = computed(
|
||||
() => (selectedDefinition.value?.options.length ?? 0) > 0 && !selectedDefinition.value?.allow_custom_values,
|
||||
)
|
||||
// Vuetify 会从对象 items 推断 return-object;这里仅收窄模板泛型,运行时仍传递原始标量。
|
||||
const comboboxListModel = computed(() => listValue.value as never[])
|
||||
const comboboxScalarModel = computed(() => scalarValue.value as never)
|
||||
|
||||
/** 根据字段值类型把控件输出转换为分类条件允许的标量。 */
|
||||
function normalizeScalarValue(value: unknown, definition: ClassificationFieldDefinition): ClassificationFactScalar {
|
||||
if (value === null || value === undefined || value === '') return null
|
||||
if (definition.value_type === 'integer' || definition.value_type === 'year') {
|
||||
const numberValue = Number(value)
|
||||
return Number.isFinite(numberValue) ? Math.trunc(numberValue) : null
|
||||
}
|
||||
if (definition.value_type === 'number') {
|
||||
const numberValue = Number(value)
|
||||
return Number.isFinite(numberValue) ? numberValue : null
|
||||
}
|
||||
if (definition.value_type === 'boolean') return value === true || value === 'true'
|
||||
return typeof value === 'string' ? value : String(value)
|
||||
}
|
||||
|
||||
/** 根据字段目录和操作符生成可继续编辑的初始值。 */
|
||||
function createDefaultValue(
|
||||
definition: ClassificationFieldDefinition,
|
||||
operator: ClassificationOperator,
|
||||
): ClassificationFactValue | undefined {
|
||||
if (NO_VALUE_OPERATORS.has(operator)) return undefined
|
||||
if (operator === 'between') return [null, null]
|
||||
if (LIST_VALUE_OPERATORS.has(operator) || definition.value_type === 'string_list') return []
|
||||
if (definition.value_type === 'boolean') return false
|
||||
if (definition.value_type === 'integer' || definition.value_type === 'number' || definition.value_type === 'year') {
|
||||
return null
|
||||
}
|
||||
return definition.options[0]?.value ?? ''
|
||||
}
|
||||
|
||||
/** 由字段目录的首个可用字段构造叶子,不在前端臆造字段或操作符。 */
|
||||
function createDefaultCondition(): ClassificationCondition | null {
|
||||
const definition = availableFields.value[0]
|
||||
const operator = definition?.operators[0]
|
||||
if (!definition || !operator) return null
|
||||
|
||||
const value = createDefaultValue(definition, operator)
|
||||
return value === undefined ? { field: definition.id, operator } : { field: definition.id, operator, value }
|
||||
}
|
||||
|
||||
/** 发出新的受控节点,所有编辑均保持 props 不变。 */
|
||||
function updateNode(node: ClassificationConditionNode): void {
|
||||
emit('update:modelValue', node)
|
||||
}
|
||||
|
||||
/** 切换叶子或条件组类型,并尽量保留当前已有条件子树。 */
|
||||
function updateNodeKind(kind: ConditionNodeKind): void {
|
||||
if (kind === nodeKind.value) return
|
||||
if (kind !== 'condition' && !canUseGroup.value) return
|
||||
|
||||
if (kind === 'condition') {
|
||||
const condition = createDefaultCondition()
|
||||
if (condition) updateNode(condition)
|
||||
return
|
||||
}
|
||||
|
||||
const existingChildren = isCondition(props.modelValue) ? [props.modelValue] : groupChildren.value
|
||||
const fallbackCondition = existingChildren.length === 0 ? createDefaultCondition() : null
|
||||
const children = existingChildren.length > 0 ? existingChildren : fallbackCondition ? [fallbackCondition] : []
|
||||
|
||||
if (kind === 'not') {
|
||||
if (children[0]) updateNode({ not: children[0] })
|
||||
return
|
||||
}
|
||||
updateNode(kind === 'all' ? { all: children } : { any: children })
|
||||
}
|
||||
|
||||
/** 切换字段时同步采用该字段目录声明的首个操作符和值类型。 */
|
||||
function updateField(fieldId: string): void {
|
||||
const definition = availableFields.value.find(field => field.id === fieldId)
|
||||
const operator = definition?.operators[0]
|
||||
if (!definition || !operator) return
|
||||
|
||||
const value = createDefaultValue(definition, operator)
|
||||
updateNode(value === undefined ? { field: fieldId, operator } : { field: fieldId, operator, value })
|
||||
}
|
||||
|
||||
/** 切换操作符时重置值形状,防止旧操作符的数组或标量泄漏到新条件。 */
|
||||
function updateOperator(operator: ClassificationOperator): void {
|
||||
const condition = selectedCondition.value
|
||||
const definition = selectedDefinition.value
|
||||
if (!condition || !definition || !definition.operators.includes(operator)) return
|
||||
|
||||
const value = createDefaultValue(definition, operator)
|
||||
updateNode(value === undefined ? { field: condition.field, operator } : { field: condition.field, operator, value })
|
||||
}
|
||||
|
||||
/** 写入标量、枚举或布尔值,并按字段类型保持 JSON 值类型。 */
|
||||
function updateScalarValue(value: unknown): void {
|
||||
const condition = selectedCondition.value
|
||||
const definition = selectedDefinition.value
|
||||
if (!condition || !definition) return
|
||||
updateNode({ ...condition, value: normalizeScalarValue(value, definition) })
|
||||
}
|
||||
|
||||
/** 写入成员列表,数字字段会把控件字符串转换为 number。 */
|
||||
function updateListValue(value: unknown): void {
|
||||
const condition = selectedCondition.value
|
||||
const definition = selectedDefinition.value
|
||||
if (!condition || !definition) return
|
||||
|
||||
const values = Array.isArray(value) ? value : value === null || value === undefined ? [] : [value]
|
||||
const normalized = values
|
||||
.map(item => normalizeScalarValue(item, definition))
|
||||
.filter((item): item is Exclude<ClassificationFactScalar, null> => item !== null)
|
||||
updateNode({ ...condition, value: normalized })
|
||||
}
|
||||
|
||||
/** 从 VSelect 的 return-object 结果中读取字段目录声明的原始值。 */
|
||||
function catalogOptionValue(value: unknown): unknown {
|
||||
return typeof value === 'object' && value !== null && 'value' in value ? (value as { value: unknown }).value : value
|
||||
}
|
||||
|
||||
/** 将目录多选结果还原为规则条件的标量数组。 */
|
||||
function updateCatalogListValue(value: unknown): void {
|
||||
updateListValue(Array.isArray(value) ? value.map(catalogOptionValue) : [])
|
||||
}
|
||||
|
||||
/** 将目录单选结果还原为规则条件的标量值。 */
|
||||
function updateCatalogScalarValue(value: unknown): void {
|
||||
updateScalarValue(catalogOptionValue(value))
|
||||
}
|
||||
|
||||
/** 更新 between 的单个边界,同时保留另一侧边界。 */
|
||||
function updateRangeBoundary(index: 0 | 1, value: unknown): void {
|
||||
const condition = selectedCondition.value
|
||||
const definition = selectedDefinition.value
|
||||
if (!condition || !definition) return
|
||||
|
||||
const nextValue: ClassificationFactScalar[] = [...rangeValue.value]
|
||||
nextValue[index] = normalizeScalarValue(value, definition)
|
||||
updateNode({ ...condition, value: nextValue })
|
||||
}
|
||||
|
||||
/** 替换指定条件组子节点。 */
|
||||
function updateChild(index: number, child: ClassificationConditionNode): void {
|
||||
const children = [...groupChildren.value]
|
||||
children[index] = child
|
||||
if (nodeKind.value === 'all') updateNode({ all: children })
|
||||
else if (nodeKind.value === 'any') updateNode({ any: children })
|
||||
else if (children[0]) updateNode({ not: children[0] })
|
||||
}
|
||||
|
||||
/** 向 all/any 组追加叶子,not 组仅在缺少子节点时补充一次。 */
|
||||
function addChild(): void {
|
||||
if (!canAddChild.value) return
|
||||
const child = createDefaultCondition()
|
||||
if (!child) return
|
||||
|
||||
if (nodeKind.value === 'not') updateNode({ not: child })
|
||||
else if (nodeKind.value === 'all') updateNode({ all: [...groupChildren.value, child] })
|
||||
else if (nodeKind.value === 'any') updateNode({ any: [...groupChildren.value, child] })
|
||||
}
|
||||
|
||||
/** 删除 all/any 组中的子节点,并至少保留一个可编辑条件。 */
|
||||
function removeChild(index: number): void {
|
||||
if (!['all', 'any'].includes(nodeKind.value) || groupChildren.value.length <= 1) return
|
||||
const children = groupChildren.value.filter((_, childIndex) => childIndex !== index)
|
||||
updateNode(nodeKind.value === 'all' ? { all: children } : { any: children })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="classification-condition-builder"
|
||||
:data-depth="props.depth"
|
||||
:aria-label="`条件节点,第 ${props.depth + 1} 层`"
|
||||
>
|
||||
<div class="classification-condition-builder__toolbar">
|
||||
<VBtnToggle
|
||||
:model-value="nodeKind"
|
||||
mandatory
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
class="classification-condition-builder__kind-toggle"
|
||||
aria-label="条件节点类型"
|
||||
@update:model-value="updateNodeKind"
|
||||
>
|
||||
<VBtn
|
||||
v-for="item in NODE_KIND_ITEMS"
|
||||
:key="item.value"
|
||||
:value="item.value"
|
||||
:disabled="item.value !== 'condition' && !canUseGroup"
|
||||
size="small"
|
||||
>
|
||||
{{ item.title }}
|
||||
</VBtn>
|
||||
</VBtnToggle>
|
||||
|
||||
<VChip v-if="!canUseGroup" size="small" variant="tonal" color="warning" data-testid="depth-limit">
|
||||
已达最大组深度
|
||||
</VChip>
|
||||
</div>
|
||||
|
||||
<template v-if="nodeKind === 'condition'">
|
||||
<VAlert v-if="availableFields.length === 0" type="warning" variant="tonal" density="compact" class="mt-3">
|
||||
当前媒体类型没有共同可用的条件字段
|
||||
</VAlert>
|
||||
|
||||
<div v-else class="classification-condition-builder__leaf">
|
||||
<VSelect
|
||||
:model-value="selectedCondition?.field"
|
||||
:items="fieldItems"
|
||||
label="字段"
|
||||
aria-label="条件字段"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
hide-details="auto"
|
||||
data-testid="field-select"
|
||||
@update:model-value="updateField"
|
||||
/>
|
||||
|
||||
<VSelect
|
||||
:model-value="selectedCondition?.operator"
|
||||
:items="operatorItems"
|
||||
label="操作符"
|
||||
aria-label="条件操作符"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
hide-details="auto"
|
||||
data-testid="operator-select"
|
||||
@update:model-value="updateOperator"
|
||||
/>
|
||||
|
||||
<div class="classification-condition-builder__value">
|
||||
<VChip
|
||||
v-if="valueControlKind === 'none'"
|
||||
size="small"
|
||||
variant="tonal"
|
||||
color="secondary"
|
||||
data-testid="no-value"
|
||||
>
|
||||
此操作符无需值
|
||||
</VChip>
|
||||
|
||||
<div v-else-if="valueControlKind === 'range'" class="classification-condition-builder__range">
|
||||
<VTextField
|
||||
:model-value="rangeValue[0]"
|
||||
type="number"
|
||||
:step="numericStep"
|
||||
label="起始值"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
hide-details="auto"
|
||||
data-testid="range-start"
|
||||
@update:model-value="updateRangeBoundary(0, $event)"
|
||||
/>
|
||||
<VTextField
|
||||
:model-value="rangeValue[1]"
|
||||
type="number"
|
||||
:step="numericStep"
|
||||
label="结束值"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
hide-details="auto"
|
||||
data-testid="range-end"
|
||||
@update:model-value="updateRangeBoundary(1, $event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<template v-else-if="valueControlKind === 'list'">
|
||||
<VSelect
|
||||
v-if="usesCatalogSelect"
|
||||
:model-value="catalogListSelection"
|
||||
:items="optionItems"
|
||||
return-object
|
||||
label="条件值"
|
||||
aria-label="条件值列表"
|
||||
multiple
|
||||
chips
|
||||
closable-chips
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
hide-details="auto"
|
||||
data-testid="list-value-input"
|
||||
@update:model-value="updateCatalogListValue"
|
||||
/>
|
||||
<VCombobox
|
||||
v-else
|
||||
:model-value="comboboxListModel"
|
||||
:items="optionItems"
|
||||
label="条件值"
|
||||
aria-label="条件值列表"
|
||||
multiple
|
||||
chips
|
||||
closable-chips
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
hide-details="auto"
|
||||
data-testid="list-value-input"
|
||||
@update:model-value="updateListValue"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<VBtnToggle
|
||||
v-else-if="valueControlKind === 'boolean'"
|
||||
:model-value="booleanValue"
|
||||
mandatory
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
aria-label="布尔条件值"
|
||||
data-testid="boolean-value-input"
|
||||
@update:model-value="updateScalarValue"
|
||||
>
|
||||
<VBtn :value="true" size="small">是</VBtn>
|
||||
<VBtn :value="false" size="small">否</VBtn>
|
||||
</VBtnToggle>
|
||||
|
||||
<VTextField
|
||||
v-else-if="valueControlKind === 'number'"
|
||||
:model-value="scalarValue"
|
||||
type="number"
|
||||
:step="numericStep"
|
||||
label="条件值"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
hide-details="auto"
|
||||
data-testid="number-value-input"
|
||||
@update:model-value="updateScalarValue"
|
||||
/>
|
||||
|
||||
<template v-else-if="valueControlKind === 'select'">
|
||||
<VSelect
|
||||
v-if="usesCatalogSelect"
|
||||
:model-value="catalogScalarSelection"
|
||||
:items="optionItems"
|
||||
return-object
|
||||
label="条件值"
|
||||
aria-label="条件值"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
hide-details="auto"
|
||||
data-testid="select-value-input"
|
||||
@update:model-value="updateCatalogScalarValue"
|
||||
/>
|
||||
<VCombobox
|
||||
v-else
|
||||
:model-value="comboboxScalarModel"
|
||||
:items="optionItems"
|
||||
label="条件值"
|
||||
aria-label="条件值"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
hide-details="auto"
|
||||
data-testid="select-value-input"
|
||||
@update:model-value="updateScalarValue"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<VTextField
|
||||
v-else
|
||||
:model-value="scalarValue"
|
||||
label="条件值"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
hide-details="auto"
|
||||
data-testid="text-value-input"
|
||||
@update:model-value="updateScalarValue"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="sourceSupportHints.length > 0"
|
||||
class="classification-condition-builder__source-hints"
|
||||
role="status"
|
||||
aria-label="数据源字段支持提示"
|
||||
data-testid="source-support-hints"
|
||||
>
|
||||
<VChip
|
||||
v-for="hint in sourceSupportHints"
|
||||
:key="`${hint.source}:${hint.support}`"
|
||||
:color="hint.color"
|
||||
:prepend-icon="hint.icon"
|
||||
size="small"
|
||||
variant="tonal"
|
||||
>
|
||||
{{ hint.source }}:{{ hint.label }}
|
||||
</VChip>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-else class="classification-condition-builder__group">
|
||||
<div v-for="(child, index) in groupChildren" :key="index" class="classification-condition-builder__child">
|
||||
<ClassificationConditionBuilder
|
||||
:model-value="child"
|
||||
:fields="props.fields"
|
||||
:media-types="props.mediaTypes"
|
||||
:sources="props.sources"
|
||||
:depth="props.depth + 1"
|
||||
:max-depth="props.maxDepth"
|
||||
@update:model-value="updateChild(index, $event)"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="nodeKind !== 'not' && groupChildren.length > 1"
|
||||
class="classification-condition-builder__child-action"
|
||||
>
|
||||
<VTooltip text="删除子条件" location="top">
|
||||
<template #activator="{ props: tooltipProps }">
|
||||
<VBtn
|
||||
v-bind="tooltipProps"
|
||||
icon="mdi-delete-outline"
|
||||
variant="text"
|
||||
color="error"
|
||||
size="small"
|
||||
:aria-label="`删除子条件 ${index + 1}`"
|
||||
@click="removeChild(index)"
|
||||
/>
|
||||
</template>
|
||||
</VTooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="classification-condition-builder__group-actions">
|
||||
<VTooltip :text="canAddChild ? '新增子条件' : '没有可用字段或 not 已有子条件'" location="top">
|
||||
<template #activator="{ props: tooltipProps }">
|
||||
<VBtn
|
||||
v-bind="tooltipProps"
|
||||
icon="mdi-plus"
|
||||
variant="tonal"
|
||||
color="primary"
|
||||
size="small"
|
||||
aria-label="新增子条件"
|
||||
:disabled="!canAddChild"
|
||||
@click="addChild"
|
||||
/>
|
||||
</template>
|
||||
</VTooltip>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.classification-condition-builder {
|
||||
min-inline-size: 0;
|
||||
padding: 12px;
|
||||
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.classification-condition-builder__toolbar,
|
||||
.classification-condition-builder__source-hints,
|
||||
.classification-condition-builder__group-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.classification-condition-builder__kind-toggle {
|
||||
flex-wrap: wrap;
|
||||
block-size: auto;
|
||||
max-inline-size: 100%;
|
||||
}
|
||||
|
||||
.classification-condition-builder__kind-toggle :deep(.v-btn) {
|
||||
min-inline-size: 64px;
|
||||
}
|
||||
|
||||
.classification-condition-builder__leaf {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(180px, 1.15fr) minmax(150px, 0.85fr) minmax(220px, 1.4fr);
|
||||
gap: 12px;
|
||||
align-items: start;
|
||||
min-inline-size: 0;
|
||||
margin-block-start: 12px;
|
||||
}
|
||||
|
||||
.classification-condition-builder__value {
|
||||
min-inline-size: 0;
|
||||
}
|
||||
|
||||
.classification-condition-builder__range {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.classification-condition-builder__source-hints {
|
||||
margin-block-start: 10px;
|
||||
}
|
||||
|
||||
.classification-condition-builder__group {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
min-inline-size: 0;
|
||||
margin-block-start: 12px;
|
||||
}
|
||||
|
||||
.classification-condition-builder__child {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 36px;
|
||||
gap: 8px;
|
||||
align-items: start;
|
||||
min-inline-size: 0;
|
||||
}
|
||||
|
||||
.classification-condition-builder__child-action {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding-block-start: 4px;
|
||||
}
|
||||
|
||||
.classification-condition-builder__group-actions {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.classification-condition-builder {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.classification-condition-builder__toolbar {
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.classification-condition-builder__kind-toggle {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.classification-condition-builder__kind-toggle :deep(.v-btn) {
|
||||
inline-size: 100%;
|
||||
min-inline-size: 0;
|
||||
}
|
||||
|
||||
.classification-condition-builder__leaf,
|
||||
.classification-condition-builder__range {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.classification-condition-builder__child {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.classification-condition-builder__child-action {
|
||||
justify-content: flex-end;
|
||||
padding-block-start: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,890 @@
|
||||
<script setup lang="ts">
|
||||
import type {
|
||||
ClassificationImpactAnalysis,
|
||||
ClassificationResult,
|
||||
ClassificationSelection,
|
||||
} from '@/api/mediaClassificationTypes'
|
||||
|
||||
/** 影响分析面板输入属性。 */
|
||||
interface ClassificationImpactPanelProps {
|
||||
analysis: ClassificationImpactAnalysis | null
|
||||
loading: boolean
|
||||
disabled: boolean
|
||||
}
|
||||
|
||||
/** 触发有界影响分析时提交的样本和示例上限。 */
|
||||
interface ClassificationImpactAnalyzeOptions {
|
||||
sampleLimit: number
|
||||
exampleLimit: number
|
||||
}
|
||||
|
||||
/** 统计网格中的单个可读指标。 */
|
||||
interface ClassificationImpactMetric {
|
||||
key: string
|
||||
label: string
|
||||
value: string | number
|
||||
}
|
||||
|
||||
const props = defineProps<ClassificationImpactPanelProps>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
analyze: [options: ClassificationImpactAnalyzeOptions]
|
||||
}>()
|
||||
|
||||
const { t, locale } = useI18n()
|
||||
const sampleLimit = ref('100')
|
||||
const exampleLimit = ref('20')
|
||||
const scopeDescriptionId = `classification-impact-scope-${useId()}`
|
||||
|
||||
const overviewMetrics = computed<ClassificationImpactMetric[]>(() => {
|
||||
const analysis = props.analysis
|
||||
if (!analysis) return []
|
||||
return [
|
||||
{
|
||||
key: 'requested_limit',
|
||||
label: t('setting.classification.impact.metrics.requestedLimit'),
|
||||
value: analysis.requested_limit,
|
||||
},
|
||||
{
|
||||
key: 'scanned_count',
|
||||
label: t('setting.classification.impact.metrics.scannedCount'),
|
||||
value: analysis.scanned_count,
|
||||
},
|
||||
{
|
||||
key: 'skipped_count',
|
||||
label: t('setting.classification.impact.metrics.skippedCount'),
|
||||
value: analysis.skipped_count,
|
||||
},
|
||||
{
|
||||
key: 'truncated',
|
||||
label: t('setting.classification.impact.metrics.truncated'),
|
||||
value: t(analysis.truncated ? 'setting.classification.impact.yes' : 'setting.classification.impact.no'),
|
||||
},
|
||||
{
|
||||
key: 'sample_count',
|
||||
label: t('setting.classification.impact.metrics.sampleCount'),
|
||||
value: analysis.sample_count,
|
||||
},
|
||||
{
|
||||
key: 'changed_count',
|
||||
label: t('setting.classification.impact.metrics.changedCount'),
|
||||
value: analysis.changed_count,
|
||||
},
|
||||
{
|
||||
key: 'unchanged_count',
|
||||
label: t('setting.classification.impact.metrics.unchangedCount'),
|
||||
value: analysis.unchanged_count,
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
const changeTypeMetrics = computed<ClassificationImpactMetric[]>(() => {
|
||||
const analysis = props.analysis
|
||||
if (!analysis) return []
|
||||
return [
|
||||
{
|
||||
key: 'category_changed_count',
|
||||
label: t('setting.classification.impact.metrics.categoryChangedCount'),
|
||||
value: analysis.category_changed_count,
|
||||
},
|
||||
{
|
||||
key: 'path_only_changed_count',
|
||||
label: t('setting.classification.impact.metrics.pathOnlyChangedCount'),
|
||||
value: analysis.path_only_changed_count,
|
||||
},
|
||||
{
|
||||
key: 'rule_changed_only_count',
|
||||
label: t('setting.classification.impact.metrics.ruleChangedOnlyCount'),
|
||||
value: analysis.rule_changed_only_count,
|
||||
},
|
||||
{
|
||||
key: 'became_fallback_count',
|
||||
label: t('setting.classification.impact.metrics.becameFallbackCount'),
|
||||
value: analysis.became_fallback_count,
|
||||
},
|
||||
{
|
||||
key: 'partial_count',
|
||||
label: t('setting.classification.impact.metrics.partialCount'),
|
||||
value: analysis.partial_count,
|
||||
},
|
||||
{
|
||||
key: 'degraded_count',
|
||||
label: t('setting.classification.impact.metrics.degradedCount'),
|
||||
value: analysis.degraded_count,
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
/** 将输入约束为服务端允许的整数范围,避免空值或小数进入分析请求。 */
|
||||
function normalizeLimit(value: string, minimum: number, maximum: number, fallback: number): number {
|
||||
const parsed = Number(value)
|
||||
if (!Number.isFinite(parsed)) return fallback
|
||||
return Math.min(maximum, Math.max(minimum, Math.trunc(parsed)))
|
||||
}
|
||||
|
||||
/** 提交经过边界规范化的分析参数。 */
|
||||
function requestAnalysis(): void {
|
||||
if (props.loading || props.disabled) return
|
||||
|
||||
const normalizedSampleLimit = normalizeLimit(sampleLimit.value, 1, 200, 100)
|
||||
const normalizedExampleLimit = normalizeLimit(exampleLimit.value, 0, 50, 20)
|
||||
sampleLimit.value = String(normalizedSampleLimit)
|
||||
exampleLimit.value = String(normalizedExampleLimit)
|
||||
emit('analyze', { sampleLimit: normalizedSampleLimit, exampleLimit: normalizedExampleLimit })
|
||||
}
|
||||
|
||||
/** 返回影响分析来源的本地化说明,同时保留原始 sample_source 值。 */
|
||||
function sampleSourceLabel(source: ClassificationImpactAnalysis['sample_source']): string {
|
||||
return t(
|
||||
source === 'request'
|
||||
? 'setting.classification.impact.sampleSources.request'
|
||||
: 'setting.classification.impact.sampleSources.recentHistory',
|
||||
)
|
||||
}
|
||||
|
||||
/** 将采样时间格式化为本地可读时间;无效值保持原样以便诊断。 */
|
||||
function formatSampledAt(value: string): string {
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return value
|
||||
return new Intl.DateTimeFormat(locale.value, {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false,
|
||||
}).format(date)
|
||||
}
|
||||
|
||||
/** 按稳定分类 ID 排序聚合计数,保证前后策略列表易于比对。 */
|
||||
function sortedCategoryCounts(counts: Record<string, number>): [string, number][] {
|
||||
return Object.entries(counts).sort(([left], [right]) => left.localeCompare(right))
|
||||
}
|
||||
|
||||
/** 选择最终有效结果,缺失时退回自动推荐结果。 */
|
||||
function resultSelection(result: ClassificationResult): ClassificationSelection | null {
|
||||
return result.effective ?? result.recommended ?? null
|
||||
}
|
||||
|
||||
/** 返回结果中的稳定分类 ID。 */
|
||||
function resultCategory(result: ClassificationResult): string {
|
||||
return resultSelection(result)?.category_id || t('setting.classification.impact.uncategorized')
|
||||
}
|
||||
|
||||
/** 返回结果中的多级分类路径。 */
|
||||
function resultPath(result: ClassificationResult): string {
|
||||
const path = resultSelection(result)?.category_path ?? []
|
||||
return path.length ? path.join(' / ') : t('setting.classification.impact.noCategoryPath')
|
||||
}
|
||||
|
||||
/** 返回结果中的命中规则 ID。 */
|
||||
function resultRule(result: ClassificationResult): string {
|
||||
return resultSelection(result)?.rule_id || t('setting.classification.impact.none')
|
||||
}
|
||||
|
||||
/** 返回结果中的分类来源。 */
|
||||
function resultSource(result: ClassificationResult): string {
|
||||
return resultSelection(result)?.source || t('setting.classification.impact.none')
|
||||
}
|
||||
|
||||
/** 返回求值状态的本地化说明。 */
|
||||
function resultState(result: ClassificationResult): string {
|
||||
const stateKeys: Record<ClassificationResult['state'], string> = {
|
||||
complete: 'setting.classification.impact.states.complete',
|
||||
partial: 'setting.classification.impact.states.partial',
|
||||
not_evaluated: 'setting.classification.impact.states.notEvaluated',
|
||||
invalid_policy: 'setting.classification.impact.states.invalidPolicy',
|
||||
}
|
||||
return t(stateKeys[result.state])
|
||||
}
|
||||
|
||||
/** 返回变化字段的用户可读名称,并保留未知扩展字段。 */
|
||||
function changedFieldLabel(field: string): string {
|
||||
const fieldKeys: Record<string, string> = {
|
||||
category_id: 'setting.classification.impact.changedFields.categoryId',
|
||||
category_path: 'setting.classification.impact.changedFields.categoryPath',
|
||||
rule_id: 'setting.classification.impact.changedFields.ruleId',
|
||||
labels: 'setting.classification.impact.changedFields.labels',
|
||||
state: 'setting.classification.impact.changedFields.state',
|
||||
}
|
||||
return fieldKeys[field] ? t(fieldKeys[field]) : field
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="classification-impact-panel" aria-labelledby="classification-impact-title" :aria-busy="loading">
|
||||
<header class="classification-impact-header">
|
||||
<div>
|
||||
<h2 id="classification-impact-title">{{ t('setting.classification.impact.title') }}</h2>
|
||||
<p>{{ t('setting.classification.impact.description') }}</p>
|
||||
</div>
|
||||
<VChip v-if="analysis" color="info" variant="tonal" size="small">
|
||||
{{
|
||||
t('setting.classification.impact.sampleSource', {
|
||||
source: analysis.sample_source,
|
||||
label: sampleSourceLabel(analysis.sample_source),
|
||||
})
|
||||
}}
|
||||
</VChip>
|
||||
</header>
|
||||
|
||||
<form
|
||||
class="classification-impact-controls"
|
||||
:aria-describedby="scopeDescriptionId"
|
||||
@submit.prevent="requestAnalysis"
|
||||
>
|
||||
<VTextField
|
||||
v-model="sampleLimit"
|
||||
type="number"
|
||||
:label="t('setting.classification.impact.sampleLimit')"
|
||||
:aria-label="t('setting.classification.impact.sampleLimit')"
|
||||
min="1"
|
||||
max="200"
|
||||
step="1"
|
||||
inputmode="numeric"
|
||||
density="compact"
|
||||
hide-details="auto"
|
||||
:disabled="disabled || loading"
|
||||
/>
|
||||
<VTextField
|
||||
v-model="exampleLimit"
|
||||
type="number"
|
||||
:label="t('setting.classification.impact.exampleLimit')"
|
||||
:aria-label="t('setting.classification.impact.exampleLimit')"
|
||||
min="0"
|
||||
max="50"
|
||||
step="1"
|
||||
inputmode="numeric"
|
||||
density="compact"
|
||||
hide-details="auto"
|
||||
:disabled="disabled || loading"
|
||||
/>
|
||||
<VBtn
|
||||
type="submit"
|
||||
color="primary"
|
||||
variant="tonal"
|
||||
prepend-icon="mdi-chart-box-outline"
|
||||
:loading="loading"
|
||||
:disabled="disabled || loading"
|
||||
:aria-label="t('setting.classification.impact.analyzeAria')"
|
||||
@click.prevent="requestAnalysis"
|
||||
>
|
||||
{{ t('setting.classification.impact.analyze') }}
|
||||
</VBtn>
|
||||
</form>
|
||||
|
||||
<p :id="scopeDescriptionId" class="classification-impact-scope">
|
||||
{{ t('setting.classification.impact.scope') }}
|
||||
</p>
|
||||
|
||||
<p v-if="loading" class="classification-impact-status" role="status" aria-live="polite">
|
||||
{{ t('setting.classification.impact.loading') }}
|
||||
</p>
|
||||
|
||||
<div v-if="analysis" class="classification-impact-result">
|
||||
<div class="classification-impact-meta" :aria-label="t('setting.classification.impact.metadataAria')">
|
||||
<span>{{ t('setting.classification.impact.baselineRevision', { revision: analysis.baseline_revision }) }}</span>
|
||||
<VIcon icon="mdi-arrow-right" size="small" aria-hidden="true" />
|
||||
<span>{{
|
||||
t('setting.classification.impact.candidateRevision', { revision: analysis.candidate_revision })
|
||||
}}</span>
|
||||
<span class="classification-impact-time">{{
|
||||
t('setting.classification.impact.sampledAt', { time: formatSampledAt(analysis.sampled_at) })
|
||||
}}</span>
|
||||
</div>
|
||||
|
||||
<section aria-labelledby="classification-impact-overview-title">
|
||||
<h3 id="classification-impact-overview-title">{{ t('setting.classification.impact.overviewTitle') }}</h3>
|
||||
<dl class="classification-impact-metrics">
|
||||
<div
|
||||
v-for="metric in overviewMetrics"
|
||||
:key="metric.key"
|
||||
class="classification-impact-metric"
|
||||
:data-testid="`impact-metric-${metric.key}`"
|
||||
>
|
||||
<dt>
|
||||
{{ metric.label }} <code>{{ metric.key }}</code>
|
||||
</dt>
|
||||
<dd>{{ metric.value }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<p v-if="analysis.truncated" class="classification-impact-truncated" role="note">
|
||||
{{ t('setting.classification.impact.truncated') }}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="classification-impact-change-types-title">
|
||||
<h3 id="classification-impact-change-types-title">{{ t('setting.classification.impact.changeTypesTitle') }}</h3>
|
||||
<dl class="classification-impact-metrics classification-impact-metrics--changes">
|
||||
<div
|
||||
v-for="metric in changeTypeMetrics"
|
||||
:key="metric.key"
|
||||
class="classification-impact-metric"
|
||||
:data-testid="`impact-metric-${metric.key}`"
|
||||
>
|
||||
<dt>
|
||||
{{ metric.label }} <code>{{ metric.key }}</code>
|
||||
</dt>
|
||||
<dd>{{ metric.value }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section
|
||||
class="classification-impact-category-comparison"
|
||||
aria-labelledby="classification-impact-categories-title"
|
||||
>
|
||||
<h3 id="classification-impact-categories-title">{{ t('setting.classification.impact.categoriesTitle') }}</h3>
|
||||
<div class="classification-impact-category-columns">
|
||||
<div :aria-label="t('setting.classification.impact.previousCategoriesAria')">
|
||||
<h4>previous_categories</h4>
|
||||
<dl v-if="sortedCategoryCounts(analysis.previous_categories).length" class="classification-impact-counts">
|
||||
<div v-for="[categoryId, count] in sortedCategoryCounts(analysis.previous_categories)" :key="categoryId">
|
||||
<dt>
|
||||
<code>{{ categoryId }}</code>
|
||||
</dt>
|
||||
<dd>{{ count }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<p v-else class="classification-impact-empty">
|
||||
{{ t('setting.classification.impact.emptyCategoryCounts') }}
|
||||
</p>
|
||||
</div>
|
||||
<div :aria-label="t('setting.classification.impact.candidateCategoriesAria')">
|
||||
<h4>candidate_categories</h4>
|
||||
<dl v-if="sortedCategoryCounts(analysis.candidate_categories).length" class="classification-impact-counts">
|
||||
<div v-for="[categoryId, count] in sortedCategoryCounts(analysis.candidate_categories)" :key="categoryId">
|
||||
<dt>
|
||||
<code>{{ categoryId }}</code>
|
||||
</dt>
|
||||
<dd>{{ count }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<p v-else class="classification-impact-empty">
|
||||
{{ t('setting.classification.impact.emptyCategoryCounts') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="classification-impact-groups-title">
|
||||
<h3 id="classification-impact-groups-title">{{ t('setting.classification.impact.groupsTitle') }}</h3>
|
||||
<div
|
||||
v-if="analysis.groups.length"
|
||||
class="classification-impact-table-wrap"
|
||||
role="region"
|
||||
:aria-label="t('setting.classification.impact.groupsAria')"
|
||||
tabindex="0"
|
||||
>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">{{ t('setting.classification.impact.columns.mediaType') }}</th>
|
||||
<th scope="col">{{ t('setting.classification.impact.columns.source') }}</th>
|
||||
<th scope="col">{{ t('setting.classification.impact.columns.sample') }}</th>
|
||||
<th scope="col">{{ t('setting.classification.impact.columns.changed') }}</th>
|
||||
<th scope="col">{{ t('setting.classification.impact.columns.degraded') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="group in analysis.groups" :key="`${group.media_type}:${group.media_source}`">
|
||||
<th scope="row">{{ group.media_type }}</th>
|
||||
<td>
|
||||
<code>{{ group.media_source }}</code>
|
||||
</td>
|
||||
<td>{{ group.sampled }}</td>
|
||||
<td>{{ group.changed }}</td>
|
||||
<td>{{ group.degraded }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p v-else class="classification-impact-empty">{{ t('setting.classification.impact.emptyGroups') }}</p>
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="classification-impact-examples-title">
|
||||
<div class="classification-impact-section-head">
|
||||
<h3 id="classification-impact-examples-title">{{ t('setting.classification.impact.examplesTitle') }}</h3>
|
||||
<span>{{
|
||||
t('setting.classification.impact.exampleSummary', {
|
||||
returned: analysis.changes.length,
|
||||
changed: analysis.changed_count,
|
||||
})
|
||||
}}</span>
|
||||
</div>
|
||||
<ol
|
||||
v-if="analysis.changes.length"
|
||||
class="classification-impact-changes"
|
||||
:aria-label="t('setting.classification.impact.examplesAria')"
|
||||
>
|
||||
<li
|
||||
v-for="(change, index) in analysis.changes"
|
||||
:key="`${change.identity.media_source}:${change.identity.media_id}`"
|
||||
>
|
||||
<article
|
||||
:aria-label="
|
||||
t('setting.classification.impact.exampleAria', {
|
||||
index: index + 1,
|
||||
title: change.title || change.identity.media_id,
|
||||
})
|
||||
"
|
||||
>
|
||||
<header class="classification-impact-change-head">
|
||||
<div>
|
||||
<strong>{{ change.title || t('setting.classification.impact.untitledMedia') }}</strong>
|
||||
<span>{{ change.media_type }}</span>
|
||||
</div>
|
||||
<code>{{ change.identity.media_source }}:{{ change.identity.media_id }}</code>
|
||||
</header>
|
||||
|
||||
<ul
|
||||
class="classification-impact-fields"
|
||||
:aria-label="t('setting.classification.impact.changedFieldsAria')"
|
||||
>
|
||||
<li v-for="field in change.changed_fields" :key="field">{{ changedFieldLabel(field) }}</li>
|
||||
</ul>
|
||||
|
||||
<div class="classification-impact-result-comparison">
|
||||
<section :aria-label="t('setting.classification.impact.previousResultAria', { index: index + 1 })">
|
||||
<h4>{{ t('setting.classification.impact.previous') }}</h4>
|
||||
<dl>
|
||||
<div>
|
||||
<dt>{{ t('setting.classification.impact.resultFields.category') }}</dt>
|
||||
<dd>
|
||||
<code>{{ resultCategory(change.previous) }}</code>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{{ t('setting.classification.impact.resultFields.path') }}</dt>
|
||||
<dd>{{ resultPath(change.previous) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{{ t('setting.classification.impact.resultFields.rule') }}</dt>
|
||||
<dd>
|
||||
<code>{{ resultRule(change.previous) }}</code>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{{ t('setting.classification.impact.resultFields.source') }}</dt>
|
||||
<dd>
|
||||
<code>{{ resultSource(change.previous) }}</code>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{{ t('setting.classification.impact.resultFields.state') }}</dt>
|
||||
<dd>{{ resultState(change.previous) }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
<section :aria-label="t('setting.classification.impact.candidateResultAria', { index: index + 1 })">
|
||||
<h4>{{ t('setting.classification.impact.candidate') }}</h4>
|
||||
<dl>
|
||||
<div>
|
||||
<dt>{{ t('setting.classification.impact.resultFields.category') }}</dt>
|
||||
<dd>
|
||||
<code>{{ resultCategory(change.candidate) }}</code>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{{ t('setting.classification.impact.resultFields.path') }}</dt>
|
||||
<dd>{{ resultPath(change.candidate) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{{ t('setting.classification.impact.resultFields.rule') }}</dt>
|
||||
<dd>
|
||||
<code>{{ resultRule(change.candidate) }}</code>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{{ t('setting.classification.impact.resultFields.source') }}</dt>
|
||||
<dd>
|
||||
<code>{{ resultSource(change.candidate) }}</code>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{{ t('setting.classification.impact.resultFields.state') }}</dt>
|
||||
<dd>{{ resultState(change.candidate) }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
</div>
|
||||
</article>
|
||||
</li>
|
||||
</ol>
|
||||
<p v-else class="classification-impact-empty">{{ t('setting.classification.impact.emptyExamples') }}</p>
|
||||
</section>
|
||||
|
||||
<section v-if="analysis.warnings.length" aria-labelledby="classification-impact-warnings-title">
|
||||
<h3 id="classification-impact-warnings-title">{{ t('setting.classification.impact.warningsTitle') }}</h3>
|
||||
<div class="classification-impact-warnings" role="alert">
|
||||
<VIcon icon="mdi-alert-outline" aria-hidden="true" />
|
||||
<ul>
|
||||
<li v-for="warning in analysis.warnings" :key="warning">{{ warning }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div v-else-if="!loading" class="classification-impact-empty-state" role="status">
|
||||
<VIcon icon="mdi-chart-box-outline" size="30" aria-hidden="true" />
|
||||
<span>{{ t('setting.classification.impact.emptyAnalysis') }}</span>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.classification-impact-panel,
|
||||
.classification-impact-result {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.classification-impact-header,
|
||||
.classification-impact-meta,
|
||||
.classification-impact-section-head,
|
||||
.classification-impact-change-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.classification-impact-header h2,
|
||||
.classification-impact-result h3,
|
||||
.classification-impact-result h4 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.classification-impact-header h2 {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.classification-impact-header p,
|
||||
.classification-impact-scope,
|
||||
.classification-impact-time,
|
||||
.classification-impact-section-head span,
|
||||
.classification-impact-empty {
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.classification-impact-header p {
|
||||
margin: 4px 0 0;
|
||||
}
|
||||
|
||||
.classification-impact-header :deep(.v-chip) {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.classification-impact-controls {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(130px, 180px) minmax(150px, 190px) auto;
|
||||
align-items: start;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.classification-impact-controls :deep(.v-btn) {
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
.classification-impact-scope,
|
||||
.classification-impact-status,
|
||||
.classification-impact-empty {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.classification-impact-status {
|
||||
color: rgb(var(--v-theme-primary));
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.classification-impact-result > section {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
padding-block-start: 14px;
|
||||
border-top: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||
}
|
||||
|
||||
.classification-impact-result h3 {
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
|
||||
.classification-impact-result h4 {
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.classification-impact-meta {
|
||||
justify-content: flex-start;
|
||||
flex-wrap: wrap;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.classification-impact-time {
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
|
||||
.classification-impact-metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, minmax(92px, 1fr));
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.classification-impact-metrics--changes {
|
||||
grid-template-columns: repeat(6, minmax(110px, 1fr));
|
||||
}
|
||||
|
||||
.classification-impact-metric {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
padding: 9px 10px;
|
||||
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.classification-impact-metric dt {
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.classification-impact-metric dt code {
|
||||
display: block;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.classification-impact-metric dd {
|
||||
margin: 0;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.classification-impact-truncated {
|
||||
margin: 0;
|
||||
padding: 8px 10px;
|
||||
border-inline-start: 3px solid rgb(var(--v-theme-warning));
|
||||
background: rgba(var(--v-theme-warning), 0.08);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.classification-impact-category-columns,
|
||||
.classification-impact-result-comparison {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.classification-impact-category-columns > div,
|
||||
.classification-impact-result-comparison > section {
|
||||
min-width: 0;
|
||||
padding: 10px;
|
||||
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.classification-impact-counts,
|
||||
.classification-impact-result-comparison dl {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
margin: 8px 0 0;
|
||||
}
|
||||
|
||||
.classification-impact-counts > div,
|
||||
.classification-impact-result-comparison dl > div {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.classification-impact-counts dt,
|
||||
.classification-impact-result-comparison dt {
|
||||
min-width: 0;
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.classification-impact-counts dd,
|
||||
.classification-impact-result-comparison dd {
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
text-align: end;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.classification-impact-table-wrap {
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.classification-impact-table-wrap:focus-visible {
|
||||
outline: 2px solid rgb(var(--v-theme-primary));
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.classification-impact-table-wrap table {
|
||||
width: 100%;
|
||||
min-width: 520px;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.classification-impact-table-wrap th,
|
||||
.classification-impact-table-wrap td {
|
||||
padding: 9px 10px;
|
||||
border-bottom: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||
text-align: start;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.classification-impact-table-wrap tbody tr:last-child th,
|
||||
.classification-impact-table-wrap tbody tr:last-child td {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.classification-impact-table-wrap thead th {
|
||||
background: rgba(var(--v-theme-on-surface), 0.04);
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.classification-impact-changes {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.classification-impact-changes article {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
padding: 11px;
|
||||
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.classification-impact-change-head > div {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.classification-impact-change-head span {
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.classification-impact-change-head code {
|
||||
overflow-wrap: anywhere;
|
||||
text-align: end;
|
||||
}
|
||||
|
||||
.classification-impact-fields {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.classification-impact-fields li {
|
||||
padding: 3px 7px;
|
||||
border-radius: 4px;
|
||||
background: rgba(var(--v-theme-primary), 0.1);
|
||||
color: rgb(var(--v-theme-primary));
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.classification-impact-warnings {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
border-inline-start: 3px solid rgb(var(--v-theme-warning));
|
||||
background: rgba(var(--v-theme-warning), 0.08);
|
||||
}
|
||||
|
||||
.classification-impact-warnings ul {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
margin: 0;
|
||||
padding-inline-start: 18px;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.classification-impact-empty-state {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
gap: 6px;
|
||||
min-height: 104px;
|
||||
padding: 12px;
|
||||
border: 1px dashed rgba(var(--v-border-color), var(--v-border-opacity));
|
||||
border-radius: 6px;
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
|
||||
text-align: center;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.classification-impact-metrics,
|
||||
.classification-impact-metrics--changes {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.classification-impact-header,
|
||||
.classification-impact-section-head,
|
||||
.classification-impact-change-head {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.classification-impact-controls,
|
||||
.classification-impact-category-columns,
|
||||
.classification-impact-result-comparison {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.classification-impact-controls :deep(.v-btn) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.classification-impact-meta {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.classification-impact-time {
|
||||
flex-basis: 100%;
|
||||
margin-inline-start: 0;
|
||||
}
|
||||
|
||||
.classification-impact-metrics,
|
||||
.classification-impact-metrics--changes {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.classification-impact-change-head code {
|
||||
text-align: start;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 390px) {
|
||||
.classification-impact-metrics,
|
||||
.classification-impact-metrics--changes {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,682 @@
|
||||
<script setup lang="ts">
|
||||
import type {
|
||||
ClassificationImpactAnalysis,
|
||||
ClassificationPolicyHistory,
|
||||
ClassificationRevisionConflict,
|
||||
ClassificationValidationResult,
|
||||
} from '@/api/mediaClassificationTypes'
|
||||
|
||||
/** 策略发布、冲突恢复和历史回滚控制面板输入。 */
|
||||
interface ClassificationPolicyControlPanelProps {
|
||||
activeRevision: number
|
||||
isDirty: boolean
|
||||
validationResult: ClassificationValidationResult | null
|
||||
validationIsCurrent?: boolean
|
||||
impactResult?: ClassificationImpactAnalysis | null
|
||||
impactIsCurrent?: boolean
|
||||
conflict: ClassificationRevisionConflict | null
|
||||
history: ClassificationPolicyHistory | null
|
||||
validating?: boolean
|
||||
publishing?: boolean
|
||||
refreshing?: boolean
|
||||
loadingHistory?: boolean
|
||||
rollingBack?: boolean
|
||||
analyzingImpact?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<ClassificationPolicyControlPanelProps>(), {
|
||||
validationIsCurrent: false,
|
||||
impactResult: null,
|
||||
impactIsCurrent: false,
|
||||
validating: false,
|
||||
publishing: false,
|
||||
refreshing: false,
|
||||
loadingHistory: false,
|
||||
rollingBack: false,
|
||||
analyzingImpact: false,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
validate: []
|
||||
analyze: []
|
||||
publish: []
|
||||
refresh: []
|
||||
'keep-draft': []
|
||||
'load-history': []
|
||||
rollback: [revision: number]
|
||||
}>()
|
||||
|
||||
const { t, locale } = useI18n()
|
||||
const componentId = useId()
|
||||
const titleId = 'classification-policy-control-title-' + componentId
|
||||
const publishRequirementsId = 'classification-publish-requirements-' + componentId
|
||||
const impactReviewed = ref(false)
|
||||
const selectedRevision = ref<number | null>(null)
|
||||
const statusMessage = ref('')
|
||||
|
||||
/** 当前任意策略写操作或其前置请求是否正在执行。 */
|
||||
const isBusy = computed(
|
||||
() =>
|
||||
props.validating ||
|
||||
props.publishing ||
|
||||
props.refreshing ||
|
||||
props.loadingHistory ||
|
||||
props.rollingBack ||
|
||||
props.analyzingImpact,
|
||||
)
|
||||
|
||||
/** 服务端校验是否通过且仍对应当前草稿。 */
|
||||
const hasCurrentValidation = computed(() => props.validationIsCurrent && props.validationResult?.valid === true)
|
||||
|
||||
/** 影响分析是否基于当前活动 revision 且仍对应当前草稿。 */
|
||||
const hasCurrentImpact = computed(
|
||||
() =>
|
||||
props.impactIsCurrent &&
|
||||
props.impactResult !== null &&
|
||||
props.impactResult.baseline_revision === props.activeRevision,
|
||||
)
|
||||
|
||||
/** 发布动作必须满足的全部服务端和人工审阅前置条件。 */
|
||||
const canPublish = computed(
|
||||
() =>
|
||||
props.isDirty &&
|
||||
hasCurrentValidation.value &&
|
||||
hasCurrentImpact.value &&
|
||||
impactReviewed.value &&
|
||||
!props.conflict &&
|
||||
!isBusy.value,
|
||||
)
|
||||
|
||||
/** 历史版本按 revision 从新到旧展示,不修改父级只读快照。 */
|
||||
const historyItems = computed(() =>
|
||||
[...(props.history?.items ?? [])].sort((left, right) => right.revision - left.revision),
|
||||
)
|
||||
|
||||
/** 返回发布门禁项对应的图标和语义颜色。 */
|
||||
function requirementPresentation(passed: boolean): { color: string; icon: string } {
|
||||
return passed
|
||||
? { color: 'success', icon: 'mdi-check-circle-outline' }
|
||||
: { color: 'warning', icon: 'mdi-alert-circle-outline' }
|
||||
}
|
||||
|
||||
/** 使用当前浏览器区域格式化历史更新时间,并保留无法解析的服务端原值。 */
|
||||
function formatUpdatedAt(value?: string | null): string {
|
||||
if (!value) return t('setting.classification.control.updatedAtUnknown')
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return value
|
||||
return new Intl.DateTimeFormat(locale.value, {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short',
|
||||
hour12: false,
|
||||
}).format(date)
|
||||
}
|
||||
|
||||
/** 发布前再次检查门禁,避免键盘或程序触发绕过禁用状态。 */
|
||||
function requestPublish(): void {
|
||||
if (!canPublish.value) return
|
||||
statusMessage.value = t('setting.classification.control.publishingStatus', { revision: props.activeRevision })
|
||||
emit('publish')
|
||||
}
|
||||
|
||||
/** 冲突后保留本地草稿,并立即请求以最新远端 revision 重新分析。 */
|
||||
function keepDraftAndAnalyze(): void {
|
||||
statusMessage.value = t('setting.classification.control.keepDraftStatus')
|
||||
emit('keep-draft')
|
||||
}
|
||||
|
||||
/** 对所选历史 revision 发起 CAS 回滚,服务端会创建一个全新版本。 */
|
||||
function requestRollback(): void {
|
||||
if (selectedRevision.value === null || isBusy.value || props.conflict) return
|
||||
statusMessage.value = t('setting.classification.control.rollbackStatus', { revision: selectedRevision.value })
|
||||
emit('rollback', selectedRevision.value)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [
|
||||
props.activeRevision,
|
||||
props.impactIsCurrent,
|
||||
props.impactResult?.baseline_revision,
|
||||
props.impactResult?.candidate_revision,
|
||||
props.impactResult?.sampled_at,
|
||||
],
|
||||
() => {
|
||||
impactReviewed.value = false
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => [props.activeRevision, props.history?.active_revision, historyItems.value.map(item => item.revision).join(',')],
|
||||
() => {
|
||||
if (!historyItems.value.some(item => item.revision === selectedRevision.value)) selectedRevision.value = null
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="classification-policy-control" :aria-labelledby="titleId">
|
||||
<header class="classification-policy-control__header">
|
||||
<div>
|
||||
<h2 :id="titleId">{{ t('setting.classification.control.title') }}</h2>
|
||||
<p>{{ t('setting.classification.control.description') }}</p>
|
||||
</div>
|
||||
<div
|
||||
class="classification-policy-control__revision"
|
||||
:aria-label="t('setting.classification.control.policyStatusAria')"
|
||||
>
|
||||
<VChip size="small" prepend-icon="mdi-source-branch" variant="tonal"> revision {{ activeRevision }} </VChip>
|
||||
<VChip v-if="isDirty" size="small" color="warning" variant="tonal">
|
||||
{{ t('setting.classification.control.unpublishedChanges') }}
|
||||
</VChip>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<p class="sr-only" role="status" aria-live="polite">{{ statusMessage }}</p>
|
||||
|
||||
<VAlert
|
||||
v-if="conflict"
|
||||
class="classification-policy-control__conflict"
|
||||
type="warning"
|
||||
variant="tonal"
|
||||
:title="t('setting.classification.control.conflictTitle')"
|
||||
role="alert"
|
||||
>
|
||||
<p>
|
||||
{{
|
||||
t('setting.classification.control.conflictDescription', {
|
||||
expected: conflict.expected_revision,
|
||||
current: conflict.current_revision,
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
<div class="classification-policy-control__actions">
|
||||
<VBtn
|
||||
color="warning"
|
||||
variant="outlined"
|
||||
prepend-icon="mdi-refresh"
|
||||
:loading="refreshing"
|
||||
:disabled="isBusy && !refreshing"
|
||||
:aria-label="t('setting.classification.control.reloadRemote')"
|
||||
@click="emit('refresh')"
|
||||
>
|
||||
{{ t('setting.classification.control.reloadRemote') }}
|
||||
</VBtn>
|
||||
<VBtn
|
||||
color="warning"
|
||||
variant="tonal"
|
||||
prepend-icon="mdi-chart-box-outline"
|
||||
:loading="analyzingImpact"
|
||||
:disabled="isBusy && !analyzingImpact"
|
||||
:aria-label="t('setting.classification.control.keepDraft')"
|
||||
@click="keepDraftAndAnalyze"
|
||||
>
|
||||
{{ t('setting.classification.control.keepDraft') }}
|
||||
</VBtn>
|
||||
</div>
|
||||
</VAlert>
|
||||
|
||||
<div class="classification-policy-control__layout">
|
||||
<section class="classification-policy-control__section" aria-labelledby="classification-publish-title">
|
||||
<div class="classification-policy-control__section-header">
|
||||
<div>
|
||||
<h3 id="classification-publish-title">{{ t('setting.classification.control.publishTitle') }}</h3>
|
||||
<p>{{ t('setting.classification.control.publishDescription') }}</p>
|
||||
</div>
|
||||
<div class="classification-policy-control__actions">
|
||||
<VBtn
|
||||
variant="outlined"
|
||||
prepend-icon="mdi-shield-check-outline"
|
||||
:loading="validating"
|
||||
:disabled="isBusy && !validating"
|
||||
:aria-label="t('setting.classification.control.validateDraftAria')"
|
||||
@click="emit('validate')"
|
||||
>
|
||||
{{ t('setting.classification.control.serverValidation') }}
|
||||
</VBtn>
|
||||
<VBtn
|
||||
variant="outlined"
|
||||
prepend-icon="mdi-chart-box-outline"
|
||||
:loading="analyzingImpact"
|
||||
:disabled="isBusy && !analyzingImpact"
|
||||
:aria-label="t('setting.classification.control.analyzeDraftAria')"
|
||||
@click="emit('analyze')"
|
||||
>
|
||||
{{ t('setting.classification.control.impactAnalysis') }}
|
||||
</VBtn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul
|
||||
:id="publishRequirementsId"
|
||||
class="classification-policy-control__requirements"
|
||||
:aria-label="t('setting.classification.control.publishRequirementsAria')"
|
||||
>
|
||||
<li :data-passed="isDirty">
|
||||
<VIcon
|
||||
:icon="requirementPresentation(isDirty).icon"
|
||||
:color="requirementPresentation(isDirty).color"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>{{
|
||||
t(
|
||||
isDirty
|
||||
? 'setting.classification.control.requirements.dirtyPassed'
|
||||
: 'setting.classification.control.requirements.dirtyPending',
|
||||
)
|
||||
}}</span>
|
||||
</li>
|
||||
<li :data-passed="hasCurrentValidation">
|
||||
<VIcon
|
||||
:icon="requirementPresentation(hasCurrentValidation).icon"
|
||||
:color="requirementPresentation(hasCurrentValidation).color"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>
|
||||
{{
|
||||
t(
|
||||
hasCurrentValidation
|
||||
? 'setting.classification.control.requirements.validationPassed'
|
||||
: 'setting.classification.control.requirements.validationPending',
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
</li>
|
||||
<li :data-passed="hasCurrentImpact">
|
||||
<VIcon
|
||||
:icon="requirementPresentation(hasCurrentImpact).icon"
|
||||
:color="requirementPresentation(hasCurrentImpact).color"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>
|
||||
{{
|
||||
hasCurrentImpact
|
||||
? t('setting.classification.control.requirements.impactPassed', { revision: activeRevision })
|
||||
: t('setting.classification.control.requirements.impactPending')
|
||||
}}
|
||||
</span>
|
||||
</li>
|
||||
<li :data-passed="impactReviewed">
|
||||
<VIcon
|
||||
:icon="requirementPresentation(impactReviewed).icon"
|
||||
:color="requirementPresentation(impactReviewed).color"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>{{
|
||||
t(
|
||||
impactReviewed
|
||||
? 'setting.classification.control.requirements.reviewPassed'
|
||||
: 'setting.classification.control.requirements.reviewPending',
|
||||
)
|
||||
}}</span>
|
||||
</li>
|
||||
<li :data-passed="!conflict">
|
||||
<VIcon
|
||||
:icon="requirementPresentation(!conflict).icon"
|
||||
:color="requirementPresentation(!conflict).color"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>{{
|
||||
t(
|
||||
conflict
|
||||
? 'setting.classification.control.requirements.conflictPending'
|
||||
: 'setting.classification.control.requirements.conflictPassed',
|
||||
)
|
||||
}}</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<section
|
||||
v-if="impactResult"
|
||||
class="classification-policy-control__impact"
|
||||
:aria-label="t('setting.classification.control.latestImpactAria')"
|
||||
>
|
||||
<div class="classification-policy-control__metrics">
|
||||
<div>
|
||||
<span>{{ t('setting.classification.control.sample') }}</span>
|
||||
<strong>{{ impactResult.sample_count }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>{{ t('setting.classification.control.classificationChanges') }}</span>
|
||||
<strong>{{ impactResult.changed_count }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>{{ t('setting.classification.control.degraded') }}</span>
|
||||
<strong>{{ impactResult.degraded_count }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<p :class="{ 'text-warning': !hasCurrentImpact }">
|
||||
{{
|
||||
hasCurrentImpact
|
||||
? t('setting.classification.control.analysisTime', { time: formatUpdatedAt(impactResult.sampled_at) })
|
||||
: t('setting.classification.control.impactExpired')
|
||||
}}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<VCheckbox
|
||||
v-model="impactReviewed"
|
||||
color="primary"
|
||||
hide-details
|
||||
:label="t('setting.classification.control.reviewConfirmation')"
|
||||
:disabled="!hasCurrentImpact || isBusy"
|
||||
/>
|
||||
|
||||
<VBtn
|
||||
color="primary"
|
||||
variant="flat"
|
||||
size="large"
|
||||
block
|
||||
prepend-icon="mdi-cloud-upload-outline"
|
||||
:loading="publishing"
|
||||
:disabled="!canPublish"
|
||||
:aria-describedby="publishRequirementsId"
|
||||
:aria-label="t('setting.classification.control.publishAria')"
|
||||
@click="requestPublish"
|
||||
>
|
||||
{{ t('setting.classification.control.publish') }}
|
||||
</VBtn>
|
||||
</section>
|
||||
|
||||
<section class="classification-policy-control__section" aria-labelledby="classification-history-title">
|
||||
<div class="classification-policy-control__section-header">
|
||||
<div>
|
||||
<h3 id="classification-history-title">{{ t('setting.classification.control.historyTitle') }}</h3>
|
||||
<p>{{ t('setting.classification.control.historyDescription', { revision: activeRevision }) }}</p>
|
||||
</div>
|
||||
<VBtn
|
||||
icon
|
||||
variant="text"
|
||||
:loading="loadingHistory"
|
||||
:disabled="isBusy && !loadingHistory"
|
||||
:aria-label="t('setting.classification.control.refreshHistory')"
|
||||
@click="emit('load-history')"
|
||||
>
|
||||
<VIcon icon="mdi-history" />
|
||||
<VTooltip activator="parent" location="top">{{
|
||||
t('setting.classification.control.refreshHistory')
|
||||
}}</VTooltip>
|
||||
</VBtn>
|
||||
</div>
|
||||
|
||||
<div v-if="loadingHistory" class="classification-policy-control__loading" role="status">
|
||||
<VProgressCircular indeterminate size="24" color="primary" />
|
||||
<span>{{ t('setting.classification.control.loadingHistory') }}</span>
|
||||
</div>
|
||||
|
||||
<div v-else-if="!history" class="classification-policy-control__empty" role="status">
|
||||
{{ t('setting.classification.control.historyNotLoaded') }}
|
||||
</div>
|
||||
|
||||
<div v-else-if="historyItems.length === 0" class="classification-policy-control__empty" role="status">
|
||||
{{ t('setting.classification.control.historyEmpty') }}
|
||||
</div>
|
||||
|
||||
<fieldset v-else class="classification-policy-control__history-list">
|
||||
<legend class="sr-only">{{ t('setting.classification.control.selectHistoryLegend') }}</legend>
|
||||
<label
|
||||
v-for="policy in historyItems"
|
||||
:key="policy.revision"
|
||||
class="classification-policy-control__history-row"
|
||||
:class="{ 'classification-policy-control__history-row--selected': selectedRevision === policy.revision }"
|
||||
:data-testid="'classification-history-revision-' + policy.revision"
|
||||
>
|
||||
<input
|
||||
v-model="selectedRevision"
|
||||
type="radio"
|
||||
name="classification-history-revision"
|
||||
:value="policy.revision"
|
||||
:aria-label="
|
||||
t('setting.classification.control.selectHistoryAria', {
|
||||
revision: policy.revision,
|
||||
categories: policy.categories.length,
|
||||
rules: policy.rules.length,
|
||||
})
|
||||
"
|
||||
/>
|
||||
<span class="classification-policy-control__history-summary">
|
||||
<span class="classification-policy-control__history-title">
|
||||
<strong>revision {{ policy.revision }}</strong>
|
||||
<time v-if="policy.updated_at" :datetime="policy.updated_at">{{
|
||||
formatUpdatedAt(policy.updated_at)
|
||||
}}</time>
|
||||
<span v-else>{{ t('setting.classification.control.updatedAtUnknown') }}</span>
|
||||
</span>
|
||||
<span class="classification-policy-control__history-counts">
|
||||
<span>{{
|
||||
t('setting.classification.control.categoryCount', { count: policy.categories.length })
|
||||
}}</span>
|
||||
<span>{{ t('setting.classification.control.ruleCount', { count: policy.rules.length }) }}</span>
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<VAlert density="compact" type="info" variant="tonal">
|
||||
{{ t('setting.classification.control.rollbackNotice') }}
|
||||
</VAlert>
|
||||
|
||||
<VBtn
|
||||
color="primary"
|
||||
variant="tonal"
|
||||
block
|
||||
prepend-icon="mdi-backup-restore"
|
||||
:loading="rollingBack"
|
||||
:disabled="selectedRevision === null || isBusy || !!conflict"
|
||||
:aria-label="t('setting.classification.control.rollbackAria')"
|
||||
@click="requestRollback"
|
||||
>
|
||||
{{
|
||||
selectedRevision === null
|
||||
? t('setting.classification.control.selectBeforeRollback')
|
||||
: t('setting.classification.control.rollbackRevision', { revision: selectedRevision })
|
||||
}}
|
||||
</VBtn>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.classification-policy-control {
|
||||
display: grid;
|
||||
gap: 20px;
|
||||
inline-size: 100%;
|
||||
min-inline-size: 0;
|
||||
}
|
||||
|
||||
.classification-policy-control__header,
|
||||
.classification-policy-control__section-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.classification-policy-control__header h2,
|
||||
.classification-policy-control__section-header h3 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
line-height: 1.4;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.classification-policy-control__header p,
|
||||
.classification-policy-control__section-header p,
|
||||
.classification-policy-control__impact p {
|
||||
margin: 4px 0 0;
|
||||
color: rgb(var(--v-theme-on-surface-variant));
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.classification-policy-control__revision,
|
||||
.classification-policy-control__actions {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.classification-policy-control__layout {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.classification-policy-control__section {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
min-inline-size: 0;
|
||||
padding: 16px;
|
||||
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.classification-policy-control__requirements {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.classification-policy-control__requirements li {
|
||||
display: grid;
|
||||
grid-template-columns: 20px minmax(0, 1fr);
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
color: rgb(var(--v-theme-on-surface-variant));
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.classification-policy-control__requirements li[data-passed='true'] {
|
||||
color: rgb(var(--v-theme-on-surface));
|
||||
}
|
||||
|
||||
.classification-policy-control__impact {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
background: rgba(var(--v-theme-surface-variant), 0.24);
|
||||
}
|
||||
|
||||
.classification-policy-control__metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.classification-policy-control__metrics div {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
min-inline-size: 0;
|
||||
}
|
||||
|
||||
.classification-policy-control__metrics span,
|
||||
.classification-policy-control__history-counts,
|
||||
.classification-policy-control__history-title time,
|
||||
.classification-policy-control__history-title > span {
|
||||
color: rgb(var(--v-theme-on-surface-variant));
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.classification-policy-control__metrics strong {
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
.classification-policy-control__history-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
min-inline-size: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.classification-policy-control__history-row {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
min-inline-size: 0;
|
||||
padding: 12px;
|
||||
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.classification-policy-control__history-row:hover,
|
||||
.classification-policy-control__history-row:focus-within,
|
||||
.classification-policy-control__history-row--selected {
|
||||
border-color: rgb(var(--v-theme-primary));
|
||||
background: rgba(var(--v-theme-primary), 0.06);
|
||||
}
|
||||
|
||||
.classification-policy-control__history-row input {
|
||||
inline-size: 18px;
|
||||
block-size: 18px;
|
||||
accent-color: rgb(var(--v-theme-primary));
|
||||
}
|
||||
|
||||
.classification-policy-control__history-summary,
|
||||
.classification-policy-control__history-title,
|
||||
.classification-policy-control__history-counts {
|
||||
display: flex;
|
||||
min-inline-size: 0;
|
||||
}
|
||||
|
||||
.classification-policy-control__history-summary {
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.classification-policy-control__history-title {
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.classification-policy-control__history-counts {
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.classification-policy-control__loading,
|
||||
.classification-policy-control__empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
min-block-size: 88px;
|
||||
color: rgb(var(--v-theme-on-surface-variant));
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.classification-policy-control__layout {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.classification-policy-control__header,
|
||||
.classification-policy-control__section-header,
|
||||
.classification-policy-control__history-title {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.classification-policy-control__revision,
|
||||
.classification-policy-control__actions,
|
||||
.classification-policy-control__actions :deep(.v-btn) {
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.classification-policy-control__metrics {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.classification-policy-control__section {
|
||||
padding: 14px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,919 @@
|
||||
<script setup lang="ts">
|
||||
import type {
|
||||
ClassificationCategory,
|
||||
ClassificationEvaluation,
|
||||
ClassificationFactSource,
|
||||
ClassificationFactScalar,
|
||||
ClassificationFactValue,
|
||||
ClassificationFacts,
|
||||
ClassificationFieldDefinition,
|
||||
ClassificationMediaFacts,
|
||||
ClassificationMediaType,
|
||||
ClassificationMusicFacts,
|
||||
ClassificationPreviewInput,
|
||||
ClassificationSelection,
|
||||
ClassificationSourceSupport,
|
||||
} from '@/api/mediaClassificationTypes'
|
||||
|
||||
defineOptions({ name: 'ClassificationPreviewPanel' })
|
||||
|
||||
/** 事实预览可选择的策略快照。 */
|
||||
type ClassificationPreviewPolicyMode = 'draft' | 'active'
|
||||
|
||||
/** 事实预览组件的只读输入。 */
|
||||
interface ClassificationPreviewPanelProps {
|
||||
fields: readonly ClassificationFieldDefinition[]
|
||||
categories: readonly ClassificationCategory[]
|
||||
result: ClassificationEvaluation | null
|
||||
loading: boolean
|
||||
}
|
||||
|
||||
/** 向父层请求预览时提交的完整事实和策略模式。 */
|
||||
interface ClassificationPreviewEvent {
|
||||
input: ClassificationPreviewInput
|
||||
policyMode: ClassificationPreviewPolicyMode
|
||||
}
|
||||
|
||||
/** 动态字段在界面中的分组。 */
|
||||
interface ClassificationPreviewFieldGroup {
|
||||
name: string
|
||||
fields: ClassificationFieldDefinition[]
|
||||
}
|
||||
|
||||
/** 扩展字段在事实对象中的来源与来源内键名。 */
|
||||
interface ClassificationExtensionPath {
|
||||
source: string
|
||||
key: string
|
||||
}
|
||||
|
||||
const props = defineProps<ClassificationPreviewPanelProps>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'request-preview': [request: ClassificationPreviewEvent]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const MEDIA_TYPES: ReadonlyArray<{ value: ClassificationMediaType; labelKey: string; icon: string }> = [
|
||||
{ value: '电影', labelKey: 'setting.classification.preview.mediaTypes.movie', icon: 'mdi-movie-open-outline' },
|
||||
{ value: '电视剧', labelKey: 'setting.classification.preview.mediaTypes.tv', icon: 'mdi-television-classic' },
|
||||
{ value: '音乐', labelKey: 'setting.classification.preview.mediaTypes.music', icon: 'mdi-music-note-outline' },
|
||||
]
|
||||
|
||||
const booleanItems = computed<ReadonlyArray<{ title: string; value: boolean | null }>>(() => [
|
||||
{ title: t('setting.classification.preview.boolean.unset'), value: null },
|
||||
{ title: t('setting.classification.preview.boolean.yes'), value: true },
|
||||
{ title: t('setting.classification.preview.boolean.no'), value: false },
|
||||
])
|
||||
|
||||
const previewMode = ref<ClassificationPreviewPolicyMode>('draft')
|
||||
const mediaType = ref<ClassificationMediaType>('电影')
|
||||
const mediaSource = ref('')
|
||||
const mediaId = ref('')
|
||||
const fieldValues = ref<Record<string, ClassificationFactValue | undefined>>({})
|
||||
const validationMessage = ref('')
|
||||
const validationErrorId = `classification-preview-error-${useId()}`
|
||||
|
||||
const editableFields = computed(() =>
|
||||
props.fields.filter(
|
||||
field =>
|
||||
field.id !== 'identity.media_source' &&
|
||||
field.id !== 'media.type' &&
|
||||
field.media_types.includes(mediaType.value) &&
|
||||
(field.id.startsWith('media.') || field.id.startsWith('music.') || field.id.startsWith('extensions.')),
|
||||
),
|
||||
)
|
||||
|
||||
const fieldGroups = computed<ClassificationPreviewFieldGroup[]>(() => {
|
||||
const groups = new Map<string, ClassificationFieldDefinition[]>()
|
||||
for (const field of editableFields.value) {
|
||||
const groupName =
|
||||
field.group ||
|
||||
t(
|
||||
field.id.startsWith('extensions.')
|
||||
? 'setting.classification.preview.groups.sourceExtension'
|
||||
: 'setting.classification.preview.groups.shared',
|
||||
)
|
||||
const group = groups.get(groupName) ?? []
|
||||
group.push(field)
|
||||
groups.set(groupName, group)
|
||||
}
|
||||
return [...groups].map(([name, fields]) => ({ name, fields }))
|
||||
})
|
||||
|
||||
const categoryMap = computed(() => new Map(props.categories.map(category => [category.id, category])))
|
||||
|
||||
/** 为 VSelect 与 VCombobox 的真实激活元素提供业务标签。 */
|
||||
function comboboxMenuProps(label: string): { activatorProps: { 'aria-label': string } } {
|
||||
return { activatorProps: { 'aria-label': label } }
|
||||
}
|
||||
|
||||
/** 返回字段当前保存的值,数组会复制后再交给控件。 */
|
||||
function fieldValue(fieldId: string): ClassificationFactValue | undefined {
|
||||
const value = fieldValues.value[fieldId]
|
||||
return Array.isArray(value) ? [...value] : value
|
||||
}
|
||||
|
||||
/** 返回多值控件需要的稳定数组,忽略与字段目录不一致的标量旧值。 */
|
||||
function listFieldValue(fieldId: string): ClassificationFactScalar[] {
|
||||
const value = fieldValues.value[fieldId]
|
||||
return Array.isArray(value) ? [...value] : []
|
||||
}
|
||||
|
||||
/** 为 Vuetify 可自定义枚举控件收窄模板泛型,运行时仍保留原始标量。 */
|
||||
function comboboxScalarValue(fieldId: string): never {
|
||||
const value = fieldValues.value[fieldId]
|
||||
return (Array.isArray(value) ? undefined : value) as never
|
||||
}
|
||||
|
||||
/** 为 Vuetify 可自定义列表控件收窄模板泛型,运行时仍保留原始列表。 */
|
||||
function comboboxListValue(fieldId: string): never[] {
|
||||
return listFieldValue(fieldId) as never[]
|
||||
}
|
||||
|
||||
/** 将控件值归一为字段目录声明的 JSON 值类型。 */
|
||||
function normalizeFieldValue(
|
||||
field: ClassificationFieldDefinition,
|
||||
value: unknown,
|
||||
): ClassificationFactValue | undefined {
|
||||
if (value === undefined || value === null || value === '') return undefined
|
||||
|
||||
if (field.value_type === 'string_list') {
|
||||
const values = Array.isArray(value) ? value : [value]
|
||||
const normalized = values
|
||||
.filter(
|
||||
(item): item is Exclude<ClassificationFactScalar, null> => item !== null && item !== undefined && item !== '',
|
||||
)
|
||||
.map(item => (typeof item === 'string' ? item.trim() : item))
|
||||
.filter(item => item !== '')
|
||||
return normalized.length ? normalized : undefined
|
||||
}
|
||||
|
||||
if (field.value_type === 'integer' || field.value_type === 'year' || field.value_type === 'number') {
|
||||
const numericValue = Number(value)
|
||||
if (!Number.isFinite(numericValue)) return undefined
|
||||
return field.value_type === 'number' ? numericValue : Math.trunc(numericValue)
|
||||
}
|
||||
|
||||
if (field.value_type === 'boolean') return value === true || value === false ? value : undefined
|
||||
if (typeof value === 'string') return value.trim() || undefined
|
||||
if (typeof value === 'number' || typeof value === 'boolean') return value
|
||||
return String(value)
|
||||
}
|
||||
|
||||
/** 更新单个动态事实;空值会从请求中移除而不是提交 null。 */
|
||||
function updateFieldValue(field: ClassificationFieldDefinition, value: unknown): void {
|
||||
const normalized = normalizeFieldValue(field, value)
|
||||
const nextValues = { ...fieldValues.value }
|
||||
if (normalized === undefined) delete nextValues[field.id]
|
||||
else nextValues[field.id] = Array.isArray(normalized) ? [...normalized] : normalized
|
||||
fieldValues.value = nextValues
|
||||
}
|
||||
|
||||
/** 从字段能力声明中解析允许包含点号的扩展来源。 */
|
||||
function extensionPath(field: ClassificationFieldDefinition): ClassificationExtensionPath | null {
|
||||
const source = Object.entries(field.source_support).find(([, support]) => support === 'extension')?.[0]
|
||||
if (!source) return null
|
||||
|
||||
const prefix = `extensions.${source}.`
|
||||
if (!field.id.startsWith(prefix)) return null
|
||||
const key = field.id.slice(prefix.length)
|
||||
return key ? { source, key } : null
|
||||
}
|
||||
|
||||
/** 复制事实值,防止组件内部数组与父层请求共享引用。 */
|
||||
function cloneFactValue(value: ClassificationFactValue): ClassificationFactValue {
|
||||
return Array.isArray(value) ? [...value] : value
|
||||
}
|
||||
|
||||
/** 按后端标准事实结构组装当前可见字段,不把动态字段写回 identity。 */
|
||||
function buildFacts(): ClassificationFacts {
|
||||
const media: Record<string, ClassificationFactValue> = { type: mediaType.value }
|
||||
const music: Record<string, ClassificationFactValue> = {}
|
||||
const extensions: Record<string, Record<string, ClassificationFactValue>> = {}
|
||||
|
||||
for (const field of editableFields.value) {
|
||||
const value = fieldValues.value[field.id]
|
||||
if (value === undefined) continue
|
||||
const clonedValue = cloneFactValue(value)
|
||||
|
||||
if (field.id.startsWith('media.')) {
|
||||
media[field.id.slice('media.'.length)] = clonedValue
|
||||
continue
|
||||
}
|
||||
if (field.id.startsWith('music.')) {
|
||||
music[field.id.slice('music.'.length)] = clonedValue
|
||||
continue
|
||||
}
|
||||
|
||||
const path = extensionPath(field)
|
||||
if (!path) continue
|
||||
extensions[path.source] = { ...(extensions[path.source] ?? {}), [path.key]: clonedValue }
|
||||
}
|
||||
|
||||
return {
|
||||
identity: {
|
||||
media_source: mediaSource.value.trim(),
|
||||
media_id: mediaId.value.trim(),
|
||||
},
|
||||
media: media as unknown as ClassificationMediaFacts,
|
||||
...(mediaType.value === '音乐' ? { music: music as unknown as ClassificationMusicFacts } : {}),
|
||||
extensions,
|
||||
field_sources: {},
|
||||
}
|
||||
}
|
||||
|
||||
/** 校验稳定身份后发出预览请求,实际 API 调用由父层负责。 */
|
||||
function requestPreview(): void {
|
||||
if (!mediaSource.value.trim()) {
|
||||
validationMessage.value = t('setting.classification.preview.validation.mediaSourceRequired')
|
||||
return
|
||||
}
|
||||
if (!mediaId.value.trim()) {
|
||||
validationMessage.value = t('setting.classification.preview.validation.mediaIdRequired')
|
||||
return
|
||||
}
|
||||
|
||||
validationMessage.value = ''
|
||||
emit('request-preview', {
|
||||
input: { kind: 'facts', facts: buildFacts() },
|
||||
policyMode: previewMode.value,
|
||||
})
|
||||
}
|
||||
|
||||
/** 返回当前媒体来源对字段的能力提示。 */
|
||||
function sourceSupportHint(field: ClassificationFieldDefinition): string | null {
|
||||
const source = mediaSource.value.trim()
|
||||
if (!source) return null
|
||||
const support = field.source_support[source]
|
||||
if (!support) return null
|
||||
const supportKeys: Partial<Record<ClassificationSourceSupport, string>> = {
|
||||
partial: 'setting.classification.preview.support.partial',
|
||||
unavailable: 'setting.classification.preview.support.unavailable',
|
||||
extension: 'setting.classification.preview.support.extension',
|
||||
}
|
||||
const key = supportKeys[support]
|
||||
return key ? t(key) : null
|
||||
}
|
||||
|
||||
/** 将分类选择解析为名称、路径和稳定 ID。 */
|
||||
function selectionTitle(selection: ClassificationSelection | null | undefined): string {
|
||||
if (!selection?.category_id) return t('setting.classification.preview.selection.unmatched')
|
||||
const category = categoryMap.value.get(selection.category_id)
|
||||
const path = selection.category_path.length ? selection.category_path : (category?.path ?? [])
|
||||
const name = category?.name ?? t('setting.classification.preview.selection.unknown')
|
||||
return t('setting.classification.preview.selection.summary', {
|
||||
name,
|
||||
path: path.length ? path.join(' / ') : t('setting.classification.preview.selection.unsetPath'),
|
||||
id: selection.category_id,
|
||||
})
|
||||
}
|
||||
|
||||
/** 将选择来源转换为界面可读文本。 */
|
||||
function selectionSourceLabel(source: string | null | undefined): string {
|
||||
const labels: Record<string, string> = {
|
||||
automatic: t('setting.classification.preview.selectionSource.automatic'),
|
||||
source_fallback: t('setting.classification.preview.selectionSource.sourceFallback'),
|
||||
fallback: t('setting.classification.preview.selectionSource.fallback'),
|
||||
}
|
||||
return source ? (labels[source] ?? source) : t('setting.classification.preview.missing')
|
||||
}
|
||||
|
||||
/** 将求值状态转换为界面可读文本。 */
|
||||
function stateLabel(state: ClassificationEvaluation['result']['state']): string {
|
||||
const labels: Record<ClassificationEvaluation['result']['state'], string> = {
|
||||
complete: t('setting.classification.preview.states.complete'),
|
||||
partial: t('setting.classification.preview.states.partial'),
|
||||
not_evaluated: t('setting.classification.preview.states.notEvaluated'),
|
||||
invalid_policy: t('setting.classification.preview.states.invalidPolicy'),
|
||||
}
|
||||
return labels[state]
|
||||
}
|
||||
|
||||
/** 按求值状态返回一致的 Vuetify 语义色。 */
|
||||
function stateColor(state: ClassificationEvaluation['result']['state']): string {
|
||||
if (state === 'complete') return 'success'
|
||||
if (state === 'partial') return 'warning'
|
||||
return 'error'
|
||||
}
|
||||
|
||||
/** 将结构化路径格式化为可定位的点号与数组索引形式。 */
|
||||
function formatPath(path: readonly (string | number)[]): string {
|
||||
if (!path.length) return t('setting.classification.preview.root')
|
||||
return path.reduce<string>((result, segment) => {
|
||||
if (typeof segment === 'number') return `${result}[${segment}]`
|
||||
return result ? `${result}.${segment}` : segment
|
||||
}, '')
|
||||
}
|
||||
|
||||
/** 将 expected 与 actual 值稳定格式化,明确区分缺失和 null。 */
|
||||
function formatFactValue(value: ClassificationFactValue | undefined): string {
|
||||
if (value === undefined) return t('setting.classification.preview.missing')
|
||||
return JSON.stringify(value)
|
||||
}
|
||||
|
||||
/** 将字段级来源转换为提供者和媒体源的稳定展示文本。 */
|
||||
function factSourceLabel(source: ClassificationFactSource | null | undefined): string {
|
||||
if (!source) return t('setting.classification.preview.missing')
|
||||
return `${source.provider_name} · ${source.media_source}`
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="classification-preview" aria-labelledby="classification-preview-title">
|
||||
<header class="classification-preview__header">
|
||||
<div>
|
||||
<h2 id="classification-preview-title">{{ t('setting.classification.preview.title') }}</h2>
|
||||
<p>{{ t('setting.classification.preview.description') }}</p>
|
||||
</div>
|
||||
<VBtn
|
||||
color="primary"
|
||||
prepend-icon="mdi-play-outline"
|
||||
:loading="loading"
|
||||
:disabled="loading"
|
||||
:aria-label="t('setting.classification.preview.run')"
|
||||
@click="requestPreview"
|
||||
>
|
||||
{{ t('setting.classification.preview.run') }}
|
||||
</VBtn>
|
||||
</header>
|
||||
|
||||
<div class="classification-preview__mode">
|
||||
<span id="classification-preview-mode-label">{{ t('setting.classification.preview.modeLabel') }}</span>
|
||||
<VBtnToggle
|
||||
v-model="previewMode"
|
||||
mandatory
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
aria-labelledby="classification-preview-mode-label"
|
||||
>
|
||||
<VBtn value="draft">{{ t('setting.classification.preview.draftPolicy') }}</VBtn>
|
||||
<VBtn value="active">{{ t('setting.classification.preview.activePolicy') }}</VBtn>
|
||||
</VBtnToggle>
|
||||
</div>
|
||||
|
||||
<section
|
||||
class="classification-preview__facts"
|
||||
aria-labelledby="classification-preview-facts-title"
|
||||
:aria-describedby="validationMessage ? validationErrorId : undefined"
|
||||
>
|
||||
<h3 id="classification-preview-facts-title">{{ t('setting.classification.preview.factsTitle') }}</h3>
|
||||
|
||||
<div class="classification-preview__identity">
|
||||
<VTextField
|
||||
v-model="mediaSource"
|
||||
:label="t('setting.classification.preview.mediaSource')"
|
||||
:placeholder="t('setting.classification.preview.mediaSourcePlaceholder')"
|
||||
autocomplete="off"
|
||||
density="comfortable"
|
||||
hide-details="auto"
|
||||
/>
|
||||
<VTextField
|
||||
v-model="mediaId"
|
||||
:label="t('setting.classification.preview.mediaId')"
|
||||
:placeholder="t('setting.classification.preview.mediaIdPlaceholder')"
|
||||
autocomplete="off"
|
||||
density="comfortable"
|
||||
hide-details="auto"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="classification-preview__media-type">
|
||||
<span id="classification-preview-media-type-label">{{ t('setting.classification.preview.mediaType') }}</span>
|
||||
<VBtnToggle
|
||||
v-model="mediaType"
|
||||
mandatory
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
aria-labelledby="classification-preview-media-type-label"
|
||||
>
|
||||
<VBtn v-for="item in MEDIA_TYPES" :key="item.value" :value="item.value">
|
||||
<VIcon :icon="item.icon" start />
|
||||
{{ t(item.labelKey) }}
|
||||
</VBtn>
|
||||
</VBtnToggle>
|
||||
</div>
|
||||
|
||||
<VAlert
|
||||
v-if="validationMessage"
|
||||
:id="validationErrorId"
|
||||
type="error"
|
||||
variant="tonal"
|
||||
density="compact"
|
||||
role="alert"
|
||||
>
|
||||
{{ validationMessage }}
|
||||
</VAlert>
|
||||
|
||||
<div v-if="fieldGroups.length" class="classification-preview__field-groups">
|
||||
<section
|
||||
v-for="(group, groupIndex) in fieldGroups"
|
||||
:key="group.name"
|
||||
class="classification-preview__field-group"
|
||||
:aria-labelledby="`classification-preview-field-group-${groupIndex}`"
|
||||
>
|
||||
<h4 :id="`classification-preview-field-group-${groupIndex}`">{{ group.name }}</h4>
|
||||
<div class="classification-preview__field-grid">
|
||||
<div v-for="field in group.fields" :key="field.id" class="classification-preview__field">
|
||||
<VSelect
|
||||
v-if="field.value_type === 'boolean'"
|
||||
:model-value="fieldValue(field.id) ?? null"
|
||||
:items="booleanItems"
|
||||
:label="field.label"
|
||||
:menu-props="comboboxMenuProps(field.label)"
|
||||
clearable
|
||||
density="comfortable"
|
||||
hide-details="auto"
|
||||
@update:model-value="value => updateFieldValue(field, value)"
|
||||
/>
|
||||
|
||||
<VSelect
|
||||
v-else-if="field.value_type === 'enum' && !field.allow_custom_values"
|
||||
:model-value="fieldValue(field.id)"
|
||||
:items="field.options"
|
||||
item-title="label"
|
||||
item-value="value"
|
||||
:label="field.label"
|
||||
:menu-props="comboboxMenuProps(field.label)"
|
||||
clearable
|
||||
density="comfortable"
|
||||
hide-details="auto"
|
||||
@update:model-value="value => updateFieldValue(field, value)"
|
||||
/>
|
||||
|
||||
<VSelect
|
||||
v-else-if="field.value_type === 'string_list' && field.options.length && !field.allow_custom_values"
|
||||
:model-value="listFieldValue(field.id)"
|
||||
:items="field.options"
|
||||
item-title="label"
|
||||
item-value="value"
|
||||
:label="field.label"
|
||||
:menu-props="comboboxMenuProps(field.label)"
|
||||
multiple
|
||||
chips
|
||||
closable-chips
|
||||
clearable
|
||||
density="comfortable"
|
||||
hide-details="auto"
|
||||
@update:model-value="value => updateFieldValue(field, value)"
|
||||
/>
|
||||
|
||||
<VCombobox
|
||||
v-else-if="field.value_type === 'enum'"
|
||||
:model-value="comboboxScalarValue(field.id)"
|
||||
:items="field.options"
|
||||
item-title="label"
|
||||
item-value="value"
|
||||
:label="field.label"
|
||||
:menu-props="comboboxMenuProps(field.label)"
|
||||
clearable
|
||||
density="comfortable"
|
||||
hide-details="auto"
|
||||
@update:model-value="value => updateFieldValue(field, value)"
|
||||
/>
|
||||
|
||||
<VCombobox
|
||||
v-else-if="field.value_type === 'string_list'"
|
||||
:model-value="comboboxListValue(field.id)"
|
||||
:items="field.options"
|
||||
item-title="label"
|
||||
item-value="value"
|
||||
:label="field.label"
|
||||
:menu-props="comboboxMenuProps(field.label)"
|
||||
multiple
|
||||
chips
|
||||
closable-chips
|
||||
clearable
|
||||
density="comfortable"
|
||||
hide-details="auto"
|
||||
@update:model-value="value => updateFieldValue(field, value)"
|
||||
/>
|
||||
|
||||
<VTextField
|
||||
v-else
|
||||
:model-value="fieldValue(field.id)"
|
||||
:label="field.label"
|
||||
:type="['integer', 'number', 'year'].includes(field.value_type) ? 'number' : 'text'"
|
||||
:step="field.value_type === 'number' ? 'any' : undefined"
|
||||
autocomplete="off"
|
||||
clearable
|
||||
density="comfortable"
|
||||
hide-details="auto"
|
||||
@update:model-value="value => updateFieldValue(field, value)"
|
||||
/>
|
||||
|
||||
<div class="classification-preview__field-meta">
|
||||
<code>{{ field.id }}</code>
|
||||
<span v-if="field.description">{{ field.description }}</span>
|
||||
<VChip
|
||||
v-if="sourceSupportHint(field)"
|
||||
size="x-small"
|
||||
variant="tonal"
|
||||
:color="field.source_support[mediaSource.trim()] === 'unavailable' ? 'error' : 'warning'"
|
||||
>
|
||||
{{ sourceSupportHint(field) }}
|
||||
</VChip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<p v-else class="classification-preview__empty">{{ t('setting.classification.preview.noEditableFields') }}</p>
|
||||
</section>
|
||||
|
||||
<section
|
||||
class="classification-preview__result"
|
||||
aria-labelledby="classification-preview-result-title"
|
||||
aria-live="polite"
|
||||
:aria-busy="loading"
|
||||
>
|
||||
<div class="classification-preview__result-heading">
|
||||
<h3 id="classification-preview-result-title">{{ t('setting.classification.preview.resultTitle') }}</h3>
|
||||
<VProgressCircular
|
||||
v-if="loading"
|
||||
indeterminate
|
||||
size="24"
|
||||
width="2"
|
||||
:aria-label="t('setting.classification.preview.loading')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p v-if="!result && !loading" class="classification-preview__empty">
|
||||
{{ t('setting.classification.preview.emptyResult') }}
|
||||
</p>
|
||||
|
||||
<template v-if="result">
|
||||
<div class="classification-preview__summary">
|
||||
<div>
|
||||
<span>{{ t('setting.classification.preview.status') }}</span>
|
||||
<VChip :color="stateColor(result.result.state)" variant="tonal" size="small">
|
||||
{{ stateLabel(result.result.state) }}
|
||||
</VChip>
|
||||
</div>
|
||||
<div>
|
||||
<span>{{ t('setting.classification.preview.policyRevision') }}</span>
|
||||
<strong>{{ result.result.policy_revision }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="classification-preview__selections">
|
||||
<section aria-labelledby="classification-preview-recommended-title">
|
||||
<h4 id="classification-preview-recommended-title">{{ t('setting.classification.preview.recommended') }}</h4>
|
||||
<strong>{{ selectionTitle(result.result.recommended) }}</strong>
|
||||
<dl v-if="result.result.recommended">
|
||||
<div>
|
||||
<dt>{{ t('setting.classification.preview.rule') }}</dt>
|
||||
<dd>{{ result.result.recommended.rule_id || t('setting.classification.preview.none') }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{{ t('setting.classification.preview.source') }}</dt>
|
||||
<dd>{{ selectionSourceLabel(result.result.recommended.source) }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
<section aria-labelledby="classification-preview-effective-title">
|
||||
<h4 id="classification-preview-effective-title">{{ t('setting.classification.preview.effective') }}</h4>
|
||||
<strong>{{ selectionTitle(result.result.effective) }}</strong>
|
||||
<dl v-if="result.result.effective">
|
||||
<div>
|
||||
<dt>{{ t('setting.classification.preview.rule') }}</dt>
|
||||
<dd>{{ result.result.effective.rule_id || t('setting.classification.preview.none') }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{{ t('setting.classification.preview.source') }}</dt>
|
||||
<dd>{{ selectionSourceLabel(result.result.effective.source) }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="classification-preview__labels" aria-labelledby="classification-preview-labels-title">
|
||||
<h4 id="classification-preview-labels-title">{{ t('setting.classification.preview.labels') }}</h4>
|
||||
<div v-if="result.result.labels.length" class="classification-preview__chips">
|
||||
<VChip v-for="label in result.result.labels" :key="label" size="small" variant="tonal">
|
||||
{{ label }}
|
||||
</VChip>
|
||||
</div>
|
||||
<span v-else>{{ t('setting.classification.preview.none') }}</span>
|
||||
</section>
|
||||
|
||||
<section
|
||||
v-if="result.warnings.length"
|
||||
class="classification-preview__warnings"
|
||||
aria-labelledby="classification-preview-warnings-title"
|
||||
>
|
||||
<h4 id="classification-preview-warnings-title">{{ t('setting.classification.preview.warnings') }}</h4>
|
||||
<VAlert
|
||||
v-for="(warning, index) in result.warnings"
|
||||
:key="`${warning.code}-${index}`"
|
||||
type="warning"
|
||||
variant="tonal"
|
||||
density="compact"
|
||||
>
|
||||
<strong>{{ warning.code }}</strong
|
||||
>:{{ warning.message }}
|
||||
<div class="classification-preview__warning-meta">
|
||||
<code>{{ formatPath(warning.path) }}</code>
|
||||
<span v-if="warning.field">{{
|
||||
t('setting.classification.preview.warningField', { field: warning.field })
|
||||
}}</span>
|
||||
<span v-if="warning.source">{{
|
||||
t('setting.classification.preview.warningSource', { source: warning.source })
|
||||
}}</span>
|
||||
</div>
|
||||
</VAlert>
|
||||
</section>
|
||||
|
||||
<section class="classification-preview__trace" aria-labelledby="classification-preview-trace-title">
|
||||
<h4 id="classification-preview-trace-title">{{ t('setting.classification.preview.trace') }}</h4>
|
||||
<p v-if="!result.trace.length" class="classification-preview__empty">
|
||||
{{ t('setting.classification.preview.noRules') }}
|
||||
</p>
|
||||
<details
|
||||
v-for="rule in result.trace"
|
||||
:key="rule.rule_id"
|
||||
class="classification-preview__rule"
|
||||
:open="rule.matched"
|
||||
>
|
||||
<summary>
|
||||
<code>{{ rule.rule_id }}</code>
|
||||
<VChip :color="rule.matched ? 'success' : 'default'" size="x-small" variant="tonal">
|
||||
{{
|
||||
t(
|
||||
rule.matched
|
||||
? 'setting.classification.preview.matched'
|
||||
: 'setting.classification.preview.notMatched',
|
||||
)
|
||||
}}
|
||||
</VChip>
|
||||
</summary>
|
||||
<div v-if="rule.conditions.length" class="classification-preview__trace-table">
|
||||
<VTable density="compact">
|
||||
<table :aria-label="t('setting.classification.preview.traceTableAria', { rule: rule.rule_id })">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">{{ t('setting.classification.preview.columns.result') }}</th>
|
||||
<th scope="col">{{ t('setting.classification.preview.columns.field') }}</th>
|
||||
<th scope="col">{{ t('setting.classification.preview.columns.operator') }}</th>
|
||||
<th scope="col">{{ t('setting.classification.preview.columns.expected') }}</th>
|
||||
<th scope="col">{{ t('setting.classification.preview.columns.actual') }}</th>
|
||||
<th scope="col">{{ t('setting.classification.preview.columns.factSource') }}</th>
|
||||
<th scope="col">{{ t('setting.classification.preview.columns.path') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(condition, index) in rule.conditions" :key="`${condition.field}-${index}`">
|
||||
<td>
|
||||
<VIcon
|
||||
:icon="condition.matched ? 'mdi-check-circle-outline' : 'mdi-close-circle-outline'"
|
||||
:color="condition.matched ? 'success' : 'error'"
|
||||
:aria-label="
|
||||
t(
|
||||
condition.matched
|
||||
? 'setting.classification.preview.conditionMatched'
|
||||
: 'setting.classification.preview.conditionNotMatched',
|
||||
)
|
||||
"
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<code>{{ condition.field }}</code>
|
||||
</td>
|
||||
<td>{{ condition.operator }}</td>
|
||||
<td>
|
||||
<code>{{ formatFactValue(condition.expected) }}</code>
|
||||
</td>
|
||||
<td>
|
||||
<code>{{ formatFactValue(condition.actual) }}</code>
|
||||
</td>
|
||||
<td>
|
||||
<span :title="condition.source?.provider_id">{{ factSourceLabel(condition.source) }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<code>{{ formatPath(condition.path) }}</code>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</VTable>
|
||||
</div>
|
||||
<p v-else class="classification-preview__empty">
|
||||
{{ t('setting.classification.preview.noConditionTrace') }}
|
||||
</p>
|
||||
</details>
|
||||
</section>
|
||||
</template>
|
||||
</section>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.classification-preview {
|
||||
display: grid;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.classification-preview__header,
|
||||
.classification-preview__result-heading,
|
||||
.classification-preview__mode,
|
||||
.classification-preview__media-type,
|
||||
.classification-preview__summary > div,
|
||||
.classification-preview__rule summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.classification-preview__header {
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.classification-preview__header h2,
|
||||
.classification-preview__facts h3,
|
||||
.classification-preview__result h3,
|
||||
.classification-preview h4,
|
||||
.classification-preview p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.classification-preview__header p,
|
||||
.classification-preview__field-meta,
|
||||
.classification-preview__empty {
|
||||
color: rgb(var(--v-theme-on-surface-variant));
|
||||
}
|
||||
|
||||
.classification-preview__mode,
|
||||
.classification-preview__media-type {
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.classification-preview__mode > span,
|
||||
.classification-preview__media-type > span {
|
||||
min-inline-size: 5rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.classification-preview__facts,
|
||||
.classification-preview__result {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
padding-block: 1rem;
|
||||
border-block-start: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||
}
|
||||
|
||||
.classification-preview__identity,
|
||||
.classification-preview__field-grid,
|
||||
.classification-preview__selections {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.classification-preview__field-groups,
|
||||
.classification-preview__field-group,
|
||||
.classification-preview__result,
|
||||
.classification-preview__warnings,
|
||||
.classification-preview__trace {
|
||||
display: grid;
|
||||
gap: 0.875rem;
|
||||
}
|
||||
|
||||
.classification-preview__field-group {
|
||||
padding-block-start: 0.25rem;
|
||||
}
|
||||
|
||||
.classification-preview__field {
|
||||
min-inline-size: 0;
|
||||
}
|
||||
|
||||
.classification-preview__field-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.375rem 0.75rem;
|
||||
padding-block-start: 0.35rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.classification-preview code {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.classification-preview__result-heading {
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.classification-preview__summary {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem 1.5rem;
|
||||
}
|
||||
|
||||
.classification-preview__summary > div {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.classification-preview__selections > section {
|
||||
display: grid;
|
||||
gap: 0.625rem;
|
||||
padding-block: 0.875rem;
|
||||
border-block: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||
}
|
||||
|
||||
.classification-preview__selections strong {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.classification-preview__selections dl {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.classification-preview__selections dl > div {
|
||||
display: grid;
|
||||
grid-template-columns: 4rem minmax(0, 1fr);
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.classification-preview__selections dt {
|
||||
color: rgb(var(--v-theme-on-surface-variant));
|
||||
}
|
||||
|
||||
.classification-preview__selections dd {
|
||||
min-inline-size: 0;
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.classification-preview__labels,
|
||||
.classification-preview__chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.classification-preview__warning-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem 0.75rem;
|
||||
padding-block-start: 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.classification-preview__rule {
|
||||
border-block-start: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||
}
|
||||
|
||||
.classification-preview__rule summary {
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
min-block-size: 3rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.classification-preview__trace-table {
|
||||
max-inline-size: 100%;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.classification-preview__trace-table table {
|
||||
min-inline-size: 48rem;
|
||||
}
|
||||
|
||||
.classification-preview__trace-table th {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.classification-preview__header {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.classification-preview__identity,
|
||||
.classification-preview__field-grid,
|
||||
.classification-preview__selections {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.classification-preview__mode .v-btn-toggle,
|
||||
.classification-preview__media-type .v-btn-toggle {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.classification-preview__media-type .v-btn-toggle {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.classification-preview__mode .v-btn,
|
||||
.classification-preview__media-type .v-btn {
|
||||
min-inline-size: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,635 @@
|
||||
<script lang="ts" setup>
|
||||
import type {
|
||||
ClassificationCategory,
|
||||
ClassificationCondition,
|
||||
ClassificationConditionGroup,
|
||||
ClassificationConditionNode,
|
||||
ClassificationFactValue,
|
||||
ClassificationFieldDefinition,
|
||||
ClassificationMediaType,
|
||||
ClassificationRule,
|
||||
ClassificationRuleKind,
|
||||
} from '@/api/mediaClassificationTypes'
|
||||
import ClassificationConditionBuilder from './ClassificationConditionBuilder.vue'
|
||||
|
||||
const MEDIA_TYPES: ClassificationMediaType[] = ['电影', '电视剧', '音乐']
|
||||
const DEFAULT_MAX_RULES = 200
|
||||
const DEFAULT_MAX_CONDITION_DEPTH = 8
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
rules: ClassificationRule[]
|
||||
categories: ClassificationCategory[]
|
||||
fields: readonly ClassificationFieldDefinition[]
|
||||
maxRules?: number
|
||||
maxConditionDepth?: number
|
||||
}>(),
|
||||
{
|
||||
maxRules: DEFAULT_MAX_RULES,
|
||||
maxConditionDepth: DEFAULT_MAX_CONDITION_DEPTH,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:rules': [rules: ClassificationRule[]]
|
||||
}>()
|
||||
|
||||
// 拖拽能力仅在规则编辑器出现时加载,避免增加其他设置页的首屏体积。
|
||||
const Draggable = defineAsyncComponent(() => import('vuedraggable').then(module => module.default))
|
||||
const draftRules = ref<ClassificationRule[]>([])
|
||||
|
||||
/** 复制条件值,保留标量和列表的原始数据形状。 */
|
||||
function cloneConditionValue(value: ClassificationFactValue | undefined): ClassificationFactValue | undefined {
|
||||
return Array.isArray(value) ? [...value] : value
|
||||
}
|
||||
|
||||
/** 按条件联合类型递归复制,避免 Vue 响应式代理进入 structuredClone。 */
|
||||
function cloneCondition(node: ClassificationConditionNode): ClassificationConditionNode {
|
||||
if ('field' in node) {
|
||||
const condition = node as ClassificationCondition
|
||||
return {
|
||||
field: condition.field,
|
||||
operator: condition.operator,
|
||||
...(condition.value === undefined ? {} : { value: cloneConditionValue(condition.value) }),
|
||||
}
|
||||
}
|
||||
|
||||
const group = node as ClassificationConditionGroup
|
||||
if (group.all !== undefined) return { all: group.all?.map(cloneCondition) ?? group.all }
|
||||
if (group.any !== undefined) return { any: group.any?.map(cloneCondition) ?? group.any }
|
||||
if (group.not !== undefined) return { not: group.not ? cloneCondition(group.not) : group.not }
|
||||
return {}
|
||||
}
|
||||
|
||||
/** 深拷贝规则,隔离父级策略草稿和编辑器内部的临时修改。 */
|
||||
function cloneRule(rule: ClassificationRule): ClassificationRule {
|
||||
return {
|
||||
...rule,
|
||||
media_types: [...rule.media_types],
|
||||
sources: [...rule.sources],
|
||||
when: cloneCondition(rule.when),
|
||||
target: {
|
||||
category_id: rule.target.category_id ?? null,
|
||||
labels: [...rule.target.labels],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** 按当前数组索引生成零基优先级,与后端 schema 和迁移保持一致。 */
|
||||
function normalizePriorities(rules: ClassificationRule[]): ClassificationRule[] {
|
||||
return rules.map((rule, index) => ({ ...cloneRule(rule), priority: index }))
|
||||
}
|
||||
|
||||
/** 更新本地草稿并向父级提交一个不共享引用的新数组。 */
|
||||
function commitRules(rules: ClassificationRule[]) {
|
||||
const normalized = normalizePriorities(rules)
|
||||
draftRules.value = normalized
|
||||
emit('update:rules', normalized.map(cloneRule))
|
||||
}
|
||||
|
||||
/** 生成在当前规则集合中唯一且可读的稳定标识。 */
|
||||
function uniqueId(base: string): string {
|
||||
const usedIds = new Set(draftRules.value.map(rule => rule.id))
|
||||
if (!usedIds.has(base)) return base
|
||||
|
||||
let suffix = 2
|
||||
while (usedIds.has(`${base}-${suffix}`)) suffix += 1
|
||||
return `${base}-${suffix}`
|
||||
}
|
||||
|
||||
/** 生成不与现有规则重复的默认名称。 */
|
||||
function uniqueName(base: string): string {
|
||||
const usedNames = new Set(draftRules.value.map(rule => rule.name))
|
||||
if (!usedNames.has(base)) return base
|
||||
|
||||
let suffix = 2
|
||||
while (usedNames.has(`${base} ${suffix}`)) suffix += 1
|
||||
return `${base} ${suffix}`
|
||||
}
|
||||
|
||||
/** 返回新规则的首个可用顺序编号。 */
|
||||
function nextRuleNumber(): number {
|
||||
let sequence = draftRules.value.length + 1
|
||||
while (draftRules.value.some(rule => rule.id === `rule-${sequence}`)) sequence += 1
|
||||
return sequence
|
||||
}
|
||||
|
||||
/** 按当前规则媒体类型返回允许选择的分类目标。 */
|
||||
function categoryItems(rule: ClassificationRule) {
|
||||
const selectedMediaTypes = new Set(rule.media_types)
|
||||
return props.categories
|
||||
.filter(category => selectedMediaTypes.size === 0 || selectedMediaTypes.has(category.media_type))
|
||||
.map(category => ({
|
||||
title: `${category.path.join(' / ')}${category.enabled ? '' : '(已停用)'}`,
|
||||
value: category.id,
|
||||
props: { disabled: !category.enabled },
|
||||
}))
|
||||
}
|
||||
|
||||
/** 判断分类目标是否仍与规则媒体类型兼容。 */
|
||||
function isCategoryCompatible(categoryId: string | null | undefined, mediaTypes: ClassificationMediaType[]): boolean {
|
||||
if (!categoryId) return true
|
||||
const category = props.categories.find(item => item.id === categoryId)
|
||||
return Boolean(category && (mediaTypes.length === 0 || mediaTypes.includes(category.media_type)))
|
||||
}
|
||||
|
||||
/** 替换单条规则并统一提交,避免模板直接修改 props。 */
|
||||
function updateRule(index: number, patch: Partial<ClassificationRule>) {
|
||||
const current = draftRules.value[index]
|
||||
if (!current) return
|
||||
const nextRules = draftRules.value.map((rule, ruleIndex) =>
|
||||
ruleIndex === index ? cloneRule({ ...current, ...patch }) : cloneRule(rule),
|
||||
)
|
||||
commitRules(nextRules)
|
||||
}
|
||||
|
||||
/** 新增一条具备稳定默认值的分类规则。 */
|
||||
function addRule() {
|
||||
if (draftRules.value.length >= props.maxRules) return
|
||||
const sequence = nextRuleNumber()
|
||||
const mediaTypes: ClassificationMediaType[] = ['电影']
|
||||
const defaultCategory = props.categories.find(category => category.enabled && category.media_type === mediaTypes[0])
|
||||
const rule: ClassificationRule = {
|
||||
id: uniqueId(`rule-${sequence}`),
|
||||
name: uniqueName(`新规则 ${sequence}`),
|
||||
kind: 'category',
|
||||
enabled: true,
|
||||
priority: draftRules.value.length,
|
||||
media_types: mediaTypes,
|
||||
sources: [],
|
||||
when: { all: [] },
|
||||
target: {
|
||||
category_id: defaultCategory?.id ?? null,
|
||||
labels: [],
|
||||
},
|
||||
}
|
||||
commitRules([...draftRules.value, rule])
|
||||
}
|
||||
|
||||
/** 复制规则的完整条件与输出,同时生成新的稳定 ID 和名称。 */
|
||||
function copyRule(index: number) {
|
||||
if (draftRules.value.length >= props.maxRules) return
|
||||
const source = draftRules.value[index]
|
||||
if (!source) return
|
||||
const copied = cloneRule(source)
|
||||
copied.id = uniqueId(`${source.id}-copy`)
|
||||
copied.name = uniqueName(`${source.name} 副本`)
|
||||
commitRules([...draftRules.value.slice(0, index + 1), copied, ...draftRules.value.slice(index + 1)])
|
||||
}
|
||||
|
||||
/** 删除指定位置的规则。 */
|
||||
function deleteRule(index: number) {
|
||||
commitRules(draftRules.value.filter((_, ruleIndex) => ruleIndex !== index))
|
||||
}
|
||||
|
||||
/** 将规则移动到目标位置,并保护首尾边界。 */
|
||||
function moveRule(index: number, targetIndex: number) {
|
||||
if (targetIndex < 0 || targetIndex >= draftRules.value.length || index === targetIndex) return
|
||||
const nextRules = draftRules.value.map(cloneRule)
|
||||
const [rule] = nextRules.splice(index, 1)
|
||||
if (!rule) return
|
||||
nextRules.splice(targetIndex, 0, rule)
|
||||
commitRules(nextRules)
|
||||
}
|
||||
|
||||
/** 切换规则类型,并移除标签规则不应携带的分类目标。 */
|
||||
function updateKind(index: number, value: ClassificationRuleKind | null) {
|
||||
if (!value) return
|
||||
const rule = draftRules.value[index]
|
||||
if (!rule) return
|
||||
updateRule(index, {
|
||||
kind: value,
|
||||
target: {
|
||||
...rule.target,
|
||||
category_id: value === 'label' ? null : rule.target.category_id,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** 更新规则媒体类型,并清理已不兼容的分类目标。 */
|
||||
function updateMediaTypes(index: number, value: ClassificationMediaType[] | null) {
|
||||
const rule = draftRules.value[index]
|
||||
if (!rule) return
|
||||
const mediaTypes = value ?? []
|
||||
updateRule(index, {
|
||||
media_types: [...mediaTypes],
|
||||
target: {
|
||||
...rule.target,
|
||||
category_id: isCategoryCompatible(rule.target.category_id, mediaTypes) ? rule.target.category_id : null,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** 更新规则限定的数据源集合。 */
|
||||
function updateSources(index: number, value: string[] | null) {
|
||||
updateRule(index, { sources: [...(value ?? [])] })
|
||||
}
|
||||
|
||||
/** 更新条件树并保留其他规则字段。 */
|
||||
function updateCondition(index: number, value: ClassificationConditionNode) {
|
||||
updateRule(index, { when: cloneCondition(value) })
|
||||
}
|
||||
|
||||
/** 更新分类和标签输出。 */
|
||||
function updateTarget(index: number, patch: Partial<ClassificationRule['target']>) {
|
||||
const rule = draftRules.value[index]
|
||||
if (!rule) return
|
||||
updateRule(index, {
|
||||
target: {
|
||||
category_id: patch.category_id === undefined ? rule.target.category_id : patch.category_id,
|
||||
labels: patch.labels === undefined ? [...rule.target.labels] : [...patch.labels],
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const sourceItems = computed(() =>
|
||||
[...new Set(props.fields.flatMap(field => Object.keys(field.source_support)))].sort((left, right) =>
|
||||
left.localeCompare(right),
|
||||
),
|
||||
)
|
||||
|
||||
const orderedRules = computed({
|
||||
get: () => draftRules.value,
|
||||
set: (rules: ClassificationRule[]) => commitRules(rules),
|
||||
})
|
||||
|
||||
const hasReachedLimit = computed(() => draftRules.value.length >= props.maxRules)
|
||||
|
||||
watch(
|
||||
() => props.rules,
|
||||
rules => {
|
||||
draftRules.value = normalizePriorities(rules)
|
||||
},
|
||||
{ deep: true, immediate: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="classification-rule-editor" aria-label="分类规则编辑器">
|
||||
<header class="classification-rule-toolbar">
|
||||
<div class="classification-rule-count">
|
||||
<strong>有序规则</strong>
|
||||
<span>{{ draftRules.length }} / {{ maxRules }}</span>
|
||||
</div>
|
||||
<VBtn
|
||||
color="primary"
|
||||
variant="tonal"
|
||||
prepend-icon="mdi-plus"
|
||||
:disabled="hasReachedLimit"
|
||||
aria-label="新增分类规则"
|
||||
@click="addRule"
|
||||
>
|
||||
新增规则
|
||||
<VTooltip activator="parent" location="top">
|
||||
{{ hasReachedLimit ? `最多允许 ${maxRules} 条规则` : '新增分类规则' }}
|
||||
</VTooltip>
|
||||
</VBtn>
|
||||
</header>
|
||||
|
||||
<Draggable
|
||||
v-model="orderedRules"
|
||||
item-key="id"
|
||||
handle=".classification-rule-drag"
|
||||
tag="div"
|
||||
:component-data="{ class: 'classification-rule-list' }"
|
||||
>
|
||||
<template #item="{ element: rule, index }">
|
||||
<article class="classification-rule" :aria-label="`规则 ${index + 1}:${rule.name || rule.id}`">
|
||||
<div class="classification-rule-head">
|
||||
<div class="classification-rule-order">
|
||||
<IconBtn
|
||||
class="classification-rule-drag cursor-move"
|
||||
icon="mdi-drag-vertical"
|
||||
variant="text"
|
||||
:aria-label="`拖拽排序规则 ${rule.name || rule.id}`"
|
||||
>
|
||||
<VTooltip activator="parent" location="top">拖拽排序</VTooltip>
|
||||
</IconBtn>
|
||||
<VChip size="small" variant="tonal" color="primary">优先级 {{ rule.priority }}</VChip>
|
||||
<VSwitch
|
||||
:model-value="rule.enabled"
|
||||
color="primary"
|
||||
density="compact"
|
||||
hide-details
|
||||
inset
|
||||
:aria-label="`启用规则 ${rule.name || rule.id}`"
|
||||
@update:model-value="value => updateRule(index, { enabled: Boolean(value) })"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="classification-rule-actions">
|
||||
<IconBtn
|
||||
icon="mdi-arrow-up"
|
||||
variant="text"
|
||||
:disabled="index === 0"
|
||||
:aria-label="`上移规则 ${rule.name || rule.id}`"
|
||||
@click="moveRule(index, index - 1)"
|
||||
>
|
||||
<VTooltip activator="parent" location="top">上移规则</VTooltip>
|
||||
</IconBtn>
|
||||
<IconBtn
|
||||
icon="mdi-arrow-down"
|
||||
variant="text"
|
||||
:disabled="index === draftRules.length - 1"
|
||||
:aria-label="`下移规则 ${rule.name || rule.id}`"
|
||||
@click="moveRule(index, index + 1)"
|
||||
>
|
||||
<VTooltip activator="parent" location="top">下移规则</VTooltip>
|
||||
</IconBtn>
|
||||
<IconBtn
|
||||
icon="mdi-content-copy"
|
||||
variant="text"
|
||||
:disabled="hasReachedLimit"
|
||||
:aria-label="`复制规则 ${rule.name || rule.id}`"
|
||||
@click="copyRule(index)"
|
||||
>
|
||||
<VTooltip activator="parent" location="top">复制规则</VTooltip>
|
||||
</IconBtn>
|
||||
<IconBtn
|
||||
icon="mdi-delete-outline"
|
||||
variant="text"
|
||||
color="error"
|
||||
:aria-label="`删除规则 ${rule.name || rule.id}`"
|
||||
@click="deleteRule(index)"
|
||||
>
|
||||
<VTooltip activator="parent" location="top">删除规则</VTooltip>
|
||||
</IconBtn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="classification-rule-grid classification-rule-grid--identity">
|
||||
<VTextField
|
||||
:model-value="rule.name"
|
||||
label="规则名称"
|
||||
density="compact"
|
||||
hide-details="auto"
|
||||
:aria-label="`规则名称 ${index + 1}`"
|
||||
@update:model-value="value => updateRule(index, { name: value })"
|
||||
/>
|
||||
<VTextField
|
||||
:model-value="rule.id"
|
||||
label="稳定 ID"
|
||||
density="compact"
|
||||
hide-details="auto"
|
||||
:aria-label="`规则 ID ${index + 1}`"
|
||||
@update:model-value="value => updateRule(index, { id: value })"
|
||||
/>
|
||||
<VBtnToggle
|
||||
:model-value="rule.kind"
|
||||
mandatory
|
||||
divided
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
class="classification-rule-kind"
|
||||
:aria-label="`规则类型 ${rule.name || rule.id}`"
|
||||
@update:model-value="value => updateKind(index, value)"
|
||||
>
|
||||
<VBtn value="category">分类</VBtn>
|
||||
<VBtn value="label">标签</VBtn>
|
||||
</VBtnToggle>
|
||||
</div>
|
||||
|
||||
<div class="classification-rule-grid">
|
||||
<VSelect
|
||||
:model-value="rule.media_types"
|
||||
:items="MEDIA_TYPES"
|
||||
label="媒体类型"
|
||||
multiple
|
||||
chips
|
||||
closable-chips
|
||||
clearable
|
||||
density="compact"
|
||||
hide-details="auto"
|
||||
:aria-label="`媒体类型 ${rule.name || rule.id}`"
|
||||
@update:model-value="value => updateMediaTypes(index, value)"
|
||||
/>
|
||||
<VSelect
|
||||
:model-value="rule.sources"
|
||||
:items="sourceItems"
|
||||
label="数据来源"
|
||||
multiple
|
||||
chips
|
||||
closable-chips
|
||||
clearable
|
||||
density="compact"
|
||||
hide-details="auto"
|
||||
hint="留空表示全部来源"
|
||||
:aria-label="`数据来源 ${rule.name || rule.id}`"
|
||||
@update:model-value="value => updateSources(index, value)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="classification-rule-condition">
|
||||
<div class="classification-rule-section-title">匹配条件</div>
|
||||
<ClassificationConditionBuilder
|
||||
:model-value="rule.when"
|
||||
:fields="fields"
|
||||
:media-types="rule.media_types"
|
||||
:sources="rule.sources"
|
||||
:max-depth="maxConditionDepth"
|
||||
@update:model-value="value => updateCondition(index, value)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="classification-rule-target">
|
||||
<div class="classification-rule-section-title">规则输出</div>
|
||||
<div class="classification-rule-grid">
|
||||
<VSelect
|
||||
v-if="rule.kind === 'category'"
|
||||
:model-value="rule.target.category_id"
|
||||
:items="categoryItems(rule)"
|
||||
label="分类目标"
|
||||
clearable
|
||||
density="compact"
|
||||
hide-details="auto"
|
||||
no-data-text="当前媒体类型没有可用分类"
|
||||
:aria-label="`分类目标 ${rule.name || rule.id}`"
|
||||
@update:model-value="value => updateTarget(index, { category_id: value })"
|
||||
/>
|
||||
<VCombobox
|
||||
:model-value="rule.target.labels"
|
||||
label="输出标签"
|
||||
multiple
|
||||
chips
|
||||
closable-chips
|
||||
clearable
|
||||
density="compact"
|
||||
hide-details="auto"
|
||||
:class="{ 'classification-rule-labels--wide': rule.kind === 'label' }"
|
||||
:aria-label="`标签输出 ${rule.name || rule.id}`"
|
||||
@update:model-value="value => updateTarget(index, { labels: value })"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
</Draggable>
|
||||
|
||||
<div v-if="draftRules.length === 0" class="classification-rule-empty">
|
||||
<VIcon icon="mdi-filter-plus-outline" size="30" />
|
||||
<span>暂无分类规则</span>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.classification-rule-editor {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.classification-rule-toolbar,
|
||||
.classification-rule-head,
|
||||
.classification-rule-order,
|
||||
.classification-rule-actions,
|
||||
.classification-rule-count {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.classification-rule-toolbar,
|
||||
.classification-rule-head {
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.classification-rule-count {
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.classification-rule-count strong {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.classification-rule-count span {
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.classification-rule-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.classification-rule {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
padding: 12px;
|
||||
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||
border-radius: 6px;
|
||||
background: rgb(var(--v-theme-surface));
|
||||
}
|
||||
|
||||
.classification-rule-order,
|
||||
.classification-rule-actions {
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.classification-rule-drag {
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.classification-rule-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.classification-rule-grid--identity {
|
||||
grid-template-columns: minmax(160px, 1.2fr) minmax(150px, 1fr) auto;
|
||||
}
|
||||
|
||||
.classification-rule-kind {
|
||||
align-self: start;
|
||||
min-width: 144px;
|
||||
}
|
||||
|
||||
.classification-rule-kind :deep(.v-btn) {
|
||||
min-width: 70px;
|
||||
}
|
||||
|
||||
.classification-rule-condition,
|
||||
.classification-rule-target {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.classification-rule-section-title {
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.classification-rule-labels--wide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.classification-rule-empty {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
gap: 6px;
|
||||
min-height: 112px;
|
||||
border: 1px dashed rgba(var(--v-border-color), var(--v-border-opacity));
|
||||
border-radius: 6px;
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.classification-rule-toolbar {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.classification-rule-toolbar :deep(.v-btn) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.classification-rule-head {
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.classification-rule-actions {
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
|
||||
.classification-rule-grid,
|
||||
.classification-rule-grid--identity {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.classification-rule-kind {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.classification-rule-kind :deep(.v-btn) {
|
||||
flex: 1 1 0;
|
||||
}
|
||||
|
||||
.classification-rule-labels--wide {
|
||||
grid-column: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 420px) {
|
||||
.classification-rule {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.classification-rule-order {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.classification-rule-actions {
|
||||
width: 100%;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -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<Record<ClassificationMediaType, string>>
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -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<ToggleHandler> = 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<unknown[]>, default: () => [] },
|
||||
label: String,
|
||||
modelValue: { type: null as unknown as PropType<unknown>, 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<unknown>, 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<unknown>, 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<string | boolean>, 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<string, unknown> = {}) {
|
||||
return renderWithProviders(ClassificationConditionBuilder, {
|
||||
props: { ...defaultProps, modelValue, ...overrides },
|
||||
global: { stubs: componentStubs },
|
||||
})
|
||||
}
|
||||
|
||||
/** 读取最近一次受控节点更新。 */
|
||||
function latestModel(result: Awaited<ReturnType<typeof renderBuilder>>): 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<HTMLElement>('section[data-depth="0"]')
|
||||
expect(root).not.toBeNull()
|
||||
expect(result.container.querySelectorAll('section[data-depth="1"]')).toHaveLength(2)
|
||||
|
||||
const firstChild = result.container.querySelector<HTMLElement>('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)
|
||||
})
|
||||
})
|
||||
@@ -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> = {}): 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<string, string> = {
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -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<InstanceType<typeof ClassificationPolicyControlPanel>['$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()
|
||||
})
|
||||
})
|
||||
@@ -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<ClassificationFieldDefinition> &
|
||||
Pick<ClassificationFieldDefinition, 'id' | 'label' | 'value_type'>,
|
||||
): 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()
|
||||
})
|
||||
})
|
||||
@@ -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> = {}): 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()
|
||||
})
|
||||
})
|
||||
@@ -1,652 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import draggable from 'vuedraggable'
|
||||
import api from '@/api'
|
||||
import type { CategoryConfig } from '@/api/types'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useDisplay } from 'vuetify'
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
|
||||
// 定义输入参数
|
||||
defineProps<{
|
||||
modelValue?: boolean
|
||||
}>()
|
||||
|
||||
// 定义事件
|
||||
const emit = defineEmits(['close', 'save'])
|
||||
|
||||
const activeTab = ref('movie')
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const toast = useToast()
|
||||
const { t } = useI18n()
|
||||
|
||||
const generateId = () => {
|
||||
return 'id-' + Math.random().toString(36).substr(2, 9) + '-' + Date.now()
|
||||
}
|
||||
|
||||
interface CategoryItem {
|
||||
id: string
|
||||
name: string
|
||||
rule: any
|
||||
}
|
||||
|
||||
const movieList = ref<CategoryItem[]>([])
|
||||
const tvList = ref<CategoryItem[]>([])
|
||||
|
||||
// TMDB 类型映射
|
||||
const genreOptions = [
|
||||
{ title: '动作 (Action)', value: '28' },
|
||||
{ title: '冒险 (Adventure)', value: '12' },
|
||||
{ title: '动画 (Animation)', value: '16' },
|
||||
{ title: '喜剧 (Comedy)', value: '35' },
|
||||
{ title: '犯罪 (Crime)', value: '80' },
|
||||
{ title: '纪录 (Documentary)', value: '99' },
|
||||
{ title: '剧情 (Drama)', value: '18' },
|
||||
{ title: '家庭 (Family)', value: '10751' },
|
||||
{ title: '奇幻 (Fantasy)', value: '14' },
|
||||
{ title: '历史 (History)', value: '36' },
|
||||
{ title: '恐怖 (Horror)', value: '27' },
|
||||
{ title: '音乐 (Music)', value: '10402' },
|
||||
{ title: '悬疑 (Mystery)', value: '9648' },
|
||||
{ title: '爱情 (Romance)', value: '10749' },
|
||||
{ title: '科幻 (SF)', value: '878' },
|
||||
{ title: '电视电影', value: '10770' },
|
||||
{ title: '惊悚 (Thriller)', value: '53' },
|
||||
{ title: '战争 (War)', value: '10752' },
|
||||
{ title: '西部 (Western)', value: '37' },
|
||||
{ title: '儿童 (Kids)', value: '10762' },
|
||||
{ title: '新闻 (News)', value: '10763' },
|
||||
{ title: '真人秀 (Reality)', value: '10764' },
|
||||
{ title: '科幻/奇幻 (Sci-Fi)', value: '10765' },
|
||||
{ title: '肥皂剧 (Soap)', value: '10766' },
|
||||
{ title: '访谈 (Talk)', value: '10767' },
|
||||
{ title: '战争/政治', value: '10768' },
|
||||
]
|
||||
|
||||
// 语种选项 (original_language)
|
||||
const languageOptions = [
|
||||
{ title: '中文', value: 'zh' },
|
||||
{ title: '中文', value: 'cn' },
|
||||
{ title: '英语 (English)', value: 'en' },
|
||||
{ title: '日语 (Japanese)', value: 'ja' },
|
||||
{ title: '韩语 (Korean)', value: 'ko' },
|
||||
{ title: '法语 (French)', value: 'fr' },
|
||||
{ title: '德语 (German)', value: 'de' },
|
||||
{ title: '西班牙语 (Spanish)', value: 'es' },
|
||||
{ title: '意大利语 (Italian)', value: 'it' },
|
||||
{ title: '葡萄牙语 (Portuguese)', value: 'pt' },
|
||||
{ title: '俄语 (Russian)', value: 'ru' },
|
||||
{ title: '阿拉伯语', value: 'ar' },
|
||||
{ title: '泰语 (Thai)', value: 'th' },
|
||||
{ title: '越南语 (Vietnamese)', value: 'vi' },
|
||||
{ title: '印地语 (Hindi)', value: 'hi' },
|
||||
{ title: '土耳其语 (Turkish)', value: 'tr' },
|
||||
{ title: '荷兰语 (Dutch)', value: 'nl' },
|
||||
{ title: '波兰语 (Polish)', value: 'pl' },
|
||||
{ title: '瑞典语 (Swedish)', value: 'sv' },
|
||||
{ title: '丹麦语 (Danish)', value: 'da' },
|
||||
{ title: '挪威语 (Norwegian)', value: 'nb' },
|
||||
{ title: '芬兰语 (Finnish)', value: 'fi' },
|
||||
{ title: '希腊语 (Greek)', value: 'el' },
|
||||
{ title: '捷克语 (Czech)', value: 'cs' },
|
||||
{ title: '匈牙利语 (Hungarian)', value: 'hu' },
|
||||
{ title: '罗马尼亚语 (Romanian)', value: 'ro' },
|
||||
{ title: '乌克兰语 (Ukrainian)', value: 'uk' },
|
||||
{ title: '印度尼西亚语 (Indonesian)', value: 'id' },
|
||||
{ title: '马来语 (Malay)', value: 'ms' },
|
||||
{ title: '希伯来语 (Hebrew)', value: 'he' },
|
||||
]
|
||||
|
||||
// 国家/地区选项 (origin_country/production_countries)
|
||||
const countryOptions = [
|
||||
{ title: '中国大陆 (CN)', value: 'CN' },
|
||||
{ title: '中国香港 (HK)', value: 'HK' },
|
||||
{ title: '中国台湾 (TW)', value: 'TW' },
|
||||
{ title: '美国 (US)', value: 'US' },
|
||||
{ title: '英国 (GB)', value: 'GB' },
|
||||
{ title: '日本 (JP)', value: 'JP' },
|
||||
{ title: '韩国 (KR)', value: 'KR' },
|
||||
{ title: '法国 (FR)', value: 'FR' },
|
||||
{ title: '德国 (DE)', value: 'DE' },
|
||||
{ title: '意大利 (IT)', value: 'IT' },
|
||||
{ title: '西班牙 (ES)', value: 'ES' },
|
||||
{ title: '加拿大 (CA)', value: 'CA' },
|
||||
{ title: '澳大利亚 (AU)', value: 'AU' },
|
||||
{ title: '俄罗斯 (RU)', value: 'RU' },
|
||||
{ title: '印度 (IN)', value: 'IN' },
|
||||
{ title: '泰国 (TH)', value: 'TH' },
|
||||
{ title: '新加坡 (SG)', value: 'SG' },
|
||||
{ title: '马来西亚 (MY)', value: 'MY' },
|
||||
{ title: '越南 (VN)', value: 'VN' },
|
||||
{ title: '菲律宾 (PH)', value: 'PH' },
|
||||
{ title: '巴西 (BR)', value: 'BR' },
|
||||
{ title: '墨西哥 (MX)', value: 'MX' },
|
||||
{ title: '阿根廷 (AR)', value: 'AR' },
|
||||
{ title: '荷兰 (NL)', value: 'NL' },
|
||||
{ title: '比利时 (BE)', value: 'BE' },
|
||||
{ title: '瑞士 (CH)', value: 'CH' },
|
||||
{ title: '瑞典 (SE)', value: 'SE' },
|
||||
{ title: '挪威 (NO)', value: 'NO' },
|
||||
{ title: '丹麦 (DK)', value: 'DK' },
|
||||
{ title: '波兰 (PL)', value: 'PL' },
|
||||
{ title: '捷克 (CZ)', value: 'CZ' },
|
||||
{ title: '土耳其 (TR)', value: 'TR' },
|
||||
{ title: '以色列 (IL)', value: 'IL' },
|
||||
{ title: '埃及 (EG)', value: 'EG' },
|
||||
{ title: '南非 (ZA)', value: 'ZA' },
|
||||
{ title: '新西兰 (NZ)', value: 'NZ' },
|
||||
]
|
||||
|
||||
const fetchConfig = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const config = await api.get<CategoryConfig | null>('media/category/config', { feedback: 'silent' })
|
||||
if (config) parseConfig(config)
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
toast.error(t('setting.category.loadFailed'))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const parseConfig = (data: CategoryConfig) => {
|
||||
// 将对象 { "Name": { ... } } 转换为数组 [ { id: uuid, name: "Name", rule: { ... } } ]
|
||||
movieList.value = []
|
||||
if (data.movie) {
|
||||
for (const [key, value] of Object.entries(data.movie)) {
|
||||
// 为了UI一致性处理 genre_ids 为数组或字符串,但 API 发送的是字符串
|
||||
const rule = { ...value }
|
||||
if (rule.genre_ids && typeof rule.genre_ids === 'string') {
|
||||
// UI 多选预期为数组,检查输入。实际上 VAutocomplete 多选预期数组。我们需要将字符串分割为数组。
|
||||
// @ts-ignore
|
||||
rule.genre_ids = rule.genre_ids.split(',')
|
||||
} else {
|
||||
// @ts-ignore
|
||||
rule.genre_ids = []
|
||||
}
|
||||
|
||||
// 处理语种
|
||||
if (rule.original_language && typeof rule.original_language === 'string') {
|
||||
// @ts-ignore
|
||||
rule.original_language = rule.original_language.split(',')
|
||||
} else {
|
||||
// @ts-ignore
|
||||
rule.original_language = []
|
||||
}
|
||||
|
||||
// 处理制片国家/地区
|
||||
if (rule.production_countries && typeof rule.production_countries === 'string') {
|
||||
// @ts-ignore
|
||||
rule.production_countries = rule.production_countries.split(',')
|
||||
} else {
|
||||
// @ts-ignore
|
||||
rule.production_countries = []
|
||||
}
|
||||
|
||||
movieList.value.push({
|
||||
id: generateId(),
|
||||
name: key,
|
||||
rule: rule as any,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
tvList.value = []
|
||||
if (data.tv) {
|
||||
for (const [key, value] of Object.entries(data.tv)) {
|
||||
const rule = { ...value }
|
||||
if (rule.genre_ids && typeof rule.genre_ids === 'string') {
|
||||
// @ts-ignore
|
||||
rule.genre_ids = rule.genre_ids.split(',')
|
||||
} else {
|
||||
// @ts-ignore
|
||||
rule.genre_ids = []
|
||||
}
|
||||
|
||||
// 处理语种
|
||||
if (rule.original_language && typeof rule.original_language === 'string') {
|
||||
// @ts-ignore
|
||||
rule.original_language = rule.original_language.split(',')
|
||||
} else {
|
||||
// @ts-ignore
|
||||
rule.original_language = []
|
||||
}
|
||||
|
||||
// 处理发行国家/地区
|
||||
if (rule.origin_country && typeof rule.origin_country === 'string') {
|
||||
// @ts-ignore
|
||||
rule.origin_country = rule.origin_country.split(',')
|
||||
} else {
|
||||
// @ts-ignore
|
||||
rule.origin_country = []
|
||||
}
|
||||
|
||||
tvList.value.push({
|
||||
id: generateId(),
|
||||
name: key,
|
||||
rule: rule as any,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const addMovieItem = () => {
|
||||
movieList.value.push({
|
||||
id: generateId(),
|
||||
name: '新分类',
|
||||
rule: { genre_ids: [] as any },
|
||||
})
|
||||
}
|
||||
|
||||
const removeMovieItem = (index: number) => {
|
||||
movieList.value.splice(index, 1)
|
||||
}
|
||||
|
||||
const addTvItem = () => {
|
||||
tvList.value.push({
|
||||
id: generateId(),
|
||||
name: '新分类',
|
||||
rule: { genre_ids: [] as any },
|
||||
})
|
||||
}
|
||||
|
||||
const removeTvItem = (index: number) => {
|
||||
tvList.value.splice(index, 1)
|
||||
}
|
||||
|
||||
const saveConfig = async () => {
|
||||
saving.value = true
|
||||
try {
|
||||
// 将数组转换回对象
|
||||
const payload: CategoryConfig = {
|
||||
movie: {},
|
||||
tv: {},
|
||||
}
|
||||
|
||||
movieList.value.forEach(item => {
|
||||
if (item.name) {
|
||||
const rule = { ...item.rule }
|
||||
// 将 genre_ids 数组转换回字符串
|
||||
if (Array.isArray(rule.genre_ids) && rule.genre_ids.length > 0) {
|
||||
rule.genre_ids = rule.genre_ids.join(',')
|
||||
} else {
|
||||
// @ts-ignore
|
||||
rule.genre_ids = null
|
||||
}
|
||||
|
||||
// 将 original_language 数组转换回字符串
|
||||
if (Array.isArray(rule.original_language) && rule.original_language.length > 0) {
|
||||
rule.original_language = rule.original_language.join(',')
|
||||
} else {
|
||||
rule.original_language = undefined
|
||||
}
|
||||
|
||||
// 将 production_countries 数组转换回字符串
|
||||
if (Array.isArray(rule.production_countries) && rule.production_countries.length > 0) {
|
||||
rule.production_countries = rule.production_countries.join(',')
|
||||
} else {
|
||||
rule.production_countries = undefined
|
||||
}
|
||||
|
||||
// 清理空字符串
|
||||
if (!rule.release_year) rule.release_year = undefined
|
||||
|
||||
// @ts-ignore
|
||||
payload.movie[item.name] = rule
|
||||
}
|
||||
})
|
||||
|
||||
tvList.value.forEach(item => {
|
||||
if (item.name) {
|
||||
const rule = { ...item.rule }
|
||||
if (Array.isArray(rule.genre_ids) && rule.genre_ids.length > 0) {
|
||||
rule.genre_ids = rule.genre_ids.join(',')
|
||||
} else {
|
||||
// @ts-ignore
|
||||
rule.genre_ids = null
|
||||
}
|
||||
|
||||
// 将 original_language 数组转换回字符串
|
||||
if (Array.isArray(rule.original_language) && rule.original_language.length > 0) {
|
||||
rule.original_language = rule.original_language.join(',')
|
||||
} else {
|
||||
rule.original_language = undefined
|
||||
}
|
||||
|
||||
// 将 origin_country 数组转换回字符串
|
||||
if (Array.isArray(rule.origin_country) && rule.origin_country.length > 0) {
|
||||
rule.origin_country = rule.origin_country.join(',')
|
||||
} else {
|
||||
rule.origin_country = undefined
|
||||
}
|
||||
|
||||
// 清理空字符串
|
||||
if (!rule.release_year) rule.release_year = undefined
|
||||
|
||||
// @ts-ignore
|
||||
payload.tv[item.name] = rule
|
||||
}
|
||||
})
|
||||
|
||||
await api.post<null>('media/category/config', payload, { feedback: 'silent' })
|
||||
toast.success(t('setting.category.saveSuccess'))
|
||||
emit('save')
|
||||
emit('close')
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
toast.error(t('setting.category.saveFailed', { message: e instanceof Error ? e.message : t('common.error') }))
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchConfig()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog :model-value="modelValue" max-width="1000" scrollable :fullscreen="!display.mdAndUp.value">
|
||||
<VCard>
|
||||
<VDialogCloseBtn @click="emit('close')" />
|
||||
<VCardItem class="py-3">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-shape-outline" class="me-2" />
|
||||
</template>
|
||||
<VCardTitle>
|
||||
{{ t('setting.category.title') }}
|
||||
</VCardTitle>
|
||||
<VCardSubtitle>
|
||||
{{ t('setting.category.subtitle') }}
|
||||
</VCardSubtitle>
|
||||
</VCardItem>
|
||||
|
||||
<VCardText>
|
||||
<VTabs v-model="activeTab" show-arrows class="mb-4">
|
||||
<VTab value="movie">
|
||||
<VIcon icon="mdi-movie-outline" class="me-2" />
|
||||
{{ t('setting.category.movie') }}
|
||||
</VTab>
|
||||
<VTab value="tv">
|
||||
<VIcon icon="mdi-television" class="me-2" />
|
||||
{{ t('setting.category.tv') }}
|
||||
</VTab>
|
||||
</VTabs>
|
||||
|
||||
<div v-if="loading" class="d-flex justify-center align-center" style="min-block-size: 300px">
|
||||
<VProgressCircular indeterminate color="primary" size="64" />
|
||||
</div>
|
||||
|
||||
<VWindow v-else v-model="activeTab" class="disable-tab-transition" :touch="false">
|
||||
<VWindowItem value="movie">
|
||||
<draggable v-model="movieList" handle=".drag-handle" item-key="id" animation="200">
|
||||
<template #item="{ element, index }">
|
||||
<VCard variant="tonal" class="mb-4 category-item">
|
||||
<VCardText class="pa-4">
|
||||
<div class="d-flex align-center mb-5">
|
||||
<VTextField
|
||||
v-model="element.name"
|
||||
:label="t('setting.category.name')"
|
||||
density="comfortable"
|
||||
hide-details
|
||||
variant="plain"
|
||||
class="font-bold"
|
||||
prepend-inner-icon="mdi-tag-outline"
|
||||
/>
|
||||
<VSpacer />
|
||||
<VBtn
|
||||
icon="mdi-drag-vertical"
|
||||
variant="text"
|
||||
size="small"
|
||||
class="drag-handle me-2"
|
||||
color="primary"
|
||||
/>
|
||||
<VBtn
|
||||
icon="mdi-delete-outline"
|
||||
color="error"
|
||||
variant="text"
|
||||
size="small"
|
||||
@click="removeMovieItem(index)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<VRow>
|
||||
<VCol cols="12" md="6">
|
||||
<VAutocomplete
|
||||
v-model="element.rule.genre_ids"
|
||||
:items="genreOptions"
|
||||
:label="t('setting.category.genre')"
|
||||
item-title="title"
|
||||
item-value="value"
|
||||
multiple
|
||||
chips
|
||||
closable-chips
|
||||
density="comfortable"
|
||||
variant="outlined"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-movie-filter-outline"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VAutocomplete
|
||||
v-model="element.rule.production_countries"
|
||||
:items="countryOptions"
|
||||
:label="t('setting.category.country')"
|
||||
item-title="title"
|
||||
item-value="value"
|
||||
multiple
|
||||
chips
|
||||
closable-chips
|
||||
density="comfortable"
|
||||
variant="outlined"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-earth"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VAutocomplete
|
||||
v-model="element.rule.original_language"
|
||||
:items="languageOptions"
|
||||
:label="t('setting.category.language')"
|
||||
item-title="title"
|
||||
item-value="value"
|
||||
multiple
|
||||
chips
|
||||
closable-chips
|
||||
density="comfortable"
|
||||
variant="outlined"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-translate"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="element.rule.release_year"
|
||||
:label="t('setting.category.year')"
|
||||
:placeholder="t('setting.category.yearPlaceholder')"
|
||||
density="comfortable"
|
||||
variant="outlined"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-calendar-range"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</template>
|
||||
</draggable>
|
||||
|
||||
<VBtn
|
||||
block
|
||||
variant="outlined"
|
||||
size="large"
|
||||
prepend-icon="mdi-plus-circle-outline"
|
||||
class="mt-2 add-category-btn"
|
||||
@click="addMovieItem"
|
||||
>
|
||||
{{ t('setting.category.addMovie') }}
|
||||
</VBtn>
|
||||
</VWindowItem>
|
||||
|
||||
<VWindowItem value="tv">
|
||||
<draggable v-model="tvList" handle=".drag-handle" item-key="id" animation="200">
|
||||
<template #item="{ element, index }">
|
||||
<VCard variant="tonal" class="mb-4 category-item">
|
||||
<VCardText class="pa-4">
|
||||
<div class="d-flex align-center mb-5">
|
||||
<VTextField
|
||||
v-model="element.name"
|
||||
:label="t('setting.category.name')"
|
||||
density="comfortable"
|
||||
hide-details
|
||||
variant="plain"
|
||||
class="font-bold"
|
||||
prepend-inner-icon="mdi-tag-outline"
|
||||
/>
|
||||
<VSpacer />
|
||||
<VBtn
|
||||
icon="mdi-drag-vertical"
|
||||
variant="text"
|
||||
size="small"
|
||||
class="drag-handle me-2"
|
||||
color="primary"
|
||||
/>
|
||||
<VBtn
|
||||
icon="mdi-delete-outline"
|
||||
color="error"
|
||||
variant="text"
|
||||
size="small"
|
||||
@click="removeTvItem(index)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<VRow>
|
||||
<VCol cols="12" md="6">
|
||||
<VAutocomplete
|
||||
v-model="element.rule.genre_ids"
|
||||
:items="genreOptions"
|
||||
:label="t('setting.category.genre')"
|
||||
item-title="title"
|
||||
item-value="value"
|
||||
multiple
|
||||
chips
|
||||
closable-chips
|
||||
density="comfortable"
|
||||
variant="outlined"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-movie-filter-outline"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VAutocomplete
|
||||
v-model="element.rule.origin_country"
|
||||
:items="countryOptions"
|
||||
:label="t('setting.category.country')"
|
||||
item-title="title"
|
||||
item-value="value"
|
||||
multiple
|
||||
chips
|
||||
closable-chips
|
||||
density="comfortable"
|
||||
variant="outlined"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-earth"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VAutocomplete
|
||||
v-model="element.rule.original_language"
|
||||
:items="languageOptions"
|
||||
:label="t('setting.category.language')"
|
||||
item-title="title"
|
||||
item-value="value"
|
||||
multiple
|
||||
chips
|
||||
closable-chips
|
||||
density="comfortable"
|
||||
variant="outlined"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-translate"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="element.rule.release_year"
|
||||
:label="t('setting.category.year')"
|
||||
:placeholder="t('setting.category.yearPlaceholder')"
|
||||
density="comfortable"
|
||||
variant="outlined"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-calendar-range"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</template>
|
||||
</draggable>
|
||||
|
||||
<VBtn
|
||||
block
|
||||
variant="outlined"
|
||||
size="large"
|
||||
prepend-icon="mdi-plus-circle-outline"
|
||||
class="mt-2 add-category-btn"
|
||||
@click="addTvItem"
|
||||
>
|
||||
{{ t('setting.category.addTv') }}
|
||||
</VBtn>
|
||||
</VWindowItem>
|
||||
</VWindow>
|
||||
</VCardText>
|
||||
|
||||
<VCardActions class="app-dialog-actions">
|
||||
<VSpacer />
|
||||
<VBtn
|
||||
color="primary"
|
||||
variant="flat"
|
||||
:loading="saving"
|
||||
prepend-icon="mdi-content-save"
|
||||
class="px-5"
|
||||
@click="saveConfig"
|
||||
>
|
||||
{{ t('common.save') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.drag-handle {
|
||||
cursor: grab;
|
||||
opacity: 0.6;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.drag-handle:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.drag-handle:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.add-category-btn {
|
||||
border-style: dashed !important;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.add-category-btn:hover {
|
||||
border-style: solid !important;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.disable-tab-transition > * {
|
||||
transition: none !important;
|
||||
}
|
||||
</style>
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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<typeof import('@/api/mediaClassification')>()),
|
||||
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<T>(status: number, payload: ApiResponse<T>): ApiRequestError<ApiResponse<T>> {
|
||||
const config = { headers: new AxiosHeaders() } as InternalAxiosRequestConfig
|
||||
const response: AxiosResponse<ApiResponse<T>> = {
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -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<ClassificationFieldCatalog> | null = null
|
||||
let fieldCatalogEpoch = 0
|
||||
|
||||
/** 清除共享字段目录缓存,供插件字段注册变化或测试隔离时显式刷新。 */
|
||||
export function clearMediaClassificationFieldCatalogCache(): void {
|
||||
fieldCatalogEpoch += 1
|
||||
fieldCatalogCache = null
|
||||
fieldCatalogPromise = null
|
||||
}
|
||||
|
||||
/** 读取共享字段目录,并隔离每个调用方拿到的可变对象。 */
|
||||
async function resolveFieldCatalog(force: boolean): Promise<ClassificationFieldCatalog> {
|
||||
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<ClassificationPolicy | null>(null)
|
||||
const draftPolicy = ref<ClassificationPolicy | null>(null)
|
||||
const fieldCatalogState = ref<ClassificationFieldCatalog | null>(null)
|
||||
const historyState = ref<ClassificationPolicyHistory | null>(null)
|
||||
const validationState = ref<ClassificationValidationResult | null>(null)
|
||||
const previewState = ref<ClassificationEvaluation | null>(null)
|
||||
const impactState = ref<ClassificationImpactAnalysis | null>(null)
|
||||
const conflictState = ref<ClassificationRevisionConflict | null>(null)
|
||||
const lastError = ref<unknown>(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<ClassificationPolicy> {
|
||||
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<ClassificationFieldCatalog> {
|
||||
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<ClassificationPolicyHistory> {
|
||||
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<ClassificationValidationResult> {
|
||||
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<ClassificationEvaluation> {
|
||||
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<ClassificationImpactAnalysis> {
|
||||
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<ClassificationPolicy> {
|
||||
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<ClassificationPolicyRollbackResult> {
|
||||
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<void> {
|
||||
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,
|
||||
}
|
||||
}
|
||||
+357
-19
@@ -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',
|
||||
|
||||
+342
-19
@@ -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: '自定义规则',
|
||||
|
||||
+342
-19
@@ -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: '自定義規則',
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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<typeof import('vue-router')>()
|
||||
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<string> {
|
||||
const registration = mocks.registerHeaderTab.mock.calls[0]?.[0] as { modelValue?: Ref<string> } | undefined
|
||||
expect(registration?.modelValue).toBeDefined()
|
||||
return registration!.modelValue!
|
||||
}
|
||||
|
||||
/** 渲染设置页框架但不实例化各异步设置面板。 */
|
||||
async function renderSettingPage() {
|
||||
return renderWithProviders(SettingPage, {
|
||||
global: {
|
||||
stubs: {
|
||||
VWindow: { template: '<div data-testid="setting-window" />' },
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
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'))
|
||||
})
|
||||
})
|
||||
+21
-3
@@ -1,6 +1,5 @@
|
||||
<script lang="ts" setup>
|
||||
import { useRoute } from 'vue-router'
|
||||
import router from '@/router'
|
||||
import { getSettingTabs } from '@/router/i18n-menu'
|
||||
import { useDynamicHeaderTab } from '@/composables/useDynamicHeaderTab'
|
||||
|
||||
@@ -13,6 +12,9 @@ const settingTabs = computed(() => getSettingTabs(t))
|
||||
// 设置页的每个大类都很重,按标签页拆包,避免进入设置时一次性下载全部配置面板。
|
||||
const AccountSettingSystem = defineAsyncComponent(() => import('@/views/setting/AccountSettingSystem.vue'))
|
||||
const AccountSettingDirectory = defineAsyncComponent(() => import('@/views/setting/AccountSettingDirectory.vue'))
|
||||
const AccountSettingClassification = defineAsyncComponent(
|
||||
() => import('@/views/setting/AccountSettingClassification.vue'),
|
||||
)
|
||||
const AccountSettingSite = defineAsyncComponent(() => import('@/views/setting/AccountSettingSite.vue'))
|
||||
const AccountSettingRule = defineAsyncComponent(() => import('@/views/setting/AccountSettingRule.vue'))
|
||||
const AccountSettingSearch = defineAsyncComponent(() => import('@/views/setting/AccountSettingSearch.vue'))
|
||||
@@ -24,12 +26,14 @@ const visitedTabs = ref(new Set<string>())
|
||||
const settingTabComponents = [
|
||||
{ value: 'system', component: AccountSettingSystem },
|
||||
{ value: 'directory', component: AccountSettingDirectory },
|
||||
{ value: 'classification', component: AccountSettingClassification },
|
||||
{ value: 'site', component: AccountSettingSite },
|
||||
{ value: 'rule', component: AccountSettingRule },
|
||||
{ value: 'search', component: AccountSettingSearch },
|
||||
{ value: 'subscribe', component: AccountSettingSubscribe },
|
||||
{ value: 'notification', component: AccountSettingNotification },
|
||||
]
|
||||
const settingTabValues = new Set(settingTabComponents.map(item => item.value))
|
||||
|
||||
function markTabVisited(tab: string) {
|
||||
if (!tab) return
|
||||
@@ -39,6 +43,12 @@ function markTabVisited(tab: string) {
|
||||
visitedTabs.value = nextTabs
|
||||
}
|
||||
|
||||
/** 从路由查询参数提取一个存在于当前设置页的标签值。 */
|
||||
function validRouteTab(value: unknown): string | null {
|
||||
const tab = Array.isArray(value) ? value[0] : value
|
||||
return typeof tab === 'string' && settingTabValues.has(tab) ? tab : null
|
||||
}
|
||||
|
||||
// 使用动态标签页
|
||||
const { registerHeaderTab } = useDynamicHeaderTab()
|
||||
|
||||
@@ -50,14 +60,22 @@ registerHeaderTab({
|
||||
|
||||
// 注册动态标签页
|
||||
onMounted(() => {
|
||||
// 设置初始activeTab值
|
||||
if (!activeTab.value && settingTabs.value.length > 0) {
|
||||
// 无效的深链参数不能让设置页停留在空白 VWindow。
|
||||
if (!settingTabValues.has(activeTab.value) && settingTabs.value.length > 0) {
|
||||
activeTab.value = settingTabs.value[0].tab
|
||||
}
|
||||
markTabVisited(activeTab.value)
|
||||
})
|
||||
|
||||
watch(activeTab, markTabVisited, { immediate: true })
|
||||
watch(
|
||||
() => route.query.tab,
|
||||
value => {
|
||||
const nextTab = validRouteTab(value)
|
||||
if (nextTab && nextTab !== activeTab.value) activeTab.value = nextTab
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -211,6 +211,12 @@ export function getSettingTabs(t: Composer['t']): NavMenuTabItem[] {
|
||||
tab: 'directory',
|
||||
description: t('settingTabs.directory.description'),
|
||||
},
|
||||
{
|
||||
title: t('settingTabs.classification.title'),
|
||||
icon: 'mdi-file-tree',
|
||||
tab: 'classification',
|
||||
description: t('settingTabs.classification.description'),
|
||||
},
|
||||
{
|
||||
title: t('settingTabs.site.title'),
|
||||
icon: 'mdi-web',
|
||||
|
||||
@@ -60,7 +60,7 @@ const albumMedia = computed<MediaInfo | undefined>(() => {
|
||||
|
||||
const attributes = computed(() => {
|
||||
const values: string[] = []
|
||||
if (album.value?.category) values.push(album.value.category)
|
||||
if (album.value?.metadata_category) values.push(album.value.metadata_category)
|
||||
if (album.value?.release_date) values.push(album.value.release_date)
|
||||
if (album.value?.total_tracks) values.push(t('music.trackCount', { count: album.value.total_tracks }))
|
||||
const duration = formatMusicDuration(album.value?.duration)
|
||||
@@ -174,7 +174,7 @@ watch(() => [props.mediaSource, props.mediaId], loadAlbumDetail, { immediate: tr
|
||||
</div>
|
||||
<div v-if="album.album_type" class="music-fact">
|
||||
<span>{{ t('music.albumType') }}</span>
|
||||
<span class="music-fact-value">{{ album.category || album.album_type }}</span>
|
||||
<span class="music-fact-value">{{ album.metadata_category || album.album_type }}</span>
|
||||
</div>
|
||||
<div v-if="album.release_date" class="music-fact">
|
||||
<span>{{ t('music.releaseDate') }}</span>
|
||||
|
||||
@@ -48,7 +48,7 @@ const primaryArtistId = computed(() => artistLinks.value.find(artist => artist.i
|
||||
// 头部属性行只展示各音乐源已映射到标准模型的字段
|
||||
const attributes = computed(() => {
|
||||
const values: string[] = []
|
||||
if (music.value?.category) values.push(music.value.category)
|
||||
if (music.value?.metadata_category) values.push(music.value.metadata_category)
|
||||
const releaseDate = music.value?.release_date || music.value?.year?.toString()
|
||||
if (releaseDate) values.push(releaseDate)
|
||||
const duration = formatMusicDuration(music.value?.duration)
|
||||
|
||||
@@ -0,0 +1,802 @@
|
||||
<script lang="ts" setup>
|
||||
import api from '@/api'
|
||||
import type { TransferDirectoryConf } from '@/api/types'
|
||||
import type {
|
||||
ClassificationCategory,
|
||||
ClassificationEnrichmentMode,
|
||||
ClassificationEvaluation,
|
||||
ClassificationFieldDefinition,
|
||||
ClassificationImpactAnalysis,
|
||||
ClassificationMediaType,
|
||||
ClassificationPolicy,
|
||||
ClassificationPolicyHistory,
|
||||
ClassificationPreviewInput,
|
||||
ClassificationRule,
|
||||
ClassificationValidationResult,
|
||||
} from '@/api/mediaClassificationTypes'
|
||||
import ClassificationCategoryEditor from '@/components/classification/ClassificationCategoryEditor.vue'
|
||||
import ClassificationImpactPanel from '@/components/classification/ClassificationImpactPanel.vue'
|
||||
import ClassificationPolicyControlPanel from '@/components/classification/ClassificationPolicyControlPanel.vue'
|
||||
import ClassificationPreviewPanel from '@/components/classification/ClassificationPreviewPanel.vue'
|
||||
import ClassificationRuleEditor from '@/components/classification/ClassificationRuleEditor.vue'
|
||||
import { useMediaClassification } from '@/composables/useMediaClassification'
|
||||
import { cloneDeep, isEqual } from 'lodash-es'
|
||||
import { useToast } from 'vue-toastification'
|
||||
|
||||
/** 事实预览组件提交的策略模式与完整输入。 */
|
||||
interface ClassificationPreviewRequestEvent {
|
||||
input: ClassificationPreviewInput
|
||||
policyMode: 'draft' | 'active'
|
||||
}
|
||||
|
||||
/** 影响分析组件提交的服务端有界参数。 */
|
||||
interface ClassificationImpactRequestEvent {
|
||||
sampleLimit: number
|
||||
exampleLimit: number
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
active?: boolean
|
||||
}>(),
|
||||
{ active: true },
|
||||
)
|
||||
|
||||
const { t } = useI18n()
|
||||
const toast = useToast()
|
||||
const initialized = ref(false)
|
||||
const initializing = ref(false)
|
||||
const loadError = ref(false)
|
||||
const directoryReferencesUnavailable = ref(false)
|
||||
const directories = ref<TransferDirectoryConf[]>([])
|
||||
const analysisTab = ref<'preview' | 'impact' | 'publish'>('preview')
|
||||
const validatedDraftSnapshot = ref<ClassificationPolicy | null>(null)
|
||||
const analyzedDraftSnapshot = ref<ClassificationPolicy | null>(null)
|
||||
const lastImpactOptions = ref<ClassificationImpactRequestEvent>({ sampleLimit: 100, exampleLimit: 20 })
|
||||
const mediaTypes: ClassificationMediaType[] = ['电影', '电视剧', '音乐']
|
||||
|
||||
const {
|
||||
activeRevision,
|
||||
analyzingImpact,
|
||||
conflict,
|
||||
draftPolicy,
|
||||
fieldCatalog,
|
||||
history,
|
||||
impactResult,
|
||||
isDirty,
|
||||
loadingHistory,
|
||||
loadingFields,
|
||||
loadingPolicy,
|
||||
previewResult,
|
||||
previewing,
|
||||
publishing,
|
||||
rollingBack,
|
||||
validationResult,
|
||||
validating,
|
||||
analyzeImpact,
|
||||
initialize,
|
||||
loadHistory,
|
||||
preview,
|
||||
publishDraft,
|
||||
refreshPolicy,
|
||||
resetDraft,
|
||||
rollback,
|
||||
validateDraft,
|
||||
} = useMediaClassification()
|
||||
|
||||
/** 服务端校验结果是否仍对应当前未发布草稿。 */
|
||||
const validationIsCurrent = computed(
|
||||
() =>
|
||||
validationResult.value?.valid === true &&
|
||||
!!draftPolicy.value &&
|
||||
!!validatedDraftSnapshot.value &&
|
||||
isEqual(draftPolicy.value, validatedDraftSnapshot.value),
|
||||
)
|
||||
|
||||
/** 影响分析是否仍对应当前草稿和当前活动 revision。 */
|
||||
const impactIsCurrent = computed(
|
||||
() =>
|
||||
!!impactResult.value &&
|
||||
impactResult.value.baseline_revision === activeRevision.value &&
|
||||
!!draftPolicy.value &&
|
||||
!!analyzedDraftSnapshot.value &&
|
||||
isEqual(draftPolicy.value, analyzedDraftSnapshot.value),
|
||||
)
|
||||
|
||||
/** 汇总规则和来源兜底引用;全局兜底由分类树按媒体类型单独判断。 */
|
||||
const referencedCategoryIds = computed(() => {
|
||||
const policy = draftPolicy.value
|
||||
if (!policy) return []
|
||||
|
||||
const references = new Set<string>()
|
||||
for (const rule of policy.rules) {
|
||||
if (rule.target.category_id) references.add(rule.target.category_id)
|
||||
}
|
||||
for (const sourceFallbacks of Object.values(policy.source_fallbacks)) {
|
||||
for (const categoryId of Object.values(sourceFallbacks)) {
|
||||
if (categoryId) references.add(categoryId)
|
||||
}
|
||||
}
|
||||
return [...references]
|
||||
})
|
||||
|
||||
/** 按稳定分类 ID 汇总目录名称,供分类树展示并执行引用保护。 */
|
||||
const directoryCategoryReferences = computed(() => {
|
||||
const references = new Map<string, Set<string>>()
|
||||
for (const directory of directories.value) {
|
||||
const categoryId = directory.media_category_id?.trim()
|
||||
if (!categoryId) continue
|
||||
const names = references.get(categoryId) ?? new Set<string>()
|
||||
names.add(directory.name)
|
||||
references.set(categoryId, names)
|
||||
}
|
||||
return [...references.entries()].map(([categoryId, names]) => ({
|
||||
categoryId,
|
||||
directoryNames: [...names].sort((left, right) => left.localeCompare(right)),
|
||||
}))
|
||||
})
|
||||
|
||||
/** 从动态字段支持表汇总当前可配置的内置和插件来源。 */
|
||||
const availableSources = computed(() => {
|
||||
const sources = new Set<string>()
|
||||
for (const field of fieldCatalog.value?.fields ?? []) {
|
||||
for (const source of Object.keys(field.source_support)) sources.add(source)
|
||||
}
|
||||
for (const source of Object.keys(draftPolicy.value?.source_fallbacks ?? {})) sources.add(source)
|
||||
return [...sources].sort((left, right) => left.localeCompare(right))
|
||||
})
|
||||
|
||||
/** 将只读 API 字段目录复制为编辑器输入,避免组件边界泄漏深层响应式只读类型。 */
|
||||
const editorFields = computed<ClassificationFieldDefinition[]>(() =>
|
||||
(fieldCatalog.value?.fields ?? []).map(field => ({
|
||||
...field,
|
||||
media_types: [...field.media_types],
|
||||
operators: [...field.operators],
|
||||
options: field.options.map(option => ({ ...option })),
|
||||
source_support: { ...field.source_support },
|
||||
})),
|
||||
)
|
||||
|
||||
/** 将 JSON API 深层只读值复制并恢复为组件 DTO,副本不会回写 composable 状态。 */
|
||||
function mutableApiSnapshot<T>(value: unknown): T {
|
||||
return cloneDeep(value) as T
|
||||
}
|
||||
|
||||
/** 将深层只读预览响应复制为展示组件无法回写服务状态的隔离快照。 */
|
||||
const previewResultSnapshot = computed<ClassificationEvaluation | null>(() =>
|
||||
previewResult.value ? mutableApiSnapshot<ClassificationEvaluation>(previewResult.value) : null,
|
||||
)
|
||||
|
||||
/** 将深层只读影响响应复制为展示组件输入。 */
|
||||
const impactResultSnapshot = computed<ClassificationImpactAnalysis | null>(() =>
|
||||
impactResult.value ? mutableApiSnapshot<ClassificationImpactAnalysis>(impactResult.value) : null,
|
||||
)
|
||||
|
||||
/** 将深层只读校验响应复制为发布控制组件输入。 */
|
||||
const validationResultSnapshot = computed<ClassificationValidationResult | null>(() =>
|
||||
validationResult.value ? mutableApiSnapshot<ClassificationValidationResult>(validationResult.value) : null,
|
||||
)
|
||||
|
||||
/** 将深层只读历史快照复制为发布控制组件输入。 */
|
||||
const historySnapshot = computed<ClassificationPolicyHistory | null>(() =>
|
||||
history.value ? mutableApiSnapshot<ClassificationPolicyHistory>(history.value) : null,
|
||||
)
|
||||
|
||||
/** 返回指定媒体类型可作为来源兜底的启用分类。 */
|
||||
function fallbackCategoryOptions(mediaType: ClassificationMediaType) {
|
||||
return (draftPolicy.value?.categories ?? [])
|
||||
.filter(category => category.enabled && category.media_type === mediaType)
|
||||
.map(category => ({
|
||||
title: `${category.name} · ${category.path.join(' / ')} · ${category.id}`,
|
||||
value: category.id,
|
||||
}))
|
||||
}
|
||||
|
||||
/** 将来源兜底标签传给 VSelect 的真实 combobox 激活元素。 */
|
||||
function sourceFallbackMenuProps(source: string, mediaType: ClassificationMediaType) {
|
||||
return {
|
||||
activatorProps: {
|
||||
'aria-label': t('setting.classification.sourceFallbackFor', { source, mediaType }),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** 标签首次激活时加载策略与动态字段,失败后允许用户显式重试。 */
|
||||
async function ensureInitialized(force = false): Promise<void> {
|
||||
if (!props.active || initializing.value || (initialized.value && !force)) return
|
||||
initializing.value = true
|
||||
loadError.value = false
|
||||
try {
|
||||
await Promise.all([initialize(), loadDirectoryReferences()])
|
||||
initialized.value = true
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
loadError.value = true
|
||||
} finally {
|
||||
initializing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 读取目录配置中的稳定分类引用;失败时保留策略编辑但明确提示保护信息不完整。 */
|
||||
async function loadDirectoryReferences(): Promise<void> {
|
||||
directoryReferencesUnavailable.value = false
|
||||
try {
|
||||
const result = await api.get<{ value?: TransferDirectoryConf[] }>('system/setting/public/Directories', {
|
||||
feedback: 'silent',
|
||||
})
|
||||
directories.value = result.value ?? []
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
directories.value = []
|
||||
directoryReferencesUnavailable.value = true
|
||||
}
|
||||
}
|
||||
|
||||
/** 使用不可变数组替换分类草稿,避免子组件原地污染活动策略。 */
|
||||
function updateCategories(categories: ClassificationCategory[]): void {
|
||||
if (!draftPolicy.value) return
|
||||
draftPolicy.value = { ...draftPolicy.value, categories }
|
||||
}
|
||||
|
||||
/** 更新三个媒体类型的稳定兜底分类 ID。 */
|
||||
function updateFallbacks(fallbacks: Partial<Record<ClassificationMediaType, string>>): void {
|
||||
if (!draftPolicy.value) return
|
||||
draftPolicy.value = { ...draftPolicy.value, fallbacks }
|
||||
}
|
||||
|
||||
/** 切换分类前的缺失事实补充策略,空值不会覆盖当前草稿。 */
|
||||
function updateEnrichmentMode(mode: ClassificationEnrichmentMode | null): void {
|
||||
if (!draftPolicy.value || !mode) return
|
||||
draftPolicy.value = { ...draftPolicy.value, enrichment_mode: mode }
|
||||
}
|
||||
|
||||
/** 使用规则编辑器返回的优先级顺序替换草稿规则。 */
|
||||
function updateRules(rules: ClassificationRule[]): void {
|
||||
if (!draftPolicy.value) return
|
||||
draftPolicy.value = { ...draftPolicy.value, rules }
|
||||
}
|
||||
|
||||
/** 更新单个来源和媒体类型的稳定兜底引用,空值会清理无用来源节点。 */
|
||||
function updateSourceFallback(source: string, mediaType: ClassificationMediaType, categoryId: string | null): void {
|
||||
if (!draftPolicy.value) return
|
||||
const sourceFallbacks = Object.fromEntries(
|
||||
Object.entries(draftPolicy.value.source_fallbacks).map(([sourceId, values]) => [sourceId, { ...values }]),
|
||||
)
|
||||
const sourceValues = { ...(sourceFallbacks[source] ?? {}) }
|
||||
if (categoryId) sourceValues[mediaType] = categoryId
|
||||
else delete sourceValues[mediaType]
|
||||
if (Object.keys(sourceValues).length) sourceFallbacks[source] = sourceValues
|
||||
else delete sourceFallbacks[source]
|
||||
draftPolicy.value = { ...draftPolicy.value, source_fallbacks: sourceFallbacks }
|
||||
}
|
||||
|
||||
/** 通过服务端真实字段目录校验当前草稿,并保留结构化问题供页面展示。 */
|
||||
async function validateCurrentDraft(): Promise<void> {
|
||||
if (!draftPolicy.value) return
|
||||
const requestedPolicy = cloneDeep(draftPolicy.value)
|
||||
try {
|
||||
const result = await validateDraft(requestedPolicy)
|
||||
validatedDraftSnapshot.value = result.valid ? requestedPolicy : null
|
||||
if (result.valid) toast.success(t('setting.classification.validationPassed'))
|
||||
else toast.error(t('setting.classification.validationFailed', { count: result.issues.length }))
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
validatedDraftSnapshot.value = null
|
||||
toast.error(t('setting.classification.validationRequestFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
/** 使用草稿或活动策略执行显式事实预览,并保留完整命中解释。 */
|
||||
async function previewFacts(request: ClassificationPreviewRequestEvent): Promise<void> {
|
||||
try {
|
||||
await preview(request.input, { policy: request.policyMode === 'active' ? null : undefined })
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
toast.error(t('setting.classification.previewFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
/** 分析当前草稿对有界近期历史样本的影响,并冻结本次分析对应的草稿。 */
|
||||
async function analyzeCurrentDraft(options: ClassificationImpactRequestEvent = lastImpactOptions.value): Promise<void> {
|
||||
if (!draftPolicy.value) return
|
||||
lastImpactOptions.value = { ...options }
|
||||
const requestedPolicy = cloneDeep(draftPolicy.value)
|
||||
try {
|
||||
await analyzeImpact({
|
||||
policy: requestedPolicy,
|
||||
sampleLimit: options.sampleLimit,
|
||||
exampleLimit: options.exampleLimit,
|
||||
})
|
||||
analyzedDraftSnapshot.value = requestedPolicy
|
||||
analysisTab.value = 'impact'
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
analyzedDraftSnapshot.value = null
|
||||
toast.error(t('setting.classification.impactFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
/** 发布已经通过当前校验、影响分析和人工审阅门禁的草稿。 */
|
||||
async function publishCurrentDraft(): Promise<void> {
|
||||
if (!validationIsCurrent.value || !impactIsCurrent.value) return
|
||||
try {
|
||||
const policy = await publishDraft()
|
||||
validatedDraftSnapshot.value = null
|
||||
analyzedDraftSnapshot.value = null
|
||||
await loadHistory()
|
||||
toast.success(t('setting.classification.publishSucceeded', { revision: policy.revision }))
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
toast.error(t('setting.classification.publishFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
/** 放弃冲突中的本地草稿,重新加载并使用服务端当前活动策略。 */
|
||||
async function reloadRemotePolicy(): Promise<void> {
|
||||
try {
|
||||
await refreshPolicy()
|
||||
resetDraft()
|
||||
validatedDraftSnapshot.value = null
|
||||
analyzedDraftSnapshot.value = null
|
||||
await loadHistory()
|
||||
toast.info(t('setting.classification.remoteReloaded'))
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
toast.error(t('setting.classification.remoteReloadFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
/** 保留本地草稿,先刷新活动 revision,再顺序执行最新影响分析。 */
|
||||
async function keepDraftAndReanalyze(): Promise<void> {
|
||||
try {
|
||||
await refreshPolicy()
|
||||
validatedDraftSnapshot.value = null
|
||||
await analyzeCurrentDraft(lastImpactOptions.value)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
/** 按需读取有界策略历史。 */
|
||||
async function loadPolicyHistory(): Promise<void> {
|
||||
try {
|
||||
await loadHistory()
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
toast.error(t('setting.classification.historyFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
/** 将历史内容通过 CAS 发布为新 revision,并刷新历史列表。 */
|
||||
async function rollbackPolicy(revision: number): Promise<void> {
|
||||
try {
|
||||
const result = await rollback(revision)
|
||||
validatedDraftSnapshot.value = null
|
||||
analyzedDraftSnapshot.value = null
|
||||
await loadHistory()
|
||||
toast.success(
|
||||
t('setting.classification.rollbackSucceeded', {
|
||||
source: result.restored_from_revision,
|
||||
revision: result.policy.revision,
|
||||
}),
|
||||
)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
toast.error(t('setting.classification.rollbackFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
/** 放弃全部未保存编辑并恢复当前活动 revision。 */
|
||||
function discardDraft(): void {
|
||||
resetDraft()
|
||||
validatedDraftSnapshot.value = null
|
||||
analyzedDraftSnapshot.value = null
|
||||
toast.info(t('setting.classification.draftReset'))
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.active,
|
||||
active => {
|
||||
if (active) void ensureInitialized()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(analysisTab, tab => {
|
||||
if (tab === 'publish' && initialized.value && !history.value && !loadingHistory.value) {
|
||||
void loadPolicyHistory()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VCard class="classification-settings">
|
||||
<VCardItem>
|
||||
<template #prepend>
|
||||
<VAvatar color="primary" variant="tonal" size="40">
|
||||
<VIcon icon="mdi-file-tree" />
|
||||
</VAvatar>
|
||||
</template>
|
||||
<VCardTitle>{{ t('setting.classification.title') }}</VCardTitle>
|
||||
<VCardSubtitle>{{ t('setting.classification.description') }}</VCardSubtitle>
|
||||
<template #append>
|
||||
<div class="classification-settings__status">
|
||||
<VChip size="small" variant="tonal" prepend-icon="mdi-source-branch">
|
||||
{{ t('setting.classification.revision', { revision: activeRevision }) }}
|
||||
</VChip>
|
||||
<VChip v-if="isDirty" size="small" color="warning" variant="tonal">
|
||||
{{ t('setting.classification.unsaved') }}
|
||||
</VChip>
|
||||
</div>
|
||||
</template>
|
||||
</VCardItem>
|
||||
|
||||
<VDivider />
|
||||
|
||||
<VCardText v-if="loadError">
|
||||
<VAlert type="error" variant="tonal" :title="t('setting.classification.loadFailed')">
|
||||
<template #append>
|
||||
<VBtn variant="text" prepend-icon="mdi-refresh" @click="ensureInitialized(true)">
|
||||
{{ t('common.retry') }}
|
||||
</VBtn>
|
||||
</template>
|
||||
</VAlert>
|
||||
</VCardText>
|
||||
|
||||
<VCardText v-else-if="initializing || loadingPolicy || loadingFields || !draftPolicy || !fieldCatalog">
|
||||
<div class="classification-settings__loading" role="status" :aria-label="t('common.loading')">
|
||||
<VProgressCircular color="primary" indeterminate />
|
||||
<span>{{ t('setting.classification.loading') }}</span>
|
||||
</div>
|
||||
</VCardText>
|
||||
|
||||
<template v-else>
|
||||
<VCardText class="classification-settings__workspace">
|
||||
<VAlert
|
||||
v-if="directoryReferencesUnavailable"
|
||||
type="warning"
|
||||
variant="tonal"
|
||||
density="compact"
|
||||
class="mb-4"
|
||||
:title="t('setting.classification.directoryReferencesUnavailable')"
|
||||
>
|
||||
{{ t('setting.classification.directoryReferencesUnavailableHint') }}
|
||||
</VAlert>
|
||||
|
||||
<section class="classification-settings__enrichment" aria-labelledby="classification-enrichment-title">
|
||||
<div class="classification-settings__section-heading">
|
||||
<div>
|
||||
<h3 id="classification-enrichment-title">{{ t('setting.classification.enrichmentTitle') }}</h3>
|
||||
<p>{{ t('setting.classification.enrichmentHint') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="classification-settings__enrichment-control">
|
||||
<span id="classification-enrichment-mode-label">{{ t('setting.classification.enrichmentModeLabel') }}</span>
|
||||
<VBtnToggle
|
||||
:model-value="draftPolicy.enrichment_mode"
|
||||
mandatory
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
aria-labelledby="classification-enrichment-mode-label"
|
||||
@update:model-value="updateEnrichmentMode"
|
||||
>
|
||||
<VBtn value="primary_only">{{ t('setting.classification.enrichmentPrimaryOnly') }}</VBtn>
|
||||
<VBtn value="enrich_missing">{{ t('setting.classification.enrichmentMissing') }}</VBtn>
|
||||
</VBtnToggle>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<VRow align="start">
|
||||
<VCol cols="12" lg="4">
|
||||
<ClassificationCategoryEditor
|
||||
:categories="draftPolicy.categories"
|
||||
:fallbacks="draftPolicy.fallbacks"
|
||||
:referenced-category-ids="referencedCategoryIds"
|
||||
:directory-references="directoryCategoryReferences"
|
||||
:max-depth="fieldCatalog.limits.max_category_depth"
|
||||
@update:categories="updateCategories"
|
||||
@update:fallbacks="updateFallbacks"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" lg="8">
|
||||
<ClassificationRuleEditor
|
||||
:rules="draftPolicy.rules"
|
||||
:categories="draftPolicy.categories"
|
||||
:fields="editorFields"
|
||||
:max-rules="fieldCatalog.limits.max_rules"
|
||||
:max-condition-depth="fieldCatalog.limits.max_condition_depth"
|
||||
@update:rules="updateRules"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
|
||||
<section v-if="availableSources.length" class="classification-settings__source-fallbacks">
|
||||
<div class="classification-settings__section-heading">
|
||||
<div>
|
||||
<h3>{{ t('setting.classification.sourceFallbacks') }}</h3>
|
||||
<p>{{ t('setting.classification.sourceFallbacksHint') }}</p>
|
||||
</div>
|
||||
<VChip size="small" variant="tonal">{{ availableSources.length }}</VChip>
|
||||
</div>
|
||||
<div class="classification-settings__source-table">
|
||||
<VTable density="compact">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ t('setting.classification.source') }}</th>
|
||||
<th v-for="mediaType in mediaTypes" :key="mediaType">{{ mediaType }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="source in availableSources" :key="source">
|
||||
<td>
|
||||
<code>{{ source }}</code>
|
||||
</td>
|
||||
<td v-for="mediaType in mediaTypes" :key="mediaType">
|
||||
<VSelect
|
||||
:model-value="draftPolicy.source_fallbacks[source]?.[mediaType] ?? null"
|
||||
:items="fallbackCategoryOptions(mediaType)"
|
||||
:aria-label="t('setting.classification.sourceFallbackFor', { source, mediaType })"
|
||||
:menu-props="sourceFallbackMenuProps(source, mediaType)"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
hide-details
|
||||
clearable
|
||||
@update:model-value="updateSourceFallback(source, mediaType, $event)"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</VTable>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="classification-settings__analysis" aria-labelledby="classification-analysis-title">
|
||||
<div class="classification-settings__section-heading">
|
||||
<div>
|
||||
<h3 id="classification-analysis-title">{{ t('setting.classification.analysisTitle') }}</h3>
|
||||
<p>{{ t('setting.classification.analysisHint') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<VTabs v-model="analysisTab" color="primary" show-arrows>
|
||||
<VTab value="preview" prepend-icon="mdi-play-box-outline">
|
||||
{{ t('setting.classification.previewTab') }}
|
||||
</VTab>
|
||||
<VTab value="impact" prepend-icon="mdi-chart-box-outline">
|
||||
{{ t('setting.classification.impactTab') }}
|
||||
</VTab>
|
||||
<VTab value="publish" prepend-icon="mdi-source-branch-sync">
|
||||
{{ t('setting.classification.publishTab') }}
|
||||
</VTab>
|
||||
</VTabs>
|
||||
|
||||
<VWindow v-model="analysisTab" class="classification-settings__analysis-window">
|
||||
<VWindowItem value="preview">
|
||||
<ClassificationPreviewPanel
|
||||
:fields="editorFields"
|
||||
:categories="draftPolicy.categories"
|
||||
:result="previewResultSnapshot"
|
||||
:loading="previewing"
|
||||
@request-preview="previewFacts"
|
||||
/>
|
||||
</VWindowItem>
|
||||
<VWindowItem value="impact">
|
||||
<ClassificationImpactPanel
|
||||
:analysis="impactResultSnapshot"
|
||||
:loading="analyzingImpact"
|
||||
:disabled="publishing || rollingBack"
|
||||
@analyze="analyzeCurrentDraft"
|
||||
/>
|
||||
</VWindowItem>
|
||||
<VWindowItem value="publish">
|
||||
<ClassificationPolicyControlPanel
|
||||
:active-revision="activeRevision"
|
||||
:is-dirty="isDirty"
|
||||
:validation-result="validationResultSnapshot"
|
||||
:validation-is-current="validationIsCurrent"
|
||||
:impact-result="impactResultSnapshot"
|
||||
:impact-is-current="impactIsCurrent"
|
||||
:conflict="conflict"
|
||||
:history="historySnapshot"
|
||||
:validating="validating"
|
||||
:publishing="publishing"
|
||||
:refreshing="loadingPolicy"
|
||||
:loading-history="loadingHistory"
|
||||
:rolling-back="rollingBack"
|
||||
:analyzing-impact="analyzingImpact"
|
||||
@validate="validateCurrentDraft"
|
||||
@analyze="analyzeCurrentDraft()"
|
||||
@publish="publishCurrentDraft"
|
||||
@refresh="reloadRemotePolicy"
|
||||
@keep-draft="keepDraftAndReanalyze"
|
||||
@load-history="loadPolicyHistory"
|
||||
@rollback="rollbackPolicy"
|
||||
/>
|
||||
</VWindowItem>
|
||||
</VWindow>
|
||||
</section>
|
||||
</VCardText>
|
||||
|
||||
<VCardText v-if="validationResult?.issues.length" class="pt-0">
|
||||
<VAlert
|
||||
:type="validationResult.valid ? 'warning' : 'error'"
|
||||
variant="tonal"
|
||||
:title="t('setting.classification.validationIssues', { count: validationResult.issues.length })"
|
||||
>
|
||||
<ul class="classification-settings__issues">
|
||||
<li v-for="(issue, index) in validationResult.issues" :key="`${issue.code}-${index}`">
|
||||
<strong>{{ issue.code }}</strong>
|
||||
<span>{{ issue.message }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</VAlert>
|
||||
</VCardText>
|
||||
|
||||
<VDivider />
|
||||
<VCardActions class="classification-settings__actions">
|
||||
<VBtn variant="text" prepend-icon="mdi-undo-variant" :disabled="!isDirty || validating" @click="discardDraft">
|
||||
{{ t('setting.classification.discardDraft') }}
|
||||
</VBtn>
|
||||
<VSpacer />
|
||||
<VBtn
|
||||
color="primary"
|
||||
variant="tonal"
|
||||
prepend-icon="mdi-check-decagram-outline"
|
||||
:loading="validating"
|
||||
@click="validateCurrentDraft"
|
||||
>
|
||||
{{ t('setting.classification.validateDraft') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</template>
|
||||
</VCard>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.classification-settings {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.classification-settings__status {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.classification-settings__loading {
|
||||
display: flex;
|
||||
min-block-size: 18rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.75rem;
|
||||
color: rgba(var(--v-theme-on-surface), 0.68);
|
||||
}
|
||||
|
||||
.classification-settings__workspace {
|
||||
padding-block: 1.25rem;
|
||||
}
|
||||
|
||||
.classification-settings__enrichment {
|
||||
margin-block-end: 1.25rem;
|
||||
padding-block-end: 1.25rem;
|
||||
border-block-end: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||
}
|
||||
|
||||
.classification-settings__enrichment-control {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.classification-settings__enrichment-control > span {
|
||||
min-inline-size: 6rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.classification-settings__source-fallbacks {
|
||||
border-block-start: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||
margin-block-start: 1rem;
|
||||
padding-block-start: 1.25rem;
|
||||
}
|
||||
|
||||
.classification-settings__analysis {
|
||||
border-block-start: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||
margin-block-start: 1.5rem;
|
||||
padding-block-start: 1.25rem;
|
||||
}
|
||||
|
||||
.classification-settings__analysis-window {
|
||||
margin-block-start: 0.75rem;
|
||||
}
|
||||
|
||||
.classification-settings__analysis-window :deep(.v-window-item) {
|
||||
padding-block: 0.5rem;
|
||||
}
|
||||
|
||||
.classification-settings__section-heading {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
margin-block-end: 0.75rem;
|
||||
}
|
||||
|
||||
.classification-settings__section-heading h3,
|
||||
.classification-settings__section-heading p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.classification-settings__section-heading h3 {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.classification-settings__section-heading p {
|
||||
color: rgba(var(--v-theme-on-surface), 0.62);
|
||||
font-size: 0.8125rem;
|
||||
margin-block-start: 0.25rem;
|
||||
}
|
||||
|
||||
.classification-settings__source-table {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.classification-settings__source-table :deep(table) {
|
||||
min-inline-size: 48rem;
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
.classification-settings__source-table :deep(th:first-child),
|
||||
.classification-settings__source-table :deep(td:first-child) {
|
||||
inline-size: 13rem;
|
||||
}
|
||||
|
||||
.classification-settings__issues {
|
||||
display: grid;
|
||||
gap: 0.4rem;
|
||||
margin: 0.75rem 0 0;
|
||||
padding-inline-start: 1.25rem;
|
||||
}
|
||||
|
||||
.classification-settings__issues li {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.classification-settings__actions {
|
||||
min-block-size: 4rem;
|
||||
padding: 0.75rem 1.25rem;
|
||||
}
|
||||
|
||||
@media (max-width: 599px) {
|
||||
.classification-settings :deep(.v-card-item__append) {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.classification-settings__status {
|
||||
max-inline-size: 8rem;
|
||||
}
|
||||
|
||||
.classification-settings__enrichment-control :deep(.v-btn-toggle) {
|
||||
display: grid;
|
||||
inline-size: 100%;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.classification-settings__enrichment-control :deep(.v-btn) {
|
||||
min-inline-size: 0;
|
||||
}
|
||||
|
||||
.classification-settings__actions {
|
||||
align-items: stretch;
|
||||
flex-direction: column-reverse;
|
||||
}
|
||||
|
||||
.classification-settings__actions :deep(.v-spacer) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,19 +1,23 @@
|
||||
<!-- eslint-disable sonarjs/no-duplicate-string -->
|
||||
<script lang="ts" setup>
|
||||
import { useToast } from 'vue-toastification'
|
||||
import api from '@/api'
|
||||
import api, { getApiErrorMessage } from '@/api'
|
||||
import type { StorageConf, TransferDirectoryConf } from '@/api/types'
|
||||
import type { ClassificationCategory } from '@/api/mediaClassification'
|
||||
import DirectoryCard from '@/components/cards/DirectoryCard.vue'
|
||||
import StorageCard from '@/components/cards/StorageCard.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useTheme } from 'vuetify'
|
||||
import { storageAttributes } from '@/api/constants'
|
||||
import { useSilentSettingRefresh } from '@/composables/useSilentSettingRefresh'
|
||||
import { openSharedDialog } from '@/composables/useSharedDialog'
|
||||
import { configureAceEditorPadding } from '@/utils/aceEditor'
|
||||
import { useMediaClassification } from '@/composables/useMediaClassification'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
const { t } = useI18n()
|
||||
const { global: globalTheme } = useTheme()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const props = defineProps({
|
||||
active: {
|
||||
@@ -22,9 +26,8 @@ const props = defineProps({
|
||||
},
|
||||
})
|
||||
|
||||
// 拖拽排序和分类编辑弹窗按需加载,避免设置框架预加载目录页时带上这些交互依赖。
|
||||
// 拖拽排序按需加载,避免设置框架预加载目录页时带上交互依赖。
|
||||
const Draggable = defineAsyncComponent(() => import('vuedraggable').then(module => module.default))
|
||||
const CategoryEditDialog = defineAsyncComponent(() => import('@/components/dialog/CategoryEditDialog.vue'))
|
||||
|
||||
// 所有下载目录
|
||||
const directories = ref<TransferDirectoryConf[]>([])
|
||||
@@ -32,8 +35,18 @@ const directories = ref<TransferDirectoryConf[]>([])
|
||||
// 所有存储
|
||||
const storages = ref<StorageConf[]>([])
|
||||
|
||||
// 二级分类策略
|
||||
const mediaCategories = ref<{ [key: string]: any }>({})
|
||||
const { activePolicy, refreshPolicy } = useMediaClassification()
|
||||
|
||||
// 目录卡片只消费活动策略中的稳定分类定义。
|
||||
const mediaCategories = computed<ClassificationCategory[]>(() =>
|
||||
(activePolicy.value?.categories ?? []).map(category => ({
|
||||
...category,
|
||||
path: [...category.path],
|
||||
labels: [...category.labels],
|
||||
})),
|
||||
)
|
||||
const classificationLoadError = ref<string | null>(null)
|
||||
const directorySaveError = ref<string | null>(null)
|
||||
|
||||
// 提示框
|
||||
const $toast = useToast()
|
||||
@@ -85,16 +98,9 @@ const renameEditorOptions = {
|
||||
showGutter: true,
|
||||
}
|
||||
|
||||
// 打开共享分类编辑弹窗,保存后刷新本页分类配置。
|
||||
function openCategoryDialog() {
|
||||
openSharedDialog(
|
||||
CategoryEditDialog,
|
||||
{},
|
||||
{
|
||||
save: loadMediaCategories,
|
||||
},
|
||||
{ closeOn: ['close', 'save', 'update:modelValue'] },
|
||||
)
|
||||
/** 切换到统一自动分类设置页,并保留当前路由的其它查询参数。 */
|
||||
function openClassificationSettings(): void {
|
||||
void router.push({ query: { ...route.query, tab: 'classification' } })
|
||||
}
|
||||
|
||||
const movieRenameFormat = computed({
|
||||
@@ -175,29 +181,59 @@ async function saveStorages() {
|
||||
}
|
||||
|
||||
// 查询目录
|
||||
async function loadDirectories() {
|
||||
async function loadDirectories(options: { rethrow?: boolean } = {}) {
|
||||
try {
|
||||
const result = await api.get<{ value?: TransferDirectoryConf[] }>('system/setting/public/Directories')
|
||||
directories.value = result.value ?? []
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
if (options.rethrow) throw error
|
||||
}
|
||||
}
|
||||
|
||||
/** 判断目录中的稳定分类引用是否必须在保存前修复。 */
|
||||
function invalidDirectoryCategory(directory: TransferDirectoryConf): boolean {
|
||||
const categoryId = directory.media_category_id?.trim()
|
||||
if (!categoryId) {
|
||||
const legacyPath = directory.media_category?.trim()
|
||||
if (!legacyPath) return false
|
||||
if (!directory.media_type) return true
|
||||
return (
|
||||
mediaCategories.value.filter(
|
||||
category =>
|
||||
category.enabled && category.media_type === directory.media_type && category.path.join('/') === legacyPath,
|
||||
).length !== 1
|
||||
)
|
||||
}
|
||||
const category = mediaCategories.value.find(item => item.id === categoryId)
|
||||
return !category || !category.enabled || !directory.media_type || category.media_type !== directory.media_type
|
||||
}
|
||||
|
||||
// 保存目录
|
||||
async function saveDirectories() {
|
||||
orderDirectoryCards()
|
||||
directorySaveError.value = null
|
||||
try {
|
||||
const names = directories.value.map(item => item.name)
|
||||
if (new Set(names).size !== names.length) {
|
||||
$toast.error(t('setting.directory.duplicateDirectoryName'))
|
||||
return
|
||||
}
|
||||
if (directories.value.some(invalidDirectoryCategory)) {
|
||||
const message = t('setting.directory.classification.saveBlocked')
|
||||
directorySaveError.value = message
|
||||
$toast.error(message)
|
||||
return
|
||||
}
|
||||
await api.post('system/setting/Directories', directories.value, { feedback: 'silent' })
|
||||
// 服务端负责把稳定 ID 解析为当前规范路径,成功后必须以回读快照替换本地草稿。
|
||||
await loadDirectories({ rethrow: true })
|
||||
$toast.success(t('setting.directory.directorySaveSuccess'))
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
$toast.error(t('setting.directory.directorySaveFailed'))
|
||||
const message = getApiErrorMessage(error) || t('setting.directory.directorySaveFailed')
|
||||
directorySaveError.value = message
|
||||
$toast.error(message)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -217,6 +253,7 @@ function addDirectory() {
|
||||
monitor_type: '',
|
||||
media_type: '',
|
||||
media_category: '',
|
||||
media_category_id: null,
|
||||
transfer_type: '',
|
||||
})
|
||||
orderDirectoryCards()
|
||||
@@ -232,10 +269,12 @@ function removeDirectory(directory: TransferDirectoryConf) {
|
||||
|
||||
// 调用API查询自动分类配置
|
||||
async function loadMediaCategories() {
|
||||
classificationLoadError.value = null
|
||||
try {
|
||||
mediaCategories.value = await api.get('media/category')
|
||||
await refreshPolicy()
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
classificationLoadError.value = getApiErrorMessage(error) || t('setting.directory.classification.loadFailed')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -366,6 +405,24 @@ useSilentSettingRefresh(loadPageData, {
|
||||
<VCardSubtitle>{{ t('setting.directory.directoryDesc') }}</VCardSubtitle>
|
||||
</VCardItem>
|
||||
<VCardText>
|
||||
<VAlert
|
||||
v-if="classificationLoadError"
|
||||
type="error"
|
||||
variant="tonal"
|
||||
class="mb-4"
|
||||
data-testid="directory-classification-load-error"
|
||||
>
|
||||
{{ classificationLoadError }}
|
||||
</VAlert>
|
||||
<VAlert
|
||||
v-if="directorySaveError"
|
||||
type="error"
|
||||
variant="tonal"
|
||||
class="mb-4"
|
||||
data-testid="directory-save-error"
|
||||
>
|
||||
{{ directorySaveError }}
|
||||
</VAlert>
|
||||
<Draggable
|
||||
v-model="directories"
|
||||
handle=".cursor-move"
|
||||
@@ -400,8 +457,8 @@ useSilentSettingRefresh(loadPageData, {
|
||||
<VIcon icon="mdi-plus" />
|
||||
</VBtn>
|
||||
<VSpacer />
|
||||
<VBtn color="info" variant="tonal" prepend-icon="mdi-shape-plus" @click="openCategoryDialog">
|
||||
{{ t('setting.category.title') }}
|
||||
<VBtn color="info" variant="tonal" prepend-icon="mdi-file-tree" @click="openClassificationSettings">
|
||||
{{ t('settingTabs.classification.title') }}
|
||||
</VBtn>
|
||||
</div>
|
||||
</VForm>
|
||||
|
||||
@@ -0,0 +1,497 @@
|
||||
import type { ClassificationImpactAnalysis, ClassificationPolicy } from '@/api/mediaClassificationTypes'
|
||||
import AccountSettingClassification from '@/views/setting/AccountSettingClassification.vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { screen, waitFor } from '@testing-library/vue'
|
||||
import { renderWithProviders } from '@tests/support/render'
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
apiGet: vi.fn(),
|
||||
analyzeImpact: vi.fn(),
|
||||
initialize: vi.fn(),
|
||||
loadHistory: vi.fn(),
|
||||
preview: vi.fn(),
|
||||
publishDraft: vi.fn(),
|
||||
refreshPolicy: vi.fn(),
|
||||
resetDraft: vi.fn(),
|
||||
rollback: vi.fn(),
|
||||
toastError: vi.fn(),
|
||||
toastInfo: vi.fn(),
|
||||
toastSuccess: vi.fn(),
|
||||
useMediaClassification: vi.fn(),
|
||||
validateDraft: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api', () => ({
|
||||
default: { get: mocks.apiGet },
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useMediaClassification', () => ({
|
||||
useMediaClassification: mocks.useMediaClassification,
|
||||
}))
|
||||
|
||||
vi.mock('vue-toastification', () => ({
|
||||
useToast: () => ({ error: mocks.toastError, info: mocks.toastInfo, success: mocks.toastSuccess }),
|
||||
}))
|
||||
|
||||
vi.mock('@/components/classification/ClassificationCategoryEditor.vue', async () => {
|
||||
const { defineComponent } = await import('vue')
|
||||
return {
|
||||
default: defineComponent({
|
||||
name: 'ClassificationCategoryEditorStub',
|
||||
props: {
|
||||
categories: { type: Array, required: true },
|
||||
fallbacks: { type: Object, required: true },
|
||||
referencedCategoryIds: { type: Array, default: () => [] },
|
||||
directoryReferences: { type: Array, default: () => [] },
|
||||
},
|
||||
emits: ['update:categories', 'update:fallbacks'],
|
||||
template: `
|
||||
<section aria-label="category-editor">
|
||||
<output aria-label="category-references">{{ referencedCategoryIds.join(',') }}</output>
|
||||
<output aria-label="directory-references">{{ JSON.stringify(directoryReferences) }}</output>
|
||||
<button aria-label="replace-categories" @click="$emit('update:categories', [{ ...categories[0], name: '新电影' }])">categories</button>
|
||||
<button aria-label="replace-fallbacks" @click="$emit('update:fallbacks', { ...fallbacks, 电影: 'movie.new' })">fallbacks</button>
|
||||
</section>
|
||||
`,
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/components/classification/ClassificationRuleEditor.vue', async () => {
|
||||
const { defineComponent } = await import('vue')
|
||||
return {
|
||||
default: defineComponent({
|
||||
name: 'ClassificationRuleEditorStub',
|
||||
props: { rules: { type: Array, required: true } },
|
||||
emits: ['update:rules'],
|
||||
template: `
|
||||
<section aria-label="rule-editor">
|
||||
<button aria-label="replace-rules" @click="$emit('update:rules', [{ ...rules[0], name: '新规则' }])">rules</button>
|
||||
</section>
|
||||
`,
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/components/classification/ClassificationPreviewPanel.vue', async () => {
|
||||
const { defineComponent } = await import('vue')
|
||||
return {
|
||||
default: defineComponent({
|
||||
name: 'ClassificationPreviewPanelStub',
|
||||
emits: ['request-preview'],
|
||||
template: `
|
||||
<section aria-label="preview-panel">
|
||||
<button
|
||||
aria-label="request-active-preview"
|
||||
@click="$emit('request-preview', {
|
||||
input: {
|
||||
kind: 'facts',
|
||||
facts: {
|
||||
identity: { media_source: 'themoviedb', media_id: '550' },
|
||||
media: { type: '电影' },
|
||||
extensions: {},
|
||||
field_sources: {},
|
||||
},
|
||||
},
|
||||
policyMode: 'active',
|
||||
})"
|
||||
>preview</button>
|
||||
</section>
|
||||
`,
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/components/classification/ClassificationImpactPanel.vue', async () => {
|
||||
const { defineComponent } = await import('vue')
|
||||
return {
|
||||
default: defineComponent({
|
||||
name: 'ClassificationImpactPanelStub',
|
||||
emits: ['analyze'],
|
||||
template: `
|
||||
<section aria-label="impact-panel">
|
||||
<button aria-label="request-impact" @click="$emit('analyze', { sampleLimit: 30, exampleLimit: 5 })">
|
||||
impact
|
||||
</button>
|
||||
</section>
|
||||
`,
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/components/classification/ClassificationPolicyControlPanel.vue', async () => {
|
||||
const { defineComponent } = await import('vue')
|
||||
return {
|
||||
default: defineComponent({
|
||||
name: 'ClassificationPolicyControlPanelStub',
|
||||
props: {
|
||||
validationIsCurrent: Boolean,
|
||||
impactIsCurrent: Boolean,
|
||||
},
|
||||
emits: ['validate', 'analyze', 'publish', 'refresh', 'keep-draft', 'load-history', 'rollback'],
|
||||
template: `
|
||||
<section aria-label="policy-control-panel">
|
||||
<output aria-label="validation-current">{{ validationIsCurrent }}</output>
|
||||
<output aria-label="impact-current">{{ impactIsCurrent }}</output>
|
||||
<button aria-label="control-validate" @click="$emit('validate')">validate</button>
|
||||
<button aria-label="control-analyze" @click="$emit('analyze')">analyze</button>
|
||||
<button aria-label="control-publish" @click="$emit('publish')">publish</button>
|
||||
<button aria-label="control-refresh" @click="$emit('refresh')">refresh</button>
|
||||
<button aria-label="control-keep-draft" @click="$emit('keep-draft')">keep</button>
|
||||
<button aria-label="control-load-history" @click="$emit('load-history')">history</button>
|
||||
<button aria-label="control-rollback" @click="$emit('rollback', 3)">rollback</button>
|
||||
</section>
|
||||
`,
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
function createPolicy(): ClassificationPolicy {
|
||||
return {
|
||||
schema_version: 2,
|
||||
revision: 7,
|
||||
mode: 'first_match',
|
||||
enrichment_mode: 'primary_only',
|
||||
categories: [{ id: 'movie.base', media_type: '电影', name: '电影', path: ['电影'], enabled: true, labels: [] }],
|
||||
rules: [
|
||||
{
|
||||
id: 'rule.movie',
|
||||
name: '电影规则',
|
||||
kind: 'category',
|
||||
enabled: true,
|
||||
priority: 0,
|
||||
media_types: ['电影'],
|
||||
sources: [],
|
||||
when: { field: 'media.type', operator: 'equals', value: '电影' },
|
||||
target: { category_id: 'movie.base', labels: [] },
|
||||
},
|
||||
],
|
||||
fallbacks: { 电影: 'movie.base' },
|
||||
source_fallbacks: { themoviedb: { 电影: 'movie.base' } },
|
||||
field_aliases: {},
|
||||
}
|
||||
}
|
||||
|
||||
/** 构造与活动 revision 对齐的有界影响分析结果。 */
|
||||
function createImpact(): ClassificationImpactAnalysis {
|
||||
return {
|
||||
estimated: true,
|
||||
sampled_at: '2026-09-02T00:00:00Z',
|
||||
sample_source: 'recent_history',
|
||||
baseline_revision: 7,
|
||||
candidate_revision: 8,
|
||||
requested_limit: 30,
|
||||
scanned_count: 10,
|
||||
skipped_count: 0,
|
||||
truncated: false,
|
||||
sample_count: 10,
|
||||
changed_count: 1,
|
||||
unchanged_count: 9,
|
||||
category_changed_count: 1,
|
||||
path_only_changed_count: 0,
|
||||
rule_changed_only_count: 0,
|
||||
became_fallback_count: 0,
|
||||
partial_count: 0,
|
||||
degraded_count: 0,
|
||||
previous_categories: { 'movie.base': 10 },
|
||||
candidate_categories: { 'movie.base': 10 },
|
||||
groups: [],
|
||||
changes: [],
|
||||
warnings: [],
|
||||
}
|
||||
}
|
||||
|
||||
describe('AccountSettingClassification', () => {
|
||||
beforeEach(() => {
|
||||
mocks.apiGet.mockReset().mockResolvedValue({
|
||||
value: [
|
||||
{
|
||||
name: '电影目录',
|
||||
priority: 0,
|
||||
storage: 'local',
|
||||
transfer_type: 'copy',
|
||||
media_type: '电影',
|
||||
media_category_id: 'movie.base',
|
||||
media_category: '电影',
|
||||
},
|
||||
],
|
||||
})
|
||||
mocks.analyzeImpact.mockReset()
|
||||
mocks.initialize.mockReset().mockResolvedValue(undefined)
|
||||
mocks.loadHistory.mockReset().mockResolvedValue(undefined)
|
||||
mocks.preview.mockReset().mockResolvedValue(undefined)
|
||||
mocks.publishDraft.mockReset().mockResolvedValue(createPolicy())
|
||||
mocks.refreshPolicy.mockReset().mockResolvedValue(createPolicy())
|
||||
mocks.resetDraft.mockReset()
|
||||
mocks.rollback.mockReset().mockResolvedValue({ restored_from_revision: 3, policy: createPolicy() })
|
||||
mocks.toastError.mockReset()
|
||||
mocks.toastInfo.mockReset()
|
||||
mocks.toastSuccess.mockReset()
|
||||
mocks.validateDraft.mockReset()
|
||||
|
||||
const draftPolicy = ref(createPolicy())
|
||||
const activePolicy = ref(createPolicy())
|
||||
const validationResult = ref<{ valid: boolean; issues: never[] } | null>(null)
|
||||
const impactResult = ref<ClassificationImpactAnalysis | null>(null)
|
||||
mocks.validateDraft.mockImplementation(async () => {
|
||||
const result = { valid: true, issues: [] as never[] }
|
||||
validationResult.value = result
|
||||
return result
|
||||
})
|
||||
mocks.analyzeImpact.mockImplementation(async () => {
|
||||
const result = createImpact()
|
||||
impactResult.value = result
|
||||
return result
|
||||
})
|
||||
mocks.useMediaClassification.mockReturnValue({
|
||||
activeRevision: computed(() => activePolicy.value.revision),
|
||||
analyzingImpact: ref(false),
|
||||
conflict: ref(null),
|
||||
draftPolicy,
|
||||
fieldCatalog: ref({
|
||||
fields: [
|
||||
{
|
||||
id: 'media.type',
|
||||
label: '媒体类型',
|
||||
group: '媒体',
|
||||
value_type: 'enum',
|
||||
operators: ['equals'],
|
||||
media_types: ['电影', '电视剧', '音乐'],
|
||||
options: [],
|
||||
allow_custom_values: false,
|
||||
source_support: { themoviedb: 'native', musicbrainz: 'native' },
|
||||
},
|
||||
],
|
||||
limits: {
|
||||
max_category_depth: 4,
|
||||
max_category_segment_length: 64,
|
||||
max_category_path_length: 240,
|
||||
max_condition_depth: 3,
|
||||
max_conditions_per_rule: 30,
|
||||
max_rules: 1000,
|
||||
max_total_conditions: 30000,
|
||||
},
|
||||
}),
|
||||
history: ref(null),
|
||||
impactResult,
|
||||
isDirty: computed(() => JSON.stringify(draftPolicy.value) !== JSON.stringify(activePolicy.value)),
|
||||
loadingHistory: ref(false),
|
||||
loadingFields: ref(false),
|
||||
loadingPolicy: ref(false),
|
||||
previewResult: ref(null),
|
||||
previewing: ref(false),
|
||||
publishing: ref(false),
|
||||
rollingBack: ref(false),
|
||||
validationResult,
|
||||
validating: ref(false),
|
||||
analyzeImpact: mocks.analyzeImpact,
|
||||
initialize: mocks.initialize,
|
||||
loadHistory: mocks.loadHistory,
|
||||
preview: mocks.preview,
|
||||
publishDraft: mocks.publishDraft,
|
||||
refreshPolicy: mocks.refreshPolicy,
|
||||
resetDraft: mocks.resetDraft,
|
||||
rollback: mocks.rollback,
|
||||
validateDraft: mocks.validateDraft,
|
||||
})
|
||||
})
|
||||
|
||||
it('loads only when the settings tab becomes active', async () => {
|
||||
const { rerender } = await renderWithProviders(AccountSettingClassification, { props: { active: false } })
|
||||
|
||||
expect(mocks.initialize).not.toHaveBeenCalled()
|
||||
await rerender({ active: true })
|
||||
|
||||
await waitFor(() => expect(mocks.initialize).toHaveBeenCalledTimes(1))
|
||||
expect(await screen.findByRole('region', { name: 'category-editor' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('replaces category, fallback, and rule slices without losing the rest of the draft', async () => {
|
||||
const user = userEvent.setup()
|
||||
await renderWithProviders(AccountSettingClassification)
|
||||
await screen.findByRole('region', { name: 'category-editor' })
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'replace-categories' }))
|
||||
await user.click(screen.getByRole('button', { name: 'replace-fallbacks' }))
|
||||
await user.click(screen.getByRole('button', { name: 'replace-rules' }))
|
||||
|
||||
const state = mocks.useMediaClassification.mock.results[0].value
|
||||
expect(state.draftPolicy.value.categories[0].name).toBe('新电影')
|
||||
expect(state.draftPolicy.value.fallbacks.电影).toBe('movie.new')
|
||||
expect(state.draftPolicy.value.rules[0].name).toBe('新规则')
|
||||
expect(state.draftPolicy.value.source_fallbacks.themoviedb.电影).toBe('movie.base')
|
||||
expect(screen.getByLabelText('category-references')).toHaveTextContent('movie.base')
|
||||
expect(screen.getByLabelText('directory-references')).toHaveTextContent('movie.base')
|
||||
expect(screen.getByLabelText('directory-references')).toHaveTextContent('电影目录')
|
||||
})
|
||||
|
||||
it('switches missing-fact enrichment through the policy draft', async () => {
|
||||
const user = userEvent.setup()
|
||||
await renderWithProviders(AccountSettingClassification)
|
||||
await screen.findByRole('region', { name: 'category-editor' })
|
||||
|
||||
const state = mocks.useMediaClassification.mock.results[0].value
|
||||
expect(state.draftPolicy.value.enrichment_mode).toBe('primary_only')
|
||||
await user.click(screen.getByRole('button', { name: '补充缺失事实' }))
|
||||
|
||||
expect(state.draftPolicy.value.enrichment_mode).toBe('enrich_missing')
|
||||
expect(state.isDirty.value).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps policy editing available while warning when directory references cannot be loaded', async () => {
|
||||
mocks.apiGet.mockRejectedValueOnce(new Error('directory unavailable'))
|
||||
await renderWithProviders(AccountSettingClassification)
|
||||
|
||||
expect(await screen.findByText('目录引用加载失败')).toBeInTheDocument()
|
||||
expect(screen.getByRole('region', { name: 'category-editor' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('validates the draft and exposes discard as a separate action', async () => {
|
||||
const user = userEvent.setup()
|
||||
await renderWithProviders(AccountSettingClassification)
|
||||
await screen.findByRole('region', { name: 'rule-editor' })
|
||||
await user.click(screen.getByRole('button', { name: 'replace-rules' }))
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '校验草稿' }))
|
||||
expect(mocks.validateDraft).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.toastSuccess).toHaveBeenCalledWith('草稿校验通过')
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '放弃修改' }))
|
||||
expect(mocks.resetDraft).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.toastInfo).toHaveBeenCalledWith('已恢复当前活动策略')
|
||||
})
|
||||
|
||||
it('updates source fallbacks through stable category IDs', async () => {
|
||||
const user = userEvent.setup()
|
||||
await renderWithProviders(AccountSettingClassification)
|
||||
await screen.findByRole('region', { name: 'category-editor' })
|
||||
|
||||
const musicbrainzFallback = screen.getByRole('combobox', {
|
||||
name: 'musicbrainz 的电影来源兜底',
|
||||
})
|
||||
await user.click(musicbrainzFallback)
|
||||
await user.click(await screen.findByRole('option', { name: '电影 · 电影 · movie.base' }))
|
||||
|
||||
const state = mocks.useMediaClassification.mock.results[0].value
|
||||
expect(state.draftPolicy.value.source_fallbacks.musicbrainz.电影).toBe('movie.base')
|
||||
})
|
||||
|
||||
it('maps fact preview modes and bounded impact options to the composable', async () => {
|
||||
const user = userEvent.setup()
|
||||
await renderWithProviders(AccountSettingClassification)
|
||||
await screen.findByRole('region', { name: 'preview-panel' })
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'request-active-preview' }))
|
||||
expect(mocks.preview).toHaveBeenCalledWith(
|
||||
{
|
||||
kind: 'facts',
|
||||
facts: {
|
||||
identity: { media_source: 'themoviedb', media_id: '550' },
|
||||
media: { type: '电影' },
|
||||
extensions: {},
|
||||
field_sources: {},
|
||||
},
|
||||
},
|
||||
{ policy: null },
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('tab', { name: '影响分析' }))
|
||||
await user.click(await screen.findByRole('button', { name: 'request-impact' }))
|
||||
expect(mocks.analyzeImpact).toHaveBeenCalledWith({
|
||||
policy: createPolicy(),
|
||||
sampleLimit: 30,
|
||||
exampleLimit: 5,
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps validation and impact stale when the draft changes while requests are in flight', async () => {
|
||||
const user = userEvent.setup()
|
||||
await renderWithProviders(AccountSettingClassification)
|
||||
await screen.findByRole('region', { name: 'rule-editor' })
|
||||
await user.click(screen.getByRole('button', { name: 'replace-rules' }))
|
||||
await user.click(screen.getByRole('tab', { name: '发布与历史' }))
|
||||
await screen.findByRole('region', { name: 'policy-control-panel' })
|
||||
|
||||
const state = mocks.useMediaClassification.mock.results[0].value
|
||||
const validation = { valid: true, issues: [] as never[] }
|
||||
let resolveValidation!: () => void
|
||||
mocks.validateDraft.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise(resolve => {
|
||||
resolveValidation = () => {
|
||||
state.validationResult.value = validation
|
||||
resolve(validation)
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'control-validate' }))
|
||||
await waitFor(() => expect(mocks.validateDraft).toHaveBeenCalledTimes(1))
|
||||
state.draftPolicy.value.rules[0].name = '校验请求后的编辑'
|
||||
await nextTick()
|
||||
resolveValidation()
|
||||
await waitFor(() => expect(screen.getByLabelText('validation-current')).toHaveTextContent('false'))
|
||||
|
||||
const impact = createImpact()
|
||||
let resolveImpact!: () => void
|
||||
mocks.analyzeImpact.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise(resolve => {
|
||||
resolveImpact = () => {
|
||||
state.impactResult.value = impact
|
||||
resolve(impact)
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'control-analyze' }))
|
||||
await waitFor(() => expect(mocks.analyzeImpact).toHaveBeenCalledTimes(1))
|
||||
state.draftPolicy.value.rules[0].name = '影响请求后的编辑'
|
||||
await nextTick()
|
||||
resolveImpact()
|
||||
await waitFor(() => expect(screen.getByRole('tab', { name: '影响分析' })).toHaveAttribute('aria-selected', 'true'))
|
||||
await user.click(screen.getByRole('tab', { name: '发布与历史' }))
|
||||
await waitFor(() => expect(screen.getByLabelText('impact-current')).toHaveTextContent('false'))
|
||||
})
|
||||
|
||||
it('requires current validation and impact snapshots before publishing, then sequences conflict recovery and rollback', async () => {
|
||||
const user = userEvent.setup()
|
||||
await renderWithProviders(AccountSettingClassification)
|
||||
await screen.findByRole('region', { name: 'rule-editor' })
|
||||
await user.click(screen.getByRole('button', { name: 'replace-rules' }))
|
||||
|
||||
await user.click(screen.getByRole('tab', { name: '发布与历史' }))
|
||||
await screen.findByRole('region', { name: 'policy-control-panel' })
|
||||
await waitFor(() => expect(mocks.loadHistory).toHaveBeenCalledTimes(1))
|
||||
|
||||
expect(screen.getByLabelText('validation-current')).toHaveTextContent('false')
|
||||
expect(screen.getByLabelText('impact-current')).toHaveTextContent('false')
|
||||
await user.click(screen.getByRole('button', { name: 'control-validate' }))
|
||||
await user.click(screen.getByRole('button', { name: 'control-analyze' }))
|
||||
await waitFor(() => expect(screen.getByRole('tab', { name: '影响分析' })).toHaveAttribute('aria-selected', 'true'))
|
||||
await user.click(screen.getByRole('tab', { name: '发布与历史' }))
|
||||
await waitFor(() => expect(screen.getByLabelText('validation-current')).toHaveTextContent('true'))
|
||||
await waitFor(() => expect(screen.getByLabelText('impact-current')).toHaveTextContent('true'))
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'control-publish' }))
|
||||
await waitFor(() => expect(mocks.publishDraft).toHaveBeenCalledTimes(1))
|
||||
expect(mocks.toastSuccess).toHaveBeenCalledWith('分类策略已发布为 revision 7')
|
||||
|
||||
mocks.refreshPolicy.mockClear()
|
||||
mocks.analyzeImpact.mockClear()
|
||||
await user.click(screen.getByRole('button', { name: 'control-keep-draft' }))
|
||||
await waitFor(() => expect(mocks.refreshPolicy).toHaveBeenCalledTimes(1))
|
||||
await waitFor(() => expect(mocks.analyzeImpact).toHaveBeenCalledTimes(1))
|
||||
expect(mocks.refreshPolicy.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
mocks.analyzeImpact.mock.invocationCallOrder[0],
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('tab', { name: '发布与历史' }))
|
||||
await screen.findByRole('region', { name: 'policy-control-panel' })
|
||||
await user.click(screen.getByRole('button', { name: 'control-rollback' }))
|
||||
await waitFor(() => expect(mocks.rollback).toHaveBeenCalledWith(3))
|
||||
expect(mocks.toastSuccess).toHaveBeenCalledWith('revision 3 已回滚并发布为 revision 7')
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,6 @@
|
||||
import AccountSettingDirectory from '@/views/setting/AccountSettingDirectory.vue'
|
||||
import type { ClassificationCategory } from '@/api/mediaClassification'
|
||||
import type { TransferDirectoryConf } from '@/api/types'
|
||||
import { fireEvent, screen, waitFor, within } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { renderWithProviders } from '@tests/support/render'
|
||||
@@ -8,7 +10,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
const mocks = vi.hoisted(() => ({
|
||||
apiGet: vi.fn(),
|
||||
apiPost: vi.fn(),
|
||||
openSharedDialog: vi.fn(),
|
||||
routerPush: vi.fn(),
|
||||
toastError: vi.fn(),
|
||||
toastSuccess: vi.fn(),
|
||||
useSilentSettingRefresh: vi.fn(),
|
||||
@@ -16,6 +18,7 @@ const mocks = vi.hoisted(() => ({
|
||||
|
||||
vi.mock('@/api', () => ({
|
||||
default: createDataApiMock({ get: mocks.apiGet, post: mocks.apiPost }),
|
||||
getApiErrorMessage: (error: unknown) => (error instanceof Error ? error.message : undefined),
|
||||
}))
|
||||
|
||||
vi.mock('vue-toastification', () => ({
|
||||
@@ -26,20 +29,30 @@ vi.mock('@/composables/useSilentSettingRefresh', () => ({
|
||||
useSilentSettingRefresh: mocks.useSilentSettingRefresh,
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useSharedDialog', () => ({
|
||||
openSharedDialog: mocks.openSharedDialog,
|
||||
}))
|
||||
vi.mock('vue-router', async importOriginal => {
|
||||
const actual = await importOriginal<typeof import('vue-router')>()
|
||||
return {
|
||||
...actual,
|
||||
useRoute: () => ({ query: { section: 'directories' } }),
|
||||
useRouter: () => ({ push: mocks.routerPush }),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/components/cards/DirectoryCard.vue', async () => {
|
||||
const { defineComponent } = await import('vue')
|
||||
return {
|
||||
default: defineComponent({
|
||||
name: 'DirectoryCardStub',
|
||||
props: { directory: { type: Object, required: true } },
|
||||
props: {
|
||||
directory: { type: Object, required: true },
|
||||
categories: { type: Array, default: () => [] },
|
||||
},
|
||||
emits: ['close', 'update:modelValue'],
|
||||
template: `
|
||||
<section :aria-label="'directory-' + directory.name">
|
||||
<span>{{ directory.name }}</span>
|
||||
<span :data-testid="'category-path-' + directory.name">{{ directory.media_category }}</span>
|
||||
<span :data-testid="'category-count-' + directory.name">{{ categories.length }}</span>
|
||||
<button :aria-label="'rename-' + directory.name" @click="directory.name = '目录1'">rename</button>
|
||||
<button :aria-label="'remove-' + directory.name" @click="$emit('close')">remove</button>
|
||||
<button
|
||||
@@ -106,7 +119,7 @@ const storagesFixture = [
|
||||
{ name: '自定义存储 1', type: 'custom1', config: {} },
|
||||
]
|
||||
|
||||
const directoriesFixture = [
|
||||
const directoriesFixture: TransferDirectoryConf[] = [
|
||||
{
|
||||
name: '目录1',
|
||||
storage: 'local',
|
||||
@@ -116,6 +129,7 @@ const directoriesFixture = [
|
||||
monitor_type: '',
|
||||
media_type: '',
|
||||
media_category: '',
|
||||
media_category_id: null,
|
||||
transfer_type: '',
|
||||
},
|
||||
{
|
||||
@@ -127,16 +141,53 @@ const directoriesFixture = [
|
||||
monitor_type: '',
|
||||
media_type: '',
|
||||
media_category: '',
|
||||
media_category_id: null,
|
||||
transfer_type: '',
|
||||
},
|
||||
]
|
||||
|
||||
function mockLoadedSettings(options: { mountedDisk?: boolean | null } = {}) {
|
||||
const classificationCategories: ClassificationCategory[] = [
|
||||
{ id: 'movie.animation', media_type: '电影', name: '动画', path: ['电影', '动画'], enabled: true, labels: [] },
|
||||
{ id: 'movie.disabled', media_type: '电影', name: '停用', path: ['电影', '停用'], enabled: false, labels: [] },
|
||||
]
|
||||
|
||||
const classificationPolicyFixture = {
|
||||
schema_version: 2,
|
||||
revision: 7,
|
||||
mode: 'first_match',
|
||||
enrichment_mode: 'primary_only',
|
||||
categories: classificationCategories,
|
||||
rules: [],
|
||||
fallbacks: {},
|
||||
source_fallbacks: {},
|
||||
field_aliases: {},
|
||||
}
|
||||
|
||||
function mockLoadedSettings(
|
||||
options: {
|
||||
mountedDisk?: boolean | null
|
||||
directories?: TransferDirectoryConf[]
|
||||
reloadedDirectories?: TransferDirectoryConf[]
|
||||
categories?: ClassificationCategory[]
|
||||
} = {},
|
||||
) {
|
||||
let directoryReadCount = 0
|
||||
mocks.apiGet.mockImplementation((endpoint: string) => {
|
||||
if (endpoint === 'system/setting/public/Directories')
|
||||
return { data: { value: structuredClone(directoriesFixture) } }
|
||||
if (endpoint === 'system/setting/public/Directories') {
|
||||
directoryReadCount += 1
|
||||
const value =
|
||||
directoryReadCount > 1 && options.reloadedDirectories
|
||||
? options.reloadedDirectories
|
||||
: (options.directories ?? directoriesFixture)
|
||||
return { data: { value: structuredClone(value) } }
|
||||
}
|
||||
if (endpoint === 'system/setting/public/Storages') return { data: { value: structuredClone(storagesFixture) } }
|
||||
if (endpoint === 'media/category') return { 电影: ['华语'] }
|
||||
if (endpoint === 'media/classification/policy') {
|
||||
return {
|
||||
...structuredClone(classificationPolicyFixture),
|
||||
categories: structuredClone(options.categories ?? classificationCategories),
|
||||
}
|
||||
}
|
||||
if (endpoint === 'system/env') {
|
||||
return {
|
||||
success: true,
|
||||
@@ -154,6 +205,7 @@ function mockLoadedSettings(options: { mountedDisk?: boolean | null } = {}) {
|
||||
throw new Error(`Unexpected GET ${endpoint}`)
|
||||
})
|
||||
mocks.apiPost.mockResolvedValue({ success: true })
|
||||
return () => directoryReadCount
|
||||
}
|
||||
|
||||
async function renderDirectorySettings() {
|
||||
@@ -177,7 +229,7 @@ describe('AccountSettingDirectory', () => {
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
mocks.apiGet.mockReset()
|
||||
mocks.apiPost.mockReset()
|
||||
mocks.openSharedDialog.mockReset()
|
||||
mocks.routerPush.mockReset().mockResolvedValue(undefined)
|
||||
mocks.toastError.mockReset()
|
||||
mocks.toastSuccess.mockReset()
|
||||
mocks.useSilentSettingRefresh.mockReset()
|
||||
@@ -189,6 +241,8 @@ describe('AccountSettingDirectory', () => {
|
||||
|
||||
expect(await screen.findByText('目录1')).toBeInTheDocument()
|
||||
expect(screen.getByText('目录3')).toBeInTheDocument()
|
||||
await waitFor(() => expect(screen.getByTestId('category-count-目录1')).toHaveTextContent('2'))
|
||||
expect(mocks.apiGet).toHaveBeenCalledWith('media/classification/policy')
|
||||
expect(screen.getByRole('checkbox', { name: '挂载盘删除空目录' })).toBeChecked()
|
||||
expect(getRenameEditors().map(input => (input as HTMLTextAreaElement).value)).toEqual([
|
||||
'{{ title }}',
|
||||
@@ -241,6 +295,57 @@ describe('AccountSettingDirectory', () => {
|
||||
expect(mocks.toastError).toHaveBeenCalledWith('存在重复目录名称!无法保存,请修改!')
|
||||
})
|
||||
|
||||
it('blocks invalid stable category ids before saving', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockLoadedSettings({
|
||||
directories: [
|
||||
{
|
||||
...directoriesFixture[0],
|
||||
media_type: '电影',
|
||||
media_category_id: 'movie.disabled',
|
||||
media_category: '电影/停用',
|
||||
},
|
||||
],
|
||||
})
|
||||
await renderDirectorySettings()
|
||||
await screen.findByText('目录1')
|
||||
|
||||
await user.click(getCard('目录').getByRole('button', { name: '保存' }))
|
||||
|
||||
expect(mocks.apiPost).not.toHaveBeenCalledWith('system/setting/Directories', expect.anything())
|
||||
expect(mocks.toastError).toHaveBeenCalledWith('目录中存在无效或失效的分类引用,请修复后再保存。')
|
||||
expect(screen.getByTestId('directory-save-error')).toHaveTextContent(
|
||||
'目录中存在无效或失效的分类引用,请修复后再保存。',
|
||||
)
|
||||
})
|
||||
|
||||
it('reloads normalized directory snapshots after a successful save', async () => {
|
||||
const user = userEvent.setup()
|
||||
const initialDirectory: TransferDirectoryConf = {
|
||||
...directoriesFixture[0],
|
||||
media_type: '电影',
|
||||
media_category_id: 'movie.animation',
|
||||
media_category: '旧电影/动画',
|
||||
}
|
||||
const normalizedDirectory: TransferDirectoryConf = {
|
||||
...initialDirectory,
|
||||
media_category: '电影/动画',
|
||||
}
|
||||
const directoryReads = mockLoadedSettings({
|
||||
directories: [initialDirectory],
|
||||
reloadedDirectories: [normalizedDirectory],
|
||||
})
|
||||
await renderDirectorySettings()
|
||||
await screen.findByText('目录1')
|
||||
expect(screen.getByTestId('category-path-目录1')).toHaveTextContent('旧电影/动画')
|
||||
|
||||
await user.click(getCard('目录').getByRole('button', { name: '保存' }))
|
||||
|
||||
await waitFor(() => expect(directoryReads()).toBe(2))
|
||||
expect(screen.getByTestId('category-path-目录1')).toHaveTextContent('电影/动画')
|
||||
expect(mocks.toastSuccess).toHaveBeenCalledWith('目录设置保存成功')
|
||||
})
|
||||
|
||||
it('removes directories and storages and persists the remaining collections', async () => {
|
||||
const user = userEvent.setup()
|
||||
await renderDirectorySettings()
|
||||
@@ -317,20 +422,23 @@ describe('AccountSettingDirectory', () => {
|
||||
|
||||
mocks.apiPost.mockRejectedValueOnce(new Error('offline'))
|
||||
await user.click(getCard('目录').getByRole('button', { name: '保存' }))
|
||||
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith('目录设置保存失败!'))
|
||||
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith('offline'))
|
||||
expect(screen.getByTestId('directory-save-error')).toHaveTextContent('offline')
|
||||
|
||||
mocks.apiPost.mockRejectedValueOnce(new Error('offline'))
|
||||
await user.click(getCard('整理 & 刮削').getByRole('button', { name: '保存' }))
|
||||
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith('整理选项设置保存失败!'))
|
||||
})
|
||||
|
||||
it('opens the shared category editor and reloads storage data after a card completes', async () => {
|
||||
it('opens unified classification settings and reloads storage data after a card completes', async () => {
|
||||
const user = userEvent.setup()
|
||||
await renderDirectorySettings()
|
||||
await screen.findByText('本地存储')
|
||||
const directoryCard = getCard('目录')
|
||||
await user.click(directoryCard.getByRole('button', { name: '分类策略' }))
|
||||
expect(mocks.openSharedDialog).toHaveBeenCalledOnce()
|
||||
await user.click(directoryCard.getByRole('button', { name: '自动分类' }))
|
||||
expect(mocks.routerPush).toHaveBeenCalledWith({
|
||||
query: { section: 'directories', tab: 'classification' },
|
||||
})
|
||||
|
||||
const initialStorageLoads = mocks.apiGet.mock.calls.filter(
|
||||
([url]) => url === 'system/setting/public/Storages',
|
||||
|
||||
@@ -144,7 +144,8 @@ const resultSubtitle = computed(() => {
|
||||
return parts.filter(Boolean).join(' · ') || t('nameTest.waitingResult')
|
||||
})
|
||||
const mediaClassification = computed(() => {
|
||||
return [mediaInfo.value?.type || metaInfo.value?.type, mediaInfo.value?.category].filter(Boolean).join(' · ') || '-'
|
||||
const libraryCategory = mediaInfo.value?.library_category || mediaInfo.value?.category
|
||||
return [mediaInfo.value?.type || metaInfo.value?.type, libraryCategory].filter(Boolean).join(' · ') || '-'
|
||||
})
|
||||
const resourceChips = computed(() => {
|
||||
if (isMusicResult.value) {
|
||||
@@ -152,7 +153,7 @@ const resourceChips = computed(() => {
|
||||
mediaInfo.value?.music_type,
|
||||
metaInfo.value?.audio_format,
|
||||
metaInfo.value?.audio_specs,
|
||||
mediaInfo.value?.category,
|
||||
mediaInfo.value?.metadata_category,
|
||||
].filter(Boolean) as string[]
|
||||
}
|
||||
return [
|
||||
|
||||
@@ -169,7 +169,7 @@ describe('NameTestView media identity', () => {
|
||||
media_info: {
|
||||
album: '叶惠美',
|
||||
artist: '周杰伦',
|
||||
category: 'Single',
|
||||
metadata_category: 'Single',
|
||||
media_id: '8f97b17d-1234-4abc-9def-1234567890ab',
|
||||
media_source: 'musicbrainz',
|
||||
title: '晴天',
|
||||
|
||||
Reference in New Issue
Block a user