From dfa0724d1211fe9b259665ab97bcccc4c814a2b8 Mon Sep 17 00:00:00 2001 From: yun-zhi-ztl <15071461069@163.com> Date: Mon, 16 Mar 2026 16:28:16 +0800 Subject: [PATCH] fix: refresh skill download counts after download --- web/src/api/client.ts | 40 +++++++++ web/src/pages/skill-detail.tsx | 34 ++++++-- .../shared/lib/skill-download-cache.test.ts | 85 +++++++++++++++++++ web/src/shared/lib/skill-download-cache.ts | 77 +++++++++++++++++ 4 files changed, 231 insertions(+), 5 deletions(-) create mode 100644 web/src/shared/lib/skill-download-cache.test.ts create mode 100644 web/src/shared/lib/skill-download-cache.ts diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 4e08c3fa..bb0da6d9 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -37,6 +37,11 @@ export { ApiError } export const WEB_API_PREFIX = '/api/web' +export type DownloadedFile = { + blob: Blob + fileName?: string +} + type RuntimeConfig = { apiBaseUrl?: string appBaseUrl?: string @@ -273,6 +278,20 @@ function ensureTrailingSlash(value: string): string { return value.endsWith('/') ? value : `${value}/` } +function parseDownloadFileName(contentDisposition: string | null): string | undefined { + if (!contentDisposition) { + return undefined + } + + const utf8Match = contentDisposition.match(/filename\*=UTF-8''([^;]+)/i) + if (utf8Match) { + return decodeURIComponent(utf8Match[1]) + } + + const basicMatch = contentDisposition.match(/filename="?([^";]+)"?/i) + return basicMatch?.[1] +} + export async function getCurrentUser(): Promise { try { const user = await unwrap(client.GET('/api/v1/auth/me', { @@ -422,6 +441,27 @@ export const accountApi = { }, } +export const skillDownloadApi = { + async downloadVersion(namespace: string, slug: string, version: string): Promise { + const cleanNamespace = namespace.startsWith('@') ? namespace.slice(1) : namespace + const response = await fetch( + withBaseUrl(`${WEB_API_PREFIX}/skills/${cleanNamespace}/${slug}/versions/${version}/download`), + { + headers: withRequestHeaders(), + }, + ) + + if (!response.ok) { + throw new ApiError(`HTTP ${response.status}`, response.status) + } + + return { + blob: await response.blob(), + fileName: parseDownloadFileName(response.headers.get('content-disposition')), + } + }, +} + export const skillLifecycleApi = { async archiveSkill(namespace: string, slug: string, reason?: string): Promise { const cleanNamespace = namespace.startsWith('@') ? namespace.slice(1) : namespace diff --git a/web/src/pages/skill-detail.tsx b/web/src/pages/skill-detail.tsx index 3e5e19c8..dd7da5b3 100644 --- a/web/src/pages/skill-detail.tsx +++ b/web/src/pages/skill-detail.tsx @@ -9,9 +9,10 @@ import { InstallCommand } from '@/features/skill/install-command' import { RatingInput } from '@/features/social/rating-input' import { StarButton } from '@/features/social/star-button' import { useAuth } from '@/features/auth/use-auth' -import { adminApi, ApiError, WEB_API_PREFIX } from '@/api/client' +import { adminApi, ApiError, skillDownloadApi } from '@/api/client' import { useSubmitSkillReport } from '@/features/report/use-skill-reports' import { formatLocalDateTime } from '@/shared/lib/date-time' +import { incrementSkillDownloadCount } from '@/shared/lib/skill-download-cache' import { formatCompactCount } from '@/shared/lib/number-format' import { resolveDocumentationFilePath } from '@/shared/lib/skill-documentation' import { NamespaceBadge } from '@/shared/components/namespace-badge' @@ -135,7 +136,18 @@ export function SkillDetailPage() { const submitPromotionMutation = useSubmitPromotion() const reportMutation = useSubmitSkillReport(namespace, slug) - const handleDownload = () => { + const triggerBrowserDownload = (blob: Blob, fileName: string) => { + const objectUrl = window.URL.createObjectURL(blob) + const link = document.createElement('a') + link.href = objectUrl + link.download = fileName + document.body.appendChild(link) + link.click() + link.remove() + window.setTimeout(() => window.URL.revokeObjectURL(objectUrl), 0) + } + + const handleDownload = async () => { if (!user) { requireLogin() return @@ -143,9 +155,21 @@ export function SkillDetailPage() { if (!selectedVersionEntry || isPendingPreview) { return } - const cleanNamespace = namespace.startsWith('@') ? namespace.slice(1) : namespace - const downloadUrl = `${WEB_API_PREFIX}/skills/${cleanNamespace}/${slug}/versions/${selectedVersionEntry.version}/download` - window.open(downloadUrl, '_blank') + + try { + const downloadedFile = await skillDownloadApi.downloadVersion(namespace, slug, selectedVersionEntry.version) + triggerBrowserDownload( + downloadedFile.blob, + downloadedFile.fileName ?? `${slug}-${selectedVersionEntry.version}.zip`, + ) + incrementSkillDownloadCount(queryClient, { namespace, slug }) + queryClient.invalidateQueries({ queryKey: ['skills', namespace, slug] }) + queryClient.invalidateQueries({ queryKey: ['skills', 'my'] }) + queryClient.invalidateQueries({ queryKey: ['skills', 'stars'] }) + queryClient.invalidateQueries({ queryKey: ['skills', 'search'] }) + } catch (error) { + toast.error(t('skillDetail.reportErrorTitle'), error instanceof Error ? error.message : '') + } } const requireLogin = () => { diff --git a/web/src/shared/lib/skill-download-cache.test.ts b/web/src/shared/lib/skill-download-cache.test.ts new file mode 100644 index 00000000..38ca71ce --- /dev/null +++ b/web/src/shared/lib/skill-download-cache.test.ts @@ -0,0 +1,85 @@ +import { QueryClient } from '@tanstack/react-query' +import { describe, expect, it } from 'vitest' +import type { PagedResponse, SkillDetail, SkillSummary } from '@/api/types' +import { incrementSkillDownloadCount } from './skill-download-cache' + +function createSkillSummary(overrides: Partial = {}): SkillSummary { + return { + id: 1, + slug: 'demo-skill', + displayName: 'Demo Skill', + summary: 'summary', + status: 'PUBLISHED', + downloadCount: 10, + starCount: 2, + ratingAvg: 5, + ratingCount: 1, + latestVersion: '1.0.0', + latestVersionId: 100, + latestVersionStatus: 'PUBLISHED', + namespace: 'team', + updatedAt: '2026-03-16T00:00:00Z', + canSubmitPromotion: false, + ...overrides, + } +} + +function createSkillDetail(overrides: Partial = {}): SkillDetail { + return { + id: 1, + slug: 'demo-skill', + displayName: 'Demo Skill', + summary: 'summary', + visibility: 'PUBLIC', + status: 'ACTIVE', + downloadCount: 10, + starCount: 2, + ratingAvg: 5, + ratingCount: 1, + hidden: false, + latestVersion: '1.0.0', + latestVersionId: 100, + namespace: 'team', + canManageLifecycle: false, + canSubmitPromotion: false, + viewingVersionStatus: 'PUBLISHED', + canInteract: true, + ...overrides, + } +} + +describe('incrementSkillDownloadCount', () => { + it('increments the skill detail and cached list entries for the downloaded skill', () => { + const queryClient = new QueryClient() + const searchPage: PagedResponse = { + items: [ + createSkillSummary(), + createSkillSummary({ id: 2, slug: 'other-skill', displayName: 'Other Skill', downloadCount: 4 }), + ], + total: 2, + page: 0, + size: 12, + } + + queryClient.setQueryData(['skills', '@team', 'demo-skill'], createSkillDetail({ namespace: 'team' })) + queryClient.setQueryData(['skills', 'my'], searchPage.items) + queryClient.setQueryData(['skills', 'stars'], searchPage.items) + queryClient.setQueryData(['skills', 'search', { q: '', sort: 'downloads', page: 0, size: 12, starredOnly: false }], searchPage) + + incrementSkillDownloadCount(queryClient, { namespace: '@team', slug: 'demo-skill' }) + + expect(queryClient.getQueryData(['skills', '@team', 'demo-skill'])?.downloadCount).toBe(11) + expect(queryClient.getQueryData(['skills', 'my'])?.[0]?.downloadCount).toBe(11) + expect(queryClient.getQueryData(['skills', 'stars'])?.[0]?.downloadCount).toBe(11) + expect( + queryClient.getQueryData>( + ['skills', 'search', { q: '', sort: 'downloads', page: 0, size: 12, starredOnly: false }], + )?.items[0]?.downloadCount, + ).toBe(11) + expect( + queryClient.getQueryData>( + ['skills', 'search', { q: '', sort: 'downloads', page: 0, size: 12, starredOnly: false }], + )?.items[1]?.downloadCount, + ).toBe(4) + }) +}) diff --git a/web/src/shared/lib/skill-download-cache.ts b/web/src/shared/lib/skill-download-cache.ts new file mode 100644 index 00000000..d5af24c5 --- /dev/null +++ b/web/src/shared/lib/skill-download-cache.ts @@ -0,0 +1,77 @@ +import type { QueryClient } from '@tanstack/react-query' +import type { PagedResponse, SkillDetail, SkillSummary } from '@/api/types' + +type SkillIdentity = { + namespace: string + slug: string +} + +function normalizeNamespace(namespace: string): string { + return namespace.startsWith('@') ? namespace.slice(1) : namespace +} + +function matchesSkill(skill: SkillIdentity, target: SkillIdentity): boolean { + return normalizeNamespace(skill.namespace) === normalizeNamespace(target.namespace) && skill.slug === target.slug +} + +function incrementSummaryDownloadCount(skill: SkillSummary, target: SkillIdentity): SkillSummary { + if (!matchesSkill(skill, target)) { + return skill + } + return { + ...skill, + downloadCount: skill.downloadCount + 1, + } +} + +function incrementDetailDownloadCount(skill: SkillDetail | undefined, target: SkillIdentity): SkillDetail | undefined { + if (!skill || !matchesSkill(skill, target)) { + return skill + } + return { + ...skill, + downloadCount: skill.downloadCount + 1, + } +} + +function incrementSummaryList( + skills: SkillSummary[] | undefined, + target: SkillIdentity, +): SkillSummary[] | undefined { + return skills?.map((skill) => incrementSummaryDownloadCount(skill, target)) +} + +function incrementPagedSummaryList( + page: PagedResponse | undefined, + target: SkillIdentity, +): PagedResponse | undefined { + if (!page) { + return page + } + return { + ...page, + items: page.items.map((skill) => incrementSummaryDownloadCount(skill, target)), + } +} + +export function incrementSkillDownloadCount( + queryClient: QueryClient, + target: SkillIdentity, +): void { + queryClient.setQueryData( + ['skills', target.namespace, target.slug], + (current) => incrementDetailDownloadCount(current, target), + ) + queryClient.setQueryData( + ['skills', 'my'], + (current) => incrementSummaryList(current, target), + ) + queryClient.setQueryData( + ['skills', 'stars'], + (current) => incrementSummaryList(current, target), + ) + queryClient.setQueriesData>( + { queryKey: ['skills', 'search'] }, + (current) => incrementPagedSummaryList(current, target), + ) +}