From 2a01a2ffe032dec0423ecab3c4a33e13067fa105 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Sun, 9 Aug 2026 23:27:41 +0800 Subject: [PATCH] feat(discover): persist tab visibility settings --- .../dialog/DiscoverTabOrderDialog.vue | 126 ++++++---- .../__tests__/DiscoverTabOrderDialog.spec.ts | 36 ++- src/locales/en-US.ts | 7 +- src/locales/zh-CN.ts | 7 +- src/locales/zh-TW.ts | 7 +- src/pages/__tests__/discover.spec.ts | 173 ++++++++++++-- src/pages/discover.vue | 215 ++++++++++++------ tests/support/msw/handlers/discover.ts | 10 +- 8 files changed, 434 insertions(+), 147 deletions(-) diff --git a/src/components/dialog/DiscoverTabOrderDialog.vue b/src/components/dialog/DiscoverTabOrderDialog.vue index 71d347bd..34f2c37c 100644 --- a/src/components/dialog/DiscoverTabOrderDialog.vue +++ b/src/components/dialog/DiscoverTabOrderDialog.vue @@ -9,6 +9,7 @@ const display = useDisplay() const props = withDefaults( defineProps<{ + enabled: Record modelValue?: boolean tabs: DiscoverSource[] }>(), @@ -19,11 +20,12 @@ const props = withDefaults( const emit = defineEmits<{ (event: 'close'): void - (event: 'save', tabs: DiscoverSource[]): void + (event: 'save', payload: { enabled: Record; tabs: DiscoverSource[] }): void (event: 'update:modelValue', value: boolean): void }>() const localTabs = ref([]) +const localEnabled = ref>({}) const visible = computed({ get: () => props.modelValue, @@ -34,21 +36,39 @@ const visible = computed({ }) watch( - () => props.tabs, + [() => props.tabs, () => props.enabled], () => { - resetLocalTabs() + resetLocalSettings() }, { deep: true, immediate: true }, ) -// 重置弹窗内部排序副本。 -function resetLocalTabs() { +// 重置弹窗内部设置副本,避免拖拽与开关操作直接修改父级状态。 +function resetLocalSettings() { localTabs.value = props.tabs.map(item => ({ ...item })) + localEnabled.value = Object.fromEntries( + props.tabs.map(item => [item.mediaid_prefix, props.enabled[item.mediaid_prefix] !== false]), + ) } -// 保存当前拖拽后的发现标签顺序。 -function submitOrder() { - emit('save', localTabs.value) +// 切换单个发现标签的显示状态。 +function toggleTab(tab: DiscoverSource) { + localEnabled.value[tab.mediaid_prefix] = !localEnabled.value[tab.mediaid_prefix] +} + +// 批量设置全部发现标签的显示状态。 +function setAllTabs(enabled: boolean) { + localTabs.value.forEach(tab => { + localEnabled.value[tab.mediaid_prefix] = enabled + }) +} + +// 保存当前拖拽顺序与显示状态。 +function submitSettings() { + emit('save', { + enabled: { ...localEnabled.value }, + tabs: localTabs.value, + }) } @@ -64,14 +84,14 @@ function submitOrder() { - - {{ t('discover.setTabOrder') }} + + {{ t('discover.customizeTabs') }} -

{{ t('discover.dragToReorder') }}

+

{{ t('discover.configureTabsHint') }}

+ + {{ t('discover.selectAll') }} + + + {{ t('discover.selectNone') }} + - + @@ -122,28 +158,16 @@ function submitOrder() { } .setting-item { - position: relative; - overflow: hidden; + display: flex; + align-items: stretch; min-block-size: 48px; border: 1px solid rgba(var(--v-theme-on-surface), 0.1); - border-radius: 10px; + border-radius: 8px; background-color: rgba(var(--v-theme-on-surface), 0.04); - cursor: grab; - padding-block: 10px; - padding-inline: 12px; - transition: border-color 0.2s ease, background-color 0.2s ease, transform 0.2s ease; -} - -.setting-item::before { - position: absolute; - background-color: rgb(var(--v-theme-primary)); - block-size: 100%; - content: ''; - inline-size: 3px; - inset-block-start: 0; - inset-inline-start: 0; - opacity: 0; - transition: opacity 0.2s ease; + transition: + border-color 0.2s ease, + background-color 0.2s ease, + transform 0.2s ease; } .setting-item.enabled { @@ -151,24 +175,35 @@ function submitOrder() { background-color: rgba(var(--v-theme-primary), 0.08); } -.setting-item.enabled::before { - opacity: 1; -} - .setting-item:hover { border-color: rgba(var(--v-theme-primary), 0.32); background-color: rgba(var(--v-theme-primary), 0.06); } .setting-item:active { - cursor: grabbing; transform: scale(0.99); } -.setting-item-inner { +.setting-toggle { + appearance: none; display: flex; + flex: 1; align-items: center; gap: 10px; + min-inline-size: 0; + border: 0; + background: transparent; + cursor: pointer; + font: inherit; + padding-block: 10px; + padding-inline: 12px 6px; + text-align: start; +} + +.setting-toggle:focus-visible { + border-radius: 7px; + outline: 3px solid rgba(var(--v-theme-primary), 0.28); + outline-offset: 2px; } .setting-label { @@ -185,11 +220,18 @@ function submitOrder() { } .drag-icon { + align-self: center; flex-shrink: 0; color: rgba(var(--v-theme-on-surface), 0.52); + cursor: grab; + margin-inline-end: 10px; transition: color 0.2s ease; } +.drag-icon:active { + cursor: grabbing; +} + .setting-item:hover .drag-icon { color: rgb(var(--v-theme-primary)); } diff --git a/src/components/dialog/__tests__/DiscoverTabOrderDialog.spec.ts b/src/components/dialog/__tests__/DiscoverTabOrderDialog.spec.ts index 37b55468..145d8b30 100644 --- a/src/components/dialog/__tests__/DiscoverTabOrderDialog.spec.ts +++ b/src/components/dialog/__tests__/DiscoverTabOrderDialog.spec.ts @@ -55,12 +55,16 @@ function createSource(name: string, prefix: string): DiscoverSource { } } -async function renderDialog(tabs: DiscoverSource[]) { +async function renderDialog( + tabs: DiscoverSource[], + enabled = Object.fromEntries(tabs.map(tab => [tab.mediaid_prefix, true])), +) { const close = vi.fn() const save = vi.fn() const updateModelValue = vi.fn() const result = await renderWithProviders(DiscoverTabOrderDialog, { props: { + enabled, modelValue: true, onClose: close, onSave: save, @@ -89,7 +93,7 @@ describe('DiscoverTabOrderDialog', () => { expect(tabs).toEqual(originalOrder) expect(save).toHaveBeenCalledOnce() - const savedTabs = save.mock.calls[0][0] as DiscoverSource[] + const savedTabs = (save.mock.calls[0][0] as { tabs: DiscoverSource[] }).tabs expect(savedTabs.map(item => item.mediaid_prefix)).toEqual(['source-b', 'source-a']) expect(savedTabs[0]).not.toBe(tabs[1]) expect(savedTabs[0].filter_params).toStrictEqual(tabs[1].filter_params) @@ -104,15 +108,35 @@ describe('DiscoverTabOrderDialog', () => { await user.click(screen.getByRole('button', { name: '反转顺序' })) await rerender({ + enabled: { 'source-c': true, 'source-d': true }, modelValue: true, tabs: [createSource('来源丙', 'source-c'), createSource('来源丁', 'source-d')], }) await user.click(screen.getByRole('button', { name: '保存' })) - const savedTabs = save.mock.calls[0][0] as DiscoverSource[] + const savedTabs = (save.mock.calls[0][0] as { tabs: DiscoverSource[] }).tabs expect(savedTabs.map(item => item.mediaid_prefix)).toEqual(['source-c', 'source-d']) }) + it('toggles individual tabs and supports the same bulk choices as recommendation settings', async () => { + const tabs = [createSource('来源甲', 'source-a'), createSource('来源乙', 'source-b')] + const user = userEvent.setup() + const { save } = await renderDialog(tabs, { 'source-a': true, 'source-b': false }) + + expect(screen.getByRole('button', { name: '来源甲' })).toHaveAttribute('aria-pressed', 'true') + expect(screen.getByRole('button', { name: '来源乙' })).toHaveAttribute('aria-pressed', 'false') + + await user.click(screen.getByRole('button', { name: '来源甲' })) + await user.click(screen.getByRole('button', { name: '全选' })) + await user.click(screen.getByRole('button', { name: '来源乙' })) + await user.click(screen.getByRole('button', { name: '保存' })) + + expect(save).toHaveBeenCalledWith({ + enabled: { 'source-a': true, 'source-b': false }, + tabs: expect.any(Array), + }) + }) + it('emits both model closure and close from the close control', async () => { const user = userEvent.setup() const { close, updateModelValue } = await renderDialog([createSource('来源甲', 'source-a')]) @@ -124,13 +148,17 @@ describe('DiscoverTabOrderDialog', () => { expect(close).toHaveBeenCalledOnce() }) - it('emits only the current local order when saving', async () => { + it('emits only the current local settings when saving', async () => { const user = userEvent.setup() const { close, save, updateModelValue } = await renderDialog([createSource('来源甲', 'source-a')]) await user.click(screen.getByRole('button', { name: '保存' })) expect(save).toHaveBeenCalledOnce() + expect(save).toHaveBeenCalledWith({ + enabled: { 'source-a': true }, + tabs: [expect.objectContaining({ mediaid_prefix: 'source-a' })], + }) expect(close).not.toHaveBeenCalled() expect(updateModelValue).not.toHaveBeenCalled() }) diff --git a/src/locales/en-US.ts b/src/locales/en-US.ts index 8d5463c0..d9eafd10 100644 --- a/src/locales/en-US.ts +++ b/src/locales/en-US.ts @@ -1378,8 +1378,11 @@ export default { selectNone: 'Select None', }, discover: { - setTabOrder: 'Set Tab Order', - dragToReorder: 'Drag to reorder tabs', + customizeTabs: 'Customize Explore Tabs', + configureTabsHint: 'Drag to reorder tabs and choose which tabs to display', + selectAll: 'Select All', + selectNone: 'Select None', + saveSettingsFailed: 'Failed to save explore tab settings. Please try again later.', }, downloading: { noDownloader: 'No Downloader', diff --git a/src/locales/zh-CN.ts b/src/locales/zh-CN.ts index 736bda3a..0400e97c 100644 --- a/src/locales/zh-CN.ts +++ b/src/locales/zh-CN.ts @@ -1368,8 +1368,11 @@ export default { selectNone: '全不选', }, discover: { - setTabOrder: '设置标签顺序', - dragToReorder: '拖动对标签页进行排序', + customizeTabs: '自定义探索标签', + configureTabsHint: '拖动调整标签顺序,并选择要显示的标签', + selectAll: '全选', + selectNone: '全不选', + saveSettingsFailed: '探索标签设置保存失败,请稍后重试', }, downloading: { noDownloader: '没有下载器', diff --git a/src/locales/zh-TW.ts b/src/locales/zh-TW.ts index 68bb546a..a99d4b02 100644 --- a/src/locales/zh-TW.ts +++ b/src/locales/zh-TW.ts @@ -1366,8 +1366,11 @@ export default { selectNone: '全不選', }, discover: { - setTabOrder: '設置標籤順序', - dragToReorder: '拖動對標籤頁進行排序', + customizeTabs: '自定義探索標籤', + configureTabsHint: '拖動調整標籤順序,並選擇要顯示的標籤', + selectAll: '全選', + selectNone: '全不選', + saveSettingsFailed: '探索標籤設置保存失敗,請稍後重試', }, downloading: { noDownloader: '沒有下載器', diff --git a/src/pages/__tests__/discover.spec.ts b/src/pages/__tests__/discover.spec.ts index 96bc8141..6945332e 100644 --- a/src/pages/__tests__/discover.spec.ts +++ b/src/pages/__tests__/discover.spec.ts @@ -1,5 +1,6 @@ import DiscoverPage from '@/pages/discover.vue' import type { DiscoverSource } from '@/api/types' +import { DEFAULT_PERMISSIONS } from '@/utils/permission' import { fireEvent, waitFor } from '@testing-library/vue' import { renderWithProviders } from '@tests/support/render' import { @@ -7,7 +8,7 @@ import { discoverOrderConfigHandler, discoverSourcesHandler, saveDiscoverOrderHandler, - type DiscoverOrderItem, + type DiscoverTabConfigItem, } from '@tests/support/msw/handlers/discover' import { server } from '@tests/support/msw/server' import { HttpResponse, http } from 'msw' @@ -20,14 +21,19 @@ interface HeaderTabItem { } interface HeaderTabConfig { - appendButtons: Array<{ action: () => void }> + appendButtons?: Array<{ action: () => void }> items: ComputedRef | Ref | HeaderTabItem[] modelValue: Ref } +interface DiscoverTabSettingsPayload { + enabled: Record + tabs: DiscoverSource[] +} + interface SharedDialogEvents { close: () => void - save: (tabs: DiscoverSource[]) => Promise + save: (settings: DiscoverTabSettingsPayload) => Promise 'update:modelValue': (value: boolean) => void } @@ -41,16 +47,33 @@ const mocks = vi.hoisted(() => ({ controllers: [] as SharedDialogController[], openSharedDialog: vi.fn(), registerHeaderTab: vi.fn(), + toastError: vi.fn(), + useDynamicButton: vi.fn(), })) vi.mock('@/composables/useDynamicHeaderTab', () => ({ useDynamicHeaderTab: () => ({ registerHeaderTab: mocks.registerHeaderTab }), })) +vi.mock('@/composables/useDynamicButton', () => ({ + useDynamicButton: (options: unknown) => mocks.useDynamicButton(options), +})) + +vi.mock('@/composables/usePWA', async () => { + const { ref } = await import('vue') + return { + usePWA: () => ({ appMode: ref(false) }), + } +}) + vi.mock('@/composables/useSharedDialog', () => ({ openSharedDialog: (...args: unknown[]) => mocks.openSharedDialog(...args), })) +vi.mock('vue-toastification', () => ({ + useToast: () => ({ error: mocks.toastError }), +})) + const BuiltInViewStub = defineComponent({ name: 'BuiltInViewStub', setup: () => () => h('section', '内置发现内容'), @@ -91,10 +114,16 @@ function keepAliveHarness() { }) } -async function renderDiscover() { +async function renderDiscover(options: { discovery?: boolean; superUser?: boolean } = {}) { const componentError = vi.fn() const result = await renderWithProviders(keepAliveHarness(), { initialRoute: '/discover', + initialState: { + user: { + permissions: { ...DEFAULT_PERMISSIONS, discovery: options.discovery ?? true }, + superUser: options.superUser ?? false, + }, + }, global: { config: { errorHandler: componentError, @@ -128,11 +157,18 @@ function getDialogCall(index = 0) { const call = mocks.openSharedDialog.mock.calls[index] if (!call) throw new Error(`未找到第 ${index + 1} 个排序弹窗`) return { + enabled: (call[1] as { enabled: Record }).enabled, events: call[2] as SharedDialogEvents, tabs: (call[1] as { tabs: DiscoverSource[] }).tabs, } } +function getSettingsButton() { + const button = document.querySelector('.compact-fab') + if (!button) throw new Error('未找到探索设置 FAB') + return button +} + async function reactivateDiscover() { await fireEvent.click(document.querySelector('button') as HTMLButtonElement) const buttons = Array.from(document.querySelectorAll('button')) @@ -144,6 +180,7 @@ async function reactivateDiscover() { describe('discover page', () => { beforeEach(() => { mocks.controllers.length = 0 + server.use(discoverOrderConfigHandler(null)) mocks.openSharedDialog.mockImplementation(() => { const controller: SharedDialogController = { close: vi.fn(), @@ -155,11 +192,11 @@ describe('discover page', () => { }) }) - it('uses local order, merges sources by prefix, and keeps unconfigured tabs stable', async () => { + it('falls back to legacy local order when the server has no config and keeps new tabs visible', async () => { const configRequested = vi.fn() localStorage.setItem('MP_DISCOVER_TAB_ORDER', JSON.stringify([{ name: '豆瓣' }, { name: '自定义来源' }])) server.use( - discoverOrderConfigHandler([], 200, configRequested), + discoverOrderConfigHandler(null, 200, configRequested), discoverSourcesHandler([ createSource('自定义来源', 'custom'), createSource('重复自定义来源', 'custom'), @@ -179,7 +216,7 @@ describe('discover page', () => { '音乐', ]), ) - expect(configRequested).not.toHaveBeenCalled() + expect(configRequested).toHaveBeenCalledOnce() expect(getHeaderItems().map(item => item.tab)).toEqual([ 'douban', 'custom', @@ -211,7 +248,33 @@ describe('discover page', () => { '自定义来源', ]), ) - expect(localStorage.getItem('MP_DISCOVER_TAB_ORDER')).toBe(JSON.stringify(remoteOrder)) + expect(JSON.parse(localStorage.getItem('MP_DISCOVER_TAB_ORDER') ?? 'null')).toEqual( + remoteOrder.map(item => ({ enabled: true, name: item.name })), + ) + }) + + it('uses the server config over a stale browser cache and hides disabled tabs', async () => { + localStorage.setItem( + 'MP_DISCOVER_TAB_ORDER', + JSON.stringify([ + { enabled: true, mediaid_prefix: 'douban', name: '豆瓣' }, + { enabled: true, mediaid_prefix: 'themoviedb', name: 'TheMovieDb' }, + ]), + ) + const remoteConfig: DiscoverTabConfigItem[] = [ + { enabled: false, mediaid_prefix: 'douban', name: '豆瓣' }, + { enabled: true, mediaid_prefix: 'musicbrainz', name: '音乐' }, + { enabled: true, mediaid_prefix: 'themoviedb', name: 'TheMovieDb' }, + ] + server.use(discoverOrderConfigHandler(remoteConfig), discoverSourcesHandler([])) + + await renderDiscover() + + await waitFor(() => + expect(getHeaderItems().map(item => item.title)).toEqual(['音乐', 'TheMovieDb', 'Bangumi', 'AniList']), + ) + expect(getHeaderConfig().modelValue.value).toBe('musicbrainz') + expect(localStorage.getItem('MP_DISCOVER_TAB_ORDER')).toBe(JSON.stringify(remoteConfig)) }) it('falls back to remote order when local JSON is malformed', async () => { @@ -226,7 +289,9 @@ describe('discover page', () => { await waitFor(() => expect(getHeaderItems().map(item => item.title)).toEqual(['Bangumi', 'TheMovieDb', '豆瓣', 'AniList', '音乐']), ) - expect(localStorage.getItem('MP_DISCOVER_TAB_ORDER')).toBe(JSON.stringify(remoteOrder)) + expect(JSON.parse(localStorage.getItem('MP_DISCOVER_TAB_ORDER') ?? 'null')).toEqual( + remoteOrder.map(item => ({ enabled: true, name: item.name })), + ) expect(componentError).not.toHaveBeenCalled() }) @@ -361,37 +426,95 @@ describe('discover page', () => { expect(getHeaderItems().map(item => item.title)).not.toContain('旧来源名称') }) - it('saves the exact visible order through the shared dialog boundary', async () => { - const savedOrders: DiscoverOrderItem[][] = [] + it('registers the settings entry in the Footer area and renders the matching desktop FAB', async () => { + const sourcesRequested = vi.fn() + localStorage.setItem('MP_DISCOVER_TAB_ORDER', JSON.stringify([])) + server.use(discoverSourcesHandler([], 200, sourcesRequested)) + + await renderDiscover() + await waitFor(() => expect(sourcesRequested).toHaveBeenCalledOnce()) + + expect(getHeaderConfig().appendButtons).toBeUndefined() + expect(mocks.useDynamicButton).toHaveBeenCalledWith( + expect.objectContaining({ icon: 'mdi-tune', permission: 'discovery' }), + ) + expect(getSettingsButton()).toHaveAccessibleName('自定义探索标签') + }) + + it.each([ + { discovery: false, superUser: false, visible: false }, + { discovery: false, superUser: true, visible: true }, + ])('applies discovery permission to the desktop settings entry', async ({ discovery, superUser, visible }) => { + const sourcesRequested = vi.fn() + localStorage.setItem('MP_DISCOVER_TAB_ORDER', JSON.stringify([])) + server.use(discoverSourcesHandler([], 200, sourcesRequested)) + + await renderDiscover({ discovery, superUser }) + await waitFor(() => expect(sourcesRequested).toHaveBeenCalledOnce()) + + expect(Boolean(document.querySelector('.compact-fab'))).toBe(visible) + }) + + it('saves the exact order and visibility through the shared dialog boundary', async () => { + const savedConfigs: DiscoverTabConfigItem[][] = [] localStorage.setItem('MP_DISCOVER_TAB_ORDER', JSON.stringify([])) server.use( discoverSourcesHandler([createSource('自定义来源', 'custom')]), - saveDiscoverOrderHandler(order => { - savedOrders.push(order) + saveDiscoverOrderHandler(config => { + savedConfigs.push(config) }), ) await renderDiscover() await waitFor(() => expect(getHeaderItems().map(item => item.title)).toContain('自定义来源')) - getHeaderConfig().appendButtons[0].action() - const { events, tabs } = getDialogCall() + getSettingsButton().click() + const { enabled, events, tabs } = getDialogCall() const reorderedTabs = [tabs[4], tabs[1], tabs[0], tabs[3], tabs[2], tabs[5]] - await events.save(reorderedTabs) + const nextEnabled: Record = { ...enabled, themoviedb: false } + await events.save({ enabled: nextEnabled, tabs: reorderedTabs }) - const expectedOrder = reorderedTabs.map(item => ({ name: item.name })) - expect(savedOrders).toEqual([expectedOrder]) - expect(localStorage.getItem('MP_DISCOVER_TAB_ORDER')).toBe(JSON.stringify(expectedOrder)) - expect(getHeaderItems().map(item => item.title)).toEqual(reorderedTabs.map(item => item.name)) + const expectedConfig = reorderedTabs.map(item => ({ + enabled: nextEnabled[item.mediaid_prefix] !== false, + mediaid_prefix: item.mediaid_prefix, + name: item.name, + })) + expect(savedConfigs).toEqual([expectedConfig]) + expect(localStorage.getItem('MP_DISCOVER_TAB_ORDER')).toBe(JSON.stringify(expectedConfig)) + expect(getHeaderItems().map(item => item.title)).toEqual( + reorderedTabs.filter(item => nextEnabled[item.mediaid_prefix] !== false).map(item => item.name), + ) + expect(getHeaderConfig().modelValue.value).toBe('musicbrainz') expect(mocks.controllers[0].close).toHaveBeenCalledOnce() }) - it('closes the previous controller before opening another order dialog', async () => { + it('keeps the settings dialog open and leaves page state unchanged when server persistence fails', async () => { + const sourcesRequested = vi.fn() + vi.spyOn(console, 'error').mockImplementation(() => {}) + localStorage.setItem('MP_DISCOVER_TAB_ORDER', JSON.stringify([])) + server.use( + discoverSourcesHandler([], 200, sourcesRequested), + saveDiscoverOrderHandler(() => {}, 500), + ) + await renderDiscover() + await waitFor(() => expect(sourcesRequested).toHaveBeenCalledOnce()) + const originalTabs = getHeaderItems().map(item => item.title) + + getSettingsButton().click() + const { enabled, events, tabs } = getDialogCall() + await events.save({ enabled: { ...enabled, themoviedb: false }, tabs }) + + expect(getHeaderItems().map(item => item.title)).toEqual(originalTabs) + expect(mocks.controllers[0].close).not.toHaveBeenCalled() + expect(mocks.toastError).toHaveBeenCalledWith('探索标签设置保存失败,请稍后重试') + }) + + it('closes the previous controller before opening another settings dialog', async () => { localStorage.setItem('MP_DISCOVER_TAB_ORDER', JSON.stringify([])) server.use(discoverSourcesHandler([createSource('弹窗就绪来源', 'dialog-ready')])) await renderDiscover() await waitFor(() => expect(getHeaderItems().map(item => item.title)).toContain('弹窗就绪来源')) - const action = getHeaderConfig().appendButtons[0].action + const action = () => getSettingsButton().click() action() action() @@ -405,7 +528,7 @@ describe('discover page', () => { server.use(discoverSourcesHandler([createSource('弹窗就绪来源', 'dialog-ready')])) await renderDiscover() await waitFor(() => expect(getHeaderItems().map(item => item.title)).toContain('弹窗就绪来源')) - const action = getHeaderConfig().appendButtons[0].action + const action = () => getSettingsButton().click() action() getDialogCall(0).events.close() @@ -437,11 +560,11 @@ describe('discover page', () => { ) await renderDiscover() await waitFor(() => expect(getHeaderItems().map(item => item.title)).toContain('弹窗就绪来源')) - const action = getHeaderConfig().appendButtons[0].action + const action = () => getSettingsButton().click() action() const firstDialog = getDialogCall(0) - const pendingSave = firstDialog.events.save(firstDialog.tabs) + const pendingSave = firstDialog.events.save({ enabled: firstDialog.enabled, tabs: firstDialog.tabs }) await waitFor(() => expect(saveStarted).toHaveBeenCalledOnce()) firstDialog.events.close() action() diff --git a/src/pages/discover.vue b/src/pages/discover.vue index ab7afecc..790b5ea6 100644 --- a/src/pages/discover.vue +++ b/src/pages/discover.vue @@ -10,30 +10,52 @@ import { DiscoverSource } from '@/api/types' import api from '@/api' import { useI18n } from 'vue-i18n' import { useDynamicHeaderTab } from '@/composables/useDynamicHeaderTab' +import { useDynamicButton } from '@/composables/useDynamicButton' +import { usePWA } from '@/composables/usePWA' import { openSharedDialog } from '@/composables/useSharedDialog' +import { useUserStore } from '@/stores' +import { buildUserPermissionContext, hasPermission } from '@/utils/permission' +import { useToast } from 'vue-toastification' const DiscoverTabOrderDialog = defineAsyncComponent(() => import('@/components/dialog/DiscoverTabOrderDialog.vue')) +interface DiscoverTabConfigItem { + enabled: boolean + mediaid_prefix?: string + name: string +} + +interface DiscoverTabSettingsPayload { + enabled: Record + tabs: DiscoverSource[] +} + // 国际化 const { t } = useI18n() +const { appMode } = usePWA() +const userStore = useUserStore() +const toast = useToast() // 路由 const route = useRoute() +const canDiscovery = computed(() => + hasPermission(buildUserPermissionContext(userStore.superUser, userStore.permissions), 'discovery'), +) const activeTab = ref('') // 本地存储键值 const localOrderKey = 'MP_DISCOVER_TAB_ORDER' -// 顺序配置 -const orderConfig = ref<{ name: string }[]>([]) +// 标签顺序与显示配置 +const orderConfig = ref([]) // 标签页 const discoverTabs = ref([]) // 标签页项 const discoverTabItems = computed(() => { - return discoverTabs.value.map(item => ({ + return discoverTabs.value.filter(isTabEnabled).map(item => ({ title: item.name, tab: item.mediaid_prefix, })) @@ -46,8 +68,8 @@ let orderDialogController: ReturnType | null = null let extraSourcesRequest: Promise | null = null let initialLoadPromise: Promise | null = null -// 打开发现页标签排序共享弹窗。 -function openOrderConfigDialog() { +// 打开发现页标签设置共享弹窗。 +function openTabSettingsDialog() { orderDialogController?.close() const releaseController = () => { if (orderDialogController === controller) orderDialogController = null @@ -56,11 +78,12 @@ function openOrderConfigDialog() { const controller = openSharedDialog( DiscoverTabOrderDialog, { + enabled: Object.fromEntries(discoverTabs.value.map(tab => [tab.mediaid_prefix, isTabEnabled(tab)])), tabs: discoverTabs.value, }, { close: releaseController, - save: tabs => saveTabOrder(tabs, controller), + save: settings => saveTabSettings(settings, controller), 'update:modelValue': (value: boolean) => { if (!value) releaseController() }, @@ -70,8 +93,8 @@ function openOrderConfigDialog() { orderDialogController = controller } -// 关闭发现页标签排序共享弹窗。 -function closeOrderConfigDialog(controller = orderDialogController) { +// 关闭发现页标签设置共享弹窗。 +function closeTabSettingsDialog(controller = orderDialogController) { if (!controller || orderDialogController !== controller) return controller.close() orderDialogController = null @@ -124,57 +147,115 @@ async function refreshExtraDiscoverSources() { } } -// 按order的顺序排序 -function sortSubscribeOrder() { - if (!orderConfig.value) { - return - } +// 查找标签对应的持久化设置,优先使用不受语言与名称变化影响的来源标识。 +function getTabConfig(tab: DiscoverSource) { + return orderConfig.value.find( + item => + (item.mediaid_prefix && item.mediaid_prefix === tab.mediaid_prefix) || + (!item.mediaid_prefix && item.name === tab.name), + ) +} + +// 未出现在历史配置中的新标签默认显示,避免新增来源被旧配置意外隐藏。 +function isTabEnabled(tab: DiscoverSource) { + return getTabConfig(tab)?.enabled !== false +} + +// 按用户配置排序全部标签,未配置的新标签保持服务端返回顺序。 +function sortDiscoverTabs() { if (discoverTabs.value.length === 0) { return } discoverTabs.value.sort((a, b) => { - const aIndex = orderConfig.value.findIndex((item: { name: string }) => item.name === a.name) - const bIndex = orderConfig.value.findIndex((item: { name: string }) => item.name === b.name) + const aIndex = orderConfig.value.findIndex( + item => item.mediaid_prefix === a.mediaid_prefix || (!item.mediaid_prefix && item.name === a.name), + ) + const bIndex = orderConfig.value.findIndex( + item => item.mediaid_prefix === b.mediaid_prefix || (!item.mediaid_prefix && item.name === b.name), + ) return (aIndex === -1 ? 999 : aIndex) - (bIndex === -1 ? 999 : bIndex) }) } -// 加载顺序 -async function loadOrderConfig() { - // 顺序配置 - const local_order = localStorage.getItem(localOrderKey) - if (local_order) { - try { - orderConfig.value = JSON.parse(local_order) - return - } catch { - localStorage.removeItem(localOrderKey) - } - } +// 校验服务端或旧版本地缓存中的标签配置,并为旧版顺序项补上默认显示状态。 +function normalizeTabConfig(value: unknown): DiscoverTabConfigItem[] | null { + if (!Array.isArray(value)) return null - const response = await api.get(`/user/config/${localOrderKey}`) - if (response && response.data && response.data.value) { - orderConfig.value = response.data.value - localStorage.setItem(localOrderKey, JSON.stringify(orderConfig.value)) + const normalized: DiscoverTabConfigItem[] = [] + for (const rawItem of value) { + if (!rawItem || typeof rawItem !== 'object' || Array.isArray(rawItem)) return null + + const item = rawItem as Record + if (typeof item.name !== 'string' || !item.name.trim()) return null + if (item.enabled !== undefined && typeof item.enabled !== 'boolean') return null + if (item.mediaid_prefix !== undefined && typeof item.mediaid_prefix !== 'string') return null + + normalized.push({ + enabled: item.enabled !== false, + mediaid_prefix: item.mediaid_prefix?.trim() || undefined, + name: item.name, + }) } + return normalized } -// 保存顺序设置 -async function saveTabOrder(tabs = discoverTabs.value, controller = orderDialogController) { - discoverTabs.value = [...tabs] - // 顺序配置 - const orderObj = discoverTabs.value.map(item => ({ name: item.name })) - orderConfig.value = orderObj - const orderString = JSON.stringify(orderObj) - localStorage.setItem(localOrderKey, orderString) - - // 保存到服务端 +// 加载标签设置;服务端是跨浏览器共享的权威来源,本地值仅在远端无配置或请求失败时回退。 +async function loadOrderConfig() { try { - await api.post(`/user/config/${localOrderKey}`, orderObj) + const response = await api.get(`/user/config/${localOrderKey}`) + const remoteConfig = normalizeTabConfig(response?.data?.value) + if (remoteConfig) { + orderConfig.value = remoteConfig + localStorage.setItem(localOrderKey, JSON.stringify(remoteConfig)) + return + } } catch (error) { console.error(error) } - closeOrderConfigDialog(controller) + + const localOrder = localStorage.getItem(localOrderKey) + if (!localOrder) return + + try { + const localConfig = normalizeTabConfig(JSON.parse(localOrder)) + if (localConfig) { + orderConfig.value = localConfig + return + } + } catch { + // 损坏的本地缓存不能阻止发现页使用默认标签。 + } + localStorage.removeItem(localOrderKey) +} + +// 保存排序与显示设置,服务端确认成功后再更新页面和本地回退缓存。 +async function saveTabSettings(settings: DiscoverTabSettingsPayload, controller = orderDialogController) { + const nextConfig = settings.tabs.map(item => ({ + enabled: settings.enabled[item.mediaid_prefix] !== false, + mediaid_prefix: item.mediaid_prefix, + name: item.name, + })) + try { + await api.post(`/user/config/${localOrderKey}`, nextConfig) + } catch (error) { + console.error(error) + toast.error(t('discover.saveSettingsFailed')) + return + } + + discoverTabs.value = [...settings.tabs] + orderConfig.value = nextConfig + localStorage.setItem(localOrderKey, JSON.stringify(nextConfig)) + ensureActiveTab() + closeTabSettingsDialog(controller) +} + +// 当前标签被隐藏或来源撤销时切换到第一个可见标签,允许用户主动隐藏全部标签。 +function ensureActiveTab(selectFirst = false) { + const visibleTabs = discoverTabs.value.filter(isTabEnabled) + if (selectFirst || !visibleTabs.some(tab => tab.mediaid_prefix === activeTab.value)) { + activeTab.value = visibleTabs[0]?.mediaid_prefix ?? '' + } } // 使用动态标签页 @@ -184,16 +265,13 @@ const { registerHeaderTab } = useDynamicHeaderTab() registerHeaderTab({ items: discoverTabItems, // 传递computed值,会自动响应变化 modelValue: activeTab, - appendButtons: [ - { - icon: 'mdi-order-alphabetical-ascending', - variant: 'text', - color: 'grey', - class: 'settings-icon-button', - permission: 'discovery', - action: openOrderConfigDialog, - }, - ], +}) + +useDynamicButton({ + icon: 'mdi-tune', + onClick: openTabSettingsDialog, + permission: 'discovery', + show: computed(() => appMode.value), }) async function initializeDiscover() { @@ -204,11 +282,9 @@ async function initializeDiscover() { console.log(error) } await loadExtraDiscoverSources() - sortSubscribeOrder() - // 选中第一个标签页 - if (discoverTabs.value.length > 0) { - activeTab.value = discoverTabs.value[0].mediaid_prefix - } + sortDiscoverTabs() + // VWindow 会在异步配置返回前选中模板首项,初始化完成后必须按用户顺序重新选择。 + ensureActiveTab(true) } onBeforeMount(() => { @@ -224,13 +300,8 @@ onActivated(async () => { } await loadExtraDiscoverSources() - sortSubscribeOrder() - // 如果当前没有选中任何标签页,或者当前选中的标签页不存在,则选中第一个标签页 - if (!activeTab.value || !discoverTabs.value.find(tab => tab.mediaid_prefix === activeTab.value)) { - if (discoverTabs.value.length > 0) { - activeTab.value = discoverTabs.value[0].mediaid_prefix - } - } + sortDiscoverTabs() + ensureActiveTab() }) @@ -268,9 +339,21 @@ onActivated(async () => { + +
+ +
+
- + diff --git a/tests/support/msw/handlers/discover.ts b/tests/support/msw/handlers/discover.ts index ed12134b..a3569ae3 100644 --- a/tests/support/msw/handlers/discover.ts +++ b/tests/support/msw/handlers/discover.ts @@ -3,7 +3,9 @@ import { HttpResponse, http, type JsonBodyType } from 'msw' const API_BASE_URL = 'http://localhost/api/v1/' -export interface DiscoverOrderItem { +export interface DiscoverTabConfigItem { + enabled?: boolean + mediaid_prefix?: string name: string } @@ -24,7 +26,7 @@ export function discoverSourcesHandler( } export function discoverOrderConfigHandler( - order: DiscoverOrderItem[] | null, + order: DiscoverTabConfigItem[] | null, status = 200, onRequest: () => void | Promise = () => {}, ) { @@ -35,11 +37,11 @@ export function discoverOrderConfigHandler( } export function saveDiscoverOrderHandler( - onSave: (order: DiscoverOrderItem[]) => void | Promise = () => {}, + onSave: (order: DiscoverTabConfigItem[]) => void | Promise = () => {}, status = 200, ) { return http.post(discoverApiUrls.orderConfig, async ({ request }) => { - const order = (await request.json()) as DiscoverOrderItem[] + const order = (await request.json()) as DiscoverTabConfigItem[] await onSave(order) return HttpResponse.json({ success: status < 400 }, { status }) })