mirror of
https://github.com/iflytek/skillhub.git
synced 2026-09-10 22:41:02 +00:00
Merge pull request #43 from iflytek/feature/project-local
feat(skill): improve version management and pending previews
This commit is contained in:
commit
ed81fb0ba6
11 changed files with 355 additions and 32 deletions
|
|
@ -71,7 +71,9 @@ public class SkillController extends BaseApiController {
|
|||
detail.hidden(),
|
||||
detail.latestVersion(),
|
||||
namespace,
|
||||
detail.canManageLifecycle()
|
||||
detail.canManageLifecycle(),
|
||||
detail.viewingVersionStatus(),
|
||||
detail.canInteract()
|
||||
);
|
||||
|
||||
return ok("response.success.read", response);
|
||||
|
|
|
|||
|
|
@ -16,5 +16,7 @@ public record SkillDetailResponse(
|
|||
boolean hidden,
|
||||
String latestVersion,
|
||||
String namespace,
|
||||
boolean canManageLifecycle
|
||||
boolean canManageLifecycle,
|
||||
String viewingVersionStatus,
|
||||
boolean canInteract
|
||||
) {}
|
||||
|
|
|
|||
|
|
@ -102,6 +102,42 @@ class SkillControllerTest {
|
|||
.andExpect(jsonPath("$.requestId").isNotEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getSkillDetailShouldExposePendingPreviewFlags() throws Exception {
|
||||
when(skillQueryService.getSkillDetail(
|
||||
eq("team"),
|
||||
eq("demo"),
|
||||
eq((String) null),
|
||||
eq(Map.<Long, NamespaceRole>of())))
|
||||
.thenReturn(new SkillQueryService.SkillDetailDTO(
|
||||
1L,
|
||||
"demo",
|
||||
"Demo",
|
||||
"Pending preview",
|
||||
"PUBLIC",
|
||||
"ACTIVE",
|
||||
10L,
|
||||
2,
|
||||
null,
|
||||
0,
|
||||
false,
|
||||
"1.1.0",
|
||||
1L,
|
||||
LocalDateTime.of(2026, 3, 15, 10, 0),
|
||||
LocalDateTime.of(2026, 3, 15, 10, 0),
|
||||
null,
|
||||
true,
|
||||
"PENDING_REVIEW",
|
||||
false
|
||||
));
|
||||
|
||||
mockMvc.perform(get("/api/web/skills/team/demo"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.data.latestVersion").value("1.1.0"))
|
||||
.andExpect(jsonPath("$.data.viewingVersionStatus").value("PENDING_REVIEW"))
|
||||
.andExpect(jsonPath("$.data.canInteract").value(false));
|
||||
}
|
||||
|
||||
@Test
|
||||
void listFilesByTagShouldReturnUnifiedEnvelope() throws Exception {
|
||||
when(skillQueryService.listFilesByTag(
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import org.springframework.data.domain.Pageable;
|
|||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
|
|
@ -69,7 +70,9 @@ public class SkillQueryService {
|
|||
java.time.LocalDateTime createdAt,
|
||||
java.time.LocalDateTime updatedAt,
|
||||
SkillVersion latestVersionEntity,
|
||||
boolean canManageLifecycle
|
||||
boolean canManageLifecycle,
|
||||
String viewingVersionStatus,
|
||||
boolean canInteract
|
||||
) {}
|
||||
|
||||
public record SkillVersionDetailDTO(
|
||||
|
|
@ -111,12 +114,9 @@ public class SkillQueryService {
|
|||
}
|
||||
|
||||
String latestVersion = null;
|
||||
SkillVersion latestVersionEntity = null;
|
||||
if (skill.getLatestVersionId() != null) {
|
||||
latestVersionEntity = skillVersionRepository.findById(skill.getLatestVersionId()).orElse(null);
|
||||
if (latestVersionEntity != null) {
|
||||
latestVersion = latestVersionEntity.getVersion();
|
||||
}
|
||||
SkillVersion latestVersionEntity = resolvePreviewVersion(skill, currentUserId);
|
||||
if (latestVersionEntity != null) {
|
||||
latestVersion = latestVersionEntity.getVersion();
|
||||
}
|
||||
|
||||
return new SkillDetailDTO(
|
||||
|
|
@ -136,7 +136,9 @@ public class SkillQueryService {
|
|||
skill.getCreatedAt(),
|
||||
skill.getUpdatedAt(),
|
||||
latestVersionEntity,
|
||||
canManageRestrictedSkill(skill, currentUserId, userNsRoles)
|
||||
canManageRestrictedSkill(skill, currentUserId, userNsRoles),
|
||||
latestVersionEntity != null ? latestVersionEntity.getStatus().name() : null,
|
||||
latestVersionEntity == null || latestVersionEntity.getStatus() == SkillVersionStatus.PUBLISHED
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -171,7 +173,7 @@ public class SkillQueryService {
|
|||
Skill skill = findSkill(namespaceSlug, skillSlug);
|
||||
assertPublishedAccessible(skill, currentUserId, userNsRoles);
|
||||
SkillVersion skillVersion = findVersion(skill, version);
|
||||
assertPublishedVersion(skillVersion, version);
|
||||
assertPreviewAccessible(skill, skillVersion, version, currentUserId);
|
||||
|
||||
return new SkillVersionDetailDTO(
|
||||
skillVersion.getId(),
|
||||
|
|
@ -196,7 +198,7 @@ public class SkillQueryService {
|
|||
assertPublishedAccessible(skill, currentUserId, userNsRoles);
|
||||
|
||||
SkillVersion skillVersion = findVersion(skill, version);
|
||||
assertPublishedVersion(skillVersion, version);
|
||||
assertPreviewAccessible(skill, skillVersion, version, currentUserId);
|
||||
|
||||
return skillFileRepository.findByVersionId(skillVersion.getId());
|
||||
}
|
||||
|
|
@ -224,11 +226,11 @@ public class SkillQueryService {
|
|||
assertPublishedAccessible(skill, currentUserId, userNsRoles);
|
||||
|
||||
SkillVersion skillVersion = findVersion(skill, version);
|
||||
assertPublishedVersion(skillVersion, version);
|
||||
assertPreviewAccessible(skill, skillVersion, version, currentUserId);
|
||||
|
||||
SkillFile file = findFile(skillVersion, filePath);
|
||||
|
||||
return objectStorageService.getObject(file.getStorageKey());
|
||||
return readFileContent(file);
|
||||
}
|
||||
|
||||
public InputStream getFileContentByTag(
|
||||
|
|
@ -242,7 +244,7 @@ public class SkillQueryService {
|
|||
assertPublishedAccessible(skill, currentUserId, userNsRoles);
|
||||
SkillVersion skillVersion = resolveVersionEntity(skill, null, tagName, null);
|
||||
SkillFile file = findFile(skillVersion, filePath);
|
||||
return objectStorageService.getObject(file.getStorageKey());
|
||||
return readFileContent(file);
|
||||
}
|
||||
|
||||
public Page<SkillVersion> listVersions(String namespaceSlug,
|
||||
|
|
@ -338,6 +340,14 @@ public class SkillQueryService {
|
|||
.orElseThrow(() -> new DomainBadRequestException("error.skill.file.notFound", filePath));
|
||||
}
|
||||
|
||||
private InputStream readFileContent(SkillFile file) {
|
||||
try {
|
||||
return objectStorageService.getObject(file.getStorageKey());
|
||||
} catch (UncheckedIOException e) {
|
||||
throw new DomainBadRequestException("error.skill.file.notFound", file.getFilePath());
|
||||
}
|
||||
}
|
||||
|
||||
private SkillVersion resolveVersionEntity(Skill skill, String version, String tag, String hash) {
|
||||
if (version != null && !version.isBlank()) {
|
||||
SkillVersion exactVersion = findVersion(skill, version);
|
||||
|
|
@ -388,6 +398,28 @@ public class SkillQueryService {
|
|||
return latestVersion;
|
||||
}
|
||||
|
||||
private SkillVersion resolvePreviewVersion(Skill skill, String currentUserId) {
|
||||
SkillVersion publishedVersion = null;
|
||||
if (skill.getLatestVersionId() != null) {
|
||||
publishedVersion = skillVersionRepository.findById(skill.getLatestVersionId()).orElse(null);
|
||||
}
|
||||
if (publishedVersion != null) {
|
||||
return publishedVersion;
|
||||
}
|
||||
return resolveOwnerPendingPreview(skill, currentUserId);
|
||||
}
|
||||
|
||||
private SkillVersion resolveOwnerPendingPreview(Skill skill, String currentUserId) {
|
||||
if (!isOwner(skill, currentUserId)) {
|
||||
return null;
|
||||
}
|
||||
return skillVersionRepository.findBySkillIdAndStatus(skill.getId(), SkillVersionStatus.PENDING_REVIEW).stream()
|
||||
.max(Comparator
|
||||
.comparing(SkillVersion::getCreatedAt, Comparator.nullsLast(Comparator.naturalOrder()))
|
||||
.thenComparing(SkillVersion::getId, Comparator.nullsLast(Comparator.naturalOrder())))
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
private String computeFingerprint(SkillVersion version) {
|
||||
try {
|
||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||
|
|
@ -430,6 +462,10 @@ public class SkillQueryService {
|
|||
|| role == NamespaceRole.OWNER;
|
||||
}
|
||||
|
||||
private boolean isOwner(Skill skill, String currentUserId) {
|
||||
return currentUserId != null && skill.getOwnerId().equals(currentUserId);
|
||||
}
|
||||
|
||||
private int lifecycleListPriority(SkillVersionStatus status) {
|
||||
if (status == SkillVersionStatus.PUBLISHED) {
|
||||
return 0;
|
||||
|
|
@ -454,4 +490,14 @@ public class SkillQueryService {
|
|||
throw new DomainBadRequestException("error.skill.version.notPublished", versionStr);
|
||||
}
|
||||
}
|
||||
|
||||
private void assertPreviewAccessible(Skill skill, SkillVersion version, String versionStr, String currentUserId) {
|
||||
if (version.getStatus() == SkillVersionStatus.PUBLISHED) {
|
||||
return;
|
||||
}
|
||||
if (version.getStatus() == SkillVersionStatus.PENDING_REVIEW && isOwner(skill, currentUserId)) {
|
||||
return;
|
||||
}
|
||||
throw new DomainBadRequestException("error.skill.version.notPublished", versionStr);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import org.springframework.data.domain.Pageable;
|
|||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.InputStream;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.lang.reflect.Field;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
|
@ -202,6 +203,39 @@ class SkillQueryServiceTest {
|
|||
service.listFiles(namespaceSlug, skillSlug, version, "user-100", userNsRoles));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetFileContent_ShouldTranslateMissingStorageObject() throws Exception {
|
||||
String namespaceSlug = "test-ns";
|
||||
String skillSlug = "test-skill";
|
||||
String version = "1.0.0";
|
||||
String filePath = "SKILL.md";
|
||||
Map<Long, NamespaceRole> userNsRoles = Map.of(1L, NamespaceRole.MEMBER);
|
||||
|
||||
Namespace namespace = new Namespace(namespaceSlug, "Test NS", "user-1");
|
||||
setId(namespace, 1L);
|
||||
Skill skill = new Skill(1L, skillSlug, "user-100", SkillVisibility.PUBLIC);
|
||||
setId(skill, 1L);
|
||||
skill.setStatus(SkillStatus.ACTIVE);
|
||||
SkillVersion skillVersion = new SkillVersion(1L, version, "user-100");
|
||||
setId(skillVersion, 1L);
|
||||
skillVersion.setStatus(SkillVersionStatus.PUBLISHED);
|
||||
SkillFile file = new SkillFile(1L, filePath, 100L, "text/markdown", "hash1", "skills/1/1/SKILL.md");
|
||||
|
||||
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
|
||||
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill));
|
||||
when(visibilityChecker.canAccess(skill, "user-100", userNsRoles)).thenReturn(true);
|
||||
when(skillVersionRepository.findBySkillIdAndVersion(1L, version)).thenReturn(Optional.of(skillVersion));
|
||||
when(skillFileRepository.findByVersionId(1L)).thenReturn(List.of(file));
|
||||
when(objectStorageService.getObject(file.getStorageKey()))
|
||||
.thenThrow(new UncheckedIOException(new java.io.FileNotFoundException(file.getStorageKey())));
|
||||
|
||||
DomainBadRequestException ex = assertThrows(DomainBadRequestException.class, () ->
|
||||
service.getFileContent(namespaceSlug, skillSlug, version, filePath, "user-100", userNsRoles));
|
||||
|
||||
assertEquals("error.skill.file.notFound", ex.messageCode());
|
||||
assertArrayEquals(new Object[]{filePath}, ex.messageArgs());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetVersionDetail_ShouldReturnMetadataPayload() throws Exception {
|
||||
String namespaceSlug = "test-ns";
|
||||
|
|
@ -432,6 +466,170 @@ class SkillQueryServiceTest {
|
|||
assertFalse(result.canManageLifecycle());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetSkillDetail_ShouldPreferPendingVersionForOwnerPreview() throws Exception {
|
||||
String namespaceSlug = "test-ns";
|
||||
String skillSlug = "test-skill";
|
||||
String ownerId = "owner-1";
|
||||
Map<Long, NamespaceRole> userNsRoles = Map.of();
|
||||
|
||||
Namespace namespace = new Namespace(namespaceSlug, "Test NS", ownerId);
|
||||
setId(namespace, 1L);
|
||||
Skill skill = new Skill(1L, skillSlug, ownerId, SkillVisibility.PUBLIC);
|
||||
setId(skill, 1L);
|
||||
skill.setStatus(SkillStatus.ACTIVE);
|
||||
skill.setLatestVersionId(11L);
|
||||
|
||||
SkillVersion published = new SkillVersion(1L, "1.0.0", ownerId);
|
||||
setId(published, 11L);
|
||||
published.setStatus(SkillVersionStatus.PUBLISHED);
|
||||
published.setPublishedAt(java.time.LocalDateTime.of(2026, 3, 1, 10, 0));
|
||||
|
||||
SkillVersion pending = new SkillVersion(1L, "1.1.0", ownerId);
|
||||
setId(pending, 12L);
|
||||
pending.setStatus(SkillVersionStatus.PENDING_REVIEW);
|
||||
|
||||
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
|
||||
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill));
|
||||
when(visibilityChecker.canAccess(skill, ownerId, userNsRoles)).thenReturn(true);
|
||||
when(skillVersionRepository.findBySkillIdAndStatus(1L, SkillVersionStatus.PENDING_REVIEW))
|
||||
.thenReturn(List.of(pending));
|
||||
|
||||
SkillQueryService.SkillDetailDTO result = service.getSkillDetail(namespaceSlug, skillSlug, ownerId, userNsRoles);
|
||||
|
||||
assertEquals("1.1.0", result.latestVersion());
|
||||
assertEquals("PENDING_REVIEW", result.viewingVersionStatus());
|
||||
assertFalse(result.canInteract());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetSkillDetail_ShouldKeepPublishedVersionWhenSkillAlreadyPublic() throws Exception {
|
||||
String namespaceSlug = "test-ns";
|
||||
String skillSlug = "test-skill";
|
||||
String ownerId = "owner-1";
|
||||
Map<Long, NamespaceRole> userNsRoles = Map.of();
|
||||
|
||||
Namespace namespace = new Namespace(namespaceSlug, "Test NS", ownerId);
|
||||
setId(namespace, 1L);
|
||||
Skill skill = new Skill(1L, skillSlug, ownerId, SkillVisibility.PUBLIC);
|
||||
setId(skill, 1L);
|
||||
skill.setStatus(SkillStatus.ACTIVE);
|
||||
skill.setLatestVersionId(11L);
|
||||
|
||||
SkillVersion published = new SkillVersion(1L, "1.0.0", ownerId);
|
||||
setId(published, 11L);
|
||||
published.setStatus(SkillVersionStatus.PUBLISHED);
|
||||
|
||||
SkillVersion pending = new SkillVersion(1L, "1.1.0", ownerId);
|
||||
setId(pending, 12L);
|
||||
pending.setStatus(SkillVersionStatus.PENDING_REVIEW);
|
||||
|
||||
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
|
||||
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill));
|
||||
when(visibilityChecker.canAccess(skill, ownerId, userNsRoles)).thenReturn(true);
|
||||
when(skillVersionRepository.findById(11L)).thenReturn(Optional.of(published));
|
||||
|
||||
SkillQueryService.SkillDetailDTO result = service.getSkillDetail(namespaceSlug, skillSlug, ownerId, userNsRoles);
|
||||
|
||||
assertEquals("1.0.0", result.latestVersion());
|
||||
assertEquals("PUBLISHED", result.viewingVersionStatus());
|
||||
assertTrue(result.canInteract());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetVersionDetail_ShouldAllowPendingVersionForOwnerPreview() throws Exception {
|
||||
String namespaceSlug = "test-ns";
|
||||
String skillSlug = "test-skill";
|
||||
String version = "1.1.0";
|
||||
String ownerId = "owner-1";
|
||||
Map<Long, NamespaceRole> userNsRoles = Map.of();
|
||||
|
||||
Namespace namespace = new Namespace(namespaceSlug, "Test NS", ownerId);
|
||||
setId(namespace, 1L);
|
||||
Skill skill = new Skill(1L, skillSlug, ownerId, SkillVisibility.PUBLIC);
|
||||
setId(skill, 1L);
|
||||
skill.setStatus(SkillStatus.ACTIVE);
|
||||
|
||||
SkillVersion pending = new SkillVersion(1L, version, ownerId);
|
||||
setId(pending, 11L);
|
||||
pending.setStatus(SkillVersionStatus.PENDING_REVIEW);
|
||||
pending.setParsedMetadataJson("{\"name\":\"test-skill\"}");
|
||||
pending.setManifestJson("[{\"path\":\"SKILL.md\"}]");
|
||||
|
||||
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
|
||||
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill));
|
||||
when(visibilityChecker.canAccess(skill, ownerId, userNsRoles)).thenReturn(true);
|
||||
when(skillVersionRepository.findBySkillIdAndVersion(1L, version)).thenReturn(Optional.of(pending));
|
||||
|
||||
SkillQueryService.SkillVersionDetailDTO result = service.getVersionDetail(
|
||||
namespaceSlug,
|
||||
skillSlug,
|
||||
version,
|
||||
ownerId,
|
||||
userNsRoles
|
||||
);
|
||||
|
||||
assertEquals("PENDING_REVIEW", result.status());
|
||||
assertEquals("{\"name\":\"test-skill\"}", result.parsedMetadataJson());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testListFiles_ShouldAllowPendingVersionForOwnerPreview() throws Exception {
|
||||
String namespaceSlug = "test-ns";
|
||||
String skillSlug = "test-skill";
|
||||
String version = "1.1.0";
|
||||
String ownerId = "owner-1";
|
||||
Map<Long, NamespaceRole> userNsRoles = Map.of();
|
||||
|
||||
Namespace namespace = new Namespace(namespaceSlug, "Test NS", ownerId);
|
||||
setId(namespace, 1L);
|
||||
Skill skill = new Skill(1L, skillSlug, ownerId, SkillVisibility.PUBLIC);
|
||||
setId(skill, 1L);
|
||||
skill.setStatus(SkillStatus.ACTIVE);
|
||||
SkillVersion pending = new SkillVersion(1L, version, ownerId);
|
||||
setId(pending, 11L);
|
||||
pending.setStatus(SkillVersionStatus.PENDING_REVIEW);
|
||||
SkillFile file = new SkillFile(11L, "README.md", 12L, "text/markdown", "hash", "storage-key");
|
||||
|
||||
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
|
||||
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill));
|
||||
when(visibilityChecker.canAccess(skill, ownerId, userNsRoles)).thenReturn(true);
|
||||
when(skillVersionRepository.findBySkillIdAndVersion(1L, version)).thenReturn(Optional.of(pending));
|
||||
when(skillFileRepository.findByVersionId(11L)).thenReturn(List.of(file));
|
||||
|
||||
List<SkillFile> result = service.listFiles(namespaceSlug, skillSlug, version, ownerId, userNsRoles);
|
||||
|
||||
assertEquals(1, result.size());
|
||||
assertEquals("README.md", result.get(0).getFilePath());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetVersionDetail_ShouldRejectPendingVersionForNonOwner() throws Exception {
|
||||
String namespaceSlug = "test-ns";
|
||||
String skillSlug = "test-skill";
|
||||
String version = "1.1.0";
|
||||
String viewerId = "viewer-1";
|
||||
Map<Long, NamespaceRole> userNsRoles = Map.of(1L, NamespaceRole.MEMBER);
|
||||
|
||||
Namespace namespace = new Namespace(namespaceSlug, "Test NS", "owner-1");
|
||||
setId(namespace, 1L);
|
||||
Skill skill = new Skill(1L, skillSlug, "owner-1", SkillVisibility.PUBLIC);
|
||||
setId(skill, 1L);
|
||||
skill.setStatus(SkillStatus.ACTIVE);
|
||||
|
||||
SkillVersion pending = new SkillVersion(1L, version, "owner-1");
|
||||
setId(pending, 11L);
|
||||
pending.setStatus(SkillVersionStatus.PENDING_REVIEW);
|
||||
|
||||
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
|
||||
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill));
|
||||
when(visibilityChecker.canAccess(skill, viewerId, userNsRoles)).thenReturn(true);
|
||||
when(skillVersionRepository.findBySkillIdAndVersion(1L, version)).thenReturn(Optional.of(pending));
|
||||
|
||||
assertThrows(DomainBadRequestException.class, () ->
|
||||
service.getVersionDetail(namespaceSlug, skillSlug, version, viewerId, userNsRoles));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testListVersions_ShouldIncludeDraftAndRejectedForLifecycleManagers() throws Exception {
|
||||
String namespaceSlug = "test-ns";
|
||||
|
|
|
|||
|
|
@ -133,6 +133,8 @@ export interface SkillDetail {
|
|||
latestVersion?: string
|
||||
namespace: string
|
||||
canManageLifecycle: boolean
|
||||
viewingVersionStatus?: string
|
||||
canInteract: boolean
|
||||
}
|
||||
|
||||
export interface SkillVersion {
|
||||
|
|
|
|||
|
|
@ -384,6 +384,7 @@
|
|||
"tabFiles": "Files",
|
||||
"tabVersions": "Versions",
|
||||
"noReadme": "No README",
|
||||
"readmeUnavailable": "README is temporarily unavailable. Please try again later or re-upload this skill version.",
|
||||
"noFiles": "No files",
|
||||
"noVersions": "No versions",
|
||||
"fileCount": "{{count}} files",
|
||||
|
|
@ -402,6 +403,10 @@
|
|||
"statusActive": "Active",
|
||||
"statusArchived": "Archived",
|
||||
"statusHidden": "Hidden",
|
||||
"pendingPreviewBadge": "Pending Preview",
|
||||
"pendingPreviewTitle": "You are previewing a pending version",
|
||||
"pendingPreviewDescription": "This version is only visible to you. Before review approval, you can inspect the README, files, and version information, but you cannot star, rate, report, or download it.",
|
||||
"pendingPreviewInteractionHint": "Stars, ratings, and reports are disabled while this version is pending review.",
|
||||
"governance": "Governance",
|
||||
"processing": "Processing...",
|
||||
"archiveSkill": "Archive Skill",
|
||||
|
|
|
|||
|
|
@ -384,6 +384,7 @@
|
|||
"tabFiles": "文件",
|
||||
"tabVersions": "版本",
|
||||
"noReadme": "暂无 README",
|
||||
"readmeUnavailable": "README 暂时不可用,请稍后重试或重新上传该技能版本。",
|
||||
"noFiles": "暂无文件",
|
||||
"noVersions": "暂无版本",
|
||||
"fileCount": "{{count}} 个文件",
|
||||
|
|
@ -402,6 +403,10 @@
|
|||
"statusActive": "正常",
|
||||
"statusArchived": "已归档",
|
||||
"statusHidden": "已隐藏",
|
||||
"pendingPreviewBadge": "待审核预览",
|
||||
"pendingPreviewTitle": "当前正在预览待审核版本",
|
||||
"pendingPreviewDescription": "该版本仅你本人可见。审核通过前,你可以查看 README、文件和版本信息,但不能收藏、评分、举报或下载。",
|
||||
"pendingPreviewInteractionHint": "待审核预览期间不可收藏、评分或举报。",
|
||||
"governance": "治理操作",
|
||||
"processing": "处理中...",
|
||||
"archiveSkill": "归档技能",
|
||||
|
|
|
|||
|
|
@ -77,9 +77,10 @@ export function SkillDetailPage() {
|
|||
|
||||
const { data: skill, isLoading: isLoadingSkill, error: skillError } = useSkillDetail(namespace, slug)
|
||||
const { data: versions } = useSkillVersions(namespace, slug)
|
||||
const latestVersion = versions?.[0]
|
||||
const { data: files } = useSkillFiles(namespace, slug, latestVersion?.version)
|
||||
const { data: readme } = useSkillReadme(namespace, slug, latestVersion?.version)
|
||||
const selectedVersion = skill?.latestVersion ?? versions?.[0]?.version
|
||||
const selectedVersionEntry = versions?.find((version) => version.version === selectedVersion) ?? versions?.[0]
|
||||
const { data: files } = useSkillFiles(namespace, slug, selectedVersion)
|
||||
const { data: readme, error: readmeError } = useSkillReadme(namespace, slug, selectedVersion)
|
||||
const { data: diffSourceDetail } = useSkillVersionDetail(namespace, slug, diffSourceVersion ?? undefined)
|
||||
const { data: diffCompareDetail } = useSkillVersionDetail(namespace, slug, diffCompareVersion ?? undefined)
|
||||
const { data: diffSourceFiles } = useSkillFiles(namespace, slug, diffSourceVersion ?? undefined)
|
||||
|
|
@ -87,6 +88,8 @@ export function SkillDetailPage() {
|
|||
const { data: diffSourceReadme } = useSkillReadme(namespace, slug, diffSourceVersion ?? undefined)
|
||||
const { data: diffCompareReadme } = useSkillReadme(namespace, slug, diffCompareVersion ?? undefined)
|
||||
const governanceVisible = hasRole('SKILL_ADMIN') || hasRole('SUPER_ADMIN')
|
||||
const isPendingPreview = skill?.viewingVersionStatus === 'PENDING_REVIEW'
|
||||
const canInteract = skill?.canInteract ?? true
|
||||
|
||||
const refreshSkill = () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['skills', namespace, slug] })
|
||||
|
|
@ -105,7 +108,7 @@ export function SkillDetailPage() {
|
|||
})
|
||||
|
||||
const yankMutation = useMutation({
|
||||
mutationFn: () => adminApi.yankVersion(latestVersion!.id),
|
||||
mutationFn: () => adminApi.yankVersion(selectedVersionEntry!.id),
|
||||
onSuccess: refreshSkill,
|
||||
})
|
||||
const archiveMutation = useArchiveSkill()
|
||||
|
|
@ -120,11 +123,11 @@ export function SkillDetailPage() {
|
|||
requireLogin()
|
||||
return
|
||||
}
|
||||
if (!latestVersion) {
|
||||
if (!selectedVersionEntry || isPendingPreview) {
|
||||
return
|
||||
}
|
||||
const cleanNamespace = namespace.startsWith('@') ? namespace.slice(1) : namespace
|
||||
const downloadUrl = `${WEB_API_PREFIX}/skills/${cleanNamespace}/${slug}/versions/${latestVersion.version}/download`
|
||||
const downloadUrl = `${WEB_API_PREFIX}/skills/${cleanNamespace}/${slug}/versions/${selectedVersionEntry.version}/download`
|
||||
window.open(downloadUrl, '_blank')
|
||||
}
|
||||
|
||||
|
|
@ -389,11 +392,22 @@ export function SkillDetailPage() {
|
|||
{resolveSkillStatusLabel(skill.status)}
|
||||
</span>
|
||||
)}
|
||||
{isPendingPreview && (
|
||||
<span className="rounded-full border border-amber-500/30 bg-amber-500/10 px-2.5 py-0.5 text-xs text-amber-700">
|
||||
{t('skillDetail.pendingPreviewBadge')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<h1 className="text-4xl font-bold font-heading text-foreground">{skill.displayName}</h1>
|
||||
{skill.summary && (
|
||||
<p className="text-lg text-muted-foreground leading-relaxed">{skill.summary}</p>
|
||||
)}
|
||||
{isPendingPreview && (
|
||||
<Card className="border-amber-500/30 bg-amber-500/5 p-4 text-sm text-muted-foreground">
|
||||
<div className="font-medium text-foreground">{t('skillDetail.pendingPreviewTitle')}</div>
|
||||
<p className="mt-1">{t('skillDetail.pendingPreviewDescription')}</p>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="readme">
|
||||
|
|
@ -408,6 +422,10 @@ export function SkillDetailPage() {
|
|||
<Card className="p-8">
|
||||
<MarkdownRenderer content={readme} />
|
||||
</Card>
|
||||
) : readmeError ? (
|
||||
<Card className="p-8 text-muted-foreground text-center">
|
||||
{t('skillDetail.readmeUnavailable')}
|
||||
</Card>
|
||||
) : (
|
||||
<Card className="p-8 text-muted-foreground text-center">
|
||||
{t('skillDetail.noReadme')}
|
||||
|
|
@ -544,18 +562,24 @@ export function SkillDetailPage() {
|
|||
<div className="h-px bg-border/40" />
|
||||
|
||||
<div className="space-y-3">
|
||||
<StarButton skillId={skill.id} starCount={skill.starCount} onRequireLogin={requireLogin} />
|
||||
<RatingInput skillId={skill.id} onRequireLogin={requireLogin} />
|
||||
<Button variant="outline" className="w-full" onClick={handleOpenReport} disabled={reportMutation.isPending}>
|
||||
{reportMutation.isPending ? t('skillDetail.processing') : t('skillDetail.reportSkill')}
|
||||
</Button>
|
||||
{!user && (
|
||||
{canInteract ? (
|
||||
<>
|
||||
<StarButton skillId={skill.id} starCount={skill.starCount} onRequireLogin={requireLogin} />
|
||||
<RatingInput skillId={skill.id} onRequireLogin={requireLogin} />
|
||||
<Button variant="outline" className="w-full" onClick={handleOpenReport} disabled={reportMutation.isPending}>
|
||||
{reportMutation.isPending ? t('skillDetail.processing') : t('skillDetail.reportSkill')}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">{t('skillDetail.pendingPreviewInteractionHint')}</p>
|
||||
)}
|
||||
{!user && canInteract && (
|
||||
<p className="text-xs text-muted-foreground">{t('skillDetail.loginToRate')}</p>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{skill.latestVersion && (
|
||||
{skill.latestVersion && canInteract && (
|
||||
<Card className="p-5 space-y-4">
|
||||
<div className="text-sm font-semibold font-heading text-foreground">{t('skillDetail.install')}</div>
|
||||
{skill.status === 'ARCHIVED' && (
|
||||
|
|
@ -574,7 +598,7 @@ export function SkillDetailPage() {
|
|||
variant="outline"
|
||||
size="lg"
|
||||
onClick={handleDownload}
|
||||
disabled={!latestVersion || skill.status === 'ARCHIVED'}
|
||||
disabled={!selectedVersionEntry || skill.status === 'ARCHIVED' || isPendingPreview}
|
||||
>
|
||||
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M9 19l3 3m0 0l3-3m-3 3V10" />
|
||||
|
|
@ -615,7 +639,7 @@ export function SkillDetailPage() {
|
|||
{unhideMutation.isPending ? t('skillDetail.processing') : t('skillDetail.unhideSkill')}
|
||||
</Button>
|
||||
)}
|
||||
{latestVersion && (
|
||||
{selectedVersionEntry && (
|
||||
<Button variant="destructive" onClick={() => yankMutation.mutate()} disabled={yankMutation.isPending}>
|
||||
{yankMutation.isPending ? t('skillDetail.processing') : t('skillDetail.yankVersion')}
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ async function getSkillReadme(namespace: string, slug: string, version: string):
|
|||
try {
|
||||
return await fetchText(`${WEB_API_PREFIX}/skills/${cleanNamespace}/${slug}/versions/${version}/file?path=SKILL.md`)
|
||||
} catch {
|
||||
return ''
|
||||
return await fetchText(`${WEB_API_PREFIX}/skills/${cleanNamespace}/${slug}/versions/${version}/file?path=README.md`)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -13,9 +13,12 @@ function parseServerDateTime(value: string): Date {
|
|||
}
|
||||
|
||||
export function formatLocalDateTime(
|
||||
value: string,
|
||||
value: string | null | undefined,
|
||||
locale: string,
|
||||
options: Intl.DateTimeFormatOptions = { dateStyle: 'medium', timeStyle: 'short' },
|
||||
) {
|
||||
if (!value) {
|
||||
return '—'
|
||||
}
|
||||
return new Intl.DateTimeFormat(locale, options).format(parseServerDateTime(value))
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue