feat: complete skill promotion submission flow

This commit is contained in:
yun-zhi-ztl 2026-03-16 14:57:43 +08:00 committed by Xudong Sun
parent bead940e3e
commit 556e65efef
18 changed files with 533 additions and 19 deletions

195
scripts/promotion-smoke-test.sh Executable file
View file

@ -0,0 +1,195 @@
#!/usr/bin/env bash
set -euo pipefail
BASE_URL="${1:-http://localhost:8080}"
PASS=0
FAIL=0
USER_COOKIE="$(mktemp)"
ADMIN_COOKIE="$(mktemp)"
WORK_DIR="$(mktemp -d)"
SLUG="psmoke$(date +%s)"
cleanup() {
rm -f "$USER_COOKIE" "$ADMIN_COOKIE"
rm -rf "$WORK_DIR"
}
trap cleanup EXIT
pass() {
echo "PASS: $1"
PASS=$((PASS + 1))
}
fail() {
echo "FAIL: $1"
FAIL=$((FAIL + 1))
}
csrf_token() {
local cookie_file="$1"
awk '$6 == "XSRF-TOKEN" { print $7 }' "$cookie_file" | tail -n 1
}
bootstrap_csrf() {
local cookie_file="$1"
local user_id="$2"
curl -s -c "$cookie_file" -H "X-Mock-User-Id: $user_id" "$BASE_URL/api/v1/auth/providers" >/dev/null
}
json_field() {
local json="$1"
local expr="$2"
JSON_INPUT="$json" python3 - "$expr" <<'PY'
import json
import os
import sys
expr = sys.argv[1]
value = json.loads(os.environ["JSON_INPUT"])
for part in expr.split("."):
if part.isdigit():
value = value[int(part)]
else:
value = value[part]
if isinstance(value, (dict, list)):
print(json.dumps(value, ensure_ascii=False))
else:
print(value)
PY
}
assert_code() {
local description="$1"
local json="$2"
local expected="$3"
local actual
actual="$(json_field "$json" "code")"
if [[ "$actual" == "$expected" ]]; then
pass "$description"
else
fail "$description (expected code $expected, got $actual)"
fi
}
echo "=== Promotion Workflow Smoke Test ==="
echo "Target: $BASE_URL"
echo "Slug: $SLUG"
echo
bootstrap_csrf "$USER_COOKIE" "local-user"
bootstrap_csrf "$ADMIN_COOKIE" "local-admin"
USER_CSRF="$(csrf_token "$USER_COOKIE")"
ADMIN_CSRF="$(csrf_token "$ADMIN_COOKIE")"
if [[ -z "$USER_CSRF" || -z "$ADMIN_CSRF" ]]; then
echo "Could not bootstrap CSRF tokens"
exit 1
fi
GLOBAL_RESPONSE="$(curl -sS -H "X-Mock-User-Id: local-user" -b "$USER_COOKIE" -c "$USER_COOKIE" \
"$BASE_URL/api/web/namespaces/global")"
assert_code "Global namespace detail is available" "$GLOBAL_RESPONSE" "0"
GLOBAL_NAMESPACE_ID="$(json_field "$GLOBAL_RESPONSE" "data.id")"
CREATE_NAMESPACE_RESPONSE="$(curl -sS -H "X-Mock-User-Id: local-user" -b "$USER_COOKIE" -c "$USER_COOKIE" \
-H "X-XSRF-TOKEN: $USER_CSRF" \
-H "Content-Type: application/json" \
-X POST "$BASE_URL/api/web/namespaces" \
-d "{\"slug\":\"$SLUG\",\"displayName\":\"Promotion Smoke $SLUG\",\"description\":\"promotion smoke test\"}")"
assert_code "Owner can create promotion smoke namespace" "$CREATE_NAMESPACE_RESPONSE" "0"
NAMESPACE_ID="$(json_field "$CREATE_NAMESPACE_RESPONSE" "data.id")"
cat > "$WORK_DIR/SKILL.md" <<'EOF'
---
name: Promotion Smoke Skill
description: Promotion smoke test
version: 1.0.0
---
Body
EOF
(cd "$WORK_DIR" && zip -q skill.zip SKILL.md)
PUBLISH_RESPONSE="$(curl -sS -H "X-Mock-User-Id: local-user" -b "$USER_COOKIE" -c "$USER_COOKIE" \
-H "X-XSRF-TOKEN: $USER_CSRF" \
-F "file=@$WORK_DIR/skill.zip;type=application/zip" \
-F "visibility=PUBLIC" \
"$BASE_URL/api/web/skills/$SLUG/publish")"
assert_code "Owner can publish a team skill" "$PUBLISH_RESPONSE" "0"
SKILL_ID="$(json_field "$PUBLISH_RESPONSE" "data.skillId")"
SKILL_SLUG="$(json_field "$PUBLISH_RESPONSE" "data.slug")"
PENDING_REVIEWS_RESPONSE="$(curl -sS -H "X-Mock-User-Id: local-admin" -b "$ADMIN_COOKIE" -c "$ADMIN_COOKIE" \
"$BASE_URL/api/web/reviews?status=PENDING&namespaceId=$NAMESPACE_ID")"
assert_code "Admin can list pending namespace reviews" "$PENDING_REVIEWS_RESPONSE" "0"
REVIEW_ID="$(json_field "$PENDING_REVIEWS_RESPONSE" "data.items.0.id")"
APPROVE_REVIEW_RESPONSE="$(curl -sS -H "X-Mock-User-Id: local-admin" -b "$ADMIN_COOKIE" -c "$ADMIN_COOKIE" \
-H "X-XSRF-TOKEN: $ADMIN_CSRF" \
-H "Content-Type: application/json" \
-X POST "$BASE_URL/api/web/reviews/$REVIEW_ID/approve" \
-d '{"comment":"ok"}')"
assert_code "Admin can approve team skill review" "$APPROVE_REVIEW_RESPONSE" "0"
SKILL_DETAIL_RESPONSE="$(curl -sS -H "X-Mock-User-Id: local-user" -b "$USER_COOKIE" -c "$USER_COOKIE" \
"$BASE_URL/api/web/skills/$SLUG/$SKILL_SLUG")"
assert_code "Owner can load team skill detail" "$SKILL_DETAIL_RESPONSE" "0"
VERSION_ID="$(json_field "$SKILL_DETAIL_RESPONSE" "data.latestVersionId")"
CAN_SUBMIT_PROMOTION="$(json_field "$SKILL_DETAIL_RESPONSE" "data.canSubmitPromotion")"
if [[ "$CAN_SUBMIT_PROMOTION" == "True" || "$CAN_SUBMIT_PROMOTION" == "true" ]]; then
pass "Approved team skill is marked promotable"
else
fail "Approved team skill should expose canSubmitPromotion=true"
fi
MY_SKILLS_RESPONSE="$(curl -sS -H "X-Mock-User-Id: local-user" -b "$USER_COOKIE" -c "$USER_COOKIE" \
"$BASE_URL/api/web/me/skills")"
assert_code "Owner can list my skills with promotion metadata" "$MY_SKILLS_RESPONSE" "0"
if JSON_INPUT="$MY_SKILLS_RESPONSE" python3 - "$SKILL_ID" <<'PY'
import json
import os
import sys
skill_id = int(sys.argv[1])
items = json.loads(os.environ["JSON_INPUT"])["data"]
match = next(item for item in items if item["id"] == skill_id)
raise SystemExit(0 if match["canSubmitPromotion"] and match["latestVersionId"] else 1)
PY
then
pass "My skills response exposes promotion submission fields"
else
fail "My skills response should expose latestVersionId and canSubmitPromotion"
fi
SUBMIT_PROMOTION_RESPONSE="$(curl -sS -H "X-Mock-User-Id: local-user" -b "$USER_COOKIE" -c "$USER_COOKIE" \
-H "X-XSRF-TOKEN: $USER_CSRF" \
-H "Content-Type: application/json" \
-X POST "$BASE_URL/api/web/promotions" \
-d "{\"sourceSkillId\":$SKILL_ID,\"sourceVersionId\":$VERSION_ID,\"targetNamespaceId\":$GLOBAL_NAMESPACE_ID}")"
assert_code "Owner can submit promotion to global namespace" "$SUBMIT_PROMOTION_RESPONSE" "0"
PENDING_PROMOTIONS_RESPONSE="$(curl -sS -H "X-Mock-User-Id: local-admin" -b "$ADMIN_COOKIE" -c "$ADMIN_COOKIE" \
"$BASE_URL/api/web/promotions?status=PENDING")"
assert_code "Admin can list pending promotions" "$PENDING_PROMOTIONS_RESPONSE" "0"
if JSON_INPUT="$PENDING_PROMOTIONS_RESPONSE" python3 - "$SKILL_ID" <<'PY'
import json
import os
import sys
skill_id = int(sys.argv[1])
items = json.loads(os.environ["JSON_INPUT"])["data"]["items"]
raise SystemExit(0 if any(item["sourceSkillId"] == skill_id for item in items) else 1)
PY
then
pass "Pending promotions list contains the submitted team skill"
else
fail "Pending promotions list should include submitted team skill"
fi
echo
echo "Results: $PASS passed, $FAIL failed"
if [[ "$FAIL" -ne 0 ]]; then
exit 1
fi

