fix(classification): polish settings controls (#748)

This commit is contained in:
jxxghp
2026-09-03 17:28:26 +08:00
committed by GitHub
parent 6b8c0bf4a8
commit 2b643cfd36
10 changed files with 331 additions and 194 deletions
@@ -139,10 +139,10 @@ function isCategoryProtected(categoryId: string): boolean {
return categoryReferenceReasons(categoryId).length > 0
}
/** 为稳定 ID fallback 选择器生成可辨识的标题。 */
/** 为 fallback 选择器生成不带内部稳定 ID 的可读标题。 */
function fallbackItemTitle(category: ClassificationCategory): string {
const path = category.path.length ? category.path.join(' / ') : t('setting.classification.category.pathUnset')
return `${category.name} · ${path} · ${category.id}`
return `${category.name} · ${path}`
}
/** 返回指定媒体类型可选的稳定分类 ID 列表。 */
@@ -150,9 +150,15 @@ function fallbackItems(mediaType: ClassificationMediaType): ClassificationCatego
return props.categories.filter(category => category.media_type === mediaType)
}
/** 将业务标签传给 VSelect 的真实 combobox 激活元素。 */
function comboboxMenuProps(label: string): { activatorProps: { 'aria-label': string } } {
return { activatorProps: { 'aria-label': label } }
/** 将业务标签和有界浮层参数传给分类选择器。 */
function comboboxMenuProps(label: string) {
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: '不存在',
}
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 {
return 'field' in node && 'operator' in node
@@ -441,7 +454,7 @@ function removeChild(index: number): void {
density="compact"
hide-details="auto"
auto-select-first
:menu-props="{ contentClass: 'classification-field-menu', maxWidth: 420 }"
:menu-props="classificationFieldMenuProps"
data-testid="field-select"
@update:model-value="updateField"
/>
@@ -454,6 +467,7 @@ function removeChild(index: number): void {
variant="outlined"
density="compact"
hide-details="auto"
:menu-props="classificationMenuProps"
data-testid="operator-select"
@update:model-value="updateOperator"
/>
@@ -508,6 +522,7 @@ function removeChild(index: number): void {
variant="outlined"
density="compact"
hide-details="auto"
:menu-props="classificationMenuProps"
data-testid="list-value-input"
@update:model-value="updateCatalogListValue"
/>
@@ -523,6 +538,7 @@ function removeChild(index: number): void {
variant="outlined"
density="compact"
hide-details="auto"
:menu-props="classificationMenuProps"
data-testid="list-value-input"
@update:model-value="updateListValue"
/>
@@ -567,6 +583,7 @@ function removeChild(index: number): void {
variant="outlined"
density="compact"
hide-details="auto"
:menu-props="classificationMenuProps"
data-testid="select-value-input"
@update:model-value="updateCatalogScalarValue"
/>
@@ -579,6 +596,7 @@ function removeChild(index: number): void {
variant="outlined"
density="compact"
hide-details="auto"
:menu-props="classificationMenuProps"
data-testid="select-value-input"
@update:model-value="updateScalarValue"
/>
@@ -779,7 +797,8 @@ function removeChild(index: number): void {
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;
text-overflow: clip;
white-space: normal;
@@ -410,45 +410,47 @@ watch(
{{ t('setting.classification.control.historyEmpty') }}
</div>
<fieldset v-else class="classification-policy-control__history-list">
<legend class="sr-only">{{ t('setting.classification.control.selectHistoryLegend') }}</legend>
<label
<VRadioGroup
v-else
v-model="selectedRevision"
class="classification-policy-control__history-list"
hide-details
:aria-label="t('setting.classification.control.selectHistoryLegend')"
>
<VRadio
v-for="policy in historyItems"
:key="policy.revision"
class="classification-policy-control__history-row"
:class="{ 'classification-policy-control__history-row--selected': selectedRevision === policy.revision }"
:data-testid="'classification-history-revision-' + policy.revision"
:value="policy.revision"
:aria-label="
t('setting.classification.control.selectHistoryAria', {
revision: policy.revision,
categories: policy.categories.length,
rules: policy.rules.length,
})
"
>
<input
v-model="selectedRevision"
type="radio"
name="classification-history-revision"
:value="policy.revision"
:aria-label="
t('setting.classification.control.selectHistoryAria', {
revision: policy.revision,
categories: policy.categories.length,
rules: policy.rules.length,
})
"
/>
<span class="classification-policy-control__history-summary">
<span class="classification-policy-control__history-title">
<strong>revision {{ policy.revision }}</strong>
<time v-if="policy.updated_at" :datetime="policy.updated_at">{{
formatUpdatedAt(policy.updated_at)
}}</time>
<span v-else>{{ t('setting.classification.control.updatedAtUnknown') }}</span>
<template #label>
<span class="classification-policy-control__history-summary">
<span class="classification-policy-control__history-title">
<strong>revision {{ policy.revision }}</strong>
<time v-if="policy.updated_at" :datetime="policy.updated_at">{{
formatUpdatedAt(policy.updated_at)
}}</time>
<span v-else>{{ t('setting.classification.control.updatedAtUnknown') }}</span>
</span>
<span class="classification-policy-control__history-counts">
<span>{{
t('setting.classification.control.categoryCount', { count: policy.categories.length })
}}</span>
<span>{{ t('setting.classification.control.ruleCount', { count: policy.rules.length }) }}</span>
</span>
</span>
<span class="classification-policy-control__history-counts">
<span>{{
t('setting.classification.control.categoryCount', { count: policy.categories.length })
}}</span>
<span>{{ t('setting.classification.control.ruleCount', { count: policy.rules.length }) }}</span>
</span>
</span>
</label>
</fieldset>
</template>
</VRadio>
</VRadioGroup>
<VAlert density="compact" type="info" variant="tonal">
{{ t('setting.classification.control.rollbackNotice') }}
@@ -593,10 +595,14 @@ watch(
border: 0;
}
.classification-policy-control__history-row {
.classification-policy-control__history-list :deep(.v-selection-control-group) {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 12px;
gap: 8px;
}
.classification-policy-control__history-row {
display: flex;
width: 100%;
align-items: center;
min-inline-size: 0;
padding: 12px;
@@ -605,6 +611,17 @@ watch(
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:focus-within,
.classification-policy-control__history-row--selected {
@@ -612,12 +629,6 @@ watch(
background: rgba(var(--v-theme-primary), 0.06);
}
.classification-policy-control__history-row input {
inline-size: 18px;
block-size: 18px;
accent-color: rgb(var(--v-theme-primary));
}
.classification-policy-control__history-summary,
.classification-policy-control__history-title,
.classification-policy-control__history-counts {
@@ -103,9 +103,15 @@ const fieldGroups = computed<ClassificationPreviewFieldGroup[]>(() => {
const categoryMap = computed(() => new Map(props.categories.map(category => [category.id, category])))
/** 为 VSelect 与 VCombobox 的真实激活元素提供业务标签。 */
function comboboxMenuProps(label: string): { activatorProps: { 'aria-label': string } } {
return { activatorProps: { 'aria-label': label } }
/** 为 VSelect 与 VCombobox 提供业务标签和有界浮层参数。 */
function comboboxMenuProps(label: string) {
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 DEFAULT_MAX_RULES = 200
const DEFAULT_MAX_CONDITION_DEPTH = 8
const classificationRuleMenuProps = {
contentClass: 'classification-rule-menu',
maxHeight: 280,
location: 'bottom start' as const,
offset: 4,
}
const props = withDefaults(
defineProps<{
@@ -330,7 +336,12 @@ watch(
:component-data="{ class: 'classification-rule-list' }"
>
<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">
<IconBtn
class="classification-rule-drag cursor-move"
@@ -464,6 +475,7 @@ watch(
clearable
density="compact"
hide-details="auto"
:menu-props="classificationRuleMenuProps"
:aria-label="`媒体类型 ${rule.name || rule.id}`"
@update:model-value="value => updateMediaTypes(index, value)"
/>
@@ -478,6 +490,7 @@ watch(
density="compact"
hide-details="auto"
hint="留空表示全部来源"
:menu-props="classificationRuleMenuProps"
:aria-label="`数据来源 ${rule.name || rule.id}`"
@update:model-value="value => updateSources(index, value)"
/>
@@ -507,6 +520,7 @@ watch(
density="compact"
hide-details="auto"
no-data-text="当前媒体类型没有可用分类"
:menu-props="classificationRuleMenuProps"
:aria-label="`分类目标 ${rule.name || rule.id}`"
@update:model-value="value => updateTarget(index, { category_id: value })"
/>
@@ -519,6 +533,7 @@ watch(
clearable
density="compact"
hide-details="auto"
:menu-props="classificationRuleMenuProps"
:class="{ 'classification-rule-labels--wide': rule.kind === 'label' }"
:aria-label="`标签输出 ${rule.name || rule.id}`"
@update:model-value="value => updateTarget(index, { labels: value })"
@@ -526,7 +541,7 @@ watch(
</div>
</div>
</div>
</article>
</VCard>
</template>
</Draggable>
@@ -583,7 +598,16 @@ watch(
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)));
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 {
@@ -183,7 +183,7 @@ describe('ClassificationCategoryEditor', () => {
const { events } = await renderEditor()
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' }))
})
+12 -23
View File
@@ -1,14 +1,9 @@
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 } },
@@ -40,6 +35,13 @@ function registeredActiveTab(): Ref<string> {
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() {
return renderWithProviders(SettingPage, {
@@ -55,7 +57,6 @@ 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 () => {
@@ -77,23 +78,11 @@ describe('setting page', () => {
await waitFor(() => expect(registeredActiveTab().value).toBe('system'))
})
it('仅在设置页活动期间启用页头样式作用域', async () => {
const { unmount } = await renderSettingPage()
it('注册包含自动分类入口的设置标签,并保持标签值与窗口一致', async () => {
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)')
expect(registeredSettingTabs()).toEqual(
expect.arrayContaining([expect.objectContaining({ tab: 'classification' })]),
)
})
})
-100
View File
@@ -8,7 +8,6 @@ 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'))
@@ -51,13 +50,6 @@ 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()
@@ -67,11 +59,6 @@ registerHeaderTab({
modelValue: activeTab,
})
onBeforeMount(() => setSettingsHeaderScope(true))
onActivated(() => setSettingsHeaderScope(true))
onDeactivated(() => setSettingsHeaderScope(false))
onUnmounted(() => setSettingsHeaderScope(false))
// 注册动态标签页
onMounted(() => {
// 无效的深链参数不能让设置页停留在空白 VWindow。
@@ -103,90 +90,3 @@ 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>
@@ -3,6 +3,7 @@ import api from '@/api'
import type { TransferDirectoryConf } from '@/api/types'
import type {
ClassificationCategory,
ClassificationConditionNode,
ClassificationEnrichmentMode,
ClassificationEvaluation,
ClassificationFieldDefinition,
@@ -148,15 +149,47 @@ const availableSources = computed(() => {
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[]>(() =>
[...(fieldCatalog.value?.fields ?? []), ...(fieldCatalog.value?.retired_fields ?? [])].map(field => ({
...field,
media_types: [...field.media_types],
operators: [...field.operators],
options: field.options.map(option => ({ ...option })),
source_support: { ...field.source_support },
})),
[...(fieldCatalog.value?.fields ?? []), ...(fieldCatalog.value?.retired_fields ?? [])]
.filter(field => !isLegacyCompatibilityField(field) || referencedFieldIds.value.has(field.id))
.map(field => {
const legacy = isLegacyCompatibilityField(field)
return {
...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 状态。 */
@@ -189,17 +222,21 @@ function fallbackCategoryOptions(mediaType: ClassificationMediaType) {
return (draftPolicy.value?.categories ?? [])
.filter(category => category.enabled && category.media_type === mediaType)
.map(category => ({
title: `${category.name} · ${category.path.join(' / ')} · ${category.id}`,
title: category.path.length ? `${category.name} · ${category.path.join(' / ')}` : category.name,
value: category.id,
}))
}
/** 将来源兜底标签传给 VSelect 的真实 combobox 激活元素。 */
/** 将来源兜底标签和有界浮层参数传给 VSelect。 */
function sourceFallbackMenuProps(source: string, mediaType: ClassificationMediaType) {
return {
activatorProps: {
'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__analysis-tabs {
--classification-tabs-height: 44px;
--v-tabs-height: var(--classification-tabs-height);
inline-size: 100%;
block-size: var(--classification-tabs-height);
border: 1px solid var(--classification-border);
border-radius: 8px;
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__analysis-tabs :deep(.v-slide-group__content) {
display: grid;
inline-size: 100%;
grid-template-columns: repeat(4, minmax(0, 1fr));
}
@@ -768,6 +819,7 @@ watch(analysisTab, tab => {
.classification-settings__analysis-tabs :deep(.v-tab) {
min-inline-size: 0;
max-inline-size: none;
inline-size: 100%;
min-block-size: 44px;
padding-inline: 10px;
font-size: 0.875rem;
@@ -874,6 +926,14 @@ watch(analysisTab, tab => {
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 {
min-block-size: 52px;
padding: 8px 14px;
@@ -925,7 +985,7 @@ watch(analysisTab, tab => {
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-panel: var(--glass-surface-soft);
--classification-panel-raised: var(--glass-surface);
@@ -939,15 +999,73 @@ watch(analysisTab, tab => {
box-shadow: var(--glass-shadow);
}
:global(html[data-theme='glass']) .classification-settings__panel,
:global(html[data-theme='glass']) .classification-settings__source-list {
: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);
}
: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) {
.classification-settings__workspace-tabs,
.classification-settings__analysis-tabs {
--classification-tabs-height: 42px;
}
.classification-settings :deep(.v-card-item) {
padding: 14px 12px 10px;
}
@@ -1004,6 +1122,11 @@ watch(analysisTab, tab => {
}
@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__analysis-tabs :deep(.v-tab) {
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 userEvent from '@testing-library/user-event'
import { screen, waitFor } from '@testing-library/vue'
@@ -64,10 +68,15 @@ vi.mock('@/components/classification/ClassificationRuleEditor.vue', async () =>
return {
default: defineComponent({
name: 'ClassificationRuleEditorStub',
props: { rules: { type: Array, required: true } },
props: {
rules: { type: Array, required: true },
fields: { type: Array, required: true },
},
emits: ['update:rules'],
template: `
<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>
</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 对齐的有界影响分析结果。 */
function createImpact(): ClassificationImpactAnalysis {
return {
@@ -263,6 +287,18 @@ describe('AccountSettingClassification', () => {
allow_custom_values: false,
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: {
max_category_depth: 4,
@@ -334,6 +370,29 @@ describe('AccountSettingClassification', () => {
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 () => {
const user = userEvent.setup()
await renderWithProviders(AccountSettingClassification)
@@ -385,7 +444,7 @@ describe('AccountSettingClassification', () => {
name: 'musicbrainz 的电影来源兜底',
})
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
expect(state.draftPolicy.value.source_fallbacks.musicbrainz.).toBe('movie.base')