diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 8bef2399..b298a520 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -179,11 +179,6 @@ "count": 2 } }, - "src/components/cards/SiteCard.vue": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, "src/components/cards/SubscribeCard.vue": { "@typescript-eslint/no-explicit-any": { "count": 5 diff --git a/src/components/cards/SiteCard.vue b/src/components/cards/SiteCard.vue index 87ccefe7..e5a92fd9 100644 --- a/src/components/cards/SiteCard.vue +++ b/src/components/cards/SiteCard.vue @@ -4,7 +4,7 @@ import { getLogoUrl } from '@/utils/imageUtils' import { useToast } from 'vue-toastification' import { useI18n } from 'vue-i18n' import api from '@/api' -import type { Site, SiteStatistic, SiteUserData } from '@/api/types' +import type { ApiResponse, Site, SiteStatistic, SiteUserData } from '@/api/types' import { isNullOrEmptyObject } from '@/@core/utils' import { formatFileSize } from '@/@core/utils/formatters' import { useConfirm } from '@/composables/useConfirm' @@ -79,17 +79,17 @@ async function testSite() { testButtonText.value = t('site.testing') testButtonDisable.value = true - const result: { [key: string]: any } = await api.get(`site/test/${cardProps.site?.id}`) + const result = (await api.get(`site/test/${cardProps.site?.id}`)) as ApiResponse if (result.success) $toast.success(t('site.testSuccess', { name: cardProps.site?.name })) else $toast.error(t('site.testFailed', { name: cardProps.site?.name, message: result.message })) - testButtonText.value = t('site.testConnectivity') - testButtonDisable.value = false - // 测试完成后刷新统计数据 emit('refresh-stats', cardProps.site?.domain) } catch (error) { console.error(error) + } finally { + testButtonText.value = t('site.testConnectivity') + testButtonDisable.value = false } } @@ -166,7 +166,7 @@ async function deleteSiteInfo() { if (!isConfirmed) return try { - const result: { [key: string]: any } = await api.delete(`site/${cardProps.site?.id}`) + const result = (await api.delete(`site/${cardProps.site?.id}`)) as ApiResponse if (result.success) emit('remove') else $toast.error(t('site.deleteFailed', { name: cardProps.site?.name, message: result.message })) } catch (error) { @@ -260,167 +260,182 @@ onMounted(() => { :hover="!cardProps.sortable" @click="handleCardClick" > - -
+ +
- -
- -
- - - - - - - - -
-

{{ cardProps.site?.name }}

- - -
-
- -
-
- -
-
- -
-
-
-
- -
+ +
{{ cardProps.site?.url }}
- -
- -
- -
-
- - {{ formatFileSize(cardProps.data?.upload || 0) }} + +
+ +
+ +
+
+ + {{ formatFileSize(cardProps.data?.upload || 0) }} +
+
+ +
-
- -
-
- -
-
- - {{ formatFileSize(cardProps.data?.download || 0) }} -
-
- + +
+
+ + {{ formatFileSize(cardProps.data?.download || 0) }} +
+
+ +
-
- - - - + -
-
-
-
+ -
-
+
+
-
-
+
+
+
+
+
+ - - - - + + + + - - - - + + + + - - - - - - - - {{ t('site.actions.edit') }} - - - - {{ t('site.deleteSite') }} - - - - - + + + + + + + + {{ t('site.actions.edit') }} + + + + {{ t('site.deleteSite') }} + + + + +
@@ -442,7 +457,9 @@ onMounted(() => { inset-block-start: 0; inset-inline: 0; opacity: 0.5; - transition: block-size 0.3s ease, opacity 0.3s ease; + transition: + block-size 0.3s ease, + opacity 0.3s ease; } .site-status-indicator.error { diff --git a/src/components/cards/__tests__/SiteCard.spec.ts b/src/components/cards/__tests__/SiteCard.spec.ts new file mode 100644 index 00000000..d468670e --- /dev/null +++ b/src/components/cards/__tests__/SiteCard.spec.ts @@ -0,0 +1,253 @@ +import type { Site, SiteStatistic, SiteUserData } from '@/api/types' +import SiteCard from '@/components/cards/SiteCard.vue' +import { getActiveRequestsCount } from '@/utils/requestOptimizer' +import { fireEvent, screen, waitFor } from '@testing-library/vue' +import { createSite, createSiteStatistic, createSiteUserData } from '@tests/support/factories/site' +import { deleteSiteHandler, siteIconHandler, testSiteConnectionHandler } from '@tests/support/msw/handlers/site' +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 mocks = vi.hoisted(() => ({ + confirm: vi.fn(), + openSharedDialog: vi.fn(), + toastError: vi.fn(), + toastSuccess: vi.fn(), +})) + +vi.mock('vue-toastification', () => ({ + useToast: () => ({ error: mocks.toastError, success: mocks.toastSuccess }), +})) + +vi.mock('@/composables/useConfirm', () => ({ + useConfirm: () => mocks.confirm, +})) + +vi.mock('@/composables/useSharedDialog', () => ({ + openSharedDialog: (...args: unknown[]) => mocks.openSharedDialog(...args), +})) + +const ImageStub = defineComponent({ + inheritAttrs: false, + props: { + alt: String, + src: String, + }, + setup: props => () => h('img', { alt: props.alt, src: props.src }), +}) + +const imageStubs = { VImg: ImageStub } + +async function renderCard( + siteOverrides: Partial = {}, + props: Partial<{ data: SiteUserData; sortable: boolean; stats: SiteStatistic }> = {}, +) { + const site = createSite(siteOverrides) + server.use(siteIconHandler(site.id, `https://images.example.com/site-${site.id}.png`)) + const result = await renderWithProviders(SiteCard, { + global: { stubs: imageStubs }, + props: { site, ...props }, + }) + + await waitFor(() => { + expect(result.container.querySelector('img')?.src).toContain(`site-${site.id}.png`) + }) + await waitFor(() => expect(getActiveRequestsCount()).toBe(0)) + return { ...result, site } +} + +function getActionButton(container: Element, index: number) { + const button = container.querySelectorAll('.site-card-actions > button')[index] + if (!button) throw new Error(`Missing action button ${index}`) + return button +} + +function getTestButton(container: Element) { + const button = container.querySelector('.pulse-dot')?.closest('button') + if (!button) throw new Error('Missing connectivity test button') + return button +} + +function getDialogCall(index = 0) { + const [, props, events, options] = mocks.openSharedDialog.mock.calls[index] as [ + unknown, + Record, + Record void>, + Record, + ] + return { events, options, props } +} + +describe('SiteCard display', () => { + beforeEach(() => { + mocks.confirm.mockResolvedValue(true) + mocks.openSharedDialog.mockReturnValue({ close: vi.fn(), id: 1, updateProps: vi.fn() }) + }) + + it('renders active site metadata, transfer values, feature flags, and a healthy border', async () => { + const { container, site } = await renderCard( + { filter: 'free', limit_interval: 10, proxy: true, render: true }, + { + data: createSiteUserData({ download: 1024, upload: 2048 }), + stats: createSiteStatistic({ lst_state: 0, seconds: 2 }), + }, + ) + + expect(screen.getByText(site.name)).toBeInTheDocument() + expect(screen.getByText(site.url)).toBeInTheDocument() + expect(screen.getByText('2.00 KB')).toBeInTheDocument() + expect(screen.getByText('1.00 KB')).toBeInTheDocument() + expect(container.querySelector('.site-card')).toHaveClass('border-success') + expect(container.querySelectorAll('.ml-auto.flex.shrink-0.items-center.gap-2 > div')).toHaveLength(4) + expect( + [...container.querySelectorAll('.border-t .v-progress-linear')].map(progress => + progress.getAttribute('aria-valuenow'), + ), + ).toEqual(['100', '50']) + }) + + it.each([ + ['failed', createSiteStatistic({ lst_state: 1 }), 'border-error'], + ['slow', createSiteStatistic({ lst_state: 0, seconds: 5 }), 'border-warning'], + ['unknown without stats', undefined, null], + ['unknown without duration', createSiteStatistic({ lst_state: 0, seconds: 0 }), null], + ] as const)('projects %s connection state without inventing status', async (_case, stats, borderClass) => { + const { container } = await renderCard({}, stats ? { stats } : {}) + const card = container.querySelector('.site-card') + + if (borderClass) expect(card).toHaveClass(borderClass) + else expect(card).not.toHaveClass('border-error', 'border-warning', 'border-success') + }) + + it('keeps zero transfer data visible with stable minimum progress', async () => { + await renderCard({ is_active: false }, { data: createSiteUserData({ download: 0, upload: 0 }) }) + + expect(screen.getAllByText('0.00 B')).toHaveLength(2) + expect( + [...document.querySelectorAll('.border-t .v-progress-linear')].map(progress => + progress.getAttribute('aria-valuenow'), + ), + ).toEqual(['3', '3']) + }) +}) + +describe('SiteCard interactions', () => { + beforeEach(() => { + mocks.confirm.mockResolvedValue(true) + mocks.openSharedDialog.mockReturnValue({ close: vi.fn(), id: 1, updateProps: vi.fn() }) + vi.spyOn(console, 'error').mockImplementation(() => {}) + }) + + it.each([ + ['success', 200, { success: true }, 'success'], + ['business failure', 200, { message: '认证失败', success: false }, 'error'], + ] as const)( + 'reports connectivity %s and refreshes the current domain', + async (_case, status, response, toastType) => { + const requested = vi.fn() + const { container, emitted, site } = await renderCard() + server.use(testSiteConnectionHandler(site.id, response, status, requested)) + + await fireEvent.click(getTestButton(container)) + + await waitFor(() => expect(requested).toHaveBeenCalledOnce()) + const toast = toastType === 'success' ? mocks.toastSuccess : mocks.toastError + await waitFor(() => expect(toast).toHaveBeenCalledOnce()) + expect(emitted('refresh-stats')).toEqual([[site.domain]]) + expect(getTestButton(container)).not.toBeDisabled() + }, + ) + + it('restores connectivity controls after an HTTP failure', async () => { + const requested = vi.fn() + const { container, emitted, site } = await renderCard() + server.use(testSiteConnectionHandler(site.id, { message: 'server down', success: false }, 500, requested)) + + await fireEvent.click(getTestButton(container)) + await waitFor(() => expect(requested).toHaveBeenCalledOnce()) + + await waitFor(() => expect(getTestButton(container)).not.toBeDisabled()) + expect(emitted('refresh-stats') ?? []).toHaveLength(0) + }) + + it.each([ + ['cancelled', false, 200, { success: true }, false, null], + ['success', true, 200, { success: true }, true, null], + ['business failure', true, 200, { message: '仍在使用', success: false }, false, '仍在使用'], + ['HTTP failure', true, 500, { message: 'server down', success: false }, false, null], + ] as const)('handles deletion when %s', async (_case, confirmed, status, response, removed, expectedMessage) => { + const requested = vi.fn() + mocks.confirm.mockResolvedValue(confirmed) + const { container, emitted, site } = await renderCard() + server.use(deleteSiteHandler(site.id, response, status, requested)) + + await fireEvent.click(getActionButton(container, 3)) + await fireEvent.click(await screen.findByText('删除站点')) + await waitFor(() => expect(mocks.confirm).toHaveBeenCalledOnce()) + + if (confirmed) await waitFor(() => expect(requested).toHaveBeenCalledOnce()) + else expect(requested).not.toHaveBeenCalled() + expect(emitted('remove') ?? []).toHaveLength(removed ? 1 : 0) + if (expectedMessage) expect(mocks.toastError).toHaveBeenCalledWith(expect.stringContaining(expectedMessage)) + if (_case === 'HTTP failure') expect(mocks.toastError).toHaveBeenCalledOnce() + }) + + it('opens each shared dialog with exact props, close events, and refresh ownership', async () => { + const { container, emitted, site } = await renderCard() + + await fireEvent.click(container.querySelector('.site-card') as Element) + expect(getDialogCall().props).toEqual({ site }) + expect(getDialogCall().options).toEqual({ closeOn: ['close'] }) + getDialogCall().events.close() + expect(emitted('refresh-stats')).toEqual([[site.domain]]) + + await fireEvent.click(getActionButton(container, 1)) + expect(getDialogCall(1).props).toEqual({ site }) + expect(getDialogCall(1).options).toEqual({ closeOn: ['close'] }) + + await fireEvent.click(getActionButton(container, 2)) + expect(getDialogCall(2).props).toEqual({ site }) + expect(getDialogCall(2).options).toEqual({ closeOn: ['close', 'done'] }) + getDialogCall(2).events.done() + expect(emitted('refresh-stats')).toEqual([[site.domain], [site.domain]]) + + await fireEvent.click(getActionButton(container, 3)) + await fireEvent.click(await screen.findByText('编辑站点')) + expect(getDialogCall(3).props).toEqual({ siteid: site.id }) + expect(getDialogCall(3).options).toEqual({ closeOn: ['close', 'save', 'remove'] }) + getDialogCall(3).events.save() + getDialogCall(3).events.remove() + expect(emitted('update')).toHaveLength(1) + expect(emitted('remove')).toHaveLength(1) + }) + + it('opens the site URL normally and isolates every card action in sortable mode', async () => { + const open = vi.spyOn(window, 'open').mockImplementation(() => null) + const { container, rerender, site } = await renderCard() + + await fireEvent.click(screen.getByText(site.url)) + expect(open).toHaveBeenCalledWith(site.url, '_blank') + + await rerender({ site, sortable: true }) + expect(container.querySelector('.site-card-actions')).not.toBeInTheDocument() + await fireEvent.click(screen.getByText(site.url)) + await fireEvent.click(container.querySelector('.site-card') as Element) + expect(open).toHaveBeenCalledOnce() + expect(mocks.openSharedDialog).not.toHaveBeenCalled() + }) + + it('falls back to the default icon when the icon request fails', async () => { + const site = createSite() + const requested = vi.fn() + server.use(siteIconHandler(site.id, null, 500, requested)) + const { container } = await renderWithProviders(SiteCard, { + global: { stubs: imageStubs }, + props: { site }, + }) + + await waitFor(() => expect(requested).toHaveBeenCalledOnce()) + await waitFor(() => expect(getActiveRequestsCount()).toBe(0)) + await waitFor(() => expect(container.querySelector('img')?.src).toContain('/site.webp')) + }) +}) diff --git a/src/utils/__tests__/siteIconCache.spec.ts b/src/utils/__tests__/siteIconCache.spec.ts new file mode 100644 index 00000000..08cb2887 --- /dev/null +++ b/src/utils/__tests__/siteIconCache.spec.ts @@ -0,0 +1,77 @@ +import { getCachedSiteIcon } from '@/utils/siteIconCache' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +let keySeed = 0 + +function nextKey() { + keySeed += 1 + return `site-icon-${keySeed}` +} + +function deferred() { + let resolve!: (value: T) => void + let reject!: (reason?: unknown) => void + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise + reject = rejectPromise + }) + + return { promise, reject, resolve } +} + +describe('site icon cache', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-07-19T00:00:00Z')) + }) + + it('isolates site ids and reuses each value within the TTL', async () => { + const firstLoader = vi.fn().mockResolvedValue('first-icon') + const secondLoader = vi.fn().mockResolvedValue('second-icon') + const firstKey = nextKey() + const secondKey = nextKey() + + await expect(getCachedSiteIcon(firstKey, firstLoader)).resolves.toBe('first-icon') + await expect(getCachedSiteIcon(secondKey, secondLoader)).resolves.toBe('second-icon') + await expect(getCachedSiteIcon(firstKey, vi.fn().mockResolvedValue('stale'))).resolves.toBe('first-icon') + + expect(firstLoader).toHaveBeenCalledOnce() + expect(secondLoader).toHaveBeenCalledOnce() + }) + + it('coalesces concurrent requests for the same site', async () => { + const pending = deferred() + const loader = vi.fn().mockReturnValue(pending.promise) + const key = nextKey() + + const firstRequest = getCachedSiteIcon(key, loader) + const secondRequest = getCachedSiteIcon(key, loader) + expect(loader).toHaveBeenCalledOnce() + + pending.resolve('shared-icon') + await expect(firstRequest).resolves.toBe('shared-icon') + await expect(secondRequest).resolves.toBe('shared-icon') + }) + + it('reloads an expired value after ten minutes', async () => { + const key = nextKey() + await expect(getCachedSiteIcon(key, vi.fn().mockResolvedValue('old-icon'))).resolves.toBe('old-icon') + + vi.advanceTimersByTime(10 * 60 * 1000) + const refreshedLoader = vi.fn().mockResolvedValue('fresh-icon') + + await expect(getCachedSiteIcon(key, refreshedLoader)).resolves.toBe('fresh-icon') + expect(refreshedLoader).toHaveBeenCalledOnce() + }) + + it('allows a retry after the loader rejects', async () => { + const key = nextKey() + await expect(getCachedSiteIcon(key, vi.fn().mockRejectedValue(new Error('temporary failure')))).rejects.toThrow( + 'temporary failure', + ) + + const retryLoader = vi.fn().mockResolvedValue('recovered-icon') + await expect(getCachedSiteIcon(key, retryLoader)).resolves.toBe('recovered-icon') + expect(retryLoader).toHaveBeenCalledOnce() + }) +}) diff --git a/tests/support/msw/handlers/site.ts b/tests/support/msw/handlers/site.ts index b0b253a4..19cc1f02 100644 --- a/tests/support/msw/handlers/site.ts +++ b/tests/support/msw/handlers/site.ts @@ -1,13 +1,16 @@ -import type { Site, SiteStatistic, SiteUserData } from '@/api/types' +import type { ApiResponse, Site, SiteStatistic, SiteUserData } from '@/api/types' import { HttpResponse, http, type JsonBodyType } from 'msw' const API_BASE_URL = 'http://localhost/api/v1/' export const siteApiUrls = { + delete: (id: number) => new URL(`site/${id}`, API_BASE_URL).href, + icon: (id: number) => new URL(`site/icon/${id}`, API_BASE_URL).href, list: new URL('site/', API_BASE_URL).href, priorities: new URL('site/priorities', API_BASE_URL).href, statistic: (domain: string) => new URL(`site/statistic/${domain}`, API_BASE_URL).href, statistics: new URL('site/statistic', API_BASE_URL).href, + test: (id: number) => new URL(`site/test/${id}`, API_BASE_URL).href, userDataLatest: new URL('site/userdata/latest', API_BASE_URL).href, } @@ -71,3 +74,43 @@ export function saveSitePrioritiesHandler( ) }) } + +function response(body: JsonBodyType, status: number) { + return HttpResponse.json(body, { status }) +} + +export function siteIconHandler( + id: number, + icon: string | null, + status = 200, + onRequest: () => void | Promise = () => {}, +) { + return http.get(siteApiUrls.icon(id), async () => { + await onRequest() + return response({ data: icon ? { icon } : {}, success: Boolean(icon) }, status) + }) +} + +export function testSiteConnectionHandler( + id: number, + result: Pick, 'message' | 'success'>, + status = 200, + onRequest: () => void | Promise = () => {}, +) { + return http.get(siteApiUrls.test(id), async () => { + await onRequest() + return response(result, status) + }) +} + +export function deleteSiteHandler( + id: number, + result: Pick, 'message' | 'success'> = { success: true }, + status = 200, + onRequest: () => void | Promise = () => {}, +) { + return http.delete(siteApiUrls.delete(id), async () => { + await onRequest() + return response(result, status) + }) +} diff --git a/vite.config.ts b/vite.config.ts index 6cf837e5..06e29390 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -305,10 +305,12 @@ export default defineConfig(({ mode }) => ({ 'src/views/discover/MediaCardListView.vue', 'src/views/discover/MediaDetailView.vue', 'src/components/cards/MediaCard.vue', + 'src/components/cards/SiteCard.vue', 'src/components/slide/VirtualSlideView.vue', 'src/views/discover/PersonCardSlideView.vue', 'src/utils/mediaStatusCache.ts', 'src/views/site/SiteCardListView.vue', + 'src/utils/siteIconCache.ts', ], provider: 'v8', reporter: ['text', 'json-summary', 'html'], @@ -492,6 +494,18 @@ export default defineConfig(({ mode }) => ({ lines: 90, statements: 90, }, + 'src/components/cards/SiteCard.vue': { + branches: 85, + functions: 90, + lines: 90, + statements: 90, + }, + 'src/utils/siteIconCache.ts': { + branches: 85, + functions: 90, + lines: 90, + statements: 90, + }, 'src/components/slide/VirtualSlideView.vue': { branches: 85, functions: 90,