Merge pull request #791 from iflytek/codex/validate/issues620-621-20260901

feat(review): add progress history and light-dark themes
This commit is contained in:
XiaoSeS 2026-09-01 18:05:52 +08:00 committed by GitHub
commit 08723fd01a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
71 changed files with 2650 additions and 95 deletions

View file

@ -13,6 +13,10 @@
<artifactId>skillhub-app</artifactId>
<properties>
<testcontainers.version>1.21.4</testcontainers.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
@ -121,6 +125,16 @@
<artifactId>h2</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>postgresql</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>

View file

@ -8,11 +8,13 @@ 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.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.List;
import java.util.Map;
import org.springframework.core.io.InputStreamResource;
import org.springframework.http.HttpHeaders;
@ -136,6 +138,38 @@ public class ReviewController extends BaseApiController {
return ok("response.success.read", governanceWorkflowAppService.listMyReviewSubmissions(page, size, userId));
}
@GetMapping("/my-progress")
public ApiResponse<ReviewProgressPageResponse> listMyProgress(
@RequestParam(required = false) String status,
@RequestParam(defaultValue = "") String q,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size,
@RequestAttribute("userId") String userId) {
return ok(
"response.success.read",
governanceWorkflowAppService.listMyReviewProgress(status, q, page, size, userId)
);
}
@GetMapping("/my-progress/{id}/attempts")
public ApiResponse<List<ReviewTaskResponse>> listMyAttempts(
@PathVariable Long id,
@RequestAttribute("userId") String userId) {
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,19 @@
package com.iflytek.skillhub.dto;
import java.time.Instant;
/**
* Author-facing summary for one skill version's review attempts.
*/
public record ReviewProgressResponse(
Long latestReviewTaskId,
Long skillId,
String namespace,
String skillSlug,
String skillVersion,
String latestStatus,
String latestReviewComment,
Instant latestSubmittedAt,
Instant latestReviewedAt,
long attemptCount
) {}

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

@ -104,15 +104,19 @@ public class JpaGovernanceQueryRepository implements GovernanceQueryRepository {
? Map.of()
: skillVersionRepository.findByIdIn(versionIds).stream()
.collect(Collectors.toMap(SkillVersion::getId, Function.identity()));
List<Long> skillIds = distinct(versionsById.values().stream().map(SkillVersion::getSkillId).toList());
Set<Long> skillIds = new LinkedHashSet<>(distinct(
versionsById.values().stream().map(SkillVersion::getSkillId).toList()));
skillIds.addAll(distinct(tasks.stream().map(ReviewTask::getSkillId).toList()));
Map<Long, Skill> skillsById = skillIds.isEmpty()
? Map.of()
: skillRepository.findByIdIn(skillIds).stream()
: skillRepository.findByIdIn(List.copyOf(skillIds)).stream()
.collect(Collectors.toMap(Skill::getId, Function.identity()));
List<Long> namespaceIds = distinct(skillsById.values().stream().map(Skill::getNamespaceId).toList());
Set<Long> namespaceIds = new LinkedHashSet<>(distinct(
skillsById.values().stream().map(Skill::getNamespaceId).toList()));
namespaceIds.addAll(distinct(tasks.stream().map(ReviewTask::getNamespaceId).toList()));
Map<Long, Namespace> namespacesById = namespaceIds.isEmpty()
? Map.of()
: namespaceRepository.findByIdIn(namespaceIds).stream()
: namespaceRepository.findByIdIn(List.copyOf(namespaceIds)).stream()
.collect(Collectors.toMap(Namespace::getId, Function.identity()));
List<String> userIds = distinctStrings(tasks.stream()
.flatMap(task -> java.util.stream.Stream.of(task.getSubmittedBy(), task.getReviewedBy()))
@ -168,9 +172,14 @@ public class JpaGovernanceQueryRepository implements GovernanceQueryRepository {
}
private ReviewTaskResponse toReviewTaskResponse(ReviewTask task, ReviewReadBundle bundle) {
SkillVersion version = require(bundle.versionsById(), task.getSkillVersionId(), "skill_version.not_found");
Skill skill = require(bundle.skillsById(), version.getSkillId(), "skill.not_found");
Namespace namespace = require(bundle.namespacesById(), skill.getNamespaceId(), "namespace.not_found");
Long skillId = task.getSkillId() != null
? task.getSkillId()
: require(bundle.versionsById(), task.getSkillVersionId(), "skill_version.not_found").getSkillId();
Skill skill = require(bundle.skillsById(), skillId, "skill.not_found");
Namespace namespace = require(bundle.namespacesById(), task.getNamespaceId(), "namespace.not_found");
String skillVersion = task.getSkillVersion() != null
? task.getSkillVersion()
: require(bundle.versionsById(), task.getSkillVersionId(), "skill_version.not_found").getVersion();
UserAccount submittedBy = bundle.usersById().get(task.getSubmittedBy());
UserAccount reviewedBy = task.getReviewedBy() != null ? bundle.usersById().get(task.getReviewedBy()) : null;
return new ReviewTaskResponse(
@ -178,7 +187,7 @@ public class JpaGovernanceQueryRepository implements GovernanceQueryRepository {
task.getSkillVersionId(),
namespace.getSlug(),
skill.getSlug(),
version.getVersion(),
skillVersion,
task.getStatus().name(),
task.getSubmittedBy(),
submittedBy != null ? submittedBy.getDisplayName() : null,

View file

@ -0,0 +1,176 @@
package com.iflytek.skillhub.repository;
import com.iflytek.skillhub.domain.review.ReviewTaskStatus;
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;
import java.time.Instant;
import java.time.OffsetDateTime;
import java.util.List;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;
/**
* PostgreSQL read-model query for review progress.
*
* <p>Direct SQL is intentional here: the page boundary applies to grouped skill-version attempts,
* not individual review tasks. Window functions keep grouping, latest-attempt selection, counts,
* filtering, and pagination in the database instead of loading an author's full history.</p>
*/
@Repository
public class JpaReviewProgressQueryRepository implements ReviewProgressQueryRepository {
private static final String RANKED_CTE = """
WITH ranked AS (
SELECT task.id,
task.skill_id,
task.namespace_id,
task.skill_version,
task.status,
task.review_comment,
task.submitted_at,
task.reviewed_at,
ROW_NUMBER() OVER (
PARTITION BY task.skill_id, task.skill_version
ORDER BY task.submitted_at DESC, task.id DESC
) AS attempt_rank,
COUNT(*) OVER (
PARTITION BY task.skill_id, task.skill_version
) AS attempt_count
FROM review_task task
WHERE task.submitted_by = :userId
), latest AS (
SELECT *
FROM ranked
WHERE attempt_rank = 1
)
""";
private static final String MY_PROGRESS_SQL = RANKED_CTE + """
SELECT latest.id,
latest.skill_id,
namespace.slug,
skill.slug,
latest.skill_version,
latest.status,
latest.review_comment,
latest.submitted_at,
latest.reviewed_at,
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
""";
private final EntityManager entityManager;
public JpaReviewProgressQueryRepository(EntityManager entityManager) {
this.entityManager = entityManager;
}
@Override
@Transactional(readOnly = true)
public ReviewProgressPageResponse findMyProgress(
String userId,
ReviewTaskStatus status,
String query,
int page,
int size) {
String normalizedQuery = query == null ? "" : query.trim().toLowerCase(java.util.Locale.ROOT);
String statusName = status != null ? status.name() : "";
String queryPattern = "%" + normalizedQuery + "%";
Query nativeQuery = bindFilters(
entityManager.createNativeQuery(MY_PROGRESS_SQL),
userId,
statusName,
normalizedQuery,
queryPattern)
.setParameter("offset", (long) 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();
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) {
return new ReviewProgressResponse(
number(row[0]).longValue(),
number(row[1]).longValue(),
(String) row[2],
(String) row[3],
(String) row[4],
String.valueOf(row[5]),
(String) row[6],
instant(row[7]),
instant(row[8]),
number(row[9]).longValue()
);
}
private Number number(Object value) {
if (value instanceof Number number) {
return number;
}
throw new IllegalStateException("Expected numeric review progress value, got " + value);
}
private Instant instant(Object value) {
if (value == null) return null;
if (value instanceof Instant instant) return instant;
if (value instanceof OffsetDateTime offsetDateTime) return offsetDateTime.toInstant();
if (value instanceof Timestamp timestamp) return timestamp.toInstant();
throw new IllegalStateException("Expected review progress timestamp, got " + value.getClass().getName());
}
}

View file

@ -0,0 +1,18 @@
package com.iflytek.skillhub.repository;
import com.iflytek.skillhub.domain.review.ReviewTaskStatus;
import com.iflytek.skillhub.dto.ReviewProgressPageResponse;
/**
* Query seam for author-facing review progress grouped by skill version.
*/
public interface ReviewProgressQueryRepository {
ReviewProgressPageResponse findMyProgress(
String userId,
ReviewTaskStatus status,
String query,
int page,
int size
);
}

View file

@ -8,10 +8,12 @@ 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.ReviewProgressPageResponse;
import com.iflytek.skillhub.dto.ReviewTaskResponse;
import com.iflytek.skillhub.dto.SkillLifecycleMutationResponse;
import com.iflytek.skillhub.dto.SkillVersionRereleaseRequest;
import java.io.InputStream;
import java.util.List;
import java.util.Map;
import org.springframework.stereotype.Service;
@ -93,6 +95,26 @@ public class GovernanceWorkflowAppService {
return reviewPortalAppService.listMySubmissions(page, size, userId);
}
public ReviewProgressPageResponse listMyReviewProgress(
String status,
String query,
int page,
int size,
String userId) {
return reviewPortalAppService.listMyProgress(status, query, page, size, userId);
}
public List<ReviewTaskResponse> listMyReviewAttempts(Long reviewTaskId, String userId) {
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,9 +13,11 @@ 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.ReviewProgressPageResponse;
import com.iflytek.skillhub.dto.ReviewTaskResponse;
import com.iflytek.skillhub.observability.RequestIdAccessor;
import com.iflytek.skillhub.repository.GovernanceQueryRepository;
import com.iflytek.skillhub.repository.ReviewProgressQueryRepository;
import java.util.List;
import java.util.Map;
import java.util.Set;
@ -34,6 +36,7 @@ public class ReviewPortalAppService {
private final ReviewTaskRepository reviewTaskRepository;
private final NamespaceRepository namespaceRepository;
private final GovernanceQueryRepository governanceQueryRepository;
private final ReviewProgressQueryRepository reviewProgressQueryRepository;
private final RbacService rbacService;
private final AuditLogService auditLogService;
private final RequestIdAccessor requestIdAccessor;
@ -42,6 +45,7 @@ public class ReviewPortalAppService {
ReviewTaskRepository reviewTaskRepository,
NamespaceRepository namespaceRepository,
GovernanceQueryRepository governanceQueryRepository,
ReviewProgressQueryRepository reviewProgressQueryRepository,
RbacService rbacService,
AuditLogService auditLogService,
RequestIdAccessor requestIdAccessor) {
@ -49,6 +53,7 @@ public class ReviewPortalAppService {
this.reviewTaskRepository = reviewTaskRepository;
this.namespaceRepository = namespaceRepository;
this.governanceQueryRepository = governanceQueryRepository;
this.reviewProgressQueryRepository = reviewProgressQueryRepository;
this.rbacService = rbacService;
this.auditLogService = auditLogService;
this.requestIdAccessor = requestIdAccessor;
@ -212,6 +217,63 @@ public class ReviewPortalAppService {
));
}
public ReviewProgressPageResponse listMyProgress(
String status,
String query,
int page,
int size,
String userId) {
ReviewTaskStatus reviewStatus = status == null || status.isBlank()
? null
: ReviewTaskStatus.valueOf(status.toUpperCase(java.util.Locale.ROOT));
int safePage = Math.max(page, 0);
int safeSize = Math.min(Math.max(size, 1), 100);
return reviewProgressQueryRepository.findMyProgress(
userId,
reviewStatus,
query != null ? query : "",
safePage,
safeSize
);
}
public List<ReviewTaskResponse> listMyAttempts(Long reviewTaskId, String userId) {
ReviewTask anchor = reviewTaskRepository.findById(reviewTaskId)
.orElseThrow(() -> new DomainNotFoundException("review_task.not_found", reviewTaskId));
if (!anchor.getSubmittedBy().equals(userId)) {
throw new DomainForbiddenException("review.no_permission");
}
List<ReviewTask> attempts = reviewTaskRepository
.findBySubmittedByAndSkillIdAndSkillVersionOrderBySubmittedAtDescIdDesc(
userId, anchor.getSkillId(), anchor.getSkillVersion());
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));
Namespace namespace = namespaceRepository.findById(anchor.getNamespaceId())
.orElseThrow(() -> new DomainNotFoundException(
"namespace.not_found", anchor.getNamespaceId()));
if (!reviewService.canReviewNamespace(
anchor,
userId,
namespace.getType(),
normalizeRoles(userNsRoles),
platformRoles(userId))) {
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

@ -0,0 +1,27 @@
ALTER TABLE review_task
ADD COLUMN skill_id BIGINT,
ADD COLUMN skill_version VARCHAR(64);
UPDATE review_task task
SET skill_id = version.skill_id,
skill_version = version.version
FROM skill_version version
WHERE task.skill_version_id = version.id;
ALTER TABLE review_task
ALTER COLUMN skill_id SET NOT NULL,
ALTER COLUMN skill_version SET NOT NULL,
ALTER COLUMN skill_version_id DROP NOT NULL;
ALTER TABLE review_task
DROP CONSTRAINT review_task_skill_version_id_fkey,
ADD CONSTRAINT fk_review_task_skill_version
FOREIGN KEY (skill_version_id) REFERENCES skill_version(id) ON DELETE SET NULL,
ADD CONSTRAINT fk_review_task_skill
FOREIGN KEY (skill_id) REFERENCES skill(id);
CREATE INDEX idx_review_task_submitter_submitted
ON review_task(submitted_by, submitted_at DESC, id DESC);
CREATE INDEX idx_review_task_skill_version_attempts
ON review_task(skill_id, skill_version, submitted_at DESC, id DESC);

View file

@ -16,11 +16,15 @@ import com.iflytek.skillhub.domain.review.ReviewTaskStatus;
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;
import com.iflytek.skillhub.dto.SkillVersionResponse;
import com.iflytek.skillhub.repository.GovernanceQueryRepository;
import com.iflytek.skillhub.repository.ReviewProgressQueryRepository;
import com.iflytek.skillhub.service.ReviewSkillDetailAppService;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
@ -38,6 +42,7 @@ import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.request.RequestPostProcessor;
import java.util.List;
import java.time.Instant;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
@ -79,6 +84,9 @@ class ReviewPortalControllerTest {
@MockBean
private GovernanceQueryRepository governanceQueryRepository;
@MockBean
private ReviewProgressQueryRepository reviewProgressQueryRepository;
@MockBean
private RbacService rbacService;
@ -276,6 +284,188 @@ class ReviewPortalControllerTest {
verify(reviewTaskRepository, never()).findByStatus(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any());
}
@Test
void listMyReviewProgress_isScopedToAuthenticatedAuthor() throws Exception {
var item = new ReviewProgressResponse(
12L,
30L,
"team-a",
"skill-a",
"1.0.0",
"REJECTED",
"Please add tests",
Instant.parse("2026-08-31T10:00:00Z"),
Instant.parse("2026-08-31T11:00:00Z"),
2L
);
given(reviewProgressQueryRepository.findMyProgress("author-1", null, "", 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.statusCounts.pending").value(0))
.andExpect(jsonPath("$.data.statusCounts.rejected").value(1));
verify(reviewProgressQueryRepository).findMyProgress("author-1", null, "", 0, 20);
}
@Test
void listMyReviewAttempts_returnsOnlyTheAuthorsVersionHistory() throws Exception {
ReviewTask latest = createReviewTask(12L, 20L, "author-1", ReviewTaskStatus.REJECTED);
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");
ReviewTask otherAuthor = createReviewTask(7L, 20L, "author-2", ReviewTaskStatus.REJECTED);
setField(otherAuthor, "skillId", 30L);
setField(otherAuthor, "skillVersion", "1.0.0");
given(reviewTaskRepository.findById(12L)).willReturn(Optional.of(latest));
given(reviewTaskRepository.findBySubmittedByAndSkillIdAndSkillVersionOrderBySubmittedAtDescIdDesc(
"author-1", 30L, "1.0.0"))
.willReturn(List.of(latest, previous));
given(reviewTaskRepository.findBySkillIdAndSkillVersionOrderBySubmittedAtDescIdDesc(30L, "1.0.0"))
.willReturn(List.of(latest, previous, otherAuthor));
given(governanceQueryRepository.getReviewTaskResponses(List.of(latest, previous)))
.willReturn(List.of(toReviewResponse(latest), toReviewResponse(previous)));
mockMvc.perform(get("/api/v1/reviews/my-progress/12/attempts").with(auth("author-1")))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.length()").value(2))
.andExpect(jsonPath("$.data[0].id").value(12))
.andExpect(jsonPath("$.data[1].id").value(8))
.andExpect(jsonPath("$.data[0].submittedBy").value("author-1"))
.andExpect(jsonPath("$.data[1].submittedBy").value("author-1"));
verify(reviewTaskRepository, never())
.findBySkillIdAndSkillVersionOrderBySubmittedAtDescIdDesc(30L, "1.0.0");
}
@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-2", 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.canReviewNamespace(
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))
.andExpect(jsonPath("$.data[0].submittedBy").value("author-1"))
.andExpect(jsonPath("$.data[1].submittedBy").value("author-2"));
}
@Test
void listReviewAttempts_allowsNamespaceAdminToReadCrossAuthorHistory() 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-2", ReviewTaskStatus.REJECTED);
setField(previous, "skillId", 30L);
setField(previous, "skillVersion", "1.0.0");
Namespace namespace = createNamespace(20L, "team-a");
stubNamespaceRoles("namespace-admin", List.of(new NamespaceMember(
20L, "namespace-admin", NamespaceRole.ADMIN)));
given(rbacService.getUserRoleCodes("namespace-admin")).willReturn(Set.of());
given(reviewTaskRepository.findById(12L)).willReturn(Optional.of(latest));
given(namespaceRepository.findById(20L)).willReturn(Optional.of(namespace));
given(reviewService.canReviewNamespace(
latest,
"namespace-admin",
namespace.getType(),
Map.of(20L, NamespaceRole.ADMIN),
Set.of())).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("namespace-admin")))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.length()").value(2))
.andExpect(jsonPath("$.data[0].submittedBy").value("author-1"))
.andExpect(jsonPath("$.data[1].submittedBy").value("author-2"));
}
@Test
void listReviewAttempts_forbidsSubmitterWithoutReviewerRole() throws Exception {
ReviewTask ownAttempt = createReviewTask(12L, 20L, "author-1", ReviewTaskStatus.REJECTED);
setField(ownAttempt, "skillId", 30L);
setField(ownAttempt, "skillVersion", "1.0.0");
Namespace namespace = createNamespace(20L, "team-a");
stubNamespaceRoles("author-1", List.of(new NamespaceMember(
20L, "author-1", NamespaceRole.MEMBER)));
given(rbacService.getUserRoleCodes("author-1")).willReturn(Set.of());
given(reviewTaskRepository.findById(12L)).willReturn(Optional.of(ownAttempt));
given(namespaceRepository.findById(20L)).willReturn(Optional.of(namespace));
given(reviewService.canReviewNamespace(
ownAttempt,
"author-1",
namespace.getType(),
Map.of(20L, NamespaceRole.MEMBER),
Set.of())).willReturn(false);
mockMvc.perform(get("/api/v1/reviews/12/attempts").with(auth("author-1")))
.andExpect(status().isForbidden())
.andExpect(jsonPath("$.code").value(403));
verify(reviewTaskRepository, never())
.findBySkillIdAndSkillVersionOrderBySubmittedAtDescIdDesc(30L, "1.0.0");
}
@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.canReviewNamespace(
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

@ -212,7 +212,8 @@ class SkillApprovalVisibilityFlowIntegrationTest {
version = skillVersionRepository.save(version);
skillVersionRepository.flush();
ReviewTask reviewTask = reviewTaskJpaRepository.saveAndFlush(new ReviewTask(version.getId(), namespace.getId(), ownerId));
ReviewTask reviewTask = reviewTaskJpaRepository.saveAndFlush(new ReviewTask(
version.getId(), skill.getId(), namespace.getId(), version.getVersion(), ownerId));
return new PendingSkillGraph(namespace, skill, version, reviewTask);
}
@ -237,7 +238,8 @@ class SkillApprovalVisibilityFlowIntegrationTest {
version = skillVersionRepository.save(version);
skillVersionRepository.flush();
ReviewTask reviewTask = reviewTaskJpaRepository.saveAndFlush(new ReviewTask(version.getId(), namespace.getId(), ownerId));
ReviewTask reviewTask = reviewTaskJpaRepository.saveAndFlush(new ReviewTask(
version.getId(), skill.getId(), namespace.getId(), version.getVersion(), ownerId));
return new PendingSkillGraph(namespace, skill, version, reviewTask);
}

View file

@ -91,15 +91,18 @@ class SkillVersionDeleteFlowIntegrationTest {
retainedVersion.setStatus(SkillVersionStatus.REJECTED);
retainedVersion = skillVersionRepository.save(retainedVersion);
ReviewTask rejectedTask = new ReviewTask(rejectedVersion.getId(), namespace.getId(), ownerId);
ReviewTask rejectedTask = new ReviewTask(
rejectedVersion.getId(), skill.getId(), namespace.getId(), rejectedVersion.getVersion(), ownerId);
rejectedTask.setStatus(ReviewTaskStatus.REJECTED);
rejectedTask = reviewTaskRepository.save(rejectedTask);
ReviewTask approvedTask = new ReviewTask(rejectedVersion.getId(), namespace.getId(), ownerId);
ReviewTask approvedTask = new ReviewTask(
rejectedVersion.getId(), skill.getId(), namespace.getId(), rejectedVersion.getVersion(), ownerId);
approvedTask.setStatus(ReviewTaskStatus.APPROVED);
approvedTask = reviewTaskRepository.save(approvedTask);
ReviewTask retainedTask = new ReviewTask(retainedVersion.getId(), namespace.getId(), ownerId);
ReviewTask retainedTask = new ReviewTask(
retainedVersion.getId(), skill.getId(), namespace.getId(), retainedVersion.getVersion(), ownerId);
retainedTask.setStatus(ReviewTaskStatus.REJECTED);
retainedTask = reviewTaskRepository.save(retainedTask);

View file

@ -86,6 +86,32 @@ class JpaGovernanceQueryRepositoryTest {
assertThat(responses.get(0).reviewedByName()).isEqualTo("Reviewer");
}
@Test
void getReviewTaskResponses_usesSnapshotAfterReviewedVersionIsReplaced() {
ReviewTask task = new ReviewTask(null, 201L, 11L, "1.2.0", "submitter");
setField(task, "id", 6L);
setField(task, "status", ReviewTaskStatus.REJECTED);
Skill skill = new Skill(11L, "skill-a", "submitter", SkillVisibility.PUBLIC);
setField(skill, "id", 201L);
Namespace namespace = new Namespace("team-a", "Team A", "submitter");
setField(namespace, "id", 11L);
UserAccount submitter = new UserAccount("submitter", "Submitter", "submitter@example.com", null);
given(skillRepository.findByIdIn(List.of(201L))).willReturn(List.of(skill));
given(namespaceRepository.findByIdIn(List.of(11L))).willReturn(List.of(namespace));
given(userAccountRepository.findByIdIn(List.of("submitter"))).willReturn(List.of(submitter));
var responses = repository.getReviewTaskResponses(List.of(task));
assertThat(responses).singleElement().satisfies(response -> {
assertThat(response.skillVersionId()).isNull();
assertThat(response.namespace()).isEqualTo("team-a");
assertThat(response.skillSlug()).isEqualTo("skill-a");
assertThat(response.version()).isEqualTo("1.2.0");
});
}
@Test
void getPromotionResponses_assemblesPromotionReadModel() {
PromotionRequest request = new PromotionRequest(201L, 101L, 12L, "submitter");

View file

@ -0,0 +1,159 @@
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.jdbc.AutoConfigureTestDatabase;
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.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.springframework.test.context.ActiveProfiles;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@ActiveProfiles("test")
@Import(JpaReviewProgressQueryRepository.class)
@Testcontainers
class JpaReviewProgressQueryRepositoryTest {
@Container
private static final PostgreSQLContainer<?> POSTGRES =
new PostgreSQLContainer<>("postgres:16-alpine");
@DynamicPropertySource
static void configurePostgres(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", POSTGRES::getJdbcUrl);
registry.add("spring.datasource.username", POSTGRES::getUsername);
registry.add("spring.datasource.password", POSTGRES::getPassword);
registry.add("spring.datasource.driver-class-name", () -> "org.postgresql.Driver");
registry.add("spring.jpa.database-platform", () -> "org.hibernate.dialect.PostgreSQLDialect");
}
@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));
Skill gamma = entityManager.persistFlushFind(
new Skill(namespace.getId(), "gamma-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(
gamma,
namespace,
"author-1",
"3.0.0",
ReviewTaskStatus.REJECTED,
Instant.parse("2026-08-28T10: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.items()).hasSize(1);
assertThat(firstPage.total()).isEqualTo(3);
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()).isEqualTo(1);
var emptyPage = repository.findMyProgress("author-1", null, "", 8, 1);
assertThat(emptyPage.items()).isEmpty();
assertThat(emptyPage.total()).isEqualTo(3);
var maximumPage = repository.findMyProgress(
"author-1", null, "", Integer.MAX_VALUE, 100);
assertThat(maximumPage.items()).isEmpty();
assertThat(maximumPage.total()).isEqualTo(3);
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);
var searchMiss = repository.findMyProgress("author-1", null, "missing", 0, 20);
assertThat(searchMiss.items()).isEmpty();
assertThat(searchMiss.total()).isZero();
assertThat(searchMiss.statusCounts().pending()).isZero();
assertThat(searchMiss.statusCounts().approved()).isZero();
assertThat(searchMiss.statusCounts().rejected()).isZero();
}
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

@ -611,6 +611,18 @@ class ScanTaskConsumerTest {
throw unsupported();
}
@Override
public List<ReviewTask> findBySubmittedByAndSkillIdAndSkillVersionOrderBySubmittedAtDescIdDesc(
String submittedBy, Long skillId, String skillVersion) {
throw unsupported();
}
@Override
public List<ReviewTask> findBySkillIdAndSkillVersionOrderBySubmittedAtDescIdDesc(
Long skillId, String skillVersion) {
throw unsupported();
}
@Override
public boolean existsByNamespaceId(Long namespaceId) {
return false;
@ -621,6 +633,11 @@ class ScanTaskConsumerTest {
throw unsupported();
}
@Override
public void deleteBySkillId(Long skillId) {
throw unsupported();
}
@Override
public void delete(ReviewTask reviewTask) {
this.deletedTask = reviewTask;

View file

@ -111,7 +111,8 @@ public class ReviewService {
skillVersion.setStatus(SkillVersionStatus.PENDING_REVIEW);
skillVersionRepository.save(skillVersion);
ReviewTask task = new ReviewTask(skillVersionId, skill.getNamespaceId(), userId);
ReviewTask task = new ReviewTask(
skillVersionId, skill.getId(), skill.getNamespaceId(), skillVersion.getVersion(), userId);
try {
ReviewTask saved = reviewTaskRepository.save(task);
eventPublisher.publishEvent(new ReviewSubmittedEvent(
@ -153,7 +154,8 @@ public class ReviewService {
skillVersion.setStatus(SkillVersionStatus.PENDING_REVIEW);
skillVersionRepository.save(skillVersion);
ReviewTask task = new ReviewTask(skillVersionId, skill.getNamespaceId(), userId);
ReviewTask task = new ReviewTask(
skillVersionId, skill.getId(), skill.getNamespaceId(), skillVersion.getVersion(), userId);
try {
ReviewTask saved = reviewTaskRepository.save(task);
eventPublisher.publishEvent(new ReviewSubmittedEvent(

View file

@ -11,9 +11,15 @@ public class ReviewTask {
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "skill_version_id", nullable = false)
@Column(name = "skill_version_id")
private Long skillVersionId;
@Column(name = "skill_id", nullable = false)
private Long skillId;
@Column(name = "skill_version", nullable = false, length = 64)
private String skillVersion;
@Column(name = "namespace_id", nullable = false)
private Long namespaceId;
@ -49,10 +55,23 @@ public class ReviewTask {
this.submittedBy = submittedBy;
}
public ReviewTask(Long skillVersionId, Long skillId, Long namespaceId,
String skillVersion, String submittedBy) {
this.skillVersionId = skillVersionId;
this.skillId = skillId;
this.namespaceId = namespaceId;
this.skillVersion = skillVersion;
this.submittedBy = submittedBy;
}
public Long getId() { return id; }
public Long getSkillVersionId() { return skillVersionId; }
public Long getSkillId() { return skillId; }
public String getSkillVersion() { return skillVersion; }
public Long getNamespaceId() { return namespaceId; }
public ReviewTaskStatus getStatus() { return status; }

View file

@ -3,6 +3,7 @@ package com.iflytek.skillhub.domain.review;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import java.util.Collection;
import java.util.List;
import java.util.Optional;
/**
@ -15,8 +16,13 @@ public interface ReviewTaskRepository {
Page<ReviewTask> findByStatus(ReviewTaskStatus status, Pageable pageable);
Page<ReviewTask> findByNamespaceIdAndStatus(Long namespaceId, ReviewTaskStatus status, Pageable pageable);
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);
void delete(ReviewTask reviewTask);
int updateStatusWithVersion(Long id, ReviewTaskStatus status, String reviewedBy,
String reviewComment, Integer expectedVersion);

View file

@ -107,9 +107,8 @@ public class SkillHardDeleteService {
skillRepository.save(skill);
skillRepository.flush();
if (!versionIds.isEmpty()) {
reviewTaskRepository.deleteBySkillVersionIdIn(versionIds);
}
// Also removes detached historical attempts whose replaced skill version no longer exists.
reviewTaskRepository.deleteBySkillId(skill.getId());
promotionRequestRepository.deleteBySourceSkillIdOrTargetSkillId(skill.getId(), skill.getId());
skillTagRepository.deleteBySkillId(skill.getId());
skillStarRepository.deleteBySkillId(skill.getId());

View file

@ -561,7 +561,8 @@ public class SkillPublishService {
// Create review task for PUBLIC/NAMESPACE_ONLY (not PRIVATE)
if (!autoPublish && visibility != SkillVisibility.PRIVATE) {
ReviewTask reviewTask = new ReviewTask(version.getId(), namespace.getId(), publisherId);
ReviewTask reviewTask = new ReviewTask(
version.getId(), skill.getId(), namespace.getId(), version.getVersion(), publisherId);
ReviewTask savedReviewTask = reviewTaskRepository.save(reviewTask);
eventPublisher.publishEvent(new ReviewSubmittedEvent(
savedReviewTask.getId(),
@ -608,10 +609,11 @@ public class SkillPublishService {
skillRepository.flush();
}
// Every review task referencing this version has to go, not just a PENDING one:
// a rejected version still owns a REJECTED task whose foreign key blocks the
// skill_version delete below, which surfaces to the caller as an HTTP 500.
reviewTaskRepository.deleteBySkillVersionIdIn(List.of(version.getId()));
// A replaceable version may still have one obsolete pending task, but settled attempts are
// durable governance history. The database detaches those settled attempts from the
// replaced version while retaining their skill/version snapshot.
reviewTaskRepository.findBySkillVersionIdAndStatus(version.getId(), ReviewTaskStatus.PENDING)
.ifPresent(reviewTaskRepository::delete);
List<SkillFile> files = skillFileRepository.findByVersionId(version.getId());
List<String> storageKeys = new ArrayList<>();

View file

@ -95,7 +95,8 @@ public class SkillReviewSubmitService {
skillVersionRepository.save(version);
// Create review task
ReviewTask reviewTask = new ReviewTask(versionId, skill.getNamespaceId(), actorUserId);
ReviewTask reviewTask = new ReviewTask(
versionId, skill.getId(), skill.getNamespaceId(), version.getVersion(), actorUserId);
reviewTaskRepository.save(reviewTask);
}

View file

@ -124,7 +124,7 @@ class SkillHardDeleteServiceTest {
inOrder.verify(skillRepository).save(skill);
inOrder.verify(skillRepository).flush();
inOrder.verify(skillVersionRepository).deleteBySkillId(7L);
verify(reviewTaskRepository).deleteBySkillVersionIdIn(List.of(21L, 22L));
verify(reviewTaskRepository).deleteBySkillId(7L);
verify(promotionRequestRepository).deleteBySourceSkillIdOrTargetSkillId(7L, 7L);
verify(skillTagRepository).deleteBySkillId(7L);
verify(skillStarRepository).deleteBySkillId(7L);

View file

@ -424,7 +424,7 @@ class SkillPublishServiceTest {
}
@Test
void testPublishFromEntries_ShouldReplaceRejectedVersionWithSameVersion() throws Exception {
void testPublishFromEntries_ShouldPreserveSettledReviewHistoryWhenReplacingRejectedVersion() throws Exception {
String namespaceSlug = "test-ns";
String publisherId = "user-100";
String skillMdContent = "---\nname: test-skill\ndescription: Test\nversion: 1.0.0\n---\nBody";
@ -473,7 +473,7 @@ class SkillPublishServiceTest {
assertEquals("1.0.0", result.version().getVersion());
assertEquals(SkillVersionStatus.PENDING_REVIEW, result.version().getStatus());
verify(reviewTaskRepository).deleteBySkillVersionIdIn(List.of(8L));
verify(reviewTaskRepository, never()).deleteBySkillVersionIdIn(List.of(8L));
verify(skillFileRepository).deleteByVersionId(8L);
verify(skillVersionRepository).delete(rejectedVersion);
verify(skillVersionRepository, times(2)).flush();
@ -482,6 +482,8 @@ class SkillPublishServiceTest {
ArgumentCaptor<ReviewTask> reviewTaskCaptor = ArgumentCaptor.forClass(ReviewTask.class);
verify(reviewTaskRepository).save(reviewTaskCaptor.capture());
assertEquals(result.version().getId(), reviewTaskCaptor.getValue().getSkillVersionId());
assertEquals(skill.getId(), reviewTaskCaptor.getValue().getSkillId());
assertEquals("1.0.0", reviewTaskCaptor.getValue().getSkillVersion());
assertEquals(publisherId, reviewTaskCaptor.getValue().getSubmittedBy());
}

View file

@ -11,6 +11,7 @@ import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.Collection;
import java.util.List;
import java.util.Optional;
/**
@ -28,10 +29,18 @@ public interface ReviewTaskJpaRepository extends JpaRepository<ReviewTask, Long>
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);
@Modifying
@Query("""
UPDATE ReviewTask t

View file

@ -1,6 +1,6 @@
import { expect, test } from '@playwright/test'
import { setEnglishLocale } from './helpers/auth-fixtures'
import { loginWithCredentials, registerSession } from './helpers/session'
import { createFreshSession, loginWithCredentials } from './helpers/session'
import { E2eTestDataBuilder } from './helpers/test-data-builder'
function getOptionalEnv(name: string): string | undefined {
@ -15,20 +15,39 @@ function adminCredentials() {
}
}
function withoutKnownMetaCspWarning(messages: string[]): string[] {
return messages.filter((message) => (
!message.includes("frame-ancestors' is ignored when delivered via a <meta> element")
))
}
test.describe('Rejected version replacement (Real API)', () => {
test.describe.configure({ timeout: 150_000 })
test.beforeEach(async ({ page }, testInfo) => {
await setEnglishLocale(page)
await registerSession(page, testInfo)
await createFreshSession(page, testInfo)
})
test('re-publishes the same version after rejection', async ({ page, browser }, testInfo) => {
const consoleErrors: string[] = []
const pageErrors: string[] = []
const adminConsoleErrors: string[] = []
const adminPageErrors: string[] = []
page.on('console', (message) => {
if (message.type() === 'error') consoleErrors.push(message.text())
})
page.on('pageerror', (error) => pageErrors.push(error.message))
const publisherBuilder = new E2eTestDataBuilder(page, testInfo)
await publisherBuilder.init()
const adminContext = await browser.newContext()
const adminPage = await adminContext.newPage()
adminPage.on('console', (message) => {
if (message.type() === 'error') adminConsoleErrors.push(message.text())
})
adminPage.on('pageerror', (error) => adminPageErrors.push(error.message))
const adminBuilder = new E2eTestDataBuilder(adminPage, testInfo)
await loginWithCredentials(adminPage, adminCredentials(), testInfo)
await adminBuilder.init()
@ -52,6 +71,23 @@ test.describe('Rejected version replacement (Real API)', () => {
'PENDING_REVIEW',
)
await adminBuilder.rejectReview(rejectedReviewId)
await publisherBuilder.waitForVersionStatus(
namespace.slug,
firstPublish.slug,
firstPublish.version,
'REJECTED',
)
await page.goto('/dashboard/review-progress')
await expect(page.getByRole('heading', { name: 'My Review Progress' })).toBeVisible()
const rejectedCard = page.locator('article').filter({ hasText: firstPublish.slug })
await expect(rejectedCard).toContainText('Rejected')
await expect(rejectedCard).toContainText('1 submission')
await rejectedCard.getByRole('button', { name: 'Submission history' }).click()
await expect(rejectedCard).toContainText('Rejected by Playwright E2E')
await rejectedCard.getByRole('link', { name: 'Edit and resubmit' }).click()
await expect(page).toHaveURL(/\/dashboard\/publish/)
await expect(page.getByText(new RegExp(`Resubmit .* v${firstPublish.version}`))).toBeVisible()
const replacement = await publisherBuilder.publishSkill(namespace.slug, {
name: skillName,
@ -74,8 +110,122 @@ test.describe('Rejected version replacement (Real API)', () => {
expect(replacement.version).toBe(firstPublish.version)
expect(replacementReviewId).not.toBe(rejectedReviewId)
const progressResponse = await page.request.get(
`/api/web/reviews/my-progress?q=${encodeURIComponent(replacement.slug)}&page=0&size=20`,
)
expect(progressResponse.status()).toBe(200)
const progressBody = await progressResponse.json() as {
data: {
items: Array<{ latestStatus: string; attemptCount: number }>
total: number
statusCounts: { pending: number; approved: number; rejected: number }
}
}
expect(progressBody.data.total).toBe(1)
expect(progressBody.data.items).toHaveLength(1)
expect(progressBody.data.items[0]).toMatchObject({ latestStatus: 'PENDING', attemptCount: 2 })
expect(progressBody.data.statusCounts).toEqual({ pending: 1, approved: 0, rejected: 0 })
const rejectedFilterResponse = await page.request.get(
`/api/web/reviews/my-progress?q=${encodeURIComponent(replacement.slug)}&status=REJECTED&page=0&size=20`,
)
expect(rejectedFilterResponse.status()).toBe(200)
const rejectedFilterBody = await rejectedFilterResponse.json() as {
data: { items: unknown[]; total: number; statusCounts: { pending: number } }
}
expect(rejectedFilterBody.data.items).toEqual([])
expect(rejectedFilterBody.data.total).toBe(0)
expect(rejectedFilterBody.data.statusCounts.pending).toBe(1)
const missingSearchResponse = await page.request.get(
'/api/web/reviews/my-progress?q=definitely-missing-review-progress&page=0&size=20',
)
expect(missingSearchResponse.status()).toBe(200)
const missingSearchBody = await missingSearchResponse.json() as {
data: { items: unknown[]; total: number; statusCounts: { pending: number; approved: number; rejected: number } }
}
expect(missingSearchBody.data.items).toEqual([])
expect(missingSearchBody.data.total).toBe(0)
expect(missingSearchBody.data.statusCounts).toEqual({ pending: 0, approved: 0, rejected: 0 })
const outOfRangeResponse = await page.request.get(
`/api/web/reviews/my-progress?q=${encodeURIComponent(replacement.slug)}&page=99&size=1`,
)
expect(outOfRangeResponse.status()).toBe(200)
const outOfRangeBody = await outOfRangeResponse.json() as {
data: { items: unknown[]; total: number; statusCounts: { pending: number } }
}
expect(outOfRangeBody.data.items).toEqual([])
expect(outOfRangeBody.data.total).toBe(1)
expect(outOfRangeBody.data.statusCounts.pending).toBe(1)
const maximumPageResponse = await page.request.get(
`/api/web/reviews/my-progress?q=${encodeURIComponent(replacement.slug)}&page=2147483647&size=100`,
)
expect(maximumPageResponse.status()).toBe(200)
const maximumPageBody = await maximumPageResponse.json() as {
data: { items: unknown[]; total: number; statusCounts: { pending: number } }
}
expect(maximumPageBody.data.items).toEqual([])
expect(maximumPageBody.data.total).toBe(1)
expect(maximumPageBody.data.statusCounts.pending).toBe(1)
const attemptsResponse = await page.request.get(
`/api/web/reviews/my-progress/${replacementReviewId}/attempts`,
)
expect(attemptsResponse.status()).toBe(200)
const attemptsBody = await attemptsResponse.json() as {
data: Array<{ id: number; status: string; skillVersionId: number | null }>
}
expect(attemptsBody.data.map((attempt) => attempt.id)).toEqual([
replacementReviewId,
rejectedReviewId,
])
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(404)
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')
await progressCard.getByRole('button', { name: 'Submission history' }).click()
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 expect(progressCard.getByRole('link', { name: 'Edit and resubmit' })).toHaveCount(0)
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()
expect(withoutKnownMetaCspWarning(consoleErrors)).toEqual([])
expect(pageErrors).toEqual([])
expect(withoutKnownMetaCspWarning(adminConsoleErrors)).toEqual([])
expect(adminPageErrors).toEqual([])
} finally {
await adminBuilder.cleanup()
await adminContext.close()

View file

@ -0,0 +1,213 @@
import { expect, test } from '@playwright/test'
import { setEnglishLocale } from './helpers/auth-fixtures'
test.describe('Light and dark theme', () => {
test.beforeEach(async ({ page }) => {
await setEnglishLocale(page)
await page.context().setExtraHTTPHeaders({ 'X-Mock-User-Id': 'local-user' })
await page.route('**/api/v1/auth/me', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
code: 0,
msg: 'success',
data: {
userId: 'theme-layout-user',
displayName: 'Theme Layout User',
avatarUrl: 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw==',
platformRoles: [],
oauthProvider: 'local',
canChangePassword: true,
},
timestamp: '2026-09-01T00:00:00Z',
requestId: 'theme-auth-fixture',
}),
})
})
await page.route('**/api/web/me/namespaces', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
code: 0,
msg: 'success',
data: [],
timestamp: '2026-09-01T00:00:00Z',
requestId: 'theme-namespace-fixture',
}),
})
})
await page.route('**/api/web/notifications/unread-count', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
code: 0,
msg: 'success',
data: { count: 1 },
timestamp: '2026-09-01T00:00:00Z',
requestId: 'theme-unread-fixture',
}),
})
})
await page.route('**/api/web/notifications/sse', async (route) => {
await route.fulfill({ status: 204 })
})
await page.route('**/api/web/me/stars?*', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
code: 0,
msg: 'success',
data: { items: [], total: 0, page: 0, size: 100 },
timestamp: '2026-09-01T00:00:00Z',
requestId: 'theme-stars-fixture',
}),
})
})
await page.addInitScript(() => {
const observedWindow = window as Window & { __themeAtFirstReactContent?: boolean }
const observer = new MutationObserver(() => {
const root = document.querySelector('#root')
if (root?.childElementCount) {
observedWindow.__themeAtFirstReactContent = document.documentElement.classList.contains('dark')
observer.disconnect()
}
})
observer.observe(document, { childList: true, subtree: true })
if (!window.sessionStorage.getItem('theme-test-initialized')) {
window.localStorage.removeItem('skillhub-theme')
window.sessionStorage.setItem('theme-test-initialized', 'true')
}
})
})
test('switches themes and restores only the browser-local selection', async ({ page }, testInfo) => {
const consoleErrors: string[] = []
const pageErrors: string[] = []
page.on('console', (message) => {
if (message.type() === 'error') consoleErrors.push(message.text())
})
page.on('pageerror', (error) => pageErrors.push(error.stack ?? error.message))
await page.route('**/api/web/notifications?*', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
code: 0,
msg: 'success',
data: {
items: [{
id: 9001,
category: 'REVIEW',
eventType: 'REVIEW_SUBMITTED',
title: 'Theme notification fixture',
bodyJson: JSON.stringify({ skillName: 'Theme preview', version: '1.0.0' }),
targetRoute: '/search',
status: 'UNREAD',
createdAt: '2026-09-01T00:00:00Z',
}],
total: 1,
page: 0,
size: 5,
},
timestamp: '2026-09-01T00:00:00Z',
requestId: 'theme-notification-fixture',
}),
})
})
await page.goto('/')
await expect(page.locator('html')).not.toHaveClass(/dark/)
const header = page.locator('header')
const lightHeaderBackground = await header.evaluate((element) => getComputedStyle(element).backgroundColor)
const themeSwitch = page.getByRole('switch', { name: 'Dark theme' })
await expect(themeSwitch).toHaveAttribute('aria-checked', 'false')
await themeSwitch.click()
await expect(page.locator('html')).toHaveClass(/dark/)
await expect(themeSwitch).toHaveAttribute('aria-checked', 'true')
await expect.poll(() => header.evaluate((element) => getComputedStyle(element).backgroundColor))
.not.toBe(lightHeaderBackground)
await expect.poll(() => page.evaluate(() => window.localStorage.getItem('skillhub-theme'))).toBe('dark')
const destructiveContrast = await page.evaluate(() => {
const probe = document.createElement('button')
probe.className = 'bg-destructive text-destructive-foreground'
probe.textContent = 'Destructive contrast probe'
document.body.append(probe)
const styles = getComputedStyle(probe)
const luminance = (color: string) => {
const channels = color.match(/[\d.]+/g)?.slice(0, 3).map(Number)
if (!channels || channels.length !== 3) {
throw new Error(`Unable to parse computed color: ${color}`)
}
const linear = channels.map((channel) => {
const normalized = channel / 255
return normalized <= 0.04045
? normalized / 12.92
: ((normalized + 0.055) / 1.055) ** 2.4
})
return 0.2126 * linear[0] + 0.7152 * linear[1] + 0.0722 * linear[2]
}
const background = luminance(styles.backgroundColor)
const foreground = luminance(styles.color)
probe.remove()
return (Math.max(background, foreground) + 0.05) / (Math.min(background, foreground) + 0.05)
})
expect(destructiveContrast).toBeGreaterThanOrEqual(4.5)
await page.reload()
await expect(page.locator('html')).toHaveClass(/dark/)
await expect(page.getByRole('switch', { name: 'Dark theme' })).toHaveAttribute('aria-checked', 'true')
await expect(page.getByRole('heading', { name: 'SkillHub', exact: true })).toBeVisible()
await expect.poll(() => page.evaluate(() => (
window as Window & { __themeAtFirstReactContent?: boolean }
).__themeAtFirstReactContent)).toBe(true)
await page.getByRole('link', { name: 'Search', exact: true }).first().click()
await expect(page).toHaveURL(/\/search(?:\?|$)/)
await expect(page.locator('html')).toHaveClass(/dark/)
await expect(page.getByPlaceholder('Search skills...')).toBeVisible()
await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true)
await page.screenshot({ path: testInfo.outputPath('dark-desktop.png'), fullPage: true })
const notificationButton = page.getByRole('button', { name: 'Notifications' })
await notificationButton.click()
await expect(page.getByText('Notifications', { exact: true })).toBeVisible()
const firstNotification = page.getByRole('link').filter({ hasText: 'Review submitted' })
await expect(firstNotification).toBeVisible()
const backgroundBeforeHover = await firstNotification.evaluate((element) => getComputedStyle(element).backgroundColor)
await firstNotification.hover()
await expect.poll(() => firstNotification.evaluate((element) => getComputedStyle(element).backgroundColor))
.not.toBe(backgroundBeforeHover)
await page.screenshot({ path: testInfo.outputPath('dark-notifications.png'), fullPage: true })
await notificationButton.click()
await page.setViewportSize({ width: 390, height: 844 })
await expect(page.getByRole('switch', { name: 'Dark theme' })).toBeVisible()
await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true)
await page.screenshot({ path: testInfo.outputPath('dark-mobile.png'), fullPage: true })
await page.setViewportSize({ width: 320, height: 568 })
const headerControls = page.locator('header > div')
const [headerBox, controlsBox] = await Promise.all([header.boundingBox(), headerControls.boundingBox()])
expect(headerBox).not.toBeNull()
expect(controlsBox).not.toBeNull()
expect((controlsBox?.x ?? 0) + (controlsBox?.width ?? 0)).toBeLessThanOrEqual(
(headerBox?.x ?? 0) + (headerBox?.width ?? 0),
)
await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true)
await page.screenshot({ path: testInfo.outputPath('dark-mobile-320.png'), fullPage: true })
const unexpectedConsoleErrors = consoleErrors.filter((message) => (
!message.includes("frame-ancestors' is ignored when delivered via a <meta> element")
))
expect(unexpectedConsoleErrors).toEqual([])
expect(pageErrors).toEqual([])
})
})