View file

@ -70,8 +70,10 @@ public class SkillController extends BaseApiController {
detail.ratingCount(),
detail.hidden(),
detail.latestVersion(),
detail.latestVersionId(),
namespace,
detail.canManageLifecycle(),
detail.canSubmitPromotion(),
detail.viewingVersionStatus(),
detail.canInteract()
);

View file

@ -15,8 +15,10 @@ public record SkillDetailResponse(
Integer ratingCount,
boolean hidden,
String latestVersion,
Long latestVersionId,
String namespace,
boolean canManageLifecycle,
boolean canSubmitPromotion,
String viewingVersionStatus,
boolean canInteract
) {}

View file

@ -14,7 +14,9 @@ public record SkillSummaryResponse(
BigDecimal ratingAvg,
Integer ratingCount,
String latestVersion,
Long latestVersionId,
String latestVersionStatus,
String namespace,
LocalDateTime updatedAt
LocalDateTime updatedAt,
boolean canSubmitPromotion
) {}

View file

@ -1,6 +1,8 @@
package com.iflytek.skillhub.service;
import com.iflytek.skillhub.domain.namespace.NamespaceRepository;
import com.iflytek.skillhub.domain.namespace.NamespaceStatus;
import com.iflytek.skillhub.domain.namespace.NamespaceType;
import com.iflytek.skillhub.domain.skill.Skill;
import com.iflytek.skillhub.domain.skill.SkillRepository;
import com.iflytek.skillhub.domain.skill.SkillVersion;
@ -50,15 +52,13 @@ public class MySkillAppService {
.map(Skill::getNamespaceId)
.distinct()
.toList();
Map<Long, String> namespaceSlugsById = namespaceIds.isEmpty()
Map<Long, com.iflytek.skillhub.domain.namespace.Namespace> namespacesById = namespaceIds.isEmpty()
? Map.of()
: namespaceRepository.findByIdIn(namespaceIds).stream()
.collect(Collectors.toMap(
com.iflytek.skillhub.domain.namespace.Namespace::getId,
com.iflytek.skillhub.domain.namespace.Namespace::getSlug));
.collect(Collectors.toMap(com.iflytek.skillhub.domain.namespace.Namespace::getId, Function.identity()));
return skills.stream()
.map(skill -> toSummaryResponse(skill, versionsBySkillId, namespaceSlugsById))
.map(skill -> toSummaryResponse(skill, versionsBySkillId, namespacesById))
.toList();
}
@ -80,18 +80,16 @@ public class MySkillAppService {
.map(Skill::getNamespaceId)
.distinct()
.toList();
Map<Long, String> namespaceSlugsById = namespaceIds.isEmpty()
Map<Long, com.iflytek.skillhub.domain.namespace.Namespace> namespacesById = namespaceIds.isEmpty()
? Map.of()
: namespaceRepository.findByIdIn(namespaceIds).stream()
.collect(Collectors.toMap(
com.iflytek.skillhub.domain.namespace.Namespace::getId,
com.iflytek.skillhub.domain.namespace.Namespace::getSlug));
.collect(Collectors.toMap(com.iflytek.skillhub.domain.namespace.Namespace::getId, Function.identity()));
return stars.stream()
.sorted(Comparator.comparing(com.iflytek.skillhub.domain.social.SkillStar::getCreatedAt).reversed())
.map(star -> skillsById.get(star.getSkillId()))
.filter(java.util.Objects::nonNull)
.map(skill -> toSummaryResponse(skill, versionsBySkillId, namespaceSlugsById))
.map(skill -> toSummaryResponse(skill, versionsBySkillId, namespacesById))
.toList();
}
@ -116,8 +114,9 @@ public class MySkillAppService {
private SkillSummaryResponse toSummaryResponse(
Skill skill,
Map<Long, SkillVersion> versionsBySkillId,
Map<Long, String> namespaceSlugsById) {
Map<Long, com.iflytek.skillhub.domain.namespace.Namespace> namespacesById) {
SkillVersion latestVersion = versionsBySkillId.get(skill.getId());
com.iflytek.skillhub.domain.namespace.Namespace namespace = namespacesById.get(skill.getNamespaceId());
return new SkillSummaryResponse(
skill.getId(),
@ -130,12 +129,30 @@ public class MySkillAppService {
skill.getRatingAvg(),
skill.getRatingCount(),
Optional.ofNullable(latestVersion).map(SkillVersion::getVersion).orElse(null),
Optional.ofNullable(latestVersion).map(SkillVersion::getId).orElse(null),
Optional.ofNullable(latestVersion).map(SkillVersion::getStatus).map(Enum::name).orElse(null),
namespaceSlugsById.get(skill.getNamespaceId()),
skill.getUpdatedAt()
namespace != null ? namespace.getSlug() : null,
skill.getUpdatedAt(),
canSubmitPromotion(skill, latestVersion, namespace)
);
}
private boolean canSubmitPromotion(
Skill skill,
SkillVersion latestVersion,
com.iflytek.skillhub.domain.namespace.Namespace namespace) {
if (namespace == null) {
return false;
}
if (namespace.getType() == NamespaceType.GLOBAL) {
return false;
}
if (namespace.getStatus() != NamespaceStatus.ACTIVE || skill.getStatus() != com.iflytek.skillhub.domain.skill.SkillStatus.ACTIVE) {
return false;
}
return latestVersion != null && latestVersion.getStatus() == SkillVersionStatus.PUBLISHED;
}
private Map<Long, SkillVersion> loadLatestRelevantVersions(java.util.Collection<Skill> skills) {
if (skills.isEmpty()) {
return Map.of();

View file

@ -198,9 +198,11 @@ public class SkillSearchAppService {
skill.getRatingAvg(),
skill.getRatingCount(),
latestVersion,
skill.getLatestVersionId(),
latestVersion == null ? null : "PUBLISHED",
namespaceSlug,
skill.getUpdatedAt()
skill.getUpdatedAt(),
false
);
}

View file

@ -62,9 +62,11 @@ class ClawHubCompatControllerTest {
BigDecimal.valueOf(4.5),
2,
"1.2.0",
11L,
"PUBLISHED",
"global",
LocalDateTime.of(2026, 3, 13, 9, 0))),
LocalDateTime.of(2026, 3, 13, 9, 0),
false)),
1,
0,
20

