fix(history): refine status filter layout

This commit is contained in:
jxxghp
2026-09-03 06:47:22 +08:00
parent c5d4b9ee98
commit 849a43bed7
2 changed files with 178 additions and 68 deletions
+113 -31
View File
@@ -92,15 +92,46 @@ const completedDeleteSteps = new Map<number, { source: boolean; destination: boo
type TransferHistoryStatusFilter = 'all' | 'success' | 'failed'
interface TransferHistoryStatusFilterItem {
title: string
value: TransferHistoryStatusFilter
icon: string
color?: string
}
// 独立状态筛选,不再把本地化文案作为 title 的后端协议。
const statusFilter = ref<TransferHistoryStatusFilter>(getRouteStatusFilter(route.query.status))
const mobileStatusFilterMenu = ref(false)
const statusFilterItems = computed(() => [
{ title: t('transferHistory.statusFilter.all'), value: 'all' },
{ title: t('transferHistory.status.success'), value: 'success' },
{ title: t('transferHistory.status.failed'), value: 'failed' },
const statusFilterItems = computed<TransferHistoryStatusFilterItem[]>(() => [
{ title: t('transferHistory.statusFilter.all'), value: 'all', icon: 'mdi-filter-multiple-outline' },
{ title: t('transferHistory.status.success'), value: 'success', icon: 'mdi-check-circle', color: 'success' },
{ title: t('transferHistory.status.failed'), value: 'failed', icon: 'mdi-alert-circle', color: 'error' },
])
const currentStatusFilter = computed<TransferHistoryStatusFilterItem>(() => {
return (
statusFilterItems.value.find(item => item.value === statusFilter.value) ?? {
title: t('transferHistory.statusFilter.all'),
value: 'all',
icon: 'mdi-filter-multiple-outline',
}
)
})
const statusFilterButtonColor = computed(() =>
statusFilter.value === 'all' ? 'gray' : currentStatusFilter.value.color,
)
// 移动端状态筛选沿用订阅页的外部激活器菜单,确保按钮固定在标题栏右侧。
const mobileStatusFilterActivator = computed(() => '[data-menu-activator="history-status-filter-btn"]')
// 选择移动端状态后立即关闭菜单,状态监听器负责刷新列表并同步地址栏。
function selectStatusFilter(value: TransferHistoryStatusFilter) {
statusFilter.value = value
mobileStatusFilterMenu.value = false
}
// 移动端批量选择模式
const mobileBatchMode = ref(false)
@@ -1601,7 +1632,12 @@ onUnmounted(() => {
<VCardItem>
<VCardTitle>
<VRow>
<VCol cols="8" md="4" class="flex">
<VCol cols="8" class="flex">
<div
class="transfer-history-desktop-filter-group"
role="group"
:aria-label="t('transferHistory.statusFilter.label')"
>
<VCombobox
key="search_navbar"
:model-value="search"
@@ -1609,32 +1645,30 @@ onUnmounted(() => {
:items="searchHintList"
@compositionstart="isComposing = true"
@compositionend="isComposing = false"
class="text-disabled"
class="text-disabled transfer-history-desktop-search"
density="compact"
:placeholder="t('transferHistory.searchPlaceholder')"
:aria-label="t('transferHistory.searchPlaceholder')"
prepend-inner-icon="mdi-magnify"
variant="solo-filled"
max-width="25rem"
variant="outlined"
single-line
hide-details
flat
rounded="pill"
clearable
/>
</VCol>
<VCol cols="4" md="4">
<VSelect
v-model="statusFilter"
:items="statusFilterItems"
item-title="title"
item-value="value"
:prepend-inner-icon="currentStatusFilter.icon"
density="compact"
variant="solo-filled"
variant="outlined"
hide-details
flat
class="transfer-history-desktop-status"
:label="t('transferHistory.statusFilter.label')"
:aria-label="t('transferHistory.statusFilter.label')"
/>
</div>
</VCol>
<VCol cols="4" md="4" class="text-end">
<VBtnGroup variant="outlined" divided rounded>
@@ -1904,6 +1938,17 @@ onUnmounted(() => {
class="transfer-history-mobile-title my-0"
style="margin-block: 0"
/>
<div class="transfer-history-mobile-titlebar__actions">
<VBtn
icon="mdi-filter-multiple-outline"
:color="statusFilterButtonColor"
:aria-label="t('transferHistory.statusFilter.label')"
:title="t('transferHistory.statusFilter.label')"
variant="text"
class="settings-icon-button"
data-menu-activator="history-status-filter-btn"
@click="mobileStatusFilterMenu = true"
/>
<VBtn
v-if="canManage"
icon="mdi-checkbox-multiple-marked-outline"
@@ -1915,10 +1960,11 @@ onUnmounted(() => {
mobileBatchMode ? t('transferHistory.actions.exitBatchSelect') : t('transferHistory.actions.batchSelect')
"
variant="text"
class="settings-icon-button transfer-history-mobile-titlebar__batch"
class="settings-icon-button"
@click="toggleMobileBatchMode"
/>
</div>
</div>
<VCombobox
key="search_mobile"
@@ -1940,17 +1986,30 @@ onUnmounted(() => {
clearable
/>
<VSelect
v-model="statusFilter"
:items="statusFilterItems"
item-title="title"
item-value="value"
density="comfortable"
variant="outlined"
hide-details
class="transfer-history-mobile-status"
:label="t('transferHistory.statusFilter.label')"
/>
<Teleport to="body" v-if="mobileStatusFilterMenu">
<VMenu v-model="mobileStatusFilterMenu" :activator="mobileStatusFilterActivator" location="bottom end">
<VCard min-width="200">
<VList density="compact" class="px-2 py-1">
<VListSubheader>{{ t('transferHistory.statusFilter.label') }}</VListSubheader>
<VListItem
v-for="option in statusFilterItems"
:key="option.value"
:active="statusFilter === option.value"
density="compact"
@click="selectStatusFilter(option.value)"
>
<template #prepend>
<VIcon :icon="option.icon" :color="option.color" size="small" />
</template>
<VListItemTitle>{{ option.title }}</VListItemTitle>
<template #append>
<VIcon v-if="statusFilter === option.value" icon="mdi-check" color="primary" size="small" />
</template>
</VListItem>
</VList>
</VCard>
</VMenu>
</Teleport>
<VInfiniteScroll
:key="mobileInfiniteKey"
@@ -2169,6 +2228,31 @@ onUnmounted(() => {
border-radius: 0;
}
.transfer-history-desktop-filter-group {
display: flex;
inline-size: min(100%, 36rem);
}
.transfer-history-desktop-search {
flex: 1 1 auto;
min-inline-size: 12rem;
}
.transfer-history-desktop-search :deep(.v-field) {
border-start-end-radius: 0;
border-end-end-radius: 0;
}
.transfer-history-desktop-status {
flex: 0 0 10rem;
margin-inline-start: -1px;
}
.transfer-history-desktop-status :deep(.v-field) {
border-start-start-radius: 0;
border-end-start-radius: 0;
}
.transfer-history-desktop-media-cell {
display: flex;
align-items: center;
@@ -2236,8 +2320,10 @@ onUnmounted(() => {
min-inline-size: 0;
}
.transfer-history-mobile-titlebar__batch {
.transfer-history-mobile-titlebar__actions {
display: flex;
flex: 0 0 auto;
align-items: center;
}
.transfer-history-mobile-title :deep(h2) {
@@ -2249,10 +2335,6 @@ onUnmounted(() => {
min-inline-size: 0;
}
.transfer-history-mobile-status {
margin-block: 0.75rem;
}
.transfer-history-mobile-search :deep(.v-field) {
background: var(--transfer-history-mobile-search-bg);
min-block-size: 3.75rem;
@@ -438,6 +438,34 @@ describe('TransferHistoryView', () => {
await waitFor(() => expect(requests).toEqual([{ count: 50, page: 1, status: false, title: '失败' }]))
})
it('joins the desktop status filter to search and moves the mobile filter into the titlebar menu', () => {
expect(transferHistorySource).toContain('class="transfer-history-desktop-filter-group"')
expect(transferHistorySource).toContain('class="text-disabled transfer-history-desktop-search"')
expect(transferHistorySource).toContain('class="transfer-history-desktop-status"')
expect(transferHistorySource).toContain('border-start-end-radius: 0;')
expect(transferHistorySource).toContain('border-start-start-radius: 0;')
expect(transferHistorySource).toContain('data-menu-activator="history-status-filter-btn"')
expect(transferHistorySource).not.toContain('class="transfer-history-mobile-status"')
})
it('selects a mobile status from the titlebar dropdown and refreshes with the explicit status query', async () => {
mocks.desktop = false
const requests: Array<Record<string, unknown>> = []
mocks.apiGet.mockImplementation((path: string, config?: { params?: Record<string, unknown> }) => {
if (path === 'system/setting/public/Storages') return Promise.resolve(storageResponse())
requests.push(config?.params ?? {})
return Promise.resolve(historyResponse([]))
})
const { router } = await renderHistory()
await fireEvent.click(screen.getByRole('button', { name: '状态筛选' }))
await fireEvent.click(screen.getByRole('button', { name: '失败' }))
await waitFor(() => expect(router.currentRoute.value.query.status).toBe('failed'))
await fireEvent.click(screen.getByRole('button', { name: '加载下一页' }))
await waitFor(() => expect(requests).toContainEqual({ count: 25, page: 1, status: false, title: '' }))
})
it('prevents an older desktop request from replacing a newer route search', async () => {
const oldRequest = createDeferred<ReturnType<typeof historyResponse>>()
const newRequest = createDeferred<ReturnType<typeof historyResponse>>()