feat(avatar): add review and owner interfaces

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
XiaoSeS 2026-10-12 13:42:11 +08:00
parent f7e96bb8a4
commit b67ea7ddb8
28 changed files with 685 additions and 10 deletions

31
docs/26-skill-avatars.md Normal file
View file

@ -0,0 +1,31 @@
# 技能头像与审核
技能所有者可在技能详情页上传 PNG/JPEG,网页提供方形裁剪和缩放。服务器仍会独立校验:输入不超过 2 MB、边长不超过 1024 像素,并重新编码为 PNG,避免依赖客户端裁剪或保留原图元数据。头像候选存在私有对象存储;审核完成前,公众继续看到旧头像或技能名首字母。
## 审核策略
超级管理员在“系统配置”中分别开关机器审核和人工审核。默认关闭机器审核、开启人工审核。两个开关都关闭时直接发布;只开启机器时,通过即发布、阻止或未知结果保持私有;只开启人工时进入审核队列;两者都开启时先机器后人工。机器阻止后所有者可申请人工复核;机器服务失败时可重试,不会自动放行。相同图片且机器供应商与审核策略未变时可复用机器结果;扩大可见范围时必须重新人工审核。`PRIVATE` 技能头像由所有者直接使用。
团队范围由团队管理员审核,全局范围由平台管理员审核。头像只能由技能所有者提交、替换或删除;管理员可审核和撤下,不可代传。新申请使旧待审申请失效;旧审核决定不能覆盖新头像。被拒绝、替换、删除的候选图片私有保留 30 天后清理,文字审核记录继续保留。头像撤下后显示首字母兜底。
## 部署配置
在部署密钥配置中指定唯一供应商及其凭证,然后才可从后台开启机器审核。Spring 属性如下;将值映射到部署 Secret/环境变量时请遵循当前部署平台的配置方式,不要写入仓库或后台数据库:
| 供应商 | 属性 |
| --- | --- |
| 通用 | `skillhub.avatar.moderation.provider`:`xfyun` 或 `aliyun` |
| 讯飞 | `skillhub.avatar.moderation.xfyun.app-id`、`skillhub.avatar.moderation.xfyun.access-key-id`、`skillhub.avatar.moderation.xfyun.access-key-secret` |
| 阿里云 | `skillhub.avatar.moderation.aliyun.access-key-id`、`skillhub.avatar.moderation.aliyun.access-key-secret` |
后台只显示两个启停开关,不回显供应商或密钥。阿里云适配器使用上海地域的 `profilePhotoCheck`,通过短期上传令牌把私有候选送到官方 OSS 临时对象,再调用同步审核;不会公开候选 URL。官方返回 `RiskLevel=none/low` 通过,`medium/high` 保持私有并允许人工复核。把 `medium` 归为阻止是本产品策略。响应非 200、空结果或未知值均不放行。机器接口可能按量计费,开启前由部署方确认供应商账号与费用。
讯飞官方图片审核接口要求把 `accessKeySecret` 放在请求 URL 参数中。应用不会记录完整 URL 或异常原文,但反向代理、出口代理、链路追踪及第三方日志仍可能记录该 URL。部署方使用讯飞适配器前,必须确认相关网络组件不采集请求 URL,限制凭证权限并建立轮换机制。若无法满足,先不要开启该适配器。
## 与原 Issue 的差异
原 Issue 提议自动图片审核;本期产品决策改为“上传、裁剪、机器审核可选、人工审核可选”的四种策略。默认使用人工审核,机器审核仅在部署好单一供应商且管理员开启后运行。个人头像不在本期范围。
## 验收要点
分别验证四种开关组合、候选与当前头像隔离、拥有者和审核员权限、审核通过/驳回/复核、相同图片复用机器结果、未知机器结果不放行、连续替换后旧审核无效、删除后首字母兜底、30 天清理以及团队/全局可见范围变化。外部供应商真实计费调用需要部署方测试账号与授权;单元测试使用模拟响应。

View file

@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-12

View file

@ -0,0 +1,22 @@
## Context
当前 Skill 无头像字段。详情、搜索和“我的技能”由不同查询路径投影,Web 卡片与详情也没有统一头像组件。`ObjectStorageService` 支持写入、读取和删除,但本地实现不产生预签名 URL。现有 ReviewTask 只处理 SkillVersion/SuiteVersion;头像不能伪装成版本审核。V68 已提供 versioned `system_setting`。
## Decisions
1. 为 Skill 保存当前已激活头像引用;另建头像候选及决定记录。候选包含所属 Skill、提交者、私有对象键、内容哈希、目标可见范围、提交时审核策略快照、机器结果、人工决定、状态和时间。所有状态转移按候选 ID 和当前候选 ID 条件更新,防止迟到结果覆盖。
2. 单独实现头像审核服务和查询队列,不扩展现有 SkillVersion 审核分支。头像上传 API 显式校验 `skill.ownerId == actor`;审核、复核和撤下分别校验团队管理员或平台管理员。系统管理员若不是所有者也不能代传。
3. 图片通过受控 GET 接口提供。私人和待审对象必须鉴权,当前公开头像可提供稳定、无时效的访问 URL;服务端不得将对象存储临时签名地址持久化。公开范围变更之前须重新判断头像是否已获目标范围许可,避免私人图片借 Skill 可见性变化直接泄露。
4. 上传只接受最终裁剪图,服务端独立解码、校验、重新编码并去掉元数据。上限和格式以现有上传约定及测试验证后在实现中固定,避免前端校验成为唯一防线。
5. `system_setting` 新增独立头像审核键,仅保存机器和人工开关。部署配置只允许一个供应商及其 Secret;后台不展示供应商或 Secret。适配器在服务端主动调用,供应商异常保留候选和重试,不自动通过。供应商实现与 API 参数在接入时以官方文档验证。
6. 开关在提交候选时快照。PRIVATE 且仅所有者可见直接激活;其他候选按照快照执行。机器通过且人工关闭可激活;机器阻止时允许例外申诉;人工开启时有权审核员可改判并留理由。
7. 替换待审候选时旧候选变为 SUPERSEDED。删除和管理员撤下立即取消当前头像并恢复兜底。失效图片保留私有 30 天,再由有界清理任务删除;审计记录不随图片删除。
8. `SkillSummaryResponse` 的所有映射路径、详情 DTO 与生成类型包含当前头像 URL;Web 使用一个头像/兜底组件,所有者操作和审核队列分别展示其可执行动作。
## Risks and verification
- 私人候选和已发布头像的读取权限用非所有者、匿名和跨团队请求验证。
- 上传、更换、审核和撤下并发时验证当前候选不被旧结果激活。
- V68 后迁移在已有数据上通过;旧 Skill 无头像时无需回填。
- 服务端图片解码处理需设置资源上限;机器供应商凭证不得进入日志、数据库或前端。
- 原 Issue 的“机器不得直接拒绝”与本轮方案不同。机器单开模式的质量和误判处理需在运行验证中单列,不以外部 API 返回成功代替有效审核验证。

