From 6b8c0bf4a8a20a4d25d2c0be80e0a1caa4477d4d Mon Sep 17 00:00:00 2001 From: jxxghp Date: Thu, 3 Sep 2026 15:17:03 +0800 Subject: [PATCH] fix(classification): refine settings workspace UI --- src/api/mediaClassificationTypes.ts | 3 + .../ClassificationCategoryEditor.vue | 241 ++++---- .../ClassificationConditionBuilder.vue | 108 +++- .../ClassificationRuleEditor.vue | 501 ++++++++++------ .../ClassificationConditionBuilder.spec.ts | 28 +- .../ClassificationRuleEditor.spec.ts | 18 +- src/locales/en-US.ts | 7 + src/locales/zh-CN.ts | 7 + src/locales/zh-TW.ts | 7 + src/pages/__tests__/setting.spec.ts | 26 + src/pages/setting.vue | 101 ++++ .../setting/AccountSettingClassification.vue | 564 ++++++++++++------ .../AccountSettingClassification.spec.ts | 34 +- 13 files changed, 1162 insertions(+), 483 deletions(-) diff --git a/src/api/mediaClassificationTypes.ts b/src/api/mediaClassificationTypes.ts index c4185a3a..7581ebb1 100644 --- a/src/api/mediaClassificationTypes.ts +++ b/src/api/mediaClassificationTypes.ts @@ -233,6 +233,8 @@ export interface ClassificationFieldDefinition { options: ClassificationFieldOption[] allow_custom_values: boolean source_support: Record + selectable?: boolean + replacement_field?: string | null } /** 策略编辑器必须遵守的服务端结构限制。 */ @@ -249,6 +251,7 @@ export interface ClassificationPolicyLimits { /** 标准字段、扩展字段和服务端限制目录。 */ export interface ClassificationFieldCatalog { fields: ClassificationFieldDefinition[] + retired_fields?: ClassificationFieldDefinition[] limits: ClassificationPolicyLimits } diff --git a/src/components/classification/ClassificationCategoryEditor.vue b/src/components/classification/ClassificationCategoryEditor.vue index 71024dcf..fc28b20d 100644 --- a/src/components/classification/ClassificationCategoryEditor.vue +++ b/src/components/classification/ClassificationCategoryEditor.vue @@ -319,110 +319,123 @@ function updateFallback(mediaType: ClassificationMediaType, categoryId: string |

{{ statusMessage }}

-
-
-

- {{ - draft.originalId - ? t('setting.classification.category.editTitle') - : t('setting.classification.category.addTitle') - }} -

-
- - - - {{ t('setting.classification.category.cancelEdit') }} - - - - - - {{ t('setting.classification.category.save') }} - - +
+
+

+ {{ + draft.originalId + ? t('setting.classification.category.editTitle') + : t('setting.classification.category.addTitle') + }} +

+
+ + + + {{ t('setting.classification.category.cancelEdit') }} + + + + + + {{ t('setting.classification.category.save') }} + + +
-
-
- - - - -
+
+ + + + +
- - - {{ t('setting.classification.category.protectedEditHint') }} -
    -
  • {{ reason }}
  • -
-
- -
+ + + {{ t('setting.classification.category.protectedEditHint') }} +
    +
  • {{ reason }}
  • +
+
+ + +
.v-overlay__scrim) { + -webkit-backdrop-filter: blur(8px); + backdrop-filter: blur(8px); + background: rgba(3, 7, 18, 72%); +} + .sr-only { position: absolute; overflow: hidden; diff --git a/src/components/classification/ClassificationConditionBuilder.vue b/src/components/classification/ClassificationConditionBuilder.vue index 4c507bd7..fcfbfb8e 100644 --- a/src/components/classification/ClassificationConditionBuilder.vue +++ b/src/components/classification/ClassificationConditionBuilder.vue @@ -107,12 +107,25 @@ function supportsSelectedMediaTypes(field: ClassificationFieldDefinition): boole } const availableFields = computed(() => props.fields.filter(supportsSelectedMediaTypes)) +const selectableFields = computed(() => availableFields.value.filter(field => field.selectable !== false)) + +/** 将标准字段排在扩展字段之前,避免迁移字段遮住常用的风格、年份和国家字段。 */ +function fieldOrder(field: ClassificationFieldDefinition): number { + if (field.id.startsWith('extensions.')) return 100 + if (field.group === '音乐') return 20 + if (field.group === '影视') return 10 + return 0 +} const fieldItems = computed(() => - availableFields.value.map(field => ({ - title: field.group ? `${field.group} · ${field.label}` : field.label, - value: field.id, - })), + [...availableFields.value] + .sort((left, right) => fieldOrder(left) - fieldOrder(right)) + .filter(field => field.selectable !== false || field.id === selectedCondition.value?.field) + .map(field => ({ + title: field.group ? `${field.group} · ${field.label}` : field.label, + value: field.id, + props: { disabled: field.selectable === false }, + })), ) const nodeKind = computed(() => getNodeKind(props.modelValue)) @@ -121,7 +134,7 @@ const canUseGroup = computed(() => props.depth < props.maxDepth) const canAddChild = computed( () => nodeKind.value !== 'condition' && - availableFields.value.length > 0 && + selectableFields.value.length > 0 && (nodeKind.value !== 'not' || groupChildren.value.length === 0), ) @@ -134,6 +147,11 @@ const selectedDefinition = computed(() => { return fieldId ? availableFields.value.find(field => field.id === fieldId) : undefined }) +const replacementDefinition = computed(() => { + const replacementId = selectedDefinition.value?.replacement_field + return replacementId ? props.fields.find(field => field.id === replacementId) : undefined +}) + const operatorItems = computed(() => (selectedDefinition.value?.operators ?? []).map(operator => ({ title: OPERATOR_LABELS[operator], @@ -244,7 +262,7 @@ function createDefaultValue( /** 由字段目录的首个可用字段构造叶子,不在前端臆造字段或操作符。 */ function createDefaultCondition(): ClassificationCondition | null { - const definition = availableFields.value[0] + const definition = selectableFields.value[0] const operator = definition?.operators[0] if (!definition || !operator) return null @@ -283,7 +301,7 @@ function updateNodeKind(kind: ConditionNodeKind): void { function updateField(fieldId: string): void { const definition = availableFields.value.find(field => field.id === fieldId) const operator = definition?.operators[0] - if (!definition || !operator) return + if (!definition || definition.selectable === false || !operator) return const value = createDefaultValue(definition, operator) updateNode(value === undefined ? { field: fieldId, operator } : { field: fieldId, operator, value }) @@ -413,14 +431,17 @@ function removeChild(index: number): void {
- @@ -576,6 +597,18 @@ function removeChild(index: number): void {
+

+ + + 此字段只保留旧规则的原始匹配。 + + +

+
diff --git a/src/components/classification/ClassificationRuleEditor.vue b/src/components/classification/ClassificationRuleEditor.vue index 01a8de38..04dd8ed2 100644 --- a/src/components/classification/ClassificationRuleEditor.vue +++ b/src/components/classification/ClassificationRuleEditor.vue @@ -37,6 +37,7 @@ const emit = defineEmits<{ // 拖拽能力仅在规则编辑器出现时加载,避免增加其他设置页的首屏体积。 const Draggable = defineAsyncComponent(() => import('vuedraggable').then(module => module.default)) const draftRules = ref([]) +const expandedRuleId = ref(null) /** 复制条件值,保留标量和列表的原始数据形状。 */ function cloneConditionValue(value: ClassificationFactValue | undefined): ClassificationFactValue | undefined { @@ -141,6 +142,7 @@ function updateRule(index: number, patch: Partial) { ruleIndex === index ? cloneRule({ ...current, ...patch }) : cloneRule(rule), ) commitRules(nextRules) + if (patch.id !== undefined && expandedRuleId.value === current.id) expandedRuleId.value = patch.id } /** 新增一条具备稳定默认值的分类规则。 */ @@ -164,6 +166,7 @@ function addRule() { }, } commitRules([...draftRules.value, rule]) + expandedRuleId.value = rule.id } /** 复制规则的完整条件与输出,同时生成新的稳定 ID 和名称。 */ @@ -175,11 +178,14 @@ function copyRule(index: number) { copied.id = uniqueId(`${source.id}-copy`) copied.name = uniqueName(`${source.name} 副本`) commitRules([...draftRules.value.slice(0, index + 1), copied, ...draftRules.value.slice(index + 1)]) + expandedRuleId.value = copied.id } /** 删除指定位置的规则。 */ function deleteRule(index: number) { + const deletedId = draftRules.value[index]?.id commitRules(draftRules.value.filter((_, ruleIndex) => ruleIndex !== index)) + if (expandedRuleId.value === deletedId) expandedRuleId.value = null } /** 将规则移动到目标位置,并保护首尾边界。 */ @@ -242,6 +248,36 @@ function updateTarget(index: number, patch: Partial count + conditionCount(child), 0) + if (node.any) return node.any.reduce((count, child) => count + conditionCount(child), 0) + return node.not ? conditionCount(node.not) : 0 +} + +/** 将媒体类型压缩为可扫描的规则摘要。 */ +function mediaTypeSummary(rule: ClassificationRule): string { + return rule.media_types.length ? rule.media_types.join('、') : '全部媒体' +} + +/** 将来源限制压缩为可扫描的规则摘要。 */ +function sourceSummary(rule: ClassificationRule): string { + return rule.sources.length ? rule.sources.join('、') : '全部来源' +} + +/** 返回规则输出的人类可读摘要,不暴露稳定 ID 作为首要信息。 */ +function targetSummary(rule: ClassificationRule): string { + if (rule.kind === 'label') return rule.target.labels.length ? `标签 ${rule.target.labels.join('、')}` : '未设置标签' + const category = props.categories.find(item => item.id === rule.target.category_id) + return category?.name ?? '未设置分类' +} + const sourceItems = computed(() => [...new Set(props.fields.flatMap(field => Object.keys(field.source_support)))].sort((left, right) => left.localeCompare(right), @@ -296,169 +332,198 @@ watch( + + diff --git a/src/views/setting/AccountSettingClassification.vue b/src/views/setting/AccountSettingClassification.vue index 787ffd80..9b34e937 100644 --- a/src/views/setting/AccountSettingClassification.vue +++ b/src/views/setting/AccountSettingClassification.vue @@ -49,7 +49,9 @@ const initializing = ref(false) const loadError = ref(false) const directoryReferencesUnavailable = ref(false) const directories = ref([]) +const workspaceTab = ref<'categories' | 'rules' | 'sources' | 'review'>('categories') const analysisTab = ref<'preview' | 'impact' | 'publish'>('preview') +const expandedSource = ref(null) const validatedDraftSnapshot = ref(null) const analyzedDraftSnapshot = ref(null) const lastImpactOptions = ref({ sampleLimit: 100, exampleLimit: 20 }) @@ -148,7 +150,7 @@ const availableSources = computed(() => { /** 将只读 API 字段目录复制为编辑器输入,避免组件边界泄漏深层响应式只读类型。 */ const editorFields = computed(() => - (fieldCatalog.value?.fields ?? []).map(field => ({ + [...(fieldCatalog.value?.fields ?? []), ...(fieldCatalog.value?.retired_fields ?? [])].map(field => ({ ...field, media_types: [...field.media_types], operators: [...field.operators], @@ -201,6 +203,11 @@ function sourceFallbackMenuProps(source: string, mediaType: ClassificationMediaT } } +/** 返回来源已配置的媒体类型数量,折叠状态下仍能快速识别有效配置。 */ +function sourceFallbackCount(source: string): number { + return Object.values(draftPolicy.value?.source_fallbacks[source] ?? {}).filter(Boolean).length +} + /** 标签首次激活时加载策略与动态字段,失败后允许用户显式重试。 */ async function ensureInitialized(force = false): Promise { if (!props.active || initializing.value || (initialized.value && !force)) return @@ -308,6 +315,7 @@ async function analyzeCurrentDraft(options: ClassificationImpactRequestEvent = l exampleLimit: options.exampleLimit, }) analyzedDraftSnapshot.value = requestedPolicy + workspaceTab.value = 'review' analysisTab.value = 'impact' } catch (error) { console.error(error) @@ -410,7 +418,7 @@ watch(analysisTab, tab => {