mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-07 00:36:41 +08:00
feat(discover): persist tab visibility settings
This commit is contained in:
@@ -9,6 +9,7 @@ const display = useDisplay()
|
|||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
|
enabled: Record<string, boolean>
|
||||||
modelValue?: boolean
|
modelValue?: boolean
|
||||||
tabs: DiscoverSource[]
|
tabs: DiscoverSource[]
|
||||||
}>(),
|
}>(),
|
||||||
@@ -19,11 +20,12 @@ const props = withDefaults(
|
|||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(event: 'close'): void
|
(event: 'close'): void
|
||||||
(event: 'save', tabs: DiscoverSource[]): void
|
(event: 'save', payload: { enabled: Record<string, boolean>; tabs: DiscoverSource[] }): void
|
||||||
(event: 'update:modelValue', value: boolean): void
|
(event: 'update:modelValue', value: boolean): void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const localTabs = ref<DiscoverSource[]>([])
|
const localTabs = ref<DiscoverSource[]>([])
|
||||||
|
const localEnabled = ref<Record<string, boolean>>({})
|
||||||
|
|
||||||
const visible = computed({
|
const visible = computed({
|
||||||
get: () => props.modelValue,
|
get: () => props.modelValue,
|
||||||
@@ -34,21 +36,39 @@ const visible = computed({
|
|||||||
})
|
})
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.tabs,
|
[() => props.tabs, () => props.enabled],
|
||||||
() => {
|
() => {
|
||||||
resetLocalTabs()
|
resetLocalSettings()
|
||||||
},
|
},
|
||||||
{ deep: true, immediate: true },
|
{ deep: true, immediate: true },
|
||||||
)
|
)
|
||||||
|
|
||||||
// 重置弹窗内部排序副本。
|
// 重置弹窗内部设置副本,避免拖拽与开关操作直接修改父级状态。
|
||||||
function resetLocalTabs() {
|
function resetLocalSettings() {
|
||||||
localTabs.value = props.tabs.map(item => ({ ...item }))
|
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() {
|
function toggleTab(tab: DiscoverSource) {
|
||||||
emit('save', localTabs.value)
|
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,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -64,14 +84,14 @@ function submitOrder() {
|
|||||||
<VCard class="settings-card">
|
<VCard class="settings-card">
|
||||||
<VCardItem class="settings-card-header">
|
<VCardItem class="settings-card-header">
|
||||||
<VCardTitle>
|
<VCardTitle>
|
||||||
<VIcon icon="mdi-order-alphabetical-ascending" size="small" class="me-2" />
|
<VIcon icon="mdi-tune" size="small" class="me-2" />
|
||||||
{{ t('discover.setTabOrder') }}
|
{{ t('discover.customizeTabs') }}
|
||||||
</VCardTitle>
|
</VCardTitle>
|
||||||
<VDialogCloseBtn v-model="visible" />
|
<VDialogCloseBtn v-model="visible" />
|
||||||
</VCardItem>
|
</VCardItem>
|
||||||
<VDivider />
|
<VDivider />
|
||||||
<VCardText>
|
<VCardText>
|
||||||
<p class="settings-hint">{{ t('discover.dragToReorder') }}</p>
|
<p class="settings-hint">{{ t('discover.configureTabsHint') }}</p>
|
||||||
<draggable
|
<draggable
|
||||||
v-model="localTabs"
|
v-model="localTabs"
|
||||||
handle=".cursor-move"
|
handle=".cursor-move"
|
||||||
@@ -81,18 +101,34 @@ function submitOrder() {
|
|||||||
:component-data="{ 'class': 'settings-grid' }"
|
:component-data="{ 'class': 'settings-grid' }"
|
||||||
>
|
>
|
||||||
<template #item="{ element }">
|
<template #item="{ element }">
|
||||||
<VCard variant="text" class="setting-item enabled">
|
<div class="setting-item" :class="{ 'enabled': localEnabled[element.mediaid_prefix] }">
|
||||||
<div class="setting-item-inner">
|
<button
|
||||||
|
type="button"
|
||||||
|
class="setting-toggle"
|
||||||
|
:aria-pressed="Boolean(localEnabled[element.mediaid_prefix])"
|
||||||
|
@click="toggleTab(element)"
|
||||||
|
>
|
||||||
|
<VIcon
|
||||||
|
:icon="localEnabled[element.mediaid_prefix] ? 'mdi-check-circle' : 'mdi-circle-outline'"
|
||||||
|
:color="localEnabled[element.mediaid_prefix] ? 'primary' : undefined"
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
<span class="setting-label">{{ element.name }}</span>
|
<span class="setting-label">{{ element.name }}</span>
|
||||||
<VIcon icon="mdi-drag" class="drag-icon cursor-move" />
|
</button>
|
||||||
</div>
|
<VIcon icon="mdi-drag-vertical" class="drag-icon cursor-move" aria-hidden="true" />
|
||||||
</VCard>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</draggable>
|
</draggable>
|
||||||
</VCardText>
|
</VCardText>
|
||||||
<VCardActions class="app-dialog-actions">
|
<VCardActions class="app-dialog-actions">
|
||||||
|
<VBtn color="success" variant="tonal" @click="setAllTabs(true)">
|
||||||
|
{{ t('discover.selectAll') }}
|
||||||
|
</VBtn>
|
||||||
|
<VBtn color="warning" variant="tonal" @click="setAllTabs(false)">
|
||||||
|
{{ t('discover.selectNone') }}
|
||||||
|
</VBtn>
|
||||||
<VSpacer />
|
<VSpacer />
|
||||||
<VBtn color="primary" variant="flat" class="px-5" @click="submitOrder">
|
<VBtn color="primary" variant="flat" class="px-5" @click="submitSettings">
|
||||||
<template #prepend>
|
<template #prepend>
|
||||||
<VIcon icon="mdi-content-save" />
|
<VIcon icon="mdi-content-save" />
|
||||||
</template>
|
</template>
|
||||||
@@ -122,28 +158,16 @@ function submitOrder() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.setting-item {
|
.setting-item {
|
||||||
position: relative;
|
display: flex;
|
||||||
overflow: hidden;
|
align-items: stretch;
|
||||||
min-block-size: 48px;
|
min-block-size: 48px;
|
||||||
border: 1px solid rgba(var(--v-theme-on-surface), 0.1);
|
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);
|
background-color: rgba(var(--v-theme-on-surface), 0.04);
|
||||||
cursor: grab;
|
transition:
|
||||||
padding-block: 10px;
|
border-color 0.2s ease,
|
||||||
padding-inline: 12px;
|
background-color 0.2s ease,
|
||||||
transition: border-color 0.2s ease, background-color 0.2s ease, transform 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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.setting-item.enabled {
|
.setting-item.enabled {
|
||||||
@@ -151,24 +175,35 @@ function submitOrder() {
|
|||||||
background-color: rgba(var(--v-theme-primary), 0.08);
|
background-color: rgba(var(--v-theme-primary), 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
.setting-item.enabled::before {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.setting-item:hover {
|
.setting-item:hover {
|
||||||
border-color: rgba(var(--v-theme-primary), 0.32);
|
border-color: rgba(var(--v-theme-primary), 0.32);
|
||||||
background-color: rgba(var(--v-theme-primary), 0.06);
|
background-color: rgba(var(--v-theme-primary), 0.06);
|
||||||
}
|
}
|
||||||
|
|
||||||
.setting-item:active {
|
.setting-item:active {
|
||||||
cursor: grabbing;
|
|
||||||
transform: scale(0.99);
|
transform: scale(0.99);
|
||||||
}
|
}
|
||||||
|
|
||||||
.setting-item-inner {
|
.setting-toggle {
|
||||||
|
appearance: none;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
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 {
|
.setting-label {
|
||||||
@@ -185,11 +220,18 @@ function submitOrder() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.drag-icon {
|
.drag-icon {
|
||||||
|
align-self: center;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
color: rgba(var(--v-theme-on-surface), 0.52);
|
color: rgba(var(--v-theme-on-surface), 0.52);
|
||||||
|
cursor: grab;
|
||||||
|
margin-inline-end: 10px;
|
||||||
transition: color 0.2s ease;
|
transition: color 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.drag-icon:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
.setting-item:hover .drag-icon {
|
.setting-item:hover .drag-icon {
|
||||||
color: rgb(var(--v-theme-primary));
|
color: rgb(var(--v-theme-primary));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 close = vi.fn()
|
||||||
const save = vi.fn()
|
const save = vi.fn()
|
||||||
const updateModelValue = vi.fn()
|
const updateModelValue = vi.fn()
|
||||||
const result = await renderWithProviders(DiscoverTabOrderDialog, {
|
const result = await renderWithProviders(DiscoverTabOrderDialog, {
|
||||||
props: {
|
props: {
|
||||||
|
enabled,
|
||||||
modelValue: true,
|
modelValue: true,
|
||||||
onClose: close,
|
onClose: close,
|
||||||
onSave: save,
|
onSave: save,
|
||||||
@@ -89,7 +93,7 @@ describe('DiscoverTabOrderDialog', () => {
|
|||||||
|
|
||||||
expect(tabs).toEqual(originalOrder)
|
expect(tabs).toEqual(originalOrder)
|
||||||
expect(save).toHaveBeenCalledOnce()
|
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.map(item => item.mediaid_prefix)).toEqual(['source-b', 'source-a'])
|
||||||
expect(savedTabs[0]).not.toBe(tabs[1])
|
expect(savedTabs[0]).not.toBe(tabs[1])
|
||||||
expect(savedTabs[0].filter_params).toStrictEqual(tabs[1].filter_params)
|
expect(savedTabs[0].filter_params).toStrictEqual(tabs[1].filter_params)
|
||||||
@@ -104,15 +108,35 @@ describe('DiscoverTabOrderDialog', () => {
|
|||||||
await user.click(screen.getByRole('button', { name: '反转顺序' }))
|
await user.click(screen.getByRole('button', { name: '反转顺序' }))
|
||||||
|
|
||||||
await rerender({
|
await rerender({
|
||||||
|
enabled: { 'source-c': true, 'source-d': true },
|
||||||
modelValue: true,
|
modelValue: true,
|
||||||
tabs: [createSource('来源丙', 'source-c'), createSource('来源丁', 'source-d')],
|
tabs: [createSource('来源丙', 'source-c'), createSource('来源丁', 'source-d')],
|
||||||
})
|
})
|
||||||
await user.click(screen.getByRole('button', { name: '保存' }))
|
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'])
|
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 () => {
|
it('emits both model closure and close from the close control', async () => {
|
||||||
const user = userEvent.setup()
|
const user = userEvent.setup()
|
||||||
const { close, updateModelValue } = await renderDialog([createSource('来源甲', 'source-a')])
|
const { close, updateModelValue } = await renderDialog([createSource('来源甲', 'source-a')])
|
||||||
@@ -124,13 +148,17 @@ describe('DiscoverTabOrderDialog', () => {
|
|||||||
expect(close).toHaveBeenCalledOnce()
|
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 user = userEvent.setup()
|
||||||
const { close, save, updateModelValue } = await renderDialog([createSource('来源甲', 'source-a')])
|
const { close, save, updateModelValue } = await renderDialog([createSource('来源甲', 'source-a')])
|
||||||
|
|
||||||
await user.click(screen.getByRole('button', { name: '保存' }))
|
await user.click(screen.getByRole('button', { name: '保存' }))
|
||||||
|
|
||||||
expect(save).toHaveBeenCalledOnce()
|
expect(save).toHaveBeenCalledOnce()
|
||||||
|
expect(save).toHaveBeenCalledWith({
|
||||||
|
enabled: { 'source-a': true },
|
||||||
|
tabs: [expect.objectContaining({ mediaid_prefix: 'source-a' })],
|
||||||
|
})
|
||||||
expect(close).not.toHaveBeenCalled()
|
expect(close).not.toHaveBeenCalled()
|
||||||
expect(updateModelValue).not.toHaveBeenCalled()
|
expect(updateModelValue).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1378,8 +1378,11 @@ export default {
|
|||||||
selectNone: 'Select None',
|
selectNone: 'Select None',
|
||||||
},
|
},
|
||||||
discover: {
|
discover: {
|
||||||
setTabOrder: 'Set Tab Order',
|
customizeTabs: 'Customize Explore Tabs',
|
||||||
dragToReorder: 'Drag to reorder 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: {
|
downloading: {
|
||||||
noDownloader: 'No Downloader',
|
noDownloader: 'No Downloader',
|
||||||
|
|||||||
@@ -1368,8 +1368,11 @@ export default {
|
|||||||
selectNone: '全不选',
|
selectNone: '全不选',
|
||||||
},
|
},
|
||||||
discover: {
|
discover: {
|
||||||
setTabOrder: '设置标签顺序',
|
customizeTabs: '自定义探索标签',
|
||||||
dragToReorder: '拖动对标签页进行排序',
|
configureTabsHint: '拖动调整标签顺序,并选择要显示的标签',
|
||||||
|
selectAll: '全选',
|
||||||
|
selectNone: '全不选',
|
||||||
|
saveSettingsFailed: '探索标签设置保存失败,请稍后重试',
|
||||||
},
|
},
|
||||||
downloading: {
|
downloading: {
|
||||||
noDownloader: '没有下载器',
|
noDownloader: '没有下载器',
|
||||||
|
|||||||
@@ -1366,8 +1366,11 @@ export default {
|
|||||||
selectNone: '全不選',
|
selectNone: '全不選',
|
||||||
},
|
},
|
||||||
discover: {
|
discover: {
|
||||||
setTabOrder: '設置標籤順序',
|
customizeTabs: '自定義探索標籤',
|
||||||
dragToReorder: '拖動對標籤頁進行排序',
|
configureTabsHint: '拖動調整標籤順序,並選擇要顯示的標籤',
|
||||||
|
selectAll: '全選',
|
||||||
|
selectNone: '全不選',
|
||||||
|
saveSettingsFailed: '探索標籤設置保存失敗,請稍後重試',
|
||||||
},
|
},
|
||||||
downloading: {
|
downloading: {
|
||||||
noDownloader: '沒有下載器',
|
noDownloader: '沒有下載器',
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import DiscoverPage from '@/pages/discover.vue'
|
import DiscoverPage from '@/pages/discover.vue'
|
||||||
import type { DiscoverSource } from '@/api/types'
|
import type { DiscoverSource } from '@/api/types'
|
||||||
|
import { DEFAULT_PERMISSIONS } from '@/utils/permission'
|
||||||
import { fireEvent, waitFor } from '@testing-library/vue'
|
import { fireEvent, waitFor } from '@testing-library/vue'
|
||||||
import { renderWithProviders } from '@tests/support/render'
|
import { renderWithProviders } from '@tests/support/render'
|
||||||
import {
|
import {
|
||||||
@@ -7,7 +8,7 @@ import {
|
|||||||
discoverOrderConfigHandler,
|
discoverOrderConfigHandler,
|
||||||
discoverSourcesHandler,
|
discoverSourcesHandler,
|
||||||
saveDiscoverOrderHandler,
|
saveDiscoverOrderHandler,
|
||||||
type DiscoverOrderItem,
|
type DiscoverTabConfigItem,
|
||||||
} from '@tests/support/msw/handlers/discover'
|
} from '@tests/support/msw/handlers/discover'
|
||||||
import { server } from '@tests/support/msw/server'
|
import { server } from '@tests/support/msw/server'
|
||||||
import { HttpResponse, http } from 'msw'
|
import { HttpResponse, http } from 'msw'
|
||||||
@@ -20,14 +21,19 @@ interface HeaderTabItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface HeaderTabConfig {
|
interface HeaderTabConfig {
|
||||||
appendButtons: Array<{ action: () => void }>
|
appendButtons?: Array<{ action: () => void }>
|
||||||
items: ComputedRef<HeaderTabItem[]> | Ref<HeaderTabItem[]> | HeaderTabItem[]
|
items: ComputedRef<HeaderTabItem[]> | Ref<HeaderTabItem[]> | HeaderTabItem[]
|
||||||
modelValue: Ref<string>
|
modelValue: Ref<string>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface DiscoverTabSettingsPayload {
|
||||||
|
enabled: Record<string, boolean>
|
||||||
|
tabs: DiscoverSource[]
|
||||||
|
}
|
||||||
|
|
||||||
interface SharedDialogEvents {
|
interface SharedDialogEvents {
|
||||||
close: () => void
|
close: () => void
|
||||||
save: (tabs: DiscoverSource[]) => Promise<void>
|
save: (settings: DiscoverTabSettingsPayload) => Promise<void>
|
||||||
'update:modelValue': (value: boolean) => void
|
'update:modelValue': (value: boolean) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,16 +47,33 @@ const mocks = vi.hoisted(() => ({
|
|||||||
controllers: [] as SharedDialogController[],
|
controllers: [] as SharedDialogController[],
|
||||||
openSharedDialog: vi.fn(),
|
openSharedDialog: vi.fn(),
|
||||||
registerHeaderTab: vi.fn(),
|
registerHeaderTab: vi.fn(),
|
||||||
|
toastError: vi.fn(),
|
||||||
|
useDynamicButton: vi.fn(),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('@/composables/useDynamicHeaderTab', () => ({
|
vi.mock('@/composables/useDynamicHeaderTab', () => ({
|
||||||
useDynamicHeaderTab: () => ({ registerHeaderTab: mocks.registerHeaderTab }),
|
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', () => ({
|
vi.mock('@/composables/useSharedDialog', () => ({
|
||||||
openSharedDialog: (...args: unknown[]) => mocks.openSharedDialog(...args),
|
openSharedDialog: (...args: unknown[]) => mocks.openSharedDialog(...args),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
vi.mock('vue-toastification', () => ({
|
||||||
|
useToast: () => ({ error: mocks.toastError }),
|
||||||
|
}))
|
||||||
|
|
||||||
const BuiltInViewStub = defineComponent({
|
const BuiltInViewStub = defineComponent({
|
||||||
name: 'BuiltInViewStub',
|
name: 'BuiltInViewStub',
|
||||||
setup: () => () => h('section', '内置发现内容'),
|
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 componentError = vi.fn()
|
||||||
const result = await renderWithProviders(keepAliveHarness(), {
|
const result = await renderWithProviders(keepAliveHarness(), {
|
||||||
initialRoute: '/discover',
|
initialRoute: '/discover',
|
||||||
|
initialState: {
|
||||||
|
user: {
|
||||||
|
permissions: { ...DEFAULT_PERMISSIONS, discovery: options.discovery ?? true },
|
||||||
|
superUser: options.superUser ?? false,
|
||||||
|
},
|
||||||
|
},
|
||||||
global: {
|
global: {
|
||||||
config: {
|
config: {
|
||||||
errorHandler: componentError,
|
errorHandler: componentError,
|
||||||
@@ -128,11 +157,18 @@ function getDialogCall(index = 0) {
|
|||||||
const call = mocks.openSharedDialog.mock.calls[index]
|
const call = mocks.openSharedDialog.mock.calls[index]
|
||||||
if (!call) throw new Error(`未找到第 ${index + 1} 个排序弹窗`)
|
if (!call) throw new Error(`未找到第 ${index + 1} 个排序弹窗`)
|
||||||
return {
|
return {
|
||||||
|
enabled: (call[1] as { enabled: Record<string, boolean> }).enabled,
|
||||||
events: call[2] as SharedDialogEvents,
|
events: call[2] as SharedDialogEvents,
|
||||||
tabs: (call[1] as { tabs: DiscoverSource[] }).tabs,
|
tabs: (call[1] as { tabs: DiscoverSource[] }).tabs,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getSettingsButton() {
|
||||||
|
const button = document.querySelector<HTMLButtonElement>('.compact-fab')
|
||||||
|
if (!button) throw new Error('未找到探索设置 FAB')
|
||||||
|
return button
|
||||||
|
}
|
||||||
|
|
||||||
async function reactivateDiscover() {
|
async function reactivateDiscover() {
|
||||||
await fireEvent.click(document.querySelector('button') as HTMLButtonElement)
|
await fireEvent.click(document.querySelector('button') as HTMLButtonElement)
|
||||||
const buttons = Array.from(document.querySelectorAll('button'))
|
const buttons = Array.from(document.querySelectorAll('button'))
|
||||||
@@ -144,6 +180,7 @@ async function reactivateDiscover() {
|
|||||||
describe('discover page', () => {
|
describe('discover page', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mocks.controllers.length = 0
|
mocks.controllers.length = 0
|
||||||
|
server.use(discoverOrderConfigHandler(null))
|
||||||
mocks.openSharedDialog.mockImplementation(() => {
|
mocks.openSharedDialog.mockImplementation(() => {
|
||||||
const controller: SharedDialogController = {
|
const controller: SharedDialogController = {
|
||||||
close: vi.fn(),
|
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()
|
const configRequested = vi.fn()
|
||||||
localStorage.setItem('MP_DISCOVER_TAB_ORDER', JSON.stringify([{ name: '豆瓣' }, { name: '自定义来源' }]))
|
localStorage.setItem('MP_DISCOVER_TAB_ORDER', JSON.stringify([{ name: '豆瓣' }, { name: '自定义来源' }]))
|
||||||
server.use(
|
server.use(
|
||||||
discoverOrderConfigHandler([], 200, configRequested),
|
discoverOrderConfigHandler(null, 200, configRequested),
|
||||||
discoverSourcesHandler([
|
discoverSourcesHandler([
|
||||||
createSource('自定义来源', 'custom'),
|
createSource('自定义来源', 'custom'),
|
||||||
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([
|
expect(getHeaderItems().map(item => item.tab)).toEqual([
|
||||||
'douban',
|
'douban',
|
||||||
'custom',
|
'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 () => {
|
it('falls back to remote order when local JSON is malformed', async () => {
|
||||||
@@ -226,7 +289,9 @@ describe('discover page', () => {
|
|||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(getHeaderItems().map(item => item.title)).toEqual(['Bangumi', 'TheMovieDb', '豆瓣', 'AniList', '音乐']),
|
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()
|
expect(componentError).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -361,37 +426,95 @@ describe('discover page', () => {
|
|||||||
expect(getHeaderItems().map(item => item.title)).not.toContain('旧来源名称')
|
expect(getHeaderItems().map(item => item.title)).not.toContain('旧来源名称')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('saves the exact visible order through the shared dialog boundary', async () => {
|
it('registers the settings entry in the Footer area and renders the matching desktop FAB', async () => {
|
||||||
const savedOrders: DiscoverOrderItem[][] = []
|
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([]))
|
localStorage.setItem('MP_DISCOVER_TAB_ORDER', JSON.stringify([]))
|
||||||
server.use(
|
server.use(
|
||||||
discoverSourcesHandler([createSource('自定义来源', 'custom')]),
|
discoverSourcesHandler([createSource('自定义来源', 'custom')]),
|
||||||
saveDiscoverOrderHandler(order => {
|
saveDiscoverOrderHandler(config => {
|
||||||
savedOrders.push(order)
|
savedConfigs.push(config)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
await renderDiscover()
|
await renderDiscover()
|
||||||
await waitFor(() => expect(getHeaderItems().map(item => item.title)).toContain('自定义来源'))
|
await waitFor(() => expect(getHeaderItems().map(item => item.title)).toContain('自定义来源'))
|
||||||
|
|
||||||
getHeaderConfig().appendButtons[0].action()
|
getSettingsButton().click()
|
||||||
const { events, tabs } = getDialogCall()
|
const { enabled, events, tabs } = getDialogCall()
|
||||||
const reorderedTabs = [tabs[4], tabs[1], tabs[0], tabs[3], tabs[2], tabs[5]]
|
const reorderedTabs = [tabs[4], tabs[1], tabs[0], tabs[3], tabs[2], tabs[5]]
|
||||||
await events.save(reorderedTabs)
|
const nextEnabled: Record<string, boolean> = { ...enabled, themoviedb: false }
|
||||||
|
await events.save({ enabled: nextEnabled, tabs: reorderedTabs })
|
||||||
|
|
||||||
const expectedOrder = reorderedTabs.map(item => ({ name: item.name }))
|
const expectedConfig = reorderedTabs.map(item => ({
|
||||||
expect(savedOrders).toEqual([expectedOrder])
|
enabled: nextEnabled[item.mediaid_prefix] !== false,
|
||||||
expect(localStorage.getItem('MP_DISCOVER_TAB_ORDER')).toBe(JSON.stringify(expectedOrder))
|
mediaid_prefix: item.mediaid_prefix,
|
||||||
expect(getHeaderItems().map(item => item.title)).toEqual(reorderedTabs.map(item => item.name))
|
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()
|
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([]))
|
localStorage.setItem('MP_DISCOVER_TAB_ORDER', JSON.stringify([]))
|
||||||
server.use(discoverSourcesHandler([createSource('弹窗就绪来源', 'dialog-ready')]))
|
server.use(discoverSourcesHandler([createSource('弹窗就绪来源', 'dialog-ready')]))
|
||||||
await renderDiscover()
|
await renderDiscover()
|
||||||
await waitFor(() => expect(getHeaderItems().map(item => item.title)).toContain('弹窗就绪来源'))
|
await waitFor(() => expect(getHeaderItems().map(item => item.title)).toContain('弹窗就绪来源'))
|
||||||
|
|
||||||
const action = getHeaderConfig().appendButtons[0].action
|
const action = () => getSettingsButton().click()
|
||||||
action()
|
action()
|
||||||
action()
|
action()
|
||||||
|
|
||||||
@@ -405,7 +528,7 @@ describe('discover page', () => {
|
|||||||
server.use(discoverSourcesHandler([createSource('弹窗就绪来源', 'dialog-ready')]))
|
server.use(discoverSourcesHandler([createSource('弹窗就绪来源', 'dialog-ready')]))
|
||||||
await renderDiscover()
|
await renderDiscover()
|
||||||
await waitFor(() => expect(getHeaderItems().map(item => item.title)).toContain('弹窗就绪来源'))
|
await waitFor(() => expect(getHeaderItems().map(item => item.title)).toContain('弹窗就绪来源'))
|
||||||
const action = getHeaderConfig().appendButtons[0].action
|
const action = () => getSettingsButton().click()
|
||||||
|
|
||||||
action()
|
action()
|
||||||
getDialogCall(0).events.close()
|
getDialogCall(0).events.close()
|
||||||
@@ -437,11 +560,11 @@ describe('discover page', () => {
|
|||||||
)
|
)
|
||||||
await renderDiscover()
|
await renderDiscover()
|
||||||
await waitFor(() => expect(getHeaderItems().map(item => item.title)).toContain('弹窗就绪来源'))
|
await waitFor(() => expect(getHeaderItems().map(item => item.title)).toContain('弹窗就绪来源'))
|
||||||
const action = getHeaderConfig().appendButtons[0].action
|
const action = () => getSettingsButton().click()
|
||||||
|
|
||||||
action()
|
action()
|
||||||
const firstDialog = getDialogCall(0)
|
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())
|
await waitFor(() => expect(saveStarted).toHaveBeenCalledOnce())
|
||||||
firstDialog.events.close()
|
firstDialog.events.close()
|
||||||
action()
|
action()
|
||||||
|
|||||||
+149
-66
@@ -10,30 +10,52 @@ import { DiscoverSource } from '@/api/types'
|
|||||||
import api from '@/api'
|
import api from '@/api'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useDynamicHeaderTab } from '@/composables/useDynamicHeaderTab'
|
import { useDynamicHeaderTab } from '@/composables/useDynamicHeaderTab'
|
||||||
|
import { useDynamicButton } from '@/composables/useDynamicButton'
|
||||||
|
import { usePWA } from '@/composables/usePWA'
|
||||||
import { openSharedDialog } from '@/composables/useSharedDialog'
|
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'))
|
const DiscoverTabOrderDialog = defineAsyncComponent(() => import('@/components/dialog/DiscoverTabOrderDialog.vue'))
|
||||||
|
|
||||||
|
interface DiscoverTabConfigItem {
|
||||||
|
enabled: boolean
|
||||||
|
mediaid_prefix?: string
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DiscoverTabSettingsPayload {
|
||||||
|
enabled: Record<string, boolean>
|
||||||
|
tabs: DiscoverSource[]
|
||||||
|
}
|
||||||
|
|
||||||
// 国际化
|
// 国际化
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
const { appMode } = usePWA()
|
||||||
|
const userStore = useUserStore()
|
||||||
|
const toast = useToast()
|
||||||
|
|
||||||
// 路由
|
// 路由
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
const canDiscovery = computed(() =>
|
||||||
|
hasPermission(buildUserPermissionContext(userStore.superUser, userStore.permissions), 'discovery'),
|
||||||
|
)
|
||||||
|
|
||||||
const activeTab = ref('')
|
const activeTab = ref('')
|
||||||
|
|
||||||
// 本地存储键值
|
// 本地存储键值
|
||||||
const localOrderKey = 'MP_DISCOVER_TAB_ORDER'
|
const localOrderKey = 'MP_DISCOVER_TAB_ORDER'
|
||||||
|
|
||||||
// 顺序配置
|
// 标签顺序与显示配置
|
||||||
const orderConfig = ref<{ name: string }[]>([])
|
const orderConfig = ref<DiscoverTabConfigItem[]>([])
|
||||||
|
|
||||||
// 标签页
|
// 标签页
|
||||||
const discoverTabs = ref<DiscoverSource[]>([])
|
const discoverTabs = ref<DiscoverSource[]>([])
|
||||||
|
|
||||||
// 标签页项
|
// 标签页项
|
||||||
const discoverTabItems = computed(() => {
|
const discoverTabItems = computed(() => {
|
||||||
return discoverTabs.value.map(item => ({
|
return discoverTabs.value.filter(isTabEnabled).map(item => ({
|
||||||
title: item.name,
|
title: item.name,
|
||||||
tab: item.mediaid_prefix,
|
tab: item.mediaid_prefix,
|
||||||
}))
|
}))
|
||||||
@@ -46,8 +68,8 @@ let orderDialogController: ReturnType<typeof openSharedDialog> | null = null
|
|||||||
let extraSourcesRequest: Promise<void> | null = null
|
let extraSourcesRequest: Promise<void> | null = null
|
||||||
let initialLoadPromise: Promise<void> | null = null
|
let initialLoadPromise: Promise<void> | null = null
|
||||||
|
|
||||||
// 打开发现页标签排序共享弹窗。
|
// 打开发现页标签设置共享弹窗。
|
||||||
function openOrderConfigDialog() {
|
function openTabSettingsDialog() {
|
||||||
orderDialogController?.close()
|
orderDialogController?.close()
|
||||||
const releaseController = () => {
|
const releaseController = () => {
|
||||||
if (orderDialogController === controller) orderDialogController = null
|
if (orderDialogController === controller) orderDialogController = null
|
||||||
@@ -56,11 +78,12 @@ function openOrderConfigDialog() {
|
|||||||
const controller = openSharedDialog(
|
const controller = openSharedDialog(
|
||||||
DiscoverTabOrderDialog,
|
DiscoverTabOrderDialog,
|
||||||
{
|
{
|
||||||
|
enabled: Object.fromEntries(discoverTabs.value.map(tab => [tab.mediaid_prefix, isTabEnabled(tab)])),
|
||||||
tabs: discoverTabs.value,
|
tabs: discoverTabs.value,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
close: releaseController,
|
close: releaseController,
|
||||||
save: tabs => saveTabOrder(tabs, controller),
|
save: settings => saveTabSettings(settings, controller),
|
||||||
'update:modelValue': (value: boolean) => {
|
'update:modelValue': (value: boolean) => {
|
||||||
if (!value) releaseController()
|
if (!value) releaseController()
|
||||||
},
|
},
|
||||||
@@ -70,8 +93,8 @@ function openOrderConfigDialog() {
|
|||||||
orderDialogController = controller
|
orderDialogController = controller
|
||||||
}
|
}
|
||||||
|
|
||||||
// 关闭发现页标签排序共享弹窗。
|
// 关闭发现页标签设置共享弹窗。
|
||||||
function closeOrderConfigDialog(controller = orderDialogController) {
|
function closeTabSettingsDialog(controller = orderDialogController) {
|
||||||
if (!controller || orderDialogController !== controller) return
|
if (!controller || orderDialogController !== controller) return
|
||||||
controller.close()
|
controller.close()
|
||||||
orderDialogController = null
|
orderDialogController = null
|
||||||
@@ -124,57 +147,115 @@ async function refreshExtraDiscoverSources() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 按order的顺序排序
|
// 查找标签对应的持久化设置,优先使用不受语言与名称变化影响的来源标识。
|
||||||
function sortSubscribeOrder() {
|
function getTabConfig(tab: DiscoverSource) {
|
||||||
if (!orderConfig.value) {
|
return orderConfig.value.find(
|
||||||
return
|
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) {
|
if (discoverTabs.value.length === 0) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
discoverTabs.value.sort((a, b) => {
|
discoverTabs.value.sort((a, b) => {
|
||||||
const aIndex = orderConfig.value.findIndex((item: { name: string }) => item.name === a.name)
|
const aIndex = orderConfig.value.findIndex(
|
||||||
const bIndex = orderConfig.value.findIndex((item: { name: string }) => item.name === b.name)
|
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)
|
return (aIndex === -1 ? 999 : aIndex) - (bIndex === -1 ? 999 : bIndex)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 加载顺序
|
// 校验服务端或旧版本地缓存中的标签配置,并为旧版顺序项补上默认显示状态。
|
||||||
async function loadOrderConfig() {
|
function normalizeTabConfig(value: unknown): DiscoverTabConfigItem[] | null {
|
||||||
// 顺序配置
|
if (!Array.isArray(value)) return null
|
||||||
const local_order = localStorage.getItem(localOrderKey)
|
|
||||||
if (local_order) {
|
|
||||||
try {
|
|
||||||
orderConfig.value = JSON.parse(local_order)
|
|
||||||
return
|
|
||||||
} catch {
|
|
||||||
localStorage.removeItem(localOrderKey)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await api.get(`/user/config/${localOrderKey}`)
|
const normalized: DiscoverTabConfigItem[] = []
|
||||||
if (response && response.data && response.data.value) {
|
for (const rawItem of value) {
|
||||||
orderConfig.value = response.data.value
|
if (!rawItem || typeof rawItem !== 'object' || Array.isArray(rawItem)) return null
|
||||||
localStorage.setItem(localOrderKey, JSON.stringify(orderConfig.value))
|
|
||||||
|
const item = rawItem as Record<string, unknown>
|
||||||
|
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) {
|
async function loadOrderConfig() {
|
||||||
discoverTabs.value = [...tabs]
|
|
||||||
// 顺序配置
|
|
||||||
const orderObj = discoverTabs.value.map(item => ({ name: item.name }))
|
|
||||||
orderConfig.value = orderObj
|
|
||||||
const orderString = JSON.stringify(orderObj)
|
|
||||||
localStorage.setItem(localOrderKey, orderString)
|
|
||||||
|
|
||||||
// 保存到服务端
|
|
||||||
try {
|
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) {
|
} catch (error) {
|
||||||
console.error(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({
|
registerHeaderTab({
|
||||||
items: discoverTabItems, // 传递computed值,会自动响应变化
|
items: discoverTabItems, // 传递computed值,会自动响应变化
|
||||||
modelValue: activeTab,
|
modelValue: activeTab,
|
||||||
appendButtons: [
|
})
|
||||||
{
|
|
||||||
icon: 'mdi-order-alphabetical-ascending',
|
useDynamicButton({
|
||||||
variant: 'text',
|
icon: 'mdi-tune',
|
||||||
color: 'grey',
|
onClick: openTabSettingsDialog,
|
||||||
class: 'settings-icon-button',
|
permission: 'discovery',
|
||||||
permission: 'discovery',
|
show: computed(() => appMode.value),
|
||||||
action: openOrderConfigDialog,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
})
|
||||||
|
|
||||||
async function initializeDiscover() {
|
async function initializeDiscover() {
|
||||||
@@ -204,11 +282,9 @@ async function initializeDiscover() {
|
|||||||
console.log(error)
|
console.log(error)
|
||||||
}
|
}
|
||||||
await loadExtraDiscoverSources()
|
await loadExtraDiscoverSources()
|
||||||
sortSubscribeOrder()
|
sortDiscoverTabs()
|
||||||
// 选中第一个标签页
|
// VWindow 会在异步配置返回前选中模板首项,初始化完成后必须按用户顺序重新选择。
|
||||||
if (discoverTabs.value.length > 0) {
|
ensureActiveTab(true)
|
||||||
activeTab.value = discoverTabs.value[0].mediaid_prefix
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onBeforeMount(() => {
|
onBeforeMount(() => {
|
||||||
@@ -224,13 +300,8 @@ onActivated(async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await loadExtraDiscoverSources()
|
await loadExtraDiscoverSources()
|
||||||
sortSubscribeOrder()
|
sortDiscoverTabs()
|
||||||
// 如果当前没有选中任何标签页,或者当前选中的标签页不存在,则选中第一个标签页
|
ensureActiveTab()
|
||||||
if (!activeTab.value || !discoverTabs.value.find(tab => tab.mediaid_prefix === activeTab.value)) {
|
|
||||||
if (discoverTabs.value.length > 0) {
|
|
||||||
activeTab.value = discoverTabs.value[0].mediaid_prefix
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -268,9 +339,21 @@ onActivated(async () => {
|
|||||||
</div>
|
</div>
|
||||||
</VWindowItem>
|
</VWindowItem>
|
||||||
</VWindow>
|
</VWindow>
|
||||||
|
<Teleport to="body" v-if="route.path === '/discover'">
|
||||||
|
<div v-if="!appMode && canDiscovery" class="compact-fab-stack">
|
||||||
|
<VFab
|
||||||
|
icon="mdi-tune"
|
||||||
|
color="primary"
|
||||||
|
appear
|
||||||
|
class="compact-fab compact-fab--primary"
|
||||||
|
:aria-label="t('discover.customizeTabs')"
|
||||||
|
@click="openTabSettingsDialog"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
<!-- 快速滚动到顶部按钮 -->
|
<!-- 快速滚动到顶部按钮 -->
|
||||||
<Teleport to="body" v-if="route.path === '/discover'">
|
<Teleport to="body" v-if="route.path === '/discover'">
|
||||||
<VScrollToTopBtn />
|
<VScrollToTopBtn :offset-fab="!appMode && canDiscovery" />
|
||||||
</Teleport>
|
</Teleport>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ import { HttpResponse, http, type JsonBodyType } from 'msw'
|
|||||||
|
|
||||||
const API_BASE_URL = 'http://localhost/api/v1/'
|
const API_BASE_URL = 'http://localhost/api/v1/'
|
||||||
|
|
||||||
export interface DiscoverOrderItem {
|
export interface DiscoverTabConfigItem {
|
||||||
|
enabled?: boolean
|
||||||
|
mediaid_prefix?: string
|
||||||
name: string
|
name: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24,7 +26,7 @@ export function discoverSourcesHandler(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function discoverOrderConfigHandler(
|
export function discoverOrderConfigHandler(
|
||||||
order: DiscoverOrderItem[] | null,
|
order: DiscoverTabConfigItem[] | null,
|
||||||
status = 200,
|
status = 200,
|
||||||
onRequest: () => void | Promise<void> = () => {},
|
onRequest: () => void | Promise<void> = () => {},
|
||||||
) {
|
) {
|
||||||
@@ -35,11 +37,11 @@ export function discoverOrderConfigHandler(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function saveDiscoverOrderHandler(
|
export function saveDiscoverOrderHandler(
|
||||||
onSave: (order: DiscoverOrderItem[]) => void | Promise<void> = () => {},
|
onSave: (order: DiscoverTabConfigItem[]) => void | Promise<void> = () => {},
|
||||||
status = 200,
|
status = 200,
|
||||||
) {
|
) {
|
||||||
return http.post(discoverApiUrls.orderConfig, async ({ request }) => {
|
return http.post(discoverApiUrls.orderConfig, async ({ request }) => {
|
||||||
const order = (await request.json()) as DiscoverOrderItem[]
|
const order = (await request.json()) as DiscoverTabConfigItem[]
|
||||||
await onSave(order)
|
await onSave(order)
|
||||||
return HttpResponse.json({ success: status < 400 }, { status })
|
return HttpResponse.json({ success: status < 400 }, { status })
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user