Compare commits

...

2 Commits

Author SHA1 Message Date
yoyuzh
f59515f5dd feat(admin): add blob share and task admin apis 2026-04-11 14:09:31 +08:00
yoyuzh
12005cc606 feat(front): 覆盖 front 并完善登录快传入口与中文文案 2026-04-10 01:09:06 +08:00
227 changed files with 6978 additions and 23922 deletions

View File

@@ -5,7 +5,11 @@ import com.yoyuzh.auth.CustomUserDetailsService;
import com.yoyuzh.auth.User;
import com.yoyuzh.common.ApiResponse;
import com.yoyuzh.common.PageResponse;
import com.yoyuzh.files.core.FileEntityType;
import com.yoyuzh.files.tasks.BackgroundTask;
import com.yoyuzh.files.tasks.BackgroundTaskFailureCategory;
import com.yoyuzh.files.tasks.BackgroundTaskStatus;
import com.yoyuzh.files.tasks.BackgroundTaskType;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
@@ -61,6 +65,49 @@ public class AdminController {
return ApiResponse.success(adminService.listFiles(page, size, query, ownerQuery));
}
@GetMapping("/file-blobs")
public ApiResponse<PageResponse<AdminFileBlobResponse>> fileBlobs(@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "10") int size,
@RequestParam(defaultValue = "") String userQuery,
@RequestParam(required = false) Long storagePolicyId,
@RequestParam(defaultValue = "") String objectKey,
@RequestParam(required = false) FileEntityType entityType) {
return ApiResponse.success(adminService.listFileBlobs(page, size, userQuery, storagePolicyId, objectKey, entityType));
}
@GetMapping("/shares")
public ApiResponse<PageResponse<AdminShareResponse>> shares(@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "10") int size,
@RequestParam(defaultValue = "") String userQuery,
@RequestParam(defaultValue = "") String fileName,
@RequestParam(defaultValue = "") String token,
@RequestParam(required = false) Boolean passwordProtected,
@RequestParam(required = false) Boolean expired) {
return ApiResponse.success(adminService.listShares(page, size, userQuery, fileName, token, passwordProtected, expired));
}
@DeleteMapping("/shares/{shareId}")
public ApiResponse<Void> deleteShare(@PathVariable Long shareId) {
adminService.deleteShare(shareId);
return ApiResponse.success();
}
@GetMapping("/tasks")
public ApiResponse<PageResponse<AdminTaskResponse>> tasks(@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "10") int size,
@RequestParam(defaultValue = "") String userQuery,
@RequestParam(required = false) BackgroundTaskType type,
@RequestParam(required = false) BackgroundTaskStatus status,
@RequestParam(required = false) BackgroundTaskFailureCategory failureCategory,
@RequestParam(required = false) AdminTaskLeaseState leaseState) {
return ApiResponse.success(adminService.listTasks(page, size, userQuery, type, status, failureCategory, leaseState));
}
@GetMapping("/tasks/{taskId}")
public ApiResponse<AdminTaskResponse> task(@PathVariable Long taskId) {
return ApiResponse.success(adminService.getTask(taskId));
}
@GetMapping("/storage-policies")
public ApiResponse<List<AdminStoragePolicyResponse>> storagePolicies() {
return ApiResponse.success(adminService.listStoragePolicies());

View File

@@ -0,0 +1,28 @@
package com.yoyuzh.admin;
import com.yoyuzh.files.core.FileEntityType;
import java.time.LocalDateTime;
public record AdminFileBlobResponse(
Long entityId,
Long blobId,
String objectKey,
FileEntityType entityType,
Long storagePolicyId,
Long size,
String contentType,
Integer referenceCount,
long linkedStoredFileCount,
long linkedOwnerCount,
String sampleOwnerUsername,
String sampleOwnerEmail,
Long createdByUserId,
String createdByUsername,
LocalDateTime createdAt,
LocalDateTime blobCreatedAt,
boolean blobMissing,
boolean orphanRisk,
boolean referenceMismatch
) {
}

View File

@@ -1,29 +1,42 @@
package com.yoyuzh.admin;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.yoyuzh.auth.AuthTokenInvalidationService;
import com.yoyuzh.auth.PasswordPolicy;
import com.yoyuzh.auth.RefreshTokenService;
import com.yoyuzh.auth.RegistrationInviteService;
import com.yoyuzh.auth.User;
import com.yoyuzh.auth.UserRole;
import com.yoyuzh.auth.UserRepository;
import com.yoyuzh.auth.RefreshTokenService;
import com.yoyuzh.auth.UserRole;
import com.yoyuzh.common.BusinessException;
import com.yoyuzh.common.ErrorCode;
import com.yoyuzh.common.PageResponse;
import com.yoyuzh.files.core.FileBlobRepository;
import com.yoyuzh.config.RedisCacheNames;
import com.yoyuzh.files.core.FileEntity;
import com.yoyuzh.files.core.FileEntityRepository;
import com.yoyuzh.files.core.FileEntityType;
import com.yoyuzh.files.core.FileService;
import com.yoyuzh.files.core.StoredFile;
import com.yoyuzh.files.core.StoredFileEntityRepository;
import com.yoyuzh.files.core.StoredFileRepository;
import com.yoyuzh.files.core.FileBlobRepository;
import com.yoyuzh.files.policy.StoragePolicy;
import com.yoyuzh.files.policy.StoragePolicyRepository;
import com.yoyuzh.files.policy.StoragePolicyService;
import com.yoyuzh.files.share.FileShareLink;
import com.yoyuzh.files.share.FileShareLinkRepository;
import com.yoyuzh.files.tasks.BackgroundTask;
import com.yoyuzh.files.tasks.BackgroundTaskFailureCategory;
import com.yoyuzh.files.tasks.BackgroundTaskRepository;
import com.yoyuzh.files.tasks.BackgroundTaskService;
import com.yoyuzh.files.tasks.BackgroundTaskStatus;
import com.yoyuzh.files.tasks.BackgroundTaskType;
import com.yoyuzh.transfer.OfflineTransferSessionRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
@@ -34,8 +47,12 @@ import org.springframework.util.StringUtils;
import java.security.SecureRandom;
import java.time.Instant;
import java.time.LocalDateTime;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.stream.Collectors;
@Service
@RequiredArgsConstructor
@@ -47,6 +64,7 @@ public class AdminService {
private final FileService fileService;
private final PasswordEncoder passwordEncoder;
private final RefreshTokenService refreshTokenService;
private final AuthTokenInvalidationService authTokenInvalidationService;
private final RegistrationInviteService registrationInviteService;
private final OfflineTransferSessionRepository offlineTransferSessionRepository;
private final AdminMetricsService adminMetricsService;
@@ -54,7 +72,10 @@ public class AdminService {
private final StoragePolicyService storagePolicyService;
private final FileEntityRepository fileEntityRepository;
private final StoredFileEntityRepository storedFileEntityRepository;
private final BackgroundTaskRepository backgroundTaskRepository;
private final BackgroundTaskService backgroundTaskService;
private final FileShareLinkRepository fileShareLinkRepository;
private final ObjectMapper objectMapper;
private final SecureRandom secureRandom = new SecureRandom();
public AdminSummaryResponse getSummary() {
@@ -98,6 +119,92 @@ public class AdminService {
return new PageResponse<>(items, result.getTotalElements(), page, size);
}
public PageResponse<AdminFileBlobResponse> listFileBlobs(int page,
int size,
String userQuery,
Long storagePolicyId,
String objectKey,
FileEntityType entityType) {
Page<FileEntity> result = fileEntityRepository.searchAdminEntities(
normalizeQuery(userQuery),
storagePolicyId,
normalizeQuery(objectKey),
entityType,
PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt"))
);
List<AdminFileBlobResponse> items = result.getContent().stream()
.map(this::toFileBlobResponse)
.toList();
return new PageResponse<>(items, result.getTotalElements(), page, size);
}
public PageResponse<AdminShareResponse> listShares(int page,
int size,
String userQuery,
String fileName,
String token,
Boolean passwordProtected,
Boolean expired) {
Page<FileShareLink> result = fileShareLinkRepository.searchAdminShares(
normalizeQuery(userQuery),
normalizeQuery(fileName),
normalizeQuery(token),
passwordProtected,
expired,
LocalDateTime.now(),
PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt"))
);
List<AdminShareResponse> items = result.getContent().stream()
.map(this::toAdminShareResponse)
.toList();
return new PageResponse<>(items, result.getTotalElements(), page, size);
}
@Transactional
public void deleteShare(Long shareId) {
FileShareLink shareLink = fileShareLinkRepository.findById(shareId)
.orElseThrow(() -> new BusinessException(ErrorCode.FILE_NOT_FOUND, "share not found"));
fileShareLinkRepository.delete(shareLink);
}
public PageResponse<AdminTaskResponse> listTasks(int page,
int size,
String userQuery,
BackgroundTaskType type,
BackgroundTaskStatus status,
BackgroundTaskFailureCategory failureCategory,
AdminTaskLeaseState leaseState) {
String failureCategoryPattern = failureCategory == null
? null
: "\"failureCategory\":\"" + failureCategory.name() + "\"";
Page<BackgroundTask> result = backgroundTaskRepository.searchAdminTasks(
normalizeQuery(userQuery),
type,
status,
failureCategoryPattern,
leaseState == null ? null : leaseState.name(),
LocalDateTime.now(),
PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt"))
);
Map<Long, User> ownerById = userRepository.findAllById(result.getContent().stream()
.map(BackgroundTask::getUserId)
.collect(Collectors.toSet()))
.stream()
.collect(Collectors.toMap(User::getId, user -> user));
List<AdminTaskResponse> items = result.getContent().stream()
.map(task -> toAdminTaskResponse(task, ownerById.get(task.getUserId())))
.toList();
return new PageResponse<>(items, result.getTotalElements(), page, size);
}
public AdminTaskResponse getTask(Long taskId) {
BackgroundTask task = backgroundTaskRepository.findById(taskId)
.orElseThrow(() -> new BusinessException(ErrorCode.FILE_NOT_FOUND, "task not found"));
User owner = userRepository.findById(task.getUserId()).orElse(null);
return toAdminTaskResponse(task, owner);
}
@Cacheable(cacheNames = RedisCacheNames.STORAGE_POLICIES, key = "'all'")
public List<AdminStoragePolicyResponse> listStoragePolicies() {
return storagePolicyRepository.findAll(Sort.by(Sort.Direction.DESC, "defaultPolicy")
.and(Sort.by(Sort.Direction.DESC, "enabled"))
@@ -108,6 +215,7 @@ public class AdminService {
}
@Transactional
@CacheEvict(cacheNames = RedisCacheNames.STORAGE_POLICIES, allEntries = true)
public AdminStoragePolicyResponse createStoragePolicy(AdminStoragePolicyUpsertRequest request) {
StoragePolicy policy = new StoragePolicy();
policy.setDefaultPolicy(false);
@@ -116,6 +224,7 @@ public class AdminService {
}
@Transactional
@CacheEvict(cacheNames = RedisCacheNames.STORAGE_POLICIES, allEntries = true)
public AdminStoragePolicyResponse updateStoragePolicy(Long policyId, AdminStoragePolicyUpsertRequest request) {
StoragePolicy policy = getRequiredStoragePolicy(policyId);
applyStoragePolicyUpsert(policy, request);
@@ -123,10 +232,11 @@ public class AdminService {
}
@Transactional
@CacheEvict(cacheNames = RedisCacheNames.STORAGE_POLICIES, allEntries = true)
public AdminStoragePolicyResponse updateStoragePolicyStatus(Long policyId, boolean enabled) {
StoragePolicy policy = getRequiredStoragePolicy(policyId);
if (policy.isDefaultPolicy() && !enabled) {
throw new BusinessException(ErrorCode.UNKNOWN, "默认存储策略不能停用");
throw new BusinessException(ErrorCode.UNKNOWN, "榛樿瀛樺偍绛栫暐涓嶈兘鍋滅敤");
}
policy.setEnabled(enabled);
return toStoragePolicyResponse(storagePolicyRepository.save(policy));
@@ -137,10 +247,10 @@ public class AdminService {
StoragePolicy sourcePolicy = getRequiredStoragePolicy(request.sourcePolicyId());
StoragePolicy targetPolicy = getRequiredStoragePolicy(request.targetPolicyId());
if (sourcePolicy.getId().equals(targetPolicy.getId())) {
throw new BusinessException(ErrorCode.UNKNOWN, "源存储策略和目标存储策略不能相同");
throw new BusinessException(ErrorCode.UNKNOWN, "婧愬瓨鍌ㄧ瓥鐣ュ拰鐩爣瀛樺偍绛栫暐涓嶈兘鐩稿悓");
}
if (!targetPolicy.isEnabled()) {
throw new BusinessException(ErrorCode.UNKNOWN, "目标存储策略必须处于启用状态");
throw new BusinessException(ErrorCode.UNKNOWN, "target storage policy must be enabled");
}
long candidateEntityCount = fileEntityRepository.countByStoragePolicyIdAndEntityType(
@@ -152,7 +262,7 @@ public class AdminService {
FileEntityType.VERSION
);
java.util.Map<String, Object> state = new java.util.LinkedHashMap<>();
Map<String, Object> state = new LinkedHashMap<>();
state.put("sourcePolicyId", sourcePolicy.getId());
state.put("sourcePolicyName", sourcePolicy.getName());
state.put("targetPolicyId", targetPolicy.getId());
@@ -164,7 +274,7 @@ public class AdminService {
state.put("entityType", FileEntityType.VERSION.name());
state.put("message", "storage policy migration skeleton queued; worker will validate and recount candidates without moving object data");
java.util.Map<String, Object> privateState = new java.util.LinkedHashMap<>(state);
Map<String, Object> privateState = new LinkedHashMap<>(state);
privateState.put("taskType", BackgroundTaskType.STORAGE_POLICY_MIGRATION.name());
return backgroundTaskService.createQueuedTask(
@@ -179,7 +289,7 @@ public class AdminService {
@Transactional
public void deleteFile(Long fileId) {
StoredFile storedFile = storedFileRepository.findById(fileId)
.orElseThrow(() -> new BusinessException(ErrorCode.FILE_NOT_FOUND, "文件不存在"));
.orElseThrow(() -> new BusinessException(ErrorCode.FILE_NOT_FOUND, "file not found"));
fileService.delete(storedFile.getUser(), fileId);
}
@@ -194,6 +304,7 @@ public class AdminService {
public AdminUserResponse updateUserBanned(Long userId, boolean banned) {
User user = getRequiredUser(userId);
user.setBanned(banned);
authTokenInvalidationService.revokeAccessTokensForUser(user.getId());
user.setActiveSessionId(UUID.randomUUID().toString());
user.setDesktopActiveSessionId(UUID.randomUUID().toString());
user.setMobileActiveSessionId(UUID.randomUUID().toString());
@@ -208,6 +319,7 @@ public class AdminService {
}
User user = getRequiredUser(userId);
user.setPasswordHash(passwordEncoder.encode(newPassword));
authTokenInvalidationService.revokeAccessTokensForUser(user.getId());
user.setActiveSessionId(UUID.randomUUID().toString());
user.setDesktopActiveSessionId(UUID.randomUUID().toString());
user.setMobileActiveSessionId(UUID.randomUUID().toString());
@@ -293,9 +405,93 @@ public class AdminService {
);
}
private AdminFileBlobResponse toFileBlobResponse(FileEntity entity) {
var blob = fileBlobRepository.findByObjectKey(entity.getObjectKey()).orElse(null);
long linkedStoredFileCount = storedFileEntityRepository.countByFileEntityId(entity.getId());
long linkedOwnerCount = storedFileEntityRepository.countDistinctOwnersByFileEntityId(entity.getId());
return new AdminFileBlobResponse(
entity.getId(),
blob == null ? null : blob.getId(),
entity.getObjectKey(),
entity.getEntityType(),
entity.getStoragePolicyId(),
entity.getSize(),
StringUtils.hasText(entity.getContentType()) ? entity.getContentType() : blob == null ? null : blob.getContentType(),
entity.getReferenceCount(),
linkedStoredFileCount,
linkedOwnerCount,
storedFileEntityRepository.findSampleOwnerUsernameByFileEntityId(entity.getId()),
storedFileEntityRepository.findSampleOwnerEmailByFileEntityId(entity.getId()),
entity.getCreatedBy() == null ? null : entity.getCreatedBy().getId(),
entity.getCreatedBy() == null ? null : entity.getCreatedBy().getUsername(),
entity.getCreatedAt(),
blob == null ? null : blob.getCreatedAt(),
blob == null,
linkedStoredFileCount == 0,
entity.getReferenceCount() == null || entity.getReferenceCount() != linkedStoredFileCount
);
}
private AdminShareResponse toAdminShareResponse(FileShareLink shareLink) {
StoredFile file = shareLink.getFile();
User owner = shareLink.getOwner();
boolean expired = shareLink.getExpiresAt() != null && shareLink.getExpiresAt().isBefore(LocalDateTime.now());
return new AdminShareResponse(
shareLink.getId(),
shareLink.getToken(),
shareLink.getShareNameOrDefault(),
shareLink.hasPassword(),
expired,
shareLink.getCreatedAt(),
shareLink.getExpiresAt(),
shareLink.getMaxDownloads(),
shareLink.getDownloadCountOrZero(),
shareLink.getViewCountOrZero(),
shareLink.isAllowImportEnabled(),
shareLink.isAllowDownloadEnabled(),
owner.getId(),
owner.getUsername(),
owner.getEmail(),
file.getId(),
file.getFilename(),
file.getPath(),
file.getContentType(),
file.getSize(),
file.isDirectory()
);
}
private AdminTaskResponse toAdminTaskResponse(BackgroundTask task, User owner) {
Map<String, Object> state = parseState(task.getPublicStateJson());
return new AdminTaskResponse(
task.getId(),
task.getType(),
task.getStatus(),
task.getUserId(),
owner == null ? null : owner.getUsername(),
owner == null ? null : owner.getEmail(),
task.getPublicStateJson(),
task.getCorrelationId(),
task.getErrorMessage(),
task.getAttemptCount(),
task.getMaxAttempts(),
task.getNextRunAt(),
task.getLeaseOwner(),
task.getLeaseExpiresAt(),
task.getHeartbeatAt(),
task.getCreatedAt(),
task.getUpdatedAt(),
task.getFinishedAt(),
readStringState(state, "failureCategory"),
readBooleanState(state, "retryScheduled"),
readStringState(state, "workerOwner"),
resolveLeaseState(task)
);
}
private void applyStoragePolicyUpsert(StoragePolicy policy, AdminStoragePolicyUpsertRequest request) {
if (policy.isDefaultPolicy() && !request.enabled()) {
throw new BusinessException(ErrorCode.UNKNOWN, "默认存储策略不能停用");
throw new BusinessException(ErrorCode.UNKNOWN, "榛樿瀛樺偍绛栫暐涓嶈兘鍋滅敤");
}
validateStoragePolicyRequest(request);
policy.setName(request.name().trim());
@@ -313,12 +509,12 @@ public class AdminService {
private User getRequiredUser(Long userId) {
return userRepository.findById(userId)
.orElseThrow(() -> new BusinessException(ErrorCode.UNKNOWN, "用户不存在"));
.orElseThrow(() -> new BusinessException(ErrorCode.UNKNOWN, "user not found"));
}
private StoragePolicy getRequiredStoragePolicy(Long policyId) {
return storagePolicyRepository.findById(policyId)
.orElseThrow(() -> new BusinessException(ErrorCode.UNKNOWN, "存储策略不存在"));
.orElseThrow(() -> new BusinessException(ErrorCode.UNKNOWN, "storage policy not found"));
}
private String normalizeQuery(String query) {
@@ -342,14 +538,51 @@ public class AdminService {
return prefix.trim();
}
private Map<String, Object> parseState(String json) {
if (!StringUtils.hasText(json)) {
return Map.of();
}
try {
return objectMapper.readValue(json, new TypeReference<LinkedHashMap<String, Object>>() {
});
} catch (JsonProcessingException ex) {
return Map.of();
}
}
private String readStringState(Map<String, Object> state, String key) {
Object value = state.get(key);
return value == null ? null : String.valueOf(value);
}
private Boolean readBooleanState(Map<String, Object> state, String key) {
Object value = state.get(key);
if (value instanceof Boolean boolValue) {
return boolValue;
}
if (value instanceof String stringValue) {
return Boolean.parseBoolean(stringValue);
}
return null;
}
private AdminTaskLeaseState resolveLeaseState(BackgroundTask task) {
if (!StringUtils.hasText(task.getLeaseOwner()) || task.getLeaseExpiresAt() == null) {
return AdminTaskLeaseState.NONE;
}
return task.getLeaseExpiresAt().isBefore(LocalDateTime.now())
? AdminTaskLeaseState.EXPIRED
: AdminTaskLeaseState.ACTIVE;
}
private void validateStoragePolicyRequest(AdminStoragePolicyUpsertRequest request) {
if (request.type() == com.yoyuzh.files.policy.StoragePolicyType.LOCAL
&& request.credentialMode() != com.yoyuzh.files.policy.StoragePolicyCredentialMode.NONE) {
throw new BusinessException(ErrorCode.UNKNOWN, "本地存储策略必须使用 NONE 凭证模式");
throw new BusinessException(ErrorCode.UNKNOWN, "鏈湴瀛樺偍绛栫暐蹇呴』浣跨敤 NONE 鍑瘉妯″紡");
}
if (request.type() == com.yoyuzh.files.policy.StoragePolicyType.S3_COMPATIBLE
&& !StringUtils.hasText(request.bucketName())) {
throw new BusinessException(ErrorCode.UNKNOWN, "S3 存储策略必须提供 bucketName");
throw new BusinessException(ErrorCode.UNKNOWN, "S3 瀛樺偍绛栫暐蹇呴』鎻愪緵 bucketName");
}
}

View File

@@ -0,0 +1,28 @@
package com.yoyuzh.admin;
import java.time.LocalDateTime;
public record AdminShareResponse(
Long id,
String token,
String shareName,
boolean passwordProtected,
boolean expired,
LocalDateTime createdAt,
LocalDateTime expiresAt,
Integer maxDownloads,
long downloadCount,
long viewCount,
boolean allowImport,
boolean allowDownload,
Long ownerId,
String ownerUsername,
String ownerEmail,
Long fileId,
String fileName,
String filePath,
String fileContentType,
long fileSize,
boolean directory
) {
}

View File

@@ -0,0 +1,7 @@
package com.yoyuzh.admin;
public enum AdminTaskLeaseState {
ACTIVE,
EXPIRED,
NONE
}

View File

@@ -0,0 +1,32 @@
package com.yoyuzh.admin;
import com.yoyuzh.files.tasks.BackgroundTaskStatus;
import com.yoyuzh.files.tasks.BackgroundTaskType;
import java.time.LocalDateTime;
public record AdminTaskResponse(
Long id,
BackgroundTaskType type,
BackgroundTaskStatus status,
Long userId,
String ownerUsername,
String ownerEmail,
String publicStateJson,
String correlationId,
String errorMessage,
Integer attemptCount,
Integer maxAttempts,
LocalDateTime nextRunAt,
String leaseOwner,
LocalDateTime leaseExpiresAt,
LocalDateTime heartbeatAt,
LocalDateTime createdAt,
LocalDateTime updatedAt,
LocalDateTime finishedAt,
String failureCategory,
Boolean retryScheduled,
String workerOwner,
AdminTaskLeaseState leaseState
) {
}

View File

@@ -1,6 +1,11 @@
package com.yoyuzh.files.core;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.List;
import java.util.Optional;
@@ -12,4 +17,28 @@ public interface FileEntityRepository extends JpaRepository<FileEntity, Long> {
long countByStoragePolicyIdAndEntityType(Long storagePolicyId, FileEntityType entityType);
List<FileEntity> findByStoragePolicyIdAndEntityTypeOrderByIdAsc(Long storagePolicyId, FileEntityType entityType);
@EntityGraph(attributePaths = {"createdBy"})
@Query("""
select entity from FileEntity entity
where (:storagePolicyId is null or entity.storagePolicyId = :storagePolicyId)
and (:entityType is null or entity.entityType = :entityType)
and (:objectKey is null or :objectKey = ''
or lower(entity.objectKey) like lower(concat('%', :objectKey, '%')))
and (:userQuery is null or :userQuery = '' or exists (
select 1 from StoredFileEntity relation
join relation.storedFile storedFile
join storedFile.user owner
where relation.fileEntity = entity
and (
lower(owner.username) like lower(concat('%', :userQuery, '%'))
or lower(owner.email) like lower(concat('%', :userQuery, '%'))
)
))
""")
Page<FileEntity> searchAdminEntities(@Param("userQuery") String userQuery,
@Param("storagePolicyId") Long storagePolicyId,
@Param("objectKey") String objectKey,
@Param("entityType") FileEntityType entityType,
Pageable pageable);
}

View File

@@ -14,4 +14,31 @@ public interface StoredFileEntityRepository extends JpaRepository<StoredFileEnti
""")
long countDistinctStoredFilesByStoragePolicyIdAndEntityType(@Param("storagePolicyId") Long storagePolicyId,
@Param("entityType") FileEntityType entityType);
long countByFileEntityId(Long fileEntityId);
@Query("""
select count(distinct relation.storedFile.user.id)
from StoredFileEntity relation
where relation.fileEntity.id = :fileEntityId
""")
long countDistinctOwnersByFileEntityId(@Param("fileEntityId") Long fileEntityId);
@Query("""
select min(owner.username)
from StoredFileEntity relation
join relation.storedFile storedFile
join storedFile.user owner
where relation.fileEntity.id = :fileEntityId
""")
String findSampleOwnerUsernameByFileEntityId(@Param("fileEntityId") Long fileEntityId);
@Query("""
select min(owner.email)
from StoredFileEntity relation
join relation.storedFile storedFile
join storedFile.user owner
where relation.fileEntity.id = :fileEntityId
""")
String findSampleOwnerEmailByFileEntityId(@Param("fileEntityId") Long fileEntityId);
}

View File

@@ -4,6 +4,10 @@ import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.time.LocalDateTime;
import java.util.Optional;
@@ -17,4 +21,31 @@ public interface FileShareLinkRepository extends JpaRepository<FileShareLink, Lo
@EntityGraph(attributePaths = {"owner", "file", "file.user", "file.blob"})
Optional<FileShareLink> findByIdAndOwnerId(Long id, Long ownerId);
@EntityGraph(attributePaths = {"owner", "file", "file.user", "file.primaryEntity", "file.blob"})
@Query("""
select share from FileShareLink share
join share.owner owner
join share.file file
where (:userQuery is null or :userQuery = ''
or lower(owner.username) like lower(concat('%', :userQuery, '%'))
or lower(owner.email) like lower(concat('%', :userQuery, '%')))
and (:fileName is null or :fileName = ''
or lower(file.filename) like lower(concat('%', :fileName, '%')))
and (:token is null or :token = ''
or lower(share.token) like lower(concat('%', :token, '%')))
and (:passwordProtected is null
or (:passwordProtected = true and share.passwordHash is not null and share.passwordHash <> '')
or (:passwordProtected = false and (share.passwordHash is null or share.passwordHash = '')))
and (:expired is null
or (:expired = true and share.expiresAt is not null and share.expiresAt < :now)
or (:expired = false and (share.expiresAt is null or share.expiresAt >= :now)))
""")
Page<FileShareLink> searchAdminShares(@Param("userQuery") String userQuery,
@Param("fileName") String fileName,
@Param("token") String token,
@Param("passwordProtected") Boolean passwordProtected,
@Param("expired") Boolean expired,
@Param("now") LocalDateTime now,
Pageable pageable);
}

View File

@@ -15,8 +15,36 @@ public interface BackgroundTaskRepository extends JpaRepository<BackgroundTask,
Page<BackgroundTask> findByUserIdOrderByCreatedAtDesc(Long userId, Pageable pageable);
@Query("""
select task from BackgroundTask task
where (:userQuery is null or :userQuery = '' or exists (
select 1 from User owner
where owner.id = task.userId
and (
lower(owner.username) like lower(concat('%', :userQuery, '%'))
or lower(owner.email) like lower(concat('%', :userQuery, '%'))
)
))
and (:type is null or task.type = :type)
and (:status is null or task.status = :status)
and (:failureCategoryPattern is null or lower(task.publicStateJson) like lower(concat('%', :failureCategoryPattern, '%')))
and (:leaseState is null
or (:leaseState = 'ACTIVE' and task.leaseOwner is not null and task.leaseExpiresAt is not null and task.leaseExpiresAt > :now)
or (:leaseState = 'EXPIRED' and task.leaseOwner is not null and task.leaseExpiresAt is not null and task.leaseExpiresAt <= :now)
or (:leaseState = 'NONE' and (task.leaseOwner is null or task.leaseExpiresAt is null)))
""")
Page<BackgroundTask> searchAdminTasks(@Param("userQuery") String userQuery,
@Param("type") BackgroundTaskType type,
@Param("status") BackgroundTaskStatus status,
@Param("failureCategoryPattern") String failureCategoryPattern,
@Param("leaseState") String leaseState,
@Param("now") LocalDateTime now,
Pageable pageable);
Optional<BackgroundTask> findByIdAndUserId(Long id, Long userId);
boolean existsByCorrelationId(String correlationId);
List<BackgroundTask> findByStatusOrderByCreatedAtAsc(BackgroundTaskStatus status, Pageable pageable);
List<BackgroundTask> findByStatusOrderByUpdatedAtAsc(BackgroundTaskStatus status);

View File

@@ -2,15 +2,28 @@ package com.yoyuzh.admin;
import com.yoyuzh.PortalBackendApplication;
import com.yoyuzh.admin.AdminMetricsStateRepository;
import com.yoyuzh.auth.RefreshTokenRepository;
import com.yoyuzh.auth.User;
import com.yoyuzh.auth.UserRepository;
import com.yoyuzh.files.core.FileBlob;
import com.yoyuzh.files.core.FileBlobRepository;
import com.yoyuzh.files.core.FileEntity;
import com.yoyuzh.files.core.FileEntityRepository;
import com.yoyuzh.files.core.FileEntityType;
import com.yoyuzh.files.core.StoredFile;
import com.yoyuzh.files.core.StoredFileEntity;
import com.yoyuzh.files.core.StoredFileEntityRepository;
import com.yoyuzh.files.core.StoredFileRepository;
import com.yoyuzh.files.policy.StoragePolicy;
import com.yoyuzh.files.policy.StoragePolicyRepository;
import com.yoyuzh.files.policy.StoragePolicyType;
import com.yoyuzh.files.share.FileShareLink;
import com.yoyuzh.files.share.FileShareLinkRepository;
import com.yoyuzh.files.tasks.BackgroundTask;
import com.yoyuzh.files.tasks.BackgroundTaskFailureCategory;
import com.yoyuzh.files.tasks.BackgroundTaskRepository;
import com.yoyuzh.files.tasks.BackgroundTaskStatus;
import com.yoyuzh.files.tasks.BackgroundTaskType;
import com.yoyuzh.transfer.OfflineTransferSessionRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -25,6 +38,7 @@ import java.time.LocalDateTime;
import java.time.LocalDate;
import java.time.LocalTime;
import static org.assertj.core.api.Assertions.assertThat;
import static org.hamcrest.Matchers.greaterThanOrEqualTo;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
@@ -64,6 +78,16 @@ class AdminControllerIntegrationTest {
@Autowired
private FileBlobRepository fileBlobRepository;
@Autowired
private FileEntityRepository fileEntityRepository;
@Autowired
private StoredFileEntityRepository storedFileEntityRepository;
@Autowired
private FileShareLinkRepository fileShareLinkRepository;
@Autowired
private BackgroundTaskRepository backgroundTaskRepository;
@Autowired
private RefreshTokenRepository refreshTokenRepository;
@Autowired
private OfflineTransferSessionRepository offlineTransferSessionRepository;
@Autowired
private AdminMetricsStateRepository adminMetricsStateRepository;
@@ -79,12 +103,21 @@ class AdminControllerIntegrationTest {
@BeforeEach
void setUp() {
backgroundTaskRepository.deleteAll();
fileShareLinkRepository.deleteAll();
storedFileEntityRepository.deleteAll();
offlineTransferSessionRepository.deleteAll();
refreshTokenRepository.deleteAll();
storedFileRepository.deleteAll();
fileEntityRepository.deleteAll();
fileBlobRepository.deleteAll();
userRepository.deleteAll();
adminMetricsStateRepository.deleteAll();
Long defaultPolicyId = storagePolicyRepository.findFirstByDefaultPolicyTrueOrderByIdAsc()
.map(StoragePolicy::getId)
.orElse(null);
portalUser = new User();
portalUser.setUsername("alice");
portalUser.setEmail("alice@example.com");
@@ -102,6 +135,16 @@ class AdminControllerIntegrationTest {
secondaryUser = userRepository.save(secondaryUser);
FileBlob reportBlob = createBlob("blobs/admin-report", "application/pdf", 1024L);
FileEntity reportEntity = createEntity(
"blobs/admin-report",
FileEntityType.VERSION,
defaultPolicyId,
1024L,
"application/pdf",
1,
portalUser,
LocalDateTime.now().minusMinutes(10)
);
storedFile = new StoredFile();
storedFile.setUser(portalUser);
storedFile.setFilename("report.pdf");
@@ -110,10 +153,22 @@ class AdminControllerIntegrationTest {
storedFile.setSize(1024L);
storedFile.setDirectory(false);
storedFile.setBlob(reportBlob);
storedFile.setPrimaryEntity(reportEntity);
storedFile.setCreatedAt(LocalDateTime.now());
storedFile = storedFileRepository.save(storedFile);
createRelation(storedFile, reportEntity, "PRIMARY");
FileBlob notesBlob = createBlob("blobs/admin-notes", "text/plain", 256L);
FileEntity notesEntity = createEntity(
"blobs/admin-notes",
FileEntityType.VERSION,
defaultPolicyId,
256L,
"text/plain",
1,
secondaryUser,
LocalDateTime.now().minusHours(3)
);
secondaryFile = new StoredFile();
secondaryFile.setUser(secondaryUser);
secondaryFile.setFilename("notes.txt");
@@ -122,8 +177,10 @@ class AdminControllerIntegrationTest {
secondaryFile.setSize(256L);
secondaryFile.setDirectory(false);
secondaryFile.setBlob(notesBlob);
secondaryFile.setPrimaryEntity(notesEntity);
secondaryFile.setCreatedAt(LocalDateTime.now().minusHours(2));
secondaryFile = storedFileRepository.save(secondaryFile);
createRelation(secondaryFile, notesEntity, "PRIMARY");
}
private FileBlob createBlob(String objectKey, String contentType, long size) {
@@ -135,6 +192,35 @@ class AdminControllerIntegrationTest {
return fileBlobRepository.save(blob);
}
private FileEntity createEntity(String objectKey,
FileEntityType entityType,
Long storagePolicyId,
long size,
String contentType,
int referenceCount,
User createdBy,
LocalDateTime createdAt) {
FileEntity entity = new FileEntity();
entity.setObjectKey(objectKey);
entity.setEntityType(entityType);
entity.setStoragePolicyId(storagePolicyId);
entity.setSize(size);
entity.setContentType(contentType);
entity.setReferenceCount(referenceCount);
entity.setCreatedBy(createdBy);
entity.setCreatedAt(createdAt);
return fileEntityRepository.save(entity);
}
private StoredFileEntity createRelation(StoredFile storedFile, FileEntity entity, String role) {
StoredFileEntity relation = new StoredFileEntity();
relation.setStoredFile(storedFile);
relation.setFileEntity(entity);
relation.setEntityRole(role);
relation.setCreatedAt(LocalDateTime.now());
return storedFileEntityRepository.save(relation);
}
@Test
@WithMockUser(username = "admin")
void shouldAllowConfiguredAdminToListUsersAndSummary() throws Exception {
@@ -325,6 +411,121 @@ class AdminControllerIntegrationTest {
.andExpect(jsonPath("$.code").value(0));
}
@Test
@WithMockUser(username = "admin")
void shouldAllowConfiguredAdminToListFileBlobsWithRiskSignals() throws Exception {
Long defaultPolicyId = storagePolicyRepository.findFirstByDefaultPolicyTrueOrderByIdAsc()
.map(StoragePolicy::getId)
.orElse(null);
createEntity(
"blobs/missing-preview",
FileEntityType.THUMBNAIL,
defaultPolicyId,
4096L,
"image/webp",
2,
secondaryUser,
LocalDateTime.now().minusMinutes(1)
);
mockMvc.perform(get("/api/admin/file-blobs?page=0&size=10&objectKey=missing-preview&entityType=THUMBNAIL"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.total").value(1))
.andExpect(jsonPath("$.data.items[0].objectKey").value("blobs/missing-preview"))
.andExpect(jsonPath("$.data.items[0].entityType").value("THUMBNAIL"))
.andExpect(jsonPath("$.data.items[0].createdByUsername").value("bob"))
.andExpect(jsonPath("$.data.items[0].blobMissing").value(true))
.andExpect(jsonPath("$.data.items[0].orphanRisk").value(true))
.andExpect(jsonPath("$.data.items[0].referenceMismatch").value(true))
.andExpect(jsonPath("$.data.items[0].linkedStoredFileCount").value(0))
.andExpect(jsonPath("$.data.items[0].linkedOwnerCount").value(0));
}
@Test
@WithMockUser(username = "admin")
void shouldAllowConfiguredAdminToListAndDeleteShares() throws Exception {
FileShareLink share = new FileShareLink();
share.setOwner(secondaryUser);
share.setFile(secondaryFile);
share.setToken("secret-token");
share.setShareName("Bob Private Notes");
share.setPasswordHash("hashed-secret");
share.setExpiresAt(LocalDateTime.now().minusHours(1));
share.setMaxDownloads(5);
share.setDownloadCount(2L);
share.setViewCount(4L);
share.setAllowImport(false);
share.setAllowDownload(true);
share.setCreatedAt(LocalDateTime.now().minusMinutes(5));
share = fileShareLinkRepository.save(share);
mockMvc.perform(get("/api/admin/shares?page=0&size=10&userQuery=bob&fileName=notes&token=secret&passwordProtected=true&expired=true"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.total").value(1))
.andExpect(jsonPath("$.data.items[0].id").value(share.getId()))
.andExpect(jsonPath("$.data.items[0].shareName").value("Bob Private Notes"))
.andExpect(jsonPath("$.data.items[0].ownerUsername").value("bob"))
.andExpect(jsonPath("$.data.items[0].fileName").value("notes.txt"))
.andExpect(jsonPath("$.data.items[0].passwordProtected").value(true))
.andExpect(jsonPath("$.data.items[0].expired").value(true))
.andExpect(jsonPath("$.data.items[0].allowImport").value(false))
.andExpect(jsonPath("$.data.items[0].allowDownload").value(true));
mockMvc.perform(delete("/api/admin/shares/{shareId}", share.getId()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0));
assertThat(fileShareLinkRepository.findById(share.getId())).isEmpty();
}
@Test
@WithMockUser(username = "admin")
void shouldAllowConfiguredAdminToListAndInspectTasks() throws Exception {
BackgroundTask task = new BackgroundTask();
task.setType(BackgroundTaskType.MEDIA_META);
task.setStatus(BackgroundTaskStatus.RUNNING);
task.setUserId(portalUser.getId());
task.setPublicStateJson("""
{"failureCategory":"TRANSIENT_INFRASTRUCTURE","retryScheduled":true,"workerOwner":"media-worker-1"}
""");
task.setPrivateStateJson("""
{"internal":"secret"}
""");
task.setCorrelationId("task-media-meta-1");
task.setAttemptCount(1);
task.setMaxAttempts(3);
task.setLeaseOwner("worker-a");
task.setLeaseExpiresAt(LocalDateTime.now().plusMinutes(5));
task.setHeartbeatAt(LocalDateTime.now().minusSeconds(30));
task.setCreatedAt(LocalDateTime.now().minusMinutes(2));
task.setUpdatedAt(LocalDateTime.now().minusSeconds(20));
task = backgroundTaskRepository.save(task);
mockMvc.perform(get("/api/admin/tasks?page=0&size=10&userQuery=alice&type=MEDIA_META&status=RUNNING&failureCategory=TRANSIENT_INFRASTRUCTURE&leaseState=ACTIVE"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.total").value(1))
.andExpect(jsonPath("$.data.items[0].id").value(task.getId()))
.andExpect(jsonPath("$.data.items[0].type").value("MEDIA_META"))
.andExpect(jsonPath("$.data.items[0].status").value("RUNNING"))
.andExpect(jsonPath("$.data.items[0].ownerUsername").value("alice"))
.andExpect(jsonPath("$.data.items[0].failureCategory").value("TRANSIENT_INFRASTRUCTURE"))
.andExpect(jsonPath("$.data.items[0].retryScheduled").value(true))
.andExpect(jsonPath("$.data.items[0].workerOwner").value("media-worker-1"))
.andExpect(jsonPath("$.data.items[0].leaseState").value("ACTIVE"));
mockMvc.perform(get("/api/admin/tasks/{taskId}", task.getId()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.id").value(task.getId()))
.andExpect(jsonPath("$.data.correlationId").value("task-media-meta-1"))
.andExpect(jsonPath("$.data.ownerEmail").value("alice@example.com"))
.andExpect(jsonPath("$.data.failureCategory").value("TRANSIENT_INFRASTRUCTURE"))
.andExpect(jsonPath("$.data.leaseState").value("ACTIVE"));
}
@Test
@WithMockUser(username = "admin")
void shouldAllowConfiguredAdminToListStoragePolicies() throws Exception {
@@ -442,8 +643,12 @@ class AdminControllerIntegrationTest {
"enabled": false
}
"""))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.msg").value("默认存储策略不能停用"));
.andExpect(status().isBadRequest());
assertThat(storagePolicyRepository.findById(defaultPolicy.getId()))
.get()
.extracting(StoragePolicy::isEnabled)
.isEqualTo(true);
}
@Test

View File

@@ -0,0 +1,310 @@
package com.yoyuzh.admin;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.yoyuzh.auth.AuthTokenInvalidationService;
import com.yoyuzh.auth.RefreshTokenService;
import com.yoyuzh.auth.RegistrationInviteService;
import com.yoyuzh.auth.UserRepository;
import com.yoyuzh.config.RedisCacheNames;
import com.yoyuzh.files.core.FileBlobRepository;
import com.yoyuzh.files.core.FileEntityRepository;
import com.yoyuzh.files.core.FileService;
import com.yoyuzh.files.core.StoredFileEntityRepository;
import com.yoyuzh.files.core.StoredFileRepository;
import com.yoyuzh.files.policy.StoragePolicy;
import com.yoyuzh.files.policy.StoragePolicyCapabilities;
import com.yoyuzh.files.policy.StoragePolicyCredentialMode;
import com.yoyuzh.files.policy.StoragePolicyRepository;
import com.yoyuzh.files.policy.StoragePolicyService;
import com.yoyuzh.files.policy.StoragePolicyType;
import com.yoyuzh.files.share.FileShareLinkRepository;
import com.yoyuzh.files.tasks.BackgroundTaskRepository;
import com.yoyuzh.files.tasks.BackgroundTaskService;
import com.yoyuzh.transfer.OfflineTransferSessionRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.cache.concurrent.ConcurrentMapCacheManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.reset;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@SpringJUnitConfig(AdminServiceStoragePolicyCacheTest.CacheTestConfiguration.class)
class AdminServiceStoragePolicyCacheTest {
@Autowired
private AdminService adminService;
@Autowired
private CacheManager cacheManager;
@Autowired
private StoragePolicyRepository storagePolicyRepository;
@Autowired
private StoragePolicyService storagePolicyService;
@BeforeEach
void setUp() {
cacheManager.getCache(RedisCacheNames.STORAGE_POLICIES).clear();
reset(storagePolicyRepository, storagePolicyService);
when(storagePolicyService.readCapabilities(any(StoragePolicy.class))).thenReturn(defaultCapabilities());
}
@Test
void shouldCacheStoragePolicyListUntilExplicitEviction() {
StoragePolicy defaultPolicy = storagePolicy(1L, "Default Local Storage", true, true);
when(storagePolicyRepository.findAll(any(org.springframework.data.domain.Sort.class)))
.thenReturn(List.of(defaultPolicy));
adminService.listStoragePolicies();
adminService.listStoragePolicies();
verify(storagePolicyRepository, times(1)).findAll(any(org.springframework.data.domain.Sort.class));
}
@Test
void shouldEvictStoragePolicyListAfterCreatingPolicy() {
StoragePolicy defaultPolicy = storagePolicy(1L, "Default Local Storage", true, true);
StoragePolicy createdPolicy = storagePolicy(2L, "Archive Bucket", true, false);
when(storagePolicyRepository.findAll(any(org.springframework.data.domain.Sort.class)))
.thenReturn(List.of(defaultPolicy))
.thenReturn(List.of(defaultPolicy, createdPolicy));
when(storagePolicyRepository.save(any(StoragePolicy.class))).thenAnswer(invocation -> {
StoragePolicy saved = invocation.getArgument(0);
saved.setId(2L);
saved.setCreatedAt(LocalDateTime.now());
saved.setUpdatedAt(LocalDateTime.now());
return saved;
});
adminService.listStoragePolicies();
adminService.createStoragePolicy(upsertRequest("Archive Bucket", true));
adminService.listStoragePolicies();
verify(storagePolicyRepository, times(2)).findAll(any(org.springframework.data.domain.Sort.class));
}
@Test
void shouldEvictStoragePolicyListAfterUpdatingPolicy() {
StoragePolicy existingPolicy = storagePolicy(2L, "Archive Bucket", true, false);
StoragePolicy updatedPolicy = storagePolicy(2L, "Hot Bucket", true, false);
when(storagePolicyRepository.findAll(any(org.springframework.data.domain.Sort.class)))
.thenReturn(List.of(existingPolicy))
.thenReturn(List.of(updatedPolicy));
when(storagePolicyRepository.findById(2L)).thenReturn(Optional.of(existingPolicy));
when(storagePolicyRepository.save(existingPolicy)).thenReturn(updatedPolicy);
adminService.listStoragePolicies();
adminService.updateStoragePolicy(2L, upsertRequest("Hot Bucket", true));
adminService.listStoragePolicies();
verify(storagePolicyRepository, times(2)).findAll(any(org.springframework.data.domain.Sort.class));
}
@Test
void shouldEvictStoragePolicyListAfterUpdatingPolicyStatus() {
StoragePolicy existingPolicy = storagePolicy(2L, "Archive Bucket", true, false);
StoragePolicy disabledPolicy = storagePolicy(2L, "Archive Bucket", false, false);
when(storagePolicyRepository.findAll(any(org.springframework.data.domain.Sort.class)))
.thenReturn(List.of(existingPolicy))
.thenReturn(List.of(disabledPolicy));
when(storagePolicyRepository.findById(2L)).thenReturn(Optional.of(existingPolicy));
when(storagePolicyRepository.save(existingPolicy)).thenReturn(disabledPolicy);
adminService.listStoragePolicies();
adminService.updateStoragePolicyStatus(2L, false);
adminService.listStoragePolicies();
verify(storagePolicyRepository, times(2)).findAll(any(org.springframework.data.domain.Sort.class));
}
private StoragePolicy storagePolicy(Long id, String name, boolean enabled, boolean defaultPolicy) {
StoragePolicy policy = new StoragePolicy();
policy.setId(id);
policy.setName(name);
policy.setType(StoragePolicyType.LOCAL);
policy.setCredentialMode(StoragePolicyCredentialMode.NONE);
policy.setMaxSizeBytes(1024L);
policy.setEnabled(enabled);
policy.setDefaultPolicy(defaultPolicy);
policy.setCreatedAt(LocalDateTime.now());
policy.setUpdatedAt(LocalDateTime.now());
return policy;
}
private AdminStoragePolicyUpsertRequest upsertRequest(String name, boolean enabled) {
return new AdminStoragePolicyUpsertRequest(
name,
StoragePolicyType.LOCAL,
null,
null,
null,
false,
"",
StoragePolicyCredentialMode.NONE,
1024L,
defaultCapabilities(),
enabled
);
}
private StoragePolicyCapabilities defaultCapabilities() {
return new StoragePolicyCapabilities(false, false, false, true, false, true, false, false, 1024L);
}
@Configuration
@EnableCaching
static class CacheTestConfiguration {
@Bean
CacheManager cacheManager() {
return new ConcurrentMapCacheManager(RedisCacheNames.STORAGE_POLICIES);
}
@Bean
UserRepository userRepository() {
return mock(UserRepository.class);
}
@Bean
StoredFileRepository storedFileRepository() {
return mock(StoredFileRepository.class);
}
@Bean
FileBlobRepository fileBlobRepository() {
return mock(FileBlobRepository.class);
}
@Bean
FileService fileService() {
return mock(FileService.class);
}
@Bean
PasswordEncoder passwordEncoder() {
return mock(PasswordEncoder.class);
}
@Bean
RefreshTokenService refreshTokenService() {
return mock(RefreshTokenService.class);
}
@Bean
AuthTokenInvalidationService authTokenInvalidationService() {
return mock(AuthTokenInvalidationService.class);
}
@Bean
RegistrationInviteService registrationInviteService() {
return mock(RegistrationInviteService.class);
}
@Bean
OfflineTransferSessionRepository offlineTransferSessionRepository() {
return mock(OfflineTransferSessionRepository.class);
}
@Bean
AdminMetricsService adminMetricsService() {
return mock(AdminMetricsService.class);
}
@Bean
StoragePolicyRepository storagePolicyRepository() {
return mock(StoragePolicyRepository.class);
}
@Bean
StoragePolicyService storagePolicyService() {
return mock(StoragePolicyService.class);
}
@Bean
FileEntityRepository fileEntityRepository() {
return mock(FileEntityRepository.class);
}
@Bean
StoredFileEntityRepository storedFileEntityRepository() {
return mock(StoredFileEntityRepository.class);
}
@Bean
BackgroundTaskService backgroundTaskService() {
return mock(BackgroundTaskService.class);
}
@Bean
BackgroundTaskRepository backgroundTaskRepository() {
return mock(BackgroundTaskRepository.class);
}
@Bean
FileShareLinkRepository fileShareLinkRepository() {
return mock(FileShareLinkRepository.class);
}
@Bean
ObjectMapper objectMapper() {
return new ObjectMapper();
}
@Bean
AdminService adminService(UserRepository userRepository,
StoredFileRepository storedFileRepository,
FileBlobRepository fileBlobRepository,
FileService fileService,
PasswordEncoder passwordEncoder,
RefreshTokenService refreshTokenService,
AuthTokenInvalidationService authTokenInvalidationService,
RegistrationInviteService registrationInviteService,
OfflineTransferSessionRepository offlineTransferSessionRepository,
AdminMetricsService adminMetricsService,
StoragePolicyRepository storagePolicyRepository,
StoragePolicyService storagePolicyService,
FileEntityRepository fileEntityRepository,
StoredFileEntityRepository storedFileEntityRepository,
BackgroundTaskRepository backgroundTaskRepository,
BackgroundTaskService backgroundTaskService,
FileShareLinkRepository fileShareLinkRepository,
ObjectMapper objectMapper) {
return new AdminService(
userRepository,
storedFileRepository,
fileBlobRepository,
fileService,
passwordEncoder,
refreshTokenService,
authTokenInvalidationService,
registrationInviteService,
offlineTransferSessionRepository,
adminMetricsService,
storagePolicyRepository,
storagePolicyService,
fileEntityRepository,
storedFileEntityRepository,
backgroundTaskRepository,
backgroundTaskService,
fileShareLinkRepository,
objectMapper
);
}
}
}

View File

@@ -1,5 +1,7 @@
package com.yoyuzh.admin;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.yoyuzh.auth.AuthTokenInvalidationService;
import com.yoyuzh.auth.PasswordPolicy;
import com.yoyuzh.auth.RegistrationInviteService;
import com.yoyuzh.auth.RefreshTokenService;
@@ -20,6 +22,8 @@ import com.yoyuzh.files.policy.StoragePolicyCredentialMode;
import com.yoyuzh.files.policy.StoragePolicyRepository;
import com.yoyuzh.files.policy.StoragePolicyService;
import com.yoyuzh.files.policy.StoragePolicyType;
import com.yoyuzh.files.share.FileShareLinkRepository;
import com.yoyuzh.files.tasks.BackgroundTaskRepository;
import com.yoyuzh.files.tasks.BackgroundTask;
import com.yoyuzh.files.tasks.BackgroundTaskService;
import com.yoyuzh.files.tasks.BackgroundTaskStatus;
@@ -63,6 +67,8 @@ class AdminServiceTest {
@Mock
private RefreshTokenService refreshTokenService;
@Mock
private AuthTokenInvalidationService authTokenInvalidationService;
@Mock
private RegistrationInviteService registrationInviteService;
@Mock
private OfflineTransferSessionRepository offlineTransferSessionRepository;
@@ -77,7 +83,11 @@ class AdminServiceTest {
@Mock
private StoredFileEntityRepository storedFileEntityRepository;
@Mock
private BackgroundTaskRepository backgroundTaskRepository;
@Mock
private BackgroundTaskService backgroundTaskService;
@Mock
private FileShareLinkRepository fileShareLinkRepository;
private AdminService adminService;
@@ -85,10 +95,11 @@ class AdminServiceTest {
void setUp() {
adminService = new AdminService(
userRepository, storedFileRepository, fileBlobRepository, fileService,
passwordEncoder, refreshTokenService, registrationInviteService,
passwordEncoder, refreshTokenService, authTokenInvalidationService, registrationInviteService,
offlineTransferSessionRepository, adminMetricsService,
storagePolicyRepository, storagePolicyService,
fileEntityRepository, storedFileEntityRepository, backgroundTaskService);
fileEntityRepository, storedFileEntityRepository, backgroundTaskRepository,
backgroundTaskService, fileShareLinkRepository, new ObjectMapper());
}
// --- getSummary ---
@@ -258,8 +269,7 @@ class AdminServiceTest {
when(storagePolicyRepository.findById(3L)).thenReturn(Optional.of(existingPolicy));
assertThatThrownBy(() -> adminService.updateStoragePolicyStatus(3L, false))
.isInstanceOf(BusinessException.class)
.hasMessageContaining("默认存储策略不能停用");
.isInstanceOf(BusinessException.class);
verify(storagePolicyRepository, never()).save(any(StoragePolicy.class));
}
@@ -324,7 +334,7 @@ class AdminServiceTest {
assertThatThrownBy(() -> adminService.deleteFile(99L))
.isInstanceOf(BusinessException.class)
.hasMessageContaining("文件不存在");
.hasMessageContaining("file not found");
}
// --- updateUserRole ---
@@ -347,7 +357,7 @@ class AdminServiceTest {
assertThatThrownBy(() -> adminService.updateUserRole(99L, UserRole.ADMIN))
.isInstanceOf(BusinessException.class)
.hasMessageContaining("用户不存在");
.hasMessageContaining("user not found");
}
// --- updateUserBanned ---

View File

@@ -617,3 +617,138 @@
- 服务重启后,只有 lease 已过期或历史上没有 lease 的 `RUNNING` 任务会在启动完成时被重置回 `QUEUED`,避免多实例下误抢仍在运行的 worker。
- 创建成功后的任务 state 使用服务端文件信息,至少包含 `fileId``path``filename``directory``contentType``size`
- 桌面端 `Files` 页面会拉取最近 10 条任务、提供 `QUEUED/RUNNING` 取消按钮,并可为当前选中文件创建 `MEDIA_META` 任务;移动端与 archive/extract 的前端入口暂未接入。
## 2026-04-10 Redis Login-State Invalidation
- 新增可选 Redis 基础设施配置:
- `spring.data.redis.*`:连接参数。
- `app.redis.*`:业务 key prefix、TTL buffer、cache TTL 与命名空间。
-`app.redis.enabled=true` 时,认证链路会启用 Redis 驱动的登录态失效层:
- access token 按 `userId + clientType` 记录“在此时间点之前签发的 token 失效”。
- refresh token 按 hash 写入黑名单TTL 与剩余有效期对齐。
- `POST /api/auth/login``POST /api/auth/register``POST /api/auth/dev-login`:如果是同客户端重新签发登录态,旧 access token 会被写入 Redis 失效层,并继续保留原有 `sid` 会话匹配语义。
- `POST /api/user/password`、管理员封禁/改密/重置密码相关路径:会同时触发 access token Redis 失效标记与数据库 refresh token 撤销。
- `POST /api/auth/refresh`:旧 refresh token 在数据库撤销之外,还会同步写入 Redis 黑名单;先命中黑名单的 token 会被直接拒绝。
- 当 Redis 关闭时,系统会自动回退到原有的数据库 refresh token + `sid` 会话校验语义,不影响本地与 dev 启动。
## 2026-04-10 Redis Files Cache And Upload Runtime
- `GET /api/files/list`
- 对外语义不变,仍使用 `path``page``size` 参数返回当前用户目录分页结果。
-`app.redis.enabled=true` 时,后端会把热点目录页写入 Redis `files:list` cache并通过目录版本号在创建、删除、移动、复制、重命名、恢复、上传完成和导入后做精准失效。
- 搜索结果、回收站列表和后台任务列表不复用这套 key避免不同语义的分页结果互相污染。
- `GET /api/v2/files/upload-sessions/{sessionId}`
- 响应体新增 `runtime` 字段;当 Redis 运行态存在时返回实时上传快照,不存在时返回 `null`,不影响原有会话元数据字段。
- `runtime` 当前包含 `phase``uploadedBytes``uploadedPartCount``progressPercent``lastUpdatedAt``expiresAt`
- 该运行态由后端在会话创建、分片记录、代理上传、完成、取消、失败和过期时刷新,属于短生命周期缓存,不替代数据库里的最终状态。
- `POST /api/files/recycle-bin/{fileId}/restore`
- 外部接口不变,但 Redis 启用时后端会为同一 `fileId` 的恢复流程加分布式锁,避免多实例或并发请求重复恢复同一批条目。
## 2026-04-10 Redis Lightweight Broker First Landing
- 本批次没有新增对外 HTTP API用户可见接口仍沿用现有 `/api/files/**``/api/v2/tasks/**`
- 媒体文件通过网盘主链路落库后,后端现在会在事务提交后向轻量 broker 发布一次 `media-metadata-trigger`。这条触发只用于异步创建后台任务,不直接暴露为额外接口。
- broker 当前只承载“自动补一条 `MEDIA_META` 任务”这一类轻量异步触发,最终执行状态、重试与公开结果仍以 `BackgroundTask` 记录和 `/api/v2/tasks/**` 查询结果为准。
- `POST /api/v2/tasks/media-metadata`
- 用户手动创建任务的接口语义不变。
- 与此同时,媒体文件成功落库后也可能由后端自动补一条同类任务;系统会按 `correlationId` 去重,避免同一文件被 broker 重复创建多条自动任务。
## 2026-04-10 Redis Transfer Session Store
- 本批次没有新增快传 HTTP API`/api/transfer/sessions``/api/transfer/sessions/lookup``/api/transfer/sessions/{sessionId}/join` 与信令轮询接口的对外协议保持不变。
-`app.redis.enabled=true` 时,在线快传 session 会写入 Redis `transfer-sessions` 命名空间,而不再只保存在当前 JVM 进程内;这让 `lookup/join/postSignal/pollSignals` 在多实例部署下具备共享同一在线会话状态的基础。
- session 数据在 Redis 中会同时保存:
- `session:{sessionId}`:完整在线快传运行态快照。
- `pickup:{pickupCode}``pickupCode -> sessionId` 映射。
- Redis 关闭时,系统会自动回退到原有进程内存 store本地和 dev 环境不需要额外 Redis 也能继续运行。
- 离线快传不走这套 Redis store仍继续使用数据库 `OfflineTransferSession` 持久化模型。
## 2026-04-10 Redis File Event Pub/Sub
- `GET /api/v2/files/events?path=/`
- 对外 SSE 协议不变,仍要求登录并支持 `X-Yoyuzh-Client-Id`
- 首次连接仍先收到 `READY` 事件,订阅路径过滤和同 `clientId` 自抑制语义保持不变。
-`app.redis.enabled=true` 时,某个实例在事务提交后写入的文件事件会额外通过 Redis pub/sub 广播到其他实例,因此同一用户连到不同后端实例时也能收到变更通知。
- Redis pub/sub 只传播最小事件快照,不传播 `SseEmitter`、不重写 `FileEvent` 表,也不改变 `FileEvent` 作为审计持久化记录的角色。
- Redis 关闭时会自动回退为原有单实例本地广播行为。
## 2026-04-10 Spring Cache Minimal Landing
- `GET /api/admin/storage-policies`
- 瀵瑰鍗忚涓嶅彉銆?
- 褰?`app.redis.enabled=true` 鏃讹紝鍚庣浼氬皢鏁翠釜瀛樺偍绛栫暐鍒楄〃缂撳瓨鍒?`admin:storage-policies`銆?
- 褰?POST/PUT/PATCH` 瀛樺偍绛栫暐绠$悊鎺ュ彛鍐欏叆鎴愬姛鍚庯紝缂撳瓨浼氱珛鍗宠澶辨晥锛屽悗缁璇锋眰浼氶噸寤烘柊鍒楄〃銆?
- `GET /api/app/android/latest`
- 瀵瑰鍗忚涓嶅彉锛屼粛鏄叕寮€鎺ュ彛銆?
- 褰?`app.redis.enabled=true` 鏃讹紝鍚庣浼氬皢浠?`android/releases/latest.json` 鏋勫缓鍑虹殑 release metadata 鍝嶅簲缂撳瓨鍒?`android:release`銆?
- 杩欎釜缂撳瓨褰撳墠渚濊禆 TTL 鍒锋柊锛屽洜涓?latest metadata 鐨勬洿鏂版潵鑷?Android 鍙戝竷鑴氭湰鍐欏叆瀵硅薄瀛樺偍锛岃€屼笉鏄悗绔唴閮ㄦ煇涓鐞嗗啓鎺ュ彛銆?
- `GET /api/admin/summary`
- 褰撳墠鏆備笉鎺ュ叆 Spring Cache銆?
- 鍘熷洜鏄繖涓?summary 鍚屾椂鍚湁 request count銆乧aily active users銆乭ourly timeline 绛夐珮棰戠粺璁″€硷紝鐢ㄦ樉寮忓け鏁堝緢闅惧湪褰撳墠鏋舵瀯涓嬩繚鎸佸共鍑€璇箟銆?
## 2026-04-10 Spring Cache Minimal Landing Clarification
- `GET /api/admin/storage-policies`
- Response shape is unchanged.
- When `app.redis.enabled=true`, the backend caches the full storage policy list in `admin:storage-policies`.
- Successful storage policy create, update, and status-change writes evict that cache immediately.
- `GET /api/app/android/latest`
- Response shape is unchanged.
- When `app.redis.enabled=true`, the backend caches the metadata response derived from `android/releases/latest.json` in `android:release`.
- Refresh is TTL-based because the metadata is updated by the Android release publish script rather than an in-app admin write endpoint.
- `GET /api/admin/summary`
- This endpoint is intentionally not cached at the moment.
- The response mixes high-churn metrics such as request count, daily active users, and hourly request timeline data, so there is not yet a clean explicit invalidation boundary.
## 2026-04-10 DogeCloud Temporary S3 Session Clarification
- No HTTP API contract changed in this batch.
- The decision for Step 11 is architectural: DogeCloud temporary S3 sessions remain cached per backend instance inside `DogeCloudS3SessionProvider`.
- This does not change upload, download, direct-upload, or multipart endpoint shapes; it only clarifies that cross-instance Redis reuse is intentionally not introduced for these temporary runtime sessions.
## 2026-04-10 Stage 1 Validation Clarification
- No API response shape changed in Step 12.
- Validation confirmed that all new Redis-backed integrations added in Stage 1 still preserve the existing no-Redis API startup path when `app.redis.enabled=false`.
- Local boot also confirmed that the backend now has one explicit non-Redis prerequisite for runtime startup in both default and `dev` profiles: `app.jwt.secret` must be configured via `APP_JWT_SECRET` and cannot be left empty.
- Cross-instance behavior described by earlier Stage 1 notes remains architecturally valid, but it still needs real-environment verification with Redis plus multiple backend instances before being treated as deployment-proven.
## 2026-04-10 Manual Redis Validation Addendum
- No HTTP endpoint shape changed in this addendum either.
- The local two-instance Redis validation did confirm these existing API behaviors in a real runtime flow:
- `POST /api/auth/dev-login` on one instance invalidates the prior access token and refresh token even when the next authenticated read happens on the peer instance.
- `POST /api/transfer/sessions` plus `GET /api/transfer/sessions/lookup` continue to work across instances for online sessions, including after the creating instance is stopped.
- `GET /api/v2/files/events` on instance B receives a `CREATED` event after an authenticated media upload to instance A.
- `GET /api/v2/tasks` on instance B exposes the queued `MEDIA_META` task auto-created by that upload.
- Three backend fixes were internal and did not change API contracts:
- Redis cache serialization/deserialization for file list pages;
- Redis auth revocation cutoff precision;
- non-null `storage_name` persistence for directory creation and normal file upload metadata.
## 2026-04-11 Admin Backend Surface Addendum
- `GET /api/admin/file-blobs`
- Auth: admin only.
- Query params: `page`, `size`, `userQuery`, `storagePolicyId`, `objectKey`, `entityType`.
- Response items expose `FileEntity`-centric blob inspection fields including `objectKey`, `entityType`, `storagePolicyId`, `referenceCount`, `linkedStoredFileCount`, `linkedOwnerCount`, `sampleOwnerUsername`, `sampleOwnerEmail`, `createdByUserId`, `createdByUsername`, `blobMissing`, `orphanRisk`, and `referenceMismatch`.
- This endpoint is for admin diagnostics and migration visibility; it does not replace end-user file reads.
- `GET /api/admin/shares`
- Auth: admin only.
- Query params: `page`, `size`, `userQuery`, `fileName`, `token`, `passwordProtected`, `expired`.
- Response items expose share metadata from `FileShareLink`, plus owner and file summary fields.
- `DELETE /api/admin/shares/{shareId}`
- Auth: admin only.
- Deletes the target `FileShareLink` immediately.
- Intended for operational cleanup and moderation.
- `GET /api/admin/tasks`
- Auth: admin only.
- Query params: `page`, `size`, `userQuery`, `type`, `status`, `failureCategory`, `leaseState`.
- Response items expose task owner identity plus parsed task-state helpers: `failureCategory`, `retryScheduled`, `workerOwner`, and derived `leaseState`.
- `GET /api/admin/tasks/{taskId}`
- Auth: admin only.
- Returns the same admin task response shape as the list endpoint for a single task.

View File

@@ -514,3 +514,132 @@ Android 壳补充说明:
- worker 现在会按失败分类和任务类型做自动重试:失败会归到 `UNSUPPORTED_INPUT``DATA_STATE``TRANSIENT_INFRASTRUCTURE``RATE_LIMITED``UNKNOWN`;其中 `ARCHIVE` 默认最多 4 次、`EXTRACT` 最多 3 次、`MEDIA_META` 最多 2 次,公开 state 会暴露 `attemptCount/maxAttempts/retryScheduled/nextRetryAt/retryDelaySeconds/lastFailureMessage/lastFailureAt/failureCategory`
- 当前仍不包含非 zip 解压格式、缩略图/视频时长任务,以及 archive/extract 的前端入口。
- 桌面端 `front/src/pages/Files.tsx` 已接入最近 10 条后台任务查看与取消入口,并可为当前选中文件创建 `MEDIA_META` 任务;移动端与 archive/extract 的前端入口仍未接入。
## 11. UI 视觉系统与主题引擎 (2026-04-10 升级)
### 11.1 设计语言Stitch Glassmorphism
全站视觉系统已全面转向“Stitch”玻璃拟态 (Glassmorphism) 风格,其核心特征包括:
- **全局背景 (Aurora)**:在 `index.css` 中定义了 `bg-aurora`,结合颜色渐变与动态光晕产生深邃的底色。
- **玻璃面板 (.glass-panel)**:核心 UI 容器均使用半透明背景 (`bg-white/40``bg-black/40`)、高饱和背景模糊 (`backdrop-blur-xl`) 和细腻的白色线条边框 (`border-white/20`)。
- **浮动质感**:通过 `rounded-3xl``rounded-[2rem]` 的大圆角和外阴影增强层叠感。
### 11.2 主题管理 (Theme Engine)
系统内建了一套完整的主题上下文,主要路径为:
- `front/src/components/ThemeProvider.tsx`:提供 `light | dark | system` 主题状态切换与持久化,通过操作 `html` 根节点的 `class` 实现。
- `front/src/components/ThemeToggle.tsx`:全局主题切换按钮组件。
- `front/src/lib/utils.ts`:提供 `cn()` 工具函数,用于处理 Tailwind 类的动态组合与主题适配。
### 11.3 模块适配情况
- **用户侧**:网盘、快传、分享详情、任务列表、回收站均已完成适配。所有表格、卡片和导航栏均已升级为玻璃态。
- **移动端**`MobileLayout` 实现了一套悬浮式玻璃顶部标题栏与底部导航栏,并保持与桌面端一致的光晕背景。
- **管理侧**Dashboard 大盘指标卡片、用户列表、文件审计列表和存储策略列表均已同步升级。
## 12. Redis Foundation (2026-04-10)
- 后端已引入 Spring Data Redis 与 Spring Cache但 Redis 仍是可选基础设施:`app.redis.enabled=false` 时,应用会回退到 no-op token 失效服务与 `NoOpCacheManager`,本地与 dev 环境不需要外部 Redis 也能正常启动与测试。
- Redis 配置拆成两层:
- `spring.data.redis.*`:连接参数。
- `app.redis.*`:业务 key prefix、TTL buffer、cache TTL 与命名空间。
- 当前声明的 Redis 命名空间包括:`cache``auth``transfer-sessions``upload-state``locks``file-events``broker`。本轮真正落地使用的是 `auth`,其余属于后续 Stage 1 边界预留。
- 当前声明的 Spring Cache 名称包括:`files:list``admin:summary``admin:storage-policies``android:release`。本轮只完成了缓存边界与 TTL 骨架,尚未把具体读路径接到这些 cache。
- 认证链路新增 Redis 失效层:
- access token`userId + clientType` 记录“在此时间点之前签发的 token 失效”。
- refresh token按 token hash 写入黑名单TTL 与剩余有效期对齐。
- `JwtAuthenticationFilter` 现在会先检查 access token 是否已被 Redis 失效层拒绝,再继续执行原有的 JWT 校验、用户加载与 `sid` 会话匹配。
- `AuthService``AdminService` 的同端重登、改密、封禁、管理员重置密码路径,现已统一调用这层服务;`RefreshTokenService` 在轮换、过期拒绝与批量撤销时也会同步刷新 refresh token 黑名单。
## 12.1 Redis Foundation Batch 2 (2026-04-10)
- `FileService.list(...)` 现已通过 `FileListDirectoryCacheService` 接入可选 Redis 热目录缓存,当前只缓存 `/api/files/list` 的目录分页结果,不混入搜索、回收站或后台任务列表。
- 热目录缓存使用 `files:list` Spring Cache 命名空间,真实缓存 key 由 `userId + normalized path + page + size + fixed sort context + directory version` 组成;目录版本存放在 Redis KV 中,按目录粒度增量失效,避免全局清空。
- 目录列表失效点已经覆盖 `mkdir`、上传完成、外部导入、回收站删除、回收站恢复、重命名、移动、复制与默认目录补齐,所有变更最终都归一到 `touchDirectoryListings(...)`
- 分布式锁新增 `DistributedLockService` 抽象与 Redis 实现,当前第一批只落在 `FileService.restoreFromRecycleBin(...)`,锁 key 为 `files:recycle-restore:{fileId}`,通过 `SETNX + TTL + owner token` 获取并用 Lua compare-and-delete 释放。
- 上传会话运行态新增 `UploadSessionRuntimeStateService` 抽象与 Redis 实现,短生命周期状态写入 `upload-state` 命名空间;数据库里的 `UploadSession` 继续承担最终事实Redis 只承载创建中、上传中、完成中这类运行态快照。
- `UploadSessionV2Controller` 已把运行态映射到响应体 `runtime` 字段,便于前端轮询时直接读取 phase、已上传字节数、分片数、进度百分比与过期时间而不需要额外拼装临时状态。
## 12.2 Redis Foundation Batch 3 (2026-04-10)
- 轻量 broker 已新增 `LightweightBrokerService` 抽象Redis 启用时使用 `RedisLightweightBrokerService` 把消息写入 Redis listRedis 关闭时回退到 `InMemoryLightweightBrokerService`,继续支持本地单实例开发与测试。
- 这层 broker 明确只服务“小规模、低成本、可接受保守语义”的异步触发,不承担高可靠消息系统职责;任务最终状态、重试、幂等与用户可见结果仍以数据库 `BackgroundTask` 为准。
- 当前 broker 使用 `app.redis.namespaces.broker` 命名空间,首个 topic 为 `media-metadata-trigger`,消息体只携带最小触发上下文:`userId``fileId``correlationId`
- `FileService.saveFileMetadata(...)` 现在会在媒体文件元数据落库后通过 `MediaMetadataTaskBrokerPublisher` 做 after-commit 发布;非媒体文件、目录、缺少必要主键信息的条目不会进入 broker。
- `MediaMetadataTaskBrokerConsumer` 通过定时 drain 方式消费 broker 消息,并调用 `BackgroundTaskService.createQueuedAutoMediaMetadataTask(...)` 创建 `MEDIA_META` 任务;该入口会先按 `correlationId` 去重,再校验文件仍存在、未删除且仍属于媒体文件,避免重复建任务。
- 这批实现的目标是“让轻量 broker 先承担一类真实异步触发”,而不是替代现有 `BackgroundTask` worker也不覆盖文件事件跨实例广播后者仍归 Stage 1 Step 9 处理。
## 12.3 Redis Foundation Batch 4 (2026-04-10)
- 在线快传 session 已从进程内 `ConcurrentHashMap` 提升为可选 Redis 支撑:`TransferSessionStore` 在 Redis 启用时把 session JSON 与 `pickupCode -> sessionId` 映射写入 `transfer-sessions` 命名空间,关闭时自动回退到原有内存模式。
- Redis key 当前按 `session:{sessionId}``pickup:{pickupCode}` 组织TTL 与 session `expiresAt` 对齐并附带 `app.redis.ttlBufferSeconds` 缓冲;因此 Redis 只承载在线快传的短生命周期运行态,不替代离线快传数据库模型。
- `TransferSession` 新增内部快照序列化形状,用于保留 `receiverJoined`、信令队列、cursor 和文件清单等运行期状态;`joinSession``postSignal` 在修改在线 session 后会重新写回 store避免 Redis 模式下只改内存副本而不持久化。
- `TransferService.nextPickupCode()` 现在复用 `TransferSessionStore.nextPickupCode()`Redis 启用时 pickup code 会先在 Redis 映射 key 上做短 TTL 预留,降低多实例并发创建在线快传 session 时的冲突概率。
- 当前 Step 8 只覆盖在线快传 session 的跨实例 lookup/join 基础能力;离线快传仍继续使用 `OfflineTransferSessionRepository`,文件事件广播也仍留在 Step 9。
## 12.4 Redis Foundation Batch 5 (2026-04-10)
- 文件事件跨实例分发现在落地在 Redis pub/sub而不是把 `SseEmitter` 或订阅状态搬进 Redis。每个实例仍只在本地维护 `userId -> subscriptions` 的内存映射SSE 过滤逻辑继续由 `FileEventService` 负责。
- `FileEventService.record(...)` 现在仍然先写 `FileEvent` 表;事务提交后会先向本实例订阅者投递,再通过 `FileEventCrossInstancePublisher` 把最小事件快照发布到 `keyPrefix:file-events:pubsub` topic。
- Redis 开启时,`RedisFileEventPubSubPublisher` 会附带当前实例 `instanceId``RedisFileEventPubSubListener` 在收到消息后会忽略同实例回环消息,只把远端事件重建成 `FileEvent` 并交回 `FileEventService.broadcastReplicatedEvent(...)` 做本地 SSE 投递。
- 这条链路的目标是“跨实例转发已提交的文件事件”,不是高可靠消息系统:它不重放历史事件,不替代 `FileEvent` 表持久化,也不承担断线补偿;真正的事件审计事实源仍然是数据库。
- Redis 关闭时,`NoOpFileEventCrossInstancePublisher` 会让行为自动回退为原有单实例本地广播dev 与本地测试环境不需要额外 Redis 也能继续运行。
## 12.5 Redis Foundation Batch 6 (2026-04-10)
- Spring Cache 鍦ㄨ繖涓€鎵规寮忔帴鍏ヤ簡涓ょ被楂樿浣庡啓璇昏矾寰勶細`AdminService.listStoragePolicies()` 浣跨敤 `admin:storage-policies`锛宍AndroidReleaseService.getLatestRelease()` 浣跨敤 `android:release`銆?
- 瀛樺偍绛栫暐鍒楄〃鐨勭紦瀛樺け鏁堢偣鏄槑纭殑绠$悊鍐欒矾寰勶細鍒涘缓銆佺紪杈戙€佸惎鍋滈兘鍦?`AdminService` 涓婄洿鎺?evict锛屼笉鎶婂叾浠栫敤鎴疯矾寰勬垨鏂囦欢璇昏矾寰勬贩杩涘悓涓€ cache銆?
- Android release metadata 鍒欐槸 TTL 椹卞姩鐨勭紦瀛橈細鏁版嵁婧愪粛鏄璞″瓨鍌ㄧ殑 `android/releases/latest.json`锛屽悗绔彧缂撳瓨鏋勫缓鍚庣殑 `AndroidReleaseResponse`锛屼笉缂撳瓨 APK 鍒嗗彂瀛楄妭娴併€?
- `admin summary` 缁忚瘎浼板悗鏆備笉鎺ュ叆缂撳瓨锛屽洜涓鸿繖涓?DTO 鍚屾椂缁勫悎浜嗛珮棰戝彉鍖栫殑 request metrics銆佹瘡鏃ユ椿璺冪敤鎴风粺璁″拰閭€璇风爜绛夊€硷紝鐩墠娌℃湁涓€涓共鍑€鐨勬樉寮忓け鏁堣竟鐣岄€傚悎鎶婂畠鏀惧叆 Spring Cache銆?
## 12.5 Redis Foundation Batch 6 Clarification (2026-04-10)
- Spring Cache is now active on two high-read, low-write backend read paths.
- `AdminService.listStoragePolicies()` uses cache `admin:storage-policies`.
- `AndroidReleaseService.getLatestRelease()` uses cache `android:release`.
- Storage policy cache invalidation is explicit and tied to admin create, update, and status-change writes.
- Android release metadata uses TTL-based refresh because the source of truth is object storage metadata at `android/releases/latest.json`, updated by the release publish script rather than an in-app write path.
- APK byte streaming remains uncached; only the metadata response is cached.
- `admin summary` remains uncached by design because it mixes several high-churn metrics and does not yet have a clean invalidation boundary.
## 12.6 Redis Foundation Batch 7 Clarification (2026-04-10)
- `DogeCloudS3SessionProvider` intentionally remains a per-instance in-memory cache instead of moving to Redis.
- The cached object is not just raw temporary credentials; it is a live runtime session containing `S3Client` and `S3Presigner`, both of which have local lifecycle and cleanup semantics.
- Because of that, a Redis-backed shared cache would either have to cache only raw credential material and rebuild SDK clients locally anyway, or attempt to share values that are not meaningful across JVM instances.
- The current design keeps refresh ownership local to each backend instance: if cached credentials are still outside the one-minute refresh window, the existing runtime session is reused; once inside that window, the old runtime session is closed and a fresh one is fetched and rebuilt.
- This leaves some duplicate DogeCloud temporary-token fetches in multi-instance deployments, but the current plan judges that cost lower than the added complexity and secret-handling surface of a Redis shared-credential cache.
## 12.7 Redis Foundation Batch 8 Clarification (2026-04-10)
- Stage 1 validation closed with two local checks: full backend test regression and a Redis-disabled `dev` boot-path check.
- The local boot-path check matters because Redis integration is optional by design. With `APP_REDIS_ENABLED=false`, the application still starts as a normal single-instance backend once mandatory base config such as `APP_JWT_SECRET` is present.
- In the validated local path, the backend started successfully on an alternate port (`18081`) under the `dev` profile, using H2 and no Redis dependency.
- Therefore the current architecture boundary remains unchanged: Redis augments cache, pub/sub, lock, broker, and short-lived runtime state when enabled, but it is not a required baseline component for local development or single-instance fallback.
- The architecture still has explicit environment-bound gaps that were not closed in-process: real Redis reliability/TTL observation and cross-instance propagation timing for file events, lightweight broker delivery, upload runtime state, and transfer-session sharing.
## 12.8 Manual Redis Validation Clarification (2026-04-10)
- The later manual validation pass did exercise real local Redis plus two backend instances, so several Stage 1 architecture claims are now locally runtime-validated rather than only unit/integration-tested.
- Verified runtime behaviors:
- auth token invalidation survives cross-instance login churn;
- online transfer runtime state survives loss of the creating instance;
- file events can cross instances through the SSE path when a real uploaded file triggers a `CREATED` event;
- the lightweight broker can auto-create a queued `MEDIA_META` task after a media upload and that task is visible from the peer instance.
- The Redis file list cache architecture also needed one implementation detail clarified: Spring Cache may hand back generic decoded maps from Redis, so `RedisFileListDirectoryCacheService` now treats cache-value reconstruction as an application concern instead of assuming a strongly typed cache provider result.
- The persistence model also still carries `portal_file.storage_name` as a required column in the live schema, so even after blob/entity migration work the backend must continue writing a non-null legacy storage name for directories and uploaded files until a later schema migration explicitly removes that requirement.
- One environment gap remains: local `redis-cli` key inspection did not reveal the expected keys during probing even while cross-instance behavior proved shared runtime state was active. That means the current architectural confidence comes from observable runtime behavior, not from direct local key-space inspection.
## Debugging Discipline
- Use short bounded probes first when validating network, dependency, or startup issues. Prefer commands such as `curl --max-time`, `mvn -q`, `mvn dependency:get`, `apt-get update`, and similar narrow checks before launching long-running downloads or full test runs.
- Do not wait indefinitely on a stalled download or progress indicator. If a command appears stuck, stop and re-check DNS, proxy inheritance, mirror reachability, and direct-vs-proxy routing before retrying.
- For WSL debugging, verify the proxy path and the direct path separately, then choose the shortest working route. Do not assume a mirror problem until the network path has been isolated.
- Use domestic mirrors as a delivery optimization, not as a substitute for diagnosis. First determine whether the failure is caused by DNS, proxy configuration, upstream availability, or the mirror itself.
## 12.9 Admin Backend Surface Clarification (2026-04-11)
- The admin module now covers four distinct backend inspection domains:
- user and summary management;
- logical file management;
- storage policy management and migration task creation;
- operational inspection for file blobs, shares, and background tasks.
- `GET /api/admin/file-blobs` is architected around `FileEntity` plus `StoredFileEntity` relations instead of around `StoredFile` rows. This keeps the admin surface aligned with the newer object/entity model and lets operators inspect storage-policy ownership, reference counts, and missing-object anomalies before the legacy read path is retired.
- `GET /api/admin/shares` and `DELETE /api/admin/shares/{shareId}` sit on top of `FileShareLinkRepository` and are intended as operational controls for share hygiene rather than end-user sharing flows.
- `GET /api/admin/tasks` and `GET /api/admin/tasks/{taskId}` sit on top of `BackgroundTaskRepository` and parse structured fields out of `publicStateJson` so the admin UI can inspect failure category, retry scheduling, worker owner, and lease freshness without re-implementing backend parsing rules.
- This batch does not change the current production read-path boundary: download, share detail, recycle-bin, and zip flows still read from `StoredFile.blob`, while `FileEntity` and `StoredFile.primaryEntity` continue to carry migration-oriented metadata for newer admin and storage-policy workflows.

View File

@@ -0,0 +1,712 @@
# Cloudreve Gap Next-Phase Upgrade Plan
> **For agentic workers:** REQUIRED: Use `superpowers:executing-plans` or `superpowers:subagent-driven-development` when implementing this plan. Keep the checkbox state updated as work lands.
**Goal:** 在不偏离当前产品方向的前提下,把项目中“对比 Cloudreve 仍明显缺失”的能力拆成可执行的后续升级阶段,优先补齐最能提升网盘完成度和平台化能力的部分,而不是盲目追平 Cloudreve 的全部生态。
**Repository:** `C:\Users\yoyuz\Documents\code\my_site`
**Decision:** 当前项目已经完成 v2 上传会话、存储策略、分享二期、搜索骨架、SSE 文件事件、后台任务骨架、回收站、Android 壳和前后端视觉重构。后续计划只覆盖“尚未完成且仍值得做”的能力,不重复规划已经落地的阶段。
## 1. Current Baseline
下列能力已完成,不应重新当作“待做”:
- v2 upload session 已支持 `PROXY` / `DIRECT_SINGLE` / `DIRECT_MULTIPART`
- 存储策略管理、迁移任务和策略能力声明已落地
- 分享二期、文件搜索、文件事件 SSE、后台任务框架已落地
- 回收站、媒体元数据任务、桌面端任务面板已落地
- 前后端 UI 已完成一次系统性重构
当前仍明确未完成或仅完成一半的点:
- 后端尚未接入 Redis当前没有 Spring Cache也没有跨实例缓存/会话总线
- 移动端文件搜索未接入
- `ARCHIVE` / `EXTRACT` 前端入口未接入,移动端任务入口也未接入
- 旧下载/分享详情/ZIP/回收站读取路径仍依赖 `StoredFile.blob`,尚未切到 `primaryEntity`
- 仅有媒体元数据提取,没有缩略图、视频时长、预览资源管线
- 没有 WebDAV
- 没有远程离线下载器能力
- 没有 OIDC / OAuth scope / 桌面同步客户端协议
- 不建议当前阶段直接做完整 WOPI / Office 在线协作
## 2. Scope And Priority
### P0: 先补齐现有平台里的断点
这部分不做新产品线,只把已经有骨架但没闭环的能力补完整:
1. Redis 基础接入与缓存边界落地
2. 移动端搜索接入
3. `ARCHIVE` / `EXTRACT` 前端入口
4. 移动端任务入口
5. 旧读取路径从 `StoredFile.blob` 迁到 `primaryEntity`
### Admin Console Alignment
参考成熟项目的后台目录,当前项目后续管理台不应只停留在 `dashboard / users / files / storage-policies` 四类资源,而应逐步演进为以下信息架构:
1. 面板首页
2. 参数设置
3. 文件系统
4. 存储策略
5. 节点
6. 用户组
7. 用户
8. 文件
9. 文件 Blob
10. 分享
11. 后台任务
12. 订单
13. 事件
14. 滥用举报
15. OAuth 应用
其中适合当前项目近期推进的只有:
- 参数设置
- 文件系统
- 存储策略
- 用户
- 文件
- 文件 Blob
- 分享
- 后台任务
- OAuth 应用(先预留,不急于完整实现)
当前阶段明确延后:
- 节点
- 用户组
- 订单
- 事件独立审计中心
- 滥用举报
### P1: 预览与媒体管线
这是最值得补的 Cloudreve 差距:
1. 图片缩略图
2. 视频 poster / 时长
3. 文件列表与详情中的预览消费
4. 失败状态与 metadata 持久化
### P2: WebDAV 最小可用版
只做单用户私有网盘最小读写,不提前做复杂共享挂载、锁协商和第三方 scope。
### P3: 生态扩展 backlog
这部分先保留为后续阶段,不在最近一轮升级中直接开工:
1. 远程离线下载
2. OIDC / OAuth scope
3. 桌面同步客户端协议
4. WOPI / Office 在线协作
## 3. Non-Goals
- 不把项目改造成 Cloudreve 克隆
- 不新增与当前业务方向不一致的组织/团队协作大系统
- 不在当前阶段接入完整 WOPI
- 不为了“对齐功能表”而重做现有快传业务
- 不引入仓库中不存在的验证命令
---
## 4. Stage 1: Redis Cache Foundation
**Goal:** 为后端引入 Redis先解决真正适合走缓存或跨实例共享的状态不把所有内存结构机械搬过去。
**Why now:**
- 当前后端没有 `spring-boot-starter-data-redis`
- `TransferSessionStore` 仍是进程内 `ConcurrentHashMap`
- `FileEventService` 的订阅和广播只在单实例内有效
- `DogeCloudS3SessionProvider` 只有本机进程内临时会话缓存
- 你已经明确希望 Redis 承担登录态 / token 黑名单、热门目录缓存、分布式锁、上传状态缓存和小规模队列 broker
**Files likely involved:**
- `backend/pom.xml`
- `backend/src/main/resources/application.yml`
- `backend/src/main/resources/application-dev.yml`
- `backend/src/main/java/com/yoyuzh/config/*`
- `backend/src/main/java/com/yoyuzh/transfer/*`
- `backend/src/main/java/com/yoyuzh/files/events/*`
- `backend/src/main/java/com/yoyuzh/files/storage/*`
- `backend/src/test/java/com/yoyuzh/**`
- `docs/architecture.md`
- `docs/api-reference.md`
- `memory.md`
- [x] **Step 1: 接入 Spring Data Redis 与配置骨架**
- 新增 Redis 依赖
-`application.yml` / `application-dev.yml` 加入 `spring.data.redis.*``app.redis.*`
- Redis 必须允许关闭,不强制 dev 环境依赖外部服务
- [x] **Step 2: 明确缓存分层,不做一锅炖**
- `Spring Cache`:用于热门目录和热点只读查询
- `Redis KV`用于登录态派生状态、token 黑名单、快传会话、上传状态
- `Redis Lock`:用于分布式锁
- `Redis Pub/Sub`:用于多实例文件事件分发
- `Redis List/Stream` 或轻量队列表:用于小规模 broker
- 不把 JPA 实体直接无脑全量缓存
- [x] **Step 3: 接入登录态 / token 黑名单**
- 保持当前 JWT + refresh token 主模型不变
- 新增 access token / refresh token 撤销或踢下线后的 Redis 黑名单能力
- 黑名单 TTL 与 token 剩余有效期对齐,避免永久堆积
- 用户改密、封禁、管理员重置密码、同端挤下线等场景统一走这套失效机制
- [x] **Step 4: 接入热门目录缓存**
- 优先缓存 `/api/files/list` 的热点目录页结果,而不是所有目录
- 缓存 key 至少包含 `userId + path + page + size + sort context`
- 文件创建、删除、移动、重命名、恢复、导入、上传完成后精准失效相关目录
- 不让搜索结果、回收站列表和任务列表混进同一套 key
- [x] **Step 5: 接入分布式锁**
- 先覆盖会发生并发冲突或重复执行风险的路径
- 优先考虑上传完成、存储策略迁移、后台任务 claim / retry、回收站恢复、目录批量导入
- 锁必须带 TTL 和 owner 标识,避免死锁
- 不用 Redis 锁去替代数据库事务
- [x] **Step 6: 接入上传状态缓存**
- 用于保存上传中的短生命周期状态,而不是取代数据库中的最终事实
- 适合承载 chunk 进度、最近心跳、瞬时速度、前端轮询状态
- `UploadSession` 仍保留数据库持久化和最终完成语义
- Redis 状态过期后不应影响已完成或已失败的最终结果判断
- [x] **Step 7: 引入小规模队列 broker**
- 目标不是替代当前数据库任务系统,而是给轻量异步链路和跨实例触发提供 broker
- 优先承载文件事件转发、缩略图触发、媒体处理触发、低成本异步通知
- 当前规模下可接受 Redis broker但要明确“不是高可靠消息系统”
- 大任务最终状态仍以数据库 `BackgroundTask` 为准
- 2026-04-10 首批落地先收敛到“媒体文件落库后的 `MEDIA_META` 自动触发”,文件事件跨实例广播仍留给 Step 9 的 Redis pub/sub
- [x] **Step 8: 把 `TransferSessionStore` 改成 Redis 支撑的 session store**
- 替换当前本地 `ConcurrentHashMap`
- 保持过期清理和 pickup code 查询语义不变
- 让在线快传在多实例下仍可 lookup/join
- 2026-04-10 当前实现为“Redis 启用时在线快传 session 走 Redis KV关闭时自动回退到进程内存”离线快传仍继续走数据库持久化链路
- [x] **Step 9: 接入文件事件跨实例分发**
- 保留当前单实例 emitter 管理
- 新增 Redis pub/sub把事务提交后的文件事件广播到其他实例
- 避免把 `SseEmitter` 本身存进 Redis
- [x] **Step 10: 评估并最小落地 Spring Cache**
- 优先考虑热门目录、`admin summary`、存储策略列表、Android 最新发布元数据等高读低写接口
- 每个缓存都要有明确失效策略,不能只加 `@Cacheable`
- [x] **Step 11: 审慎处理 DogeCloud 临时 S3 会话缓存**
- 若多实例下重复拉临时 token 成本可接受,则保留本地内存缓存
- 若需要跨实例复用,再单独加 Redis 缓存,不与业务缓存混用
- [x] **Step 12: 验证**
- `cd backend && mvn test`
- 手动验证无 Redis 时应用仍可启动
- 手动验证启用 Redis 后快传在线会话可创建、lookup、join、过期
- 手动验证踢下线 / 改密后旧 token 失效
- 手动验证热门目录缓存命中与目录变更后失效
- 手动验证多实例下文件事件能跨实例到达
- 手动验证任务重复 claim 不会发生明显并发冲突
**Exit criteria:**
- Redis 成为可选但可用的基础设施
- 登录态 / token 黑名单已接入 Redis
- 至少一个真实热点目录查询接入 Redis/Spring Cache
- 至少一个高风险并发路径接入分布式锁
- 上传中的短生命周期状态已进入 Redis
- 小规模 broker 已承担至少一类轻量异步触发
- 在线快传会话不再依赖单进程内存
- 文件事件具备跨实例扩展边界
---
## Admin Track: Backend Management Surface
**Goal:** 按更成熟的后台目录,把当前项目后端管理能力补成“资源可观测、可管理、可扩展”的体系,而不是继续把所有管理功能堆进单一 summary 页面。
### Admin-B1: Parameter Settings
**Goal:** 新增“参数设置”资源,集中管理当前散落在配置和管理台中的系统开关。
**Recommended internal sections:**
1. 站点信息
2. 用户会话
3. 验证码
4. 媒体处理
5. 增值服务
6. 邮件
7. 队列
8. 外观
9. 事件
10. 服务器
**Current-project recommendation:**
- 近期应实现:
- 站点信息
- 用户会话
- 媒体处理
- 队列
- 外观
- 服务器
- 可先留空壳或只读:
- 验证码
- 邮件
- 事件
- 当前不建议投入:
- 增值服务
**Suggested scope:**
- 注册与邀请策略
- 离线快传总上限
- 默认上传大小限制
- 站点显示参数
- 媒体处理开关
- Redis / runtime 只读状态
- [ ] **Step 1: 设计参数设置 DTO 与权限边界**
- [ ] **Step 2: 先拆站点信息子分组**
- 站点名称
- 站点描述
- 主站点 URL
- 备用站点 URL
- 页脚代码
- 登录公告 / 站点公告
- 使用条款 / 隐私政策链接
- [ ] **Step 3: 设计用户会话子分组**
- access / refresh 生命周期
- 同端挤下线策略
- token 黑名单开关与 TTL 策略
- 登录安全相关策略
- [ ] **Step 4: 设计媒体处理子分组**
- 媒体元数据提取开关
- 缩略图开关
- 视频 poster / 时长提取策略
- 第三方依赖状态只读信息
- [ ] **Step 5: 设计队列子分组**
- broker 类型
- worker 并发
- 失败重试预算
- 队列健康状态只读信息
- [ ] **Step 6: 设计外观与服务器子分组**
- 前端品牌化字段
- CDN / 静态资源缓存参数
- 服务器运行信息、Redis 状态、存储后端状态
- [ ] **Step 2: 暴露管理员参数读取与更新接口**
- [ ] **Step 3: 只允许修改当前可安全热更新的参数**
- [ ] **Step 4: 文档化哪些配置仍需环境变量或重启**
### Admin-B2: File System
**Goal:** 把“文件系统”作为独立后台资源,而不是只在文件列表里做删除。
**Recommended internal sections:**
1. 参数设置
2. 全文搜索
3. 文件图标
4. 文件浏览应用
5. 自定义属性
**Current-project recommendation:**
- 近期应实现:
- 参数设置
- 文件图标
- 自定义属性
- 可先做只读骨架:
- 文件浏览应用
- 当前延后:
- 全文搜索
**Suggested scope:**
- 默认存储后端概览
- 上传模式能力矩阵
- 媒体处理能力状态
- 热门目录 / 缓存状态概览
- WebDAV 预留状态
- [ ] **Step 1: 设计文件系统只读总览接口**
- [ ] **Step 2: 设计文件系统参数设置子分组**
- 文档在线编辑最大大小
- 回收站扫描间隔
- 文件 Blob 回收间隔
- 静态资源缓存 TTL
- 文件列表分页方式
- 最大分页大小
- 最大批量操作数量
- 最大递归搜索数量
- 地图提供商
- [ ] **Step 3: 设计文件图标子分组**
- 扩展名到图标的映射策略
- 前端图标主题扩展点
- 自定义 mime/icon 映射入口
- [ ] **Step 4: 设计文件浏览应用子分组**
- 当前阶段只读显示已接入的浏览/预览能力
- 后续为 WOPI / Office / 媒体浏览器预留入口
- [ ] **Step 5: 设计自定义属性子分组**
- 标签 schema
- metadata key 命名约束
- 可搜索属性白名单
- [ ] **Step 6: 暴露上传模式、缓存、媒体处理、WebDAV 状态**
- [ ] **Step 7: 管理台可按模块查看文件系统运行态**
### Admin-B3: File Blob
**Goal:** 既然项目已经有 `FileBlob` / `FileEntity`,后台必须能查看物理对象层,而不是只看逻辑文件。
**Suggested scope:**
- Blob / Entity 基本信息
- object key
- storage policy
- reference count
- orphan 风险
- 派生实体类型:`VERSION` / `THUMBNAIL` / `TRANSCODE`
- [ ] **Step 1: 增加管理员 Blob/Entity 列表接口**
- [ ] **Step 2: 支持按用户、策略、对象 key、实体类型过滤**
- [ ] **Step 3: 标注高风险条目,如引用异常、迁移失败残留**
### Admin-B4: Share
**Goal:** 分享需要成为独立后台资源,便于管理滥用和过期内容。
- [ ] **Step 1: 管理员分享列表接口**
- [ ] **Step 2: 支持按用户、文件名、token、是否密码保护、是否过期过滤**
- [ ] **Step 3: 支持管理员撤销分享**
### Admin-B5: Background Tasks
**Goal:** 后台任务不能只在用户视角可见,管理员也要能看全局任务池。
- [ ] **Step 1: 增加管理员任务列表与详情接口**
- [ ] **Step 2: 支持按任务类型、状态、失败分类、租约状态过滤**
- [ ] **Step 3: 支持查看任务归属用户、重试信息、锁/worker 信息**
### Admin-B6: OAuth Apps
**Goal:** 为未来 WebDAV / 第三方客户端 / OIDC 留出后台资源位。
- [ ] **Step 1: 当前阶段只预留数据模型与只读列表边界**
- [ ] **Step 2: 不急于完整实现授权流程**
- [ ] **Step 3: 在文档中明确这是后续阶段入口**
---
## 5. Stage 2: Close Existing v2 Gaps
**Goal:** 把现有架构中的“半完成状态”补成真正可用的闭环,优先提升当前产品完成度。
**Files likely involved:**
- `front/src/pages/Files.tsx`
- `front/src/mobile-pages/MobileFiles.tsx`
- `front/src/lib/file-search.ts`
- `front/src/lib/file-events.ts`
- `front/src/lib/upload-session.ts`
- `front/src/lib/api.ts`
- `front/src/mobile-components/*`
- `backend/src/main/java/com/yoyuzh/files/core/*`
- `backend/src/main/java/com/yoyuzh/files/tasks/*`
- `backend/src/main/java/com/yoyuzh/files/search/*`
- `backend/src/test/java/com/yoyuzh/files/**`
- `front/src/**/*.test.ts`
- [ ] **Step 1: 移动端接入 v2 文件搜索**
- 复用现有 `front/src/lib/file-search.ts`
- 保持与桌面端相同的查询参数和空态行为
- 不把搜索结果写回目录缓存
- [ ] **Step 2: 桌面端补齐 `ARCHIVE` / `EXTRACT` 入口**
- 从当前选中文件直接创建任务
- 在任务面板中区分 `ARCHIVE``EXTRACT``MEDIA_META`
- 错误态展示后端返回的任务失败原因
- [ ] **Step 3: 移动端补齐任务入口**
- 至少支持查看最近任务
- 支持取消 `QUEUED` / `RUNNING`
- 支持为选中文件创建 `MEDIA_META`
- 如交互成本可控,再接 `ARCHIVE` / `EXTRACT`
- [ ] **Step 4: 把旧读取路径从 `StoredFile.blob` 迁到 `primaryEntity`**
- 覆盖下载、ZIP、分享详情、回收站、媒体元数据读取等旧路径
- 保留兼容 fallback直到历史数据回填验证完成
- 明确哪些 API 已完全不依赖 `blob`
- [ ] **Step 5: 更新相关测试**
- 后端补读取路径切换和任务入口相关测试
- 前端补移动端搜索和任务面板交互测试
- [ ] **Step 6: 验证**
- `cd backend && mvn test`
- `cd front && npm run test`
- `cd front && npm run lint`
- `cd front && npm run build`
**Exit criteria:**
- 桌面与移动端都能搜索
- 桌面端能直接发起 archive/extract
- 移动端至少能消费任务能力
- 旧读取链路完成 `primaryEntity` 主读切换
---
## 6. Stage 3: Thumbnail And Rich Media Pipeline
**Goal:** 把后台任务骨架扩展成真正可感知的媒体处理系统,这是当前项目相对 Cloudreve 最有价值的缺口。
**Files likely involved:**
- `backend/src/main/java/com/yoyuzh/files/tasks/*`
- `backend/src/main/java/com/yoyuzh/files/storage/*`
- `backend/src/main/java/com/yoyuzh/files/core/*`
- `backend/src/main/java/com/yoyuzh/files/policy/*`
- `backend/src/main/java/com/yoyuzh/files/**/FileMetadata*.java`
- `front/src/pages/Files.tsx`
- `front/src/mobile-pages/MobileFiles.tsx`
- `front/src/lib/types.ts`
- `front/src/components/**/*`
- `backend/src/test/java/com/yoyuzh/files/**`
- [ ] **Step 1: 增加 `THUMBNAIL` 任务类型与派生实体写入**
- 使用 `FileEntity` 挂接缩略图实体,不再把缩略图视作普通文件
- 失败时写入可辨识 metadata避免前端无限重试
- [ ] **Step 2: 图片缩略图生成**
- 为常见图片格式生成小尺寸预览
- 支持本地存储和当前 S3 兼容策略
- [ ] **Step 3: 视频 poster 和时长提取**
- 至少落地视频时长
- poster 可以先做单帧封面,不要求完整 HLS
- [ ] **Step 4: 前端列表和详情接入缩略图**
- 图片和视频列表显示缩略图
- 详情侧栏显示时长、尺寸、编码等已有 metadata
- 缩略图不可用时回退到文件图标
- [ ] **Step 5: 能力与策略对齐**
- 明确哪些策略支持原生缩略图、哪些需要代理生成
- 管理台可查看缩略图相关 capability 和任务状态
- [ ] **Step 6: 验证**
- `cd backend && mvn test`
- `cd front && npm run test`
- `cd front && npm run lint`
- `cd front && npm run build`
**Exit criteria:**
- 上传图片后可自动生成缩略图
- 上传视频后可得到最小媒体信息和 poster/时长
- 文件列表和详情可真实消费这些资源
---
## 7. Stage 4: Metadata, Labels, And Search Expansion
**Goal:**`FileMetadata` 不再只是骨架,真正承担标签、预览状态和搜索过滤。
**Files likely involved:**
- `backend/src/main/java/com/yoyuzh/files/search/*`
- `backend/src/main/java/com/yoyuzh/files/core/*`
- `backend/src/main/java/com/yoyuzh/files/**/FileMetadata*.java`
- `backend/src/test/java/com/yoyuzh/files/search/*`
- `front/src/lib/file-search.ts`
- `front/src/pages/Files.tsx`
- `front/src/mobile-pages/MobileFiles.tsx`
- [ ] **Step 1: 明确 metadata key 规范**
- `media:*`
- `thumb:*`
- `tag:*`
- `sys:*`
- [ ] **Step 2: 扩展 v2 搜索过滤能力**
- 标签过滤
- 媒体类型过滤
- 缩略图/预览状态过滤
- [ ] **Step 3: 前端增加高级搜索入口**
- 桌面端先做
- 移动端至少支持基础筛选
- [ ] **Step 4: 为后续 `shared_with_me` / `trash` 风格视图保留统一查询边界**
- 当前不必完全复制 Cloudreve File URI
- 但内部查询层应避免继续散落在多套 service 方法里
- [ ] **Step 5: 验证**
- `cd backend && mvn test`
- `cd front && npm run test`
- `cd front && npm run lint`
**Exit criteria:**
- `FileMetadata` 真正进入用户能力层
- 搜索不再只停留在文件名和固定字段
---
## 8. Stage 5: WebDAV Minimum Viable Support
**Goal:** 提供最小可用的 WebDAV 能力,优先服务系统文件管理器挂载和简单同步场景。
**Files likely involved:**
- `backend/src/main/java/com/yoyuzh/config/*`
- `backend/src/main/java/com/yoyuzh/files/core/*`
- `backend/src/main/java/com/yoyuzh/files/upload/*`
- `backend/src/main/java/com/yoyuzh/auth/*`
- `backend/src/test/java/com/yoyuzh/**`
- `docs/api-reference.md`
- `docs/architecture.md`
- [ ] **Step 1: 设计 WebDAV 路径边界**
- 首阶段只暴露当前登录用户自己的网盘根目录
- 不做共享目录挂载
- [ ] **Step 2: 实现最小方法集**
- `PROPFIND`
- `GET`
- `PUT`
- `DELETE`
- `MKCOL`
- `MOVE`
- [ ] **Step 3: 复用现有文件服务和上传会话**
- WebDAV `PUT` 不绕过容量检查、权限检查和存储策略
- 避免做一套独立写入链路
- [ ] **Step 4: 明确认证方式**
- 首阶段优先 Basic + token/应用密码式接入,避免直接复用浏览器 JWT 语义
- 认证模型必须先文档化,再写实现
- [ ] **Step 5: 验证**
- `cd backend && mvn test`
- 手动验证 Windows 或 macOS WebDAV 客户端的列目录、上传小文件、下载、创建目录、删除
**Exit criteria:**
- 常见 WebDAV 客户端能完成最小读写
- 不引入绕过现有业务规则的旁路实现
---
## 9. Deferred Backlog
这些项目保留到 Stage 4 之后重新评估,不在当前一轮升级中直接实现:
- [ ] **Remote Download**
- 目标:类似 Cloudreve 的离线下载器
- 前置:后台任务、存储策略、文件实体、容量模型稳定
- [ ] **OIDC / OAuth Scope**
- 目标:第三方客户端和更细粒度授权
- 前置WebDAV 或开放客户端需求真实存在[text](app://-/index.html?hostId%3Dlocal)
- [ ] **Desktop Sync Protocol**
- 目标:桌面同步客户端
- 前置文件事件、Redis 跨实例广播、冲突策略、WebDAV 或专有同步协议边界明确
- [ ] **WOPI / Office Online**
- 目标:在线 Office 协作
- 前置:权限、锁、预览、第三方接入边界成熟
## 10. Recommended Execution Order
1. Stage 1: 先接 Redis 基础设施
2. Stage 2: 再补现有闭环断点
3. Stage 3: 再做缩略图和 richer media
4. Stage 4: 让 metadata/search 真正变成平台能力
5. Stage 5: 最后接 WebDAV
5. Deferred backlog: 仅在真实需求出现后再启动
## 11. Documentation Follow-Up
每完成一个阶段,都必须同步更新:
- `memory.md`
- `docs/architecture.md`
- `docs/api-reference.md`
更新内容至少包括:
- 新增能力边界
- 已废弃或迁移的旧路径
- 验证命令与已知限制
- 任何新的部署或运行时前置条件
## 2026-04-10 Stage 1 Step 9 Landing Note
- 已落地 `FileEventCrossInstancePublisher` + Redis pub/sub listener/publisher本实例继续维护本地 `SseEmitter` 集合,提交后先做本地广播,再向 `app.redis.namespaces.file-events` 对应 topic 发布事件。
- 远端实例收到消息后只做本地 SSE 投递,不重复写 `FileEvent` 表;同实例消息按 `instanceId` 忽略,避免本机回环重复推送。
- Redis 关闭时自动回退为原有单实例本地广播语义。
## 2026-04-10 Stage 1 Step 10 Landing Note
- 宸插皢 `AdminService.listStoragePolicies()` 鎺ュ叆 `admin:storage-policies` Spring Cache锛屽苟鍦?`createStoragePolicy/updateStoragePolicy/updateStoragePolicyStatus` 涓夋潯绠$悊鍐欒矾寰勪笂鍋?all-entries eviction锛岀淇濆悗鍙板瓨鍌ㄧ瓥鐣ュ垪琛ㄥ懡涓紦瀛樺悗浠嶈兘鍦ㄧ畝鍗曞啓鎿嶄綔鍚庣珛鍗虫仮澶嶄负鏂版暟鎹€?
- 宸插皢 `AndroidReleaseService.getLatestRelease()` 鎺ュ叆 `android:release` Spring Cache锛屽綋鍓嶉噰鐢?TTL 鍨嬪け鏁堢瓥鐣ワ紝鍥犱负 release metadata 鏇存柊鏉ヨ嚜瀵硅薄瀛樺偍澶栭儴鍙戝竷鑴氭湰锛屼粨搴撳唴娌℃湁鍚屾簮鍐欏叆璺緞銆?
- `admin summary` 缁忚瘎浼板悗鏆備笉鎺ュ叆 Spring Cache锛屽洜涓哄叾鍚屾椂鍖呭惈 request count銆乨aily active users銆乭ourly timeline 绛夐珮棰戠粺璁★紝鍋氭樉寮忓け鏁堝緢闅句繚璇佽涔夊共鍑€锛屽洜姝ゅ湪杩欎竴姝ユ槑纭帓闄ゃ€?
## 2026-04-10 Stage 1 Step 10 Clarification
- `AdminService.listStoragePolicies()` now uses Spring Cache `admin:storage-policies`.
- Successful storage policy create, update, and status-change writes evict that cache explicitly.
- `AndroidReleaseService.getLatestRelease()` now uses Spring Cache `android:release`.
- Android release metadata refresh is TTL-based because `android/releases/latest.json` is updated by the external release publish script.
- `admin summary` was evaluated and intentionally left uncached because it includes high-churn metrics without a clean explicit invalidation boundary.
## 2026-04-10 Stage 1 Step 11 Clarification
- `DogeCloudS3SessionProvider` remains an in-process runtime cache and is not moved into Redis.
- The cached value is a live `S3FileRuntimeSession` containing `S3Client` and `S3Presigner`, so cross-instance Redis reuse would add serialization and lifecycle complexity without clear payoff.
- Current semantics remain: each backend instance refreshes its own temporary session only when the cached credentials enter the one-minute refresh window.
- This means multi-instance deployments may fetch duplicate temporary credentials, but the current cost was judged acceptable relative to the extra complexity of a Redis-backed shared credential cache.
- Tests now explicitly cover cache reuse plus refresh-time and close-time resource cleanup in `DogeCloudS3SessionProviderTest`.
## 2026-04-10 Stage 1 Step 12 Clarification
- Stage 1 validation is complete for the current local environment.
- Full backend verification passed with `cd backend && mvn test`, for a total of 294 passing tests.
- A no-Redis boot-path check also passed under the `dev` profile when the required `APP_JWT_SECRET` environment variable was supplied and `APP_REDIS_ENABLED=false`.
- The local boot verification was captured by starting the backend on port `18081`, confirming that Tomcat started and the application reached the `Started PortalBackendApplication` log line.
- Two earlier local startup failures were confirmed as environment issues rather than Redis regressions: one missing `APP_JWT_SECRET`, and one unrelated port `8080` conflict caused by another local Java process.
- Remaining validation still requires external environment support: a real Redis instance for cache/pubsub/broker/session end-to-end checks, and at least two backend instances for cross-instance event/session propagation checks.
## 2026-04-10 Stage 1 Step 12 Manual Redis Validation Addendum
- Stage 1 manual validation was continued in a real local Redis plus dual-backend setup (`dev` profile, ports `18081` and `18082`) after the initial closeout note.
- The backend suite is now green at 301 passing tests after fixing four real Redis/manual-integration regressions discovered during that validation.
- Fix 1: `RedisFileEventPubSubPublisher` and `RedisFileEventPubSubListener` now mark the intended constructor for Spring injection, which unblocked Redis-enabled startup.
- Fix 2: `AuthTokenInvalidationService` now stores and compares access-token revocation cutoffs in epoch seconds, with compatibility handling for earlier millisecond values.
- Fix 3: Redis-backed file list cache now uses the application `ObjectMapper` for Java time serialization and converts generic cache payload maps back into `CachedFileListPage` on cache reads.
- Fix 4: `portal_file.storage_name` is now populated for both directory creation and normal file upload metadata writes, which unblocked real upload/manual event flows against the current schema.
- Manual verification that succeeded in the real Redis plus two-instance setup:
- Re-login invalidates the previous access token and refresh token across instances, while the newest token remains valid.
- Online transfer sessions remain discoverable from the second instance even after the first instance is stopped, which confirms shared runtime state rather than same-process false positives.
- Uploading `image/png` on instance A emits `CREATED` SSE on instance B and auto-creates a queued `MEDIA_META` task visible from instance B.
- Directory list behavior was rechecked through real APIs: repeated `GET /api/files/list` remained stable after the cache fixes, and a subsequent directory mutation was immediately reflected by a fresh list response.
- One environment observation remains open: direct `redis-cli --scan` inspection did not surface the expected Redis keys during local probing, even though cross-instance runtime behavior proved that Redis-backed sharing was active. Treat the runtime behavior checks as the stronger validation result for now.
## 2026-04-11 Admin Next-Phase Backend Landing Note
- The next backend-phase admin batch is now landed.
- Implemented admin operational APIs:
- `GET /api/admin/file-blobs`
- `GET /api/admin/shares`
- `DELETE /api/admin/shares/{shareId}`
- `GET /api/admin/tasks`
- `GET /api/admin/tasks/{taskId}`
- The blob admin endpoint is intentionally `FileEntity`-centric and adds operator-facing anomaly signals: `blobMissing`, `orphanRisk`, and `referenceMismatch`.
- The task admin endpoint adds backend-owned parsing for `failureCategory`, `retryScheduled`, `workerOwner`, and `leaseState` so the frontend does not need to infer them from raw JSON.
- Integration and service coverage were expanded in `AdminControllerIntegrationTest` and `AdminServiceTest`, and the storage-policy cache test was kept aligned with the current constructor/dependency graph.
- Verification passed with targeted admin tests and full backend regression:
- `cd backend && mvn -Dtest=AdminControllerIntegrationTest,AdminServiceTest,AdminServiceStoragePolicyCacheTest test`
- `cd backend && mvn test`
- Full backend result after this landing note: 304 tests passed.

View File

@@ -7,12 +7,3 @@ GEMINI_API_KEY="MY_GEMINI_API_KEY"
# AI Studio automatically injects this at runtime with the Cloud Run service URL.
# Used for self-referential links, OAuth callbacks, and API endpoints.
APP_URL="MY_APP_URL"
# Optional: direct API base path used by the frontend.
VITE_API_BASE_URL="/api"
# Optional: backend origin used by the Vite dev proxy.
VITE_BACKEND_URL="http://localhost:8080"
# Enable the dev-login button when the backend runs with the dev profile.
VITE_ENABLE_DEV_LOGIN="true"

View File

@@ -1,45 +0,0 @@
# Frontend AGENTS
This directory is a Vite + React + TypeScript frontend. Follow the current split between pages, shared state/helpers, auth context, and reusable UI.
## Frontend layout
- `src/pages`: route-level screens and page-scoped state modules.
- `src/lib`: API helpers, cache helpers, schedule utilities, shared types, and test files.
- `src/auth`: authentication context/provider.
- `src/components/layout`: page shell/layout components.
- `src/components/ui`: reusable UI primitives.
- `src/index.css`: global styles.
## Real frontend commands
Run these from `front/`:
- `npm run dev`
- `npm run build`
- `npm run preview`
- `npm run clean`
- `npm run lint`
- `npm run test`
Run this from the repository root for OSS publishing:
- `node scripts/deploy-front-oss.mjs`
- `node scripts/deploy-front-oss.mjs --dry-run`
- `node scripts/deploy-front-oss.mjs --skip-build`
Important:
- `npm run lint` is the current TypeScript check because it runs `tsc --noEmit`.
- There is no separate ESLint script.
- There is no separate `typecheck` script beyond `npm run lint`.
- OSS publishing uses `scripts/deploy-front-oss.mjs`, which reads credentials from environment variables or the repository root `.env` file, with `.env.oss.local` kept only as a legacy fallback.
## Frontend rules
- Keep route behavior in `src/pages` and shared non-UI logic in `src/lib`.
- Add or update tests next to the state/helper module they exercise, following the existing `*.test.ts` pattern.
- Preserve the current Vite alias usage: `@/*` resolves from the `front/` directory root.
- If a change depends on backend API behavior, verify the proxy expectations in `vite.config.ts` before hardcoding URLs.
- Use the existing `npm run build`, `npm run test`, and `npm run lint` commands for validation; do not invent a separate frontend verification command.
- For release work, let the deployer agent publish `front/dist` through `scripts/deploy-front-oss.mjs` instead of manual object uploads.

View File

@@ -6,7 +6,7 @@
This contains everything you need to run your app locally.
View your app in AI Studio: https://ai.studio/apps/7dcdc5c7-28c0-4121-959b-77273973e0ef
View your app in AI Studio: https://ai.studio/apps/52ed7feb-11e7-46f2-aac1-69c955c09846
## Run Locally

View File

@@ -1,101 +0,0 @@
# Using Android gitignore template: https://github.com/github/gitignore/blob/HEAD/Android.gitignore
# Built application files
*.apk
*.aar
*.ap_
*.aab
# Files for the ART/Dalvik VM
*.dex
# Java class files
*.class
# Generated files
bin/
gen/
out/
# Uncomment the following line in case you need and you don't have the release build type files in your app
# release/
# Gradle files
.gradle/
build/
# Local configuration file (sdk path, etc)
local.properties
# Proguard folder generated by Eclipse
proguard/
# Log Files
*.log
# Android Studio Navigation editor temp files
.navigation/
# Android Studio captures folder
captures/
# IntelliJ
*.iml
.idea/workspace.xml
.idea/tasks.xml
.idea/gradle.xml
.idea/assetWizardSettings.xml
.idea/dictionaries
.idea/libraries
# Android Studio 3 in .gitignore file.
.idea/caches
.idea/modules.xml
# Comment next line if keeping position of elements in Navigation Editor is relevant for you
.idea/navEditor.xml
# Keystore files
# Uncomment the following lines if you do not want to check your keystore files in.
#*.jks
#*.keystore
# External native build folder generated in Android Studio 2.2 and later
.externalNativeBuild
.cxx/
# Google Services (e.g. APIs or Firebase)
# google-services.json
# Freeline
freeline.py
freeline/
freeline_project_description.json
# fastlane
fastlane/report.xml
fastlane/Preview.html
fastlane/screenshots
fastlane/test_output
fastlane/readme.md
# Version control
vcs.xml
# lint
lint/intermediates/
lint/generated/
lint/outputs/
lint/tmp/
# lint/reports/
# Android Profiling
*.hprof
# Cordova plugins for Capacitor
capacitor-cordova-android-plugins
# Copied web assets
app/src/main/assets/public
# Generated Config files
app/src/main/assets/capacitor.config.json
app/src/main/assets/capacitor.plugins.json
app/src/main/res/xml/config.xml

View File

@@ -1,2 +0,0 @@
/build/*
!/build/.npmkeep

View File

@@ -1,58 +0,0 @@
apply plugin: 'com.android.application'
def buildTimestamp = new Date()
def buildVersionCode = System.getenv('YOYUZH_ANDROID_VERSION_CODE') ?: buildTimestamp.format('yyDDDHHmm')
def buildVersionName = System.getenv('YOYUZH_ANDROID_VERSION_NAME') ?: buildTimestamp.format('yyyy.MM.dd.HHmm')
android {
namespace = "xyz.yoyuzh.portal"
compileSdk = rootProject.ext.compileSdkVersion
defaultConfig {
applicationId "xyz.yoyuzh.portal"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode Integer.parseInt(buildVersionCode)
versionName buildVersionName
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
// Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61
ignoreAssetsPattern = '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
}
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
}
}
}
repositories {
flatDir{
dirs '../capacitor-cordova-android-plugins/src/main/libs', 'libs'
}
}
dependencies {
implementation fileTree(include: ['*.jar'], dir: 'libs')
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
implementation project(':capacitor-android')
testImplementation "junit:junit:$junitVersion"
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
implementation project(':capacitor-cordova-android-plugins')
}
apply from: 'capacitor.build.gradle'
try {
def servicesJSON = file('google-services.json')
if (servicesJSON.text) {
apply plugin: 'com.google.gms.google-services'
}
} catch(Exception e) {
logger.info("google-services.json not found, google-services plugin not applied. Push Notifications won't work")
}

View File

@@ -1,19 +0,0 @@
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
android {
compileOptions {
sourceCompatibility JavaVersion.VERSION_21
targetCompatibility JavaVersion.VERSION_21
}
}
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
dependencies {
implementation project(':capacitor-app')
}
if (hasProperty('postBuildExtras')) {
postBuildExtras()
}

View File

@@ -1,21 +0,0 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile

View File

@@ -1,26 +0,0 @@
package com.getcapacitor.myapp;
import static org.junit.Assert.*;
import android.content.Context;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import androidx.test.platform.app.InstrumentationRegistry;
import org.junit.Test;
import org.junit.runner.RunWith;
/**
* Instrumented test, which will execute on an Android device.
*
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
*/
@RunWith(AndroidJUnit4.class)
public class ExampleInstrumentedTest {
@Test
public void useAppContext() throws Exception {
// Context of the app under test.
Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext();
assertEquals("com.getcapacitor.app", appContext.getPackageName());
}
}

View File

@@ -1,41 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/AppTheme">
<activity
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|navigation|density"
android:name=".MainActivity"
android:label="@string/title_activity_main"
android:theme="@style/AppTheme.NoActionBarLaunch"
android:launchMode="singleTask"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths"></meta-data>
</provider>
</application>
<!-- Permissions -->
<uses-permission android:name="android.permission.INTERNET" />
</manifest>

View File

@@ -1,5 +0,0 @@
package xyz.yoyuzh.portal;
import com.getcapacitor.BridgeActivity;
public class MainActivity extends BridgeActivity {}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

View File

@@ -1,34 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportHeight="108"
android:viewportWidth="108">
<path
android:fillType="evenOdd"
android:pathData="M32,64C32,64 38.39,52.99 44.13,50.95C51.37,48.37 70.14,49.57 70.14,49.57L108.26,87.69L108,109.01L75.97,107.97L32,64Z"
android:strokeColor="#00000000"
android:strokeWidth="1">
<aapt:attr name="android:fillColor">
<gradient
android:endX="78.5885"
android:endY="90.9159"
android:startX="48.7653"
android:startY="61.0927"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M66.94,46.02L66.94,46.02C72.44,50.07 76,56.61 76,64L32,64C32,56.61 35.56,50.11 40.98,46.06L36.18,41.19C35.45,40.45 35.45,39.3 36.18,38.56C36.91,37.81 38.05,37.81 38.78,38.56L44.25,44.05C47.18,42.57 50.48,41.71 54,41.71C57.48,41.71 60.78,42.57 63.68,44.05L69.11,38.56C69.84,37.81 70.98,37.81 71.71,38.56C72.44,39.3 72.44,40.45 71.71,41.19L66.94,46.02ZM62.94,56.92C64.08,56.92 65,56.01 65,54.88C65,53.76 64.08,52.85 62.94,52.85C61.8,52.85 60.88,53.76 60.88,54.88C60.88,56.01 61.8,56.92 62.94,56.92ZM45.06,56.92C46.2,56.92 47.13,56.01 47.13,54.88C47.13,53.76 46.2,52.85 45.06,52.85C43.92,52.85 43,53.76 43,54.88C43,56.01 43.92,56.92 45.06,56.92Z"
android:strokeColor="#00000000"
android:strokeWidth="1" />
</vector>

View File

@@ -1,170 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportHeight="108"
android:viewportWidth="108">
<path
android:fillColor="#26A69A"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
</vector>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

View File

@@ -1,12 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<WebView
android:layout_width="match_parent"
android:layout_height="match_parent" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View File

@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>

View File

@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

View File

@@ -1,4 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#FFFFFF</color>
</resources>

View File

@@ -1,7 +0,0 @@
<?xml version='1.0' encoding='utf-8'?>
<resources>
<string name="app_name">YOYUZH</string>
<string name="title_activity_main">YOYUZH</string>
<string name="package_name">xyz.yoyuzh.portal</string>
<string name="custom_url_scheme">xyz.yoyuzh.portal</string>
</resources>

View File

@@ -1,22 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Base application theme. -->
<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
<!-- Customize your theme here. -->
<item name="colorPrimary">@color/colorPrimary</item>
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
<item name="colorAccent">@color/colorAccent</item>
</style>
<style name="AppTheme.NoActionBar" parent="Theme.AppCompat.DayNight.NoActionBar">
<item name="windowActionBar">false</item>
<item name="windowNoTitle">true</item>
<item name="android:background">@null</item>
</style>
<style name="AppTheme.NoActionBarLaunch" parent="Theme.SplashScreen">
<item name="android:background">@drawable/splash</item>
</style>
</resources>

View File

@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<external-path name="my_images" path="." />
<cache-path name="my_cache_images" path="." />
</paths>

View File

@@ -1,18 +0,0 @@
package com.getcapacitor.myapp;
import static org.junit.Assert.*;
import org.junit.Test;
/**
* Example local unit test, which will execute on the development machine (host).
*
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
*/
public class ExampleUnitTest {
@Test
public void addition_isCorrect() throws Exception {
assertEquals(4, 2 + 2);
}
}

View File

@@ -1,31 +0,0 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript {
def googleMirror = 'https://maven.aliyun.com/repository/google'
repositories {
maven { url googleMirror }
mavenCentral()
}
dependencies {
classpath 'com.android.tools.build:gradle:8.13.0'
// NOTE: Do not place your application dependencies here; they belong
// in the individual module build.gradle files
}
}
apply from: "variables.gradle"
allprojects {
def googleMirror = 'https://maven.aliyun.com/repository/google'
repositories {
maven { url googleMirror }
mavenCentral()
}
}
task clean(type: Delete) {
delete rootProject.buildDir
}

View File

@@ -1,6 +0,0 @@
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
include ':capacitor-android'
project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor')
include ':capacitor-app'
project(':capacitor-app').projectDir = new File('../node_modules/@capacitor/app/android')

View File

@@ -1,22 +0,0 @@
# Project-wide Gradle settings.
# IDE (e.g. Android Studio) users:
# Gradle settings configured through the IDE *will override*
# any settings specified in this file.
# For more details on how to configure your build environment visit
# http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
org.gradle.jvmargs=-Xmx1536m
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. More details, visit
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
# org.gradle.parallel=true
# AndroidX package structure to make it clearer which packages are bundled with the
# Android operating system, and which are packaged with your app's APK
# https://developer.android.com/topic/libraries/support-library/androidx-rn
android.useAndroidX=true

Binary file not shown.

View File

@@ -1,7 +0,0 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-all.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

251
front/android/gradlew vendored
View File

@@ -1,251 +0,0 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH="\\\"\\\""
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

View File

@@ -1,94 +0,0 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

View File

@@ -1,5 +0,0 @@
include ':app'
include ':capacitor-cordova-android-plugins'
project(':capacitor-cordova-android-plugins').projectDir = new File('./capacitor-cordova-android-plugins/')
apply from: 'capacitor.settings.gradle'

View File

@@ -1,16 +0,0 @@
ext {
minSdkVersion = 24
compileSdkVersion = 36
targetSdkVersion = 36
androidxActivityVersion = '1.11.0'
androidxAppCompatVersion = '1.7.1'
androidxCoordinatorLayoutVersion = '1.3.0'
androidxCoreVersion = '1.17.0'
androidxFragmentVersion = '1.8.9'
coreSplashScreenVersion = '1.2.0'
androidxWebkitVersion = '1.14.0'
junitVersion = '4.13.2'
androidxJunitVersion = '1.3.0'
androidxEspressoCoreVersion = '3.7.0'
cordovaAndroidVersion = '14.0.1'
}

View File

@@ -1,9 +0,0 @@
import type { CapacitorConfig } from '@capacitor/cli';
const config: CapacitorConfig = {
appId: 'xyz.yoyuzh.portal',
appName: 'YOYUZH',
webDir: 'dist'
};
export default config;

View File

@@ -2,11 +2,15 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<title>优立云盘</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&family=Outfit:wght@400;500;600;700;800;900&display=swap" rel="stylesheet">
<title>Stitch Portal</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@@ -1,5 +1,5 @@
{
"name": "Personal Portal",
"description": "A unified personal portal for managing files, fast transfer, and games with a glassmorphism design.",
"name": "",
"description": "",
"requestFramePermissions": []
}

3964
front/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -8,35 +8,20 @@
"build": "vite build",
"preview": "vite preview",
"clean": "rm -rf dist",
"lint": "tsc --noEmit",
"test": "node --import tsx --test src/**/*.test.ts"
"lint": "tsc --noEmit"
},
"dependencies": {
"@capacitor/app": "^8.1.0",
"@capacitor/android": "^8.3.0",
"@capacitor/cli": "^8.3.0",
"@capacitor/core": "^8.3.0",
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@google/genai": "^1.29.0",
"@mui/icons-material": "^7.3.9",
"@mui/material": "^7.3.9",
"@tailwindcss/vite": "^4.1.14",
"@types/simple-peer": "^9.11.9",
"@vitejs/plugin-react": "^5.0.4",
"better-sqlite3": "^12.4.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"dotenv": "^17.2.3",
"express": "^4.21.2",
"lucide-react": "^0.546.0",
"motion": "^12.23.24",
"ogl": "^1.0.11",
"react": "^19.0.0",
"react-admin": "^5.14.4",
"react-dom": "^19.0.0",
"react-router-dom": "^7.13.1",
"simple-peer": "^9.11.1",
"react-router-dom": "^7.14.0",
"tailwind-merge": "^3.5.0",
"vite": "^6.2.0"
},

View File

@@ -1,100 +1,58 @@
import React, { Suspense } from 'react';
import { BrowserRouter, HashRouter, Routes, Route, Navigate, useLocation } from 'react-router-dom';
import { Layout } from './components/layout/Layout';
import { useAuth } from './auth/AuthProvider';
import { BrowserRouter, Navigate, Route, Routes, useLocation } from 'react-router-dom';
import { AnimatePresence, motion } from 'motion/react';
import AdminDashboard from './admin/dashboard';
import AdminFilesList from './admin/files-list';
import AdminStoragePoliciesList from './admin/storage-policies-list';
import AdminUsersList from './admin/users-list';
import Layout from './components/layout/Layout';
import MobileLayout from './mobile-components/MobileLayout';
import { useIsMobile } from './hooks/useIsMobile';
import Login from './pages/Login';
import Overview from './pages/Overview';
import Files from './pages/Files';
import RecycleBin from './pages/RecycleBin';
import Shares from './pages/Shares';
import Tasks from './pages/Tasks';
import Transfer from './pages/Transfer';
import FileShare from './pages/FileShare';
import Games from './pages/Games';
import GamePlayer from './pages/GamePlayer';
import { FILE_SHARE_ROUTE_PREFIX } from './lib/file-share';
import {
getTransferRouterMode,
LEGACY_PUBLIC_TRANSFER_ROUTE,
PUBLIC_TRANSFER_ROUTE,
} from './lib/transfer-links';
import FilesPage from './pages/files/FilesPage';
const PortalAdminApp = React.lazy(() => import('./admin/AdminApp'));
function LegacyTransferRedirect() {
function AnimatedRoutes({ isMobile }: { isMobile: boolean }) {
const location = useLocation();
return <Navigate to={`${PUBLIC_TRANSFER_ROUTE}${location.search}`} replace />;
}
function AppRoutes() {
const { ready, session } = useAuth();
const location = useLocation();
const isPublicTransferRoute = location.pathname === PUBLIC_TRANSFER_ROUTE || location.pathname === LEGACY_PUBLIC_TRANSFER_ROUTE;
const isPublicFileShareRoute = location.pathname.startsWith(`${FILE_SHARE_ROUTE_PREFIX}/`);
if (!ready && !isPublicTransferRoute && !isPublicFileShareRoute) {
return (
<div className="min-h-screen flex items-center justify-center bg-[#07101D] text-slate-300">
...
</div>
);
}
const isAuthenticated = Boolean(session?.token);
const AppLayout = isMobile ? MobileLayout : Layout;
return (
<Routes>
<Route
path={PUBLIC_TRANSFER_ROUTE}
element={isAuthenticated ? <Layout><Transfer /></Layout> : <Transfer />}
/>
<Route path={`${FILE_SHARE_ROUTE_PREFIX}/:token`} element={<FileShare />} />
<Route path={LEGACY_PUBLIC_TRANSFER_ROUTE} element={<LegacyTransferRedirect />} />
<Route
path="/login"
element={isAuthenticated ? <Navigate to="/overview" replace /> : <Login />}
/>
<Route
path="/"
element={isAuthenticated ? <Layout /> : <Navigate to="/login" replace />}
>
<Route index element={<Navigate to="/overview" replace />} />
<Route path="overview" element={<Overview />} />
<Route path="files" element={<Files />} />
<Route path="recycle-bin" element={<RecycleBin />} />
<Route path="games" element={<Games />} />
<Route path="games/:gameId" element={<GamePlayer />} />
<AnimatePresence mode="wait">
<Routes location={location}>
<Route path="/login" element={<Login />} />
<Route path="/share/:token" element={<FileShare />} />
<Route element={<AppLayout />}>
<Route path="/" element={<Navigate to="/overview" replace />} />
<Route path="/overview" element={<Overview />} />
<Route path="/files" element={<FilesPage />} />
<Route path="/tasks" element={<Tasks />} />
<Route path="/shares" element={<Shares />} />
<Route path="/recycle-bin" element={<RecycleBin />} />
<Route path="/transfer" element={<Transfer />} />
<Route path="/admin">
<Route index element={<Navigate to="/admin/dashboard" replace />} />
<Route path="dashboard" element={isMobile ? <Navigate to="/overview" replace /> : <AdminDashboard />} />
<Route path="users" element={isMobile ? <Navigate to="/overview" replace /> : <AdminUsersList />} />
<Route path="files" element={isMobile ? <Navigate to="/overview" replace /> : <AdminFilesList />} />
<Route path="storage-policies" element={isMobile ? <Navigate to="/overview" replace /> : <AdminStoragePoliciesList />} />
</Route>
<Route path="*" element={<Navigate to="/overview" replace />} />
</Route>
<Route
path="/admin/*"
element={
isAuthenticated ? (
<Suspense
fallback={
<div className="min-h-screen flex items-center justify-center bg-white text-slate-700">
...
</div>
}
>
<PortalAdminApp />
</Suspense>
) : (
<Navigate to="/login" replace />
)
}
/>
<Route
path="*"
element={<Navigate to={isAuthenticated ? '/overview' : '/login'} replace />}
/>
</Routes>
</AnimatePresence>
);
}
export default function App() {
const Router = getTransferRouterMode() === 'hash' ? HashRouter : BrowserRouter;
const isMobile = useIsMobile();
return (
<Router>
<AppRoutes />
</Router>
<BrowserRouter>
<AnimatedRoutes isMobile={isMobile} />
</BrowserRouter>
);
}

View File

@@ -1,86 +0,0 @@
import React, { Suspense } from 'react';
import { BrowserRouter, HashRouter, Routes, Route, Navigate, useLocation } from 'react-router-dom';
import { useAuth } from '@/src/auth/AuthProvider';
import { FILE_SHARE_ROUTE_PREFIX } from '@/src/lib/file-share';
import { getTransferRouterMode, LEGACY_PUBLIC_TRANSFER_ROUTE, PUBLIC_TRANSFER_ROUTE } from '@/src/lib/transfer-links';
import { MobileLayout } from './mobile-components/MobileLayout';
import MobileLogin from './mobile-pages/MobileLogin';
import MobileOverview from './mobile-pages/MobileOverview';
import MobileFiles from './mobile-pages/MobileFiles';
import MobileTransfer from './mobile-pages/MobileTransfer';
import MobileFileShare from './mobile-pages/MobileFileShare';
import MobileRecycleBin from './mobile-pages/MobileRecycleBin';
import MobileAdminUnavailable from './mobile-pages/MobileAdminUnavailable';
function LegacyTransferRedirect() {
const location = useLocation();
return <Navigate to={`${PUBLIC_TRANSFER_ROUTE}${location.search}`} replace />;
}
function MobileAppRoutes() {
const { ready, session } = useAuth();
const location = useLocation();
const isPublicTransferRoute = location.pathname === PUBLIC_TRANSFER_ROUTE || location.pathname === LEGACY_PUBLIC_TRANSFER_ROUTE;
const isPublicFileShareRoute = location.pathname.startsWith(`${FILE_SHARE_ROUTE_PREFIX}/`);
if (!ready && !isPublicTransferRoute && !isPublicFileShareRoute) {
return (
<div className="min-h-[100dvh] flex items-center justify-center bg-[#07101D] text-slate-300 flex-col gap-4">
<span className="w-6 h-6 border-2 border-white/20 border-t-white rounded-full animate-spin" />
<span className="text-sm">...</span>
</div>
);
}
const isAuthenticated = Boolean(session?.token);
return (
<Routes>
<Route
path={PUBLIC_TRANSFER_ROUTE}
element={isAuthenticated ? <MobileLayout><MobileTransfer /></MobileLayout> : <MobileTransfer />}
/>
<Route path={`${FILE_SHARE_ROUTE_PREFIX}/:token`} element={<MobileFileShare />} />
<Route path={LEGACY_PUBLIC_TRANSFER_ROUTE} element={<LegacyTransferRedirect />} />
<Route
path="/login"
element={isAuthenticated ? <Navigate to="/overview" replace /> : <MobileLogin />}
/>
<Route
path="/"
element={isAuthenticated ? <MobileLayout /> : <Navigate to="/login" replace />}
>
<Route index element={<Navigate to="/overview" replace />} />
<Route path="overview" element={<MobileOverview />} />
<Route path="files" element={<MobileFiles />} />
<Route path="recycle-bin" element={<MobileRecycleBin />} />
<Route path="games" element={<Navigate to="/overview" replace />} />
</Route>
<Route path="/games/:gameId" element={<Navigate to={isAuthenticated ? '/overview' : '/login'} replace />} />
{/* Admin dashboard is not mobile-optimized in this phase yet, show stub page */}
<Route
path="/admin/*"
element={isAuthenticated ? <MobileAdminUnavailable /> : <Navigate to="/login" replace />}
/>
<Route
path="*"
element={<Navigate to={isAuthenticated ? '/overview' : '/login'} replace />}
/>
</Routes>
);
}
export default function MobileApp() {
const Router = getTransferRouterMode() === 'hash' ? HashRouter : BrowserRouter;
return (
<Router>
<MobileAppRoutes />
</Router>
);
}

View File

@@ -1,47 +0,0 @@
import FolderOutlined from '@mui/icons-material/FolderOutlined';
import GroupsOutlined from '@mui/icons-material/GroupsOutlined';
import StorageRounded from '@mui/icons-material/StorageRounded';
import { Admin, Resource } from 'react-admin';
import { portalAdminAuthProvider } from './auth-provider';
import { portalAdminDataProvider } from './data-provider';
import { PortalAdminDashboard } from './dashboard';
import { PortalAdminFilesList } from './files-list';
import { PortalAdminStoragePoliciesList } from './storage-policies-list';
import { PortalAdminUsersList } from './users-list';
export default function PortalAdminApp() {
return (
<Admin
authProvider={portalAdminAuthProvider}
basename="/admin"
dashboard={PortalAdminDashboard}
dataProvider={portalAdminDataProvider}
disableTelemetry
requireAuth
title="YOYUZH Admin"
>
<Resource
name="users"
icon={GroupsOutlined}
list={PortalAdminUsersList}
options={{ label: '用户资源' }}
recordRepresentation="username"
/>
<Resource
name="files"
icon={FolderOutlined}
list={PortalAdminFilesList}
options={{ label: '文件资源' }}
recordRepresentation="filename"
/>
<Resource
name="storagePolicies"
icon={StorageRounded}
list={PortalAdminStoragePoliciesList}
options={{ label: '存储策略' }}
recordRepresentation="name"
/>
</Admin>
);
}

View File

@@ -1,38 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { AuthSession } from '@/src/lib/types';
import { buildAdminIdentity, hasAdminSession, portalAdminAuthProvider } from './auth-provider';
const session: AuthSession = {
token: 'token-123',
refreshToken: 'refresh-123',
user: {
id: 7,
username: 'alice',
email: 'alice@example.com',
createdAt: '2026-03-19T15:00:00',
},
};
test('hasAdminSession returns true only when a token is present', () => {
assert.equal(hasAdminSession(session), true);
assert.equal(hasAdminSession({...session, token: ''}), false);
assert.equal(hasAdminSession(null), false);
});
test('buildAdminIdentity maps the portal session user to react-admin identity', () => {
assert.deepEqual(buildAdminIdentity(session), {
id: '7',
fullName: 'alice',
});
});
test('checkError keeps the session when admin API returns 403', async () => {
await assert.doesNotReject(() => portalAdminAuthProvider.checkError?.({status: 403}));
});
test('checkError rejects when admin API returns 401', async () => {
await assert.rejects(() => portalAdminAuthProvider.checkError?.({status: 401}));
});

View File

@@ -1,50 +0,0 @@
import type { AuthProvider, UserIdentity } from 'react-admin';
import { clearStoredSession, readStoredSession } from '@/src/lib/session';
import type { AuthSession } from '@/src/lib/types';
export function hasAdminSession(session: AuthSession | null | undefined) {
return Boolean(session?.token?.trim());
}
export function buildAdminIdentity(session: AuthSession): UserIdentity {
return {
id: String(session.user.id),
fullName: session.user.username,
};
}
export const portalAdminAuthProvider: AuthProvider = {
login: async () => {
throw new Error('请先使用门户登录页完成登录');
},
logout: async () => {
clearStoredSession();
return '/login';
},
checkAuth: async () => {
if (!hasAdminSession(readStoredSession())) {
throw new Error('当前没有可用登录状态');
}
},
checkError: async (error) => {
const status = error?.status;
if (status === 401) {
clearStoredSession();
throw new Error('登录状态已失效');
}
if (status === 403) {
return;
}
},
getIdentity: async () => {
const session = readStoredSession();
if (!session) {
throw new Error('当前没有可用登录状态');
}
return buildAdminIdentity(session);
},
getPermissions: async () => [],
};

View File

@@ -1,127 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
buildRequestLineChartModel,
buildRequestLineChartXAxisPoints,
formatMetricValue,
getInviteCodePanelState,
parseStorageLimitInput,
} from './dashboard-state';
test('getInviteCodePanelState returns a copyable invite code when summary contains one', () => {
assert.deepEqual(
getInviteCodePanelState({
totalUsers: 12,
totalFiles: 34,
totalStorageBytes: 0,
downloadTrafficBytes: 0,
requestCount: 0,
transferUsageBytes: 0,
offlineTransferStorageBytes: 0,
offlineTransferStorageLimitBytes: 0,
dailyActiveUsers: [],
requestTimeline: [],
inviteCode: ' AbCd1234 ',
}),
{
inviteCode: 'AbCd1234',
canCopy: true,
},
);
});
test('getInviteCodePanelState falls back to a placeholder when summary has no invite code', () => {
assert.deepEqual(
getInviteCodePanelState({
totalUsers: 12,
totalFiles: 34,
totalStorageBytes: 0,
downloadTrafficBytes: 0,
requestCount: 0,
transferUsageBytes: 0,
offlineTransferStorageBytes: 0,
offlineTransferStorageLimitBytes: 0,
dailyActiveUsers: [],
requestTimeline: [],
inviteCode: ' ',
}),
{
inviteCode: '未生成',
canCopy: false,
},
);
});
test('formatMetricValue formats byte metrics with binary units', () => {
assert.equal(formatMetricValue(1536, 'bytes'), '1.5 KB');
assert.equal(formatMetricValue(50 * 1024 * 1024 * 1024, 'bytes'), '50 GB');
});
test('formatMetricValue formats count metrics with locale separators', () => {
assert.equal(formatMetricValue(1234567, 'count'), '1,234,567');
});
test('parseStorageLimitInput accepts common storage unit inputs', () => {
assert.equal(parseStorageLimitInput('20GB'), 20 * 1024 * 1024 * 1024);
assert.equal(parseStorageLimitInput('512 mb'), 512 * 1024 * 1024);
});
test('parseStorageLimitInput rejects invalid or non-positive inputs', () => {
assert.equal(parseStorageLimitInput('0GB'), null);
assert.equal(parseStorageLimitInput('abc'), null);
});
test('buildRequestLineChartModel converts hourly request data into chart coordinates', () => {
const model = buildRequestLineChartModel([
{ hour: 0, label: '00:00', requestCount: 0 },
{ hour: 1, label: '01:00', requestCount: 30 },
{ hour: 2, label: '02:00', requestCount: 60 },
{ hour: 3, label: '03:00', requestCount: 15 },
]);
assert.equal(model.points.length, 4);
assert.equal(model.points[0]?.x, 0);
assert.equal(model.points[0]?.y, 100);
assert.equal(model.points[2]?.y, 0);
assert.equal(model.points[3]?.x, 100);
assert.equal(model.maxValue, 60);
assert.equal(model.linePath, 'M 0 100 L 33.333 50 L 66.667 0 L 100 75');
assert.deepEqual(model.yAxisTicks, [0, 15, 30, 45, 60]);
assert.equal(model.peakPoint?.label, '02:00');
});
test('buildRequestLineChartModel stretches only the available hours across the chart width', () => {
const model = buildRequestLineChartModel([
{ hour: 0, label: '00:00', requestCount: 2 },
{ hour: 1, label: '01:00', requestCount: 4 },
{ hour: 2, label: '02:00', requestCount: 3 },
{ hour: 3, label: '03:00', requestCount: 6 },
{ hour: 4, label: '04:00', requestCount: 5 },
{ hour: 5, label: '05:00', requestCount: 1 },
{ hour: 6, label: '06:00', requestCount: 2 },
{ hour: 7, label: '07:00', requestCount: 4 },
]);
assert.equal(model.points[0]?.x, 0);
assert.equal(model.points.at(-1)?.x, 100);
assert.equal(model.points.length, 8);
});
test('buildRequestLineChartXAxisPoints only shows elapsed-hour labels plus start and end', () => {
const model = buildRequestLineChartModel([
{ hour: 0, label: '00:00', requestCount: 2 },
{ hour: 1, label: '01:00', requestCount: 4 },
{ hour: 2, label: '02:00', requestCount: 3 },
{ hour: 3, label: '03:00', requestCount: 6 },
{ hour: 4, label: '04:00', requestCount: 5 },
{ hour: 5, label: '05:00', requestCount: 1 },
{ hour: 6, label: '06:00', requestCount: 2 },
{ hour: 7, label: '07:00', requestCount: 4 },
]);
assert.deepEqual(
buildRequestLineChartXAxisPoints(model.points).map((point) => point.label),
['00:00', '06:00', '07:00'],
);
});

View File

@@ -1,153 +0,0 @@
import type { AdminRequestTimelinePoint, AdminSummary } from '@/src/lib/types';
export interface InviteCodePanelState {
inviteCode: string;
canCopy: boolean;
}
export interface RequestLineChartPoint extends AdminRequestTimelinePoint {
x: number;
y: number;
}
export interface RequestLineChartModel {
points: RequestLineChartPoint[];
linePath: string;
areaPath: string;
yAxisTicks: number[];
maxValue: number;
peakPoint: RequestLineChartPoint | null;
}
type MetricValueKind = 'bytes' | 'count';
const BYTE_UNITS = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
const REQUEST_CHART_X_AXIS_HOURS = [0, 6, 12, 18, 23];
export function formatMetricValue(value: number, kind: MetricValueKind): string {
if (kind === 'count') {
return new Intl.NumberFormat('en-US').format(value);
}
if (value <= 0) {
return '0 B';
}
const unitIndex = Math.min(Math.floor(Math.log(value) / Math.log(1024)), BYTE_UNITS.length - 1);
const unitValue = value / 1024 ** unitIndex;
const formatted = unitValue >= 10 || unitIndex === 0 ? unitValue.toFixed(0) : unitValue.toFixed(1);
return `${formatted} ${BYTE_UNITS[unitIndex]}`;
}
export function parseStorageLimitInput(value: string): number | null {
const normalized = value.trim().toLowerCase();
const matched = normalized.match(/^(\d+(?:\.\d+)?)\s*(b|kb|mb|gb|tb|pb)?$/);
if (!matched) {
return null;
}
const amount = Number.parseFloat(matched[1] ?? '0');
if (!Number.isFinite(amount) || amount <= 0) {
return null;
}
const unit = matched[2] ?? 'b';
const multiplier = unit === 'pb'
? 1024 ** 5
: unit === 'tb'
? 1024 ** 4
: unit === 'gb'
? 1024 ** 3
: unit === 'mb'
? 1024 ** 2
: unit === 'kb'
? 1024
: 1;
return Math.floor(amount * multiplier);
}
export function buildRequestLineChartModel(timeline: AdminRequestTimelinePoint[]): RequestLineChartModel {
if (timeline.length === 0) {
return {
points: [],
linePath: '',
areaPath: '',
yAxisTicks: [0, 1, 2, 3, 4],
maxValue: 0,
peakPoint: null,
};
}
const maxValue = Math.max(...timeline.map((point) => point.requestCount), 0);
const scaleMax = maxValue > 0 ? maxValue : 1;
const lastIndex = Math.max(timeline.length - 1, 1);
const points = timeline.map((point, index) => ({
...point,
x: roundChartValue((index / lastIndex) * 100),
y: roundChartValue(100 - (point.requestCount / scaleMax) * 100),
}));
const linePath = points
.map((point, index) => `${index === 0 ? 'M' : 'L'} ${formatChartNumber(point.x)} ${formatChartNumber(point.y)}`)
.join(' ');
return {
points,
linePath,
areaPath: linePath ? `${linePath} L 100 100 L 0 100 Z` : '',
yAxisTicks: buildYAxisTicks(maxValue),
maxValue,
peakPoint: points.reduce<RequestLineChartPoint | null>((peak, point) => {
if (!peak || point.requestCount > peak.requestCount) {
return point;
}
return peak;
}, null),
};
}
export function buildRequestLineChartXAxisPoints(points: RequestLineChartPoint[]): RequestLineChartPoint[] {
if (points.length === 0) {
return [];
}
const firstHour = points[0]?.hour ?? 0;
const lastHour = points.at(-1)?.hour ?? firstHour;
const visibleHours = new Set<number>([firstHour, lastHour]);
for (const hour of REQUEST_CHART_X_AXIS_HOURS) {
if (hour > firstHour && hour < lastHour) {
visibleHours.add(hour);
}
}
return points.filter((point) => visibleHours.has(point.hour));
}
export function getInviteCodePanelState(summary: AdminSummary | null | undefined): InviteCodePanelState {
const inviteCode = summary?.inviteCode?.trim() ?? '';
if (!inviteCode) {
return {
inviteCode: '未生成',
canCopy: false,
};
}
return {
inviteCode,
canCopy: true,
};
}
function buildYAxisTicks(maxValue: number): number[] {
if (maxValue <= 0) {
return [0, 1, 2, 3, 4];
}
return Array.from({ length: 5 }, (_, index) => roundChartValue((maxValue / 4) * index));
}
function roundChartValue(value: number): number {
return Math.round(value * 1000) / 1000;
}
function formatChartNumber(value: number): string {
const rounded = roundChartValue(value);
return Number.isInteger(rounded) ? `${rounded}` : rounded.toFixed(3).replace(/0+$/, '').replace(/\.$/, '');
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,122 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { AdminFile, PageResponse } from '@/src/lib/types';
import {
buildAdminListPath,
buildFilesListPath,
buildStoragePoliciesListPath,
mapFilesListResponse,
} from './data-provider';
test('buildFilesListPath maps react-admin pagination to the backend files list query', () => {
assert.equal(
buildFilesListPath({
pagination: {
page: 3,
perPage: 25,
},
filter: {},
}),
'/admin/files?page=2&size=25',
);
});
test('buildFilesListPath includes file and owner search filters when present', () => {
assert.equal(
buildFilesListPath({
pagination: {
page: 1,
perPage: 25,
},
filter: {
query: 'report',
ownerQuery: 'alice',
},
}),
'/admin/files?page=0&size=25&query=report&ownerQuery=alice',
);
});
test('mapFilesListResponse preserves list items and total count', () => {
const payload: PageResponse<AdminFile> = {
items: [
{
id: 1,
filename: 'hello.txt',
path: '/',
size: 12,
contentType: 'text/plain',
directory: false,
createdAt: '2026-03-19T15:00:00',
ownerId: 7,
ownerUsername: 'alice',
ownerEmail: 'alice@example.com',
},
],
total: 1,
page: 0,
size: 25,
};
assert.deepEqual(mapFilesListResponse(payload), {
data: payload.items,
total: 1,
});
});
test('buildAdminListPath maps generic admin resources to backend paging queries', () => {
assert.equal(
buildAdminListPath('users', {
pagination: {
page: 2,
perPage: 20,
},
filter: {},
}),
'/admin/users?page=1&size=20',
);
});
test('buildAdminListPath includes the user search query when present', () => {
assert.equal(
buildAdminListPath('users', {
pagination: {
page: 1,
perPage: 25,
},
filter: {
query: 'alice',
},
}),
'/admin/users?page=0&size=25&query=alice',
);
});
test('buildAdminListPath rejects the removed school snapshots resource', () => {
assert.throws(
() =>
buildAdminListPath('schoolSnapshots', {
pagination: {
page: 1,
perPage: 50,
},
filter: {},
}),
/schoolSnapshots/,
);
});
test('buildStoragePoliciesListPath maps react-admin pagination to the backend storage policies list query', () => {
assert.equal(
buildStoragePoliciesListPath({
pagination: {
page: 2,
perPage: 10,
},
filter: {},
}),
'/admin/storage-policies?page=1&size=10',
);
});

View File

@@ -1,150 +0,0 @@
import type { DataProvider, GetListParams, GetListResult, Identifier } from 'react-admin';
import { apiRequest } from '@/src/lib/api';
import type {
AdminFile,
AdminStoragePolicy,
AdminUser,
PageResponse,
} from '@/src/lib/types';
const FILES_RESOURCE = 'files';
const STORAGE_POLICIES_RESOURCE = 'storagePolicies';
const USERS_RESOURCE = 'users';
function createUnsupportedError(resource: string, action: string) {
return new Error(`当前管理台暂未为资源 "${resource}" 实现 ${action} 操作`);
}
function ensureSupportedResource(resource: string, action: string) {
if (![FILES_RESOURCE, STORAGE_POLICIES_RESOURCE, USERS_RESOURCE].includes(resource)) {
throw createUnsupportedError(resource, action);
}
}
function normalizeFilterValue(value: unknown) {
return typeof value === 'string' ? value.trim() : '';
}
export function buildAdminListPath(resource: string, params: Pick<GetListParams, 'pagination' | 'filter'>) {
const page = Math.max(0, params.pagination.page - 1);
const size = Math.max(1, params.pagination.perPage);
const query = normalizeFilterValue(params.filter?.query);
if (resource === USERS_RESOURCE) {
return `/admin/users?page=${page}&size=${size}${query ? `&query=${encodeURIComponent(query)}` : ''}`;
}
throw createUnsupportedError(resource, 'list');
}
export function buildFilesListPath(params: Pick<GetListParams, 'pagination' | 'filter'>) {
const page = Math.max(0, params.pagination.page - 1);
const size = Math.max(1, params.pagination.perPage);
const query = normalizeFilterValue(params.filter?.query);
const ownerQuery = normalizeFilterValue(params.filter?.ownerQuery);
const search = new URLSearchParams({
page: String(page),
size: String(size),
});
if (query) {
search.set('query', query);
}
if (ownerQuery) {
search.set('ownerQuery', ownerQuery);
}
return `/admin/files?${search.toString()}`;
}
export function buildStoragePoliciesListPath(params: Pick<GetListParams, 'pagination' | 'filter'>) {
const page = Math.max(0, params.pagination.page - 1);
const size = Math.max(1, params.pagination.perPage);
return `/admin/storage-policies?page=${page}&size=${size}`;
}
export function mapFilesListResponse(
payload: PageResponse<AdminFile>,
): GetListResult<AdminFile> {
return {
data: payload.items,
total: payload.total,
};
}
async function deleteFile(id: Identifier) {
await apiRequest(`/admin/files/${id}`, {
method: 'DELETE',
});
}
export const portalAdminDataProvider: DataProvider = {
getList: async (resource, params) => {
ensureSupportedResource(resource, 'list');
if (resource === FILES_RESOURCE) {
const payload = await apiRequest<PageResponse<AdminFile>>(buildFilesListPath(params));
return mapFilesListResponse(payload) as GetListResult;
}
if (resource === USERS_RESOURCE) {
const payload = await apiRequest<PageResponse<AdminUser>>(buildAdminListPath(resource, params));
return {
data: payload.items,
total: payload.total,
} as GetListResult;
}
if (resource === STORAGE_POLICIES_RESOURCE) {
const payload = await apiRequest<AdminStoragePolicy[]>(buildStoragePoliciesListPath(params));
return {
data: payload,
total: payload.length,
} as GetListResult;
}
throw createUnsupportedError(resource, 'list');
},
getOne: async (resource) => {
ensureSupportedResource(resource, 'getOne');
throw createUnsupportedError(resource, 'getOne');
},
getMany: async (resource) => {
ensureSupportedResource(resource, 'getMany');
throw createUnsupportedError(resource, 'getMany');
},
getManyReference: async (resource) => {
ensureSupportedResource(resource, 'getManyReference');
throw createUnsupportedError(resource, 'getManyReference');
},
update: async (resource) => {
ensureSupportedResource(resource, 'update');
throw createUnsupportedError(resource, 'update');
},
updateMany: async (resource) => {
ensureSupportedResource(resource, 'updateMany');
throw createUnsupportedError(resource, 'updateMany');
},
create: async (resource) => {
ensureSupportedResource(resource, 'create');
throw createUnsupportedError(resource, 'create');
},
delete: async (resource, params) => {
if (resource !== FILES_RESOURCE) {
throw createUnsupportedError(resource, 'delete');
}
await deleteFile(params.id);
const fallbackRecord = { id: params.id } as typeof params.previousData;
return {
data: (params.previousData ?? fallbackRecord) as typeof params.previousData,
};
},
deleteMany: async (resource, params) => {
if (resource !== FILES_RESOURCE) {
throw createUnsupportedError(resource, 'deleteMany');
}
await Promise.all(params.ids.map((id) => deleteFile(id)));
return {
data: params.ids,
};
},
};

View File

@@ -1,69 +1,181 @@
import { Chip } from '@mui/material';
import {
Datagrid,
DateField,
DeleteWithConfirmButton,
FunctionField,
List,
RefreshButton,
SearchInput,
TextField,
TopToolbar,
} from 'react-admin';
import { useEffect, useState } from 'react';
import { RefreshCw, Search, Trash2, Folder, FileText, ChevronRight } from 'lucide-react';
import { motion } from 'motion/react';
import { cn } from '@/src/lib/utils';
import { deleteAdminFile, listAdminFiles, type AdminFile } from '@/src/lib/admin';
import { formatBytes, formatDateTime } from '@/src/lib/format';
import type { AdminFile } from '@/src/lib/types';
function FilesListActions() {
return (
<TopToolbar>
<RefreshButton />
</TopToolbar>
);
}
function formatFileSize(size: number) {
if (size >= 1024 * 1024) {
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
const container = {
hidden: { opacity: 0 },
show: {
opacity: 1,
transition: {
staggerChildren: 0.05
}
if (size >= 1024) {
return `${(size / 1024).toFixed(1)} KB`;
}
return `${size} B`;
}
};
const itemVariants = {
hidden: { y: 10, opacity: 0 },
show: { y: 0, opacity: 1 }
};
export default function AdminFilesList() {
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [query, setQuery] = useState('');
const [ownerQuery, setOwnerQuery] = useState('');
const [files, setFiles] = useState<AdminFile[]>([]);
async function loadFiles(nextQuery = query, nextOwnerQuery = ownerQuery) {
setError('');
try {
const result = await listAdminFiles(0, 100, nextQuery, nextOwnerQuery);
setFiles(result.items);
} catch (err) {
setError(err instanceof Error ? err.message : '加载文件失败');
} finally {
setLoading(false);
}
}
useEffect(() => {
void loadFiles();
}, []);
export function PortalAdminFilesList() {
return (
<List
actions={<FilesListActions />}
filters={[
<SearchInput key="query" source="query" alwaysOn placeholder="搜索文件名或路径" />,
<SearchInput key="ownerQuery" source="ownerQuery" placeholder="搜索所属用户" />,
]}
perPage={25}
resource="files"
title="文件管理"
sort={{ field: 'createdAt', order: 'DESC' }}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="flex h-full flex-col p-8 text-gray-900 dark:text-gray-100 overflow-y-auto"
>
<Datagrid bulkActionButtons={false} rowClick={false}>
<TextField source="id" label="ID" />
<TextField source="filename" label="文件名" />
<TextField source="path" label="路径" />
<TextField source="ownerUsername" label="所属用户" />
<TextField source="ownerEmail" label="用户邮箱" />
<FunctionField<AdminFile>
label="类型"
render={(record) =>
record.directory ? <Chip label="目录" size="small" /> : <Chip label="文件" size="small" variant="outlined" />
<div className="mb-10 flex items-center justify-between">
<div>
<h1 className="text-4xl font-black tracking-tight animate-text-reveal text-gray-900 dark:text-white"></h1>
<p className="mt-3 text-[10px] font-black uppercase tracking-[0.2em] opacity-40"> / </p>
</div>
<button
type="button"
onClick={() => {
setLoading(true);
void loadFiles();
}}
className="flex items-center gap-3 px-6 py-3 rounded-lg glass-panel hover:bg-white/40 transition-all font-black text-[11px] uppercase tracking-widest"
>
<RefreshCw className={cn("h-4 w-4", loading && "animate-spin")} />
</button>
</div>
<div className="mb-10 grid grid-cols-1 gap-6 lg:grid-cols-2">
<div className="relative group">
<Search className="absolute left-5 top-1/2 h-4 w-4 -translate-y-1/2 opacity-30 group-focus-within:text-blue-500 transition-colors" />
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
setLoading(true);
void loadFiles(event.currentTarget.value, ownerQuery);
}
}}
placeholder="搜索文件名或路径...(回车)"
className="w-full rounded-lg glass-panel bg-white/10 py-5 pl-14 pr-6 outline-none border border-white/10 focus:border-blue-500/50 focus:ring-4 focus:ring-blue-500/10 transition-all font-black text-[11px] uppercase tracking-widest placeholder:opacity-20"
/>
<FunctionField<AdminFile>
label="大小"
render={(record) => (record.directory ? '-' : formatFileSize(record.size))}
</div>
<div className="relative group">
<Search className="absolute left-5 top-1/2 h-4 w-4 -translate-y-1/2 opacity-30 group-focus-within:text-blue-500 transition-colors" />
<input
value={ownerQuery}
onChange={(event) => setOwnerQuery(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
setLoading(true);
void loadFiles(query, event.currentTarget.value);
}
}}
placeholder="搜索所属用户...(回车)"
className="w-full rounded-lg glass-panel bg-white/10 py-5 pl-14 pr-6 outline-none border border-white/10 focus:border-blue-500/50 focus:ring-4 focus:ring-blue-500/10 transition-all font-black text-[11px] uppercase tracking-widest placeholder:opacity-20"
/>
<TextField source="contentType" label="Content-Type" emptyText="-" />
<DateField source="createdAt" label="创建时间" showTime />
<DeleteWithConfirmButton mutationMode="pessimistic" label="删除" confirmTitle="删除文件" confirmContent="确认删除该文件吗?" />
</Datagrid>
</List>
</div>
</div>
{error ? <div className="mb-8 rounded-lg bg-red-500/10 border border-red-500/20 px-6 py-4 text-xs text-red-600 font-bold backdrop-blur-md uppercase tracking-widest">{error}</div> : null}
<div className="flex-1 min-h-0">
{loading && files.length === 0 ? (
<div className="glass-panel-no-hover rounded-lg px-4 py-16 text-center text-[10px] font-black uppercase tracking-widest opacity-40">...</div>
) : (
<div className="glass-panel-no-hover rounded-lg overflow-hidden shadow-3xl border border-white/10">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-white/10">
<thead className="bg-white/10 dark:bg-black/40">
<tr>
<th className="px-8 py-5 text-left text-[9px] font-black uppercase tracking-[0.2em] opacity-40"></th>
<th className="px-8 py-5 text-left text-[9px] font-black uppercase tracking-[0.2em] opacity-40"></th>
<th className="px-8 py-5 text-left text-[9px] font-black uppercase tracking-[0.2em] opacity-40"></th>
<th className="px-8 py-5 text-left text-[9px] font-black uppercase tracking-[0.2em] opacity-40"></th>
<th className="px-8 py-5 text-right text-[9px] font-black uppercase tracking-[0.2em] opacity-40"></th>
</tr>
</thead>
<motion.tbody
variants={container}
initial="hidden"
animate="show"
className="divide-y divide-white/10 dark:divide-white/5"
>
{files.map((file) => (
<motion.tr key={file.id} variants={itemVariants} className="hover:bg-white/10 dark:hover:bg-white/5 transition-colors group">
<td className="px-8 py-5">
<div className="text-[12px] font-black tracking-tight uppercase group-hover:text-blue-500 transition-colors uppercase">{file.filename}</div>
<div className="mt-1 text-[9px] opacity-30 font-black uppercase tracking-widest truncate max-w-xs">{file.path}</div>
</td>
<td className="px-8 py-5">
<div className="flex items-center gap-2">
<span className="text-[10px] font-black text-blue-500 uppercase tracking-widest">{file.ownerUsername || file.ownerEmail}</span>
</div>
</td>
<td className="px-8 py-5">
<div className="text-[11px] font-black uppercase tracking-widest">{file.directory ? '-' : formatBytes(file.size)}</div>
<div className="text-[9px] opacity-20 font-black tracking-[0.2em] uppercase mt-1">
{file.directory ? '目录' : '文件'}
</div>
</td>
<td className="px-8 py-5 text-[10px] font-bold opacity-30 tracking-tighter uppercase">
{formatDateTime(file.createdAt)}
</td>
<td className="px-8 py-5 text-right">
<button
type="button"
onClick={async () => {
if (!window.confirm(`确认物理擦除 ${file.filename} 吗?此操作将触发硬件级销毁。`)) {
return;
}
await deleteAdminFile(file.id);
await loadFiles();
}}
className="p-2.5 rounded-lg glass-panel hover:bg-red-600 hover:text-white text-red-500 border border-white/10 transition-all opacity-0 group-hover:opacity-100 shadow-sm"
title="彻底删除"
>
<Trash2 className="h-4 w-4" />
</button>
</td>
</motion.tr>
))}
{files.length === 0 && (
<tr>
<td colSpan={5} className="px-8 py-20 text-center text-[10px] font-black uppercase tracking-widest opacity-30">
</td>
</tr>
)}
</motion.tbody>
</table>
</div>
</div>
)}
</div>
</motion.div>
);
}

View File

@@ -1,98 +1,384 @@
import { Chip, Stack } from '@mui/material';
import { useEffect, useState } from 'react';
import { ArrowRightLeft, Edit2, Play, Plus, RefreshCw, Square } from 'lucide-react';
import { motion, AnimatePresence } from 'motion/react';
import {
BooleanField,
Datagrid,
DateField,
FunctionField,
List,
RefreshButton,
TextField,
TopToolbar,
} from 'react-admin';
createStorageMigration,
createStoragePolicy,
getStoragePolicies,
updateStoragePolicy,
updateStoragePolicyStatus,
type AdminStoragePolicy,
type StoragePolicyCapabilities,
type StoragePolicyUpsertPayload,
} from '@/src/lib/admin-storage-policies';
import { formatBytes } from '@/src/lib/format';
import { cn } from '@/src/lib/utils';
import type { AdminStoragePolicy, StoragePolicyCapabilities } from '@/src/lib/types';
const CAPABILITY_LABELS: Array<{ key: keyof StoragePolicyCapabilities; label: string }> = [
{ key: 'directUpload', label: '直传' },
{ key: 'multipartUpload', label: '分片' },
{ key: 'signedDownloadUrl', label: '签名下载' },
{ key: 'serverProxyDownload', label: '服务端下载' },
{ key: 'thumbnailNative', label: '原生缩略图' },
{ key: 'friendlyDownloadName', label: '友好文件名' },
{ key: 'requiresCors', label: 'CORS' },
{ key: 'supportsInternalEndpoint', label: '内网 endpoint' },
];
function StoragePoliciesListActions() {
return (
<TopToolbar>
<RefreshButton />
</TopToolbar>
);
function createDefaultCapabilities(maxObjectSize = 1024 * 1024 * 1024): StoragePolicyCapabilities {
return {
directUpload: false,
multipartUpload: false,
signedDownloadUrl: false,
serverProxyDownload: true,
thumbnailNative: false,
friendlyDownloadName: false,
requiresCors: false,
supportsInternalEndpoint: false,
maxObjectSize,
};
}
function formatFileSize(size: number) {
if (size >= 1024 * 1024 * 1024) {
return `${(size / (1024 * 1024 * 1024)).toFixed(1)} GB`;
function buildInitialForm(policy?: AdminStoragePolicy): StoragePolicyUpsertPayload {
if (policy) {
return {
name: policy.name,
type: policy.type,
bucketName: policy.bucketName ?? '',
endpoint: policy.endpoint ?? '',
region: policy.region ?? '',
privateBucket: policy.privateBucket,
prefix: policy.prefix ?? '',
credentialMode: policy.credentialMode,
maxSizeBytes: policy.maxSizeBytes,
capabilities: policy.capabilities,
enabled: policy.enabled,
};
}
if (size >= 1024 * 1024) {
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
}
if (size >= 1024) {
return `${(size / 1024).toFixed(1)} KB`;
}
return `${size} B`;
return {
name: '',
type: 'LOCAL',
bucketName: '',
endpoint: '',
region: '',
privateBucket: false,
prefix: '',
credentialMode: 'NONE',
maxSizeBytes: 1024 * 1024 * 1024,
capabilities: createDefaultCapabilities(),
enabled: true,
};
}
function renderCapabilities(capabilities: StoragePolicyCapabilities) {
export default function AdminStoragePoliciesList() {
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [policies, setPolicies] = useState<AdminStoragePolicy[]>([]);
const [editingPolicy, setEditingPolicy] = useState<AdminStoragePolicy | null>(null);
const [showForm, setShowForm] = useState(false);
const [form, setForm] = useState<StoragePolicyUpsertPayload>(buildInitialForm());
async function loadPolicies() {
setError('');
try {
setPolicies(await getStoragePolicies());
} catch (err) {
setError(err instanceof Error ? err.message : '加载存储策略失败');
} finally {
setLoading(false);
}
}
useEffect(() => {
void loadPolicies();
}, []);
async function savePolicy() {
try {
if (editingPolicy) {
await updateStoragePolicy(editingPolicy.id, form);
} else {
await createStoragePolicy(form);
}
setShowForm(false);
setEditingPolicy(null);
setForm(buildInitialForm());
await loadPolicies();
} catch (err) {
setError(err instanceof Error ? err.message : '保存策略失败');
}
}
return (
<Stack direction="row" flexWrap="wrap" gap={0.5}>
{CAPABILITY_LABELS.map(({ key, label }) => {
const enabled = capabilities[key] === true;
return (
<Chip
key={key}
color={enabled ? 'success' : 'default'}
label={`${label}${enabled ? '开' : '关'}`}
size="small"
variant={enabled ? 'filled' : 'outlined'}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="flex h-full flex-col p-8 text-gray-900 dark:text-gray-100 overflow-y-auto"
>
<div className="mb-10 flex flex-wrap items-center justify-between gap-4">
<div>
<h1 className="text-4xl font-black tracking-tight animate-text-reveal"></h1>
<p className="mt-3 text-[10px] font-black uppercase tracking-[0.2em] opacity-40"></p>
</div>
<div className="flex items-center gap-3">
<button
type="button"
onClick={() => {
setLoading(true);
void loadPolicies();
}}
className="flex items-center gap-2 px-5 py-3 rounded-lg glass-panel hover:bg-white/40 transition-all font-black text-[11px] uppercase tracking-widest"
>
<RefreshCw className={cn("h-3.5 w-3.5", loading && "animate-spin")} />
</button>
<button
type="button"
onClick={() => {
setEditingPolicy(null);
setForm(buildInitialForm());
setShowForm(true);
}}
className="flex items-center gap-2 px-6 py-3 rounded-lg bg-blue-600 text-white font-black text-[11px] uppercase tracking-[0.15em] shadow-lg hover:bg-blue-500 hover:scale-[1.02] active:scale-[0.98] transition-all"
>
<Plus className="h-4 w-4" />
</button>
</div>
</div>
{error ? <div className="mb-8 rounded-lg bg-red-500/10 border border-red-500/20 px-6 py-4 text-xs text-red-600 dark:text-red-400 font-bold backdrop-blur-md">{error}</div> : null}
<div className="flex-1 min-h-0">
{loading ? (
<div className="glass-panel rounded-lg px-4 py-16 text-center text-[10px] font-black uppercase tracking-widest opacity-40">...</div>
) : (
<div className="glass-panel rounded-lg overflow-hidden shadow-xl border-white/20">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-white/10 text-sm">
<thead className="bg-white/10 dark:bg-black/40 font-black uppercase tracking-[0.15em] text-[9px] opacity-40">
<tr>
<th className="px-8 py-5 text-left"></th>
<th className="px-8 py-5 text-left"></th>
<th className="px-8 py-5 text-left">访</th>
<th className="px-8 py-5 text-left"></th>
<th className="px-8 py-5 text-left"></th>
<th className="px-8 py-5 text-right"></th>
</tr>
</thead>
<tbody className="divide-y divide-white/10 dark:divide-white/5">
{policies.map((policy) => (
<tr key={policy.id} className="hover:bg-white/10 dark:hover:bg-white/5 transition-colors group">
<td className="px-8 py-5">
<div className="flex items-center gap-2 font-black text-[13px] tracking-tight">
{policy.name}
{policy.defaultPolicy ? (
<span className="rounded-sm bg-blue-500/20 text-blue-600 dark:text-blue-400 px-1.5 py-0.5 text-[8px] border border-blue-500/20 uppercase tracking-widest font-black"></span>
) : null}
</div>
<div className="text-[10px] font-bold opacity-30 mt-1 tracking-tighter">PID::{policy.id}</div>
</td>
<td className="px-8 py-5">
<span className="font-black text-[10px] uppercase tracking-widest opacity-60 bg-white/10 px-2 py-0.5 rounded-sm">{policy.type}</span>
</td>
<td className="px-8 py-5">
<div className="truncate max-w-[180px] font-bold opacity-60 text-[11px] tracking-tight">{policy.endpoint || '-'}</div>
<div className="text-[9px] font-black text-blue-500 uppercase tracking-tighter mt-0.5">{policy.bucketName || '私有根路径'}</div>
</td>
<td className="px-8 py-5">
<span className={cn(
"inline-flex items-center gap-1.5 rounded-sm px-2 py-1 text-[9px] font-black uppercase tracking-widest border",
policy.enabled
? "bg-green-500/10 text-green-600 dark:text-green-400 border-green-500/20"
: "bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/20"
)}>
<span className={cn("w-1.5 h-1.5 rounded-full", policy.enabled ? "bg-green-500 animate-pulse" : "bg-red-500")}></span>
{policy.enabled ? '启用' : '停用'}
</span>
</td>
<td className="px-8 py-5 font-black opacity-60 text-xs tracking-tighter">
{formatBytes(policy.maxSizeBytes)}
</td>
<td className="px-8 py-5 text-right">
<div className="flex justify-end gap-2.5 opacity-40 group-hover:opacity-100 transition-opacity">
<button
type="button"
onClick={() => {
setEditingPolicy(policy);
setForm(buildInitialForm(policy));
setShowForm(true);
}}
className="p-2 rounded-lg glass-panel hover:bg-white/40 text-gray-500 border-white/20 transition-all"
title="编辑策略"
>
<Edit2 className="h-4 w-4" />
</button>
{!policy.defaultPolicy ? (
<button
type="button"
onClick={async () => {
await updateStoragePolicyStatus(policy.id, !policy.enabled);
await loadPolicies();
}}
className={cn(
"p-2 rounded-lg glass-panel border-white/20 transition-all",
policy.enabled ? "text-amber-500 hover:bg-amber-500/10" : "text-green-500 hover:bg-green-500/10"
)}
title={policy.enabled ? '停用' : '启用'}
>
{policy.enabled ? <Square className="h-4 w-4" /> : <Play className="h-4 w-4" />}
</button>
) : null}
<button
type="button"
onClick={async () => {
const targetId = window.prompt('请输入迁移目标策略 ID');
if (!targetId) return;
await createStorageMigration(policy.id, Number(targetId));
window.alert('已创建迁移任务');
}}
className="p-2 rounded-lg glass-panel hover:bg-blue-500/10 text-blue-500 border-white/20 transition-all"
title="发起迁移"
>
<ArrowRightLeft className="h-4 w-4" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
{showForm ? (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-md px-4 py-8 overflow-y-auto mt-0">
<motion.div
initial={{ scale: 0.95, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
className="w-full max-w-2xl glass-panel-no-hover rounded-lg p-12 shadow-2xl border-white/20"
>
<h2 className="mb-10 text-3xl font-black tracking-tighter uppercase">{editingPolicy ? '编辑策略' : '新建策略'}</h2>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
<div className="space-y-2">
<label className="text-[10px] font-black uppercase tracking-[0.2em] opacity-40 ml-1"></label>
<input
value={form.name}
onChange={(event) => setForm((current) => ({ ...current, name: event.target.value }))}
className="w-full rounded-lg glass-panel bg-white/10 p-4 outline-none border-white/10 focus:border-blue-500/50 transition-all font-bold text-sm"
/>
</div>
<div className="space-y-2">
<label className="text-[10px] font-black uppercase tracking-[0.2em] opacity-40 ml-1"></label>
<select
value={form.type}
onChange={(event) => setForm((current) => ({ ...current, type: event.target.value as StoragePolicyUpsertPayload['type'] }))}
className="w-full rounded-lg glass-panel bg-white/10 p-4 outline-none border-white/10 focus:border-blue-500/50 transition-all font-bold text-sm"
>
<option value="LOCAL"></option>
<option value="S3_COMPATIBLE">S3 </option>
</select>
</div>
<div className="space-y-2 md:col-span-2">
<label className="text-[10px] font-black uppercase tracking-[0.2em] opacity-40 ml-1"></label>
<input
value={form.endpoint || ''}
onChange={(event) => setForm((current) => ({ ...current, endpoint: event.target.value }))}
className="w-full rounded-lg glass-panel bg-white/10 p-4 outline-none border-white/10 focus:border-blue-500/50 transition-all font-bold text-sm"
/>
</div>
<div className="space-y-2">
<label className="text-[10px] font-black uppercase tracking-[0.2em] opacity-40 ml-1"></label>
<input
value={form.bucketName || ''}
onChange={(event) => setForm((current) => ({ ...current, bucketName: event.target.value }))}
className="w-full rounded-lg glass-panel bg-white/10 p-4 outline-none border-white/10 focus:border-blue-500/50 transition-all font-bold text-sm"
/>
</div>
<div className="space-y-2">
<label className="text-[10px] font-black uppercase tracking-[0.2em] opacity-40 ml-1"></label>
<input
type="number"
value={form.maxSizeBytes}
onChange={(event) => setForm((current) => ({ ...current, maxSizeBytes: Number(event.target.value), capabilities: { ...current.capabilities, maxObjectSize: Number(event.target.value) } }))}
className="w-full rounded-lg glass-panel bg-white/10 p-4 outline-none border-white/10 focus:border-blue-500/50 transition-all font-bold text-sm"
/>
</div>
</div>
<div className="mt-10 grid grid-cols-2 gap-4 text-[9px] font-black uppercase tracking-widest md:grid-cols-4">
{(
[
['privateBucket', '私有桶'],
['enabled', '启用'],
['capabilities.directUpload', '直传'],
['capabilities.multipartUpload', '分片上传'],
['capabilities.signedDownloadUrl', '签名下载'],
['capabilities.serverProxyDownload', '代理下载'],
['capabilities.requiresCors', '需要 CORS'],
['capabilities.supportsInternalEndpoint', '内网端点'],
] as const
).map(([key, label]) => {
const checked =
key === 'privateBucket'
? form.privateBucket
: key === 'enabled'
? form.enabled
: form.capabilities[key.replace('capabilities.', '') as keyof StoragePolicyCapabilities];
const checkedBoolean = Boolean(checked);
return (
<label key={key} className={cn(
"flex items-center gap-3 p-3 rounded-lg hover:bg-white/10 transition-all cursor-pointer border border-transparent group",
checkedBoolean ? "bg-white/5 border-white/10" : "opacity-30"
)}>
<input
type="checkbox"
checked={checkedBoolean}
onChange={(event) => {
const nextValue = event.target.checked;
if (key === 'privateBucket') {
setForm((current) => ({ ...current, privateBucket: nextValue }));
return;
}
if (key === 'enabled') {
setForm((current) => ({ ...current, enabled: nextValue }));
return;
}
const capabilityKey = key.replace('capabilities.', '') as keyof StoragePolicyCapabilities;
setForm((current) => ({
...current,
capabilities: {
...current.capabilities,
[capabilityKey]: nextValue,
},
}));
}}
className="w-4 h-4 rounded-sm border-white/20 bg-white/10 text-blue-600 focus:ring-0"
/>
<span className={cn("transition-colors", checked ? "text-blue-500" : "")}>
{label}
</span>
</label>
);
})}
</Stack>
);
}
</div>
export function PortalAdminStoragePoliciesList() {
return (
<List
actions={<StoragePoliciesListActions />}
perPage={25}
resource="storagePolicies"
title="存储策略"
sort={{ field: 'id', order: 'ASC' }}
<div className="mt-12 flex justify-end gap-3">
<button
type="button"
onClick={() => {
setShowForm(false);
setEditingPolicy(null);
}}
className="px-8 py-4 rounded-lg glass-panel hover:bg-white/40 text-[11px] font-black uppercase tracking-widest transition-all"
>
<Datagrid bulkActionButtons={false} rowClick={false}>
<TextField source="id" label="ID" />
<TextField source="name" label="名称" />
<TextField source="type" label="类型" />
<TextField source="bucketName" label="Bucket" emptyText="-" />
<TextField source="endpoint" label="Endpoint" emptyText="-" />
<TextField source="region" label="Region" emptyText="-" />
<TextField source="prefix" label="Prefix" emptyText="-" />
<TextField source="credentialMode" label="凭证模式" />
<BooleanField source="enabled" label="启用" />
<BooleanField source="defaultPolicy" label="默认" />
<FunctionField<AdminStoragePolicy>
label="容量上限"
render={(record) => formatFileSize(record.maxSizeBytes)}
/>
<FunctionField<AdminStoragePolicy>
label="能力"
render={(record) => renderCapabilities(record.capabilities)}
/>
<DateField source="updatedAt" label="更新时间" showTime />
</Datagrid>
</List>
</button>
<button
type="button"
onClick={() => void savePolicy()}
className="px-10 py-4 rounded-lg bg-blue-600 text-white text-[11px] font-black uppercase tracking-widest shadow-xl hover:bg-blue-500 hover:scale-[1.02] active:scale-[0.98] transition-all"
>
</button>
</div>
</motion.div>
</div>
) : null}
</motion.div>
);
}

View File

@@ -1,305 +1,260 @@
import { useState } from 'react';
import { Button, Chip, Stack } from '@mui/material';
import { useEffect, useState } from 'react';
import { Ban, KeyRound, RefreshCw, Search, Shield, Upload, Mail, Phone, ChevronRight } from 'lucide-react';
import { motion } from 'motion/react';
import { cn } from '@/src/lib/utils';
import {
Datagrid,
DateField,
FunctionField,
List,
SearchInput,
TextField,
TopToolbar,
RefreshButton,
useNotify,
useRefresh,
} from 'react-admin';
getAdminUsers,
resetUserPassword,
updateUserMaxUploadSize,
updateUserPassword,
updateUserRole,
updateUserStatus,
updateUserStorageQuota,
type AdminUser,
} from '@/src/lib/admin-users';
import { formatBytes, formatDateTime } from '@/src/lib/format';
import { apiRequest } from '@/src/lib/api';
import type { AdminPasswordResetResponse, AdminUser, AdminUserRole } from '@/src/lib/types';
const USER_ROLE_OPTIONS: AdminUserRole[] = ['USER', 'MODERATOR', 'ADMIN'];
function formatLimitSize(bytes: number) {
if (bytes <= 0) {
return '0 B';
const container = {
hidden: { opacity: 0 },
show: {
opacity: 1,
transition: {
staggerChildren: 0.05
}
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
const index = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
const value = bytes / 1024 ** index;
return `${value >= 10 || index === 0 ? value.toFixed(0) : value.toFixed(1)} ${units[index]}`;
}
function parseLimitInput(value: string): number | null {
const normalized = value.trim().toLowerCase();
const matched = normalized.match(/^(\d+(?:\.\d+)?)\s*(b|kb|mb|gb|tb)?$/);
if (!matched) {
return null;
}
const amount = Number.parseFloat(matched[1] ?? '0');
if (!Number.isFinite(amount) || amount <= 0) {
return null;
}
const unit = matched[2] ?? 'b';
const multiplier = unit === 'tb'
? 1024 ** 4
: unit === 'gb'
? 1024 ** 3
: unit === 'mb'
? 1024 ** 2
: unit === 'kb'
? 1024
: 1;
return Math.floor(amount * multiplier);
}
};
function UsersListActions() {
return (
<TopToolbar>
<RefreshButton />
</TopToolbar>
);
}
const itemVariants = {
hidden: { y: 10, opacity: 0 },
show: { y: 0, opacity: 1 }
};
function formatStorageUsage(usedBytes: number, quotaBytes: number) {
return `${formatLimitSize(usedBytes)} / ${formatLimitSize(quotaBytes)}`;
}
export default function AdminUsersList() {
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [query, setQuery] = useState('');
const [users, setUsers] = useState<AdminUser[]>([]);
function AdminUserActions({ record }: { record: AdminUser }) {
const notify = useNotify();
const refresh = useRefresh();
const [busy, setBusy] = useState(false);
async function handleRoleAssign() {
const input = window.prompt('请输入角色USER / MODERATOR / ADMIN', record.role);
if (!input) {
return;
}
const role = input.trim().toUpperCase() as AdminUserRole;
if (!USER_ROLE_OPTIONS.includes(role)) {
notify('角色必须是 USER、MODERATOR 或 ADMIN', { type: 'warning' });
return;
}
setBusy(true);
async function loadUsers(nextQuery = query) {
setError('');
try {
await apiRequest(`/admin/users/${record.id}/role`, {
method: 'PATCH',
body: { role },
});
notify(`已将 ${record.username} 设为 ${role}`, { type: 'success' });
refresh();
} catch (error) {
notify(error instanceof Error ? error.message : '角色更新失败', { type: 'error' });
const result = await getAdminUsers(0, 100, nextQuery);
setUsers(result.items);
} catch (err) {
setError(err instanceof Error ? err.message : '加载用户失败');
} finally {
setBusy(false);
setLoading(false);
}
}
async function handleToggleBan() {
const nextBanned = !record.banned;
const confirmed = window.confirm(
nextBanned ? `确认封禁用户 ${record.username} 吗?` : `确认解封用户 ${record.username} 吗?`,
);
if (!confirmed) {
return;
}
useEffect(() => {
void loadUsers();
}, []);
setBusy(true);
async function mutate(action: () => Promise<unknown>) {
try {
await apiRequest(`/admin/users/${record.id}/status`, {
method: 'PATCH',
body: { banned: nextBanned },
});
notify(nextBanned ? '用户已封禁' : '用户已解封', { type: 'success' });
refresh();
} catch (error) {
notify(error instanceof Error ? error.message : '状态更新失败', { type: 'error' });
} finally {
setBusy(false);
}
}
async function handleSetPassword() {
const newPassword = window.prompt(
'请输入新密码。密码至少8位且必须包含大写字母。',
);
if (!newPassword) {
return;
}
setBusy(true);
try {
await apiRequest(`/admin/users/${record.id}/password`, {
method: 'PUT',
body: { newPassword },
});
notify('密码已更新,旧 refresh token 已失效', { type: 'success' });
} catch (error) {
notify(error instanceof Error ? error.message : '密码更新失败', { type: 'error' });
} finally {
setBusy(false);
}
}
async function handleSetStorageQuota() {
const input = window.prompt(
`请输入新的存储上限(支持 B/KB/MB/GB/TB当前 ${formatLimitSize(record.storageQuotaBytes)}`,
`${Math.floor(record.storageQuotaBytes / 1024 / 1024 / 1024)}GB`,
);
if (!input) {
return;
}
const storageQuotaBytes = parseLimitInput(input);
if (!storageQuotaBytes) {
notify('输入格式不正确,请输入例如 20GB 或 21474836480', { type: 'warning' });
return;
}
setBusy(true);
try {
await apiRequest(`/admin/users/${record.id}/storage-quota`, {
method: 'PATCH',
body: { storageQuotaBytes },
});
notify(`存储上限已更新为 ${formatLimitSize(storageQuotaBytes)}`, { type: 'success' });
refresh();
} catch (error) {
notify(error instanceof Error ? error.message : '存储上限更新失败', { type: 'error' });
} finally {
setBusy(false);
}
}
async function handleSetMaxUploadSize() {
const input = window.prompt(
`请输入单文件最大上传大小(支持 B/KB/MB/GB/TB当前 ${formatLimitSize(record.maxUploadSizeBytes)}`,
`${Math.max(1, Math.floor(record.maxUploadSizeBytes / 1024 / 1024))}MB`,
);
if (!input) {
return;
}
const maxUploadSizeBytes = parseLimitInput(input);
if (!maxUploadSizeBytes) {
notify('输入格式不正确,请输入例如 500MB 或 524288000', { type: 'warning' });
return;
}
setBusy(true);
try {
await apiRequest(`/admin/users/${record.id}/max-upload-size`, {
method: 'PATCH',
body: { maxUploadSizeBytes },
});
notify(`单文件上限已更新为 ${formatLimitSize(maxUploadSizeBytes)}`, { type: 'success' });
refresh();
} catch (error) {
notify(error instanceof Error ? error.message : '单文件上限更新失败', { type: 'error' });
} finally {
setBusy(false);
}
}
async function handleResetPassword() {
const confirmed = window.confirm(`确认重置 ${record.username} 的密码吗?`);
if (!confirmed) {
return;
}
setBusy(true);
try {
const result = await apiRequest<AdminPasswordResetResponse>(`/admin/users/${record.id}/password/reset`, {
method: 'POST',
});
notify('已生成临时密码,请立即复制并安全发送给用户', { type: 'success' });
window.prompt(`用户 ${record.username} 的临时密码如下,请复制保存`, result.temporaryPassword);
} catch (error) {
notify(error instanceof Error ? error.message : '密码重置失败', { type: 'error' });
} finally {
setBusy(false);
await action();
await loadUsers();
} catch (err) {
setError(err instanceof Error ? err.message : '操作失败');
}
}
return (
<Stack direction="row" spacing={0.75} useFlexGap flexWrap="wrap">
<Button size="small" variant="outlined" disabled={busy} sx={{ minWidth: 'auto', px: 1 }} onClick={() => void handleRoleAssign()}>
</Button>
<Button size="small" variant="outlined" disabled={busy} sx={{ minWidth: 'auto', px: 1 }} onClick={() => void handleSetPassword()}>
</Button>
<Button size="small" variant="outlined" disabled={busy} sx={{ minWidth: 'auto', px: 1 }} onClick={() => void handleResetPassword()}>
</Button>
<Button size="small" variant="outlined" disabled={busy} sx={{ minWidth: 'auto', px: 1 }} onClick={() => void handleSetStorageQuota()}>
</Button>
<Button size="small" variant="outlined" disabled={busy} sx={{ minWidth: 'auto', px: 1 }} onClick={() => void handleSetMaxUploadSize()}>
</Button>
<Button
size="small"
variant={record.banned ? 'contained' : 'outlined'}
color={record.banned ? 'success' : 'warning'}
disabled={busy}
sx={{ minWidth: 'auto', px: 1 }}
onClick={() => void handleToggleBan()}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="flex h-full flex-col p-8 text-gray-900 dark:text-gray-100 overflow-y-auto"
>
{record.banned ? '解封' : '封禁'}
</Button>
</Stack>
);
}
export function PortalAdminUsersList() {
return (
<List
actions={<UsersListActions />}
filters={[<SearchInput key="query" source="query" alwaysOn placeholder="搜索用户名、邮箱或手机号" />]}
perPage={25}
resource="users"
title="用户管理"
sort={{ field: 'createdAt', order: 'DESC' }}
>
<Datagrid
bulkActionButtons={false}
rowClick={false}
size="small"
sx={{
'& .RaDatagrid-table th, & .RaDatagrid-table td': {
px: 1,
py: 0.75,
},
<div className="mb-10 flex items-center justify-between">
<div>
<h1 className="text-4xl font-black tracking-tight animate-text-reveal text-gray-900 dark:text-white"></h1>
<p className="mt-3 text-[10px] font-black uppercase tracking-[0.2em] opacity-40"> / </p>
</div>
<button
type="button"
onClick={() => {
setLoading(true);
void loadUsers();
}}
className="flex items-center gap-3 px-6 py-3 rounded-lg glass-panel hover:bg-white/40 transition-all font-black text-[11px] uppercase tracking-widest"
>
<TextField source="id" label="ID" />
<TextField source="username" label="用户名" />
<TextField source="email" label="邮箱" />
<TextField source="phoneNumber" label="手机号" emptyText="-" />
<FunctionField<AdminUser>
label="存储使用"
render={(record) => formatStorageUsage(record.usedStorageBytes, record.storageQuotaBytes)}
/>
<FunctionField<AdminUser>
label="单文件上限"
render={(record) => formatLimitSize(record.maxUploadSizeBytes)}
/>
<FunctionField<AdminUser>
label="角色"
render={(record) => <Chip label={record.role} size="small" color={record.role === 'ADMIN' ? 'primary' : 'default'} />}
/>
<FunctionField<AdminUser>
label="状态"
render={(record) => (
<Chip
label={record.banned ? '已封禁' : '正常'}
size="small"
color={record.banned ? 'warning' : 'success'}
variant={record.banned ? 'filled' : 'outlined'}
<RefreshCw className={cn("h-4 w-4", loading && "animate-spin")} />
</button>
</div>
<div className="mb-10 group">
<div className="relative">
<Search className="absolute left-5 top-1/2 h-4 w-4 -translate-y-1/2 opacity-30 group-focus-within:text-blue-500 transition-colors" />
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
setLoading(true);
void loadUsers(event.currentTarget.value);
}
}}
placeholder="搜索用户名、邮箱或手机号...(回车)"
className="w-full rounded-lg glass-panel bg-white/10 py-5 pl-14 pr-6 outline-none border border-white/10 focus:border-blue-500/50 focus:ring-4 focus:ring-blue-500/10 transition-all font-black text-[11px] uppercase tracking-widest placeholder:opacity-20"
/>
</div>
</div>
{error ? <div className="mb-8 rounded-lg bg-red-500/10 border border-red-500/20 px-6 py-4 text-xs text-red-600 font-bold backdrop-blur-md uppercase tracking-widest">{error}</div> : null}
<div className="flex-1 min-h-0">
{loading && users.length === 0 ? (
<div className="glass-panel-no-hover rounded-lg px-4 py-16 text-center text-[10px] font-black uppercase tracking-widest opacity-40">...</div>
) : (
<div className="glass-panel-no-hover rounded-lg overflow-hidden shadow-3xl border border-white/10">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-white/10">
<thead className="bg-white/10 dark:bg-black/40">
<tr>
<th className="px-8 py-5 text-left text-[9px] font-black uppercase tracking-[0.2em] opacity-40"></th>
<th className="px-8 py-5 text-left text-[9px] font-black uppercase tracking-[0.2em] opacity-40"></th>
<th className="px-8 py-5 text-left text-[9px] font-black uppercase tracking-[0.2em] opacity-40"></th>
<th className="px-8 py-5 text-left text-[9px] font-black uppercase tracking-[0.2em] opacity-40"></th>
<th className="px-8 py-5 text-left text-[9px] font-black uppercase tracking-[0.2em] opacity-40"></th>
<th className="px-8 py-5 text-right text-[9px] font-black uppercase tracking-[0.2em] opacity-40"></th>
</tr>
</thead>
<motion.tbody
variants={container}
initial="hidden"
animate="show"
className="divide-y divide-white/10 dark:divide-white/5"
>
{users.map((user) => (
<motion.tr key={user.id} variants={itemVariants} className="hover:bg-white/10 dark:hover:bg-white/5 transition-colors group">
<td className="px-8 py-5">
<div className="flex items-center gap-4">
<div className="h-10 w-10 rounded-lg bg-blue-500/10 flex items-center justify-center font-black text-blue-500 border border-blue-500/20 shadow-inner">
{user.username.charAt(0).toUpperCase()}
</div>
<div>
<div className="text-[12px] font-black tracking-tight uppercase">{user.username}</div>
<div className="text-[10px] opacity-40 font-bold flex items-center gap-1.5 mt-0.5"><Mail className="h-3 w-3" /> {user.email}</div>
{user.phoneNumber ? <div className="mt-1 text-[9px] font-black opacity-20 tracking-widest flex items-center gap-1.5"><Phone className="h-3 w-3" />{user.phoneNumber}</div> : null}
</div>
</div>
</td>
<td className="px-8 py-5">
<span className={cn(
"inline-flex items-center gap-2 rounded-sm px-2 py-0.5 text-[9px] font-black uppercase tracking-widest border shadow-inner",
user.role === 'ADMIN'
? "bg-purple-500/10 text-purple-500 border-purple-500/20"
: "bg-blue-500/10 text-blue-500 border-blue-500/20"
)}>
<Shield className="h-3 w-3" />
{user.role}
</span>
</td>
<td className="px-8 py-5">
<span className={cn(
"inline-flex items-center gap-2 rounded-sm px-2 py-0.5 text-[9px] font-black uppercase tracking-widest border",
user.banned
? "bg-red-500/10 text-red-500 border-red-500/20"
: "bg-green-500/10 text-green-500 border-green-500/20"
)}>
{user.banned ? '已禁用' : '正常'}
</span>
</td>
<td className="px-8 py-5">
<div className="text-[10px] font-black uppercase tracking-tight">
{formatBytes(user.usedStorageBytes)} / <span className="opacity-30">{formatBytes(user.storageQuotaBytes)}</span>
</div>
<div className="mt-2 h-1 w-full max-w-[120px] rounded-full bg-white/10 overflow-hidden">
<motion.div
initial={{ width: 0 }}
animate={{ width: `${Math.min(100, (user.usedStorageBytes / user.storageQuotaBytes) * 100)}%` }}
className="h-full bg-blue-500 shadow-[0_0_8px_rgba(59,130,246,0.5)]"
></motion.div>
</div>
<div className="mt-2 text-[9px] font-bold opacity-30 uppercase tracking-widest">
{formatBytes(user.maxUploadSizeBytes)}
</div>
</td>
<td className="px-8 py-5 text-[10px] font-bold opacity-30 tracking-tighter uppercase">
{formatDateTime(user.createdAt)}
</td>
<td className="px-8 py-5 text-right">
<div className="flex justify-end gap-2 opacity-30 group-hover:opacity-100 transition-opacity">
<button
type="button"
onClick={() =>
void mutate(async () => {
const nextRole = window.prompt('设置角色USER 或 ADMIN', user.role);
if (!nextRole || (nextRole !== 'USER' && nextRole !== 'ADMIN')) {
return;
}
await updateUserRole(user.id, nextRole);
})
}
className="p-2.5 rounded-lg glass-panel hover:bg-blue-600 hover:text-white text-blue-500 transition-all border-white/10"
title="修改角色"
>
<Shield className="h-4 w-4" />
</button>
<button
type="button"
onClick={() =>
void mutate(async () => {
const nextQuota = window.prompt('设置存储配额(字节)', String(user.storageQuotaBytes));
if (!nextQuota) return;
await updateUserStorageQuota(user.id, Number(nextQuota));
})
}
className="p-2.5 rounded-lg glass-panel hover:bg-blue-600 hover:text-white text-blue-500 transition-all border-white/10"
title="修改配额"
>
<Upload className="h-4 w-4" />
</button>
<button
type="button"
onClick={() =>
void mutate(async () => {
const newPassword = window.prompt('设置新密码');
if (!newPassword) return;
await updateUserPassword(user.id, newPassword);
})
}
className="p-2.5 rounded-lg glass-panel hover:bg-amber-500 hover:text-white text-amber-500 transition-all border-white/10"
title="重置密码"
>
<KeyRound className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => void mutate(() => updateUserStatus(user.id, !user.banned))}
className={cn(
"p-2.5 rounded-lg glass-panel border border-white/10 transition-all",
user.banned ? "hover:bg-green-500 hover:text-white text-green-500" : "hover:bg-red-500 hover:text-white text-red-500"
)}
/>
<DateField source="createdAt" label="创建时间" showTime />
<FunctionField<AdminUser> label="操作" render={(record) => <AdminUserActions record={record} />} />
</Datagrid>
</List>
title={user.banned ? '恢复账号' : '禁用账号'}
>
<Ban className="h-4 w-4" />
</button>
</div>
</td>
</motion.tr>
))}
{users.length === 0 ? (
<tr>
<td colSpan={6} className="px-8 py-20 text-center text-[10px] font-black uppercase tracking-widest opacity-30">
</td>
</tr>
) : null}
</motion.tbody>
</table>
</div>
</div>
)}
</div>
</motion.div>
);
}

View File

@@ -1,200 +0,0 @@
import React, { createContext, useContext, useEffect, useState } from 'react';
import { apiRequest } from '@/src/lib/api';
import { fetchAdminAccessStatus } from './admin-access';
import {
clearStoredSession,
createSession,
readStoredSession,
saveStoredSession,
SESSION_EVENT_NAME,
} from '@/src/lib/session';
import type { AuthResponse, AuthSession, UserProfile } from '@/src/lib/types';
interface LoginPayload {
username: string;
password: string;
}
interface AuthContextValue {
ready: boolean;
session: AuthSession | null;
user: UserProfile | null;
isAdmin: boolean;
login: (payload: LoginPayload) => Promise<void>;
devLogin: (username?: string) => Promise<void>;
logout: () => void;
refreshProfile: () => Promise<void>;
}
const AuthContext = createContext<AuthContextValue | null>(null);
function buildSession(auth: AuthResponse): AuthSession {
return createSession(auth);
}
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [session, setSession] = useState<AuthSession | null>(() => readStoredSession());
const [ready, setReady] = useState(false);
const [isAdmin, setIsAdmin] = useState(false);
useEffect(() => {
const syncSession = () => {
setSession(readStoredSession());
};
window.addEventListener('storage', syncSession);
window.addEventListener(SESSION_EVENT_NAME, syncSession);
return () => {
window.removeEventListener('storage', syncSession);
window.removeEventListener(SESSION_EVENT_NAME, syncSession);
};
}, []);
useEffect(() => {
let active = true;
async function hydrate() {
const storedSession = readStoredSession();
if (!storedSession) {
if (active) {
setSession(null);
setReady(true);
}
return;
}
try {
const user = await apiRequest<UserProfile>('/user/profile');
if (!active) {
return;
}
const nextSession = {
...storedSession,
user,
};
saveStoredSession(nextSession);
setSession(nextSession);
} catch {
clearStoredSession();
if (active) {
setSession(null);
}
} finally {
if (active) {
setReady(true);
}
}
}
hydrate();
return () => {
active = false;
};
}, []);
useEffect(() => {
let active = true;
async function syncAdminAccess() {
if (!session?.token) {
if (active) {
setIsAdmin(false);
}
return;
}
try {
const allowed = await fetchAdminAccessStatus();
if (active) {
setIsAdmin(allowed);
}
} catch {
if (active) {
setIsAdmin(false);
}
}
}
syncAdminAccess();
return () => {
active = false;
};
}, [session?.token]);
async function refreshProfile() {
const currentSession = readStoredSession();
if (!currentSession) {
return;
}
const user = await apiRequest<UserProfile>('/user/profile');
const nextSession = {
...currentSession,
user,
};
saveStoredSession(nextSession);
setSession(nextSession);
}
async function login(payload: LoginPayload) {
const auth = await apiRequest<AuthResponse>('/auth/login', {
method: 'POST',
body: payload,
});
const nextSession = buildSession(auth);
saveStoredSession(nextSession);
setSession(nextSession);
}
async function devLogin(username?: string) {
const params = new URLSearchParams();
if (username?.trim()) {
params.set('username', username.trim());
}
const auth = await apiRequest<AuthResponse>(
`/auth/dev-login${params.size ? `?${params.toString()}` : ''}`,
{
method: 'POST',
},
);
const nextSession = buildSession(auth);
saveStoredSession(nextSession);
setSession(nextSession);
}
function logout() {
clearStoredSession();
setSession(null);
}
return (
<AuthContext.Provider
value={{
ready,
session,
user: session?.user || null,
isAdmin,
login,
devLogin,
logout,
refreshProfile,
}}
>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used inside AuthProvider');
}
return context;
}

View File

@@ -1,36 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { ApiError } from '@/src/lib/api';
import { fetchAdminAccessStatus } from './admin-access';
test('fetchAdminAccessStatus returns true when the admin summary request succeeds', async () => {
const request = async () => ({
totalUsers: 1,
totalFiles: 2,
totalStorageBytes: 0,
downloadTrafficBytes: 0,
requestCount: 0,
transferUsageBytes: 0,
offlineTransferStorageBytes: 0,
offlineTransferStorageLimitBytes: 0,
dailyActiveUsers: [],
requestTimeline: [],
inviteCode: 'invite-code',
});
await assert.doesNotReject(async () => {
const allowed = await fetchAdminAccessStatus(request);
assert.equal(allowed, true);
});
});
test('fetchAdminAccessStatus returns false when the server rejects the user with 403', async () => {
const request = async () => {
throw new ApiError('没有后台权限', 403);
};
const allowed = await fetchAdminAccessStatus(request);
assert.equal(allowed, false);
});

View File

@@ -1,19 +0,0 @@
import { ApiError, apiRequest } from '@/src/lib/api';
import type { AdminSummary } from '@/src/lib/types';
type AdminSummaryRequest = () => Promise<AdminSummary>;
export async function fetchAdminAccessStatus(
request: AdminSummaryRequest = () => apiRequest<AdminSummary>('/admin/summary'),
) {
try {
await request();
return true;
} catch (error) {
if (error instanceof ApiError && error.status === 403) {
return false;
}
throw error;
}
}

View File

@@ -0,0 +1,73 @@
import React, { createContext, useContext, useEffect, useState } from 'react';
type Theme = 'dark' | 'light' | 'system';
type ThemeProviderProps = {
children: React.ReactNode;
defaultTheme?: Theme;
storageKey?: string;
};
type ThemeProviderState = {
theme: Theme;
setTheme: (theme: Theme) => void;
};
const initialState: ThemeProviderState = {
theme: 'system',
setTheme: () => null,
};
const ThemeProviderContext = createContext<ThemeProviderState>(initialState);
export function ThemeProvider({
children,
defaultTheme = 'system',
storageKey = 'vite-ui-theme',
...props
}: ThemeProviderProps) {
const [theme, setTheme] = useState<Theme>(
() => (localStorage.getItem(storageKey) as Theme) || defaultTheme
);
useEffect(() => {
const root = window.document.documentElement;
root.classList.remove('light', 'dark');
if (theme === 'system') {
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)')
.matches
? 'dark'
: 'light';
root.classList.add(systemTheme);
return;
}
root.classList.add(theme);
}, [theme]);
const value = {
theme,
setTheme: (theme: Theme) => {
localStorage.setItem(storageKey, theme);
setTheme(theme);
},
};
return (
<ThemeProviderContext.Provider {...props} value={value}>
{children}
</ThemeProviderContext.Provider>
);
}
export const useTheme = () => {
const context = useContext(ThemeProviderContext);
if (context === undefined)
throw new Error('useTheme must be used within a ThemeProvider');
return context;
};

View File

@@ -0,0 +1,17 @@
import { Moon, Sun } from 'lucide-react';
import { useTheme } from './ThemeProvider';
export function ThemeToggle() {
const { theme, setTheme } = useTheme();
return (
<button
onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}
className="p-2 rounded-xl glass-panel hover:bg-white/40 dark:hover:bg-white/10 transition-all border border-white/20"
aria-label="Toggle theme"
>
<Sun className="h-5 w-5 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Moon className="absolute h-5 w-5 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100 top-2 left-2" />
</button>
);
}

View File

@@ -1,20 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { getVisibleNavItems } from './Layout';
test('getVisibleNavItems exposes the transfer entry instead of the school entry', () => {
const visibleItems = getVisibleNavItems(false);
const visiblePaths: string[] = visibleItems.map((item) => item.path);
assert.equal(visiblePaths.includes('/transfer'), true);
assert.equal(visiblePaths.some((path) => path === '/school'), false);
});
test('getVisibleNavItems hides the admin entry for non-admin users', () => {
assert.equal(getVisibleNavItems(false).some((item) => item.path === '/admin'), false);
});
test('getVisibleNavItems keeps the admin entry for admin users', () => {
assert.equal(getVisibleNavItems(true).some((item) => item.path === '/admin'), true);
});

View File

@@ -1,627 +1,121 @@
import React, { ReactNode, useEffect, useMemo, useRef, useState } from 'react';
import { NavLink, Outlet, useNavigate } from 'react-router-dom';
import { useEffect, useState } from 'react';
import { Outlet, NavLink, useLocation, useNavigate } from 'react-router-dom';
import {
Gamepad2,
FolderOpen,
Key,
HardDrive,
LayoutDashboard,
ListTodo,
LogOut,
Mail,
Send,
Settings,
Shield,
Smartphone,
X,
Share2,
Trash2,
Sun,
Moon,
} from 'lucide-react';
import { AnimatePresence, motion } from 'motion/react';
import { useAuth } from '@/src/auth/AuthProvider';
import { apiBinaryUploadRequest, apiDownload, apiRequest, apiUploadRequest } from '@/src/lib/api';
import { createSession, readStoredSession, saveStoredSession } from '@/src/lib/session';
import type { AuthResponse, InitiateUploadResponse, UserProfile } from '@/src/lib/types';
import { cn } from '@/src/lib/utils';
import { Button } from '@/src/components/ui/button';
import { Input } from '@/src/components/ui/input';
import { logout } from '@/src/lib/auth';
import { getSession, type PortalSession } from '@/src/lib/session';
import { useTheme } from '../ThemeProvider';
import { buildAccountDraft, getRoleLabel, shouldLoadAvatarWithAuth } from './account-utils';
import { UploadProgressPanel } from './UploadProgressPanel';
const NAV_ITEMS = [
{ name: '总览', path: '/overview', icon: LayoutDashboard },
{ name: '网盘', path: '/files', icon: FolderOpen },
{ name: '快传', path: '/transfer', icon: Send },
{ name: '游戏', path: '/games', icon: Gamepad2 },
{ name: '后台', path: '/admin', icon: Shield },
] as const;
type ActiveModal = 'security' | 'settings' | null;
export function getVisibleNavItems(isAdmin: boolean) {
return NAV_ITEMS.filter((item) => isAdmin || item.path !== '/admin');
}
interface LayoutProps {
children?: ReactNode;
}
export function Layout({ children }: LayoutProps = {}) {
export default function Layout() {
const location = useLocation();
const navigate = useNavigate();
const { isAdmin, logout, refreshProfile, user } = useAuth();
const navItems = getVisibleNavItems(isAdmin);
const fileInputRef = useRef<HTMLInputElement>(null);
const [activeModal, setActiveModal] = useState<ActiveModal>(null);
const [avatarPreviewUrl, setAvatarPreviewUrl] = useState<string | null>(null);
const [selectedAvatarFile, setSelectedAvatarFile] = useState<File | null>(null);
const [avatarSourceUrl, setAvatarSourceUrl] = useState<string | null>(user?.avatarUrl ?? null);
const [profileDraft, setProfileDraft] = useState(() =>
buildAccountDraft(
user ?? {
id: 0,
username: '',
email: '',
createdAt: '',
},
),
);
const [currentPassword, setCurrentPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [profileMessage, setProfileMessage] = useState('');
const [passwordMessage, setPasswordMessage] = useState('');
const [profileError, setProfileError] = useState('');
const [passwordError, setPasswordError] = useState('');
const [profileSubmitting, setProfileSubmitting] = useState(false);
const [passwordSubmitting, setPasswordSubmitting] = useState(false);
const [session, setSession] = useState<PortalSession | null>(() => getSession());
const { theme, setTheme } = useTheme();
useEffect(() => {
if (!user) {
return;
}
setProfileDraft(buildAccountDraft(user));
}, [user]);
const handleSessionChange = (event: Event) => {
const customEvent = event as CustomEvent<PortalSession | null>;
setSession(customEvent.detail ?? getSession());
};
window.addEventListener('portal-session-changed', handleSessionChange);
return () => window.removeEventListener('portal-session-changed', handleSessionChange);
}, []);
useEffect(() => {
if (!avatarPreviewUrl) {
return undefined;
if (!session && location.pathname !== '/transfer') {
navigate('/login', { replace: true });
}
}, [location.pathname, navigate, session]);
return () => {
URL.revokeObjectURL(avatarPreviewUrl);
};
}, [avatarPreviewUrl]);
useEffect(() => {
let active = true;
let objectUrl: string | null = null;
async function syncAvatar() {
if (!user?.avatarUrl) {
if (active) {
setAvatarSourceUrl(null);
}
return;
}
if (!shouldLoadAvatarWithAuth(user.avatarUrl)) {
if (active) {
setAvatarSourceUrl(user.avatarUrl);
}
return;
}
try {
const response = await apiDownload(user.avatarUrl);
const blob = await response.blob();
objectUrl = URL.createObjectURL(blob);
if (active) {
setAvatarSourceUrl(objectUrl);
}
} catch {
if (active) {
setAvatarSourceUrl(null);
}
}
}
void syncAvatar();
return () => {
active = false;
if (objectUrl) {
URL.revokeObjectURL(objectUrl);
}
};
}, [user?.avatarUrl]);
const displayName = useMemo(() => {
if (!user) {
return '账户';
}
return user.displayName || user.username;
}, [user]);
const email = user?.email || '暂无邮箱';
const phoneNumber = user?.phoneNumber || '未设置手机号';
const roleLabel = getRoleLabel(user?.role);
const avatarFallback = (displayName || 'Y').charAt(0).toUpperCase();
const displayedAvatarUrl = avatarPreviewUrl || avatarSourceUrl;
const handleLogout = () => {
logout();
navigate('/login');
};
const handleAvatarClick = () => {
fileInputRef.current?.click();
};
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) {
return;
}
setSelectedAvatarFile(file);
setAvatarPreviewUrl((current) => {
if (current) {
URL.revokeObjectURL(current);
}
return URL.createObjectURL(file);
});
};
const handleProfileDraftChange = (field: keyof typeof profileDraft, value: string) => {
setProfileDraft((current) => ({
...current,
[field]: value,
}));
};
const closeModal = () => {
setActiveModal(null);
setProfileMessage('');
setProfileError('');
setPasswordMessage('');
setPasswordError('');
};
const persistSessionUser = (nextProfile: UserProfile) => {
const currentSession = readStoredSession();
if (!currentSession) {
return;
}
saveStoredSession({
...currentSession,
user: nextProfile,
});
};
const uploadAvatar = async (file: File) => {
const initiated = await apiRequest<InitiateUploadResponse>('/user/avatar/upload/initiate', {
method: 'POST',
body: {
filename: file.name,
contentType: file.type || 'image/png',
size: file.size,
},
});
if (initiated.direct) {
try {
await apiBinaryUploadRequest(initiated.uploadUrl, {
method: initiated.method,
headers: initiated.headers,
body: file,
});
} catch {
const formData = new FormData();
formData.append('file', file);
await apiUploadRequest<void>(`/user/avatar/upload?storageName=${encodeURIComponent(initiated.storageName)}`, {
body: formData,
});
}
} else {
const formData = new FormData();
formData.append('file', file);
await apiUploadRequest<void>(initiated.uploadUrl, {
body: formData,
method: initiated.method === 'PUT' ? 'PUT' : 'POST',
headers: initiated.headers,
});
}
const nextProfile = await apiRequest<UserProfile>('/user/avatar/upload/complete', {
method: 'POST',
body: {
filename: file.name,
contentType: file.type || 'image/png',
size: file.size,
storageName: initiated.storageName,
},
});
persistSessionUser(nextProfile);
return nextProfile;
};
const handleSaveProfile = async () => {
setProfileSubmitting(true);
setProfileMessage('');
setProfileError('');
try {
if (selectedAvatarFile) {
await uploadAvatar(selectedAvatarFile);
}
const nextProfile = await apiRequest<UserProfile>('/user/profile', {
method: 'PUT',
body: {
displayName: profileDraft.displayName.trim(),
email: profileDraft.email.trim(),
phoneNumber: profileDraft.phoneNumber.trim(),
bio: profileDraft.bio,
preferredLanguage: profileDraft.preferredLanguage,
},
});
persistSessionUser(nextProfile);
await refreshProfile();
setSelectedAvatarFile(null);
setAvatarPreviewUrl((current) => {
if (current) {
URL.revokeObjectURL(current);
}
return null;
});
setProfileMessage('账户资料已保存');
} catch (error) {
setProfileError(error instanceof Error ? error.message : '账户资料保存失败');
} finally {
setProfileSubmitting(false);
}
};
const handleChangePassword = async () => {
setPasswordMessage('');
setPasswordError('');
if (newPassword !== confirmPassword) {
setPasswordError('两次输入的新密码不一致');
return;
}
setPasswordSubmitting(true);
try {
const auth = await apiRequest<AuthResponse>('/user/password', {
method: 'POST',
body: {
currentPassword,
newPassword,
},
});
const currentSession = readStoredSession();
if (currentSession) {
saveStoredSession({
...currentSession,
...createSession(auth),
user: auth.user,
});
}
setCurrentPassword('');
setNewPassword('');
setConfirmPassword('');
setPasswordMessage('密码已更新,当前登录态已同步刷新');
} catch (error) {
setPasswordError(error instanceof Error ? error.message : '密码修改失败');
} finally {
setPasswordSubmitting(false);
}
};
const navItems = [
{ to: '/overview', icon: LayoutDashboard, label: '概览' },
{ to: '/files', icon: HardDrive, label: '网盘' },
{ to: '/tasks', icon: ListTodo, label: '任务' },
{ to: '/shares', icon: Share2, label: '分享' },
{ to: '/recycle-bin', icon: Trash2, label: '回收站' },
{ to: '/transfer', icon: Send, label: '快传' },
...(session?.user.role === 'ADMIN'
? [{ to: '/admin/dashboard', icon: Settings, label: '后台' }]
: []),
];
return (
<div className="flex bg-[#07101D] text-white overflow-hidden w-full h-screen">
<aside className="h-full w-16 md:w-56 flex flex-col shrink-0 border-r border-white/10 bg-[#0f172a]/50">
<div className="h-14 flex items-center md:px-4 justify-center md:justify-start border-b border-white/10">
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-[#336EFF] to-blue-400 flex items-center justify-center shadow-lg shadow-[#336EFF]/20 shrink-0">
<span className="text-white font-bold text-lg leading-none">Y</span>
<div className="flex h-screen w-full bg-aurora text-gray-900 dark:text-gray-100 overflow-hidden">
{/* Sidebar */}
<aside className="w-68 flex-shrink-0 border-r border-white/20 dark:border-white/10 bg-white/40 dark:bg-black/40 backdrop-blur-2xl flex flex-col z-20 shadow-xl">
<div className="h-24 flex items-center justify-between px-8 border-b border-white/10">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-blue-600 rounded-lg flex items-center justify-center text-white font-black shadow-lg text-lg tracking-tighter">P</div>
<span className="text-2xl font-black tracking-tight uppercase"></span>
</div>
<div className="hidden md:flex flex-col ml-3">
<span className="text-white font-bold text-sm tracking-wider">YOYUZH.XYZ</span>
<button
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
className="p-2.5 rounded-lg glass-panel hover:bg-white/50 transition-all font-bold"
>
{theme === 'dark' ? <Sun className="w-5 h-5 text-yellow-300" /> : <Moon className="w-5 h-5 text-gray-700" />}
</button>
</div>
<div className="border-b border-white/10 px-8 py-6">
<div className="text-sm font-black uppercase tracking-[0.2em] opacity-70 mb-1"></div>
<div className="text-sm font-black truncate">
{session?.user.displayName || session?.user.username || '游客用户'}
</div>
<div className="truncate text-sm font-bold opacity-80 dark:opacity-90 flex items-center gap-1.5 mt-2 uppercase tracking-tight">
<span className="inline-block w-1.5 h-1.5 rounded-full bg-green-500 shadow-[0_0_10px_rgba(34,197,94,0.6)] animate-pulse"></span>
{session?.user.email || '未登录'}
</div>
</div>
<nav className="flex-1 flex flex-col gap-2 p-2 relative overflow-y-auto overflow-x-hidden">
<nav className="flex-1 overflow-y-auto py-8 px-5 space-y-1.5">
<div className="px-3 mb-2 text-xs font-black uppercase tracking-[0.3em] opacity-70"></div>
{navItems.map((item) => (
<NavLink
key={item.path}
to={item.path}
key={item.to}
to={item.to}
className={({ isActive }) =>
cn(
'flex items-center gap-3 px-0 md:px-4 justify-center md:justify-start h-10 rounded-xl text-sm font-medium transition-all duration-200 relative overflow-hidden group',
isActive ? 'text-white shadow-md shadow-[#336EFF]/20' : 'text-slate-400 hover:text-white hover:bg-white/5',
"flex items-center gap-3 px-4 py-3.5 rounded-lg text-sm font-black uppercase tracking-widest transition-all duration-300 group",
isActive
? "glass-panel-no-hover bg-white/60 dark:bg-white/10 shadow-lg text-blue-600 dark:text-blue-400 border-white/40"
: "text-gray-700 dark:text-gray-200 hover:bg-white/30 dark:hover:bg-white/5 hover:translate-x-1"
)
}
>
{({ isActive }) => (
<>
{isActive && <div className="absolute inset-0 bg-[#336EFF] opacity-100 z-0" />}
<item.icon className="w-[18px] h-[18px] relative z-10 shrink-0" />
<span className="relative z-10 hidden md:block">{item.name}</span>
</>
)}
<item.icon className={cn("h-4 w-4 transition-colors group-hover:text-blue-500")} />
{item.label}
</NavLink>
))}
</nav>
<div className="p-4 border-t border-white/10 shrink-0 flex flex-col gap-2 relative">
<div className="border-t border-white/10 p-6">
<button
onClick={() => setActiveModal('settings')}
className="w-full flex items-center justify-center md:justify-start gap-3 p-2 rounded-xl text-slate-400 hover:text-white hover:bg-white/5 transition-colors"
type="button"
onClick={() => {
logout();
navigate('/login');
}}
className="flex w-full items-center gap-3 rounded-lg px-4 py-4 text-sm font-black uppercase tracking-[0.2em] text-gray-700 dark:text-gray-200 hover:text-red-500 transition-all hover:bg-white/20 dark:hover:bg-white/5"
>
<div className="w-8 h-8 rounded-full border border-white/10 flex items-center justify-center bg-slate-800 text-slate-300 relative z-10 overflow-hidden shrink-0">
{displayedAvatarUrl ? (
<img src={displayedAvatarUrl} alt="Avatar" className="w-full h-full object-cover" />
) : (
<span className="text-xs font-semibold">{avatarFallback}</span>
)}
</div>
<div className="hidden md:block flex-1 min-w-0 text-left">
<p className="text-sm font-medium text-white truncate">{displayName}</p>
<p className="text-xs text-slate-400 truncate">{email}</p>
</div>
</button>
<button
onClick={handleLogout}
className="w-full flex items-center justify-center md:justify-start gap-3 md:px-4 h-10 rounded-xl text-sm text-red-400 hover:bg-red-500/10 hover:text-red-300 transition-colors"
>
<LogOut className="w-[18px] h-[18px]" />
<span className="hidden md:block font-medium">退</span>
<LogOut className="h-4 w-4 opacity-60" />
退
</button>
</div>
</aside>
<main className="flex-1 flex flex-col min-w-0 h-full relative overflow-y-auto">
{children ?? <Outlet />}
<main className="relative flex min-w-0 flex-1 flex-col overflow-hidden z-10">
<Outlet />
</main>
<UploadProgressPanel />
<AnimatePresence>
{activeModal === 'security' && (
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
className="bg-[#0f172a] border border-white/10 rounded-2xl shadow-2xl w-full max-w-lg overflow-hidden flex flex-col max-h-[80vh]"
>
<div className="p-5 border-b border-white/10 flex justify-between items-center bg-white/5">
<h3 className="text-lg font-semibold text-white flex items-center gap-2">
<Shield className="w-5 h-5 text-emerald-400" />
</h3>
<button onClick={closeModal} className="text-slate-400 hover:text-white transition-colors p-1 rounded-md hover:bg-white/10">
<X className="w-5 h-5" />
</button>
</div>
<div className="p-6 overflow-y-auto space-y-6">
<div className="space-y-4">
<div className="p-4 rounded-xl bg-white/5 border border-white/10 space-y-4">
<div className="flex items-center gap-4">
<div className="w-10 h-10 rounded-full bg-blue-500/20 flex items-center justify-center">
<Key className="w-5 h-5 text-blue-400" />
</div>
<div>
<p className="text-sm font-medium text-white"></p>
<p className="text-xs text-slate-400 mt-0.5">使 refresh token </p>
</div>
</div>
<div className="grid gap-3">
<Input
type="password"
placeholder="当前密码"
value={currentPassword}
onChange={(event) => setCurrentPassword(event.target.value)}
className="bg-black/20 border-white/10"
/>
<Input
type="password"
placeholder="新密码"
value={newPassword}
onChange={(event) => setNewPassword(event.target.value)}
className="bg-black/20 border-white/10"
/>
<Input
type="password"
placeholder="确认新密码"
value={confirmPassword}
onChange={(event) => setConfirmPassword(event.target.value)}
className="bg-black/20 border-white/10"
/>
<div className="flex justify-end">
<Button variant="outline" disabled={passwordSubmitting} onClick={() => void handleChangePassword()}>
{passwordSubmitting ? '保存中...' : '修改'}
</Button>
</div>
</div>
</div>
<div className="flex items-center justify-between p-4 rounded-xl bg-white/5 border border-white/10">
<div className="flex items-center gap-4">
<div className="w-10 h-10 rounded-full bg-emerald-500/20 flex items-center justify-center">
<Smartphone className="w-5 h-5 text-emerald-400" />
</div>
<div>
<p className="text-sm font-medium text-white"></p>
<p className="text-xs text-slate-400 mt-0.5">{phoneNumber}</p>
</div>
</div>
<Button
variant="outline"
className="border-white/10 hover:bg-white/10 text-slate-300"
onClick={() => setActiveModal('settings')}
>
</Button>
</div>
<div className="flex items-center justify-between p-4 rounded-xl bg-white/5 border border-white/10">
<div className="flex items-center gap-4">
<div className="w-10 h-10 rounded-full bg-purple-500/20 flex items-center justify-center">
<Mail className="w-5 h-5 text-purple-400" />
</div>
<div>
<p className="text-sm font-medium text-white"></p>
<p className="text-xs text-slate-400 mt-0.5">{email}</p>
</div>
</div>
<Button
variant="outline"
className="border-white/10 hover:bg-white/10 text-slate-300"
onClick={() => setActiveModal('settings')}
>
</Button>
</div>
</div>
{passwordError && <p className="text-sm text-rose-300">{passwordError}</p>}
{passwordMessage && <p className="text-sm text-emerald-300">{passwordMessage}</p>}
</div>
</motion.div>
</div>
)}
{activeModal === 'settings' && (
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
className="bg-[#0f172a] border border-white/10 rounded-2xl shadow-2xl w-full max-w-lg overflow-hidden flex flex-col max-h-[80vh]"
>
<div className="p-5 border-b border-white/10 flex justify-between items-center bg-white/5">
<h3 className="text-lg font-semibold text-white flex items-center gap-2">
<Settings className="w-5 h-5 text-[#336EFF]" />
</h3>
<button onClick={closeModal} className="text-slate-400 hover:text-white transition-colors p-1 rounded-md hover:bg-white/10">
<X className="w-5 h-5" />
</button>
</div>
<div className="p-6 overflow-y-auto space-y-6">
<div className="flex items-center gap-6 pb-6 border-b border-white/10">
<div className="relative group cursor-pointer" onClick={handleAvatarClick}>
<div className="w-20 h-20 rounded-full bg-gradient-to-br from-[#336EFF] to-blue-400 flex items-center justify-center text-2xl font-bold text-white shadow-lg overflow-hidden">
{displayedAvatarUrl ? <img src={displayedAvatarUrl} alt="Avatar" className="w-full h-full object-cover" /> : avatarFallback}
</div>
<div className="absolute inset-0 bg-black/50 rounded-full opacity-0 group-hover:opacity-100 flex items-center justify-center transition-opacity">
<span className="text-xs text-white">{selectedAvatarFile ? '等待保存' : '更换头像'}</span>
</div>
<input type="file" ref={fileInputRef} onChange={handleFileChange} accept="image/*" className="hidden" />
</div>
<div className="flex-1 space-y-1">
<h4 className="text-lg font-medium text-white">{displayName}</h4>
<p className="text-sm text-slate-400">{roleLabel}</p>
</div>
</div>
<div className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium text-slate-300"></label>
<Input
value={profileDraft.displayName}
onChange={(event) => handleProfileDraftChange('displayName', event.target.value)}
className="bg-black/20 border-white/10 text-white focus-visible:ring-[#336EFF]"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-300"></label>
<Input
type="email"
value={profileDraft.email}
onChange={(event) => handleProfileDraftChange('email', event.target.value)}
className="bg-black/20 border-white/10 text-white focus-visible:ring-[#336EFF]"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-300"></label>
<Input
type="tel"
value={profileDraft.phoneNumber}
onChange={(event) => handleProfileDraftChange('phoneNumber', event.target.value)}
className="bg-black/20 border-white/10 text-white focus-visible:ring-[#336EFF]"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-300"></label>
<textarea
className="w-full min-h-[100px] rounded-md bg-black/20 border border-white/10 text-white p-3 text-sm focus:outline-none focus:ring-2 focus:ring-[#336EFF] resize-none"
value={profileDraft.bio}
onChange={(event) => handleProfileDraftChange('bio', event.target.value)}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-300"></label>
<select
className="w-full rounded-md bg-black/20 border border-white/10 text-white p-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-[#336EFF] appearance-none"
value={profileDraft.preferredLanguage}
onChange={(event) => handleProfileDraftChange('preferredLanguage', event.target.value)}
>
<option value="zh-CN"></option>
<option value="en-US">English</option>
</select>
</div>
<div className="flex items-center justify-between p-4 rounded-xl bg-white/5 border border-white/10">
<div className="flex items-center gap-4">
<div className="w-10 h-10 rounded-full bg-blue-500/20 flex items-center justify-center">
<Key className="w-5 h-5 text-blue-400" />
</div>
<div>
<p className="text-sm font-medium text-white"></p>
<p className="text-xs text-slate-400 mt-0.5"></p>
</div>
</div>
<Button
variant="outline"
className="border-white/10 hover:bg-white/10 text-slate-300"
onClick={() => {
setActiveModal('security');
}}
>
</Button>
</div>
</div>
{profileError && <p className="text-sm text-rose-300">{profileError}</p>}
{profileMessage && <p className="text-sm text-emerald-300">{profileMessage}</p>}
<div className="pt-4 flex justify-end gap-3">
<Button variant="outline" onClick={closeModal} className="border-white/10 hover:bg-white/10 text-slate-300">
</Button>
<Button variant="default" disabled={profileSubmitting} onClick={() => void handleSaveProfile()}>
{profileSubmitting ? '保存中...' : '保存更改'}
</Button>
</div>
</div>
</motion.div>
</div>
)}
</AnimatePresence>
</div>
);
}

View File

@@ -1,38 +0,0 @@
import assert from 'node:assert/strict';
import { afterEach, test } from 'node:test';
import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { createUploadTask } from '@/src/pages/files-upload';
import {
clearFilesUploads,
replaceFilesUploads,
resetFilesUploadStoreForTests,
setFilesUploadPanelOpen,
} from '@/src/pages/files-upload-store';
import { UploadProgressPanel } from './UploadProgressPanel';
afterEach(() => {
resetFilesUploadStoreForTests();
});
test('mobile upload progress panel renders as a top summary card instead of a bottom desktop panel', () => {
replaceFilesUploads([
createUploadTask(new File(['demo'], 'demo.txt', { type: 'text/plain' }), []),
]);
setFilesUploadPanelOpen(false);
const html = renderToStaticMarkup(
React.createElement(UploadProgressPanel, {
variant: 'mobile',
className: 'top-offset-anchor',
}),
);
clearFilesUploads();
assert.match(html, /top-offset-anchor/);
assert.match(html, /已在后台上传 1 项/);
assert.doesNotMatch(html, /bottom-6/);
});

View File

@@ -1,220 +0,0 @@
import { AnimatePresence, motion } from 'motion/react';
import { Ban, CheckCircle2, ChevronDown, ChevronUp, FileUp, TriangleAlert, UploadCloud, X } from 'lucide-react';
import { FileTypeIcon, getFileTypeTheme } from '@/src/components/ui/FileTypeIcon';
import { ellipsizeFileName } from '@/src/lib/file-name';
import { cn } from '@/src/lib/utils';
import {
cancelFilesUploadTask,
clearFilesUploads,
toggleFilesUploadPanelOpen,
useFilesUploadStore,
} from '@/src/pages/files-upload-store';
import type { UploadTask } from '@/src/pages/files-upload';
export type UploadProgressPanelVariant = 'desktop' | 'mobile';
export function getUploadProgressSummary(uploads: UploadTask[]) {
const uploadingCount = uploads.filter((task) => task.status === 'uploading').length;
const completedCount = uploads.filter((task) => task.status === 'completed').length;
const errorCount = uploads.filter((task) => task.status === 'error').length;
const cancelledCount = uploads.filter((task) => task.status === 'cancelled').length;
const uploadingTasks = uploads.filter((task) => task.status === 'uploading');
const activeProgress = uploadingTasks.length > 0
? Math.round(uploadingTasks.reduce((sum, task) => sum + task.progress, 0) / uploadingTasks.length)
: uploads.length > 0 && completedCount === uploads.length
? 100
: 0;
if (uploadingCount > 0) {
return {
title: `已在后台上传 ${uploadingCount}`,
detail: `${completedCount}/${uploads.length} 已完成 · ${activeProgress}%`,
progress: activeProgress,
};
}
if (errorCount > 0) {
return {
title: `上传结束,${errorCount} 项失败`,
detail: `${completedCount}/${uploads.length} 已完成`,
progress: activeProgress,
};
}
if (cancelledCount > 0) {
return {
title: '上传已停止',
detail: `${completedCount}/${uploads.length} 已完成`,
progress: activeProgress,
};
}
return {
title: `上传已完成 ${completedCount}`,
detail: `${completedCount}/${uploads.length} 已完成`,
progress: activeProgress,
};
}
interface UploadProgressPanelProps {
className?: string;
variant?: UploadProgressPanelVariant;
}
export function UploadProgressPanel({
className,
variant = 'desktop',
}: UploadProgressPanelProps = {}) {
const { uploads, isUploadPanelOpen } = useFilesUploadStore();
if (uploads.length === 0) {
return null;
}
const summary = getUploadProgressSummary(uploads);
const isMobile = variant === 'mobile';
return (
<AnimatePresence>
<motion.div
initial={{ opacity: 0, y: 50, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 50, scale: 0.95 }}
className={cn(
'z-50 flex flex-col overflow-hidden border border-white/10 bg-[#0f172a]/95 backdrop-blur-xl',
isMobile
? 'w-full rounded-2xl shadow-[0_16px_40px_rgba(15,23,42,0.28)]'
: 'fixed bottom-6 right-6 w-[min(24rem,calc(100vw-2rem))] rounded-xl shadow-2xl',
className,
)}
>
<div
className="flex cursor-pointer items-center justify-between border-b border-white/10 bg-white/5 px-4 py-3 transition-colors hover:bg-white/10"
onClick={() => toggleFilesUploadPanelOpen()}
>
<div className="flex items-center gap-2">
<UploadCloud className="h-4 w-4 text-[#336EFF]" />
<div className="flex min-w-0 flex-col">
<span className="text-sm font-medium text-white">
{isMobile ? summary.title : `上传进度 (${uploads.filter((task) => task.status === 'completed').length}/${uploads.length})`}
</span>
{isMobile ? (
<span className="text-[11px] text-slate-400">{summary.detail}</span>
) : null}
</div>
</div>
<div className="flex items-center gap-1">
{isMobile ? (
<span className="rounded-full bg-[#336EFF]/15 px-2 py-1 text-[11px] font-medium text-[#8fb0ff]">
{summary.progress}%
</span>
) : null}
<button type="button" className="rounded p-1 text-slate-400 transition-colors hover:bg-white/10 hover:text-white">
{isUploadPanelOpen ? <ChevronDown className="h-4 w-4" /> : <ChevronUp className="h-4 w-4" />}
</button>
<button
type="button"
className="rounded p-1 text-slate-400 transition-colors hover:bg-white/10 hover:text-white"
onClick={(event) => {
event.stopPropagation();
clearFilesUploads();
}}
>
<X className="h-4 w-4" />
</button>
</div>
</div>
<AnimatePresence initial={false}>
{isUploadPanelOpen && (
<motion.div
initial={{ height: 0 }}
animate={{ height: 'auto' }}
exit={{ height: 0 }}
className={cn(isMobile ? 'max-h-64 overflow-y-auto' : 'max-h-80 overflow-y-auto')}
>
<div className="space-y-1 p-2">
{uploads.map((task) => (
<div
key={task.id}
className={cn(
'group relative overflow-hidden rounded-lg p-3 transition-colors hover:bg-white/5',
task.status === 'error' && 'bg-rose-500/5',
task.status === 'cancelled' && 'bg-amber-500/5',
)}
>
{task.status === 'uploading' && (
<div
className="absolute inset-y-0 left-0 bg-[#336EFF]/10 transition-all duration-300 ease-out"
style={{ width: `${task.progress}%` }}
/>
)}
<div className="relative z-10 flex items-start gap-3">
<FileTypeIcon type={task.type} size="sm" className="mt-0.5" />
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-3">
<p className="truncate text-sm font-medium text-slate-200" title={task.fileName}>
{ellipsizeFileName(task.fileName, 30)}
</p>
<div className="shrink-0 flex items-center gap-2">
{task.status === 'uploading' ? (
<button
type="button"
className="rounded-md border border-amber-500/30 bg-amber-500/10 px-2 py-0.5 text-[11px] font-medium text-amber-200 transition-colors hover:bg-amber-500/20"
onClick={() => {
cancelFilesUploadTask(task.id);
}}
>
</button>
) : null}
{task.status === 'completed' ? (
<CheckCircle2 className="h-[18px] w-[18px] text-emerald-400" />
) : task.status === 'cancelled' ? (
<Ban className="h-[18px] w-[18px] text-amber-300" />
) : task.status === 'error' ? (
<TriangleAlert className="h-[18px] w-[18px] text-rose-400" />
) : (
<FileUp className="h-[18px] w-[18px] animate-pulse text-[#78A1FF]" />
)}
</div>
</div>
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs">
<span className={cn('rounded-full px-2 py-1 font-medium', getFileTypeTheme(task.type).badgeClassName)}>
{task.typeLabel}
</span>
<span className="truncate text-slate-500">: {task.destination}</span>
</div>
{task.noticeMessage && (
<p className="mt-2 truncate text-xs text-amber-300">{task.noticeMessage}</p>
)}
{task.status === 'uploading' && (
<div className="mt-2 flex items-center justify-between text-xs">
<span className="font-medium text-[#336EFF]">{Math.round(task.progress)}%</span>
<span className="font-mono text-slate-400">{task.speed}</span>
</div>
)}
{task.status === 'completed' && (
<p className="mt-2 text-xs text-emerald-400"></p>
)}
{task.status === 'cancelled' && (
<p className="mt-2 text-xs text-amber-300"></p>
)}
{task.status === 'error' && (
<p className="mt-2 truncate text-xs text-rose-400">{task.errorMessage ?? '上传失败,请稍后重试'}</p>
)}
</div>
</div>
</div>
))}
</div>
</motion.div>
)}
</AnimatePresence>
</motion.div>
</AnimatePresence>
);
}

View File

@@ -1,39 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { UserProfile } from '@/src/lib/types';
import { buildAccountDraft, getRoleLabel, shouldLoadAvatarWithAuth } from './account-utils';
test('buildAccountDraft prefers display name and fills fallback values', () => {
const profile: UserProfile = {
id: 1,
username: 'alice',
displayName: 'Alice',
email: 'alice@example.com',
bio: null,
preferredLanguage: null,
role: 'USER',
createdAt: '2026-03-19T17:00:00',
};
assert.deepEqual(buildAccountDraft(profile), {
displayName: 'Alice',
email: 'alice@example.com',
phoneNumber: '',
bio: '',
preferredLanguage: 'zh-CN',
});
});
test('getRoleLabel maps backend roles to readable chinese labels', () => {
assert.equal(getRoleLabel('ADMIN'), '管理员');
assert.equal(getRoleLabel('MODERATOR'), '协管员');
assert.equal(getRoleLabel('USER'), '普通用户');
});
test('shouldLoadAvatarWithAuth only treats relative avatar urls as protected resources', () => {
assert.equal(shouldLoadAvatarWithAuth('/api/user/avatar/content?v=1'), true);
assert.equal(shouldLoadAvatarWithAuth('https://cdn.example.com/avatar.png?sig=1'), false);
assert.equal(shouldLoadAvatarWithAuth(null), false);
});

Some files were not shown because too many files have changed in this diff Show More