View file

@ -126,7 +126,9 @@ class SkillControllerTest {
LocalDateTime.of(2026, 3, 15, 10, 0),
LocalDateTime.of(2026, 3, 15, 10, 0),
null,
11L,
true,
false,
"PENDING_REVIEW",
false
));
@ -135,6 +137,8 @@ class SkillControllerTest {
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.latestVersion").value("1.1.0"))
.andExpect(jsonPath("$.data.latestVersionId").value(11L))
.andExpect(jsonPath("$.data.canSubmitPromotion").value(false))
.andExpect(jsonPath("$.data.viewingVersionStatus").value("PENDING_REVIEW"))
.andExpect(jsonPath("$.data.canInteract").value(false));
}

View file

@ -110,6 +110,36 @@ class MySkillAppServiceTest {
assertThat(skills).hasSize(1);
assertThat(skills.get(0).latestVersion()).isEqualTo("1.0.0");
assertThat(skills.get(0).latestVersionId()).isEqualTo(11L);
assertThat(skills.get(0).latestVersionStatus()).isEqualTo("PENDING_REVIEW");
assertThat(skills.get(0).canSubmitPromotion()).isFalse();
}
@Test
void listMySkills_marksTeamPublishedSkillAsPromotable() {
Skill skill = new Skill(101L, "team-skill", "user-1", SkillVisibility.PUBLIC);
skill.setDisplayName("Team Skill");
skill.setSummary("published");
ReflectionTestUtils.setField(skill, "id", 2L);
ReflectionTestUtils.setField(skill, "updatedAt", LocalDateTime.of(2026, 3, 15, 11, 0));
SkillVersion publishedVersion = new SkillVersion(2L, "1.2.0", "user-1");
publishedVersion.setStatus(SkillVersionStatus.PUBLISHED);
ReflectionTestUtils.setField(publishedVersion, "id", 22L);
ReflectionTestUtils.setField(publishedVersion, "createdAt", LocalDateTime.of(2026, 3, 15, 10, 30));
Namespace namespace = new Namespace("team-ai", "Team AI", "user-1");
ReflectionTestUtils.setField(namespace, "id", 101L);
given(skillRepository.findByOwnerId("user-1")).willReturn(List.of(skill));
given(skillVersionRepository.findBySkillIdIn(List.of(2L))).willReturn(List.of(publishedVersion));
given(namespaceRepository.findByIdIn(List.of(101L))).willReturn(List.of(namespace));
var skills = service.listMySkills("user-1");
assertThat(skills).hasSize(1);
assertThat(skills.get(0).latestVersionId()).isEqualTo(22L);
assertThat(skills.get(0).latestVersionStatus()).isEqualTo("PUBLISHED");
assertThat(skills.get(0).canSubmitPromotion()).isTrue();
}
}

