+
+
{{ t('subscribe.requestFailed') }}
+
+ {{ t('common.retry') }}
+
+
{{ t('subscribe.noStatisticsData') }}
diff --git a/src/components/dialog/__tests__/ForkSubscribeDialog.spec.ts b/src/components/dialog/__tests__/ForkSubscribeDialog.spec.ts
new file mode 100644
index 00000000..d853c072
--- /dev/null
+++ b/src/components/dialog/__tests__/ForkSubscribeDialog.spec.ts
@@ -0,0 +1,395 @@
+import type { SubscribeShare } from '@/api/types'
+import ForkSubscribeDialog from '@/components/dialog/ForkSubscribeDialog.vue'
+import { screen, waitFor } from '@testing-library/vue'
+import userEvent from '@testing-library/user-event'
+import { createSubscribeShare } from '@tests/support/factories/subscribe'
+import {
+ deleteSubscribeShareHandler,
+ followSubscriberHandler,
+ followSubscribersSettingHandler,
+ forkSubscribeHandler,
+ unfollowSubscriberHandler,
+} from '@tests/support/msw/handlers/subscribe'
+import { server } from '@tests/support/msw/server'
+import { renderWithProviders } from '@tests/support/render'
+import { flushPromises } from '@vue/test-utils'
+import { defineComponent, h } from 'vue'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const mocks = vi.hoisted(() => ({
+ routerPush: vi.fn(),
+ toastError: vi.fn(),
+ toastSuccess: vi.fn(),
+}))
+
+vi.mock('@/router', () => ({
+ default: {
+ push: (...args: unknown[]) => mocks.routerPush(...args),
+ },
+}))
+
+vi.mock('@/api/nprogress', () => ({
+ doneNProgress: vi.fn(),
+ startNProgress: vi.fn(),
+}))
+
+vi.mock('vue-toastification', () => ({
+ useToast: () => ({ error: mocks.toastError, success: mocks.toastSuccess }),
+}))
+
+const DialogCloseButtonStub = defineComponent({
+ name: 'VDialogCloseBtn',
+ emits: ['click'],
+ setup(_props, { emit }) {
+ return () => h('button', { type: 'button', onClick: () => emit('click') }, '关闭')
+ },
+})
+
+const PosterStub = defineComponent({
+ name: 'VImg',
+ emits: ['click'],
+ setup(_props, { emit }) {
+ return () =>
+ h(
+ 'button',
+ {
+ 'aria-label': '查看媒体详情',
+ type: 'button',
+ onClick: () => emit('click'),
+ },
+ '查看媒体详情',
+ )
+ },
+})
+
+interface MediaIdentifiers {
+ bangumiid?: number
+ doubanid?: string
+ tmdbid?: number
+}
+
+const mediaDetailCases: Array<[string, MediaIdentifiers, string]> = [
+ ['TMDB', { tmdbid: 6301 }, 'tmdb:6301'],
+ ['Douban', { doubanid: 'db-6302', tmdbid: undefined }, 'douban:db-6302'],
+ ['Bangumi', { bangumiid: 6303, doubanid: undefined, tmdbid: undefined }, 'bangumi:6303'],
+]
+
+function createDeferred() {
+ let resolve!: () => void
+ const promise = new Promise
(done => {
+ resolve = done
+ })
+ return { promise, resolve }
+}
+
+async function renderDialog(
+ media: SubscribeShare = createSubscribeShare(),
+ settings: Record = {},
+) {
+ const events = {
+ close: vi.fn(),
+ delete: vi.fn(),
+ fork: vi.fn(),
+ }
+ const result = await renderWithProviders(ForkSubscribeDialog, {
+ global: {
+ components: {
+ VDialogCloseBtn: DialogCloseButtonStub,
+ },
+ stubs: {
+ VImg: PosterStub,
+ },
+ },
+ initialState: {
+ globalSettings: {
+ data: {
+ GLOBAL_IMAGE_CACHE: false,
+ SUBSCRIBE_SHARE_MANAGE: false,
+ USER_UNIQUE_ID: 'current-user',
+ ...settings,
+ },
+ },
+ },
+ props: {
+ media,
+ modelValue: true,
+ onClose: events.close,
+ onDelete: events.delete,
+ onFork: events.fork,
+ },
+ })
+ await flushPromises()
+ await flushPromises()
+
+ return { ...result, events, media }
+}
+
+describe('ForkSubscribeDialog follow behavior', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ vi.spyOn(console, 'error').mockImplementation(() => {})
+ vi.spyOn(console, 'log').mockImplementation(() => {})
+ })
+
+ it('loads the followed users and shows the current action', async () => {
+ const media = createSubscribeShare({ share_uid: 'followed-user' })
+ const requested = vi.fn()
+ server.use(followSubscribersSettingHandler(['followed-user'], 200, requested))
+
+ await renderDialog(media)
+
+ expect(await screen.findByRole('button', { name: '取消关注' })).toBeInTheDocument()
+ expect(screen.queryByRole('button', { name: '关注' })).not.toBeInTheDocument()
+ expect(requested).toHaveBeenCalledOnce()
+ })
+
+ it('follows a share user and refreshes the action from the server setting', async () => {
+ const media = createSubscribeShare({ share_uid: 'new-follow-user' })
+ const users: string[] = []
+ const writeRequest = vi.fn((url: URL) => {
+ users.push(url.searchParams.get('share_uid') || '')
+ })
+ server.use(
+ followSubscribersSettingHandler(users),
+ followSubscriberHandler({ success: true }, 200, writeRequest),
+ )
+ const user = userEvent.setup()
+ await renderDialog(media)
+
+ await user.click(await screen.findByRole('button', { name: '关注' }))
+
+ expect(await screen.findByRole('button', { name: '取消关注' })).toBeInTheDocument()
+ expect(writeRequest).toHaveBeenCalledOnce()
+ expect(writeRequest.mock.calls[0][0].pathname).toBe('/api/v1/subscribe/follow')
+ expect(writeRequest.mock.calls[0][0].searchParams.get('share_uid')).toBe('new-follow-user')
+ })
+
+ it('unfollows a share user and refreshes the action from the server setting', async () => {
+ const media = createSubscribeShare({ share_uid: 'old-follow-user' })
+ const users = ['old-follow-user']
+ const writeRequest = vi.fn((url: URL) => {
+ users.splice(users.indexOf(url.searchParams.get('share_uid') || ''), 1)
+ })
+ server.use(
+ followSubscribersSettingHandler(users),
+ unfollowSubscriberHandler({ success: true }, 200, writeRequest),
+ )
+ const user = userEvent.setup()
+ await renderDialog(media)
+
+ await user.click(await screen.findByRole('button', { name: '取消关注' }))
+
+ expect(await screen.findByRole('button', { name: '关注' })).toBeInTheDocument()
+ expect(writeRequest).toHaveBeenCalledOnce()
+ expect(writeRequest.mock.calls[0][0].pathname).toBe('/api/v1/subscribe/follow')
+ expect(writeRequest.mock.calls[0][0].searchParams.get('share_uid')).toBe('old-follow-user')
+ })
+
+ it('does not show follow actions when the share has no UID', async () => {
+ const media = createSubscribeShare({ share_uid: undefined })
+ server.use(followSubscribersSettingHandler([]))
+
+ await renderDialog(media)
+ await waitFor(() => expect(screen.getByRole('button', { name: '订阅' })).toBeInTheDocument())
+
+ expect(screen.queryByRole('button', { name: '关注' })).not.toBeInTheDocument()
+ expect(screen.queryByRole('button', { name: '取消关注' })).not.toBeInTheDocument()
+ })
+
+ it('shows visible feedback when the followed-user list request fails', async () => {
+ server.use(followSubscribersSettingHandler([], 500))
+
+ await renderDialog(createSubscribeShare())
+
+ await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith('请求失败,请稍后重试'))
+ })
+
+ it('keeps the follow action and shows feedback when the follow request fails', async () => {
+ const media = createSubscribeShare({ share_uid: 'failed-follow-user' })
+ server.use(followSubscribersSettingHandler([]), followSubscriberHandler({ success: true }, 500))
+ const user = userEvent.setup()
+ await renderDialog(media)
+
+ await user.click(await screen.findByRole('button', { name: '关注' }))
+
+ await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith('请求失败,请稍后重试'))
+ expect(screen.getByRole('button', { name: '关注' })).toBeInTheDocument()
+ })
+
+ it('keeps the unfollow action and shows feedback when the unfollow request fails', async () => {
+ const media = createSubscribeShare({ share_uid: 'failed-unfollow-user' })
+ server.use(
+ followSubscribersSettingHandler(['failed-unfollow-user']),
+ unfollowSubscriberHandler({ success: true }, 500),
+ )
+ const user = userEvent.setup()
+ await renderDialog(media)
+
+ await user.click(await screen.findByRole('button', { name: '取消关注' }))
+
+ await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith('请求失败,请稍后重试'))
+ expect(screen.getByRole('button', { name: '取消关注' })).toBeInTheDocument()
+ })
+})
+
+describe('ForkSubscribeDialog fork, delete, and navigation behavior', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ vi.spyOn(console, 'error').mockImplementation(() => {})
+ vi.spyOn(console, 'log').mockImplementation(() => {})
+ })
+
+ it('keeps the fork button pending and emits the created subscription ID on success', async () => {
+ const media = createSubscribeShare({ id: 6101, share_title: '待复用分享' })
+ const deferred = createDeferred()
+ const forkPayload = vi.fn(() => deferred.promise)
+ server.use(
+ followSubscribersSettingHandler([]),
+ forkSubscribeHandler({ data: { id: 7101 }, success: true }, 200, forkPayload),
+ )
+ const user = userEvent.setup()
+ const { events } = await renderDialog(media)
+ const forkButton = screen.getByRole('button', { name: '订阅' })
+
+ await user.click(forkButton)
+ await waitFor(() => expect(forkPayload).toHaveBeenCalledOnce())
+ expect(forkButton).toBeDisabled()
+ expect(events.fork).not.toHaveBeenCalled()
+
+ deferred.resolve()
+ await waitFor(() => expect(events.fork).toHaveBeenCalledWith(7101))
+ expect(forkPayload).toHaveBeenCalledWith(media)
+ expect(mocks.toastSuccess).toHaveBeenCalledWith('添加待复用分享成功!')
+ })
+
+ it('reports a fork business failure and does not emit', async () => {
+ server.use(
+ followSubscribersSettingHandler([]),
+ forkSubscribeHandler({ message: '订阅已存在', success: false }),
+ )
+ const user = userEvent.setup()
+ const { events } = await renderDialog(createSubscribeShare({ share_title: '冲突分享' }))
+
+ await user.click(screen.getByRole('button', { name: '订阅' }))
+
+ await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith('添加冲突分享失败:订阅已存在!'))
+ expect(events.fork).not.toHaveBeenCalled()
+ expect(screen.getByRole('button', { name: '订阅' })).not.toBeDisabled()
+ })
+
+ it('reports an HTTP fork failure, restores the action, and does not emit', async () => {
+ server.use(followSubscribersSettingHandler([]), forkSubscribeHandler({ success: true }, 500))
+ const user = userEvent.setup()
+ const { events } = await renderDialog(createSubscribeShare())
+
+ await user.click(screen.getByRole('button', { name: '订阅' }))
+
+ await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith(expect.stringContaining('请求失败')))
+ expect(events.fork).not.toHaveBeenCalled()
+ expect(screen.getByRole('button', { name: '订阅' })).not.toBeDisabled()
+ })
+
+ it.each([
+ ['the owner', 'owned-share', false, true],
+ ['a share manager', 'other-user', true, true],
+ ['another ordinary user', 'other-user', false, false],
+ ])('shows delete permission for %s', async (_case, shareUid, canManage, visible) => {
+ server.use(followSubscribersSettingHandler([]))
+
+ await renderDialog(createSubscribeShare({ share_uid: shareUid }), {
+ SUBSCRIBE_SHARE_MANAGE: canManage,
+ USER_UNIQUE_ID: 'owned-share',
+ })
+ await waitFor(() => expect(screen.getByRole('button', { name: '订阅' })).toBeInTheDocument())
+
+ if (visible) expect(screen.getByRole('button', { name: '取消分享' })).toBeInTheDocument()
+ else expect(screen.queryByRole('button', { name: '取消分享' })).not.toBeInTheDocument()
+ })
+
+ it('deletes the exact share and emits success without relying on response data.id', async () => {
+ const media = createSubscribeShare({ id: 6201, share_uid: 'owned-share' })
+ const deferred = createDeferred()
+ const deleteRequest = vi.fn((_url: URL) => deferred.promise)
+ server.use(
+ followSubscribersSettingHandler([]),
+ deleteSubscribeShareHandler(6201, { success: true }, 200, deleteRequest),
+ )
+ const user = userEvent.setup()
+ const { events } = await renderDialog(media, { USER_UNIQUE_ID: 'owned-share' })
+ const deleteButton = screen.getByRole('button', { name: '取消分享' })
+
+ await user.click(deleteButton)
+ await waitFor(() => expect(deleteRequest).toHaveBeenCalledOnce())
+ expect(deleteButton).toBeDisabled()
+ expect(deleteRequest.mock.calls[0][0].pathname).toBe('/api/v1/subscribe/share/6201')
+
+ deferred.resolve()
+ await waitFor(() => expect(events.delete).toHaveBeenCalledOnce())
+ expect(events.delete).toHaveBeenCalledWith()
+ expect(mocks.toastSuccess).toHaveBeenCalledWith('已取消订阅!')
+ })
+
+ it('reports a delete business failure and does not emit', async () => {
+ const media = createSubscribeShare({ id: 6202, share_uid: 'owned-share' })
+ server.use(
+ followSubscribersSettingHandler([]),
+ deleteSubscribeShareHandler(6202, { message: '没有删除权限', success: false }),
+ )
+ const user = userEvent.setup()
+ const { events } = await renderDialog(media, { USER_UNIQUE_ID: 'owned-share' })
+
+ await user.click(screen.getByRole('button', { name: '取消分享' }))
+
+ await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith('取消订阅失败:没有删除权限!'))
+ expect(events.delete).not.toHaveBeenCalled()
+ expect(screen.getByRole('button', { name: '取消分享' })).not.toBeDisabled()
+ })
+
+ it('reports an HTTP delete failure, restores the action, and does not emit', async () => {
+ const media = createSubscribeShare({ id: 6203, share_uid: 'owned-share' })
+ server.use(followSubscribersSettingHandler([]), deleteSubscribeShareHandler(6203, { success: true }, 500))
+ const user = userEvent.setup()
+ const { events } = await renderDialog(media, { USER_UNIQUE_ID: 'owned-share' })
+
+ await user.click(screen.getByRole('button', { name: '取消分享' }))
+
+ await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith(expect.stringContaining('请求失败')))
+ expect(events.delete).not.toHaveBeenCalled()
+ expect(screen.getByRole('button', { name: '取消分享' })).not.toBeDisabled()
+ })
+
+ it('emits close from the dialog close control', async () => {
+ server.use(followSubscribersSettingHandler([]))
+ const user = userEvent.setup()
+ const { events } = await renderDialog(createSubscribeShare())
+
+ await user.click(screen.getByRole('button', { name: '关闭' }))
+
+ expect(events.close).toHaveBeenCalledOnce()
+ })
+
+ it.each(mediaDetailCases)('routes %s shares to their media details', async (_source, identifiers, expectedMediaId) => {
+ const media: SubscribeShare = {
+ ...createSubscribeShare({
+ doubanid: identifiers.doubanid,
+ tmdbid: identifiers.tmdbid,
+ }),
+ bangumiid: identifiers.bangumiid,
+ }
+ server.use(followSubscribersSettingHandler([]))
+ const user = userEvent.setup()
+ await renderDialog(media)
+
+ await user.click(screen.getByRole('button', { name: '查看媒体详情' }))
+
+ expect(mocks.routerPush).toHaveBeenCalledWith({
+ path: '/media',
+ query: {
+ mediaid: expectedMediaId,
+ title: media.name,
+ type: media.type,
+ year: media.year,
+ },
+ })
+ })
+})
diff --git a/src/components/dialog/__tests__/SubscribeShareDialog.spec.ts b/src/components/dialog/__tests__/SubscribeShareDialog.spec.ts
new file mode 100644
index 00000000..3815214b
--- /dev/null
+++ b/src/components/dialog/__tests__/SubscribeShareDialog.spec.ts
@@ -0,0 +1,170 @@
+import SubscribeShareDialog from '@/components/dialog/SubscribeShareDialog.vue'
+import { screen, waitFor } from '@testing-library/vue'
+import userEvent from '@testing-library/user-event'
+import { createSubscribe } from '@tests/support/factories/subscribe'
+import { shareSubscribeHandler } from '@tests/support/msw/handlers/subscribe'
+import { server } from '@tests/support/msw/server'
+import { renderWithProviders } from '@tests/support/render'
+import { flushPromises } from '@vue/test-utils'
+import { defineComponent, h } from 'vue'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const mocks = vi.hoisted(() => ({
+ toastError: vi.fn(),
+ toastSuccess: vi.fn(),
+}))
+
+vi.mock('vue-toastification', () => ({
+ useToast: () => ({ error: mocks.toastError, success: mocks.toastSuccess }),
+}))
+
+const DialogCloseButtonStub = defineComponent({
+ name: 'VDialogCloseBtn',
+ emits: ['click'],
+ setup(_props, { emit }) {
+ return () => h('button', { type: 'button', onClick: () => emit('click') }, '关闭')
+ },
+})
+
+function createDeferred() {
+ let resolve!: () => void
+ const promise = new Promise(done => {
+ resolve = done
+ })
+ return { promise, resolve }
+}
+
+async function renderDialog(season = 2, name = '分享创建测试剧') {
+ const close = vi.fn()
+ const sub = createSubscribe({
+ id: 5201,
+ name,
+ season,
+ tmdbid: 52010,
+ type: '电视剧',
+ })
+ const result = await renderWithProviders(SubscribeShareDialog, {
+ props: {
+ modelValue: true,
+ onClose: close,
+ sub,
+ },
+ global: {
+ components: {
+ VDialogCloseBtn: DialogCloseButtonStub,
+ },
+ },
+ })
+
+ return { ...result, close, sub }
+}
+
+async function fillRequiredFields(comment = '覆盖精确订阅规则', shareUser = '测试分享人') {
+ const user = userEvent.setup()
+ await user.type(screen.getByLabelText('说明'), comment)
+ await user.type(screen.getByLabelText('分享用户'), shareUser)
+ return user
+}
+
+describe('SubscribeShareDialog', () => {
+ beforeEach(() => {
+ vi.spyOn(console, 'error').mockImplementation(() => {})
+ })
+
+ it.each([
+ ['normal season', 2, 'S02', '第 2 季'],
+ ['special season zero', 0, 'S00', '第 0 季'],
+ ])('renders the readonly default title and subtitle for %s', async (_case, season, seasonLabel, subtitle) => {
+ const { sub } = await renderDialog(season)
+
+ expect(screen.getByLabelText('标题')).toHaveValue(`${sub.name} ${seasonLabel}`)
+ expect(document.querySelector('.v-card-subtitle')).toHaveTextContent(`${sub.name} ${subtitle}`)
+ })
+
+ it.each([
+ ['description', '', '测试分享人'],
+ ['sharing user', '覆盖精确订阅规则', ''],
+ ])('blocks submission when the %s is missing', async (_case, comment, shareUser) => {
+ const requested = vi.fn()
+ server.use(shareSubscribeHandler({ success: true }, 200, requested))
+ await renderDialog()
+ const user = userEvent.setup()
+
+ if (comment) await user.type(screen.getByLabelText('说明'), comment)
+ if (shareUser) await user.type(screen.getByLabelText('分享用户'), shareUser)
+
+ await user.click(screen.getByRole('button', { name: '确认分享' }))
+ await flushPromises()
+
+ expect(requested).not.toHaveBeenCalled()
+ expect(mocks.toastSuccess).not.toHaveBeenCalled()
+ expect(mocks.toastError).not.toHaveBeenCalled()
+ })
+
+ it('submits the exact payload once, stays pending, then closes after success', async () => {
+ const deferred = createDeferred()
+ const payloads: unknown[] = []
+ server.use(
+ shareSubscribeHandler({ success: true }, 200, async payload => {
+ payloads.push(payload)
+ await deferred.promise
+ }),
+ )
+ const { close, sub } = await renderDialog()
+ const user = await fillRequiredFields('只保留精确字段', '分享者甲')
+ const submit = screen.getByRole('button', { name: '确认分享' })
+
+ await user.click(submit)
+
+ await waitFor(() => expect(payloads).toHaveLength(1))
+ expect(payloads[0]).toEqual({
+ share_comment: '只保留精确字段',
+ share_title: `${sub.name} S02`,
+ share_user: '分享者甲',
+ subscribe_id: sub.id,
+ })
+ expect(submit).toBeDisabled()
+ expect(close).not.toHaveBeenCalled()
+
+ deferred.resolve()
+
+ await waitFor(() => expect(mocks.toastSuccess).toHaveBeenCalledWith(`${sub.name} 分享成功!`))
+ expect(close).toHaveBeenCalledOnce()
+ expect(submit).not.toBeDisabled()
+ })
+
+ it('keeps the dialog open and reports a business failure', async () => {
+ server.use(shareSubscribeHandler({ message: '远端拒绝', success: false }))
+ const { close, sub } = await renderDialog()
+ const user = await fillRequiredFields()
+ const submit = screen.getByRole('button', { name: '确认分享' })
+
+ await user.click(submit)
+
+ await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith(`${sub.name} 分享失败:远端拒绝!`))
+ expect(close).not.toHaveBeenCalled()
+ expect(submit).not.toBeDisabled()
+ })
+
+ it('recovers from an HTTP failure, keeps the dialog open, and reports the failure', async () => {
+ server.use(shareSubscribeHandler({ message: '服务异常', success: false }, 500))
+ const { close, sub } = await renderDialog(2, 'HTTP失败剧')
+ const user = await fillRequiredFields()
+ const submit = screen.getByRole('button', { name: '确认分享' })
+
+ await user.click(submit)
+
+ await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith(expect.stringContaining(`${sub.name} 分享失败`)))
+ expect(close).not.toHaveBeenCalled()
+ expect(submit).not.toBeDisabled()
+ })
+
+ it('emits close from the dialog close control', async () => {
+ const user = userEvent.setup()
+ const { close } = await renderDialog()
+
+ await user.click(screen.getByRole('button', { name: '关闭' }))
+
+ expect(close).toHaveBeenCalledOnce()
+ })
+})
diff --git a/src/components/dialog/__tests__/SubscribeShareStatisticsDialog.spec.ts b/src/components/dialog/__tests__/SubscribeShareStatisticsDialog.spec.ts
new file mode 100644
index 00000000..0286a6eb
--- /dev/null
+++ b/src/components/dialog/__tests__/SubscribeShareStatisticsDialog.spec.ts
@@ -0,0 +1,174 @@
+import SubscribeShareStatisticsDialog from '@/components/dialog/SubscribeShareStatisticsDialog.vue'
+import { screen, waitFor } from '@testing-library/vue'
+import userEvent from '@testing-library/user-event'
+import { createSubscribeShareStatistics } from '@tests/support/factories/subscribe'
+import { subscribeShareStatisticsHandler } from '@tests/support/msw/handlers/subscribe'
+import { server } from '@tests/support/msw/server'
+import { renderWithProviders } from '@tests/support/render'
+import { defineComponent, h } from 'vue'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const DialogCloseButtonStub = defineComponent({
+ name: 'VDialogCloseBtn',
+ emits: ['click'],
+ setup(_props, { emit }) {
+ return () => h('button', { type: 'button', onClick: () => emit('click') }, '关闭')
+ },
+})
+
+const LoadingBannerStub = defineComponent({
+ name: 'LoadingBanner',
+ setup() {
+ return () => h('div', { role: 'status' }, '加载中')
+ },
+})
+
+function createDeferred() {
+ let resolve!: () => void
+ const promise = new Promise(done => {
+ resolve = done
+ })
+ return { promise, resolve }
+}
+
+async function renderDialog() {
+ const close = vi.fn()
+ const result = await renderWithProviders(SubscribeShareStatisticsDialog, {
+ global: {
+ components: {
+ VDialogCloseBtn: DialogCloseButtonStub,
+ },
+ stubs: {
+ LoadingBanner: LoadingBannerStub,
+ },
+ },
+ props: {
+ modelValue: true,
+ onClose: close,
+ },
+ })
+ return { ...result, close }
+}
+
+describe('SubscribeShareStatisticsDialog', () => {
+ beforeEach(() => {
+ vi.spyOn(console, 'error').mockImplementation(() => {})
+ })
+
+ it('shows loading until statistics arrive', async () => {
+ const deferred = createDeferred()
+ const requested = vi.fn(() => deferred.promise)
+ server.use(
+ subscribeShareStatisticsHandler(
+ [createSubscribeShareStatistics({ share_user: '延迟统计用户' })],
+ 200,
+ requested,
+ ),
+ )
+
+ renderDialog()
+
+ expect(await screen.findByRole('status')).toHaveTextContent('加载中')
+ await waitFor(() => expect(requested).toHaveBeenCalledOnce())
+
+ deferred.resolve()
+ expect((await screen.findAllByText('延迟统计用户')).length).toBeGreaterThan(0)
+ expect(screen.queryByRole('status')).not.toBeInTheDocument()
+ })
+
+ it('sorts by reuse count and preserves source order for ties', async () => {
+ server.use(
+ subscribeShareStatisticsHandler([
+ createSubscribeShareStatistics({ share_user: '并列第一甲', total_reuse_count: 50 }),
+ createSubscribeShareStatistics({ share_user: '并列第一乙', total_reuse_count: 50 }),
+ createSubscribeShareStatistics({ share_user: '第三名', total_reuse_count: 30 }),
+ createSubscribeShareStatistics({ share_user: '并列末位甲', total_reuse_count: 10 }),
+ createSubscribeShareStatistics({ share_user: '并列末位乙', total_reuse_count: 10 }),
+ ]),
+ )
+
+ await renderDialog()
+
+ expect(await screen.findByText('#4')).toBeInTheDocument()
+ const firstPlace = document.body.querySelector('.first-place')?.parentElement
+ expect(firstPlace).toHaveTextContent('并列第一甲')
+ expect(firstPlace).not.toHaveTextContent('并列第一乙')
+ const renderedText = document.body.textContent || ''
+ expect(renderedText.indexOf('并列末位甲')).toBeLessThan(renderedText.indexOf('并列末位乙'))
+ expect(screen.getByText('#5')).toBeInTheDocument()
+ })
+
+ it.each([1, 2, 3, 4])('renders a complete ranking with %i participant(s)', async count => {
+ const statistics = Array.from({ length: count }, (_, index) =>
+ createSubscribeShareStatistics({
+ share_user: `人数场景用户 ${count}-${index + 1}`,
+ total_reuse_count: count - index,
+ }),
+ )
+ server.use(subscribeShareStatisticsHandler(statistics))
+
+ await renderDialog()
+
+ for (const item of statistics) {
+ expect((await screen.findAllByText(item.share_user!)).length).toBeGreaterThan(0)
+ }
+ expect(screen.queryByText('暂无分享统计数据')).not.toBeInTheDocument()
+ if (count === 4) expect(screen.getByText('#4')).toBeInTheDocument()
+ })
+
+ it('renders missing aggregate fields across the podium and remaining rankings', async () => {
+ const statistics = Array.from({ length: 4 }, () =>
+ createSubscribeShareStatistics({
+ share_count: undefined,
+ share_user: undefined,
+ total_reuse_count: undefined,
+ }),
+ )
+ server.use(subscribeShareStatisticsHandler(statistics))
+
+ await renderDialog()
+
+ expect((await screen.findAllByText('未知')).length).toBeGreaterThan(0)
+ expect(screen.getAllByText('0').length).toBeGreaterThanOrEqual(2)
+ expect(screen.getByText('#4')).toBeInTheDocument()
+ })
+
+ it('treats an empty statistics list as valid empty data', async () => {
+ server.use(subscribeShareStatisticsHandler([]))
+
+ await renderDialog()
+
+ expect(await screen.findByText('暂无分享统计数据')).toBeInTheDocument()
+ expect(screen.queryByRole('status')).not.toBeInTheDocument()
+ })
+
+ it('shows an HTTP error separately and retries the same request on demand', async () => {
+ const failedRequest = vi.fn()
+ server.use(subscribeShareStatisticsHandler([], 500, failedRequest))
+ const user = userEvent.setup()
+ await renderDialog()
+
+ expect(await screen.findByText('请求失败,请稍后重试')).toBeInTheDocument()
+ expect(failedRequest).toHaveBeenCalledOnce()
+
+ server.use(
+ subscribeShareStatisticsHandler([
+ createSubscribeShareStatistics({ share_user: '重试恢复用户', total_reuse_count: 20 }),
+ ]),
+ )
+ await user.click(screen.getByRole('button', { name: '重试' }))
+
+ expect((await screen.findAllByText('重试恢复用户')).length).toBeGreaterThan(0)
+ expect(screen.queryByText('请求失败,请稍后重试')).not.toBeInTheDocument()
+ })
+
+ it('emits close from the dialog close control', async () => {
+ server.use(subscribeShareStatisticsHandler([]))
+ const user = userEvent.setup()
+ const { close } = await renderDialog()
+
+ await user.click(screen.getByRole('button', { name: '关闭' }))
+
+ expect(close).toHaveBeenCalledOnce()
+ })
+})
diff --git a/src/views/subscribe/SubscribePopularView.vue b/src/views/subscribe/SubscribePopularView.vue
index 8085fa23..d8c343c0 100644
--- a/src/views/subscribe/SubscribePopularView.vue
+++ b/src/views/subscribe/SubscribePopularView.vue
@@ -25,15 +25,18 @@ const apipath = 'subscribe/popular'
// 当前页码
const page = ref(1)
-// 是否加载中
-const loading = ref(false)
-
// 是否加载完成
const isRefreshed = ref(false)
+// 当前列表请求是否失败;合法空数组仍使用空数据状态。
+const loadError = ref(false)
+
// 数据列表
const dataList = ref([])
-const currData = ref([])
+
+// 筛选重置允许新旧请求短暂并行,只接纳当前代次的响应。
+let requestGeneration = 0
+const loadingGenerations = new Set()
// 筛选参数
const filterParams = reactive({
@@ -48,9 +51,11 @@ const filterParams = reactive({
const currentKey = ref(0)
function resetData() {
+ requestGeneration++
dataList.value = []
page.value = 1
isRefreshed.value = false
+ loadError.value = false
currentKey.value++
}
@@ -140,69 +145,62 @@ function getParams() {
}
// 获取列表数据
-async function fetchData({ done }: { done: any }) {
- try {
- // 如果正在加载中,直接返回
- if (loading.value) {
- done('ok')
- return
- }
+async function fetchData({ done }: { done: (status: 'empty' | 'error' | 'ok') => void }) {
+ const generation = requestGeneration
- // 加载到满屏或者加载出错
- if (!hasScroll()) {
- // 加载多次
- while (!hasScroll()) {
- // 设置加载中
- loading.value = true
- // 请求API
- currData.value = await api.get(apipath, {
- params: getParams(),
- })
- // 取消加载中
- loading.value = false
- // 标计为已请求完成
- isRefreshed.value = true
- if (currData.value.length === 0) {
- // 如果没有数据,跳出
- done('empty')
- return
- }
- // 合并数据
- dataList.value = [...dataList.value, ...currData.value]
- // 页码+1
- page.value++
- // 返回加载成功
- done('ok')
- await nextTick()
- }
- } else {
- // 设置加载中
- loading.value = true
- // 请求API
- currData.value = await api.get(apipath, {
+ // 同一筛选条件只允许一个分页请求在途。
+ if (loadingGenerations.has(generation)) {
+ return
+ }
+
+ loadingGenerations.add(generation)
+ loadError.value = false
+
+ try {
+ while (generation === requestGeneration) {
+ const currentData: MediaInfo[] = await api.get(apipath, {
params: getParams(),
})
- loading.value = false
- // 标计为已请求完成
+
+ if (generation !== requestGeneration) return
+
isRefreshed.value = true
- if (currData.value.length === 0) {
- // 如果没有数据,跳出
+ if (currentData.length === 0) {
done('empty')
- } else {
- // 合并数据
- dataList.value = [...dataList.value, ...currData.value]
- // 页码+1
- page.value++
- // 返回加载成功
- done('ok')
+ return
}
+
+ dataList.value = [...dataList.value, ...currentData]
+ page.value++
+ done('ok')
+ await nextTick()
+
+ if (hasScroll()) return
}
} catch (error) {
+ if (generation !== requestGeneration) return
+
console.error(error)
- // 返回加载失败
+ isRefreshed.value = true
+ loadError.value = true
done('error')
+ } finally {
+ loadingGenerations.delete(generation)
}
}
+
+/** 使用媒体来源、稳定 ID 与季号区分热门条目。 */
+function getMediaItemKey(item: MediaInfo) {
+ const mediaId = item.tmdb_id
+ ? `tmdb:${item.tmdb_id}`
+ : item.douban_id
+ ? `douban:${item.douban_id}`
+ : item.bangumi_id
+ ? `bangumi:${item.bangumi_id}`
+ : `${item.mediaid_prefix ?? 'media'}:${item.media_id ?? item.title ?? ''}`
+
+ return `${item.source ?? 'unknown'}:${mediaId}:season:${item.season ?? 'all'}`
+}
@@ -278,11 +276,19 @@ async function fetchData({ done }: { done: any }) {
:key="currentKey"
>
+
+
+ {{ t('subscribe.requestFailed') }}
+
+ {{ t('common.retry') }}
+
+
+
([])
-const currData = ref([])
+
+// 搜索或筛选重置允许新旧请求短暂并行,只接纳当前代次的响应。
+let requestGeneration = 0
+const loadingGenerations = new Set()
// 拼装参数
function getParams() {
@@ -150,67 +155,47 @@ function getParams() {
}
// 获取列表数据
-async function fetchData({ done }: { done: any }) {
- try {
- // 如果正在加载中,直接返回
- if (loading.value) {
- done('ok')
- return
- }
+async function fetchData({ done }: { done: (status: 'empty' | 'error' | 'ok') => void }) {
+ const generation = requestGeneration
- // 加载到满屏或者加载出错
- if (!hasScroll()) {
- // 加载多次
- while (!hasScroll()) {
- // 设置加载中
- loading.value = true
- // 请求API
- currData.value = await api.get(apipath, {
- params: getParams(),
- })
- // 取消加载中
- loading.value = false
- // 标计为已请求完成
- isRefreshed.value = true
- if (currData.value.length === 0) {
- // 如果没有数据,跳出
- done('empty')
- return
- }
- // 合并数据
- dataList.value = [...dataList.value, ...currData.value]
- // 页码+1
- page.value++
- // 返回加载成功
- done('ok')
- await nextTick()
- }
- } else {
- // 设置加载中
- loading.value = true
- // 请求API
- currData.value = await api.get(apipath, {
+ // 同一搜索条件只允许一个分页请求在途。
+ if (loadingGenerations.has(generation)) {
+ return
+ }
+
+ loadingGenerations.add(generation)
+ loadError.value = false
+
+ try {
+ while (generation === requestGeneration) {
+ const currentData: SubscribeShare[] = await api.get(apipath, {
params: getParams(),
})
- loading.value = false
- // 标计为已请求完成
+
+ if (generation !== requestGeneration) return
+
isRefreshed.value = true
- if (currData.value.length === 0) {
- // 如果没有数据,跳出
+ if (currentData.length === 0) {
done('empty')
- } else {
- // 合并数据
- dataList.value = [...dataList.value, ...currData.value]
- // 页码+1
- page.value++
- // 返回加载成功
- done('ok')
+ return
}
+
+ dataList.value = [...dataList.value, ...currentData]
+ page.value++
+ done('ok')
+ await nextTick()
+
+ if (hasScroll()) return
}
} catch (error) {
+ if (generation !== requestGeneration) return
+
console.error(error)
- // 返回加载失败
+ isRefreshed.value = true
+ loadError.value = true
done('error')
+ } finally {
+ loadingGenerations.delete(generation)
}
}
@@ -294,6 +279,14 @@ function removeData(id: number) {
:key="currentKey"
>
+
+
+ {{ t('subscribe.requestFailed') }}
+
+ {{ t('common.retry') }}
+
+
+
('idle')
+
+ function load() {
+ status.value = 'loading'
+ emit('load', {
+ done(nextStatus: InfiniteScrollStatus) {
+ status.value = nextStatus === 'ok' ? 'idle' : nextStatus
+ },
+ })
+ }
+
+ onMounted(load)
+
+ return () =>
+ h('section', { 'aria-label': '热门订阅无限列表' }, [
+ h('output', { 'aria-label': '热门订阅无限列表状态' }, status.value),
+ status.value === 'loading' ? slots.loading?.({}) : null,
+ status.value === 'error'
+ ? slots.error?.({
+ side: 'end',
+ props: { color: undefined, onClick: load },
+ })
+ : null,
+ status.value === 'empty' ? slots.empty?.({}) : null,
+ slots.default?.(),
+ h(
+ 'button',
+ {
+ 'aria-label': '触发热门订阅加载',
+ onClick: load,
+ type: 'button',
+ },
+ '触发热门订阅加载',
+ ),
+ ])
+ },
+})
+
+const ProgressiveCardGridStub = defineComponent({
+ name: 'ProgressiveCardGrid',
+ props: {
+ getItemKey: {
+ type: Function as PropType<(item: MediaInfo, index: number) => string | number>,
+ required: true,
+ },
+ items: {
+ type: Array as PropType,
+ required: true,
+ },
+ },
+ setup(props, { slots }) {
+ return () =>
+ h('section', { 'aria-label': '热门订阅渐进网格' }, [
+ h(
+ 'output',
+ { 'aria-label': '热门订阅渐进网格键' },
+ props.items.map((item, index) => String(props.getItemKey(item, index))).join('|'),
+ ),
+ ...props.items.flatMap(item => slots.default?.({ item }) ?? []),
+ ])
+ },
+})
+
+const MediaCardStub = defineComponent({
+ name: 'MediaCard',
+ props: {
+ media: {
+ type: Object as PropType,
+ required: true,
+ },
+ },
+ setup(props) {
+ return () => h('article', props.media.title)
+ },
+})
+
+const LoadingBannerStub = defineComponent({
+ name: 'LoadingBanner',
+ template: '正在加载热门订阅
',
+})
+
+const NoDataFoundStub = defineComponent({
+ name: 'NoDataFound',
+ props: {
+ errorDescription: String,
+ errorTitle: String,
+ },
+ template: '{{ errorTitle }} {{ errorDescription }}',
+})
+
+interface Deferred {
+ promise: Promise
+ resolve: () => void
+}
+
+function createDeferred(): Deferred {
+ let resolve!: () => void
+ const promise = new Promise(done => {
+ resolve = done
+ })
+ return { promise, resolve }
+}
+
+function setHasScroll(hasScroll: boolean) {
+ vi.spyOn(window, 'innerHeight', 'get').mockReturnValue(600)
+ return vi.spyOn(document.body, 'scrollHeight', 'get').mockReturnValue(hasScroll ? 900 : 500)
+}
+
+async function renderPopular(type: '电影' | '电视剧' = '电影') {
+ return renderWithProviders(SubscribePopularView, {
+ props: { type },
+ global: {
+ stubs: {
+ LoadingBanner: LoadingBannerStub,
+ MediaCard: MediaCardStub,
+ NoDataFound: NoDataFoundStub,
+ ProgressiveCardGrid: ProgressiveCardGridStub,
+ VInfiniteScroll: InfiniteScrollStub,
+ },
+ },
+ })
+}
+
+describe('SubscribePopularView', () => {
+ beforeEach(() => {
+ vi.spyOn(console, 'error').mockImplementation(() => {})
+ setHasScroll(true)
+ })
+
+ it.each([
+ ['电影', () => createSubscribeMovie({ popularity: 18, title: '默认热门电影' })],
+ ['电视剧', () => createSubscribeTv({ popularity: 27, title: '默认热门剧集' })],
+ ] as const)('loads %s with the exact default query', async (type, createMedia) => {
+ const requests: URL[] = []
+ server.use(
+ popularSubscribesHandler([createMedia()], 200, url => {
+ requests.push(url)
+ }),
+ )
+
+ await renderPopular(type)
+
+ expect(await screen.findByText(type === '电影' ? '默认热门电影' : '默认热门剧集')).toBeInTheDocument()
+ expect(screen.getByText(type === '电影' ? '18' : '27')).toBeInTheDocument()
+ expect(requests).toHaveLength(1)
+ expect(requests[0].searchParams.get('stype')).toBe(type)
+ expect(requests[0].searchParams.get('page')).toBe('1')
+ expect(requests[0].searchParams.get('count')).toBe('30')
+ expect(requests[0].searchParams.get('sort_type')).toBe('count')
+ expect(requests[0].searchParams.get('genre_id')).toBeNull()
+ expect(requests[0].searchParams.get('min_rating')).toBeNull()
+ expect(requests[0].searchParams.get('max_rating')).toBeNull()
+ expect(requests[0].searchParams.get('min_sub')).toBeNull()
+ })
+
+ it('resets to page one with exact sort, genre and rating filters', async () => {
+ const requests: URL[] = []
+ server.use(
+ http.get(subscribeApiUrls.popular, ({ request }) => {
+ const url = new URL(request.url)
+ requests.push(url)
+ const title = url.searchParams.has('min_rating')
+ ? '高分热门结果'
+ : url.searchParams.has('genre_id')
+ ? '动作热门结果'
+ : url.searchParams.get('sort_type') === 'time'
+ ? '最新热门结果'
+ : '默认热门结果'
+ return HttpResponse.json([createSubscribeMovie({ title })])
+ }),
+ )
+ const user = userEvent.setup()
+
+ await renderPopular()
+ expect(await screen.findByText('默认热门结果')).toBeInTheDocument()
+
+ await user.click(screen.getByText('最新'))
+ expect(await screen.findByText('最新热门结果')).toBeInTheDocument()
+ expect(screen.queryByText('默认热门结果')).not.toBeInTheDocument()
+
+ await user.click(screen.getByText('动作'))
+ expect(await screen.findByText('动作热门结果')).toBeInTheDocument()
+ expect(screen.queryByText('最新热门结果')).not.toBeInTheDocument()
+
+ screen.getByRole('slider').focus()
+ await user.keyboard('{ArrowRight}'.repeat(7))
+ expect(await screen.findByText('高分热门结果')).toBeInTheDocument()
+ expect(screen.queryByText('动作热门结果')).not.toBeInTheDocument()
+
+ expect(requests.length).toBeGreaterThanOrEqual(4)
+ expect(requests.slice(1).every(url => url.searchParams.get('page') === '1')).toBe(true)
+ expect(requests[1].searchParams.get('sort_type')).toBe('time')
+ expect(requests[2].searchParams.get('genre_id')).toBe('28')
+ expect(requests.at(-1)?.searchParams.get('min_rating')).toBe('7')
+ })
+
+ it('appends later pages and stops when a page is empty', async () => {
+ const first = createSubscribeMovie({ title: '热门第一页' })
+ const second = createSubscribeMovie({ title: '热门第二页' })
+ const requestedPages: string[] = []
+ server.use(
+ http.get(subscribeApiUrls.popular, ({ request }) => {
+ const page = new URL(request.url).searchParams.get('page') ?? ''
+ requestedPages.push(page)
+ if (page === '1') return HttpResponse.json([first])
+ if (page === '2') return HttpResponse.json([second])
+ return HttpResponse.json([])
+ }),
+ )
+ const user = userEvent.setup()
+
+ await renderPopular()
+ expect(await screen.findByText('热门第一页')).toBeInTheDocument()
+
+ await user.click(screen.getByRole('button', { name: '触发热门订阅加载' }))
+ expect(await screen.findByText('热门第二页')).toBeInTheDocument()
+ expect(screen.getByText('热门第一页')).toBeInTheDocument()
+
+ await user.click(screen.getByRole('button', { name: '触发热门订阅加载' }))
+ await waitFor(() => expect(requestedPages).toEqual(['1', '2', '3']))
+ expect(screen.getByText('热门第一页')).toBeInTheDocument()
+ expect(screen.getByText('热门第二页')).toBeInTheDocument()
+ })
+
+ it('loads consecutive pages until an underfilled viewport becomes scrollable', async () => {
+ const first = createSubscribeMovie({ title: '未满屏第一页' })
+ const second = createSubscribeMovie({ title: '未满屏第二页' })
+ const requestedPages: string[] = []
+ const scrollHeight = vi.spyOn(document.body, 'scrollHeight', 'get').mockImplementation(() =>
+ requestedPages.length >= 2 ? 900 : 500,
+ )
+ server.use(
+ http.get(subscribeApiUrls.popular, ({ request }) => {
+ const page = new URL(request.url).searchParams.get('page') ?? ''
+ requestedPages.push(page)
+ return HttpResponse.json(page === '1' ? [first] : [second])
+ }),
+ )
+
+ await renderPopular()
+
+ expect(await screen.findByText('未满屏第一页')).toBeInTheDocument()
+ expect(await screen.findByText('未满屏第二页')).toBeInTheDocument()
+ expect(requestedPages).toEqual(['1', '2'])
+ scrollHeight.mockRestore()
+ })
+
+ it('shows the no-data state for an empty first page', async () => {
+ server.use(popularSubscribesHandler([]))
+
+ await renderPopular()
+
+ expect(await screen.findByRole('region', { name: '热门订阅空态' })).toBeInTheDocument()
+ expect(screen.queryByText('正在加载热门订阅')).not.toBeInTheDocument()
+ })
+
+ it('deduplicates concurrent load events while the request is pending', async () => {
+ const gate = createDeferred()
+ const requests: URL[] = []
+ server.use(
+ popularSubscribesHandler([createSubscribeMovie({ title: '并发加载结果' })], 200, async url => {
+ requests.push(url)
+ await gate.promise
+ }),
+ )
+ const user = userEvent.setup()
+
+ await renderPopular()
+ await waitFor(() => expect(requests).toHaveLength(1))
+
+ await user.click(screen.getByRole('button', { name: '触发热门订阅加载' }))
+ await user.click(screen.getByRole('button', { name: '触发热门订阅加载' }))
+ expect(requests).toHaveLength(1)
+ expect(screen.getByRole('status', { name: '热门订阅无限列表状态' })).toHaveTextContent('loading')
+
+ gate.resolve()
+ expect(await screen.findByText('并发加载结果')).toBeInTheDocument()
+ })
+
+ it('recovers from an initial HTTP failure by retrying page one', async () => {
+ const requestedPages: string[] = []
+ server.use(
+ http.get(subscribeApiUrls.popular, ({ request }) => {
+ const page = new URL(request.url).searchParams.get('page') ?? ''
+ requestedPages.push(page)
+ if (requestedPages.length === 1) return HttpResponse.json({ detail: 'failed' }, { status: 500 })
+ return HttpResponse.json([createSubscribeMovie({ title: '热门首载重试成功' })])
+ }),
+ )
+ const user = userEvent.setup()
+
+ await renderPopular()
+ const retry = await screen.findByRole('button', { name: '重试' })
+ expect(screen.getByRole('alert')).toBeInTheDocument()
+
+ await user.click(retry)
+
+ expect(await screen.findByText('热门首载重试成功')).toBeInTheDocument()
+ expect(requestedPages).toEqual(['1', '1'])
+ })
+
+ it('keeps existing cards and retries the failed later page', async () => {
+ const first = createSubscribeMovie({ title: '热门保留第一页' })
+ const second = createSubscribeMovie({ title: '热门第二页重试成功' })
+ const requestedPages: string[] = []
+ let pageTwoAttempts = 0
+ server.use(
+ http.get(subscribeApiUrls.popular, ({ request }) => {
+ const page = new URL(request.url).searchParams.get('page') ?? ''
+ requestedPages.push(page)
+ if (page === '1') return HttpResponse.json([first])
+ pageTwoAttempts += 1
+ if (pageTwoAttempts === 1) return HttpResponse.json({ detail: 'failed' }, { status: 500 })
+ return HttpResponse.json([second])
+ }),
+ )
+ const user = userEvent.setup()
+
+ await renderPopular()
+ expect(await screen.findByText('热门保留第一页')).toBeInTheDocument()
+ await user.click(screen.getByRole('button', { name: '触发热门订阅加载' }))
+
+ const retry = await screen.findByRole('button', { name: '重试' })
+ expect(screen.getByText('热门保留第一页')).toBeInTheDocument()
+ await user.click(retry)
+
+ expect(await screen.findByText('热门第二页重试成功')).toBeInTheDocument()
+ expect(screen.getByText('热门保留第一页')).toBeInTheDocument()
+ expect(requestedPages).toEqual(['1', '2', '2'])
+ })
+
+ it('ignores an obsolete response when filters reset a pending request', async () => {
+ const gate = createDeferred()
+ const requests: URL[] = []
+ const staleResponse = vi.fn()
+ server.use(
+ http.get(subscribeApiUrls.popular, async ({ request }) => {
+ const url = new URL(request.url)
+ requests.push(url)
+ if (!url.searchParams.has('genre_id')) {
+ await gate.promise
+ staleResponse()
+ return HttpResponse.json([createSubscribeMovie({ title: '过期热门结果' })])
+ }
+ return HttpResponse.json([createSubscribeMovie({ title: '新筛选热门结果' })])
+ }),
+ )
+ const user = userEvent.setup()
+
+ await renderPopular()
+ await waitFor(() => expect(requests).toHaveLength(1))
+
+ await user.click(screen.getByText('动作'))
+ expect(await screen.findByText('新筛选热门结果')).toBeInTheDocument()
+ expect(requests.filter(url => url.searchParams.get('genre_id') === '28')).toHaveLength(1)
+ expect(requests.at(-1)?.searchParams.get('page')).toBe('1')
+
+ gate.resolve()
+ await waitFor(() => expect(staleResponse).toHaveBeenCalledOnce())
+ await flushPromises()
+ await flushPromises()
+
+ expect(screen.getByText('新筛选热门结果')).toBeInTheDocument()
+ expect(screen.queryByText('过期热门结果')).not.toBeInTheDocument()
+ })
+
+ it('provides unique progressive-grid keys for different seasons of the same TMDB title', async () => {
+ server.use(
+ popularSubscribesHandler([
+ createSubscribeTv({ season: 1, source: undefined, title: '同剧第一季', tmdb_id: 880 }),
+ createSubscribeTv({ season: 2, source: undefined, title: '同剧第二季', tmdb_id: 880 }),
+ ]),
+ )
+
+ await renderPopular('电视剧')
+
+ expect(await screen.findByText('同剧第一季')).toBeInTheDocument()
+ expect(screen.getByText('同剧第二季')).toBeInTheDocument()
+ const keys = screen.getByRole('status', { name: '热门订阅渐进网格键' }).textContent?.split('|') ?? []
+ expect(keys).toEqual(['unknown:tmdb:880:season:1', 'unknown:tmdb:880:season:2'])
+ })
+})
diff --git a/src/views/subscribe/__tests__/SubscribeShareView.spec.ts b/src/views/subscribe/__tests__/SubscribeShareView.spec.ts
new file mode 100644
index 00000000..50ccf9bb
--- /dev/null
+++ b/src/views/subscribe/__tests__/SubscribeShareView.spec.ts
@@ -0,0 +1,441 @@
+import type { SubscribeShare } from '@/api/types'
+import SubscribeShareView from '@/views/subscribe/SubscribeShareView.vue'
+import { screen, waitFor } from '@testing-library/vue'
+import userEvent from '@testing-library/user-event'
+import { createSubscribeShare } from '@tests/support/factories/subscribe'
+import { subscribeApiUrls, subscribeSharesHandler } from '@tests/support/msw/handlers/subscribe'
+import { server } from '@tests/support/msw/server'
+import { renderWithProviders } from '@tests/support/render'
+import { flushPromises } from '@vue/test-utils'
+import { HttpResponse, http } from 'msw'
+import { defineComponent, h, onMounted, ref, type PropType } from 'vue'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+type InfiniteScrollStatus = 'empty' | 'error' | 'ok'
+
+const InfiniteScrollStub = defineComponent({
+ name: 'VInfiniteScroll',
+ emits: ['load'],
+ setup(_props, { emit, slots }) {
+ const status = ref<'empty' | 'error' | 'idle' | 'loading'>('idle')
+
+ function load() {
+ status.value = 'loading'
+ emit('load', {
+ done(nextStatus: InfiniteScrollStatus) {
+ status.value = nextStatus === 'ok' ? 'idle' : nextStatus
+ },
+ })
+ }
+
+ onMounted(load)
+
+ return () =>
+ h('section', { 'aria-label': '订阅分享无限列表' }, [
+ h('output', { 'aria-label': '订阅分享无限列表状态' }, status.value),
+ status.value === 'loading' ? slots.loading?.({}) : null,
+ status.value === 'error'
+ ? slots.error?.({
+ side: 'end',
+ props: { color: undefined, onClick: load },
+ })
+ : null,
+ status.value === 'empty' ? slots.empty?.({}) : null,
+ slots.default?.(),
+ h(
+ 'button',
+ {
+ 'aria-label': '触发订阅分享加载',
+ onClick: load,
+ type: 'button',
+ },
+ '触发订阅分享加载',
+ ),
+ ])
+ },
+})
+
+const ProgressiveCardGridStub = defineComponent({
+ name: 'ProgressiveCardGrid',
+ props: {
+ getItemKey: {
+ type: Function as PropType<(item: SubscribeShare, index: number) => string | number>,
+ required: true,
+ },
+ items: {
+ type: Array as PropType,
+ required: true,
+ },
+ },
+ setup(props, { slots }) {
+ return () =>
+ h('section', { 'aria-label': '订阅分享渐进网格' }, [
+ h(
+ 'output',
+ { 'aria-label': '订阅分享渐进网格键' },
+ props.items.map((item, index) => String(props.getItemKey(item, index))).join('|'),
+ ),
+ ...props.items.flatMap(item => slots.default?.({ item }) ?? []),
+ ])
+ },
+})
+
+const SubscribeShareCardStub = defineComponent({
+ name: 'SubscribeShareCard',
+ props: {
+ media: {
+ type: Object as PropType,
+ required: true,
+ },
+ },
+ emits: ['delete'],
+ setup(props, { emit }) {
+ return () =>
+ h('article', [
+ h('span', props.media.share_title),
+ h(
+ 'button',
+ {
+ 'aria-label': `删除分享 ${props.media.id}`,
+ onClick: () => emit('delete'),
+ type: 'button',
+ },
+ '删除',
+ ),
+ ])
+ },
+})
+
+const LoadingBannerStub = defineComponent({
+ name: 'LoadingBanner',
+ template: '正在加载订阅分享
',
+})
+
+const NoDataFoundStub = defineComponent({
+ name: 'NoDataFound',
+ props: {
+ errorDescription: String,
+ errorTitle: String,
+ },
+ template: '{{ errorTitle }} {{ errorDescription }}',
+})
+
+const PageContentTitleStub = defineComponent({
+ name: 'VPageContentTitle',
+ props: { title: String },
+ template: '{{ title }}
',
+})
+
+interface Deferred {
+ promise: Promise
+ resolve: () => void
+}
+
+function createDeferred(): Deferred {
+ let resolve!: () => void
+ const promise = new Promise(done => {
+ resolve = done
+ })
+ return { promise, resolve }
+}
+
+function setHasScroll(hasScroll: boolean) {
+ vi.spyOn(window, 'innerHeight', 'get').mockReturnValue(600)
+ return vi.spyOn(document.body, 'scrollHeight', 'get').mockReturnValue(hasScroll ? 900 : 500)
+}
+
+async function renderShare(keyword = '') {
+ return renderWithProviders(SubscribeShareView, {
+ props: { keyword },
+ global: {
+ stubs: {
+ LoadingBanner: LoadingBannerStub,
+ NoDataFound: NoDataFoundStub,
+ ProgressiveCardGrid: ProgressiveCardGridStub,
+ SubscribeShareCard: SubscribeShareCardStub,
+ VInfiniteScroll: InfiniteScrollStub,
+ VPageContentTitle: PageContentTitleStub,
+ },
+ },
+ })
+}
+
+describe('SubscribeShareView', () => {
+ beforeEach(() => {
+ vi.spyOn(console, 'error').mockImplementation(() => {})
+ setHasScroll(true)
+ })
+
+ it('loads the default list with the exact query and stable share IDs', async () => {
+ const share = createSubscribeShare({ id: 3101, share_title: '默认分享卡片' })
+ const requests: URL[] = []
+ server.use(
+ subscribeSharesHandler([share], 200, url => {
+ requests.push(url)
+ }),
+ )
+
+ await renderShare()
+
+ expect(await screen.findByText('默认分享卡片')).toBeInTheDocument()
+ expect(requests).toHaveLength(1)
+ expect(requests[0].searchParams.get('page')).toBe('1')
+ expect(requests[0].searchParams.get('count')).toBe('30')
+ expect(requests[0].searchParams.get('name') ?? '').toBe('')
+ expect(requests[0].searchParams.get('sort_type')).toBe('time')
+ expect(requests[0].searchParams.get('genre_id')).toBeNull()
+ expect(requests[0].searchParams.get('min_rating')).toBeNull()
+ expect(requests[0].searchParams.get('max_rating')).toBeNull()
+ expect(screen.getByRole('status', { name: '订阅分享渐进网格键' })).toHaveTextContent('3101')
+ })
+
+ it('resets to page one with exact sort, genre and rating filters', async () => {
+ const requests: URL[] = []
+ server.use(
+ http.get(subscribeApiUrls.shares, ({ request }) => {
+ const url = new URL(request.url)
+ requests.push(url)
+ const shareTitle = url.searchParams.has('min_rating')
+ ? '高分分享结果'
+ : url.searchParams.has('genre_id')
+ ? '动作分享结果'
+ : url.searchParams.get('sort_type') === 'count'
+ ? '热门分享结果'
+ : '默认分享结果'
+ return HttpResponse.json([createSubscribeShare({ share_title: shareTitle })])
+ }),
+ )
+ const user = userEvent.setup()
+
+ await renderShare()
+ expect(await screen.findByText('默认分享结果')).toBeInTheDocument()
+
+ await user.click(screen.getByText('热门'))
+ expect(await screen.findByText('热门分享结果')).toBeInTheDocument()
+ expect(screen.queryByText('默认分享结果')).not.toBeInTheDocument()
+
+ await user.click(screen.getByText('动作'))
+ expect(await screen.findByText('动作分享结果')).toBeInTheDocument()
+ expect(screen.queryByText('热门分享结果')).not.toBeInTheDocument()
+
+ screen.getByRole('slider').focus()
+ await user.keyboard('{ArrowRight}'.repeat(6))
+ expect(await screen.findByText('高分分享结果')).toBeInTheDocument()
+ expect(screen.queryByText('动作分享结果')).not.toBeInTheDocument()
+
+ expect(requests.length).toBeGreaterThanOrEqual(4)
+ expect(requests.slice(1).every(url => url.searchParams.get('page') === '1')).toBe(true)
+ expect(requests[1].searchParams.get('sort_type')).toBe('count')
+ expect(requests[2].searchParams.get('genre_id')).toBe('28')
+ expect(requests.at(-1)?.searchParams.get('min_rating')).toBe('6')
+ })
+
+ it('appends later pages and stops when a page is empty', async () => {
+ const first = createSubscribeShare({ share_title: '分享第一页' })
+ const second = createSubscribeShare({ share_title: '分享第二页' })
+ const requestedPages: string[] = []
+ server.use(
+ http.get(subscribeApiUrls.shares, ({ request }) => {
+ const page = new URL(request.url).searchParams.get('page') ?? ''
+ requestedPages.push(page)
+ if (page === '1') return HttpResponse.json([first])
+ if (page === '2') return HttpResponse.json([second])
+ return HttpResponse.json([])
+ }),
+ )
+ const user = userEvent.setup()
+
+ await renderShare()
+ expect(await screen.findByText('分享第一页')).toBeInTheDocument()
+
+ await user.click(screen.getByRole('button', { name: '触发订阅分享加载' }))
+ expect(await screen.findByText('分享第二页')).toBeInTheDocument()
+ expect(screen.getByText('分享第一页')).toBeInTheDocument()
+
+ await user.click(screen.getByRole('button', { name: '触发订阅分享加载' }))
+ await waitFor(() => expect(requestedPages).toEqual(['1', '2', '3']))
+ expect(screen.getByText('分享第一页')).toBeInTheDocument()
+ expect(screen.getByText('分享第二页')).toBeInTheDocument()
+ })
+
+ it('loads consecutive pages until an underfilled viewport becomes scrollable', async () => {
+ const first = createSubscribeShare({ share_title: '未满屏分享第一页' })
+ const second = createSubscribeShare({ share_title: '未满屏分享第二页' })
+ const requestedPages: string[] = []
+ const scrollHeight = vi.spyOn(document.body, 'scrollHeight', 'get').mockImplementation(() =>
+ requestedPages.length >= 2 ? 900 : 500,
+ )
+ server.use(
+ http.get(subscribeApiUrls.shares, ({ request }) => {
+ const page = new URL(request.url).searchParams.get('page') ?? ''
+ requestedPages.push(page)
+ return HttpResponse.json(page === '1' ? [first] : [second])
+ }),
+ )
+
+ await renderShare()
+
+ expect(await screen.findByText('未满屏分享第一页')).toBeInTheDocument()
+ expect(await screen.findByText('未满屏分享第二页')).toBeInTheDocument()
+ expect(requestedPages).toEqual(['1', '2'])
+ scrollHeight.mockRestore()
+ })
+
+ it('shows the keyword-specific no-data state for an empty first page', async () => {
+ server.use(subscribeSharesHandler([]))
+
+ await renderShare('不存在的分享')
+
+ expect(await screen.findByRole('heading', { name: '搜索:不存在的分享' })).toBeInTheDocument()
+ expect(await screen.findByRole('region', { name: '订阅分享空态' })).toBeInTheDocument()
+ expect(screen.queryByText('正在加载订阅分享')).not.toBeInTheDocument()
+ })
+
+ it('deduplicates concurrent load events while the request is pending', async () => {
+ const gate = createDeferred()
+ const requests: URL[] = []
+ server.use(
+ subscribeSharesHandler([createSubscribeShare({ share_title: '并发分享结果' })], 200, async url => {
+ requests.push(url)
+ await gate.promise
+ }),
+ )
+ const user = userEvent.setup()
+
+ await renderShare()
+ await waitFor(() => expect(requests).toHaveLength(1))
+
+ await user.click(screen.getByRole('button', { name: '触发订阅分享加载' }))
+ await user.click(screen.getByRole('button', { name: '触发订阅分享加载' }))
+ expect(requests).toHaveLength(1)
+ expect(screen.getByRole('status', { name: '订阅分享无限列表状态' })).toHaveTextContent('loading')
+
+ gate.resolve()
+ expect(await screen.findByText('并发分享结果')).toBeInTheDocument()
+ })
+
+ it('reloads page one when the keyword prop changes', async () => {
+ const requests: URL[] = []
+ server.use(
+ http.get(subscribeApiUrls.shares, ({ request }) => {
+ const url = new URL(request.url)
+ requests.push(url)
+ const keyword = url.searchParams.get('name') ?? ''
+ return HttpResponse.json([
+ createSubscribeShare({ share_title: keyword === '新关键字' ? '新关键字分享' : '旧关键字分享' }),
+ ])
+ }),
+ )
+
+ const view = await renderShare('旧关键字')
+ expect(await screen.findByText('旧关键字分享')).toBeInTheDocument()
+ await view.rerender({ keyword: '新关键字' })
+
+ expect(await screen.findByText('新关键字分享')).toBeInTheDocument()
+ expect(screen.queryByText('旧关键字分享')).not.toBeInTheDocument()
+ expect(screen.getByRole('heading', { name: '搜索:新关键字' })).toBeInTheDocument()
+ expect(requests.map(url => url.searchParams.get('name'))).toEqual(['旧关键字', '新关键字'])
+ expect(requests.every(url => url.searchParams.get('page') === '1')).toBe(true)
+ })
+
+ it('removes only the share whose card emitted delete', async () => {
+ const first = createSubscribeShare({ id: 4101, share_title: '待删除分享' })
+ const second = createSubscribeShare({ id: 4102, share_title: '保留分享' })
+ server.use(subscribeSharesHandler([first, second]))
+ const user = userEvent.setup()
+
+ await renderShare()
+ expect(await screen.findByText('待删除分享')).toBeInTheDocument()
+
+ await user.click(screen.getByRole('button', { name: '删除分享 4101' }))
+
+ expect(screen.queryByText('待删除分享')).not.toBeInTheDocument()
+ expect(screen.getByText('保留分享')).toBeInTheDocument()
+ })
+
+ it('recovers from an initial HTTP failure by retrying page one', async () => {
+ const requestedPages: string[] = []
+ server.use(
+ http.get(subscribeApiUrls.shares, ({ request }) => {
+ const page = new URL(request.url).searchParams.get('page') ?? ''
+ requestedPages.push(page)
+ if (requestedPages.length === 1) return HttpResponse.json({ detail: 'failed' }, { status: 500 })
+ return HttpResponse.json([createSubscribeShare({ share_title: '分享首载重试成功' })])
+ }),
+ )
+ const user = userEvent.setup()
+
+ await renderShare()
+ const retry = await screen.findByRole('button', { name: '重试' })
+ expect(screen.getByRole('alert')).toBeInTheDocument()
+
+ await user.click(retry)
+
+ expect(await screen.findByText('分享首载重试成功')).toBeInTheDocument()
+ expect(requestedPages).toEqual(['1', '1'])
+ })
+
+ it('keeps existing shares and retries the failed later page', async () => {
+ const first = createSubscribeShare({ share_title: '分享保留第一页' })
+ const second = createSubscribeShare({ share_title: '分享第二页重试成功' })
+ const requestedPages: string[] = []
+ let pageTwoAttempts = 0
+ server.use(
+ http.get(subscribeApiUrls.shares, ({ request }) => {
+ const page = new URL(request.url).searchParams.get('page') ?? ''
+ requestedPages.push(page)
+ if (page === '1') return HttpResponse.json([first])
+ pageTwoAttempts += 1
+ if (pageTwoAttempts === 1) return HttpResponse.json({ detail: 'failed' }, { status: 500 })
+ return HttpResponse.json([second])
+ }),
+ )
+ const user = userEvent.setup()
+
+ await renderShare()
+ expect(await screen.findByText('分享保留第一页')).toBeInTheDocument()
+ await user.click(screen.getByRole('button', { name: '触发订阅分享加载' }))
+
+ const retry = await screen.findByRole('button', { name: '重试' })
+ expect(screen.getByText('分享保留第一页')).toBeInTheDocument()
+ await user.click(retry)
+
+ expect(await screen.findByText('分享第二页重试成功')).toBeInTheDocument()
+ expect(screen.getByText('分享保留第一页')).toBeInTheDocument()
+ expect(requestedPages).toEqual(['1', '2', '2'])
+ })
+
+ it('ignores an obsolete response when keyword resets a pending request', async () => {
+ const gate = createDeferred()
+ const requests: URL[] = []
+ const staleResponse = vi.fn()
+ server.use(
+ http.get(subscribeApiUrls.shares, async ({ request }) => {
+ const url = new URL(request.url)
+ requests.push(url)
+ if (url.searchParams.get('name') === '旧关键字') {
+ await gate.promise
+ staleResponse()
+ return HttpResponse.json([createSubscribeShare({ share_title: '过期关键字分享' })])
+ }
+ return HttpResponse.json([createSubscribeShare({ share_title: '新关键字实时分享' })])
+ }),
+ )
+
+ const view = await renderShare('旧关键字')
+ await waitFor(() => expect(requests).toHaveLength(1))
+
+ await view.rerender({ keyword: '新关键字' })
+ expect(await screen.findByText('新关键字实时分享')).toBeInTheDocument()
+ expect(requests.filter(url => url.searchParams.get('name') === '新关键字')).toHaveLength(1)
+ expect(requests.at(-1)?.searchParams.get('page')).toBe('1')
+
+ gate.resolve()
+ await waitFor(() => expect(staleResponse).toHaveBeenCalledOnce())
+ await flushPromises()
+ await flushPromises()
+
+ expect(screen.getByText('新关键字实时分享')).toBeInTheDocument()
+ expect(screen.queryByText('过期关键字分享')).not.toBeInTheDocument()
+ })
+})
diff --git a/tests/support/factories/subscribe.ts b/tests/support/factories/subscribe.ts
index dc9f7927..22c4e54c 100644
--- a/tests/support/factories/subscribe.ts
+++ b/tests/support/factories/subscribe.ts
@@ -4,11 +4,14 @@ import type {
MediaInfo,
Site,
Subscribe,
+ SubscribeShare,
+ SubscribeShareStatistics,
TransferDirectoryConf,
} from '@/api/types'
import { createMediaInfo } from './media'
let subscribeSeed = 1000
+let subscribeShareSeed = 2000
let siteSeed = 100
/** 构造满足前端订阅契约的最小记录。 */
@@ -35,6 +38,42 @@ export function createSubscribe(overrides: Partial = {}): Subscribe {
}
}
+/** 构造订阅分享列表、卡片与复用弹窗共同使用的稳定记录。 */
+export function createSubscribeShare(overrides: Partial = {}): SubscribeShare {
+ subscribeShareSeed += 1
+ return {
+ backdrop: `https://images.example.com/share-backdrop-${subscribeShareSeed}.jpg`,
+ count: 3,
+ date: '2026-07-17 12:00:00',
+ id: subscribeShareSeed,
+ name: `分享媒体 ${subscribeShareSeed}`,
+ poster: `https://images.example.com/share-poster-${subscribeShareSeed}.jpg`,
+ share_comment: `分享说明 ${subscribeShareSeed}`,
+ share_title: `分享标题 ${subscribeShareSeed}`,
+ share_uid: `share-user-${subscribeShareSeed}`,
+ share_user: `分享用户 ${subscribeShareSeed}`,
+ subscribe_id: subscribeShareSeed + 1000,
+ tmdbid: subscribeShareSeed,
+ type: '电影',
+ vote: 8.2,
+ year: '2026',
+ ...overrides,
+ }
+}
+
+/** 构造分享排行榜使用的稳定聚合记录。 */
+export function createSubscribeShareStatistics(
+ overrides: Partial = {},
+): SubscribeShareStatistics {
+ subscribeShareSeed += 1
+ return {
+ share_count: 2,
+ share_user: `统计用户 ${subscribeShareSeed}`,
+ total_reuse_count: 5,
+ ...overrides,
+ }
+}
+
/** 构造电影媒体信息。 */
export function createSubscribeMovie(overrides: Partial = {}): MediaInfo {
return createMediaInfo({ type: '电影', ...overrides })
diff --git a/tests/support/msw/handlers/subscribe.ts b/tests/support/msw/handlers/subscribe.ts
index 5a4b7100..bb3dae29 100644
--- a/tests/support/msw/handlers/subscribe.ts
+++ b/tests/support/msw/handlers/subscribe.ts
@@ -1,4 +1,13 @@
-import type { DownloaderConf, FilterRuleGroup, Site, Subscribe, TransferDirectoryConf } from '@/api/types'
+import type {
+ DownloaderConf,
+ FilterRuleGroup,
+ MediaInfo,
+ Site,
+ Subscribe,
+ SubscribeShare,
+ SubscribeShareStatistics,
+ TransferDirectoryConf,
+} from '@/api/types'
import { HttpResponse, http, type JsonBodyType, type RequestHandler } from 'msw'
const API_BASE_URL = 'http://localhost/api/v1/'
@@ -28,12 +37,20 @@ export const subscribeApiUrls = {
filesById: (id: number) => new URL(`subscribe/files/${id}`, API_BASE_URL).href,
historyById: (id: number) => new URL(`subscribe/history/${id}`, API_BASE_URL).href,
historyByType: (type: SubscribeMediaType) => new URL(`subscribe/history/${type}`, API_BASE_URL).href,
+ follow: new URL('subscribe/follow', API_BASE_URL).href,
+ followSubscribers: new URL('system/setting/public/FollowSubscribers', API_BASE_URL).href,
+ fork: new URL('subscribe/fork', API_BASE_URL).href,
queryByMedia: (mediaId: string) => new URL(`subscribe/media/${mediaId}`, API_BASE_URL).href,
list: new URL('subscribe/', API_BASE_URL).href,
orderConfig: (type: SubscribeMediaType) =>
new URL(`user/config/${type === '电影' ? 'SubscribeMovieOrder' : 'SubscribeTvOrder'}`, API_BASE_URL).href,
+ popular: new URL('subscribe/popular', API_BASE_URL).href,
resetById: (id: number) => new URL(`subscribe/reset/${id}`, API_BASE_URL).href,
searchById: (id: number) => new URL(`subscribe/search/${id}`, API_BASE_URL).href,
+ share: new URL('subscribe/share', API_BASE_URL).href,
+ shareById: (id: number) => new URL(`subscribe/share/${id}`, API_BASE_URL).href,
+ shareStatistics: new URL('subscribe/share/statistics', API_BASE_URL).href,
+ shares: new URL('subscribe/shares', API_BASE_URL).href,
sites: new URL('site/rss', API_BASE_URL).href,
statusById: (id: number) => new URL(`subscribe/status/${id}`, API_BASE_URL).href,
update: new URL('subscribe/', API_BASE_URL).href,
@@ -54,6 +71,108 @@ export function subscribeListHandler(
})
}
+export function popularSubscribesHandler(
+ response: MediaInfo[] = [],
+ status = 200,
+ onRequest: (url: URL) => void | Promise = () => {},
+) {
+ return http.get(subscribeApiUrls.popular, async ({ request }) => {
+ await onRequest(new URL(request.url))
+ return jsonResponse(response as unknown as JsonBodyType, status)
+ })
+}
+
+export function subscribeSharesHandler(
+ response: SubscribeShare[] = [],
+ status = 200,
+ onRequest: (url: URL) => void | Promise = () => {},
+) {
+ return http.get(subscribeApiUrls.shares, async ({ request }) => {
+ await onRequest(new URL(request.url))
+ return jsonResponse(response as unknown as JsonBodyType, status)
+ })
+}
+
+export function shareSubscribeHandler(
+ response: SubscribeMutationResponse = { success: true },
+ status = 200,
+ onShare: (payload: SubscribeShare) => void | Promise = () => {},
+) {
+ return http.post(subscribeApiUrls.share, async ({ request }) => {
+ const payload = (await request.json()) as SubscribeShare
+ await onShare(payload)
+ return jsonResponse(response, status)
+ })
+}
+
+export function forkSubscribeHandler(
+ response: SubscribeMutationResponse = { data: { id: 1 }, success: true },
+ status = 200,
+ onFork: (payload: SubscribeShare) => void | Promise = () => {},
+) {
+ return http.post(subscribeApiUrls.fork, async ({ request }) => {
+ const payload = (await request.json()) as SubscribeShare
+ await onFork(payload)
+ return jsonResponse(response, status)
+ })
+}
+
+export function followSubscribersSettingHandler(
+ users: string[] = [],
+ status = 200,
+ onRequest: (url: URL) => void | Promise = () => {},
+) {
+ return http.get(subscribeApiUrls.followSubscribers, async ({ request }) => {
+ await onRequest(new URL(request.url))
+ return jsonResponse({ data: { value: users }, success: status < 400 }, status)
+ })
+}
+
+export function followSubscriberHandler(
+ response: SubscribeMutationResponse = { success: true },
+ status = 200,
+ onRequest: (url: URL) => void | Promise = () => {},
+) {
+ return http.post(subscribeApiUrls.follow, async ({ request }) => {
+ await onRequest(new URL(request.url))
+ return jsonResponse(response, status)
+ })
+}
+
+export function unfollowSubscriberHandler(
+ response: SubscribeMutationResponse = { success: true },
+ status = 200,
+ onRequest: (url: URL) => void | Promise = () => {},
+) {
+ return http.delete(subscribeApiUrls.follow, async ({ request }) => {
+ await onRequest(new URL(request.url))
+ return jsonResponse(response, status)
+ })
+}
+
+export function deleteSubscribeShareHandler(
+ id: number,
+ response: SubscribeMutationResponse = { success: true },
+ status = 200,
+ onRequest: (url: URL) => void | Promise = () => {},
+) {
+ return http.delete(subscribeApiUrls.shareById(id), async ({ request }) => {
+ await onRequest(new URL(request.url))
+ return jsonResponse(response, status)
+ })
+}
+
+export function subscribeShareStatisticsHandler(
+ response: SubscribeShareStatistics[] = [],
+ status = 200,
+ onRequest: (url: URL) => void | Promise = () => {},
+) {
+ return http.get(subscribeApiUrls.shareStatistics, async ({ request }) => {
+ await onRequest(new URL(request.url))
+ return jsonResponse(response as unknown as JsonBodyType, status)
+ })
+}
+
export function subscribeFilesHandler(
id: number,
response: JsonBodyType = { episodes: {}, subscribe: null },
diff --git a/vite.config.ts b/vite.config.ts
index 229afd89..17d85117 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -280,12 +280,18 @@ export default defineConfig(({ mode }) => ({
'src/views/dashboard/MediaRecommend.vue',
'src/views/subscribe/FullCalendarView.vue',
'src/views/subscribe/SubscribeListView.vue',
+ 'src/views/subscribe/SubscribePopularView.vue',
+ 'src/views/subscribe/SubscribeShareView.vue',
'src/composables/useMediaSubscribe.ts',
'src/components/cards/SubscribeCard.vue',
+ 'src/components/cards/SubscribeShareCard.vue',
+ 'src/components/dialog/ForkSubscribeDialog.vue',
'src/components/dialog/SubscribeEditDialog.vue',
'src/components/dialog/SubscribeFilesDialog.vue',
'src/components/dialog/SubscribeHistoryDialog.vue',
'src/components/dialog/SubscribeSeasonDialog.vue',
+ 'src/components/dialog/SubscribeShareDialog.vue',
+ 'src/components/dialog/SubscribeShareStatisticsDialog.vue',
],
provider: 'v8',
reporter: ['text', 'json-summary', 'html'],
@@ -301,6 +307,18 @@ export default defineConfig(({ mode }) => ({
lines: 80,
statements: 80,
},
+ 'src/components/cards/SubscribeShareCard.vue': {
+ branches: 75,
+ functions: 80,
+ lines: 80,
+ statements: 80,
+ },
+ 'src/components/dialog/ForkSubscribeDialog.vue': {
+ branches: 75,
+ functions: 80,
+ lines: 80,
+ statements: 80,
+ },
'src/components/dialog/SubscribeEditDialog.vue': {
branches: 75,
functions: 80,
@@ -325,6 +343,18 @@ export default defineConfig(({ mode }) => ({
lines: 90,
statements: 90,
},
+ 'src/components/dialog/SubscribeShareDialog.vue': {
+ branches: 75,
+ functions: 80,
+ lines: 80,
+ statements: 80,
+ },
+ 'src/components/dialog/SubscribeShareStatisticsDialog.vue': {
+ branches: 75,
+ functions: 80,
+ lines: 80,
+ statements: 80,
+ },
'src/composables/useMediaSubscribe.ts': {
branches: 75,
functions: 80,
@@ -379,6 +409,18 @@ export default defineConfig(({ mode }) => ({
lines: 80,
statements: 80,
},
+ 'src/views/subscribe/SubscribePopularView.vue': {
+ branches: 75,
+ functions: 80,
+ lines: 80,
+ statements: 80,
+ },
+ 'src/views/subscribe/SubscribeShareView.vue': {
+ branches: 75,
+ functions: 80,
+ lines: 80,
+ statements: 80,
+ },
},
},
},