View file

@ -15,6 +15,7 @@ import type {
MergeInitiateResponse,
MergeVerifyRequest,
ReviewSkillDetail,
ReviewProgressPage,
ReviewTask,
PromotionSortBy,
PromotionSortDirection,
@ -882,6 +883,25 @@ export const reviewApi = {
return fetchJson<ReviewTask>(`${WEB_API_PREFIX}/reviews/${id}`)
},
async listMyProgress(params: { status?: string; q?: string; page?: number; size?: number }) {
const searchParams = new URLSearchParams()
if (params.status) searchParams.set('status', params.status)
if (params.q) searchParams.set('q', params.q)
searchParams.set('page', String(params.page ?? 0))
searchParams.set('size', String(params.size ?? 20))
return fetchJson<ReviewProgressPage>(
`${WEB_API_PREFIX}/reviews/my-progress?${searchParams.toString()}`,
)
},
async listMyAttempts(reviewTaskId: number): Promise<ReviewTask[]> {
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;
@ -2500,6 +2532,70 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/web/reviews/my-progress/{id}/attempts": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["listMyAttempts"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/reviews/my-progress/{id}/attempts": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["listMyAttempts_1"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/web/reviews/my-progress": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["listMyProgress"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/reviews/my-progress": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["listMyProgress_1"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/web/promotions/{id}": {
parameters: {
query?: never;
@ -4629,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;
@ -4647,6 +4752,50 @@ export interface components {
/** Format: int32 */
size?: number;
};
ApiResponseReviewProgressPageResponse: {
/** Format: int32 */
code?: number;
msg?: string;
data?: components["schemas"]["ReviewProgressPageResponse"];
/** Format: date-time */
timestamp?: string;
requestId?: string;
};
ReviewProgressPageResponse: {
items?: components["schemas"]["ReviewProgressResponse"][];
/** Format: int64 */
total?: number;
/** Format: int32 */
page?: number;
/** Format: int32 */
size?: number;
statusCounts?: components["schemas"]["ReviewProgressStatusCounts"];
};
ReviewProgressResponse: {
/** Format: int64 */
latestReviewTaskId?: number;
/** Format: int64 */
skillId?: number;
namespace?: string;
skillSlug?: string;
skillVersion?: string;
latestStatus?: string;
latestReviewComment?: string;
/** Format: date-time */
latestSubmittedAt?: string;
/** Format: date-time */
latestReviewedAt?: string;
/** Format: int64 */
attemptCount?: number;
};
ReviewProgressStatusCounts: {
/** Format: int64 */
pending?: number;
/** Format: int64 */
approved?: number;
/** Format: int64 */
rejected?: number;
};
ApiResponsePageResponsePromotionResponseDto: {
/** Format: int32 */
code?: number;
@ -10029,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;
@ -10167,6 +10360,100 @@ export interface operations {
};
};
};
listMyAttempts: {
parameters: {
query?: never;
header?: never;
path: {
id: number;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["ApiResponseListReviewTaskResponse"];
};
};
};
};
listMyAttempts_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"];
};
};
};
};
listMyProgress: {
parameters: {
query?: {
status?: string;
q?: string;
page?: number;
size?: number;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["ApiResponseReviewProgressPageResponse"];
};
};
};
};
listMyProgress_1: {
parameters: {
query?: {
status?: string;
q?: string;
page?: number;
size?: number;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["ApiResponseReviewProgressPageResponse"];
};
};
};
};
getPromotionDetail: {
parameters: {
query?: never;

View file

@ -427,7 +427,7 @@ export interface SkillDeleteResult {
export interface ReviewTask {
id: number
skillVersionId: number
skillVersionId: number | null
namespace: string
skillSlug: string
version: string
@ -441,6 +441,33 @@ export interface ReviewTask {
reviewedAt?: string
}
export interface ReviewProgress {
latestReviewTaskId: number
skillId: number
namespace: string
skillSlug: string
skillVersion: string
latestStatus: 'PENDING' | 'APPROVED' | 'REJECTED'
latestReviewComment?: string
latestSubmittedAt: string
latestReviewedAt?: string
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

@ -3,7 +3,11 @@ import { APP_HEADER_ELEVATED_CLASS_NAME, getAppHeaderClassName } from './layout-
describe('getAppHeaderClassName', () => {
it('keeps the header flat before the page starts scrolling', () => {
expect(getAppHeaderClassName(false)).not.toContain(APP_HEADER_ELEVATED_CLASS_NAME)
const className = getAppHeaderClassName(false)
expect(className).not.toContain(APP_HEADER_ELEVATED_CLASS_NAME)
expect(className).toContain('bg-background/90')
expect(className).not.toContain('bg-white')
})
it('adds a subtle drop shadow after the header becomes sticky', () => {

View file

@ -1,9 +1,10 @@
import { cn } from '@/shared/lib/utils'
export const APP_HEADER_BASE_CLASS_NAME =
'sticky top-0 z-50 flex items-center justify-between border-b bg-white px-6 py-4 transition-shadow duration-200 md:px-12'
'sticky top-0 z-50 flex items-center justify-between border-b border-border/70 bg-background/90 px-4 py-4 backdrop-blur-xl transition-[background-color,border-color,box-shadow] duration-200 supports-[backdrop-filter]:bg-background/80 sm:px-6 md:px-12'
export const APP_HEADER_ELEVATED_CLASS_NAME = 'shadow-[0_10px_24px_-20px_rgba(15,23,42,0.32)]'
export const APP_HEADER_ELEVATED_CLASS_NAME =
'shadow-[0_12px_30px_-24px_hsl(var(--foreground)/0.45)]'
export function getAppHeaderClassName(isElevated: boolean): string {
return cn(APP_HEADER_BASE_CLASS_NAME, isElevated && APP_HEADER_ELEVATED_CLASS_NAME)

View file

@ -3,6 +3,7 @@ import { Outlet, Link, useRouterState } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next'
import { useAuth } from '@/features/auth/use-auth'
import { LanguageSwitcher } from '@/shared/components/language-switcher'
import { ThemeToggle } from '@/shared/components/theme-toggle'
import { UserMenu } from '@/shared/components/user-menu'
import { NotificationBell } from '@/features/notification/notification-bell'
import { dismissOpenOverlays } from '@/shared/lib/dismiss-open-overlays'
@ -82,7 +83,7 @@ export function Layout() {
<div
className="absolute top-0 right-0 w-[600px] h-[500px] rounded-full opacity-90"
style={{
background: 'radial-gradient(ellipse at 70% 20%, rgba(184,94,255,0.25) 0%, rgba(106,109,255,0.15) 40%, transparent 70%)',
background: 'radial-gradient(ellipse at 70% 20%, hsl(var(--glow-accent) / 0.12) 0%, hsl(var(--glow-primary) / 0.07) 40%, transparent 70%)',
filter: 'blur(60px)',
}}
/>
@ -115,7 +116,8 @@ export function Layout() {
})}
</nav>
<div className="flex items-center gap-6 text-[15px] font-normal" style={{ color: 'hsl(var(--text-secondary))' }}>
<div className="flex items-center gap-2 text-[15px] font-normal sm:gap-3" style={{ color: 'hsl(var(--text-secondary))' }}>
<ThemeToggle />
<LanguageSwitcher />
{user && <NotificationBell />}
{isLoading ? null : user ? (
@ -149,7 +151,7 @@ export function Layout() {
</main>
{/* Footer */}
<footer className="relative z-10 border-t rounded-t-2xl mt-auto" style={{ background: '#F1F5F9', borderColor: 'hsl(var(--border))' }}>
<footer className="relative z-10 mt-auto rounded-t-2xl border-t bg-secondary/70" style={{ borderColor: 'hsl(var(--border))' }}>
<div className="max-w-6xl mx-auto px-6 md:px-12 py-10">
<div className="flex flex-col md:flex-row md:items-start md:justify-between gap-10 md:gap-12">
<div className="flex-shrink-0">

View file

@ -103,6 +103,10 @@ const NamespaceReviewDetailPage = createLazyRouteComponent(
)
const GovernancePage = createLazyRouteComponent(() => import('@/pages/dashboard/governance'), 'GovernancePage')
const ReviewsPage = createLazyRouteComponent(() => import('@/pages/dashboard/reviews'), 'ReviewsPage')
const ReviewProgressPage = createLazyRouteComponent(
() => import('@/pages/dashboard/review-progress'),
'ReviewProgressPage',
)
const ReportsPage = createRoleProtectedRouteComponent(
() => import('@/pages/dashboard/reports'),
'ReportsPage',
@ -283,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,
})
@ -328,6 +343,24 @@ const dashboardReviewsRoute = createRoute({
component: ReviewsPage,
})
const dashboardReviewProgressRoute = createRoute({
getParentRoute: () => rootRoute,
path: 'dashboard/review-progress',
beforeLoad: requireAuth,
validateSearch: (search: Record<string, unknown>): {
status?: 'PENDING' | 'APPROVED' | 'REJECTED'
q?: string
page?: number
} => ({
status: search.status === 'PENDING' || search.status === 'APPROVED' || search.status === 'REJECTED'
? search.status
: undefined,
q: typeof search.q === 'string' && search.q.trim() ? search.q.trim() : undefined,
page: typeof search.page === 'number' && search.page > 0 ? search.page : undefined,
}),
component: ReviewProgressPage,
})
const dashboardReportsRoute = createRoute({
getParentRoute: () => rootRoute,
path: 'dashboard/reports',
@ -478,6 +511,7 @@ const routeTree = rootRoute.addChildren([
dashboardNamespaceReviewDetailRoute,
dashboardGovernanceRoute,
dashboardReviewsRoute,
dashboardReviewProgressRoute,
dashboardReportsRoute,
dashboardReviewDetailRoute,
dashboardPromotionsRoute,

View file

@ -5,6 +5,10 @@
* that config or a safe fallback object before importing the main entry.
*/
import './legacy-polyfills'
import { initializeTheme } from './shared/lib/theme'
initializeTheme()
async function loadRuntimeConfig() {
await new Promise<void>((resolve, reject) => {
const script = document.createElement('script')

View file

@ -45,7 +45,7 @@ export function NotificationBell() {
type="button"
aria-label={t('notification.title')}
onClick={() => setOpen((v) => !v)}
className="relative flex items-center justify-center w-8 h-8 rounded-full hover:bg-gray-100 transition-colors"
className="relative flex h-8 w-8 items-center justify-center rounded-full transition-colors hover:bg-muted"
>
{/* Bell SVG */}
<svg

View file

@ -37,7 +37,7 @@ export function NotificationDropdown({ onClose }: Props) {
return (
<div
className="absolute right-0 top-10 z-50 w-80 rounded-xl border bg-white shadow-lg"
className="absolute right-0 top-10 z-50 w-80 rounded-xl border bg-popover text-popover-foreground shadow-lg"
style={{ borderColor: 'hsl(var(--border))' }}
>
{/* Header */}
@ -75,7 +75,7 @@ export function NotificationDropdown({ onClose }: Props) {
<Link
to={resolveNotificationTarget(item)}
onClick={() => handleItemClick(item)}
className="flex items-start gap-3 px-4 py-3 hover:bg-gray-50 transition-colors"
className="flex items-start gap-3 px-4 py-3 transition-colors hover:bg-muted"
>
{/* Unread dot */}
<span className={`mt-1.5 flex-shrink-0 w-2 h-2 rounded-full ${item.status === 'UNREAD' ? 'bg-red-500' : 'bg-transparent'}`} />

View file

@ -27,7 +27,10 @@ export function createNotificationSseConnection(
url: string,
eventSourceFactory: EventSourceFactory = (targetUrl) =>
new EventSource(targetUrl, { withCredentials: true }),
timerApi: TimerApi = { setTimeout, clearTimeout },
timerApi: TimerApi = {
setTimeout: (...args) => globalThis.setTimeout(...args),
clearTimeout: (timer) => globalThis.clearTimeout(timer),
},
): NotificationSseConnection {
return new ManagedNotificationSseConnection(url, eventSourceFactory, timerApi)
}

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

@ -0,0 +1,24 @@
import { useQuery } from '@tanstack/react-query'
import { reviewApi } from '@/api/client'
interface MyReviewProgressParams {
status?: string
q?: string
page: number
size: number
}
export function useMyReviewProgress(params: MyReviewProgressParams) {
return useQuery({
queryKey: ['reviews', 'my-progress', params],
queryFn: () => reviewApi.listMyProgress(params),
})
}
export function useMyReviewAttempts(reviewTaskId: number | null) {
return useQuery({
queryKey: ['reviews', 'my-progress', reviewTaskId, 'attempts'],
queryFn: () => reviewApi.listMyAttempts(reviewTaskId!),
enabled: reviewTaskId !== null,
})
}

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

@ -46,7 +46,7 @@ export function SkillCard({ skill, onClick, highlightStarred = true }: SkillCard
return (
<Card
className="h-full p-5 cursor-pointer group relative overflow-hidden bg-white border shadow-sm transition-shadow hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/70 focus-visible:ring-offset-2"
className="group relative h-full cursor-pointer overflow-hidden border bg-card p-5 text-card-foreground shadow-sm transition-shadow hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/70 focus-visible:ring-offset-2"
style={{ borderColor: 'hsl(var(--border-card))' }}
onClick={onClick}
onKeyDown={(event) => {

View file

@ -147,8 +147,8 @@ export function SkillLabelPanel({ namespace, slug, initialLabels, canManage, isS
className={cn(
'inline-flex items-center rounded-full border px-2.5 py-1 text-[11px] font-medium',
label.type === 'PRIVILEGED'
? 'border-amber-500/40 bg-amber-100 text-amber-900'
: 'border-slate-300 bg-slate-100 text-slate-800',
? 'border-amber-500/40 bg-amber-100 text-amber-900 dark:bg-amber-950/60 dark:text-amber-300'
: 'border-border bg-secondary text-secondary-foreground',
)}
>
{label.displayName}

View file

@ -487,6 +487,38 @@
"pageTitle": "Token Management",
"pageSubtitle": "Manage access credentials for CLI and API"
},
"theme": {
"darkMode": "Dark theme",
"switchToLight": "Switch to light theme",
"switchToDark": "Switch to dark theme"
},
"reviewProgress": {
"title": "My Review Progress",
"subtitle": "Track the current decision, reviewer feedback, and every resubmission of your skills",
"searchLabel": "Search skills or namespaces",
"searchPlaceholder": "Search skill or namespace",
"searchAction": "Search",
"statusFilter": "Filter by review status",
"statusAll": "All statuses",
"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",
"error": "Review progress could not be loaded. Try again later.",
"historyError": "Submission history could not be loaded. Try again later.",
"emptyTitle": "No review history yet",
"emptyDescription": "Reviews for public or team skills will appear here after submission."
},
"reviews": {
"title": "Review Center",
"subtitle": "Manage platform review tasks",
@ -1328,6 +1360,7 @@
"governance": "Governance Center",
"stars": "Starred",
"subscriptions": "My Subscriptions",
"reviewProgress": "My Review Progress",
"reviews": "Review Management",
"promotions": "Promotion Management",
"reports": "Report Management",
@ -1358,6 +1391,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",
@ -1420,6 +1457,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

@ -487,6 +487,38 @@
"pageTitle": "Управление токенами",
"pageSubtitle": "Учётные данные доступа для CLI и API"
},
"theme": {
"darkMode": "Тёмная тема",
"switchToLight": "Переключить на светлую тему",
"switchToDark": "Переключить на тёмную тему"
},
"reviewProgress": {
"title": "Мои проверки",
"subtitle": "Текущий статус, комментарии проверяющего и история повторных отправок",
"searchLabel": "Поиск навыков или пространств имён",
"searchPlaceholder": "Навык или пространство имён",
"searchAction": "Найти",
"statusFilter": "Фильтр по статусу проверки",
"statusAll": "Все статусы",
"statusPending": "На проверке",
"statusApproved": "Одобрено",
"statusRejected": "Отклонено",
"statusSummary": "Сводка текущих статусов проверки",
"latestSubmitted": "Последняя отправка: {{time}}",
"latestReviewed": "Последнее решение: {{time}}",
"attemptCount": "Отправок: {{count}}",
"attemptNumber": "Попытка {{number}}",
"submittedAt": "Отправлено {{time}}",
"reviewedAt": "Проверено {{time}}",
"reviewedBy": "Проверил: {{reviewer}}",
"history": "История отправок",
"resubmit": "Изменить и отправить снова",
"loading": "Загрузка статуса проверки",
"error": "Не удалось загрузить статус проверки. Повторите попытку позже.",
"historyError": "Не удалось загрузить историю. Повторите попытку позже.",
"emptyTitle": "Истории проверок пока нет",
"emptyDescription": "После отправки публичного или командного навыка его статус появится здесь."
},
"reviews": {
"title": "Центр рецензирования",
"subtitle": "Задачи рецензирования на платформе",
@ -1437,6 +1469,10 @@
"title": "Уведомление о ревью",
"description": "Отправленные пакеты скиллов проходят ревью администратора перед публикацией."
},
"resubmitNotice": {
"title": "Повторная отправка {{skill}} v{{version}}",
"description": "Загрузите исправленный пакет с тем же именем и версией навыка. Он снова пройдёт обычную проверку."
},
"namespace": "Пространство имён",
"selectNamespace": "Выберите пространство имён",
"visibility": "Видимость",
@ -1490,6 +1526,8 @@
"reviewer": "Рецензент",
"reviewTime": "Рассмотрено",
"reviewComment": "Комментарий ревью",
"attemptHistory": "История отправок",
"attemptHistoryError": "Не удалось загрузить историю отправок. Повторите попытку позже.",
"actions": "Действия ревью",
"commentLabel": "Комментарий (необязательно)",
"commentPlaceholder": "Введите комментарий ревью...",
@ -1629,6 +1667,7 @@
"governance": "Центр управления",
"stars": "Избранное",
"subscriptions": "Мои подписки",
"reviewProgress": "Мои проверки",
"reviews": "Управление ревью",
"promotions": "Управление продвижением",
"reports": "Управление жалобами",

View file

@ -487,6 +487,38 @@
"pageTitle": "Token 管理",
"pageSubtitle": "管理 CLI 和 API 使用的访问凭证"
},
"theme": {
"darkMode": "深色主题",
"switchToLight": "切换到浅色主题",
"switchToDark": "切换到深色主题"
},
"reviewProgress": {
"title": "我的审核进度",
"subtitle": "查看技能提交的当前状态、审核意见和每次重提记录",
"searchLabel": "搜索技能或命名空间",
"searchPlaceholder": "搜索 Skill 或命名空间",
"searchAction": "搜索",
"statusFilter": "按审核状态筛选",
"statusAll": "全部状态",
"statusPending": "审核中",
"statusApproved": "已通过",
"statusRejected": "已拒绝",
"statusSummary": "当前审核状态汇总",
"latestSubmitted": "最近提交:{{time}}",
"latestReviewed": "最近审核:{{time}}",
"attemptCount": "共 {{count}} 次提交",
"attemptNumber": "第 {{number}} 次",
"submittedAt": "提交于 {{time}}",
"reviewedAt": "审核于 {{time}}",
"reviewedBy": "审核人:{{reviewer}}",
"history": "提交历史",
"resubmit": "修改并重提",
"loading": "正在加载审核进度",
"error": "审核进度加载失败,请稍后重试。",
"historyError": "提交历史加载失败,请稍后重试。",
"emptyTitle": "还没有审核记录",
"emptyDescription": "提交公开或团队 Skill 后,审核进度会显示在这里。"
},
"reviews": {
"title": "审核中心",
"subtitle": "管理平台审核事务",
@ -1328,6 +1360,7 @@
"governance": "治理中心",
"stars": "我的收藏",
"subscriptions": "我的订阅",
"reviewProgress": "我的审核进度",
"reviews": "审核管理",
"promotions": "推广管理",
"reports": "举报管理",
@ -1358,6 +1391,10 @@
"title": "发布审核说明",
"description": "技能包提交后需要经过管理员审核才能正式发布。"
},
"resubmitNotice": {
"title": "重新提交 {{skill}} v{{version}}",
"description": "上传修正后的技能包,并保持相同的技能名称和版本;提交后会重新进入正常审核流程。"
},
"namespace": "命名空间",
"selectNamespace": "选择命名空间",
"visibility": "可见性",
@ -1420,6 +1457,8 @@
"reviewer": "审核者",
"reviewTime": "审核时间",
"reviewComment": "审核意见",
"attemptHistory": "提交历史",
"attemptHistoryError": "提交历史加载失败,请稍后重试。",
"actions": "审核操作",
"commentLabel": "审核意见(可选)",
"commentPlaceholder": "填写审核意见...",

View file

@ -51,29 +51,33 @@
}
.dark {
/* Dark theme (preserved, not default) */
--background: 222 47% 6%;
--foreground: 210 40% 96%;
--card: 222 40% 9%;
--card-foreground: 210 40% 96%;
--popover: 222 40% 9%;
--popover-foreground: 210 40% 96%;
--primary: 239 100% 75%;
--primary-foreground: 222 47% 6%;
--secondary: 222 30% 13%;
--secondary-foreground: 210 30% 85%;
--muted: 222 25% 15%;
--muted-foreground: 215 20% 55%;
--accent: 271 100% 72%;
/* Cool ink surfaces: distinct depth without near-black dead zones. */
--background: 222 30% 10%;
--foreground: 216 28% 93%;
--card: 222 25% 14%;
--card-foreground: 216 28% 93%;
--popover: 222 24% 15%;
--popover-foreground: 216 28% 93%;
--primary: 241 92% 74%;
--primary-foreground: 222 30% 10%;
--secondary: 222 20% 18%;
--secondary-foreground: 216 24% 88%;
--muted: 222 18% 19%;
--muted-foreground: 216 14% 66%;
--accent: 272 86% 72%;
--accent-foreground: 0 0% 100%;
--destructive: 0 72% 55%;
--destructive: 0 72% 52%;
--destructive-foreground: 0 0% 100%;
--border: 222 20% 18%;
--input: 222 20% 18%;
--ring: 239 100% 75%;
--surface-glass: 222 35% 11%;
--glow-primary: 239 100% 75%;
--glow-accent: 271 100% 72%;
--border: 222 16% 25%;
--input: 222 16% 25%;
--ring: 241 92% 74%;
--surface-glass: 222 24% 14%;
--glow-primary: 241 92% 70%;
--glow-accent: 272 80% 68%;
--text-secondary: 216 17% 76%;
--text-muted: 216 14% 66%;
--text-placeholder: 216 12% 54%;
--border-card: 222 16% 24%;
}
}
@ -323,9 +327,9 @@
padding: 4px 10px;
font-size: 12px;
font-weight: 500;
color: #0369a1;
background: #e0f2fe;
border: 1px solid #7dd3fc;
color: hsl(199 76% 34%);
background: hsl(199 89% 94%);
border: 1px solid hsl(199 93% 74%);
border-radius: 9999px;
}
@ -342,7 +346,7 @@
}
.status-pill--published { background: #16a34a; }
.status-pill--review { background: #ea580c; }
.status-pill--review { background: #c2410c; }
.status-pill--archived { background: #6b7280; }
.status-pill--rejected { background: #dc2626; }
@ -378,10 +382,26 @@
color: #1d4ed8;
}
.dark .handle-tag {
color: hsl(196 80% 72%);
background: hsl(199 70% 15%);
border-color: hsl(199 60% 30%);
}
.dark .badge-soft-green {
background: hsl(142 55% 16%);
color: hsl(142 70% 72%);
}
.dark .badge-soft-blue {
background: hsl(221 55% 18%);
color: hsl(213 90% 75%);
}
/* ─── Tab active ─── */
.tab-active {
border-bottom: 2px solid #3b82f6;
color: #1d4ed8;
border-bottom: 2px solid hsl(var(--primary));
color: hsl(var(--primary));
}
/* ─── Code block ─── */

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

@ -0,0 +1,66 @@
import type { ReactNode } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it, vi } from 'vitest'
import { ReviewProgressPage } from './review-progress'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
i18n: { language: 'zh' },
}),
}))
vi.mock('@tanstack/react-router', () => ({
useNavigate: () => vi.fn(),
useSearch: () => ({}),
Link: ({ children, to, className }: { children: ReactNode; to: string; className?: string }) => (
<a href={to} className={className}>{children}</a>
),
}))
vi.mock('@/features/review/use-my-review-progress', () => ({
useMyReviewProgress: () => ({
data: {
items: [{
latestReviewTaskId: 42,
skillId: 7,
namespace: 'team-a',
skillSlug: 'demo-skill',
skillVersion: '1.2.0',
latestStatus: 'REJECTED',
latestReviewComment: 'Please clarify the permission requirements.',
latestSubmittedAt: '2026-09-01T01:00:00Z',
latestReviewedAt: '2026-09-01T02:00:00Z',
attemptCount: 2,
}],
total: 1,
page: 0,
size: 20,
statusCounts: {
pending: 0,
approved: 0,
rejected: 1,
},
},
isLoading: false,
isError: false,
}),
useMyReviewAttempts: () => ({ data: [], isLoading: false, isError: false }),
}))
describe('ReviewProgressPage', () => {
it('renders the author-facing latest result and keeps reviewer management out of the page', () => {
const html = renderToStaticMarkup(<ReviewProgressPage />)
expect(html).toContain('reviewProgress.title')
expect(html).toContain('@team-a/demo-skill')
expect(html).toContain('v1.2.0')
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

@ -0,0 +1,267 @@
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 } 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'
import { formatLocalDateTime } from '@/shared/lib/date-time'
import { cn } from '@/shared/lib/utils'
import { Button, buttonVariants } from '@/shared/ui/button'
import { Card, CardContent } from '@/shared/ui/card'
import { Input } from '@/shared/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
type ReviewStatus = 'PENDING' | 'APPROVED' | 'REJECTED'
const PAGE_SIZE = 20
const statusClassNames: Record<ReviewStatus, 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',
}
export function ReviewProgressPage() {
const { t, i18n } = useTranslation()
const navigate = useNavigate()
const search = useSearch({ from: '/dashboard/review-progress' })
const [queryInput, setQueryInput] = useState(search.q ?? '')
const [expandedId, setExpandedId] = useState<number | null>(null)
const progressQuery = useMyReviewProgress({
status: search.status,
q: search.q,
page: search.page ?? 0,
size: PAGE_SIZE,
})
const page = search.page ?? 0
const totalPages = progressQuery.data
? Math.ceil(progressQuery.data.total / progressQuery.data.size)
: 0
function updateSearch(next: { status?: ReviewStatus | null; q?: string; page?: number }) {
void navigate({
to: '/dashboard/review-progress',
search: {
status: next.status === null ? undefined : next.status ?? search.status,
q: next.q ?? search.q,
page: next.page ?? 0,
},
replace: true,
})
}
function submitSearch(event: FormEvent) {
event.preventDefault()
updateSearch({ q: queryInput.trim(), page: 0 })
}
return (
<div className="space-y-8 animate-fade-up">
<DashboardPageHeader
title={t('reviewProgress.title')}
subtitle={t('reviewProgress.subtitle')}
/>
<Card className="border-border/70">
<CardContent className="space-y-6 p-5 md:p-6">
<div className="flex flex-col gap-3 md:flex-row md:items-end">
<form className="flex min-w-0 flex-1 gap-2" onSubmit={submitSearch}>
<div className="relative min-w-0 flex-1">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={queryInput}
onChange={(event) => setQueryInput(event.target.value)}
placeholder={t('reviewProgress.searchPlaceholder')}
className="pl-9"
aria-label={t('reviewProgress.searchLabel')}
/>
</div>
<Button type="submit" variant="outline">{t('reviewProgress.searchAction')}</Button>
</form>
<div className="w-full md:w-48">
<Select
value={search.status ?? 'ALL'}
onValueChange={(value) => updateSearch({
status: value === 'ALL' ? null : value as ReviewStatus,
page: 0,
})}
>
<SelectTrigger aria-label={t('reviewProgress.statusFilter')}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="ALL">{t('reviewProgress.statusAll')}</SelectItem>
<SelectItem value="PENDING">{t('reviewProgress.statusPending')}</SelectItem>
<SelectItem value="APPROVED">{t('reviewProgress.statusApproved')}</SelectItem>
<SelectItem value="REJECTED">{t('reviewProgress.statusRejected')}</SelectItem>
</SelectContent>
</Select>
</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) => (
<div key={index} className="h-28 animate-shimmer rounded-xl" />
))}
</div>
) : progressQuery.isError ? (
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-sm text-destructive">
{t('reviewProgress.error')}
</div>
) : progressQuery.data?.items.length ? (
<div className="space-y-3">
{progressQuery.data.items.map((item) => (
<ProgressItem
key={`${item.skillId}:${item.skillVersion}`}
item={item}
expanded={expandedId === item.latestReviewTaskId}
onToggle={() => setExpandedId((current) => (
current === item.latestReviewTaskId ? null : item.latestReviewTaskId
))}
locale={i18n.language}
/>
))}
</div>
) : (
<div className="rounded-xl border border-dashed border-border/70 px-6 py-14 text-center">
<Clock3 className="mx-auto h-8 w-8 text-muted-foreground" />
<p className="mt-4 font-medium text-foreground">{t('reviewProgress.emptyTitle')}</p>
<p className="mt-1 text-sm text-muted-foreground">{t('reviewProgress.emptyDescription')}</p>
</div>
)}
{totalPages > 1 ? (
<Pagination
page={page}
totalPages={totalPages}
onPageChange={(nextPage) => updateSearch({ page: nextPage })}
/>
) : null}
</CardContent>
</Card>
</div>
)
}
function ProgressItem({
item,
expanded,
onToggle,
locale,
}: {
item: ReviewProgress
expanded: boolean
onToggle: () => void
locale: string
}) {
const { t } = useTranslation()
const attemptsQuery = useMyReviewAttempts(expanded ? item.latestReviewTaskId : null)
const status = item.latestStatus
return (
<article className="overflow-hidden rounded-xl border border-border/70 bg-card text-card-foreground">
<div className="flex flex-col gap-4 p-4 md:flex-row md:items-center md:justify-between md:p-5">
<div className="min-w-0 space-y-2">
<div className="flex flex-wrap items-center gap-2">
<Link
to="/space/$namespace/$slug"
params={{ namespace: item.namespace, slug: item.skillSlug }}
className="truncate font-semibold text-foreground underline-offset-4 hover:underline"
>
@{item.namespace}/{item.skillSlug}
</Link>
<span className="rounded-full bg-secondary px-2.5 py-1 text-xs text-secondary-foreground">
v{item.skillVersion}
</span>
<span className={cn('rounded-full border px-2.5 py-1 text-xs font-medium', statusClassNames[status])}>
{t(`reviewProgress.status${status === 'PENDING' ? 'Pending' : status === 'APPROVED' ? 'Approved' : 'Rejected'}`)}
</span>
</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 ? (
<p className="line-clamp-2 text-sm text-foreground/85">{item.latestReviewComment}</p>
) : null}
</div>
<div className="flex shrink-0 flex-wrap gap-2">
{status === 'REJECTED' ? (
<Link
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" />
{t('reviewProgress.resubmit')}
</Link>
) : null}
<Button
type="button"
variant="ghost"
size="sm"
onClick={onToggle}
aria-expanded={expanded}
className="gap-2"
>
{t('reviewProgress.history')}
{expanded ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
</Button>
</div>
</div>
{expanded ? (
<div className="border-t border-border/70 bg-muted/30 px-4 py-4 md:px-5">
{attemptsQuery.isLoading ? (
<div className="h-16 animate-shimmer rounded-lg" />
) : attemptsQuery.isError ? (
<p className="text-sm text-destructive">{t('reviewProgress.historyError')}</p>
) : (
<ReviewAttemptTimeline attempts={attemptsQuery.data ?? []} locale={locale} />
)}
</div>
) : null}
</article>
)
}

View file

@ -110,7 +110,7 @@ export function LandingPage() {
{/* Search box */}
<div className="w-full max-w-2xl mb-8">
<div
className="flex items-center bg-white rounded-xl border shadow-sm px-5 py-3.5"
className="flex items-center rounded-xl border bg-card px-5 py-3.5 text-card-foreground shadow-sm"
style={{ borderColor: 'hsl(var(--border))' }}
>
<SearchIcon className="w-5 h-5 flex-shrink-0 mr-3" style={{ color: 'hsl(var(--text-placeholder))' }} strokeWidth={1.5} />
@ -181,7 +181,7 @@ export function LandingPage() {
{features.map((feature) => (
<div
key={feature.title}
className="bg-white rounded-xl p-8 border shadow-sm transition-shadow hover:shadow-md"
className="rounded-xl border bg-card p-8 text-card-foreground shadow-sm transition-shadow hover:shadow-md"
style={{ borderColor: 'hsl(var(--border-card))' }}
>
<div className="feature-icon w-12 h-12 rounded-2xl flex items-center justify-center mb-6 mx-auto bg-brand-gradient">

View file

@ -168,7 +168,7 @@ export function RegisterPage() {
return (
<div className="mx-auto flex min-h-[70vh] max-w-2xl items-center justify-center">
<Card className="w-full border-slate-200 bg-white/95 shadow-xl">
<Card className="w-full border-border bg-card/95 shadow-xl">
<CardHeader className="space-y-3 text-center">
<CardTitle>{t('register.title')}</CardTitle>
<CardDescription>{t('register.subtitle')}</CardDescription>

View file

@ -89,7 +89,7 @@ export function ResetPasswordPage() {
return (
<div className="mx-auto flex min-h-[70vh] max-w-2xl items-center justify-center">
<Card className="w-full border-slate-200 bg-white/95 shadow-xl">
<Card className="w-full border-border bg-card/95 shadow-xl">
<CardHeader className="space-y-3 text-center">
<CardTitle>{t('resetPassword.title')}</CardTitle>
<CardDescription>{t('resetPassword.subtitle')}</CardDescription>

View file

@ -827,12 +827,12 @@ export function SkillDetailPage() {
</span>
)}
{isReviewFlowPending && (
<span className="badge-soft" style={{ background: '#fef3c7', color: '#92400e' }}>
<span className="badge-soft bg-amber-100 text-amber-900 dark:bg-amber-950/60 dark:text-amber-300">
{t('skillDetail.versionStatusPendingReview')}
</span>
)}
{!isPendingPreview && (isRejectedPreview || hasRejectedOwnerPreview) && skill.canManageLifecycle && (
<span className="badge-soft" style={{ background: '#fee2e2', color: '#991b1b' }}>
<span className="badge-soft bg-red-100 text-red-900 dark:bg-red-950/60 dark:text-red-300">
{t('skillDetail.rejectedBadge')}
</span>
)}
@ -861,8 +861,8 @@ export function SkillDetailPage() {
className={cn(
'inline-flex items-center rounded-full border px-3 py-1 text-xs font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/70 focus-visible:ring-offset-2',
label.type === 'PRIVILEGED'
? 'border-amber-500/40 bg-amber-100 text-amber-900 hover:bg-amber-200/80'
: 'border-slate-300 bg-slate-100 text-slate-800 hover:bg-slate-200/80',
? 'border-amber-500/40 bg-amber-100 text-amber-900 hover:bg-amber-200/80 dark:bg-amber-950/60 dark:text-amber-300 dark:hover:bg-amber-900/70'
: 'border-border bg-secondary text-secondary-foreground hover:bg-secondary/80',
)}
>
{label.displayName}

View file

@ -56,7 +56,7 @@ function CompactCopyButton({ text }: { text: string }) {
onClick={handleCopy}
aria-label={label}
title={label}
className="absolute right-2.5 top-1/2 flex h-9 w-9 -translate-y-1/2 items-center justify-center rounded-xl border bg-white transition-colors hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 cursor-pointer"
className="absolute right-2 top-1/2 flex h-11 w-11 -translate-y-1/2 cursor-pointer items-center justify-center rounded-xl border bg-card text-card-foreground transition-colors hover:bg-secondary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
style={{ borderColor: 'hsl(var(--border))', color: 'hsl(var(--foreground))' }}
>
{copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
@ -111,12 +111,11 @@ export function LandingQuickStartSection() {
</div>
<div
className="mx-auto max-w-2xl rounded-[28px] border bg-white p-3 shadow-[0_24px_60px_-28px_rgba(15,23,42,0.25)]"
className="mx-auto max-w-2xl rounded-[28px] border bg-card p-3 text-card-foreground shadow-[0_24px_60px_-28px_hsl(var(--foreground)/0.18)]"
style={{ borderColor: 'hsl(var(--border-card))' }}
>
<div
className="grid grid-cols-1 gap-2 rounded-2xl p-1.5 md:grid-cols-3"
style={{ background: 'linear-gradient(180deg, rgba(248,250,252,0.98) 0%, rgba(241,245,249,0.92) 100%)' }}
className="grid grid-cols-1 gap-2 rounded-2xl bg-muted/70 p-1.5 md:grid-cols-3"
>
{tabs.map((tab) => {
const isActive = tab.id === currentTab.id
@ -130,9 +129,9 @@ export function LandingQuickStartSection() {
aria-pressed={isActive}
className="flex min-h-11 items-center justify-center gap-2 rounded-[14px] px-4 py-3 text-base font-medium transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 cursor-pointer"
style={{
background: isActive ? 'rgba(255,255,255,0.96)' : 'transparent',
background: isActive ? 'hsl(var(--card))' : 'transparent',
color: isActive ? 'hsl(var(--foreground))' : 'hsl(var(--muted-foreground))',
boxShadow: isActive ? '0 6px 18px rgba(15, 23, 42, 0.08)' : 'none',
boxShadow: isActive ? '0 6px 18px hsl(var(--foreground) / 0.08)' : 'none',
}}
>
<Icon className="h-4 w-4" strokeWidth={1.75} />
@ -151,13 +150,12 @@ export function LandingQuickStartSection() {
</p>
<div
className="relative rounded-2xl border bg-slate-50/90 px-4 py-3 pr-14 shadow-[inset_0_1px_0_rgba(255,255,255,0.7)]"
className="relative rounded-2xl border bg-muted/65 px-4 py-3 pr-16"
style={{ borderColor: 'hsl(var(--border))' }}
>
<div className="overflow-x-auto whitespace-nowrap">
<code
className="font-mono text-sm md:text-base"
style={{ color: currentTab.id === 'agent' ? '#16A34A' : '#0F172A' }}
className={`font-mono text-sm md:text-base ${currentTab.id === 'agent' ? 'text-emerald-700 dark:text-emerald-400' : 'text-foreground'}`}
>
{currentTab.command}
</code>

View file

@ -65,8 +65,8 @@ export function LanguageSwitcher({ className }: LanguageSwitcherProps) {
onClick={() => setOpen((current) => !current)}
>
<Globe className="h-4 w-4" />
<span className="text-sm text-inherit">{currentLanguage.name}</span>
<ChevronDown className="h-3.5 w-3.5 opacity-70" />
<span className="hidden text-sm text-inherit sm:inline">{currentLanguage.name}</span>
<ChevronDown className="hidden h-3.5 w-3.5 opacity-70 sm:block" />
</Button>
{open ? (
<div className="absolute right-0 top-full z-50 pt-2">

View file

@ -0,0 +1,34 @@
/** @vitest-environment jsdom */
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { THEME_STORAGE_KEY } from '@/shared/lib/theme'
import { ThemeToggle } from './theme-toggle'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
describe('ThemeToggle', () => {
beforeEach(() => {
window.localStorage.clear()
document.documentElement.classList.remove('dark')
document.documentElement.dataset.theme = 'light'
document.documentElement.style.colorScheme = 'light'
})
it('switches theme and keeps the selection in browser-local storage', () => {
render(<ThemeToggle />)
const toggle = screen.getByRole('switch', { name: 'theme.darkMode' })
expect(toggle.getAttribute('aria-checked')).toBe('false')
fireEvent.click(toggle)
expect(screen.getByRole('switch', { name: 'theme.darkMode' }).getAttribute('aria-checked')).toBe('true')
expect(document.documentElement.classList.contains('dark')).toBe(true)
expect(window.localStorage.getItem(THEME_STORAGE_KEY)).toBe('dark')
})
})

View file

@ -0,0 +1,51 @@
import { Moon, Sun } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { useTheme } from '@/shared/hooks/use-theme'
import { cn } from '@/shared/lib/utils'
interface ThemeToggleProps {
className?: string
}
export function ThemeToggle({ className }: ThemeToggleProps) {
const { t } = useTranslation()
const { theme, toggleTheme } = useTheme()
const isDark = theme === 'dark'
const label = isDark ? t('theme.switchToLight') : t('theme.switchToDark')
const accessibleName = t('theme.darkMode')
return (
<button
type="button"
role="switch"
aria-label={accessibleName}
aria-checked={isDark}
title={label}
onClick={toggleTheme}
className={cn(
'group relative inline-flex h-11 w-16 shrink-0 items-center rounded-full border border-border bg-muted/70 px-1 text-muted-foreground shadow-sm transition-[background-color,border-color] duration-200 hover:border-primary/40 hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background',
className,
)}
>
<span
aria-hidden="true"
className={cn(
'absolute left-1 top-1.5 h-8 w-7 rounded-full border border-border/80 bg-card shadow-[0_3px_10px_-4px_hsl(var(--foreground)/0.45)] transition-transform duration-200 ease-out motion-reduce:transition-none',
isDark && 'translate-x-7',
)}
/>
<span className="relative z-10 inline-flex h-8 w-7 items-center justify-center">
<Sun
aria-hidden="true"
className={cn('h-4 w-4 transition-colors duration-200', !isDark && 'text-foreground')}
/>
</span>
<span className="relative z-10 inline-flex h-8 w-7 items-center justify-center">
<Moon
aria-hidden="true"
className={cn('h-4 w-4 transition-colors duration-200', isDark && 'text-foreground')}
/>
</span>
</button>
)
}

View file

@ -77,6 +77,20 @@ describe('user-menu module exports', () => {
})
describe('UserMenu security settings visibility', () => {
it('keeps author review progress separate from reviewer management', () => {
const html = renderToStaticMarkup(
<UserMenu
user={{
displayName: 'Skill Author',
platformRoles: ['USER'],
}}
/>,
)
expect(html).toContain('user.menu.reviewProgress')
expect(html).not.toContain('user.menu.reviews')
})
it('shows security settings when password changes are allowed, independent of OAuth provider', () => {
const html = renderToStaticMarkup(
<UserMenu

View file

@ -130,7 +130,7 @@ export function UserMenu({ user, triggerClassName }: UserMenuProps) {
className="w-8 h-8 rounded-full border border-border/60"
/>
)}
<span className="text-sm font-medium text-inherit">
<span className="hidden text-sm font-medium text-inherit lg:inline">
{user.displayName}
</span>
</button>
@ -164,6 +164,9 @@ export function UserMenu({ user, triggerClassName }: UserMenuProps) {
<Link to="/dashboard/subscriptions" className={menuItemClassName} onClick={closeMenu}>
{t('user.menu.subscriptions')}
</Link>
<Link to="/dashboard/review-progress" className={menuItemClassName} onClick={closeMenu}>
{t('user.menu.reviewProgress')}
</Link>
{reviewCenterVisible ? (
<Link to={buildGlobalReviewsPath()} className={menuItemClassName} onClick={closeMenu}>
{t('user.menu.reviews')}

View file

@ -0,0 +1,24 @@
import { useCallback, useState } from 'react'
import {
applyTheme,
DEFAULT_THEME,
readStoredTheme,
saveTheme,
type Theme,
} from '@/shared/lib/theme'
export function useTheme() {
const [theme, setThemeState] = useState<Theme>(() => readStoredTheme() ?? DEFAULT_THEME)
const setTheme = useCallback((nextTheme: Theme) => {
applyTheme(nextTheme)
saveTheme(nextTheme)
setThemeState(nextTheme)
}, [])
const toggleTheme = useCallback(() => {
setTheme(theme === 'light' ? 'dark' : 'light')
}, [setTheme, theme])
return { theme, setTheme, toggleTheme }
}

View file

@ -0,0 +1,62 @@
/** @vitest-environment jsdom */
import { beforeEach, describe, expect, it } from 'vitest'
import {
applyTheme,
DEFAULT_THEME,
initializeTheme,
readStoredTheme,
saveTheme,
THEME_STORAGE_KEY,
} from './theme'
describe('theme preference', () => {
beforeEach(() => {
window.localStorage.clear()
document.documentElement.classList.remove('dark')
document.documentElement.removeAttribute('data-theme')
document.documentElement.style.colorScheme = ''
})
it('uses light when the browser has no saved preference', () => {
expect(initializeTheme()).toBe(DEFAULT_THEME)
expect(document.documentElement.classList.contains('dark')).toBe(false)
expect(document.documentElement.dataset.theme).toBe('light')
expect(document.documentElement.style.colorScheme).toBe('light')
})
it('restores a valid browser-local preference before render', () => {
window.localStorage.setItem(THEME_STORAGE_KEY, 'dark')
expect(initializeTheme()).toBe('dark')
expect(document.documentElement.classList.contains('dark')).toBe(true)
expect(document.documentElement.dataset.theme).toBe('dark')
})
it('ignores invalid and inaccessible storage values', () => {
expect(readStoredTheme({ getItem: () => 'system', setItem: () => undefined })).toBeNull()
expect(readStoredTheme({
getItem: () => {
throw new Error('blocked')
},
setItem: () => undefined,
})).toBeNull()
})
it('applies and saves only the selected frontend theme', () => {
applyTheme('dark')
saveTheme('dark')
expect(document.documentElement.classList.contains('dark')).toBe(true)
expect(window.localStorage.getItem(THEME_STORAGE_KEY)).toBe('dark')
})
it('ignores storage write failures', () => {
expect(() => saveTheme('dark', {
getItem: () => null,
setItem: () => {
throw new Error('blocked')
},
})).not.toThrow()
})
})

View file

@ -0,0 +1,56 @@
export type Theme = 'light' | 'dark'
export const THEME_STORAGE_KEY = 'skillhub-theme'
export const DEFAULT_THEME: Theme = 'light'
interface ThemeStorage {
getItem(key: string): string | null
setItem(key: string, value: string): void
}
export function isTheme(value: unknown): value is Theme {
return value === 'light' || value === 'dark'
}
export function readStoredTheme(storage: ThemeStorage | undefined = getBrowserStorage()): Theme | null {
if (!storage) return null
try {
const value = storage.getItem(THEME_STORAGE_KEY)
return isTheme(value) ? value : null
} catch {
return null
}
}
export function applyTheme(theme: Theme, root: HTMLElement = document.documentElement) {
root.classList.toggle('dark', theme === 'dark')
root.dataset.theme = theme
root.style.colorScheme = theme
}
export function initializeTheme(root: HTMLElement = document.documentElement): Theme {
const theme = readStoredTheme() ?? DEFAULT_THEME
applyTheme(theme, root)
return theme
}
export function saveTheme(theme: Theme, storage: ThemeStorage | undefined = getBrowserStorage()) {
if (!storage) return
try {
storage.setItem(THEME_STORAGE_KEY, theme)
} catch {
// Storage can be unavailable in private browsing or hardened browser contexts.
}
}
function getBrowserStorage(): Storage | undefined {
if (typeof window === 'undefined') return undefined
try {
return window.localStorage
} catch {
return undefined
}
}

View file

@ -4,7 +4,7 @@ import { cn } from '@/shared/lib/utils'
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
export const INPUT_BASE_CLASS_NAME =
'flex h-11 w-full rounded-lg border bg-white px-4 py-2 text-sm text-foreground ring-offset-background transition-all duration-200 file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 focus-visible:border-primary/50 disabled:cursor-not-allowed disabled:opacity-50'
'flex h-11 w-full rounded-lg border bg-background px-4 py-2 text-sm text-foreground ring-offset-background transition-all duration-200 file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 focus-visible:border-primary/50 disabled:cursor-not-allowed disabled:opacity-50'
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, style, ...props }, ref) => {