feat(web): add skill version management UI

This commit is contained in:
yun-zhi-ztl 2026-03-15 19:48:29 +08:00
parent 4ad97aa55a
commit 3446740b5f
6 changed files with 361 additions and 1 deletions

View file

@ -447,6 +447,17 @@ export const skillLifecycleApi = {
headers: await ensureCsrfHeaders(),
})
},
async rereleaseVersion(namespace: string, slug: string, version: string, targetVersion: string): Promise<void> {
const cleanNamespace = namespace.startsWith('@') ? namespace.slice(1) : namespace
await fetchJson<void>(`${WEB_API_PREFIX}/skills/${cleanNamespace}/${slug}/versions/${encodeURIComponent(version)}/rerelease`, {
method: 'POST',
headers: await ensureCsrfHeaders({
'Content-Type': 'application/json',
}),
body: JSON.stringify({ targetVersion }),
})
},
}
export const tokenApi = {

View file

@ -145,6 +145,18 @@ export interface SkillVersion {
publishedAt: string
}
export interface SkillVersionDetail {
id: number
version: string
status: string
changelog?: string
fileCount: number
totalSize: number
publishedAt: string
parsedMetadataJson?: string
manifestJson?: string
}
export interface SkillFile {
id: number
filePath: string

View file

@ -429,6 +429,31 @@
"deleteVersionSuccessTitle": "Version deleted",
"deleteVersionSuccessDescription": "Version {{version}} has been deleted.",
"deleteVersionErrorTitle": "Failed to delete version",
"currentVersion": "Current",
"compareVersions": "Compare",
"compareDialogTitle": "Version comparison",
"compareDialogDescription": "Compare v{{source}} with v{{target}}.",
"compareSourceLabel": "Selected version",
"compareTargetLabel": "Compared with",
"versionCompareUnavailableTitle": "Not enough versions to compare",
"versionCompareUnavailableDescription": "Publish at least two versions before using version comparison.",
"metadataChanges": "Metadata changes",
"noMetadataChanges": "No metadata changes",
"readmeChange": "README change",
"readmeChanged": "README content changed",
"readmeUnchanged": "README content unchanged",
"fileChanges": "File changes",
"filesAdded": "Added",
"filesRemoved": "Removed",
"filesChanged": "Changed",
"rereleaseVersion": "Re-release",
"rereleaseDialogTitle": "Re-release from version",
"rereleaseDialogDescription": "Create a new published version based on v{{version}}.",
"rereleaseSourceVersion": "Source version",
"rereleaseTargetVersion": "New version",
"rereleaseSuccessTitle": "Version re-released",
"rereleaseSuccessDescription": "Created v{{target}} from v{{source}}.",
"rereleaseErrorTitle": "Failed to re-release version",
"yankVersion": "Yank Current Version",
"reportSkill": "Report Skill",
"reportDialogTitle": "Report skill",

View file

@ -429,6 +429,31 @@
"deleteVersionSuccessTitle": "版本已删除",
"deleteVersionSuccessDescription": "版本 {{version}} 已删除。",
"deleteVersionErrorTitle": "删除版本失败",
"currentVersion": "当前版本",
"compareVersions": "对比版本",
"compareDialogTitle": "版本对比",
"compareDialogDescription": "对比 v{{source}} 与 v{{target}}。",
"compareSourceLabel": "选中版本",
"compareTargetLabel": "对比版本",
"versionCompareUnavailableTitle": "暂无可对比版本",
"versionCompareUnavailableDescription": "至少发布两个版本后才能进行版本对比。",
"metadataChanges": "元数据变化",
"noMetadataChanges": "元数据没有变化",
"readmeChange": "README 变化",
"readmeChanged": "README 内容已变化",
"readmeUnchanged": "README 内容未变化",
"fileChanges": "文件变化",
"filesAdded": "新增",
"filesRemoved": "删除",
"filesChanged": "修改",
"rereleaseVersion": "重新发布",
"rereleaseDialogTitle": "基于旧版本重新发布",
"rereleaseDialogDescription": "基于 v{{version}} 创建一个新的已发布版本。",
"rereleaseSourceVersion": "来源版本",
"rereleaseTargetVersion": "新版本号",
"rereleaseSuccessTitle": "版本已重新发布",
"rereleaseSuccessDescription": "已基于 v{{source}} 创建新版本 v{{target}}。",
"rereleaseErrorTitle": "重新发布版本失败",
"yankVersion": "撤回当前版本",
"reportSkill": "举报技能",
"reportDialogTitle": "举报技能",

View file

@ -25,14 +25,37 @@ import { toast } from '@/shared/lib/toast'
import {
useSkillDetail,
useSkillVersions,
useSkillVersionDetail,
useSkillFiles,
useSkillReadme,
useArchiveSkill,
useDeleteSkillVersion,
useRereleaseSkillVersion,
useUnarchiveSkill,
useWithdrawSkillReview,
} from '@/shared/hooks/use-skill-queries'
function suggestNextVersion(version: string) {
const semverMatch = version.match(/^(\d+)\.(\d+)\.(\d+)$/)
if (semverMatch) {
const [, major, minor, patch] = semverMatch
return `${major}.${minor}.${Number.parseInt(patch, 10) + 1}`
}
return `${version}.1`
}
function parseMetadataJson(parsed?: string) {
if (!parsed) {
return {}
}
try {
const value = JSON.parse(parsed)
return typeof value === 'object' && value !== null ? value : {}
} catch {
return {}
}
}
export function SkillDetailPage() {
const { t, i18n } = useTranslation()
const navigate = useNavigate()
@ -45,6 +68,10 @@ export function SkillDetailPage() {
const [unarchiveConfirmOpen, setUnarchiveConfirmOpen] = useState(false)
const [deleteVersionTarget, setDeleteVersionTarget] = useState<string | null>(null)
const [withdrawVersionTarget, setWithdrawVersionTarget] = useState<string | null>(null)
const [rereleaseTarget, setRereleaseTarget] = useState<string | null>(null)
const [targetVersionInput, setTargetVersionInput] = useState('')
const [diffSourceVersion, setDiffSourceVersion] = useState<string | null>(null)
const [diffCompareVersion, setDiffCompareVersion] = useState<string | null>(null)
const { namespace, slug } = useParams({ from: '/space/$namespace/$slug' })
const { user, hasRole } = useAuth()
@ -53,6 +80,12 @@ export function SkillDetailPage() {
const latestVersion = versions?.[0]
const { data: files } = useSkillFiles(namespace, slug, latestVersion?.version)
const { data: readme } = useSkillReadme(namespace, slug, latestVersion?.version)
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 governanceVisible = hasRole('SKILL_ADMIN') || hasRole('SUPER_ADMIN')
const refreshSkill = () => {
@ -79,6 +112,7 @@ export function SkillDetailPage() {
const unarchiveMutation = useUnarchiveSkill()
const deleteVersionMutation = useDeleteSkillVersion()
const withdrawReviewMutation = useWithdrawSkillReview()
const rereleaseVersionMutation = useRereleaseSkillVersion()
const reportMutation = useSubmitSkillReport(namespace, slug)
const handleDownload = () => {
@ -154,6 +188,43 @@ export function SkillDetailPage() {
const canDeleteVersion = (status?: string) => status === 'DRAFT' || status === 'REJECTED'
const canWithdrawVersion = (status?: string) => status === 'PENDING_REVIEW'
const canRereleaseVersion = (status?: string) => status === 'PUBLISHED'
const metadataDiffEntries = (() => {
const source = parseMetadataJson(diffSourceDetail?.parsedMetadataJson)
const compare = parseMetadataJson(diffCompareDetail?.parsedMetadataJson)
const keys = Array.from(new Set([...Object.keys(source), ...Object.keys(compare)])).sort()
return keys
.filter((key) => JSON.stringify(source[key]) !== JSON.stringify(compare[key]))
.map((key) => ({
key,
source: source[key],
target: compare[key],
}))
})()
const fileDiffSummary = (() => {
const sourceMap = new Map((diffSourceFiles ?? []).map((file) => [file.filePath, file.sha256]))
const compareMap = new Map((diffCompareFiles ?? []).map((file) => [file.filePath, file.sha256]))
const added: string[] = []
const removed: string[] = []
const changed: string[] = []
for (const [path, hash] of sourceMap.entries()) {
if (!compareMap.has(path)) {
removed.push(path)
} else if (compareMap.get(path) !== hash) {
changed.push(path)
}
}
for (const path of compareMap.keys()) {
if (!sourceMap.has(path)) {
added.push(path)
}
}
return { added, removed, changed }
})()
const handleArchive = async () => {
try {
@ -218,6 +289,45 @@ export function SkillDetailPage() {
}
}
const handleOpenRerelease = (version: string) => {
setRereleaseTarget(version)
setTargetVersionInput(suggestNextVersion(version))
}
const handleRereleaseVersion = async () => {
if (!rereleaseTarget || !targetVersionInput.trim()) {
return
}
try {
await rereleaseVersionMutation.mutateAsync({
namespace,
slug,
version: rereleaseTarget,
targetVersion: targetVersionInput.trim(),
})
toast.success(
t('skillDetail.rereleaseSuccessTitle'),
t('skillDetail.rereleaseSuccessDescription', { source: rereleaseTarget, target: targetVersionInput.trim() }),
)
setRereleaseTarget(null)
setTargetVersionInput('')
} catch (error) {
toast.error(t('skillDetail.rereleaseErrorTitle'), error instanceof Error ? error.message : '')
throw error
}
}
const handleOpenDiff = (version: string) => {
const publishedVersions = versions?.filter((item) => item.status === 'PUBLISHED') ?? []
const compareVersion = publishedVersions.find((item) => item.version !== version)?.version ?? null
if (!compareVersion) {
toast.error(t('skillDetail.versionCompareUnavailableTitle'), t('skillDetail.versionCompareUnavailableDescription'))
return
}
setDiffSourceVersion(version)
setDiffCompareVersion(compareVersion)
}
if (isLoadingSkill) {
return (
<div className="space-y-6 animate-fade-up">
@ -331,11 +441,34 @@ export function SkillDetailPage() {
{version.status}
</span>
)}
{skill.latestVersion === version.version && (
<span className="rounded-full bg-primary px-2.5 py-0.5 text-xs text-primary-foreground">
{t('skillDetail.currentVersion')}
</span>
)}
</span>
<div className="flex items-center gap-3">
<span className="text-sm text-muted-foreground">
{formatLocalDateTime(version.publishedAt, i18n.language)}
</span>
{skill.canManageLifecycle && canRereleaseVersion(version.status) && (
<>
<Button
size="sm"
variant="outline"
onClick={() => handleOpenDiff(version.version)}
>
{t('skillDetail.compareVersions')}
</Button>
<Button
size="sm"
variant="outline"
onClick={() => handleOpenRerelease(version.version)}
>
{t('skillDetail.rereleaseVersion')}
</Button>
</>
)}
{skill.canManageLifecycle && canDeleteVersion(version.status) && (
<Button
size="sm"
@ -567,6 +700,132 @@ export function SkillDetailPage() {
confirmText={t('skillDetail.withdrawReview')}
onConfirm={handleWithdrawVersion}
/>
<Dialog
open={!!rereleaseTarget}
onOpenChange={(open) => {
if (!open) {
setRereleaseTarget(null)
setTargetVersionInput('')
}
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('skillDetail.rereleaseDialogTitle')}</DialogTitle>
<DialogDescription>
{rereleaseTarget ? t('skillDetail.rereleaseDialogDescription', { version: rereleaseTarget }) : ''}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<div className="text-sm text-muted-foreground">{t('skillDetail.rereleaseSourceVersion')}</div>
<div className="rounded-lg border border-border/60 bg-secondary/30 px-3 py-2 font-mono text-sm text-foreground">
{rereleaseTarget ? `v${rereleaseTarget}` : '—'}
</div>
</div>
<div className="space-y-2">
<div className="text-sm text-muted-foreground">{t('skillDetail.rereleaseTargetVersion')}</div>
<Input value={targetVersionInput} onChange={(event) => setTargetVersionInput(event.target.value)} />
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setRereleaseTarget(null)}>
{t('dialog.cancel')}
</Button>
<Button onClick={handleRereleaseVersion} disabled={rereleaseVersionMutation.isPending || !targetVersionInput.trim()}>
{rereleaseVersionMutation.isPending ? t('skillDetail.processing') : t('skillDetail.rereleaseVersion')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog
open={!!diffSourceVersion && !!diffCompareVersion}
onOpenChange={(open) => {
if (!open) {
setDiffSourceVersion(null)
setDiffCompareVersion(null)
}
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('skillDetail.compareDialogTitle')}</DialogTitle>
<DialogDescription>
{diffSourceVersion && diffCompareVersion
? t('skillDetail.compareDialogDescription', { source: diffSourceVersion, target: diffCompareVersion })
: ''}
</DialogDescription>
</DialogHeader>
<div className="space-y-5">
<div className="grid grid-cols-2 gap-4 text-sm">
<div className="rounded-lg border border-border/60 p-3">
<div className="text-muted-foreground">{t('skillDetail.compareSourceLabel')}</div>
<div className="mt-1 font-mono text-foreground">v{diffSourceVersion}</div>
</div>
<div className="rounded-lg border border-border/60 p-3">
<div className="text-muted-foreground">{t('skillDetail.compareTargetLabel')}</div>
<div className="mt-1 font-mono text-foreground">v{diffCompareVersion}</div>
</div>
</div>
<div className="space-y-2">
<div className="text-sm font-semibold text-foreground">{t('skillDetail.metadataChanges')}</div>
{metadataDiffEntries.length > 0 ? (
<div className="space-y-2">
{metadataDiffEntries.map((entry) => (
<div key={entry.key} className="rounded-lg border border-border/60 p-3 text-sm">
<div className="font-medium text-foreground">{entry.key}</div>
<div className="mt-1 text-muted-foreground">
{String(entry.source ?? '—')} → {String(entry.target ?? '—')}
</div>
</div>
))}
</div>
) : (
<div className="text-sm text-muted-foreground">{t('skillDetail.noMetadataChanges')}</div>
)}
</div>
<div className="space-y-2">
<div className="text-sm font-semibold text-foreground">{t('skillDetail.readmeChange')}</div>
<div className="text-sm text-muted-foreground">
{diffSourceReadme !== diffCompareReadme ? t('skillDetail.readmeChanged') : t('skillDetail.readmeUnchanged')}
</div>
</div>
<div className="space-y-2">
<div className="text-sm font-semibold text-foreground">{t('skillDetail.fileChanges')}</div>
<div className="grid grid-cols-3 gap-3 text-sm">
<div className="rounded-lg border border-border/60 p-3">
<div className="text-muted-foreground">{t('skillDetail.filesAdded')}</div>
<div className="mt-1 font-semibold text-foreground">{fileDiffSummary.added.length}</div>
</div>
<div className="rounded-lg border border-border/60 p-3">
<div className="text-muted-foreground">{t('skillDetail.filesRemoved')}</div>
<div className="mt-1 font-semibold text-foreground">{fileDiffSummary.removed.length}</div>
</div>
<div className="rounded-lg border border-border/60 p-3">
<div className="text-muted-foreground">{t('skillDetail.filesChanged')}</div>
<div className="mt-1 font-semibold text-foreground">{fileDiffSummary.changed.length}</div>
</div>
</div>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => {
setDiffSourceVersion(null)
setDiffCompareVersion(null)
}}
>
{t('dialog.close')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}

View file

@ -1,5 +1,5 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import type { SkillSummary, SkillDetail, SkillVersion, SkillFile, SearchParams, PagedResponse, PublishResult, Namespace, NamespaceMember } from '@/api/types'
import type { SkillSummary, SkillDetail, SkillVersion, SkillVersionDetail, SkillFile, SearchParams, PagedResponse, PublishResult, Namespace, NamespaceMember } from '@/api/types'
import { fetchJson, fetchText, getCsrfHeaders, meApi, skillLifecycleApi, WEB_API_PREFIX } from '@/api/client'
const PUBLISH_REQUEST_TIMEOUT_MS = 60_000
@ -34,6 +34,11 @@ async function getSkillFiles(namespace: string, slug: string, version: string):
return fetchJson<SkillFile[]>(`${WEB_API_PREFIX}/skills/${cleanNamespace}/${slug}/versions/${version}/files`)
}
async function getSkillVersionDetail(namespace: string, slug: string, version: string): Promise<SkillVersionDetail> {
const cleanNamespace = namespace.startsWith('@') ? namespace.slice(1) : namespace
return fetchJson<SkillVersionDetail>(`${WEB_API_PREFIX}/skills/${cleanNamespace}/${slug}/versions/${version}`)
}
async function getSkillReadme(namespace: string, slug: string, version: string): Promise<string> {
const cleanNamespace = namespace.startsWith('@') ? namespace.slice(1) : namespace
try {
@ -122,6 +127,14 @@ export function useSkillReadme(namespace: string, slug: string, version?: string
})
}
export function useSkillVersionDetail(namespace: string, slug: string, version?: string) {
return useQuery({
queryKey: ['skills', namespace, slug, 'versions', version, 'detail'],
queryFn: () => getSkillVersionDetail(namespace, slug, version!),
enabled: !!namespace && !!slug && !!version,
})
}
export function useMySkills() {
return useQuery({
queryKey: ['skills', 'my'],
@ -233,3 +246,18 @@ export function useWithdrawSkillReview() {
},
})
}
export function useRereleaseSkillVersion() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({ namespace, slug, version, targetVersion }: { namespace: string; slug: string; version: string; targetVersion: string }) =>
skillLifecycleApi.rereleaseVersion(namespace, slug, version, targetVersion),
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({ queryKey: ['skills', 'my'] })
queryClient.invalidateQueries({ queryKey: ['skills', variables.namespace, variables.slug] })
queryClient.invalidateQueries({ queryKey: ['skills', variables.namespace, variables.slug, 'versions'] })
queryClient.invalidateQueries({ queryKey: ['skills'] })
},
})
}