mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-08 17:26:41 +08:00
fix(classification): refine settings workspace UI
This commit is contained in:
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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: '在发布前检查事实命中、估算近期样本影响并审阅版本历史。',
|
||||
|
||||
@@ -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: '在發佈前檢查事實命中、估算近期樣本影響並審閱版本歷史。',
|
||||
|
||||
@@ -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)')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user