fix(web): explain pending scope and linked global skills

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
XiaoSeS 2026-10-16 11:32:46 +08:00
parent 5560976c2c
commit dd4700bccd
20 changed files with 519 additions and 18 deletions

View file

@ -37,8 +37,8 @@ public record SearchQuery(
public record SearchVisibilityScope(
boolean includeAllPublic, // 是否包含所有 PUBLIC 技能
Set<Long> memberNamespaceIds, // 用户是 MEMBER 的 namespace(可见 NAMESPACE_ONLY)
Set<Long> adminNamespaceIds, // 用户是 ADMIN 的 namespace(可见 PRIVATE)
String userId // 当前用户 ID(可见自己的 PRIVATE skill),匿名为 null
Set<Long> adminNamespaceIds, // 管理上下文,门户搜索不因此展示 PRIVATE
String userId // 当前用户 ID,匿名为 null;PRIVATE 在“我的技能”管理
) {}
```
@ -50,9 +50,10 @@ ACL 投影计算规则:
```sql
WHERE (visibility = 'PUBLIC')
OR (visibility = 'NAMESPACE_ONLY' AND namespace_id IN (:memberNamespaceIds))
OR (visibility = 'PRIVATE' AND (namespace_id IN (:adminNamespaceIds) OR owner_id = :userId))
```
门户市场仅展示公开技能和当前用户所属空间的成员可见技能;即使本人或管理员有权访问 PRIVATE 详情,也不会将其列入市场。私有技能在“我的技能”管理。查询权限使用当前 `skill.visibility`,不信任可能尚未刷新的索引范围。
迁移到 ES 时,`SearchVisibilityScope` 可直接映射为 bool query 的 should/filter 子句。
## 3 搜索文档表 skill_search_document

View file

@ -19,6 +19,14 @@
私有并不代表平台无法治理:技能所有者、拥有相应空间权限的管理员以及现有超级管理员访问规则仍然适用。
## 列表中的当前范围和申请进度
「我的技能」会同时说明当前生效范围和待审目标,例如「当前范围:私有」以及「申请公开 · 审核中,批准前保持当前范围」。申请提交后刷新页面,仍可看到这项说明;批准、拒绝或撤回后,待审说明消失。申请公开不等于已经公开。「待审核」筛选同时包含待审内容版本和待审范围申请;同一技能只列一次,范围申请结束后退出该筛选(仍有待审内容版本时继续保留)。
私有技能不出现在技能市场,包括所有者自己的私有技能;请在「我的技能」管理。
已提升的团队技能仍保留,同时存在独立的全局副本。「我的技能」分别标明「团队来源」和「全局副本」,有权查看时提供互相跳转入口。团队来源再次提交时使用「更新全局版本」;两条技能的版本和统计仍各自独立,不会自动同步。
## 扩大范围后的审核
- 批准前仍按原范围访问;不提前出现在目标受众的搜索或下载结果中。

View file

@ -4,5 +4,7 @@
- 技能行锁协调提交、发布和审核;范围申请不修改 SkillVersion 状态和 latestVersionId。
- 审批 CAS 保留现有冲突机制。成功改变范围后通过专用范围变更事件复用现有搜索重建,避免重复发送内容更新通知;审计区分提交、通过、拒绝、撤回。
- Web 复用管理交互与统一审核详情;展示当前范围和申请范围。
- 我的技能响应按当前页技能批量投影管理状态,提供待审范围及真实来源/全局关联。不得逐卡拉取审核或提升详情,不从同名推断关联;公共市场、收藏等摘要不附带这些私有管理信息。相关技能无访问权限时不暴露其坐标或跳转。
- 团队来源与全局发布仍是独立记录,可分别更新;列表只解释现有关系,不引入自动同步或删除联动。市场维持现有收录范围,私有技能在“我的技能”维护。
- 业务约束在服务代码与测试维护,迁移只增加必要列,不增加 SQL CHECK。
- 已发布 PRIVATE 版本没有可复用的安全通过结果时,复用 ScanTask/outbox 对已存 bundle 重新扫描,保留 PUBLISHED 状态。范围申请绑定这一次 taskId;旧结果不能解锁新申请,未完成或不安全结果不能批准。scanner 关闭且需要新扫描时明确拒绝,不改变原范围。

View file

@ -8,6 +8,7 @@ Issue #951:当前必须重新上传包才能改变可见范围。用户需要
- 缩小范围立即生效;扩大范围进入现有统一技能审核,批准前继续使用原范围。
- 可见范围申请与待审内容版本互斥,支持在现有审核进度中撤回。
- 所有入口继续遵守访问控制、安全扫描、头像受众审核和操作审计。
- 我的技能列表同时说明当前生效范围与待审目标;提升后通过真实关联区分团队来源和全局发布记录。
## Impact
后端 ReviewTask、技能生命周期、发布互斥、搜索投影;Web 管理入口和统一审核详情;API 类型及用户文档。

View file

@ -27,3 +27,17 @@
#### Scenario: 审核期间发布版本改变
- **WHEN** 范围申请绑定的发布版本或原范围与当前技能不符
- **THEN** 审批拒绝过期申请,不能覆盖较新的状态
### Requirement: REQ-VIS-05 管理列表 SHALL 区分当前范围与待审目标
我的技能列表 SHALL 显示当前生效范围;存在待审范围申请时 SHALL 同时说明申请目标与审核中状态,不将目标误作已经生效的范围。刷新、分页后 SHALL 保留正确状态。“待审核”筛选 SHALL 同时包含原内容待审与范围待审,计数与分页使用同一规则,同一技能不重复出现;其余分类沿用原内容状态。审批通过后显示新范围,拒绝或撤回后保留原范围并移除待审提示;不再有待审内容或范围时 SHALL 从待审核筛选退出。管理状态 SHALL 由服务端按当前页批量提供,不逐卡请求详情,不将私有审核元数据暴露到公共摘要。
#### Scenario: 列表中的私有转公开申请
- **WHEN** 所有者提交将 PRIVATE 改为 PUBLIC 的申请并刷新“我的技能”
- **THEN** 卡片说明当前私有及申请公开审核中;公众仍无权访问;审批通过后卡片更新为公开且不再显示待审提示
### Requirement: REQ-VIS-06 提升关系 SHALL 可识别且保留独立记录
我的技能列表 SHALL 基于真实来源关联区分团队来源与全局发布记录,在调用者有权访问关联技能时提供对应入口,不按相同名称推断关联。首次提升与已有全局记录的更新入口 SHALL 使用准确文案。该展示 SHALL 不改变已确认的双记录模型,不自动同步内容、版本、统计或权限。
#### Scenario: 同时管理团队来源和全局发布记录
- **WHEN** 调用者的列表包含一组已提升的团队来源与全局技能
- **THEN** 列表明确两条记录各自角色及有权查看的关联入口,用户可分别管理;不能查看来源时不通过关联展示泄露来源坐标

View file

@ -6,6 +6,11 @@
## 验证
- [x] 后端成功、拒绝、权限、并发及真实数据库迁移(开发反馈使用 Java 25;正式 Java 21 整合验收另记)
- [x] 前端请求、状态、审核和类型检查
- [ ] 本地构建部署后验证详情、搜索、下载一致性
- [x] 本地构建部署后验证详情、搜索、下载一致性(上一轮已完成;本轮人工反馈补验见下)
开发检查覆盖扫描失败/旧 task、完整派生 bundle/缺文件拒绝、陈旧搜索索引、提升更新两事务陈旧实体与待审互斥。部署与真实浏览器验收尚未执行,不以单测代替。
开发及运行验证已覆盖扫描失败/旧 task、完整派生 bundle/缺文件拒绝、陈旧搜索索引、提升更新两事务陈旧实体与待审互斥;实际执行版本、组合继承边界、浏览器及故障演练记录由 PR 验收报告说明。
## 人工反馈补充
- [x] 按当前页批量提供待审范围与真实提升关联,更新实际 OpenAPI 类型
- [x] 列表展示当前范围/待审目标及来源/全局角色,不改变市场收录或双记录模型
- [ ] 覆盖分页、无权关联、批准/拒绝/撤回后的投影及实际页面交互

View file

@ -33,9 +33,36 @@ public record SkillSummaryResponse(
*/
@JsonInclude(JsonInclude.Include.NON_NULL)
List<SkillLabelDto> labels,
String avatarUrl
String avatarUrl,
@JsonInclude(JsonInclude.Include.NON_NULL)
ManagementState managementState
) {
/** Owner workspace projection, never populated by public discovery or social lists. */
public record ManagementState(String requestedVisibility, Long reviewTaskId,
String promotionRole, String relatedNamespace, String relatedSlug) {}
public SkillSummaryResponse(
Long id, String slug, String displayName, String summary, String visibility, String status,
Long downloadCount, Integer starCount, BigDecimal ratingAvg, Integer ratingCount,
String namespace, Instant updatedAt, String ownerId, String ownerDisplayName,
boolean canSubmitPromotion, SkillLifecycleVersionResponse headlineVersion,
SkillLifecycleVersionResponse publishedVersion, SkillLifecycleVersionResponse ownerPreviewVersion,
String resolutionMode, ComplianceSnapshotResponse complianceSnapshot, List<SkillLabelDto> labels,
String avatarUrl) {
this(id, slug, displayName, summary, visibility, status, downloadCount, starCount, ratingAvg,
ratingCount, namespace, updatedAt, ownerId, ownerDisplayName, canSubmitPromotion,
headlineVersion, publishedVersion, ownerPreviewVersion, resolutionMode, complianceSnapshot,
labels, avatarUrl, null);
}
public SkillSummaryResponse withManagementState(ManagementState state) {
return new SkillSummaryResponse(id, slug, displayName, summary, visibility, status, downloadCount,
starCount, ratingAvg, ratingCount, namespace, updatedAt, ownerId, ownerDisplayName,
canSubmitPromotion, headlineVersion, publishedVersion, ownerPreviewVersion,
resolutionMode, complianceSnapshot, labels, avatarUrl, state);
}
public SkillSummaryResponse(
Long id, String slug, String displayName, String summary, String visibility, String status,
Long downloadCount, Integer starCount, BigDecimal ratingAvg, Integer ratingCount,
@ -107,6 +134,6 @@ public record SkillSummaryResponse(
return new SkillSummaryResponse(id, slug, displayName, summary, visibility, status, downloadCount,
starCount, ratingAvg, ratingCount, namespace, updatedAt, ownerId, ownerDisplayName,
canSubmitPromotion, headlineVersion,
publishedVersion, ownerPreviewVersion, resolutionMode, complianceSnapshot, labels, avatarUrl);
publishedVersion, ownerPreviewVersion, resolutionMode, complianceSnapshot, labels, avatarUrl, managementState);
}
}

View file

@ -24,6 +24,9 @@ import org.springframework.beans.factory.annotation.Autowired;
@Repository
public class JpaMySkillQueryRepository implements MySkillQueryRepository {
@jakarta.persistence.PersistenceContext
private jakarta.persistence.EntityManager entityManager;
private final NamespaceRepository namespaceRepository;
private final PromotionRequestRepository promotionRequestRepository;
private final SkillLifecycleProjectionService skillLifecycleProjectionService;
@ -64,6 +67,103 @@ public class JpaMySkillQueryRepository implements MySkillQueryRepository {
.toList();
}
/** Page and count share this read-model predicate, including the existing owner-preview recency rule. */
private static final String PENDING_FROM_WHERE = """
FROM skill s
LEFT JOIN LATERAL (
SELECT published.id, published.created_at FROM skill_version published
WHERE published.skill_id = s.id AND published.status = 'PUBLISHED'
ORDER BY CASE WHEN published.id = s.latest_version_id THEN 0 ELSE 1 END,
published.published_at DESC NULLS FIRST, published.created_at DESC, published.id DESC
LIMIT 1
) published ON TRUE
LEFT JOIN LATERAL (
SELECT work.status FROM skill_version work
WHERE work.skill_id = s.id AND work.status NOT IN ('PUBLISHED', 'YANKED')
AND (published.id IS NULL OR (work.created_at, work.id) > (published.created_at, published.id))
ORDER BY work.created_at DESC, work.id DESC LIMIT 1
) preview ON TRUE
WHERE s.owner_id = :ownerId AND s.hidden = FALSE AND s.status <> 'ARCHIVED'
AND (CAST(:namespaceId AS BIGINT) IS NULL OR s.namespace_id = :namespaceId)
AND (:keyword = '' OR POSITION(:keyword IN LOWER(COALESCE(s.display_name, ''))) > 0
OR POSITION(:keyword IN LOWER(s.slug)) > 0
OR POSITION(:keyword IN LOWER(COALESCE(s.summary, ''))) > 0)
AND (preview.status = 'PENDING_REVIEW' OR EXISTS (
SELECT 1 FROM review_task r WHERE r.skill_id = s.id
AND r.requested_visibility IS NOT NULL AND r.status = 'PENDING'
))
""";
@Override
@org.springframework.transaction.annotation.Transactional(readOnly = true)
public org.springframework.data.domain.Page<Skill> findPendingSkills(String ownerId, String keyword,
Long namespaceId, org.springframework.data.domain.Pageable pageable) {
String query = keyword == null ? "" : keyword.trim().toLowerCase(java.util.Locale.ROOT);
// Native PostgreSQL LATERAL mirrors the owner-preview resolver without per-skill lookups.
var rows = entityManager.createNativeQuery("SELECT s.* " + PENDING_FROM_WHERE
+ " ORDER BY s.updated_at DESC, s.id DESC", Skill.class);
var count = entityManager.createNativeQuery("SELECT COUNT(*) " + PENDING_FROM_WHERE);
for (var statement : List.of(rows, count)) {
statement.setParameter("ownerId", ownerId).setParameter("keyword", query)
.setParameter("namespaceId", namespaceId);
}
@SuppressWarnings("unchecked")
List<Skill> items = rows.setFirstResult((int) pageable.getOffset()).setMaxResults(pageable.getPageSize()).getResultList();
return new org.springframework.data.domain.PageImpl<>(items, pageable, ((Number) count.getSingleResult()).longValue());
}
/**
* One page-bounded management read. Authorizing related coordinates by the same owner avoids
* revealing a private source through a public/global card. No per-card visibility requests.
*/
@Override
@org.springframework.transaction.annotation.Transactional(readOnly = true)
public Map<Long, SkillSummaryResponse.ManagementState> getManagementStates(List<Skill> skills, String ownerId) {
List<Long> ids = skills.stream().filter(skill -> ownerId.equals(skill.getOwnerId()))
.map(Skill::getId).toList();
if (ids.isEmpty()) {
return Map.of();
}
// App read-model joins stay in this query repository; domain repositories remain unchanged.
@SuppressWarnings("unchecked")
List<Object[]> rows = entityManager.createNativeQuery("""
SELECT s.id, pending.requested_visibility, pending.id,
CASE WHEN s.source_skill_id IS NOT NULL THEN 'GLOBAL_COPY'
WHEN target_ns.id IS NOT NULL THEN 'TEAM_SOURCE' END,
CASE WHEN s.source_skill_id IS NOT NULL THEN source_ns.slug
WHEN target.visibility <> 'NAMESPACE_ONLY' OR EXISTS (
SELECT 1 FROM namespace_member m
WHERE m.namespace_id = target.namespace_id AND m.user_id = :ownerId
) THEN target_ns.slug END,
CASE WHEN s.source_skill_id IS NOT NULL THEN source.slug ELSE target.slug END
FROM skill s
LEFT JOIN LATERAL (
SELECT r.id, r.requested_visibility FROM review_task r
WHERE r.skill_id = s.id AND r.requested_visibility IS NOT NULL AND r.status = 'PENDING'
ORDER BY r.id DESC LIMIT 1
) pending ON TRUE
LEFT JOIN skill source ON source.id = s.source_skill_id AND source.owner_id = :ownerId
AND source.hidden = FALSE AND source.status = 'ACTIVE'
AND (source.visibility <> 'NAMESPACE_ONLY' OR EXISTS (
SELECT 1 FROM namespace_member m WHERE m.namespace_id = source.namespace_id AND m.user_id = :ownerId
))
LEFT JOIN namespace source_ns ON source_ns.id = source.namespace_id AND source_ns.status = 'ACTIVE'
LEFT JOIN skill target ON target.source_skill_id = s.id AND target.owner_id = :ownerId
AND target.hidden = FALSE AND target.status = 'ACTIVE'
LEFT JOIN namespace target_ns ON target_ns.id = target.namespace_id
AND target_ns.type = 'GLOBAL' AND target_ns.status = 'ACTIVE'
WHERE s.id IN :ids AND s.owner_id = :ownerId
""")
.setParameter("ids", ids).setParameter("ownerId", ownerId).getResultList();
Map<Long, SkillSummaryResponse.ManagementState> states = new java.util.HashMap<>();
for (Object[] row : rows) {
states.put(((Number) row[0]).longValue(), new SkillSummaryResponse.ManagementState(
(String) row[1], row[2] == null ? null : ((Number) row[2]).longValue(),
(String) row[3], (String) row[4], row[4] == null ? null : (String) row[5]));
}
return states;
}
@Override
public List<SkillSummaryResponse> getHiddenSkillSummaries(List<Skill> skills) {
if (skills.isEmpty()) {

View file

@ -11,5 +11,10 @@ import java.util.List;
public interface MySkillQueryRepository {
List<SkillSummaryResponse> getSkillSummaries(List<Skill> skills, String currentUserId);
org.springframework.data.domain.Page<Skill> findPendingSkills(String ownerId, String keyword,
Long namespaceId, org.springframework.data.domain.Pageable pageable);
java.util.Map<Long, SkillSummaryResponse.ManagementState> getManagementStates(List<Skill> skills, String ownerId);
List<SkillSummaryResponse> getHiddenSkillSummaries(List<Skill> skills);
}

View file

@ -83,7 +83,9 @@ public class MySkillAppService {
MySkillFilter normalizedFilter = parseFilter(filter);
Page<Skill> skillPage;
if (normalizedFilter == MySkillFilter.HIDDEN) {
if (normalizedFilter == MySkillFilter.PENDING_REVIEW) {
skillPage = mySkillQueryRepository.findPendingSkills(userId, keyword, resolveNamespaceId(namespace), PageRequest.of(page, size));
} else if (normalizedFilter == MySkillFilter.HIDDEN) {
skillPage = listHiddenSkills(page, size, keyword, namespace, platformRoles);
} else if (normalizedFilter == MySkillFilter.ALL
&& (keyword == null || keyword.isBlank())
@ -97,6 +99,10 @@ public class MySkillAppService {
? mySkillQueryRepository.getHiddenSkillSummaries(skillPage.getContent())
: mySkillQueryRepository.getSkillSummaries(skillPage.getContent(), userId);
if (normalizedFilter != MySkillFilter.HIDDEN) {
var managementStates = mySkillQueryRepository.getManagementStates(skillPage.getContent(), userId);
items = items.stream().map(item -> item.withManagementState(managementStates.get(item.id()))).toList();
}
return new PageResponse<>(items, skillPage.getTotalElements(), skillPage.getNumber(), skillPage.getSize());
}

View file

@ -55,6 +55,10 @@ class SkillVisibilityControllerTest {
assertThat(schema.path("paths").has("/api/web/skills/{namespace}/{slug}/visibility")).isTrue();
assertThat(schema.path("components").path("schemas").path("ReviewTaskResponse")
.path("properties").has("requestedVisibility")).isTrue();
assertThat(schema.path("components").path("schemas").path("SkillSummaryResponse")
.path("properties").has("managementState")).isTrue();
assertThat(schema.path("components").path("schemas").path("ManagementState")
.path("properties").has("requestedVisibility")).isTrue();
String output = System.getProperty("skillhub.openapi.output");
if (output != null && !output.isBlank()) { Files.writeString(Path.of(output), json); }
}

View file

@ -0,0 +1,236 @@
package com.iflytek.skillhub.repository;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import com.iflytek.skillhub.domain.namespace.*;
import com.iflytek.skillhub.domain.review.*;
import com.iflytek.skillhub.domain.skill.*;
import com.iflytek.skillhub.domain.skill.service.SkillLifecycleProjectionService;
import com.iflytek.skillhub.domain.user.UserAccount;
import jakarta.persistence.EntityManager;
import java.util.List;
import org.hibernate.SessionFactory;
import org.junit.jupiter.api.BeforeEach;
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.test.context.ActiveProfiles;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.springframework.test.util.ReflectionTestUtils;
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")
@Testcontainers
class MySkillManagementPostgresTest {
@Container static final PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>("postgres:16-alpine");
@DynamicPropertySource static void database(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");
registry.add("spring.flyway.enabled", () -> true);
registry.add("spring.jpa.hibernate.ddl-auto", () -> "validate");
}
@Autowired EntityManager entities;
JpaMySkillQueryRepository query;
Namespace team;
Namespace global;
@BeforeEach void setup() {
query = new JpaMySkillQueryRepository(mock(NamespaceRepository.class), mock(PromotionRequestRepository.class),
mock(SkillLifecycleProjectionService.class));
ReflectionTestUtils.setField(query, "entityManager", entities);
entities.persist(new UserAccount("owner", "Owner", "owner@example.test", null));
entities.persist(new UserAccount("other", "Other", "other@example.test", null));
team = new Namespace("management-team", "Team", "owner");
entities.persist(team);
global = new Namespace("management-global", "Global", "owner");
global.setType(NamespaceType.GLOBAL);
entities.persist(global);
}
Skill skill(Namespace namespace, String slug) {
Skill skill = new Skill(namespace.getId(), slug, "owner", SkillVisibility.PRIVATE);
skill.setDisplayName(slug);
entities.persist(skill);
return skill;
}
@Test void pendingProjectionIsPageBoundedSingleQueryAndClearsForAllFinishedStatuses() {
Skill first = skill(team, "first");
Skill second = skill(team, "second");
Skill outside = skill(team, "outside-page");
ReviewTask task = ReviewTask.forVisibility(first, SkillVisibility.PUBLIC, "owner");
entities.persist(task);
entities.persist(ReviewTask.forVisibility(outside, SkillVisibility.PUBLIC, "owner"));
entities.flush();
var statistics = entities.getEntityManagerFactory().unwrap(SessionFactory.class).getStatistics();
statistics.setStatisticsEnabled(true);
long before = statistics.getPrepareStatementCount();
var states = query.getManagementStates(List.of(first, second), "owner");
assertThat(statistics.getPrepareStatementCount() - before).isEqualTo(1);
assertThat(states.keySet()).containsExactlyInAnyOrder(first.getId(), second.getId());
assertThat(states.get(first.getId()).requestedVisibility()).isEqualTo("PUBLIC");
assertThat(states.get(first.getId()).reviewTaskId()).isEqualTo(task.getId());
assertThat(states.get(second.getId()).requestedVisibility()).isNull();
assertThat(query.getManagementStates(List.of(first), "other")).isEmpty();
for (ReviewTaskStatus status : List.of(ReviewTaskStatus.APPROVED, ReviewTaskStatus.REJECTED)) {
task.setStatus(status);
entities.flush();
assertThat(query.getManagementStates(List.of(first), "owner").get(first.getId()).requestedVisibility()).isNull();
}
entities.remove(task);
entities.flush();
assertThat(query.getManagementStates(List.of(first), "owner").get(first.getId()).reviewTaskId()).isNull();
}
@Test void actualSourceIdentityDrivesLinksAndNeverSameSlugOrForeignOwner() {
Skill source = skill(team, "original");
Skill unrelated = skill(team, "same-name");
Skill copy = skill(global, "same-name");
copy.setVisibility(SkillVisibility.PUBLIC);
copy.setSourceSkillId(source.getId());
entities.flush();
var states = query.getManagementStates(List.of(source, copy, unrelated), "owner");
assertThat(states.get(source.getId()).promotionRole()).isEqualTo("TEAM_SOURCE");
assertThat(states.get(source.getId()).relatedNamespace()).isEqualTo(global.getSlug());
assertThat(states.get(copy.getId()).promotionRole()).isEqualTo("GLOBAL_COPY");
assertThat(states.get(copy.getId()).relatedSlug()).isEqualTo(source.getSlug());
assertThat(states.get(unrelated.getId()).promotionRole()).isNull();
ReflectionTestUtils.setField(source, "ownerId", "other");
entities.flush();
var hidden = query.getManagementStates(List.of(copy), "owner").get(copy.getId());
assertThat(hidden.promotionRole()).isEqualTo("GLOBAL_COPY");
assertThat(hidden.relatedNamespace()).isNull();
assertThat(hidden.relatedSlug()).isNull();
}
@Test void pendingFilterPagesAndCountsScopeAndContentOnceAndRespectsOwnership() {
Skill scope = skill(team, "scope-only");
Skill content = skill(team, "content-only");
Skill both = skill(team, "both-pending");
Skill foreign = skill(team, "foreign-pending");
ReflectionTestUtils.setField(foreign, "ownerId", "other");
Skill settled = skill(team, "settled");
Namespace elsewhere = new Namespace("elsewhere", "Elsewhere", "owner");
entities.persist(elsewhere);
Skill outside = skill(elsewhere, "outside-namespace");
for (Skill skill : List.of(scope, content, both, foreign, settled, outside)) {
ReflectionTestUtils.setField(skill, "updatedAt", java.time.Instant.parse("2026-10-15T00:00:00Z"));
}
var scopeTask = ReviewTask.forVisibility(scope, SkillVisibility.PUBLIC, "owner");
var bothTask = ReviewTask.forVisibility(both, SkillVisibility.PUBLIC, "owner");
entities.persist(scopeTask);
entities.persist(bothTask);
entities.persist(ReviewTask.forVisibility(foreign, SkillVisibility.PUBLIC, "other"));
entities.persist(ReviewTask.forVisibility(outside, SkillVisibility.PUBLIC, "owner"));
var contentWork = new SkillVersion(content.getId(), "1.0.0", "owner");
contentWork.setStatus(SkillVersionStatus.PENDING_REVIEW);
entities.persist(contentWork);
var bothWork = new SkillVersion(both.getId(), "1.0.0", "owner");
bothWork.setStatus(SkillVersionStatus.PENDING_REVIEW);
entities.persist(bothWork);
entities.flush();
var first = query.findPendingSkills("owner", null, team.getId(), org.springframework.data.domain.PageRequest.of(0, 2));
var second = query.findPendingSkills("owner", null, team.getId(), org.springframework.data.domain.PageRequest.of(1, 2));
assertThat(first.getTotalElements()).isEqualTo(3);
assertThat(second.getTotalElements()).isEqualTo(3);
assertThat(first.getContent()).extracting(Skill::getId).containsExactly(both.getId(), content.getId());
assertThat(second.getContent()).extracting(Skill::getId).containsExactly(scope.getId());
assertThat(query.findPendingSkills("owner", "content", team.getId(), org.springframework.data.domain.PageRequest.of(0, 1)).getTotalElements()).isEqualTo(1);
assertThat(query.findPendingSkills("owner", null, -1L, org.springframework.data.domain.PageRequest.of(0, 2)).getTotalElements()).isZero();
assertThat(query.findPendingSkills("owner", null, null, org.springframework.data.domain.PageRequest.of(0, 10)).getTotalElements()).isEqualTo(4);
for (ReviewTaskStatus status : List.of(ReviewTaskStatus.APPROVED, ReviewTaskStatus.REJECTED)) {
scopeTask.setStatus(status);
bothTask.setStatus(status);
entities.flush();
var remaining = query.findPendingSkills("owner", null, team.getId(), org.springframework.data.domain.PageRequest.of(0, 10));
assertThat(remaining.getTotalElements()).isEqualTo(2);
assertThat(remaining.getContent()).extracting(Skill::getId).containsExactly(both.getId(), content.getId());
}
entities.remove(scopeTask);
entities.remove(bothTask);
entities.flush();
assertThat(query.findPendingSkills("owner", null, team.getId(), org.springframework.data.domain.PageRequest.of(0, 10)).getTotalElements()).isEqualTo(2);
}
@Test void pendingContentFilterPreservesOwnerPreviewRecencyRules() {
Skill skill = skill(team, "version-recency");
SkillVersion oldPending = new SkillVersion(skill.getId(), "1.0.0", "owner");
oldPending.setStatus(SkillVersionStatus.PENDING_REVIEW);
ReflectionTestUtils.setField(oldPending, "createdAt", java.time.Instant.parse("2026-10-01T00:00:00Z"));
entities.persist(oldPending);
SkillVersion published = new SkillVersion(skill.getId(), "2.0.0", "owner");
published.setStatus(SkillVersionStatus.PUBLISHED);
ReflectionTestUtils.setField(published, "createdAt", java.time.Instant.parse("2026-10-02T00:00:00Z"));
entities.persist(published);
skill.setLatestVersionId(published.getId());
entities.flush();
assertThat(query.findPendingSkills("owner", null, null, org.springframework.data.domain.PageRequest.of(0, 10)).getTotalElements()).isZero();
SkillVersion pending = new SkillVersion(skill.getId(), "3.0.0", "owner");
pending.setStatus(SkillVersionStatus.PENDING_REVIEW);
ReflectionTestUtils.setField(pending, "createdAt", java.time.Instant.parse("2026-10-03T00:00:00Z"));
entities.persist(pending);
entities.flush();
assertThat(query.findPendingSkills("owner", null, null, org.springframework.data.domain.PageRequest.of(0, 10)).getTotalElements()).isEqualTo(1);
SkillVersion newerWork = new SkillVersion(skill.getId(), "4.0.0", "owner");
newerWork.setStatus(SkillVersionStatus.UPLOADED);
ReflectionTestUtils.setField(newerWork, "createdAt", java.time.Instant.parse("2026-10-04T00:00:00Z"));
entities.persist(newerWork);
entities.flush();
assertThat(query.findPendingSkills("owner", null, null, org.springframework.data.domain.PageRequest.of(0, 10)).getTotalElements()).isZero();
}
@Test void normalSummarySerializationDoesNotExposeOwnerManagementState() throws Exception {
var summary = new com.iflytek.skillhub.dto.SkillSummaryResponse(1L, "foo", "Foo", null,
"PUBLIC", "ACTIVE", 0L, 0, null, 0, "global", null, false,
null, null, null, null, null);
var mapper = new com.fasterxml.jackson.databind.ObjectMapper();
assertThat(mapper.readTree(mapper.writeValueAsString(summary)).has("managementState")).isFalse();
var managed = summary.withManagementState(new com.iflytek.skillhub.dto.SkillSummaryResponse.ManagementState(
"PUBLIC", 42L, null, null, null));
assertThat(mapper.readTree(mapper.writeValueAsString(managed)).path("managementState")
.path("requestedVisibility").asText()).isEqualTo("PUBLIC");
}
@Test void privateGlobalCopyKeepsSourceRelationshipAndNamespaceOnlyCopyRequiresMembership() {
Skill source = skill(team, "source-to-global");
Skill copy = skill(global, "global-copy");
copy.setVisibility(SkillVisibility.PRIVATE);
copy.setSourceSkillId(source.getId());
entities.flush();
var privateState = query.getManagementStates(List.of(source), "owner").get(source.getId());
assertThat(privateState.promotionRole()).isEqualTo("TEAM_SOURCE");
assertThat(privateState.relatedNamespace()).isEqualTo(global.getSlug());
assertThat(privateState.relatedSlug()).isEqualTo(copy.getSlug());
copy.setVisibility(SkillVisibility.NAMESPACE_ONLY);
entities.flush();
var restricted = query.getManagementStates(List.of(source), "owner").get(source.getId());
assertThat(restricted.promotionRole()).isEqualTo("TEAM_SOURCE");
assertThat(restricted.relatedNamespace()).isNull();
assertThat(restricted.relatedSlug()).isNull();
entities.persist(new NamespaceMember(global.getId(), "owner", NamespaceRole.MEMBER));
entities.flush();
var memberState = query.getManagementStates(List.of(source), "owner").get(source.getId());
assertThat(memberState.promotionRole()).isEqualTo("TEAM_SOURCE");
assertThat(memberState.relatedSlug()).isEqualTo(copy.getSlug());
copy.setHidden(true);
entities.flush();
assertThat(query.getManagementStates(List.of(source), "owner").get(source.getId()).relatedSlug()).isNull();
}
@Test void namespaceOnlySourceRequiresMembershipAndArchivedSourceNeverLeaksCoordinates() {
Skill source = skill(team, "scoped-source");
Skill copy = skill(global, "scoped-copy");
copy.setVisibility(SkillVisibility.PUBLIC);
copy.setSourceSkillId(source.getId());
source.setVisibility(SkillVisibility.NAMESPACE_ONLY);
entities.flush();
assertThat(query.getManagementStates(List.of(copy), "owner").get(copy.getId()).relatedSlug()).isNull();
entities.persist(new NamespaceMember(team.getId(), "owner", NamespaceRole.MEMBER));
entities.flush();
assertThat(query.getManagementStates(List.of(copy), "owner").get(copy.getId()).relatedSlug()).isEqualTo(source.getSlug());
team.setStatus(NamespaceStatus.ARCHIVED);
entities.flush();
assertThat(query.getManagementStates(List.of(copy), "owner").get(copy.getId()).relatedNamespace()).isNull();
}
}

View file

@ -68,11 +68,19 @@ class MySkillAppServiceTest {
@BeforeEach
void setUp() {
skillLifecycleProjectionService = new SkillLifecycleProjectionService(skillVersionRepository);
mySkillQueryRepository = new JpaMySkillQueryRepository(
mySkillQueryRepository = org.mockito.Mockito.spy(new JpaMySkillQueryRepository(
namespaceRepository,
promotionRequestRepository,
skillLifecycleProjectionService
);
));
var entityManager = org.mockito.Mockito.mock(jakarta.persistence.EntityManager.class);
var managementQuery = org.mockito.Mockito.mock(jakarta.persistence.Query.class);
org.mockito.Mockito.lenient().when(entityManager.createNativeQuery(org.mockito.ArgumentMatchers.anyString()))
.thenReturn(managementQuery);
org.mockito.Mockito.lenient().when(managementQuery.setParameter(org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.any()))
.thenReturn(managementQuery);
org.mockito.Mockito.lenient().when(managementQuery.getResultList()).thenReturn(List.of());
ReflectionTestUtils.setField(mySkillQueryRepository, "entityManager", entityManager);
service = new MySkillAppService(
skillRepository,
skillVersionRepository,
@ -219,12 +227,10 @@ class MySkillAppServiceTest {
void listMySkills_filtersPendingReviewForRegularUsers() {
Skill pendingSkill = createSkill(1L, 101L, "pending-skill", "user-1");
SkillVersion pendingVersion = createVersion(1L, 11L, "1.0.0", SkillVersionStatus.PENDING_REVIEW, "2026-03-15T09:30:00Z");
Skill publishedSkill = createSkill(2L, 101L, "published-skill", "user-1");
SkillVersion publishedVersion = createVersion(2L, 22L, "1.2.0", SkillVersionStatus.PUBLISHED, "2026-03-15T10:30:00Z");
given(skillRepository.findByOwnerId("user-1")).willReturn(List.of(pendingSkill, publishedSkill));
org.mockito.Mockito.doReturn(new PageImpl<>(List.of(pendingSkill), PageRequest.of(0, 10), 1))
.when(mySkillQueryRepository).findPendingSkills("user-1", null, null, PageRequest.of(0, 10));
given(skillVersionRepository.findBySkillId(1L)).willReturn(List.of(pendingVersion));
given(skillVersionRepository.findBySkillId(2L)).willReturn(List.of(publishedVersion));
given(namespaceRepository.findByIdIn(List.of(101L))).willReturn(List.of(namespace(101L, "team-ai")));
var result = service.listMySkills("user-1", 0, 10, "PENDING_REVIEW", Set.of("USER"));

View file

@ -7735,6 +7735,14 @@ export interface components {
items?: components["schemas"]["ComplianceMappingResponse"][];
digest?: string;
};
ManagementState: {
requestedVisibility?: string;
/** Format: int64 */
reviewTaskId?: number;
promotionRole?: string;
relatedNamespace?: string;
relatedSlug?: string;
};
SearchResponse: {
items?: components["schemas"]["SkillSummaryResponse"][];
/** Format: int64 */
@ -7780,6 +7788,7 @@ export interface components {
complianceSnapshot?: components["schemas"]["ComplianceSnapshotResponse"];
labels?: components["schemas"]["SkillLabelDto"][];
avatarUrl?: string;
managementState?: components["schemas"]["ManagementState"];
};
ApiResponseBoolean: {
/** Format: int32 */

View file

@ -253,6 +253,7 @@ export interface BatchMemberResponse {
// Skill types
export interface SkillSummary {
managementState?: components['schemas']['ManagementState'] | null
id: number
slug: string
displayName: string

View file

@ -518,6 +518,10 @@
"securityDesc": "Change password and account security options"
},
"mySkills": {
"updateGlobalVersion": "Update global version",
"viewGlobalCopy": "View global copy",
"viewTeamSource": "View team source",
"promotionRoles": {"TEAM_SOURCE": "Team source · promoted to global", "GLOBAL_COPY": "Global copy · updated independently"},
"versionDifference": "This update v{{version}} · Public v{{published}}",
"statusUpdatePendingReview": "Update awaiting review",
"title": "My Skills",
@ -570,6 +574,7 @@
"withdrawSuccessDescription": "The pending version for \"{{skill}}\" has been withdrawn.",
"withdrawErrorTitle": "Failed to withdraw upload",
"promoteToGlobal": "Promote to Global",
"submitVersionToGlobal": "Submit version to global",
"promotionConfirmTitle": "Submit promotion request",
"promotionConfirmDescription": "Submit v{{version}} of \"{{skill}}\" for promotion into the global namespace?",
"promotionSuccessTitle": "Promotion request submitted",
@ -2768,6 +2773,7 @@
"deleteHint": "Permanently delete the space. Handle its skills and members first."
},
"skillVisibility": {
"pendingList": "Requested {{visibility}} · review pending. Current access remains in effect.",
"title": "Visibility",
"description": "Change access without changing the skill identity, package version, or statistics.",
"current": "Current scope: {{visibility}}",

View file

@ -518,6 +518,10 @@
"securityDesc": "Изменение пароля и параметров безопасности"
},
"mySkills": {
"updateGlobalVersion": "Обновить глобальную версию",
"viewGlobalCopy": "Открыть глобальную копию",
"viewTeamSource": "Открыть командный источник",
"promotionRoles": {"TEAM_SOURCE": "Командный источник · опубликован глобально", "GLOBAL_COPY": "Глобальная копия · обновляется отдельно"},
"versionDifference": "Обновление v{{version}} · Публичная v{{published}}",
"statusUpdatePendingReview": "Обновление на проверке",
"title": "Мои скиллы",
@ -570,6 +574,7 @@
"withdrawSuccessDescription": "Ожидающая версия для «{{skill}}» отозвана.",
"withdrawErrorTitle": "Не удалось отозвать загрузку",
"promoteToGlobal": "Продвинуть в Global",
"submitVersionToGlobal": "Отправить версию в глобальный каталог",
"promotionConfirmTitle": "Отправить запрос на продвижение",
"promotionConfirmDescription": "Отправить v{{version}} скилла «{{skill}}» на продвижение в глобальное пространство имён?",
"promotionSuccessTitle": "Запрос на продвижение отправлен",
@ -2770,6 +2775,7 @@
"deleteHint": "Удалить пространство навсегда. Сначала обработайте его навыки и участников."
},
"skillVisibility": {
"pendingList": "Запрошено: {{visibility}} · на рассмотрении. Текущий доступ сохраняется.",
"title": "Видимость",
"description": "Изменение доступа без изменения идентификатора навыка, версии пакета и статистики.",
"current": "Текущий доступ: {{visibility}}",

View file

@ -518,6 +518,10 @@
"securityDesc": "修改密码和账户安全选项"
},
"mySkills": {
"updateGlobalVersion": "更新全局版本",
"viewGlobalCopy": "查看全局副本",
"viewTeamSource": "查看团队来源",
"promotionRoles": {"TEAM_SOURCE": "团队来源 · 已提升到全局", "GLOBAL_COPY": "全局副本 · 与团队来源独立更新"},
"versionDifference": "本次 v{{version}} · 当前公开 v{{published}}",
"statusUpdatePendingReview": "更新待审核",
"title": "我的技能",
@ -570,6 +574,7 @@
"withdrawSuccessDescription": "“{{skill}}”的待审核版本已撤销。",
"withdrawErrorTitle": "撤销上传失败",
"promoteToGlobal": "申请提升到全局",
"submitVersionToGlobal": "提交版本到全局",
"promotionConfirmTitle": "确认提交提升申请",
"promotionConfirmDescription": "确认将“{{skill}}”的 v{{version}} 提交为提升到全局空间的申请吗?",
"promotionSuccessTitle": "提升申请已提交",
@ -2767,6 +2772,7 @@
"deleteHint": "永久删除空间,请先处理其中的技能和成员。"
},
"skillVisibility": {
"pendingList": "申请{{visibility}} · 审核中,批准前保持当前范围。",
"title": "可见范围",
"description": "仅调整访问范围,保留技能标识、内容版本和统计。",
"current": "当前范围:{{visibility}}",

View file

@ -1,12 +1,16 @@
import { createElement, type ReactNode } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createInstance, type i18n } from 'i18next'
import zh from '@/i18n/locales/zh.json'
import en from '@/i18n/locales/en.json'
const navigateMock = vi.fn()
const buttonRecords: Array<{ label: string; onClick?: ((event?: { stopPropagation: () => void }) => void) | undefined }> = []
const useMySkillsMock = vi.fn()
const searchMock: { filter?: string } = {}
let isSuperAdmin = false
let renderI18n: i18n | undefined
vi.mock('@tanstack/react-router', () => ({
useNavigate: () => navigateMock,
@ -19,7 +23,7 @@ vi.mock('react-i18next', async () => {
return {
...actual,
useTranslation: () => ({
t: (key: string) => key,
t: (key: string, options?: Record<string, unknown>) => renderI18n ? String(renderI18n.t(key, options)) : key,
}),
}
})
@ -126,6 +130,7 @@ function findButton(label: string) {
describe('MySkillsPage', () => {
beforeEach(() => {
renderI18n = undefined
navigateMock.mockReset()
buttonRecords.length = 0
delete searchMock.filter
@ -311,6 +316,37 @@ describe('MySkillsPage', () => {
expect(html).not.toContain('mySkills.statusPublished')
})
it.each(['zh', 'en'])('shows effective private access and the pending public request after refresh in %s', async language => {
renderI18n = createInstance()
await renderI18n.init({ lng: language, fallbackLng: false, resources: { zh: { translation: zh }, en: { translation: en } }, interpolation: { escapeValue: false } })
const item = { id: 1, displayName: 'Owned skill', namespace: 'team-ai', slug: 'owned', status: 'ACTIVE',
visibility: 'PRIVATE', downloadCount: 0, managementState: { requestedVisibility: 'PUBLIC', reviewTaskId: 42 } }
useMySkillsMock.mockReturnValue({ data: { items: [item], total: 1, page: 0, size: 10 }, isLoading: false })
const html = renderToStaticMarkup(createElement(MySkillsPage))
expect(html).toContain(renderI18n.t('skillVisibility.current', { visibility: renderI18n.t('skillVisibility.PRIVATE') }))
expect(html).toContain(renderI18n.t('skillVisibility.pendingList', { visibility: renderI18n.t('skillVisibility.PUBLIC') }))
expect(html.match(/class="status-pill[^"]*"/g)).toHaveLength(1)
item.managementState = { requestedVisibility: '', reviewTaskId: 0 }
const afterReview = renderToStaticMarkup(createElement(MySkillsPage))
expect(afterReview).not.toContain('role="status"')
})
it('distinguishes actual promotion links and update action without merging the two skills', () => {
useMySkillsMock.mockReturnValue({ data: { items: [
{ id: 1, displayName: 'Team', namespace: 'team-ai', slug: 'one', status: 'ACTIVE', downloadCount: 0,
canSubmitPromotion: true, managementState: { promotionRole: 'TEAM_SOURCE', relatedNamespace: 'global', relatedSlug: 'copy' } },
{ id: 2, displayName: 'Global', namespace: 'global', slug: 'copy', status: 'ACTIVE', downloadCount: 0,
managementState: { promotionRole: 'GLOBAL_COPY' } },
], total: 2, page: 0, size: 10 }, isLoading: false })
const html = renderToStaticMarkup(createElement(MySkillsPage))
expect(html).toContain('mySkills.promotionRoles.TEAM_SOURCE')
expect(html).toContain('mySkills.promotionRoles.GLOBAL_COPY')
expect(html).toContain('mySkills.viewGlobalCopy')
expect(html).not.toContain('mySkills.viewTeamSource')
expect(buttonRecords.some(button => button.label === 'mySkills.updateGlobalVersion')).toBe(true)
expect(buttonRecords.some(button => button.label === 'mySkills.promoteToGlobal')).toBe(false)
})
it('exports a named component function', () => {
expect(typeof MySkillsPage).toBe('function')
})

View file

@ -371,6 +371,8 @@ export function MySkillsPage() {
<div className="grid grid-cols-1 gap-4">
{skills.map((skill, idx) => (
(() => {
const relatedNamespace = skill.managementState?.relatedNamespace
const relatedSlug = skill.managementState?.relatedSlug
const headlineVersion = getHeadlineVersion(skill)
const publishedVersion = getPublishedVersion(skill)
const ownerPreviewVersion = getOwnerPreviewVersion(skill)
@ -397,12 +399,27 @@ export function MySkillsPage() {
{displayName}
</h3>
</div>
{skill.managementState?.promotionRole && (
<div className="mb-3 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<span>{t(`mySkills.promotionRoles.${skill.managementState.promotionRole}`)}</span>
{relatedNamespace && relatedSlug && (
<button type="button" className="text-primary underline underline-offset-4"
onClick={event => {
event.stopPropagation()
handleSkillClick(relatedNamespace, relatedSlug)
}}>
{t(skill.managementState.promotionRole === 'TEAM_SOURCE' ? 'mySkills.viewGlobalCopy' : 'mySkills.viewTeamSource')}
</button>
)}
</div>
)}
{skill.status === 'ACTIVE' && filter !== 'HIDDEN' && <SkillAvatarManager namespace={skill.namespace} slug={skill.slug} previewOnly />}
{summary && (
<p className="text-sm text-muted-foreground mb-3 leading-relaxed">{summary}</p>
)}
<div className="flex flex-wrap items-center gap-3 text-sm text-muted-foreground">
<span className="handle-tag">@{skill.namespace}</span>
<span className="handle-tag max-w-full break-all">@{skill.namespace}</span>
<span className="text-xs">{t('skillVisibility.current', { visibility: t(`skillVisibility.${skill.visibility ?? 'PUBLIC'}`) })}</span>
{preview && publishedVersion ? (
<span className="text-xs">{t('mySkills.versionDifference', { version: preview.version, published: publishedVersion.version })}</span>
) : headlineVersion ? (
@ -421,6 +438,11 @@ export function MySkillsPage() {
</span>
)}
</div>
{skill.managementState?.requestedVisibility && (
<p role="status" className="mt-3 text-sm text-muted-foreground">
{t('skillVisibility.pendingList', { visibility: t(`skillVisibility.${skill.managementState.requestedVisibility}`) })}
</p>
)}
</div>
<div className="flex shrink-0 flex-wrap items-center gap-2 sm:max-w-[45%] sm:justify-end">
{canRestoreHidden ? (
@ -480,7 +502,7 @@ export function MySkillsPage() {
})
}}
>
{t('mySkills.promoteToGlobal')}
{t(skill.managementState?.promotionRole === 'TEAM_SOURCE' ? 'mySkills.updateGlobalVersion' : 'mySkills.promoteToGlobal')}
</Button>
) : skill.status === 'ARCHIVED' ? (
<Button
@ -566,7 +588,7 @@ export function MySkillsPage() {
}}
title={t('mySkills.promotionConfirmTitle')}
description={promotionTarget ? t('mySkills.promotionConfirmDescription', { skill: promotionTarget.name, version: promotionTarget.version }) : ''}
confirmText={t('mySkills.promoteToGlobal')}
confirmText={t('mySkills.submitVersionToGlobal')}
onConfirm={handleSubmitPromotion}
/>