mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-09 01:36:45 +08:00
fix(classification): polish settings controls (#748)
This commit is contained in:
@@ -139,10 +139,10 @@ function isCategoryProtected(categoryId: string): boolean {
|
|||||||
return categoryReferenceReasons(categoryId).length > 0
|
return categoryReferenceReasons(categoryId).length > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 为稳定 ID fallback 选择器生成可辨识的标题。 */
|
/** 为 fallback 选择器生成不带内部稳定 ID 的可读标题。 */
|
||||||
function fallbackItemTitle(category: ClassificationCategory): string {
|
function fallbackItemTitle(category: ClassificationCategory): string {
|
||||||
const path = category.path.length ? category.path.join(' / ') : t('setting.classification.category.pathUnset')
|
const path = category.path.length ? category.path.join(' / ') : t('setting.classification.category.pathUnset')
|
||||||
return `${category.name} · ${path} · ${category.id}`
|
return `${category.name} · ${path}`
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 返回指定媒体类型可选的稳定分类 ID 列表。 */
|
/** 返回指定媒体类型可选的稳定分类 ID 列表。 */
|
||||||
@@ -150,9 +150,15 @@ function fallbackItems(mediaType: ClassificationMediaType): ClassificationCatego
|
|||||||
return props.categories.filter(category => category.media_type === mediaType)
|
return props.categories.filter(category => category.media_type === mediaType)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 将业务标签传给 VSelect 的真实 combobox 激活元素。 */
|
/** 将业务标签和有界浮层参数传给分类选择器。 */
|
||||||
function comboboxMenuProps(label: string): { activatorProps: { 'aria-label': string } } {
|
function comboboxMenuProps(label: string) {
|
||||||
return { activatorProps: { 'aria-label': label } }
|
return {
|
||||||
|
activatorProps: { 'aria-label': label },
|
||||||
|
contentClass: 'classification-category-menu',
|
||||||
|
maxHeight: 280,
|
||||||
|
location: 'bottom start' as const,
|
||||||
|
offset: 4,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 开始新增当前分段的分类。 */
|
/** 开始新增当前分段的分类。 */
|
||||||
|
|||||||
@@ -80,6 +80,19 @@ const OPERATOR_LABELS: Record<ClassificationOperator, string> = {
|
|||||||
not_exists: '不存在',
|
not_exists: '不存在',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const classificationMenuProps = {
|
||||||
|
contentClass: 'classification-condition-menu',
|
||||||
|
maxHeight: 280,
|
||||||
|
location: 'bottom start' as const,
|
||||||
|
offset: 4,
|
||||||
|
}
|
||||||
|
|
||||||
|
const classificationFieldMenuProps = {
|
||||||
|
...classificationMenuProps,
|
||||||
|
contentClass: 'classification-field-menu',
|
||||||
|
maxWidth: 420,
|
||||||
|
}
|
||||||
|
|
||||||
/** 判断节点是否为字段条件叶子。 */
|
/** 判断节点是否为字段条件叶子。 */
|
||||||
function isCondition(node: ClassificationConditionNode): node is ClassificationCondition {
|
function isCondition(node: ClassificationConditionNode): node is ClassificationCondition {
|
||||||
return 'field' in node && 'operator' in node
|
return 'field' in node && 'operator' in node
|
||||||
@@ -441,7 +454,7 @@ function removeChild(index: number): void {
|
|||||||
density="compact"
|
density="compact"
|
||||||
hide-details="auto"
|
hide-details="auto"
|
||||||
auto-select-first
|
auto-select-first
|
||||||
:menu-props="{ contentClass: 'classification-field-menu', maxWidth: 420 }"
|
:menu-props="classificationFieldMenuProps"
|
||||||
data-testid="field-select"
|
data-testid="field-select"
|
||||||
@update:model-value="updateField"
|
@update:model-value="updateField"
|
||||||
/>
|
/>
|
||||||
@@ -454,6 +467,7 @@ function removeChild(index: number): void {
|
|||||||
variant="outlined"
|
variant="outlined"
|
||||||
density="compact"
|
density="compact"
|
||||||
hide-details="auto"
|
hide-details="auto"
|
||||||
|
:menu-props="classificationMenuProps"
|
||||||
data-testid="operator-select"
|
data-testid="operator-select"
|
||||||
@update:model-value="updateOperator"
|
@update:model-value="updateOperator"
|
||||||
/>
|
/>
|
||||||
@@ -508,6 +522,7 @@ function removeChild(index: number): void {
|
|||||||
variant="outlined"
|
variant="outlined"
|
||||||
density="compact"
|
density="compact"
|
||||||
hide-details="auto"
|
hide-details="auto"
|
||||||
|
:menu-props="classificationMenuProps"
|
||||||
data-testid="list-value-input"
|
data-testid="list-value-input"
|
||||||
@update:model-value="updateCatalogListValue"
|
@update:model-value="updateCatalogListValue"
|
||||||
/>
|
/>
|
||||||
@@ -523,6 +538,7 @@ function removeChild(index: number): void {
|
|||||||
variant="outlined"
|
variant="outlined"
|
||||||
density="compact"
|
density="compact"
|
||||||
hide-details="auto"
|
hide-details="auto"
|
||||||
|
:menu-props="classificationMenuProps"
|
||||||
data-testid="list-value-input"
|
data-testid="list-value-input"
|
||||||
@update:model-value="updateListValue"
|
@update:model-value="updateListValue"
|
||||||
/>
|
/>
|
||||||
@@ -567,6 +583,7 @@ function removeChild(index: number): void {
|
|||||||
variant="outlined"
|
variant="outlined"
|
||||||
density="compact"
|
density="compact"
|
||||||
hide-details="auto"
|
hide-details="auto"
|
||||||
|
:menu-props="classificationMenuProps"
|
||||||
data-testid="select-value-input"
|
data-testid="select-value-input"
|
||||||
@update:model-value="updateCatalogScalarValue"
|
@update:model-value="updateCatalogScalarValue"
|
||||||
/>
|
/>
|
||||||
@@ -579,6 +596,7 @@ function removeChild(index: number): void {
|
|||||||
variant="outlined"
|
variant="outlined"
|
||||||
density="compact"
|
density="compact"
|
||||||
hide-details="auto"
|
hide-details="auto"
|
||||||
|
:menu-props="classificationMenuProps"
|
||||||
data-testid="select-value-input"
|
data-testid="select-value-input"
|
||||||
@update:model-value="updateScalarValue"
|
@update:model-value="updateScalarValue"
|
||||||
/>
|
/>
|
||||||
@@ -779,7 +797,8 @@ function removeChild(index: number): void {
|
|||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
:global(.classification-field-menu .v-list-item-title) {
|
:global(.classification-field-menu .v-list-item-title),
|
||||||
|
:global(.classification-condition-menu .v-list-item-title) {
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
text-overflow: clip;
|
text-overflow: clip;
|
||||||
white-space: normal;
|
white-space: normal;
|
||||||
|
|||||||
@@ -410,45 +410,47 @@ watch(
|
|||||||
{{ t('setting.classification.control.historyEmpty') }}
|
{{ t('setting.classification.control.historyEmpty') }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<fieldset v-else class="classification-policy-control__history-list">
|
<VRadioGroup
|
||||||
<legend class="sr-only">{{ t('setting.classification.control.selectHistoryLegend') }}</legend>
|
v-else
|
||||||
<label
|
v-model="selectedRevision"
|
||||||
|
class="classification-policy-control__history-list"
|
||||||
|
hide-details
|
||||||
|
:aria-label="t('setting.classification.control.selectHistoryLegend')"
|
||||||
|
>
|
||||||
|
<VRadio
|
||||||
v-for="policy in historyItems"
|
v-for="policy in historyItems"
|
||||||
:key="policy.revision"
|
:key="policy.revision"
|
||||||
class="classification-policy-control__history-row"
|
class="classification-policy-control__history-row"
|
||||||
:class="{ 'classification-policy-control__history-row--selected': selectedRevision === policy.revision }"
|
:class="{ 'classification-policy-control__history-row--selected': selectedRevision === policy.revision }"
|
||||||
:data-testid="'classification-history-revision-' + policy.revision"
|
:data-testid="'classification-history-revision-' + policy.revision"
|
||||||
|
:value="policy.revision"
|
||||||
|
:aria-label="
|
||||||
|
t('setting.classification.control.selectHistoryAria', {
|
||||||
|
revision: policy.revision,
|
||||||
|
categories: policy.categories.length,
|
||||||
|
rules: policy.rules.length,
|
||||||
|
})
|
||||||
|
"
|
||||||
>
|
>
|
||||||
<input
|
<template #label>
|
||||||
v-model="selectedRevision"
|
<span class="classification-policy-control__history-summary">
|
||||||
type="radio"
|
<span class="classification-policy-control__history-title">
|
||||||
name="classification-history-revision"
|
<strong>revision {{ policy.revision }}</strong>
|
||||||
:value="policy.revision"
|
<time v-if="policy.updated_at" :datetime="policy.updated_at">{{
|
||||||
:aria-label="
|
formatUpdatedAt(policy.updated_at)
|
||||||
t('setting.classification.control.selectHistoryAria', {
|
}}</time>
|
||||||
revision: policy.revision,
|
<span v-else>{{ t('setting.classification.control.updatedAtUnknown') }}</span>
|
||||||
categories: policy.categories.length,
|
</span>
|
||||||
rules: policy.rules.length,
|
<span class="classification-policy-control__history-counts">
|
||||||
})
|
<span>{{
|
||||||
"
|
t('setting.classification.control.categoryCount', { count: policy.categories.length })
|
||||||
/>
|
}}</span>
|
||||||
<span class="classification-policy-control__history-summary">
|
<span>{{ t('setting.classification.control.ruleCount', { count: policy.rules.length }) }}</span>
|
||||||
<span class="classification-policy-control__history-title">
|
</span>
|
||||||
<strong>revision {{ policy.revision }}</strong>
|
|
||||||
<time v-if="policy.updated_at" :datetime="policy.updated_at">{{
|
|
||||||
formatUpdatedAt(policy.updated_at)
|
|
||||||
}}</time>
|
|
||||||
<span v-else>{{ t('setting.classification.control.updatedAtUnknown') }}</span>
|
|
||||||
</span>
|
</span>
|
||||||
<span class="classification-policy-control__history-counts">
|
</template>
|
||||||
<span>{{
|
</VRadio>
|
||||||
t('setting.classification.control.categoryCount', { count: policy.categories.length })
|
</VRadioGroup>
|
||||||
}}</span>
|
|
||||||
<span>{{ t('setting.classification.control.ruleCount', { count: policy.rules.length }) }}</span>
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
</fieldset>
|
|
||||||
|
|
||||||
<VAlert density="compact" type="info" variant="tonal">
|
<VAlert density="compact" type="info" variant="tonal">
|
||||||
{{ t('setting.classification.control.rollbackNotice') }}
|
{{ t('setting.classification.control.rollbackNotice') }}
|
||||||
@@ -593,10 +595,14 @@ watch(
|
|||||||
border: 0;
|
border: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.classification-policy-control__history-row {
|
.classification-policy-control__history-list :deep(.v-selection-control-group) {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: auto minmax(0, 1fr);
|
gap: 8px;
|
||||||
gap: 12px;
|
}
|
||||||
|
|
||||||
|
.classification-policy-control__history-row {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
min-inline-size: 0;
|
min-inline-size: 0;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
@@ -605,6 +611,17 @@ watch(
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.classification-policy-control__history-row :deep(.v-selection-control__wrapper) {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
margin-inline-end: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.classification-policy-control__history-row :deep(.v-label) {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-inline-size: 0;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.classification-policy-control__history-row:hover,
|
.classification-policy-control__history-row:hover,
|
||||||
.classification-policy-control__history-row:focus-within,
|
.classification-policy-control__history-row:focus-within,
|
||||||
.classification-policy-control__history-row--selected {
|
.classification-policy-control__history-row--selected {
|
||||||
@@ -612,12 +629,6 @@ watch(
|
|||||||
background: rgba(var(--v-theme-primary), 0.06);
|
background: rgba(var(--v-theme-primary), 0.06);
|
||||||
}
|
}
|
||||||
|
|
||||||
.classification-policy-control__history-row input {
|
|
||||||
inline-size: 18px;
|
|
||||||
block-size: 18px;
|
|
||||||
accent-color: rgb(var(--v-theme-primary));
|
|
||||||
}
|
|
||||||
|
|
||||||
.classification-policy-control__history-summary,
|
.classification-policy-control__history-summary,
|
||||||
.classification-policy-control__history-title,
|
.classification-policy-control__history-title,
|
||||||
.classification-policy-control__history-counts {
|
.classification-policy-control__history-counts {
|
||||||
|
|||||||
@@ -103,9 +103,15 @@ const fieldGroups = computed<ClassificationPreviewFieldGroup[]>(() => {
|
|||||||
|
|
||||||
const categoryMap = computed(() => new Map(props.categories.map(category => [category.id, category])))
|
const categoryMap = computed(() => new Map(props.categories.map(category => [category.id, category])))
|
||||||
|
|
||||||
/** 为 VSelect 与 VCombobox 的真实激活元素提供业务标签。 */
|
/** 为 VSelect 与 VCombobox 提供业务标签和有界浮层参数。 */
|
||||||
function comboboxMenuProps(label: string): { activatorProps: { 'aria-label': string } } {
|
function comboboxMenuProps(label: string) {
|
||||||
return { activatorProps: { 'aria-label': label } }
|
return {
|
||||||
|
activatorProps: { 'aria-label': label },
|
||||||
|
contentClass: 'classification-preview-menu',
|
||||||
|
maxHeight: 280,
|
||||||
|
location: 'bottom start' as const,
|
||||||
|
offset: 4,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 返回字段当前保存的值,数组会复制后再交给控件。 */
|
/** 返回字段当前保存的值,数组会复制后再交给控件。 */
|
||||||
|
|||||||
@@ -15,6 +15,12 @@ import ClassificationConditionBuilder from './ClassificationConditionBuilder.vue
|
|||||||
const MEDIA_TYPES: ClassificationMediaType[] = ['电影', '电视剧', '音乐']
|
const MEDIA_TYPES: ClassificationMediaType[] = ['电影', '电视剧', '音乐']
|
||||||
const DEFAULT_MAX_RULES = 200
|
const DEFAULT_MAX_RULES = 200
|
||||||
const DEFAULT_MAX_CONDITION_DEPTH = 8
|
const DEFAULT_MAX_CONDITION_DEPTH = 8
|
||||||
|
const classificationRuleMenuProps = {
|
||||||
|
contentClass: 'classification-rule-menu',
|
||||||
|
maxHeight: 280,
|
||||||
|
location: 'bottom start' as const,
|
||||||
|
offset: 4,
|
||||||
|
}
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
@@ -330,7 +336,12 @@ watch(
|
|||||||
:component-data="{ class: 'classification-rule-list' }"
|
:component-data="{ class: 'classification-rule-list' }"
|
||||||
>
|
>
|
||||||
<template #item="{ element: rule, index }">
|
<template #item="{ element: rule, index }">
|
||||||
<article class="classification-rule" :aria-label="`规则 ${index + 1}:${rule.name || rule.id}`">
|
<VCard
|
||||||
|
class="classification-rule"
|
||||||
|
variant="outlined"
|
||||||
|
role="article"
|
||||||
|
:aria-label="`规则 ${index + 1}:${rule.name || rule.id}`"
|
||||||
|
>
|
||||||
<div class="classification-rule-head">
|
<div class="classification-rule-head">
|
||||||
<IconBtn
|
<IconBtn
|
||||||
class="classification-rule-drag cursor-move"
|
class="classification-rule-drag cursor-move"
|
||||||
@@ -464,6 +475,7 @@ watch(
|
|||||||
clearable
|
clearable
|
||||||
density="compact"
|
density="compact"
|
||||||
hide-details="auto"
|
hide-details="auto"
|
||||||
|
:menu-props="classificationRuleMenuProps"
|
||||||
:aria-label="`媒体类型 ${rule.name || rule.id}`"
|
:aria-label="`媒体类型 ${rule.name || rule.id}`"
|
||||||
@update:model-value="value => updateMediaTypes(index, value)"
|
@update:model-value="value => updateMediaTypes(index, value)"
|
||||||
/>
|
/>
|
||||||
@@ -478,6 +490,7 @@ watch(
|
|||||||
density="compact"
|
density="compact"
|
||||||
hide-details="auto"
|
hide-details="auto"
|
||||||
hint="留空表示全部来源"
|
hint="留空表示全部来源"
|
||||||
|
:menu-props="classificationRuleMenuProps"
|
||||||
:aria-label="`数据来源 ${rule.name || rule.id}`"
|
:aria-label="`数据来源 ${rule.name || rule.id}`"
|
||||||
@update:model-value="value => updateSources(index, value)"
|
@update:model-value="value => updateSources(index, value)"
|
||||||
/>
|
/>
|
||||||
@@ -507,6 +520,7 @@ watch(
|
|||||||
density="compact"
|
density="compact"
|
||||||
hide-details="auto"
|
hide-details="auto"
|
||||||
no-data-text="当前媒体类型没有可用分类"
|
no-data-text="当前媒体类型没有可用分类"
|
||||||
|
:menu-props="classificationRuleMenuProps"
|
||||||
:aria-label="`分类目标 ${rule.name || rule.id}`"
|
:aria-label="`分类目标 ${rule.name || rule.id}`"
|
||||||
@update:model-value="value => updateTarget(index, { category_id: value })"
|
@update:model-value="value => updateTarget(index, { category_id: value })"
|
||||||
/>
|
/>
|
||||||
@@ -519,6 +533,7 @@ watch(
|
|||||||
clearable
|
clearable
|
||||||
density="compact"
|
density="compact"
|
||||||
hide-details="auto"
|
hide-details="auto"
|
||||||
|
:menu-props="classificationRuleMenuProps"
|
||||||
:class="{ 'classification-rule-labels--wide': rule.kind === 'label' }"
|
:class="{ 'classification-rule-labels--wide': rule.kind === 'label' }"
|
||||||
:aria-label="`标签输出 ${rule.name || rule.id}`"
|
:aria-label="`标签输出 ${rule.name || rule.id}`"
|
||||||
@update:model-value="value => updateTarget(index, { labels: value })"
|
@update:model-value="value => updateTarget(index, { labels: value })"
|
||||||
@@ -526,7 +541,7 @@ watch(
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</VCard>
|
||||||
</template>
|
</template>
|
||||||
</Draggable>
|
</Draggable>
|
||||||
|
|
||||||
@@ -583,7 +598,16 @@ watch(
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border: 1px solid var(--classification-border, 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;
|
border-radius: 8px;
|
||||||
background: var(--classification-panel-raised, rgb(var(--v-theme-surface)));
|
background-color: var(--classification-panel-raised, rgb(var(--v-theme-surface)));
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(html[data-theme='glass'] .classification-rule) {
|
||||||
|
border-color: var(--glass-border-raised) !important;
|
||||||
|
-webkit-backdrop-filter: var(--glass-raised-backdrop-filter) !important;
|
||||||
|
backdrop-filter: var(--glass-raised-backdrop-filter) !important;
|
||||||
|
background-color: var(--glass-surface-raised) !important;
|
||||||
|
background-image: var(--glass-sheen) !important;
|
||||||
|
box-shadow: var(--glass-shadow-raised) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.classification-rule-head {
|
.classification-rule-head {
|
||||||
|
|||||||
@@ -183,7 +183,7 @@ describe('ClassificationCategoryEditor', () => {
|
|||||||
const { events } = await renderEditor()
|
const { events } = await renderEditor()
|
||||||
|
|
||||||
await user.click(screen.getByRole('combobox', { name: '音乐回退分类' }))
|
await user.click(screen.getByRole('combobox', { name: '音乐回退分类' }))
|
||||||
await user.click(await screen.findByRole('option', { name: /无损音乐.*music.lossless/ }))
|
await user.click(await screen.findByRole('option', { name: '无损音乐 · 音乐 / 专辑 / 无损' }))
|
||||||
|
|
||||||
await waitFor(() => expect(events.updateFallbacks).toHaveBeenCalledWith({ 音乐: 'music.lossless' }))
|
await waitFor(() => expect(events.updateFallbacks).toHaveBeenCalledWith({ 音乐: 'music.lossless' }))
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,14 +1,9 @@
|
|||||||
import SettingPage from '@/pages/setting.vue'
|
import SettingPage from '@/pages/setting.vue'
|
||||||
import { waitFor } from '@testing-library/vue'
|
import { waitFor } from '@testing-library/vue'
|
||||||
import { renderWithProviders } from '@tests/support/render'
|
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 type { Ref } from 'vue'
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
const settingPageSource = readFileSync(resolve(cwd(), 'src/pages/setting.vue'), 'utf8')
|
|
||||||
|
|
||||||
const mocks = vi.hoisted(() => ({
|
const mocks = vi.hoisted(() => ({
|
||||||
registerHeaderTab: vi.fn(),
|
registerHeaderTab: vi.fn(),
|
||||||
route: null as unknown as { query: { tab: string | string[] | undefined } },
|
route: null as unknown as { query: { tab: string | string[] | undefined } },
|
||||||
@@ -40,6 +35,13 @@ function registeredActiveTab(): Ref<string> {
|
|||||||
return registration!.modelValue!
|
return registration!.modelValue!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 读取页头注册项中的设置标签列表。 */
|
||||||
|
function registeredSettingTabs(): Array<{ tab: string }> {
|
||||||
|
const registration = mocks.registerHeaderTab.mock.calls[0]?.[0] as { items?: Ref<Array<{ tab: string }>> } | undefined
|
||||||
|
expect(registration?.items).toBeDefined()
|
||||||
|
return registration!.items!.value
|
||||||
|
}
|
||||||
|
|
||||||
/** 渲染设置页框架但不实例化各异步设置面板。 */
|
/** 渲染设置页框架但不实例化各异步设置面板。 */
|
||||||
async function renderSettingPage() {
|
async function renderSettingPage() {
|
||||||
return renderWithProviders(SettingPage, {
|
return renderWithProviders(SettingPage, {
|
||||||
@@ -55,7 +57,6 @@ describe('setting page', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mocks.registerHeaderTab.mockReset()
|
mocks.registerHeaderTab.mockReset()
|
||||||
mocks.route.query.tab = 'directory'
|
mocks.route.query.tab = 'directory'
|
||||||
document.documentElement.classList.remove('settings-page-header-tabs-active')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('响应有效的 route.query.tab 变化并忽略未知标签', async () => {
|
it('响应有效的 route.query.tab 变化并忽略未知标签', async () => {
|
||||||
@@ -77,23 +78,11 @@ describe('setting page', () => {
|
|||||||
await waitFor(() => expect(registeredActiveTab().value).toBe('system'))
|
await waitFor(() => expect(registeredActiveTab().value).toBe('system'))
|
||||||
})
|
})
|
||||||
|
|
||||||
it('仅在设置页活动期间启用页头样式作用域', async () => {
|
it('注册包含自动分类入口的设置标签,并保持标签值与窗口一致', async () => {
|
||||||
const { unmount } = await renderSettingPage()
|
await renderSettingPage()
|
||||||
|
|
||||||
expect(document.documentElement).toHaveClass('settings-page-header-tabs-active')
|
expect(registeredSettingTabs()).toEqual(
|
||||||
|
expect.arrayContaining([expect.objectContaining({ tab: 'classification' })]),
|
||||||
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,7 +8,6 @@ const route = useRoute()
|
|||||||
|
|
||||||
const activeTab = ref((route.query.tab as string) || '')
|
const activeTab = ref((route.query.tab as string) || '')
|
||||||
const settingTabs = computed(() => getSettingTabs(t))
|
const settingTabs = computed(() => getSettingTabs(t))
|
||||||
const SETTINGS_HEADER_SCOPE_CLASS = 'settings-page-header-tabs-active'
|
|
||||||
|
|
||||||
// 设置页的每个大类都很重,按标签页拆包,避免进入设置时一次性下载全部配置面板。
|
// 设置页的每个大类都很重,按标签页拆包,避免进入设置时一次性下载全部配置面板。
|
||||||
const AccountSettingSystem = defineAsyncComponent(() => import('@/views/setting/AccountSettingSystem.vue'))
|
const AccountSettingSystem = defineAsyncComponent(() => import('@/views/setting/AccountSettingSystem.vue'))
|
||||||
@@ -51,13 +50,6 @@ function validRouteTab(value: unknown): string | null {
|
|||||||
return typeof tab === 'string' && settingTabValues.has(tab) ? tab : 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()
|
const { registerHeaderTab } = useDynamicHeaderTab()
|
||||||
|
|
||||||
@@ -67,11 +59,6 @@ registerHeaderTab({
|
|||||||
modelValue: activeTab,
|
modelValue: activeTab,
|
||||||
})
|
})
|
||||||
|
|
||||||
onBeforeMount(() => setSettingsHeaderScope(true))
|
|
||||||
onActivated(() => setSettingsHeaderScope(true))
|
|
||||||
onDeactivated(() => setSettingsHeaderScope(false))
|
|
||||||
onUnmounted(() => setSettingsHeaderScope(false))
|
|
||||||
|
|
||||||
// 注册动态标签页
|
// 注册动态标签页
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
// 无效的深链参数不能让设置页停留在空白 VWindow。
|
// 无效的深链参数不能让设置页停留在空白 VWindow。
|
||||||
@@ -103,90 +90,3 @@ watch(
|
|||||||
</VWindow>
|
</VWindow>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</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>
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import api from '@/api'
|
|||||||
import type { TransferDirectoryConf } from '@/api/types'
|
import type { TransferDirectoryConf } from '@/api/types'
|
||||||
import type {
|
import type {
|
||||||
ClassificationCategory,
|
ClassificationCategory,
|
||||||
|
ClassificationConditionNode,
|
||||||
ClassificationEnrichmentMode,
|
ClassificationEnrichmentMode,
|
||||||
ClassificationEvaluation,
|
ClassificationEvaluation,
|
||||||
ClassificationFieldDefinition,
|
ClassificationFieldDefinition,
|
||||||
@@ -148,15 +149,47 @@ const availableSources = computed(() => {
|
|||||||
return [...sources].sort((left, right) => left.localeCompare(right))
|
return [...sources].sort((left, right) => left.localeCompare(right))
|
||||||
})
|
})
|
||||||
|
|
||||||
/** 将只读 API 字段目录复制为编辑器输入,避免组件边界泄漏深层响应式只读类型。 */
|
/** 按条件树顺序提取当前策略实际引用的字段 ID。 */
|
||||||
|
function collectConditionFieldIds(node: ClassificationConditionNode): string[] {
|
||||||
|
if ('field' in node) return [node.field]
|
||||||
|
if (node.all) return node.all.flatMap(collectConditionFieldIds)
|
||||||
|
if (node.any) return node.any.flatMap(collectConditionFieldIds)
|
||||||
|
return node.not ? collectConditionFieldIds(node.not) : []
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 识别仅为旧 TMDB 分类规则保留的来源扩展字段。 */
|
||||||
|
function isLegacyCompatibilityField(field: Readonly<Pick<ClassificationFieldDefinition, 'group' | 'label'>>): boolean {
|
||||||
|
return field.group === 'TMDB 兼容' || field.label.startsWith('TMDB 旧字段')
|
||||||
|
}
|
||||||
|
|
||||||
|
const referencedFieldIds = computed(
|
||||||
|
() => new Set((draftPolicy.value?.rules ?? []).flatMap(rule => collectConditionFieldIds(rule.when))),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** 将只读 API 字段目录复制为编辑器输入,并隐藏已迁移且未被使用的旧字段。 */
|
||||||
const editorFields = computed<ClassificationFieldDefinition[]>(() =>
|
const editorFields = computed<ClassificationFieldDefinition[]>(() =>
|
||||||
[...(fieldCatalog.value?.fields ?? []), ...(fieldCatalog.value?.retired_fields ?? [])].map(field => ({
|
[...(fieldCatalog.value?.fields ?? []), ...(fieldCatalog.value?.retired_fields ?? [])]
|
||||||
...field,
|
.filter(field => !isLegacyCompatibilityField(field) || referencedFieldIds.value.has(field.id))
|
||||||
media_types: [...field.media_types],
|
.map(field => {
|
||||||
operators: [...field.operators],
|
const legacy = isLegacyCompatibilityField(field)
|
||||||
options: field.options.map(option => ({ ...option })),
|
return {
|
||||||
source_support: { ...field.source_support },
|
...field,
|
||||||
})),
|
label: field.id === 'media.genre_keys' ? '风格' : field.label,
|
||||||
|
group: legacy ? 'TMDB 旧规则' : field.group,
|
||||||
|
selectable: legacy ? false : field.selectable,
|
||||||
|
replacement_field:
|
||||||
|
field.replacement_field ??
|
||||||
|
(field.id === 'extensions.themoviedb.genre_ids'
|
||||||
|
? 'media.genre_keys'
|
||||||
|
: field.id === 'extensions.themoviedb.origin_country'
|
||||||
|
? 'media.countries'
|
||||||
|
: null),
|
||||||
|
media_types: [...field.media_types],
|
||||||
|
operators: [...field.operators],
|
||||||
|
options: field.options.map(option => ({ ...option })),
|
||||||
|
source_support: { ...field.source_support },
|
||||||
|
}
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
/** 将 JSON API 深层只读值复制并恢复为组件 DTO,副本不会回写 composable 状态。 */
|
/** 将 JSON API 深层只读值复制并恢复为组件 DTO,副本不会回写 composable 状态。 */
|
||||||
@@ -189,17 +222,21 @@ function fallbackCategoryOptions(mediaType: ClassificationMediaType) {
|
|||||||
return (draftPolicy.value?.categories ?? [])
|
return (draftPolicy.value?.categories ?? [])
|
||||||
.filter(category => category.enabled && category.media_type === mediaType)
|
.filter(category => category.enabled && category.media_type === mediaType)
|
||||||
.map(category => ({
|
.map(category => ({
|
||||||
title: `${category.name} · ${category.path.join(' / ')} · ${category.id}`,
|
title: category.path.length ? `${category.name} · ${category.path.join(' / ')}` : category.name,
|
||||||
value: category.id,
|
value: category.id,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 将来源兜底标签传给 VSelect 的真实 combobox 激活元素。 */
|
/** 将来源兜底标签和有界浮层参数传给 VSelect。 */
|
||||||
function sourceFallbackMenuProps(source: string, mediaType: ClassificationMediaType) {
|
function sourceFallbackMenuProps(source: string, mediaType: ClassificationMediaType) {
|
||||||
return {
|
return {
|
||||||
activatorProps: {
|
activatorProps: {
|
||||||
'aria-label': t('setting.classification.sourceFallbackFor', { source, mediaType }),
|
'aria-label': t('setting.classification.sourceFallbackFor', { source, mediaType }),
|
||||||
},
|
},
|
||||||
|
contentClass: 'classification-source-fallback-menu',
|
||||||
|
maxHeight: 280,
|
||||||
|
location: 'bottom start' as const,
|
||||||
|
offset: 4,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -748,15 +785,29 @@ watch(analysisTab, tab => {
|
|||||||
|
|
||||||
.classification-settings__workspace-tabs,
|
.classification-settings__workspace-tabs,
|
||||||
.classification-settings__analysis-tabs {
|
.classification-settings__analysis-tabs {
|
||||||
|
--classification-tabs-height: 44px;
|
||||||
|
--v-tabs-height: var(--classification-tabs-height);
|
||||||
inline-size: 100%;
|
inline-size: 100%;
|
||||||
|
block-size: var(--classification-tabs-height);
|
||||||
border: 1px solid var(--classification-border);
|
border: 1px solid var(--classification-border);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: var(--classification-control);
|
background: var(--classification-control);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.classification-settings__workspace-tabs :deep(.v-slide-group),
|
||||||
|
.classification-settings__workspace-tabs :deep(.v-slide-group__container),
|
||||||
|
.classification-settings__workspace-tabs :deep(.v-slide-group__content),
|
||||||
|
.classification-settings__analysis-tabs :deep(.v-slide-group),
|
||||||
|
.classification-settings__analysis-tabs :deep(.v-slide-group__container),
|
||||||
|
.classification-settings__analysis-tabs :deep(.v-slide-group__content) {
|
||||||
|
block-size: var(--classification-tabs-height);
|
||||||
|
min-block-size: var(--classification-tabs-height);
|
||||||
|
}
|
||||||
|
|
||||||
.classification-settings__workspace-tabs :deep(.v-slide-group__content),
|
.classification-settings__workspace-tabs :deep(.v-slide-group__content),
|
||||||
.classification-settings__analysis-tabs :deep(.v-slide-group__content) {
|
.classification-settings__analysis-tabs :deep(.v-slide-group__content) {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
inline-size: 100%;
|
||||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -768,6 +819,7 @@ watch(analysisTab, tab => {
|
|||||||
.classification-settings__analysis-tabs :deep(.v-tab) {
|
.classification-settings__analysis-tabs :deep(.v-tab) {
|
||||||
min-inline-size: 0;
|
min-inline-size: 0;
|
||||||
max-inline-size: none;
|
max-inline-size: none;
|
||||||
|
inline-size: 100%;
|
||||||
min-block-size: 44px;
|
min-block-size: 44px;
|
||||||
padding-inline: 10px;
|
padding-inline: 10px;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
@@ -874,6 +926,14 @@ watch(analysisTab, tab => {
|
|||||||
border-block-end: 0;
|
border-block-end: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.classification-settings__source-list :deep(.v-expansion-panel-title) {
|
||||||
|
color: rgb(var(--v-theme-on-surface));
|
||||||
|
}
|
||||||
|
|
||||||
|
.classification-settings__source-list :deep(.v-expansion-panel-text__wrapper) {
|
||||||
|
padding: 12px 14px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
.classification-settings__source-title {
|
.classification-settings__source-title {
|
||||||
min-block-size: 52px;
|
min-block-size: 52px;
|
||||||
padding: 8px 14px;
|
padding: 8px 14px;
|
||||||
@@ -925,7 +985,7 @@ watch(analysisTab, tab => {
|
|||||||
padding: 0.75rem 1.25rem;
|
padding: 0.75rem 1.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
:global(html[data-theme='glass']) .classification-settings {
|
:global(html[data-theme='glass'] .classification-settings) {
|
||||||
--classification-border: var(--glass-border);
|
--classification-border: var(--glass-border);
|
||||||
--classification-panel: var(--glass-surface-soft);
|
--classification-panel: var(--glass-surface-soft);
|
||||||
--classification-panel-raised: var(--glass-surface);
|
--classification-panel-raised: var(--glass-surface);
|
||||||
@@ -939,15 +999,73 @@ watch(analysisTab, tab => {
|
|||||||
box-shadow: var(--glass-shadow);
|
box-shadow: var(--glass-shadow);
|
||||||
}
|
}
|
||||||
|
|
||||||
:global(html[data-theme='glass']) .classification-settings__panel,
|
:global(html[data-theme='glass'] .classification-settings__panel),
|
||||||
:global(html[data-theme='glass']) .classification-settings__source-list {
|
:global(html[data-theme='glass'] .classification-settings__source-list) {
|
||||||
-webkit-backdrop-filter: var(--glass-surface-backdrop-filter);
|
-webkit-backdrop-filter: var(--glass-surface-backdrop-filter);
|
||||||
backdrop-filter: var(--glass-surface-backdrop-filter);
|
backdrop-filter: var(--glass-surface-backdrop-filter);
|
||||||
background-image: var(--glass-sheen);
|
background-image: var(--glass-sheen);
|
||||||
box-shadow: var(--glass-control-shadow);
|
box-shadow: var(--glass-control-shadow);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:global(html[data-theme='glass'] .classification-settings__source-list) {
|
||||||
|
border-color: var(--glass-border-raised) !important;
|
||||||
|
background-color: var(--glass-surface-raised) !important;
|
||||||
|
background-image: var(--glass-sheen) !important;
|
||||||
|
box-shadow: var(--glass-shadow-raised) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(html[data-theme='glass'] .classification-settings__source-list .v-expansion-panel) {
|
||||||
|
border-color: var(--glass-border) !important;
|
||||||
|
background-color: transparent !important;
|
||||||
|
background-image: none !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.classification-source-fallback-menu),
|
||||||
|
:global(.classification-category-menu),
|
||||||
|
:global(.classification-preview-menu),
|
||||||
|
:global(.classification-condition-menu),
|
||||||
|
:global(.classification-field-menu),
|
||||||
|
:global(.classification-rule-menu) {
|
||||||
|
max-block-size: min(280px, calc(100dvh - 8rem)) !important;
|
||||||
|
overflow-y: auto !important;
|
||||||
|
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||||
|
border-radius: 8px;
|
||||||
|
background-color: rgb(var(--v-theme-surface)) !important;
|
||||||
|
color: rgb(var(--v-theme-on-surface));
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.24);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.classification-source-fallback-menu .v-list),
|
||||||
|
:global(.classification-category-menu .v-list),
|
||||||
|
:global(.classification-preview-menu .v-list),
|
||||||
|
:global(.classification-condition-menu .v-list),
|
||||||
|
:global(.classification-field-menu .v-list),
|
||||||
|
:global(.classification-rule-menu .v-list) {
|
||||||
|
background: transparent;
|
||||||
|
padding-block: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(html[data-theme='glass'] .classification-source-fallback-menu),
|
||||||
|
:global(html[data-theme='glass'] .classification-category-menu),
|
||||||
|
:global(html[data-theme='glass'] .classification-preview-menu),
|
||||||
|
:global(html[data-theme='glass'] .classification-condition-menu),
|
||||||
|
:global(html[data-theme='glass'] .classification-field-menu),
|
||||||
|
:global(html[data-theme='glass'] .classification-rule-menu) {
|
||||||
|
border-color: var(--glass-border-raised) !important;
|
||||||
|
-webkit-backdrop-filter: var(--glass-overlay-backdrop-filter) !important;
|
||||||
|
backdrop-filter: var(--glass-overlay-backdrop-filter) !important;
|
||||||
|
background-color: var(--glass-overlay-surface) !important;
|
||||||
|
background-image: var(--glass-sheen) !important;
|
||||||
|
box-shadow: var(--glass-shadow-raised) !important;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 599px) {
|
@media (max-width: 599px) {
|
||||||
|
.classification-settings__workspace-tabs,
|
||||||
|
.classification-settings__analysis-tabs {
|
||||||
|
--classification-tabs-height: 42px;
|
||||||
|
}
|
||||||
|
|
||||||
.classification-settings :deep(.v-card-item) {
|
.classification-settings :deep(.v-card-item) {
|
||||||
padding: 14px 12px 10px;
|
padding: 14px 12px 10px;
|
||||||
}
|
}
|
||||||
@@ -1004,6 +1122,11 @@ watch(analysisTab, tab => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 420px) {
|
@media (max-width: 420px) {
|
||||||
|
.classification-settings__workspace-tabs,
|
||||||
|
.classification-settings__analysis-tabs {
|
||||||
|
--classification-tabs-height: 54px;
|
||||||
|
}
|
||||||
|
|
||||||
.classification-settings__workspace-tabs :deep(.v-tab),
|
.classification-settings__workspace-tabs :deep(.v-tab),
|
||||||
.classification-settings__analysis-tabs :deep(.v-tab) {
|
.classification-settings__analysis-tabs :deep(.v-tab) {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
import type { ClassificationImpactAnalysis, ClassificationPolicy } from '@/api/mediaClassificationTypes'
|
import type {
|
||||||
|
ClassificationFieldDefinition,
|
||||||
|
ClassificationImpactAnalysis,
|
||||||
|
ClassificationPolicy,
|
||||||
|
} from '@/api/mediaClassificationTypes'
|
||||||
import AccountSettingClassification from '@/views/setting/AccountSettingClassification.vue'
|
import AccountSettingClassification from '@/views/setting/AccountSettingClassification.vue'
|
||||||
import userEvent from '@testing-library/user-event'
|
import userEvent from '@testing-library/user-event'
|
||||||
import { screen, waitFor } from '@testing-library/vue'
|
import { screen, waitFor } from '@testing-library/vue'
|
||||||
@@ -64,10 +68,15 @@ vi.mock('@/components/classification/ClassificationRuleEditor.vue', async () =>
|
|||||||
return {
|
return {
|
||||||
default: defineComponent({
|
default: defineComponent({
|
||||||
name: 'ClassificationRuleEditorStub',
|
name: 'ClassificationRuleEditorStub',
|
||||||
props: { rules: { type: Array, required: true } },
|
props: {
|
||||||
|
rules: { type: Array, required: true },
|
||||||
|
fields: { type: Array, required: true },
|
||||||
|
},
|
||||||
emits: ['update:rules'],
|
emits: ['update:rules'],
|
||||||
template: `
|
template: `
|
||||||
<section aria-label="rule-editor">
|
<section aria-label="rule-editor">
|
||||||
|
<output aria-label="editor-field-ids">{{ fields.map(field => field.id).join(',') }}</output>
|
||||||
|
<output aria-label="editor-field-labels">{{ fields.map(field => field.label).join(',') }}</output>
|
||||||
<button aria-label="replace-rules" @click="$emit('update:rules', [{ ...rules[0], name: '新规则' }])">rules</button>
|
<button aria-label="replace-rules" @click="$emit('update:rules', [{ ...rules[0], name: '新规则' }])">rules</button>
|
||||||
</section>
|
</section>
|
||||||
`,
|
`,
|
||||||
@@ -174,6 +183,21 @@ function createPolicy(): ClassificationPolicy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 构造迁移后只应在旧规则仍引用时保留的 TMDB 兼容字段。 */
|
||||||
|
function createLegacyTmdbField(): ClassificationFieldDefinition {
|
||||||
|
return {
|
||||||
|
id: 'extensions.themoviedb.genre_ids',
|
||||||
|
label: 'TMDB 旧字段 genre_ids',
|
||||||
|
group: 'TMDB 兼容',
|
||||||
|
value_type: 'string_list',
|
||||||
|
operators: ['contains_any'],
|
||||||
|
media_types: ['电影'],
|
||||||
|
options: [],
|
||||||
|
allow_custom_values: true,
|
||||||
|
source_support: { themoviedb: 'extension' },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** 构造与活动 revision 对齐的有界影响分析结果。 */
|
/** 构造与活动 revision 对齐的有界影响分析结果。 */
|
||||||
function createImpact(): ClassificationImpactAnalysis {
|
function createImpact(): ClassificationImpactAnalysis {
|
||||||
return {
|
return {
|
||||||
@@ -263,6 +287,18 @@ describe('AccountSettingClassification', () => {
|
|||||||
allow_custom_values: false,
|
allow_custom_values: false,
|
||||||
source_support: { themoviedb: 'native', musicbrainz: 'native' },
|
source_support: { themoviedb: 'native', musicbrainz: 'native' },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'media.genre_keys',
|
||||||
|
label: '规范类型',
|
||||||
|
group: '通用',
|
||||||
|
value_type: 'string_list',
|
||||||
|
operators: ['contains_any'],
|
||||||
|
media_types: ['电影', '电视剧', '音乐'],
|
||||||
|
options: [],
|
||||||
|
allow_custom_values: true,
|
||||||
|
source_support: { themoviedb: 'derived', musicbrainz: 'derived' },
|
||||||
|
},
|
||||||
|
createLegacyTmdbField(),
|
||||||
],
|
],
|
||||||
limits: {
|
limits: {
|
||||||
max_category_depth: 4,
|
max_category_depth: 4,
|
||||||
@@ -334,6 +370,29 @@ describe('AccountSettingClassification', () => {
|
|||||||
expect(screen.getByLabelText('directory-references')).toHaveTextContent('电影目录')
|
expect(screen.getByLabelText('directory-references')).toHaveTextContent('电影目录')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('hides migrated TMDB fields but keeps a field visible while an old rule still references it', async () => {
|
||||||
|
await renderWithProviders(AccountSettingClassification)
|
||||||
|
await openWorkspace('规则')
|
||||||
|
|
||||||
|
expect(screen.getByLabelText('editor-field-ids')).toHaveTextContent('media.type,media.genre_keys')
|
||||||
|
expect(screen.getByLabelText('editor-field-ids')).not.toHaveTextContent('extensions.themoviedb.genre_ids')
|
||||||
|
expect(screen.getByLabelText('editor-field-labels')).toHaveTextContent('风格')
|
||||||
|
|
||||||
|
const state = mocks.useMediaClassification.mock.results[0].value
|
||||||
|
state.draftPolicy.value = {
|
||||||
|
...state.draftPolicy.value,
|
||||||
|
rules: [
|
||||||
|
{
|
||||||
|
...state.draftPolicy.value.rules[0],
|
||||||
|
when: { field: 'extensions.themoviedb.genre_ids', operator: 'contains_any', value: ['16'] },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(screen.getByLabelText('editor-field-ids')).toHaveTextContent('extensions.themoviedb.genre_ids')
|
||||||
|
})
|
||||||
|
|
||||||
it('switches missing-fact enrichment through the policy draft', async () => {
|
it('switches missing-fact enrichment through the policy draft', async () => {
|
||||||
const user = userEvent.setup()
|
const user = userEvent.setup()
|
||||||
await renderWithProviders(AccountSettingClassification)
|
await renderWithProviders(AccountSettingClassification)
|
||||||
@@ -385,7 +444,7 @@ describe('AccountSettingClassification', () => {
|
|||||||
name: 'musicbrainz 的电影来源兜底',
|
name: 'musicbrainz 的电影来源兜底',
|
||||||
})
|
})
|
||||||
await user.click(musicbrainzFallback)
|
await user.click(musicbrainzFallback)
|
||||||
await user.click(await screen.findByRole('option', { name: '电影 · 电影 · movie.base' }))
|
await user.click(await screen.findByRole('option', { name: '电影 · 电影' }))
|
||||||
|
|
||||||
const state = mocks.useMediaClassification.mock.results[0].value
|
const state = mocks.useMediaClassification.mock.results[0].value
|
||||||
expect(state.draftPolicy.value.source_fallbacks.musicbrainz.电影).toBe('movie.base')
|
expect(state.draftPolicy.value.source_fallbacks.musicbrainz.电影).toBe('movie.base')
|
||||||
|
|||||||
Reference in New Issue
Block a user