mirror of
https://github.com/iflytek/skillhub.git
synced 2026-09-09 22:31:14 +00:00
feat(skill): add lifecycle management UI
This commit is contained in:
parent
411e592362
commit
04eb468a19
12 changed files with 427 additions and 9 deletions
|
|
@ -8,6 +8,7 @@ public record SkillSummaryResponse(
|
|||
String slug,
|
||||
String displayName,
|
||||
String summary,
|
||||
String status,
|
||||
Long downloadCount,
|
||||
Integer starCount,
|
||||
BigDecimal ratingAvg,
|
||||
|
|
|
|||
|
|
@ -124,6 +124,7 @@ public class MySkillAppService {
|
|||
skill.getSlug(),
|
||||
skill.getDisplayName(),
|
||||
skill.getSummary(),
|
||||
skill.getStatus().name(),
|
||||
skill.getDownloadCount(),
|
||||
skill.getStarCount(),
|
||||
skill.getRatingAvg(),
|
||||
|
|
|
|||
|
|
@ -147,6 +147,7 @@ public class SkillSearchAppService {
|
|||
skill.getSlug(),
|
||||
skill.getDisplayName(),
|
||||
skill.getSummary(),
|
||||
skill.getStatus().name(),
|
||||
skill.getDownloadCount(),
|
||||
skill.getStarCount(),
|
||||
skill.getRatingAvg(),
|
||||
|
|
|
|||
|
|
@ -56,6 +56,7 @@ class ClawHubCompatControllerTest {
|
|||
"my-skill",
|
||||
"My Skill",
|
||||
"test summary",
|
||||
"ACTIVE",
|
||||
10L,
|
||||
5,
|
||||
BigDecimal.valueOf(4.5),
|
||||
|
|
|
|||
|
|
@ -75,6 +75,7 @@ class ClawHubRegistryControllerTest {
|
|||
"global-skill",
|
||||
"Global Skill",
|
||||
"global summary",
|
||||
"ACTIVE",
|
||||
10L,
|
||||
5,
|
||||
BigDecimal.ZERO,
|
||||
|
|
@ -89,6 +90,7 @@ class ClawHubRegistryControllerTest {
|
|||
"team-skill",
|
||||
"Team Skill",
|
||||
"team summary",
|
||||
"ACTIVE",
|
||||
20L,
|
||||
8,
|
||||
BigDecimal.ONE,
|
||||
|
|
|
|||
|
|
@ -412,6 +412,35 @@ export const accountApi = {
|
|||
},
|
||||
}
|
||||
|
||||
export const skillLifecycleApi = {
|
||||
async archiveSkill(namespace: string, slug: string, reason?: string): Promise<void> {
|
||||
const cleanNamespace = namespace.startsWith('@') ? namespace.slice(1) : namespace
|
||||
await fetchJson<void>(`${WEB_API_PREFIX}/skills/${cleanNamespace}/${slug}/archive`, {
|
||||
method: 'POST',
|
||||
headers: await ensureCsrfHeaders({
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
body: JSON.stringify(reason?.trim() ? { reason: reason.trim() } : {}),
|
||||
})
|
||||
},
|
||||
|
||||
async unarchiveSkill(namespace: string, slug: string): Promise<void> {
|
||||
const cleanNamespace = namespace.startsWith('@') ? namespace.slice(1) : namespace
|
||||
await fetchJson<void>(`${WEB_API_PREFIX}/skills/${cleanNamespace}/${slug}/unarchive`, {
|
||||
method: 'POST',
|
||||
headers: await ensureCsrfHeaders(),
|
||||
})
|
||||
},
|
||||
|
||||
async deleteVersion(namespace: string, slug: string, version: string): Promise<void> {
|
||||
const cleanNamespace = namespace.startsWith('@') ? namespace.slice(1) : namespace
|
||||
await fetchJson<void>(`${WEB_API_PREFIX}/skills/${cleanNamespace}/${slug}/versions/${encodeURIComponent(version)}`, {
|
||||
method: 'DELETE',
|
||||
headers: await ensureCsrfHeaders(),
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
export const tokenApi = {
|
||||
async getTokens(params?: { page?: number, size?: number }): Promise<{ items: ApiToken[], total: number, page: number, size: number }> {
|
||||
const page = await unwrap<{ items: ApiToken[], total: number, page: number, size: number }>(client.GET('/api/v1/tokens', {
|
||||
|
|
|
|||
|
|
@ -107,6 +107,7 @@ export interface SkillSummary {
|
|||
slug: string
|
||||
displayName: string
|
||||
summary?: string
|
||||
status?: string
|
||||
downloadCount: number
|
||||
starCount: number
|
||||
ratingAvg?: number
|
||||
|
|
|
|||
|
|
@ -190,8 +190,21 @@
|
|||
"title": "My Skills",
|
||||
"subtitle": "Manage your published skills",
|
||||
"publishNew": "Publish New Skill",
|
||||
"archive": "Archive",
|
||||
"unarchive": "Restore",
|
||||
"statusArchived": "Archived",
|
||||
"statusPendingReview": "Pending Review",
|
||||
"statusPublished": "Published",
|
||||
"archiveConfirmTitle": "Archive skill",
|
||||
"archiveConfirmDescription": "After archiving, regular users will no longer be able to view or download \"{{skill}}\". Continue?",
|
||||
"unarchiveConfirmTitle": "Restore skill",
|
||||
"unarchiveConfirmDescription": "\"{{skill}}\" will become visible again and can publish new versions after being restored.",
|
||||
"archiveSuccessTitle": "Skill archived",
|
||||
"archiveSuccessDescription": "\"{{skill}}\" has been archived.",
|
||||
"archiveErrorTitle": "Failed to archive skill",
|
||||
"unarchiveSuccessTitle": "Skill restored",
|
||||
"unarchiveSuccessDescription": "\"{{skill}}\" has been restored and can publish new versions again.",
|
||||
"unarchiveErrorTitle": "Failed to restore skill",
|
||||
"emptyTitle": "No skills yet",
|
||||
"emptyDescription": "Start publishing your first skill",
|
||||
"publishSkill": "Publish Skill"
|
||||
|
|
@ -372,10 +385,34 @@
|
|||
"loginToRate": "Login to star and rate",
|
||||
"install": "Install",
|
||||
"download": "Download",
|
||||
"lifecycle": "Lifecycle",
|
||||
"lifecycleHint": "You can archive this skill. Archived skills are hidden from regular users and cannot be downloaded.",
|
||||
"archivedPublishHint": "This skill is archived. Restore it before publishing a new version.",
|
||||
"archivedInstallHint": "This skill is archived and not available for public download.",
|
||||
"statusActive": "Active",
|
||||
"statusArchived": "Archived",
|
||||
"statusHidden": "Hidden",
|
||||
"governance": "Governance",
|
||||
"processing": "Processing...",
|
||||
"archiveSkill": "Archive Skill",
|
||||
"hideSkill": "Hide Skill",
|
||||
"unhideSkill": "Unhide Skill",
|
||||
"archiveConfirmTitle": "Archive skill",
|
||||
"archiveConfirmDescription": "After archiving, regular users will no longer be able to view or download \"{{skill}}\". Continue?",
|
||||
"unarchiveConfirmTitle": "Restore skill",
|
||||
"unarchiveConfirmDescription": "\"{{skill}}\" will become visible again and can publish new versions after being restored.",
|
||||
"archiveSuccessTitle": "Skill archived",
|
||||
"archiveSuccessDescription": "\"{{skill}}\" has been archived.",
|
||||
"archiveErrorTitle": "Failed to archive skill",
|
||||
"unarchiveSuccessTitle": "Skill restored",
|
||||
"unarchiveSuccessDescription": "\"{{skill}}\" has been restored.",
|
||||
"unarchiveErrorTitle": "Failed to restore skill",
|
||||
"deleteVersion": "Delete Version",
|
||||
"deleteVersionConfirmTitle": "Delete version",
|
||||
"deleteVersionConfirmDescription": "Version {{version}} cannot be recovered after deletion. Continue?",
|
||||
"deleteVersionSuccessTitle": "Version deleted",
|
||||
"deleteVersionSuccessDescription": "Version {{version}} has been deleted.",
|
||||
"deleteVersionErrorTitle": "Failed to delete version",
|
||||
"yankVersion": "Yank Current Version",
|
||||
"reportSkill": "Report Skill",
|
||||
"reportDialogTitle": "Report skill",
|
||||
|
|
|
|||
|
|
@ -190,8 +190,21 @@
|
|||
"title": "我的技能",
|
||||
"subtitle": "管理你发布的技能",
|
||||
"publishNew": "发布新技能",
|
||||
"archive": "归档",
|
||||
"unarchive": "恢复",
|
||||
"statusArchived": "已归档",
|
||||
"statusPendingReview": "审核中",
|
||||
"statusPublished": "已发布",
|
||||
"archiveConfirmTitle": "确认归档技能",
|
||||
"archiveConfirmDescription": "归档后普通用户将无法看到或下载“{{skill}}”,确定继续吗?",
|
||||
"unarchiveConfirmTitle": "确认恢复技能",
|
||||
"unarchiveConfirmDescription": "恢复后“{{skill}}”会重新对外可见,并允许继续发布新版本。",
|
||||
"archiveSuccessTitle": "技能已归档",
|
||||
"archiveSuccessDescription": "“{{skill}}”已归档。",
|
||||
"archiveErrorTitle": "归档技能失败",
|
||||
"unarchiveSuccessTitle": "技能已恢复",
|
||||
"unarchiveSuccessDescription": "“{{skill}}”已恢复,可继续发布新版本。",
|
||||
"unarchiveErrorTitle": "恢复技能失败",
|
||||
"emptyTitle": "还没有技能",
|
||||
"emptyDescription": "开始发布你的第一个技能吧",
|
||||
"publishSkill": "发布技能"
|
||||
|
|
@ -372,10 +385,34 @@
|
|||
"loginToRate": "登录后可以收藏和评分",
|
||||
"install": "安装",
|
||||
"download": "下载",
|
||||
"lifecycle": "生命周期管理",
|
||||
"lifecycleHint": "你可以归档这个技能,归档后普通用户将无法查看或下载。",
|
||||
"archivedPublishHint": "该技能已归档,请先恢复后再继续发布新版本。",
|
||||
"archivedInstallHint": "该技能已归档,普通用户不可下载。",
|
||||
"statusActive": "正常",
|
||||
"statusArchived": "已归档",
|
||||
"statusHidden": "已隐藏",
|
||||
"governance": "治理操作",
|
||||
"processing": "处理中...",
|
||||
"archiveSkill": "归档技能",
|
||||
"hideSkill": "隐藏技能",
|
||||
"unhideSkill": "恢复技能",
|
||||
"archiveConfirmTitle": "确认归档技能",
|
||||
"archiveConfirmDescription": "归档后普通用户将无法看到或下载“{{skill}}”,确定继续吗?",
|
||||
"unarchiveConfirmTitle": "确认恢复技能",
|
||||
"unarchiveConfirmDescription": "恢复后“{{skill}}”会重新对外可见,并允许继续发布新版本。",
|
||||
"archiveSuccessTitle": "技能已归档",
|
||||
"archiveSuccessDescription": "“{{skill}}”已归档。",
|
||||
"archiveErrorTitle": "归档技能失败",
|
||||
"unarchiveSuccessTitle": "技能已恢复",
|
||||
"unarchiveSuccessDescription": "“{{skill}}”已恢复。",
|
||||
"unarchiveErrorTitle": "恢复技能失败",
|
||||
"deleteVersion": "删除版本",
|
||||
"deleteVersionConfirmTitle": "确认删除版本",
|
||||
"deleteVersionConfirmDescription": "版本 {{version}} 删除后无法恢复,确定继续吗?",
|
||||
"deleteVersionSuccessTitle": "版本已删除",
|
||||
"deleteVersionSuccessDescription": "版本 {{version}} 已删除。",
|
||||
"deleteVersionErrorTitle": "删除版本失败",
|
||||
"yankVersion": "撤回当前版本",
|
||||
"reportSkill": "举报技能",
|
||||
"reportDialogTitle": "举报技能",
|
||||
|
|
|
|||
|
|
@ -1,22 +1,32 @@
|
|||
import { useState } from 'react'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Card } from '@/shared/ui/card'
|
||||
import { EmptyState } from '@/shared/components/empty-state'
|
||||
import { ConfirmDialog } from '@/shared/components/confirm-dialog'
|
||||
import { DashboardPageHeader } from '@/shared/components/dashboard-page-header'
|
||||
import { useMySkills } from '@/shared/hooks/use-skill-queries'
|
||||
import { useArchiveSkill, useMySkills, useUnarchiveSkill } from '@/shared/hooks/use-skill-queries'
|
||||
import { formatCompactCount } from '@/shared/lib/number-format'
|
||||
import { toast } from '@/shared/lib/toast'
|
||||
|
||||
export function MySkillsPage() {
|
||||
const navigate = useNavigate()
|
||||
const { t } = useTranslation()
|
||||
const [archiveTarget, setArchiveTarget] = useState<{ namespace: string; slug: string; name: string } | null>(null)
|
||||
const [unarchiveTarget, setUnarchiveTarget] = useState<{ namespace: string; slug: string; name: string } | null>(null)
|
||||
const { data: skills, isLoading } = useMySkills()
|
||||
const archiveMutation = useArchiveSkill()
|
||||
const unarchiveMutation = useUnarchiveSkill()
|
||||
|
||||
const handleSkillClick = (namespace: string, slug: string) => {
|
||||
navigate({ to: `/space/${namespace}/${slug}` })
|
||||
}
|
||||
|
||||
const resolveStatusLabel = (status?: string) => {
|
||||
if (status === 'ARCHIVED') {
|
||||
return t('mySkills.statusArchived')
|
||||
}
|
||||
if (status === 'PENDING_REVIEW') {
|
||||
return t('mySkills.statusPendingReview')
|
||||
}
|
||||
|
|
@ -27,6 +37,9 @@ export function MySkillsPage() {
|
|||
}
|
||||
|
||||
const resolveStatusClassName = (status?: string) => {
|
||||
if (status === 'ARCHIVED') {
|
||||
return 'bg-slate-500/10 text-slate-500 border-slate-500/20'
|
||||
}
|
||||
if (status === 'PENDING_REVIEW') {
|
||||
return 'bg-amber-500/10 text-amber-500 border-amber-500/20'
|
||||
}
|
||||
|
|
@ -36,6 +49,46 @@ export function MySkillsPage() {
|
|||
return 'bg-secondary/60 text-muted-foreground border-border/40'
|
||||
}
|
||||
|
||||
const handleArchiveSkill = async () => {
|
||||
if (!archiveTarget) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await archiveMutation.mutateAsync({
|
||||
namespace: archiveTarget.namespace,
|
||||
slug: archiveTarget.slug,
|
||||
})
|
||||
toast.success(
|
||||
t('mySkills.archiveSuccessTitle'),
|
||||
t('mySkills.archiveSuccessDescription', { skill: archiveTarget.name }),
|
||||
)
|
||||
setArchiveTarget(null)
|
||||
} catch (error) {
|
||||
toast.error(t('mySkills.archiveErrorTitle'), error instanceof Error ? error.message : '')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const handleUnarchiveSkill = async () => {
|
||||
if (!unarchiveTarget) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await unarchiveMutation.mutateAsync({
|
||||
namespace: unarchiveTarget.namespace,
|
||||
slug: unarchiveTarget.slug,
|
||||
})
|
||||
toast.success(
|
||||
t('mySkills.unarchiveSuccessTitle'),
|
||||
t('mySkills.unarchiveSuccessDescription', { skill: unarchiveTarget.name }),
|
||||
)
|
||||
setUnarchiveTarget(null)
|
||||
} catch (error) {
|
||||
toast.error(t('mySkills.unarchiveErrorTitle'), error instanceof Error ? error.message : '')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-4 animate-fade-up">
|
||||
|
|
@ -79,6 +132,11 @@ export function MySkillsPage() {
|
|||
{skill.latestVersion && (
|
||||
<span className="font-mono text-xs">v{skill.latestVersion}</span>
|
||||
)}
|
||||
{skill.status ? (
|
||||
<span className={`rounded-full border px-2.5 py-0.5 text-xs ${resolveStatusClassName(skill.status)}`}>
|
||||
{resolveStatusLabel(skill.status)}
|
||||
</span>
|
||||
) : null}
|
||||
{skill.latestVersionStatus ? (
|
||||
<span className={`rounded-full border px-2.5 py-0.5 text-xs ${resolveStatusClassName(skill.latestVersionStatus)}`}>
|
||||
{resolveStatusLabel(skill.latestVersionStatus)}
|
||||
|
|
@ -92,9 +150,42 @@ export function MySkillsPage() {
|
|||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<svg className="w-5 h-5 text-muted-foreground group-hover:text-primary transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
<div className="flex items-center gap-2 pl-4">
|
||||
{skill.status === 'ARCHIVED' ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
setUnarchiveTarget({
|
||||
namespace: skill.namespace,
|
||||
slug: skill.slug,
|
||||
name: skill.displayName,
|
||||
})
|
||||
}}
|
||||
>
|
||||
{t('mySkills.unarchive')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
setArchiveTarget({
|
||||
namespace: skill.namespace,
|
||||
slug: skill.slug,
|
||||
name: skill.displayName,
|
||||
})
|
||||
}}
|
||||
>
|
||||
{t('mySkills.archive')}
|
||||
</Button>
|
||||
)}
|
||||
<svg className="w-5 h-5 text-muted-foreground group-hover:text-primary transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
|
|
@ -110,6 +201,32 @@ export function MySkillsPage() {
|
|||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!archiveTarget}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setArchiveTarget(null)
|
||||
}
|
||||
}}
|
||||
title={t('mySkills.archiveConfirmTitle')}
|
||||
description={archiveTarget ? t('mySkills.archiveConfirmDescription', { skill: archiveTarget.name }) : ''}
|
||||
confirmText={t('mySkills.archive')}
|
||||
onConfirm={handleArchiveSkill}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!unarchiveTarget}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setUnarchiveTarget(null)
|
||||
}
|
||||
}}
|
||||
title={t('mySkills.unarchiveConfirmTitle')}
|
||||
description={unarchiveTarget ? t('mySkills.unarchiveConfirmDescription', { skill: unarchiveTarget.name }) : ''}
|
||||
confirmText={t('mySkills.unarchive')}
|
||||
onConfirm={handleUnarchiveSkill}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import { NamespaceBadge } from '@/shared/components/namespace-badge'
|
|||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/shared/ui/tabs'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Card } from '@/shared/ui/card'
|
||||
import { ConfirmDialog } from '@/shared/components/confirm-dialog'
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/shared/ui/dialog'
|
||||
import { Input } from '@/shared/ui/input'
|
||||
import { Textarea } from '@/shared/ui/textarea'
|
||||
|
|
@ -26,6 +27,9 @@ import {
|
|||
useSkillVersions,
|
||||
useSkillFiles,
|
||||
useSkillReadme,
|
||||
useArchiveSkill,
|
||||
useDeleteSkillVersion,
|
||||
useUnarchiveSkill,
|
||||
} from '@/shared/hooks/use-skill-queries'
|
||||
|
||||
export function SkillDetailPage() {
|
||||
|
|
@ -36,6 +40,9 @@ export function SkillDetailPage() {
|
|||
const [reportDialogOpen, setReportDialogOpen] = useState(false)
|
||||
const [reportReason, setReportReason] = useState('')
|
||||
const [reportDetails, setReportDetails] = useState('')
|
||||
const [archiveConfirmOpen, setArchiveConfirmOpen] = useState(false)
|
||||
const [unarchiveConfirmOpen, setUnarchiveConfirmOpen] = useState(false)
|
||||
const [deleteVersionTarget, setDeleteVersionTarget] = useState<string | null>(null)
|
||||
const { namespace, slug } = useParams({ from: '/space/$namespace/$slug' })
|
||||
const { user, hasRole } = useAuth()
|
||||
|
||||
|
|
@ -66,6 +73,9 @@ export function SkillDetailPage() {
|
|||
mutationFn: () => adminApi.yankVersion(latestVersion!.id),
|
||||
onSuccess: refreshSkill,
|
||||
})
|
||||
const archiveMutation = useArchiveSkill()
|
||||
const unarchiveMutation = useUnarchiveSkill()
|
||||
const deleteVersionMutation = useDeleteSkillVersion()
|
||||
const reportMutation = useSubmitSkillReport(namespace, slug)
|
||||
|
||||
const handleDownload = () => {
|
||||
|
|
@ -126,6 +136,66 @@ export function SkillDetailPage() {
|
|||
navigate({ to: '/search', search: { q: '', sort: 'relevance', page: 0, starredOnly: false } })
|
||||
}
|
||||
|
||||
const resolveSkillStatusLabel = (status?: string) => {
|
||||
if (status === 'ARCHIVED') {
|
||||
return t('skillDetail.statusArchived')
|
||||
}
|
||||
if (status === 'ACTIVE') {
|
||||
return t('skillDetail.statusActive')
|
||||
}
|
||||
if (status === 'HIDDEN') {
|
||||
return t('skillDetail.statusHidden')
|
||||
}
|
||||
return status ?? ''
|
||||
}
|
||||
|
||||
const canDeleteVersion = (status?: string) => status === 'DRAFT' || status === 'REJECTED'
|
||||
|
||||
const handleArchive = async () => {
|
||||
try {
|
||||
await archiveMutation.mutateAsync({ namespace, slug })
|
||||
toast.success(
|
||||
t('skillDetail.archiveSuccessTitle'),
|
||||
t('skillDetail.archiveSuccessDescription', { skill: skill?.displayName ?? slug }),
|
||||
)
|
||||
setArchiveConfirmOpen(false)
|
||||
} catch (error) {
|
||||
toast.error(t('skillDetail.archiveErrorTitle'), error instanceof Error ? error.message : '')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const handleUnarchive = async () => {
|
||||
try {
|
||||
await unarchiveMutation.mutateAsync({ namespace, slug })
|
||||
toast.success(
|
||||
t('skillDetail.unarchiveSuccessTitle'),
|
||||
t('skillDetail.unarchiveSuccessDescription', { skill: skill?.displayName ?? slug }),
|
||||
)
|
||||
setUnarchiveConfirmOpen(false)
|
||||
} catch (error) {
|
||||
toast.error(t('skillDetail.unarchiveErrorTitle'), error instanceof Error ? error.message : '')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteVersion = async () => {
|
||||
if (!deleteVersionTarget) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await deleteVersionMutation.mutateAsync({ namespace, slug, version: deleteVersionTarget })
|
||||
toast.success(
|
||||
t('skillDetail.deleteVersionSuccessTitle'),
|
||||
t('skillDetail.deleteVersionSuccessDescription', { version: deleteVersionTarget }),
|
||||
)
|
||||
setDeleteVersionTarget(null)
|
||||
} catch (error) {
|
||||
toast.error(t('skillDetail.deleteVersionErrorTitle'), error instanceof Error ? error.message : '')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoadingSkill) {
|
||||
return (
|
||||
<div className="space-y-6 animate-fade-up">
|
||||
|
|
@ -182,6 +252,11 @@ export function SkillDetailPage() {
|
|||
</Button>
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
<NamespaceBadge type="GLOBAL" name={namespace} />
|
||||
{skill.status && (
|
||||
<span className="rounded-full border border-border/60 bg-secondary/40 px-2.5 py-0.5 text-xs text-muted-foreground">
|
||||
{resolveSkillStatusLabel(skill.status)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<h1 className="text-4xl font-bold font-heading text-foreground">{skill.displayName}</h1>
|
||||
{skill.summary && (
|
||||
|
|
@ -229,10 +304,26 @@ export function SkillDetailPage() {
|
|||
<span className="px-2.5 py-0.5 rounded-full bg-primary/10 text-primary text-sm font-mono">
|
||||
v{version.version}
|
||||
</span>
|
||||
{version.status && (
|
||||
<span className="rounded-full border border-border/60 bg-secondary/40 px-2.5 py-0.5 text-xs text-muted-foreground">
|
||||
{version.status}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{formatLocalDateTime(version.publishedAt, i18n.language)}
|
||||
</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{formatLocalDateTime(version.publishedAt, i18n.language)}
|
||||
</span>
|
||||
{canDeleteVersion(version.status) && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setDeleteVersionTarget(version.version)}
|
||||
>
|
||||
{t('skillDetail.deleteVersion')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{version.changelog && (
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">{version.changelog}</p>
|
||||
|
|
@ -303,6 +394,9 @@ export function SkillDetailPage() {
|
|||
{skill.latestVersion && (
|
||||
<Card className="p-5 space-y-4">
|
||||
<div className="text-sm font-semibold font-heading text-foreground">{t('skillDetail.install')}</div>
|
||||
{skill.status === 'ARCHIVED' && (
|
||||
<p className="text-sm text-muted-foreground">{t('skillDetail.archivedInstallHint')}</p>
|
||||
)}
|
||||
<InstallCommand
|
||||
namespace={namespace}
|
||||
slug={slug}
|
||||
|
|
@ -316,7 +410,7 @@ export function SkillDetailPage() {
|
|||
variant="outline"
|
||||
size="lg"
|
||||
onClick={handleDownload}
|
||||
disabled={!latestVersion}
|
||||
disabled={!latestVersion || skill.status === 'ARCHIVED'}
|
||||
>
|
||||
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M9 19l3 3m0 0l3-3m-3 3V10" />
|
||||
|
|
@ -324,6 +418,26 @@ export function SkillDetailPage() {
|
|||
{t('skillDetail.download')}
|
||||
</Button>
|
||||
|
||||
{user && (
|
||||
<Card className="p-5 space-y-3">
|
||||
<div className="text-sm font-semibold font-heading text-foreground">{t('skillDetail.lifecycle')}</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{skill.status === 'ARCHIVED'
|
||||
? t('skillDetail.archivedPublishHint')
|
||||
: t('skillDetail.lifecycleHint')}
|
||||
</p>
|
||||
{skill.status === 'ARCHIVED' ? (
|
||||
<Button variant="outline" onClick={() => setUnarchiveConfirmOpen(true)} disabled={unarchiveMutation.isPending}>
|
||||
{unarchiveMutation.isPending ? t('skillDetail.processing') : t('skillDetail.unarchiveSkill')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="outline" onClick={() => setArchiveConfirmOpen(true)} disabled={archiveMutation.isPending}>
|
||||
{archiveMutation.isPending ? t('skillDetail.processing') : t('skillDetail.archiveSkill')}
|
||||
</Button>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{governanceVisible && (
|
||||
<Card className="p-5 space-y-3">
|
||||
<div className="text-sm font-semibold font-heading text-foreground">{t('skillDetail.governance')}</div>
|
||||
|
|
@ -377,6 +491,38 @@ export function SkillDetailPage() {
|
|||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<ConfirmDialog
|
||||
open={archiveConfirmOpen}
|
||||
onOpenChange={setArchiveConfirmOpen}
|
||||
title={t('skillDetail.archiveConfirmTitle')}
|
||||
description={t('skillDetail.archiveConfirmDescription', { skill: skill.displayName })}
|
||||
confirmText={t('skillDetail.archiveSkill')}
|
||||
onConfirm={handleArchive}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={unarchiveConfirmOpen}
|
||||
onOpenChange={setUnarchiveConfirmOpen}
|
||||
title={t('skillDetail.unarchiveConfirmTitle')}
|
||||
description={t('skillDetail.unarchiveConfirmDescription', { skill: skill.displayName })}
|
||||
confirmText={t('skillDetail.unarchiveSkill')}
|
||||
onConfirm={handleUnarchive}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!deleteVersionTarget}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setDeleteVersionTarget(null)
|
||||
}
|
||||
}}
|
||||
title={t('skillDetail.deleteVersionConfirmTitle')}
|
||||
description={deleteVersionTarget ? t('skillDetail.deleteVersionConfirmDescription', { version: deleteVersionTarget }) : ''}
|
||||
confirmText={t('skillDetail.deleteVersion')}
|
||||
variant="destructive"
|
||||
onConfirm={handleDeleteVersion}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import type { SkillSummary, SkillDetail, SkillVersion, SkillFile, SearchParams, PagedResponse, PublishResult, Namespace, NamespaceMember } from '@/api/types'
|
||||
import { fetchJson, fetchText, getCsrfHeaders, meApi, WEB_API_PREFIX } from '@/api/client'
|
||||
import { fetchJson, fetchText, getCsrfHeaders, meApi, skillLifecycleApi, WEB_API_PREFIX } from '@/api/client'
|
||||
|
||||
const PUBLISH_REQUEST_TIMEOUT_MS = 60_000
|
||||
|
||||
|
|
@ -170,3 +170,48 @@ export function usePublishSkill() {
|
|||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useArchiveSkill() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ namespace, slug, reason }: { namespace: string; slug: string; reason?: string }) =>
|
||||
skillLifecycleApi.archiveSkill(namespace, slug, reason),
|
||||
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'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useUnarchiveSkill() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ namespace, slug }: { namespace: string; slug: string }) =>
|
||||
skillLifecycleApi.unarchiveSkill(namespace, slug),
|
||||
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'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useDeleteSkillVersion() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ namespace, slug, version }: { namespace: string; slug: string; version: string }) =>
|
||||
skillLifecycleApi.deleteVersion(namespace, slug, version),
|
||||
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'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue