fix: stabilize promotion approval and documentation fallback

This commit is contained in:
yun-zhi-ztl 2026-03-16 15:51:21 +08:00 • committed by Xudong Sun
parent df5883ec04
commit 2b0bb25240
6 changed files with 106 additions and 19 deletions

View file

@ -497,10 +497,18 @@
"accessDenied": "Access Denied",
"accessDeniedDesc": "You don't have permission to view this skill",
"tabReadme": "README",
"tabOverview": "Overview",
"tabFiles": "Files",
"tabVersions": "Versions",
"noReadme": "No README",
"readmeUnavailable": "README is temporarily unavailable. Please try again later or re-upload this skill version.",
"documentationSource": "Source: {{path}}",
"documentationUnavailableTitle": "Documentation is unavailable",
"documentationUnavailable": "The documentation file could not be loaded. You can still inspect the package contents in the file list.",
"noDocumentationTitle": "No package documentation",
"noDocumentationDescription": "This skill version does not include a readable overview file.",
"noDocumentationHint": "Many skills only ship executable files. You can continue with the file list and version details below.",
"summaryLabel": "Summary",
"noFiles": "No files",
"noVersions": "No versions",
"fileCount": "{{count}} files",

View file

@ -497,10 +497,18 @@
"accessDenied": "访问被拒绝",
"accessDeniedDesc": "您没有权限查看该技能",
"tabReadme": "README",
"tabOverview": "概览",
"tabFiles": "文件",
"tabVersions": "版本",
"noReadme": "暂无 README",
"readmeUnavailable": "README 暂时不可用,请稍后重试或重新上传该技能版本。",
"documentationSource": "来源:{{path}}",
"documentationUnavailableTitle": "文档暂时不可用",
"documentationUnavailable": "当前无法读取这个技能版本的文档文件。你仍然可以在文件列表里查看包内容。",
"noDocumentationTitle": "这个版本没有概览文档",
"noDocumentationDescription": "该技能版本没有包含可直接展示的 README.md 或 SKILL.md。",
"noDocumentationHint": "很多技能包只包含可执行文件或配置文件,可以继续查看下方文件列表和版本信息。",
"summaryLabel": "摘要",
"noFiles": "暂无文件",
"noVersions": "暂无版本",
"fileCount": "{{count}} 个文件",

View file

