fix: refresh skill download counts after download

This commit is contained in:
yun-zhi-ztl 2026-03-16 16:28:16 +08:00
parent 5168e866da
commit dfa0724d12
4 changed files with 231 additions and 5 deletions

View file

@ -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<User | null> {
try {
const user = await unwrap<User>(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<DownloadedFile> {
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<void> {
const cleanNamespace = namespace.startsWith('@') ? namespace.slice(1) : namespace

View file

@ -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 = () => {

View file

@ -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> = {}): 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> = {}): 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<SkillSummary> = {
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<SkillDetail>(['skills', '@team', 'demo-skill'])?.downloadCount).toBe(11)
expect(queryClient.getQueryData<SkillSummary[]>(['skills', 'my'])?.[0]?.downloadCount).toBe(11)
expect(queryClient.getQueryData<SkillSummary[]>(['skills', 'stars'])?.[0]?.downloadCount).toBe(11)
expect(
queryClient.getQueryData<PagedResponse<SkillSummary>>(
['skills', 'search', { q: '', sort: 'downloads', page: 0, size: 12, starredOnly: false }],
)?.items[0]?.downloadCount,
).toBe(11)
expect(
queryClient.getQueryData<PagedResponse<SkillSummary>>(
['skills', 'search', { q: '', sort: 'downloads', page: 0, size: 12, starredOnly: false }],
)?.items[1]?.downloadCount,
).toBe(4)
})
})

View file

@ -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<SkillSummary> | undefined,
target: SkillIdentity,
): PagedResponse<SkillSummary> | 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<SkillDetail>(
['skills', target.namespace, target.slug],
(current) => incrementDetailDownloadCount(current, target),
)
queryClient.setQueryData<SkillSummary[]>(
['skills', 'my'],
(current) => incrementSummaryList(current, target),
)
queryClient.setQueryData<SkillSummary[]>(
['skills', 'stars'],
(current) => incrementSummaryList(current, target),
)
queryClient.setQueriesData<PagedResponse<SkillSummary>>(
{ queryKey: ['skills', 'search'] },
(current) => incrementPagedSummaryList(current, target),
)
}