feat(calendar): add movie/TV series filter to subscription calendar (#575)

This commit is contained in:
zangse
2026-07-23 06:04:55 +08:00
committed by GitHub
parent 4a196731f1
commit b4be18e71f
5 changed files with 197 additions and 2 deletions

View File

@@ -1314,6 +1314,10 @@ export default {
movie: 'Movie',
imageLoadFailed: 'Failed',
noMatchingEvents: 'No calendar items match the current filters',
mediaTypeFilterTitle: 'Media type filter',
filterAll: 'All',
filterMovie: 'Movie',
filterTv: 'TV Series',
},
storage: {
name: 'Name',

View File

@@ -1307,6 +1307,10 @@ export default {
movie: '电影',
imageLoadFailed: '加载失败',
noMatchingEvents: '暂无符合筛选条件的日历内容',
mediaTypeFilterTitle: '媒体类型筛选',
filterAll: '全部',
filterMovie: '电影',
filterTv: '电视剧',
},
storage: {
name: '名称',

View File

@@ -1305,6 +1305,10 @@ export default {
movie: '電影',
imageLoadFailed: '載入失敗',
noMatchingEvents: '暫無符合篩選條件的日曆內容',
mediaTypeFilterTitle: '媒體類型篩選',
filterAll: '全部',
filterMovie: '電影',
filterTv: '電視劇',
},
storage: {
name: '名稱',

View File

@@ -145,6 +145,28 @@ const mobileSelectedFilterValue = ref(ALL_MOBILE_FILTER_VALUE)
// 移动端默认隐藏已经过期的日历项。
const mobileHideExpired = ref(true)
// 媒体类型筛选取值。
type MediaTypeFilterValue = 'all' | 'movie' | 'tv'
// 当前媒体类型筛选值,桌面端和移动端共用。
const mediaTypeFilter = ref<MediaTypeFilterValue>('all')
// 判断日历事件是否命中当前媒体类型筛选。
function matchesMediaTypeFilter(event: CalendarEventInfo) {
if (mediaTypeFilter.value === 'all') return true
const isMovie = event.mediaType === '电影'
return mediaTypeFilter.value === 'movie' ? isMovie : !isMovie
}
// 媒体类型筛选选项,桌面端和移动端共用。
const mediaTypeFilterOptions = computed<{ label: string; value: MediaTypeFilterValue }[]>(() => [
{ label: t('calendar.filterAll'), value: 'all' },
{ label: t('calendar.filterMovie'), value: 'movie' },
{ label: t('calendar.filterTv'), value: 'tv' },
])
// 移动端剧集筛选项。
const mobileSeriesFilterOptions = computed<MobileCalendarFilterOption[]>(() => {
const optionMap = new Map<string, MobileCalendarFilterOption>()
@@ -178,6 +200,10 @@ const mobileSeriesFilterOptions = computed<MobileCalendarFilterOption[]>(() => {
// 移动端筛选后的日历事件。
const mobileFilteredCalendarEvents = computed(() => {
return rawCalendarEvents.value.filter(event => {
if (!matchesMediaTypeFilter(event)) {
return false
}
if (mobileSelectedFilterValue.value !== ALL_MOBILE_FILTER_VALUE && event.title !== mobileSelectedFilterValue.value) {
return false
}
@@ -219,6 +245,13 @@ const mobileCalendarDayGroups = computed<MobileCalendarDayGroup[]>(() => {
})
})
watch(mediaTypeFilter, () => {
// 切换筛选前先收起所有已展开日期,避免 expandCalendarDay 直接写入 FullCalendar
// 内部事件的 visibleEvents 残留旧筛选条件下的展开内容。
expandedDateKeys.value = new Set()
renderVisibleCalendarEvents()
})
watch(mobileSeriesFilterOptions, options => {
if (
mobileSelectedFilterValue.value !== ALL_MOBILE_FILTER_VALUE &&
@@ -282,7 +315,7 @@ function normalizeCalendarEventOrder(events: CalendarEventInfo[]) {
function renderVisibleCalendarEvents() {
const groupedEvents = new Map<string, CalendarEventInfo[]>()
rawCalendarEvents.value.forEach(event => {
rawCalendarEvents.value.filter(matchesMediaTypeFilter).forEach(event => {
const dateKey = getDateKey(event.start)
if (!dateKey) return
@@ -297,7 +330,9 @@ function renderVisibleCalendarEvents() {
// 展开指定日期在桌面日历中的折叠事件。
function expandCalendarDay(dateKey: string) {
const currentScrollY = window.scrollY
const events = rawCalendarEvents.value.filter(event => getDateKey(event.start) === dateKey)
const events = rawCalendarEvents.value
.filter(matchesMediaTypeFilter)
.filter(event => getDateKey(event.start) === dateKey)
const calendarApi = calendarRef.value?.getApi()
expandedDateKeys.value = new Set(expandedDateKeys.value).add(dateKey)
@@ -667,6 +702,20 @@ onActivated(() => {
</script>
<template>
<div v-if="display.mdAndUp.value" class="calendar-media-type-filter" role="group" :aria-label="t('calendar.mediaTypeFilterTitle')">
<button
v-for="option in mediaTypeFilterOptions"
:key="option.value"
type="button"
class="calendar-media-type-chip"
:class="{ 'calendar-media-type-chip--active': mediaTypeFilter === option.value }"
:aria-pressed="mediaTypeFilter === option.value"
@click="mediaTypeFilter = option.value"
>
{{ option.label }}
</button>
</div>
<FullCalendar v-if="display.mdAndUp.value" ref="calendarRef" :options="calendarOptions">
<template #eventContent="arg">
<div v-if="arg.event.extendedProps.isDayGroup" class="calendar-day-events">
@@ -782,6 +831,20 @@ onActivated(() => {
</button>
</div>
<div class="mobile-calendar-mediatype-list" role="group" :aria-label="t('calendar.mediaTypeFilterTitle')">
<button
v-for="option in mediaTypeFilterOptions"
:key="option.value"
type="button"
class="mobile-calendar-filter-chip mobile-calendar-mediatype-chip"
:class="{ 'mobile-calendar-filter-chip--active': mediaTypeFilter === option.value }"
:aria-pressed="mediaTypeFilter === option.value"
@click="mediaTypeFilter = option.value"
>
{{ option.label }}
</button>
</div>
<div class="mobile-calendar-filter-list" role="listbox" :aria-label="t('calendar.mobileFilterTitle')">
<button
v-for="option in mobileSeriesFilterOptions"
@@ -1165,6 +1228,33 @@ onActivated(() => {
background-color: transparent !important;
}
.calendar-media-type-filter {
display: flex;
gap: 0.5rem;
margin-block-end: 0.75rem;
}
.calendar-media-type-chip {
display: inline-flex;
align-items: center;
justify-content: center;
padding-block: 0.4rem;
padding-inline: 1rem;
border: 0;
border-radius: var(--app-control-radius);
background: rgba(var(--v-theme-on-surface), 0.08);
color: rgba(var(--v-theme-on-surface), 0.72);
cursor: pointer;
font-size: 0.86rem;
font-weight: 700;
line-height: 1.2;
}
.calendar-media-type-chip--active {
background: rgba(var(--v-theme-primary), 0.14);
color: rgb(var(--v-theme-primary));
}
.calendar-event-card {
display: flex;
overflow: hidden;
@@ -1452,6 +1542,16 @@ onActivated(() => {
color: rgb(var(--v-theme-primary));
}
.mobile-calendar-mediatype-list {
display: flex;
gap: 0.5rem;
margin-block-end: 0.75rem;
}
.mobile-calendar-mediatype-chip {
flex: 1 1 0;
}
.mobile-calendar-filter-list {
display: flex;
gap: 0.6rem;

View File

@@ -434,6 +434,89 @@ describe('FullCalendarView', () => {
expect(onListRequest).toHaveBeenCalledTimes(2)
})
it('filters desktop calendar events by media type', async () => {
const movie = movieSubscribe(3801, '筛选电影')
const tv = tvSubscribe(3802, '筛选剧集')
server.use(
subscribeListHandler([movie, tv]),
mediaDetailsHandler(3801, createMediaInfo({ release_date: '2026-07-24', tmdb_id: 3801 })),
tmdbSeasonEpisodesHandler(3802, 1, [createTmdbEpisode({ air_date: '2026-07-24', episode_number: 1 })]),
)
await renderCalendar()
expect(await screen.findByText('筛选电影')).toBeInTheDocument()
expect(screen.getByText('筛选剧集')).toBeInTheDocument()
await fireEvent.click(screen.getByRole('button', { name: '电视剧' }))
expect(screen.queryByText('筛选电影')).not.toBeInTheDocument()
expect(screen.getByText('筛选剧集')).toBeInTheDocument()
await fireEvent.click(screen.getByRole('button', { name: '电影' }))
expect(screen.getByText('筛选电影')).toBeInTheDocument()
expect(screen.queryByText('筛选剧集')).not.toBeInTheDocument()
await fireEvent.click(screen.getByRole('button', { name: '全部' }))
expect(screen.getByText('筛选电影')).toBeInTheDocument()
expect(screen.getByText('筛选剧集')).toBeInTheDocument()
})
it('re-collapses a previously expanded desktop day when the media type filter changes', async () => {
const sameDaySubscriptions = Array.from({ length: 6 }, (_, index) =>
tvSubscribe(4000 + index, `折叠项目 ${index + 1}`),
)
const sameDayEpisode = createTmdbEpisode({ air_date: '2026-08-05', episode_number: 1 })
server.use(
subscribeListHandler(sameDaySubscriptions),
...sameDaySubscriptions.map(subscribe =>
tmdbSeasonEpisodesHandler(subscribe.tmdbid as number, 1, [sameDayEpisode]),
),
)
vi.spyOn(window, 'scrollTo').mockImplementation(() => {})
vi.spyOn(window, 'requestAnimationFrame').mockImplementation(callback => {
callback(0)
return 1
})
await renderCalendar()
expect(await screen.findByText('折叠项目 1')).toBeInTheDocument()
expect(screen.queryByText('折叠项目 6')).not.toBeInTheDocument()
await fireEvent.click(screen.getByRole('button', { name: '展开当天剩余 1 个条目' }))
expect(screen.getByText('折叠项目 6')).toBeInTheDocument()
await fireEvent.click(screen.getByRole('button', { name: '电影' }))
await fireEvent.click(screen.getByRole('button', { name: '全部' }))
expect(await screen.findByText('折叠项目 1')).toBeInTheDocument()
expect(screen.queryByText('折叠项目 6')).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: '展开当天剩余 1 个条目' })).toBeInTheDocument()
})
it('filters mobile calendar events by media type', async () => {
setViewport(480)
const movie = movieSubscribe(3901, '移动筛选电影')
const tv = tvSubscribe(3902, '移动筛选剧集')
server.use(
subscribeListHandler([movie, tv]),
mediaDetailsHandler(3901, createMediaInfo({ release_date: '2026-07-24', tmdb_id: 3901 })),
tmdbSeasonEpisodesHandler(3902, 1, [createTmdbEpisode({ air_date: '2026-07-24', episode_number: 1 })]),
)
await renderCalendar()
await waitFor(() => expect(queryMobileCalendarEventCard('移动筛选电影')).toBeInTheDocument())
expect(queryMobileCalendarEventCard('移动筛选剧集')).toBeInTheDocument()
await fireEvent.click(screen.getByRole('button', { name: '电视剧' }))
expect(queryMobileCalendarEventCard('移动筛选电影')).not.toBeInTheDocument()
expect(queryMobileCalendarEventCard('移动筛选剧集')).toBeInTheDocument()
await fireEvent.click(screen.getByRole('button', { name: '全部' }))
expect(queryMobileCalendarEventCard('移动筛选电影')).toBeInTheDocument()
expect(queryMobileCalendarEventCard('移动筛选剧集')).toBeInTheDocument()
})
it('shows the mobile empty state for an empty subscription list', async () => {
setViewport(480)
server.use(subscribeListHandler([]))