View file

@ -4,6 +4,7 @@ import com.iflytek.skillhub.domain.namespace.Namespace;
import com.iflytek.skillhub.domain.namespace.NamespaceRepository;
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
import com.iflytek.skillhub.domain.namespace.NamespaceStatus;
import com.iflytek.skillhub.domain.namespace.NamespaceType;
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException;
import com.iflytek.skillhub.domain.skill.*;
@ -71,7 +72,9 @@ public class SkillQueryService {
java.time.LocalDateTime createdAt,
java.time.LocalDateTime updatedAt,
SkillVersion latestVersionEntity,
Long latestVersionId,
boolean canManageLifecycle,
boolean canSubmitPromotion,
String viewingVersionStatus,
boolean canInteract
) {}
@ -137,7 +140,9 @@ public class SkillQueryService {
skill.getCreatedAt(),
skill.getUpdatedAt(),
latestVersionEntity,
latestVersionEntity != null ? latestVersionEntity.getId() : null,
canManageRestrictedSkill(skill, currentUserId, userNsRoles),
canSubmitPromotion(namespace, skill, latestVersionEntity, currentUserId, userNsRoles),
latestVersionEntity != null ? latestVersionEntity.getStatus().name() : null,
latestVersionEntity == null || latestVersionEntity.getStatus() == SkillVersionStatus.PUBLISHED
);
@ -481,6 +486,24 @@ public class SkillQueryService {
|| role == NamespaceRole.OWNER;
}
private boolean canSubmitPromotion(
Namespace namespace,
Skill skill,
SkillVersion latestVersionEntity,
String currentUserId,
Map<Long, NamespaceRole> userNsRoles) {
if (namespace.getType() == NamespaceType.GLOBAL) {
return false;
}
if (namespace.getStatus() != NamespaceStatus.ACTIVE || skill.getStatus() != SkillStatus.ACTIVE) {
return false;
}
if (latestVersionEntity == null || latestVersionEntity.getStatus() != SkillVersionStatus.PUBLISHED) {
return false;
}
return canManageRestrictedSkill(skill, currentUserId, userNsRoles);
}
private boolean isOwner(Skill skill, String currentUserId) {
return currentUserId != null && skill.getOwnerId().equals(currentUserId);
}

