diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/ReviewController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/ReviewController.java index 9ec3ad3f..8923664b 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/ReviewController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/ReviewController.java @@ -8,14 +8,14 @@ import com.iflytek.skillhub.dto.ApiResponseFactory; import com.iflytek.skillhub.dto.PageResponse; import com.iflytek.skillhub.dto.ReviewActionRequest; import com.iflytek.skillhub.dto.ReviewSkillDetailResponse; -import com.iflytek.skillhub.dto.ReviewProgressResponse; +import com.iflytek.skillhub.dto.ReviewProgressPageResponse; import com.iflytek.skillhub.dto.ReviewTaskRequest; import com.iflytek.skillhub.dto.ReviewTaskResponse; import com.iflytek.skillhub.service.AuditRequestContext; import com.iflytek.skillhub.service.GovernanceWorkflowAppService; import jakarta.servlet.http.HttpServletRequest; -import java.util.Map; import java.util.List; +import java.util.Map; import org.springframework.core.io.InputStreamResource; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; @@ -139,7 +139,7 @@ public class ReviewController extends BaseApiController { } @GetMapping("/my-progress") - public ApiResponse> listMyProgress( + public ApiResponse listMyProgress( @RequestParam(required = false) String status, @RequestParam(defaultValue = "") String q, @RequestParam(defaultValue = "0") int page, @@ -158,6 +158,18 @@ public class ReviewController extends BaseApiController { return ok("response.success.read", governanceWorkflowAppService.listMyReviewAttempts(id, userId)); } + @GetMapping("/{id}/attempts") + public ApiResponse> listReviewAttempts( + @PathVariable Long id, + @RequestAttribute("userId") String userId, + @RequestAttribute(value = "userNsRoles", required = false) + Map userNsRoles) { + return ok( + "response.success.read", + governanceWorkflowAppService.listReviewAttempts(id, userId, userNsRoles) + ); + } + @GetMapping("/{id}") public ApiResponse getReviewDetail(@PathVariable Long id, @RequestAttribute("userId") String userId, diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ReviewProgressPageResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ReviewProgressPageResponse.java new file mode 100644 index 00000000..dfae6715 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ReviewProgressPageResponse.java @@ -0,0 +1,14 @@ +package com.iflytek.skillhub.dto; + +import java.util.List; + +/** + * Author-facing review progress page with search-scoped current-status totals. + */ +public record ReviewProgressPageResponse( + List items, + long total, + int page, + int size, + ReviewProgressStatusCounts statusCounts +) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ReviewProgressStatusCounts.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ReviewProgressStatusCounts.java new file mode 100644 index 00000000..a94361b8 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ReviewProgressStatusCounts.java @@ -0,0 +1,10 @@ +package com.iflytek.skillhub.dto; + +/** + * Current review-status totals for the author's grouped skill-version progress. + */ +public record ReviewProgressStatusCounts( + long pending, + long approved, + long rejected +) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/JpaReviewProgressQueryRepository.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/JpaReviewProgressQueryRepository.java index b3b72ce3..f3ca5fee 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/JpaReviewProgressQueryRepository.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/JpaReviewProgressQueryRepository.java @@ -1,8 +1,9 @@ package com.iflytek.skillhub.repository; import com.iflytek.skillhub.domain.review.ReviewTaskStatus; -import com.iflytek.skillhub.dto.PageResponse; +import com.iflytek.skillhub.dto.ReviewProgressPageResponse; import com.iflytek.skillhub.dto.ReviewProgressResponse; +import com.iflytek.skillhub.dto.ReviewProgressStatusCounts; import jakarta.persistence.EntityManager; import jakarta.persistence.Query; import java.sql.Timestamp; @@ -22,7 +23,7 @@ import org.springframework.transaction.annotation.Transactional; @Repository public class JpaReviewProgressQueryRepository implements ReviewProgressQueryRepository { - private static final String MY_PROGRESS_SQL = """ + private static final String RANKED_CTE = """ WITH ranked AS ( SELECT task.id, task.skill_id, @@ -45,8 +46,10 @@ public class JpaReviewProgressQueryRepository implements ReviewProgressQueryRepo SELECT * FROM ranked WHERE attempt_rank = 1 - AND (:status = '' OR status = :status) ) + """; + + private static final String MY_PROGRESS_SQL = RANKED_CTE + """ SELECT latest.id, latest.skill_id, namespace.slug, @@ -56,16 +59,31 @@ public class JpaReviewProgressQueryRepository implements ReviewProgressQueryRepo latest.review_comment, latest.submitted_at, latest.reviewed_at, - latest.attempt_count, - COUNT(*) OVER () AS total_groups + latest.attempt_count + FROM latest + JOIN skill ON skill.id = latest.skill_id + JOIN namespace ON namespace.id = latest.namespace_id + WHERE ( + :query = '' + OR LOWER(skill.slug) LIKE :queryPattern + OR LOWER(namespace.slug) LIKE :queryPattern + ) + AND (:status = '' OR latest.status = :status) + ORDER BY latest.submitted_at DESC, latest.id DESC + OFFSET :offset ROWS FETCH NEXT :size ROWS ONLY + """; + + private static final String MY_PROGRESS_SUMMARY_SQL = RANKED_CTE + """ + SELECT COUNT(*) FILTER (WHERE :status = '' OR latest.status = :status) AS filtered_total, + COUNT(*) FILTER (WHERE latest.status = 'PENDING') AS pending_count, + COUNT(*) FILTER (WHERE latest.status = 'APPROVED') AS approved_count, + COUNT(*) FILTER (WHERE latest.status = 'REJECTED') AS rejected_count FROM latest JOIN skill ON skill.id = latest.skill_id JOIN namespace ON namespace.id = latest.namespace_id WHERE :query = '' OR LOWER(skill.slug) LIKE :queryPattern OR LOWER(namespace.slug) LIKE :queryPattern - ORDER BY latest.submitted_at DESC, latest.id DESC - OFFSET :offset ROWS FETCH NEXT :size ROWS ONLY """; private final EntityManager entityManager; @@ -76,26 +94,54 @@ public class JpaReviewProgressQueryRepository implements ReviewProgressQueryRepo @Override @Transactional(readOnly = true) - public PageResponse findMyProgress( + public ReviewProgressPageResponse findMyProgress( String userId, ReviewTaskStatus status, String query, int page, int size) { String normalizedQuery = query == null ? "" : query.trim().toLowerCase(java.util.Locale.ROOT); - Query nativeQuery = entityManager.createNativeQuery(MY_PROGRESS_SQL) - .setParameter("userId", userId) - .setParameter("status", status != null ? status.name() : "") - .setParameter("query", normalizedQuery) - .setParameter("queryPattern", "%" + normalizedQuery + "%") + String statusName = status != null ? status.name() : ""; + String queryPattern = "%" + normalizedQuery + "%"; + Query nativeQuery = bindFilters( + entityManager.createNativeQuery(MY_PROGRESS_SQL), + userId, + statusName, + normalizedQuery, + queryPattern) .setParameter("offset", page * size) .setParameter("size", size); + Query summaryQuery = bindFilters( + entityManager.createNativeQuery(MY_PROGRESS_SUMMARY_SQL), + userId, + statusName, + normalizedQuery, + queryPattern); @SuppressWarnings("unchecked") List rows = nativeQuery.getResultList(); List items = rows.stream().map(this::mapRow).toList(); - long total = rows.isEmpty() ? 0 : number(rows.get(0)[10]).longValue(); - return new PageResponse<>(items, total, page, size); + Object[] summary = (Object[]) summaryQuery.getSingleResult(); + long total = number(summary[0]).longValue(); + ReviewProgressStatusCounts statusCounts = new ReviewProgressStatusCounts( + number(summary[1]).longValue(), + number(summary[2]).longValue(), + number(summary[3]).longValue() + ); + return new ReviewProgressPageResponse(items, total, page, size, statusCounts); + } + + private Query bindFilters( + Query query, + String userId, + String status, + String normalizedQuery, + String queryPattern) { + return query + .setParameter("userId", userId) + .setParameter("status", status) + .setParameter("query", normalizedQuery) + .setParameter("queryPattern", queryPattern); } private ReviewProgressResponse mapRow(Object[] row) { diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/ReviewProgressQueryRepository.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/ReviewProgressQueryRepository.java index a6982dd0..535cefbe 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/ReviewProgressQueryRepository.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/ReviewProgressQueryRepository.java @@ -1,15 +1,14 @@ package com.iflytek.skillhub.repository; import com.iflytek.skillhub.domain.review.ReviewTaskStatus; -import com.iflytek.skillhub.dto.PageResponse; -import com.iflytek.skillhub.dto.ReviewProgressResponse; +import com.iflytek.skillhub.dto.ReviewProgressPageResponse; /** * Query seam for author-facing review progress grouped by skill version. */ public interface ReviewProgressQueryRepository { - PageResponse findMyProgress( + ReviewProgressPageResponse findMyProgress( String userId, ReviewTaskStatus status, String query, diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/GovernanceWorkflowAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/GovernanceWorkflowAppService.java index 087842ca..ae578ae8 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/GovernanceWorkflowAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/GovernanceWorkflowAppService.java @@ -8,7 +8,7 @@ import com.iflytek.skillhub.dto.NamespaceResponse; import com.iflytek.skillhub.dto.PageResponse; import com.iflytek.skillhub.dto.PromotionResponseDto; import com.iflytek.skillhub.dto.ReviewSkillDetailResponse; -import com.iflytek.skillhub.dto.ReviewProgressResponse; +import com.iflytek.skillhub.dto.ReviewProgressPageResponse; import com.iflytek.skillhub.dto.ReviewTaskResponse; import com.iflytek.skillhub.dto.SkillLifecycleMutationResponse; import com.iflytek.skillhub.dto.SkillVersionRereleaseRequest; @@ -95,7 +95,7 @@ public class GovernanceWorkflowAppService { return reviewPortalAppService.listMySubmissions(page, size, userId); } - public PageResponse listMyReviewProgress( + public ReviewProgressPageResponse listMyReviewProgress( String status, String query, int page, @@ -108,6 +108,13 @@ public class GovernanceWorkflowAppService { return reviewPortalAppService.listMyAttempts(reviewTaskId, userId); } + public List listReviewAttempts( + Long reviewTaskId, + String userId, + Map userNsRoles) { + return reviewPortalAppService.listReviewAttempts(reviewTaskId, userId, userNsRoles); + } + public ReviewTaskResponse getReviewDetail(Long reviewTaskId, String userId, Map userNsRoles) { diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/ReviewPortalAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/ReviewPortalAppService.java index 1a4ad7a6..6bd1a64e 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/ReviewPortalAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/ReviewPortalAppService.java @@ -13,7 +13,7 @@ import com.iflytek.skillhub.domain.review.ReviewTaskStatus; import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException; import com.iflytek.skillhub.dto.PageResponse; -import com.iflytek.skillhub.dto.ReviewProgressResponse; +import com.iflytek.skillhub.dto.ReviewProgressPageResponse; import com.iflytek.skillhub.dto.ReviewTaskResponse; import com.iflytek.skillhub.observability.RequestIdAccessor; import com.iflytek.skillhub.repository.GovernanceQueryRepository; @@ -217,7 +217,7 @@ public class ReviewPortalAppService { )); } - public PageResponse listMyProgress( + public ReviewProgressPageResponse listMyProgress( String status, String query, int page, @@ -250,6 +250,22 @@ public class ReviewPortalAppService { return governanceQueryRepository.getReviewTaskResponses(attempts); } + public List listReviewAttempts( + Long reviewTaskId, + String userId, + Map userNsRoles) { + ReviewTask anchor = reviewTaskRepository.findById(reviewTaskId) + .orElseThrow(() -> new DomainNotFoundException("review_task.not_found", reviewTaskId)); + if (!canViewReview(anchor, userId, normalizeRoles(userNsRoles))) { + throw new DomainForbiddenException("review.no_permission"); + } + + List attempts = reviewTaskRepository + .findBySkillIdAndSkillVersionOrderBySubmittedAtDescIdDesc( + anchor.getSkillId(), anchor.getSkillVersion()); + return governanceQueryRepository.getReviewTaskResponses(attempts); + } + public ReviewTaskResponse getReviewDetail(Long reviewTaskId, String userId, Map userNsRoles) { diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/ReviewPortalControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/ReviewPortalControllerTest.java index 53a6fc84..6142d33a 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/ReviewPortalControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/ReviewPortalControllerTest.java @@ -17,6 +17,8 @@ import com.iflytek.skillhub.domain.skill.service.SkillDownloadService; import com.iflytek.skillhub.dto.ReviewTaskResponse; import com.iflytek.skillhub.dto.ReviewSkillDetailResponse; import com.iflytek.skillhub.dto.ReviewProgressResponse; +import com.iflytek.skillhub.dto.ReviewProgressPageResponse; +import com.iflytek.skillhub.dto.ReviewProgressStatusCounts; import com.iflytek.skillhub.dto.SkillDetailResponse; import com.iflytek.skillhub.dto.SkillFileResponse; import com.iflytek.skillhub.dto.SkillLifecycleVersionResponse; @@ -297,13 +299,21 @@ class ReviewPortalControllerTest { 2L ); given(reviewProgressQueryRepository.findMyProgress("author-1", null, "", 0, 20)) - .willReturn(new com.iflytek.skillhub.dto.PageResponse<>(List.of(item), 1, 0, 20)); + .willReturn(new ReviewProgressPageResponse( + List.of(item), + 1, + 0, + 20, + new ReviewProgressStatusCounts(0, 0, 1) + )); mockMvc.perform(get("/api/v1/reviews/my-progress").with(auth("author-1"))) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.items[0].skillSlug").value("skill-a")) .andExpect(jsonPath("$.data.items[0].attemptCount").value(2)) - .andExpect(jsonPath("$.data.items[0].latestStatus").value("REJECTED")); + .andExpect(jsonPath("$.data.items[0].latestStatus").value("REJECTED")) + .andExpect(jsonPath("$.data.statusCounts.pending").value(0)) + .andExpect(jsonPath("$.data.statusCounts.rejected").value(1)); verify(reviewProgressQueryRepository).findMyProgress("author-1", null, "", 0, 20); } @@ -330,6 +340,62 @@ class ReviewPortalControllerTest { .andExpect(jsonPath("$.data[1].id").value(8)); } + @Test + void listReviewAttempts_allowsAuthorizedReviewerToReadVersionHistory() throws Exception { + ReviewTask latest = createReviewTask(12L, 20L, "author-1", ReviewTaskStatus.PENDING); + setField(latest, "skillId", 30L); + setField(latest, "skillVersion", "1.0.0"); + ReviewTask previous = createReviewTask(8L, 20L, "author-1", ReviewTaskStatus.REJECTED); + setField(previous, "skillId", 30L); + setField(previous, "skillVersion", "1.0.0"); + Namespace namespace = createNamespace(20L, "team-a"); + stubNamespaceRoles("reviewer-1", List.of()); + given(rbacService.getUserRoleCodes("reviewer-1")).willReturn(Set.of("SKILL_ADMIN")); + given(reviewTaskRepository.findById(12L)).willReturn(Optional.of(latest)); + given(namespaceRepository.findById(20L)).willReturn(Optional.of(namespace)); + given(reviewService.canViewReview( + latest, + "reviewer-1", + namespace.getType(), + Map.of(), + Set.of("SKILL_ADMIN"))).willReturn(true); + given(reviewTaskRepository.findBySkillIdAndSkillVersionOrderBySubmittedAtDescIdDesc(30L, "1.0.0")) + .willReturn(List.of(latest, previous)); + given(governanceQueryRepository.getReviewTaskResponses(List.of(latest, previous))) + .willReturn(List.of(toReviewResponse(latest), toReviewResponse(previous))); + + mockMvc.perform(get("/api/v1/reviews/12/attempts").with(auth("reviewer-1"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.length()").value(2)) + .andExpect(jsonPath("$.data[0].id").value(12)) + .andExpect(jsonPath("$.data[1].id").value(8)); + } + + @Test + void listReviewAttempts_forbidsUnrelatedUser() throws Exception { + ReviewTask latest = createReviewTask(12L, 20L, "author-1", ReviewTaskStatus.PENDING); + setField(latest, "skillId", 30L); + setField(latest, "skillVersion", "1.0.0"); + Namespace namespace = createNamespace(20L, "team-a"); + stubNamespaceRoles("other-user", List.of()); + given(rbacService.getUserRoleCodes("other-user")).willReturn(Set.of()); + given(reviewTaskRepository.findById(12L)).willReturn(Optional.of(latest)); + given(namespaceRepository.findById(20L)).willReturn(Optional.of(namespace)); + given(reviewService.canViewReview( + latest, + "other-user", + namespace.getType(), + Map.of(), + Set.of())).willReturn(false); + + mockMvc.perform(get("/api/v1/reviews/12/attempts").with(auth("other-user"))) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(403)); + + verify(reviewTaskRepository, never()) + .findBySkillIdAndSkillVersionOrderBySubmittedAtDescIdDesc(30L, "1.0.0"); + } + @Test void downloadReviewVersion_streamsZipForAuthorizedReviewer() throws Exception { stubNamespaceRoles("admin", List.of()); diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/JpaReviewProgressQueryRepositoryTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/JpaReviewProgressQueryRepositoryTest.java new file mode 100644 index 00000000..1a7832a2 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/JpaReviewProgressQueryRepositoryTest.java @@ -0,0 +1,116 @@ +package com.iflytek.skillhub.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.iflytek.skillhub.domain.namespace.Namespace; +import com.iflytek.skillhub.domain.review.ReviewTask; +import com.iflytek.skillhub.domain.review.ReviewTaskStatus; +import com.iflytek.skillhub.domain.skill.Skill; +import com.iflytek.skillhub.domain.skill.SkillVisibility; +import java.time.Instant; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; + +@DataJpaTest +@ActiveProfiles("test") +@Import(JpaReviewProgressQueryRepository.class) +class JpaReviewProgressQueryRepositoryTest { + + @Autowired + private TestEntityManager entityManager; + + @Autowired + private JpaReviewProgressQueryRepository repository; + + @Test + void groupsAttemptsFiltersLatestStatusAndKeepsTotalsOnEmptyPage() { + Namespace namespace = entityManager.persistFlushFind( + new Namespace("team-review", "Review Team", "owner")); + Skill alpha = entityManager.persistFlushFind( + new Skill(namespace.getId(), "alpha-skill", "author-1", SkillVisibility.PUBLIC)); + Skill beta = entityManager.persistFlushFind( + new Skill(namespace.getId(), "beta-skill", "author-1", SkillVisibility.PUBLIC)); + + persistAttempt( + alpha, + namespace, + "author-1", + "1.0.0", + ReviewTaskStatus.REJECTED, + Instant.parse("2026-08-30T10:00:00Z")); + persistAttempt( + alpha, + namespace, + "author-1", + "1.0.0", + ReviewTaskStatus.PENDING, + Instant.parse("2026-08-31T10:00:00Z")); + persistAttempt( + beta, + namespace, + "author-1", + "2.0.0", + ReviewTaskStatus.APPROVED, + Instant.parse("2026-08-29T10:00:00Z")); + persistAttempt( + beta, + namespace, + "other-author", + "3.0.0", + ReviewTaskStatus.REJECTED, + Instant.parse("2026-08-31T11:00:00Z")); + entityManager.flush(); + entityManager.clear(); + + var firstPage = repository.findMyProgress("author-1", null, "", 0, 1); + + assertThat(firstPage.total()).isEqualTo(2); + assertThat(firstPage.items()).singleElement().satisfies(item -> { + assertThat(item.skillSlug()).isEqualTo("alpha-skill"); + assertThat(item.latestStatus()).isEqualTo("PENDING"); + assertThat(item.attemptCount()).isEqualTo(2); + }); + assertThat(firstPage.statusCounts().pending()).isEqualTo(1); + assertThat(firstPage.statusCounts().approved()).isEqualTo(1); + assertThat(firstPage.statusCounts().rejected()).isZero(); + + var emptyPage = repository.findMyProgress("author-1", null, "", 8, 1); + assertThat(emptyPage.items()).isEmpty(); + assertThat(emptyPage.total()).isEqualTo(2); + + var searchedAndFiltered = repository.findMyProgress( + "author-1", ReviewTaskStatus.APPROVED, "BETA", 0, 20); + assertThat(searchedAndFiltered.items()).singleElement() + .satisfies(item -> assertThat(item.skillSlug()).isEqualTo("beta-skill")); + assertThat(searchedAndFiltered.total()).isEqualTo(1); + assertThat(searchedAndFiltered.statusCounts().approved()).isEqualTo(1); + } + + private void persistAttempt( + Skill skill, + Namespace namespace, + String author, + String version, + ReviewTaskStatus status, + Instant submittedAt) { + ReviewTask task = new ReviewTask( + null, skill.getId(), namespace.getId(), version, author); + task.setStatus(status); + setField(task, "submittedAt", submittedAt); + entityManager.persist(task); + } + + private void setField(Object target, String fieldName, Object value) { + try { + java.lang.reflect.Field field = target.getClass().getDeclaredField(fieldName); + field.setAccessible(true); + field.set(target, value); + } catch (ReflectiveOperationException error) { + throw new AssertionError(error); + } + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/stream/ScanTaskConsumerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/stream/ScanTaskConsumerTest.java index fdf0c24b..b277f484 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/stream/ScanTaskConsumerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/stream/ScanTaskConsumerTest.java @@ -617,6 +617,12 @@ class ScanTaskConsumerTest { throw unsupported(); } + @Override + public List findBySkillIdAndSkillVersionOrderBySubmittedAtDescIdDesc( + Long skillId, String skillVersion) { + throw unsupported(); + } + @Override public boolean existsByNamespaceId(Long namespaceId) { return false; diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewTaskRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewTaskRepository.java index 991a5c12..3f0faccb 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewTaskRepository.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewTaskRepository.java @@ -18,6 +18,8 @@ public interface ReviewTaskRepository { Page findBySubmittedByAndStatus(String submittedBy, ReviewTaskStatus status, Pageable pageable); List findBySubmittedByAndSkillIdAndSkillVersionOrderBySubmittedAtDescIdDesc( String submittedBy, Long skillId, String skillVersion); + List findBySkillIdAndSkillVersionOrderBySubmittedAtDescIdDesc( + Long skillId, String skillVersion); boolean existsByNamespaceId(Long namespaceId); void deleteBySkillVersionIdIn(Collection skillVersionIds); void deleteBySkillId(Long skillId); diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/ReviewTaskJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/ReviewTaskJpaRepository.java index c5081c79..d742064c 100644 --- a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/ReviewTaskJpaRepository.java +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/ReviewTaskJpaRepository.java @@ -32,6 +32,9 @@ public interface ReviewTaskJpaRepository extends JpaRepository List findBySubmittedByAndSkillIdAndSkillVersionOrderBySubmittedAtDescIdDesc( String submittedBy, Long skillId, String skillVersion); + List findBySkillIdAndSkillVersionOrderBySubmittedAtDescIdDesc( + Long skillId, String skillVersion); + boolean existsByNamespaceId(Long namespaceId); void deleteBySkillVersionIdIn(Collection skillVersionIds); diff --git a/web/e2e/rejected-version-republish.spec.ts b/web/e2e/rejected-version-republish.spec.ts index 0b16ef1c..492c55ac 100644 --- a/web/e2e/rejected-version-republish.spec.ts +++ b/web/e2e/rejected-version-republish.spec.ts @@ -95,11 +95,24 @@ test.describe('Rejected version replacement (Real API)', () => { expect(attemptsBody.data.map((attempt) => attempt.status)).toEqual(['PENDING', 'REJECTED']) expect(attemptsBody.data[1]?.skillVersionId).toBeNull() + const reviewerAttemptsResponse = await adminPage.request.get( + `/api/web/reviews/${replacementReviewId}/attempts`, + ) + expect(reviewerAttemptsResponse.status()).toBe(200) + const reviewerAttemptsBody = await reviewerAttemptsResponse.json() as { + data: Array<{ id: number; status: string }> + } + expect(reviewerAttemptsBody.data.map((attempt) => attempt.id)).toEqual([ + replacementReviewId, + rejectedReviewId, + ]) + const replacedReviewResponse = await adminPage.request.get(`/api/web/reviews/${rejectedReviewId}`) expect(replacedReviewResponse.status()).toBe(200) await page.goto('/dashboard/review-progress') await expect(page.getByRole('heading', { name: 'My Review Progress' })).toBeVisible() + await expect(page.getByRole('button', { name: /In review/ })).toContainText('1') const progressCard = page.locator('article').filter({ hasText: replacement.slug }) await expect(progressCard).toContainText('In review') await expect(progressCard).toContainText('2 submissions') @@ -107,12 +120,22 @@ test.describe('Rejected version replacement (Real API)', () => { await expect(progressCard).toContainText('Attempt 2') await expect(progressCard).toContainText('Attempt 1') await expect(progressCard).toContainText('Rejected by Playwright E2E') + await expect(progressCard).toContainText('Reviewed by') await page.screenshot({ path: testInfo.outputPath('author-review-progress-desktop.png'), fullPage: true }) await page.setViewportSize({ width: 390, height: 844 }) await expect(progressCard).toBeVisible() await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true) await page.screenshot({ path: testInfo.outputPath('author-review-progress-mobile.png'), fullPage: true }) + + await adminPage.goto(`/dashboard/reviews/${replacementReviewId}`) + await expect(adminPage.getByRole('heading', { name: 'Submission History' })).toBeVisible() + await expect(adminPage.getByText('Attempt 2')).toBeVisible() + await expect(adminPage.getByText('Attempt 1')).toBeVisible() + + await progressCard.getByRole('link', { name: 'Edit and resubmit' }).click() + await expect(page).toHaveURL(/\/dashboard\/publish/) + await expect(page.getByText(new RegExp(`Resubmit .* v${replacement.version}`))).toBeVisible() const unexpectedConsoleErrors = consoleErrors.filter((message) => ( !message.includes("frame-ancestors' is ignored when delivered via a element") )) diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 11366047..c503257f 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -15,7 +15,7 @@ import type { MergeInitiateResponse, MergeVerifyRequest, ReviewSkillDetail, - ReviewProgress, + ReviewProgressPage, ReviewTask, PromotionSortBy, PromotionSortDirection, @@ -889,7 +889,7 @@ export const reviewApi = { if (params.q) searchParams.set('q', params.q) searchParams.set('page', String(params.page ?? 0)) searchParams.set('size', String(params.size ?? 20)) - return fetchJson<{ items: ReviewProgress[]; total: number; page: number; size: number }>( + return fetchJson( `${WEB_API_PREFIX}/reviews/my-progress?${searchParams.toString()}`, ) }, @@ -898,6 +898,10 @@ export const reviewApi = { return fetchJson(`${WEB_API_PREFIX}/reviews/my-progress/${reviewTaskId}/attempts`) }, + async listAttempts(reviewTaskId: number): Promise { + return fetchJson(`${WEB_API_PREFIX}/reviews/${reviewTaskId}/attempts`) + }, + async getSkillDetail(id: number): Promise { return fetchJson(`${WEB_API_PREFIX}/reviews/${id}/skill-detail`) }, diff --git a/web/src/api/generated/schema.d.ts b/web/src/api/generated/schema.d.ts index 7eb405e4..03786be7 100644 --- a/web/src/api/generated/schema.d.ts +++ b/web/src/api/generated/schema.d.ts @@ -2404,6 +2404,38 @@ export interface paths { patch?: never; trace?: never; }; + "/api/web/reviews/{id}/attempts": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listReviewAttempts"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/reviews/{id}/attempts": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listReviewAttempts_1"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/reviews/{id}": { parameters: { query?: never; @@ -4693,6 +4725,15 @@ export interface components { downloadUrl?: string; activeVersion?: string; }; + ApiResponseListReviewTaskResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["ReviewTaskResponse"][]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; ApiResponsePageResponseReviewTaskResponse: { /** Format: int32 */ code?: number; @@ -4711,25 +4752,16 @@ export interface components { /** Format: int32 */ size?: number; }; - ApiResponseListReviewTaskResponse: { + ApiResponseReviewProgressPageResponse: { /** Format: int32 */ code?: number; msg?: string; - data?: components["schemas"]["ReviewTaskResponse"][]; + data?: components["schemas"]["ReviewProgressPageResponse"]; /** Format: date-time */ timestamp?: string; requestId?: string; }; - ApiResponsePageResponseReviewProgressResponse: { - /** Format: int32 */ - code?: number; - msg?: string; - data?: components["schemas"]["PageResponseReviewProgressResponse"]; - /** Format: date-time */ - timestamp?: string; - requestId?: string; - }; - PageResponseReviewProgressResponse: { + ReviewProgressPageResponse: { items?: components["schemas"]["ReviewProgressResponse"][]; /** Format: int64 */ total?: number; @@ -4737,6 +4769,7 @@ export interface components { page?: number; /** Format: int32 */ size?: number; + statusCounts?: components["schemas"]["ReviewProgressStatusCounts"]; }; ReviewProgressResponse: { /** Format: int64 */ @@ -4755,6 +4788,14 @@ export interface components { /** Format: int64 */ attemptCount?: number; }; + ReviewProgressStatusCounts: { + /** Format: int64 */ + pending?: number; + /** Format: int64 */ + approved?: number; + /** Format: int64 */ + rejected?: number; + }; ApiResponsePageResponsePromotionResponseDto: { /** Format: int32 */ code?: number; @@ -10137,6 +10178,50 @@ export interface operations { }; }; }; + listReviewAttempts: { + parameters: { + query?: never; + header?: never; + path: { + id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseListReviewTaskResponse"]; + }; + }; + }; + }; + listReviewAttempts_1: { + parameters: { + query?: never; + header?: never; + path: { + id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseListReviewTaskResponse"]; + }; + }; + }; + }; getReviewDetail: { parameters: { query?: never; @@ -10339,7 +10424,7 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponsePageResponseReviewProgressResponse"]; + "*/*": components["schemas"]["ApiResponseReviewProgressPageResponse"]; }; }; }; @@ -10364,7 +10449,7 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponsePageResponseReviewProgressResponse"]; + "*/*": components["schemas"]["ApiResponseReviewProgressPageResponse"]; }; }; }; diff --git a/web/src/api/types.ts b/web/src/api/types.ts index d504b564..4db8e8db 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -454,6 +454,20 @@ export interface ReviewProgress { attemptCount: number } +export interface ReviewProgressStatusCounts { + pending: number + approved: number + rejected: number +} + +export interface ReviewProgressPage { + items: ReviewProgress[] + total: number + page: number + size: number + statusCounts: ReviewProgressStatusCounts +} + export interface ReviewSkillDetail { skill: SkillDetail versions: SkillVersion[] diff --git a/web/src/app/router.tsx b/web/src/app/router.tsx index 4e54a608..1a2d175b 100644 --- a/web/src/app/router.tsx +++ b/web/src/app/router.tsx @@ -287,9 +287,20 @@ const dashboardPublishRoute = createRoute({ getParentRoute: () => rootRoute, path: 'dashboard/publish', beforeLoad: requireAuth, - validateSearch: (search: Record): { namespace?: string; visibility?: string } => ({ + validateSearch: (search: Record): { + namespace?: string + visibility?: string + resubmitSkill?: string + resubmitVersion?: string + } => ({ namespace: typeof search.namespace === 'string' && search.namespace ? search.namespace : undefined, visibility: typeof search.visibility === 'string' && search.visibility ? search.visibility : undefined, + resubmitSkill: typeof search.resubmitSkill === 'string' && search.resubmitSkill + ? search.resubmitSkill + : undefined, + resubmitVersion: typeof search.resubmitVersion === 'string' && search.resubmitVersion + ? search.resubmitVersion + : undefined, }), component: PublishPage, }) diff --git a/web/src/features/publish/publish-prefill.test.ts b/web/src/features/publish/publish-prefill.test.ts index 8152c711..47ebee3e 100644 --- a/web/src/features/publish/publish-prefill.test.ts +++ b/web/src/features/publish/publish-prefill.test.ts @@ -6,9 +6,13 @@ describe('normalizePublishPrefill', () => { expect(normalizePublishPrefill({ namespace: 'team-ai', visibility: 'private', + resubmitSkill: 'agent-helper', + resubmitVersion: '1.2.0', })).toEqual({ namespace: 'team-ai', visibility: 'PRIVATE', + resubmitSkill: 'agent-helper', + resubmitVersion: '1.2.0', }) }) @@ -19,6 +23,8 @@ describe('normalizePublishPrefill', () => { })).toEqual({ namespace: 'team-ai', visibility: 'PUBLIC', + resubmitSkill: '', + resubmitVersion: '', }) }) @@ -28,6 +34,8 @@ describe('normalizePublishPrefill', () => { })).toEqual({ namespace: 'team-ml', visibility: 'PUBLIC', + resubmitSkill: '', + resubmitVersion: '', }) }) }) diff --git a/web/src/features/publish/publish-prefill.ts b/web/src/features/publish/publish-prefill.ts index 629b1339..e02e466f 100644 --- a/web/src/features/publish/publish-prefill.ts +++ b/web/src/features/publish/publish-prefill.ts @@ -3,11 +3,15 @@ const VALID_VISIBILITIES = new Set(['PUBLIC', 'NAMESPACE_ONLY', 'PRIVATE']) interface PublishPrefillSearch { namespace?: string visibility?: string + resubmitSkill?: string + resubmitVersion?: string } export interface PublishPrefillState { namespace: string visibility: string + resubmitSkill: string + resubmitVersion: string } export function normalizePublishPrefill(search: PublishPrefillSearch): PublishPrefillState { @@ -16,8 +20,17 @@ export function normalizePublishPrefill(search: PublishPrefillSearch): PublishPr ? search.visibility.trim().toUpperCase() : '' + const resubmitSkill = typeof search.resubmitSkill === 'string' + ? search.resubmitSkill.trim() + : '' + const resubmitVersion = typeof search.resubmitVersion === 'string' + ? search.resubmitVersion.trim() + : '' + return { namespace, visibility: VALID_VISIBILITIES.has(normalizedVisibility) ? normalizedVisibility : 'PUBLIC', + resubmitSkill, + resubmitVersion, } } diff --git a/web/src/features/review/review-attempt-timeline.test.tsx b/web/src/features/review/review-attempt-timeline.test.tsx new file mode 100644 index 00000000..d13297ac --- /dev/null +++ b/web/src/features/review/review-attempt-timeline.test.tsx @@ -0,0 +1,54 @@ +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, expect, it, vi } from 'vitest' +import type { ReviewTask } from '@/api/types' +import { ReviewAttemptTimeline } from './review-attempt-timeline' + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, values?: Record) => ( + values ? `${key}:${Object.values(values).join(':')}` : key + ), + }), +})) + +describe('ReviewAttemptTimeline', () => { + it('shows newest-first numbering plus submission and review metadata', () => { + const attempts: ReviewTask[] = [ + { + id: 2, + skillVersionId: 20, + namespace: 'team-a', + skillSlug: 'demo', + version: '1.0.0', + status: 'PENDING', + submittedBy: 'author', + submittedAt: '2026-09-01T02:00:00Z', + }, + { + id: 1, + skillVersionId: null, + namespace: 'team-a', + skillSlug: 'demo', + version: '1.0.0', + status: 'REJECTED', + submittedBy: 'author', + reviewedBy: 'reviewer', + reviewedByName: 'Reviewer One', + reviewComment: 'Add tests', + submittedAt: '2026-09-01T00:00:00Z', + reviewedAt: '2026-09-01T01:00:00Z', + }, + ] + + const html = renderToStaticMarkup( + , + ) + + expect(html).toContain('reviewProgress.attemptNumber:2') + expect(html).toContain('reviewProgress.attemptNumber:1') + expect(html).toContain('reviewProgress.submittedAt:') + expect(html).toContain('reviewProgress.reviewedAt:') + expect(html).toContain('reviewProgress.reviewedBy:Reviewer One') + expect(html).toContain('Add tests') + }) +}) diff --git a/web/src/features/review/review-attempt-timeline.tsx b/web/src/features/review/review-attempt-timeline.tsx new file mode 100644 index 00000000..99ae715b --- /dev/null +++ b/web/src/features/review/review-attempt-timeline.tsx @@ -0,0 +1,73 @@ +import { useTranslation } from 'react-i18next' +import type { ReviewTask } from '@/api/types' +import { formatLocalDateTime } from '@/shared/lib/date-time' +import { cn } from '@/shared/lib/utils' + +const statusClassNames: Record = { + PENDING: 'border-amber-500/25 bg-amber-500/10 text-amber-800 dark:text-amber-300', + APPROVED: 'border-emerald-500/25 bg-emerald-500/10 text-emerald-800 dark:text-emerald-300', + REJECTED: 'border-red-500/25 bg-red-500/10 text-red-800 dark:text-red-300', +} + +function statusKey(status: ReviewTask['status']) { + if (status === 'PENDING') return 'reviewProgress.statusPending' + if (status === 'APPROVED') return 'reviewProgress.statusApproved' + return 'reviewProgress.statusRejected' +} + +export function ReviewAttemptTimeline({ + attempts, + locale, +}: { + attempts: ReviewTask[] + locale: string +}) { + const { t } = useTranslation() + + return ( +
    + {attempts.map((attempt, index) => ( +
  1. + + {t('reviewProgress.attemptNumber', { number: attempts.length - index })} + +
    + + {t(statusKey(attempt.status))} + + {attempt.reviewComment ? ( +

    {attempt.reviewComment}

    + ) : null} + {attempt.reviewedBy ? ( +

    + {t('reviewProgress.reviewedBy', { + reviewer: attempt.reviewedByName || attempt.reviewedBy, + })} +

    + ) : null} +
    +
    + + {attempt.reviewedAt ? ( + + ) : null} +
    +
  2. + ))} +
+ ) +} diff --git a/web/src/features/review/use-review-detail.test.ts b/web/src/features/review/use-review-detail.test.ts index dbda642d..77ab3431 100644 --- a/web/src/features/review/use-review-detail.test.ts +++ b/web/src/features/review/use-review-detail.test.ts @@ -21,6 +21,12 @@ describe('use-review-detail exports', () => { expect(typeof mod.useReviewSkillDetail).toBe('function') }) + it('exports useReviewAttempts', async () => { + const mod = await import('./use-review-detail') + expect(mod.useReviewAttempts).toBeDefined() + expect(typeof mod.useReviewAttempts).toBe('function') + }) + it('exports useApproveReview', async () => { const mod = await import('./use-review-detail') expect(mod.useApproveReview).toBeDefined() diff --git a/web/src/features/review/use-review-detail.ts b/web/src/features/review/use-review-detail.ts index 1d39d7eb..88700518 100644 --- a/web/src/features/review/use-review-detail.ts +++ b/web/src/features/review/use-review-detail.ts @@ -47,6 +47,14 @@ export function useReviewSkillDetail(taskId: number) { }) } +export function useReviewAttempts(taskId: number) { + return useQuery({ + queryKey: ['reviews', taskId, 'attempts'], + queryFn: () => reviewApi.listAttempts(taskId), + enabled: !!taskId, + }) +} + /** * Approves a review and refreshes both the review queue and the governance * dashboard, which reads aggregate review state from separate endpoints. diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 58e08207..219daffc 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -502,9 +502,14 @@ "statusPending": "In review", "statusApproved": "Approved", "statusRejected": "Rejected", + "statusSummary": "Current review status summary", "latestSubmitted": "Latest submission: {{time}}", + "latestReviewed": "Latest decision: {{time}}", "attemptCount": "{{count}} submissions", "attemptNumber": "Attempt {{number}}", + "submittedAt": "Submitted {{time}}", + "reviewedAt": "Reviewed {{time}}", + "reviewedBy": "Reviewed by {{reviewer}}", "history": "Submission history", "resubmit": "Edit and resubmit", "loading": "Loading review progress", @@ -1385,6 +1390,10 @@ "title": "Review Notice", "description": "Submitted skill packages require admin review before publication." }, + "resubmitNotice": { + "title": "Resubmit {{skill}} v{{version}}", + "description": "Upload a corrected package that keeps the same skill name and version. It will enter the normal review flow again." + }, "namespace": "Namespace", "selectNamespace": "Select namespace", "visibility": "Visibility", @@ -1447,6 +1456,8 @@ "reviewer": "Reviewed By", "reviewTime": "Reviewed At", "reviewComment": "Review Comment", + "attemptHistory": "Submission History", + "attemptHistoryError": "Submission history could not be loaded. Try again later.", "actions": "Review Actions", "commentLabel": "Comment (optional)", "commentPlaceholder": "Enter review comment...", diff --git a/web/src/i18n/locales/ru.json b/web/src/i18n/locales/ru.json index b54c2a26..7fabf7af 100644 --- a/web/src/i18n/locales/ru.json +++ b/web/src/i18n/locales/ru.json @@ -502,9 +502,14 @@ "statusPending": "На проверке", "statusApproved": "Одобрено", "statusRejected": "Отклонено", + "statusSummary": "Сводка текущих статусов проверки", "latestSubmitted": "Последняя отправка: {{time}}", + "latestReviewed": "Последнее решение: {{time}}", "attemptCount": "Отправок: {{count}}", "attemptNumber": "Попытка {{number}}", + "submittedAt": "Отправлено {{time}}", + "reviewedAt": "Проверено {{time}}", + "reviewedBy": "Проверил: {{reviewer}}", "history": "История отправок", "resubmit": "Изменить и отправить снова", "loading": "Загрузка статуса проверки", @@ -1463,6 +1468,10 @@ "title": "Уведомление о ревью", "description": "Отправленные пакеты скиллов проходят ревью администратора перед публикацией." }, + "resubmitNotice": { + "title": "Повторная отправка {{skill}} v{{version}}", + "description": "Загрузите исправленный пакет с тем же именем и версией навыка. Он снова пройдёт обычную проверку." + }, "namespace": "Пространство имён", "selectNamespace": "Выберите пространство имён", "visibility": "Видимость", @@ -1516,6 +1525,8 @@ "reviewer": "Рецензент", "reviewTime": "Рассмотрено", "reviewComment": "Комментарий ревью", + "attemptHistory": "История отправок", + "attemptHistoryError": "Не удалось загрузить историю отправок. Повторите попытку позже.", "actions": "Действия ревью", "commentLabel": "Комментарий (необязательно)", "commentPlaceholder": "Введите комментарий ревью...", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 3a7c7f40..c8ef69a4 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -502,9 +502,14 @@ "statusPending": "审核中", "statusApproved": "已通过", "statusRejected": "已拒绝", + "statusSummary": "当前审核状态汇总", "latestSubmitted": "最近提交:{{time}}", + "latestReviewed": "最近审核:{{time}}", "attemptCount": "共 {{count}} 次提交", "attemptNumber": "第 {{number}} 次", + "submittedAt": "提交于 {{time}}", + "reviewedAt": "审核于 {{time}}", + "reviewedBy": "审核人:{{reviewer}}", "history": "提交历史", "resubmit": "修改并重提", "loading": "正在加载审核进度", @@ -1385,6 +1390,10 @@ "title": "发布审核说明", "description": "技能包提交后需要经过管理员审核才能正式发布。" }, + "resubmitNotice": { + "title": "重新提交 {{skill}} v{{version}}", + "description": "上传修正后的技能包,并保持相同的技能名称和版本;提交后会重新进入正常审核流程。" + }, "namespace": "命名空间", "selectNamespace": "选择命名空间", "visibility": "可见性", @@ -1447,6 +1456,8 @@ "reviewer": "审核者", "reviewTime": "审核时间", "reviewComment": "审核意见", + "attemptHistory": "提交历史", + "attemptHistoryError": "提交历史加载失败,请稍后重试。", "actions": "审核操作", "commentLabel": "审核意见(可选)", "commentPlaceholder": "填写审核意见...", diff --git a/web/src/pages/dashboard/publish.tsx b/web/src/pages/dashboard/publish.tsx index 23770c85..343c979f 100644 --- a/web/src/pages/dashboard/publish.tsx +++ b/web/src/pages/dashboard/publish.tsx @@ -158,6 +158,20 @@ export function PublishPage() {
+ {prefill.resubmitSkill && prefill.resubmitVersion ? ( + +

+ {t('publish.resubmitNotice.title', { + skill: `@${prefill.namespace}/${prefill.resubmitSkill}`, + version: prefill.resubmitVersion, + })} +

+

+ {t('publish.resubmitNotice.description')} +

+
+ ) : null} +
diff --git a/web/src/pages/dashboard/review-detail.test.tsx b/web/src/pages/dashboard/review-detail.test.tsx index 6b8cde31..da76b1d3 100644 --- a/web/src/pages/dashboard/review-detail.test.tsx +++ b/web/src/pages/dashboard/review-detail.test.tsx @@ -103,9 +103,25 @@ const useReviewSkillDetailMock = vi.fn<() => unknown>(() => ({ error: null, })) +const useReviewAttemptsMock = vi.fn<() => unknown>(() => ({ + data: [{ + id: 13, + skillVersionId: 10, + namespace: 'global', + skillSlug: 'demo-skill', + version: '1.2.0', + status: 'PENDING', + submittedBy: 'local-admin', + submittedAt: '2026-03-19T00:00:00Z', + }], + isLoading: false, + isError: false, +})) + vi.mock('@/features/review/use-review-detail', () => ({ useReviewDetail: () => useReviewDetailMock(), useReviewSkillDetail: () => useReviewSkillDetailMock(), + useReviewAttempts: () => useReviewAttemptsMock(), useApproveReview: () => ({ mutate: vi.fn(), isPending: false, @@ -139,6 +155,21 @@ describe('ReviewDetailPage', () => { userMock.platformRoles = ['SKILL_ADMIN'] useReviewDetailMock.mockReset() useReviewSkillDetailMock.mockReset() + useReviewAttemptsMock.mockReset() + useReviewAttemptsMock.mockReturnValue({ + data: [{ + id: 13, + skillVersionId: 10, + namespace: 'global', + skillSlug: 'demo-skill', + version: '1.2.0', + status: 'PENDING', + submittedBy: 'local-admin', + submittedAt: '2026-03-19T00:00:00Z', + }], + isLoading: false, + isError: false, + }) useReviewDetailMock.mockReturnValue({ data: { id: 13, @@ -203,6 +234,8 @@ describe('ReviewDetailPage', () => { expect(html).toContain('max-w-6xl mx-auto flex') expect(html).toContain('aria-expanded="false"') + expect(html).toContain('review.attemptHistory') + expect(html).toContain('reviewProgress.attemptNumber') }) it('renders not-found state when the review record is missing', () => { diff --git a/web/src/pages/dashboard/review-detail.tsx b/web/src/pages/dashboard/review-detail.tsx index 959d5af3..fdc5fd6c 100644 --- a/web/src/pages/dashboard/review-detail.tsx +++ b/web/src/pages/dashboard/review-detail.tsx @@ -19,13 +19,20 @@ import { toast } from '@/shared/lib/toast' import { cn } from '@/shared/lib/utils' import { resolveReviewActionErrorDescription } from '@/features/review/review-error' import { ReviewSkillDetailSection } from '@/features/review/review-skill-detail-section' +import { ReviewAttemptTimeline } from '@/features/review/review-attempt-timeline' import { SecurityAuditSection } from '@/features/security-audit/security-audit-section' import { FileTree } from '@/features/skill/file-tree' import { FilePreviewDialog } from '@/features/skill/file-preview-dialog' import type { FileTreeNode } from '@/features/skill/file-tree-builder' import { useReviewFile } from '@/features/review/use-review-file' import { buildApiUrl, WEB_API_PREFIX } from '@/api/client' -import { useReviewDetail, useReviewSkillDetail, useApproveReview, useRejectReview } from '@/features/review/use-review-detail' +import { + useReviewAttempts, + useReviewDetail, + useReviewSkillDetail, + useApproveReview, + useRejectReview, +} from '@/features/review/use-review-detail' /** * Review task detail page for moderators. The route owns the approve/reject @@ -46,6 +53,11 @@ function ReviewDetailScreen({ const { user } = useAuth() const { data: review, isLoading } = useReviewDetail(taskId) + const { + data: reviewAttempts, + isLoading: isLoadingReviewAttempts, + isError: isReviewAttemptsError, + } = useReviewAttempts(taskId) const { data: reviewSkillDetail, isLoading: isLoadingReviewSkillDetail, @@ -253,6 +265,17 @@ function ReviewDetailScreen({ )} + +

{t('review.attemptHistory')}

+ {isLoadingReviewAttempts ? ( +
+ ) : isReviewAttemptsError ? ( +

{t('review.attemptHistoryError')}

+ ) : ( + + )} + + {review.status === 'PENDING' && (

{t('review.actions')}

diff --git a/web/src/pages/dashboard/review-progress.test.tsx b/web/src/pages/dashboard/review-progress.test.tsx index d7c4f173..7a36f777 100644 --- a/web/src/pages/dashboard/review-progress.test.tsx +++ b/web/src/pages/dashboard/review-progress.test.tsx @@ -36,6 +36,11 @@ vi.mock('@/features/review/use-my-review-progress', () => ({ total: 1, page: 0, size: 20, + statusCounts: { + pending: 0, + approved: 0, + rejected: 1, + }, }, isLoading: false, isError: false, @@ -53,6 +58,9 @@ describe('ReviewProgressPage', () => { expect(html).toContain('reviewProgress.statusRejected') expect(html).toContain('reviewProgress.resubmit') expect(html).toContain('reviewProgress.history') + expect(html).toContain('reviewProgress.statusSummary') + expect(html).toContain('reviewProgress.latestReviewed') + expect(html).toContain('/dashboard/publish') expect(html).not.toContain('reviews.typeSkill') }) }) diff --git a/web/src/pages/dashboard/review-progress.tsx b/web/src/pages/dashboard/review-progress.tsx index 4be1f93b..4fee9682 100644 --- a/web/src/pages/dashboard/review-progress.tsx +++ b/web/src/pages/dashboard/review-progress.tsx @@ -2,7 +2,8 @@ import { useState, type FormEvent } from 'react' import { Link, useNavigate, useSearch } from '@tanstack/react-router' import { ChevronDown, ChevronUp, Clock3, RotateCcw, Search } from 'lucide-react' import { useTranslation } from 'react-i18next' -import type { ReviewProgress, ReviewTask } from '@/api/types' +import type { ReviewProgress } from '@/api/types' +import { ReviewAttemptTimeline } from '@/features/review/review-attempt-timeline' import { useMyReviewAttempts, useMyReviewProgress } from '@/features/review/use-my-review-progress' import { DashboardPageHeader } from '@/shared/components/dashboard-page-header' import { Pagination } from '@/shared/components/pagination' @@ -101,6 +102,35 @@ export function ReviewProgressPage() {
+ {progressQuery.data ? ( +
+ {([ + ['PENDING', progressQuery.data.statusCounts.pending, 'statusPending'], + ['APPROVED', progressQuery.data.statusCounts.approved, 'statusApproved'], + ['REJECTED', progressQuery.data.statusCounts.rejected, 'statusRejected'], + ] as const).map(([status, count, labelKey]) => ( + + ))} +
+ ) : null} + {progressQuery.isLoading ? (
{Array.from({ length: 4 }).map((_, index) => ( @@ -182,6 +212,9 @@ function ProgressItem({
{t('reviewProgress.latestSubmitted', { time: formatLocalDateTime(item.latestSubmittedAt, locale) })} + {item.latestReviewedAt ? ( + {t('reviewProgress.latestReviewed', { time: formatLocalDateTime(item.latestReviewedAt, locale) })} + ) : null} {t('reviewProgress.attemptCount', { count: item.attemptCount })}
{item.latestReviewComment ? ( @@ -192,8 +225,12 @@ function ProgressItem({
{status === 'REJECTED' ? ( @@ -221,35 +258,10 @@ function ProgressItem({ ) : attemptsQuery.isError ? (

{t('reviewProgress.historyError')}

) : ( - + )}
) : null} ) } - -function AttemptTimeline({ attempts, locale }: { attempts: ReviewTask[]; locale: string }) { - const { t } = useTranslation() - - return ( -
    - {attempts.map((attempt, index) => ( -
  1. - - {t('reviewProgress.attemptNumber', { number: attempts.length - index })} - -
    - - {t(`reviewProgress.status${attempt.status === 'PENDING' ? 'Pending' : attempt.status === 'APPROVED' ? 'Approved' : 'Rejected'}`)} - - {attempt.reviewComment ?

    {attempt.reviewComment}

    : null} -
    - -
  2. - ))} -
- ) -}