fix(review): complete progress history workflow

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
XiaoSeS 2026-09-01 14:14:48 +08:00
parent b8b0fba3d4
commit 3e77365a5d
31 changed files with 793 additions and 74 deletions

View file

@ -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<PageResponse<ReviewProgressResponse>> listMyProgress(
public ApiResponse<ReviewProgressPageResponse> 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<List<ReviewTaskResponse>> listReviewAttempts(
@PathVariable Long id,
@RequestAttribute("userId") String userId,
@RequestAttribute(value = "userNsRoles", required = false)
Map<Long, NamespaceRole> userNsRoles) {
return ok(
"response.success.read",
governanceWorkflowAppService.listReviewAttempts(id, userId, userNsRoles)
);
}
@GetMapping("/{id}")
public ApiResponse<ReviewTaskResponse> getReviewDetail(@PathVariable Long id,
@RequestAttribute("userId") String userId,

View file

@ -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<ReviewProgressResponse> items,
long total,
int page,
int size,
ReviewProgressStatusCounts statusCounts
) {}

View file

@ -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
) {}

View file

@ -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<ReviewProgressResponse> 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<Object[]> rows = nativeQuery.getResultList();
List<ReviewProgressResponse> 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) {

View file

@ -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<ReviewProgressResponse> findMyProgress(
ReviewProgressPageResponse findMyProgress(
String userId,
ReviewTaskStatus status,
String query,

View file

@ -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<ReviewProgressResponse> listMyReviewProgress(
public ReviewProgressPageResponse listMyReviewProgress(
String status,
String query,
int page,
@ -108,6 +108,13 @@ public class GovernanceWorkflowAppService {
return reviewPortalAppService.listMyAttempts(reviewTaskId, userId);
}
public List<ReviewTaskResponse> listReviewAttempts(
Long reviewTaskId,
String userId,
Map<Long, NamespaceRole> userNsRoles) {
return reviewPortalAppService.listReviewAttempts(reviewTaskId, userId, userNsRoles);
}
public ReviewTaskResponse getReviewDetail(Long reviewTaskId,
String userId,
Map<Long, NamespaceRole> userNsRoles) {

View file

@ -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<ReviewProgressResponse> listMyProgress(
public ReviewProgressPageResponse listMyProgress(
String status,
String query,
int page,
@ -250,6 +250,22 @@ public class ReviewPortalAppService {
return governanceQueryRepository.getReviewTaskResponses(attempts);
}
public List<ReviewTaskResponse> listReviewAttempts(
Long reviewTaskId,
String userId,
Map<Long, NamespaceRole> 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<ReviewTask> attempts = reviewTaskRepository
.findBySkillIdAndSkillVersionOrderBySubmittedAtDescIdDesc(
anchor.getSkillId(), anchor.getSkillVersion());
return governanceQueryRepository.getReviewTaskResponses(attempts);
}
public ReviewTaskResponse getReviewDetail(Long reviewTaskId,
String userId,
Map<Long, NamespaceRole> userNsRoles) {

View file

@ -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());

View file

@ -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);
}
}
}

View file

@ -617,6 +617,12 @@ class ScanTaskConsumerTest {
throw unsupported();
}
@Override
public List<ReviewTask> findBySkillIdAndSkillVersionOrderBySubmittedAtDescIdDesc(
Long skillId, String skillVersion) {
throw unsupported();
}
@Override
public boolean existsByNamespaceId(Long namespaceId) {
return false;

View file

@ -18,6 +18,8 @@ public interface ReviewTaskRepository {
Page<ReviewTask> findBySubmittedByAndStatus(String submittedBy, ReviewTaskStatus status, Pageable pageable);
List<ReviewTask> findBySubmittedByAndSkillIdAndSkillVersionOrderBySubmittedAtDescIdDesc(
String submittedBy, Long skillId, String skillVersion);
List<ReviewTask> findBySkillIdAndSkillVersionOrderBySubmittedAtDescIdDesc(
Long skillId, String skillVersion);
boolean existsByNamespaceId(Long namespaceId);
void deleteBySkillVersionIdIn(Collection<Long> skillVersionIds);
void deleteBySkillId(Long skillId);

View file

@ -32,6 +32,9 @@ public interface ReviewTaskJpaRepository extends JpaRepository<ReviewTask, Long>
List<ReviewTask> findBySubmittedByAndSkillIdAndSkillVersionOrderBySubmittedAtDescIdDesc(
String submittedBy, Long skillId, String skillVersion);
List<ReviewTask> findBySkillIdAndSkillVersionOrderBySubmittedAtDescIdDesc(
Long skillId, String skillVersion);
boolean existsByNamespaceId(Long namespaceId);
void deleteBySkillVersionIdIn(Collection<Long> skillVersionIds);

View file

@ -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 <meta> element")
))

View file

@ -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<ReviewProgressPage>(
`${WEB_API_PREFIX}/reviews/my-progress?${searchParams.toString()}`,
)
},
@ -898,6 +898,10 @@ export const reviewApi = {
return fetchJson<ReviewTask[]>(`${WEB_API_PREFIX}/reviews/my-progress/${reviewTaskId}/attempts`)
},
async listAttempts(reviewTaskId: number): Promise<ReviewTask[]> {
return fetchJson<ReviewTask[]>(`${WEB_API_PREFIX}/reviews/${reviewTaskId}/attempts`)
},
async getSkillDetail(id: number): Promise<ReviewSkillDetail> {
return fetchJson<ReviewSkillDetail>(`${WEB_API_PREFIX}/reviews/${id}/skill-detail`)
},

View file

@ -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"];
};
};
};

