mirror of https://github.com/halo-dev/halo
feat: add support for user avatar upload (#4253)
#### What type of PR is this? /kind improvement /area console /area core #### What this PR does / why we need it: 此 PR 对用户头像上传的方式进行了重构,移除了原有的头像链接及上传至附件库的方案。允许具有用户管理权限的用户对其他用户的头像进行修改和移除。 Core: 新增了 `/apis/api.console.halo.run/v1alpha1/users/-/avatar` 的 `POST` 以及 `DELETE` 接口,用来上传用户的头像及删除当前用户的头像。 Console: 新增对用户头像进行裁剪的功能,并调用上传接口保存用户头像。 需等待 #4247 合并 #### Which issue(s) this PR fixes: Fixes #2688 See #4251 See #4247 #### Special notes for your reviewer: 1. 测试上传、删除头像接口是否能够正常执行。 2. 查看当前用户的头像是否能够设置成功。 3. 查看附件库中,当前用户的头像文件是否为 0 或 1 个。 #### Does this PR introduce a user-facing change? ```release-note 支持裁剪、上传和删除用户头像。 ```pull/4206/head^2
parent
fdfaa53614
commit
84093d8db0
|
@ -34,6 +34,11 @@ public class User extends AbstractExtension {
|
||||||
|
|
||||||
public static final String ROLE_NAMES_ANNO = "rbac.authorization.halo.run/role-names";
|
public static final String ROLE_NAMES_ANNO = "rbac.authorization.halo.run/role-names";
|
||||||
|
|
||||||
|
public static final String LAST_AVATAR_ATTACHMENT_NAME_ANNO =
|
||||||
|
"halo.run/last-avatar-attachment-name";
|
||||||
|
|
||||||
|
public static final String AVATAR_ATTACHMENT_NAME_ANNO = "halo.run/avatar-attachment-name";
|
||||||
|
|
||||||
public static final String HIDDEN_USER_LABEL = "halo.run/hidden-user";
|
public static final String HIDDEN_USER_LABEL = "halo.run/hidden-user";
|
||||||
|
|
||||||
@Schema(required = true)
|
@Schema(required = true)
|
||||||
|
|
|
@ -68,6 +68,7 @@ public class SystemSetting {
|
||||||
public static final String GROUP = "user";
|
public static final String GROUP = "user";
|
||||||
Boolean allowRegistration;
|
Boolean allowRegistration;
|
||||||
String defaultRole;
|
String defaultRole;
|
||||||
|
String avatarPolicy;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Data
|
@Data
|
||||||
|
|
|
@ -5,13 +5,17 @@ import static java.util.Comparator.comparing;
|
||||||
import static org.apache.commons.lang3.ObjectUtils.defaultIfNull;
|
import static org.apache.commons.lang3.ObjectUtils.defaultIfNull;
|
||||||
import static org.apache.commons.lang3.StringUtils.defaultIfBlank;
|
import static org.apache.commons.lang3.StringUtils.defaultIfBlank;
|
||||||
import static org.springdoc.core.fn.builders.apiresponse.Builder.responseBuilder;
|
import static org.springdoc.core.fn.builders.apiresponse.Builder.responseBuilder;
|
||||||
|
import static org.springdoc.core.fn.builders.content.Builder.contentBuilder;
|
||||||
import static org.springdoc.core.fn.builders.parameter.Builder.parameterBuilder;
|
import static org.springdoc.core.fn.builders.parameter.Builder.parameterBuilder;
|
||||||
import static org.springdoc.core.fn.builders.requestbody.Builder.requestBodyBuilder;
|
import static org.springdoc.core.fn.builders.requestbody.Builder.requestBodyBuilder;
|
||||||
|
import static org.springdoc.core.fn.builders.schema.Builder.schemaBuilder;
|
||||||
|
import static org.springframework.web.reactive.function.server.RequestPredicates.contentType;
|
||||||
import static run.halo.app.extension.ListResult.generateGenericClass;
|
import static run.halo.app.extension.ListResult.generateGenericClass;
|
||||||
import static run.halo.app.extension.router.QueryParamBuildUtil.buildParametersFromType;
|
import static run.halo.app.extension.router.QueryParamBuildUtil.buildParametersFromType;
|
||||||
import static run.halo.app.extension.router.selector.SelectorUtil.labelAndFieldSelectorToPredicate;
|
import static run.halo.app.extension.router.selector.SelectorUtil.labelAndFieldSelectorToPredicate;
|
||||||
|
|
||||||
import com.fasterxml.jackson.core.type.TypeReference;
|
import com.fasterxml.jackson.core.type.TypeReference;
|
||||||
|
import com.google.common.io.Files;
|
||||||
import io.swagger.v3.oas.annotations.enums.ParameterIn;
|
import io.swagger.v3.oas.annotations.enums.ParameterIn;
|
||||||
import io.swagger.v3.oas.annotations.media.ArraySchema;
|
import io.swagger.v3.oas.annotations.media.ArraySchema;
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
@ -25,22 +29,32 @@ import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
import java.util.function.Function;
|
import java.util.function.Function;
|
||||||
import java.util.function.Predicate;
|
import java.util.function.Predicate;
|
||||||
import java.util.regex.Matcher;
|
import java.util.regex.Matcher;
|
||||||
import java.util.regex.Pattern;
|
import java.util.regex.Pattern;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.apache.commons.lang3.StringUtils;
|
import org.apache.commons.lang3.StringUtils;
|
||||||
|
import org.springdoc.core.fn.builders.requestbody.Builder;
|
||||||
import org.springdoc.webflux.core.fn.SpringdocRouteBuilder;
|
import org.springdoc.webflux.core.fn.SpringdocRouteBuilder;
|
||||||
|
import org.springframework.core.io.buffer.DataBuffer;
|
||||||
import org.springframework.dao.OptimisticLockingFailureException;
|
import org.springframework.dao.OptimisticLockingFailureException;
|
||||||
import org.springframework.data.domain.Sort;
|
import org.springframework.data.domain.Sort;
|
||||||
import org.springframework.http.MediaType;
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.http.codec.multipart.FilePart;
|
||||||
|
import org.springframework.http.codec.multipart.Part;
|
||||||
import org.springframework.lang.NonNull;
|
import org.springframework.lang.NonNull;
|
||||||
import org.springframework.security.core.Authentication;
|
import org.springframework.security.core.Authentication;
|
||||||
import org.springframework.security.core.context.ReactiveSecurityContextHolder;
|
import org.springframework.security.core.context.ReactiveSecurityContextHolder;
|
||||||
import org.springframework.security.core.context.SecurityContext;
|
import org.springframework.security.core.context.SecurityContext;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
import org.springframework.util.Assert;
|
import org.springframework.util.Assert;
|
||||||
|
import org.springframework.util.CollectionUtils;
|
||||||
|
import org.springframework.util.MultiValueMap;
|
||||||
|
import org.springframework.util.unit.DataSize;
|
||||||
|
import org.springframework.web.reactive.function.BodyExtractors;
|
||||||
import org.springframework.web.reactive.function.server.RouterFunction;
|
import org.springframework.web.reactive.function.server.RouterFunction;
|
||||||
import org.springframework.web.reactive.function.server.ServerRequest;
|
import org.springframework.web.reactive.function.server.ServerRequest;
|
||||||
import org.springframework.web.reactive.function.server.ServerResponse;
|
import org.springframework.web.reactive.function.server.ServerResponse;
|
||||||
|
@ -51,6 +65,8 @@ import reactor.core.publisher.Mono;
|
||||||
import reactor.util.retry.Retry;
|
import reactor.util.retry.Retry;
|
||||||
import run.halo.app.core.extension.Role;
|
import run.halo.app.core.extension.Role;
|
||||||
import run.halo.app.core.extension.User;
|
import run.halo.app.core.extension.User;
|
||||||
|
import run.halo.app.core.extension.attachment.Attachment;
|
||||||
|
import run.halo.app.core.extension.service.AttachmentService;
|
||||||
import run.halo.app.core.extension.service.RoleService;
|
import run.halo.app.core.extension.service.RoleService;
|
||||||
import run.halo.app.core.extension.service.UserService;
|
import run.halo.app.core.extension.service.UserService;
|
||||||
import run.halo.app.extension.Comparators;
|
import run.halo.app.extension.Comparators;
|
||||||
|
@ -59,6 +75,8 @@ import run.halo.app.extension.Metadata;
|
||||||
import run.halo.app.extension.MetadataUtil;
|
import run.halo.app.extension.MetadataUtil;
|
||||||
import run.halo.app.extension.ReactiveExtensionClient;
|
import run.halo.app.extension.ReactiveExtensionClient;
|
||||||
import run.halo.app.extension.router.IListRequest;
|
import run.halo.app.extension.router.IListRequest;
|
||||||
|
import run.halo.app.infra.SystemConfigurableEnvironmentFetcher;
|
||||||
|
import run.halo.app.infra.SystemSetting;
|
||||||
import run.halo.app.infra.utils.JsonUtils;
|
import run.halo.app.infra.utils.JsonUtils;
|
||||||
|
|
||||||
@Component
|
@Component
|
||||||
|
@ -66,9 +84,14 @@ import run.halo.app.infra.utils.JsonUtils;
|
||||||
public class UserEndpoint implements CustomEndpoint {
|
public class UserEndpoint implements CustomEndpoint {
|
||||||
|
|
||||||
private static final String SELF_USER = "-";
|
private static final String SELF_USER = "-";
|
||||||
|
private static final String USER_AVATAR_GROUP_NAME = "user-avatar-group";
|
||||||
|
private static final String DEFAULT_USER_AVATAR_ATTACHMENT_POLICY_NAME = "default-policy";
|
||||||
|
private static final DataSize MAX_AVATAR_FILE_SIZE = DataSize.ofMegabytes(2L);
|
||||||
private final ReactiveExtensionClient client;
|
private final ReactiveExtensionClient client;
|
||||||
private final UserService userService;
|
private final UserService userService;
|
||||||
private final RoleService roleService;
|
private final RoleService roleService;
|
||||||
|
private final AttachmentService attachmentService;
|
||||||
|
private final SystemConfigurableEnvironmentFetcher environmentFetcher;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public RouterFunction<ServerResponse> endpoint() {
|
public RouterFunction<ServerResponse> endpoint() {
|
||||||
|
@ -145,9 +168,137 @@ public class UserEndpoint implements CustomEndpoint {
|
||||||
.implementation(generateGenericClass(ListedUser.class)));
|
.implementation(generateGenericClass(ListedUser.class)));
|
||||||
buildParametersFromType(builder, ListRequest.class);
|
buildParametersFromType(builder, ListRequest.class);
|
||||||
})
|
})
|
||||||
|
.POST("users/{name}/avatar", contentType(MediaType.MULTIPART_FORM_DATA),
|
||||||
|
this::uploadUserAvatar,
|
||||||
|
builder -> builder
|
||||||
|
.operationId("UploadUserAvatar")
|
||||||
|
.description("upload user avatar")
|
||||||
|
.tag(tag)
|
||||||
|
.parameter(parameterBuilder()
|
||||||
|
.in(ParameterIn.PATH)
|
||||||
|
.name("name")
|
||||||
|
.description("User name")
|
||||||
|
.required(true)
|
||||||
|
)
|
||||||
|
.requestBody(Builder.requestBodyBuilder()
|
||||||
|
.required(true)
|
||||||
|
.content(contentBuilder()
|
||||||
|
.mediaType(MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||||
|
.schema(schemaBuilder().implementation(IAvatarUploadRequest.class))
|
||||||
|
))
|
||||||
|
.response(responseBuilder().implementation(User.class))
|
||||||
|
)
|
||||||
|
.DELETE("users/{name}/avatar", this::deleteUserAvatar, builder -> builder
|
||||||
|
.tag(tag)
|
||||||
|
.operationId("DeleteUserAvatar")
|
||||||
|
.description("delete user avatar")
|
||||||
|
.parameter(parameterBuilder()
|
||||||
|
.in(ParameterIn.PATH)
|
||||||
|
.name("name")
|
||||||
|
.description("User name")
|
||||||
|
.required(true)
|
||||||
|
)
|
||||||
|
.response(responseBuilder().implementation(User.class))
|
||||||
|
.build())
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private Mono<ServerResponse> deleteUserAvatar(ServerRequest request) {
|
||||||
|
final var nameInPath = request.pathVariable("name");
|
||||||
|
return getUserOrSelf(nameInPath)
|
||||||
|
.flatMap(user -> {
|
||||||
|
MetadataUtil.nullSafeAnnotations(user)
|
||||||
|
.remove(User.AVATAR_ATTACHMENT_NAME_ANNO);
|
||||||
|
return client.update(user);
|
||||||
|
})
|
||||||
|
.flatMap(user -> ServerResponse.ok().bodyValue(user));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Mono<User> getUserOrSelf(String name) {
|
||||||
|
if (!SELF_USER.equals(name)) {
|
||||||
|
return client.get(User.class, name);
|
||||||
|
}
|
||||||
|
return ReactiveSecurityContextHolder.getContext()
|
||||||
|
.map(SecurityContext::getAuthentication)
|
||||||
|
.map(Authentication::getName)
|
||||||
|
.flatMap(currentUserName -> client.get(User.class, currentUserName));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Mono<ServerResponse> uploadUserAvatar(ServerRequest request) {
|
||||||
|
final var username = request.pathVariable("name");
|
||||||
|
return request.body(BodyExtractors.toMultipartData())
|
||||||
|
.map(AvatarUploadRequest::new)
|
||||||
|
.flatMap(this::uploadAvatar)
|
||||||
|
.flatMap(attachment -> getUserOrSelf(username)
|
||||||
|
.flatMap(user -> {
|
||||||
|
MetadataUtil.nullSafeAnnotations(user)
|
||||||
|
.put(User.AVATAR_ATTACHMENT_NAME_ANNO,
|
||||||
|
attachment.getMetadata().getName());
|
||||||
|
return client.update(user);
|
||||||
|
})
|
||||||
|
.retryWhen(Retry.backoff(5, Duration.ofMillis(100))
|
||||||
|
.filter(OptimisticLockingFailureException.class::isInstance))
|
||||||
|
)
|
||||||
|
.flatMap(user -> ServerResponse.ok().bodyValue(user));
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface IAvatarUploadRequest {
|
||||||
|
@Schema(requiredMode = REQUIRED, description = "Avatar file")
|
||||||
|
FilePart getFile();
|
||||||
|
}
|
||||||
|
|
||||||
|
public record AvatarUploadRequest(MultiValueMap<String, Part> formData) {
|
||||||
|
public FilePart getFile() {
|
||||||
|
Part file = formData.getFirst("file");
|
||||||
|
if (file == null) {
|
||||||
|
throw new ServerWebInputException("No file part found in the request");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(file instanceof FilePart filePart)) {
|
||||||
|
throw new ServerWebInputException("Invalid part of file");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!filePart.filename().endsWith(".png")) {
|
||||||
|
throw new ServerWebInputException("Only support avatar in PNG format");
|
||||||
|
}
|
||||||
|
return filePart;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Mono<Attachment> uploadAvatar(AvatarUploadRequest uploadRequest) {
|
||||||
|
return environmentFetcher.fetch(SystemSetting.User.GROUP, SystemSetting.User.class)
|
||||||
|
.switchIfEmpty(
|
||||||
|
Mono.error(new IllegalStateException("User setting is not configured"))
|
||||||
|
)
|
||||||
|
.flatMap(userSetting -> Mono.defer(
|
||||||
|
() -> {
|
||||||
|
String avatarPolicy = userSetting.getAvatarPolicy();
|
||||||
|
if (StringUtils.isBlank(avatarPolicy)) {
|
||||||
|
avatarPolicy = DEFAULT_USER_AVATAR_ATTACHMENT_POLICY_NAME;
|
||||||
|
}
|
||||||
|
FilePart filePart = uploadRequest.getFile();
|
||||||
|
var ext = Files.getFileExtension(filePart.filename());
|
||||||
|
return attachmentService.upload(avatarPolicy,
|
||||||
|
USER_AVATAR_GROUP_NAME,
|
||||||
|
UUID.randomUUID() + "." + ext,
|
||||||
|
maxSizeCheck(filePart.content()),
|
||||||
|
filePart.headers().getContentType()
|
||||||
|
);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Flux<DataBuffer> maxSizeCheck(Flux<DataBuffer> content) {
|
||||||
|
var lenRef = new AtomicInteger(0);
|
||||||
|
return content.doOnNext(dataBuffer -> {
|
||||||
|
int len = lenRef.accumulateAndGet(dataBuffer.readableByteCount(), Integer::sum);
|
||||||
|
if (len > MAX_AVATAR_FILE_SIZE.toBytes()) {
|
||||||
|
throw new ServerWebInputException("The avatar file needs to be smaller than "
|
||||||
|
+ MAX_AVATAR_FILE_SIZE.toMegabytes() + " MB.");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private Mono<ServerResponse> createUser(ServerRequest request) {
|
private Mono<ServerResponse> createUser(ServerRequest request) {
|
||||||
return request.bodyToMono(CreateUserRequest.class)
|
return request.bodyToMono(CreateUserRequest.class)
|
||||||
.doOnNext(createUserRequest -> {
|
.doOnNext(createUserRequest -> {
|
||||||
|
@ -234,10 +385,18 @@ public class UserEndpoint implements CustomEndpoint {
|
||||||
.switchIfEmpty(
|
.switchIfEmpty(
|
||||||
Mono.error(() -> new ServerWebInputException("Username didn't match.")))
|
Mono.error(() -> new ServerWebInputException("Username didn't match.")))
|
||||||
.map(user -> {
|
.map(user -> {
|
||||||
currentUser.getMetadata().setAnnotations(user.getMetadata().getAnnotations());
|
Map<String, String> oldAnnotations =
|
||||||
|
MetadataUtil.nullSafeAnnotations(currentUser);
|
||||||
|
Map<String, String> newAnnotations = user.getMetadata().getAnnotations();
|
||||||
|
if (!CollectionUtils.isEmpty(newAnnotations)) {
|
||||||
|
newAnnotations.put(User.LAST_AVATAR_ATTACHMENT_NAME_ANNO,
|
||||||
|
oldAnnotations.get(User.LAST_AVATAR_ATTACHMENT_NAME_ANNO));
|
||||||
|
newAnnotations.put(User.AVATAR_ATTACHMENT_NAME_ANNO,
|
||||||
|
oldAnnotations.get(User.AVATAR_ATTACHMENT_NAME_ANNO));
|
||||||
|
currentUser.getMetadata().setAnnotations(newAnnotations);
|
||||||
|
}
|
||||||
var spec = currentUser.getSpec();
|
var spec = currentUser.getSpec();
|
||||||
var newSpec = user.getSpec();
|
var newSpec = user.getSpec();
|
||||||
spec.setAvatar(newSpec.getAvatar());
|
|
||||||
spec.setBio(newSpec.getBio());
|
spec.setBio(newSpec.getBio());
|
||||||
spec.setDisplayName(newSpec.getDisplayName());
|
spec.setDisplayName(newSpec.getDisplayName());
|
||||||
spec.setTwoFactorAuthEnabled(newSpec.getTwoFactorAuthEnabled());
|
spec.setTwoFactorAuthEnabled(newSpec.getTwoFactorAuthEnabled());
|
||||||
|
|
|
@ -3,6 +3,7 @@ package run.halo.app.core.extension.reconciler;
|
||||||
import static run.halo.app.core.extension.User.GROUP;
|
import static run.halo.app.core.extension.User.GROUP;
|
||||||
import static run.halo.app.core.extension.User.KIND;
|
import static run.halo.app.core.extension.User.KIND;
|
||||||
|
|
||||||
|
import java.net.URI;
|
||||||
import java.util.HashSet;
|
import java.util.HashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
@ -16,6 +17,8 @@ import run.halo.app.core.extension.Role;
|
||||||
import run.halo.app.core.extension.RoleBinding;
|
import run.halo.app.core.extension.RoleBinding;
|
||||||
import run.halo.app.core.extension.User;
|
import run.halo.app.core.extension.User;
|
||||||
import run.halo.app.core.extension.UserConnection;
|
import run.halo.app.core.extension.UserConnection;
|
||||||
|
import run.halo.app.core.extension.attachment.Attachment;
|
||||||
|
import run.halo.app.core.extension.service.AttachmentService;
|
||||||
import run.halo.app.core.extension.service.RoleService;
|
import run.halo.app.core.extension.service.RoleService;
|
||||||
import run.halo.app.extension.ExtensionClient;
|
import run.halo.app.extension.ExtensionClient;
|
||||||
import run.halo.app.extension.GroupKind;
|
import run.halo.app.extension.GroupKind;
|
||||||
|
@ -37,6 +40,7 @@ public class UserReconciler implements Reconciler<Request> {
|
||||||
private final ExtensionClient client;
|
private final ExtensionClient client;
|
||||||
private final ExternalUrlSupplier externalUrlSupplier;
|
private final ExternalUrlSupplier externalUrlSupplier;
|
||||||
private final RoleService roleService;
|
private final RoleService roleService;
|
||||||
|
private final AttachmentService attachmentService;
|
||||||
private final RetryTemplate retryTemplate = RetryTemplate.builder()
|
private final RetryTemplate retryTemplate = RetryTemplate.builder()
|
||||||
.maxAttempts(20)
|
.maxAttempts(20)
|
||||||
.fixedBackoff(300)
|
.fixedBackoff(300)
|
||||||
|
@ -54,10 +58,48 @@ public class UserReconciler implements Reconciler<Request> {
|
||||||
addFinalizerIfNecessary(user);
|
addFinalizerIfNecessary(user);
|
||||||
ensureRoleNamesAnno(request.name());
|
ensureRoleNamesAnno(request.name());
|
||||||
updatePermalink(request.name());
|
updatePermalink(request.name());
|
||||||
|
handleAvatar(request.name());
|
||||||
});
|
});
|
||||||
return new Result(false, null);
|
return new Result(false, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void handleAvatar(String name) {
|
||||||
|
client.fetch(User.class, name).ifPresent(user -> {
|
||||||
|
Map<String, String> annotations = MetadataUtil.nullSafeAnnotations(user);
|
||||||
|
|
||||||
|
String avatarAttachmentName = annotations.get(User.AVATAR_ATTACHMENT_NAME_ANNO);
|
||||||
|
String oldAvatarAttachmentName = annotations.get(User.LAST_AVATAR_ATTACHMENT_NAME_ANNO);
|
||||||
|
|
||||||
|
if (StringUtils.isNotBlank(oldAvatarAttachmentName)
|
||||||
|
&& !StringUtils.equals(oldAvatarAttachmentName, avatarAttachmentName)) {
|
||||||
|
client.fetch(Attachment.class, oldAvatarAttachmentName)
|
||||||
|
.ifPresent(client::delete);
|
||||||
|
annotations.remove(User.LAST_AVATAR_ATTACHMENT_NAME_ANNO);
|
||||||
|
oldAvatarAttachmentName = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (StringUtils.isBlank(oldAvatarAttachmentName)
|
||||||
|
&& StringUtils.isNotBlank(avatarAttachmentName)) {
|
||||||
|
oldAvatarAttachmentName = avatarAttachmentName;
|
||||||
|
annotations.put(User.LAST_AVATAR_ATTACHMENT_NAME_ANNO, oldAvatarAttachmentName);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (StringUtils.isNotBlank(avatarAttachmentName)) {
|
||||||
|
client.fetch(Attachment.class, avatarAttachmentName)
|
||||||
|
.ifPresent(attachment -> {
|
||||||
|
URI avatarUri = attachmentService.getPermalink(attachment).block();
|
||||||
|
if (avatarUri == null) {
|
||||||
|
throw new IllegalStateException("User avatar attachment not found.");
|
||||||
|
}
|
||||||
|
user.getSpec().setAvatar(avatarUri.toString());
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
user.getSpec().setAvatar(null);
|
||||||
|
}
|
||||||
|
client.update(user);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private void ensureRoleNamesAnno(String name) {
|
private void ensureRoleNamesAnno(String name) {
|
||||||
client.fetch(User.class, name).ifPresent(user -> {
|
client.fetch(User.class, name).ifPresent(user -> {
|
||||||
Map<String, String> annotations = MetadataUtil.nullSafeAnnotations(user);
|
Map<String, String> annotations = MetadataUtil.nullSafeAnnotations(user);
|
||||||
|
|
|
@ -35,3 +35,14 @@ spec:
|
||||||
name: location
|
name: location
|
||||||
label: 存储位置
|
label: 存储位置
|
||||||
help: ~/.halo2/attachments/upload 下的子目录
|
help: ~/.halo2/attachments/upload 下的子目录
|
||||||
|
---
|
||||||
|
apiVersion: storage.halo.run/v1alpha1
|
||||||
|
kind: Group
|
||||||
|
metadata:
|
||||||
|
name: user-avatar-group
|
||||||
|
labels:
|
||||||
|
halo.run/hidden: "true"
|
||||||
|
finalizers:
|
||||||
|
- system-protection
|
||||||
|
spec:
|
||||||
|
displayName: UserAvatar
|
|
@ -35,6 +35,10 @@ rules:
|
||||||
resources: [ "users" ]
|
resources: [ "users" ]
|
||||||
resourceNames: [ "-" ]
|
resourceNames: [ "-" ]
|
||||||
verbs: [ "get", "update" ]
|
verbs: [ "get", "update" ]
|
||||||
|
- apiGroups: [ "api.console.halo.run" ]
|
||||||
|
resources: [ "users/avatar" ]
|
||||||
|
resourceNames: [ "-" ]
|
||||||
|
verbs: [ "create", "delete" ]
|
||||||
---
|
---
|
||||||
apiVersion: v1alpha1
|
apiVersion: v1alpha1
|
||||||
kind: "Role"
|
kind: "Role"
|
||||||
|
|
|
@ -16,7 +16,7 @@ rules:
|
||||||
resources: [ "users" ]
|
resources: [ "users" ]
|
||||||
verbs: [ "create", "patch", "update", "delete", "deletecollection" ]
|
verbs: [ "create", "patch", "update", "delete", "deletecollection" ]
|
||||||
- apiGroups: [ "api.console.halo.run" ]
|
- apiGroups: [ "api.console.halo.run" ]
|
||||||
resources: [ "users", "users/permissions", "users/password" ]
|
resources: [ "users", "users/permissions", "users/password", "users/avatar" ]
|
||||||
verbs: [ "*" ]
|
verbs: [ "*" ]
|
||||||
---
|
---
|
||||||
apiVersion: v1alpha1
|
apiVersion: v1alpha1
|
||||||
|
|
|
@ -78,6 +78,10 @@ spec:
|
||||||
- $formkit: roleSelect
|
- $formkit: roleSelect
|
||||||
name: defaultRole
|
name: defaultRole
|
||||||
label: "默认角色"
|
label: "默认角色"
|
||||||
|
- $formkit: attachmentPolicySelect
|
||||||
|
name: avatarPolicy
|
||||||
|
label: "头像存储位置"
|
||||||
|
value: "default-policy"
|
||||||
- group: comment
|
- group: comment
|
||||||
label: 评论设置
|
label: 评论设置
|
||||||
formSchema:
|
formSchema:
|
||||||
|
|
|
@ -7,6 +7,7 @@ import static org.mockito.ArgumentMatchers.anyString;
|
||||||
import static org.mockito.ArgumentMatchers.argThat;
|
import static org.mockito.ArgumentMatchers.argThat;
|
||||||
import static org.mockito.ArgumentMatchers.eq;
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
import static org.mockito.ArgumentMatchers.same;
|
import static org.mockito.ArgumentMatchers.same;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
import static org.mockito.Mockito.never;
|
import static org.mockito.Mockito.never;
|
||||||
import static org.mockito.Mockito.times;
|
import static org.mockito.Mockito.times;
|
||||||
import static org.mockito.Mockito.verify;
|
import static org.mockito.Mockito.verify;
|
||||||
|
@ -17,6 +18,7 @@ import static run.halo.app.extension.GroupVersionKind.fromExtension;
|
||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
|
@ -30,19 +32,25 @@ import org.mockito.Mock;
|
||||||
import org.springframework.boot.test.autoconfigure.web.reactive.AutoConfigureWebTestClient;
|
import org.springframework.boot.test.autoconfigure.web.reactive.AutoConfigureWebTestClient;
|
||||||
import org.springframework.boot.test.context.SpringBootTest;
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
import org.springframework.http.MediaType;
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.http.client.MultipartBodyBuilder;
|
||||||
import org.springframework.security.test.context.support.WithMockUser;
|
import org.springframework.security.test.context.support.WithMockUser;
|
||||||
import org.springframework.test.web.reactive.server.WebTestClient;
|
import org.springframework.test.web.reactive.server.WebTestClient;
|
||||||
|
import org.springframework.web.reactive.function.BodyInserters;
|
||||||
import reactor.core.publisher.Flux;
|
import reactor.core.publisher.Flux;
|
||||||
import reactor.core.publisher.Mono;
|
import reactor.core.publisher.Mono;
|
||||||
import run.halo.app.core.extension.Role;
|
import run.halo.app.core.extension.Role;
|
||||||
import run.halo.app.core.extension.RoleBinding;
|
import run.halo.app.core.extension.RoleBinding;
|
||||||
import run.halo.app.core.extension.User;
|
import run.halo.app.core.extension.User;
|
||||||
|
import run.halo.app.core.extension.attachment.Attachment;
|
||||||
|
import run.halo.app.core.extension.service.AttachmentService;
|
||||||
import run.halo.app.core.extension.service.RoleService;
|
import run.halo.app.core.extension.service.RoleService;
|
||||||
import run.halo.app.core.extension.service.UserService;
|
import run.halo.app.core.extension.service.UserService;
|
||||||
import run.halo.app.extension.ListResult;
|
import run.halo.app.extension.ListResult;
|
||||||
import run.halo.app.extension.Metadata;
|
import run.halo.app.extension.Metadata;
|
||||||
import run.halo.app.extension.ReactiveExtensionClient;
|
import run.halo.app.extension.ReactiveExtensionClient;
|
||||||
import run.halo.app.extension.exception.ExtensionNotFoundException;
|
import run.halo.app.extension.exception.ExtensionNotFoundException;
|
||||||
|
import run.halo.app.infra.SystemConfigurableEnvironmentFetcher;
|
||||||
|
import run.halo.app.infra.SystemSetting;
|
||||||
import run.halo.app.infra.utils.JsonUtils;
|
import run.halo.app.infra.utils.JsonUtils;
|
||||||
|
|
||||||
@SpringBootTest
|
@SpringBootTest
|
||||||
|
@ -55,6 +63,12 @@ class UserEndpointTest {
|
||||||
@Mock
|
@Mock
|
||||||
RoleService roleService;
|
RoleService roleService;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
AttachmentService attachmentService;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
SystemConfigurableEnvironmentFetcher environmentFetcher;
|
||||||
|
|
||||||
@Mock
|
@Mock
|
||||||
ReactiveExtensionClient client;
|
ReactiveExtensionClient client;
|
||||||
|
|
||||||
|
@ -512,4 +526,104 @@ class UserEndpointTest {
|
||||||
.exchange()
|
.exchange()
|
||||||
.expectStatus().isOk();
|
.expectStatus().isOk();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Nested
|
||||||
|
class AvatarUploadTest {
|
||||||
|
@Test
|
||||||
|
void respondWithErrorIfTypeNotPNG() {
|
||||||
|
|
||||||
|
var multipartBodyBuilder = new MultipartBodyBuilder();
|
||||||
|
multipartBodyBuilder.part("file", "fake-file")
|
||||||
|
.contentType(MediaType.IMAGE_JPEG)
|
||||||
|
.filename("fake-filename.jpg");
|
||||||
|
|
||||||
|
SystemSetting.User user = mock(SystemSetting.User.class);
|
||||||
|
when(environmentFetcher.fetch(SystemSetting.User.GROUP, SystemSetting.User.class))
|
||||||
|
.thenReturn(Mono.just(user));
|
||||||
|
|
||||||
|
webClient
|
||||||
|
.post()
|
||||||
|
.uri("/users/-/avatar")
|
||||||
|
.contentType(MediaType.MULTIPART_FORM_DATA)
|
||||||
|
.body(BodyInserters.fromMultipartData(multipartBodyBuilder.build()))
|
||||||
|
.exchange()
|
||||||
|
.expectStatus()
|
||||||
|
.is4xxClientError();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldUploadSuccessfully() {
|
||||||
|
var currentUser = createUser("fake-user");
|
||||||
|
|
||||||
|
Attachment attachment = new Attachment();
|
||||||
|
Metadata metadata = new Metadata();
|
||||||
|
metadata.setName("fake-attachment");
|
||||||
|
attachment.setMetadata(metadata);
|
||||||
|
|
||||||
|
var multipartBodyBuilder = new MultipartBodyBuilder();
|
||||||
|
multipartBodyBuilder.part("file", "fake-file")
|
||||||
|
.contentType(MediaType.IMAGE_PNG)
|
||||||
|
.filename("fake-filename.png");
|
||||||
|
|
||||||
|
SystemSetting.User user = mock(SystemSetting.User.class);
|
||||||
|
when(environmentFetcher.fetch(SystemSetting.User.GROUP, SystemSetting.User.class))
|
||||||
|
.thenReturn(Mono.just(user));
|
||||||
|
when(client.get(User.class, "fake-user")).thenReturn(Mono.just(currentUser));
|
||||||
|
when(attachmentService.upload(anyString(), anyString(), anyString(),
|
||||||
|
any(), any(MediaType.IMAGE_PNG.getClass()))).thenReturn(Mono.just(attachment));
|
||||||
|
|
||||||
|
when(client.update(currentUser)).thenReturn(Mono.just(currentUser));
|
||||||
|
|
||||||
|
webClient.post()
|
||||||
|
.uri("/users/-/avatar")
|
||||||
|
.contentType(MediaType.MULTIPART_FORM_DATA)
|
||||||
|
.body(BodyInserters.fromMultipartData(multipartBodyBuilder.build()))
|
||||||
|
.exchange()
|
||||||
|
.expectStatus()
|
||||||
|
.isOk()
|
||||||
|
.expectBody()
|
||||||
|
.json("""
|
||||||
|
{
|
||||||
|
"spec":{
|
||||||
|
"displayName":"Faker",
|
||||||
|
"avatar":"fake-avatar.png",
|
||||||
|
"email":"hi@halo.run",
|
||||||
|
"password":"fake-password",
|
||||||
|
"bio":"Fake bio"
|
||||||
|
},
|
||||||
|
"status":null,
|
||||||
|
"apiVersion":"v1alpha1",
|
||||||
|
"kind":"User",
|
||||||
|
"metadata":{
|
||||||
|
"name":"fake-user",
|
||||||
|
"annotations":{
|
||||||
|
"halo.run/avatar-attachment-name":
|
||||||
|
"fake-attachment"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""");
|
||||||
|
|
||||||
|
verify(client).get(User.class, "fake-user");
|
||||||
|
verify(client).update(currentUser);
|
||||||
|
}
|
||||||
|
|
||||||
|
User createUser(String name) {
|
||||||
|
var spec = new User.UserSpec();
|
||||||
|
spec.setEmail("hi@halo.run");
|
||||||
|
spec.setBio("Fake bio");
|
||||||
|
spec.setDisplayName("Faker");
|
||||||
|
spec.setAvatar("fake-avatar.png");
|
||||||
|
spec.setPassword("fake-password");
|
||||||
|
|
||||||
|
var metadata = new Metadata();
|
||||||
|
metadata.setName(name);
|
||||||
|
metadata.setAnnotations(new HashMap<>());
|
||||||
|
|
||||||
|
var user = new User();
|
||||||
|
user.setSpec(spec);
|
||||||
|
user.setMetadata(metadata);
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -64,10 +64,10 @@ class UserReconcilerTest {
|
||||||
when(client.fetch(eq(User.class), eq("fake-user")))
|
when(client.fetch(eq(User.class), eq("fake-user")))
|
||||||
.thenReturn(Optional.of(user("fake-user")));
|
.thenReturn(Optional.of(user("fake-user")));
|
||||||
userReconciler.reconcile(new Reconciler.Request("fake-user"));
|
userReconciler.reconcile(new Reconciler.Request("fake-user"));
|
||||||
verify(client, times(2)).update(any(User.class));
|
verify(client, times(3)).update(any(User.class));
|
||||||
|
|
||||||
ArgumentCaptor<User> captor = ArgumentCaptor.forClass(User.class);
|
ArgumentCaptor<User> captor = ArgumentCaptor.forClass(User.class);
|
||||||
verify(client, times(2)).update(captor.capture());
|
verify(client, times(3)).update(captor.capture());
|
||||||
assertThat(captor.getValue().getStatus().getPermalink())
|
assertThat(captor.getValue().getStatus().getPermalink())
|
||||||
.isEqualTo("http://localhost:8090/authors/fake-user");
|
.isEqualTo("http://localhost:8090/authors/fake-user");
|
||||||
}
|
}
|
||||||
|
@ -77,7 +77,7 @@ class UserReconcilerTest {
|
||||||
when(client.fetch(eq(User.class), eq(AnonymousUserConst.PRINCIPAL)))
|
when(client.fetch(eq(User.class), eq(AnonymousUserConst.PRINCIPAL)))
|
||||||
.thenReturn(Optional.of(user(AnonymousUserConst.PRINCIPAL)));
|
.thenReturn(Optional.of(user(AnonymousUserConst.PRINCIPAL)));
|
||||||
userReconciler.reconcile(new Reconciler.Request(AnonymousUserConst.PRINCIPAL));
|
userReconciler.reconcile(new Reconciler.Request(AnonymousUserConst.PRINCIPAL));
|
||||||
verify(client, times(1)).update(any(User.class));
|
verify(client, times(2)).update(any(User.class));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
@ -102,7 +102,7 @@ class UserReconcilerTest {
|
||||||
|
|
||||||
userReconciler.reconcile(new Reconciler.Request("fake-user"));
|
userReconciler.reconcile(new Reconciler.Request("fake-user"));
|
||||||
ArgumentCaptor<User> captor = ArgumentCaptor.forClass(User.class);
|
ArgumentCaptor<User> captor = ArgumentCaptor.forClass(User.class);
|
||||||
verify(client, times(2)).update(captor.capture());
|
verify(client, times(3)).update(captor.capture());
|
||||||
User user = captor.getAllValues().get(1);
|
User user = captor.getAllValues().get(1);
|
||||||
assertThat(user.getMetadata().getAnnotations().get(User.ROLE_NAMES_ANNO))
|
assertThat(user.getMetadata().getAnnotations().get(User.ROLE_NAMES_ANNO))
|
||||||
.isEqualTo("[\"fake-role\"]");
|
.isEqualTo("[\"fake-role\"]");
|
||||||
|
@ -113,6 +113,7 @@ class UserReconcilerTest {
|
||||||
user.setMetadata(new Metadata());
|
user.setMetadata(new Metadata());
|
||||||
user.getMetadata().setName(name);
|
user.getMetadata().setName(name);
|
||||||
user.getMetadata().setFinalizers(Set.of("user-protection"));
|
user.getMetadata().setFinalizers(Set.of("user-protection"));
|
||||||
|
user.setSpec(new User.UserSpec());
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -74,6 +74,7 @@
|
||||||
"axios": "^0.27.2",
|
"axios": "^0.27.2",
|
||||||
"codemirror": "^6.0.1",
|
"codemirror": "^6.0.1",
|
||||||
"colorjs.io": "^0.4.3",
|
"colorjs.io": "^0.4.3",
|
||||||
|
"cropperjs": "^1.5.13",
|
||||||
"dayjs": "^1.11.7",
|
"dayjs": "^1.11.7",
|
||||||
"emoji-mart": "^5.3.3",
|
"emoji-mart": "^5.3.3",
|
||||||
"fastq": "^1.15.0",
|
"fastq": "^1.15.0",
|
||||||
|
|
|
@ -185,6 +185,60 @@ export const ApiConsoleHaloRunV1alpha1UserApiAxiosParamCreator = function (
|
||||||
options: localVarRequestOptions,
|
options: localVarRequestOptions,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
/**
|
||||||
|
* delete user avatar
|
||||||
|
* @param {string} name User name
|
||||||
|
* @param {*} [options] Override http request option.
|
||||||
|
* @throws {RequiredError}
|
||||||
|
*/
|
||||||
|
deleteUserAvatar: async (
|
||||||
|
name: string,
|
||||||
|
options: AxiosRequestConfig = {}
|
||||||
|
): Promise<RequestArgs> => {
|
||||||
|
// verify required parameter 'name' is not null or undefined
|
||||||
|
assertParamExists("deleteUserAvatar", "name", name);
|
||||||
|
const localVarPath =
|
||||||
|
`/apis/api.console.halo.run/v1alpha1/users/{name}/avatar`.replace(
|
||||||
|
`{${"name"}}`,
|
||||||
|
encodeURIComponent(String(name))
|
||||||
|
);
|
||||||
|
// use dummy base URL string because the URL constructor only accepts absolute URLs.
|
||||||
|
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
|
||||||
|
let baseOptions;
|
||||||
|
if (configuration) {
|
||||||
|
baseOptions = configuration.baseOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
const localVarRequestOptions = {
|
||||||
|
method: "DELETE",
|
||||||
|
...baseOptions,
|
||||||
|
...options,
|
||||||
|
};
|
||||||
|
const localVarHeaderParameter = {} as any;
|
||||||
|
const localVarQueryParameter = {} as any;
|
||||||
|
|
||||||
|
// authentication BasicAuth required
|
||||||
|
// http basic authentication required
|
||||||
|
setBasicAuthToObject(localVarRequestOptions, configuration);
|
||||||
|
|
||||||
|
// authentication BearerAuth required
|
||||||
|
// http bearer authentication required
|
||||||
|
await setBearerAuthToObject(localVarHeaderParameter, configuration);
|
||||||
|
|
||||||
|
setSearchParams(localVarUrlObj, localVarQueryParameter);
|
||||||
|
let headersFromBaseOptions =
|
||||||
|
baseOptions && baseOptions.headers ? baseOptions.headers : {};
|
||||||
|
localVarRequestOptions.headers = {
|
||||||
|
...localVarHeaderParameter,
|
||||||
|
...headersFromBaseOptions,
|
||||||
|
...options.headers,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
url: toPathString(localVarUrlObj),
|
||||||
|
options: localVarRequestOptions,
|
||||||
|
};
|
||||||
|
},
|
||||||
/**
|
/**
|
||||||
* Get current user detail
|
* Get current user detail
|
||||||
* @param {*} [options] Override http request option.
|
* @param {*} [options] Override http request option.
|
||||||
|
@ -544,6 +598,74 @@ export const ApiConsoleHaloRunV1alpha1UserApiAxiosParamCreator = function (
|
||||||
configuration
|
configuration
|
||||||
);
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
url: toPathString(localVarUrlObj),
|
||||||
|
options: localVarRequestOptions,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* upload user avatar
|
||||||
|
* @param {string} name User name
|
||||||
|
* @param {File} file
|
||||||
|
* @param {*} [options] Override http request option.
|
||||||
|
* @throws {RequiredError}
|
||||||
|
*/
|
||||||
|
uploadUserAvatar: async (
|
||||||
|
name: string,
|
||||||
|
file: File,
|
||||||
|
options: AxiosRequestConfig = {}
|
||||||
|
): Promise<RequestArgs> => {
|
||||||
|
// verify required parameter 'name' is not null or undefined
|
||||||
|
assertParamExists("uploadUserAvatar", "name", name);
|
||||||
|
// verify required parameter 'file' is not null or undefined
|
||||||
|
assertParamExists("uploadUserAvatar", "file", file);
|
||||||
|
const localVarPath =
|
||||||
|
`/apis/api.console.halo.run/v1alpha1/users/{name}/avatar`.replace(
|
||||||
|
`{${"name"}}`,
|
||||||
|
encodeURIComponent(String(name))
|
||||||
|
);
|
||||||
|
// use dummy base URL string because the URL constructor only accepts absolute URLs.
|
||||||
|
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
|
||||||
|
let baseOptions;
|
||||||
|
if (configuration) {
|
||||||
|
baseOptions = configuration.baseOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
const localVarRequestOptions = {
|
||||||
|
method: "POST",
|
||||||
|
...baseOptions,
|
||||||
|
...options,
|
||||||
|
};
|
||||||
|
const localVarHeaderParameter = {} as any;
|
||||||
|
const localVarQueryParameter = {} as any;
|
||||||
|
const localVarFormParams = new ((configuration &&
|
||||||
|
configuration.formDataCtor) ||
|
||||||
|
FormData)();
|
||||||
|
|
||||||
|
// authentication BasicAuth required
|
||||||
|
// http basic authentication required
|
||||||
|
setBasicAuthToObject(localVarRequestOptions, configuration);
|
||||||
|
|
||||||
|
// authentication BearerAuth required
|
||||||
|
// http bearer authentication required
|
||||||
|
await setBearerAuthToObject(localVarHeaderParameter, configuration);
|
||||||
|
|
||||||
|
if (file !== undefined) {
|
||||||
|
localVarFormParams.append("file", file as any);
|
||||||
|
}
|
||||||
|
|
||||||
|
localVarHeaderParameter["Content-Type"] = "multipart/form-data";
|
||||||
|
|
||||||
|
setSearchParams(localVarUrlObj, localVarQueryParameter);
|
||||||
|
let headersFromBaseOptions =
|
||||||
|
baseOptions && baseOptions.headers ? baseOptions.headers : {};
|
||||||
|
localVarRequestOptions.headers = {
|
||||||
|
...localVarHeaderParameter,
|
||||||
|
...headersFromBaseOptions,
|
||||||
|
...options.headers,
|
||||||
|
};
|
||||||
|
localVarRequestOptions.data = localVarFormParams;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
url: toPathString(localVarUrlObj),
|
url: toPathString(localVarUrlObj),
|
||||||
options: localVarRequestOptions,
|
options: localVarRequestOptions,
|
||||||
|
@ -611,6 +733,27 @@ export const ApiConsoleHaloRunV1alpha1UserApiFp = function (
|
||||||
configuration
|
configuration
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
/**
|
||||||
|
* delete user avatar
|
||||||
|
* @param {string} name User name
|
||||||
|
* @param {*} [options] Override http request option.
|
||||||
|
* @throws {RequiredError}
|
||||||
|
*/
|
||||||
|
async deleteUserAvatar(
|
||||||
|
name: string,
|
||||||
|
options?: AxiosRequestConfig
|
||||||
|
): Promise<
|
||||||
|
(axios?: AxiosInstance, basePath?: string) => AxiosPromise<User>
|
||||||
|
> {
|
||||||
|
const localVarAxiosArgs =
|
||||||
|
await localVarAxiosParamCreator.deleteUserAvatar(name, options);
|
||||||
|
return createRequestFunction(
|
||||||
|
localVarAxiosArgs,
|
||||||
|
globalAxios,
|
||||||
|
BASE_PATH,
|
||||||
|
configuration
|
||||||
|
);
|
||||||
|
},
|
||||||
/**
|
/**
|
||||||
* Get current user detail
|
* Get current user detail
|
||||||
* @param {*} [options] Override http request option.
|
* @param {*} [options] Override http request option.
|
||||||
|
@ -767,6 +910,29 @@ export const ApiConsoleHaloRunV1alpha1UserApiFp = function (
|
||||||
configuration
|
configuration
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
/**
|
||||||
|
* upload user avatar
|
||||||
|
* @param {string} name User name
|
||||||
|
* @param {File} file
|
||||||
|
* @param {*} [options] Override http request option.
|
||||||
|
* @throws {RequiredError}
|
||||||
|
*/
|
||||||
|
async uploadUserAvatar(
|
||||||
|
name: string,
|
||||||
|
file: File,
|
||||||
|
options?: AxiosRequestConfig
|
||||||
|
): Promise<
|
||||||
|
(axios?: AxiosInstance, basePath?: string) => AxiosPromise<User>
|
||||||
|
> {
|
||||||
|
const localVarAxiosArgs =
|
||||||
|
await localVarAxiosParamCreator.uploadUserAvatar(name, file, options);
|
||||||
|
return createRequestFunction(
|
||||||
|
localVarAxiosArgs,
|
||||||
|
globalAxios,
|
||||||
|
BASE_PATH,
|
||||||
|
configuration
|
||||||
|
);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -813,6 +979,20 @@ export const ApiConsoleHaloRunV1alpha1UserApiFactory = function (
|
||||||
.createUser(requestParameters.createUserRequest, options)
|
.createUser(requestParameters.createUserRequest, options)
|
||||||
.then((request) => request(axios, basePath));
|
.then((request) => request(axios, basePath));
|
||||||
},
|
},
|
||||||
|
/**
|
||||||
|
* delete user avatar
|
||||||
|
* @param {ApiConsoleHaloRunV1alpha1UserApiDeleteUserAvatarRequest} requestParameters Request parameters.
|
||||||
|
* @param {*} [options] Override http request option.
|
||||||
|
* @throws {RequiredError}
|
||||||
|
*/
|
||||||
|
deleteUserAvatar(
|
||||||
|
requestParameters: ApiConsoleHaloRunV1alpha1UserApiDeleteUserAvatarRequest,
|
||||||
|
options?: AxiosRequestConfig
|
||||||
|
): AxiosPromise<User> {
|
||||||
|
return localVarFp
|
||||||
|
.deleteUserAvatar(requestParameters.name, options)
|
||||||
|
.then((request) => request(axios, basePath));
|
||||||
|
},
|
||||||
/**
|
/**
|
||||||
* Get current user detail
|
* Get current user detail
|
||||||
* @param {*} [options] Override http request option.
|
* @param {*} [options] Override http request option.
|
||||||
|
@ -908,6 +1088,24 @@ export const ApiConsoleHaloRunV1alpha1UserApiFactory = function (
|
||||||
.updateCurrentUser(requestParameters.user, options)
|
.updateCurrentUser(requestParameters.user, options)
|
||||||
.then((request) => request(axios, basePath));
|
.then((request) => request(axios, basePath));
|
||||||
},
|
},
|
||||||
|
/**
|
||||||
|
* upload user avatar
|
||||||
|
* @param {ApiConsoleHaloRunV1alpha1UserApiUploadUserAvatarRequest} requestParameters Request parameters.
|
||||||
|
* @param {*} [options] Override http request option.
|
||||||
|
* @throws {RequiredError}
|
||||||
|
*/
|
||||||
|
uploadUserAvatar(
|
||||||
|
requestParameters: ApiConsoleHaloRunV1alpha1UserApiUploadUserAvatarRequest,
|
||||||
|
options?: AxiosRequestConfig
|
||||||
|
): AxiosPromise<User> {
|
||||||
|
return localVarFp
|
||||||
|
.uploadUserAvatar(
|
||||||
|
requestParameters.name,
|
||||||
|
requestParameters.file,
|
||||||
|
options
|
||||||
|
)
|
||||||
|
.then((request) => request(axios, basePath));
|
||||||
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -946,6 +1144,20 @@ export interface ApiConsoleHaloRunV1alpha1UserApiCreateUserRequest {
|
||||||
readonly createUserRequest: CreateUserRequest;
|
readonly createUserRequest: CreateUserRequest;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request parameters for deleteUserAvatar operation in ApiConsoleHaloRunV1alpha1UserApi.
|
||||||
|
* @export
|
||||||
|
* @interface ApiConsoleHaloRunV1alpha1UserApiDeleteUserAvatarRequest
|
||||||
|
*/
|
||||||
|
export interface ApiConsoleHaloRunV1alpha1UserApiDeleteUserAvatarRequest {
|
||||||
|
/**
|
||||||
|
* User name
|
||||||
|
* @type {string}
|
||||||
|
* @memberof ApiConsoleHaloRunV1alpha1UserApiDeleteUserAvatar
|
||||||
|
*/
|
||||||
|
readonly name: string;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Request parameters for getPermissions operation in ApiConsoleHaloRunV1alpha1UserApi.
|
* Request parameters for getPermissions operation in ApiConsoleHaloRunV1alpha1UserApi.
|
||||||
* @export
|
* @export
|
||||||
|
@ -1065,6 +1277,27 @@ export interface ApiConsoleHaloRunV1alpha1UserApiUpdateCurrentUserRequest {
|
||||||
readonly user: User;
|
readonly user: User;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request parameters for uploadUserAvatar operation in ApiConsoleHaloRunV1alpha1UserApi.
|
||||||
|
* @export
|
||||||
|
* @interface ApiConsoleHaloRunV1alpha1UserApiUploadUserAvatarRequest
|
||||||
|
*/
|
||||||
|
export interface ApiConsoleHaloRunV1alpha1UserApiUploadUserAvatarRequest {
|
||||||
|
/**
|
||||||
|
* User name
|
||||||
|
* @type {string}
|
||||||
|
* @memberof ApiConsoleHaloRunV1alpha1UserApiUploadUserAvatar
|
||||||
|
*/
|
||||||
|
readonly name: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @type {File}
|
||||||
|
* @memberof ApiConsoleHaloRunV1alpha1UserApiUploadUserAvatar
|
||||||
|
*/
|
||||||
|
readonly file: File;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ApiConsoleHaloRunV1alpha1UserApi - object-oriented interface
|
* ApiConsoleHaloRunV1alpha1UserApi - object-oriented interface
|
||||||
* @export
|
* @export
|
||||||
|
@ -1108,6 +1341,22 @@ export class ApiConsoleHaloRunV1alpha1UserApi extends BaseAPI {
|
||||||
.then((request) => request(this.axios, this.basePath));
|
.then((request) => request(this.axios, this.basePath));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* delete user avatar
|
||||||
|
* @param {ApiConsoleHaloRunV1alpha1UserApiDeleteUserAvatarRequest} requestParameters Request parameters.
|
||||||
|
* @param {*} [options] Override http request option.
|
||||||
|
* @throws {RequiredError}
|
||||||
|
* @memberof ApiConsoleHaloRunV1alpha1UserApi
|
||||||
|
*/
|
||||||
|
public deleteUserAvatar(
|
||||||
|
requestParameters: ApiConsoleHaloRunV1alpha1UserApiDeleteUserAvatarRequest,
|
||||||
|
options?: AxiosRequestConfig
|
||||||
|
) {
|
||||||
|
return ApiConsoleHaloRunV1alpha1UserApiFp(this.configuration)
|
||||||
|
.deleteUserAvatar(requestParameters.name, options)
|
||||||
|
.then((request) => request(this.axios, this.basePath));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get current user detail
|
* Get current user detail
|
||||||
* @param {*} [options] Override http request option.
|
* @param {*} [options] Override http request option.
|
||||||
|
@ -1212,4 +1461,20 @@ export class ApiConsoleHaloRunV1alpha1UserApi extends BaseAPI {
|
||||||
.updateCurrentUser(requestParameters.user, options)
|
.updateCurrentUser(requestParameters.user, options)
|
||||||
.then((request) => request(this.axios, this.basePath));
|
.then((request) => request(this.axios, this.basePath));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* upload user avatar
|
||||||
|
* @param {ApiConsoleHaloRunV1alpha1UserApiUploadUserAvatarRequest} requestParameters Request parameters.
|
||||||
|
* @param {*} [options] Override http request option.
|
||||||
|
* @throws {RequiredError}
|
||||||
|
* @memberof ApiConsoleHaloRunV1alpha1UserApi
|
||||||
|
*/
|
||||||
|
public uploadUserAvatar(
|
||||||
|
requestParameters: ApiConsoleHaloRunV1alpha1UserApiUploadUserAvatarRequest,
|
||||||
|
options?: AxiosRequestConfig
|
||||||
|
) {
|
||||||
|
return ApiConsoleHaloRunV1alpha1UserApiFp(this.configuration)
|
||||||
|
.uploadUserAvatar(requestParameters.name, requestParameters.file, options)
|
||||||
|
.then((request) => request(this.axios, this.basePath));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -59,6 +59,12 @@ import IconArrowDownCircleLine from "~icons/ri/arrow-down-circle-line";
|
||||||
import IconTerminalBoxLine from "~icons/ri/terminal-box-line";
|
import IconTerminalBoxLine from "~icons/ri/terminal-box-line";
|
||||||
import IconClipboardLine from "~icons/ri/clipboard-line";
|
import IconClipboardLine from "~icons/ri/clipboard-line";
|
||||||
import IconLockPasswordLine from "~icons/ri/lock-password-line";
|
import IconLockPasswordLine from "~icons/ri/lock-password-line";
|
||||||
|
import IconRiPencilFill from "~icons/ri/pencil-fill";
|
||||||
|
import IconZoomInLine from "~icons/ri/zoom-in-line";
|
||||||
|
import IconZoomOutLine from "~icons/ri/zoom-out-line";
|
||||||
|
import IconArrowLeftRightLine from "~icons/ri/arrow-left-right-line";
|
||||||
|
import IconArrowUpDownLine from "~icons/ri/arrow-up-down-line";
|
||||||
|
import IconRiUpload2Fill from "~icons/ri/upload-2-fill";
|
||||||
|
|
||||||
export {
|
export {
|
||||||
IconDashboard,
|
IconDashboard,
|
||||||
|
@ -122,4 +128,10 @@ export {
|
||||||
IconTerminalBoxLine,
|
IconTerminalBoxLine,
|
||||||
IconClipboardLine,
|
IconClipboardLine,
|
||||||
IconLockPasswordLine,
|
IconLockPasswordLine,
|
||||||
|
IconRiPencilFill,
|
||||||
|
IconZoomInLine,
|
||||||
|
IconZoomOutLine,
|
||||||
|
IconArrowLeftRightLine,
|
||||||
|
IconArrowUpDownLine,
|
||||||
|
IconRiUpload2Fill,
|
||||||
};
|
};
|
||||||
|
|
|
@ -128,6 +128,9 @@ importers:
|
||||||
colorjs.io:
|
colorjs.io:
|
||||||
specifier: ^0.4.3
|
specifier: ^0.4.3
|
||||||
version: 0.4.3
|
version: 0.4.3
|
||||||
|
cropperjs:
|
||||||
|
specifier: ^1.5.13
|
||||||
|
version: 1.5.13
|
||||||
dayjs:
|
dayjs:
|
||||||
specifier: ^1.11.7
|
specifier: ^1.11.7
|
||||||
version: 1.11.7
|
version: 1.11.7
|
||||||
|
@ -5550,6 +5553,10 @@ packages:
|
||||||
/crelt@1.0.5:
|
/crelt@1.0.5:
|
||||||
resolution: {integrity: sha512-+BO9wPPi+DWTDcNYhr/W90myha8ptzftZT+LwcmUbbok0rcP/fequmFYCw8NMoH7pkAZQzU78b3kYrlua5a9eA==}
|
resolution: {integrity: sha512-+BO9wPPi+DWTDcNYhr/W90myha8ptzftZT+LwcmUbbok0rcP/fequmFYCw8NMoH7pkAZQzU78b3kYrlua5a9eA==}
|
||||||
|
|
||||||
|
/cropperjs@1.5.13:
|
||||||
|
resolution: {integrity: sha512-by7jKAo73y5/Do0K6sxdTKHgndY0NMjG2bEdgeJxycbcmHuCiMXqw8sxy5C5Y5WTOTcDGmbT7Sr5CgKOXR06OA==}
|
||||||
|
dev: false
|
||||||
|
|
||||||
/cross-spawn@5.1.0:
|
/cross-spawn@5.1.0:
|
||||||
resolution: {integrity: sha512-pTgQJ5KC0d2hcY8eyL1IzlBPYjTkyH72XRZPnLyKus2mBfNjQs3klqbJU2VILqZryAZUt9JOb3h/mWMy23/f5A==}
|
resolution: {integrity: sha512-pTgQJ5KC0d2hcY8eyL1IzlBPYjTkyH72XRZPnLyKus2mBfNjQs3klqbJU2VILqZryAZUt9JOb3h/mWMy23/f5A==}
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|
|
@ -9,6 +9,8 @@ export enum rbacAnnotations {
|
||||||
ROLE_NAMES = "rbac.authorization.halo.run/role-names",
|
ROLE_NAMES = "rbac.authorization.halo.run/role-names",
|
||||||
DISPLAY_NAME = "rbac.authorization.halo.run/display-name",
|
DISPLAY_NAME = "rbac.authorization.halo.run/display-name",
|
||||||
DEPENDENCIES = "rbac.authorization.halo.run/dependencies",
|
DEPENDENCIES = "rbac.authorization.halo.run/dependencies",
|
||||||
|
AVATAR_ATTACHMENT_NAME = "halo.run/avatar-attachment-name",
|
||||||
|
LAST_AVATAR_ATTACHMENT_NAME = "halo.run/last-avatar-attachment-name",
|
||||||
}
|
}
|
||||||
|
|
||||||
// content
|
// content
|
||||||
|
|
|
@ -874,6 +874,14 @@ core:
|
||||||
bio: Bio
|
bio: Bio
|
||||||
creation_time: Creation time
|
creation_time: Creation time
|
||||||
identity_authentication: Identity authentication
|
identity_authentication: Identity authentication
|
||||||
|
avatar:
|
||||||
|
title: Avatar
|
||||||
|
toast_upload_failed: Failed to upload avatar
|
||||||
|
toast_remove_failed: Failed to delete avatar
|
||||||
|
cropper_modal:
|
||||||
|
title: Crop Avatar
|
||||||
|
remove:
|
||||||
|
title: Are you sure you want to delete the avatar?
|
||||||
role:
|
role:
|
||||||
title: Roles
|
title: Roles
|
||||||
common:
|
common:
|
||||||
|
|
|
@ -874,6 +874,14 @@ core:
|
||||||
bio: 描述
|
bio: 描述
|
||||||
creation_time: 注册时间
|
creation_time: 注册时间
|
||||||
identity_authentication: 登录方式
|
identity_authentication: 登录方式
|
||||||
|
avatar:
|
||||||
|
title: 头像
|
||||||
|
toast_upload_failed: 上传头像失败
|
||||||
|
toast_remove_failed: 删除头像失败
|
||||||
|
cropper_modal:
|
||||||
|
title: 裁剪头像
|
||||||
|
remove:
|
||||||
|
title: 确定要删除头像吗?
|
||||||
role:
|
role:
|
||||||
title: 角色
|
title: 角色
|
||||||
common:
|
common:
|
||||||
|
|
|
@ -874,6 +874,14 @@ core:
|
||||||
bio: 描述
|
bio: 描述
|
||||||
creation_time: 註冊時間
|
creation_time: 註冊時間
|
||||||
identity_authentication: 登入方式
|
identity_authentication: 登入方式
|
||||||
|
avatar:
|
||||||
|
title: 頭像
|
||||||
|
toast_upload_failed: 上傳頭像失敗
|
||||||
|
toast_remove_failed: 刪除頭像失敗
|
||||||
|
cropper_modal:
|
||||||
|
title: 裁剪頭像
|
||||||
|
remove:
|
||||||
|
title: 確定要刪除頭像嗎?
|
||||||
role:
|
role:
|
||||||
title: 角色
|
title: 角色
|
||||||
common:
|
common:
|
||||||
|
|
|
@ -0,0 +1,259 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
IconZoomInLine,
|
||||||
|
IconZoomOutLine,
|
||||||
|
IconArrowLeftRightLine,
|
||||||
|
IconArrowUpDownLine,
|
||||||
|
IconRefreshLine,
|
||||||
|
IconRiUpload2Fill,
|
||||||
|
} from "@halo-dev/components";
|
||||||
|
import { type Ref } from "vue";
|
||||||
|
import { ref, watch } from "vue";
|
||||||
|
import "cropperjs/dist/cropper.css";
|
||||||
|
import Cropper from "cropperjs";
|
||||||
|
import { onMounted } from "vue";
|
||||||
|
import { computed } from "vue";
|
||||||
|
import type { Component } from "vue";
|
||||||
|
import { markRaw } from "vue";
|
||||||
|
|
||||||
|
export type ToolbarName =
|
||||||
|
| "upload"
|
||||||
|
| "zoomIn"
|
||||||
|
| "zoomOut"
|
||||||
|
| "flipHorizontal"
|
||||||
|
| "flipVertical"
|
||||||
|
| "reset";
|
||||||
|
|
||||||
|
export interface ToolbarItem {
|
||||||
|
name: ToolbarName;
|
||||||
|
icon: Component;
|
||||||
|
onClick: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
file: File;
|
||||||
|
preview?: boolean;
|
||||||
|
cropperWidth?: number;
|
||||||
|
cropperHeight?: number;
|
||||||
|
cropperImageType?: "png" | "jpeg" | "webp" | "jpg";
|
||||||
|
toolbars?: boolean | ToolbarName[];
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
preview: true,
|
||||||
|
cropperWidth: 200,
|
||||||
|
cropperHeight: 200,
|
||||||
|
cropperImageType: "png",
|
||||||
|
toolbars: true,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(event: "changeFile"): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const cropper = ref<Cropper>();
|
||||||
|
const defaultToolbars: ToolbarItem[] = [
|
||||||
|
{
|
||||||
|
name: "upload",
|
||||||
|
icon: markRaw(IconRiUpload2Fill),
|
||||||
|
onClick: () => {
|
||||||
|
emit("changeFile");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "zoomIn",
|
||||||
|
icon: markRaw(IconZoomInLine),
|
||||||
|
onClick: () => {
|
||||||
|
cropper.value?.zoom(0.1);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "zoomOut",
|
||||||
|
icon: markRaw(IconZoomOutLine),
|
||||||
|
onClick: () => {
|
||||||
|
cropper.value?.zoom(-0.1);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "flipHorizontal",
|
||||||
|
icon: markRaw(IconArrowLeftRightLine),
|
||||||
|
onClick: () => {
|
||||||
|
cropper.value?.scaleX(-cropper.value?.getData().scaleX || -1);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "flipVertical",
|
||||||
|
icon: markRaw(IconArrowUpDownLine),
|
||||||
|
onClick: () => {
|
||||||
|
cropper.value?.scaleY(-cropper.value?.getData().scaleY || -1);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "reset",
|
||||||
|
icon: markRaw(IconRefreshLine),
|
||||||
|
onClick: () => {
|
||||||
|
cropper.value?.reset();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const previewElement = ref<HTMLElement>();
|
||||||
|
const imageElement = ref<HTMLImageElement>() as Ref<HTMLImageElement>;
|
||||||
|
const toolbarItems = computed(() => {
|
||||||
|
if (props.toolbars === true) {
|
||||||
|
return defaultToolbars;
|
||||||
|
}
|
||||||
|
if (Array.isArray(props.toolbars) && props.toolbars.length > 0) {
|
||||||
|
return defaultToolbars.filter((item) =>
|
||||||
|
(props.toolbars as string[]).includes(item.name)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
cropper.value = new Cropper(imageElement.value, {
|
||||||
|
aspectRatio: props.cropperHeight / props.cropperWidth,
|
||||||
|
viewMode: 1,
|
||||||
|
dragMode: "move",
|
||||||
|
checkCrossOrigin: false,
|
||||||
|
cropBoxResizable: false,
|
||||||
|
center: false,
|
||||||
|
minCropBoxWidth: props.cropperWidth,
|
||||||
|
minCropBoxHeight: props.cropperHeight,
|
||||||
|
preview: previewElement.value?.querySelectorAll(".preview"),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const imageUrl = ref<string>("");
|
||||||
|
|
||||||
|
const renderImages = (file: File) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = function () {
|
||||||
|
imageUrl.value = reader.result as string;
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCropperFile = (): Promise<File> => {
|
||||||
|
return new Promise<File>((resolve, reject) => {
|
||||||
|
if (!cropper.value) {
|
||||||
|
reject();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
cropper.value
|
||||||
|
.getCroppedCanvas({
|
||||||
|
width: props.cropperWidth,
|
||||||
|
height: props.cropperHeight,
|
||||||
|
})
|
||||||
|
.toBlob((blob) => {
|
||||||
|
if (blob === null) {
|
||||||
|
reject();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const fileName = props.file.name.replace(
|
||||||
|
/\.[^/.]+$/,
|
||||||
|
`.${props.cropperImageType}`
|
||||||
|
);
|
||||||
|
const file = new File([blob], fileName, {
|
||||||
|
type: `image/${props.cropperImageType}`,
|
||||||
|
});
|
||||||
|
resolve(file);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.file,
|
||||||
|
(file) => {
|
||||||
|
if (file) {
|
||||||
|
if (file.type.indexOf("image") === -1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
renderImages(file);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
immediate: true,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => imageUrl.value,
|
||||||
|
(imageUrl) => {
|
||||||
|
if (imageUrl) {
|
||||||
|
cropper.value?.replace(imageUrl);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
immediate: true,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
getCropperFile,
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-col-reverse sm:flex-row">
|
||||||
|
<div
|
||||||
|
class="relative max-h-[500px] flex-auto overflow-hidden rounded-md sm:mr-4"
|
||||||
|
:style="{ minHeight: `${cropperHeight}px` }"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
ref="imageElement"
|
||||||
|
alt="Uploaded Image"
|
||||||
|
class="block h-full w-full object-cover"
|
||||||
|
/>
|
||||||
|
<div class="absolute bottom-3 left-1/2 -translate-x-1/2">
|
||||||
|
<ul class="flex rounded-md bg-gray-100 p-0.5 opacity-75">
|
||||||
|
<li
|
||||||
|
v-for="toolbar in toolbarItems"
|
||||||
|
:key="toolbar.name"
|
||||||
|
:title="toolbar.name"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="p-2 hover:rounded-md hover:bg-gray-200"
|
||||||
|
@click="toolbar.onClick"
|
||||||
|
>
|
||||||
|
<component :is="toolbar.icon" />
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
ref="previewElement"
|
||||||
|
class="mb-4 flex justify-around sm:mb-0 sm:inline-block sm:justify-start"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="preview overflow-hidden rounded-md border border-gray-300 shadow-md sm:mb-4"
|
||||||
|
:style="{ width: `${cropperWidth}px`, height: `${cropperHeight}px` }"
|
||||||
|
></div>
|
||||||
|
<div class="flex flex-col justify-between sm:flex-row">
|
||||||
|
<div
|
||||||
|
class="preview overflow-hidden rounded-full border border-gray-300"
|
||||||
|
:style="{
|
||||||
|
width: `${cropperWidth * 0.5}px`,
|
||||||
|
height: `${cropperWidth * 0.5}px`,
|
||||||
|
}"
|
||||||
|
></div>
|
||||||
|
<div
|
||||||
|
class="preview overflow-hidden rounded-full border border-gray-300"
|
||||||
|
:style="{
|
||||||
|
width: `${cropperWidth * 0.3}px`,
|
||||||
|
height: `${cropperWidth * 0.3}px`,
|
||||||
|
}"
|
||||||
|
></div>
|
||||||
|
<div
|
||||||
|
class="preview overflow-hidden rounded-full border border-gray-300"
|
||||||
|
:style="{
|
||||||
|
width: `${cropperWidth * 0.2}px`,
|
||||||
|
height: `${cropperWidth * 0.2}px`,
|
||||||
|
}"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
|
@ -149,13 +149,6 @@ const handleCreateUser = async () => {
|
||||||
name="phone"
|
name="phone"
|
||||||
validation="length:0,20"
|
validation="length:0,20"
|
||||||
></FormKit>
|
></FormKit>
|
||||||
<FormKit
|
|
||||||
v-model="formState.avatar"
|
|
||||||
:label="$t('core.user.editing_modal.fields.avatar.label')"
|
|
||||||
type="attachment"
|
|
||||||
name="avatar"
|
|
||||||
validation="length:0,1024"
|
|
||||||
></FormKit>
|
|
||||||
<FormKit
|
<FormKit
|
||||||
v-model="formState.password"
|
v-model="formState.password"
|
||||||
:label="$t('core.user.change_password_modal.fields.new_password.label')"
|
:label="$t('core.user.change_password_modal.fields.new_password.label')"
|
||||||
|
|
|
@ -42,7 +42,6 @@ const emit = defineEmits<{
|
||||||
const initialFormState: User = {
|
const initialFormState: User = {
|
||||||
spec: {
|
spec: {
|
||||||
displayName: "",
|
displayName: "",
|
||||||
avatar: "",
|
|
||||||
email: "",
|
email: "",
|
||||||
phone: "",
|
phone: "",
|
||||||
password: "",
|
password: "",
|
||||||
|
@ -182,14 +181,6 @@ const handleUpdateUser = async () => {
|
||||||
name="phone"
|
name="phone"
|
||||||
validation="length:0,20"
|
validation="length:0,20"
|
||||||
></FormKit>
|
></FormKit>
|
||||||
<FormKit
|
|
||||||
v-model="formState.spec.avatar"
|
|
||||||
:label="$t('core.user.editing_modal.fields.avatar.label')"
|
|
||||||
type="attachment"
|
|
||||||
name="avatar"
|
|
||||||
:accepts="['image/*']"
|
|
||||||
validation="length:0,1024"
|
|
||||||
></FormKit>
|
|
||||||
<FormKit
|
<FormKit
|
||||||
v-model="formState.spec.bio"
|
v-model="formState.spec.bio"
|
||||||
:label="$t('core.user.editing_modal.fields.bio.label')"
|
:label="$t('core.user.editing_modal.fields.bio.label')"
|
||||||
|
|
|
@ -2,11 +2,17 @@
|
||||||
import BasicLayout from "@/layouts/BasicLayout.vue";
|
import BasicLayout from "@/layouts/BasicLayout.vue";
|
||||||
import { apiClient } from "@/utils/api-client";
|
import { apiClient } from "@/utils/api-client";
|
||||||
import {
|
import {
|
||||||
|
IconRiPencilFill,
|
||||||
VButton,
|
VButton,
|
||||||
VTabbar,
|
VTabbar,
|
||||||
VAvatar,
|
VAvatar,
|
||||||
VDropdown,
|
VDropdown,
|
||||||
VDropdownItem,
|
VDropdownItem,
|
||||||
|
VModal,
|
||||||
|
VSpace,
|
||||||
|
Toast,
|
||||||
|
VLoading,
|
||||||
|
Dialog,
|
||||||
} from "@halo-dev/components";
|
} from "@halo-dev/components";
|
||||||
import {
|
import {
|
||||||
computed,
|
computed,
|
||||||
|
@ -14,6 +20,7 @@ import {
|
||||||
provide,
|
provide,
|
||||||
ref,
|
ref,
|
||||||
watch,
|
watch,
|
||||||
|
defineAsyncComponent,
|
||||||
type ComputedRef,
|
type ComputedRef,
|
||||||
type Ref,
|
type Ref,
|
||||||
} from "vue";
|
} from "vue";
|
||||||
|
@ -25,7 +32,22 @@ import { usePermission } from "@/utils/permission";
|
||||||
import { useUserStore } from "@/stores/user";
|
import { useUserStore } from "@/stores/user";
|
||||||
import { useQuery } from "@tanstack/vue-query";
|
import { useQuery } from "@tanstack/vue-query";
|
||||||
import { useI18n } from "vue-i18n";
|
import { useI18n } from "vue-i18n";
|
||||||
|
import { useFileDialog } from "@vueuse/core";
|
||||||
|
import { rbacAnnotations } from "@/constants/annotations";
|
||||||
|
|
||||||
|
const UserAvatarCropper = defineAsyncComponent(
|
||||||
|
() => import("../components/UserAvatarCropper.vue")
|
||||||
|
);
|
||||||
|
|
||||||
|
interface IUserAvatarCropperType
|
||||||
|
extends Ref<InstanceType<typeof UserAvatarCropper>> {
|
||||||
|
getCropperFile(): Promise<File>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { open, reset, onChange } = useFileDialog({
|
||||||
|
accept: ".jpg, .jpeg, .png",
|
||||||
|
multiple: false,
|
||||||
|
});
|
||||||
const { currentUserHasPermission } = usePermission();
|
const { currentUserHasPermission } = usePermission();
|
||||||
const userStore = useUserStore();
|
const userStore = useUserStore();
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
@ -50,6 +72,7 @@ const { params } = useRoute();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: user,
|
data: user,
|
||||||
|
isFetching,
|
||||||
isLoading,
|
isLoading,
|
||||||
refetch,
|
refetch,
|
||||||
} = useQuery({
|
} = useQuery({
|
||||||
|
@ -65,6 +88,13 @@ const {
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
refetchInterval: (data) => {
|
||||||
|
const annotations = data?.user.metadata.annotations;
|
||||||
|
return annotations?.[rbacAnnotations.AVATAR_ATTACHMENT_NAME] !==
|
||||||
|
annotations?.[rbacAnnotations.LAST_AVATAR_ATTACHMENT_NAME]
|
||||||
|
? 1000
|
||||||
|
: false;
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const isCurrentUser = computed(() => {
|
const isCurrentUser = computed(() => {
|
||||||
|
@ -104,6 +134,74 @@ const handleTabChange = (id: string) => {
|
||||||
router.push({ name: tab.routeName });
|
router.push({ name: tab.routeName });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const userAvatarCropper = ref<IUserAvatarCropperType>();
|
||||||
|
const showAvatarEditor = ref(false);
|
||||||
|
const visibleCropperModal = ref(false);
|
||||||
|
const originalFile = ref<File>() as Ref<File>;
|
||||||
|
onChange((files) => {
|
||||||
|
if (!files) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (files.length > 0) {
|
||||||
|
originalFile.value = files[0];
|
||||||
|
visibleCropperModal.value = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const uploadSaving = ref(false);
|
||||||
|
const handleUploadAvatar = () => {
|
||||||
|
userAvatarCropper.value?.getCropperFile().then((file) => {
|
||||||
|
uploadSaving.value = true;
|
||||||
|
apiClient.user
|
||||||
|
.uploadUserAvatar({
|
||||||
|
name: params.name as string,
|
||||||
|
file: file,
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
refetch();
|
||||||
|
handleCloseCropperModal();
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
Toast.error(t("core.user.detail.avatar.toast_upload_failed"));
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
uploadSaving.value = false;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveCurrentAvatar = () => {
|
||||||
|
Dialog.warning({
|
||||||
|
title: t("core.user.detail.avatar.remove.title"),
|
||||||
|
description: t("core.common.dialog.descriptions.cannot_be_recovered"),
|
||||||
|
confirmType: "danger",
|
||||||
|
confirmText: t("core.common.buttons.confirm"),
|
||||||
|
cancelText: t("core.common.buttons.cancel"),
|
||||||
|
onConfirm: async () => {
|
||||||
|
apiClient.user
|
||||||
|
.deleteUserAvatar({
|
||||||
|
name: params.name as string,
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
refetch();
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
Toast.error(t("core.user.detail.avatar.toast_remove_failed"));
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseCropperModal = () => {
|
||||||
|
visibleCropperModal.value = false;
|
||||||
|
reset();
|
||||||
|
};
|
||||||
|
|
||||||
|
const changeUploadAvatar = () => {
|
||||||
|
reset();
|
||||||
|
open();
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<BasicLayout>
|
<BasicLayout>
|
||||||
|
@ -119,16 +217,47 @@ const handleTabChange = (id: string) => {
|
||||||
<div class="p-4">
|
<div class="p-4">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div class="flex flex-row items-center gap-5">
|
<div class="flex flex-row items-center gap-5">
|
||||||
<div class="h-20 w-20">
|
<div class="group relative h-20 w-20">
|
||||||
<VAvatar
|
<VLoading v-if="isFetching" class="h-full w-full" />
|
||||||
v-if="user"
|
<div
|
||||||
:src="user.user.spec.avatar"
|
v-else
|
||||||
:alt="user.user.spec.displayName"
|
class="h-full w-full"
|
||||||
circle
|
@mouseover="showAvatarEditor = true"
|
||||||
width="100%"
|
@mouseout="showAvatarEditor = false"
|
||||||
height="100%"
|
>
|
||||||
class="ring-4 ring-white drop-shadow-md"
|
<VAvatar
|
||||||
/>
|
v-if="user"
|
||||||
|
:src="user.user.spec.avatar"
|
||||||
|
:alt="user.user.spec.displayName"
|
||||||
|
circle
|
||||||
|
width="100%"
|
||||||
|
height="100%"
|
||||||
|
class="ring-4 ring-white drop-shadow-md"
|
||||||
|
/>
|
||||||
|
<VDropdown
|
||||||
|
v-if="
|
||||||
|
currentUserHasPermission(['system:users:manage']) ||
|
||||||
|
isCurrentUser
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-show="showAvatarEditor"
|
||||||
|
class="absolute left-0 right-0 top-0 h-full w-full cursor-pointer rounded-full border-0 bg-black/60 text-center leading-[5rem] transition-opacity duration-300 group-hover:opacity-100"
|
||||||
|
>
|
||||||
|
<IconRiPencilFill
|
||||||
|
class="inline-block w-full self-center text-2xl text-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<template #popper>
|
||||||
|
<VDropdownItem @click="open()">
|
||||||
|
{{ $t("core.common.buttons.upload") }}
|
||||||
|
</VDropdownItem>
|
||||||
|
<VDropdownItem @click="handleRemoveCurrentAvatar">
|
||||||
|
{{ $t("core.common.buttons.delete") }}
|
||||||
|
</VDropdownItem>
|
||||||
|
</template>
|
||||||
|
</VDropdown>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="block">
|
<div class="block">
|
||||||
<h1 class="truncate text-lg font-bold text-gray-900">
|
<h1 class="truncate text-lg font-bold text-gray-900">
|
||||||
|
@ -173,5 +302,32 @@ const handleTabChange = (id: string) => {
|
||||||
<RouterView></RouterView>
|
<RouterView></RouterView>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
<VModal
|
||||||
|
:visible="visibleCropperModal"
|
||||||
|
:width="1200"
|
||||||
|
:title="$t('core.user.detail.avatar.cropper_modal.title')"
|
||||||
|
@update:visible="handleCloseCropperModal"
|
||||||
|
>
|
||||||
|
<UserAvatarCropper
|
||||||
|
ref="userAvatarCropper"
|
||||||
|
:file="originalFile"
|
||||||
|
@change-file="changeUploadAvatar"
|
||||||
|
/>
|
||||||
|
<template #footer>
|
||||||
|
<VSpace>
|
||||||
|
<VButton
|
||||||
|
v-if="visibleCropperModal"
|
||||||
|
:loading="uploadSaving"
|
||||||
|
type="secondary"
|
||||||
|
@click="handleUploadAvatar"
|
||||||
|
>
|
||||||
|
{{ $t("core.common.buttons.submit") }}
|
||||||
|
</VButton>
|
||||||
|
<VButton @click="handleCloseCropperModal">
|
||||||
|
{{ $t("core.common.buttons.cancel") }}
|
||||||
|
</VButton>
|
||||||
|
</VSpace>
|
||||||
|
</template>
|
||||||
|
</VModal>
|
||||||
</BasicLayout>
|
</BasicLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|
Loading…
Reference in New Issue