View file

@ -463,6 +463,35 @@ class SkillQueryServiceTest {
assertTrue(result.canManageLifecycle());
}
@Test
void testGetSkillDetail_ShouldAllowPromotionForTeamOwnerOnPublishedSkill() throws Exception {
String namespaceSlug = "team-ns";
String skillSlug = "team-skill";
String userId = "owner-1";
Map<Long, NamespaceRole> userNsRoles = Map.of();
Namespace namespace = new Namespace(namespaceSlug, "Team NS", userId);
setId(namespace, 1L);
Skill skill = new Skill(1L, skillSlug, userId, SkillVisibility.PUBLIC);
setId(skill, 1L);
skill.setStatus(SkillStatus.ACTIVE);
skill.setLatestVersionId(11L);
SkillVersion published = new SkillVersion(1L, "1.0.0", userId);
setId(published, 11L);
published.setStatus(SkillVersionStatus.PUBLISHED);
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill));
when(visibilityChecker.canAccess(skill, userId, userNsRoles)).thenReturn(true);
when(skillVersionRepository.findById(11L)).thenReturn(Optional.of(published));
SkillQueryService.SkillDetailDTO result = service.getSkillDetail(namespaceSlug, skillSlug, userId, userNsRoles);
assertEquals(11L, result.latestVersionId());
assertTrue(result.canSubmitPromotion());
}
@Test
void testGetSkillDetail_ShouldNotFlagLifecyclePermissionForRegularViewer() throws Exception {
String namespaceSlug = "test-ns";
@ -483,6 +512,7 @@ class SkillQueryServiceTest {
SkillQueryService.SkillDetailDTO result = service.getSkillDetail(namespaceSlug, skillSlug, userId, userNsRoles);
assertFalse(result.canManageLifecycle());
assertFalse(result.canSubmitPromotion());
}
@Test

View file

@ -693,6 +693,16 @@ export const reviewApi = {
}
export const promotionApi = {
async submit(request: { sourceSkillId: number; sourceVersionId: number; targetNamespaceId: number }): Promise<void> {
await fetchJson<void>(`${WEB_API_PREFIX}/promotions`, {
method: 'POST',
headers: getCsrfHeaders({
'Content-Type': 'application/json',
}),
body: JSON.stringify(request),
})
},
async list(params: { status?: string; page?: number; size?: number }) {
const searchParams = new URLSearchParams()
searchParams.set('status', params.status ?? 'PENDING')

View file

@ -139,9 +139,11 @@ export interface SkillSummary {
ratingAvg?: number
ratingCount: number
latestVersion?: string
latestVersionId?: number
latestVersionStatus?: string
namespace: string
updatedAt: string
canSubmitPromotion: boolean
}
export interface SkillDetail {
@ -157,12 +159,20 @@ export interface SkillDetail {
ratingCount: number
hidden: boolean
latestVersion?: string
latestVersionId?: number
namespace: string
canManageLifecycle: boolean
canSubmitPromotion: boolean
viewingVersionStatus?: string
canInteract: boolean
}
export interface SubmitPromotionRequest {
sourceSkillId: number
sourceVersionId: number
targetNamespaceId: number
}
export interface SkillVersion {
id: number
version: string

View file

@ -256,6 +256,14 @@
"withdrawSuccessTitle": "Upload withdrawn",
"withdrawSuccessDescription": "The pending version for \"{{skill}}\" has been withdrawn.",
"withdrawErrorTitle": "Failed to withdraw upload",
"promoteToGlobal": "Promote to Global",
"promotionConfirmTitle": "Submit promotion request",
"promotionConfirmDescription": "Submit v{{version}} of \"{{skill}}\" for promotion into the global namespace?",
"promotionSuccessTitle": "Promotion request submitted",
"promotionSuccessDescription": "v{{version}} of \"{{skill}}\" is now in the global promotion review queue.",
"promotionDuplicateTitle": "Promotion already pending",
"promotionDuplicateDescription": "This version already has a pending promotion request.",
"promotionErrorTitle": "Failed to submit promotion request",
"emptyTitle": "No skills yet",
"emptyDescription": "Start publishing your first skill",
"publishSkill": "Publish Skill"
@ -568,6 +576,16 @@
"rereleaseSuccessDescription": "Created v{{target}} from v{{source}}.",
"rereleaseErrorTitle": "Failed to re-release version",
"yankVersion": "Yank Current Version",
"promoteToGlobal": "Promote to Global",
"promotionSectionTitle": "Promote to Global",
"promotionSectionDescription": "Submit the currently published version v{{version}} for review into the global namespace.",
"promotionConfirmTitle": "Submit promotion request",
"promotionConfirmDescription": "Submit v{{version}} of \"{{skill}}\" for promotion into the global namespace?",
"promotionSuccessTitle": "Promotion request submitted",
"promotionSuccessDescription": "v{{version}} of \"{{skill}}\" is now in the global promotion review queue.",
"promotionDuplicateTitle": "Promotion already pending",
"promotionDuplicateDescription": "This version already has a pending promotion request.",
"promotionErrorTitle": "Failed to submit promotion request",
"reportSkill": "Report Skill",
"reportDialogTitle": "Report skill",
"reportDialogDescription": "Provide a reason so administrators can review and act on it quickly.",

View file

@ -256,6 +256,14 @@
"withdrawSuccessTitle": "已撤销上传",
"withdrawSuccessDescription": "“{{skill}}”的待审核版本已撤销。",
"withdrawErrorTitle": "撤销上传失败",
"promoteToGlobal": "申请提升到全局",
"promotionConfirmTitle": "确认提交提升申请",
"promotionConfirmDescription": "确认将“{{skill}}”的 v{{version}} 提交为提升到全局空间的申请吗?",
"promotionSuccessTitle": "提升申请已提交",
"promotionSuccessDescription": "“{{skill}}”的 v{{version}} 已提交到全局空间审核队列。",
"promotionDuplicateTitle": "已存在待处理提升申请",
"promotionDuplicateDescription": "这个版本已经有待处理的提升申请,无需重复提交。",
"promotionErrorTitle": "提交提升申请失败",
"emptyTitle": "还没有技能",
"emptyDescription": "开始发布你的第一个技能吧",
"publishSkill": "发布技能"
@ -568,6 +576,16 @@
"rereleaseSuccessDescription": "已基于 v{{source}} 创建新版本 v{{target}}。",
"rereleaseErrorTitle": "重新发布版本失败",
"yankVersion": "撤回当前版本",
"promoteToGlobal": "申请提升到全局",
"promotionSectionTitle": "提升到全局",
"promotionSectionDescription": "将当前已发布版本 v{{version}} 提交到全局空间审核。",
"promotionConfirmTitle": "确认提交提升申请",
"promotionConfirmDescription": "确认将“{{skill}}”的 v{{version}} 提交为提升到全局空间的申请吗?",
"promotionSuccessTitle": "提升申请已提交",
"promotionSuccessDescription": "“{{skill}}”的 v{{version}} 已提交到全局空间审核队列。",
"promotionDuplicateTitle": "已存在待处理提升申请",
"promotionDuplicateDescription": "这个版本已经有待处理的提升申请,无需重复提交。",
"promotionErrorTitle": "提交提升申请失败",
"reportSkill": "举报技能",
"reportDialogTitle": "举报技能",
"reportDialogDescription": "请填写举报原因,帮助管理员快速判断和处理。",

View file

@ -6,9 +6,20 @@ import { Card } from '@/shared/ui/card'
import { EmptyState } from '@/shared/components/empty-state'
import { ConfirmDialog } from '@/shared/components/confirm-dialog'
import { DashboardPageHeader } from '@/shared/components/dashboard-page-header'
import { useArchiveSkill, useMySkills, useUnarchiveSkill, useWithdrawSkillReview } from '@/shared/hooks/use-skill-queries'
import { useArchiveSkill, useMySkills, useSubmitPromotion, useUnarchiveSkill, useWithdrawSkillReview } from '@/shared/hooks/use-skill-queries'
import { formatCompactCount } from '@/shared/lib/number-format'
import { toast } from '@/shared/lib/toast'
import { ApiError } from '@/api/client'
function isDuplicatePromotionMessage(message?: string): boolean {
if (!message) {
return false
}
return message.includes('promotion.duplicate_pending')
|| message.includes('已有待处理的提升申请')
|| message.includes('Duplicate pending promotion')
}
export function MySkillsPage() {
const navigate = useNavigate()
@ -16,10 +27,12 @@ export function MySkillsPage() {
const [archiveTarget, setArchiveTarget] = useState<{ namespace: string; slug: string; name: string } | null>(null)
const [unarchiveTarget, setUnarchiveTarget] = useState<{ namespace: string; slug: string; name: string } | null>(null)
const [withdrawTarget, setWithdrawTarget] = useState<{ namespace: string; slug: string; name: string; version: string } | null>(null)
const [promotionTarget, setPromotionTarget] = useState<{ skillId: number; versionId: number; name: string; version: string } | null>(null)
const { data: skills, isLoading } = useMySkills()
const archiveMutation = useArchiveSkill()
const unarchiveMutation = useUnarchiveSkill()
const withdrawMutation = useWithdrawSkillReview()
const submitPromotionMutation = useSubmitPromotion()
const handleSkillClick = (namespace: string, slug: string) => {
navigate({ to: `/space/${namespace}/${slug}` })
@ -112,6 +125,30 @@ export function MySkillsPage() {
}
}
const handleSubmitPromotion = async () => {
if (!promotionTarget) {
return
}
try {
await submitPromotionMutation.mutateAsync({
sourceSkillId: promotionTarget.skillId,
sourceVersionId: promotionTarget.versionId,
})
toast.success(
t('mySkills.promotionSuccessTitle'),
t('mySkills.promotionSuccessDescription', { skill: promotionTarget.name, version: promotionTarget.version }),
)
setPromotionTarget(null)
} catch (error) {
if (error instanceof ApiError && isDuplicatePromotionMessage(error.serverMessage || error.message)) {
toast.error(t('mySkills.promotionDuplicateTitle'), t('mySkills.promotionDuplicateDescription'))
return
}
toast.error(t('mySkills.promotionErrorTitle'), error instanceof Error ? error.message : '')
throw error
}
}
if (isLoading) {
return (
<div className="space-y-4 animate-fade-up">
@ -194,6 +231,22 @@ export function MySkillsPage() {
>
{t('mySkills.withdrawReview')}
</Button>
) : skill.canSubmitPromotion && skill.latestVersionId && skill.latestVersion ? (
<Button
size="sm"
variant="outline"
onClick={(event) => {
event.stopPropagation()
setPromotionTarget({
skillId: skill.id,
versionId: skill.latestVersionId!,
name: skill.displayName,
version: skill.latestVersion!,
})
}}
>
{t('mySkills.promoteToGlobal')}
</Button>
) : skill.status === 'ARCHIVED' ? (
<Button
size="sm"
@ -245,6 +298,19 @@ export function MySkillsPage() {
/>
)}
<ConfirmDialog
open={!!promotionTarget}
onOpenChange={(open) => {
if (!open) {
setPromotionTarget(null)
}
}}
title={t('mySkills.promotionConfirmTitle')}
description={promotionTarget ? t('mySkills.promotionConfirmDescription', { skill: promotionTarget.name, version: promotionTarget.version }) : ''}
confirmText={t('mySkills.promoteToGlobal')}
onConfirm={handleSubmitPromotion}
/>
<ConfirmDialog
open={!!archiveTarget}
onOpenChange={(open) => {

View file

@ -9,7 +9,7 @@ import { InstallCommand } from '@/features/skill/install-command'
import { RatingInput } from '@/features/social/rating-input'
import { StarButton } from '@/features/social/star-button'
import { useAuth } from '@/features/auth/use-auth'
import { adminApi, WEB_API_PREFIX } from '@/api/client'
import { adminApi, ApiError, WEB_API_PREFIX } from '@/api/client'
import { useSubmitSkillReport } from '@/features/report/use-skill-reports'
import { formatLocalDateTime } from '@/shared/lib/date-time'
import { formatCompactCount } from '@/shared/lib/number-format'
@ -31,6 +31,7 @@ import {
useArchiveSkill,
useDeleteSkillVersion,
useRereleaseSkillVersion,
useSubmitPromotion,
useUnarchiveSkill,
useWithdrawSkillReview,
} from '@/shared/hooks/use-skill-queries'
@ -56,6 +57,16 @@ function parseMetadataJson(parsed?: string) {
}
}
function isDuplicatePromotionMessage(message?: string): boolean {
if (!message) {
return false
}
return message.includes('promotion.duplicate_pending')
|| message.includes('已有待处理的提升申请')
|| message.includes('Duplicate pending promotion')
}
export function SkillDetailPage() {
const { t, i18n } = useTranslation()
const navigate = useNavigate()
@ -66,6 +77,7 @@ export function SkillDetailPage() {
const [reportDetails, setReportDetails] = useState('')
const [archiveConfirmOpen, setArchiveConfirmOpen] = useState(false)
const [unarchiveConfirmOpen, setUnarchiveConfirmOpen] = useState(false)
const [promotionConfirmOpen, setPromotionConfirmOpen] = useState(false)
const [deleteVersionTarget, setDeleteVersionTarget] = useState<string | null>(null)
const [withdrawVersionTarget, setWithdrawVersionTarget] = useState<string | null>(null)
const [rereleaseTarget, setRereleaseTarget] = useState<string | null>(null)
@ -116,6 +128,7 @@ export function SkillDetailPage() {
const deleteVersionMutation = useDeleteSkillVersion()
const withdrawReviewMutation = useWithdrawSkillReview()
const rereleaseVersionMutation = useRereleaseSkillVersion()
const submitPromotionMutation = useSubmitPromotion()
const reportMutation = useSubmitSkillReport(namespace, slug)
const handleDownload = () => {
@ -331,6 +344,30 @@ export function SkillDetailPage() {
setDiffCompareVersion(compareVersion)
}
const handleSubmitPromotion = async () => {
if (!skill?.latestVersionId) {
return
}
try {
await submitPromotionMutation.mutateAsync({
sourceSkillId: skill.id,
sourceVersionId: skill.latestVersionId,
})
toast.success(
t('skillDetail.promotionSuccessTitle'),
t('skillDetail.promotionSuccessDescription', { skill: skill.displayName, version: skill.latestVersion ?? '' }),
)
setPromotionConfirmOpen(false)
} catch (error) {
if (error instanceof ApiError && isDuplicatePromotionMessage(error.serverMessage || error.message)) {
toast.error(t('skillDetail.promotionDuplicateTitle'), t('skillDetail.promotionDuplicateDescription'))
return
}
toast.error(t('skillDetail.promotionErrorTitle'), error instanceof Error ? error.message : '')
throw error
}
}
if (isLoadingSkill) {
return (
<div className="space-y-6 animate-fade-up">
@ -626,6 +663,18 @@ export function SkillDetailPage() {
</Card>
)}
{skill.canSubmitPromotion && skill.latestVersion && skill.latestVersionId && (
<Card className="p-5 space-y-3">
<div className="text-sm font-semibold font-heading text-foreground">{t('skillDetail.promotionSectionTitle')}</div>
<p className="text-sm text-muted-foreground">
{t('skillDetail.promotionSectionDescription', { version: skill.latestVersion })}
</p>
<Button variant="outline" onClick={() => setPromotionConfirmOpen(true)} disabled={submitPromotionMutation.isPending}>
{submitPromotionMutation.isPending ? t('skillDetail.processing') : t('skillDetail.promoteToGlobal')}
</Button>
</Card>
)}
{governanceVisible && (
<Card className="p-5 space-y-3">
<div className="text-sm font-semibold font-heading text-foreground">{t('skillDetail.governance')}</div>
@ -680,6 +729,18 @@ export function SkillDetailPage() {
</DialogContent>
</Dialog>
<ConfirmDialog
open={promotionConfirmOpen}
onOpenChange={setPromotionConfirmOpen}
title={t('skillDetail.promotionConfirmTitle')}
description={t('skillDetail.promotionConfirmDescription', {
skill: skill.displayName,
version: skill.latestVersion ?? '',
})}
confirmText={t('skillDetail.promoteToGlobal')}
onConfirm={handleSubmitPromotion}
/>
<ConfirmDialog
open={archiveConfirmOpen}
onOpenChange={setArchiveConfirmOpen}

View file

@ -1,6 +1,6 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import type { SkillSummary, SkillDetail, SkillVersion, SkillVersionDetail, SkillFile, SearchParams, PagedResponse, PublishResult, Namespace, NamespaceMember, ManagedNamespace, CreateNamespaceRequest, NamespaceCandidateUser, NamespaceRole } from '@/api/types'
import { fetchJson, fetchText, getCsrfHeaders, meApi, namespaceApi, skillLifecycleApi, WEB_API_PREFIX } from '@/api/client'
import { fetchJson, fetchText, getCsrfHeaders, meApi, namespaceApi, promotionApi, skillLifecycleApi, WEB_API_PREFIX } from '@/api/client'
const PUBLISH_REQUEST_TIMEOUT_MS = 60_000
@ -72,6 +72,15 @@ async function getNamespaceMembers(slug: string): Promise<NamespaceMember[]> {
return namespaceApi.listMembers(slug)
}
async function submitPromotion(params: { sourceSkillId: number; sourceVersionId: number }): Promise<void> {
const globalNamespace = await namespaceApi.getDetail('global')
await promotionApi.submit({
sourceSkillId: params.sourceSkillId,
sourceVersionId: params.sourceVersionId,
targetNamespaceId: globalNamespace.id,
})
}
async function searchNamespaceMemberCandidates(params: { slug: string; search: string }): Promise<NamespaceCandidateUser[]> {
return namespaceApi.searchMemberCandidates(params.slug, params.search)
}
@ -210,6 +219,19 @@ export function useNamespaceMemberCandidates(slug: string, search: string, enabl
})
}
export function useSubmitPromotion() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: submitPromotion,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['promotions'] })
queryClient.invalidateQueries({ queryKey: ['governance'] })
queryClient.invalidateQueries({ queryKey: ['skills', 'my'] })
},
})
}
function invalidateNamespaceQueries(queryClient: ReturnType<typeof useQueryClient>, slug: string) {
queryClient.invalidateQueries({ queryKey: ['namespaces', 'my'] })
queryClient.invalidateQueries({ queryKey: ['namespaces', slug] })