feat(skill): allow withdrawing pending submissions (#40)

This commit is contained in:
yun-zhi-ztl 2026-03-15 04:26:52 -07:00 committed by GitHub
parent 7908cbc842
commit 155a59790a
17 changed files with 372 additions and 8 deletions

View file

@ -1,9 +1,11 @@
package com.iflytek.skillhub.controller.portal;
import com.iflytek.skillhub.controller.BaseApiController;
import com.iflytek.skillhub.domain.audit.AuditLogService;
import com.iflytek.skillhub.domain.namespace.Namespace;
import com.iflytek.skillhub.domain.namespace.NamespaceRepository;
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
import com.iflytek.skillhub.domain.review.ReviewService;
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
import com.iflytek.skillhub.domain.skill.Skill;
import com.iflytek.skillhub.domain.skill.SkillRepository;
@ -32,17 +34,23 @@ public class SkillLifecycleController extends BaseApiController {
private final SkillRepository skillRepository;
private final SkillVersionRepository skillVersionRepository;
private final SkillGovernanceService skillGovernanceService;
private final ReviewService reviewService;
private final AuditLogService auditLogService;
public SkillLifecycleController(NamespaceRepository namespaceRepository,
SkillRepository skillRepository,
SkillVersionRepository skillVersionRepository,
SkillGovernanceService skillGovernanceService,
ReviewService reviewService,
AuditLogService auditLogService,
ApiResponseFactory responseFactory) {
super(responseFactory);
this.namespaceRepository = namespaceRepository;
this.skillRepository = skillRepository;
this.skillVersionRepository = skillVersionRepository;
this.skillGovernanceService = skillGovernanceService;
this.reviewService = reviewService;
this.auditLogService = auditLogService;
}
@PostMapping("/{namespace}/{slug}/archive")
@ -108,6 +116,31 @@ public class SkillLifecycleController extends BaseApiController {
new SkillLifecycleMutationResponse(skill.getId(), skillVersion.getId(), "DELETE_VERSION", version));
}
@PostMapping("/{namespace}/{slug}/versions/{version}/withdraw-review")
public ApiResponse<SkillLifecycleMutationResponse> withdrawReview(@PathVariable String namespace,
@PathVariable String slug,
@PathVariable String version,
@RequestAttribute("userId") String userId,
HttpServletRequest httpRequest) {
Skill skill = findSkill(namespace, slug);
SkillVersion skillVersion = skillVersionRepository.findBySkillIdAndVersion(skill.getId(), version)
.orElseThrow(() -> new DomainBadRequestException("error.skill.version.notFound", version));
reviewService.withdrawReview(skillVersion.getId(), userId);
auditLogService.record(
userId,
"REVIEW_WITHDRAW",
"SKILL_VERSION",
skillVersion.getId(),
null,
httpRequest.getRemoteAddr(),
httpRequest.getHeader("User-Agent"),
"{\"version\":\"" + version.replace("\"", "\\\"") + "\"}"
);
return ok("response.success.updated",
new SkillLifecycleMutationResponse(skill.getId(), skillVersion.getId(), "WITHDRAW_REVIEW", "DELETED"));
}
private Skill findSkill(String namespaceSlug, String skillSlug) {
String cleanNamespace = namespaceSlug.startsWith("@") ? namespaceSlug.substring(1) : namespaceSlug;
Namespace namespace = namespaceRepository.findBySlug(cleanNamespace)

View file

@ -79,6 +79,9 @@ error.skill.publish.package.invalid=Package validation failed: {0}
error.skill.publish.skillMd.notFound=SKILL.md not found
error.skill.publish.precheck.failed=Pre-publish validation failed: {0}
error.skill.publish.archived=Archived skill must be restored before publishing: {0}
review.withdraw.not_pending=Only pending review submissions can be withdrawn: {0}
review.withdraw.not_submitter=Only the submitter can withdraw this review
review_task.not_found_for_version=No pending review submission found for version: {0}
error.skill.publish.summary.tooLong=Skill description must not exceed {0} characters
error.skill.notFound=Skill not found: {0}
error.skill.access.denied=Access denied to skill: {0}

View file

@ -79,6 +79,9 @@ error.skill.publish.package.invalid=技能包校验失败:{0}
error.skill.publish.skillMd.notFound=未找到 SKILL.md
error.skill.publish.precheck.failed=预发布校验失败:{0}
error.skill.publish.archived=该技能已归档,请先恢复后再发布:{0}
review.withdraw.not_pending=只有待审核版本才能撤销审核:{0}
review.withdraw.not_submitter=只有提交人本人可以撤销此次审核
review_task.not_found_for_version=未找到该版本对应的待审核记录:{0}
error.skill.publish.summary.tooLong=技能描述长度不能超过 {0} 个字符
error.skill.notFound=未找到技能:{0}
error.skill.access.denied=没有权限访问技能:{0}

View file

@ -93,6 +93,28 @@ class TokenControllerTest {
.andExpect(jsonPath("$.msg").value("Token 名称最多 64 个字符"));
}
@Test
void create_rejectsDuplicateActiveNames() throws Exception {
PlatformPrincipal principal = new PlatformPrincipal(
"user-42", "tester", "tester@example.com", "", "github", Set.of("USER")
);
var auth = new UsernamePasswordAuthenticationToken(
principal, null, List.of(new SimpleGrantedAuthority("ROLE_USER"))
);
given(apiTokenService.createToken(anyString(), anyString(), anyString(), org.mockito.ArgumentMatchers.nullable(String.class)))
.willThrow(new DomainBadRequestException("error.token.name.duplicate"));
mockMvc.perform(post("/api/v1/tokens")
.with(authentication(auth))
.with(csrf())
.contentType("application/json")
.content("""
{"name":"cli"}
"""))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.msg").value("你已经有同名 Token"));
}
@Test
void create_passesExpirationToService() throws Exception {
PlatformPrincipal principal = new PlatformPrincipal(

View file

@ -13,10 +13,12 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
import com.iflytek.skillhub.TestRedisConfig;
import com.iflytek.skillhub.auth.device.DeviceAuthService;
import com.iflytek.skillhub.domain.audit.AuditLogService;
import com.iflytek.skillhub.domain.namespace.Namespace;
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
import com.iflytek.skillhub.domain.namespace.NamespaceRepository;
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
import com.iflytek.skillhub.domain.review.ReviewService;
import com.iflytek.skillhub.domain.skill.Skill;
import com.iflytek.skillhub.domain.skill.SkillRepository;
import com.iflytek.skillhub.domain.skill.SkillVersion;
@ -55,6 +57,12 @@ class SkillLifecycleControllerTest {
@MockBean
private SkillGovernanceService skillGovernanceService;
@MockBean
private ReviewService reviewService;
@MockBean
private AuditLogService auditLogService;
@MockBean
private NamespaceMemberRepository namespaceMemberRepository;
@ -139,6 +147,32 @@ class SkillLifecycleControllerTest {
.andExpect(jsonPath("$.data.status").value("1.0.0"));
}
@Test
void withdrawReview_returnsUnifiedEnvelope() throws Exception {
Namespace namespace = new Namespace("global", "Global", "owner");
setNamespaceId(namespace, 1L);
Skill skill = new Skill(1L, "demo-skill", "owner", SkillVisibility.PUBLIC);
setSkillId(skill, 1L);
SkillVersion version = new SkillVersion(1L, "1.0.0", "owner");
setSkillVersionId(version, 2L);
version.setStatus(SkillVersionStatus.PENDING_REVIEW);
given(namespaceRepository.findBySlug("global")).willReturn(java.util.Optional.of(namespace));
given(skillRepository.findByNamespaceIdAndSlug(1L, "demo-skill")).willReturn(java.util.Optional.of(skill));
given(skillVersionRepository.findBySkillIdAndVersion(1L, "1.0.0")).willReturn(java.util.Optional.of(version));
mockMvc.perform(post("/api/web/skills/global/demo-skill/versions/1.0.0/withdraw-review")
.requestAttr("userId", "usr_1")
.with(user("usr_1"))
.with(csrf()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.skillId").value(1))
.andExpect(jsonPath("$.data.versionId").value(2))
.andExpect(jsonPath("$.data.action").value("WITHDRAW_REVIEW"))
.andExpect(jsonPath("$.data.status").value("DELETED"));
}
private Skill skillWithStatus(Skill skill, com.iflytek.skillhub.domain.skill.SkillStatus status) {
skill.setStatus(status);
return skill;

View file

@ -1,12 +1,16 @@
package com.iflytek.skillhub.auth.token;
import com.iflytek.skillhub.auth.repository.ApiTokenRepository;
import com.iflytek.skillhub.auth.entity.ApiToken;
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.dao.DataIntegrityViolationException;
import java.time.LocalDateTime;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.assertj.core.api.Assertions.assertThat;
@ -83,4 +87,37 @@ class ApiTokenServiceTest {
verify(tokenRepo, never()).save(any());
}
@Test
void createToken_rejectsBlankNamesAfterTrimming() {
assertThatThrownBy(() -> service.createToken("user-1", " ", "[]"))
.isInstanceOf(DomainBadRequestException.class)
.hasMessageContaining("validation.token.name.notBlank");
verify(tokenRepo, never()).save(any());
}
@Test
void createToken_allowsReusingNameWhenPreviousTokenIsRevoked() {
when(tokenRepo.existsByUserIdAndRevokedAtIsNullAndNameIgnoreCase("user-1", "CLI"))
.thenReturn(false);
when(tokenRepo.save(any())).thenAnswer(invocation -> invocation.getArgument(0));
var result = service.createToken("user-1", " CLI ", "[]");
assertThat(result.entity().getName()).isEqualTo("CLI");
verify(tokenRepo).existsByUserIdAndRevokedAtIsNullAndNameIgnoreCase("user-1", "CLI");
verify(tokenRepo).save(any(ApiToken.class));
}
@Test
void createToken_translatesDatabaseConstraintViolationToDuplicateError() {
when(tokenRepo.existsByUserIdAndRevokedAtIsNullAndNameIgnoreCase("user-1", "CLI"))
.thenReturn(false);
when(tokenRepo.save(any())).thenThrow(new DataIntegrityViolationException("duplicate key"));
assertThatThrownBy(() -> service.createToken("user-1", "CLI", "[]"))
.isInstanceOf(DomainBadRequestException.class)
.hasMessageContaining("error.token.name.duplicate");
}
}

View file

@ -14,6 +14,7 @@ import com.iflytek.skillhub.domain.skill.SkillVersion;
import com.iflytek.skillhub.domain.skill.SkillVersionRepository;
import com.iflytek.skillhub.domain.skill.SkillVersionStatus;
import com.iflytek.skillhub.domain.skill.metadata.SkillMetadata;
import com.iflytek.skillhub.domain.skill.service.SkillGovernanceService;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Service;
@ -34,6 +35,7 @@ public class ReviewService {
private final ReviewPermissionChecker permissionChecker;
private final ApplicationEventPublisher eventPublisher;
private final ObjectMapper objectMapper;
private final SkillGovernanceService skillGovernanceService;
public ReviewService(ReviewTaskRepository reviewTaskRepository,
SkillVersionRepository skillVersionRepository,
@ -41,7 +43,8 @@ public class ReviewService {
NamespaceRepository namespaceRepository,
ReviewPermissionChecker permissionChecker,
ApplicationEventPublisher eventPublisher,
ObjectMapper objectMapper) {
ObjectMapper objectMapper,
SkillGovernanceService skillGovernanceService) {
this.reviewTaskRepository = reviewTaskRepository;
this.skillVersionRepository = skillVersionRepository;
this.skillRepository = skillRepository;
@ -49,6 +52,7 @@ public class ReviewService {
this.permissionChecker = permissionChecker;
this.eventPublisher = eventPublisher;
this.objectMapper = objectMapper;
this.skillGovernanceService = skillGovernanceService;
}
@Transactional
@ -202,8 +206,9 @@ public class ReviewService {
SkillVersion skillVersion = skillVersionRepository.findById(skillVersionId)
.orElseThrow(() -> new DomainNotFoundException("skill_version.not_found", skillVersionId));
skillVersion.setStatus(SkillVersionStatus.DRAFT);
skillVersionRepository.save(skillVersion);
Skill skill = skillRepository.findById(skillVersion.getSkillId())
.orElseThrow(() -> new DomainNotFoundException("skill.not_found", skillVersion.getSkillId()));
skillGovernanceService.withdrawPendingVersion(skill, skillVersion, userId);
}
public boolean canReviewNamespace(ReviewTask task,

View file

@ -10,6 +10,7 @@ public interface SkillRepository {
Optional<Skill> findByNamespaceIdAndSlug(Long namespaceId, String slug);
List<Skill> findByNamespaceIdAndStatus(Long namespaceId, SkillStatus status);
Skill save(Skill skill);
void delete(Skill skill);
List<Skill> findByOwnerId(String ownerId);
void incrementDownloadCount(Long skillId);
List<Skill> findBySlug(String slug);

View file

@ -142,6 +142,39 @@ public class SkillGovernanceService {
);
}
@Transactional
public boolean withdrawPendingVersion(Skill skill,
SkillVersion version,
String actorUserId) {
if (version.getStatus() != SkillVersionStatus.PENDING_REVIEW) {
throw new DomainBadRequestException("review.withdraw.not_pending", version.getId());
}
List<SkillFile> files = skillFileRepository.findByVersionId(version.getId());
if (!files.isEmpty()) {
objectStorageService.deleteObjects(files.stream().map(SkillFile::getStorageKey).toList());
}
objectStorageService.deleteObject(String.format("packages/%d/%d/bundle.zip", skill.getId(), version.getId()));
skillFileRepository.deleteByVersionId(version.getId());
skillVersionRepository.delete(version);
List<SkillVersion> remainingVersions = skillVersionRepository.findBySkillId(skill.getId()).stream()
.filter(existing -> !existing.getId().equals(version.getId()))
.toList();
if (remainingVersions.isEmpty()) {
skillRepository.delete(skill);
return true;
}
if (version.getId().equals(skill.getLatestVersionId())) {
skill.setLatestVersionId(null);
}
skill.setUpdatedBy(actorUserId);
skillRepository.save(skill);
return false;
}
@Transactional
public SkillVersion yankVersion(Long versionId, String actorUserId, String clientIp, String userAgent, String reason) {
SkillVersion version = skillVersionRepository.findById(versionId)

View file

@ -14,6 +14,7 @@ import com.iflytek.skillhub.domain.skill.SkillVersion;
import com.iflytek.skillhub.domain.skill.SkillVersionRepository;
import com.iflytek.skillhub.domain.skill.SkillVersionStatus;
import com.iflytek.skillhub.domain.skill.SkillVisibility;
import com.iflytek.skillhub.domain.skill.service.SkillGovernanceService;
import com.iflytek.skillhub.domain.skill.metadata.SkillMetadata;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Nested;
@ -43,6 +44,7 @@ class ReviewServiceTest {
@Mock private NamespaceRepository namespaceRepository;
@Mock private ReviewPermissionChecker permissionChecker;
@Mock private ApplicationEventPublisher eventPublisher;
@Mock private SkillGovernanceService skillGovernanceService;
private ReviewService reviewService;
@ -59,7 +61,7 @@ class ReviewServiceTest {
objectMapper = new ObjectMapper();
reviewService = new ReviewService(
reviewTaskRepository, skillVersionRepository, skillRepository,
namespaceRepository, permissionChecker, eventPublisher, objectMapper);
namespaceRepository, permissionChecker, eventPublisher, objectMapper, skillGovernanceService);
}
private SkillVersion createDraftSkillVersion() {
@ -414,16 +416,18 @@ class ReviewServiceTest {
void shouldWithdrawReviewSuccessfully() {
ReviewTask task = createPendingReviewTask();
SkillVersion sv = createPendingReviewSkillVersion();
Skill skill = createSkill();
when(reviewTaskRepository.findBySkillVersionIdAndStatus(SKILL_VERSION_ID, ReviewTaskStatus.PENDING))
.thenReturn(Optional.of(task));
when(skillVersionRepository.findById(SKILL_VERSION_ID)).thenReturn(Optional.of(sv));
when(skillRepository.findById(SKILL_ID)).thenReturn(Optional.of(skill));
when(skillGovernanceService.withdrawPendingVersion(skill, sv, USER_ID)).thenReturn(false);
reviewService.withdrawReview(SKILL_VERSION_ID, USER_ID);
verify(reviewTaskRepository).delete(task);
assertEquals(SkillVersionStatus.DRAFT, sv.getStatus());
verify(skillVersionRepository).save(sv);
verify(skillGovernanceService).withdrawPendingVersion(skill, sv, USER_ID);
}
@Test
@ -445,5 +449,42 @@ class ReviewServiceTest {
assertThrows(DomainForbiddenException.class,
() -> reviewService.withdrawReview(SKILL_VERSION_ID, otherUserId));
}
@Test
void shouldDeleteEntireSkillWhenOnlyPendingVersionExists() {
ReviewTask task = createPendingReviewTask();
SkillVersion sv = createPendingReviewSkillVersion();
Skill skill = createSkill();
when(reviewTaskRepository.findBySkillVersionIdAndStatus(SKILL_VERSION_ID, ReviewTaskStatus.PENDING))
.thenReturn(Optional.of(task));
when(skillVersionRepository.findById(SKILL_VERSION_ID)).thenReturn(Optional.of(sv));
when(skillRepository.findById(SKILL_ID)).thenReturn(Optional.of(skill));
when(skillGovernanceService.withdrawPendingVersion(skill, sv, USER_ID)).thenReturn(true);
reviewService.withdrawReview(SKILL_VERSION_ID, USER_ID);
verify(reviewTaskRepository).delete(task);
verify(skillGovernanceService).withdrawPendingVersion(skill, sv, USER_ID);
}
@Test
void shouldDeletePendingVersionAndKeepSkillWhenPublishedHistoryExists() {
ReviewTask task = createPendingReviewTask();
SkillVersion sv = createPendingReviewSkillVersion();
Skill skill = createSkill();
setField(skill, "latestVersionId", 99L);
when(reviewTaskRepository.findBySkillVersionIdAndStatus(SKILL_VERSION_ID, ReviewTaskStatus.PENDING))
.thenReturn(Optional.of(task));
when(skillVersionRepository.findById(SKILL_VERSION_ID)).thenReturn(Optional.of(sv));
when(skillRepository.findById(SKILL_ID)).thenReturn(Optional.of(skill));
when(skillGovernanceService.withdrawPendingVersion(skill, sv, USER_ID)).thenReturn(false);
reviewService.withdrawReview(SKILL_VERSION_ID, USER_ID);
verify(reviewTaskRepository).delete(task);
verify(skillGovernanceService).withdrawPendingVersion(skill, sv, USER_ID);
}
}
}

View file

@ -52,6 +52,11 @@ public class JpaSkillRepositoryAdapter implements SkillRepository {
return jpaDelegate.save(skill);
}
@Override
public void delete(Skill skill) {
jpaDelegate.delete(skill);
}
@Override
public List<Skill> findByOwnerId(String ownerId) {
return delegate.findByOwnerId(ownerId);

View file

@ -439,6 +439,14 @@ export const skillLifecycleApi = {
headers: await ensureCsrfHeaders(),
})
},
async withdrawReview(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)}/withdraw-review`, {
method: 'POST',
headers: await ensureCsrfHeaders(),
})
},
}
export const tokenApi = {

View file

@ -205,6 +205,12 @@
"unarchiveSuccessTitle": "Skill restored",
"unarchiveSuccessDescription": "\"{{skill}}\" has been restored and can publish new versions again.",
"unarchiveErrorTitle": "Failed to restore skill",
"withdrawReview": "Withdraw Review",
"withdrawConfirmTitle": "Withdraw upload",
"withdrawConfirmDescription": "After withdrawal, \"{{skill}}\" will no longer be reviewed and the pending version will be deleted.",
"withdrawSuccessTitle": "Upload withdrawn",
"withdrawSuccessDescription": "The pending version for \"{{skill}}\" has been withdrawn.",
"withdrawErrorTitle": "Failed to withdraw upload",
"emptyTitle": "No skills yet",
"emptyDescription": "Start publishing your first skill",
"publishSkill": "Publish Skill"
@ -399,6 +405,7 @@
"governance": "Governance",
"processing": "Processing...",
"archiveSkill": "Archive Skill",
"withdrawReview": "Withdraw Review",
"hideSkill": "Hide Skill",
"unhideSkill": "Unhide Skill",
"archiveConfirmTitle": "Archive skill",
@ -411,6 +418,11 @@
"unarchiveSuccessTitle": "Skill restored",
"unarchiveSuccessDescription": "\"{{skill}}\" has been restored.",
"unarchiveErrorTitle": "Failed to restore skill",
"withdrawReviewConfirmTitle": "Withdraw review",
"withdrawReviewConfirmDescription": "After withdrawal, version {{version}} will leave the review queue and be removed from this skill.",
"withdrawReviewSuccessTitle": "Review withdrawn",
"withdrawReviewSuccessDescription": "Version {{version}} has been withdrawn from review.",
"withdrawReviewErrorTitle": "Failed to withdraw review",
"deleteVersion": "Delete Version",
"deleteVersionConfirmTitle": "Delete version",
"deleteVersionConfirmDescription": "Version {{version}} cannot be recovered after deletion. Continue?",

View file

@ -205,6 +205,12 @@
"unarchiveSuccessTitle": "技能已恢复",
"unarchiveSuccessDescription": "“{{skill}}”已恢复,可继续发布新版本。",
"unarchiveErrorTitle": "恢复技能失败",
"withdrawReview": "撤销审核",
"withdrawConfirmTitle": "确认撤销上传",
"withdrawConfirmDescription": "撤销后“{{skill}}”将不再进入审核流程,当前待审核版本会被删除。",
"withdrawSuccessTitle": "已撤销上传",
"withdrawSuccessDescription": "“{{skill}}”的待审核版本已撤销。",
"withdrawErrorTitle": "撤销上传失败",
"emptyTitle": "还没有技能",
"emptyDescription": "开始发布你的第一个技能吧",
"publishSkill": "发布技能"
@ -399,6 +405,7 @@
"governance": "治理操作",
"processing": "处理中...",
"archiveSkill": "归档技能",
"withdrawReview": "撤销审核",
"hideSkill": "隐藏技能",
"unhideSkill": "恢复技能",
"archiveConfirmTitle": "确认归档技能",
@ -411,6 +418,11 @@
"unarchiveSuccessTitle": "技能已恢复",
"unarchiveSuccessDescription": "“{{skill}}”已恢复。",
"unarchiveErrorTitle": "恢复技能失败",
"withdrawReviewConfirmTitle": "确认撤销审核",
"withdrawReviewConfirmDescription": "撤销后,版本 {{version}} 将不再进入审核流程,并从当前技能中移除。",
"withdrawReviewSuccessTitle": "已撤销审核",
"withdrawReviewSuccessDescription": "版本 {{version}} 已撤销审核。",
"withdrawReviewErrorTitle": "撤销审核失败",
"deleteVersion": "删除版本",
"deleteVersionConfirmTitle": "确认删除版本",
"deleteVersionConfirmDescription": "版本 {{version}} 删除后无法恢复,确定继续吗?",

View file

@ -6,7 +6,7 @@ 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 { useArchiveSkill, useMySkills, useUnarchiveSkill } from '@/shared/hooks/use-skill-queries'
import { useArchiveSkill, useMySkills, useUnarchiveSkill, useWithdrawSkillReview } from '@/shared/hooks/use-skill-queries'
import { formatCompactCount } from '@/shared/lib/number-format'
import { toast } from '@/shared/lib/toast'
@ -15,9 +15,11 @@ export function MySkillsPage() {
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 [withdrawTarget, setWithdrawTarget] = useState<{ namespace: string; slug: string; name: string; version: string } | null>(null)
const { data: skills, isLoading } = useMySkills()
const archiveMutation = useArchiveSkill()
const unarchiveMutation = useUnarchiveSkill()
const withdrawMutation = useWithdrawSkillReview()
const handleSkillClick = (namespace: string, slug: string) => {
navigate({ to: `/space/${namespace}/${slug}` })
@ -89,6 +91,27 @@ export function MySkillsPage() {
}
}
const handleWithdrawSkill = async () => {
if (!withdrawTarget) {
return
}
try {
await withdrawMutation.mutateAsync({
namespace: withdrawTarget.namespace,
slug: withdrawTarget.slug,
version: withdrawTarget.version,
})
toast.success(
t('mySkills.withdrawSuccessTitle'),
t('mySkills.withdrawSuccessDescription', { skill: withdrawTarget.name }),
)
setWithdrawTarget(null)
} catch (error) {
toast.error(t('mySkills.withdrawErrorTitle'), error instanceof Error ? error.message : '')
throw error
}
}
if (isLoading) {
return (
<div className="space-y-4 animate-fade-up">
@ -151,7 +174,27 @@ export function MySkillsPage() {
</div>
</div>
<div className="flex items-center gap-2 pl-4">
{skill.status === 'ARCHIVED' ? (
{skill.latestVersionStatus === 'PENDING_REVIEW' && skill.latestVersion ? (
<Button
size="sm"
variant="outline"
onClick={(event) => {
event.stopPropagation()
const pendingVersion = skill.latestVersion
if (!pendingVersion) {
return
}
setWithdrawTarget({
namespace: skill.namespace,
slug: skill.slug,
name: skill.displayName,
version: pendingVersion,
})
}}
>
{t('mySkills.withdrawReview')}
</Button>
) : skill.status === 'ARCHIVED' ? (
<Button
size="sm"
variant="outline"
@ -227,6 +270,19 @@ export function MySkillsPage() {
confirmText={t('mySkills.unarchive')}
onConfirm={handleUnarchiveSkill}
/>
<ConfirmDialog
open={!!withdrawTarget}
onOpenChange={(open) => {
if (!open) {
setWithdrawTarget(null)
}
}}
title={t('mySkills.withdrawConfirmTitle')}
description={withdrawTarget ? t('mySkills.withdrawConfirmDescription', { skill: withdrawTarget.name }) : ''}
confirmText={t('mySkills.withdrawReview')}
onConfirm={handleWithdrawSkill}
/>
</div>
)
}

View file

@ -30,6 +30,7 @@ import {
useArchiveSkill,
useDeleteSkillVersion,
useUnarchiveSkill,
useWithdrawSkillReview,
} from '@/shared/hooks/use-skill-queries'
export function SkillDetailPage() {
@ -43,6 +44,7 @@ export function SkillDetailPage() {
const [archiveConfirmOpen, setArchiveConfirmOpen] = useState(false)
const [unarchiveConfirmOpen, setUnarchiveConfirmOpen] = useState(false)
const [deleteVersionTarget, setDeleteVersionTarget] = useState<string | null>(null)
const [withdrawVersionTarget, setWithdrawVersionTarget] = useState<string | null>(null)
const { namespace, slug } = useParams({ from: '/space/$namespace/$slug' })
const { user, hasRole } = useAuth()
@ -76,6 +78,7 @@ export function SkillDetailPage() {
const archiveMutation = useArchiveSkill()
const unarchiveMutation = useUnarchiveSkill()
const deleteVersionMutation = useDeleteSkillVersion()
const withdrawReviewMutation = useWithdrawSkillReview()
const reportMutation = useSubmitSkillReport(namespace, slug)
const handleDownload = () => {
@ -150,6 +153,7 @@ export function SkillDetailPage() {
}
const canDeleteVersion = (status?: string) => status === 'DRAFT' || status === 'REJECTED'
const canWithdrawVersion = (status?: string) => status === 'PENDING_REVIEW'
const handleArchive = async () => {
try {
@ -196,6 +200,24 @@ export function SkillDetailPage() {
}
}
const handleWithdrawVersion = async () => {
if (!withdrawVersionTarget) {
return
}
try {
await withdrawReviewMutation.mutateAsync({ namespace, slug, version: withdrawVersionTarget })
toast.success(
t('skillDetail.withdrawReviewSuccessTitle'),
t('skillDetail.withdrawReviewSuccessDescription', { version: withdrawVersionTarget }),
)
setWithdrawVersionTarget(null)
navigate({ to: '/dashboard/skills' })
} catch (error) {
toast.error(t('skillDetail.withdrawReviewErrorTitle'), error instanceof Error ? error.message : '')
throw error
}
}
if (isLoadingSkill) {
return (
<div className="space-y-6 animate-fade-up">
@ -323,6 +345,15 @@ export function SkillDetailPage() {
{t('skillDetail.deleteVersion')}
</Button>
)}
{skill.canManageLifecycle && canWithdrawVersion(version.status) && (
<Button
size="sm"
variant="outline"
onClick={() => setWithdrawVersionTarget(version.version)}
>
{t('skillDetail.withdrawReview')}
</Button>
)}
</div>
</div>
{version.changelog && (
@ -523,6 +554,19 @@ export function SkillDetailPage() {
variant="destructive"
onConfirm={handleDeleteVersion}
/>
<ConfirmDialog
open={!!withdrawVersionTarget}
onOpenChange={(open) => {
if (!open) {
setWithdrawVersionTarget(null)
}
}}
title={t('skillDetail.withdrawReviewConfirmTitle')}
description={withdrawVersionTarget ? t('skillDetail.withdrawReviewConfirmDescription', { version: withdrawVersionTarget }) : ''}
confirmText={t('skillDetail.withdrawReview')}
onConfirm={handleWithdrawVersion}
/>
</div>
)
}

View file

@ -218,3 +218,18 @@ export function useDeleteSkillVersion() {
},
})
}
export function useWithdrawSkillReview() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({ namespace, slug, version }: { namespace: string; slug: string; version: string }) =>
skillLifecycleApi.withdrawReview(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'] })
},
})
}