View file

@ -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[]

View file

@ -287,9 +287,20 @@ const dashboardPublishRoute = createRoute({
getParentRoute: () => rootRoute,
path: 'dashboard/publish',
beforeLoad: requireAuth,
validateSearch: (search: Record<string, unknown>): { namespace?: string; visibility?: string } => ({
validateSearch: (search: Record<string, unknown>): {
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,
})

View file

@ -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: '',
})
})
})

View file

@ -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,
}
}

View file

@ -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<string, string | number>) => (
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(
<ReviewAttemptTimeline attempts={attempts} locale="en" />,
)
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')
})
})

View file

@ -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<ReviewTask['status'], string> = {
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 (
<ol className="space-y-3">
{attempts.map((attempt, index) => (
<li
key={attempt.id}
className="grid gap-3 rounded-lg border border-border/60 bg-background/70 p-3 text-sm md:grid-cols-[auto_1fr_auto] md:items-start"
>
<span className="font-medium text-foreground">
{t('reviewProgress.attemptNumber', { number: attempts.length - index })}
</span>
<div className="min-w-0">
<span className={cn(
'inline-flex rounded-full border px-2 py-0.5 text-xs font-medium',
statusClassNames[attempt.status],
)}>
{t(statusKey(attempt.status))}
</span>
{attempt.reviewComment ? (
<p className="mt-2 whitespace-pre-wrap text-foreground/85">{attempt.reviewComment}</p>
) : null}
{attempt.reviewedBy ? (
<p className="mt-2 text-xs text-muted-foreground">
{t('reviewProgress.reviewedBy', {
reviewer: attempt.reviewedByName || attempt.reviewedBy,
})}
</p>
) : null}
</div>
<div className="space-y-1 text-xs text-muted-foreground md:text-right">
<time dateTime={attempt.submittedAt}>
{t('reviewProgress.submittedAt', {
time: formatLocalDateTime(attempt.submittedAt, locale),
})}
</time>
{attempt.reviewedAt ? (
<time className="block" dateTime={attempt.reviewedAt}>
{t('reviewProgress.reviewedAt', {
time: formatLocalDateTime(attempt.reviewedAt, locale),
})}
</time>
) : null}
</div>
</li>
))}
</ol>
)
}

View file

@ -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()

View file

@ -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.

View file

@ -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...",

View file

@ -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": "Введите комментарий ревью...",

View file

@ -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": "填写审核意见...",

View file

@ -158,6 +158,20 @@ export function PublishPage() {
<div className="max-w-2xl mx-auto space-y-8 animate-fade-up">
<DashboardPageHeader title={t('publish.title')} subtitle={t('publish.subtitle')} />
{prefill.resubmitSkill && prefill.resubmitVersion ? (
<Card className="border-amber-500/25 bg-amber-500/5 p-4">
<h2 className="text-sm font-semibold text-foreground">
{t('publish.resubmitNotice.title', {
skill: `@${prefill.namespace}/${prefill.resubmitSkill}`,
version: prefill.resubmitVersion,
})}
</h2>
<p className="mt-1 text-sm text-muted-foreground">
{t('publish.resubmitNotice.description')}
</p>
</Card>
) : null}
<Card className="p-4 bg-blue-500/5 border-blue-500/20">
<div className="flex items-start gap-3">
<svg className="w-5 h-5 text-blue-500 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">

View file

@ -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', () => {

View file

@ -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({
)}
</Card>
<Card className="space-y-4 p-6 md:p-8">
<h2 className="text-xl font-bold font-heading">{t('review.attemptHistory')}</h2>
{isLoadingReviewAttempts ? (
<div className="h-20 animate-shimmer rounded-lg" />
) : isReviewAttemptsError ? (
<p className="text-sm text-destructive">{t('review.attemptHistoryError')}</p>
) : (
<ReviewAttemptTimeline attempts={reviewAttempts ?? []} locale={i18n.language} />
)}
</Card>
{review.status === 'PENDING' && (
<Card className="p-8 space-y-6">
<h2 className="text-xl font-bold font-heading">{t('review.actions')}</h2>

View file

@ -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')
})
})