View file

@ -0,0 +1,32 @@
## Why
Skill 目前没有稳定的图像标识,卡片和详情难以快速区分。上传图片后还需要保证文件安全、访问权限和可配置的内容审核,不让待审图片提前公开。
## What Changes
- 为个人、团队和全局 Skill 增加由 Skill 所有者维护的可选头像;所有展示位置使用一致的首字兜底。
- 上传最终裁剪图片,经服务端规范化后存为私有候选;头像属于 Skill,不随版本更新重置。
- 按实际可见范围处理审核:仅本人可见的 PRIVATE 头像可直接使用;团队可见或公开头像按部署方的机器、人工审核开关处理;全局头像由平台管理员审核。
- 部署时选择唯一机器审核供应商并配置凭证,提供讯飞和阿里云适配;后台仅管理审核开关。机器失败不放行,机器误判可申请人工复核。
- 仅 Skill 所有者可提交、更换或主动删除;管理员可以审核、复核和撤下违规头像。旧候选失效后不能被迟到的审核结果激活。
- 被驳回、被替换或主动删除的图片私有保留 30 天后清理,文字审计记录保留。
## Capabilities
### New Capabilities
- `skill-avatars`: Skill 头像、权限、候选生命周期、审核策略、访问和清理。
### Modified Capabilities
无。
## Impact
- Skill 领域、数据库迁移、对象存储、公开摘要和详情 API、OpenAPI 生成类型。
- 系统配置、机器审核供应商配置、团队及平台审核入口、审计和清理任务。
- Skill 卡片、详情、所有者操作和审核页面;后端、前端与浏览器验证。
## 相对 Issue #910 的调整
原 Issue 建议机器审核首版仅作风险分流。本方案允许部署方单独开启机器审核并据此自动发布或阻止发布,同时提供误判申诉。实现和验收必须明确这一差异、供应商故障时不放行,以及机器判定质量的回归验证。

View file

@ -0,0 +1,117 @@
## Purpose
为 Skill 提供安全、一致、可治理的自定义头像。
## ADDED Requirements
### Requirement: REQ-AV-01 Skill SHALL 有可选头像和统一兜底
Skill SHALL 支持可选头像和统一兜底。
头像属于 Skill 容器而非 SkillVersion。摘要与详情只返回当前已激活的稳定头像访问地址;无头像时所有 Skill 展示位置使用展示名称首字符的大写兜底。新版本发布不得重置头像。
#### Scenario: 旧 Skill 没有头像
- **WHEN** 客户端读取无头像的 Skill
- **THEN** 不需数据回填即可展示一致的首字兜底
#### Scenario: 发布新版本
- **WHEN** 已有头像的 Skill 发布新版本
- **THEN** 原头像保持不变
### Requirement: REQ-AV-02 仅 Skill 所有者 SHALL 维护头像
头像维护接口 SHALL 仅允许 Skill 所有者调用。
只有当前 Skill 所有者可提交、更换或主动删除头像。团队及平台管理员的审核权不授予代传权;有权管理员可以撤下违规头像并记录原因。所有者停用时本期不新增所有权交接或代传机制。
#### Scenario: 管理员不是所有者
- **WHEN** 团队或平台管理员尝试上传、替换或主动删除别人的 Skill 头像
- **THEN** 服务端拒绝,不依赖前端隐藏按钮
#### Scenario: 所有者停用
- **WHEN** 头像已发布而所有者无法操作
- **THEN** 有权管理员仍可撤下该头像并恢复兜底,但不能代传新头像
### Requirement: REQ-AV-03 服务端 SHALL 验证并私存最终裁剪图片
服务端 SHALL 验证并私存最终裁剪图片。
Web 端提供正方形裁剪且只上传最终裁剪图。服务端独立校验文件签名、尺寸、字节与像素限制,解码、重新编码并去除元数据。待审候选只在私有存储中,且只能由提交者和有权审核员读取;不得持久化临时预签名 URL 作为头像地址。
#### Scenario: 伪造图片或畸形文件
- **WHEN** 上传的文件签名、解码结果或大小不符合限制
- **THEN** 服务端拒绝创建候选,不公开文件
#### Scenario: 候选待审
- **WHEN** 新候选尚未通过所需审核
- **THEN** 公众继续看到原头像或兜底,无法读取候选图片
### Requirement: REQ-AV-04 审核 SHALL 依据实际可见范围
头像审核 SHALL 依据实际可见范围执行。
PRIVATE 且仅所有者可访问的头像完成文件安全处理后可直接使用。NAMESPACE_ONLY 或 PUBLIC 的团队 Skill 使用团队范围策略和团队管理员人工审核;全局 Skill 使用全局范围策略和平台管理员人工审核。个人 Namespace 当前按 TEAM 处理,不能仅凭名称免审。可见范围扩大时,未经过目标范围审核的头像不得直接对新受众公开。
#### Scenario: 个人私有 Skill 扩大可见范围
- **WHEN** 原来仅本人可见的 Skill 变为团队可见或公开
- **THEN** 原头像进入目标范围审核,审核完成前新受众只能看到兜底或先前已获目标范围批准的头像
#### Scenario: 团队 Skill 提升至全局
- **WHEN** 团队 Skill 派生全局 Skill
- **THEN** 团队头像不会自动成为全局头像;全局候选按全局策略审核
### Requirement: REQ-AV-05 部署方 SHALL 控制两项审核开关
部署方 SHALL 可独立控制机器和人工审核开关。
机器与人工审核开关保存于独立的系统设置键,后台仅显示和修改这两个开关;唯一机器供应商及凭证由部署配置确定,凭证不得写入系统设置或回显。机器开关开启而供应商未正确配置时应拒绝设置。
#### Scenario: 四种策略
- **WHEN** 机器/人工为关/开、开/开、开/关、关/关
- **THEN** 分别执行人工通过、机器后人工、机器通过自动发布且判违规不发布、直接发布
- **AND** 关闭全部审核时后台显示风险提示
### Requirement: REQ-AV-06 机器审核 SHALL 失败关闭并支持复核
机器审核 SHALL 在失败时保持候选私有,并支持例外复核。
服务端主动调用部署时确定的讯飞或阿里云适配器,首版不接受外部结果回调。机器调用失败、超时或结果不明不能视为通过;候选保持私有并可重试。机器判违规且人工审核开启时审核员可记理由改判;仅机器开启时提交者可申请例外人工复核,不把全部申请放入人工队列。
同一最终图片在供应商和审核策略未变时可以复用机器结果;新目标范围的人工决定不能复用。图片、供应商或策略改变时必须重新检测。
#### Scenario: 供应商超时
- **WHEN** 机器审核超时
- **THEN** 候选不公开,系统保留可重试状态
#### Scenario: 机器误判
- **WHEN** 提交者对机器阻止的候选申请复核
- **THEN** 相应范围的管理员可以批准或驳回,记录决定人和理由
### Requirement: REQ-AV-07 候选状态 SHALL 防止旧结果激活
候选状态 SHALL 防止已失效申请被迟到结果激活。
提交候选时固定当时的审核策略。一个 Skill 的新申请替换待审旧申请,旧申请保留为已被替换状态;迟到的机器结果或审核动作不得激活它。管理员本人也是所有者时可以自审,但提交和决定都须留痕。
#### Scenario: 旧机器结果晚到
- **WHEN** 新候选已经替换旧候选,旧候选的机器结果才返回
- **THEN** 旧候选保持失效,当前头像不受其影响
### Requirement: REQ-AV-08 删除与撤下 SHALL 恢复兜底并保留审计
删除与撤下 SHALL 立即恢复兜底并保留审计。
所有者主动删除当前头像无需审核;管理员违规撤下时须记录操作者和原因。两种动作均立即恢复兜底。被驳回、被替换或主动删除的图片私有保留 30 天后清理,文字申请与审核记录继续保留;确认违法的图片可提前清理。
#### Scenario: 管理员撤下
- **WHEN** 有权管理员撤下违规的已发布头像
- **THEN** 对外立即显示兜底,审计保留原因与操作者
### Requirement: REQ-AV-09 前后端 SHALL 提供可理解的操作闭环
前后端 SHALL 提供可理解的头像提交与审核闭环。
所有者可以在 Skill 详情查看当前头像、提交候选、状态和驳回原因;审核员可以读取其管辖范围候选并处理,机器单开时可以处理例外申诉。所有展示 Skill 身份的位置使用同一头像与兜底组件。
#### Scenario: 申请等待审核
- **WHEN** 所有者上传候选后需人工决定
- **THEN** 页面展示待审状态且已发布头像维持不变

