mirror of
https://github.com/iflytek/skillhub.git
synced 2026-10-07 02:57:51 +00:00
fix: refresh skill download counts after download
This commit is contained in:
parent
5168e866da
commit
dfa0724d12
4 changed files with 231 additions and 5 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 = () => {
|
||||
|
|
|
|||
85
web/src/shared/lib/skill-download-cache.test.ts
Normal file
85
web/src/shared/lib/skill-download-cache.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
77
web/src/shared/lib/skill-download-cache.ts
Normal file
77
web/src/shared/lib/skill-download-cache.ts
Normal 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),
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue