Merge pull request #43 from iflytek/feature/project-local

feat(skill): improve version management and pending previews
This commit is contained in:
yun-zhi-ztl 2026-03-15 05:13:52 -07:00 committed by GitHub
commit ed81fb0ba6
11 changed files with 355 additions and 32 deletions

View file

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

View file

@ -16,5 +16,7 @@ public record SkillDetailResponse(
boolean hidden,
String latestVersion,
String namespace,
boolean canManageLifecycle
boolean canManageLifecycle,
String viewingVersionStatus,
boolean canInteract
) {}

View file

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

View file

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

View file

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

View file

@ -133,6 +133,8 @@ export interface SkillDetail {
latestVersion?: string
namespace: string
canManageLifecycle: boolean
viewingVersionStatus?: string
canInteract: boolean
}
export interface SkillVersion {

View file

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

View file

@ -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": "归档技能",

View file

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

View file

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

View file

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