@ -13,6 +13,7 @@ import { adminApi, ApiError, WEB_API_PREFIX } from '@/api/client'
import { useSubmitSkillReport } from '@/features/report/use-skill-reports'
import { formatLocalDateTime } from '@/shared/lib/date-time'
import { formatCompactCount } from '@/shared/lib/number-format'
import { resolveDocumentationFilePath } from '@/shared/lib/skill-documentation'
import { NamespaceBadge } from '@/shared/components/namespace-badge'
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/shared/ui/tabs'
import { Button } from '@/shared/ui/button'
@ -92,13 +93,16 @@ export function SkillDetailPage() {
const selectedVersion = skill?.latestVersion ?? versions?.[0]?.version
const selectedVersionEntry = versions?.find((version) => version.version === selectedVersion) ?? versions?.[0]
const { data: files } = useSkillFiles(namespace, slug, selectedVersion)
const { data: readme, error: readmeError } = useSkillReadme(namespace, slug, selectedVersion)
const documentationPath = resolveDocumentationFilePath(files)
const { data: readme, error: readmeError } = useSkillReadme(namespace, slug, selectedVersion, documentationPath)
const { data: diffSourceDetail } = useSkillVersionDetail(namespace, slug, diffSourceVersion ?? undefined)
const { data: diffCompareDetail } = useSkillVersionDetail(namespace, slug, diffCompareVersion ?? undefined)
const { data: diffSourceFiles } = useSkillFiles(namespace, slug, diffSourceVersion ?? undefined)
const { data: diffCompareFiles } = useSkillFiles(namespace, slug, diffCompareVersion ?? undefined)
const { data: diffSourceReadme } = useSkillReadme(namespace, slug, diffSourceVersion ?? undefined)
const { data: diffCompareReadme } = useSkillReadme(namespace, slug, diffCompareVersion ?? undefined)
const diffSourceDocumentationPath = resolveDocumentationFilePath(diffSourceFiles)
const diffCompareDocumentationPath = resolveDocumentationFilePath(diffCompareFiles)
const { data: diffSourceReadme } = useSkillReadme(namespace, slug, diffSourceVersion ?? undefined, diffSourceDocumentationPath)
const { data: diffCompareReadme } = useSkillReadme(namespace, slug, diffCompareVersion ?? undefined, diffCompareDocumentationPath)
const governanceVisible = hasRole('SKILL_ADMIN') || hasRole('SUPER_ADMIN')
const isPendingPreview = skill?.viewingVersionStatus === 'PENDING_REVIEW'
const canInteract = skill?.canInteract ?? true
@ -449,23 +453,43 @@ export function SkillDetailPage() {
<Tabs defaultValue="readme">
<TabsList>
<TabsTrigger value="readme">{t('skillDetail.tabReadme')}</TabsTrigger>
<TabsTrigger value="readme">{t('skillDetail.tabOverview')}</TabsTrigger>
<TabsTrigger value="files">{t('skillDetail.tabFiles')}</TabsTrigger>
<TabsTrigger value="versions">{t('skillDetail.tabVersions')}</TabsTrigger>
</TabsList>
<TabsContent value="readme" className="mt-6">
{readme ? (
<Card className="p-8">
<Card className="p-8 space-y-4">
{documentationPath ? (
<div className="text-xs uppercase tracking-[0.2em] text-muted-foreground">
{t('skillDetail.documentationSource', { path: documentationPath })}
</div>
) : null}
<MarkdownRenderer content={readme} />
</Card>
) : readmeError ? (
<Card className="p-8 text-muted-foreground text-center">
{t('skillDetail.readmeUnavailable')}
<Card className="p-8 text-center">
<div className="text-base font-semibold text-foreground">{t('skillDetail.documentationUnavailableTitle')}</div>
<p className="mt-2 text-sm text-muted-foreground">{t('skillDetail.documentationUnavailable')}</p>
</Card>
) : (
<Card className="p-8 text-muted-foreground text-center">
{t('skillDetail.noReadme')}
<Card className="p-8 space-y-4">
<div>
<div className="text-base font-semibold text-foreground">{t('skillDetail.noDocumentationTitle')}</div>
<p className="mt-2 text-sm text-muted-foreground">{t('skillDetail.noDocumentationDescription')}</p>
</div>
{skill.summary ? (
<div className="rounded-xl border border-border/60 bg-secondary/20 p-4">
<div className="text-xs uppercase tracking-[0.2em] text-muted-foreground">
{t('skillDetail.summaryLabel')}
</div>
<p className="mt-2 text-sm leading-6 text-foreground">{skill.summary}</p>
</div>
) : null}
<div className="text-sm text-muted-foreground">
{t('skillDetail.noDocumentationHint')}
</div>
</Card>
)}
</TabsContent>

View file

@ -39,13 +39,9 @@ async function getSkillVersionDetail(namespace: string, slug: string, version: s
return fetchJson<SkillVersionDetail>(`${WEB_API_PREFIX}/skills/${cleanNamespace}/${slug}/versions/${version}`)
}
async function getSkillReadme(namespace: string, slug: string, version: string): Promise<string> {
async function getSkillDocumentation(namespace: string, slug: string, version: string, path: string): Promise<string> {
const cleanNamespace = namespace.startsWith('@') ? namespace.slice(1) : namespace
try {
return await fetchText(`${WEB_API_PREFIX}/skills/${cleanNamespace}/${slug}/versions/${version}/file?path=SKILL.md`)
} catch {
return await fetchText(`${WEB_API_PREFIX}/skills/${cleanNamespace}/${slug}/versions/${version}/file?path=README.md`)
}
return fetchText(`${WEB_API_PREFIX}/skills/${cleanNamespace}/${slug}/versions/${version}/file?path=${encodeURIComponent(path)}`)
}
async function getMySkills(): Promise<SkillSummary[]> {
@ -144,11 +140,11 @@ export function useSkillFiles(namespace: string, slug: string, version?: string)
})
}
export function useSkillReadme(namespace: string, slug: string, version?: string) {
export function useSkillReadme(namespace: string, slug: string, version?: string, path?: string | null) {
return useQuery({
queryKey: ['skills', namespace, slug, 'versions', version, 'readme'],
queryFn: () => getSkillReadme(namespace, slug, version!),
enabled: !!namespace && !!slug && !!version,
queryKey: ['skills', namespace, slug, 'versions', version, 'readme', path],
queryFn: () => getSkillDocumentation(namespace, slug, version!, path!),
enabled: !!namespace && !!slug && !!version && !!path,
})
}

View file

@ -0,0 +1,33 @@
import { describe, expect, it } from 'vitest'
import { resolveDocumentationFilePath } from '@/shared/lib/skill-documentation'
import type { SkillFile } from '@/api/types'
function createFile(filePath: string): SkillFile {
return {
id: 1,
filePath,
fileSize: 1,
contentType: 'text/plain',
sha256: 'hash',
}
}
describe('resolveDocumentationFilePath', () => {
it('prefers README over SKILL markdown when both exist', () => {
const files = [createFile('SKILL.md'), createFile('README.md')]
expect(resolveDocumentationFilePath(files)).toBe('README.md')
})
it('falls back to SKILL markdown when readme is absent', () => {
const files = [createFile('SKILL.md'), createFile('_meta.json')]
expect(resolveDocumentationFilePath(files)).toBe('SKILL.md')
})
it('returns null when the package has no documentation files', () => {
const files = [createFile('_meta.json'), createFile('icon.png')]
expect(resolveDocumentationFilePath(files)).toBeNull()
})
})

View file

@ -0,0 +1,18 @@
import type { SkillFile } from '@/api/types'
const DOCUMENTATION_PREFERENCE = ['README.md', 'SKILL.md'] as const
export function resolveDocumentationFilePath(files?: SkillFile[] | null): string | null {
if (!files || files.length === 0) {
return null
}
const filePaths = new Set(files.map((file) => file.filePath))
for (const preferredPath of DOCUMENTATION_PREFERENCE) {
if (filePaths.has(preferredPath)) {
return preferredPath
}
}
return null
}