mirror of
https://github.com/iflytek/skillhub.git
synced 2026-10-14 04:08:16 +00:00
feat(avatar): add review and owner interfaces
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
parent
f7e96bb8a4
commit
b67ea7ddb8
28 changed files with 685 additions and 10 deletions
31
docs/26-skill-avatars.md
Normal file
31
docs/26-skill-avatars.md
Normal 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 天清理以及团队/全局可见范围变化。外部供应商真实计费调用需要部署方测试账号与授权;单元测试使用模拟响应。
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
schema: spec-driven
|
||||
created: 2026-10-12
|
||||
22
openspec/changes/add-skill-avatars-and-moderation/design.md
Normal file
22
openspec/changes/add-skill-avatars-and-moderation/design.md
Normal 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 返回成功代替有效审核验证。
|
||||
|
|
@ -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 建议机器审核首版仅作风险分流。本方案允许部署方单独开启机器审核并据此自动发布或阻止发布,同时提供误判申诉。实现和验收必须明确这一差异、供应商故障时不放行,以及机器判定质量的回归验证。
|
||||
|
|
@ -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** 页面展示待审状态且已发布头像维持不变
|
||||
23
openspec/changes/add-skill-avatars-and-moderation/tasks.md
Normal file
23
openspec/changes/add-skill-avatars-and-moderation/tasks.md
Normal 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 与验收报告。
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,5 @@
|
|||
package com.iflytek.skillhub.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
public record SkillAvatarReasonRequest(@NotBlank String reason) {}
|
||||
|
|
@ -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) {}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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', {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
35
web/src/features/skill/avatar-review-settings.tsx
Normal file
35
web/src/features/skill/avatar-review-settings.tsx
Normal 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>
|
||||
}
|
||||
86
web/src/features/skill/skill-avatar-manager.tsx
Normal file
86
web/src/features/skill/skill-avatar-manager.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
41
web/src/features/skill/skill-avatar-review.tsx
Normal file
41
web/src/features/skill/skill-avatar-review.tsx
Normal 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>
|
||||
}
|
||||
24
web/src/features/skill/skill-avatar-takedown.tsx
Normal file
24
web/src/features/skill/skill-avatar-takedown.tsx
Normal 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>
|
||||
}
|
||||
12
web/src/features/skill/skill-avatar.tsx
Normal file
12
web/src/features/skill/skill-avatar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": "Ошибка проверки"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": "机器审核失败"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue