mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-08 17:26:41 +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
|
||||
}
|
||||
|
||||
/** 为稳定 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' }))
|
||||
})
|
||||
|
||||
@@ -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' })]),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user