View file

@ -0,0 +1,23 @@
## 1. 领域与存储
- [ ] 1.1 增加 Skill 头像指针、候选/审计持久化和向后兼容迁移。
- [ ] 1.2 实现图片解码、大小及像素上限、规范化、私有写入、受控读取与 30 天清理。
- [ ] 1.3 实现所有者提交/替换/删除、管理员审核/复核/撤下和旧候选失效保护。
## 2. 审核配置与供应商
- [ ] 2.1 在 `system_setting` 增加独立头像审核开关,验证四种策略及未配置供应商时拒绝开启。
- [ ] 2.2 依据官方接口实现讯飞和阿里云适配器,部署只选择一家,凭证仅使用部署 Secret。
- [ ] 2.3 处理超时、未知结果、重试、人工改判和例外申诉。
## 3. API 与 Web
- [ ] 3.1 扩展 Skill 摘要/详情和头像操作/审核接口,重新生成 OpenAPI 类型。
- [ ] 3.2 实现统一头像兜底、所有者裁剪/提交/状态及审核员队列。
- [ ] 3.3 更新中文、英文、俄文文案和操作说明。
## 4. 验证与交付
- [ ] 4.1 覆盖权限、文件安全、状态并发、策略矩阵、可见范围变化和清理边界。
- [ ] 4.2 运行相关后端、前端、API 类型检查、容器化本地预览及浏览器操作。
- [ ] 4.3 独立 Review、Draft PR、CI 与验收报告。

View file

