diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 7fcb02bf..06d8db58 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -447,6 +447,17 @@ export const skillLifecycleApi = { headers: await ensureCsrfHeaders(), }) }, + + async rereleaseVersion(namespace: string, slug: string, version: string, targetVersion: string): Promise { + const cleanNamespace = namespace.startsWith('@') ? namespace.slice(1) : namespace + await fetchJson(`${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 = { diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 13ffb46d..36a203eb 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -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 diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 56283497..0568cb92 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -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", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index ab37f248..34e840b8 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -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": "举报技能", diff --git a/web/src/pages/skill-detail.tsx b/web/src/pages/skill-detail.tsx index 7fe06f77..a82d0c82 100644 --- a/web/src/pages/skill-detail.tsx +++ b/web/src/pages/skill-detail.tsx @@ -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(null) const [withdrawVersionTarget, setWithdrawVersionTarget] = useState(null) + const [rereleaseTarget, setRereleaseTarget] = useState(null) + const [targetVersionInput, setTargetVersionInput] = useState('') + const [diffSourceVersion, setDiffSourceVersion] = useState(null) + const [diffCompareVersion, setDiffCompareVersion] = useState(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 (
@@ -331,11 +441,34 @@ export function SkillDetailPage() { {version.status} )} + {skill.latestVersion === version.version && ( + + {t('skillDetail.currentVersion')} + + )}
{formatLocalDateTime(version.publishedAt, i18n.language)} + {skill.canManageLifecycle && canRereleaseVersion(version.status) && ( + <> + + + + )} {skill.canManageLifecycle && canDeleteVersion(version.status) && ( + + + + + + { + if (!open) { + setDiffSourceVersion(null) + setDiffCompareVersion(null) + } + }} + > + + + {t('skillDetail.compareDialogTitle')} + + {diffSourceVersion && diffCompareVersion + ? t('skillDetail.compareDialogDescription', { source: diffSourceVersion, target: diffCompareVersion }) + : ''} + + +
+
+
+
{t('skillDetail.compareSourceLabel')}
+
v{diffSourceVersion}
+
+
+
{t('skillDetail.compareTargetLabel')}
+
v{diffCompareVersion}
+
+
+ +
+
{t('skillDetail.metadataChanges')}
+ {metadataDiffEntries.length > 0 ? ( +
+ {metadataDiffEntries.map((entry) => ( +
+
{entry.key}
+
+ {String(entry.source ?? '—')} → {String(entry.target ?? '—')} +
+
+ ))} +
+ ) : ( +
{t('skillDetail.noMetadataChanges')}
+ )} +
+ +
+
{t('skillDetail.readmeChange')}
+
+ {diffSourceReadme !== diffCompareReadme ? t('skillDetail.readmeChanged') : t('skillDetail.readmeUnchanged')} +
+
+ +
+
{t('skillDetail.fileChanges')}
+
+
+
{t('skillDetail.filesAdded')}
+
{fileDiffSummary.added.length}
+
+
+
{t('skillDetail.filesRemoved')}
+
{fileDiffSummary.removed.length}
+
+
+
{t('skillDetail.filesChanged')}
+
{fileDiffSummary.changed.length}
+
+
+
+
+ + + +
+
) } diff --git a/web/src/shared/hooks/use-skill-queries.ts b/web/src/shared/hooks/use-skill-queries.ts index d16dc584..ce2bf1b5 100644 --- a/web/src/shared/hooks/use-skill-queries.ts +++ b/web/src/shared/hooks/use-skill-queries.ts @@ -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(`${WEB_API_PREFIX}/skills/${cleanNamespace}/${slug}/versions/${version}/files`) } +async function getSkillVersionDetail(namespace: string, slug: string, version: string): Promise { + const cleanNamespace = namespace.startsWith('@') ? namespace.slice(1) : namespace + return fetchJson(`${WEB_API_PREFIX}/skills/${cleanNamespace}/${slug}/versions/${version}`) +} + async function getSkillReadme(namespace: string, slug: string, version: string): Promise { 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'] }) + }, + }) +}