View file

@ -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() {
</div>
</div>
{progressQuery.data ? (
<div className="grid gap-3 sm:grid-cols-3" aria-label={t('reviewProgress.statusSummary')}>
{([
['PENDING', progressQuery.data.statusCounts.pending, 'statusPending'],
['APPROVED', progressQuery.data.statusCounts.approved, 'statusApproved'],
['REJECTED', progressQuery.data.statusCounts.rejected, 'statusRejected'],
] as const).map(([status, count, labelKey]) => (
<button
key={status}
type="button"
aria-pressed={search.status === status}
onClick={() => updateSearch({
status: search.status === status ? null : status,
page: 0,
})}
className={cn(
'rounded-xl border border-border/70 bg-background px-4 py-3 text-left transition-colors hover:bg-muted/70 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
search.status === status && 'border-primary/40 bg-primary/5',
)}
>
<span className="block text-2xl font-semibold text-foreground">{count}</span>
<span className="text-sm text-muted-foreground">
{t(`reviewProgress.${labelKey}`)}
</span>
</button>
))}
</div>
) : null}
{progressQuery.isLoading ? (
<div className="space-y-3" aria-label={t('reviewProgress.loading')}>
{Array.from({ length: 4 }).map((_, index) => (
@ -182,6 +212,9 @@ function ProgressItem({
</div>
<div className="flex flex-wrap gap-x-4 gap-y-1 text-sm text-muted-foreground">
<span>{t('reviewProgress.latestSubmitted', { time: formatLocalDateTime(item.latestSubmittedAt, locale) })}</span>
{item.latestReviewedAt ? (
<span>{t('reviewProgress.latestReviewed', { time: formatLocalDateTime(item.latestReviewedAt, locale) })}</span>
) : null}
<span>{t('reviewProgress.attemptCount', { count: item.attemptCount })}</span>
</div>
{item.latestReviewComment ? (
@ -192,8 +225,12 @@ function ProgressItem({
<div className="flex shrink-0 flex-wrap gap-2">
{status === 'REJECTED' ? (
<Link
to="/space/$namespace/$slug"
params={{ namespace: item.namespace, slug: item.skillSlug }}
to="/dashboard/publish"
search={{
namespace: item.namespace,
resubmitSkill: item.skillSlug,
resubmitVersion: item.skillVersion,
}}
className={cn(buttonVariants({ variant: 'outline', size: 'sm' }), 'gap-2')}
>
<RotateCcw className="h-4 w-4" />
@ -221,35 +258,10 @@ function ProgressItem({
) : attemptsQuery.isError ? (
<p className="text-sm text-destructive">{t('reviewProgress.historyError')}</p>
) : (
<AttemptTimeline attempts={attemptsQuery.data ?? []} locale={locale} />
<ReviewAttemptTimeline attempts={attemptsQuery.data ?? []} locale={locale} />
)}
</div>
) : null}
</article>
)
}
function AttemptTimeline({ attempts, locale }: { attempts: ReviewTask[]; locale: string }) {
const { t } = useTranslation()
return (
<ol className="space-y-3">
{attempts.map((attempt, index) => (
<li key={attempt.id} className="grid gap-2 rounded-lg border border-border/60 bg-background/70 p-3 text-sm md:grid-cols-[auto_1fr_auto] md:items-start">
<span className="font-medium text-foreground">
{t('reviewProgress.attemptNumber', { number: attempts.length - index })}
</span>
<div className="min-w-0">
<span className={cn('inline-flex rounded-full border px-2 py-0.5 text-xs font-medium', statusClassNames[attempt.status])}>
{t(`reviewProgress.status${attempt.status === 'PENDING' ? 'Pending' : attempt.status === 'APPROVED' ? 'Approved' : 'Rejected'}`)}
</span>
{attempt.reviewComment ? <p className="mt-2 whitespace-pre-wrap text-foreground/85">{attempt.reviewComment}</p> : null}
</div>
<time className="text-xs text-muted-foreground" dateTime={attempt.submittedAt}>
{formatLocalDateTime(attempt.submittedAt, locale)}
</time>
</li>
))}
</ol>
)
}