@ -7,6 +7,7 @@ import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
import com.iflytek.skillhub.dto.ApiResponse;
import com.iflytek.skillhub.dto.ApiResponseFactory;
import com.iflytek.skillhub.dto.SkillAvatarDecisionRequest;
import com.iflytek.skillhub.dto.SkillAvatarReasonRequest;
import com.iflytek.skillhub.dto.SkillAvatarStatusResponse;
import com.iflytek.skillhub.service.AuditRequestContext;
import com.iflytek.skillhub.service.SkillAvatarAppService;
@ -125,10 +126,10 @@ public class SkillAvatarController extends BaseApiController {
@PostMapping("/{namespace}/{slug}/avatar/takedown")
public ApiResponse<Void> takedown(@PathVariable String namespace, @PathVariable String slug,
@RequestBody String reason, @AuthenticationPrincipal PlatformPrincipal principal,
@Valid @RequestBody SkillAvatarReasonRequest input, @AuthenticationPrincipal PlatformPrincipal principal,
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> roles,
HttpServletRequest request) {
service.remove(namespace, slug, userId(principal), true, reason, roles, platformRoles(principal),
service.remove(namespace, slug, userId(principal), true, input.reason(), roles, platformRoles(principal),
AuditRequestContext.from(request));
return ok("response.success.updated", null);
}

View file

@ -0,0 +1,5 @@
package com.iflytek.skillhub.dto;
import jakarta.validation.constraints.NotBlank;
public record SkillAvatarReasonRequest(@NotBlank String reason) {}

View file

@ -2,4 +2,4 @@ package com.iflytek.skillhub.dto;
public record SkillAvatarStatusResponse(String avatarUrl, Long candidateId, String candidateStatus,
String machineStatus, String reviewStatus, String reviewReason,
String candidateUrl) {}
String candidateUrl, String namespace, String slug) {}

View file

@ -8,6 +8,7 @@ import com.aliyun.green20220302.models.ImageModerationResponse;
import com.aliyun.green20220302.models.ImageModerationResponseBody;
import com.aliyun.oss.OSS;
import com.aliyun.oss.OSSClientBuilder;
import com.aliyun.oss.ClientBuilderConfiguration;
import com.aliyun.oss.model.PutObjectRequest;
import com.aliyun.teaopenapi.models.Config;
import com.aliyun.teautil.models.RuntimeOptions;
@ -51,7 +52,9 @@ public class AliyunAvatarModerationProvider implements AvatarModerationProvider
config.setAccessKeySecret(accessKeySecret);
config.setEndpoint(ENDPOINT);
Client client = new Client(config);
DescribeUploadTokenResponse tokenResponse = client.describeUploadToken();
RuntimeOptions runtime = new RuntimeOptions().setConnectTimeout(3000).setReadTimeout(8000)
.setAutoretry(false);
DescribeUploadTokenResponse tokenResponse = client.describeUploadTokenWithOptions(runtime);
if (tokenResponse == null || tokenResponse.getBody() == null
|| !Objects.equals(tokenResponse.getBody().getCode(), 200)) {
return Verdict.ERROR;
@ -61,8 +64,11 @@ public class AliyunAvatarModerationProvider implements AvatarModerationProvider
|| token.getOssInternetEndPoint() == null || token.getSecurityToken() == null) {
return Verdict.ERROR;
}
ClientBuilderConfiguration ossConfig = new ClientBuilderConfiguration();
ossConfig.setConnectionTimeout(3000);
ossConfig.setSocketTimeout(8000);
oss = new OSSClientBuilder().build(token.getOssInternetEndPoint(), token.getAccessKeyId(),
token.getAccessKeySecret(), token.getSecurityToken());
token.getAccessKeySecret(), token.getSecurityToken(), ossConfig);
String objectName = token.getFileNamePrefix() + UUID.randomUUID() + ".png";
oss.putObject(new PutObjectRequest(token.getBucketName(), objectName,
new ByteArrayInputStream(normalizedPng)));
@ -73,7 +79,7 @@ public class AliyunAvatarModerationProvider implements AvatarModerationProvider
"ossBucketName", token.getBucketName(),
"ossObjectName", objectName,
"dataId", UUID.randomUUID().toString())));
ImageModerationResponse response = client.imageModerationWithOptions(request, new RuntimeOptions());
ImageModerationResponse response = client.imageModerationWithOptions(request, runtime);
return classify(response);
} catch (Exception failure) {
// SDK exception text may include credential or temporary upload details.

View file

@ -365,7 +365,8 @@ public class SkillAvatarAppService {
candidate == null ? null : candidate.getReviewStatus().name(),
candidate == null ? null : candidate.getReviewReason(),
candidate == null || candidate.getObjectKey() == null ? null
: SkillAvatarUrls.candidate(namespaceSlug, skill, candidate.getId()));
: SkillAvatarUrls.candidate(namespaceSlug, skill, candidate.getId()),
namespaceSlug, skill.getSlug());
}
private String sha256(byte[] image) {

View file

@ -22,6 +22,15 @@ class AliyunAvatarModerationProviderTest {
assertThat(provider.classify(response("high"))).isEqualTo(AvatarModerationProvider.Verdict.BLOCK);
assertThat(provider.classify(response(null))).isEqualTo(AvatarModerationProvider.Verdict.ERROR);
assertThat(provider.classify(response("unknown"))).isEqualTo(AvatarModerationProvider.Verdict.ERROR);
assertThat(provider.classify(response("none").setStatusCode(500)))
.isEqualTo(AvatarModerationProvider.Verdict.ERROR);
assertThat(provider.classify(response("none").setBody(
new ImageModerationResponseBody().setCode(500))))
.isEqualTo(AvatarModerationProvider.Verdict.ERROR);
assertThat(provider.classify(response("none").setBody(
new ImageModerationResponseBody().setCode(200).setData(
new ImageModerationResponseBodyData().setRiskLevel("none").setResult(List.of())))))
.isEqualTo(AvatarModerationProvider.Verdict.ERROR);
}
private ImageModerationResponse response(String level) {

View file

@ -36,6 +36,8 @@ import type {
AuthMethod,
LocalAuthCapabilities,
SystemAuthSettings,
AvatarReviewSettings,
SkillAvatarStatus,
ExternalRoleGrantRule,
PlatformRole,
OAuthProvider,
@ -459,6 +461,17 @@ export const authApi = {
}
export const systemConfigApi = {
getAvatarReview(): Promise<AvatarReviewSettings> {
return fetchJson<AvatarReviewSettings>('/api/v1/admin/system-config/avatar-review')
},
updateAvatarReview(request: Pick<AvatarReviewSettings, 'machineEnabled' | 'manualEnabled' | 'version'>): Promise<AvatarReviewSettings> {
return fetchJson<AvatarReviewSettings>('/api/v1/admin/system-config/avatar-review', {
method: 'PUT',
headers: getCsrfHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify(request),
})
},
getLocalAuth(): Promise<SystemAuthSettings> {
return fetchJson<SystemAuthSettings>('/api/v1/admin/system-config/auth/local')
},
@ -503,6 +516,51 @@ export const systemConfigApi = {
},
}
function avatarPath(namespace: string, slug: string): string {
return `/api/v1/skills/${encodeURIComponent(namespace)}/${encodeURIComponent(slug)}/avatar`
}
export const skillAvatarApi = {
status(namespace: string, slug: string): Promise<SkillAvatarStatus> {
return fetchJson<SkillAvatarStatus>(`${avatarPath(namespace, slug)}/status`)
},
upload(namespace: string, slug: string, file: Blob): Promise<SkillAvatarStatus> {
const body = new FormData()
body.append('file', file, 'avatar.png')
return fetchJson<SkillAvatarStatus>(avatarPath(namespace, slug), {
method: 'POST', headers: getCsrfHeaders(), body,
})
},
delete(namespace: string, slug: string): Promise<void> {
return fetchJson<void>(avatarPath(namespace, slug), { method: 'DELETE', headers: getCsrfHeaders() })
},
appeal(namespace: string, slug: string, candidateId: number): Promise<SkillAvatarStatus> {
return fetchJson<SkillAvatarStatus>(`${avatarPath(namespace, slug)}/candidates/${candidateId}/appeal`, {
method: 'POST', headers: getCsrfHeaders(),
})
},
retry(namespace: string, slug: string, candidateId: number): Promise<SkillAvatarStatus> {
return fetchJson<SkillAvatarStatus>(`${avatarPath(namespace, slug)}/candidates/${candidateId}/retry`, {
method: 'POST', headers: getCsrfHeaders(),
})
},
queue(): Promise<SkillAvatarStatus[]> {
return fetchJson<SkillAvatarStatus[]>('/api/v1/skills/avatar-review/queue')
},
decide(namespace: string, slug: string, candidateId: number, approved: boolean, reason: string): Promise<SkillAvatarStatus> {
return fetchJson<SkillAvatarStatus>(`${avatarPath(namespace, slug)}/candidates/${candidateId}/decision`, {
method: 'POST', headers: getCsrfHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ approved, reason }),
})
},
takedown(namespace: string, slug: string, reason: string): Promise<void> {
return fetchJson<void>(`${avatarPath(namespace, slug)}/takedown`, {
method: 'POST', headers: getCsrfHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ reason }),
})
},
}
export const accountApi = {
async initiateMerge(request: MergeInitiateRequest): Promise<MergeInitiateResponse> {
return fetchJson<MergeInitiateResponse>('/api/v1/account/merge/initiate', {

View file

@ -36,6 +36,25 @@ export interface SystemAuthSettings {
updatedAt: string
}
export interface AvatarReviewSettings {
machineEnabled: boolean
manualEnabled: boolean
version: number
updatedAt: string
}
export interface SkillAvatarStatus {
avatarUrl: string | null
candidateId: number | null
candidateStatus: 'PENDING' | 'ACTIVE' | 'REJECTED' | 'SUPERSEDED' | 'REMOVED' | null
machineStatus: 'SKIPPED' | 'PENDING' | 'PASSED' | 'BLOCKED' | 'ERROR' | null
reviewStatus: 'NOT_REQUIRED' | 'PENDING' | 'APPROVED' | 'REJECTED' | 'APPEAL_PENDING' | null
reviewReason: string | null
candidateUrl: string | null
namespace: string
slug: string
}
export interface ExternalRoleGrantRule {
id: number
providerCode: string
@ -252,6 +271,7 @@ export interface SkillSummary {
ownerPreviewVersion?: SkillLifecycleVersion
resolutionMode?: string
complianceSnapshot?: ComplianceSnapshot
avatarUrl?: string | null
}
export type LabelItem = Omit<components['schemas']['SkillLabelDto'], 'slug' | 'type' | 'displayName'> & {
@ -345,6 +365,7 @@ export type SkillDetail = Omit<
>,
'labels' | 'headlineVersion' | 'publishedVersion' | 'ownerPreviewVersion' | 'entryForSuites' | 'memberOfSuites'
> & {
avatarUrl?: string | null
labels?: LabelItem[]
headlineVersion?: SkillLifecycleVersion
publishedVersion?: SkillLifecycleVersion

View file

@ -0,0 +1,35 @@
import { useEffect, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { systemConfigApi } from '@/api/client'
import { Button } from '@/shared/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/shared/ui/card'
export function AvatarReviewSettingsPanel() {
const { t } = useTranslation()
const queryClient = useQueryClient()
const settings = useQuery({ queryKey: ['system-config', 'avatar-review'], queryFn: systemConfigApi.getAvatarReview })
const [draft, setDraft] = useState({ machineEnabled: false, manualEnabled: true })
const [error, setError] = useState('')
const [saved, setSaved] = useState(false)
useEffect(() => { if (settings.data) setDraft({ machineEnabled: settings.data.machineEnabled, manualEnabled: settings.data.manualEnabled }) }, [settings.data])
const update = useMutation({
mutationFn: systemConfigApi.updateAvatarReview,
onSuccess: async () => { setError(''); setSaved(true); await queryClient.invalidateQueries({ queryKey: ['system-config', 'avatar-review'] }) },
onError: (cause) => { setSaved(false); setError(cause instanceof Error ? cause.message : t('skillAvatar.failed')) },
})
const dirty = !!settings.data && (settings.data.machineEnabled !== draft.machineEnabled || settings.data.manualEnabled !== draft.manualEnabled)
return <Card>
<CardHeader><CardTitle>{t('skillAvatar.settingsTitle')}</CardTitle><p className="text-sm text-muted-foreground">{t('skillAvatar.settingsHelp')}</p></CardHeader>
<CardContent className="space-y-4">
{settings.isLoading && <p>{t('skillAvatar.loading')}</p>}
{settings.isError && <p role="alert">{t('skillAvatar.failed')}</p>}
{settings.data && <>
{(['machineEnabled', 'manualEnabled'] as const).map((field) => <label key={field} className="flex items-center justify-between gap-4 rounded-lg border p-4"><span>{t(`skillAvatar.${field}`)}</span><input type="checkbox" checked={draft[field]} onChange={(event) => { setDraft({ ...draft, [field]: event.target.checked }); setSaved(false) }} /></label>)}
<Button disabled={!dirty || update.isPending} onClick={() => update.mutate({ ...draft, version: settings.data!.version })}>{t('skillAvatar.save')}</Button>
</>}
{saved && <p role="status" className="text-sm">{t('skillAvatar.saved')}</p>}
{error && <p role="alert" className="text-sm text-destructive">{error}</p>}
</CardContent>
</Card>
}

View file

@ -0,0 +1,86 @@
import { useEffect, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { buildApiUrl, skillAvatarApi } from '@/api/client'
import { Button } from '@/shared/ui/button'
import { Card } from '@/shared/ui/card'
import { Input } from '@/shared/ui/input'
function cropSquare(image: HTMLImageElement, zoom: number): Promise<Blob> {
const canvas = document.createElement('canvas')
canvas.width = 512
canvas.height = 512
const context = canvas.getContext('2d')
if (!context) return Promise.reject(new Error('Canvas unavailable'))
const side = Math.min(image.naturalWidth, image.naturalHeight) / zoom
context.drawImage(image, (image.naturalWidth - side) / 2, (image.naturalHeight - side) / 2, side, side, 0, 0, 512, 512)
return new Promise((resolve, reject) => canvas.toBlob((blob) => blob ? resolve(blob) : reject(new Error('Image conversion failed')), 'image/png'))
}
export function SkillAvatarManager({ namespace, slug }: { namespace: string; slug: string }) {
const { t } = useTranslation()
const queryClient = useQueryClient()
const key = ['skill-avatar', namespace, slug]
const status = useQuery({ queryKey: key, queryFn: () => skillAvatarApi.status(namespace, slug) })
const [sourceUrl, setSourceUrl] = useState<string | null>(null)
const [zoom, setZoom] = useState(1)
const [error, setError] = useState('')
const [busy, setBusy] = useState(false)
useEffect(() => () => { if (sourceUrl) URL.revokeObjectURL(sourceUrl) }, [sourceUrl])
const mutation = useMutation({
mutationFn: async (action: { kind: 'upload'; blob: Blob } | { kind: 'remove' | 'appeal' | 'retry'; id?: number }) => {
if (action.kind === 'upload') return skillAvatarApi.upload(namespace, slug, action.blob)
if (action.kind === 'remove') return skillAvatarApi.delete(namespace, slug)
if (action.kind === 'appeal') return skillAvatarApi.appeal(namespace, slug, action.id!)
return skillAvatarApi.retry(namespace, slug, action.id!)
},
onSuccess: async () => {
setError('')
setSourceUrl(null)
await Promise.all([queryClient.invalidateQueries({ queryKey: key }), queryClient.invalidateQueries({ queryKey: ['skill'] }), queryClient.invalidateQueries({ queryKey: ['skills'] })])
},
onError: (cause) => setError(cause instanceof Error ? cause.message : t('skillAvatar.failed')),
})
async function submit() {
if (!sourceUrl) return
setBusy(true)
try {
const image = new Image()
image.src = sourceUrl
await image.decode()
await mutation.mutateAsync({ kind: 'upload', blob: await cropSquare(image, zoom) })
} catch (cause) {
setError(cause instanceof Error ? cause.message : t('skillAvatar.failed'))
} finally { setBusy(false) }
}
const candidate = status.data
return (
<Card className="space-y-3 p-4">
<h2 className="font-semibold">{t('skillAvatar.title')}</h2>
<p className="text-sm text-muted-foreground">{t('skillAvatar.ownerHelp')}</p>
{candidate?.candidateStatus && <p role="status" className="text-sm">{t('skillAvatar.status')}: {t(`skillAvatar.states.${candidate.candidateStatus}`)} · {t(`skillAvatar.machine.${candidate.machineStatus ?? 'SKIPPED'}`)}</p>}
{candidate?.reviewReason && <p className="text-sm">{candidate.reviewReason}</p>}
{candidate?.candidateUrl && <img src={buildApiUrl(candidate.candidateUrl)} alt={t('skillAvatar.candidatePreview')} className="h-24 w-24 rounded-lg object-cover" />}
<Input type="file" accept="image/png,image/jpeg" aria-label={t('skillAvatar.choose')} onChange={(event) => {
const file = event.target.files?.[0]
if (!file) return
if (!['image/png', 'image/jpeg'].includes(file.type) || file.size > 2 * 1024 * 1024) { setError(t('skillAvatar.invalid')); return }
if (sourceUrl) URL.revokeObjectURL(sourceUrl)
setSourceUrl(URL.createObjectURL(file))
setZoom(1)
setError('')
}} />
{sourceUrl && <div className="space-y-2">
<div className="h-40 w-40 overflow-hidden rounded-lg border"><img src={sourceUrl} alt={t('skillAvatar.cropPreview')} className="h-full w-full object-cover" style={{ transform: `scale(${zoom})` }} /></div>
<label className="block text-sm">{t('skillAvatar.zoom')} <input type="range" min="1" max="3" step="0.1" value={zoom} onChange={(event) => setZoom(Number(event.target.value))} /></label>
<Button disabled={busy || mutation.isPending} onClick={() => void submit()}>{t('skillAvatar.submit')}</Button>
</div>}
<div className="flex flex-wrap gap-2">
{(candidate?.avatarUrl || candidate?.candidateStatus === 'PENDING') && <Button variant="outline" disabled={mutation.isPending} onClick={() => void mutation.mutateAsync({ kind: 'remove' })}>{t('skillAvatar.remove')}</Button>}
{candidate?.candidateStatus === 'REJECTED' && candidate.candidateId && <Button variant="outline" disabled={mutation.isPending} onClick={() => void mutation.mutateAsync({ kind: 'appeal', id: candidate.candidateId! })}>{t('skillAvatar.appeal')}</Button>}
{candidate?.machineStatus === 'ERROR' && candidate.candidateId && <Button variant="outline" disabled={mutation.isPending} onClick={() => void mutation.mutateAsync({ kind: 'retry', id: candidate.candidateId! })}>{t('skillAvatar.retry')}</Button>}
</div>
{error && <p role="alert" className="text-sm text-destructive">{error}</p>}
</Card>
)
}

View file

@ -0,0 +1,41 @@
import { useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { buildApiUrl, skillAvatarApi } from '@/api/client'
import { Button } from '@/shared/ui/button'
import { Card } from '@/shared/ui/card'
import { Input } from '@/shared/ui/input'
export function SkillAvatarReviewQueue({ namespace }: { namespace?: string }) {
const { t } = useTranslation()
const queryClient = useQueryClient()
const [reasons, setReasons] = useState<Record<number, string>>({})
const [error, setError] = useState('')
const queue = useQuery({ queryKey: ['skill-avatar-review'], queryFn: skillAvatarApi.queue })
const decide = useMutation({
mutationFn: (input: { namespace: string; slug: string; candidateId: number; approved: boolean; reason: string }) =>
skillAvatarApi.decide(input.namespace, input.slug, input.candidateId, input.approved, input.reason),
onSuccess: async () => { setError(''); await queryClient.invalidateQueries({ queryKey: ['skill-avatar-review'] }) },
onError: (cause) => setError(cause instanceof Error ? cause.message : t('skillAvatar.failed')),
})
const items = queue.data?.filter((item) => !namespace || item.namespace === namespace) ?? []
return <Card className="space-y-4 p-5">
<h2 className="text-lg font-semibold">{t('skillAvatar.reviewTitle')}</h2>
{queue.isLoading && <p className="text-sm text-muted-foreground">{t('skillAvatar.loading')}</p>}
{queue.isError && <p role="alert" className="text-sm text-destructive">{t('skillAvatar.failed')}</p>}
{!queue.isLoading && !queue.isError && items.length === 0 && <p className="text-sm text-muted-foreground">{t('skillAvatar.emptyQueue')}</p>}
{items.map((item) => item.candidateId && <div key={item.candidateId} className="flex flex-wrap items-start gap-4 rounded-lg border p-4">
{item.candidateUrl && <img src={buildApiUrl(item.candidateUrl)} alt={t('skillAvatar.candidatePreview')} className="h-20 w-20 rounded-lg object-cover" />}
<div className="min-w-0 flex-1 space-y-2">
<p className="font-medium">@{item.namespace}/{item.slug}</p>
<p className="text-sm text-muted-foreground">{t(`skillAvatar.machine.${item.machineStatus ?? 'SKIPPED'}`)}</p>
<Input value={reasons[item.candidateId] ?? ''} onChange={(event) => setReasons({ ...reasons, [item.candidateId!]: event.target.value })} placeholder={t('skillAvatar.reason')} aria-label={t('skillAvatar.reason')} />
<div className="flex gap-2">
<Button disabled={decide.isPending || !(reasons[item.candidateId!] ?? '').trim()} onClick={() => decide.mutate({ namespace: item.namespace, slug: item.slug, candidateId: item.candidateId!, approved: true, reason: reasons[item.candidateId!] ?? '' })}>{t('skillAvatar.approve')}</Button>
<Button variant="outline" disabled={decide.isPending || !(reasons[item.candidateId!] ?? '').trim()} onClick={() => decide.mutate({ namespace: item.namespace, slug: item.slug, candidateId: item.candidateId!, approved: false, reason: reasons[item.candidateId!] ?? '' })}>{t('skillAvatar.reject')}</Button>
</div>
</div>
</div>)}
{error && <p role="alert" className="text-sm text-destructive">{error}</p>}
</Card>
}

View file

@ -0,0 +1,24 @@
import { useState } from 'react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { skillAvatarApi } from '@/api/client'
import { Button } from '@/shared/ui/button'
import { Input } from '@/shared/ui/input'
export function SkillAvatarTakedown({ namespace, slug }: { namespace: string; slug: string }) {
const { t } = useTranslation()
const queryClient = useQueryClient()
const [reason, setReason] = useState('')
const [error, setError] = useState('')
const mutation = useMutation({
mutationFn: () => skillAvatarApi.takedown(namespace, slug, reason),
onSuccess: async () => { setError(''); setReason(''); await Promise.all([queryClient.invalidateQueries({ queryKey: ['skill'] }), queryClient.invalidateQueries({ queryKey: ['skills'] })]) },
onError: (cause) => setError(cause instanceof Error ? cause.message : t('skillAvatar.failed')),
})
return <div className="space-y-2 rounded-lg border border-destructive/30 p-4">
<p className="text-sm font-medium">{t('skillAvatar.takedownHelp')}</p>
<Input value={reason} onChange={(event) => setReason(event.target.value)} placeholder={t('skillAvatar.reason')} aria-label={t('skillAvatar.reason')} />
<Button variant="outline" disabled={!reason.trim() || mutation.isPending} onClick={() => mutation.mutate()}>{t('skillAvatar.takedown')}</Button>
{error && <p role="alert" className="text-sm text-destructive">{error}</p>}
</div>
}

View file

@ -0,0 +1,12 @@
import { useState } from 'react'
import { buildApiUrl } from '@/api/client'
export function SkillAvatar({ name, url, size = 'h-10 w-10' }: { name: string; url?: string | null; size?: string }) {
const [failedUrl, setFailedUrl] = useState<string | null>(null)
const imageUrl = url && failedUrl !== url ? buildApiUrl(url) : null
return (
<span className={`inline-flex shrink-0 items-center justify-center overflow-hidden rounded-lg bg-primary/10 text-primary font-semibold ${size}`} aria-hidden="true">
{imageUrl ? <img src={imageUrl} alt="" className="h-full w-full object-cover" onError={() => setFailedUrl(url ?? null)} /> : (Array.from(name.trim())[0] ?? '?').toLocaleUpperCase()}
</span>
)
}

View file

@ -4,6 +4,7 @@ import { useAuth } from '@/features/auth/use-auth'
import { useStarredIdSet } from '@/features/social/use-star'
import { Card } from '@/shared/ui/card'
import { NamespaceBadge } from '@/shared/components/namespace-badge'
import { SkillAvatar } from './skill-avatar'
import { getHeadlineVersion } from '@/shared/lib/skill-lifecycle'
import { formatCompactCount } from '@/shared/lib/number-format'
import { formatRelativeTime } from '@/shared/lib/format-relative-time'
@ -51,7 +52,8 @@ export function SkillCard({ skill, onClick, highlightStarred = true }: SkillCard
>
<div className="flex h-full flex-col">
<div className="flex items-start justify-between mb-3">
<div className="space-y-2">
<div className="flex min-w-0 items-center gap-3 space-y-2">
<SkillAvatar name={skill.displayName} url={skill.avatarUrl} />
<h3 className="font-semibold text-base group-hover:text-primary transition-colors duration-150" style={{ color: 'hsl(var(--foreground))' }}>
{skill.displayName}
</h3>

View file

@ -1598,6 +1598,8 @@
"save": "Save review",
"cancel": "Cancel",
"delete": "Delete review",
"takedown": "Take down avatar",
"takedownHelp": "Enter a reason to remove the current avatar. The skill will show its initial.",
"saved": "Review saved",
"deleted": "Review deleted; your star rating was kept",
"textRequired": "Enter a review before saving",
@ -2566,5 +2568,46 @@
"VERSION_UNAVAILABLE": "Version unavailable",
"VISIBILITY_INCOMPATIBLE": "Visibility incompatible"
}
},
"skillAvatar": {
"title": "Skill avatar",
"ownerHelp": "Upload and crop a square image. The current avatar stays visible until the new one is approved.",
"status": "Status",
"candidatePreview": "Avatar candidate",
"choose": "Choose avatar image",
"cropPreview": "Crop preview",
"zoom": "Zoom",
"submit": "Submit avatar",
"remove": "Remove current avatar",
"appeal": "Request review",
"retry": "Retry machine review",
"failed": "Action failed. Please try again.",
"invalid": "Choose a PNG or JPEG image under 2 MB.",
"reviewTitle": "Avatar review",
"loading": "Loading",
"emptyQueue": "No avatars awaiting review",
"reason": "Review reason (required to reject)",
"approve": "Approve",
"reject": "Reject",
"settingsTitle": "Avatar review settings",
"settingsHelp": "The machine provider is set at deployment; these switches control machine and manual review.",
"machineEnabled": "Enable machine review",
"manualEnabled": "Enable manual review",
"save": "Save settings",
"saved": "Saved",
"states": {
"PENDING": "Pending",
"ACTIVE": "Active",
"REJECTED": "Rejected",
"SUPERSEDED": "Superseded",
"REMOVED": "Removed"
},
"machine": {
"SKIPPED": "Machine review off",
"PENDING": "Machine review pending",
"PASSED": "Machine review passed",
"BLOCKED": "Machine review blocked",
"ERROR": "Machine review failed"
}
}
}

View file

@ -1820,6 +1820,8 @@
"save": "Сохранить отзыв",
"cancel": "Отмена",
"delete": "Удалить отзыв",
"takedown": "Снять аватар",
"takedownHelp": "Укажите причину снятия текущего аватара. Будет показана первая буква названия.",
"saved": "Отзыв сохранён",
"deleted": "Отзыв удалён, оценка сохранена",
"textRequired": "Введите текст отзыва",
@ -2568,5 +2570,46 @@
"title": "Что-то пошло не так",
"description": "На этой странице произошла ошибка. Можно вернуться назад или перезагрузить раздел.",
"reset": "Попробовать снова"
},
"skillAvatar": {
"title": "Аватар навыка",
"ownerHelp": "Загрузите и обрежьте квадратное изображение. Текущий аватар останется до одобрения нового.",
"status": "Статус",
"candidatePreview": "Новый аватар",
"choose": "Выбрать изображение",
"cropPreview": "Предпросмотр обрезки",
"zoom": "Масштаб",
"submit": "Отправить аватар",
"remove": "Удалить аватар",
"appeal": "Запросить пересмотр",
"retry": "Повторить автоматическую проверку",
"failed": "Ошибка. Повторите попытку.",
"invalid": "Выберите PNG или JPEG размером до 2 МБ.",
"reviewTitle": "Проверка аватаров",
"loading": "Загрузка",
"emptyQueue": "Нет аватаров на проверке",
"reason": "Причина (обязательна при отклонении)",
"approve": "Одобрить",
"reject": "Отклонить",
"settingsTitle": "Настройки проверки аватаров",
"settingsHelp": "Поставщик автоматической проверки задаётся при развертывании; здесь включаются виды проверки.",
"machineEnabled": "Автоматическая проверка",
"manualEnabled": "Ручная проверка",
"save": "Сохранить",
"saved": "Сохранено",
"states": {
"PENDING": "Ожидает",
"ACTIVE": "Активен",
"REJECTED": "Отклонён",
"SUPERSEDED": "Заменён",
"REMOVED": "Удалён"
},
"machine": {
"SKIPPED": "Проверка выключена",
"PENDING": "Проверяется",
"PASSED": "Проверка пройдена",
"BLOCKED": "Заблокирован",
"ERROR": "Ошибка проверки"
}
}
}

View file

@ -1597,6 +1597,8 @@
"save": "保存评价",
"cancel": "取消",
"delete": "删除评价",
"takedown": "撤下头像",
"takedownHelp": "请输入原因后撤下当前头像,技能将显示默认首字母。",
"saved": "评价已保存",
"deleted": "评价已删除,星级评分已保留",
"textRequired": "请先填写评价内容",
@ -2565,5 +2567,46 @@
"VERSION_UNAVAILABLE": "版本不可用",
"VISIBILITY_INCOMPATIBLE": "可见性不兼容"
}
},
"skillAvatar": {
"title": "技能头像",
"ownerHelp": "上传并裁剪方形图片。新头像通过审核后才会替换当前头像。",
"status": "状态",
"candidatePreview": "待审核头像",
"choose": "选择头像图片",
"cropPreview": "裁剪预览",
"zoom": "放大",
"submit": "提交头像",
"remove": "删除当前头像",
"appeal": "申请复核",
"retry": "重试机器审核",
"failed": "操作失败,请稍后重试",
"invalid": "请选择不超过 2 MB 的 PNG 或 JPEG 图片",
"reviewTitle": "头像审核",
"loading": "加载中",
"emptyQueue": "暂无待审核头像",
"reason": "审核原因(驳回时必填)",
"approve": "通过",
"reject": "驳回",
"settingsTitle": "头像审核设置",
"settingsHelp": "机器审核服务在部署时指定;这里只控制是否启用机器与人工审核。",
"machineEnabled": "启用机器审核",
"manualEnabled": "启用人工审核",
"save": "保存设置",
"saved": "已保存",
"states": {
"PENDING": "待处理",
"ACTIVE": "已使用",
"REJECTED": "已拒绝",
"SUPERSEDED": "已替换",
"REMOVED": "已删除"
},
"machine": {
"SKIPPED": "未启用机器审核",
"PENDING": "机器审核中",
"PASSED": "机器审核通过",
"BLOCKED": "机器审核阻止",
"ERROR": "机器审核失败"
}
}
}

View file

@ -8,6 +8,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/shared/ui/card'
import { Input } from '@/shared/ui/input'
import { Label } from '@/shared/ui/label'
import { ConfirmDialog } from '@/shared/components/confirm-dialog'
import { AvatarReviewSettingsPanel } from '@/features/skill/avatar-review-settings'
export function SystemConfigPage() {
const { t } = useTranslation()
@ -105,6 +106,7 @@ export function SystemConfigPage() {
</div>
{error ? <p role="alert" className="rounded-lg bg-destructive/10 px-4 py-3 text-sm text-destructive">{error}</p> : null}
{message ? <p role="status" className="rounded-lg bg-emerald-500/10 px-4 py-3 text-sm text-emerald-700">{message}</p> : null}
<AvatarReviewSettingsPanel />
<Card>
<CardHeader>
<CardTitle>{t('systemConfig.localAuth')}</CardTitle>

View file

@ -8,6 +8,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/shared/ui/tabs'
import { useNamespaceDetail } from '@/shared/hooks/use-namespace-queries'
import { useReviewList } from '@/features/review/use-review-list'
import { SkillAvatarReviewQueue } from '@/features/skill/skill-avatar-review'
import { DashboardPageHeader } from '@/shared/components/dashboard-page-header'
import { Pagination } from '@/shared/components/pagination'
import { NamespaceHeader } from '@/features/namespace/namespace-header'
@ -161,6 +162,7 @@ export function NamespaceReviewsPage() {
{readOnlyMessage}
</Card>
) : null}
<SkillAvatarReviewQueue namespace={slug} />
<ReviewListSection namespaceId={namespace?.id} slug={slug} />
</div>
)

View file

@ -25,6 +25,7 @@ import { DashboardPageHeader } from '@/shared/components/dashboard-page-header'
import { Pagination } from '@/shared/components/pagination'
import { formatLocalDateTime } from '@/shared/lib/date-time'
import { ProfileReviewTable } from './profile-review-table'
import { SkillAvatarReviewQueue } from '@/features/skill/skill-avatar-review'
type ReviewStatus = 'PENDING' | 'APPROVED' | 'REJECTED'
type TimeSortDirection = 'ASC' | 'DESC'
@ -265,6 +266,8 @@ export function ReviewsPage() {
<div className="space-y-8 animate-fade-up">
<DashboardPageHeader title={t('reviews.title')} subtitle={t('reviews.subtitle')} />
{isSkillAdmin && <SkillAvatarReviewQueue />}
{showTypeTabs ? (
<Tabs value={activeType} onValueChange={handleTypeChange}>
<TabsList className="gap-2 rounded-2xl border-b-0 bg-muted/80 p-1 shadow-sm">

View file

@ -4,6 +4,9 @@ import { Link, useParams, useNavigate, useRouterState, useSearch } from '@tansta
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { ArrowLeft, ArrowUpCircle, Boxes, ChevronDown, ChevronUp, Clock, Folder, Globe, Lock, RefreshCw, ShieldCheck, Terminal, User, Users } from 'lucide-react'
import { MarkdownRenderer } from '@/features/skill/markdown-renderer'
import { SkillAvatar } from '@/features/skill/skill-avatar'
import { SkillAvatarManager } from '@/features/skill/skill-avatar-manager'
import { SkillAvatarTakedown } from '@/features/skill/skill-avatar-takedown'
import { resolvePackageRelativeLink } from '@/features/skill/package-relative-link'
import { FileTree } from '@/features/skill/file-tree'
import { FilePreviewDialog } from '@/features/skill/file-preview-dialog'
@ -29,6 +32,7 @@ import { StarButton } from '@/features/social/star-button'
import { SubscribeButton } from '@/features/social/subscribe-button'
import { SkillReviews } from '@/features/social/skill-reviews'
import { useAuth } from '@/features/auth/use-auth'
import { useMyNamespaces } from '@/shared/hooks/use-namespace-queries'
import { adminApi, ApiError, buildApiUrl, WEB_API_PREFIX } from '@/api/client'
import { useSubmitSkillReport } from '@/features/report/use-skill-reports'
import { SecurityAuditSummary } from '@/features/security-audit/security-audit-summary'
@ -166,6 +170,7 @@ export function SkillDetailPage() {
const overviewQuietGenerationRef = useRef(0)
const { namespace, slug } = useParams({ from: '/space/$namespace/$slug' })
const { user, hasRole } = useAuth()
const { data: myNamespaces } = useMyNamespaces(Boolean(user))
const isPromotionAdmin = hasRole('SKILL_ADMIN') || hasRole('SUPER_ADMIN')
const detailQueriesEnabled = isSkillDetailQueriesEnabled(skillDeleted)
const qns = detailQueriesEnabled ? namespace : ''
@ -897,7 +902,12 @@ export function SkillDetailPage() {
</span>
)}
</div>
<h1 className="text-balance break-words text-4xl font-bold font-heading text-foreground [overflow-wrap:anywhere]">{skill.displayName}</h1>
<div className="flex items-center gap-4"><SkillAvatar name={skill.displayName} url={skill.avatarUrl} size="h-16 w-16 text-2xl" /><h1 className="text-balance break-words text-4xl font-bold font-heading text-foreground [overflow-wrap:anywhere]">{skill.displayName}</h1></div>
{user?.userId === skill.ownerId && <SkillAvatarManager namespace={namespace} slug={slug} />}
{skill.avatarUrl && user?.userId !== skill.ownerId && (namespace === 'global'
? isPromotionAdmin
: myNamespaces?.some((item) => item.slug === namespace && (item.currentUserRole === 'OWNER' || item.currentUserRole === 'ADMIN')))
&& <SkillAvatarTakedown namespace={namespace} slug={slug} />}
{skill.ownerDisplayName && (
<div className="flex min-w-0">
<div className="inline-flex max-w-full items-center gap-2 rounded-full border border-border/60 bg-background/85 px-3 py-1.5 text-sm text-muted-foreground shadow-sm backdrop-blur-sm">

View file

@ -78,10 +78,11 @@ function invalidateNamespaceQueries(queryClient: ReturnType<typeof useQueryClien
queryClient.invalidateQueries({ queryKey: ['reviews'] })
}
export function useMyNamespaces() {
export function useMyNamespaces(enabled = true) {
return useQuery({
queryKey: ['namespaces', 'my'],
queryFn: getMyNamespaces,
enabled,
})
}