mirror of
https://github.com/iflytek/skillhub.git
synced 2026-10-10 03:27:54 +00:00
Merge pull request #26 from iflytek/feature/project-local
Improve auth, search, publish, token, and dashboard UX
This commit is contained in:
commit
7218019aea
42 changed files with 1097 additions and 137 deletions
|
|
@ -4,6 +4,7 @@ import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
|
|||
import com.iflytek.skillhub.auth.token.ApiTokenService;
|
||||
import com.iflytek.skillhub.dto.ApiResponse;
|
||||
import com.iflytek.skillhub.dto.ApiResponseFactory;
|
||||
import com.iflytek.skillhub.dto.PageResponse;
|
||||
import com.iflytek.skillhub.dto.TokenCreateRequest;
|
||||
import com.iflytek.skillhub.dto.TokenCreateResponse;
|
||||
import com.iflytek.skillhub.dto.TokenSummaryResponse;
|
||||
|
|
@ -45,17 +46,20 @@ public class TokenController extends BaseApiController {
|
|||
}
|
||||
|
||||
@GetMapping
|
||||
public ApiResponse<List<TokenSummaryResponse>> list(@AuthenticationPrincipal PlatformPrincipal principal) {
|
||||
var tokens = apiTokenService.listActiveTokens(principal.userId());
|
||||
var result = tokens.stream().map(t -> new TokenSummaryResponse(
|
||||
public ApiResponse<PageResponse<TokenSummaryResponse>> list(
|
||||
@AuthenticationPrincipal PlatformPrincipal principal,
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "10") int size) {
|
||||
var tokens = apiTokenService.listActiveTokens(principal.userId(), page, size);
|
||||
var result = tokens.map(t -> new TokenSummaryResponse(
|
||||
t.getId(),
|
||||
t.getName(),
|
||||
t.getTokenPrefix(),
|
||||
t.getCreatedAt().toString(),
|
||||
t.getExpiresAt() != null ? t.getExpiresAt().toString() : "",
|
||||
t.getLastUsedAt() != null ? t.getLastUsedAt().toString() : ""
|
||||
)).toList();
|
||||
return ok("response.success.read", result);
|
||||
));
|
||||
return ok("response.success.read", PageResponse.from(result));
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
|
|
|
|||
|
|
@ -4,10 +4,10 @@ import jakarta.validation.constraints.Email;
|
|||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
public record LocalRegisterRequest(
|
||||
@NotBlank(message = "用户名不能为空")
|
||||
@NotBlank(message = "{validation.auth.local.username.notBlank}")
|
||||
String username,
|
||||
@NotBlank(message = "密码不能为空")
|
||||
@NotBlank(message = "{validation.auth.local.password.notBlank}")
|
||||
String password,
|
||||
@Email(message = "邮箱格式不正确")
|
||||
@Email(message = "{validation.auth.local.email.invalid}")
|
||||
String email
|
||||
) {}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,13 @@
|
|||
package com.iflytek.skillhub.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public record TokenCreateRequest(
|
||||
@NotBlank(message = "{validation.token.name.notBlank}")
|
||||
@Size(max = 64, message = "{validation.token.name.size}")
|
||||
String name,
|
||||
List<String> scopes
|
||||
) {}
|
||||
|
|
|
|||
|
|
@ -1,16 +1,19 @@
|
|||
package com.iflytek.skillhub.exception;
|
||||
|
||||
import com.iflytek.skillhub.auth.exception.AuthFlowException;
|
||||
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
|
||||
import com.iflytek.skillhub.dto.ApiResponse;
|
||||
import com.iflytek.skillhub.dto.ApiResponseFactory;
|
||||
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
|
||||
import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException;
|
||||
import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.slf4j.MDC;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.validation.FieldError;
|
||||
import org.springframework.web.bind.MethodArgumentNotValidException;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
|
|
@ -27,39 +30,44 @@ public class GlobalExceptionHandler {
|
|||
}
|
||||
|
||||
@ExceptionHandler(LocalizedException.class)
|
||||
public ResponseEntity<ApiResponse<Void>> handleLocalizedError(LocalizedException ex) {
|
||||
public ResponseEntity<ApiResponse<Void>> handleLocalizedError(LocalizedException ex, HttpServletRequest request) {
|
||||
HttpStatus status = ex.status();
|
||||
logHandledException(status, ex.messageCode(), request);
|
||||
return ResponseEntity.status(status).body(
|
||||
apiResponseFactory.error(status.value(), ex.messageCode(), ex.messageArgs()));
|
||||
}
|
||||
|
||||
@ExceptionHandler(AuthFlowException.class)
|
||||
public ResponseEntity<ApiResponse<Void>> handleAuthFlowException(AuthFlowException ex) {
|
||||
public ResponseEntity<ApiResponse<Void>> handleAuthFlowException(AuthFlowException ex, HttpServletRequest request) {
|
||||
HttpStatus status = ex.getStatus();
|
||||
logHandledException(status, ex.getMessageCode(), request);
|
||||
return ResponseEntity.status(status).body(
|
||||
apiResponseFactory.error(status.value(), ex.getMessageCode(), ex.getMessageArgs()));
|
||||
}
|
||||
|
||||
@ExceptionHandler(DomainBadRequestException.class)
|
||||
public ResponseEntity<ApiResponse<Void>> handleDomainBadRequest(DomainBadRequestException ex) {
|
||||
public ResponseEntity<ApiResponse<Void>> handleDomainBadRequest(DomainBadRequestException ex, HttpServletRequest request) {
|
||||
logHandledException(HttpStatus.BAD_REQUEST, ex.messageCode(), request);
|
||||
return ResponseEntity.badRequest().body(
|
||||
apiResponseFactory.error(400, ex.messageCode(), ex.messageArgs()));
|
||||
}
|
||||
|
||||
@ExceptionHandler(DomainForbiddenException.class)
|
||||
public ResponseEntity<ApiResponse<Void>> handleDomainForbidden(DomainForbiddenException ex) {
|
||||
public ResponseEntity<ApiResponse<Void>> handleDomainForbidden(DomainForbiddenException ex, HttpServletRequest request) {
|
||||
logHandledException(HttpStatus.FORBIDDEN, ex.messageCode(), request);
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(
|
||||
apiResponseFactory.error(403, ex.messageCode(), ex.messageArgs()));
|
||||
}
|
||||
|
||||
@ExceptionHandler(DomainNotFoundException.class)
|
||||
public ResponseEntity<ApiResponse<Void>> handleDomainNotFound(DomainNotFoundException ex) {
|
||||
public ResponseEntity<ApiResponse<Void>> handleDomainNotFound(DomainNotFoundException ex, HttpServletRequest request) {
|
||||
logHandledException(HttpStatus.NOT_FOUND, ex.messageCode(), request);
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(
|
||||
apiResponseFactory.error(404, ex.messageCode(), ex.messageArgs()));
|
||||
}
|
||||
|
||||
@ExceptionHandler(MethodArgumentNotValidException.class)
|
||||
public ResponseEntity<ApiResponse<Void>> handleValidation(MethodArgumentNotValidException ex) {
|
||||
public ResponseEntity<ApiResponse<Void>> handleValidation(MethodArgumentNotValidException ex, HttpServletRequest request) {
|
||||
String msg = ex.getBindingResult().getFieldErrors().stream()
|
||||
.findFirst()
|
||||
.map(FieldError::getDefaultMessage)
|
||||
|
|
@ -67,6 +75,7 @@ public class GlobalExceptionHandler {
|
|||
.findFirst()
|
||||
.map(error -> error.getDefaultMessage())
|
||||
.orElse(null));
|
||||
logHandledException(HttpStatus.BAD_REQUEST, "validation.request.invalid", request);
|
||||
if (msg == null || msg.isBlank()) {
|
||||
return ResponseEntity.badRequest().body(apiResponseFactory.error(400, "error.badRequest"));
|
||||
}
|
||||
|
|
@ -74,22 +83,52 @@ public class GlobalExceptionHandler {
|
|||
}
|
||||
|
||||
@ExceptionHandler(IllegalArgumentException.class)
|
||||
public ResponseEntity<ApiResponse<Void>> handleBadRequest(IllegalArgumentException ex) {
|
||||
public ResponseEntity<ApiResponse<Void>> handleBadRequest(IllegalArgumentException ex, HttpServletRequest request) {
|
||||
logHandledException(HttpStatus.BAD_REQUEST, "error.badRequest", request);
|
||||
return ResponseEntity.badRequest().body(
|
||||
apiResponseFactory.error(400, "error.badRequest"));
|
||||
}
|
||||
|
||||
@ExceptionHandler(SecurityException.class)
|
||||
public ResponseEntity<ApiResponse<Void>> handleForbidden(SecurityException ex) {
|
||||
public ResponseEntity<ApiResponse<Void>> handleForbidden(SecurityException ex, HttpServletRequest request) {
|
||||
logHandledException(HttpStatus.FORBIDDEN, "error.forbidden", request);
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(
|
||||
apiResponseFactory.error(403, "error.forbidden"));
|
||||
}
|
||||
|
||||
@ExceptionHandler(Exception.class)
|
||||
public ResponseEntity<ApiResponse<Void>> handleGlobalException(Exception ex) {
|
||||
String requestId = MDC.get("requestId");
|
||||
logger.error("Unhandled exception [requestId={}]", requestId, ex);
|
||||
public ResponseEntity<ApiResponse<Void>> handleGlobalException(Exception ex, HttpServletRequest request) {
|
||||
logger.error(
|
||||
"Unhandled API exception [requestId={}, method={}, path={}, userId={}]",
|
||||
MDC.get("requestId"),
|
||||
request.getMethod(),
|
||||
request.getRequestURI(),
|
||||
resolveUserId(request),
|
||||
ex
|
||||
);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(
|
||||
apiResponseFactory.error(500, "error.internal"));
|
||||
}
|
||||
|
||||
private void logHandledException(HttpStatus status, String messageCode, HttpServletRequest request) {
|
||||
logger.info(
|
||||
"API request failed [requestId={}, status={}, method={}, path={}, userId={}, code={}]",
|
||||
MDC.get("requestId"),
|
||||
status.value(),
|
||||
request.getMethod(),
|
||||
request.getRequestURI(),
|
||||
resolveUserId(request),
|
||||
messageCode
|
||||
);
|
||||
}
|
||||
|
||||
private String resolveUserId(HttpServletRequest request) {
|
||||
if (!(request.getUserPrincipal() instanceof Authentication authentication)) {
|
||||
return "anonymous";
|
||||
}
|
||||
if (authentication.getPrincipal() instanceof PlatformPrincipal principal) {
|
||||
return principal.userId();
|
||||
}
|
||||
return authentication.getName();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,9 @@ import com.iflytek.skillhub.dto.ApiResponse;
|
|||
import com.iflytek.skillhub.dto.ApiResponseFactory;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.slf4j.MDC;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.security.access.AccessDeniedException;
|
||||
import org.springframework.security.web.access.AccessDeniedHandler;
|
||||
|
|
@ -15,6 +18,7 @@ import java.io.IOException;
|
|||
@Component
|
||||
public class ApiAccessDeniedHandler implements AccessDeniedHandler {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(ApiAccessDeniedHandler.class);
|
||||
private final ObjectMapper objectMapper;
|
||||
private final ApiResponseFactory apiResponseFactory;
|
||||
|
||||
|
|
@ -27,6 +31,13 @@ public class ApiAccessDeniedHandler implements AccessDeniedHandler {
|
|||
public void handle(HttpServletRequest request,
|
||||
HttpServletResponse response,
|
||||
AccessDeniedException accessDeniedException) throws IOException {
|
||||
logger.info(
|
||||
"Forbidden API request [requestId={}, method={}, path={}, reason={}]",
|
||||
MDC.get("requestId"),
|
||||
request.getMethod(),
|
||||
request.getRequestURI(),
|
||||
accessDeniedException.getClass().getSimpleName()
|
||||
);
|
||||
ApiResponse<Void> body = apiResponseFactory.error(403, "error.forbidden");
|
||||
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
|
||||
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
|
||||
|
|
|
|||
|
|
@ -5,6 +5,9 @@ import com.iflytek.skillhub.dto.ApiResponse;
|
|||
import com.iflytek.skillhub.dto.ApiResponseFactory;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.slf4j.MDC;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.security.core.AuthenticationException;
|
||||
import org.springframework.security.web.AuthenticationEntryPoint;
|
||||
|
|
@ -15,6 +18,7 @@ import java.io.IOException;
|
|||
@Component
|
||||
public class ApiAuthenticationEntryPoint implements AuthenticationEntryPoint {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(ApiAuthenticationEntryPoint.class);
|
||||
private final ObjectMapper objectMapper;
|
||||
private final ApiResponseFactory apiResponseFactory;
|
||||
|
||||
|
|
@ -27,6 +31,13 @@ public class ApiAuthenticationEntryPoint implements AuthenticationEntryPoint {
|
|||
public void commence(HttpServletRequest request,
|
||||
HttpServletResponse response,
|
||||
AuthenticationException authException) throws IOException {
|
||||
logger.info(
|
||||
"Unauthorized API request [requestId={}, method={}, path={}, reason={}]",
|
||||
MDC.get("requestId"),
|
||||
request.getMethod(),
|
||||
request.getRequestURI(),
|
||||
authException.getClass().getSimpleName()
|
||||
);
|
||||
ApiResponse<Void> body = apiResponseFactory.error(401, "error.auth.required");
|
||||
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
|
||||
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import com.iflytek.skillhub.domain.skill.SkillVersion;
|
|||
import com.iflytek.skillhub.domain.skill.SkillVersionRepository;
|
||||
import com.iflytek.skillhub.domain.social.SkillStarRepository;
|
||||
import com.iflytek.skillhub.dto.SkillSummaryResponse;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
|
||||
|
|
@ -19,6 +20,7 @@ import java.util.stream.Collectors;
|
|||
|
||||
@Service
|
||||
public class MySkillAppService {
|
||||
private static final int STAR_PAGE_SIZE = 200;
|
||||
|
||||
private final SkillRepository skillRepository;
|
||||
private final NamespaceRepository namespaceRepository;
|
||||
|
|
@ -68,10 +70,7 @@ public class MySkillAppService {
|
|||
}
|
||||
|
||||
public List<SkillSummaryResponse> listMyStars(String userId) {
|
||||
List<com.iflytek.skillhub.domain.social.SkillStar> stars = skillStarRepository.findByUserId(
|
||||
userId,
|
||||
PageRequest.of(0, 200)
|
||||
).getContent();
|
||||
List<com.iflytek.skillhub.domain.social.SkillStar> stars = loadAllStars(userId);
|
||||
|
||||
List<Long> skillIds = stars.stream()
|
||||
.map(com.iflytek.skillhub.domain.social.SkillStar::getSkillId)
|
||||
|
|
@ -111,6 +110,24 @@ public class MySkillAppService {
|
|||
.toList();
|
||||
}
|
||||
|
||||
private List<com.iflytek.skillhub.domain.social.SkillStar> loadAllStars(String userId) {
|
||||
List<com.iflytek.skillhub.domain.social.SkillStar> stars = new java.util.ArrayList<>();
|
||||
int pageNumber = 0;
|
||||
|
||||
while (true) {
|
||||
Page<com.iflytek.skillhub.domain.social.SkillStar> page = skillStarRepository.findByUserId(
|
||||
userId,
|
||||
PageRequest.of(pageNumber, STAR_PAGE_SIZE)
|
||||
);
|
||||
stars.addAll(page.getContent());
|
||||
|
||||
if (!page.hasNext()) {
|
||||
return stars;
|
||||
}
|
||||
pageNumber++;
|
||||
}
|
||||
}
|
||||
|
||||
private SkillSummaryResponse toSummaryResponse(
|
||||
Skill skill,
|
||||
Map<Long, SkillVersion> versionsById,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,6 @@
|
|||
ALTER TABLE api_token
|
||||
ALTER COLUMN name TYPE VARCHAR(64);
|
||||
|
||||
CREATE UNIQUE INDEX uk_api_token_user_active_name
|
||||
ON api_token (user_id, LOWER(name))
|
||||
WHERE revoked_at IS NULL;
|
||||
|
|
@ -14,7 +14,12 @@ validation.namespace.displayName.size=Display name must not exceed 128 character
|
|||
validation.namespace.description.size=Description must not exceed 512 characters
|
||||
validation.member.userId.notNull=User ID is required
|
||||
validation.member.role.notNull=Role is required
|
||||
validation.auth.local.username.notBlank=Username cannot be blank
|
||||
validation.auth.local.password.notBlank=Password cannot be blank
|
||||
validation.auth.local.email.invalid=Email format is invalid
|
||||
validation.token.name.notBlank=Token name cannot be blank
|
||||
validation.token.name.size=Token name must be at most 64 characters
|
||||
error.token.name.duplicate=You already have a token with this name
|
||||
|
||||
error.auth.required=Authentication required
|
||||
error.auth.local.username.exists=Username already exists
|
||||
|
|
|
|||
|
|
@ -14,7 +14,12 @@ validation.namespace.displayName.size=显示名称长度不能超过 128 个字
|
|||
validation.namespace.description.size=描述长度不能超过 512 个字符
|
||||
validation.member.userId.notNull=用户 ID 不能为空
|
||||
validation.member.role.notNull=角色不能为空
|
||||
validation.auth.local.username.notBlank=用户名不能为空
|
||||
validation.auth.local.password.notBlank=密码不能为空
|
||||
validation.auth.local.email.invalid=邮箱格式不正确
|
||||
validation.token.name.notBlank=Token 名称不能为空
|
||||
validation.token.name.size=Token 名称最多 64 个字符
|
||||
error.token.name.duplicate=你已经有同名 Token
|
||||
|
||||
error.auth.required=需要先登录
|
||||
error.auth.local.username.exists=用户名已存在
|
||||
|
|
|
|||
|
|
@ -95,6 +95,23 @@ class LocalAuthControllerTest {
|
|||
verify(skillHubMetrics).incrementUserRegister();
|
||||
}
|
||||
|
||||
@Test
|
||||
void register_rejectsInvalidEmailFormat() throws Exception {
|
||||
given(localAuthService.register("bob", "Abcd123!", "not-an-email"))
|
||||
.willThrow(new AuthFlowException(HttpStatus.BAD_REQUEST, "validation.auth.local.email.invalid"));
|
||||
|
||||
mockMvc.perform(post("/api/v1/auth/local/register")
|
||||
.with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{"username":"bob","password":"Abcd123!","email":"not-an-email"}
|
||||
"""))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.msg").value("邮箱格式不正确"));
|
||||
|
||||
verify(localAuthService).register("bob", "Abcd123!", "not-an-email");
|
||||
}
|
||||
|
||||
@Test
|
||||
void login_failure_recordsFailureMetric() throws Exception {
|
||||
given(localAuthService.login("alice", "wrong"))
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import com.iflytek.skillhub.TestRedisConfig;
|
|||
import com.iflytek.skillhub.auth.device.DeviceAuthService;
|
||||
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
|
||||
import com.iflytek.skillhub.auth.token.ApiTokenService;
|
||||
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
|
|
@ -18,11 +19,18 @@ import org.springframework.test.web.servlet.MockMvc;
|
|||
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import org.springframework.data.domain.PageImpl;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.BDDMockito.given;
|
||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication;
|
||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
|
|
@ -61,4 +69,63 @@ class TokenControllerTest {
|
|||
|
||||
verify(apiTokenService).revokeToken(7L, "user-42");
|
||||
}
|
||||
|
||||
@Test
|
||||
void create_rejectsNamesLongerThan64Characters() throws Exception {
|
||||
PlatformPrincipal principal = new PlatformPrincipal(
|
||||
"user-42", "tester", "tester@example.com", "", "github", Set.of("USER")
|
||||
);
|
||||
var auth = new UsernamePasswordAuthenticationToken(
|
||||
principal, null, List.of(new SimpleGrantedAuthority("ROLE_USER"))
|
||||
);
|
||||
given(apiTokenService.createToken(anyString(), anyString(), anyString()))
|
||||
.willThrow(new DomainBadRequestException("validation.token.name.size"));
|
||||
|
||||
mockMvc.perform(post("/api/v1/tokens")
|
||||
.with(authentication(auth))
|
||||
.with(csrf())
|
||||
.contentType("application/json")
|
||||
.content("""
|
||||
{"name":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}
|
||||
"""))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.msg").value("Token 名称最多 64 个字符"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void list_returns_paginated_tokens() throws Exception {
|
||||
PlatformPrincipal principal = new PlatformPrincipal(
|
||||
"user-42", "tester", "tester@example.com", "", "github", Set.of("USER")
|
||||
);
|
||||
var auth = new UsernamePasswordAuthenticationToken(
|
||||
principal, null, List.of(new SimpleGrantedAuthority("ROLE_USER"))
|
||||
);
|
||||
var tokenPage = new PageImpl<>(
|
||||
List.of(
|
||||
new com.iflytek.skillhub.auth.entity.ApiToken("user-42", "cli", "sk_123456", "hash-1", "[]"),
|
||||
new com.iflytek.skillhub.auth.entity.ApiToken("user-42", "deploy", "sk_654321", "hash-2", "[]")
|
||||
),
|
||||
PageRequest.of(1, 10),
|
||||
12
|
||||
);
|
||||
var first = tokenPage.getContent().get(0);
|
||||
var second = tokenPage.getContent().get(1);
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(first, "id", 7L);
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(first, "createdAt", java.time.LocalDateTime.of(2026, 3, 14, 10, 0));
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(second, "id", 8L);
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(second, "createdAt", java.time.LocalDateTime.of(2026, 3, 14, 11, 0));
|
||||
|
||||
given(apiTokenService.listActiveTokens("user-42", 1, 10)).willReturn(tokenPage);
|
||||
|
||||
mockMvc.perform(get("/api/v1/tokens")
|
||||
.with(authentication(auth))
|
||||
.param("page", "1")
|
||||
.param("size", "10"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.data.items[0].name").value("cli"))
|
||||
.andExpect(jsonPath("$.data.items[1].name").value("deploy"))
|
||||
.andExpect(jsonPath("$.data.total").value(12))
|
||||
.andExpect(jsonPath("$.data.page").value(1))
|
||||
.andExpect(jsonPath("$.data.size").value(10));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,89 @@
|
|||
package com.iflytek.skillhub.service;
|
||||
|
||||
import com.iflytek.skillhub.domain.namespace.Namespace;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceRepository;
|
||||
import com.iflytek.skillhub.domain.skill.Skill;
|
||||
import com.iflytek.skillhub.domain.skill.SkillRepository;
|
||||
import com.iflytek.skillhub.domain.skill.SkillVersion;
|
||||
import com.iflytek.skillhub.domain.skill.SkillVersionRepository;
|
||||
import com.iflytek.skillhub.domain.skill.SkillVisibility;
|
||||
import com.iflytek.skillhub.domain.social.SkillStar;
|
||||
import com.iflytek.skillhub.domain.social.SkillStarRepository;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.data.domain.PageImpl;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.BDDMockito.given;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class MySkillAppServiceTest {
|
||||
|
||||
@Mock
|
||||
private SkillRepository skillRepository;
|
||||
|
||||
@Mock
|
||||
private NamespaceRepository namespaceRepository;
|
||||
|
||||
@Mock
|
||||
private SkillVersionRepository skillVersionRepository;
|
||||
|
||||
@Mock
|
||||
private SkillStarRepository skillStarRepository;
|
||||
|
||||
private MySkillAppService service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
service = new MySkillAppService(skillRepository, namespaceRepository, skillVersionRepository, skillStarRepository);
|
||||
}
|
||||
|
||||
@Test
|
||||
void listMyStars_loadsAllPagesOfStars() {
|
||||
SkillStar firstStar = new SkillStar(1L, "user-1");
|
||||
ReflectionTestUtils.setField(firstStar, "createdAt", LocalDateTime.of(2026, 3, 14, 10, 0));
|
||||
SkillStar secondStar = new SkillStar(2L, "user-1");
|
||||
ReflectionTestUtils.setField(secondStar, "createdAt", LocalDateTime.of(2026, 3, 14, 11, 0));
|
||||
|
||||
given(skillStarRepository.findByUserId("user-1", PageRequest.of(0, 200)))
|
||||
.willReturn(new PageImpl<>(List.of(firstStar), PageRequest.of(0, 200), 201));
|
||||
given(skillStarRepository.findByUserId("user-1", PageRequest.of(1, 200)))
|
||||
.willReturn(new PageImpl<>(List.of(secondStar), PageRequest.of(1, 200), 201));
|
||||
|
||||
Skill firstSkill = new Skill(1L, "first-skill", "user-1", SkillVisibility.PUBLIC);
|
||||
firstSkill.setDisplayName("First Skill");
|
||||
firstSkill.setSummary("first summary");
|
||||
ReflectionTestUtils.setField(firstSkill, "id", 1L);
|
||||
ReflectionTestUtils.setField(firstSkill, "starCount", 1);
|
||||
ReflectionTestUtils.setField(firstSkill, "namespaceId", 101L);
|
||||
ReflectionTestUtils.setField(firstSkill, "updatedAt", LocalDateTime.of(2026, 3, 14, 10, 0));
|
||||
|
||||
Skill secondSkill = new Skill(2L, "second-skill", "user-1", SkillVisibility.PUBLIC);
|
||||
secondSkill.setDisplayName("Second Skill");
|
||||
secondSkill.setSummary("second summary");
|
||||
ReflectionTestUtils.setField(secondSkill, "id", 2L);
|
||||
ReflectionTestUtils.setField(secondSkill, "starCount", 2);
|
||||
ReflectionTestUtils.setField(secondSkill, "namespaceId", 101L);
|
||||
ReflectionTestUtils.setField(secondSkill, "updatedAt", LocalDateTime.of(2026, 3, 14, 11, 0));
|
||||
|
||||
given(skillRepository.findByIdIn(List.of(1L, 2L))).willReturn(List.of(firstSkill, secondSkill));
|
||||
given(namespaceRepository.findByIdIn(List.of(101L))).willReturn(List.of(new Namespace("team-ai", "Team AI", "user-1")));
|
||||
|
||||
var stars = service.listMyStars("user-1");
|
||||
|
||||
assertThat(stars).hasSize(2);
|
||||
assertThat(stars.get(0).slug()).isEqualTo("second-skill");
|
||||
assertThat(stars.get(1).slug()).isEqualTo("first-skill");
|
||||
}
|
||||
}
|
||||
|
|
@ -21,7 +21,7 @@ public class ApiToken {
|
|||
@Column(name = "user_id", nullable = false)
|
||||
private String userId;
|
||||
|
||||
@Column(nullable = false, length = 128)
|
||||
@Column(nullable = false, length = 64)
|
||||
private String name;
|
||||
|
||||
@Column(name = "token_prefix", nullable = false, length = 16)
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import org.springframework.transaction.annotation.Transactional;
|
|||
public class LocalAuthService {
|
||||
|
||||
private static final Pattern USERNAME_PATTERN = Pattern.compile("^[A-Za-z0-9_]{3,64}$");
|
||||
private static final Pattern EMAIL_PATTERN = Pattern.compile("^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$");
|
||||
private static final int MAX_FAILED_ATTEMPTS = 5;
|
||||
private static final Duration LOCK_DURATION = Duration.ofMinutes(15);
|
||||
|
||||
|
|
@ -57,6 +58,7 @@ public class LocalAuthService {
|
|||
}
|
||||
|
||||
String normalizedEmail = normalizeEmail(email);
|
||||
validateEmail(normalizedEmail);
|
||||
if (normalizedEmail != null && userAccountRepository.findByEmailIgnoreCase(normalizedEmail).isPresent()) {
|
||||
throw new AuthFlowException(HttpStatus.CONFLICT, "error.auth.local.email.exists");
|
||||
}
|
||||
|
|
@ -190,4 +192,13 @@ public class LocalAuthService {
|
|||
throw new AuthFlowException(HttpStatus.BAD_REQUEST, "error.auth.local.username.invalid");
|
||||
}
|
||||
}
|
||||
|
||||
private void validateEmail(String email) {
|
||||
if (email == null) {
|
||||
return;
|
||||
}
|
||||
if (!EMAIL_PATTERN.matcher(email).matches()) {
|
||||
throw new AuthFlowException(HttpStatus.BAD_REQUEST, "validation.auth.local.email.invalid");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
package com.iflytek.skillhub.auth.repository;
|
||||
|
||||
import com.iflytek.skillhub.auth.entity.ApiToken;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import java.util.List;
|
||||
|
|
@ -11,4 +13,6 @@ public interface ApiTokenRepository extends JpaRepository<ApiToken, Long> {
|
|||
Optional<ApiToken> findByTokenHash(String tokenHash);
|
||||
List<ApiToken> findByUserId(String userId);
|
||||
List<ApiToken> findByUserIdAndRevokedAtIsNullOrderByCreatedAtDesc(String userId);
|
||||
Page<ApiToken> findByUserIdAndRevokedAtIsNullOrderByCreatedAtDesc(String userId, Pageable pageable);
|
||||
boolean existsByUserIdAndRevokedAtIsNullAndNameIgnoreCase(String userId, String name);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,10 @@ package com.iflytek.skillhub.auth.token;
|
|||
|
||||
import com.iflytek.skillhub.auth.entity.ApiToken;
|
||||
import com.iflytek.skillhub.auth.repository.ApiTokenRepository;
|
||||
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
|
|
@ -20,6 +24,7 @@ public class ApiTokenService {
|
|||
|
||||
private static final String TOKEN_PREFIX = "sk_";
|
||||
private static final int TOKEN_BYTES = 32;
|
||||
private static final int MAX_NAME_LENGTH = 64;
|
||||
private final SecureRandom secureRandom = new SecureRandom();
|
||||
private final ApiTokenRepository tokenRepo;
|
||||
|
||||
|
|
@ -31,14 +36,21 @@ public class ApiTokenService {
|
|||
|
||||
@Transactional
|
||||
public TokenCreateResult createToken(String userId, String name, String scopeJson) {
|
||||
String normalizedName = normalizeName(name);
|
||||
validateTokenName(userId, normalizedName);
|
||||
|
||||
byte[] randomBytes = new byte[TOKEN_BYTES];
|
||||
secureRandom.nextBytes(randomBytes);
|
||||
String rawToken = TOKEN_PREFIX + Base64.getUrlEncoder().withoutPadding().encodeToString(randomBytes);
|
||||
String tokenHash = sha256(rawToken);
|
||||
String prefix = rawToken.substring(0, Math.min(rawToken.length(), 8));
|
||||
|
||||
ApiToken token = new ApiToken(userId, name, prefix, tokenHash, scopeJson);
|
||||
token = tokenRepo.save(token);
|
||||
ApiToken token = new ApiToken(userId, normalizedName, prefix, tokenHash, scopeJson);
|
||||
try {
|
||||
token = tokenRepo.save(token);
|
||||
} catch (DataIntegrityViolationException ex) {
|
||||
throw new DomainBadRequestException("error.token.name.duplicate");
|
||||
}
|
||||
return new TokenCreateResult(rawToken, token);
|
||||
}
|
||||
|
||||
|
|
@ -61,6 +73,12 @@ public class ApiTokenService {
|
|||
return tokenRepo.findByUserIdAndRevokedAtIsNullOrderByCreatedAtDesc(userId);
|
||||
}
|
||||
|
||||
public Page<ApiToken> listActiveTokens(String userId, int page, int size) {
|
||||
int resolvedPage = Math.max(page, 0);
|
||||
int resolvedSize = Math.max(size, 1);
|
||||
return tokenRepo.findByUserIdAndRevokedAtIsNullOrderByCreatedAtDesc(userId, PageRequest.of(resolvedPage, resolvedSize));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void touchLastUsed(ApiToken token) {
|
||||
token.setLastUsedAt(LocalDateTime.now());
|
||||
|
|
@ -76,4 +94,23 @@ public class ApiTokenService {
|
|||
throw new RuntimeException("SHA-256 not available", e);
|
||||
}
|
||||
}
|
||||
|
||||
private String normalizeName(String name) {
|
||||
if (name == null) {
|
||||
return "";
|
||||
}
|
||||
return name.trim();
|
||||
}
|
||||
|
||||
private void validateTokenName(String userId, String name) {
|
||||
if (name.isBlank()) {
|
||||
throw new DomainBadRequestException("validation.token.name.notBlank");
|
||||
}
|
||||
if (name.length() > MAX_NAME_LENGTH) {
|
||||
throw new DomainBadRequestException("validation.token.name.size");
|
||||
}
|
||||
if (tokenRepo.existsByUserIdAndRevokedAtIsNullAndNameIgnoreCase(userId, name)) {
|
||||
throw new DomainBadRequestException("error.token.name.duplicate");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -131,4 +131,13 @@ class LocalAuthServiceTest {
|
|||
.isInstanceOf(AuthFlowException.class)
|
||||
.hasMessageContaining("error.auth.local.accountDisabled");
|
||||
}
|
||||
|
||||
@Test
|
||||
void register_rejectsInvalidEmailFormat() {
|
||||
given(credentialRepository.existsByUsernameIgnoreCase("alice")).willReturn(false);
|
||||
|
||||
assertThatThrownBy(() -> service.register("Alice", "Abcd123!", "not-an-email"))
|
||||
.isInstanceOf(AuthFlowException.class)
|
||||
.hasMessageContaining("validation.auth.local.email.invalid");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,65 @@
|
|||
package com.iflytek.skillhub.auth.token;
|
||||
|
||||
import com.iflytek.skillhub.auth.repository.ApiTokenRepository;
|
||||
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class ApiTokenServiceTest {
|
||||
|
||||
@Mock
|
||||
private ApiTokenRepository tokenRepo;
|
||||
|
||||
private ApiTokenService service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
service = new ApiTokenService(tokenRepo);
|
||||
}
|
||||
|
||||
@Test
|
||||
void createToken_rejectsNamesLongerThan64Characters() {
|
||||
String longName = "a".repeat(65);
|
||||
|
||||
assertThatThrownBy(() -> service.createToken("user-1", longName, "[]"))
|
||||
.isInstanceOf(DomainBadRequestException.class)
|
||||
.hasMessageContaining("validation.token.name.size");
|
||||
|
||||
verify(tokenRepo, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void createToken_rejectsDuplicateActiveNamesIgnoringCase() {
|
||||
when(tokenRepo.existsByUserIdAndRevokedAtIsNullAndNameIgnoreCase("user-1", "My Token"))
|
||||
.thenReturn(true);
|
||||
|
||||
assertThatThrownBy(() -> service.createToken("user-1", " My Token ", "[]"))
|
||||
.isInstanceOf(DomainBadRequestException.class)
|
||||
.hasMessageContaining("error.token.name.duplicate");
|
||||
|
||||
verify(tokenRepo, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void createToken_trimsNameBeforeCheckingDuplicates() {
|
||||
when(tokenRepo.existsByUserIdAndRevokedAtIsNullAndNameIgnoreCase("user-1", "My Token"))
|
||||
.thenReturn(true);
|
||||
|
||||
assertThatThrownBy(() -> service.createToken("user-1", " My Token ", "[]"))
|
||||
.isInstanceOf(DomainBadRequestException.class);
|
||||
|
||||
verify(tokenRepo).existsByUserIdAndRevokedAtIsNullAndNameIgnoreCase("user-1", "My Token");
|
||||
verify(tokenRepo, never()).save(any());
|
||||
}
|
||||
}
|
||||
|
|
@ -27,5 +27,10 @@
|
|||
<groupId>org.springframework</groupId>
|
||||
<artifactId>spring-context</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</project>
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import java.util.Set;
|
|||
|
||||
@Service
|
||||
public class PostgresFullTextQueryService implements SearchQueryService {
|
||||
private static final int SHORT_KEYWORD_LENGTH = 2;
|
||||
|
||||
private final EntityManager entityManager;
|
||||
|
||||
|
|
@ -21,6 +22,9 @@ public class PostgresFullTextQueryService implements SearchQueryService {
|
|||
|
||||
@Override
|
||||
public SearchResult search(SearchQuery query) {
|
||||
String normalizedKeyword = normalizeKeyword(query.keyword());
|
||||
boolean hasKeyword = normalizedKeyword != null;
|
||||
boolean useShortKeywordFallback = hasKeyword && normalizedKeyword.length() <= SHORT_KEYWORD_LENGTH;
|
||||
Set<Long> memberNamespaceIds = query.visibilityScope().memberNamespaceIds().isEmpty()
|
||||
? Set.of(-1L)
|
||||
: query.visibilityScope().memberNamespaceIds();
|
||||
|
|
@ -48,8 +52,17 @@ public class PostgresFullTextQueryService implements SearchQueryService {
|
|||
}
|
||||
|
||||
// Full-text search
|
||||
if (query.keyword() != null && !query.keyword().isBlank()) {
|
||||
sql.append("AND search_vector @@ plainto_tsquery('simple', :keyword) ");
|
||||
if (hasKeyword) {
|
||||
if (useShortKeywordFallback) {
|
||||
sql.append("AND (");
|
||||
sql.append("LOWER(title) LIKE LOWER(:keywordLike) ");
|
||||
sql.append("OR LOWER(summary) LIKE LOWER(:keywordLike) ");
|
||||
sql.append("OR LOWER(keywords) LIKE LOWER(:keywordLike) ");
|
||||
sql.append("OR LOWER(search_text) LIKE LOWER(:keywordLike)");
|
||||
sql.append(") ");
|
||||
} else {
|
||||
sql.append("AND search_vector @@ plainto_tsquery('simple', :keyword) ");
|
||||
}
|
||||
}
|
||||
|
||||
// Sorting
|
||||
|
|
@ -59,7 +72,7 @@ public class PostgresFullTextQueryService implements SearchQueryService {
|
|||
sql.append("ORDER BY (SELECT rating_avg FROM skill WHERE id = skill_id) DESC ");
|
||||
} else if ("newest".equals(query.sortBy())) {
|
||||
sql.append("ORDER BY (SELECT updated_at FROM skill WHERE id = skill_id) DESC ");
|
||||
} else if ("relevance".equals(query.sortBy()) && query.keyword() != null && !query.keyword().isBlank()) {
|
||||
} else if ("relevance".equals(query.sortBy()) && hasKeyword && !useShortKeywordFallback) {
|
||||
sql.append("ORDER BY ts_rank(search_vector, plainto_tsquery('simple', :keyword)) DESC ");
|
||||
} else {
|
||||
sql.append("ORDER BY updated_at DESC ");
|
||||
|
|
@ -80,8 +93,12 @@ public class PostgresFullTextQueryService implements SearchQueryService {
|
|||
nativeQuery.setParameter("namespaceId", query.namespaceId());
|
||||
}
|
||||
|
||||
if (query.keyword() != null && !query.keyword().isBlank()) {
|
||||
nativeQuery.setParameter("keyword", query.keyword());
|
||||
if (hasKeyword) {
|
||||
if (useShortKeywordFallback) {
|
||||
nativeQuery.setParameter("keywordLike", "%" + normalizedKeyword + "%");
|
||||
} else {
|
||||
nativeQuery.setParameter("keyword", normalizedKeyword);
|
||||
}
|
||||
}
|
||||
|
||||
nativeQuery.setParameter("limit", query.size());
|
||||
|
|
@ -115,12 +132,23 @@ public class PostgresFullTextQueryService implements SearchQueryService {
|
|||
countQuery.setParameter("namespaceId", query.namespaceId());
|
||||
}
|
||||
|
||||
if (query.keyword() != null && !query.keyword().isBlank()) {
|
||||
countQuery.setParameter("keyword", query.keyword());
|
||||
if (hasKeyword) {
|
||||
if (useShortKeywordFallback) {
|
||||
countQuery.setParameter("keywordLike", "%" + normalizedKeyword + "%");
|
||||
} else {
|
||||
countQuery.setParameter("keyword", normalizedKeyword);
|
||||
}
|
||||
}
|
||||
|
||||
long total = ((Number) countQuery.getSingleResult()).longValue();
|
||||
|
||||
return new SearchResult(skillIds, total, query.page(), query.size());
|
||||
}
|
||||
|
||||
private String normalizeKeyword(String keyword) {
|
||||
if (keyword == null || keyword.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
return keyword.trim();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,110 @@
|
|||
package com.iflytek.skillhub.search.postgres;
|
||||
|
||||
import com.iflytek.skillhub.search.SearchQuery;
|
||||
import com.iflytek.skillhub.search.SearchVisibilityScope;
|
||||
import jakarta.persistence.EntityManager;
|
||||
import jakarta.persistence.Query;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class PostgresFullTextQueryServiceTest {
|
||||
|
||||
@Test
|
||||
void shortKeywordsShouldUseLikeFallback() {
|
||||
EntityManager entityManager = mock(EntityManager.class);
|
||||
Query nativeQuery = mock(Query.class);
|
||||
Query countQuery = mock(Query.class);
|
||||
when(entityManager.createNativeQuery(anyString()))
|
||||
.thenReturn(nativeQuery)
|
||||
.thenReturn(countQuery);
|
||||
when(nativeQuery.setParameter(anyString(), org.mockito.ArgumentMatchers.any())).thenReturn(nativeQuery);
|
||||
when(countQuery.setParameter(anyString(), org.mockito.ArgumentMatchers.any())).thenReturn(countQuery);
|
||||
when(nativeQuery.getResultList()).thenReturn(List.of(1L));
|
||||
when(countQuery.getSingleResult()).thenReturn(1L);
|
||||
|
||||
PostgresFullTextQueryService service = new PostgresFullTextQueryService(entityManager);
|
||||
|
||||
service.search(new SearchQuery(
|
||||
"ai",
|
||||
null,
|
||||
new SearchVisibilityScope(null, Set.of(), Set.of()),
|
||||
"relevance",
|
||||
0,
|
||||
20
|
||||
));
|
||||
|
||||
verify(nativeQuery).setParameter("keywordLike", "%ai%");
|
||||
verify(countQuery).setParameter("keywordLike", "%ai%");
|
||||
verify(nativeQuery, never()).setParameter("keyword", "ai");
|
||||
verify(countQuery, never()).setParameter("keyword", "ai");
|
||||
}
|
||||
|
||||
@Test
|
||||
void longerKeywordsShouldKeepFullTextSearch() {
|
||||
EntityManager entityManager = mock(EntityManager.class);
|
||||
Query nativeQuery = mock(Query.class);
|
||||
Query countQuery = mock(Query.class);
|
||||
when(entityManager.createNativeQuery(anyString()))
|
||||
.thenReturn(nativeQuery)
|
||||
.thenReturn(countQuery);
|
||||
when(nativeQuery.setParameter(anyString(), org.mockito.ArgumentMatchers.any())).thenReturn(nativeQuery);
|
||||
when(countQuery.setParameter(anyString(), org.mockito.ArgumentMatchers.any())).thenReturn(countQuery);
|
||||
when(nativeQuery.getResultList()).thenReturn(List.of(1L));
|
||||
when(countQuery.getSingleResult()).thenReturn(1L);
|
||||
|
||||
PostgresFullTextQueryService service = new PostgresFullTextQueryService(entityManager);
|
||||
|
||||
service.search(new SearchQuery(
|
||||
"agent",
|
||||
null,
|
||||
new SearchVisibilityScope(null, Set.of(), Set.of()),
|
||||
"relevance",
|
||||
0,
|
||||
20
|
||||
));
|
||||
|
||||
verify(nativeQuery).setParameter("keyword", "agent");
|
||||
verify(countQuery).setParameter("keyword", "agent");
|
||||
verify(nativeQuery, never()).setParameter("keywordLike", "%agent%");
|
||||
verify(countQuery, never()).setParameter("keywordLike", "%agent%");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shortKeywordSqlShouldAvoidTsRankOrdering() {
|
||||
EntityManager entityManager = mock(EntityManager.class);
|
||||
Query nativeQuery = mock(Query.class);
|
||||
Query countQuery = mock(Query.class);
|
||||
when(entityManager.createNativeQuery(anyString()))
|
||||
.thenReturn(nativeQuery)
|
||||
.thenReturn(countQuery);
|
||||
when(nativeQuery.setParameter(anyString(), org.mockito.ArgumentMatchers.any())).thenReturn(nativeQuery);
|
||||
when(countQuery.setParameter(anyString(), org.mockito.ArgumentMatchers.any())).thenReturn(countQuery);
|
||||
when(nativeQuery.getResultList()).thenReturn(List.of());
|
||||
when(countQuery.getSingleResult()).thenReturn(0L);
|
||||
|
||||
PostgresFullTextQueryService service = new PostgresFullTextQueryService(entityManager);
|
||||
|
||||
service.search(new SearchQuery(
|
||||
"go",
|
||||
null,
|
||||
new SearchVisibilityScope(null, Set.of(), Set.of()),
|
||||
"relevance",
|
||||
0,
|
||||
20
|
||||
));
|
||||
|
||||
var sqlCaptor = org.mockito.ArgumentCaptor.forClass(String.class);
|
||||
verify(entityManager, org.mockito.Mockito.times(2)).createNativeQuery(sqlCaptor.capture());
|
||||
assertThat(sqlCaptor.getAllValues().getFirst()).contains("LOWER(title) LIKE LOWER(:keywordLike)");
|
||||
assertThat(sqlCaptor.getAllValues().getFirst()).doesNotContain("ts_rank");
|
||||
}
|
||||
}
|
||||
|
|
@ -170,15 +170,54 @@ type ApiEnvelope<T> = {
|
|||
requestId: string
|
||||
}
|
||||
|
||||
export async function fetchJson<T>(input: RequestInfo | URL, init?: RequestInit): Promise<T> {
|
||||
type RequestWithTimeout = RequestInit & {
|
||||
timeoutMs?: number
|
||||
}
|
||||
|
||||
function createRequestSignal(init?: RequestWithTimeout): { signal?: AbortSignal, cleanup: () => void } {
|
||||
if (!init?.timeoutMs && !init?.signal) {
|
||||
return { signal: init?.signal ?? undefined, cleanup: () => {} }
|
||||
}
|
||||
|
||||
const controller = new AbortController()
|
||||
const timeoutId = init?.timeoutMs ? window.setTimeout(() => controller.abort('timeout'), init.timeoutMs) : undefined
|
||||
const abortListener = () => controller.abort()
|
||||
|
||||
if (init?.signal) {
|
||||
if (init.signal.aborted) {
|
||||
controller.abort()
|
||||
} else {
|
||||
init.signal.addEventListener('abort', abortListener, { once: true })
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
signal: controller.signal,
|
||||
cleanup: () => {
|
||||
if (timeoutId !== undefined) {
|
||||
window.clearTimeout(timeoutId)
|
||||
}
|
||||
init?.signal?.removeEventListener('abort', abortListener)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchJson<T>(input: RequestInfo | URL, init?: RequestWithTimeout): Promise<T> {
|
||||
const { signal, cleanup } = createRequestSignal(init)
|
||||
let response: Response
|
||||
try {
|
||||
response = await fetch(withBaseUrl(input), {
|
||||
...init,
|
||||
signal,
|
||||
headers: withRequestHeaders(init?.headers),
|
||||
})
|
||||
} catch {
|
||||
} catch (error) {
|
||||
if (error instanceof DOMException && error.name === 'AbortError') {
|
||||
throw new ApiError('error.request.timeout', 408)
|
||||
}
|
||||
throw new ApiError('Network error', 0)
|
||||
} finally {
|
||||
cleanup()
|
||||
}
|
||||
|
||||
let json: ApiEnvelope<T> | null = null
|
||||
|
|
@ -372,19 +411,28 @@ export const accountApi = {
|
|||
}
|
||||
|
||||
export const tokenApi = {
|
||||
async getTokens(): Promise<ApiToken[]> {
|
||||
const tokens = await unwrap<ApiToken[]>(client.GET('/api/v1/tokens', {
|
||||
async getTokens(params?: { page?: number, size?: number }): Promise<{ items: ApiToken[], total: number, page: number, size: number }> {
|
||||
const page = await unwrap<{ items: ApiToken[], total: number, page: number, size: number }>(client.GET('/api/v1/tokens', {
|
||||
params: {
|
||||
query: {
|
||||
page: params?.page ?? 0,
|
||||
size: params?.size ?? 10,
|
||||
},
|
||||
},
|
||||
headers: withRequestHeaders(),
|
||||
} as never) as never)
|
||||
return tokens
|
||||
.filter((token) => token.id !== undefined && token.name && token.tokenPrefix && token.createdAt)
|
||||
.map((token) => ({
|
||||
...token,
|
||||
id: token.id!,
|
||||
name: token.name!,
|
||||
tokenPrefix: token.tokenPrefix!,
|
||||
createdAt: token.createdAt!,
|
||||
}))
|
||||
return {
|
||||
...page,
|
||||
items: page.items
|
||||
.filter((token) => token.id !== undefined && token.name && token.tokenPrefix && token.createdAt)
|
||||
.map((token) => ({
|
||||
...token,
|
||||
id: token.id!,
|
||||
name: token.name!,
|
||||
tokenPrefix: token.tokenPrefix!,
|
||||
createdAt: token.createdAt!,
|
||||
})),
|
||||
}
|
||||
},
|
||||
|
||||
async createToken(request: CreateTokenRequest): Promise<CreateTokenResponse> {
|
||||
|
|
@ -408,14 +456,23 @@ export const tokenApi = {
|
|||
},
|
||||
|
||||
async deleteToken(tokenId: number): Promise<void> {
|
||||
await unwrap(client.DELETE('/api/v1/tokens/{id}', {
|
||||
const { error, response } = await client.DELETE('/api/v1/tokens/{id}', {
|
||||
params: {
|
||||
path: {
|
||||
id: tokenId,
|
||||
},
|
||||
},
|
||||
headers: withCsrf(),
|
||||
}) as never)
|
||||
} as never)
|
||||
|
||||
if (response.status === 204) {
|
||||
return
|
||||
}
|
||||
|
||||
const envelope = (error && isApiEnvelope<void>(error) ? error : null) as { msg?: string } | null
|
||||
if (!response.ok || error) {
|
||||
throw new ApiError(envelope?.msg || `HTTP ${response.status}`, response.status, envelope?.msg)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -19,12 +19,18 @@ const queryClient = new QueryClient({
|
|||
},
|
||||
},
|
||||
queryCache: new QueryCache({
|
||||
onError: (error) => {
|
||||
onError: (error, query) => {
|
||||
if (query.meta?.skipGlobalErrorHandler) {
|
||||
return
|
||||
}
|
||||
handleApiError(error)
|
||||
},
|
||||
}),
|
||||
mutationCache: new MutationCache({
|
||||
onError: (error, _variables, _context, mutation) => {
|
||||
if (mutation.meta?.skipGlobalErrorHandler) {
|
||||
return
|
||||
}
|
||||
// Only auto-handle if the mutation doesn't have its own onError
|
||||
if (!mutation.options.onError) {
|
||||
handleApiError(error)
|
||||
|
|
|
|||
|
|
@ -1,22 +1,39 @@
|
|||
import { useState } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Input } from '@/shared/ui/input'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
|
||||
interface SearchBarProps {
|
||||
defaultValue?: string
|
||||
value?: string
|
||||
placeholder?: string
|
||||
onChange?: (query: string) => void
|
||||
onSearch?: (query: string) => void
|
||||
}
|
||||
|
||||
export function SearchBar({ defaultValue = '', placeholder, onSearch }: SearchBarProps) {
|
||||
export function SearchBar({ defaultValue = '', value, placeholder, onChange, onSearch }: SearchBarProps) {
|
||||
const { t } = useTranslation()
|
||||
const [query, setQuery] = useState(defaultValue)
|
||||
const isControlled = value !== undefined
|
||||
const currentQuery = isControlled ? value : query
|
||||
|
||||
useEffect(() => {
|
||||
if (!isControlled) {
|
||||
setQuery(defaultValue)
|
||||
}
|
||||
}, [defaultValue, isControlled])
|
||||
|
||||
const handleChange = (nextQuery: string) => {
|
||||
if (!isControlled) {
|
||||
setQuery(nextQuery)
|
||||
}
|
||||
onChange?.(nextQuery)
|
||||
}
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (onSearch) {
|
||||
onSearch(query)
|
||||
onSearch(currentQuery)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -38,8 +55,8 @@ export function SearchBar({ defaultValue = '', placeholder, onSearch }: SearchBa
|
|||
</svg>
|
||||
<Input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
value={currentQuery}
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
placeholder={placeholder || t('searchBar.placeholder')}
|
||||
className="pl-10 border-0 bg-transparent focus-visible:ring-0 focus-visible:ring-offset-0 h-12"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -9,13 +9,48 @@ interface MarkdownRendererProps {
|
|||
}
|
||||
|
||||
export function MarkdownRenderer({ content, className }: MarkdownRendererProps) {
|
||||
const containerClassName = [className, 'prose prose-sm max-w-none dark:prose-invert']
|
||||
const containerClassName = [
|
||||
className,
|
||||
'prose prose-sm max-w-none break-words [overflow-wrap:anywhere] dark:prose-invert',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
|
||||
return (
|
||||
<div className={containerClassName}>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeSanitize, rehypeHighlight]}>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[rehypeSanitize, rehypeHighlight]}
|
||||
components={{
|
||||
pre: ({ children }) => (
|
||||
<div className="max-w-full overflow-x-auto rounded-lg bg-muted/40 p-4">
|
||||
<pre className="m-0 min-w-max bg-transparent p-0">{children}</pre>
|
||||
</div>
|
||||
),
|
||||
code: ({ className: codeClassName, children, ...props }) => {
|
||||
const isInline = !codeClassName?.includes('language-')
|
||||
|
||||
if (isInline) {
|
||||
return (
|
||||
<code className="break-words rounded bg-muted px-1 py-0.5 text-sm" {...props}>
|
||||
{children}
|
||||
</code>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<code className={codeClassName} {...props}>
|
||||
{children}
|
||||
</code>
|
||||
)
|
||||
},
|
||||
table: ({ children }) => (
|
||||
<div className="max-w-full overflow-x-auto">
|
||||
<table>{children}</table>
|
||||
</div>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -10,13 +10,13 @@ interface SkillCardProps {
|
|||
export function SkillCard({ skill, onClick }: SkillCardProps) {
|
||||
return (
|
||||
<Card
|
||||
className="p-5 cursor-pointer group relative overflow-hidden"
|
||||
className="h-full p-5 cursor-pointer group relative overflow-hidden"
|
||||
onClick={onClick}
|
||||
>
|
||||
{/* Hover gradient border effect */}
|
||||
<div className="absolute inset-0 rounded-xl opacity-0 group-hover:opacity-100 transition-opacity duration-300 bg-gradient-to-br from-primary/20 via-transparent to-accent/20 pointer-events-none" />
|
||||
|
||||
<div className="relative z-10">
|
||||
<div className="relative z-10 flex h-full flex-col">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<h3 className="font-semibold font-heading text-lg text-foreground group-hover:text-primary transition-colors">
|
||||
{skill.displayName}
|
||||
|
|
@ -30,7 +30,7 @@ export function SkillCard({ skill, onClick }: SkillCardProps) {
|
|||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||
<div className="mt-auto flex items-center gap-4 text-xs text-muted-foreground">
|
||||
{skill.latestVersion && (
|
||||
<span className="px-2.5 py-1 rounded-full bg-secondary/60 font-mono">
|
||||
v{skill.latestVersion}
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ export function useToggleStar(skillId: number) {
|
|||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['skills', skillId, 'star'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['skills'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['skills', 'stars'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,33 +19,57 @@ import type { CreateTokenRequest, CreateTokenResponse } from '@/api/types'
|
|||
|
||||
interface CreateTokenDialogProps {
|
||||
children: React.ReactNode
|
||||
existingNames?: string[]
|
||||
}
|
||||
|
||||
export function CreateTokenDialog({ children }: CreateTokenDialogProps) {
|
||||
const MAX_TOKEN_NAME_LENGTH = 64
|
||||
|
||||
export function CreateTokenDialog({ children, existingNames = [] }: CreateTokenDialogProps) {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [name, setName] = useState('')
|
||||
const [createdToken, setCreatedToken] = useState<CreateTokenResponse | null>(null)
|
||||
const [nameError, setNameError] = useState<string | null>(null)
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const normalizedName = name.trim()
|
||||
const hasDuplicateName = existingNames.some(
|
||||
(existingName) => existingName.trim().toLocaleLowerCase() === normalizedName.toLocaleLowerCase()
|
||||
)
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (request: CreateTokenRequest) => tokenApi.createToken(request),
|
||||
onSuccess: (data) => {
|
||||
setCreatedToken(data)
|
||||
setName('')
|
||||
setNameError(null)
|
||||
queryClient.invalidateQueries({ queryKey: ['tokens'] })
|
||||
},
|
||||
})
|
||||
|
||||
const handleCreate = () => {
|
||||
if (!name.trim()) return
|
||||
createMutation.mutate({ name: name.trim() })
|
||||
if (!normalizedName) {
|
||||
setNameError(t('createToken.nameRequired'))
|
||||
return
|
||||
}
|
||||
if (normalizedName.length > MAX_TOKEN_NAME_LENGTH) {
|
||||
setNameError(t('createToken.nameTooLong', { max: MAX_TOKEN_NAME_LENGTH }))
|
||||
return
|
||||
}
|
||||
if (hasDuplicateName) {
|
||||
setNameError(t('createToken.nameDuplicate'))
|
||||
return
|
||||
}
|
||||
|
||||
setNameError(null)
|
||||
createMutation.mutate({ name: normalizedName })
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
setOpen(false)
|
||||
setCreatedToken(null)
|
||||
setName('')
|
||||
setNameError(null)
|
||||
createMutation.reset()
|
||||
}
|
||||
|
||||
|
|
@ -54,10 +78,22 @@ export function CreateTokenDialog({ children }: CreateTokenDialogProps) {
|
|||
|
||||
try {
|
||||
await navigator.clipboard.writeText(createdToken.token)
|
||||
toast.success(t('createToken.copySuccess'))
|
||||
toast.success(t('createToken.copySuccess'), undefined, {
|
||||
position: 'top-center',
|
||||
classNames: {
|
||||
title: 'text-center font-semibold',
|
||||
description: 'text-center',
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to copy token:', error)
|
||||
toast.error(t('createToken.copyFailed'))
|
||||
toast.error(t('createToken.copyFailed'), undefined, {
|
||||
position: 'top-center',
|
||||
classNames: {
|
||||
title: 'text-center font-semibold',
|
||||
description: 'text-center',
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -80,22 +116,40 @@ export function CreateTokenDialog({ children }: CreateTokenDialogProps) {
|
|||
id="token-name"
|
||||
placeholder={t('createToken.namePlaceholder')}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
maxLength={MAX_TOKEN_NAME_LENGTH}
|
||||
onChange={(e) => {
|
||||
setName(e.target.value)
|
||||
if (nameError) {
|
||||
setNameError(null)
|
||||
}
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleCreate()
|
||||
}
|
||||
}}
|
||||
aria-invalid={nameError || hasDuplicateName ? 'true' : 'false'}
|
||||
/>
|
||||
<div className="flex items-center justify-between gap-3 text-xs">
|
||||
<span className="text-red-600">
|
||||
{nameError ?? (hasDuplicateName && normalizedName ? t('createToken.nameDuplicate') : '')}
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
{normalizedName.length}/{MAX_TOKEN_NAME_LENGTH}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{createMutation.error ? (
|
||||
<p className="text-sm text-red-600">{createMutation.error.message}</p>
|
||||
) : null}
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={handleClose}>
|
||||
{t('dialog.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCreate}
|
||||
disabled={!name.trim() || createMutation.isPending}
|
||||
disabled={!normalizedName || hasDuplicateName || createMutation.isPending}
|
||||
>
|
||||
{createMutation.isPending ? t('createToken.creating') : t('createToken.create')}
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -13,28 +13,67 @@ import {
|
|||
} from '@/shared/ui/table'
|
||||
import { CreateTokenDialog } from './create-token-dialog'
|
||||
import { ConfirmDialog } from '@/shared/components/confirm-dialog'
|
||||
import { Pagination } from '@/shared/components/pagination'
|
||||
import { toast } from '@/shared/lib/toast'
|
||||
import { formatLocalDateTime } from '@/shared/lib/date-time'
|
||||
import type { ApiToken } from '@/api/types'
|
||||
|
||||
const PAGE_SIZE = 10
|
||||
type TokenPage = { items: ApiToken[]; total: number; page: number; size: number }
|
||||
|
||||
export function TokenList() {
|
||||
const { t } = useTranslation()
|
||||
const { t, i18n } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const [page, setPage] = useState(0)
|
||||
const [deleteDialog, setDeleteDialog] = useState<{ open: boolean; tokenId?: number; name?: string }>({
|
||||
open: false,
|
||||
})
|
||||
|
||||
const { data: tokens, isLoading } = useQuery<ApiToken[]>({
|
||||
queryKey: ['tokens'],
|
||||
queryFn: tokenApi.getTokens,
|
||||
const { data: tokenPage, isLoading, isError, error } = useQuery<TokenPage>({
|
||||
queryKey: ['tokens', page, PAGE_SIZE],
|
||||
queryFn: () => tokenApi.getTokens({ page, size: PAGE_SIZE }),
|
||||
meta: {
|
||||
skipGlobalErrorHandler: true,
|
||||
},
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (tokenId: number) => tokenApi.deleteToken(tokenId),
|
||||
onMutate: async (tokenId) => {
|
||||
await queryClient.cancelQueries({ queryKey: ['tokens'] })
|
||||
|
||||
const previousPages = queryClient.getQueriesData<TokenPage>({ queryKey: ['tokens'] })
|
||||
queryClient.setQueriesData<TokenPage>({ queryKey: ['tokens'] }, (current) => {
|
||||
if (!current) {
|
||||
return current
|
||||
}
|
||||
|
||||
const nextItems = current.items.filter((token) => token.id !== tokenId)
|
||||
if (nextItems.length === current.items.length) {
|
||||
return current
|
||||
}
|
||||
|
||||
return {
|
||||
...current,
|
||||
items: nextItems,
|
||||
total: Math.max(current.total - 1, 0),
|
||||
}
|
||||
})
|
||||
|
||||
return { previousPages }
|
||||
},
|
||||
onSuccess: () => {
|
||||
if (tokenPage && tokenPage.items.length === 1 && page > 0) {
|
||||
setPage(page - 1)
|
||||
}
|
||||
setDeleteDialog({ open: false })
|
||||
queryClient.invalidateQueries({ queryKey: ['tokens'] })
|
||||
toast.success(t('token.deleteSuccess'))
|
||||
},
|
||||
onError: () => {
|
||||
onError: (_error, _tokenId, context) => {
|
||||
context?.previousPages.forEach(([queryKey, previousPage]) => {
|
||||
queryClient.setQueryData(queryKey, previousPage)
|
||||
})
|
||||
toast.error(t('token.deleteFailed'))
|
||||
},
|
||||
})
|
||||
|
|
@ -45,29 +84,48 @@ export function TokenList() {
|
|||
|
||||
const confirmDelete = async () => {
|
||||
if (deleteDialog.tokenId) {
|
||||
deleteMutation.mutate(deleteDialog.tokenId)
|
||||
await deleteMutation.mutateAsync(deleteDialog.tokenId)
|
||||
}
|
||||
}
|
||||
|
||||
const formatDate = (dateString?: string | null) => {
|
||||
if (!dateString) return '-'
|
||||
return new Date(dateString).toLocaleString('zh-CN')
|
||||
return formatLocalDateTime(dateString, i18n.language)
|
||||
}
|
||||
|
||||
const tokens = tokenPage?.items ?? []
|
||||
const totalPages = tokenPage ? Math.max(Math.ceil(tokenPage.total / tokenPage.size), 1) : 1
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="text-center py-8 text-muted-foreground">{t('token.loading')}</div>
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xl font-semibold">{t('token.title')}</h2>
|
||||
<CreateTokenDialog existingNames={[]}>
|
||||
<Button>{t('token.createNew')}</Button>
|
||||
</CreateTokenDialog>
|
||||
</div>
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 px-4 py-6 text-sm text-destructive">
|
||||
{error instanceof Error ? error.message : t('apiError.unknown')}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xl font-semibold">{t('token.title')}</h2>
|
||||
<CreateTokenDialog>
|
||||
<CreateTokenDialog existingNames={tokens.map((token) => token.name)}>
|
||||
<Button>{t('token.createNew')}</Button>
|
||||
</CreateTokenDialog>
|
||||
</div>
|
||||
|
||||
{!tokens || tokens.length === 0 ? (
|
||||
{!tokenPage || tokenPage.total === 0 ? (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
<p>{t('token.empty')}</p>
|
||||
<p className="text-sm mt-2">{t('token.emptyHint')}</p>
|
||||
|
|
@ -114,6 +172,10 @@ export function TokenList() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{tokenPage && tokenPage.total > PAGE_SIZE ? (
|
||||
<Pagination page={page} totalPages={totalPages} onPageChange={setPage} />
|
||||
) : null}
|
||||
|
||||
<ConfirmDialog
|
||||
open={deleteDialog.open}
|
||||
onOpenChange={(open) => setDeleteDialog({ ...deleteDialog, open })}
|
||||
|
|
|
|||
|
|
@ -111,6 +111,10 @@
|
|||
"password": "Password",
|
||||
"usernamePlaceholder": "Enter username",
|
||||
"passwordPlaceholder": "Enter password",
|
||||
"usernameRequired": "Username is required",
|
||||
"passwordRequired": "Password is required",
|
||||
"showPassword": "Show password",
|
||||
"hidePassword": "Hide password",
|
||||
"submitting": "Logging in...",
|
||||
"submit": "Login",
|
||||
"noAccount": "Don't have an account?",
|
||||
|
|
@ -322,6 +326,7 @@
|
|||
"emptyDescription": "No skills have been published in this namespace yet"
|
||||
},
|
||||
"skillDetail": {
|
||||
"back": "Back",
|
||||
"notFound": "Skill not found",
|
||||
"notFoundDesc": "This skill may have been deleted or never existed",
|
||||
"loginRequired": "Login Required",
|
||||
|
|
@ -393,6 +398,9 @@
|
|||
"description": "Create a new API Token for CLI or API access",
|
||||
"nameLabel": "Token Name",
|
||||
"namePlaceholder": "e.g.: my-cli-token",
|
||||
"nameRequired": "Token name is required",
|
||||
"nameTooLong": "Token name must be at most {{max}} characters",
|
||||
"nameDuplicate": "You already have a token with this name",
|
||||
"creating": "Creating...",
|
||||
"create": "Create",
|
||||
"successTitle": "Token Created",
|
||||
|
|
@ -462,6 +470,10 @@
|
|||
"success": "Published Successfully",
|
||||
"successDescription": "{{skill}} has been submitted for review and will be available after admin approval",
|
||||
"error": "Publish Failed",
|
||||
"timeoutTitle": "Publish timed out",
|
||||
"timeoutDescription": "The publish request took too long. Please check the skill list later or try again.",
|
||||
"versionExistsTitle": "Version already exists",
|
||||
"versionExistsDescription": "This skill version has already been published. Update the version in SKILL.md, rebuild the package, and upload it again.",
|
||||
"selectRequired": "Please select namespace and file"
|
||||
},
|
||||
"toast": {
|
||||
|
|
|
|||
|
|
@ -111,6 +111,10 @@
|
|||
"password": "密码",
|
||||
"usernamePlaceholder": "输入用户名",
|
||||
"passwordPlaceholder": "输入密码",
|
||||
"usernameRequired": "请输入用户名",
|
||||
"passwordRequired": "请输入密码",
|
||||
"showPassword": "显示密码",
|
||||
"hidePassword": "隐藏密码",
|
||||
"submitting": "登录中...",
|
||||
"submit": "登录",
|
||||
"noAccount": "还没有账号?",
|
||||
|
|
@ -322,6 +326,7 @@
|
|||
"emptyDescription": "该命名空间下还没有发布任何技能"
|
||||
},
|
||||
"skillDetail": {
|
||||
"back": "返回上一页",
|
||||
"notFound": "技能不存在",
|
||||
"notFoundDesc": "该技能可能已被删除或从未存在",
|
||||
"loginRequired": "需要登录",
|
||||
|
|
@ -393,6 +398,9 @@
|
|||
"description": "创建一个新的 API Token 用于 CLI 或 API 访问",
|
||||
"nameLabel": "Token 名称",
|
||||
"namePlaceholder": "例如: my-cli-token",
|
||||
"nameRequired": "请输入 Token 名称",
|
||||
"nameTooLong": "Token 名称最多 {{max}} 个字符",
|
||||
"nameDuplicate": "你已经有同名 Token",
|
||||
"creating": "创建中...",
|
||||
"create": "创建",
|
||||
"successTitle": "Token 创建成功",
|
||||
|
|
@ -462,6 +470,10 @@
|
|||
"success": "发布成功",
|
||||
"successDescription": "{{skill}} 已提交审核,等待管理员批准后即可使用",
|
||||
"error": "发布失败",
|
||||
"timeoutTitle": "发布请求超时",
|
||||
"timeoutDescription": "本次发布等待时间过长,请稍后到技能列表确认结果,或重新尝试发布。",
|
||||
"versionExistsTitle": "版本号已存在",
|
||||
"versionExistsDescription": "当前技能版本已经发布过,请修改 SKILL.md 中的 version 后重新打包上传。",
|
||||
"selectRequired": "请选择命名空间和文件"
|
||||
},
|
||||
"toast": {
|
||||
|
|
|
|||
|
|
@ -1,11 +1,8 @@
|
|||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { App } from './app/providers'
|
||||
import './i18n/config'
|
||||
import './index.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
<App />,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -8,6 +8,17 @@ import { Label } from '@/shared/ui/label'
|
|||
import { Card } from '@/shared/ui/card'
|
||||
import { useMyNamespaces, usePublishSkill } from '@/shared/hooks/use-skill-queries'
|
||||
import { toast } from '@/shared/lib/toast'
|
||||
import { ApiError } from '@/api/client'
|
||||
|
||||
function isVersionExistsMessage(message?: string): boolean {
|
||||
if (!message) {
|
||||
return false
|
||||
}
|
||||
|
||||
return message.includes('error.skill.version.exists')
|
||||
|| message.includes('Version already exists')
|
||||
|| message.includes('版本已存在')
|
||||
}
|
||||
|
||||
export function PublishPage() {
|
||||
const { t } = useTranslation()
|
||||
|
|
@ -39,6 +50,19 @@ export function PublishPage() {
|
|||
)
|
||||
navigate({ to: '/dashboard/skills' })
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && error.status === 408) {
|
||||
toast.error(t('publish.timeoutTitle'), t('publish.timeoutDescription'))
|
||||
return
|
||||
}
|
||||
|
||||
if (error instanceof ApiError && isVersionExistsMessage(error.serverMessage || error.message)) {
|
||||
toast.error(
|
||||
t('publish.versionExistsTitle'),
|
||||
t('publish.versionExistsDescription'),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
toast.error(t('publish.error'), error instanceof Error ? error.message : '')
|
||||
}
|
||||
}
|
||||
|
|
@ -113,7 +137,7 @@ export function PublishPage() {
|
|||
</div>
|
||||
|
||||
<Button
|
||||
className="w-full"
|
||||
className="w-full text-primary-foreground disabled:text-primary-foreground"
|
||||
size="lg"
|
||||
onClick={handlePublish}
|
||||
disabled={!selectedFile || !namespaceSlug || publishMutation.isPending}
|
||||
|
|
@ -123,4 +147,4 @@ export function PublishPage() {
|
|||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { Link, useNavigate, useSearch } from '@tanstack/react-router'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Eye, EyeOff } from 'lucide-react'
|
||||
import { getDirectAuthRuntimeConfig } from '@/api/client'
|
||||
import { LoginButton } from '@/features/auth/login-button'
|
||||
import { SessionBootstrapEntry } from '@/features/auth/session-bootstrap-entry'
|
||||
|
|
@ -18,6 +19,8 @@ export function LoginPage() {
|
|||
const directAuthConfig = getDirectAuthRuntimeConfig()
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [fieldErrors, setFieldErrors] = useState<{ username?: string, password?: string }>({})
|
||||
const isChinese = i18n.resolvedLanguage?.split('-')[0] === 'zh'
|
||||
const { data: authMethods } = useAuthMethods(search.returnTo)
|
||||
|
||||
|
|
@ -30,8 +33,23 @@ export function LoginPage() {
|
|||
|
||||
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
const trimmedUsername = username.trim()
|
||||
const nextFieldErrors: { username?: string, password?: string } = {}
|
||||
|
||||
if (!trimmedUsername) {
|
||||
nextFieldErrors.username = t('login.usernameRequired')
|
||||
}
|
||||
if (!password) {
|
||||
nextFieldErrors.password = t('login.passwordRequired')
|
||||
}
|
||||
if (nextFieldErrors.username || nextFieldErrors.password) {
|
||||
setFieldErrors(nextFieldErrors)
|
||||
return
|
||||
}
|
||||
|
||||
setFieldErrors({})
|
||||
try {
|
||||
await loginMutation.mutateAsync({ username, password })
|
||||
await loginMutation.mutateAsync({ username: trimmedUsername, password })
|
||||
await navigate({ to: returnTo })
|
||||
} catch {
|
||||
// mutation state drives the error UI
|
||||
|
|
@ -79,20 +97,50 @@ export function LoginPage() {
|
|||
id="username"
|
||||
autoComplete="username"
|
||||
value={username}
|
||||
onChange={(event) => setUsername(event.target.value)}
|
||||
onChange={(event) => {
|
||||
setUsername(event.target.value)
|
||||
if (fieldErrors.username) {
|
||||
setFieldErrors((current) => ({ ...current, username: undefined }))
|
||||
}
|
||||
}}
|
||||
placeholder={t('login.usernamePlaceholder')}
|
||||
aria-invalid={fieldErrors.username ? 'true' : 'false'}
|
||||
/>
|
||||
{fieldErrors.username ? (
|
||||
<p className="text-sm text-red-600">{fieldErrors.username}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="password">{t('login.password')}</label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
placeholder={t('login.passwordPlaceholder')}
|
||||
/>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(event) => {
|
||||
setPassword(event.target.value)
|
||||
if (fieldErrors.password) {
|
||||
setFieldErrors((current) => ({ ...current, password: undefined }))
|
||||
}
|
||||
}}
|
||||
placeholder={t('login.passwordPlaceholder')}
|
||||
className="pr-12"
|
||||
aria-invalid={fieldErrors.password ? 'true' : 'false'}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={showPassword ? t('login.hidePassword') : t('login.showPassword')}
|
||||
aria-pressed={showPassword}
|
||||
onClick={() => setShowPassword((current) => !current)}
|
||||
className="absolute inset-y-0 right-0 flex w-12 items-center justify-center text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
{fieldErrors.password ? (
|
||||
<p className="text-sm text-red-600">{fieldErrors.password}</p>
|
||||
) : null}
|
||||
</div>
|
||||
{loginMutation.error ? (
|
||||
<p className="text-sm text-red-600">{loginMutation.error.message}</p>
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { startTransition, useEffect, useState } from 'react'
|
||||
import { useNavigate, useSearch } from '@tanstack/react-router'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { SearchBar } from '@/features/search/search-bar'
|
||||
|
|
@ -16,6 +17,11 @@ export function SearchPage() {
|
|||
const q = searchParams.q || ''
|
||||
const sort = searchParams.sort || 'relevance'
|
||||
const page = searchParams.page ?? 0
|
||||
const [queryInput, setQueryInput] = useState(q)
|
||||
|
||||
useEffect(() => {
|
||||
setQueryInput(q)
|
||||
}, [q])
|
||||
|
||||
const { data, isLoading } = useSearchSkills({
|
||||
q,
|
||||
|
|
@ -24,8 +30,34 @@ export function SearchPage() {
|
|||
size: 12,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const normalizedQuery = queryInput.trim()
|
||||
if (normalizedQuery === q) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!normalizedQuery) {
|
||||
startTransition(() => {
|
||||
navigate({ to: '/search', search: { q: '', sort, page: 0 }, replace: page === 0 })
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
startTransition(() => {
|
||||
navigate({ to: '/search', search: { q: normalizedQuery, sort, page: 0 }, replace: true })
|
||||
})
|
||||
}, 250)
|
||||
|
||||
return () => window.clearTimeout(timeoutId)
|
||||
}, [navigate, page, q, queryInput, sort])
|
||||
|
||||
const handleSearch = (query: string) => {
|
||||
navigate({ to: '/search', search: { q: query, sort, page: 0 } })
|
||||
const normalizedQuery = query.trim()
|
||||
setQueryInput(query)
|
||||
startTransition(() => {
|
||||
navigate({ to: '/search', search: { q: normalizedQuery, sort, page: 0 }, replace: true })
|
||||
})
|
||||
}
|
||||
|
||||
const handleSortChange = (newSort: string) => {
|
||||
|
|
@ -46,7 +78,7 @@ export function SearchPage() {
|
|||
<div className="space-y-8 animate-fade-up">
|
||||
{/* Search Bar */}
|
||||
<div className="max-w-3xl mx-auto">
|
||||
<SearchBar defaultValue={q} onSearch={handleSearch} />
|
||||
<SearchBar value={queryInput} onChange={setQueryInput} onSearch={handleSearch} />
|
||||
</div>
|
||||
|
||||
{/* Sort Selector */}
|
||||
|
|
@ -92,7 +124,7 @@ export function SearchPage() {
|
|||
<>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
|
||||
{data.items.map((skill, idx) => (
|
||||
<div key={skill.id} className={`animate-fade-up delay-${Math.min(idx % 6 + 1, 6)}`}>
|
||||
<div key={skill.id} className={`h-full animate-fade-up delay-${Math.min(idx % 6 + 1, 6)}`}>
|
||||
<SkillCard
|
||||
skill={skill}
|
||||
onClick={() => handleSkillClick(skill.namespace, skill.slug)}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { useTranslation } from 'react-i18next'
|
||||
import { useParams, useNavigate, useRouterState } from '@tanstack/react-router'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { ArrowLeft } from 'lucide-react'
|
||||
import { MarkdownRenderer } from '@/features/skill/markdown-renderer'
|
||||
import { FileTree } from '@/features/skill/file-tree'
|
||||
import { InstallCommand } from '@/features/skill/install-command'
|
||||
|
|
@ -8,6 +9,7 @@ import { RatingInput } from '@/features/social/rating-input'
|
|||
import { StarButton } from '@/features/social/star-button'
|
||||
import { useAuth } from '@/features/auth/use-auth'
|
||||
import { adminApi } from '@/api/client'
|
||||
import { formatLocalDateTime } from '@/shared/lib/date-time'
|
||||
import { NamespaceBadge } from '@/shared/components/namespace-badge'
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/shared/ui/tabs'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
|
|
@ -77,6 +79,14 @@ export function SkillDetailPage() {
|
|||
})
|
||||
}
|
||||
|
||||
const handleBack = () => {
|
||||
if (window.history.length > 1) {
|
||||
window.history.back()
|
||||
return
|
||||
}
|
||||
navigate({ to: '/search', search: { q: '', sort: 'relevance', page: 0 } })
|
||||
}
|
||||
|
||||
if (isLoadingSkill) {
|
||||
return (
|
||||
<div className="space-y-6 animate-fade-up">
|
||||
|
|
@ -122,6 +132,15 @@ export function SkillDetailPage() {
|
|||
{/* Main Content */}
|
||||
<div className="lg:col-span-2 space-y-8">
|
||||
<div className="space-y-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="gap-2 px-0 text-muted-foreground hover:text-foreground"
|
||||
onClick={handleBack}
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
{t('skillDetail.back')}
|
||||
</Button>
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
<NamespaceBadge type="GLOBAL" name={namespace} />
|
||||
</div>
|
||||
|
|
@ -173,7 +192,7 @@ export function SkillDetailPage() {
|
|||
</span>
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{new Date(version.publishedAt).toLocaleDateString(i18n.language)}
|
||||
{formatLocalDateTime(version.publishedAt, i18n.language)}
|
||||
</span>
|
||||
</div>
|
||||
{version.changelog && (
|
||||
|
|
|
|||
|
|
@ -26,7 +26,9 @@ export function EmptyState({ title, description, action }: EmptyStateProps) {
|
|||
</div>
|
||||
<h3 className="text-lg font-semibold font-heading text-foreground mb-2">{title}</h3>
|
||||
{description && (
|
||||
<p className="text-sm text-muted-foreground max-w-md mb-6">{description}</p>
|
||||
<p className="text-sm text-muted-foreground max-w-md mb-6 break-words [overflow-wrap:anywhere]">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
{action && <div>{action}</div>}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
|||
import type { SkillSummary, SkillDetail, SkillVersion, SkillFile, SearchParams, PagedResponse, PublishResult, Namespace, NamespaceMember } from '@/api/types'
|
||||
import { fetchJson, fetchText, getCsrfHeaders, meApi } from '@/api/client'
|
||||
|
||||
const PUBLISH_REQUEST_TIMEOUT_MS = 60_000
|
||||
|
||||
async function searchSkills(params: SearchParams): Promise<PagedResponse<SkillSummary>> {
|
||||
const queryParams = new URLSearchParams()
|
||||
if (params.q) queryParams.append('q', params.q)
|
||||
|
|
@ -75,6 +77,7 @@ async function publishSkill(params: { namespace: string; file: File; visibility:
|
|||
method: 'POST',
|
||||
headers: getCsrfHeaders(),
|
||||
body: formData,
|
||||
timeoutMs: PUBLISH_REQUEST_TIMEOUT_MS,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
21
web/src/shared/lib/date-time.ts
Normal file
21
web/src/shared/lib/date-time.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
function parseServerDateTime(value: string): Date {
|
||||
if (/[zZ]$|[+-]\d{2}:\d{2}$/.test(value)) {
|
||||
return new Date(value)
|
||||
}
|
||||
|
||||
const [datePart, timePart = '00:00:00'] = value.split('T')
|
||||
const [year, month, day] = datePart.split('-').map(Number)
|
||||
const [rawTime, fractional = ''] = timePart.split('.')
|
||||
const [hours = 0, minutes = 0, seconds = 0] = rawTime.split(':').map(Number)
|
||||
const milliseconds = Number((fractional + '000').slice(0, 3))
|
||||
|
||||
return new Date(year, (month || 1) - 1, day || 1, hours, minutes, seconds, milliseconds)
|
||||
}
|
||||
|
||||
export function formatLocalDateTime(
|
||||
value: string,
|
||||
locale: string,
|
||||
options: Intl.DateTimeFormatOptions = { dateStyle: 'medium', timeStyle: 'short' },
|
||||
) {
|
||||
return new Intl.DateTimeFormat(locale, options).format(parseServerDateTime(value))
|
||||
}
|
||||
|
|
@ -1,17 +1,17 @@
|
|||
import { toast as sonnerToast } from 'sonner'
|
||||
import { toast as sonnerToast, type ExternalToast } from 'sonner'
|
||||
|
||||
export const toast = {
|
||||
success: (message: string, description?: string) => {
|
||||
sonnerToast.success(message, { description })
|
||||
success: (message: string, description?: string, options?: ExternalToast) => {
|
||||
sonnerToast.success(message, { description, ...options })
|
||||
},
|
||||
error: (message: string, description?: string) => {
|
||||
sonnerToast.error(message, { description })
|
||||
error: (message: string, description?: string, options?: ExternalToast) => {
|
||||
sonnerToast.error(message, { description, ...options })
|
||||
},
|
||||
warning: (message: string, description?: string) => {
|
||||
sonnerToast.warning(message, { description })
|
||||
warning: (message: string, description?: string, options?: ExternalToast) => {
|
||||
sonnerToast.warning(message, { description, ...options })
|
||||
},
|
||||
info: (message: string, description?: string) => {
|
||||
sonnerToast.info(message, { description })
|
||||
info: (message: string, description?: string, options?: ExternalToast) => {
|
||||
sonnerToast.info(message, { description, ...options })
|
||||
},
|
||||
promise: <T,>(
|
||||
promise: Promise<T>,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import * as React from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { cn } from '@/shared/lib/utils'
|
||||
|
||||
interface DialogContextValue {
|
||||
|
|
@ -27,6 +28,20 @@ const Dialog = ({ open: controlledOpen, onOpenChange, children }: DialogProps) =
|
|||
const open = controlledOpen ?? uncontrolledOpen
|
||||
const handleOpenChange = onOpenChange ?? setUncontrolledOpen
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open || typeof document === 'undefined') {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const { body } = document
|
||||
const previousOverflow = body.style.overflow
|
||||
body.style.overflow = 'hidden'
|
||||
|
||||
return () => {
|
||||
body.style.overflow = previousOverflow
|
||||
}
|
||||
}, [open])
|
||||
|
||||
return (
|
||||
<DialogContext.Provider value={{ open, onOpenChange: handleOpenChange }}>
|
||||
{children}
|
||||
|
|
@ -65,7 +80,10 @@ DialogTrigger.displayName = 'DialogTrigger'
|
|||
const DialogPortal = ({ children }: { children: React.ReactNode }) => {
|
||||
const { open } = useDialog()
|
||||
if (!open) return null
|
||||
return <>{children}</>
|
||||
if (typeof document === 'undefined') {
|
||||
return null
|
||||
}
|
||||
return createPortal(children, document.body)
|
||||
}
|
||||
|
||||
const DialogOverlay = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
|
|
@ -92,39 +110,37 @@ const DialogContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTML
|
|||
return (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative z-50 grid w-full max-w-lg gap-4 border border-border/60 bg-card p-8 shadow-card rounded-2xl animate-fade-up',
|
||||
className
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
{...props}
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed left-1/2 top-1/2 z-50 grid max-h-[calc(100vh-2rem)] w-[min(calc(100vw-2rem),32rem)] -translate-x-1/2 -translate-y-1/2 gap-4 overflow-y-auto rounded-2xl border border-border/60 bg-card p-8 shadow-card animate-fade-up',
|
||||
className
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<button
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none"
|
||||
>
|
||||
{children}
|
||||
<button
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none"
|
||||
<span className="sr-only">Close</span>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="h-4 w-4"
|
||||
>
|
||||
<span className="sr-only">Close</span>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="h-4 w-4"
|
||||
>
|
||||
<path d="M18 6 6 18" />
|
||||
<path d="m6 6 12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<path d="M18 6 6 18" />
|
||||
<path d="m6 6 12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</DialogPortal>
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue