fix(classification): refine settings workspace UI

This commit is contained in:
jxxghp
2026-09-03 15:17:03 +08:00
parent b67c6ee427
commit 6b8c0bf4a8
13 changed files with 1162 additions and 483 deletions
+3
View File
@@ -233,6 +233,8 @@ export interface ClassificationFieldDefinition {
options: ClassificationFieldOption[]
allow_custom_values: boolean
source_support: Record<string, ClassificationSourceSupport>
selectable?: boolean
replacement_field?: string | null
}
/** 策略编辑器必须遵守的服务端结构限制。 */
@@ -249,6 +251,7 @@ export interface ClassificationPolicyLimits {
/** 标准字段、扩展字段和服务端限制目录。 */
export interface ClassificationFieldCatalog {
fields: ClassificationFieldDefinition[]
retired_fields?: ClassificationFieldDefinition[]
limits: ClassificationPolicyLimits
}
@@ -319,110 +319,123 @@ function updateFallback(mediaType: ClassificationMediaType, categoryId: string |
<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"
<VDialog
:model-value="Boolean(draft)"
class="classification-category-dialog"
width="calc(100% - 24px)"
max-width="720"
scrollable
@update:model-value="
value => {
if (!value) cancelEdit()
}
"
>
<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>
<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>
<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>
<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>
<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>
</VDialog>
<div
class="classification-category-list"
@@ -593,9 +606,11 @@ function updateFallback(mediaType: ClassificationMediaType, categoryId: string |
display: grid;
gap: 16px;
padding: 16px;
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
max-block-size: calc(100dvh - 32px);
overflow-y: auto;
border: 1px solid var(--classification-border, rgba(var(--v-border-color), var(--v-border-opacity)));
border-radius: 8px;
background: rgba(var(--v-theme-surface-variant), 0.18);
background: var(--classification-panel-raised, rgb(var(--v-theme-surface)));
}
.classification-category-form-actions,
@@ -640,8 +655,9 @@ function updateFallback(mediaType: ClassificationMediaType, categoryId: string |
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: 1px solid var(--classification-border, rgba(var(--v-border-color), var(--v-border-opacity)));
border-radius: 8px;
background: var(--classification-panel-raised, rgb(var(--v-theme-surface)));
}
.classification-category-summary {
@@ -714,7 +730,7 @@ function updateFallback(mediaType: ClassificationMediaType, categoryId: string |
justify-items: center;
gap: 6px;
padding: 28px 16px;
border-block: 1px dashed rgba(var(--v-border-color), var(--v-border-opacity));
border-block: 1px dashed var(--classification-border, rgba(var(--v-border-color), var(--v-border-opacity)));
color: rgb(var(--v-theme-on-surface-variant));
}
@@ -722,13 +738,28 @@ function updateFallback(mediaType: ClassificationMediaType, categoryId: string |
display: grid;
gap: 14px;
padding-block-start: 4px;
border-block-start: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
border-block-start: 1px solid var(--classification-border, rgba(var(--v-border-color), var(--v-border-opacity)));
}
.classification-fallback-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
:global(html[data-theme='glass'] .classification-category-dialog .classification-category-form) {
border-color: var(--glass-border-raised) !important;
-webkit-backdrop-filter: blur(28px) saturate(125%) !important;
backdrop-filter: blur(28px) saturate(125%) !important;
background-color: color-mix(in srgb, rgb(var(--v-theme-surface)) 90%, transparent) !important;
background-image: var(--glass-sheen) !important;
box-shadow: var(--glass-shadow-raised) !important;
}
:global(html[data-theme='glass'] .classification-category-dialog > .v-overlay__scrim) {
-webkit-backdrop-filter: blur(8px);
backdrop-filter: blur(8px);
background: rgba(3, 7, 18, 72%);
}
.sr-only {
position: absolute;
overflow: hidden;
@@ -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 {
</VAlert>
<div v-else class="classification-condition-builder__leaf">
<VSelect
<VAutocomplete
:model-value="selectedCondition?.field"
:items="fieldItems"
label="字段"
placeholder="搜索风格、年份、国家或字段 ID"
aria-label="条件字段"
variant="outlined"
density="compact"
hide-details="auto"
auto-select-first
:menu-props="{ contentClass: 'classification-field-menu', maxWidth: 420 }"
data-testid="field-select"
@update:model-value="updateField"
/>
@@ -576,6 +597,18 @@ function removeChild(index: number): void {
</div>
</div>
<p
v-if="selectedDefinition?.selectable === false"
class="classification-condition-builder__retired-field"
data-testid="retired-field-hint"
>
<VIcon icon="mdi-history" size="16" />
<span>
此字段只保留旧规则的原始匹配
<template v-if="replacementDefinition">建议改用{{ replacementDefinition.label }}</template>
</span>
</p>
<div
v-if="sourceSupportHints.length > 0"
class="classification-condition-builder__source-hints"
@@ -652,8 +685,17 @@ function removeChild(index: number): void {
.classification-condition-builder {
min-inline-size: 0;
padding: 12px;
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
border: 1px solid var(--classification-border, rgba(var(--v-border-color), var(--v-border-opacity)));
border-radius: 8px;
background: var(--classification-panel, rgba(var(--v-theme-surface-variant), 0.12));
}
.classification-condition-builder:not([data-depth='0']) {
padding: 8px 0 8px 10px;
border: 0;
border-inline-start: 2px solid var(--classification-border, rgba(var(--v-border-color), var(--v-border-opacity)));
border-radius: 0;
background: transparent;
}
.classification-condition-builder__toolbar,
@@ -666,13 +708,18 @@ function removeChild(index: number): void {
}
.classification-condition-builder__kind-toggle {
flex-wrap: wrap;
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
inline-size: min(100%, 360px);
block-size: auto;
max-inline-size: 100%;
}
.classification-condition-builder__kind-toggle :deep(.v-btn) {
min-inline-size: 64px;
inline-size: 100%;
min-inline-size: 0;
min-block-size: 36px;
padding-inline: 6px;
letter-spacing: 0;
}
.classification-condition-builder__leaf {
@@ -698,6 +745,15 @@ function removeChild(index: number): void {
margin-block-start: 10px;
}
.classification-condition-builder__retired-field {
display: flex;
align-items: flex-start;
gap: 6px;
margin: 10px 0 0;
color: rgb(var(--v-theme-warning));
font-size: 0.8125rem;
}
.classification-condition-builder__group {
display: grid;
gap: 10px;
@@ -723,6 +779,12 @@ function removeChild(index: number): void {
justify-content: flex-end;
}
:global(.classification-field-menu .v-list-item-title) {
overflow: visible;
text-overflow: clip;
white-space: normal;
}
@media (max-width: 720px) {
.classification-condition-builder {
padding: 10px;
@@ -733,16 +795,9 @@ function removeChild(index: number): void {
}
.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);
@@ -757,4 +812,19 @@ function removeChild(index: number): void {
padding-block-start: 0;
}
}
@media (max-width: 420px) {
.classification-condition-builder {
padding: 8px;
}
.classification-condition-builder:not([data-depth='0']) {
padding: 7px 0 7px 8px;
}
.classification-condition-builder__kind-toggle :deep(.v-btn) {
padding-inline: 2px;
font-size: 0.75rem;
}
}
</style>
@@ -37,6 +37,7 @@ const emit = defineEmits<{
// 拖拽能力仅在规则编辑器出现时加载,避免增加其他设置页的首屏体积。
const Draggable = defineAsyncComponent(() => import('vuedraggable').then(module => module.default))
const draftRules = ref<ClassificationRule[]>([])
const expandedRuleId = ref<string | null>(null)
/** 复制条件值,保留标量和列表的原始数据形状。 */
function cloneConditionValue(value: ClassificationFactValue | undefined): ClassificationFactValue | undefined {
@@ -141,6 +142,7 @@ function updateRule(index: number, patch: Partial<ClassificationRule>) {
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<ClassificationRule['target']
})
}
/** 只展开当前正在编辑的规则,避免长条件树同时占满移动端页面。 */
function toggleRule(ruleId: string): void {
expandedRuleId.value = expandedRuleId.value === ruleId ? null : ruleId
}
/** 递归统计叶子条件数量,供折叠摘要快速判断规则复杂度。 */
function conditionCount(node: ClassificationConditionNode): number {
if ('field' in node) return 1
if (node.all) return node.all.reduce((count, child) => 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(
<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)"
<IconBtn
class="classification-rule-drag cursor-move"
icon="mdi-drag-vertical"
variant="text"
:aria-label="`拖拽排序规则 ${rule.name || rule.id}`"
>
<VBtn value="category">分类</VBtn>
<VBtn value="label">标签</VBtn>
</VBtnToggle>
</div>
<VTooltip activator="parent" location="top">拖拽排序</VTooltip>
</IconBtn>
<div class="classification-rule-grid">
<VSelect
:model-value="rule.media_types"
:items="MEDIA_TYPES"
label="媒体类型"
multiple
chips
closable-chips
clearable
<button
class="classification-rule-summary"
type="button"
:aria-expanded="expandedRuleId === rule.id"
:aria-controls="`classification-rule-body-${rule.id}`"
@click="toggleRule(rule.id)"
>
<span class="classification-rule-title">
<strong>{{ rule.name || rule.id }}</strong>
<span>优先级 {{ rule.priority }}</span>
</span>
<span class="classification-rule-meta">
<span>{{ mediaTypeSummary(rule) }}</span>
<span>{{ sourceSummary(rule) }}</span>
<span>{{ conditionCount(rule.when) }} 个条件</span>
<span>{{ targetSummary(rule) }}</span>
</span>
</button>
<VSwitch
:model-value="rule.enabled"
class="classification-rule-enabled"
color="primary"
density="compact"
hide-details="auto"
:aria-label="`媒体类型 ${rule.name || rule.id}`"
@update:model-value="value => updateMediaTypes(index, value)"
hide-details
inset
:aria-label="`启用规则 ${rule.name || rule.id}`"
@update:model-value="value => updateRule(index, { enabled: Boolean(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)"
<VMenu location="bottom end">
<template #activator="{ props: menuProps }">
<IconBtn
v-bind="menuProps"
icon="mdi-dots-vertical"
variant="text"
:aria-label="`规则操作 ${rule.name || rule.id}`"
/>
</template>
<VList density="compact" min-width="180">
<VListItem
prepend-icon="mdi-arrow-up"
:title="`上移规则 ${rule.name || rule.id}`"
:disabled="index === 0"
@click="moveRule(index, index - 1)"
/>
<VListItem
prepend-icon="mdi-arrow-down"
:title="`下移规则 ${rule.name || rule.id}`"
:disabled="index === draftRules.length - 1"
@click="moveRule(index, index + 1)"
/>
<VListItem
prepend-icon="mdi-content-copy"
:title="`复制规则 ${rule.name || rule.id}`"
:disabled="hasReachedLimit"
@click="copyRule(index)"
/>
<VListItem
prepend-icon="mdi-delete-outline"
:title="`删除规则 ${rule.name || rule.id}`"
base-color="error"
@click="deleteRule(index)"
/>
</VList>
</VMenu>
<IconBtn
:icon="expandedRuleId === rule.id ? 'mdi-chevron-up' : 'mdi-chevron-down'"
variant="text"
:aria-label="`${expandedRuleId === rule.id ? '收起' : '编辑'}规则 ${rule.name || rule.id}`"
@click="toggleRule(rule.id)"
/>
</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
<div
v-if="expandedRuleId === rule.id"
:id="`classification-rule-body-${rule.id}`"
class="classification-rule-body"
>
<div class="classification-rule-grid classification-rule-grid--identity">
<VTextField
:model-value="rule.name"
label="规则名称"
density="compact"
hide-details="auto"
no-data-text="当前媒体类型没有可用分类"
:aria-label="`分类目标 ${rule.name || rule.id}`"
@update:model-value="value => updateTarget(index, { category_id: value })"
:aria-label="`规则名称 ${index + 1}`"
@update:model-value="value => updateRule(index, { name: value })"
/>
<VCombobox
:model-value="rule.target.labels"
label="输出标签"
<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"
:class="{ 'classification-rule-labels--wide': rule.kind === 'label' }"
:aria-label="`标签输出 ${rule.name || rule.id}`"
@update:model-value="value => updateTarget(index, { labels: value })"
: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>
</div>
</article>
@@ -481,8 +546,6 @@ watch(
.classification-rule-toolbar,
.classification-rule-head,
.classification-rule-order,
.classification-rule-actions,
.classification-rule-count {
display: flex;
align-items: center;
@@ -516,23 +579,100 @@ watch(
.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));
overflow: hidden;
border: 1px solid var(--classification-border, rgba(var(--v-border-color), var(--v-border-opacity)));
border-radius: 8px;
background: var(--classification-panel-raised, rgb(var(--v-theme-surface)));
}
.classification-rule-order,
.classification-rule-actions {
gap: 2px;
.classification-rule-head {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto auto auto;
gap: 4px;
min-inline-size: 0;
padding: 8px;
}
.classification-rule-drag {
touch-action: none;
}
.classification-rule-summary {
display: grid;
gap: 3px;
min-inline-size: 0;
padding: 5px 8px;
border: 0;
border-radius: 6px;
background: transparent;
color: inherit;
cursor: pointer;
text-align: start;
}
.classification-rule-summary:hover,
.classification-rule-summary:focus-visible {
background: var(--classification-control, rgba(var(--v-theme-surface-variant), 0.24));
}
.classification-rule-summary:focus-visible {
outline: 2px solid rgb(var(--v-theme-primary));
outline-offset: -2px;
}
.classification-rule-title,
.classification-rule-meta {
display: flex;
align-items: center;
min-inline-size: 0;
}
.classification-rule-title {
gap: 8px;
}
.classification-rule-title strong {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.classification-rule-title > span {
flex: 0 0 auto;
color: rgb(var(--v-theme-primary));
font-size: 0.75rem;
}
.classification-rule-meta {
flex-wrap: wrap;
gap: 3px 10px;
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
font-size: 0.75rem;
}
.classification-rule-meta > span {
position: relative;
}
.classification-rule-meta > span:not(:first-child)::before {
position: absolute;
inset-inline-start: -6px;
content: '·';
}
.classification-rule-enabled {
flex: 0 0 auto;
}
.classification-rule-body {
display: grid;
gap: 12px;
min-inline-size: 0;
padding: 12px;
border-block-start: 1px solid var(--classification-border, rgba(var(--v-border-color), var(--v-border-opacity)));
}
.classification-rule-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -545,12 +685,15 @@ watch(
}
.classification-rule-kind {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
align-self: start;
min-width: 144px;
block-size: auto;
}
.classification-rule-kind :deep(.v-btn) {
min-width: 70px;
min-width: 0;
}
.classification-rule-condition,
@@ -591,15 +734,6 @@ watch(
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);
@@ -619,17 +753,32 @@ watch(
}
@media (max-width: 420px) {
.classification-rule {
.classification-rule-head {
grid-template-columns: minmax(0, 1fr) auto auto auto;
padding: 6px;
}
.classification-rule-drag {
display: none;
}
.classification-rule-summary {
padding-inline: 6px;
}
.classification-rule-title {
align-items: flex-start;
flex-direction: column;
gap: 1px;
}
.classification-rule-meta > span:nth-child(2),
.classification-rule-meta > span:nth-child(3) {
display: none;
}
.classification-rule-body {
padding: 10px;
}
.classification-rule-order {
flex-wrap: wrap;
}
.classification-rule-actions {
width: 100%;
justify-content: flex-end;
}
}
</style>
@@ -197,6 +197,7 @@ const PassThroughStub = defineComponent({
const componentStubs = {
VAlert: PassThroughStub,
VAutocomplete: SelectStub,
VBtn: ButtonStub,
VBtnToggle: ButtonToggleStub,
VChip: PassThroughStub,
@@ -252,9 +253,15 @@ const fields: ClassificationFieldDefinition[] = [
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.genre_keys', '风格', 'string_list', ['contains_any', 'contains_all', 'exists']),
fieldDefinition('media.adult', '成人内容', 'boolean', ['equals', 'is_true', 'is_false', 'exists']),
fieldDefinition('music.tags', '音乐标签', 'string_list', ['contains_any'], [], ['音乐']),
{
...fieldDefinition('extensions.themoviedb.genre_ids', '风格(旧规则)', 'string_list', ['contains_any']),
group: '旧规则',
selectable: false,
replacement_field: 'media.genre_keys',
},
]
const defaultProps = {
@@ -285,6 +292,7 @@ describe('ClassificationConditionBuilder', () => {
expect(fieldSelect).toHaveAttribute('aria-label', '条件字段')
expect(within(fieldSelect).getByRole('button', { name: '媒体 · 年份' })).toBeInTheDocument()
expect(within(fieldSelect).queryByRole('button', { name: '音乐 · 音乐标签' })).not.toBeInTheDocument()
expect(within(fieldSelect).queryByRole('button', { name: '旧规则 · 风格(旧规则)' })).not.toBeInTheDocument()
const operatorSelect = screen.getByTestId('operator-select')
expect(operatorSelect).toHaveAttribute('aria-label', '条件操作符')
@@ -303,6 +311,20 @@ describe('ClassificationConditionBuilder', () => {
).not.toBeInTheDocument()
})
it('保留已有退役字段的编辑语义,但不允许将其用于新条件', async () => {
await renderBuilder({
field: 'extensions.themoviedb.genre_ids',
operator: 'contains_any',
value: ['16'],
})
expect(
within(screen.getByTestId('field-select')).getByRole('button', { name: '旧规则 · 风格(旧规则)' }),
).toBeInTheDocument()
expect(screen.getByTestId('retired-field-hint')).toHaveTextContent('此字段只保留旧规则的原始匹配。')
expect(screen.getByTestId('retired-field-hint')).toHaveTextContent('建议改用“风格”')
})
it('按 string、enum、integer、number、year、string_list、boolean 和无值操作符输出类型化值', async () => {
const result = await renderBuilder({ field: 'media.title', operator: 'equals', value: '旧标题' })
@@ -331,12 +353,12 @@ describe('ClassificationConditionBuilder', () => {
await result.rerender({
...defaultProps,
modelValue: { field: 'media.genres', operator: 'contains_any', value: ['剧情'] },
modelValue: { field: 'media.genre_keys', 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',
field: 'media.genre_keys',
operator: 'contains_any',
value: ['动画', '家庭'],
})
@@ -106,6 +106,7 @@ async function renderEditor(rules: ClassificationRule[], options: { maxRules?: n
},
})
await screen.findByTestId('rule-draggable')
if (rules[0]) await userEvent.click(screen.getByRole('button', { name: `编辑规则 ${rules[0].name}` }))
return {
...result,
@@ -116,6 +117,13 @@ async function renderEditor(rules: ClassificationRule[], options: { maxRules?: n
}
}
/** 从紧凑规则摘要的操作菜单执行排序、复制或删除。 */
async function runRuleAction(ruleName: string, action: '上移' | '下移' | '复制' | '删除'): Promise<void> {
const user = userEvent.setup()
await user.click(screen.getByRole('button', { name: `规则操作 ${ruleName}` }))
await user.click(await screen.findByText(`${action}规则 ${ruleName}`))
}
/** 打开 Vuetify 下拉框并选择一个选项。 */
async function selectOption(label: string, option: string) {
const user = userEvent.setup()
@@ -144,14 +152,14 @@ describe('ClassificationRuleEditor', () => {
expect.objectContaining({ id: 'rule-2', name: '新规则 2', priority: 1 }),
])
await user.click(screen.getByRole('button', { name: '复制规则 电影规则' }))
await runRuleAction('电影规则', '复制')
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: '删除规则 电影规则 副本' }))
await runRuleAction('电影规则 副本', '删除')
expect(editor.latestRules().map(rule => [rule.id, rule.priority])).toEqual([
['rule-movie', 0],
['rule-2', 1],
@@ -166,7 +174,7 @@ describe('ClassificationRuleEditor', () => {
createRule({ id: 'rule-tv', name: '剧集规则', priority: 2, media_types: ['电视剧'] }),
])
await user.click(screen.getByRole('button', { name: '下移规则 电影规则' }))
await runRuleAction('电影规则', '下移')
expect(editor.latestRules().map(rule => [rule.id, rule.priority])).toEqual([
['rule-music', 0],
['rule-movie', 1],
@@ -243,12 +251,10 @@ describe('ClassificationRuleEditor', () => {
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)
await runRuleAction('电影规则', '复制')
expect(editor.emitted()['update:rules']).toBeUndefined()
})
})
+7
View File
@@ -1933,6 +1933,10 @@ export default {
classification: {
title: 'Media Auto Classification',
description: 'Configure shared movie, TV, and music rules with stable category IDs and dynamic fields.',
workspaceCategories: 'Categories',
workspaceRules: 'Rules',
workspaceSources: 'Sources',
workspaceReview: 'Review',
revision: 'Active revision {revision}',
unsaved: 'Unsaved changes',
loading: 'Loading the classification policy and field catalog...',
@@ -1953,6 +1957,9 @@ export default {
sourceFallbacks: 'Source-specific Fallbacks',
sourceFallbacksHint: 'Used only when a source-specific rule does not match; stores stable category IDs.',
source: 'Source',
sourceFallbackPanel: '{source} fallbacks, {count} configured',
sourceFallbackConfigured: '{count} configured',
sourceFallbackEmpty: 'Not configured',
sourceFallbackFor: '{mediaType} fallback for {source}',
analysisTitle: 'Validation, Preview, and Version Control',
analysisHint: 'Inspect fact matches, estimate recent-sample impact, and review history before publishing.',
+7
View File
@@ -1914,6 +1914,10 @@ export default {
classification: {
title: '媒体自动分类',
description: '使用稳定分类 ID 和动态字段,为电影、电视剧和音乐配置统一规则。',
workspaceCategories: '分类树',
workspaceRules: '规则',
workspaceSources: '来源',
workspaceReview: '验证发布',
revision: '活动版本 {revision}',
unsaved: '有未保存修改',
loading: '正在加载分类策略和字段目录...',
@@ -1933,6 +1937,9 @@ export default {
sourceFallbacks: '数据源专用兜底',
sourceFallbacksHint: '仅在对应数据源规则未命中时使用,保存稳定分类 ID。',
source: '数据源',
sourceFallbackPanel: '{source} 来源兜底,已配置 {count} 项',
sourceFallbackConfigured: '已配置 {count} 项',
sourceFallbackEmpty: '未配置',
sourceFallbackFor: '{source} 的{mediaType}来源兜底',
analysisTitle: '验证、预览与版本控制',
analysisHint: '在发布前检查事实命中、估算近期样本影响并审阅版本历史。',
+7
View File
@@ -1914,6 +1914,10 @@ export default {
classification: {
title: '媒體自動分類',
description: '使用穩定分類 ID 和動態欄位,為電影、電視劇和音樂配置統一規則。',
workspaceCategories: '分類樹',
workspaceRules: '規則',
workspaceSources: '來源',
workspaceReview: '驗證發佈',
revision: '活動版本 {revision}',
unsaved: '有未儲存修改',
loading: '正在載入分類策略和欄位目錄...',
@@ -1933,6 +1937,9 @@ export default {
sourceFallbacks: '資料源專用兜底',
sourceFallbacksHint: '僅在對應資料源規則未命中時使用,儲存穩定分類 ID。',
source: '資料源',
sourceFallbackPanel: '{source} 來源兜底,已設定 {count} 項',
sourceFallbackConfigured: '已設定 {count} 項',
sourceFallbackEmpty: '未設定',
sourceFallbackFor: '{source} 的{mediaType}來源兜底',
analysisTitle: '驗證、預覽與版本控制',
analysisHint: '在發佈前檢查事實命中、估算近期樣本影響並審閱版本歷史。',
+26
View File
@@ -1,9 +1,14 @@
import SettingPage from '@/pages/setting.vue'
import { waitFor } from '@testing-library/vue'
import { renderWithProviders } from '@tests/support/render'
import { readFileSync } from 'node:fs'
import { cwd } from 'node:process'
import { resolve } from 'node:path'
import type { Ref } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const settingPageSource = readFileSync(resolve(cwd(), 'src/pages/setting.vue'), 'utf8')
const mocks = vi.hoisted(() => ({
registerHeaderTab: vi.fn(),
route: null as unknown as { query: { tab: string | string[] | undefined } },
@@ -50,6 +55,7 @@ describe('setting page', () => {
beforeEach(() => {
mocks.registerHeaderTab.mockReset()
mocks.route.query.tab = 'directory'
document.documentElement.classList.remove('settings-page-header-tabs-active')
})
it('响应有效的 route.query.tab 变化并忽略未知标签', async () => {
@@ -70,4 +76,24 @@ describe('setting page', () => {
await waitFor(() => expect(registeredActiveTab().value).toBe('system'))
})
it('仅在设置页活动期间启用页头样式作用域', async () => {
const { unmount } = await renderSettingPage()
expect(document.documentElement).toHaveClass('settings-page-header-tabs-active')
unmount()
expect(document.documentElement).not.toHaveClass('settings-page-header-tabs-active')
})
it('移动端页头标签等宽并复用玻璃主题材质 token', () => {
expect(settingPageSource).toContain('flex: 0 0 calc(100% / 3)')
expect(settingPageSource).toContain('block-size: 44px')
expect(settingPageSource).toContain('font-size: 0.875rem')
expect(settingPageSource).toContain('flex: 0 0 20px')
expect(settingPageSource).toContain('var(--glass-surface-soft)')
expect(settingPageSource).toContain('var(--glass-border)')
expect(settingPageSource).toContain('var(--glass-sheen)')
expect(settingPageSource).toContain('var(--glass-control-prominent)')
})
})
+101
View File
@@ -8,6 +8,7 @@ const route = useRoute()
const activeTab = ref((route.query.tab as string) || '')
const settingTabs = computed(() => getSettingTabs(t))
const SETTINGS_HEADER_SCOPE_CLASS = 'settings-page-header-tabs-active'
//
const AccountSettingSystem = defineAsyncComponent(() => import('@/views/setting/AccountSettingSystem.vue'))
@@ -35,6 +36,7 @@ const settingTabComponents = [
]
const settingTabValues = new Set(settingTabComponents.map(item => item.value))
/** 记录已访问标签,保留重型设置面板的状态并避免首次加载全部面板。 */
function markTabVisited(tab: string) {
if (!tab) return
@@ -49,6 +51,13 @@ function validRouteTab(value: unknown): string | null {
return typeof tab === 'string' && settingTabValues.has(tab) ? tab : null
}
/** 限定设置页动态页头样式,并在页面失活后及时移除全局标记。 */
function setSettingsHeaderScope(enabled: boolean) {
if (typeof document === 'undefined') return
document.documentElement.classList.toggle(SETTINGS_HEADER_SCOPE_CLASS, enabled)
}
// 使
const { registerHeaderTab } = useDynamicHeaderTab()
@@ -58,6 +67,11 @@ registerHeaderTab({
modelValue: activeTab,
})
onBeforeMount(() => setSettingsHeaderScope(true))
onActivated(() => setSettingsHeaderScope(true))
onDeactivated(() => setSettingsHeaderScope(false))
onUnmounted(() => setSettingsHeaderScope(false))
//
onMounted(() => {
// VWindow
@@ -89,3 +103,90 @@ watch(
</VWindow>
</div>
</template>
<style lang="scss">
@media (max-width: 599.98px) {
html.settings-page-header-tabs-active {
.layout-dynamic-header-tab,
.layout-dynamic-header-tab .tab-header {
inline-size: 100%;
}
.layout-dynamic-header-tab .scroll-button {
display: none !important;
}
.layout-dynamic-header-tab .header-tabs {
display: flex;
gap: 0;
inline-size: 100%;
mask-image: none;
overflow-x: auto;
overscroll-behavior-inline: contain;
padding: 2px;
scroll-padding-inline: 2px;
scroll-snap-type: inline proximity;
}
.layout-dynamic-header-tab .header-tab {
box-sizing: border-box;
flex: 0 0 calc(100% / 3);
justify-content: center;
border-radius: 6px;
block-size: 44px;
font-size: 0.875rem;
line-height: 1.25rem;
min-inline-size: 0;
padding-block: 0;
padding-inline: 8px;
scroll-snap-align: start;
}
.layout-dynamic-header-tab .header-tab > span {
min-inline-size: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.layout-dynamic-header-tab .header-tab-icon {
flex: 0 0 20px;
block-size: 20px;
font-size: 20px;
inline-size: 20px;
margin-inline-end: 6px;
}
.layout-dynamic-header-tab .header-tab::after {
block-size: 2px;
inline-size: min(72px, calc(100% - 16px));
inset-block-end: 1px;
}
}
html[data-theme='glass'].settings-page-header-tabs-active {
.layout-dynamic-header-tab .header-tabs {
border: 1px solid var(--glass-border);
border-radius: 8px;
-webkit-backdrop-filter: var(--glass-control-backdrop-filter);
backdrop-filter: var(--glass-control-backdrop-filter);
background-color: var(--glass-surface-soft);
background-image: var(--glass-sheen);
box-shadow: var(--glass-control-shadow);
}
.layout-dynamic-header-tab .header-tab {
text-shadow: none;
}
.layout-dynamic-header-tab .header-tab:hover:not(.active) {
background-color: var(--glass-control);
}
.layout-dynamic-header-tab .header-tab.active {
background-color: var(--glass-control-prominent);
box-shadow: var(--glass-control-prominent-shadow);
}
}
}
</style>
+391 -173
View File
@@ -49,7 +49,9 @@ const initializing = ref(false)
const loadError = ref(false)
const directoryReferencesUnavailable = ref(false)
const directories = ref<TransferDirectoryConf[]>([])
const workspaceTab = ref<'categories' | 'rules' | 'sources' | 'review'>('categories')
const analysisTab = ref<'preview' | 'impact' | 'publish'>('preview')
const expandedSource = ref<string | null>(null)
const validatedDraftSnapshot = ref<ClassificationPolicy | null>(null)
const analyzedDraftSnapshot = ref<ClassificationPolicy | null>(null)
const lastImpactOptions = ref<ClassificationImpactRequestEvent>({ sampleLimit: 100, exampleLimit: 20 })
@@ -148,7 +150,7 @@ const availableSources = computed(() => {
/** 将只读 API 字段目录复制为编辑器输入,避免组件边界泄漏深层响应式只读类型。 */
const editorFields = computed<ClassificationFieldDefinition[]>(() =>
(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<void> {
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 => {
</script>
<template>
<VCard class="classification-settings">
<VCard class="classification-settings" variant="flat">
<VCardItem>
<template #prepend>
<VAvatar color="primary" variant="tonal" size="40">
@@ -463,158 +471,213 @@ watch(analysisTab, tab => {
{{ 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>
<VTabs
v-model="workspaceTab"
class="classification-settings__workspace-tabs"
color="primary"
density="compact"
grow
>
<VTab value="categories" prepend-icon="mdi-file-tree-outline">{{
t('setting.classification.workspaceCategories')
}}</VTab>
<VTab value="rules" prepend-icon="mdi-filter-cog-outline">{{
t('setting.classification.workspaceRules')
}}</VTab>
<VTab value="sources" prepend-icon="mdi-database-sync-outline">{{
t('setting.classification.workspaceSources')
}}</VTab>
<VTab value="review" prepend-icon="mdi-check-decagram-outline">{{
t('setting.classification.workspaceReview')
}}</VTab>
</VTabs>
<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>
<VWindow v-model="workspaceTab" class="classification-settings__workspace-window">
<VWindowItem value="categories">
<section class="classification-settings__panel">
<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"
class="classification-settings__binary-toggle"
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>
<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"
<ClassificationCategoryEditor
:categories="draftPolicy.categories"
:result="previewResultSnapshot"
:loading="previewing"
@request-preview="previewFacts"
:fallbacks="draftPolicy.fallbacks"
:referenced-category-ids="referencedCategoryIds"
:directory-references="directoryCategoryReferences"
:max-depth="fieldCatalog.limits.max_category_depth"
@update:categories="updateCategories"
@update:fallbacks="updateFallbacks"
/>
</VWindowItem>
<VWindowItem value="impact">
<ClassificationImpactPanel
:analysis="impactResultSnapshot"
:loading="analyzingImpact"
:disabled="publishing || rollingBack"
@analyze="analyzeCurrentDraft"
</section>
</VWindowItem>
<VWindowItem value="rules">
<section class="classification-settings__panel">
<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"
/>
</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>
</section>
</VWindowItem>
<VWindowItem value="sources">
<section class="classification-settings__panel classification-settings__source-fallbacks">
<div class="classification-settings__section-heading">
<div>
<h3>{{ t('setting.classification.sourceFallbacks') }}</h3>
<p>{{ t('setting.classification.sourceFallbacksHint') }}</p>
</div>
<span class="classification-settings__count">{{ availableSources.length }}</span>
</div>
<VExpansionPanels
v-model="expandedSource"
class="classification-settings__source-list"
variant="accordion"
>
<VExpansionPanel v-for="source in availableSources" :key="source" :value="source">
<VExpansionPanelTitle
class="classification-settings__source-title"
:aria-label="
t('setting.classification.sourceFallbackPanel', {
source,
count: sourceFallbackCount(source),
})
"
>
<span class="classification-settings__source-name">
<VIcon icon="mdi-database-outline" size="18" />
<code>{{ source }}</code>
</span>
<VChip size="x-small" variant="tonal">
{{
sourceFallbackCount(source)
? t('setting.classification.sourceFallbackConfigured', {
count: sourceFallbackCount(source),
})
: t('setting.classification.sourceFallbackEmpty')
}}
</VChip>
</VExpansionPanelTitle>
<VExpansionPanelText>
<div class="classification-settings__source-options">
<VSelect
v-for="mediaType in mediaTypes"
:key="mediaType"
:model-value="draftPolicy.source_fallbacks[source]?.[mediaType] ?? null"
:items="fallbackCategoryOptions(mediaType)"
:label="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)"
/>
</div>
</VExpansionPanelText>
</VExpansionPanel>
</VExpansionPanels>
</section>
</VWindowItem>
<VWindowItem value="review">
<section
class="classification-settings__panel 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" class="classification-settings__analysis-tabs" color="primary" grow>
<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>
</VWindowItem>
</VWindow>
</VCardText>
<VCardText v-if="validationResult?.issues.length" class="pt-0">
@@ -654,7 +717,13 @@ watch(analysisTab, tab => {
<style scoped>
.classification-settings {
--classification-border: rgba(var(--v-border-color), var(--v-border-opacity));
--classification-panel: rgba(var(--v-theme-surface-variant), 0.12);
--classification-panel-raised: rgb(var(--v-theme-surface));
--classification-control: rgba(var(--v-theme-surface-variant), 0.24);
overflow: hidden;
background: transparent;
}
.classification-settings__status {
@@ -674,13 +743,53 @@ watch(analysisTab, tab => {
}
.classification-settings__workspace {
padding-block: 1.25rem;
padding: 1rem 1.25rem 1.25rem;
}
.classification-settings__workspace-tabs,
.classification-settings__analysis-tabs {
inline-size: 100%;
border: 1px solid var(--classification-border);
border-radius: 8px;
background: var(--classification-control);
}
.classification-settings__workspace-tabs :deep(.v-slide-group__content),
.classification-settings__analysis-tabs :deep(.v-slide-group__content) {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.classification-settings__analysis-tabs :deep(.v-slide-group__content) {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.classification-settings__workspace-tabs :deep(.v-tab),
.classification-settings__analysis-tabs :deep(.v-tab) {
min-inline-size: 0;
max-inline-size: none;
min-block-size: 44px;
padding-inline: 10px;
font-size: 0.875rem;
letter-spacing: 0;
}
.classification-settings__workspace-window {
margin-block-start: 1rem;
}
.classification-settings__panel {
min-inline-size: 0;
padding: 18px;
border: 1px solid var(--classification-border);
border-radius: 8px;
background: var(--classification-panel);
}
.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));
border-block-end: 1px solid var(--classification-border);
}
.classification-settings__enrichment-control {
@@ -695,16 +804,15 @@ watch(analysisTab, tab => {
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__binary-toggle {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
min-inline-size: min(100%, 24rem);
block-size: auto;
}
.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__binary-toggle :deep(.v-btn) {
min-inline-size: 0;
}
.classification-settings__analysis-window {
@@ -739,18 +847,64 @@ watch(analysisTab, tab => {
margin-block-start: 0.25rem;
}
.classification-settings__source-table {
overflow-x: auto;
.classification-settings__count {
display: inline-grid;
min-inline-size: 28px;
block-size: 28px;
place-items: center;
border: 1px solid var(--classification-border);
border-radius: 50%;
color: rgba(var(--v-theme-on-surface), 0.7);
font-size: 0.8125rem;
}
.classification-settings__source-table :deep(table) {
min-inline-size: 48rem;
table-layout: fixed;
.classification-settings__source-list {
overflow: hidden;
border: 1px solid var(--classification-border);
border-radius: 8px;
background: var(--classification-panel-raised);
}
.classification-settings__source-table :deep(th:first-child),
.classification-settings__source-table :deep(td:first-child) {
inline-size: 13rem;
.classification-settings__source-list :deep(.v-expansion-panel) {
border-block-end: 1px solid var(--classification-border);
background: transparent;
}
.classification-settings__source-list :deep(.v-expansion-panel:last-child) {
border-block-end: 0;
}
.classification-settings__source-title {
min-block-size: 52px;
padding: 8px 14px;
}
.classification-settings__source-title :deep(.v-expansion-panel-title__overlay) {
background: var(--classification-control);
}
.classification-settings__source-title :deep(.v-expansion-panel-title__icon) {
margin-inline-start: 10px;
}
.classification-settings__source-name {
display: flex;
flex: 1 1 auto;
align-items: center;
gap: 8px;
min-inline-size: 0;
}
.classification-settings__source-name code {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.classification-settings__source-options {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
}
.classification-settings__issues {
@@ -771,7 +925,33 @@ watch(analysisTab, tab => {
padding: 0.75rem 1.25rem;
}
:global(html[data-theme='glass']) .classification-settings {
--classification-border: var(--glass-border);
--classification-panel: var(--glass-surface-soft);
--classification-panel-raised: var(--glass-surface);
--classification-control: var(--glass-control);
border-color: var(--glass-border);
-webkit-backdrop-filter: var(--glass-surface-backdrop-filter);
backdrop-filter: var(--glass-surface-backdrop-filter);
background: var(--glass-surface) !important;
background-image: var(--glass-sheen) !important;
box-shadow: var(--glass-shadow);
}
:global(html[data-theme='glass']) .classification-settings__panel,
:global(html[data-theme='glass']) .classification-settings__source-list {
-webkit-backdrop-filter: var(--glass-surface-backdrop-filter);
backdrop-filter: var(--glass-surface-backdrop-filter);
background-image: var(--glass-sheen);
box-shadow: var(--glass-control-shadow);
}
@media (max-width: 599px) {
.classification-settings :deep(.v-card-item) {
padding: 14px 12px 10px;
}
.classification-settings :deep(.v-card-item__append) {
align-self: flex-start;
}
@@ -780,14 +960,37 @@ watch(analysisTab, tab => {
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__workspace {
padding: 10px 10px calc(6rem + env(safe-area-inset-bottom));
}
.classification-settings__enrichment-control :deep(.v-btn) {
min-inline-size: 0;
.classification-settings__workspace-tabs :deep(.v-tab),
.classification-settings__analysis-tabs :deep(.v-tab) {
min-block-size: 42px;
padding-inline: 4px;
font-size: 0.8125rem;
}
.classification-settings__workspace-tabs :deep(.v-btn__prepend),
.classification-settings__analysis-tabs :deep(.v-btn__prepend) {
margin-inline-end: 4px;
}
.classification-settings__panel {
padding: 12px;
}
.classification-settings__enrichment-control {
align-items: stretch;
flex-direction: column;
}
.classification-settings__binary-toggle {
inline-size: 100%;
}
.classification-settings__source-options {
grid-template-columns: minmax(0, 1fr);
}
.classification-settings__actions {
@@ -799,4 +1002,19 @@ watch(analysisTab, tab => {
display: none;
}
}
@media (max-width: 420px) {
.classification-settings__workspace-tabs :deep(.v-tab),
.classification-settings__analysis-tabs :deep(.v-tab) {
flex-direction: column;
gap: 2px;
min-block-size: 54px;
line-height: 1.15;
}
.classification-settings__workspace-tabs :deep(.v-btn__prepend),
.classification-settings__analysis-tabs :deep(.v-btn__prepend) {
margin-inline-end: 0;
}
}
</style>
@@ -298,6 +298,12 @@ describe('AccountSettingClassification', () => {
})
})
/** 切换一级工作区,模拟移动端按需展示大型编辑面板。 */
async function openWorkspace(name: '分类树' | '规则' | '来源' | '验证发布'): Promise<void> {
const user = userEvent.setup()
await user.click(await screen.findByRole('tab', { name }))
}
it('loads only when the settings tab becomes active', async () => {
const { rerender } = await renderWithProviders(AccountSettingClassification, { props: { active: false } })
@@ -315,6 +321,7 @@ describe('AccountSettingClassification', () => {
await user.click(screen.getByRole('button', { name: 'replace-categories' }))
await user.click(screen.getByRole('button', { name: 'replace-fallbacks' }))
await openWorkspace('规则')
await user.click(screen.getByRole('button', { name: 'replace-rules' }))
const state = mocks.useMediaClassification.mock.results[0].value
@@ -351,6 +358,7 @@ describe('AccountSettingClassification', () => {
it('validates the draft and exposes discard as a separate action', async () => {
const user = userEvent.setup()
await renderWithProviders(AccountSettingClassification)
await openWorkspace('规则')
await screen.findByRole('region', { name: 'rule-editor' })
await user.click(screen.getByRole('button', { name: 'replace-rules' }))
@@ -366,7 +374,12 @@ describe('AccountSettingClassification', () => {
it('updates source fallbacks through stable category IDs', async () => {
const user = userEvent.setup()
await renderWithProviders(AccountSettingClassification)
await screen.findByRole('region', { name: 'category-editor' })
await openWorkspace('来源')
await user.click(
screen.getByRole('button', {
name: 'musicbrainz 来源兜底,已配置 0 项',
}),
)
const musicbrainzFallback = screen.getByRole('combobox', {
name: 'musicbrainz 的电影来源兜底',
@@ -378,9 +391,24 @@ describe('AccountSettingClassification', () => {
expect(state.draftPolicy.value.source_fallbacks.musicbrainz.).toBe('movie.base')
})
it('keeps source fallbacks collapsed and opens one source at a time', async () => {
const user = userEvent.setup()
await renderWithProviders(AccountSettingClassification)
await openWorkspace('来源')
expect(screen.queryByRole('combobox', { name: 'musicbrainz 的电影来源兜底' })).not.toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'musicbrainz 来源兜底,已配置 0 项' }))
expect(screen.getByRole('combobox', { name: 'musicbrainz 的电影来源兜底' })).toBeVisible()
await user.click(screen.getByRole('button', { name: 'themoviedb 来源兜底,已配置 1 项' }))
expect(screen.queryByRole('combobox', { name: 'musicbrainz 的电影来源兜底' })).not.toBeInTheDocument()
expect(screen.getByRole('combobox', { name: 'themoviedb 的电影来源兜底' })).toBeVisible()
})
it('maps fact preview modes and bounded impact options to the composable', async () => {
const user = userEvent.setup()
await renderWithProviders(AccountSettingClassification)
await openWorkspace('验证发布')
await screen.findByRole('region', { name: 'preview-panel' })
await user.click(screen.getByRole('button', { name: 'request-active-preview' }))
@@ -409,8 +437,10 @@ describe('AccountSettingClassification', () => {
it('keeps validation and impact stale when the draft changes while requests are in flight', async () => {
const user = userEvent.setup()
await renderWithProviders(AccountSettingClassification)
await openWorkspace('规则')
await screen.findByRole('region', { name: 'rule-editor' })
await user.click(screen.getByRole('button', { name: 'replace-rules' }))
await openWorkspace('验证发布')
await user.click(screen.getByRole('tab', { name: '发布与历史' }))
await screen.findByRole('region', { name: 'policy-control-panel' })
@@ -459,9 +489,11 @@ describe('AccountSettingClassification', () => {
it('requires current validation and impact snapshots before publishing, then sequences conflict recovery and rollback', async () => {
const user = userEvent.setup()
await renderWithProviders(AccountSettingClassification)
await openWorkspace('规则')
await screen.findByRole('region', { name: 'rule-editor' })
await user.click(screen.getByRole('button', { name: 'replace-rules' }))
await openWorkspace('验证发布')
await user.click(screen.getByRole('tab', { name: '发布与历史' }))
await screen.findByRole('region', { name: 'policy-control-panel' })
await waitFor(() => expect(mocks.loadHistory).toHaveBeenCalledTimes(1))