feat(portal): land files platform and frontend workspace refresh

This commit is contained in:
yoyuzh
2026-04-09 18:35:03 +08:00
parent 67cd0f6e6f
commit 99e00cd7f7
68 changed files with 5795 additions and 2911 deletions

View File

@@ -1,5 +1,5 @@
name = "tester" name = "tester"
description = "Verification-only agent. It runs lint, test, build, package, and type-check commands that already exist in this repo, reports failures, and does not edit source files." description = "Verification-only agent. It runs only repository-backed verification commands that already exist in this repo, reports exact failures, and does not edit source files. Use the `multi-angle-verification` skill as the default verification workflow so command coverage, browser-flow checks, UI review, and coverage-gap reporting stay consistent. Android emulator or device simulation is out of scope for this agent and is handled manually by the user."
nickname_candidates = ["tester", "qa", "verify"] nickname_candidates = ["tester", "qa", "verify"]
sandbox_mode = "workspace-write" sandbox_mode = "workspace-write"
include_apply_patch_tool = false include_apply_patch_tool = false

View File

@@ -22,7 +22,7 @@ config_file = ".codex/agents/implementer.toml"
nickname_candidates = ["implementer", "impl", "builder"] nickname_candidates = ["implementer", "impl", "builder"]
[agents.tester] [agents.tester]
description = "Runs repository-backed verification commands only." description = "Runs repository-backed verification through the multi-angle-verification workflow, including browser and UI review when the task warrants it."
config_file = ".codex/agents/tester.toml" config_file = ".codex/agents/tester.toml"
nickname_candidates = ["tester", "qa", "verify"] nickname_candidates = ["tester", "qa", "verify"]

View File

@@ -1,10 +1,16 @@
package com.yoyuzh.admin; package com.yoyuzh.admin;
import com.yoyuzh.api.v2.tasks.BackgroundTaskResponse;
import com.yoyuzh.auth.CustomUserDetailsService;
import com.yoyuzh.auth.User;
import com.yoyuzh.common.ApiResponse; import com.yoyuzh.common.ApiResponse;
import com.yoyuzh.common.PageResponse; import com.yoyuzh.common.PageResponse;
import com.yoyuzh.files.tasks.BackgroundTask;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PatchMapping;
@@ -25,6 +31,7 @@ import java.util.List;
public class AdminController { public class AdminController {
private final AdminService adminService; private final AdminService adminService;
private final CustomUserDetailsService userDetailsService;
@GetMapping("/summary") @GetMapping("/summary")
public ApiResponse<AdminSummaryResponse> summary() { public ApiResponse<AdminSummaryResponse> summary() {
@@ -59,6 +66,34 @@ public class AdminController {
return ApiResponse.success(adminService.listStoragePolicies()); return ApiResponse.success(adminService.listStoragePolicies());
} }
@PostMapping("/storage-policies")
public ApiResponse<AdminStoragePolicyResponse> createStoragePolicy(
@Valid @RequestBody AdminStoragePolicyUpsertRequest request) {
return ApiResponse.success(adminService.createStoragePolicy(request));
}
@PutMapping("/storage-policies/{policyId}")
public ApiResponse<AdminStoragePolicyResponse> updateStoragePolicy(
@PathVariable Long policyId,
@Valid @RequestBody AdminStoragePolicyUpsertRequest request) {
return ApiResponse.success(adminService.updateStoragePolicy(policyId, request));
}
@PatchMapping("/storage-policies/{policyId}/status")
public ApiResponse<AdminStoragePolicyResponse> updateStoragePolicyStatus(
@PathVariable Long policyId,
@Valid @RequestBody AdminStoragePolicyStatusUpdateRequest request) {
return ApiResponse.success(adminService.updateStoragePolicyStatus(policyId, request.enabled()));
}
@PostMapping("/storage-policies/migrations")
public ApiResponse<BackgroundTaskResponse> createStoragePolicyMigrationTask(
@AuthenticationPrincipal UserDetails userDetails,
@Valid @RequestBody AdminStoragePolicyMigrationCreateRequest request) {
User user = userDetailsService.loadDomainUser(userDetails.getUsername());
return ApiResponse.success(toTaskResponse(adminService.createStoragePolicyMigrationTask(user, request)));
}
@DeleteMapping("/files/{fileId}") @DeleteMapping("/files/{fileId}")
public ApiResponse<Void> deleteFile(@PathVariable Long fileId) { public ApiResponse<Void> deleteFile(@PathVariable Long fileId) {
adminService.deleteFile(fileId); adminService.deleteFile(fileId);
@@ -99,4 +134,19 @@ public class AdminController {
public ApiResponse<AdminPasswordResetResponse> resetUserPassword(@PathVariable Long userId) { public ApiResponse<AdminPasswordResetResponse> resetUserPassword(@PathVariable Long userId) {
return ApiResponse.success(adminService.resetUserPassword(userId)); return ApiResponse.success(adminService.resetUserPassword(userId));
} }
private BackgroundTaskResponse toTaskResponse(BackgroundTask task) {
return new BackgroundTaskResponse(
task.getId(),
task.getType(),
task.getStatus(),
task.getUserId(),
task.getPublicStateJson(),
task.getCorrelationId(),
task.getErrorMessage(),
task.getCreatedAt(),
task.getUpdatedAt(),
task.getFinishedAt()
);
}
} }

View File

@@ -10,12 +10,18 @@ import com.yoyuzh.common.BusinessException;
import com.yoyuzh.common.ErrorCode; import com.yoyuzh.common.ErrorCode;
import com.yoyuzh.common.PageResponse; import com.yoyuzh.common.PageResponse;
import com.yoyuzh.files.core.FileBlobRepository; import com.yoyuzh.files.core.FileBlobRepository;
import com.yoyuzh.files.core.FileEntityRepository;
import com.yoyuzh.files.core.FileEntityType;
import com.yoyuzh.files.core.FileService; import com.yoyuzh.files.core.FileService;
import com.yoyuzh.files.core.StoredFile; import com.yoyuzh.files.core.StoredFile;
import com.yoyuzh.files.core.StoredFileEntityRepository;
import com.yoyuzh.files.core.StoredFileRepository; import com.yoyuzh.files.core.StoredFileRepository;
import com.yoyuzh.files.policy.StoragePolicy; import com.yoyuzh.files.policy.StoragePolicy;
import com.yoyuzh.files.policy.StoragePolicyRepository; import com.yoyuzh.files.policy.StoragePolicyRepository;
import com.yoyuzh.files.policy.StoragePolicyService; import com.yoyuzh.files.policy.StoragePolicyService;
import com.yoyuzh.files.tasks.BackgroundTask;
import com.yoyuzh.files.tasks.BackgroundTaskService;
import com.yoyuzh.files.tasks.BackgroundTaskType;
import com.yoyuzh.transfer.OfflineTransferSessionRepository; import com.yoyuzh.transfer.OfflineTransferSessionRepository;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page; import org.springframework.data.domain.Page;
@@ -24,6 +30,7 @@ import org.springframework.data.domain.Sort;
import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.security.SecureRandom; import java.security.SecureRandom;
import java.time.Instant; import java.time.Instant;
@@ -45,6 +52,9 @@ public class AdminService {
private final AdminMetricsService adminMetricsService; private final AdminMetricsService adminMetricsService;
private final StoragePolicyRepository storagePolicyRepository; private final StoragePolicyRepository storagePolicyRepository;
private final StoragePolicyService storagePolicyService; private final StoragePolicyService storagePolicyService;
private final FileEntityRepository fileEntityRepository;
private final StoredFileEntityRepository storedFileEntityRepository;
private final BackgroundTaskService backgroundTaskService;
private final SecureRandom secureRandom = new SecureRandom(); private final SecureRandom secureRandom = new SecureRandom();
public AdminSummaryResponse getSummary() { public AdminSummaryResponse getSummary() {
@@ -97,6 +107,75 @@ public class AdminService {
.toList(); .toList();
} }
@Transactional
public AdminStoragePolicyResponse createStoragePolicy(AdminStoragePolicyUpsertRequest request) {
StoragePolicy policy = new StoragePolicy();
policy.setDefaultPolicy(false);
applyStoragePolicyUpsert(policy, request);
return toStoragePolicyResponse(storagePolicyRepository.save(policy));
}
@Transactional
public AdminStoragePolicyResponse updateStoragePolicy(Long policyId, AdminStoragePolicyUpsertRequest request) {
StoragePolicy policy = getRequiredStoragePolicy(policyId);
applyStoragePolicyUpsert(policy, request);
return toStoragePolicyResponse(storagePolicyRepository.save(policy));
}
@Transactional
public AdminStoragePolicyResponse updateStoragePolicyStatus(Long policyId, boolean enabled) {
StoragePolicy policy = getRequiredStoragePolicy(policyId);
if (policy.isDefaultPolicy() && !enabled) {
throw new BusinessException(ErrorCode.UNKNOWN, "默认存储策略不能停用");
}
policy.setEnabled(enabled);
return toStoragePolicyResponse(storagePolicyRepository.save(policy));
}
@Transactional
public BackgroundTask createStoragePolicyMigrationTask(User user, AdminStoragePolicyMigrationCreateRequest request) {
StoragePolicy sourcePolicy = getRequiredStoragePolicy(request.sourcePolicyId());
StoragePolicy targetPolicy = getRequiredStoragePolicy(request.targetPolicyId());
if (sourcePolicy.getId().equals(targetPolicy.getId())) {
throw new BusinessException(ErrorCode.UNKNOWN, "源存储策略和目标存储策略不能相同");
}
if (!targetPolicy.isEnabled()) {
throw new BusinessException(ErrorCode.UNKNOWN, "目标存储策略必须处于启用状态");
}
long candidateEntityCount = fileEntityRepository.countByStoragePolicyIdAndEntityType(
sourcePolicy.getId(),
FileEntityType.VERSION
);
long candidateStoredFileCount = storedFileEntityRepository.countDistinctStoredFilesByStoragePolicyIdAndEntityType(
sourcePolicy.getId(),
FileEntityType.VERSION
);
java.util.Map<String, Object> state = new java.util.LinkedHashMap<>();
state.put("sourcePolicyId", sourcePolicy.getId());
state.put("sourcePolicyName", sourcePolicy.getName());
state.put("targetPolicyId", targetPolicy.getId());
state.put("targetPolicyName", targetPolicy.getName());
state.put("candidateEntityCount", candidateEntityCount);
state.put("candidateStoredFileCount", candidateStoredFileCount);
state.put("migrationPerformed", false);
state.put("migrationMode", "skeleton");
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);
privateState.put("taskType", BackgroundTaskType.STORAGE_POLICY_MIGRATION.name());
return backgroundTaskService.createQueuedTask(
user,
BackgroundTaskType.STORAGE_POLICY_MIGRATION,
state,
privateState,
request.correlationId()
);
}
@Transactional @Transactional
public void deleteFile(Long fileId) { public void deleteFile(Long fileId) {
StoredFile storedFile = storedFileRepository.findById(fileId) StoredFile storedFile = storedFileRepository.findById(fileId)
@@ -214,11 +293,34 @@ public class AdminService {
); );
} }
private void applyStoragePolicyUpsert(StoragePolicy policy, AdminStoragePolicyUpsertRequest request) {
if (policy.isDefaultPolicy() && !request.enabled()) {
throw new BusinessException(ErrorCode.UNKNOWN, "默认存储策略不能停用");
}
validateStoragePolicyRequest(request);
policy.setName(request.name().trim());
policy.setType(request.type());
policy.setBucketName(normalizeNullable(request.bucketName()));
policy.setEndpoint(normalizeNullable(request.endpoint()));
policy.setRegion(normalizeNullable(request.region()));
policy.setPrivateBucket(request.privateBucket());
policy.setPrefix(normalizePrefix(request.prefix()));
policy.setCredentialMode(request.credentialMode());
policy.setMaxSizeBytes(request.maxSizeBytes());
policy.setCapabilitiesJson(storagePolicyService.writeCapabilities(request.capabilities()));
policy.setEnabled(request.enabled());
}
private User getRequiredUser(Long userId) { private User getRequiredUser(Long userId) {
return userRepository.findById(userId) return userRepository.findById(userId)
.orElseThrow(() -> new BusinessException(ErrorCode.UNKNOWN, "用户不存在")); .orElseThrow(() -> new BusinessException(ErrorCode.UNKNOWN, "用户不存在"));
} }
private StoragePolicy getRequiredStoragePolicy(Long policyId) {
return storagePolicyRepository.findById(policyId)
.orElseThrow(() -> new BusinessException(ErrorCode.UNKNOWN, "存储策略不存在"));
}
private String normalizeQuery(String query) { private String normalizeQuery(String query) {
if (query == null) { if (query == null) {
return ""; return "";
@@ -226,6 +328,31 @@ public class AdminService {
return query.trim(); return query.trim();
} }
private String normalizeNullable(String value) {
if (!StringUtils.hasText(value)) {
return null;
}
return value.trim();
}
private String normalizePrefix(String prefix) {
if (!StringUtils.hasText(prefix)) {
return "";
}
return prefix.trim();
}
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 凭证模式");
}
if (request.type() == com.yoyuzh.files.policy.StoragePolicyType.S3_COMPATIBLE
&& !StringUtils.hasText(request.bucketName())) {
throw new BusinessException(ErrorCode.UNKNOWN, "S3 存储策略必须提供 bucketName");
}
}
private String generateTemporaryPassword() { private String generateTemporaryPassword() {
String lowers = "abcdefghjkmnpqrstuvwxyz"; String lowers = "abcdefghjkmnpqrstuvwxyz";
String uppers = "ABCDEFGHJKMNPQRSTUVWXYZ"; String uppers = "ABCDEFGHJKMNPQRSTUVWXYZ";

View File

@@ -0,0 +1,12 @@
package com.yoyuzh.admin;
import jakarta.validation.constraints.NotNull;
public record AdminStoragePolicyMigrationCreateRequest(
@NotNull(message = "sourcePolicyId 不能为空")
Long sourcePolicyId,
@NotNull(message = "targetPolicyId 不能为空")
Long targetPolicyId,
String correlationId
) {
}

View File

@@ -0,0 +1,9 @@
package com.yoyuzh.admin;
import jakarta.validation.constraints.NotNull;
public record AdminStoragePolicyStatusUpdateRequest(
@NotNull(message = "enabled 不能为空")
Boolean enabled
) {
}

View File

@@ -0,0 +1,28 @@
package com.yoyuzh.admin;
import com.yoyuzh.files.policy.StoragePolicyCapabilities;
import com.yoyuzh.files.policy.StoragePolicyCredentialMode;
import com.yoyuzh.files.policy.StoragePolicyType;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
public record AdminStoragePolicyUpsertRequest(
@NotBlank(message = "存储策略名称不能为空")
String name,
@NotNull(message = "存储策略类型不能为空")
StoragePolicyType type,
String bucketName,
String endpoint,
String region,
boolean privateBucket,
String prefix,
@NotNull(message = "凭证模式不能为空")
StoragePolicyCredentialMode credentialMode,
@Positive(message = "最大对象大小必须大于 0")
long maxSizeBytes,
@NotNull(message = "能力声明不能为空")
StoragePolicyCapabilities capabilities,
boolean enabled
) {
}

View File

@@ -5,6 +5,7 @@ import com.yoyuzh.auth.CustomUserDetailsService;
import com.yoyuzh.auth.User; import com.yoyuzh.auth.User;
import com.yoyuzh.files.upload.UploadSession; import com.yoyuzh.files.upload.UploadSession;
import com.yoyuzh.files.upload.UploadSessionCreateCommand; import com.yoyuzh.files.upload.UploadSessionCreateCommand;
import com.yoyuzh.files.upload.UploadSessionUploadMode;
import com.yoyuzh.files.upload.UploadSessionPartCommand; import com.yoyuzh.files.upload.UploadSessionPartCommand;
import com.yoyuzh.files.upload.UploadSessionService; import com.yoyuzh.files.upload.UploadSessionService;
import com.yoyuzh.files.storage.PreparedUpload; import com.yoyuzh.files.storage.PreparedUpload;
@@ -19,7 +20,9 @@ import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
@RestController @RestController
@RequestMapping("/api/v2/files/upload-sessions") @RequestMapping("/api/v2/files/upload-sessions")
@@ -49,6 +52,20 @@ public class UploadSessionV2Controller {
return ApiV2Response.success(toResponse(uploadSessionService.getOwnedSession(user, sessionId))); return ApiV2Response.success(toResponse(uploadSessionService.getOwnedSession(user, sessionId)));
} }
@GetMapping("/{sessionId}/prepare")
public ApiV2Response<PreparedUploadV2Response> prepareUpload(@AuthenticationPrincipal UserDetails userDetails,
@PathVariable String sessionId) {
User user = userDetailsService.loadDomainUser(userDetails.getUsername());
PreparedUpload preparedUpload = uploadSessionService.prepareOwnedUpload(user, sessionId);
return ApiV2Response.success(new PreparedUploadV2Response(
preparedUpload.direct(),
preparedUpload.uploadUrl(),
preparedUpload.method(),
preparedUpload.headers(),
preparedUpload.storageName()
));
}
@DeleteMapping("/{sessionId}") @DeleteMapping("/{sessionId}")
public ApiV2Response<UploadSessionV2Response> cancelSession(@AuthenticationPrincipal UserDetails userDetails, public ApiV2Response<UploadSessionV2Response> cancelSession(@AuthenticationPrincipal UserDetails userDetails,
@PathVariable String sessionId) { @PathVariable String sessionId) {
@@ -78,6 +95,14 @@ public class UploadSessionV2Controller {
return ApiV2Response.success(toResponse(session)); return ApiV2Response.success(toResponse(session));
} }
@PostMapping("/{sessionId}/content")
public ApiV2Response<UploadSessionV2Response> uploadContent(@AuthenticationPrincipal UserDetails userDetails,
@PathVariable String sessionId,
@RequestPart("file") MultipartFile file) {
User user = userDetailsService.loadDomainUser(userDetails.getUsername());
return ApiV2Response.success(toResponse(uploadSessionService.uploadOwnedContent(user, sessionId, file)));
}
@GetMapping("/{sessionId}/parts/{partIndex}/prepare") @GetMapping("/{sessionId}/parts/{partIndex}/prepare")
public ApiV2Response<PreparedUploadV2Response> preparePartUpload(@AuthenticationPrincipal UserDetails userDetails, public ApiV2Response<PreparedUploadV2Response> preparePartUpload(@AuthenticationPrincipal UserDetails userDetails,
@PathVariable String sessionId, @PathVariable String sessionId,
@@ -94,10 +119,18 @@ public class UploadSessionV2Controller {
} }
private UploadSessionV2Response toResponse(UploadSession session) { private UploadSessionV2Response toResponse(UploadSession session) {
UploadSessionUploadMode uploadMode = uploadSessionService.resolveUploadMode(session);
if (uploadMode == null) {
uploadMode = session.getMultipartUploadId() != null
? UploadSessionUploadMode.DIRECT_MULTIPART
: UploadSessionUploadMode.PROXY;
}
return new UploadSessionV2Response( return new UploadSessionV2Response(
session.getSessionId(), session.getSessionId(),
session.getObjectKey(), session.getObjectKey(),
session.getMultipartUploadId() != null, uploadMode != UploadSessionUploadMode.PROXY,
uploadMode == UploadSessionUploadMode.DIRECT_MULTIPART,
uploadMode.name(),
session.getTargetPath(), session.getTargetPath(),
session.getFilename(), session.getFilename(),
session.getContentType(), session.getContentType(),
@@ -108,7 +141,38 @@ public class UploadSessionV2Controller {
session.getChunkCount(), session.getChunkCount(),
session.getExpiresAt(), session.getExpiresAt(),
session.getCreatedAt(), session.getCreatedAt(),
session.getUpdatedAt() session.getUpdatedAt(),
toStrategyResponse(session.getSessionId(), uploadMode)
); );
} }
private UploadSessionV2StrategyResponse toStrategyResponse(String sessionId, UploadSessionUploadMode uploadMode) {
String sessionBasePath = "/api/v2/files/upload-sessions/" + sessionId;
return switch (uploadMode) {
case PROXY -> new UploadSessionV2StrategyResponse(
null,
sessionBasePath + "/content",
null,
null,
sessionBasePath + "/complete",
"file"
);
case DIRECT_SINGLE -> new UploadSessionV2StrategyResponse(
sessionBasePath + "/prepare",
null,
null,
null,
sessionBasePath + "/complete",
null
);
case DIRECT_MULTIPART -> new UploadSessionV2StrategyResponse(
null,
null,
sessionBasePath + "/parts/{partIndex}/prepare",
sessionBasePath + "/parts/{partIndex}",
sessionBasePath + "/complete",
null
);
};
}
} }

View File

@@ -5,7 +5,9 @@ import java.time.LocalDateTime;
public record UploadSessionV2Response( public record UploadSessionV2Response(
String sessionId, String sessionId,
String objectKey, String objectKey,
boolean directUpload,
boolean multipartUpload, boolean multipartUpload,
String uploadMode,
String path, String path,
String filename, String filename,
String contentType, String contentType,
@@ -16,6 +18,7 @@ public record UploadSessionV2Response(
int chunkCount, int chunkCount,
LocalDateTime expiresAt, LocalDateTime expiresAt,
LocalDateTime createdAt, LocalDateTime createdAt,
LocalDateTime updatedAt LocalDateTime updatedAt,
UploadSessionV2StrategyResponse strategy
) { ) {
} }

View File

@@ -0,0 +1,11 @@
package com.yoyuzh.api.v2.files;
public record UploadSessionV2StrategyResponse(
String prepareUrl,
String proxyContentUrl,
String partPrepareUrlTemplate,
String partRecordUrlTemplate,
String completeUrl,
String proxyFormField
) {
}

View File

@@ -2,9 +2,14 @@ package com.yoyuzh.files.core;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.Optional; import java.util.Optional;
public interface FileEntityRepository extends JpaRepository<FileEntity, Long> { public interface FileEntityRepository extends JpaRepository<FileEntity, Long> {
Optional<FileEntity> findByObjectKeyAndEntityType(String objectKey, FileEntityType entityType); Optional<FileEntity> findByObjectKeyAndEntityType(String objectKey, FileEntityType entityType);
long countByStoragePolicyIdAndEntityType(Long storagePolicyId, FileEntityType entityType);
List<FileEntity> findByStoragePolicyIdAndEntityTypeOrderByIdAsc(Long storagePolicyId, FileEntityType entityType);
} }

View File

@@ -8,6 +8,8 @@ import com.yoyuzh.common.PageResponse;
import com.yoyuzh.config.FileStorageProperties; import com.yoyuzh.config.FileStorageProperties;
import com.yoyuzh.files.events.FileEventService; import com.yoyuzh.files.events.FileEventService;
import com.yoyuzh.files.events.FileEventType; import com.yoyuzh.files.events.FileEventType;
import com.yoyuzh.files.policy.StoragePolicy;
import com.yoyuzh.files.policy.StoragePolicyCapabilities;
import com.yoyuzh.files.policy.StoragePolicyService; import com.yoyuzh.files.policy.StoragePolicyService;
import com.yoyuzh.files.share.CreateFileShareLinkResponse; import com.yoyuzh.files.share.CreateFileShareLinkResponse;
import com.yoyuzh.files.share.FileShareDetailsResponse; import com.yoyuzh.files.share.FileShareDetailsResponse;
@@ -159,6 +161,10 @@ public class FileService {
validateUpload(user, normalizedPath, filename, request.size()); validateUpload(user, normalizedPath, filename, request.size());
String objectKey = createBlobObjectKey(); String objectKey = createBlobObjectKey();
StoragePolicyCapabilities capabilities = resolveDefaultStoragePolicyCapabilities();
if (capabilities != null && !capabilities.directUpload()) {
return new InitiateUploadResponse(false, "", "POST", Map.of(), objectKey);
}
PreparedUpload preparedUpload = fileContentStorage.prepareBlobUpload( PreparedUpload preparedUpload = fileContentStorage.prepareBlobUpload(
normalizedPath, normalizedPath,
filename, filename,
@@ -856,6 +862,13 @@ public class FileService {
return storagePolicyService.ensureDefaultPolicy().getId(); return storagePolicyService.ensureDefaultPolicy().getId();
} }
private StoragePolicyCapabilities resolveDefaultStoragePolicyCapabilities() {
if (storagePolicyService == null) {
return null;
}
return storagePolicyService.readCapabilities(storagePolicyService.ensureDefaultPolicy());
}
private void savePrimaryEntityRelation(StoredFile storedFile, FileEntity primaryEntity) { private void savePrimaryEntityRelation(StoredFile storedFile, FileEntity primaryEntity) {
if (storedFileEntityRepository == null) { if (storedFileEntityRepository == null) {
return; return;
@@ -927,6 +940,14 @@ public class FileService {
private void validateUpload(User user, String normalizedPath, String filename, long size) { private void validateUpload(User user, String normalizedPath, String filename, long size) {
long effectiveMaxUploadSize = Math.min(maxFileSize, user.getMaxUploadSizeBytes()); long effectiveMaxUploadSize = Math.min(maxFileSize, user.getMaxUploadSizeBytes());
StoragePolicy defaultPolicy = storagePolicyService == null ? null : storagePolicyService.ensureDefaultPolicy();
StoragePolicyCapabilities capabilities = defaultPolicy == null ? null : storagePolicyService.readCapabilities(defaultPolicy);
if (defaultPolicy != null && defaultPolicy.getMaxSizeBytes() > 0) {
effectiveMaxUploadSize = Math.min(effectiveMaxUploadSize, defaultPolicy.getMaxSizeBytes());
}
if (capabilities != null && capabilities.maxObjectSize() > 0) {
effectiveMaxUploadSize = Math.min(effectiveMaxUploadSize, capabilities.maxObjectSize());
}
if (size > effectiveMaxUploadSize) { if (size > effectiveMaxUploadSize) {
throw new BusinessException(ErrorCode.UNKNOWN, "文件大小超出限制"); throw new BusinessException(ErrorCode.UNKNOWN, "文件大小超出限制");
} }

View File

@@ -1,6 +1,17 @@
package com.yoyuzh.files.core; package com.yoyuzh.files.core;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
public interface StoredFileEntityRepository extends JpaRepository<StoredFileEntity, Long> { public interface StoredFileEntityRepository extends JpaRepository<StoredFileEntity, Long> {
@Query("""
select count(distinct relation.storedFile.id)
from StoredFileEntity relation
where relation.fileEntity.storagePolicyId = :storagePolicyId
and relation.fileEntity.entityType = :entityType
""")
long countDistinctStoredFilesByStoragePolicyIdAndEntityType(@Param("storagePolicyId") Long storagePolicyId,
@Param("entityType") FileEntityType entityType);
} }

View File

@@ -1,6 +1,8 @@
package com.yoyuzh.files.policy; package com.yoyuzh.files.policy;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.yoyuzh.common.BusinessException;
import com.yoyuzh.common.ErrorCode;
import com.yoyuzh.config.FileStorageProperties; import com.yoyuzh.config.FileStorageProperties;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.boot.CommandLineRunner; import org.springframework.boot.CommandLineRunner;
@@ -38,6 +40,19 @@ public class StoragePolicyService implements CommandLineRunner {
} }
} }
public String writeCapabilities(StoragePolicyCapabilities capabilities) {
try {
return objectMapper.writeValueAsString(capabilities);
} catch (Exception ex) {
throw new IllegalStateException("Storage policy capabilities cannot be serialized", ex);
}
}
public StoragePolicy getRequiredPolicy(Long policyId) {
return storagePolicyRepository.findById(policyId)
.orElseThrow(() -> new BusinessException(ErrorCode.UNKNOWN, "存储策略不存在"));
}
private StoragePolicy createDefaultPolicy() { private StoragePolicy createDefaultPolicy() {
if ("s3".equalsIgnoreCase(properties.getProvider())) { if ("s3".equalsIgnoreCase(properties.getProvider())) {
return createDefaultS3Policy(); return createDefaultS3Policy();
@@ -95,14 +110,6 @@ public class StoragePolicyService implements CommandLineRunner {
return policy; return policy;
} }
private String writeCapabilities(StoragePolicyCapabilities capabilities) {
try {
return objectMapper.writeValueAsString(capabilities);
} catch (Exception ex) {
throw new IllegalStateException("Storage policy capabilities cannot be serialized", ex);
}
}
private String extractScopeBucketName(String scope) { private String extractScopeBucketName(String scope) {
if (!StringUtils.hasText(scope)) { if (!StringUtils.hasText(scope)) {
return null; return null;

View File

@@ -3,6 +3,7 @@ package com.yoyuzh.files.tasks;
public enum BackgroundTaskType { public enum BackgroundTaskType {
ARCHIVE, ARCHIVE,
EXTRACT, EXTRACT,
STORAGE_POLICY_MIGRATION,
THUMBNAIL, THUMBNAIL,
MEDIA_META, MEDIA_META,
REMOTE_DOWNLOAD, REMOTE_DOWNLOAD,

View File

@@ -98,6 +98,7 @@ public class BackgroundTaskWorker {
case ARCHIVE -> "archiving"; case ARCHIVE -> "archiving";
case EXTRACT -> "extracting"; case EXTRACT -> "extracting";
case MEDIA_META -> "extracting-metadata"; case MEDIA_META -> "extracting-metadata";
case STORAGE_POLICY_MIGRATION -> "planning-storage-policy-migration";
default -> "running"; default -> "running";
}; };
} }

View File

@@ -0,0 +1,305 @@
package com.yoyuzh.files.tasks;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.yoyuzh.common.BusinessException;
import com.yoyuzh.common.ErrorCode;
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.StoredFileRepository;
import com.yoyuzh.files.policy.StoragePolicy;
import com.yoyuzh.files.policy.StoragePolicyRepository;
import com.yoyuzh.files.policy.StoragePolicyType;
import com.yoyuzh.files.storage.FileContentStorage;
import com.yoyuzh.files.storage.LocalFileContentStorage;
import com.yoyuzh.files.storage.S3FileContentStorage;
import jakarta.transaction.Transactional;
import org.springframework.stereotype.Component;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import org.springframework.util.StringUtils;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.UUID;
@Component
@Transactional
public class StoragePolicyMigrationBackgroundTaskHandler implements BackgroundTaskHandler {
private final StoragePolicyRepository storagePolicyRepository;
private final FileEntityRepository fileEntityRepository;
private final FileBlobRepository fileBlobRepository;
private final StoredFileRepository storedFileRepository;
private final FileContentStorage fileContentStorage;
private final ObjectMapper objectMapper;
public StoragePolicyMigrationBackgroundTaskHandler(StoragePolicyRepository storagePolicyRepository,
FileEntityRepository fileEntityRepository,
FileBlobRepository fileBlobRepository,
StoredFileRepository storedFileRepository,
FileContentStorage fileContentStorage,
ObjectMapper objectMapper) {
this.storagePolicyRepository = storagePolicyRepository;
this.fileEntityRepository = fileEntityRepository;
this.fileBlobRepository = fileBlobRepository;
this.storedFileRepository = storedFileRepository;
this.fileContentStorage = fileContentStorage;
this.objectMapper = objectMapper;
}
@Override
public boolean supports(BackgroundTaskType type) {
return type == BackgroundTaskType.STORAGE_POLICY_MIGRATION;
}
@Override
public BackgroundTaskHandlerResult handle(BackgroundTask task) {
return handle(task, publicStatePatch -> {
});
}
@Override
public BackgroundTaskHandlerResult handle(BackgroundTask task, BackgroundTaskProgressReporter progressReporter) {
Map<String, Object> state = parseState(task.getPrivateStateJson());
Long sourcePolicyId = readLong(state.get("sourcePolicyId"), "sourcePolicyId");
Long targetPolicyId = readLong(state.get("targetPolicyId"), "targetPolicyId");
StoragePolicy sourcePolicy = storagePolicyRepository.findById(sourcePolicyId)
.orElseThrow(() -> new IllegalStateException("storage policy migration source policy not found"));
StoragePolicy targetPolicy = storagePolicyRepository.findById(targetPolicyId)
.orElseThrow(() -> new IllegalStateException("storage policy migration target policy not found"));
validatePolicyPair(sourcePolicy, targetPolicy);
List<FileEntity> entities = fileEntityRepository.findByStoragePolicyIdAndEntityTypeOrderByIdAsc(
sourcePolicyId,
FileEntityType.VERSION
);
long candidateEntityCount = entities.size();
long candidateStoredFileCount = 0L;
for (FileEntity entity : entities) {
validateTargetCapacity(entity, targetPolicy);
candidateStoredFileCount += storedFileRepository.countByBlobId(getRequiredBlob(entity).getId());
}
long processedEntityCount = 0L;
long migratedStoredFileCount = 0L;
List<String> copiedObjectKeys = new ArrayList<>();
LinkedHashSet<String> staleObjectKeys = new LinkedHashSet<>();
progressReporter.report(progressPatch(
sourcePolicy,
targetPolicy,
candidateEntityCount,
candidateStoredFileCount,
0L,
0L,
0L,
"copying-object-data",
false
));
try {
for (FileEntity entity : entities) {
FileBlob blob = getRequiredBlob(entity);
long storedFileCount = storedFileRepository.countByBlobId(blob.getId());
String oldObjectKey = entity.getObjectKey();
String newObjectKey = buildTargetObjectKey(targetPolicy.getId());
String contentType = StringUtils.hasText(entity.getContentType()) ? entity.getContentType() : blob.getContentType();
byte[] content = fileContentStorage.readBlob(oldObjectKey);
copiedObjectKeys.add(newObjectKey);
fileContentStorage.storeBlob(newObjectKey, contentType, content);
entity.setObjectKey(newObjectKey);
entity.setStoragePolicyId(targetPolicy.getId());
fileEntityRepository.save(entity);
blob.setObjectKey(newObjectKey);
fileBlobRepository.save(blob);
staleObjectKeys.add(oldObjectKey);
processedEntityCount += 1;
migratedStoredFileCount += storedFileCount;
progressReporter.report(progressPatch(
sourcePolicy,
targetPolicy,
candidateEntityCount,
candidateStoredFileCount,
processedEntityCount,
processedEntityCount,
migratedStoredFileCount,
"copying-object-data",
false
));
}
} catch (RuntimeException ex) {
cleanupCopiedObjects(copiedObjectKeys);
throw ex;
}
scheduleStaleObjectCleanup(staleObjectKeys);
return new BackgroundTaskHandlerResult(progressPatch(
sourcePolicy,
targetPolicy,
candidateEntityCount,
candidateStoredFileCount,
processedEntityCount,
processedEntityCount,
migratedStoredFileCount,
"completed",
true
));
}
private void validatePolicyPair(StoragePolicy sourcePolicy, StoragePolicy targetPolicy) {
if (sourcePolicy.getId().equals(targetPolicy.getId())) {
throw new BusinessException(ErrorCode.UNKNOWN, "源存储策略和目标存储策略不能相同");
}
if (!targetPolicy.isEnabled()) {
throw new BusinessException(ErrorCode.UNKNOWN, "目标存储策略必须处于启用状态");
}
if (sourcePolicy.getType() != targetPolicy.getType()) {
throw new BusinessException(ErrorCode.UNKNOWN, "当前只支持迁移同类型存储策略");
}
StoragePolicyType runtimeType = resolveRuntimePolicyType();
if (runtimeType != null
&& (sourcePolicy.getType() != runtimeType || targetPolicy.getType() != runtimeType)) {
throw new BusinessException(ErrorCode.UNKNOWN, "当前运行时只支持迁移同类型活动存储后端的策略");
}
}
private StoragePolicyType resolveRuntimePolicyType() {
if (fileContentStorage instanceof LocalFileContentStorage) {
return StoragePolicyType.LOCAL;
}
if (fileContentStorage instanceof S3FileContentStorage) {
return StoragePolicyType.S3_COMPATIBLE;
}
return null;
}
private void validateTargetCapacity(FileEntity entity, StoragePolicy targetPolicy) {
if (targetPolicy.getMaxSizeBytes() > 0 && entity.getSize() != null && entity.getSize() > targetPolicy.getMaxSizeBytes()) {
throw new BusinessException(ErrorCode.UNKNOWN, "目标存储策略容量上限不足以承载待迁移对象");
}
}
private FileBlob getRequiredBlob(FileEntity entity) {
return fileBlobRepository.findByObjectKey(entity.getObjectKey())
.orElseThrow(() -> new IllegalStateException("storage policy migration blob not found"));
}
private String buildTargetObjectKey(Long targetPolicyId) {
return "policies/" + targetPolicyId + "/blobs/" + UUID.randomUUID().toString().replace("-", "");
}
private Map<String, Object> progressPatch(StoragePolicy sourcePolicy,
StoragePolicy targetPolicy,
long candidateEntityCount,
long candidateStoredFileCount,
long processedEntityCount,
long migratedEntityCount,
long migratedStoredFileCount,
String migrationStage,
boolean migrationPerformed) {
Map<String, Object> patch = new LinkedHashMap<>();
patch.put(BackgroundTaskService.STATE_PHASE_KEY, "migrating-storage-policy");
patch.put("worker", "storage-policy-migration");
patch.put("migrationStage", migrationStage);
patch.put("migrationMode", migrationPerformed ? "executed" : "executing");
patch.put("migrationPerformed", migrationPerformed);
patch.put("sourcePolicyId", sourcePolicy.getId());
patch.put("sourcePolicyName", sourcePolicy.getName());
patch.put("targetPolicyId", targetPolicy.getId());
patch.put("targetPolicyName", targetPolicy.getName());
patch.put("candidateEntityCount", candidateEntityCount);
patch.put("candidateStoredFileCount", candidateStoredFileCount);
patch.put("processedEntityCount", processedEntityCount);
patch.put("totalEntityCount", candidateEntityCount);
patch.put("processedStoredFileCount", migratedStoredFileCount);
patch.put("totalStoredFileCount", candidateStoredFileCount);
patch.put("migratedEntityCount", migratedEntityCount);
patch.put("migratedStoredFileCount", migratedStoredFileCount);
patch.put("entityType", FileEntityType.VERSION.name());
patch.put("plannedAt", LocalDateTime.now().toString());
patch.put("progressPercent", calculateProgressPercent(
processedEntityCount,
candidateEntityCount,
migratedStoredFileCount,
candidateStoredFileCount
));
patch.put("message", migrationPerformed
? "storage policy migration moved object data through the active storage backend and updated metadata references"
: "storage policy migration is copying object data and updating metadata references");
return patch;
}
private int calculateProgressPercent(long processedEntityCount,
long totalEntityCount,
long processedStoredFileCount,
long totalStoredFileCount) {
long total = Math.max(0L, totalEntityCount) + Math.max(0L, totalStoredFileCount);
long processed = Math.max(0L, processedEntityCount) + Math.max(0L, processedStoredFileCount);
if (total <= 0L) {
return 100;
}
return (int) Math.min(100L, Math.floor((processed * 100.0d) / total));
}
private void scheduleStaleObjectCleanup(LinkedHashSet<String> staleObjectKeys) {
if (staleObjectKeys.isEmpty() || !TransactionSynchronizationManager.isSynchronizationActive()) {
return;
}
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCommit() {
for (String staleObjectKey : staleObjectKeys) {
try {
fileContentStorage.deleteBlob(staleObjectKey);
} catch (RuntimeException ignored) {
// Database state already committed; leave old object cleanup as best effort.
}
}
}
});
}
private void cleanupCopiedObjects(List<String> copiedObjectKeys) {
for (String copiedObjectKey : copiedObjectKeys) {
try {
fileContentStorage.deleteBlob(copiedObjectKey);
} catch (RuntimeException ignored) {
// Best-effort cleanup while metadata rolls back.
}
}
}
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) {
throw new IllegalStateException("storage policy migration task state is invalid", ex);
}
}
private Long readLong(Object value, String key) {
if (value instanceof Number number) {
return number.longValue();
}
if (value instanceof String text && StringUtils.hasText(text)) {
return Long.parseLong(text.trim());
}
throw new IllegalStateException("storage policy migration task missing " + key);
}
}

View File

@@ -9,6 +9,7 @@ import com.yoyuzh.config.FileStorageProperties;
import com.yoyuzh.files.core.FileService; import com.yoyuzh.files.core.FileService;
import com.yoyuzh.files.core.StoredFileRepository; import com.yoyuzh.files.core.StoredFileRepository;
import com.yoyuzh.files.policy.StoragePolicy; import com.yoyuzh.files.policy.StoragePolicy;
import com.yoyuzh.files.policy.StoragePolicyCapabilities;
import com.yoyuzh.files.policy.StoragePolicyService; import com.yoyuzh.files.policy.StoragePolicyService;
import com.yoyuzh.files.storage.FileContentStorage; import com.yoyuzh.files.storage.FileContentStorage;
import com.yoyuzh.files.storage.MultipartCompletedPart; import com.yoyuzh.files.storage.MultipartCompletedPart;
@@ -18,6 +19,7 @@ import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils; import org.springframework.util.StringUtils;
import org.springframework.web.multipart.MultipartFile;
import java.time.Clock; import java.time.Clock;
import java.time.LocalDateTime; import java.time.LocalDateTime;
@@ -78,7 +80,10 @@ public class UploadSessionService {
public UploadSession createSession(User user, UploadSessionCreateCommand command) { public UploadSession createSession(User user, UploadSessionCreateCommand command) {
String normalizedPath = normalizeDirectoryPath(command.path()); String normalizedPath = normalizeDirectoryPath(command.path());
String filename = normalizeLeafName(command.filename()); String filename = normalizeLeafName(command.filename());
validateTarget(user, normalizedPath, filename, command.size()); StoragePolicy policy = storagePolicyService.ensureDefaultPolicy();
StoragePolicyCapabilities capabilities = storagePolicyService.readCapabilities(policy);
validateTarget(user, normalizedPath, filename, command.size(), policy, capabilities);
UploadSessionUploadMode uploadMode = resolveUploadMode(capabilities);
UploadSession session = new UploadSession(); UploadSession session = new UploadSession();
session.setSessionId(UUID.randomUUID().toString()); session.setSessionId(UUID.randomUUID().toString());
@@ -88,17 +93,18 @@ public class UploadSessionService {
session.setContentType(command.contentType()); session.setContentType(command.contentType());
session.setSize(command.size()); session.setSize(command.size());
session.setObjectKey(createBlobObjectKey()); session.setObjectKey(createBlobObjectKey());
StoragePolicy policy = storagePolicyService.ensureDefaultPolicy();
session.setStoragePolicyId(policy.getId()); session.setStoragePolicyId(policy.getId());
session.setChunkSize(DEFAULT_CHUNK_SIZE); session.setChunkSize(DEFAULT_CHUNK_SIZE);
session.setChunkCount(calculateChunkCount(command.size(), DEFAULT_CHUNK_SIZE)); session.setChunkCount(uploadMode == UploadSessionUploadMode.DIRECT_MULTIPART
? calculateChunkCount(command.size(), DEFAULT_CHUNK_SIZE)
: 1);
session.setUploadedPartsJson("[]"); session.setUploadedPartsJson("[]");
session.setStatus(UploadSessionStatus.CREATED); session.setStatus(UploadSessionStatus.CREATED);
LocalDateTime now = LocalDateTime.ofInstant(clock.instant(), clock.getZone()); LocalDateTime now = LocalDateTime.ofInstant(clock.instant(), clock.getZone());
session.setCreatedAt(now); session.setCreatedAt(now);
session.setUpdatedAt(now); session.setUpdatedAt(now);
session.setExpiresAt(now.plusHours(SESSION_TTL_HOURS)); session.setExpiresAt(now.plusHours(SESSION_TTL_HOURS));
if (storagePolicyService.readCapabilities(policy).multipartUpload()) { if (uploadMode == UploadSessionUploadMode.DIRECT_MULTIPART) {
session.setMultipartUploadId(fileContentStorage.createMultipartUpload(session.getObjectKey(), session.getContentType())); session.setMultipartUploadId(fileContentStorage.createMultipartUpload(session.getObjectKey(), session.getContentType()));
} }
return uploadSessionRepository.save(session); return uploadSessionRepository.save(session);
@@ -121,12 +127,30 @@ public class UploadSessionService {
return uploadSessionRepository.save(session); return uploadSessionRepository.save(session);
} }
@Transactional(readOnly = true)
public PreparedUpload prepareOwnedUpload(User user, String sessionId) {
UploadSession session = getOwnedSession(user, sessionId);
LocalDateTime now = LocalDateTime.ofInstant(clock.instant(), clock.getZone());
ensureSessionCanReceiveContent(session, now);
if (resolveUploadMode(session) != UploadSessionUploadMode.DIRECT_SINGLE) {
throw new BusinessException(ErrorCode.UNKNOWN, "上传会话未启用单请求直传");
}
return fileContentStorage.prepareBlobUpload(
session.getTargetPath(),
session.getFilename(),
session.getObjectKey(),
session.getContentType(),
session.getSize()
);
}
@Transactional(readOnly = true) @Transactional(readOnly = true)
public PreparedUpload prepareOwnedPartUpload(User user, String sessionId, int partIndex) { public PreparedUpload prepareOwnedPartUpload(User user, String sessionId, int partIndex) {
UploadSession session = getOwnedSession(user, sessionId); UploadSession session = getOwnedSession(user, sessionId);
LocalDateTime now = LocalDateTime.ofInstant(clock.instant(), clock.getZone()); LocalDateTime now = LocalDateTime.ofInstant(clock.instant(), clock.getZone());
ensureSessionCanReceivePart(session, now); ensureSessionCanReceivePart(session, now);
if (!StringUtils.hasText(session.getMultipartUploadId())) { if (resolveUploadMode(session) != UploadSessionUploadMode.DIRECT_MULTIPART
|| !StringUtils.hasText(session.getMultipartUploadId())) {
throw new BusinessException(ErrorCode.UNKNOWN, "上传会话未启用 multipart"); throw new BusinessException(ErrorCode.UNKNOWN, "上传会话未启用 multipart");
} }
if (partIndex < 0 || partIndex >= session.getChunkCount()) { if (partIndex < 0 || partIndex >= session.getChunkCount()) {
@@ -149,6 +173,9 @@ public class UploadSessionService {
UploadSession session = getOwnedSession(user, sessionId); UploadSession session = getOwnedSession(user, sessionId);
LocalDateTime now = LocalDateTime.ofInstant(clock.instant(), clock.getZone()); LocalDateTime now = LocalDateTime.ofInstant(clock.instant(), clock.getZone());
ensureSessionCanReceivePart(session, now); ensureSessionCanReceivePart(session, now);
if (resolveUploadMode(session) != UploadSessionUploadMode.DIRECT_MULTIPART) {
throw new BusinessException(ErrorCode.UNKNOWN, "上传会话未启用 multipart");
}
if (partIndex < 0 || partIndex >= session.getChunkCount()) { if (partIndex < 0 || partIndex >= session.getChunkCount()) {
throw new BusinessException(ErrorCode.UNKNOWN, "分片序号不合法"); throw new BusinessException(ErrorCode.UNKNOWN, "分片序号不合法");
} }
@@ -172,6 +199,28 @@ public class UploadSessionService {
return uploadSessionRepository.save(session); return uploadSessionRepository.save(session);
} }
@Transactional
public UploadSession uploadOwnedContent(User user, String sessionId, MultipartFile file) {
UploadSession session = getOwnedSession(user, sessionId);
LocalDateTime now = LocalDateTime.ofInstant(clock.instant(), clock.getZone());
ensureSessionCanReceiveContent(session, now);
if (resolveUploadMode(session) != UploadSessionUploadMode.PROXY) {
throw new BusinessException(ErrorCode.UNKNOWN, "上传会话未启用代理上传");
}
if (file == null || file.isEmpty()) {
throw new BusinessException(ErrorCode.UNKNOWN, "上传内容不能为空");
}
if (file.getSize() != session.getSize()) {
throw new BusinessException(ErrorCode.UNKNOWN, "上传内容大小与会话不一致");
}
fileContentStorage.uploadBlob(session.getObjectKey(), file);
if (session.getStatus() == UploadSessionStatus.CREATED) {
session.setStatus(UploadSessionStatus.UPLOADING);
}
session.setUpdatedAt(now);
return uploadSessionRepository.save(session);
}
@Transactional @Transactional
public UploadSession completeOwnedSession(User user, String sessionId) { public UploadSession completeOwnedSession(User user, String sessionId) {
UploadSession session = getOwnedSession(user, sessionId); UploadSession session = getOwnedSession(user, sessionId);
@@ -194,7 +243,8 @@ public class UploadSessionService {
uploadSessionRepository.save(session); uploadSessionRepository.save(session);
try { try {
if (StringUtils.hasText(session.getMultipartUploadId())) { if (resolveUploadMode(session) == UploadSessionUploadMode.DIRECT_MULTIPART
&& StringUtils.hasText(session.getMultipartUploadId())) {
fileContentStorage.completeMultipartUpload( fileContentStorage.completeMultipartUpload(
session.getObjectKey(), session.getObjectKey(),
session.getMultipartUploadId(), session.getMultipartUploadId(),
@@ -246,8 +296,40 @@ public class UploadSessionService {
return expiredSessions.size(); return expiredSessions.size();
} }
private void validateTarget(User user, String normalizedPath, String filename, long size) { public UploadSessionUploadMode resolveUploadMode(UploadSession session) {
if (session.getStoragePolicyId() == null) {
if (StringUtils.hasText(session.getMultipartUploadId()) || session.getChunkCount() > 1) {
return UploadSessionUploadMode.DIRECT_MULTIPART;
}
return UploadSessionUploadMode.PROXY;
}
StoragePolicy policy = storagePolicyService.getRequiredPolicy(session.getStoragePolicyId());
return resolveUploadMode(storagePolicyService.readCapabilities(policy));
}
private UploadSessionUploadMode resolveUploadMode(StoragePolicyCapabilities capabilities) {
if (!capabilities.directUpload()) {
return UploadSessionUploadMode.PROXY;
}
if (capabilities.multipartUpload()) {
return UploadSessionUploadMode.DIRECT_MULTIPART;
}
return UploadSessionUploadMode.DIRECT_SINGLE;
}
private void validateTarget(User user,
String normalizedPath,
String filename,
long size,
StoragePolicy policy,
StoragePolicyCapabilities capabilities) {
long effectiveMaxUploadSize = Math.min(maxFileSize, user.getMaxUploadSizeBytes()); long effectiveMaxUploadSize = Math.min(maxFileSize, user.getMaxUploadSizeBytes());
if (policy.getMaxSizeBytes() > 0) {
effectiveMaxUploadSize = Math.min(effectiveMaxUploadSize, policy.getMaxSizeBytes());
}
if (capabilities.maxObjectSize() > 0) {
effectiveMaxUploadSize = Math.min(effectiveMaxUploadSize, capabilities.maxObjectSize());
}
if (size > effectiveMaxUploadSize) { if (size > effectiveMaxUploadSize) {
throw new BusinessException(ErrorCode.UNKNOWN, "文件大小超出限制"); throw new BusinessException(ErrorCode.UNKNOWN, "文件大小超出限制");
} }
@@ -260,6 +342,13 @@ public class UploadSessionService {
} }
} }
private void ensureSessionCanReceiveContent(UploadSession session, LocalDateTime now) {
ensureSessionCanReceivePart(session, now);
if (session.getStatus() == UploadSessionStatus.UPLOADING && StringUtils.hasText(session.getMultipartUploadId())) {
throw new BusinessException(ErrorCode.UNKNOWN, "multipart 上传会话不能走整体内容上传");
}
}
private void ensureSessionCanReceivePart(UploadSession session, LocalDateTime now) { private void ensureSessionCanReceivePart(UploadSession session, LocalDateTime now) {
if (session.getStatus() == UploadSessionStatus.CANCELLED if (session.getStatus() == UploadSessionStatus.CANCELLED
|| session.getStatus() == UploadSessionStatus.FAILED || session.getStatus() == UploadSessionStatus.FAILED

View File

@@ -0,0 +1,7 @@
package com.yoyuzh.files.upload;
public enum UploadSessionUploadMode {
PROXY,
DIRECT_SINGLE,
DIRECT_MULTIPART
}

View File

@@ -8,6 +8,9 @@ import com.yoyuzh.files.core.FileBlob;
import com.yoyuzh.files.core.FileBlobRepository; import com.yoyuzh.files.core.FileBlobRepository;
import com.yoyuzh.files.core.StoredFile; import com.yoyuzh.files.core.StoredFile;
import com.yoyuzh.files.core.StoredFileRepository; 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.transfer.OfflineTransferSessionRepository; import com.yoyuzh.transfer.OfflineTransferSessionRepository;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
@@ -41,7 +44,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
"spring.datasource.password=", "spring.datasource.password=",
"spring.jpa.hibernate.ddl-auto=create-drop", "spring.jpa.hibernate.ddl-auto=create-drop",
"app.jwt.secret=0123456789abcdef0123456789abcdef", "app.jwt.secret=0123456789abcdef0123456789abcdef",
"app.admin.usernames=admin", "app.admin.usernames=admin,alice",
"app.storage.root-dir=./target/test-storage-admin" "app.storage.root-dir=./target/test-storage-admin"
} }
) )
@@ -66,6 +69,8 @@ class AdminControllerIntegrationTest {
private AdminMetricsStateRepository adminMetricsStateRepository; private AdminMetricsStateRepository adminMetricsStateRepository;
@Autowired @Autowired
private AdminMetricsService adminMetricsService; private AdminMetricsService adminMetricsService;
@Autowired
private StoragePolicyRepository storagePolicyRepository;
private User portalUser; private User portalUser;
private User secondaryUser; private User secondaryUser;
@@ -338,6 +343,149 @@ class AdminControllerIntegrationTest {
.andExpect(jsonPath("$.data[0].maxSizeBytes").isNumber()); .andExpect(jsonPath("$.data[0].maxSizeBytes").isNumber());
} }
@Test
@WithMockUser(username = "admin")
void shouldAllowConfiguredAdminToCreateUpdateAndDisableNonDefaultStoragePolicy() throws Exception {
mockMvc.perform(post("/api/admin/storage-policies")
.contentType("application/json")
.content("""
{
"name": "Archive Bucket",
"type": "S3_COMPATIBLE",
"bucketName": "archive-bucket",
"endpoint": "https://s3.example.com",
"region": "auto",
"privateBucket": true,
"prefix": "archive/",
"credentialMode": "STATIC",
"maxSizeBytes": 20480,
"enabled": true,
"capabilities": {
"directUpload": true,
"multipartUpload": true,
"signedDownloadUrl": true,
"serverProxyDownload": true,
"thumbnailNative": false,
"friendlyDownloadName": true,
"requiresCors": true,
"supportsInternalEndpoint": false,
"maxObjectSize": 20480
}
}
"""))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.name").value("Archive Bucket"))
.andExpect(jsonPath("$.data.type").value("S3_COMPATIBLE"))
.andExpect(jsonPath("$.data.defaultPolicy").value(false));
Long createdPolicyId = storagePolicyRepository.findAll().stream()
.filter(policy -> "Archive Bucket".equals(policy.getName()))
.map(StoragePolicy::getId)
.findFirst()
.orElseThrow();
mockMvc.perform(put("/api/admin/storage-policies/{policyId}", createdPolicyId)
.contentType("application/json")
.content("""
{
"name": "Hot Bucket",
"type": "S3_COMPATIBLE",
"bucketName": "hot-bucket",
"endpoint": "https://hot.example.com",
"region": "cn-north-1",
"privateBucket": false,
"prefix": "hot/",
"credentialMode": "DOGECLOUD_TEMP",
"maxSizeBytes": 40960,
"enabled": true,
"capabilities": {
"directUpload": true,
"multipartUpload": true,
"signedDownloadUrl": true,
"serverProxyDownload": true,
"thumbnailNative": false,
"friendlyDownloadName": true,
"requiresCors": true,
"supportsInternalEndpoint": false,
"maxObjectSize": 40960
}
}
"""))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.id").value(createdPolicyId))
.andExpect(jsonPath("$.data.name").value("Hot Bucket"))
.andExpect(jsonPath("$.data.bucketName").value("hot-bucket"))
.andExpect(jsonPath("$.data.credentialMode").value("DOGECLOUD_TEMP"));
mockMvc.perform(patch("/api/admin/storage-policies/{policyId}/status", createdPolicyId)
.contentType("application/json")
.content("""
{
"enabled": false
}
"""))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.id").value(createdPolicyId))
.andExpect(jsonPath("$.data.enabled").value(false));
}
@Test
@WithMockUser(username = "admin")
void shouldRejectDisablingDefaultStoragePolicy() throws Exception {
StoragePolicy defaultPolicy = storagePolicyRepository.findFirstByDefaultPolicyTrueOrderByIdAsc().orElseThrow();
mockMvc.perform(patch("/api/admin/storage-policies/{policyId}/status", defaultPolicy.getId())
.contentType("application/json")
.content("""
{
"enabled": false
}
"""))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.msg").value("默认存储策略不能停用"));
}
@Test
void shouldAllowAdminUserToCreateStoragePolicyMigrationTask() throws Exception {
StoragePolicy sourcePolicy = storagePolicyRepository.findFirstByDefaultPolicyTrueOrderByIdAsc().orElseThrow();
StoragePolicy targetPolicy = new StoragePolicy();
targetPolicy.setName("Archive Bucket");
targetPolicy.setType(StoragePolicyType.S3_COMPATIBLE);
targetPolicy.setBucketName("archive-bucket");
targetPolicy.setEndpoint("https://s3.example.com");
targetPolicy.setRegion("auto");
targetPolicy.setPrivateBucket(true);
targetPolicy.setPrefix("archive/");
targetPolicy.setCredentialMode(com.yoyuzh.files.policy.StoragePolicyCredentialMode.STATIC);
targetPolicy.setMaxSizeBytes(40960L);
targetPolicy.setCapabilitiesJson("""
{"directUpload":true,"multipartUpload":true,"signedDownloadUrl":true,"serverProxyDownload":true,"thumbnailNative":false,"friendlyDownloadName":true,"requiresCors":true,"supportsInternalEndpoint":false,"maxObjectSize":40960}
""");
targetPolicy.setEnabled(true);
targetPolicy.setDefaultPolicy(false);
targetPolicy = storagePolicyRepository.save(targetPolicy);
mockMvc.perform(post("/api/admin/storage-policies/migrations")
.with(user("alice"))
.contentType("application/json")
.content("""
{
"sourcePolicyId": %d,
"targetPolicyId": %d,
"correlationId": "migration-1"
}
""".formatted(sourcePolicy.getId(), targetPolicy.getId())))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.type").value("STORAGE_POLICY_MIGRATION"))
.andExpect(jsonPath("$.data.status").value("QUEUED"))
.andExpect(jsonPath("$.data.publicStateJson").value(org.hamcrest.Matchers.containsString("\"sourcePolicyId\":" + sourcePolicy.getId())))
.andExpect(jsonPath("$.data.publicStateJson").value(org.hamcrest.Matchers.containsString("\"targetPolicyId\":" + targetPolicy.getId())))
.andExpect(jsonPath("$.data.publicStateJson").value(org.hamcrest.Matchers.containsString("\"migrationPerformed\":false")));
}
@Test @Test
@WithMockUser(username = "portal-user") @WithMockUser(username = "portal-user")
void shouldRejectNonAdminUser() throws Exception { void shouldRejectNonAdminUser() throws Exception {

View File

@@ -9,11 +9,21 @@ import com.yoyuzh.auth.UserRole;
import com.yoyuzh.common.BusinessException; import com.yoyuzh.common.BusinessException;
import com.yoyuzh.common.PageResponse; import com.yoyuzh.common.PageResponse;
import com.yoyuzh.files.core.FileBlobRepository; import com.yoyuzh.files.core.FileBlobRepository;
import com.yoyuzh.files.core.FileEntityRepository;
import com.yoyuzh.files.core.FileService; import com.yoyuzh.files.core.FileService;
import com.yoyuzh.files.core.StoredFile; import com.yoyuzh.files.core.StoredFile;
import com.yoyuzh.files.core.StoredFileEntityRepository;
import com.yoyuzh.files.core.StoredFileRepository; 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.StoragePolicyRepository;
import com.yoyuzh.files.policy.StoragePolicyService; import com.yoyuzh.files.policy.StoragePolicyService;
import com.yoyuzh.files.policy.StoragePolicyType;
import com.yoyuzh.files.tasks.BackgroundTask;
import com.yoyuzh.files.tasks.BackgroundTaskService;
import com.yoyuzh.files.tasks.BackgroundTaskStatus;
import com.yoyuzh.files.tasks.BackgroundTaskType;
import com.yoyuzh.transfer.OfflineTransferSessionRepository; import com.yoyuzh.transfer.OfflineTransferSessionRepository;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
@@ -62,6 +72,12 @@ class AdminServiceTest {
private StoragePolicyRepository storagePolicyRepository; private StoragePolicyRepository storagePolicyRepository;
@Mock @Mock
private StoragePolicyService storagePolicyService; private StoragePolicyService storagePolicyService;
@Mock
private FileEntityRepository fileEntityRepository;
@Mock
private StoredFileEntityRepository storedFileEntityRepository;
@Mock
private BackgroundTaskService backgroundTaskService;
private AdminService adminService; private AdminService adminService;
@@ -71,7 +87,8 @@ class AdminServiceTest {
userRepository, storedFileRepository, fileBlobRepository, fileService, userRepository, storedFileRepository, fileBlobRepository, fileService,
passwordEncoder, refreshTokenService, registrationInviteService, passwordEncoder, refreshTokenService, registrationInviteService,
offlineTransferSessionRepository, adminMetricsService, offlineTransferSessionRepository, adminMetricsService,
storagePolicyRepository, storagePolicyService); storagePolicyRepository, storagePolicyService,
fileEntityRepository, storedFileEntityRepository, backgroundTaskService);
} }
// --- getSummary --- // --- getSummary ---
@@ -161,6 +178,133 @@ class AdminServiceTest {
assertThat(response.items().get(0).ownerUsername()).isEqualTo("alice"); assertThat(response.items().get(0).ownerUsername()).isEqualTo("alice");
} }
@Test
void shouldCreateStoragePolicy() {
when(storagePolicyService.writeCapabilities(any(StoragePolicyCapabilities.class))).thenReturn("{\"maxObjectSize\":20480}");
when(storagePolicyRepository.save(any(StoragePolicy.class))).thenAnswer(invocation -> {
StoragePolicy policy = invocation.getArgument(0);
policy.setId(9L);
return policy;
});
when(storagePolicyService.readCapabilities(any(StoragePolicy.class))).thenReturn(defaultCapabilities(20_480L));
AdminStoragePolicyResponse response = adminService.createStoragePolicy(new AdminStoragePolicyUpsertRequest(
" Archive Bucket ",
StoragePolicyType.S3_COMPATIBLE,
"archive-bucket",
"https://s3.example.com",
"auto",
true,
"archive/",
StoragePolicyCredentialMode.STATIC,
20_480L,
defaultCapabilities(20_480L),
true
));
assertThat(response.name()).isEqualTo("Archive Bucket");
assertThat(response.type()).isEqualTo(StoragePolicyType.S3_COMPATIBLE);
assertThat(response.bucketName()).isEqualTo("archive-bucket");
assertThat(response.endpoint()).isEqualTo("https://s3.example.com");
assertThat(response.region()).isEqualTo("auto");
assertThat(response.privateBucket()).isTrue();
assertThat(response.prefix()).isEqualTo("archive/");
assertThat(response.credentialMode()).isEqualTo(StoragePolicyCredentialMode.STATIC);
assertThat(response.maxSizeBytes()).isEqualTo(20_480L);
assertThat(response.enabled()).isTrue();
assertThat(response.defaultPolicy()).isFalse();
}
@Test
void shouldUpdateStoragePolicyFieldsWithoutChangingDefaultFlag() {
StoragePolicy existingPolicy = createStoragePolicy(7L, "Archive Bucket");
existingPolicy.setDefaultPolicy(false);
when(storagePolicyService.writeCapabilities(any(StoragePolicyCapabilities.class))).thenReturn("{\"maxObjectSize\":40960}");
when(storagePolicyRepository.findById(7L)).thenReturn(Optional.of(existingPolicy));
when(storagePolicyRepository.save(existingPolicy)).thenReturn(existingPolicy);
when(storagePolicyService.readCapabilities(existingPolicy)).thenReturn(defaultCapabilities(40_960L));
AdminStoragePolicyResponse response = adminService.updateStoragePolicy(7L, new AdminStoragePolicyUpsertRequest(
"Hot Bucket",
StoragePolicyType.S3_COMPATIBLE,
"hot-bucket",
"https://hot.example.com",
"cn-north-1",
false,
"hot/",
StoragePolicyCredentialMode.DOGECLOUD_TEMP,
40_960L,
defaultCapabilities(40_960L),
true
));
assertThat(existingPolicy.getName()).isEqualTo("Hot Bucket");
assertThat(existingPolicy.getBucketName()).isEqualTo("hot-bucket");
assertThat(existingPolicy.getEndpoint()).isEqualTo("https://hot.example.com");
assertThat(existingPolicy.getRegion()).isEqualTo("cn-north-1");
assertThat(existingPolicy.isPrivateBucket()).isFalse();
assertThat(existingPolicy.getPrefix()).isEqualTo("hot/");
assertThat(existingPolicy.getCredentialMode()).isEqualTo(StoragePolicyCredentialMode.DOGECLOUD_TEMP);
assertThat(existingPolicy.getMaxSizeBytes()).isEqualTo(40_960L);
assertThat(existingPolicy.isEnabled()).isTrue();
assertThat(response.defaultPolicy()).isFalse();
}
@Test
void shouldRejectDisablingDefaultStoragePolicy() {
StoragePolicy existingPolicy = createStoragePolicy(3L, "Default Local Storage");
existingPolicy.setDefaultPolicy(true);
existingPolicy.setEnabled(true);
when(storagePolicyRepository.findById(3L)).thenReturn(Optional.of(existingPolicy));
assertThatThrownBy(() -> adminService.updateStoragePolicyStatus(3L, false))
.isInstanceOf(BusinessException.class)
.hasMessageContaining("默认存储策略不能停用");
verify(storagePolicyRepository, never()).save(any(StoragePolicy.class));
}
@Test
void shouldCreateStoragePolicyMigrationTaskSkeleton() {
User adminUser = createUser(99L, "alice", "alice@example.com");
StoragePolicy sourcePolicy = createStoragePolicy(3L, "Source Policy");
StoragePolicy targetPolicy = createStoragePolicy(4L, "Target Policy");
targetPolicy.setEnabled(true);
when(storagePolicyRepository.findById(3L)).thenReturn(Optional.of(sourcePolicy));
when(storagePolicyRepository.findById(4L)).thenReturn(Optional.of(targetPolicy));
when(fileEntityRepository.countByStoragePolicyIdAndEntityType(3L, com.yoyuzh.files.core.FileEntityType.VERSION)).thenReturn(5L);
when(storedFileEntityRepository.countDistinctStoredFilesByStoragePolicyIdAndEntityType(3L, com.yoyuzh.files.core.FileEntityType.VERSION)).thenReturn(8L);
when(backgroundTaskService.createQueuedTask(eq(adminUser), eq(BackgroundTaskType.STORAGE_POLICY_MIGRATION), any(), any(), eq("migration-1")))
.thenAnswer(invocation -> {
BackgroundTask task = new BackgroundTask();
task.setId(11L);
task.setType(BackgroundTaskType.STORAGE_POLICY_MIGRATION);
task.setStatus(BackgroundTaskStatus.QUEUED);
task.setUserId(adminUser.getId());
task.setPublicStateJson(new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(invocation.getArgument(2)));
task.setPrivateStateJson(new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(invocation.getArgument(3)));
task.setCorrelationId("migration-1");
task.setCreatedAt(LocalDateTime.now());
task.setUpdatedAt(LocalDateTime.now());
return task;
});
BackgroundTask task = adminService.createStoragePolicyMigrationTask(adminUser, new AdminStoragePolicyMigrationCreateRequest(
3L,
4L,
"migration-1"
));
assertThat(task.getType()).isEqualTo(BackgroundTaskType.STORAGE_POLICY_MIGRATION);
assertThat(task.getStatus()).isEqualTo(BackgroundTaskStatus.QUEUED);
assertThat(task.getPublicStateJson()).contains("\"sourcePolicyId\":3");
assertThat(task.getPublicStateJson()).contains("\"targetPolicyId\":4");
assertThat(task.getPublicStateJson()).contains("\"candidateEntityCount\":5");
assertThat(task.getPublicStateJson()).contains("\"candidateStoredFileCount\":8");
assertThat(task.getPublicStateJson()).contains("\"migrationPerformed\":false");
assertThat(task.getPrivateStateJson()).contains("\"taskType\":\"STORAGE_POLICY_MIGRATION\"");
}
// --- deleteFile --- // --- deleteFile ---
@Test @Test
@@ -297,4 +441,38 @@ class AdminServiceTest {
file.setCreatedAt(LocalDateTime.now()); file.setCreatedAt(LocalDateTime.now());
return file; return file;
} }
private StoragePolicy createStoragePolicy(Long id, String name) {
StoragePolicy policy = new StoragePolicy();
policy.setId(id);
policy.setName(name);
policy.setType(StoragePolicyType.S3_COMPATIBLE);
policy.setBucketName("bucket");
policy.setEndpoint("https://s3.example.com");
policy.setRegion("auto");
policy.setPrivateBucket(true);
policy.setPrefix("files/");
policy.setCredentialMode(StoragePolicyCredentialMode.STATIC);
policy.setMaxSizeBytes(10_240L);
policy.setCapabilitiesJson("{}");
policy.setEnabled(true);
policy.setDefaultPolicy(false);
policy.setCreatedAt(LocalDateTime.now());
policy.setUpdatedAt(LocalDateTime.now());
return policy;
}
private StoragePolicyCapabilities defaultCapabilities(long maxObjectSize) {
return new StoragePolicyCapabilities(
true,
true,
true,
true,
false,
true,
true,
false,
maxObjectSize
);
}
} }

View File

@@ -3,6 +3,7 @@ package com.yoyuzh.api.v2.files;
import com.yoyuzh.auth.CustomUserDetailsService; import com.yoyuzh.auth.CustomUserDetailsService;
import com.yoyuzh.auth.User; import com.yoyuzh.auth.User;
import com.yoyuzh.files.upload.UploadSession; import com.yoyuzh.files.upload.UploadSession;
import com.yoyuzh.files.upload.UploadSessionUploadMode;
import com.yoyuzh.files.upload.UploadSessionService; import com.yoyuzh.files.upload.UploadSessionService;
import com.yoyuzh.files.upload.UploadSessionStatus; import com.yoyuzh.files.upload.UploadSessionStatus;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
@@ -27,6 +28,7 @@ import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
@@ -70,7 +72,12 @@ class UploadSessionV2ControllerTest {
.andExpect(jsonPath("$.data.sessionId").value("session-1")) .andExpect(jsonPath("$.data.sessionId").value("session-1"))
.andExpect(jsonPath("$.data.objectKey").value("blobs/session-1")) .andExpect(jsonPath("$.data.objectKey").value("blobs/session-1"))
.andExpect(jsonPath("$.data.status").value("CREATED")) .andExpect(jsonPath("$.data.status").value("CREATED"))
.andExpect(jsonPath("$.data.directUpload").value(true))
.andExpect(jsonPath("$.data.multipartUpload").value(true)) .andExpect(jsonPath("$.data.multipartUpload").value(true))
.andExpect(jsonPath("$.data.uploadMode").value("DIRECT_MULTIPART"))
.andExpect(jsonPath("$.data.strategy.partPrepareUrlTemplate").value("/api/v2/files/upload-sessions/session-1/parts/{partIndex}/prepare"))
.andExpect(jsonPath("$.data.strategy.partRecordUrlTemplate").value("/api/v2/files/upload-sessions/session-1/parts/{partIndex}"))
.andExpect(jsonPath("$.data.strategy.completeUrl").value("/api/v2/files/upload-sessions/session-1/complete"))
.andExpect(jsonPath("$.data.chunkSize").value(8388608)) .andExpect(jsonPath("$.data.chunkSize").value(8388608))
.andExpect(jsonPath("$.data.chunkCount").value(3)); .andExpect(jsonPath("$.data.chunkCount").value(3));
} }
@@ -88,7 +95,76 @@ class UploadSessionV2ControllerTest {
.andExpect(jsonPath("$.code").value(0)) .andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.sessionId").value("session-1")) .andExpect(jsonPath("$.data.sessionId").value("session-1"))
.andExpect(jsonPath("$.data.status").value("CREATED")) .andExpect(jsonPath("$.data.status").value("CREATED"))
.andExpect(jsonPath("$.data.multipartUpload").value(true)); .andExpect(jsonPath("$.data.directUpload").value(true))
.andExpect(jsonPath("$.data.uploadMode").value("DIRECT_MULTIPART"))
.andExpect(jsonPath("$.data.multipartUpload").value(true))
.andExpect(jsonPath("$.data.strategy.partPrepareUrlTemplate").value("/api/v2/files/upload-sessions/session-1/parts/{partIndex}/prepare"))
.andExpect(jsonPath("$.data.strategy.partRecordUrlTemplate").value("/api/v2/files/upload-sessions/session-1/parts/{partIndex}"))
.andExpect(jsonPath("$.data.strategy.completeUrl").value("/api/v2/files/upload-sessions/session-1/complete"));
}
@Test
void shouldReturnDirectSingleStrategyInSessionResponse() throws Exception {
User user = createUser(7L);
UploadSession session = createSession(user);
session.setMultipartUploadId(null);
session.setChunkCount(1);
when(userDetailsService.loadDomainUser("alice")).thenReturn(user);
when(uploadSessionService.getOwnedSession(user, "session-1")).thenReturn(session);
when(uploadSessionService.resolveUploadMode(session)).thenReturn(UploadSessionUploadMode.DIRECT_SINGLE);
mockMvc.perform(get("/api/v2/files/upload-sessions/session-1")
.with(user(userDetails())))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.uploadMode").value("DIRECT_SINGLE"))
.andExpect(jsonPath("$.data.strategy.prepareUrl").value("/api/v2/files/upload-sessions/session-1/prepare"))
.andExpect(jsonPath("$.data.strategy.completeUrl").value("/api/v2/files/upload-sessions/session-1/complete"));
}
@Test
void shouldPrepareSingleUploadWithV2Envelope() throws Exception {
User user = createUser(7L);
when(userDetailsService.loadDomainUser("alice")).thenReturn(user);
when(uploadSessionService.prepareOwnedUpload(user, "session-1"))
.thenReturn(new com.yoyuzh.files.storage.PreparedUpload(
true,
"https://upload.example.com/session-1",
"PUT",
Map.of("Content-Type", "video/mp4"),
"blobs/session-1"
));
mockMvc.perform(get("/api/v2/files/upload-sessions/session-1/prepare")
.with(user(userDetails())))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.direct").value(true))
.andExpect(jsonPath("$.data.uploadUrl").value("https://upload.example.com/session-1"))
.andExpect(jsonPath("$.data.method").value("PUT"))
.andExpect(jsonPath("$.data.headers['Content-Type']").value("video/mp4"));
}
@Test
void shouldUploadProxyContentWithV2Envelope() throws Exception {
User user = createUser(7L);
UploadSession session = createSession(user);
session.setStatus(UploadSessionStatus.UPLOADING);
session.setMultipartUploadId(null);
session.setChunkCount(1);
when(userDetailsService.loadDomainUser("alice")).thenReturn(user);
when(uploadSessionService.uploadOwnedContent(eq(user), eq("session-1"), any())).thenReturn(session);
mockMvc.perform(multipart("/api/v2/files/upload-sessions/session-1/content")
.file("file", "payload".getBytes())
.with(user(userDetails())))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.sessionId").value("session-1"))
.andExpect(jsonPath("$.data.status").value("UPLOADING"))
.andExpect(jsonPath("$.data.uploadMode").value("PROXY"))
.andExpect(jsonPath("$.data.strategy.proxyContentUrl").value("/api/v2/files/upload-sessions/session-1/content"))
.andExpect(jsonPath("$.data.strategy.proxyFormField").value("file"))
.andExpect(jsonPath("$.data.strategy.completeUrl").value("/api/v2/files/upload-sessions/session-1/complete"));
} }
@Test @Test

View File

@@ -240,6 +240,43 @@ class FileServiceTest {
verify(fileContentStorage).prepareBlobUpload(eq("/docs"), eq("movie.zip"), org.mockito.ArgumentMatchers.argThat(key -> key != null && key.startsWith("blobs/")), eq("application/zip"), eq(uploadSize)); verify(fileContentStorage).prepareBlobUpload(eq("/docs"), eq("movie.zip"), org.mockito.ArgumentMatchers.argThat(key -> key != null && key.startsWith("blobs/")), eq("application/zip"), eq(uploadSize));
} }
@Test
void shouldInitiateProxyUploadWhenDefaultPolicyDisablesDirectUpload() {
fileService = new FileService(
storedFileRepository,
fileBlobRepository,
fileEntityRepository,
storedFileEntityRepository,
fileContentStorage,
fileShareLinkRepository,
adminMetricsService,
storagePolicyService,
new FileStorageProperties()
);
User user = createUser(7L);
when(storedFileRepository.existsByUserIdAndPathAndFilename(7L, "/docs", "notes.txt")).thenReturn(false);
StoragePolicy policy = createDefaultStoragePolicy();
when(storagePolicyService.ensureDefaultPolicy()).thenReturn(policy);
when(storagePolicyService.readCapabilities(policy)).thenReturn(new com.yoyuzh.files.policy.StoragePolicyCapabilities(
false,
false,
false,
true,
false,
true,
false,
false,
500L * 1024 * 1024
));
InitiateUploadResponse response = fileService.initiateUpload(user,
new InitiateUploadRequest("/docs", "notes.txt", "text/plain", 12L));
assertThat(response.direct()).isFalse();
assertThat(response.storageName()).startsWith("blobs/");
verify(fileContentStorage, never()).prepareBlobUpload(any(), any(), any(), any(), any(Long.class));
}
@Test @Test
void shouldCompleteDirectUploadAndPersistMetadata() { void shouldCompleteDirectUploadAndPersistMetadata() {
User user = createUser(7L); User user = createUser(7L);

View File

@@ -0,0 +1,162 @@
package com.yoyuzh.files.tasks;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.yoyuzh.files.core.FileBlob;
import com.yoyuzh.files.core.FileBlobRepository;
import com.yoyuzh.files.core.FileEntityType;
import com.yoyuzh.files.core.FileEntityRepository;
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.storage.FileContentStorage;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.startsWith;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class StoragePolicyMigrationBackgroundTaskHandlerTest {
@Mock
private StoragePolicyRepository storagePolicyRepository;
@Mock
private FileEntityRepository fileEntityRepository;
@Mock
private FileBlobRepository fileBlobRepository;
@Mock
private StoredFileRepository storedFileRepository;
@Mock
private FileContentStorage fileContentStorage;
private StoragePolicyMigrationBackgroundTaskHandler handler;
@BeforeEach
void setUp() {
handler = new StoragePolicyMigrationBackgroundTaskHandler(
storagePolicyRepository,
fileEntityRepository,
fileBlobRepository,
storedFileRepository,
fileContentStorage,
new ObjectMapper()
);
}
@Test
void shouldMigrateCandidateEntitiesAndUpdatePolicyCounts() {
StoragePolicy sourcePolicy = createPolicy(3L, "Source Policy");
StoragePolicy targetPolicy = createPolicy(4L, "Target Policy");
FileBlob blob = new FileBlob();
blob.setId(30L);
blob.setObjectKey("blobs/source-1");
blob.setContentType("video/mp4");
blob.setSize(12L);
var entity = new com.yoyuzh.files.core.FileEntity();
entity.setId(21L);
entity.setObjectKey("blobs/source-1");
entity.setContentType("video/mp4");
entity.setSize(12L);
entity.setEntityType(FileEntityType.VERSION);
entity.setStoragePolicyId(3L);
when(storagePolicyRepository.findById(3L)).thenReturn(Optional.of(sourcePolicy));
when(storagePolicyRepository.findById(4L)).thenReturn(Optional.of(targetPolicy));
when(fileEntityRepository.findByStoragePolicyIdAndEntityTypeOrderByIdAsc(3L, FileEntityType.VERSION)).thenReturn(List.of(entity));
when(fileBlobRepository.findByObjectKey("blobs/source-1")).thenReturn(Optional.of(blob));
when(storedFileRepository.countByBlobId(30L)).thenReturn(2L);
when(fileEntityRepository.save(any())).thenAnswer(invocation -> invocation.getArgument(0));
when(fileBlobRepository.save(any())).thenAnswer(invocation -> invocation.getArgument(0));
when(fileContentStorage.readBlob("blobs/source-1")).thenReturn("payload".getBytes());
BackgroundTask task = new BackgroundTask();
task.setId(11L);
task.setType(BackgroundTaskType.STORAGE_POLICY_MIGRATION);
task.setStatus(BackgroundTaskStatus.RUNNING);
task.setUserId(99L);
task.setPrivateStateJson("""
{"sourcePolicyId":3,"targetPolicyId":4,"sourcePolicyName":"Source Policy","targetPolicyName":"Target Policy"}
""");
task.setPublicStateJson("{}");
BackgroundTaskHandlerResult result = handler.handle(task);
assertThat(result.publicStatePatch()).containsEntry("worker", "storage-policy-migration");
assertThat(result.publicStatePatch()).containsEntry("migrationMode", "executed");
assertThat(result.publicStatePatch()).containsEntry("migrationPerformed", true);
assertThat(result.publicStatePatch()).containsEntry("candidateEntityCount", 1L);
assertThat(result.publicStatePatch()).containsEntry("candidateStoredFileCount", 2L);
assertThat(result.publicStatePatch()).containsEntry("migratedEntityCount", 1L);
assertThat(result.publicStatePatch()).containsEntry("migratedStoredFileCount", 2L);
assertThat(result.publicStatePatch()).containsEntry("processedEntityCount", 1L);
assertThat(result.publicStatePatch()).containsEntry("progressPercent", 100);
assertThat(entity.getStoragePolicyId()).isEqualTo(4L);
assertThat(entity.getObjectKey()).startsWith("policies/4/blobs/");
assertThat(blob.getObjectKey()).startsWith("policies/4/blobs/");
verify(fileContentStorage).storeBlob(startsWith("policies/4/blobs/"), eq("video/mp4"), any());
}
@Test
void shouldDeleteCopiedObjectsWhenMigrationFails() {
StoragePolicy sourcePolicy = createPolicy(3L, "Source Policy");
StoragePolicy targetPolicy = createPolicy(4L, "Target Policy");
FileBlob blob = new FileBlob();
blob.setId(30L);
blob.setObjectKey("blobs/source-1");
blob.setContentType("video/mp4");
blob.setSize(12L);
var entity = new com.yoyuzh.files.core.FileEntity();
entity.setId(21L);
entity.setObjectKey("blobs/source-1");
entity.setContentType("video/mp4");
entity.setSize(12L);
entity.setEntityType(FileEntityType.VERSION);
entity.setStoragePolicyId(3L);
when(storagePolicyRepository.findById(3L)).thenReturn(Optional.of(sourcePolicy));
when(storagePolicyRepository.findById(4L)).thenReturn(Optional.of(targetPolicy));
when(fileEntityRepository.findByStoragePolicyIdAndEntityTypeOrderByIdAsc(3L, FileEntityType.VERSION)).thenReturn(List.of(entity));
when(fileBlobRepository.findByObjectKey("blobs/source-1")).thenReturn(Optional.of(blob));
when(storedFileRepository.countByBlobId(30L)).thenReturn(2L);
when(fileContentStorage.readBlob("blobs/source-1")).thenReturn("payload".getBytes());
doThrow(new IllegalStateException("store failed")).when(fileContentStorage).storeBlob(startsWith("policies/4/blobs/"), eq("video/mp4"), any());
BackgroundTask task = new BackgroundTask();
task.setId(11L);
task.setType(BackgroundTaskType.STORAGE_POLICY_MIGRATION);
task.setStatus(BackgroundTaskStatus.RUNNING);
task.setUserId(99L);
task.setPrivateStateJson("""
{"sourcePolicyId":3,"targetPolicyId":4,"sourcePolicyName":"Source Policy","targetPolicyName":"Target Policy"}
""");
task.setPublicStateJson("{}");
assertThatThrownBy(() -> handler.handle(task))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("store failed");
verify(fileContentStorage).deleteBlob(startsWith("policies/4/blobs/"));
}
private StoragePolicy createPolicy(Long id, String name) {
StoragePolicy policy = new StoragePolicy();
policy.setId(id);
policy.setName(name);
policy.setType(StoragePolicyType.LOCAL);
policy.setEnabled(true);
policy.setCreatedAt(LocalDateTime.now());
policy.setUpdatedAt(LocalDateTime.now());
return policy;
}
}

View File

@@ -11,6 +11,7 @@ import com.yoyuzh.files.policy.StoragePolicyService;
import com.yoyuzh.files.policy.StoragePolicyType; import com.yoyuzh.files.policy.StoragePolicyType;
import com.yoyuzh.files.storage.FileContentStorage; import com.yoyuzh.files.storage.FileContentStorage;
import com.yoyuzh.files.storage.PreparedUpload; import com.yoyuzh.files.storage.PreparedUpload;
import org.springframework.mock.web.MockMultipartFile;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.extension.ExtendWith;
@@ -134,6 +135,111 @@ class UploadSessionServiceTest {
assertThat(preparedUpload.method()).isEqualTo("PUT"); assertThat(preparedUpload.method()).isEqualTo("PUT");
} }
@Test
void shouldPrepareDirectSingleUploadForOwnedSessionWhenPolicyDisablesMultipart() {
User user = createUser(7L);
UploadSession session = createSession(user);
session.setStoragePolicyId(42L);
session.setMultipartUploadId(null);
session.setChunkCount(1);
when(uploadSessionRepository.findBySessionIdAndUserId("session-1", 7L))
.thenReturn(Optional.of(session));
StoragePolicy policy = createDefaultStoragePolicy();
when(storagePolicyService.getRequiredPolicy(42L)).thenReturn(policy);
when(storagePolicyService.readCapabilities(policy)).thenReturn(new StoragePolicyCapabilities(
true,
false,
true,
true,
false,
true,
true,
false,
500L * 1024 * 1024
));
when(fileContentStorage.prepareBlobUpload("/docs", "movie.mp4", "blobs/session-1", "video/mp4", 20L))
.thenReturn(new PreparedUpload(
true,
"https://upload.example.com/session-1",
"PUT",
Map.of("Content-Type", "video/mp4"),
"blobs/session-1"
));
PreparedUpload preparedUpload = uploadSessionService.prepareOwnedUpload(user, "session-1");
assertThat(preparedUpload.direct()).isTrue();
assertThat(preparedUpload.uploadUrl()).isEqualTo("https://upload.example.com/session-1");
assertThat(preparedUpload.method()).isEqualTo("PUT");
}
@Test
void shouldUploadProxyContentForOwnedSessionWhenPolicyDisablesDirectUpload() {
User user = createUser(7L);
UploadSession session = createSession(user);
session.setStoragePolicyId(42L);
session.setMultipartUploadId(null);
session.setChunkCount(1);
session.setSize(7L);
when(uploadSessionRepository.findBySessionIdAndUserId("session-1", 7L))
.thenReturn(Optional.of(session));
StoragePolicy policy = createDefaultStoragePolicy();
when(storagePolicyService.getRequiredPolicy(42L)).thenReturn(policy);
when(storagePolicyService.readCapabilities(policy)).thenReturn(new StoragePolicyCapabilities(
false,
false,
false,
true,
false,
true,
false,
false,
500L * 1024 * 1024
));
when(uploadSessionRepository.save(any(UploadSession.class))).thenAnswer(invocation -> invocation.getArgument(0));
UploadSession result = uploadSessionService.uploadOwnedContent(
user,
"session-1",
new MockMultipartFile("file", "movie.mp4", "video/mp4", "payload".getBytes())
);
assertThat(result.getStatus()).isEqualTo(UploadSessionStatus.UPLOADING);
verify(fileContentStorage).uploadBlob(eq("blobs/session-1"), any(MockMultipartFile.class));
}
@Test
void shouldCreateProxyUploadSessionWhenPolicyDisablesDirectUpload() {
User user = createUser(7L);
when(storedFileRepository.existsByUserIdAndPathAndFilename(7L, "/docs", "movie.mp4")).thenReturn(false);
StoragePolicy policy = createDefaultStoragePolicy();
when(storagePolicyService.ensureDefaultPolicy()).thenReturn(policy);
when(storagePolicyService.readCapabilities(policy)).thenReturn(new StoragePolicyCapabilities(
false,
false,
false,
true,
false,
true,
false,
false,
500L * 1024 * 1024
));
when(uploadSessionRepository.save(any(UploadSession.class))).thenAnswer(invocation -> {
UploadSession saved = invocation.getArgument(0);
saved.setId(100L);
return saved;
});
UploadSession session = uploadSessionService.createSession(
user,
new UploadSessionCreateCommand("/docs", "movie.mp4", "video/mp4", 20L)
);
assertThat(session.getMultipartUploadId()).isNull();
assertThat(session.getChunkCount()).isEqualTo(1);
}
@Test @Test
void shouldOnlyReturnSessionOwnedByCurrentUser() { void shouldOnlyReturnSessionOwnedByCurrentUser() {
User user = createUser(7L); User user = createUser(7L);
@@ -153,6 +259,19 @@ class UploadSessionServiceTest {
void shouldRejectDuplicateTargetWhenCreatingSession() { void shouldRejectDuplicateTargetWhenCreatingSession() {
User user = createUser(7L); User user = createUser(7L);
when(storedFileRepository.existsByUserIdAndPathAndFilename(7L, "/docs", "movie.mp4")).thenReturn(true); when(storedFileRepository.existsByUserIdAndPathAndFilename(7L, "/docs", "movie.mp4")).thenReturn(true);
StoragePolicy policy = createDefaultStoragePolicy();
when(storagePolicyService.ensureDefaultPolicy()).thenReturn(policy);
when(storagePolicyService.readCapabilities(policy)).thenReturn(new StoragePolicyCapabilities(
true,
true,
true,
true,
false,
true,
true,
false,
500L * 1024 * 1024
));
assertThatThrownBy(() -> uploadSessionService.createSession( assertThatThrownBy(() -> uploadSessionService.createSession(
user, user,

View File

@@ -151,27 +151,36 @@ npm run test
- v2 upload session 后端已补齐创建、查询、取消、prepare-part、record-part、complete 和过期清理 - v2 upload session 后端已补齐创建、查询、取消、prepare-part、record-part、complete 和过期清理
- `FileContentStorage` 已新增 multipart 抽象;`S3FileContentStorage` 已实现 create/upload-part/complete/abort - `FileContentStorage` 已新增 multipart 抽象;`S3FileContentStorage` 已实现 create/upload-part/complete/abort
- 默认 S3 存储策略现在会声明 `multipartUpload=true`;创建会话时会生成 `multipartUploadId`v2 响应会返回 `multipartUpload` - 默认 S3 存储策略现在会声明 `multipartUpload=true`;创建会话时会生成 `multipartUploadId`v2 响应会返回 `multipartUpload`
- v2 会话现在还会返回 `directUpload``uploadMode=PROXY|DIRECT_SINGLE|DIRECT_MULTIPART`
- v2 会话响应还会返回 `strategy`,显式给出当前模式应该走的 `prepareUrl/proxyContentUrl/partPrepareUrlTemplate/partRecordUrlTemplate/completeUrl`
- `GET /api/v2/files/upload-sessions/{sessionId}/prepare` 已支持 `DIRECT_SINGLE` 模式下的整文件直传
- `POST /api/v2/files/upload-sessions/{sessionId}/content` 已支持 `PROXY` 模式下的整文件代理上传
- `GET /api/v2/files/upload-sessions/{sessionId}/parts/{partIndex}/prepare` 已可返回单分片直传地址;`POST /complete` 会先提交 multipart complete再复用旧 `FileService.completeUpload()` 落库 - `GET /api/v2/files/upload-sessions/{sessionId}/parts/{partIndex}/prepare` 已可返回单分片直传地址;`POST /complete` 会先提交 multipart complete再复用旧 `FileService.completeUpload()` 落库
- 过期清理已从普通 `deleteBlob` 升级为对未完成 multipart 执行 abort - 过期清理已从普通 `deleteBlob` 升级为对未完成 multipart 执行 abort
- 前端上传队列仍未切到 v2 upload session - `/api/files/upload/initiate` 现在也会尊重默认策略 `directUpload/maxObjectSize`
- 桌面端 `FilesPage`、移动端 `MobileFilesPage``saveFileToNetdisk()` 已切到 v2 upload session并按 `uploadMode + strategy` 自动选择 `PROXY / DIRECT_SINGLE / DIRECT_MULTIPART`
- 前端现已接上 `create/get/cancel/prepare/content/part-prepare/part-record/complete` 全套 v2 upload session helper
后续未完成: 后续未完成:
- 前端上传队列切到 v2 session - 头像上传等非 files 子系统上传入口仍在走旧 `/api/**` 上传链路
### P2存储策略继续推进 ### P2存储策略继续推进
当前状态: 当前状态:
- `StoragePolicy` 和能力声明骨架已落地 - `StoragePolicy` 和能力声明骨架已落地
- 管理台只读展示已落地 - 管理台查看、新增、编辑、启停非默认策略的后端接口已落地
- `POST /api/admin/storage-policies/migrations` 已可创建 `STORAGE_POLICY_MIGRATION` 后台任务
- `STORAGE_POLICY_MIGRATION` 现在会在当前活动存储后端内真实复制对象数据到新的 `policies/{targetPolicyId}/blobs/...` key并更新 `FileBlob``FileEntity.VERSION`
- 迁移任务会暴露 `migrationStage``processedEntityCount/totalEntityCount``processedStoredFileCount/totalStoredFileCount``migratedEntityCount``migratedStoredFileCount` 和真实 `progressPercent`
- 如果迁移过程中失败handler 会删除本轮新写对象,并依赖事务回滚元数据;旧对象清理则在成功提交后 best-effort 执行
- 物理实体可追踪 `storagePolicyId` - 物理实体可追踪 `storagePolicyId`
后续未完成: 后续未完成:
- 管理台新增/编辑/停用策略 - 跨不同运行时后端类型的真正 provider 级迁移
- 多策略迁移任务 - 继续把按策略能力的上传体验外扩到其他非 files 子系统上传入口
- 按策略能力决定上传路径与前端上传策略
## 当前本地运行状态 ## 当前本地运行状态

View File

@@ -251,6 +251,8 @@
- `POST /api/v2/files/upload-sessions` - `POST /api/v2/files/upload-sessions`
- `GET /api/v2/files/upload-sessions/{sessionId}` - `GET /api/v2/files/upload-sessions/{sessionId}`
- `DELETE /api/v2/files/upload-sessions/{sessionId}` - `DELETE /api/v2/files/upload-sessions/{sessionId}`
- `GET /api/v2/files/upload-sessions/{sessionId}/prepare`
- `POST /api/v2/files/upload-sessions/{sessionId}/content`
- `GET /api/v2/files/upload-sessions/{sessionId}/parts/{partIndex}/prepare` - `GET /api/v2/files/upload-sessions/{sessionId}/parts/{partIndex}/prepare`
- `PUT /api/v2/files/upload-sessions/{sessionId}/parts/{partIndex}` - `PUT /api/v2/files/upload-sessions/{sessionId}/parts/{partIndex}`
- `POST /api/v2/files/upload-sessions/{sessionId}/complete` - `POST /api/v2/files/upload-sessions/{sessionId}/complete`
@@ -258,12 +260,17 @@
说明: 说明:
- 需要登录,只允许操作当前用户自己的上传会话 - 需要登录,只允许操作当前用户自己的上传会话
- 会话响应返回 `sessionId``objectKey``multipartUpload``path``filename``contentType``size``storagePolicyId``status``chunkSize``chunkCount``expiresAt``createdAt``updatedAt` - 会话响应返回 `sessionId``objectKey``directUpload``multipartUpload``uploadMode``path``filename``contentType``size``storagePolicyId``status``chunkSize``chunkCount``expiresAt``createdAt``updatedAt`,以及一个面向前端消费的 `strategy` 对象
- 默认 S3 存储策略下,创建会话时会立即初始化 multipart upload并把 `multipartUpload=true` 返回给客户端;本地策略仍会返回 `multipartUpload=false` - `uploadMode` 目前有三种:`PROXY``DIRECT_SINGLE``DIRECT_MULTIPART`
- 默认 S3 存储策略下,创建会话时会立即初始化 multipart upload并把 `directUpload=true``multipartUpload=true``uploadMode=DIRECT_MULTIPART` 返回给客户端;若默认策略 `directUpload=true``multipartUpload=false`,会返回 `DIRECT_SINGLE`;若 `directUpload=false`,则返回 `PROXY`
- `strategy` 会把当前会话下一步该调用的后端入口显式返回出来:`DIRECT_SINGLE` 返回 `prepareUrl` + `completeUrl``PROXY` 返回 `proxyContentUrl` + `proxyFormField=file` + `completeUrl``DIRECT_MULTIPART` 返回 `partPrepareUrlTemplate``partRecordUrlTemplate``completeUrl`
- `GET /{sessionId}/prepare` 仅用于 `DIRECT_SINGLE`,返回整文件直传所需的 `direct/uploadUrl/method/headers/storageName`
- `POST /{sessionId}/content` 仅用于 `PROXY`,以 multipart 表单上传整文件内容到当前 upload session 绑定的 `objectKey`
- `GET /parts/{partIndex}/prepare` 会返回当前分片的直传信息:`direct``uploadUrl``method``headers``storageName` - `GET /parts/{partIndex}/prepare` 会返回当前分片的直传信息:`direct``uploadUrl``method``headers``storageName`
- `PUT /parts/{partIndex}` 请求体仍为 `{ "etag": "...", "size": 8388608 }`,只负责记录 part 元数据,不直接接收字节流 - `PUT /parts/{partIndex}` 请求体仍为 `{ "etag": "...", "size": 8388608 }`,只负责记录 part 元数据,不直接接收字节流
- `POST /complete` 会先按已记录的 part 元数据提交 multipart complete再复用旧上传完成链路写入 `FileBlob + StoredFile + FileEntity.VERSION` - `POST /complete` 会先按已记录的 part 元数据提交 multipart complete再复用旧上传完成链路写入 `FileBlob + StoredFile + FileEntity.VERSION`
- 后端每小时清理过期且未完成的会话;若会话已绑定 multipart upload会优先向对象存储发送 abort - 后端每小时清理过期且未完成的会话;若会话已绑定 multipart upload会优先向对象存储发送 abort
- 当前前端网盘上传主链路已经消费这套 v2 接口:桌面/移动文件页和“存入网盘”入口都会按 `uploadMode + strategy` 自动选择代理上传、单请求直传或 multipart 分片上传
## 4. 快传模块 ## 4. 快传模块
@@ -401,13 +408,21 @@
### 5.4 存储策略 ### 5.4 存储策略
`GET /api/admin/storage-policies` - `GET /api/admin/storage-policies`
- `POST /api/admin/storage-policies`
- `PUT /api/admin/storage-policies/{policyId}`
- `PATCH /api/admin/storage-policies/{policyId}/status`
- `POST /api/admin/storage-policies/migrations`
说明: 说明:
- 需要管理员登录 - 需要管理员登录
- 返回当前存储策略的只读列表和结构化能力声明 - 返回当前存储策略列表和结构化能力声明
- 当前仅用于管理台查看默认策略、启用状态、存储类型和能力矩阵,不支持新增、编辑、启停或删除策略 - 新增/编辑接口当前允许维护名称、类型、bucket/endpoint/region、前缀、凭证模式、最大对象大小、能力声明与启用状态
- `PATCH /status` 用于启用或停用非默认策略;默认策略不能被停用
- `POST /migrations` 需要管理员登录,请求体为 `sourcePolicyId``targetPolicyId` 与可选 `correlationId`;当前会创建一个 `STORAGE_POLICY_MIGRATION` 后台任务,返回值沿用 `/api/v2/tasks/{id}` 的任务响应形状
- 当前迁移任务会在“当前活动存储后端”内执行真实对象迁移:复制旧对象到新的 target-policy object key更新 `FileBlob``FileEntity.VERSION`,并在事务提交后清理旧对象;如果源/目标策略类型与当前运行时存储后端不匹配,任务会失败
- 当前仍不支持删除策略、切换默认策略或通过管理接口暴露实际凭证内容
- `capabilities.multipartUpload` 现在会反映默认策略是否支持 v2 上传会话 multipart当前默认 S3 策略为 `true`,本地策略为 `false` - `capabilities.multipartUpload` 现在会反映默认策略是否支持 v2 上传会话 multipart当前默认 S3 策略为 `true`,本地策略为 `false`
## 6. 前端公开路由与接口关系 ## 6. 前端公开路由与接口关系

View File

@@ -166,7 +166,8 @@
- 定时清理任务会删除超过 10 天的回收站条目;只有当某个 `FileBlob` 的最后一个逻辑引用随之消失时,才真正删除底层对象 - 定时清理任务会删除超过 10 天的回收站条目;只有当某个 `FileBlob` 的最后一个逻辑引用随之消失时,才真正删除底层对象
- 应用启动时会把旧 `portal_file.storage_name` 行自动回填到新的 `blob_id` 引用,保证存量数据能继续读取 - 应用启动时会把旧 `portal_file.storage_name` 行自动回填到新的 `blob_id` 引用,保证存量数据能继续读取
- 当前线上网盘文件存储已切到多吉云对象存储,后端先通过多吉云临时密钥 API 换取短期 S3 会话,再访问底层 COS 兼容桶 - 当前线上网盘文件存储已切到多吉云对象存储,后端先通过多吉云临时密钥 API 换取短期 S3 会话,再访问底层 COS 兼容桶
- v2 上传会话后端现已支持按存储策略能力走真实 multipart默认 S3 策略会在创建会话时初始化 `multipartUploadId`,分片上传通过预签名 `UploadPart` 直传对象存储,完成时先提交 multipart complete再复用旧 `FileService.completeUpload()` 落库;本地策略仍保持 `multipartUpload=false` - v2 上传会话后端现已按默认策略能力明确区分三种上传模式:`PROXY``DIRECT_SINGLE``DIRECT_MULTIPART`。默认 S3 策略会走 `DIRECT_MULTIPART`在创建会话时初始化 `multipartUploadId`,分片上传通过预签名 `UploadPart` 直传对象存储,完成时先提交 multipart complete再复用旧 `FileService.completeUpload()` 落库;若默认策略 `directUpload=true``multipartUpload=false`,则通过 `GET /api/v2/files/upload-sessions/{sessionId}/prepare` 返回整文件直传信息;若 `directUpload=false`,则通过 `POST /api/v2/files/upload-sessions/{sessionId}/content` 走代理上传。当前会话响应还会附带 `strategy`,把当前模式下应调用的后续接口模板显式返回给前端,减少前端自己硬编码 `uploadMode -> endpoint` 映射
- 前端 files 子系统上传入口现已消费这套 v2 upload session桌面端 `FilesPage`、移动端 `MobileFilesPage``saveFileToNetdisk()` 统一通过共享 helper 按 `uploadMode + strategy` 自动选路,并在 multipart 模式下逐片调用 `prepare -> direct upload -> record -> complete`;因此网盘上传主链路已经不再依赖旧 `/api/files/upload/**`
- 前端会缓存目录列表和最后访问路径 - 前端会缓存目录列表和最后访问路径
- 桌面网盘页在左侧树状目录栏底部固定展示回收站入口;移动端在网盘页顶部提供回收站入口;两端共用独立 `RecycleBin` 页面调用 `/api/files/recycle-bin` 与恢复接口 - 桌面网盘页在左侧树状目录栏底部固定展示回收站入口;移动端在网盘页顶部提供回收站入口;两端共用独立 `RecycleBin` 页面调用 `/api/files/recycle-bin` 与恢复接口
@@ -240,6 +241,7 @@ Android 壳补充说明:
- 当前邀请码由后端返回给管理台展示 - 当前邀请码由后端返回给管理台展示
- 用户列表会展示每个用户的已用空间 / 配额 - 用户列表会展示每个用户的已用空间 / 配额
- 管理员修改用户密码后,旧密码应立即失效,新密码可直接重新登录 - 管理员修改用户密码后,旧密码应立即失效,新密码可直接重新登录
- 管理台当前已可查看、新增、编辑并启停非默认 `StoragePolicy`,也可创建 `STORAGE_POLICY_MIGRATION` 后台任务;策略能力继续以结构化 `StoragePolicyCapabilities` 持久化和回显。当前迁移任务会在“当前活动存储后端”内复制对象数据到新的 target-policy object key、更新 `FileBlob/FileEntity.VERSION` 元数据,并在事务提交后清理旧对象;但仍不支持跨不同运行时后端类型的真正 provider 级迁移。默认策略切换和策略删除仍未落地
- JWT 过滤器在受保护接口鉴权成功后,会把当天首次上线的用户写入管理统计表,只保留最近 7 天 - JWT 过滤器在受保护接口鉴权成功后,会把当天首次上线的用户写入管理统计表,只保留最近 7 天
- 管理台请求折线图只渲染当天已发生的小时,不再为未来小时补空点 - 管理台请求折线图只渲染当天已发生的小时,不再为未来小时补空点
@@ -469,6 +471,8 @@ Android 壳补充说明:
- 2026-04-08 `files/storage` 合并补充S3 存储实现拆出多吉云临时密钥客户端与运行期会话提供器。`S3FileContentStorage` 现在通过 `S3SessionProvider.currentSession()` 获取当前 bucket、`S3Client``S3Presigner`,避免每次操作重复内联多吉云 token 解析逻辑;测试环境可直接注入 mock S3 client/presigner。当时该改动还没有引入 multipart仍是单对象 PUT/HEAD/GET/COPY/DELETE 路径。 - 2026-04-08 `files/storage` 合并补充S3 存储实现拆出多吉云临时密钥客户端与运行期会话提供器。`S3FileContentStorage` 现在通过 `S3SessionProvider.currentSession()` 获取当前 bucket、`S3Client``S3Presigner`,避免每次操作重复内联多吉云 token 解析逻辑;测试环境可直接注入 mock S3 client/presigner。当时该改动还没有引入 multipart仍是单对象 PUT/HEAD/GET/COPY/DELETE 路径。
- 2026-04-08 阶段 4 第二小步补充:`FileService` 在创建新的 `FileEntity.VERSION` 时会通过 `StoragePolicyService.ensureDefaultPolicy()` 写入默认 `storagePolicyId``FileEntityBackfillService` 对历史 `FileBlob` 回填新实体时也写入同一默认策略。复用已有实体时保持原策略字段不变,只增加引用计数,避免在兼容迁移阶段覆盖历史数据。 - 2026-04-08 阶段 4 第二小步补充:`FileService` 在创建新的 `FileEntity.VERSION` 时会通过 `StoragePolicyService.ensureDefaultPolicy()` 写入默认 `storagePolicyId``FileEntityBackfillService` 对历史 `FileBlob` 回填新实体时也写入同一默认策略。复用已有实体时保持原策略字段不变,只增加引用计数,避免在兼容迁移阶段覆盖历史数据。
- 2026-04-08 阶段 4 第三小步补充:管理台新增只读存储策略列表。`AdminController` 暴露 `GET /api/admin/storage-policies``AdminService` 通过白名单 DTO 返回策略基础字段和结构化 `StoragePolicyCapabilities`;前端 `react-admin` 新增 `storagePolicies` 资源展示能力矩阵。该能力只做配置可视化,不改变旧上传下载路径,也不暴露凭证或提供策略编辑能力。 - 2026-04-08 阶段 4 第三小步补充:管理台新增只读存储策略列表。`AdminController` 暴露 `GET /api/admin/storage-policies``AdminService` 通过白名单 DTO 返回策略基础字段和结构化 `StoragePolicyCapabilities`;前端 `react-admin` 新增 `storagePolicies` 资源展示能力矩阵。该能力只做配置可视化,不改变旧上传下载路径,也不暴露凭证或提供策略编辑能力。
- 2026-04-09 存储策略管理补充:`AdminController` 现已补 `POST /api/admin/storage-policies``PUT /api/admin/storage-policies/{policyId}``PATCH /api/admin/storage-policies/{policyId}/status``POST /api/admin/storage-policies/migrations`。当前允许新增、编辑、启停非默认策略,并沿用 `StoragePolicyCapabilities` 作为强类型能力声明;迁移接口会为管理员创建 `STORAGE_POLICY_MIGRATION` 后台任务worker 只校验源/目标策略并重算候选 `FileEntity.VERSION` / `StoredFile` 数量,不直接移动对象数据。默认策略仍不能被停用,也还不支持删除策略或切换默认策略。
- 2026-04-09 存储策略迁移补充:`StoragePolicyMigrationBackgroundTaskHandler` 现在会在当前活动存储后端内执行真实对象迁移。它要求源/目标策略类型一致且与运行时后端匹配,复制旧 object key 的字节内容到新的 `policies/{targetPolicyId}/blobs/...` key更新 `FileBlob.objectKey``FileEntity.VERSION.storagePolicyId/objectKey`,并在事务提交后清理旧对象;若中途失败,会删除本轮新写对象,依赖事务回滚数据库状态。
- 2026-04-09 上传会话二期补充:`FileContentStorage` 抽象已新增 `createMultipartUpload/prepareMultipartPartUpload/completeMultipartUpload/abortMultipartUpload``S3FileContentStorage` 基于预签名 `UploadPart` 与 S3 `Complete/AbortMultipartUpload` 实现真实 multipart。`UploadSession` 新增 `multipartUploadId``UploadSessionService.createSession()` 会在默认策略声明 `multipartUpload=true` 时初始化 uploadId并通过 `GET /api/v2/files/upload-sessions/{sessionId}/parts/{partIndex}/prepare` 返回单分片直传地址。会话完成时先按 `uploadedPartsJson` 提交 multipart complete再复用旧上传完成链路落库过期清理则改为优先 abort 未完成 multipart。 - 2026-04-09 上传会话二期补充:`FileContentStorage` 抽象已新增 `createMultipartUpload/prepareMultipartPartUpload/completeMultipartUpload/abortMultipartUpload``S3FileContentStorage` 基于预签名 `UploadPart` 与 S3 `Complete/AbortMultipartUpload` 实现真实 multipart。`UploadSession` 新增 `multipartUploadId``UploadSessionService.createSession()` 会在默认策略声明 `multipartUpload=true` 时初始化 uploadId并通过 `GET /api/v2/files/upload-sessions/{sessionId}/parts/{partIndex}/prepare` 返回单分片直传地址。会话完成时先按 `uploadedPartsJson` 提交 multipart complete再复用旧上传完成链路落库过期清理则改为优先 abort 未完成 multipart。
## 2026-04-08 阶段 5 文件搜索第一小步 ## 2026-04-08 阶段 5 文件搜索第一小步

View File

@@ -0,0 +1,446 @@
# Multi User Platform Upgrade Phase 2 Implementation Plan
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 在当前“邀请制多用户个人盘”稳定后,把系统升级到最小可用的“空间 + 成员角色 + 文件 ACL + 站内共享 + 审计”平台模型,同时不破坏现有个人网盘、公开分享、快传和管理台主链路。
**Architecture:** 继续保留现有 `portal_user + StoredFile + FileBlob/FileEntity` 主模型,不推翻个人网盘语义,而是在其上增加 `Space``SpaceMember``FilePermissionEntry``AuditLog` 这四层扩展。个人网盘先通过“每个用户自动拥有一个 personal space”兼容到新模型桌面端和管理台先接入空间与权限入口移动端只保兼容、不在本阶段新增完整协作 UI。
**Tech Stack:** Spring Boot 3.3.8, Spring Data JPA, H2/MySQL, React 19, TypeScript, Vite 6, Tailwind CSS v4, existing `/api/v2` patterns, existing `mvn test`, `npm run test`, `npm run lint`, @test-driven-development.
---
## Scope And Sequencing
- 本计划默认在当前 `2026-04-08-cloudreve-inspired-upgrade-and-refactor.md` 的既定升级、尤其是阶段 6 和全站前端重设计完成并合入后执行。
- 本计划只覆盖桌面 Web、现有管理台、后端模型/API、现有测试体系不在本阶段实现移动端协作页、WebDAV 权限联动、组织/部门层级、团队回收站、第三方 OAuth scope 细化。
- 所有新能力优先挂在 `/api/v2/**`;旧 `/api/files/**``/api/admin/**` 只做必要兼容,不强制一次性重写。
- 执行中始终使用 @test-driven-development先写失败测试再补最小实现再跑最小验证最后补全集验证。
### Task 1: Add Space Domain Skeleton And Default Personal Space
**Files:**
- Create: `backend/src/main/java/com/yoyuzh/files/space/Space.java`
- Create: `backend/src/main/java/com/yoyuzh/files/space/SpaceType.java`
- Create: `backend/src/main/java/com/yoyuzh/files/space/SpaceRole.java`
- Create: `backend/src/main/java/com/yoyuzh/files/space/SpaceMember.java`
- Create: `backend/src/main/java/com/yoyuzh/files/space/SpaceRepository.java`
- Create: `backend/src/main/java/com/yoyuzh/files/space/SpaceMemberRepository.java`
- Create: `backend/src/main/java/com/yoyuzh/files/space/SpaceService.java`
- Modify: `backend/src/main/java/com/yoyuzh/auth/User.java`
- Modify: `backend/src/main/java/com/yoyuzh/files/StoredFile.java`
- Modify: `backend/src/main/java/com/yoyuzh/files/StoredFileRepository.java`
- Create: `backend/src/test/java/com/yoyuzh/files/space/SpaceServiceTest.java`
- Modify: `backend/src/test/java/com/yoyuzh/files/FileServiceTest.java`
- [ ] **Step 1: Write the failing space-domain tests**
Run: `cd backend && mvn test -Dtest=SpaceServiceTest,FileServiceTest`
Expected: FAIL because `Space`, `SpaceMember`, and `StoredFile.space` support do not exist yet.
- [ ] **Step 2: Add the minimal schema and repository layer**
Implement:
- `Space` with `PERSONAL` and `COLLABORATIVE` types
- `SpaceMember` with `OWNER`, `MANAGER`, `EDITOR`, `VIEWER`
- `StoredFile.space` as nullable-to-required migration target
- repository methods for “default personal space by user” and “space membership by user”
- [ ] **Step 3: Add default personal-space bootstrap**
Implement `SpaceService.ensurePersonalSpace(User)` so:
- existing users can lazily receive a personal space
- new users automatically get one after registration/bootstrap
- files created through current private flows can still resolve to a valid space
- [ ] **Step 4: Re-run the targeted backend tests**
Run: `cd backend && mvn test -Dtest=SpaceServiceTest,FileServiceTest`
Expected: PASS for personal-space bootstrap and `StoredFile` compatibility assertions.
- [ ] **Step 5: Commit**
```bash
git add backend/src/main/java/com/yoyuzh/files/space backend/src/main/java/com/yoyuzh/auth/User.java backend/src/main/java/com/yoyuzh/files/StoredFile.java backend/src/main/java/com/yoyuzh/files/StoredFileRepository.java backend/src/test/java/com/yoyuzh/files/space/SpaceServiceTest.java backend/src/test/java/com/yoyuzh/files/FileServiceTest.java
git commit -m "feat(files): add space domain skeleton"
```
### Task 2: Route Existing File Ownership Through Spaces Without Breaking Private Disk
**Files:**
- Modify: `backend/src/main/java/com/yoyuzh/files/FileService.java`
- Modify: `backend/src/main/java/com/yoyuzh/files/FileSearchService.java`
- Modify: `backend/src/main/java/com/yoyuzh/files/ShareV2Service.java`
- Modify: `backend/src/main/java/com/yoyuzh/files/BackgroundTaskService.java`
- Modify: `backend/src/main/java/com/yoyuzh/files/StoredFileRepository.java`
- Create: `backend/src/test/java/com/yoyuzh/files/StoredFileSpaceCompatibilityTest.java`
- Modify: `backend/src/test/java/com/yoyuzh/files/FileSearchServiceTest.java`
- Modify: `backend/src/test/java/com/yoyuzh/files/BackgroundTaskServiceTest.java`
- [ ] **Step 1: Write failing compatibility tests for old personal-disk flows**
Run: `cd backend && mvn test -Dtest=StoredFileSpaceCompatibilityTest,FileSearchServiceTest,BackgroundTaskServiceTest`
Expected: FAIL because current create/search/task flows still assume “user owns everything directly”.
- [ ] **Step 2: Thread `spaceId` through internal file creation and lookup**
Implement the smallest compatibility rules:
- private files default to current user personal space
- current search/list/task flows keep returning the same personal files as before
- share import, archive/extract, upload complete, and metadata tasks preserve the owning space
- [ ] **Step 3: Keep old API semantics stable**
Do not change:
- existing personal path semantics
- public share token behavior
- anonymous transfer behavior
- current admin file listing shape
Only add internal `space` routing needed for future collaborative flows.
- [ ] **Step 4: Re-run targeted compatibility tests**
Run: `cd backend && mvn test -Dtest=StoredFileSpaceCompatibilityTest,FileSearchServiceTest,BackgroundTaskServiceTest`
Expected: PASS with no regression on existing personal-disk behavior.
- [ ] **Step 5: Commit**
```bash
git add backend/src/main/java/com/yoyuzh/files/FileService.java backend/src/main/java/com/yoyuzh/files/FileSearchService.java backend/src/main/java/com/yoyuzh/files/ShareV2Service.java backend/src/main/java/com/yoyuzh/files/BackgroundTaskService.java backend/src/main/java/com/yoyuzh/files/StoredFileRepository.java backend/src/test/java/com/yoyuzh/files/StoredFileSpaceCompatibilityTest.java backend/src/test/java/com/yoyuzh/files/FileSearchServiceTest.java backend/src/test/java/com/yoyuzh/files/BackgroundTaskServiceTest.java
git commit -m "refactor(files): route private disk through personal spaces"
```
### Task 3: Add V2 Space APIs And Membership Management
**Files:**
- Create: `backend/src/main/java/com/yoyuzh/api/v2/spaces/SpaceV2Controller.java`
- Create: `backend/src/main/java/com/yoyuzh/api/v2/spaces/SpaceResponse.java`
- Create: `backend/src/main/java/com/yoyuzh/api/v2/spaces/SpaceMemberResponse.java`
- Create: `backend/src/main/java/com/yoyuzh/api/v2/spaces/CreateSpaceRequest.java`
- Create: `backend/src/main/java/com/yoyuzh/api/v2/spaces/AddSpaceMemberRequest.java`
- Create: `backend/src/main/java/com/yoyuzh/api/v2/spaces/UpdateSpaceMemberRoleRequest.java`
- Modify: `backend/src/main/java/com/yoyuzh/config/SecurityConfig.java`
- Modify: `backend/src/main/java/com/yoyuzh/auth/UserDetailsServiceImpl.java`
- Create: `backend/src/test/java/com/yoyuzh/api/v2/spaces/SpaceV2ControllerIntegrationTest.java`
- [ ] **Step 1: Write failing API tests for creating and listing spaces**
Run: `cd backend && mvn test -Dtest=SpaceV2ControllerIntegrationTest`
Expected: FAIL because `/api/v2/spaces/**` endpoints and security wiring do not exist.
- [ ] **Step 2: Implement the minimal V2 space endpoints**
Implement:
- `POST /api/v2/spaces`
- `GET /api/v2/spaces`
- `GET /api/v2/spaces/{id}`
- `POST /api/v2/spaces/{id}/members`
- `PATCH /api/v2/spaces/{id}/members/{userId}`
- `DELETE /api/v2/spaces/{id}/members/{userId}`
Rules:
- only authenticated users can list their spaces
- only `OWNER`/`MANAGER` can manage members
- personal spaces cannot remove the owner or add arbitrary members
- [ ] **Step 3: Return DTOs only**
Do not expose JPA entities directly. Keep API outputs small:
- space identity
- type
- display name
- current user role
- member summaries
- [ ] **Step 4: Re-run targeted V2 API tests**
Run: `cd backend && mvn test -Dtest=SpaceV2ControllerIntegrationTest`
Expected: PASS for create/list/member add/remove/role change rules.
- [ ] **Step 5: Commit**
```bash
git add backend/src/main/java/com/yoyuzh/api/v2/spaces backend/src/main/java/com/yoyuzh/config/SecurityConfig.java backend/src/main/java/com/yoyuzh/auth/UserDetailsServiceImpl.java backend/src/test/java/com/yoyuzh/api/v2/spaces/SpaceV2ControllerIntegrationTest.java
git commit -m "feat(api): add v2 space and member management"
```
### Task 4: Add File ACL Entries And Permission Evaluation
**Files:**
- Create: `backend/src/main/java/com/yoyuzh/files/permission/FilePermissionEntry.java`
- Create: `backend/src/main/java/com/yoyuzh/files/permission/FilePermissionRole.java`
- Create: `backend/src/main/java/com/yoyuzh/files/permission/FilePermissionSubjectType.java`
- Create: `backend/src/main/java/com/yoyuzh/files/permission/FilePermissionEntryRepository.java`
- Create: `backend/src/main/java/com/yoyuzh/files/permission/FilePermissionService.java`
- Create: `backend/src/main/java/com/yoyuzh/files/permission/FileAccessEvaluator.java`
- Modify: `backend/src/main/java/com/yoyuzh/files/FileService.java`
- Modify: `backend/src/main/java/com/yoyuzh/files/FileSearchService.java`
- Modify: `backend/src/main/java/com/yoyuzh/files/ShareV2Service.java`
- Create: `backend/src/test/java/com/yoyuzh/files/permission/FilePermissionServiceTest.java`
- Modify: `backend/src/test/java/com/yoyuzh/files/FileServiceEdgeCaseTest.java`
- [ ] **Step 1: Write failing ACL tests**
Run: `cd backend && mvn test -Dtest=FilePermissionServiceTest,FileServiceEdgeCaseTest`
Expected: FAIL because file access is still governed only by owner/admin checks.
- [ ] **Step 2: Implement the minimal ACL model**
Implement:
- subject types: `USER`, `SPACE`
- permission roles: `VIEWER`, `EDITOR`, `MANAGER`
- inheritance flag for directory-to-descendant lookup
Rules:
- owner/personal-space owner always has full access
- collaborative space role provides the default baseline
- explicit file ACL can grant additional access to a user
- [ ] **Step 3: Enforce ACL at service boundaries**
Apply permission checks to:
- list directory
- upload into folder
- rename/move/copy/delete
- create public share
- import into target folder
- start background tasks on a file
- [ ] **Step 4: Re-run the ACL and edge-case tests**
Run: `cd backend && mvn test -Dtest=FilePermissionServiceTest,FileServiceEdgeCaseTest`
Expected: PASS with clear denials for users outside the owning space or without explicit grants.
- [ ] **Step 5: Commit**
```bash
git add backend/src/main/java/com/yoyuzh/files/permission backend/src/main/java/com/yoyuzh/files/FileService.java backend/src/main/java/com/yoyuzh/files/FileSearchService.java backend/src/main/java/com/yoyuzh/files/ShareV2Service.java backend/src/test/java/com/yoyuzh/files/permission/FilePermissionServiceTest.java backend/src/test/java/com/yoyuzh/files/FileServiceEdgeCaseTest.java
git commit -m "feat(files): add acl-based permission evaluation"
```
### Task 5: Add In-App Sharing And “Shared With Me” V2 Views
**Files:**
- Create: `backend/src/main/java/com/yoyuzh/api/v2/files/ShareFileToUserV2Request.java`
- Create: `backend/src/main/java/com/yoyuzh/api/v2/files/SharedFileV2Response.java`
- Create: `backend/src/main/java/com/yoyuzh/api/v2/files/FilePermissionsV2Controller.java`
- Modify: `backend/src/main/java/com/yoyuzh/api/v2/files/FileSearchV2Controller.java`
- Modify: `backend/src/main/java/com/yoyuzh/files/permission/FilePermissionService.java`
- Modify: `backend/src/main/java/com/yoyuzh/files/StoredFileRepository.java`
- Create: `backend/src/test/java/com/yoyuzh/api/v2/files/FilePermissionsV2ControllerIntegrationTest.java`
- Modify: `backend/src/test/java/com/yoyuzh/files/FileShareControllerIntegrationTest.java`
- [ ] **Step 1: Write failing tests for in-app share and shared-with-me listing**
Run: `cd backend && mvn test -Dtest=FilePermissionsV2ControllerIntegrationTest,FileShareControllerIntegrationTest`
Expected: FAIL because there is no station-internal share grant endpoint or “shared with me” query.
- [ ] **Step 2: Add minimal in-app sharing endpoints**
Implement:
- `GET /api/v2/files/{fileId}/permissions`
- `PUT /api/v2/files/{fileId}/permissions`
- `DELETE /api/v2/files/{fileId}/permissions/{entryId}`
- `GET /api/v2/files/shared-with-me`
Rules:
- only `MANAGER` or owner can grant/revoke
- station-internal share writes ACL entries, not public share tokens
- `shared-with-me` excludes files already owned through the current users own personal space
- [ ] **Step 3: Keep public sharing separate**
Do not merge station-internal sharing into `FileShareLink`.
Continue using:
- `FileShareLink` for public token shares
- `FilePermissionEntry` for logged-in user-to-user sharing
- [ ] **Step 4: Re-run targeted integration tests**
Run: `cd backend && mvn test -Dtest=FilePermissionsV2ControllerIntegrationTest,FileShareControllerIntegrationTest`
Expected: PASS for grant, revoke, and `shared-with-me` listing behavior.
- [ ] **Step 5: Commit**
```bash
git add backend/src/main/java/com/yoyuzh/api/v2/files backend/src/main/java/com/yoyuzh/files/permission/FilePermissionService.java backend/src/main/java/com/yoyuzh/files/StoredFileRepository.java backend/src/test/java/com/yoyuzh/api/v2/files/FilePermissionsV2ControllerIntegrationTest.java backend/src/test/java/com/yoyuzh/files/FileShareControllerIntegrationTest.java
git commit -m "feat(files): add in-app sharing and shared-with-me"
```
### Task 6: Add Cross-Cutting Audit Logs And Admin Audit Read API
**Files:**
- Create: `backend/src/main/java/com/yoyuzh/common/audit/AuditLogEntry.java`
- Create: `backend/src/main/java/com/yoyuzh/common/audit/AuditAction.java`
- Create: `backend/src/main/java/com/yoyuzh/common/audit/AuditLogEntryRepository.java`
- Create: `backend/src/main/java/com/yoyuzh/common/audit/AuditLogService.java`
- Create: `backend/src/main/java/com/yoyuzh/admin/AdminAuditLogResponse.java`
- Modify: `backend/src/main/java/com/yoyuzh/admin/AdminController.java`
- Modify: `backend/src/main/java/com/yoyuzh/admin/AdminService.java`
- Modify: `backend/src/main/java/com/yoyuzh/files/FileService.java`
- Modify: `backend/src/main/java/com/yoyuzh/files/space/SpaceService.java`
- Modify: `backend/src/main/java/com/yoyuzh/files/permission/FilePermissionService.java`
- Modify: `backend/src/test/java/com/yoyuzh/admin/AdminControllerIntegrationTest.java`
- Create: `backend/src/test/java/com/yoyuzh/common/audit/AuditLogServiceTest.java`
- [ ] **Step 1: Write failing audit tests**
Run: `cd backend && mvn test -Dtest=AuditLogServiceTest,AdminControllerIntegrationTest`
Expected: FAIL because no audit entity/service exists and admin cannot query audit logs.
- [ ] **Step 2: Implement the smallest useful audit model**
Persist at least:
- actor user id
- action type
- target type
- target id
- summary text
- created at
Log these events:
- space create/member change
- file permission grant/revoke
- public share create/delete
- file delete/restore
- [ ] **Step 3: Add admin read API only**
Implement a read-only admin endpoint:
- `GET /api/admin/audit-logs?page=0&size=20&query=...`
Do not add end-user audit UI in this phase.
- [ ] **Step 4: Re-run audit tests**
Run: `cd backend && mvn test -Dtest=AuditLogServiceTest,AdminControllerIntegrationTest`
Expected: PASS for audit persistence and admin listing.
- [ ] **Step 5: Commit**
```bash
git add backend/src/main/java/com/yoyuzh/common/audit backend/src/main/java/com/yoyuzh/admin/AdminController.java backend/src/main/java/com/yoyuzh/admin/AdminService.java backend/src/main/java/com/yoyuzh/files/FileService.java backend/src/main/java/com/yoyuzh/files/space/SpaceService.java backend/src/main/java/com/yoyuzh/files/permission/FilePermissionService.java backend/src/test/java/com/yoyuzh/admin/AdminControllerIntegrationTest.java backend/src/test/java/com/yoyuzh/common/audit/AuditLogServiceTest.java
git commit -m "feat(admin): add audit log backbone"
```
### Task 7: Add Desktop-Web Space, Permission, And Shared-With-Me UI
**Files:**
- Modify: `front/src/lib/types.ts`
- Modify: `front/src/lib/api.ts`
- Create: `front/src/lib/spaces.ts`
- Create: `front/src/lib/spaces.test.ts`
- Create: `front/src/lib/file-permissions.ts`
- Create: `front/src/lib/file-permissions.test.ts`
- Modify: `front/src/App.tsx`
- Modify: `front/src/components/layout/Layout.tsx`
- Modify: `front/src/pages/files/FilesDirectoryRail.tsx`
- Modify: `front/src/pages/files/FilesToolbar.tsx`
- Modify: `front/src/pages/files/useFilesDirectoryState.ts`
- Create: `front/src/pages/Spaces.tsx`
- Create: `front/src/pages/SharedWithMe.tsx`
- Create: `front/src/pages/files/SpaceMembersDialog.tsx`
- Create: `front/src/pages/files/FilePermissionsDialog.tsx`
- Create: `front/src/pages/spaces-state.ts`
- Create: `front/src/pages/spaces-state.test.ts`
- Create: `front/src/pages/shared-with-me-state.ts`
- Create: `front/src/pages/shared-with-me-state.test.ts`
- [ ] **Step 1: Write failing frontend helper tests**
Run: `cd front && npm run test -- src/lib/spaces.test.ts src/lib/file-permissions.test.ts src/pages/spaces-state.test.ts src/pages/shared-with-me-state.test.ts`
Expected: FAIL because the new API helpers and page state modules do not exist.
- [ ] **Step 2: Add typed API helpers first**
Implement the smallest helpers for:
- list/create spaces
- list/update members
- get/update file permissions
- list shared-with-me files
Do not start with JSX-heavy pages before helpers and tests exist.
- [ ] **Step 3: Add desktop-only navigation and management UI**
Implement:
- `Spaces` page for list/create/member management
- `SharedWithMe` page for station-internal shares
- file page dialogs for members and permissions
- a space switcher in the file rail or toolbar
Rules:
- keep current private disk route working
- personal space remains default landing target
- mobile app can stay unchanged in this phase
- [ ] **Step 4: Re-run targeted frontend tests**
Run: `cd front && npm run test -- src/lib/spaces.test.ts src/lib/file-permissions.test.ts src/pages/spaces-state.test.ts src/pages/shared-with-me-state.test.ts`
Expected: PASS with helpers and page state stabilized before broad UI verification.
- [ ] **Step 5: Commit**
```bash
git add front/src/lib/types.ts front/src/lib/api.ts front/src/lib/spaces.ts front/src/lib/spaces.test.ts front/src/lib/file-permissions.ts front/src/lib/file-permissions.test.ts front/src/App.tsx front/src/components/layout/Layout.tsx front/src/pages/files/FilesDirectoryRail.tsx front/src/pages/files/FilesToolbar.tsx front/src/pages/files/useFilesDirectoryState.ts front/src/pages/Spaces.tsx front/src/pages/SharedWithMe.tsx front/src/pages/files/SpaceMembersDialog.tsx front/src/pages/files/FilePermissionsDialog.tsx front/src/pages/spaces-state.ts front/src/pages/spaces-state.test.ts front/src/pages/shared-with-me-state.ts front/src/pages/shared-with-me-state.test.ts
git commit -m "feat(front): add spaces and shared-with-me ui"
```
### Task 8: Full Verification And Documentation Handoff
**Files:**
- Modify: `docs/architecture.md`
- Modify: `docs/api-reference.md`
- Modify: `memory.md`
- Modify only if verification reveals gaps: `backend/**`, `front/**`
- [ ] **Step 1: Run full backend verification**
Run: `cd backend && mvn test`
Expected: PASS with no regressions in auth, files, tasks, shares, admin, and API v2 tests.
- [ ] **Step 2: Run full frontend verification**
Run: `cd front && npm run test`
Expected: PASS with no regressions in files, transfer, admin, and new spaces/shared state tests.
- [ ] **Step 3: Run frontend type/lint verification**
Run: `cd front && npm run lint`
Expected: PASS with no TypeScript errors.
- [ ] **Step 4: Update project memory and architecture docs**
Document:
- space model
- member roles
- ACL rules
- in-app sharing vs public share split
- admin audit log availability
- mobile deferred scope
- [ ] **Step 5: Final commit**
```bash
git add docs/architecture.md docs/api-reference.md memory.md
git commit -m "docs: record multi-user platform phase 2 architecture"
```
## Deferred Explicitly
- 移动端 `MobileFiles` / `MobileOverview` / `MobileApp` 的空间协作 UI
- 组织/部门/用户组层级,不在本计划混入
- WebDAV、OAuth scope、桌面同步客户端权限联动
- 团队回收站、空间级生命周期策略、空间级存储策略切换
- 文件评论、审批流、在线协作文档
## Success Criteria
- 现有个人网盘用户登录后仍能像今天一样使用自己的私有文件空间。
- 每个用户可看到自己的 personal space且可创建 collaborative space。
- collaborative space 支持最小成员角色与目录/文件 ACL。
- 站内用户可通过 ACL 被共享文件,并在 “Shared With Me” 中看到结果。
- 审计日志能覆盖空间、权限、分享、删除/恢复等关键动作。
- 旧公开分享、快传、上传会话、后台任务和管理台文件列表不被打断。

View File

@@ -11,7 +11,8 @@ import MobileOverview from './mobile-pages/MobileOverview';
import MobileFiles from './mobile-pages/MobileFiles'; import MobileFiles from './mobile-pages/MobileFiles';
import MobileTransfer from './mobile-pages/MobileTransfer'; import MobileTransfer from './mobile-pages/MobileTransfer';
import MobileFileShare from './mobile-pages/MobileFileShare'; import MobileFileShare from './mobile-pages/MobileFileShare';
import RecycleBin from './pages/RecycleBin'; import MobileRecycleBin from './mobile-pages/MobileRecycleBin';
import MobileAdminUnavailable from './mobile-pages/MobileAdminUnavailable';
function LegacyTransferRedirect() { function LegacyTransferRedirect() {
const location = useLocation(); const location = useLocation();
@@ -54,16 +55,16 @@ function MobileAppRoutes() {
<Route index element={<Navigate to="/overview" replace />} /> <Route index element={<Navigate to="/overview" replace />} />
<Route path="overview" element={<MobileOverview />} /> <Route path="overview" element={<MobileOverview />} />
<Route path="files" element={<MobileFiles />} /> <Route path="files" element={<MobileFiles />} />
<Route path="recycle-bin" element={<RecycleBin />} /> <Route path="recycle-bin" element={<MobileRecycleBin />} />
<Route path="games" element={<Navigate to="/overview" replace />} /> <Route path="games" element={<Navigate to="/overview" replace />} />
</Route> </Route>
<Route path="/games/:gameId" element={<Navigate to={isAuthenticated ? '/overview' : '/login'} replace />} /> <Route path="/games/:gameId" element={<Navigate to={isAuthenticated ? '/overview' : '/login'} replace />} />
{/* Admin dashboard is not mobile-optimized in this phase yet, redirect to overview or login */} {/* Admin dashboard is not mobile-optimized in this phase yet, show stub page */}
<Route <Route
path="/admin/*" path="/admin/*"
element={isAuthenticated ? <Navigate to="/overview" replace /> : <Navigate to="/login" replace />} element={isAuthenticated ? <MobileAdminUnavailable /> : <Navigate to="/login" replace />}
/> />
<Route <Route

View File

@@ -50,7 +50,6 @@ export function Layout({ children }: LayoutProps = {}) {
const navItems = getVisibleNavItems(isAdmin); const navItems = getVisibleNavItems(isAdmin);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const [activeModal, setActiveModal] = useState<ActiveModal>(null); const [activeModal, setActiveModal] = useState<ActiveModal>(null);
const [avatarPreviewUrl, setAvatarPreviewUrl] = useState<string | null>(null); const [avatarPreviewUrl, setAvatarPreviewUrl] = useState<string | null>(null);
const [selectedAvatarFile, setSelectedAvatarFile] = useState<File | null>(null); const [selectedAvatarFile, setSelectedAvatarFile] = useState<File | null>(null);
@@ -192,7 +191,6 @@ export function Layout({ children }: LayoutProps = {}) {
if (!currentSession) { if (!currentSession) {
return; return;
} }
saveStoredSession({ saveStoredSession({
...currentSession, ...currentSession,
user: nextProfile, user: nextProfile,
@@ -326,33 +324,25 @@ export function Layout({ children }: LayoutProps = {}) {
}; };
return ( return (
<div className="min-h-screen flex flex-col bg-[#07101D] text-white relative overflow-hidden"> <div className="flex bg-[#07101D] text-white overflow-hidden w-full h-screen">
<div className="fixed inset-0 z-0 pointer-events-none"> <aside className="h-full w-16 md:w-56 flex flex-col shrink-0 border-r border-white/10 bg-[#0f172a]/50">
<div className="absolute top-[-10%] left-[-10%] w-[40%] h-[40%] rounded-full bg-[#336EFF] opacity-20 mix-blend-screen blur-[120px] animate-blob" /> <div className="h-14 flex items-center md:px-4 justify-center md:justify-start border-b border-white/10">
<div className="absolute top-[20%] right-[-10%] w-[50%] h-[50%] rounded-full bg-purple-600 opacity-20 mix-blend-screen blur-[120px] animate-blob animation-delay-2000" /> <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">
<div className="absolute bottom-[-20%] left-[20%] w-[60%] h-[60%] rounded-full bg-indigo-600 opacity-20 mix-blend-screen blur-[120px] animate-blob animation-delay-4000" />
</div>
<header className="fixed top-0 left-0 right-0 z-50 w-full glass-panel border-b border-white/10 bg-[#07101D]/60 backdrop-blur-xl">
<div className="container mx-auto px-4 h-16 flex items-center justify-between">
<div className="flex items-center gap-3">
<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">
<span className="text-white font-bold text-lg leading-none">Y</span> <span className="text-white font-bold text-lg leading-none">Y</span>
</div> </div>
<div className="flex flex-col"> <div className="hidden md:flex flex-col ml-3">
<span className="text-white font-bold text-sm tracking-wider">YOYUZH.XYZ</span> <span className="text-white font-bold text-sm tracking-wider">YOYUZH.XYZ</span>
<span className="text-slate-400 text-[10px] uppercase tracking-widest">Personal Portal</span>
</div> </div>
</div> </div>
<nav className="hidden md:flex items-center gap-2"> <nav className="flex-1 flex flex-col gap-2 p-2 relative overflow-y-auto overflow-x-hidden">
{navItems.map((item) => ( {navItems.map((item) => (
<NavLink <NavLink
key={item.path} key={item.path}
to={item.path} to={item.path}
className={({ isActive }) => className={({ isActive }) =>
cn( cn(
'flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-medium transition-all duration-200 relative overflow-hidden group', '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', isActive ? 'text-white shadow-md shadow-[#336EFF]/20' : 'text-slate-400 hover:text-white hover:bg-white/5',
) )
} }
@@ -360,79 +350,43 @@ export function Layout({ children }: LayoutProps = {}) {
{({ isActive }) => ( {({ isActive }) => (
<> <>
{isActive && <div className="absolute inset-0 bg-[#336EFF] opacity-100 z-0" />} {isActive && <div className="absolute inset-0 bg-[#336EFF] opacity-100 z-0" />}
<item.icon className="w-4 h-4 relative z-10" /> <item.icon className="w-[18px] h-[18px] relative z-10 shrink-0" />
<span className="relative z-10">{item.name}</span> <span className="relative z-10 hidden md:block">{item.name}</span>
</> </>
)} )}
</NavLink> </NavLink>
))} ))}
</nav> </nav>
<div className="flex items-center gap-4 relative"> <div className="p-4 border-t border-white/10 shrink-0 flex flex-col gap-2 relative">
<button <button
onClick={() => setIsDropdownOpen((current) => !current)} onClick={() => setActiveModal('settings')}
className="w-10 h-10 rounded-full bg-slate-800 border border-white/10 flex items-center justify-center text-slate-300 hover:text-white hover:border-white/20 transition-all relative z-10 overflow-hidden" 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"
aria-label="Account"
> >
<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 ? ( {displayedAvatarUrl ? (
<img src={displayedAvatarUrl} alt="Avatar" className="w-full h-full object-cover" /> <img src={displayedAvatarUrl} alt="Avatar" className="w-full h-full object-cover" />
) : ( ) : (
<span className="text-sm font-semibold">{avatarFallback}</span> <span className="text-xs font-semibold">{avatarFallback}</span>
)} )}
</button> </div>
<div className="hidden md:block flex-1 min-w-0 text-left">
<AnimatePresence> <p className="text-sm font-medium text-white truncate">{displayName}</p>
{isDropdownOpen && (
<>
<div className="fixed inset-0 z-40" onClick={() => setIsDropdownOpen(false)} />
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 10 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 10 }}
transition={{ duration: 0.15 }}
className="absolute right-0 top-full mt-2 w-56 bg-[#0f172a] border border-white/10 rounded-xl shadow-2xl z-50 py-2 overflow-hidden"
>
<div className="px-4 py-3 border-b border-white/10 mb-2">
<p className="text-sm font-medium text-white">{displayName}</p>
<p className="text-xs text-slate-400 truncate">{email}</p> <p className="text-xs text-slate-400 truncate">{email}</p>
</div> </div>
<button
onClick={() => {
setActiveModal('security');
setIsDropdownOpen(false);
}}
className="w-full text-left px-4 py-2 text-sm text-slate-300 hover:bg-white/10 hover:text-white flex items-center gap-3 transition-colors"
>
<Shield className="w-4 h-4" />
</button> </button>
<button
onClick={() => {
setActiveModal('settings');
setIsDropdownOpen(false);
}}
className="w-full text-left px-4 py-2 text-sm text-slate-300 hover:bg-white/10 hover:text-white flex items-center gap-3 transition-colors"
>
<Settings className="w-4 h-4" />
</button>
<div className="h-px bg-white/10 my-2" />
<button <button
onClick={handleLogout} onClick={handleLogout}
className="w-full text-left px-4 py-2 text-sm text-red-400 hover:bg-red-500/10 hover:text-red-300 flex items-center gap-3 transition-colors" 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-4 h-4" /> 退 <LogOut className="w-[18px] h-[18px]" />
<span className="hidden md:block font-medium">退</span>
</button> </button>
</motion.div>
</>
)}
</AnimatePresence>
</div> </div>
</div> </aside>
</header>
<main className="flex-1 container mx-auto px-4 pt-24 pb-8 relative z-10"> <main className="flex-1 flex flex-col min-w-0 h-full relative overflow-y-auto">
{children ?? <Outlet />} {children ?? <Outlet />}
</main> </main>
@@ -629,6 +583,27 @@ export function Layout({ children }: LayoutProps = {}) {
<option value="en-US">English</option> <option value="en-US">English</option>
</select> </select>
</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-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> </div>
{profileError && <p className="text-sm text-rose-300">{profileError}</p>} {profileError && <p className="text-sm text-rose-300">{profileError}</p>}

View File

@@ -0,0 +1,42 @@
import React, { ReactNode } from 'react';
import { cn } from '@/src/lib/utils';
interface AppPageShellProps {
toolbar: ReactNode;
rail?: ReactNode;
inspector?: ReactNode;
children: ReactNode;
}
export function AppPageShell({ toolbar, rail, inspector, children }: AppPageShellProps) {
return (
<div className="flex-1 flex flex-col min-w-0 h-full overflow-hidden relative z-10 w-full bg-[#07101D]">
{/* Top Toolbar */}
<header className="h-14 shrink-0 border-b border-white/10 bg-[#0f172a]/70 flex items-center px-4 w-full z-20 backdrop-blur-xl">
{toolbar}
</header>
{/* 3-Zone Content Segment */}
<div className="flex-1 flex min-h-0 w-full overflow-hidden">
{/* Nav Rail (e.g. Directory Tree) */}
{rail && (
<div className="w-64 shrink-0 border-r border-white/10 bg-[#0f172a]/20 h-full overflow-y-auto">
{rail}
</div>
)}
{/* Center Main Pane */}
<main className="flex-1 min-w-0 h-full overflow-y-auto bg-transparent relative">
{children}
</main>
{/* Inspector Panel (e.g. File Details) */}
{inspector && (
<div className="w-72 shrink-0 border-l border-white/10 bg-[#0f172a]/20 h-full overflow-y-auto hidden lg:block">
{inspector}
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,25 @@
import React, { ReactNode } from 'react';
interface PageToolbarProps {
title: ReactNode;
actions?: ReactNode;
}
export function PageToolbar({ title, actions }: PageToolbarProps) {
return (
<div className="flex w-full items-center justify-between">
<div className="flex items-center gap-3">
{typeof title === 'string' ? (
<h2 className="text-lg font-semibold text-white tracking-tight">{title}</h2>
) : (
title
)}
</div>
{actions && (
<div className="flex items-center gap-2">
{actions}
</div>
)}
</div>
);
}

View File

@@ -436,15 +436,41 @@ test('apiBinaryUploadRequest sends raw file body to signed upload url', async ()
request.triggerProgress(64, 128); request.triggerProgress(64, 128);
request.triggerProgress(128, 128); request.triggerProgress(128, 128);
request.responseHeaders.set('etag', '"etag-1"');
request.respond('', 200, 'text/plain'); request.respond('', 200, 'text/plain');
await uploadPromise; const payload = await uploadPromise;
assert.deepEqual(payload, {
status: 200,
headers: {},
});
assert.deepEqual(progressCalls, [ assert.deepEqual(progressCalls, [
{loaded: 64, total: 128}, {loaded: 64, total: 128},
{loaded: 128, total: 128}, {loaded: 128, total: 128},
]); ]);
}); });
test('apiBinaryUploadRequest returns requested response headers', async () => {
const uploadPromise = apiBinaryUploadRequest('https://upload.example.com/object', {
method: 'PUT',
body: new Blob(['hello-oss']),
responseHeaders: ['etag'],
});
const request = FakeXMLHttpRequest.latest;
assert.ok(request);
request.responseHeaders.set('etag', '"etag-part-2"');
request.respond('', 200, 'text/plain');
const payload = await uploadPromise;
assert.deepEqual(payload, {
status: 200,
headers: {
etag: '"etag-part-2"',
},
});
});
test('apiUploadRequest supports aborting a single upload task', async () => { test('apiUploadRequest supports aborting a single upload task', async () => {
const controller = new AbortController(); const controller = new AbortController();
const formData = new FormData(); const formData = new FormData();

View File

@@ -25,9 +25,15 @@ interface ApiBinaryUploadRequestInit {
headers?: HeadersInit; headers?: HeadersInit;
method?: 'PUT' | 'POST'; method?: 'PUT' | 'POST';
onProgress?: (progress: {loaded: number; total: number}) => void; onProgress?: (progress: {loaded: number; total: number}) => void;
responseHeaders?: string[];
signal?: AbortSignal; signal?: AbortSignal;
} }
export interface ApiBinaryUploadResponse {
status: number;
headers: Record<string, string>;
}
const AUTH_REFRESH_PATH = '/auth/refresh'; const AUTH_REFRESH_PATH = '/auth/refresh';
const DEFAULT_API_BASE_URL = '/api'; const DEFAULT_API_BASE_URL = '/api';
const DEFAULT_CAPACITOR_API_ORIGIN = 'https://api.yoyuzh.xyz'; const DEFAULT_CAPACITOR_API_ORIGIN = 'https://api.yoyuzh.xyz';
@@ -537,7 +543,7 @@ export function apiUploadRequest<T>(path: string, init: ApiUploadRequestInit): P
export function apiBinaryUploadRequest(path: string, init: ApiBinaryUploadRequestInit) { export function apiBinaryUploadRequest(path: string, init: ApiBinaryUploadRequestInit) {
const headers = new Headers(init.headers); const headers = new Headers(init.headers);
return new Promise<void>((resolve, reject) => { return new Promise<ApiBinaryUploadResponse>((resolve, reject) => {
const xhr = new XMLHttpRequest(); const xhr = new XMLHttpRequest();
let settled = false; let settled = false;
@@ -545,14 +551,14 @@ export function apiBinaryUploadRequest(path: string, init: ApiBinaryUploadReques
init.signal?.removeEventListener('abort', handleAbortSignal); init.signal?.removeEventListener('abort', handleAbortSignal);
}; };
const resolveOnce = () => { const resolveOnce = (value: ApiBinaryUploadResponse) => {
if (settled) { if (settled) {
return; return;
} }
settled = true; settled = true;
detachAbortSignal(); detachAbortSignal();
resolve(); resolve(value);
}; };
const rejectOnce = (error: unknown) => { const rejectOnce = (error: unknown) => {
@@ -613,7 +619,18 @@ export function apiBinaryUploadRequest(path: string, init: ApiBinaryUploadReques
xhr.onload = () => { xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) { if (xhr.status >= 200 && xhr.status < 300) {
resolveOnce(); const responseHeaders = Object.fromEntries(
(init.responseHeaders ?? [])
.map((headerName) => {
const value = xhr.getResponseHeader(headerName);
return [headerName.toLowerCase(), value];
})
.filter((entry): entry is [string, string] => Boolean(entry[1])),
);
resolveOnce({
status: xhr.status,
headers: responseHeaders,
});
return; return;
} }

View File

@@ -1,6 +1,5 @@
import { apiBinaryUploadRequest, apiRequest, apiUploadRequest, ApiError } from './api';
import { joinNetdiskPath, resolveTransferSaveDirectory, splitNetdiskPath } from './netdisk-paths'; import { joinNetdiskPath, resolveTransferSaveDirectory, splitNetdiskPath } from './netdisk-paths';
import type { FileMetadata, InitiateUploadResponse } from './types'; import { uploadFileToNetdiskViaSession } from './upload-session';
export function normalizeNetdiskTargetPath(path: string | null | undefined, fallback = '/下载') { export function normalizeNetdiskTargetPath(path: string | null | undefined, fallback = '/下载') {
const rawPath = path?.trim(); const rawPath = path?.trim();
@@ -17,44 +16,5 @@ export function resolveNetdiskSaveDirectory(relativePath: string | null | undefi
export async function saveFileToNetdisk(file: File, path: string) { export async function saveFileToNetdisk(file: File, path: string) {
const normalizedPath = normalizeNetdiskTargetPath(path); const normalizedPath = normalizeNetdiskTargetPath(path);
const initiated = await apiRequest<InitiateUploadResponse>('/files/upload/initiate', { return uploadFileToNetdiskViaSession(file, normalizedPath);
method: 'POST',
body: {
path: normalizedPath,
filename: file.name,
contentType: file.type || null,
size: file.size,
},
});
if (initiated.direct) {
try {
await apiBinaryUploadRequest(initiated.uploadUrl, {
method: initiated.method,
headers: initiated.headers,
body: file,
});
return await apiRequest<FileMetadata>('/files/upload/complete', {
method: 'POST',
body: {
path: normalizedPath,
filename: file.name,
storageName: initiated.storageName,
contentType: file.type || null,
size: file.size,
},
});
} catch (error) {
if (!(error instanceof ApiError && error.isNetworkError)) {
throw error;
}
}
}
const formData = new FormData();
formData.append('file', file);
return apiUploadRequest<FileMetadata>(`/files/upload?path=${encodeURIComponent(normalizedPath)}`, {
body: formData,
});
} }

View File

@@ -159,6 +159,45 @@ export interface InitiateUploadResponse {
storageName: string; storageName: string;
} }
export type UploadSessionUploadMode = 'PROXY' | 'DIRECT_SINGLE' | 'DIRECT_MULTIPART';
export interface UploadSessionStrategy {
prepareUrl: string | null;
proxyContentUrl: string | null;
partPrepareUrlTemplate: string | null;
partRecordUrlTemplate: string | null;
completeUrl: string;
proxyFormField: string | null;
}
export interface UploadSessionResponse {
sessionId: string;
objectKey: string;
directUpload: boolean;
multipartUpload: boolean;
uploadMode: UploadSessionUploadMode;
path: string;
filename: string;
contentType: string | null;
size: number;
storagePolicyId: number | null;
status: string;
chunkSize: number;
chunkCount: number;
expiresAt: string;
createdAt: string;
updatedAt: string;
strategy: UploadSessionStrategy;
}
export interface PreparedUploadResponse {
direct: boolean;
uploadUrl: string;
method: 'POST' | 'PUT';
headers: Record<string, string>;
storageName: string;
}
export interface DownloadUrlResponse { export interface DownloadUrlResponse {
url: string; url: string;
} }

View File

@@ -0,0 +1,425 @@
import assert from 'node:assert/strict';
import { afterEach, beforeEach, test } from 'node:test';
import {
uploadFileToNetdiskViaSession,
} from './upload-session';
class MemoryStorage implements Storage {
private store = new Map<string, string>();
get length() {
return this.store.size;
}
clear() {
this.store.clear();
}
getItem(key: string) {
return this.store.has(key) ? this.store.get(key)! : null;
}
key(index: number) {
return Array.from(this.store.keys())[index] ?? null;
}
removeItem(key: string) {
this.store.delete(key);
}
setItem(key: string, value: string) {
this.store.set(key, value);
}
}
const originalFetch = globalThis.fetch;
const originalStorage = globalThis.localStorage;
const originalXMLHttpRequest = globalThis.XMLHttpRequest;
class FakeXMLHttpRequest {
static instances: FakeXMLHttpRequest[] = [];
method = '';
url = '';
requestBody: Document | XMLHttpRequestBodyInit | null = null;
responseText = '';
status = 200;
headers = new Map<string, string>();
responseHeaders = new Map<string, string>();
onload: null | (() => void) = null;
onerror: null | (() => void) = null;
onabort: null | (() => void) = null;
aborted = false;
upload = {
addEventListener: () => {},
};
constructor() {
FakeXMLHttpRequest.instances.push(this);
}
open(method: string, url: string) {
this.method = method;
this.url = url;
}
setRequestHeader(name: string, value: string) {
this.headers.set(name.toLowerCase(), value);
}
getResponseHeader(name: string) {
return this.responseHeaders.get(name.toLowerCase()) ?? this.responseHeaders.get(name) ?? null;
}
send(body: Document | XMLHttpRequestBodyInit | null) {
this.requestBody = body;
}
abort() {
this.aborted = true;
this.onabort?.();
}
respond(body: unknown, status = 200, contentType = 'application/json') {
this.status = status;
this.responseText = typeof body === 'string' ? body : JSON.stringify(body);
this.responseHeaders.set('content-type', contentType);
this.onload?.();
}
}
beforeEach(() => {
Object.defineProperty(globalThis, 'localStorage', {
configurable: true,
value: new MemoryStorage(),
});
Object.defineProperty(globalThis, 'XMLHttpRequest', {
configurable: true,
value: FakeXMLHttpRequest,
});
FakeXMLHttpRequest.instances = [];
});
afterEach(() => {
globalThis.fetch = originalFetch;
Object.defineProperty(globalThis, 'localStorage', {
configurable: true,
value: originalStorage,
});
Object.defineProperty(globalThis, 'XMLHttpRequest', {
configurable: true,
value: originalXMLHttpRequest,
});
});
async function waitFor(predicate: () => boolean, timeoutMs = 100) {
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
if (predicate()) {
return;
}
await new Promise((resolve) => setTimeout(resolve, 0));
}
throw new Error('timed out waiting for async upload work');
}
test('uploadFileToNetdiskViaSession completes a direct single upload session', async () => {
const calls: string[] = [];
globalThis.fetch = async (input, init) => {
const request = input instanceof Request ? input : new Request(new URL(String(input), 'http://localhost'), init);
calls.push(`${request.method} ${request.url}`);
if (request.url.endsWith('/api/v2/files/upload-sessions')) {
return new Response(JSON.stringify({
code: 0,
msg: 'success',
data: {
sessionId: 'session-1',
objectKey: 'blobs/session-1',
directUpload: true,
multipartUpload: false,
uploadMode: 'DIRECT_SINGLE',
path: '/docs',
filename: 'movie.mp4',
contentType: 'video/mp4',
size: 9,
storagePolicyId: 1,
status: 'CREATED',
chunkSize: 8388608,
chunkCount: 1,
expiresAt: '2026-04-09T00:00:00',
createdAt: '2026-04-09T00:00:00',
updatedAt: '2026-04-09T00:00:00',
strategy: {
prepareUrl: '/api/v2/files/upload-sessions/session-1/prepare',
proxyContentUrl: null,
partPrepareUrlTemplate: null,
partRecordUrlTemplate: null,
completeUrl: '/api/v2/files/upload-sessions/session-1/complete',
proxyFormField: null,
},
},
}), {headers: {'Content-Type': 'application/json'}});
}
if (request.url.endsWith('/api/v2/files/upload-sessions/session-1/prepare')) {
return new Response(JSON.stringify({
code: 0,
msg: 'success',
data: {
direct: true,
uploadUrl: 'https://upload.example.com/single',
method: 'PUT',
headers: {'Content-Type': 'video/mp4'},
storageName: 'blobs/session-1',
},
}), {headers: {'Content-Type': 'application/json'}});
}
if (request.url.endsWith('/api/v2/files/upload-sessions/session-1/complete')) {
return new Response(JSON.stringify({
code: 0,
msg: 'success',
data: {
sessionId: 'session-1',
status: 'COMPLETED',
},
}), {headers: {'Content-Type': 'application/json'}});
}
throw new Error(`unexpected fetch ${request.method} ${request.url}`);
};
const uploadPromise = uploadFileToNetdiskViaSession(
new File([new Blob(['123456789'])], 'movie.mp4', {type: 'video/mp4'}),
'/docs',
);
await waitFor(() => FakeXMLHttpRequest.instances.length === 1);
assert.equal(FakeXMLHttpRequest.instances.length, 1);
const uploadRequest = FakeXMLHttpRequest.instances[0];
assert.equal(uploadRequest.method, 'PUT');
assert.equal(uploadRequest.url, 'https://upload.example.com/single');
uploadRequest.respond('', 200, 'text/plain');
const result = await uploadPromise;
assert.deepEqual(result, {
sessionId: 'session-1',
filename: 'movie.mp4',
path: '/docs',
});
assert.deepEqual(calls, [
'POST http://localhost/api/v2/files/upload-sessions',
'GET http://localhost/api/v2/files/upload-sessions/session-1/prepare',
'POST http://localhost/api/v2/files/upload-sessions/session-1/complete',
]);
});
test('uploadFileToNetdiskViaSession completes a proxy upload session', async () => {
const calls: string[] = [];
globalThis.fetch = async (input, init) => {
const request = input instanceof Request ? input : new Request(new URL(String(input), 'http://localhost'), init);
calls.push(`${request.method} ${request.url}`);
if (request.url.endsWith('/api/v2/files/upload-sessions')) {
return new Response(JSON.stringify({
code: 0,
msg: 'success',
data: {
sessionId: 'session-2',
objectKey: 'blobs/session-2',
directUpload: false,
multipartUpload: false,
uploadMode: 'PROXY',
path: '/docs',
filename: 'notes.txt',
contentType: 'text/plain',
size: 5,
storagePolicyId: 1,
status: 'CREATED',
chunkSize: 8388608,
chunkCount: 1,
expiresAt: '2026-04-09T00:00:00',
createdAt: '2026-04-09T00:00:00',
updatedAt: '2026-04-09T00:00:00',
strategy: {
prepareUrl: null,
proxyContentUrl: '/api/v2/files/upload-sessions/session-2/content',
partPrepareUrlTemplate: null,
partRecordUrlTemplate: null,
completeUrl: '/api/v2/files/upload-sessions/session-2/complete',
proxyFormField: 'file',
},
},
}), {headers: {'Content-Type': 'application/json'}});
}
if (request.url.endsWith('/api/v2/files/upload-sessions/session-2/complete')) {
return new Response(JSON.stringify({
code: 0,
msg: 'success',
data: {
sessionId: 'session-2',
status: 'COMPLETED',
},
}), {headers: {'Content-Type': 'application/json'}});
}
throw new Error(`unexpected fetch ${request.method} ${request.url}`);
};
const uploadPromise = uploadFileToNetdiskViaSession(
new File([new Blob(['hello'])], 'notes.txt', {type: 'text/plain'}),
'/docs',
);
await waitFor(() => FakeXMLHttpRequest.instances.length === 1);
assert.equal(FakeXMLHttpRequest.instances.length, 1);
const uploadRequest = FakeXMLHttpRequest.instances[0];
assert.equal(uploadRequest.method, 'POST');
assert.equal(uploadRequest.url, '/api/v2/files/upload-sessions/session-2/content');
uploadRequest.respond({
code: 0,
msg: 'success',
data: {
sessionId: 'session-2',
status: 'UPLOADING',
},
});
const result = await uploadPromise;
assert.deepEqual(result, {
sessionId: 'session-2',
filename: 'notes.txt',
path: '/docs',
});
assert.deepEqual(calls, [
'POST http://localhost/api/v2/files/upload-sessions',
'POST http://localhost/api/v2/files/upload-sessions/session-2/complete',
]);
});
test('uploadFileToNetdiskViaSession completes a multipart upload session', async () => {
const calls: string[] = [];
globalThis.fetch = async (input, init) => {
const request = input instanceof Request ? input : new Request(new URL(String(input), 'http://localhost'), init);
calls.push(`${request.method} ${request.url}`);
if (request.url.endsWith('/api/v2/files/upload-sessions')) {
return new Response(JSON.stringify({
code: 0,
msg: 'success',
data: {
sessionId: 'session-3',
objectKey: 'blobs/session-3',
directUpload: true,
multipartUpload: true,
uploadMode: 'DIRECT_MULTIPART',
path: '/docs',
filename: 'archive.zip',
contentType: 'application/zip',
size: 10,
storagePolicyId: 1,
status: 'CREATED',
chunkSize: 5,
chunkCount: 2,
expiresAt: '2026-04-09T00:00:00',
createdAt: '2026-04-09T00:00:00',
updatedAt: '2026-04-09T00:00:00',
strategy: {
prepareUrl: null,
proxyContentUrl: null,
partPrepareUrlTemplate: '/api/v2/files/upload-sessions/session-3/parts/{partIndex}/prepare',
partRecordUrlTemplate: '/api/v2/files/upload-sessions/session-3/parts/{partIndex}',
completeUrl: '/api/v2/files/upload-sessions/session-3/complete',
proxyFormField: null,
},
},
}), {headers: {'Content-Type': 'application/json'}});
}
if (request.url.endsWith('/api/v2/files/upload-sessions/session-3/parts/0/prepare')) {
return new Response(JSON.stringify({
code: 0,
msg: 'success',
data: {
direct: true,
uploadUrl: 'https://upload.example.com/part-1',
method: 'PUT',
headers: {'Content-Type': 'application/zip'},
storageName: 'blobs/session-3',
},
}), {headers: {'Content-Type': 'application/json'}});
}
if (request.url.endsWith('/api/v2/files/upload-sessions/session-3/parts/1/prepare')) {
return new Response(JSON.stringify({
code: 0,
msg: 'success',
data: {
direct: true,
uploadUrl: 'https://upload.example.com/part-2',
method: 'PUT',
headers: {'Content-Type': 'application/zip'},
storageName: 'blobs/session-3',
},
}), {headers: {'Content-Type': 'application/json'}});
}
if (request.url.endsWith('/api/v2/files/upload-sessions/session-3/parts/0')
|| request.url.endsWith('/api/v2/files/upload-sessions/session-3/parts/1')) {
return new Response(JSON.stringify({
code: 0,
msg: 'success',
data: {
sessionId: 'session-3',
status: 'UPLOADING',
},
}), {headers: {'Content-Type': 'application/json'}});
}
if (request.url.endsWith('/api/v2/files/upload-sessions/session-3/complete')) {
return new Response(JSON.stringify({
code: 0,
msg: 'success',
data: {
sessionId: 'session-3',
status: 'COMPLETED',
},
}), {headers: {'Content-Type': 'application/json'}});
}
throw new Error(`unexpected fetch ${request.method} ${request.url}`);
};
const uploadPromise = uploadFileToNetdiskViaSession(
new File([new Blob(['abcdefghij'])], 'archive.zip', {type: 'application/zip'}),
'/docs',
);
await waitFor(() => FakeXMLHttpRequest.instances.length === 1);
assert.equal(FakeXMLHttpRequest.instances.length, 1);
FakeXMLHttpRequest.instances[0].responseHeaders.set('etag', '"part-1"');
FakeXMLHttpRequest.instances[0].respond('', 200, 'text/plain');
await waitFor(() => FakeXMLHttpRequest.instances.length === 2);
assert.equal(FakeXMLHttpRequest.instances.length, 2);
FakeXMLHttpRequest.instances[1].responseHeaders.set('etag', '"part-2"');
FakeXMLHttpRequest.instances[1].respond('', 200, 'text/plain');
const result = await uploadPromise;
assert.deepEqual(result, {
sessionId: 'session-3',
filename: 'archive.zip',
path: '/docs',
});
assert.deepEqual(calls, [
'POST http://localhost/api/v2/files/upload-sessions',
'GET http://localhost/api/v2/files/upload-sessions/session-3/parts/0/prepare',
'PUT http://localhost/api/v2/files/upload-sessions/session-3/parts/0',
'GET http://localhost/api/v2/files/upload-sessions/session-3/parts/1/prepare',
'PUT http://localhost/api/v2/files/upload-sessions/session-3/parts/1',
'POST http://localhost/api/v2/files/upload-sessions/session-3/complete',
]);
});

View File

@@ -0,0 +1,220 @@
import { apiBinaryUploadRequest, apiUploadRequest, apiV2Request, ApiError } from './api';
import type {
PreparedUploadResponse,
UploadSessionResponse,
UploadSessionStrategy,
} from './types';
export interface UploadFileToNetdiskOptions {
onProgress?: (progress: {loaded: number; total: number}) => void;
signal?: AbortSignal;
}
export interface UploadedNetdiskFileRef {
sessionId: string;
filename: string;
path: string;
}
interface CreateUploadSessionRequest {
path: string;
filename: string;
contentType: string | null;
size: number;
}
interface UploadSessionPartRecordRequest {
etag: string;
size: number;
}
function replacePartIndex(template: string, partIndex: number) {
return template.replace('{partIndex}', String(partIndex));
}
function toInternalApiPath(path: string) {
return path.startsWith('/api/') ? path.slice('/api'.length) : path;
}
function getRequiredStrategyValue(value: string | null | undefined, key: keyof UploadSessionStrategy) {
if (!value) {
throw new Error(`上传会话缺少 ${key},无法继续上传`);
}
return value;
}
export function createUploadSession(request: CreateUploadSessionRequest) {
return apiV2Request<UploadSessionResponse>('/files/upload-sessions', {
method: 'POST',
body: request,
});
}
export function getUploadSession(sessionId: string) {
return apiV2Request<UploadSessionResponse>(`/files/upload-sessions/${sessionId}`);
}
export function cancelUploadSession(sessionId: string) {
return apiV2Request<UploadSessionResponse>(`/files/upload-sessions/${sessionId}`, {
method: 'DELETE',
});
}
export function prepareSingleUploadSession(sessionId: string) {
return apiV2Request<PreparedUploadResponse>(`/files/upload-sessions/${sessionId}/prepare`);
}
export function uploadUploadSessionContent(
sessionId: string,
file: File,
options: UploadFileToNetdiskOptions = {},
) {
const formData = new FormData();
formData.append('file', file);
return apiUploadRequest<UploadSessionResponse>(`/v2/files/upload-sessions/${sessionId}/content`, {
body: formData,
onProgress: options.onProgress,
signal: options.signal,
});
}
export function prepareUploadSessionPart(sessionId: string, partIndex: number) {
return apiV2Request<PreparedUploadResponse>(`/files/upload-sessions/${sessionId}/parts/${partIndex}/prepare`);
}
export function recordUploadSessionPart(
sessionId: string,
partIndex: number,
request: UploadSessionPartRecordRequest,
) {
return apiV2Request<UploadSessionResponse>(`/files/upload-sessions/${sessionId}/parts/${partIndex}`, {
method: 'PUT',
body: request,
});
}
export function completeUploadSession(sessionId: string) {
return apiV2Request<UploadSessionResponse>(`/files/upload-sessions/${sessionId}/complete`, {
method: 'POST',
});
}
async function runProxyUpload(session: UploadSessionResponse, file: File, options: UploadFileToNetdiskOptions) {
const proxyFormField = getRequiredStrategyValue(session.strategy.proxyFormField, 'proxyFormField');
const formData = new FormData();
formData.append(proxyFormField, file);
await apiUploadRequest<UploadSessionResponse>(
toInternalApiPath(getRequiredStrategyValue(session.strategy.proxyContentUrl, 'proxyContentUrl')),
{
body: formData,
onProgress: options.onProgress,
signal: options.signal,
},
);
}
async function runDirectSingleUpload(session: UploadSessionResponse, file: File, options: UploadFileToNetdiskOptions) {
const prepared = await prepareSingleUploadSession(session.sessionId);
await apiBinaryUploadRequest(prepared.uploadUrl, {
method: prepared.method,
headers: prepared.headers,
body: file,
onProgress: options.onProgress,
signal: options.signal,
});
}
async function runMultipartUpload(session: UploadSessionResponse, file: File, options: UploadFileToNetdiskOptions) {
const partPrepareUrlTemplate = getRequiredStrategyValue(session.strategy.partPrepareUrlTemplate, 'partPrepareUrlTemplate');
const partRecordUrlTemplate = getRequiredStrategyValue(session.strategy.partRecordUrlTemplate, 'partRecordUrlTemplate');
let uploadedBytes = 0;
for (let partIndex = 0; partIndex < session.chunkCount; partIndex += 1) {
const partStart = partIndex * session.chunkSize;
const partEnd = Math.min(file.size, partStart + session.chunkSize);
const partBlob = file.slice(partStart, partEnd);
const prepared = await apiV2Request<PreparedUploadResponse>(
toInternalApiPath(replacePartIndex(partPrepareUrlTemplate, partIndex)).replace('/v2', ''),
);
const uploadResult = await apiBinaryUploadRequest(prepared.uploadUrl, {
method: prepared.method,
headers: prepared.headers,
body: partBlob,
responseHeaders: ['etag'],
signal: options.signal,
onProgress: options.onProgress
? ({loaded, total}) => {
options.onProgress?.({
loaded: uploadedBytes + loaded,
total: Math.max(file.size, uploadedBytes + total),
});
}
: undefined,
});
const etag = uploadResult.headers.etag;
if (!etag) {
throw new Error('分片上传成功但未返回 etag无法完成上传');
}
await apiV2Request<UploadSessionResponse>(
toInternalApiPath(replacePartIndex(partRecordUrlTemplate, partIndex)).replace('/v2', ''),
{
method: 'PUT',
body: {
etag,
size: partBlob.size,
},
},
);
uploadedBytes += partBlob.size;
options.onProgress?.({
loaded: uploadedBytes,
total: file.size,
});
}
}
export async function uploadFileToNetdiskViaSession(
file: File,
path: string,
options: UploadFileToNetdiskOptions = {},
): Promise<UploadedNetdiskFileRef> {
const session = await createUploadSession({
path,
filename: file.name,
contentType: file.type || null,
size: file.size,
});
let shouldCancelSession = true;
try {
switch (session.uploadMode) {
case 'PROXY':
await runProxyUpload(session, file, options);
break;
case 'DIRECT_SINGLE':
await runDirectSingleUpload(session, file, options);
break;
case 'DIRECT_MULTIPART':
await runMultipartUpload(session, file, options);
break;
default:
throw new Error(`不支持的上传模式:${String(session.uploadMode)}`);
}
await completeUploadSession(session.sessionId);
shouldCancelSession = false;
return {
sessionId: session.sessionId,
filename: file.name,
path,
};
} catch (error) {
if (shouldCancelSession && !(error instanceof ApiError && error.message === '上传已取消')) {
await cancelUploadSession(session.sessionId).catch(() => undefined);
}
if (shouldCancelSession && error instanceof ApiError && error.message === '上传已取消') {
await cancelUploadSession(session.sessionId).catch(() => undefined);
}
throw error;
}
}

View File

@@ -0,0 +1,41 @@
import React, { ReactNode } from 'react';
import { AnimatePresence, motion } from 'motion/react';
import { cn } from '@/src/lib/utils';
export interface ResponsiveSheetProps {
isOpen: boolean;
onClose: () => void;
children: ReactNode;
className?: string;
}
export function ResponsiveSheet({ isOpen, onClose, children, className }: ResponsiveSheetProps) {
return (
<AnimatePresence>
{isOpen && (
<div className="fixed inset-0 z-50 flex items-end">
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={onClose}
/>
<motion.div
initial={{ y: '100%' }}
animate={{ y: 0 }}
exit={{ y: '100%' }}
transition={{ type: 'spring', damping: 25, stiffness: 200 }}
className={cn(
'relative w-full max-h-[90vh] overflow-y-auto bg-[#0f172a] rounded-t-3xl border-t border-white/10 pt-4 pb-8 px-4 flex flex-col z-10 glass-panel',
className
)}
>
<div className="w-12 h-1 shrink-0 bg-white/20 rounded-full mx-auto mb-4" />
{children}
</motion.div>
</div>
)}
</AnimatePresence>
);
}

View File

@@ -0,0 +1,26 @@
import React from 'react';
import { ShieldAlert } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { Button } from '@/src/components/ui/button';
export default function MobileAdminUnavailable() {
const navigate = useNavigate();
return (
<div className="flex flex-col h-full bg-[#07101D] text-slate-300 min-h-[100dvh] pb-24 items-center justify-center p-6">
<div className="w-20 h-20 rounded-full bg-blue-500/10 flex items-center justify-center mb-6 shadow-inner border border-blue-500/20">
<ShieldAlert className="w-10 h-10 text-blue-400" />
</div>
<h1 className="text-xl font-bold text-white mb-3 text-center"></h1>
<p className="text-sm text-slate-400 text-center max-w-sm mb-8 leading-relaxed">
使访
</p>
<Button
className="bg-[#336EFF] hover:bg-blue-600 text-white rounded-xl shadow-lg border-none px-8 py-6"
onClick={() => navigate('/overview')}
>
</Button>
</div>
);
}

View File

@@ -1,650 +1 @@
import React, { useEffect, useRef, useState } from 'react'; export { MobileFilesPage as default, getMobileFilesLayoutClassNames } from './files/MobileFilesPage';
import { AnimatePresence, motion } from 'motion/react';
import { useNavigate } from 'react-router-dom';
import {
ChevronRight,
Folder,
Download,
Upload,
Plus,
MoreVertical,
Copy,
Share2,
X,
Edit2,
Trash2,
FolderPlus,
ChevronLeft,
RotateCcw,
} from 'lucide-react';
import { NetdiskPathPickerModal } from '@/src/components/ui/NetdiskPathPickerModal';
import { Button } from '@/src/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/src/components/ui/card';
import { FileTypeIcon, getFileTypeTheme } from '@/src/components/ui/FileTypeIcon';
import { Input } from '@/src/components/ui/input';
import { ApiError, apiBinaryUploadRequest, apiDownload, apiRequest, apiUploadRequest } from '@/src/lib/api';
import { copyFileToNetdiskPath } from '@/src/lib/file-copy';
import { moveFileToNetdiskPath } from '@/src/lib/file-move';
import { resolveStoredFileType, type FileTypeKind } from '@/src/lib/file-type';
import { readCachedValue, removeCachedValue, writeCachedValue } from '@/src/lib/cache';
import { createFileShareLink, getCurrentFileShareUrl } from '@/src/lib/file-share';
import { subscribeFileEvents } from '@/src/lib/file-events';
import { ellipsizeFileName } from '@/src/lib/file-name';
import { getFilesLastPathCacheKey, getFilesListCacheKey } from '@/src/lib/page-cache';
import type { DownloadUrlResponse, FileMetadata, InitiateUploadResponse, PageResponse } from '@/src/lib/types';
import { cn } from '@/src/lib/utils';
// Imports directly from the original pages directories
import {
buildUploadProgressSnapshot,
cancelUploadTask,
createUploadMeasurement,
createUploadTasks,
completeUploadTask,
failUploadTask,
prepareUploadTaskForCompletion,
prepareFolderUploadEntries,
prepareUploadFile,
shouldUploadEntriesSequentially,
type PendingUploadEntry,
type UploadMeasurement,
type UploadTask,
} from '@/src/pages/files-upload';
import {
registerFilesUploadTaskCanceler,
replaceFilesUploads,
setFilesUploadPanelOpen,
unregisterFilesUploadTaskCanceler,
updateFilesUploadTask,
} from '@/src/pages/files-upload-store';
import {
clearSelectionIfDeleted,
getNextAvailableName,
getActionErrorMessage,
removeUiFile,
replaceUiFile,
syncSelectedFile,
} from '@/src/pages/files-state';
import {
toDirectoryPath,
} from '@/src/pages/files-tree';
import { RECYCLE_BIN_RETENTION_DAYS, RECYCLE_BIN_ROUTE } from '@/src/pages/recycle-bin-state';
function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function toBackendPath(pathParts: string[]) {
return toDirectoryPath(pathParts);
}
function formatFileSize(size: number) {
if (size <= 0) return '—';
const units = ['B', 'KB', 'MB', 'GB'];
const index = Math.min(Math.floor(Math.log(size) / Math.log(1024)), units.length - 1);
const value = size / 1024 ** index;
return `${value.toFixed(value >= 10 || index === 0 ? 0 : 1)} ${units[index]}`;
}
function formatDateTime(value: string) {
const date = new Date(value);
return `${date.getMonth() + 1}-${date.getDate()} ${date.getHours().toString().padStart(2, '0')}:${date.getMinutes().toString().padStart(2, '0')}`;
}
function toUiFile(file: FileMetadata) {
const resolvedType = resolveStoredFileType({
filename: file.filename,
contentType: file.contentType,
directory: file.directory,
});
return {
id: file.id,
name: file.filename,
type: resolvedType.kind,
typeLabel: resolvedType.label,
size: file.directory ? '—' : formatFileSize(file.size),
originalSize: file.directory ? 0 : file.size,
modified: formatDateTime(file.createdAt),
};
}
interface UiFile {
id: FileMetadata['id'];
modified: string;
name: string;
size: string;
originalSize: number;
type: FileTypeKind;
typeLabel: string;
}
type NetdiskTargetAction = 'move' | 'copy';
export function getMobileFilesLayoutClassNames() {
return {
root: 'relative flex min-h-full flex-col text-white bg-transparent',
toolbar: 'sticky top-0 z-30 flex-none px-4 py-2',
toolbarInner: 'glass-panel flex items-center gap-3 rounded-[22px] border border-white/10 bg-[#0f172a]/72 px-3.5 py-2.5 shadow-md backdrop-blur-2xl',
list: 'relative z-10 flex-1 px-3 pt-2 pb-4 space-y-1.5',
};
}
export default function MobileFiles() {
const navigate = useNavigate();
const initialPath = readCachedValue<string[]>(getFilesLastPathCacheKey()) ?? [];
const initialCachedFiles = readCachedValue<FileMetadata[]>(getFilesListCacheKey(toBackendPath(initialPath))) ?? [];
const fileInputRef = useRef<HTMLInputElement | null>(null);
const directoryInputRef = useRef<HTMLInputElement | null>(null);
const uploadMeasurementsRef = useRef(new Map<string, UploadMeasurement>());
const [currentPath, setCurrentPath] = useState<string[]>(initialPath);
const currentPathRef = useRef(currentPath);
const [currentFiles, setCurrentFiles] = useState<UiFile[]>(initialCachedFiles.map(toUiFile));
const [selectedFile, setSelectedFile] = useState<UiFile | null>(null);
// Modals inside mobile action sheet
const [actionSheetOpen, setActionSheetOpen] = useState(false);
const [renameModalOpen, setRenameModalOpen] = useState(false);
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
const [fileToRename, setFileToRename] = useState<UiFile | null>(null);
const [fileToDelete, setFileToDelete] = useState<UiFile | null>(null);
const [targetActionFile, setTargetActionFile] = useState<UiFile | null>(null);
const [targetAction, setTargetAction] = useState<NetdiskTargetAction | null>(null);
const [newFileName, setNewFileName] = useState('');
const [renameError, setRenameError] = useState('');
const [isRenaming, setIsRenaming] = useState(false);
const [shareStatus, setShareStatus] = useState('');
// Floating Action Button
const [fabOpen, setFabOpen] = useState(false);
const layoutClassNames = getMobileFilesLayoutClassNames();
const loadCurrentPath = async (pathParts: string[]) => {
const response = await apiRequest<PageResponse<FileMetadata>>(
`/files/list?path=${encodeURIComponent(toBackendPath(pathParts))}&page=0&size=100`
);
writeCachedValue(getFilesListCacheKey(toBackendPath(pathParts)), response.items);
writeCachedValue(getFilesLastPathCacheKey(), pathParts);
setCurrentFiles(response.items.map(toUiFile));
};
useEffect(() => {
currentPathRef.current = currentPath;
const cachedFiles = readCachedValue<FileMetadata[]>(getFilesListCacheKey(toBackendPath(currentPath)));
writeCachedValue(getFilesLastPathCacheKey(), currentPath);
if (cachedFiles) {
setCurrentFiles(cachedFiles.map(toUiFile));
}
loadCurrentPath(currentPath).catch(() => {
if (!cachedFiles) setCurrentFiles([]);
});
}, [currentPath]);
useEffect(() => {
if (directoryInputRef.current) {
directoryInputRef.current.setAttribute('webkitdirectory', '');
directoryInputRef.current.setAttribute('directory', '');
}
}, []);
useEffect(() => {
const subscription = subscribeFileEvents({
path: toBackendPath(currentPath),
onFileEvent: () => {
const activePath = currentPathRef.current;
removeCachedValue(getFilesListCacheKey(toBackendPath(activePath)));
loadCurrentPath(activePath).catch(() => undefined);
},
onError: () => undefined,
});
return () => {
subscription.close();
};
}, [currentPath]);
const handleBreadcrumbClick = (index: number) => {
setCurrentPath(currentPath.slice(0, index + 1));
};
const handleBackClick = () => {
if (currentPath.length > 0) {
setCurrentPath(currentPath.slice(0, -1));
}
};
const handleFolderClick = (file: UiFile) => {
if (file.type === 'folder') {
setCurrentPath([...currentPath, file.name]);
} else {
openActionSheet(file);
}
};
const openActionSheet = (file: UiFile) => {
setSelectedFile(file);
setActionSheetOpen(true);
setShareStatus('');
};
const closeActionSheet = () => {
setActionSheetOpen(false);
};
const openRenameModal = (file: UiFile) => {
setFileToRename(file);
setNewFileName(file.name);
setRenameError('');
setRenameModalOpen(true);
closeActionSheet();
};
const openDeleteModal = (file: UiFile) => {
setFileToDelete(file);
setDeleteModalOpen(true);
closeActionSheet();
};
const openTargetActionModal = (file: UiFile, action: NetdiskTargetAction) => {
setTargetAction(action);
setTargetActionFile(file);
closeActionSheet();
};
// Upload Logic (Identical to reference)
const runUploadEntries = async (entries: PendingUploadEntry[]) => {
if (entries.length === 0) return;
setFilesUploadPanelOpen(true);
uploadMeasurementsRef.current.clear();
const batchTasks = createUploadTasks(entries);
replaceFilesUploads(batchTasks);
const runSingleUpload = async ({file: uploadFile, pathParts: uploadPathParts}: PendingUploadEntry, uploadTask: UploadTask) => {
const uploadPath = toBackendPath(uploadPathParts);
const uploadAbortController = new AbortController();
registerFilesUploadTaskCanceler(uploadTask.id, () => uploadAbortController.abort());
uploadMeasurementsRef.current.set(uploadTask.id, createUploadMeasurement(Date.now()));
try {
const updateProgress = ({loaded, total}: {loaded: number; total: number}) => {
const snapshot = buildUploadProgressSnapshot({
loaded, total, now: Date.now(), previous: uploadMeasurementsRef.current.get(uploadTask.id),
});
uploadMeasurementsRef.current.set(uploadTask.id, snapshot.measurement);
updateFilesUploadTask(uploadTask.id, (task) => ({ ...task, progress: snapshot.progress, speed: snapshot.speed }));
};
let initiated: InitiateUploadResponse | null = null;
try {
initiated = await apiRequest<InitiateUploadResponse>('/files/upload/initiate', {
method: 'POST', body: { path: uploadPath, filename: uploadFile.name, contentType: uploadFile.type || null, size: uploadFile.size },
});
} catch (e) { if (!(e instanceof ApiError && e.status === 404)) throw e; }
let uploadedFile: FileMetadata;
if (initiated?.direct) {
try {
await apiBinaryUploadRequest(initiated.uploadUrl, { method: initiated.method, headers: initiated.headers, body: uploadFile, onProgress: updateProgress, signal: uploadAbortController.signal });
uploadedFile = await apiRequest<FileMetadata>('/files/upload/complete', { method: 'POST', signal: uploadAbortController.signal, body: { path: uploadPath, filename: uploadFile.name, storageName: initiated.storageName, contentType: uploadFile.type || null, size: uploadFile.size } });
} catch (error) {
if (!(error instanceof ApiError && error.isNetworkError)) throw error;
const formData = new FormData(); formData.append('file', uploadFile);
uploadedFile = await apiUploadRequest<FileMetadata>(`/files/upload?path=${encodeURIComponent(uploadPath)}`, { body: formData, onProgress: updateProgress, signal: uploadAbortController.signal });
}
} else if (initiated) {
const formData = new FormData(); formData.append('file', uploadFile);
uploadedFile = await apiUploadRequest<FileMetadata>(initiated.uploadUrl, { body: formData, method: initiated.method, headers: initiated.headers, onProgress: updateProgress, signal: uploadAbortController.signal });
} else {
const formData = new FormData(); formData.append('file', uploadFile);
uploadedFile = await apiUploadRequest<FileMetadata>(`/files/upload?path=${encodeURIComponent(uploadPath)}`, { body: formData, onProgress: updateProgress, signal: uploadAbortController.signal });
}
updateFilesUploadTask(uploadTask.id, (task) => prepareUploadTaskForCompletion(task));
await sleep(120);
updateFilesUploadTask(uploadTask.id, (task) => completeUploadTask(task));
return uploadedFile;
} catch (error) {
if (uploadAbortController.signal.aborted) { updateFilesUploadTask(uploadTask.id, (task) => cancelUploadTask(task)); return null; }
updateFilesUploadTask(uploadTask.id, (task) => failUploadTask(task, error instanceof Error && error.message ? error.message : '上传失败'));
return null;
} finally {
uploadMeasurementsRef.current.delete(uploadTask.id);
unregisterFilesUploadTaskCanceler(uploadTask.id);
}
};
if (shouldUploadEntriesSequentially(entries)) {
let previousPromise = Promise.resolve<Array<FileMetadata | null>>([]);
for (let i = 0; i < entries.length; i++) {
previousPromise = previousPromise.then(async (prev) => {
const current = await runSingleUpload(entries[i], batchTasks[i]);
return [...prev, current];
});
}
const results = await previousPromise;
if (results.some(Boolean)) await loadCurrentPath(currentPathRef.current).catch(() => {});
} else {
const results = await Promise.all(entries.map((entry, index) => runSingleUpload(entry, batchTasks[index])));
if (results.some(Boolean)) await loadCurrentPath(currentPathRef.current).catch(() => {});
}
};
const handleFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
setFabOpen(false);
const files = event.target.files ? (Array.from(event.target.files) as File[]) : [];
event.target.value = '';
if (files.length === 0) return;
const reservedNames = new Set<string>(currentFiles.map((file) => file.name));
const entries: PendingUploadEntry[] = files.map((file) => {
const preparedUpload = prepareUploadFile(file, reservedNames);
reservedNames.add(preparedUpload.file.name);
return { file: preparedUpload.file, pathParts: [...currentPath], source: 'file', noticeMessage: preparedUpload.noticeMessage };
});
await runUploadEntries(entries);
};
const handleFolderChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
setFabOpen(false);
const files = event.target.files ? (Array.from(event.target.files) as File[]) : [];
event.target.value = '';
if (files.length === 0) return;
const entries = prepareFolderUploadEntries(files, [...currentPath], currentFiles.map((file) => file.name));
await runUploadEntries(entries);
};
const handleCreateFolder = async () => {
setFabOpen(false);
const folderName = window.prompt('请输入新文件夹名称');
if (!folderName?.trim()) return;
const normalizedFolderName = folderName.trim();
const nextFolderName = getNextAvailableName(normalizedFolderName, new Set(currentFiles.filter(f => f.type === 'folder').map(f => f.name)));
if (nextFolderName !== normalizedFolderName) window.alert(`名称冲突,重命名为 ${nextFolderName}`);
const basePath = toBackendPath(currentPath).replace(/\/$/, '');
const fullPath = `${basePath}/${nextFolderName}` || '/';
await apiRequest('/files/mkdir', {
method: 'POST',
body: new URLSearchParams({ path: fullPath.startsWith('/') ? fullPath : `/${fullPath}` }),
headers: { 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8' },
});
await loadCurrentPath(currentPath);
};
const handleRename = async () => {
if (!fileToRename || !newFileName.trim() || isRenaming) return;
setIsRenaming(true); setRenameError('');
try {
const renamedFile = await apiRequest<FileMetadata>(`/files/${fileToRename.id}/rename`, {
method: 'PATCH', body: { filename: newFileName.trim() },
});
const nextUiFile = toUiFile(renamedFile);
setCurrentFiles((prev) => replaceUiFile(prev, nextUiFile));
setSelectedFile((prev) => syncSelectedFile(prev, nextUiFile));
setRenameModalOpen(false); setFileToRename(null); setNewFileName('');
await loadCurrentPath(currentPath).catch(() => {});
} catch (error) {
setRenameError(getActionErrorMessage(error, '重命名失败'));
} finally { setIsRenaming(false); }
};
const handleDelete = async () => {
if (!fileToDelete) return;
await apiRequest(`/files/${fileToDelete.id}`, { method: 'DELETE' });
setCurrentFiles((prev) => removeUiFile(prev, fileToDelete.id));
setSelectedFile((prev) => clearSelectionIfDeleted(prev, fileToDelete.id));
setDeleteModalOpen(false); setFileToDelete(null);
await loadCurrentPath(currentPath).catch(() => {});
};
const handleMoveToPath = async (path: string) => {
if (!targetActionFile || !targetAction) return;
if (targetAction === 'move') {
await moveFileToNetdiskPath(targetActionFile.id, path);
setSelectedFile((prev) => clearSelectionIfDeleted(prev, targetActionFile.id));
} else {
await copyFileToNetdiskPath(targetActionFile.id, path);
}
setTargetAction(null); setTargetActionFile(null);
await loadCurrentPath(currentPath).catch(() => {});
};
const handleDownload = async (targetFile: UiFile | null = selectedFile) => {
const actFile = targetFile || selectedFile;
if (!actFile) return;
if (actFile.type === 'folder') {
const response = await apiDownload(`/files/download/${actFile.id}`);
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url; link.download = `${actFile.name}.zip`; link.click();
window.URL.revokeObjectURL(url);
return;
}
try {
const response = await apiRequest<DownloadUrlResponse>(`/files/download/${actFile.id}/url`);
const link = document.createElement('a'); link.href = response.url; link.download = actFile.name; link.rel = 'noreferrer'; link.target = '_blank';
link.click(); return;
} catch (error) {
if (!(error instanceof ApiError && error.status === 404)) throw error;
}
const response = await apiDownload(`/files/download/${actFile.id}`);
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a'); link.href = url; link.download = actFile.name; link.click();
window.URL.revokeObjectURL(url);
};
const handleShare = async (targetFile: UiFile) => {
try {
const response = await createFileShareLink(targetFile.id);
const shareUrl = getCurrentFileShareUrl(response.token);
try {
await navigator.clipboard.writeText(shareUrl);
setShareStatus('链接已复制到剪贴板,快发送给朋友吧');
} catch {
setShareStatus(`可全选复制链接:${shareUrl}`);
}
} catch (error) {
setShareStatus(error instanceof Error ? error.message : '分享失败');
}
};
return (
<div className={layoutClassNames.root}>
<div className="pointer-events-none absolute inset-0 z-0">
<div className="absolute top-[-12%] left-[-24%] h-72 w-72 rounded-full bg-[#336EFF] opacity-20 mix-blend-screen blur-[100px] animate-blob" />
<div className="absolute top-[22%] right-[-20%] h-80 w-80 rounded-full bg-purple-600 opacity-20 mix-blend-screen blur-[100px] animate-blob animation-delay-2000" />
<div className="absolute bottom-[-18%] left-[8%] h-80 w-80 rounded-full bg-indigo-600 opacity-20 mix-blend-screen blur-[100px] animate-blob animation-delay-4000" />
</div>
<input type="file" multiple ref={fileInputRef} className="hidden" onChange={handleFileChange} />
<input type="file" ref={directoryInputRef} className="hidden" onChange={handleFolderChange} />
{/* Top Header - Path navigation */}
<div className={layoutClassNames.toolbar}>
<div className={layoutClassNames.toolbarInner}>
<div className="flex min-w-0 flex-1 flex-nowrap items-center text-sm overflow-x-auto custom-scrollbar whitespace-nowrap">
{currentPath.length > 0 && (
<button className="mr-3 p-1.5 rounded-full bg-white/5 text-slate-300 active:bg-white/10" onClick={handleBackClick}>
<ChevronLeft className="w-4 h-4" />
</button>
)}
<button className="text-slate-400 hover:text-white" onClick={() => handleBreadcrumbClick(-1)}></button>
{currentPath.map((pathItem, index) => (
<React.Fragment key={index}>
<ChevronRight className="w-3 h-3 mx-1 text-slate-600 shrink-0" />
<button onClick={() => handleBreadcrumbClick(index)} className={cn(index === currentPath.length - 1 ? 'text-white font-medium' : 'text-slate-400', 'shrink-0')}>{pathItem}</button>
</React.Fragment>
))}
</div>
<button
type="button"
onClick={() => navigate(RECYCLE_BIN_ROUTE)}
className="flex shrink-0 items-center gap-1.5 rounded-full border border-white/10 bg-white/5 px-3 py-1.5 text-xs text-slate-200"
>
<RotateCcw className="h-3.5 w-3.5" />
</button>
</div>
</div>
{/* File List */}
<div className={layoutClassNames.list}>
{currentFiles.length === 0 ? (
<div className="flex flex-col items-center justify-center h-48 text-slate-500">
<FolderPlus className="w-10 h-10 mb-3 opacity-20" />
<p className="text-sm"></p>
</div>
) : (
currentFiles.map((file) => (
<div key={file.id} className="glass-panel w-full rounded-xl p-3 flex flex-row items-center gap-3 active:bg-white/5 select-none" onClick={() => handleFolderClick(file)}>
<div className="shrink-0 p-1.5 rounded-xl bg-black/20 border border-white/5">
<FileTypeIcon type={file.type} size="md" />
</div>
<div className="flex-1 min-w-0 flex flex-col justify-center">
<span className="text-sm text-white truncate w-full block">{file.name}</span>
<div className="flex items-center text-[10px] text-slate-400 mt-0.5 gap-2">
<span className={cn('px-1.5 py-0.5 rounded text-[9px] font-medium', getFileTypeTheme(file.type).badgeClassName)}>{file.typeLabel}</span>
<span>{file.modified}</span>
{file.type !== 'folder' && <span>{file.size}</span>}
</div>
</div>
{file.type !== 'folder' && (
<button className="p-2 shrink-0 text-slate-400 hover:text-white" onClick={(e) => { e.stopPropagation(); openActionSheet(file); }}>
<MoreVertical className="w-5 h-5" />
</button>
)}
</div>
))
)}
</div>
{/* Floating Action Button (FAB) + Menu */}
<div className="fixed bottom-20 right-6 z-30 flex flex-col items-end gap-3 pointer-events-none">
<AnimatePresence>
{fabOpen && (
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: 20 }} className="flex flex-col gap-3 pointer-events-auto items-end mr-1">
<button onClick={() => { fileInputRef.current?.click(); setFabOpen(false); }} className="flex items-center gap-2 px-4 py-2.5 rounded-full bg-blue-500 text-white shadow-lg active:scale-95 text-sm font-medium">
<Upload className="w-4 h-4"/>
</button>
<button onClick={() => { directoryInputRef.current?.click(); setFabOpen(false); }} className="flex items-center gap-2 px-4 py-2.5 rounded-full bg-emerald-500 text-white shadow-lg active:scale-95 text-sm font-medium">
<FolderPlus className="w-4 h-4"/>
</button>
<button onClick={handleCreateFolder} className="flex items-center gap-2 px-4 py-2.5 rounded-full bg-purple-500 text-white shadow-lg active:scale-95 text-sm font-medium">
<Plus className="w-4 h-4"/>
</button>
</motion.div>
)}
</AnimatePresence>
<button onClick={() => setFabOpen(!fabOpen)} className={cn("pointer-events-auto flex items-center justify-center w-14 h-14 rounded-full shadow-2xl transition-transform active:scale-95", fabOpen ? "bg-[#0f172a] border border-white/10 rotate-45" : "bg-[#336EFF]")}>
<Plus className="w-6 h-6 text-white" />
</button>
</div>
{/* FAB Backdrop */}
<AnimatePresence>
{fabOpen && <motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} className="fixed inset-0 z-20 bg-black/40 backdrop-blur-sm" onClick={() => setFabOpen(false)} />}
</AnimatePresence>
{/* Action Sheet */}
<AnimatePresence>
{actionSheetOpen && selectedFile && (
<div className="fixed inset-0 z-50 flex items-end">
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={closeActionSheet} />
<motion.div initial={{ y: '100%' }} animate={{ y: 0 }} exit={{ y: '100%' }} transition={{ type: "spring", damping: 25, stiffness: 200 }} className="relative w-full bg-[#0f172a] rounded-t-3xl border-t border-white/10 pt-4 pb-8 px-4 flex flex-col z-10 glass-panel">
<div className="w-12 h-1 bg-white/20 rounded-full mx-auto mb-4" />
<div className="flex border-b border-white/10 pb-4 mb-4 gap-4 items-center px-2">
<FileTypeIcon type={selectedFile.type} size="md" />
<div className="min-w-0">
<p className="text-sm font-semibold truncate text-white">{selectedFile.name}</p>
<p className="text-xs text-slate-400 mt-1">{selectedFile.size} {selectedFile.modified}</p>
</div>
</div>
<div className="grid grid-cols-4 gap-2 mb-4 px-2">
<ActionButton icon={Download} label="下载" onClick={() => { handleDownload(); closeActionSheet(); }} color="text-amber-400" />
<ActionButton icon={Share2} label="分享" onClick={() => handleShare(selectedFile)} color="text-emerald-400" />
<ActionButton icon={Copy} label="复制" onClick={() => openTargetActionModal(selectedFile, 'copy')} color="text-blue-400" />
<ActionButton icon={Folder} label="移动" onClick={() => openTargetActionModal(selectedFile, 'move')} color="text-indigo-400" />
<ActionButton icon={Edit2} label="重命名" onClick={() => openRenameModal(selectedFile)} color="text-slate-300" />
<ActionButton icon={Trash2} label="删除" onClick={() => openDeleteModal(selectedFile)} color="text-red-400" />
</div>
{shareStatus && <div className="mx-2 mt-2 p-3 rounded-lg bg-emerald-500/10 border border-emerald-500/20 text-[10px] text-emerald-400 break-all">{shareStatus}</div>}
<Button variant="ghost" onClick={closeActionSheet} className="mt-4 text-slate-400 py-6 text-sm"></Button>
</motion.div>
</div>
)}
</AnimatePresence>
{/* Target Action Modal */}
{targetAction && (
<NetdiskPathPickerModal
isOpen
title={targetAction === 'move' ? '移动到' : '复制到'}
confirmLabel={targetAction === 'move' ? '移动至此' : '复制至此'}
onClose={() => setTargetAction(null)}
onConfirm={(path) => void handleMoveToPath(path)}
/>
)}
{/* Rename Modal */}
<AnimatePresence>
{renameModalOpen && (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4">
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={() => setRenameModalOpen(false)} />
<motion.div initial={{ opacity: 0, scale: 0.95 }} animate={{ opacity: 1, scale: 1 }} className="relative w-full max-w-sm glass-panel bg-[#0f172a] border border-white/10 rounded-2xl p-5 z-10 shadow-2xl">
<h3 className="text-lg font-bold text-white mb-4"></h3>
<Input value={newFileName} onChange={(e) => setNewFileName(e.target.value)} className="bg-black/20 text-white mb-2 h-12" placeholder="请输入新名称" />
{renameError && <p className="text-xs text-red-400 mb-4">{renameError}</p>}
<div className="flex gap-3 mt-6">
<Button variant="outline" className="flex-1 bg-white/5 border-white/10 text-white" onClick={() => setRenameModalOpen(false)}></Button>
<Button className="flex-1 bg-[#336EFF] hover:bg-[#2958cc] text-white" onClick={handleRename} disabled={isRenaming}>{isRenaming ? '保存中' : '保存'}</Button>
</div>
</motion.div>
</div>
)}
</AnimatePresence>
{/* Delete Modal */}
<AnimatePresence>
{deleteModalOpen && (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4">
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={() => setDeleteModalOpen(false)} />
<motion.div initial={{ opacity: 0, scale: 0.95 }} animate={{ opacity: 1, scale: 1 }} className="relative w-full max-w-sm glass-panel bg-[#0f172a] border border-white/10 rounded-2xl p-5 z-10 shadow-2xl">
<h3 className="text-lg font-bold text-white mb-2 flex items-center gap-2"><Trash2 className="text-red-400 w-5 h-5"/></h3>
<p className="text-sm text-slate-300 mb-6 mt-3"> <span className="text-white font-medium break-all">{fileToDelete?.name}</span> {RECYCLE_BIN_RETENTION_DAYS} </p>
<div className="flex gap-3">
<Button variant="outline" className="flex-1 bg-white/5 border-white/10 text-white" onClick={() => setDeleteModalOpen(false)}></Button>
<Button className="flex-1 bg-red-500 text-white hover:bg-red-600" onClick={handleDelete}></Button>
</div>
</motion.div>
</div>
)}
</AnimatePresence>
</div>
);
}
function ActionButton({ icon: Icon, label, color, onClick }: any) {
return (
<div className="flex flex-col items-center gap-2 p-2 hover:bg-white/5 rounded-xl transition-colors active:bg-white/10" onClick={onClick}>
<div className={cn("p-3 rounded-full bg-black/20 border border-white/5 shadow-inner", color)}>
<Icon className="w-5 h-5" />
</div>
<span className="text-xs text-slate-300">{label}</span>
</div>
);
}

View File

@@ -0,0 +1,128 @@
import React, { useEffect, useState } from 'react';
import { RefreshCw, RotateCcw, Trash2, Folder, Clock3 } from 'lucide-react';
import { apiRequest } from '@/src/lib/api';
import type { PageResponse, RecycleBinItem } from '@/src/lib/types';
import { formatRecycleBinExpiresLabel, RECYCLE_BIN_RETENTION_DAYS } from '@/src/pages/recycle-bin-state';
import { Button } from '@/src/components/ui/button';
function formatFileSize(size: number) {
if (size <= 0) return '—';
const units = ['B', 'KB', 'MB', 'GB'];
const index = Math.min(Math.floor(Math.log(size) / Math.log(1024)), units.length - 1);
const value = size / 1024 ** index;
return `${value.toFixed(value >= 10 || index === 0 ? 0 : 1)} ${units[index]}`;
}
export default function MobileRecycleBin() {
const [items, setItems] = useState<RecycleBinItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [restoringId, setRestoringId] = useState<number | null>(null);
const loadRecycleBin = async () => {
setLoading(true);
setError('');
try {
const response = await apiRequest<PageResponse<RecycleBinItem>>('/files/recycle-bin?page=0&size=100');
setItems(response.items);
} catch (requestError) {
setError(requestError instanceof Error ? requestError.message : '回收站加载失败');
} finally {
setLoading(false);
}
};
useEffect(() => {
void loadRecycleBin();
}, []);
const handleRestore = async (itemId: number) => {
setRestoringId(itemId);
setError('');
try {
await apiRequest(`/files/recycle-bin/${itemId}/restore`, { method: 'POST' });
setItems((previous) => previous.filter((item) => item.id !== itemId));
} catch (requestError) {
setError(requestError instanceof Error ? requestError.message : '恢复失败');
} finally {
setRestoringId(null);
}
};
return (
<div className="flex flex-col h-full bg-[#07101D] text-slate-300 min-h-[100dvh] pb-24">
{/* Mobile Top Header */}
<header className="sticky top-0 z-40 flex items-center justify-between px-4 py-3 bg-[#07101D]/80 backdrop-blur-xl border-b border-white/5">
<h1 className="text-lg font-bold text-white tracking-tight flex items-center gap-2">
<Trash2 className="w-5 h-5 text-slate-400" />
</h1>
<button
onClick={() => void loadRecycleBin()}
className="p-2 -mr-2 rounded-full hover:bg-white/10 active:bg-white/20 transition-colors"
disabled={loading}
>
<RefreshCw className={`w-5 h-5 text-white ${loading ? 'animate-spin' : ''}`} />
</button>
</header>
<div className="px-4 py-4 space-y-4">
<div className="rounded-xl border border-white/5 bg-white/[0.02] p-4 text-xs text-slate-400 leading-relaxed shadow-sm">
{RECYCLE_BIN_RETENTION_DAYS}
</div>
{error && (
<div className="rounded-xl border border-red-500/20 bg-red-500/10 px-4 py-3 text-sm text-red-200">
{error}
</div>
)}
{loading ? (
<div className="py-20 text-center text-sm text-slate-500">...</div>
) : items.length === 0 ? (
<div className="py-20 flex flex-col items-center justify-center gap-4 opacity-60">
<div className="w-16 h-16 rounded-full bg-white/5 flex items-center justify-center">
<Trash2 className="w-8 h-8 text-slate-400" />
</div>
<p className="text-sm"></p>
</div>
) : (
<div className="space-y-3">
{items.map((item) => (
<div key={item.id} className="rounded-2xl border border-white/5 bg-black/20 p-4 transition-colors active:bg-white/5 relative">
<div className="flex items-start gap-3">
<div className="mt-0.5 rounded-xl border border-white/5 bg-white/[0.03] p-2 text-slate-300 shrink-0">
<Folder className="h-4 w-4" />
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-base font-medium text-white mb-1">{item.filename}</p>
<p className="truncate text-xs text-slate-500 mb-2">{item.path}</p>
<div className="flex flex-wrap items-center gap-2 text-[10px] text-slate-400">
<span>{item.directory ? '文件夹' : formatFileSize(item.size)}</span>
<span className="flex items-center gap-1 rounded bg-amber-500/10 px-1.5 py-0.5 text-amber-200/80">
<Clock3 className="h-3 w-3" />
{formatRecycleBinExpiresLabel(item.expiresAt)}
</span>
</div>
</div>
</div>
<div className="mt-4 pt-3 border-t border-white/5 flex justify-end">
<Button
size="sm"
variant="outline"
className="h-8 border-white/10 text-slate-300 shrink-0"
onClick={() => void handleRestore(item.id)}
disabled={restoringId === item.id}
>
<RotateCcw className="mr-1.5 h-3.5 w-3.5" />
{restoringId === item.id ? '恢复中' : '恢复'}
</Button>
</div>
</div>
))}
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,68 @@
import React from 'react';
import { Download, Share2, Copy, Folder, Edit2, Trash2 } from 'lucide-react';
import { Button } from '@/src/components/ui/button';
import { FileTypeIcon } from '@/src/components/ui/FileTypeIcon';
import { cn } from '@/src/lib/utils';
import type { UiFile } from '@/src/pages/files/file-types';
import { ResponsiveSheet } from '@/src/mobile-components/ResponsiveSheet';
export function ActionButton({ icon: Icon, label, color, onClick }: any) {
return (
<div className="flex flex-col items-center gap-2 p-2 hover:bg-white/5 rounded-xl transition-colors active:bg-white/10" onClick={onClick}>
<div className={cn("p-3 rounded-full bg-black/20 border border-white/5 shadow-inner", color)}>
<Icon className="w-5 h-5" />
</div>
<span className="text-xs text-slate-300">{label}</span>
</div>
);
}
export function MobileFileActionSheet({
isOpen,
selectedFile,
shareStatus,
onClose,
onDownload,
onShare,
onMove,
onCopy,
onRename,
onDelete,
}: {
isOpen: boolean;
selectedFile: UiFile | null;
shareStatus: string;
onClose: () => void;
onDownload: () => void;
onShare: (file: UiFile) => void;
onMove: (file: UiFile) => void;
onCopy: (file: UiFile) => void;
onRename: (file: UiFile) => void;
onDelete: (file: UiFile) => void;
}) {
return (
<ResponsiveSheet isOpen={isOpen && selectedFile !== null} onClose={onClose}>
{selectedFile && (
<>
<div className="flex border-b border-white/10 pb-4 mb-4 gap-4 items-center px-2">
<FileTypeIcon type={selectedFile.type} size="md" />
<div className="min-w-0">
<p className="text-sm font-semibold truncate text-white">{selectedFile.name}</p>
<p className="text-xs text-slate-400 mt-1">{selectedFile.size} {selectedFile.modified}</p>
</div>
</div>
<div className="grid grid-cols-4 gap-2 mb-4 px-2">
<ActionButton icon={Download} label="下载" onClick={() => { onDownload(); onClose(); }} color="text-amber-400" />
{selectedFile.type !== 'folder' && <ActionButton icon={Share2} label="分享" onClick={() => onShare(selectedFile)} color="text-emerald-400" />}
<ActionButton icon={Copy} label="复制" onClick={() => onCopy(selectedFile)} color="text-blue-400" />
<ActionButton icon={Folder} label="移动" onClick={() => onMove(selectedFile)} color="text-indigo-400" />
<ActionButton icon={Edit2} label="重命名" onClick={() => onRename(selectedFile)} color="text-slate-300" />
<ActionButton icon={Trash2} label="删除" onClick={() => onDelete(selectedFile)} color="text-red-400" />
</div>
{shareStatus && <div className="mx-2 mt-2 p-3 rounded-lg bg-emerald-500/10 border border-emerald-500/20 text-[10px] text-emerald-400 break-all">{shareStatus}</div>}
<Button variant="ghost" onClick={onClose} className="mt-4 text-slate-400 py-6 text-sm"></Button>
</>
)}
</ResponsiveSheet>
);
}

View File

@@ -0,0 +1,49 @@
import React from 'react';
import { FolderPlus, MoreVertical } from 'lucide-react';
import { FileTypeIcon, getFileTypeTheme } from '@/src/components/ui/FileTypeIcon';
import { cn } from '@/src/lib/utils';
import type { UiFile } from '@/src/pages/files/file-types';
export function MobileFilesList({
currentFiles,
onFolderClick,
onOpenActionSheet,
}: {
currentFiles: UiFile[];
onFolderClick: (file: UiFile) => void;
onOpenActionSheet: (file: UiFile) => void;
}) {
if (currentFiles.length === 0) {
return (
<div className="flex flex-col items-center justify-center h-48 text-slate-500">
<FolderPlus className="w-10 h-10 mb-3 opacity-20" />
<p className="text-sm"></p>
</div>
);
}
return (
<>
{currentFiles.map((file) => (
<div key={file.id} className="glass-panel w-full rounded-xl p-3 flex flex-row items-center gap-3 active:bg-white/5 select-none" onClick={() => onFolderClick(file)}>
<div className="shrink-0 p-1.5 rounded-xl bg-black/20 border border-white/5">
<FileTypeIcon type={file.type} size="md" />
</div>
<div className="flex-1 min-w-0 flex flex-col justify-center">
<span className="text-sm text-white truncate w-full block">{file.name}</span>
<div className="flex items-center text-[10px] text-slate-400 mt-0.5 gap-2">
<span className={cn('px-1.5 py-0.5 rounded text-[9px] font-medium', getFileTypeTheme(file.type).badgeClassName)}>{file.typeLabel}</span>
<span>{file.modified}</span>
{file.type !== 'folder' && <span>{file.size}</span>}
</div>
</div>
{file.type !== 'folder' && (
<button className="p-2 shrink-0 text-slate-400 hover:text-white" onClick={(e) => { e.stopPropagation(); onOpenActionSheet(file); }}>
<MoreVertical className="w-5 h-5" />
</button>
)}
</div>
))}
</>
);
}

View File

@@ -0,0 +1,449 @@
import React, { useEffect, useRef, useState } from 'react';
import { AnimatePresence, motion } from 'motion/react';
import { useNavigate } from 'react-router-dom';
import { ChevronRight, ChevronLeft, RotateCcw, Plus, Upload, FolderPlus, Trash2 } from 'lucide-react';
import { NetdiskPathPickerModal } from '@/src/components/ui/NetdiskPathPickerModal';
import { Button } from '@/src/components/ui/button';
import { Input } from '@/src/components/ui/input';
import { ApiError, apiDownload, apiRequest } from '@/src/lib/api';
import { copyFileToNetdiskPath } from '@/src/lib/file-copy';
import { moveFileToNetdiskPath } from '@/src/lib/file-move';
import { createFileShareLink, getCurrentFileShareUrl } from '@/src/lib/file-share';
import { uploadFileToNetdiskViaSession } from '@/src/lib/upload-session';
import type { DownloadUrlResponse, FileMetadata } from '@/src/lib/types';
import { cn } from '@/src/lib/utils';
import {
buildUploadProgressSnapshot,
cancelUploadTask,
createUploadMeasurement,
createUploadTasks,
completeUploadTask,
failUploadTask,
prepareUploadTaskForCompletion,
prepareFolderUploadEntries,
prepareUploadFile,
shouldUploadEntriesSequentially,
type PendingUploadEntry,
} from '@/src/pages/files-upload';
import {
registerFilesUploadTaskCanceler,
replaceFilesUploads,
setFilesUploadPanelOpen,
unregisterFilesUploadTaskCanceler,
updateFilesUploadTask,
} from '@/src/pages/files-upload-store';
import {
clearSelectionIfDeleted,
getNextAvailableName,
getActionErrorMessage,
removeUiFile,
replaceUiFile,
syncSelectedFile,
} from '@/src/pages/files-state';
import { RECYCLE_BIN_RETENTION_DAYS, RECYCLE_BIN_ROUTE } from '@/src/pages/recycle-bin-state';
import { useFilesDirectoryState, splitBackendPath, toBackendPath } from '@/src/pages/files/useFilesDirectoryState';
import { toUiFile, type UiFile } from '@/src/pages/files/file-types';
import { MobileFilesList } from './MobileFilesList';
import { MobileFileActionSheet } from './MobileFileActionSheet';
function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export function getMobileFilesLayoutClassNames() {
return {
root: 'relative flex min-h-full flex-col text-white bg-transparent',
toolbar: 'sticky top-0 z-30 flex-none px-4 py-2',
toolbarInner: 'glass-panel flex items-center gap-3 rounded-[22px] border border-white/10 bg-[#0f172a]/72 px-3.5 py-2.5 shadow-md backdrop-blur-2xl',
list: 'relative z-10 flex-1 px-3 pt-2 pb-4 space-y-1.5',
};
}
export function MobileFilesPage() {
const navigate = useNavigate();
const directoryState = useFilesDirectoryState();
const layoutClassNames = getMobileFilesLayoutClassNames();
const fileInputRef = useRef<HTMLInputElement | null>(null);
const directoryInputRef = useRef<HTMLInputElement | null>(null);
const uploadMeasurementsRef = useRef(new Map());
const [selectedFile, setSelectedFile] = useState<UiFile | null>(null);
const [actionSheetOpen, setActionSheetOpen] = useState(false);
const [renameModalOpen, setRenameModalOpen] = useState(false);
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
const [fileToRename, setFileToRename] = useState<UiFile | null>(null);
const [fileToDelete, setFileToDelete] = useState<UiFile | null>(null);
const [targetActionFile, setTargetActionFile] = useState<UiFile | null>(null);
const [targetAction, setTargetAction] = useState<'move' | 'copy' | null>(null);
const [newFileName, setNewFileName] = useState('');
const [renameError, setRenameError] = useState('');
const [isRenaming, setIsRenaming] = useState(false);
const [shareStatus, setShareStatus] = useState('');
const [fabOpen, setFabOpen] = useState(false);
useEffect(() => {
if (directoryInputRef.current) {
directoryInputRef.current.setAttribute('webkitdirectory', '');
directoryInputRef.current.setAttribute('directory', '');
}
}, []);
const handleBreadcrumbClick = (index: number) => {
directoryState.setCurrentPath(directoryState.currentPath.slice(0, index + 1));
};
const handleBackClick = () => {
if (directoryState.currentPath.length > 0) {
directoryState.setCurrentPath(directoryState.currentPath.slice(0, -1));
}
};
const handleFolderClick = (file: UiFile) => {
if (file.type === 'folder') {
directoryState.setCurrentPath([...directoryState.currentPath, file.name]);
} else {
openActionSheet(file);
}
};
const openActionSheet = (file: UiFile) => {
setSelectedFile(file);
setActionSheetOpen(true);
setShareStatus('');
};
const closeActionSheet = () => {
setActionSheetOpen(false);
};
const openRenameModal = (file: UiFile) => {
setFileToRename(file);
setNewFileName(file.name);
setRenameError('');
setRenameModalOpen(true);
closeActionSheet();
};
const openDeleteModal = (file: UiFile) => {
setFileToDelete(file);
setDeleteModalOpen(true);
closeActionSheet();
};
const openTargetActionModal = (file: UiFile, action: 'move' | 'copy') => {
setTargetAction(action);
setTargetActionFile(file);
closeActionSheet();
};
const runUploadEntries = async (entries: PendingUploadEntry[]) => {
if (entries.length === 0) return;
setFilesUploadPanelOpen(true);
uploadMeasurementsRef.current.clear();
const batchTasks = createUploadTasks(entries);
replaceFilesUploads(batchTasks);
const runSingleUpload = async ({file: uploadFile, pathParts: uploadPathParts}: PendingUploadEntry, uploadTask: any) => {
const uploadPath = toBackendPath(uploadPathParts);
const uploadAbortController = new AbortController();
registerFilesUploadTaskCanceler(uploadTask.id, () => uploadAbortController.abort());
uploadMeasurementsRef.current.set(uploadTask.id, createUploadMeasurement(Date.now()));
try {
const updateProgress = ({loaded, total}: {loaded: number; total: number}) => {
const snapshot = buildUploadProgressSnapshot({ loaded, total, now: Date.now(), previous: uploadMeasurementsRef.current.get(uploadTask.id) });
uploadMeasurementsRef.current.set(uploadTask.id, snapshot.measurement);
updateFilesUploadTask(uploadTask.id, (task) => ({ ...task, progress: snapshot.progress, speed: snapshot.speed }));
};
const uploadedFile = await uploadFileToNetdiskViaSession(uploadFile, uploadPath, {
onProgress: updateProgress,
signal: uploadAbortController.signal,
});
updateFilesUploadTask(uploadTask.id, (task) => prepareUploadTaskForCompletion(task));
await sleep(120);
updateFilesUploadTask(uploadTask.id, (task) => completeUploadTask(task));
return uploadedFile;
} catch (error) {
if (uploadAbortController.signal.aborted) { updateFilesUploadTask(uploadTask.id, (task) => cancelUploadTask(task)); return null; }
updateFilesUploadTask(uploadTask.id, (task) => failUploadTask(task, error instanceof Error && error.message ? error.message : '上传失败'));
return null;
} finally {
uploadMeasurementsRef.current.delete(uploadTask.id);
unregisterFilesUploadTaskCanceler(uploadTask.id);
}
};
if (shouldUploadEntriesSequentially(entries)) {
let previousPromise = Promise.resolve<Array<Awaited<ReturnType<typeof runSingleUpload>>>>([]);
for (let i = 0; i < entries.length; i++) {
previousPromise = previousPromise.then(async (prev) => {
const current = await runSingleUpload(entries[i], batchTasks[i]);
return [...prev, current];
});
}
const results = await previousPromise;
if (results.some(Boolean)) await directoryState.loadCurrentPath(directoryState.currentPath).catch(() => {});
} else {
const results = await Promise.all(entries.map((entry, index) => runSingleUpload(entry, batchTasks[index])));
if (results.some(Boolean)) await directoryState.loadCurrentPath(directoryState.currentPath).catch(() => {});
}
};
const handleFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
setFabOpen(false);
const files = event.target.files ? (Array.from(event.target.files) as File[]) : [];
event.target.value = '';
if (files.length === 0) return;
const reservedNames = new Set<string>(directoryState.currentFiles.map((file) => file.name));
const entries: PendingUploadEntry[] = files.map((file) => {
const preparedUpload = prepareUploadFile(file, reservedNames);
reservedNames.add(preparedUpload.file.name);
return { file: preparedUpload.file, pathParts: [...directoryState.currentPath], source: 'file', noticeMessage: preparedUpload.noticeMessage };
});
await runUploadEntries(entries);
};
const handleFolderChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
setFabOpen(false);
const files = event.target.files ? (Array.from(event.target.files) as File[]) : [];
event.target.value = '';
if (files.length === 0) return;
const entries = prepareFolderUploadEntries(files, [...directoryState.currentPath], directoryState.currentFiles.map((file) => file.name));
await runUploadEntries(entries);
};
const handleCreateFolder = async () => {
setFabOpen(false);
const folderName = window.prompt('请输入新文件夹名称');
if (!folderName?.trim()) return;
const normalizedFolderName = folderName.trim();
const nextFolderName = getNextAvailableName(normalizedFolderName, new Set(directoryState.currentFiles.filter(f => f.type === 'folder').map(f => f.name)));
if (nextFolderName !== normalizedFolderName) window.alert(`名称冲突,重命名为 ${nextFolderName}`);
const basePath = toBackendPath(directoryState.currentPath).replace(/\/$/, '');
const fullPath = `${basePath}/${nextFolderName}` || '/';
await apiRequest('/files/mkdir', {
method: 'POST',
body: new URLSearchParams({ path: fullPath.startsWith('/') ? fullPath : `/${fullPath}` }),
headers: { 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8' },
});
await directoryState.loadCurrentPath(directoryState.currentPath);
};
const handleRename = async () => {
if (!fileToRename || !newFileName.trim() || isRenaming) return;
setIsRenaming(true); setRenameError('');
try {
const renamedFile = await apiRequest<FileMetadata>(`/files/${fileToRename.id}/rename`, {
method: 'PATCH', body: { filename: newFileName.trim() },
});
const nextUiFile = toUiFile(renamedFile);
directoryState.setCurrentFiles((prev) => replaceUiFile(prev, nextUiFile));
setSelectedFile((prev) => syncSelectedFile(prev, nextUiFile));
setRenameModalOpen(false); setFileToRename(null); setNewFileName('');
await directoryState.loadCurrentPath(directoryState.currentPath).catch(() => {});
} catch (error) {
setRenameError(getActionErrorMessage(error, '重命名失败'));
} finally { setIsRenaming(false); }
};
const handleDelete = async () => {
if (!fileToDelete) return;
await apiRequest(`/files/${fileToDelete.id}`, { method: 'DELETE' });
directoryState.setCurrentFiles((prev) => removeUiFile(prev, fileToDelete.id));
setSelectedFile((prev) => clearSelectionIfDeleted(prev, fileToDelete.id));
setDeleteModalOpen(false); setFileToDelete(null);
await directoryState.loadCurrentPath(directoryState.currentPath).catch(() => {});
};
const handleMoveToPath = async (path: string) => {
if (!targetActionFile || !targetAction) return;
if (targetAction === 'move') {
await moveFileToNetdiskPath(targetActionFile.id, path);
setSelectedFile((prev) => clearSelectionIfDeleted(prev, targetActionFile.id));
} else {
await copyFileToNetdiskPath(targetActionFile.id, path);
}
setTargetAction(null); setTargetActionFile(null);
await directoryState.loadCurrentPath(directoryState.currentPath).catch(() => {});
};
const handleDownload = async (targetFile: UiFile | null = selectedFile) => {
const actFile = targetFile || selectedFile;
if (!actFile) return;
if (actFile.type === 'folder') {
const response = await apiDownload(`/files/download/${actFile.id}`);
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url; link.download = `${actFile.name}.zip`; link.click();
window.URL.revokeObjectURL(url);
return;
}
try {
const response = await apiRequest<DownloadUrlResponse>(`/files/download/${actFile.id}/url`);
const link = document.createElement('a'); link.href = response.url; link.download = actFile.name; link.rel = 'noreferrer'; link.target = '_blank';
link.click(); return;
} catch (error) {
if (!(error instanceof ApiError && error.status === 404)) throw error;
}
const response = await apiDownload(`/files/download/${actFile.id}`);
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a'); link.href = url; link.download = actFile.name; link.click();
window.URL.revokeObjectURL(url);
};
const handleShare = async (targetFile: UiFile) => {
try {
const response = await createFileShareLink(targetFile.id);
const shareUrl = getCurrentFileShareUrl(response.token);
try {
await navigator.clipboard.writeText(shareUrl);
setShareStatus('链接已复制到剪贴板,快发送给朋友吧');
} catch {
setShareStatus(`可全选复制链接:${shareUrl}`);
}
} catch (error) {
setShareStatus(error instanceof Error ? error.message : '分享失败');
}
};
return (
<div className={layoutClassNames.root}>
<div className="pointer-events-none absolute inset-0 z-0">
<div className="absolute top-[-12%] left-[-24%] h-72 w-72 rounded-full bg-[#336EFF] opacity-20 mix-blend-screen blur-[100px] animate-blob" />
<div className="absolute top-[22%] right-[-20%] h-80 w-80 rounded-full bg-purple-600 opacity-20 mix-blend-screen blur-[100px] animate-blob animation-delay-2000" />
<div className="absolute bottom-[-18%] left-[8%] h-80 w-80 rounded-full bg-indigo-600 opacity-20 mix-blend-screen blur-[100px] animate-blob animation-delay-4000" />
</div>
<input type="file" multiple ref={fileInputRef} className="hidden" onChange={handleFileChange} />
<input type="file" ref={directoryInputRef} className="hidden" onChange={handleFolderChange} />
<div className={layoutClassNames.toolbar}>
<div className={layoutClassNames.toolbarInner}>
<div className="flex min-w-0 flex-1 flex-nowrap items-center text-sm overflow-x-auto custom-scrollbar whitespace-nowrap">
{directoryState.currentPath.length > 0 && (
<button className="mr-3 p-1.5 rounded-full bg-white/5 text-slate-300 active:bg-white/10" onClick={handleBackClick}>
<ChevronLeft className="w-4 h-4" />
</button>
)}
<button className="text-slate-400 hover:text-white" onClick={() => handleBreadcrumbClick(-1)}></button>
{directoryState.currentPath.map((pathItem, index) => (
<React.Fragment key={index}>
<ChevronRight className="w-3 h-3 mx-1 text-slate-600 shrink-0" />
<button onClick={() => handleBreadcrumbClick(index)} className={cn(index === directoryState.currentPath.length - 1 ? 'text-white font-medium' : 'text-slate-400', 'shrink-0')}>{pathItem}</button>
</React.Fragment>
))}
</div>
<button
type="button"
onClick={() => navigate(RECYCLE_BIN_ROUTE)}
className="flex shrink-0 items-center gap-1.5 rounded-full border border-white/10 bg-white/5 px-3 py-1.5 text-xs text-slate-200"
>
<RotateCcw className="h-3.5 w-3.5" />
</button>
</div>
</div>
<div className={layoutClassNames.list}>
<MobileFilesList currentFiles={directoryState.currentFiles} onFolderClick={handleFolderClick} onOpenActionSheet={openActionSheet} />
</div>
<div className="fixed bottom-20 right-6 z-30 flex flex-col items-end gap-3 pointer-events-none">
<AnimatePresence>
{fabOpen && (
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: 20 }} className="flex flex-col gap-3 pointer-events-auto items-end mr-1">
<button onClick={() => { fileInputRef.current?.click(); setFabOpen(false); }} className="flex items-center gap-2 px-4 py-2.5 rounded-full bg-blue-500 text-white shadow-lg active:scale-95 text-sm font-medium">
<Upload className="w-4 h-4"/>
</button>
<button onClick={() => { directoryInputRef.current?.click(); setFabOpen(false); }} className="flex items-center gap-2 px-4 py-2.5 rounded-full bg-emerald-500 text-white shadow-lg active:scale-95 text-sm font-medium">
<FolderPlus className="w-4 h-4"/>
</button>
<button onClick={handleCreateFolder} className="flex items-center gap-2 px-4 py-2.5 rounded-full bg-purple-500 text-white shadow-lg active:scale-95 text-sm font-medium">
<Plus className="w-4 h-4"/>
</button>
</motion.div>
)}
</AnimatePresence>
<button onClick={() => setFabOpen(!fabOpen)} className={cn("pointer-events-auto flex items-center justify-center w-14 h-14 rounded-full shadow-2xl transition-transform active:scale-95", fabOpen ? "bg-[#0f172a] border border-white/10 rotate-45" : "bg-[#336EFF]")}>
<Plus className="w-6 h-6 text-white" />
</button>
</div>
<AnimatePresence>
{fabOpen && <motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} className="fixed inset-0 z-20 bg-black/40 backdrop-blur-sm" onClick={() => setFabOpen(false)} />}
</AnimatePresence>
<MobileFileActionSheet
isOpen={actionSheetOpen}
selectedFile={selectedFile}
shareStatus={shareStatus}
onClose={closeActionSheet}
onDownload={handleDownload}
onShare={handleShare}
onMove={(f) => openTargetActionModal(f, 'move')}
onCopy={(f) => openTargetActionModal(f, 'copy')}
onRename={openRenameModal}
onDelete={openDeleteModal}
/>
{targetAction && (
<NetdiskPathPickerModal
isOpen
title={targetAction === 'move' ? '移动到' : '复制到'}
confirmLabel={targetAction === 'move' ? '移动至此' : '复制至此'}
onClose={() => setTargetAction(null)}
onConfirm={(path) => void handleMoveToPath(path)}
/>
)}
<AnimatePresence>
{renameModalOpen && (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4">
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={() => setRenameModalOpen(false)} />
<motion.div initial={{ opacity: 0, scale: 0.95 }} animate={{ opacity: 1, scale: 1 }} className="relative w-full max-w-sm glass-panel bg-[#0f172a] border border-white/10 rounded-2xl p-5 z-10 shadow-2xl">
<h3 className="text-lg font-bold text-white mb-4"></h3>
<Input value={newFileName} onChange={(e) => setNewFileName(e.target.value)} className="bg-black/20 text-white mb-2 h-12" placeholder="请输入新名称" />
{renameError && <p className="text-xs text-red-400 mb-4">{renameError}</p>}
<div className="flex gap-3 mt-6">
<Button variant="outline" className="flex-1 bg-white/5 border-white/10 text-white" onClick={() => setRenameModalOpen(false)}></Button>
<Button className="flex-1 bg-[#336EFF] hover:bg-[#2958cc] text-white" onClick={handleRename} disabled={isRenaming}>{isRenaming ? '保存中' : '保存'}</Button>
</div>
</motion.div>
</div>
)}
</AnimatePresence>
<AnimatePresence>
{deleteModalOpen && (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4">
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={() => setDeleteModalOpen(false)} />
<motion.div initial={{ opacity: 0, scale: 0.95 }} animate={{ opacity: 1, scale: 1 }} className="relative w-full max-w-sm glass-panel bg-[#0f172a] border border-white/10 rounded-2xl p-5 z-10 shadow-2xl">
<h3 className="text-lg font-bold text-white mb-2 flex items-center gap-2"><Trash2 className="text-red-400 w-5 h-5"/></h3>
<p className="text-sm text-slate-300 mb-6 mt-3"> <span className="text-white font-medium break-all">{fileToDelete?.name}</span> {RECYCLE_BIN_RETENTION_DAYS} </p>
<div className="flex gap-3">
<Button variant="outline" className="flex-1 bg-white/5 border-white/10 text-white" onClick={() => setDeleteModalOpen(false)}></Button>
<Button className="flex-1 bg-red-500 text-white hover:bg-red-600" onClick={handleDelete}></Button>
</div>
</motion.div>
</div>
)}
</AnimatePresence>
</div>
);
}
export default MobileFilesPage;

View File

@@ -8,6 +8,8 @@ import { Button } from '@/src/components/ui/button';
import { getFileShareDetails, importSharedFile } from '@/src/lib/file-share'; import { getFileShareDetails, importSharedFile } from '@/src/lib/file-share';
import { normalizeNetdiskTargetPath } from '@/src/lib/netdisk-upload'; import { normalizeNetdiskTargetPath } from '@/src/lib/netdisk-upload';
import type { FileMetadata, FileShareDetailsResponse } from '@/src/lib/types'; import type { FileMetadata, FileShareDetailsResponse } from '@/src/lib/types';
import { AppPageShell } from '@/src/components/ui/AppPageShell';
import { PageToolbar } from '@/src/components/ui/PageToolbar';
function formatFileSize(size: number) { function formatFileSize(size: number) {
if (size <= 0) { if (size <= 0) {
@@ -97,15 +99,8 @@ export default function FileShare() {
} }
return ( return (
<div className="min-h-screen bg-[#07101D] px-4 py-10 text-white"> <AppPageShell toolbar={<PageToolbar title="网盘分享导入" />}>
<div className="mx-auto w-full max-w-3xl"> <div className="p-4 md:p-6 mx-auto w-full max-w-3xl h-full">
<div className="mb-10 text-center">
<div className="mx-auto mb-5 flex h-16 w-16 items-center justify-center rounded-2xl bg-gradient-to-br from-cyan-500 via-sky-500 to-blue-500 shadow-lg shadow-cyan-500/20">
<Link2 className="h-8 w-8 text-white" />
</div>
<h1 className="text-3xl font-bold"></h1>
<p className="mt-3 text-slate-400"></p>
</div>
<div className="rounded-3xl border border-white/10 bg-[#0f172a]/80 p-8 shadow-2xl backdrop-blur-xl"> <div className="rounded-3xl border border-white/10 bg-[#0f172a]/80 p-8 shadow-2xl backdrop-blur-xl">
{loading ? ( {loading ? (
@@ -204,6 +199,6 @@ export default function FileShare() {
onClose={() => setPathPickerOpen(false)} onClose={() => setPathPickerOpen(false)}
onConfirm={handleImportToPath} onConfirm={handleImportToPath}
/> />
</div> </AppPageShell>
); );
} }

File diff suppressed because it is too large Load Diff

View File

@@ -7,6 +7,8 @@ import { Gamepad2, Cat, Car, ExternalLink, Play } from 'lucide-react';
import { cn } from '@/src/lib/utils'; import { cn } from '@/src/lib/utils';
import { calculateCardTilt } from './games-card-tilt'; import { calculateCardTilt } from './games-card-tilt';
import { MORE_GAMES_LABEL, MORE_GAMES_URL, resolveGamePlayerPath, type GameId } from './games-links'; import { MORE_GAMES_LABEL, MORE_GAMES_URL, resolveGamePlayerPath, type GameId } from './games-links';
import { AppPageShell } from '@/src/components/ui/AppPageShell';
import { PageToolbar } from '@/src/components/ui/PageToolbar';
const GAMES: Array<{ const GAMES: Array<{
id: GameId; id: GameId;
@@ -137,34 +139,20 @@ export default function Games() {
const [activeTab, setActiveTab] = useState<'featured' | 'all'>('featured'); const [activeTab, setActiveTab] = useState<'featured' | 'all'>('featured');
return ( return (
<div className="space-y-8"> <AppPageShell
{/* Hero Section */} toolbar={
<motion.div <PageToolbar
initial={{ opacity: 0, y: 20 }} title="游戏"
animate={{ opacity: 1, y: 0 }} actions={
className="glass-panel rounded-3xl p-8 relative overflow-hidden" <a href={MORE_GAMES_URL} target="_blank" rel="noreferrer" className="inline-flex items-center gap-2 text-sm text-slate-300 transition-colors hover:text-white">
>
<div className="absolute top-0 right-0 w-64 h-64 bg-purple-500 rounded-full mix-blend-screen filter blur-[100px] opacity-20" />
<div className="relative z-10 space-y-4 max-w-2xl">
<div className="inline-flex items-center gap-2 px-3 py-1.5 rounded-full bg-white/5 border border-white/10 w-fit">
<Gamepad2 className="w-4 h-4 text-purple-400" />
<span className="text-xs text-slate-300 font-medium tracking-wide uppercase">Entertainment</span>
</div>
<h1 className="text-3xl md:text-4xl font-bold text-white tracking-tight"></h1>
<p className="text-sm text-slate-400 leading-relaxed">
</p>
<a
href={MORE_GAMES_URL}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-2 text-sm text-slate-300 transition-colors hover:text-white"
>
<ExternalLink className="h-4 w-4" /> <ExternalLink className="h-4 w-4" />
{MORE_GAMES_LABEL} {MORE_GAMES_LABEL}
</a> </a>
</div> }
</motion.div> />
}
>
<div className="p-4 md:p-6 space-y-8 h-full">
{/* Category Tabs */} {/* Category Tabs */}
<div className="flex bg-black/20 p-1 rounded-xl w-fit"> <div className="flex bg-black/20 p-1 rounded-xl w-fit">
@@ -195,5 +183,6 @@ export default function Games() {
))} ))}
</div> </div>
</div> </div>
</AppPageShell>
); );
} }

View File

@@ -19,6 +19,8 @@ import { shouldLoadAvatarWithAuth } from '@/src/components/layout/account-utils'
import { Button } from '@/src/components/ui/button'; import { Button } from '@/src/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/src/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/src/components/ui/card';
import { FileTypeIcon, getFileTypeTheme } from '@/src/components/ui/FileTypeIcon'; import { FileTypeIcon, getFileTypeTheme } from '@/src/components/ui/FileTypeIcon';
import { AppPageShell } from '@/src/components/ui/AppPageShell';
import { PageToolbar } from '@/src/components/ui/PageToolbar';
import { apiDownload, apiRequest } from '@/src/lib/api'; import { apiDownload, apiRequest } from '@/src/lib/api';
import { readCachedValue, writeCachedValue } from '@/src/lib/cache'; import { readCachedValue, writeCachedValue } from '@/src/lib/cache';
import { resolveStoredFileType } from '@/src/lib/file-type'; import { resolveStoredFileType } from '@/src/lib/file-type';
@@ -197,23 +199,8 @@ export default function Overview() {
}, [profile?.avatarUrl]); }, [profile?.avatarUrl]);
return ( return (
<div className="space-y-6"> <AppPageShell toolbar={<PageToolbar title={`总览 · ${greeting}${profile?.username ?? '访客'}`} />}>
<motion.div <div className="p-4 md:p-6 space-y-6 relative z-10">
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="glass-panel rounded-3xl p-8 relative overflow-hidden"
>
<div className="absolute top-0 right-0 w-64 h-64 bg-[#336EFF] rounded-full mix-blend-screen filter blur-[100px] opacity-20" />
<div className="relative z-10 space-y-2">
<h1 className="text-3xl md:text-4xl font-bold text-white tracking-tight">
{profile?.username ?? '访客'}
</h1>
<p className="text-[#336EFF] font-medium"> {currentTime} · {greeting}</p>
<p className="text-sm text-slate-400 mt-4 max-w-xl leading-relaxed">
</p>
</div>
</motion.div>
{loadingError ? ( {loadingError ? (
<motion.div initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }}> <motion.div initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }}>
@@ -442,6 +429,7 @@ export default function Overview() {
</div> </div>
</div> </div>
</div> </div>
</AppPageShell>
); );
} }

View File

@@ -6,6 +6,8 @@ import { Button } from '@/src/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/src/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/src/components/ui/card';
import { apiRequest } from '@/src/lib/api'; import { apiRequest } from '@/src/lib/api';
import type { PageResponse, RecycleBinItem } from '@/src/lib/types'; import type { PageResponse, RecycleBinItem } from '@/src/lib/types';
import { AppPageShell } from '@/src/components/ui/AppPageShell';
import { PageToolbar } from '@/src/components/ui/PageToolbar';
import { formatRecycleBinExpiresLabel, RECYCLE_BIN_RETENTION_DAYS } from './recycle-bin-state'; import { formatRecycleBinExpiresLabel, RECYCLE_BIN_RETENTION_DAYS } from './recycle-bin-state';
@@ -68,38 +70,35 @@ export default function RecycleBin() {
}; };
return ( return (
<div className="mx-auto flex h-full w-full max-w-6xl flex-col gap-6"> <AppPageShell
<Card className="overflow-hidden"> toolbar={
<CardHeader className="flex flex-col gap-4 border-b border-white/10 sm:flex-row sm:items-end sm:justify-between"> <PageToolbar
<div className="space-y-2"> title={
<div className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/5 px-3 py-1 text-xs text-slate-300"> <div className="flex items-center gap-3">
<span></span>
<div className="hidden sm:flex items-center gap-2 rounded-full border border-white/10 bg-white/5 px-3 py-1 text-xs text-slate-300 font-normal">
<Trash2 className="h-3.5 w-3.5" /> <Trash2 className="h-3.5 w-3.5" />
{RECYCLE_BIN_RETENTION_DAYS} {RECYCLE_BIN_RETENTION_DAYS}
</div> </div>
<CardTitle className="text-2xl text-white"></CardTitle>
<p className="text-sm text-slate-400">
{RECYCLE_BIN_RETENTION_DAYS}
</p>
</div> </div>
<div className="flex items-center gap-3"> }
<Button actions={
variant="outline" <>
className="border-white/10 bg-white/5 text-slate-200 hover:bg-white/10" <Button variant="outline" className="h-9 border-white/10 bg-white/5 text-slate-200 hover:bg-white/10" onClick={() => void loadRecycleBin()} disabled={loading}>
onClick={() => void loadRecycleBin()}
disabled={loading}
>
<RefreshCw className="mr-2 h-4 w-4" /> <RefreshCw className="mr-2 h-4 w-4" />
</Button> </Button>
<Link <Link to="/files" className="inline-flex h-9 items-center justify-center rounded-xl border border-white/10 bg-white/5 px-4 text-sm font-medium text-slate-200 transition-colors hover:bg-white/10">
to="/files"
className="inline-flex h-10 items-center justify-center rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm font-medium text-slate-200 transition-colors hover:bg-white/10"
>
</Link> </Link>
</div> </>
</CardHeader> }
<CardContent className="p-6"> />
}
>
<div className="p-4 md:p-6 mx-auto flex h-full w-full max-w-6xl flex-col gap-6">
<Card className="overflow-hidden bg-transparent border-0 shadow-none">
<CardContent className="p-0">
{error ? ( {error ? (
<div className="mb-4 rounded-2xl border border-red-500/20 bg-red-500/10 px-4 py-3 text-sm text-red-200"> <div className="mb-4 rounded-2xl border border-red-500/20 bg-red-500/10 px-4 py-3 text-sm text-red-200">
{error} {error}
@@ -161,5 +160,6 @@ export default function RecycleBin() {
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
</AppPageShell>
); );
} }

View File

@@ -65,6 +65,8 @@ import {
resolveInitialTransferTab, resolveInitialTransferTab,
} from './transfer-state'; } from './transfer-state';
import TransferReceive from './TransferReceive'; import TransferReceive from './TransferReceive';
import { AppPageShell } from '@/src/components/ui/AppPageShell';
import { PageToolbar } from '@/src/components/ui/PageToolbar';
type SendPhase = 'idle' | 'creating' | 'waiting' | 'connecting' | 'uploading' | 'transferring' | 'completed' | 'error'; type SendPhase = 'idle' | 'creating' | 'waiting' | 'connecting' | 'uploading' | 'transferring' | 'completed' | 'error';
@@ -576,16 +578,8 @@ export default function Transfer() {
} }
return ( return (
<div className="flex-1 py-6 md:py-10"> <AppPageShell toolbar={<PageToolbar title="文件快传" />}>
<div className="mx-auto w-full max-w-4xl"> <div className="p-4 md:p-6 mx-auto w-full max-w-4xl">
<div className="text-center mb-10">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-gradient-to-br from-[#336EFF] via-blue-500 to-cyan-400 shadow-lg shadow-[#336EFF]/20 mb-6">
<Send className="w-8 h-8 text-white" />
</div>
<h1 className="text-3xl font-bold text-white mb-3"></h1>
<p className="text-slate-400">线 P2P 线 7 </p>
</div>
<div className="glass-panel border border-white/10 rounded-3xl overflow-hidden bg-[#0f172a]/80 backdrop-blur-xl shadow-2xl"> <div className="glass-panel border border-white/10 rounded-3xl overflow-hidden bg-[#0f172a]/80 backdrop-blur-xl shadow-2xl">
{allowSend ? ( {allowSend ? (
<div className="flex border-b border-white/10"> <div className="flex border-b border-white/10">
@@ -1046,6 +1040,6 @@ export default function Transfer() {
</motion.div> </motion.div>
) : null} ) : null}
</AnimatePresence> </AnimatePresence>
</div> </AppPageShell>
); );
} }

View File

@@ -0,0 +1,131 @@
import React from 'react';
import { AnimatePresence, motion } from 'motion/react';
import { Download, Share2, Folder, Copy, Edit2, Trash2, MoreVertical } from 'lucide-react';
import type { UiFile } from './file-types';
export function FileActionMenu({
file,
activeDropdown,
onToggle,
onDownload,
onShare,
onMove,
onCopy,
onRename,
onDelete,
onClose,
allowMutatingActions = true,
}: {
file: UiFile;
activeDropdown: number | null;
onToggle: (fileId: number) => void;
onDownload: (file: UiFile) => Promise<void>;
onShare: (file: UiFile) => Promise<void>;
onMove: (file: UiFile) => void;
onCopy: (file: UiFile) => void;
onRename: (file: UiFile) => void;
onDelete: (file: UiFile) => void;
onClose: () => void;
allowMutatingActions?: boolean;
}) {
return (
<div className="relative inline-block text-left">
<button
onClick={(event) => {
event.stopPropagation();
onToggle(file.id);
}}
className="rounded-md p-1.5 text-slate-500 opacity-0 transition-all hover:bg-white/10 hover:text-white group-hover:opacity-100"
>
<MoreVertical className="w-4 h-4" />
</button>
{activeDropdown === file.id && (
<div
className="fixed inset-0 z-40"
onClick={(event) => {
event.stopPropagation();
onClose();
}}
/>
)}
<AnimatePresence>
{activeDropdown === file.id && (
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 10 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 10 }}
transition={{ duration: 0.15 }}
className="absolute right-0 top-full z-50 mt-1 w-32 overflow-hidden rounded-lg border border-white/10 bg-[#1e293b] py-1 shadow-xl"
>
<button
onClick={(event) => {
event.stopPropagation();
void onDownload(file);
onClose();
}}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-slate-300 transition-colors hover:bg-white/10 hover:text-white"
>
<Download className="w-4 h-4" /> {file.type === 'folder' ? '下载文件夹' : '下载文件'}
</button>
{file.type !== 'folder' ? (
<button
onClick={(event) => {
event.stopPropagation();
void onShare(file);
onClose();
}}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-slate-300 transition-colors hover:bg-white/10 hover:text-white"
>
<Share2 className="w-4 h-4" />
</button>
) : null}
{allowMutatingActions ? (
<>
<button
onClick={(event) => {
event.stopPropagation();
onMove(file);
onClose();
}}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-slate-300 transition-colors hover:bg-white/10 hover:text-white"
>
<Folder className="w-4 h-4" />
</button>
<button
onClick={(event) => {
event.stopPropagation();
onCopy(file);
onClose();
}}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-slate-300 transition-colors hover:bg-white/10 hover:text-white"
>
<Copy className="w-4 h-4" />
</button>
<button
onClick={(event) => {
event.stopPropagation();
onRename(file);
onClose();
}}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-slate-300 transition-colors hover:bg-white/10 hover:text-white"
>
<Edit2 className="w-4 h-4" />
</button>
<button
onClick={(event) => {
event.stopPropagation();
onDelete(file);
onClose();
}}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-red-400 transition-colors hover:bg-red-500/10 hover:text-red-300"
>
<Trash2 className="w-4 h-4" />
</button>
</>
) : null}
</motion.div>
)}
</AnimatePresence>
</div>
);
}

View File

@@ -0,0 +1,88 @@
import React from 'react';
import { FileTypeIcon, getFileTypeTheme } from '@/src/components/ui/FileTypeIcon';
import { cn } from '@/src/lib/utils';
import { ellipsizeFileName } from '@/src/lib/file-name';
import { FileActionMenu } from './FileActionMenu';
import type { UiFile } from './file-types';
export function FileGridView({
files,
selectedFileId,
activeDropdown,
isSearchResult = false,
onFileClick,
onFileDoubleClick,
onToggleDropdown,
onDownload,
onShare,
onMove,
onCopy,
onRename,
onDelete,
onCloseDropdown,
}: {
files: (UiFile & { originalPath?: string; originalDirectory?: boolean })[];
selectedFileId: number | null;
activeDropdown: number | null;
isSearchResult?: boolean;
onFileClick: (file: any) => void;
onFileDoubleClick: (file: any) => void;
onToggleDropdown: (fileId: number) => void;
onDownload: (file: UiFile) => Promise<void>;
onShare: (file: UiFile) => Promise<void>;
onMove: (file: UiFile) => void;
onCopy: (file: UiFile) => void;
onRename: (file: UiFile) => void;
onDelete: (file: UiFile) => void;
onCloseDropdown: () => void;
}) {
return (
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
{files.map((file) => {
const selected = selectedFileId === file.id;
return (
<div
key={file.id}
onClick={() => onFileClick(file)}
onDoubleClick={() => onFileDoubleClick(file)}
className={cn(
'group relative flex cursor-pointer flex-col items-center rounded-xl border p-4 transition-all',
selected
? 'border-[#336EFF]/30 bg-[#336EFF]/10'
: 'border-white/5 bg-white/[0.02] hover:border-white/10 hover:bg-white/[0.04]',
)}
>
<div className="absolute right-2 top-2">
<FileActionMenu
file={file}
activeDropdown={activeDropdown}
onToggle={onToggleDropdown}
onDownload={onDownload}
onShare={onShare}
onMove={onMove}
onCopy={onCopy}
onRename={onRename}
onDelete={onDelete}
onClose={onCloseDropdown}
allowMutatingActions={!isSearchResult}
/>
</div>
<FileTypeIcon type={file.type} size="lg" className="mb-3 transition-transform duration-200 group-hover:scale-[1.03]" />
<span className={cn('w-full truncate px-2 text-center text-sm font-medium', selected ? 'text-[#336EFF]' : 'text-slate-200')}>
{ellipsizeFileName(file.name, 24)}
</span>
<span className={cn('mt-1 inline-flex rounded-full px-2 py-1 text-[11px] font-medium', getFileTypeTheme(file.type).badgeClassName)}>
{file.typeLabel}
</span>
<span className="mt-2 text-xs text-slate-500">
{file.type === 'folder' ? file.modified : file.size}
</span>
</div>
);
})}
</div>
);
}

View File

@@ -0,0 +1,125 @@
import React from 'react';
import { FileTypeIcon, getFileTypeTheme } from '@/src/components/ui/FileTypeIcon';
import { cn } from '@/src/lib/utils';
import { ellipsizeFileName } from '@/src/lib/file-name';
import { FileActionMenu } from './FileActionMenu';
import type { UiFile } from './file-types';
import type { FileMetadata } from '@/src/lib/types';
import { splitBackendPath } from './useFilesDirectoryState';
export function FileListView({
files,
selectedFileId,
activeDropdown,
isSearchResult = false,
onFileClick,
onFileDoubleClick,
onToggleDropdown,
onDownload,
onShare,
onMove,
onCopy,
onRename,
onDelete,
onCloseDropdown,
}: {
files: (UiFile & { originalPath?: string; originalDirectory?: boolean })[];
selectedFileId: number | null;
activeDropdown: number | null;
isSearchResult?: boolean;
onFileClick: (file: any) => void;
onFileDoubleClick: (file: any) => void;
onToggleDropdown: (fileId: number) => void;
onDownload: (file: UiFile) => Promise<void>;
onShare: (file: UiFile) => Promise<void>;
onMove: (file: UiFile) => void;
onCopy: (file: UiFile) => void;
onRename: (file: UiFile) => void;
onDelete: (file: UiFile) => void;
onCloseDropdown: () => void;
}) {
return (
<table className="w-full table-fixed text-left border-collapse">
<thead>
<tr className="text-xs font-semibold text-slate-500 uppercase tracking-wider border-b border-white/5">
<th className={cn("pb-3 pl-4 font-medium", isSearchResult ? "w-[40%]" : "w-[44%]")}></th>
{isSearchResult && <th className="hidden pb-3 font-medium md:table-cell w-[26%]"></th>}
<th className={cn("hidden pb-3 font-medium", isSearchResult ? "lg:table-cell w-[20%]" : "md:table-cell w-[22%]")}></th>
{!isSearchResult && <th className="hidden pb-3 font-medium lg:table-cell w-[14%]"></th>}
<th className="pb-3 font-medium w-[10%]"></th>
<th className={cn("pb-3", isSearchResult ? "w-[4%]" : "w-[10%]")}></th>
</tr>
</thead>
<tbody>
{files.map((file) => {
const selected = selectedFileId === file.id;
return (
<tr
key={file.id}
onClick={() => onFileClick(file)}
onDoubleClick={() => onFileDoubleClick(file)}
className={cn(
'group cursor-pointer transition-colors border-b border-white/5 last:border-0',
selected ? 'bg-[#336EFF]/10' : 'hover:bg-white/[0.02]',
)}
>
<td className="py-3 pl-4 max-w-0">
<div className="flex min-w-0 items-center gap-3">
<FileTypeIcon type={file.type} size="sm" />
<div className="min-w-0">
<span
className={cn('block truncate text-sm font-medium', selected ? 'text-[#336EFF]' : 'text-slate-200')}
title={file.name}
>
{ellipsizeFileName(file.name, 48)}
</span>
{isSearchResult && file.originalPath && (
<span className="hidden truncate text-xs text-slate-500 md:block" title={file.originalPath}>
{file.originalPath}
</span>
)}
</div>
</div>
</td>
{isSearchResult && (
<td className="hidden py-3 text-sm text-slate-400 md:table-cell">{file.originalPath}</td>
)}
<td className={cn("hidden py-3 text-sm text-slate-400", isSearchResult ? "lg:table-cell" : "md:table-cell")}>
{file.modified}
</td>
{!isSearchResult && (
<td className="hidden py-3 text-sm text-slate-400 lg:table-cell">
<span
className={cn(
'inline-flex items-center rounded-full px-2.5 py-1 text-[11px] font-medium tracking-wide',
getFileTypeTheme(file.type).badgeClassName,
)}
>
{file.typeLabel}
</span>
</td>
)}
<td className="py-3 text-sm text-slate-400 font-mono">{file.size}</td>
<td className="py-3 pr-4 text-right">
<FileActionMenu
file={file}
activeDropdown={activeDropdown}
onToggle={onToggleDropdown}
onDownload={onDownload}
onShare={onShare}
onMove={onMove}
onCopy={onCopy}
onRename={onRename}
onDelete={onDelete}
onClose={onCloseDropdown}
allowMutatingActions={!isSearchResult}
/>
</td>
</tr>
);
})}
</tbody>
</table>
);
}

View File

@@ -0,0 +1,124 @@
import React from 'react';
import { Card, CardContent } from '@/src/components/ui/card';
import { Folder, ChevronDown, ChevronRight, RotateCcw } from 'lucide-react';
import { cn } from '@/src/lib/utils';
import { getFilesSidebarFooterEntries, RECYCLE_BIN_RETENTION_DAYS, RECYCLE_BIN_ROUTE } from '../recycle-bin-state';
import type { DirectoryTreeNode } from '../files-tree';
import { useLocation, useNavigate } from 'react-router-dom';
function DirectoryTreeItem({
node,
onSelect,
onToggle,
}: {
node: DirectoryTreeNode;
onSelect: (path: string[]) => void;
onToggle: (path: string[]) => void;
}) {
return (
<div>
<div
className={cn(
'group flex items-center gap-1 rounded-xl px-2 py-1.5 transition-colors',
node.active ? 'bg-[#336EFF]/15' : 'hover:bg-white/5',
)}
style={{ paddingLeft: `${node.depth * 14 + 8}px` }}
>
<button
type="button"
className="flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-slate-500 transition-colors hover:bg-white/5 hover:text-white"
onClick={() => onToggle(node.path)}
aria-label={`${node.expanded ? '收起' : '展开'} ${node.name}`}
>
{node.expanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
</button>
<button
type="button"
className={cn(
'flex min-w-0 flex-1 items-center gap-2 rounded-lg px-2 py-1 text-left text-sm transition-colors',
node.active ? 'text-[#336EFF]' : 'text-slate-300 hover:text-white',
)}
onClick={() => onSelect(node.path)}
>
<Folder className={cn('h-4 w-4 shrink-0', node.active ? 'text-[#336EFF]' : 'text-slate-500')} />
<span className="truncate">{node.name}</span>
</button>
</div>
{node.expanded ? node.children.map((child) => (
<DirectoryTreeItem key={child.id} node={child} onSelect={onSelect} onToggle={onToggle} />
)) : null}
</div>
);
}
export function FilesDirectoryRail({
currentPath,
directoryTree,
onNavigateToPath,
onDirectoryToggle,
}: {
currentPath: string[];
directoryTree: DirectoryTreeNode[];
onNavigateToPath: (pathParts: string[]) => void;
onDirectoryToggle: (pathParts: string[]) => void;
}) {
const navigate = useNavigate();
const location = useLocation();
return (
<Card className="w-full lg:w-64 shrink-0 flex flex-col h-full overflow-hidden">
<CardContent className="flex h-full flex-col p-4">
<div className="min-h-0 flex-1 space-y-2">
<p className="px-3 text-xs font-semibold text-slate-500 uppercase tracking-wider"></p>
<div className="flex min-h-0 flex-1 flex-col rounded-2xl border border-white/5 bg-black/20 p-2">
<button
type="button"
onClick={() => onNavigateToPath([])}
className={cn(
'flex w-full items-center gap-2 rounded-xl px-3 py-2 text-left text-sm font-medium transition-colors',
currentPath.length === 0 ? 'bg-[#336EFF]/15 text-[#336EFF]' : 'text-slate-200 hover:bg-white/5 hover:text-white',
)}
>
<Folder className={cn('h-4 w-4', currentPath.length === 0 ? 'text-[#336EFF]' : 'text-slate-500')} />
<span className="truncate"></span>
</button>
<div className="mt-1 min-h-0 flex-1 space-y-0.5 overflow-y-auto pr-1">
{directoryTree.map((node) => (
<DirectoryTreeItem
key={node.id}
node={node}
onSelect={onNavigateToPath}
onToggle={onDirectoryToggle}
/>
))}
</div>
</div>
</div>
<div className="mt-4 border-t border-white/10 pt-4">
{getFilesSidebarFooterEntries().map((entry) => {
const isActive = location.pathname === entry.path || location.pathname === RECYCLE_BIN_ROUTE;
return (
<button
key={entry.path}
type="button"
onClick={() => navigate(entry.path)}
className={cn(
'flex w-full items-center gap-3 rounded-2xl border px-3 py-3 text-left text-sm transition-colors',
isActive
? 'border-[#336EFF]/30 bg-[#336EFF]/15 text-[#7ea6ff]'
: 'border-white/10 bg-white/5 text-slate-300 hover:bg-white/10 hover:text-white',
)}
>
<RotateCcw className={cn('h-4 w-4', isActive ? 'text-[#7ea6ff]' : 'text-slate-400')} />
<div className="min-w-0">
<p className="font-medium">{entry.label}</p>
<p className="truncate text-xs text-slate-500"> {RECYCLE_BIN_RETENTION_DAYS} </p>
</div>
</button>
);
})}
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,124 @@
import React from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/src/components/ui/card';
import { FileTypeIcon } from '@/src/components/ui/FileTypeIcon';
import { Button } from '@/src/components/ui/button';
import { Share2, Edit2, Folder, Copy, RotateCcw, Trash2, Download } from 'lucide-react';
import { cn } from '@/src/lib/utils';
import type { UiFile } from './file-types';
function DetailItem({ label, value }: { label: string; value: string }) {
return (
<div>
<p className="text-xs font-medium text-slate-500 mb-1">{label}</p>
<p className="text-sm text-slate-300">{value}</p>
</div>
);
}
export function FilesInspector({
selectedFile,
currentPath,
shareStatus,
backgroundTaskActionId,
onShare,
onRename,
onMove,
onCopy,
onCreateMediaMetadataTask,
onDelete,
onFolderDoubleClick,
onDownload,
}: {
selectedFile: UiFile;
currentPath: string[];
shareStatus: string;
backgroundTaskActionId: number | null;
onShare: (file: UiFile) => void;
onRename: (file: UiFile) => void;
onMove: (file: UiFile) => void;
onCopy: (file: UiFile) => void;
onCreateMediaMetadataTask: () => void;
onDelete: (file: UiFile) => void;
onFolderDoubleClick: (file: UiFile) => void;
onDownload: (file: UiFile) => void;
}) {
return (
<Card className="h-full">
<CardHeader className="pb-4 border-b border-white/10">
<CardTitle className="text-base"></CardTitle>
</CardHeader>
<CardContent className="p-6 space-y-6">
<div className="flex w-full flex-col items-center text-center space-y-3">
<FileTypeIcon type={selectedFile.type} size="lg" />
<h3 className="w-full truncate text-sm font-medium text-white" title={selectedFile.name}>
{selectedFile.name}
</h3>
</div>
<div className="space-y-4">
<DetailItem label="位置" value={`网盘 > ${currentPath.length === 0 ? '根目录' : currentPath.join(' > ')}`} />
<DetailItem label="大小" value={selectedFile.size} />
<DetailItem label="修改时间" value={selectedFile.modified} />
<DetailItem label="类型" value={selectedFile.typeLabel} />
</div>
<div className="pt-4 space-y-3 border-t border-white/10">
<div className="grid grid-cols-2 gap-3">
{selectedFile.type !== 'folder' ? (
<Button variant="outline" className="w-full gap-2 bg-white/5 border-white/10 hover:bg-white/10" onClick={() => onShare(selectedFile)}>
<Share2 className="w-4 h-4" />
</Button>
) : null}
<Button variant="outline" className="w-full gap-2 bg-white/5 border-white/10 hover:bg-white/10" onClick={() => onRename(selectedFile)}>
<Edit2 className="w-4 h-4" />
</Button>
<Button variant="outline" className="w-full gap-2 bg-white/5 border-white/10 hover:bg-white/10" onClick={() => onMove(selectedFile)}>
<Folder className="w-4 h-4" />
</Button>
<Button variant="outline" className="w-full gap-2 bg-white/5 border-white/10 hover:bg-white/10" onClick={() => onCopy(selectedFile)}>
<Copy className="w-4 h-4" />
</Button>
{selectedFile.type !== 'folder' ? (
<Button
variant="outline"
className="col-span-2 w-full gap-2 border-white/10 bg-white/5 hover:bg-white/10"
onClick={onCreateMediaMetadataTask}
disabled={backgroundTaskActionId === selectedFile.id}
>
<RotateCcw className={cn('w-4 h-4', backgroundTaskActionId === selectedFile.id ? 'animate-spin' : '')} />
{backgroundTaskActionId === selectedFile.id ? '创建中...' : '提取媒体信息'}
</Button>
) : null}
<Button
variant="outline"
className="w-full gap-2 border-red-500/20 bg-red-500/5 text-red-400 hover:bg-red-500/10 hover:text-red-300"
onClick={() => onDelete(selectedFile)}
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
{selectedFile.type === 'folder' && (
<div className="space-y-3">
<Button variant="default" className="w-full gap-2" onClick={() => onFolderDoubleClick(selectedFile)}>
</Button>
<Button variant="default" className="w-full gap-2" onClick={() => onDownload(selectedFile)}>
<Download className="w-4 h-4" />
</Button>
</div>
)}
{selectedFile.type !== 'folder' && (
<Button variant="default" className="w-full gap-2" onClick={() => onDownload(selectedFile)}>
<Download className="w-4 h-4" />
</Button>
)}
{shareStatus && selectedFile.type !== 'folder' ? (
<div className="rounded-xl border border-emerald-500/20 bg-emerald-500/10 px-3 py-2 text-xs text-emerald-200">
{shareStatus}
</div>
) : null}
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,175 @@
import React from 'react';
import { Folder } from 'lucide-react';
import { cn } from '@/src/lib/utils';
import { FilesSearchPanel } from './FilesSearchPanel';
import { FileListView } from './FileListView';
import { FileGridView } from './FileGridView';
import type { UiFile } from './file-types';
import type { FileMetadata } from '@/src/lib/types';
export function FilesMainPane({
currentPath,
currentFiles,
shareStatus,
viewMode,
isSearchActive,
searchQuery,
searchLoading,
searchError,
searchResults,
selectedSearchFile,
selectedFile,
activeDropdown,
onViewModeChange,
onSearchQueryChange,
onSearchSubmit,
onClearSearch,
onFileClick,
onFileDoubleClick,
onSearchFileClick,
onSearchFileDoubleClick,
onToggleDropdown,
onDownload,
onShare,
onMove,
onCopy,
onRename,
onDelete,
onCloseDropdown,
}: {
currentPath: string[];
currentFiles: UiFile[];
shareStatus: string;
viewMode: 'list' | 'grid';
isSearchActive: boolean;
searchQuery: string;
searchLoading: boolean;
searchError: string;
searchResults: FileMetadata[] | null;
selectedSearchFile: FileMetadata | null;
selectedFile: UiFile | null;
activeDropdown: number | null;
onViewModeChange: (mode: 'list' | 'grid') => void;
onSearchQueryChange: (query: string) => void;
onSearchSubmit: (event: React.FormEvent<HTMLFormElement>) => void;
onClearSearch: () => void;
onFileClick: (file: UiFile) => void;
onFileDoubleClick: (file: UiFile) => void;
onSearchFileClick: (file: FileMetadata) => void;
onSearchFileDoubleClick: (file: FileMetadata) => void;
onToggleDropdown: (fileId: number) => void;
onDownload: (file: UiFile) => Promise<void>;
onShare: (file: UiFile) => Promise<void>;
onMove: (file: UiFile) => void;
onCopy: (file: UiFile) => void;
onRename: (file: UiFile) => void;
onDelete: (file: UiFile) => void;
onCloseDropdown: () => void;
}) {
return (
<div className="flex-1 flex flex-col h-full overflow-hidden bg-transparent">
<FilesSearchPanel
searchQuery={searchQuery}
searchLoading={searchLoading}
isSearchActive={isSearchActive}
searchError={searchError}
onSearchQueryChange={onSearchQueryChange}
onSearchSubmit={onSearchSubmit}
onClearSearch={onClearSearch}
/>
{isSearchActive ? (
<div className="flex-1 overflow-y-auto p-0">
{searchLoading ? (
<div className="flex flex-col items-center justify-center space-y-3 py-12 text-slate-500">
<Folder className="h-12 w-12 opacity-20" />
<p className="text-sm">...</p>
</div>
) : (searchResults?.length ?? 0) === 0 ? (
<div className="flex flex-col items-center justify-center space-y-3 py-12 text-slate-500">
<Folder className="h-12 w-12 opacity-20" />
<p className="text-sm"></p>
</div>
) : viewMode === 'list' ? (
<FileListView
files={searchResults!.map(f => ({ ...f, typeLabel: f.contentType || 'unknown', originalPath: f.path, modified: f.createdAt } as any))}
selectedFileId={selectedSearchFile?.id ?? null}
activeDropdown={activeDropdown}
isSearchResult={true}
onFileClick={onSearchFileClick}
onFileDoubleClick={onSearchFileDoubleClick}
onToggleDropdown={onToggleDropdown}
onDownload={onDownload}
onShare={onShare}
onMove={onMove}
onCopy={onCopy}
onRename={onRename}
onDelete={onDelete}
onCloseDropdown={onCloseDropdown}
/>
) : (
<FileGridView
files={searchResults!.map(f => ({ ...f, typeLabel: f.contentType || 'unknown', originalPath: f.path, modified: f.createdAt } as any))}
selectedFileId={selectedSearchFile?.id ?? null}
activeDropdown={activeDropdown}
isSearchResult={true}
onFileClick={onSearchFileClick}
onFileDoubleClick={onSearchFileDoubleClick}
onToggleDropdown={onToggleDropdown}
onDownload={onDownload}
onShare={onShare}
onMove={onMove}
onCopy={onCopy}
onRename={onRename}
onDelete={onDelete}
onCloseDropdown={onCloseDropdown}
/>
)}
</div>
) : (
<div className="flex-1 overflow-y-auto p-0 md:p-4">
{currentFiles.length === 0 ? (
<div className="flex flex-col items-center justify-center space-y-3 py-12 text-slate-500">
<Folder className="w-12 h-12 opacity-20" />
<p className="text-sm"></p>
</div>
) : viewMode === 'list' ? (
<FileListView
files={currentFiles}
selectedFileId={selectedFile?.id ?? null}
activeDropdown={activeDropdown}
isSearchResult={false}
onFileClick={onFileClick}
onFileDoubleClick={onFileDoubleClick}
onToggleDropdown={onToggleDropdown}
onDownload={onDownload}
onShare={onShare}
onMove={onMove}
onCopy={onCopy}
onRename={onRename}
onDelete={onDelete}
onCloseDropdown={onCloseDropdown}
/>
) : (
<FileGridView
files={currentFiles}
selectedFileId={selectedFile?.id ?? null}
activeDropdown={activeDropdown}
isSearchResult={false}
onFileClick={onFileClick}
onFileDoubleClick={onFileDoubleClick}
onToggleDropdown={onToggleDropdown}
onDownload={onDownload}
onShare={onShare}
onMove={onMove}
onCopy={onCopy}
onRename={onRename}
onDelete={onDelete}
onCloseDropdown={onCloseDropdown}
/>
)}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,425 @@
import React, { useRef, useState, useEffect } from 'react';
import { AnimatePresence, motion } from 'motion/react';
import { Edit2, X, Trash2 } from 'lucide-react';
import { Input } from '@/src/components/ui/input';
import { Button } from '@/src/components/ui/button';
import { NetdiskPathPickerModal } from '@/src/components/ui/NetdiskPathPickerModal';
import { ApiError, apiDownload, apiRequest } from '@/src/lib/api';
import { copyFileToNetdiskPath } from '@/src/lib/file-copy';
import { moveFileToNetdiskPath } from '@/src/lib/file-move';
import { createFileShareLink, getCurrentFileShareUrl } from '@/src/lib/file-share';
import { uploadFileToNetdiskViaSession } from '@/src/lib/upload-session';
import { getNextAvailableName, getActionErrorMessage, removeUiFile, replaceUiFile, syncSelectedFile, clearSelectionIfDeleted } from '../files-state';
import {
buildUploadProgressSnapshot,
cancelUploadTask,
createUploadMeasurement,
createUploadTasks,
completeUploadTask,
failUploadTask,
prepareUploadTaskForCompletion,
prepareFolderUploadEntries,
prepareUploadFile,
shouldUploadEntriesSequentially,
type PendingUploadEntry,
type UploadMeasurement,
type UploadTask,
} from '../files-upload';
import {
registerFilesUploadTaskCanceler,
replaceFilesUploads,
setFilesUploadPanelOpen,
unregisterFilesUploadTaskCanceler,
updateFilesUploadTask,
} from '../files-upload-store';
import { buildDirectoryTree } from '../files-tree';
import { RECYCLE_BIN_RETENTION_DAYS } from '../recycle-bin-state';
import type { FileMetadata } from '@/src/lib/types';
import { toUiFile, type UiFile } from './file-types';
import { useFilesDirectoryState, splitBackendPath, toBackendPath } from './useFilesDirectoryState';
import { useFilesSearchState } from './useFilesSearchState';
import { useBackgroundTasksState } from './useBackgroundTasksState';
import { useFilesOverlayState } from './useFilesOverlayState';
import { FilesDirectoryRail } from './FilesDirectoryRail';
import { FilesMainPane } from './FilesMainPane';
import { FilesInspector } from './FilesInspector';
import { FilesTaskPanel } from './FilesTaskPanel';
import { FilesToolbar } from './FilesToolbar';
import { AppPageShell } from '@/src/components/ui/AppPageShell';
function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export function FilesPage() {
const directoryState = useFilesDirectoryState();
const searchState = useFilesSearchState();
const tasksState = useBackgroundTasksState();
const overlayState = useFilesOverlayState();
const fileInputRef = useRef<HTMLInputElement | null>(null);
const directoryInputRef = useRef<HTMLInputElement | null>(null);
const uploadMeasurementsRef = useRef(new Map<string, UploadMeasurement>());
const [viewMode, setViewMode] = useState<'list' | 'grid'>('list');
const [shareStatus, setShareStatus] = useState('');
const [selectedFile, setSelectedFile] = useState<UiFile | null>(null);
useEffect(() => {
if (directoryInputRef.current) {
directoryInputRef.current.setAttribute('webkitdirectory', '');
directoryInputRef.current.setAttribute('directory', '');
}
void tasksState.loadBackgroundTasks();
}, [tasksState.loadBackgroundTasks]);
const handleNavigateToPath = (pathParts: string[]) => {
searchState.clearSearchState();
directoryState.setCurrentPath(pathParts);
setSelectedFile(null);
overlayState.setActiveDropdown(null);
};
const directoryTree = buildDirectoryTree(directoryState.directoryChildren, directoryState.currentPath, directoryState.expandedDirectories);
const handleDownload = async (targetFile: UiFile | null = selectedFile) => {
if (!targetFile) return;
if (targetFile.type === 'folder') {
const response = await apiDownload(`/files/download/${targetFile.id}`);
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `${targetFile.name}.zip`;
link.click();
window.URL.revokeObjectURL(url);
return;
}
try {
const response = await apiRequest<{url: string}>(`/files/download/${targetFile.id}/url`);
const url = response.url;
const link = document.createElement('a');
link.href = url;
link.download = targetFile.name;
link.rel = 'noreferrer';
link.target = '_blank';
link.click();
return;
} catch (error) {
if (!(error instanceof ApiError && error.status === 404)) {
throw error;
}
}
const response = await apiDownload(`/files/download/${targetFile.id}`);
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = targetFile.name;
link.click();
window.URL.revokeObjectURL(url);
};
const handleShare = async (targetFile: UiFile) => {
try {
const response = await createFileShareLink(targetFile.id);
const shareUrl = getCurrentFileShareUrl(response.token);
try {
await navigator.clipboard.writeText(shareUrl);
setShareStatus('分享链接已复制到剪贴板');
} catch {
setShareStatus(`分享链接:${shareUrl}`);
}
} catch (error) {
setShareStatus(error instanceof Error ? error.message : '创建分享链接失败');
}
};
const runUploadEntries = async (entries: PendingUploadEntry[]) => {
if (entries.length === 0) return;
setFilesUploadPanelOpen(true);
uploadMeasurementsRef.current.clear();
const batchTasks = createUploadTasks(entries);
replaceFilesUploads(batchTasks);
const runSingleUpload = async (
{file: uploadFile, pathParts: uploadPathParts}: PendingUploadEntry,
uploadTask: UploadTask,
) => {
const uploadPath = toBackendPath(uploadPathParts);
const startedAt = Date.now();
const uploadAbortController = new AbortController();
registerFilesUploadTaskCanceler(uploadTask.id, () => uploadAbortController.abort());
uploadMeasurementsRef.current.set(uploadTask.id, createUploadMeasurement(startedAt));
try {
const updateProgress = ({loaded, total}: {loaded: number; total: number}) => {
const snapshot = buildUploadProgressSnapshot({
loaded, total, now: Date.now(), previous: uploadMeasurementsRef.current.get(uploadTask.id),
});
uploadMeasurementsRef.current.set(uploadTask.id, snapshot.measurement);
updateFilesUploadTask(uploadTask.id, (task) => ({
...task, progress: snapshot.progress, speed: snapshot.speed,
}));
};
const uploadedFile = await uploadFileToNetdiskViaSession(uploadFile, uploadPath, {
onProgress: updateProgress,
signal: uploadAbortController.signal,
});
updateFilesUploadTask(uploadTask.id, (task) => prepareUploadTaskForCompletion(task));
await sleep(120);
updateFilesUploadTask(uploadTask.id, (task) => completeUploadTask(task));
return uploadedFile;
} catch (error) {
if (uploadAbortController.signal.aborted) {
updateFilesUploadTask(uploadTask.id, (task) => cancelUploadTask(task));
return null;
}
updateFilesUploadTask(uploadTask.id, (task) => failUploadTask(task, error instanceof Error && error.message ? error.message : '上传失败没查到原因'));
return null;
} finally {
uploadMeasurementsRef.current.delete(uploadTask.id);
unregisterFilesUploadTaskCanceler(uploadTask.id);
}
};
const results = shouldUploadEntriesSequentially(entries)
? await entries.reduce<Promise<Array<Awaited<ReturnType<typeof runSingleUpload>>>>>(async (prev, entry, i) => [...await prev, await runSingleUpload(entry, batchTasks[i])], Promise.resolve([]))
: await Promise.all(entries.map((entry, index) => runSingleUpload(entry, batchTasks[index])));
if (results.some(Boolean)) {
await directoryState.loadCurrentPath(directoryState.currentPath).catch(() => undefined);
}
};
const handleRename = async () => {
if (!overlayState.fileToRename || !overlayState.newFileName.trim() || overlayState.isRenaming) return;
overlayState.setIsRenaming(true);
overlayState.setRenameError('');
try {
const renamedFile = await apiRequest<FileMetadata>(`/files/${overlayState.fileToRename.id}/rename`, {
method: 'PATCH', body: { filename: overlayState.newFileName.trim() },
});
const nextUiFile = toUiFile(renamedFile);
directoryState.setCurrentFiles((prev) => replaceUiFile(prev, nextUiFile));
setSelectedFile((prev) => syncSelectedFile(prev, nextUiFile));
overlayState.setRenameModalOpen(false);
overlayState.setFileToRename(null);
overlayState.setNewFileName('');
await directoryState.loadCurrentPath(directoryState.currentPath).catch(() => undefined);
} catch (error) {
overlayState.setRenameError(getActionErrorMessage(error, '重命名失败'));
} finally {
overlayState.setIsRenaming(false);
}
};
const handleDelete = async () => {
if (!overlayState.fileToDelete) return;
await apiRequest(`/files/${overlayState.fileToDelete.id}`, { method: 'DELETE' });
directoryState.setCurrentFiles((prev) => removeUiFile(prev, overlayState.fileToDelete!.id));
setSelectedFile((prev) => clearSelectionIfDeleted(prev, overlayState.fileToDelete!.id));
overlayState.setDeleteModalOpen(false);
overlayState.setFileToDelete(null);
await directoryState.loadCurrentPath(directoryState.currentPath).catch(() => undefined);
};
const handleMoveToPath = async (path: string) => {
if (!overlayState.targetActionFile || !overlayState.targetAction) return;
if (overlayState.targetAction === 'move') {
await moveFileToNetdiskPath(overlayState.targetActionFile.id, path);
setSelectedFile((prev) => clearSelectionIfDeleted(prev, overlayState.targetActionFile!.id));
} else {
await copyFileToNetdiskPath(overlayState.targetActionFile.id, path);
}
overlayState.setTargetAction(null);
overlayState.setTargetActionFile(null);
await directoryState.loadCurrentPath(directoryState.currentPath).catch(() => undefined);
};
const handleFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const files = event.target.files ? (Array.from(event.target.files) as File[]) : [];
event.target.value = '';
if (files.length === 0) return;
const reservedNames = new Set<string>(directoryState.currentFiles.map((file) => file.name));
const entries: PendingUploadEntry[] = files.map((file) => {
const preparedUpload = prepareUploadFile(file, reservedNames);
reservedNames.add(preparedUpload.file.name);
return { file: preparedUpload.file, pathParts: [...directoryState.currentPath], source: 'file', noticeMessage: preparedUpload.noticeMessage };
});
await runUploadEntries(entries);
};
const handleFolderChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const files = event.target.files ? (Array.from(event.target.files) as File[]) : [];
event.target.value = '';
if (files.length === 0) return;
const entries = prepareFolderUploadEntries(files, [...directoryState.currentPath], directoryState.currentFiles.map((f) => f.name));
await runUploadEntries(entries);
};
const handleCreateFolder = async () => {
const folderName = window.prompt('请输入新文件夹名称');
if (!folderName?.trim()) return;
const nextFolderName = getNextAvailableName(folderName.trim(), new Set(directoryState.currentFiles.filter((f) => f.type === 'folder').map((f) => f.name)));
const basePath = toBackendPath(directoryState.currentPath).replace(/\/$/, '');
const fullPath = `${basePath}/${nextFolderName}` || '/';
await apiRequest('/files/mkdir', { method: 'POST', body: new URLSearchParams({ path: fullPath.startsWith('/') ? fullPath : `/${fullPath}` }), headers: { 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8' } });
await directoryState.loadCurrentPath(directoryState.currentPath).catch(() => undefined);
};
const toolbar = (
<FilesToolbar
currentPath={directoryState.currentPath}
shareStatus={shareStatus}
viewMode={viewMode}
onNavigateToRoot={() => handleNavigateToPath([])}
onBreadcrumbClick={(index) => handleNavigateToPath(directoryState.currentPath.slice(0, index + 1))}
onViewModeChange={setViewMode}
onUploadClick={() => fileInputRef.current?.click()}
onUploadFolderClick={() => directoryInputRef.current?.click()}
onCreateFolder={handleCreateFolder}
fileInputRef={fileInputRef}
directoryInputRef={directoryInputRef}
onFileChange={handleFileChange}
onFolderChange={handleFolderChange}
/>
);
const rail = (
<div className="h-full p-4 pl-0 pr-0">
<FilesDirectoryRail
currentPath={directoryState.currentPath}
directoryTree={directoryTree}
onNavigateToPath={handleNavigateToPath}
onDirectoryToggle={directoryState.handleDirectoryToggle}
/>
</div>
);
const inspector = (
<div className="h-full space-y-4 p-4 pr-0">
{selectedFile && (
<FilesInspector
selectedFile={selectedFile}
currentPath={directoryState.currentPath}
shareStatus={shareStatus}
backgroundTaskActionId={tasksState.backgroundTaskActionId}
onShare={handleShare}
onRename={overlayState.openRenameModal}
onMove={(f) => overlayState.openTargetActionModal(f, 'move')}
onCopy={(f) => overlayState.openTargetActionModal(f, 'copy')}
onCreateMediaMetadataTask={() => tasksState.handleCreateMediaMetadataTask(selectedFile.id, selectedFile.name, selectedFile.type === 'folder', directoryState.currentPath)}
onDelete={overlayState.openDeleteModal}
onFolderDoubleClick={(f) => f.type === 'folder' && handleNavigateToPath([...directoryState.currentPath, f.name])}
onDownload={handleDownload}
/>
)}
<FilesTaskPanel
backgroundTasks={tasksState.backgroundTasks}
backgroundTasksLoading={tasksState.backgroundTasksLoading}
backgroundTasksError={tasksState.backgroundTasksError}
backgroundTaskNotice={tasksState.backgroundTaskNotice}
backgroundTaskActionId={tasksState.backgroundTaskActionId}
onRefresh={tasksState.loadBackgroundTasks}
onCancelTask={tasksState.handleCancelBackgroundTask}
/>
</div>
);
return (
<AppPageShell toolbar={toolbar} rail={rail} inspector={inspector}>
<FilesMainPane
currentPath={directoryState.currentPath}
currentFiles={directoryState.currentFiles}
shareStatus={shareStatus}
viewMode={viewMode}
isSearchActive={searchState.isSearchActive}
searchQuery={searchState.searchQuery}
searchLoading={searchState.searchLoading}
searchError={searchState.searchError}
searchResults={searchState.searchResults}
selectedSearchFile={searchState.selectedSearchFile}
selectedFile={selectedFile}
activeDropdown={overlayState.activeDropdown}
onViewModeChange={setViewMode}
onSearchQueryChange={searchState.setSearchQuery}
onSearchSubmit={searchState.handleSearchSubmit}
onClearSearch={searchState.clearSearchState}
onFileClick={(f) => setSelectedFile(f)}
onFileDoubleClick={(f) => f.type === 'folder' && handleNavigateToPath([...directoryState.currentPath, f.name])}
onSearchFileClick={(f) => searchState.setSelectedSearchFile(f)}
onSearchFileDoubleClick={(f) => f.directory && handleNavigateToPath(splitBackendPath(f.path))}
onToggleDropdown={(id) => overlayState.setActiveDropdown(overlayState.activeDropdown === id ? null : id)}
onDownload={handleDownload}
onShare={handleShare}
onMove={(f) => overlayState.openTargetActionModal(f, 'move')}
onCopy={(f) => overlayState.openTargetActionModal(f, 'copy')}
onRename={overlayState.openRenameModal}
onDelete={overlayState.openDeleteModal}
onCloseDropdown={() => overlayState.setActiveDropdown(null)}
/>
<AnimatePresence>
{overlayState.renameModalOpen && (
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm">
<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="w-full max-w-sm overflow-hidden rounded-xl border border-white/10 bg-[#0f172a] shadow-2xl">
<div className="flex items-center justify-between border-b border-white/10 bg-white/5 p-4">
<h3 className="flex items-center gap-2 text-lg font-semibold text-white"><Edit2 className="w-5 h-5 text-[#336EFF]" /> </h3>
<button onClick={() => { overlayState.setRenameModalOpen(false); overlayState.setFileToRename(null); overlayState.setRenameError(''); }} className="rounded-md p-1 text-slate-400 transition-colors hover:bg-white/10 hover:text-white"><X className="w-5 h-5" /></button>
</div>
<div className="space-y-5 p-5">
<div className="space-y-2">
<label className="text-sm font-medium text-slate-300"></label>
<Input value={overlayState.newFileName} onChange={(e) => overlayState.setNewFileName(e.target.value)} className="bg-black/20 border-white/10 text-white focus-visible:ring-[#336EFF]" autoFocus disabled={overlayState.isRenaming} onKeyDown={(e) => { if (e.key === 'Enter' && !overlayState.isRenaming) void handleRename(); }} />
</div>
{overlayState.renameError && <div className="rounded-xl border border-red-500/20 bg-red-500/10 p-3 text-sm text-red-400">{overlayState.renameError}</div>}
<div className="flex justify-end gap-3 pt-2">
<Button variant="outline" onClick={() => { overlayState.setRenameModalOpen(false); overlayState.setFileToRename(null); overlayState.setRenameError(''); }} disabled={overlayState.isRenaming} className="border-white/10 text-slate-300 hover:bg-white/10"></Button>
<Button variant="default" onClick={() => void handleRename()} disabled={overlayState.isRenaming}>{overlayState.isRenaming ? '重命名中...' : '确定'}</Button>
</div>
</div>
</motion.div>
</div>
)}
{overlayState.deleteModalOpen && (
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm">
<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="w-full max-w-sm overflow-hidden rounded-xl border border-white/10 bg-[#0f172a] shadow-2xl">
<div className="flex items-center justify-between border-b border-white/10 bg-white/5 p-4">
<h3 className="flex items-center gap-2 text-lg font-semibold text-white"><Trash2 className="w-5 h-5 text-red-500" /> </h3>
<button onClick={() => { overlayState.setDeleteModalOpen(false); overlayState.setFileToDelete(null); }} className="rounded-md p-1 text-slate-400 transition-colors hover:bg-white/10 hover:text-white"><X className="w-5 h-5" /></button>
</div>
<div className="space-y-5 p-5">
<p className="text-sm leading-relaxed text-slate-300"> <span className="rounded bg-white/10 px-1 py-0.5 font-medium text-white">{overlayState.fileToDelete?.name}</span> {RECYCLE_BIN_RETENTION_DAYS} </p>
<div className="flex justify-end gap-3 pt-2">
<Button variant="outline" onClick={() => { overlayState.setDeleteModalOpen(false); overlayState.setFileToDelete(null); }} className="border-white/10 text-slate-300 hover:bg-white/10"></Button>
<Button variant="outline" className="border-red-500/30 bg-red-500 text-white hover:bg-red-600" onClick={() => void handleDelete()}></Button>
</div>
</div>
</motion.div>
</div>
)}
</AnimatePresence>
<NetdiskPathPickerModal
isOpen={Boolean(overlayState.targetActionFile && overlayState.targetAction)}
title={overlayState.targetAction === 'copy' ? '选择复制目标' : '选择移动目标'}
description={overlayState.targetAction === 'copy' ? '选择要把当前文件或文件夹复制到哪个目录。' : '选择要把当前文件或文件夹移动到哪个目录。'}
initialPath={toBackendPath(directoryState.currentPath)}
confirmLabel={overlayState.targetAction === 'copy' ? '复制到这里' : '移动到这里'}
onClose={() => { overlayState.setTargetAction(null); overlayState.setTargetActionFile(null); }}
onConfirm={handleMoveToPath}
/>
</AppPageShell>
);
}
export default FilesPage;

View File

@@ -0,0 +1,50 @@
import React from 'react';
import { Input } from '@/src/components/ui/input';
import { Button } from '@/src/components/ui/button';
export function FilesSearchPanel({
searchQuery,
searchLoading,
isSearchActive,
searchError,
onSearchQueryChange,
onSearchSubmit,
onClearSearch,
}: {
searchQuery: string;
searchLoading: boolean;
isSearchActive: boolean;
searchError: string;
onSearchQueryChange: (query: string) => void;
onSearchSubmit: (event: React.FormEvent<HTMLFormElement>) => void;
onClearSearch: () => void;
}) {
return (
<form className="border-b border-white/10 p-4 pt-0" onSubmit={onSearchSubmit}>
<div className="mt-3 flex flex-col gap-2 md:flex-row">
<Input
value={searchQuery}
onChange={(event) => onSearchQueryChange(event.target.value)}
placeholder="按文件名搜索"
className="h-10 border-white/10 bg-black/20 text-white placeholder:text-slate-500 focus-visible:ring-[#336EFF]"
/>
<div className="flex gap-2">
<Button type="submit" className="shrink-0" disabled={searchLoading}>
{searchLoading ? '搜索中...' : '搜索'}
</Button>
{isSearchActive ? (
<Button
type="button"
variant="outline"
className="shrink-0 border-white/10 text-slate-300 hover:bg-white/10"
onClick={onClearSearch}
>
</Button>
) : null}
</div>
</div>
{searchError ? <p className="mt-2 text-sm text-red-400">{searchError}</p> : null}
</form>
);
}

View File

@@ -0,0 +1,148 @@
import React from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/src/components/ui/card';
import { Button } from '@/src/components/ui/button';
import { RotateCcw } from 'lucide-react';
import { cn } from '@/src/lib/utils';
import type { BackgroundTask } from '@/src/lib/background-tasks';
import { formatDateTime } from './file-types';
export function formatTaskDateTime(value: string) {
return formatDateTime(value);
}
export function getBackgroundTaskTypeLabel(type: BackgroundTask['type']) {
switch (type) {
case 'ARCHIVE': return '压缩任务';
case 'EXTRACT': return '解压任务';
case 'MEDIA_META': return '媒体信息提取任务';
}
}
export function getBackgroundTaskStatusLabel(status: BackgroundTask['status']) {
switch (status) {
case 'QUEUED': return '排队中';
case 'RUNNING': return '执行中';
case 'COMPLETED': return '已完成';
case 'FAILED': return '已失败';
case 'CANCELLED': return '已取消';
}
}
export function getBackgroundTaskStatusClassName(status: BackgroundTask['status']) {
switch (status) {
case 'QUEUED': return 'text-amber-300';
case 'RUNNING': return 'text-sky-300';
case 'COMPLETED': return 'text-emerald-300';
case 'FAILED': return 'text-red-300';
case 'CANCELLED': return 'text-slate-400';
}
}
export function FilesTaskPanel({
backgroundTasks,
backgroundTasksLoading,
backgroundTasksError,
backgroundTaskNotice,
backgroundTaskActionId,
onRefresh,
onCancelTask,
}: {
backgroundTasks: BackgroundTask[];
backgroundTasksLoading: boolean;
backgroundTasksError: string;
backgroundTaskNotice: { kind: 'success' | 'error'; message: string } | null;
backgroundTaskActionId: number | null;
onRefresh: () => void;
onCancelTask: (taskId: number) => void;
}) {
return (
<Card>
<CardHeader className="border-b border-white/10 pb-4">
<div className="flex items-center justify-between gap-3">
<CardTitle className="text-base"></CardTitle>
<button
type="button"
className="flex h-8 w-8 items-center justify-center rounded-md text-slate-400 transition-colors hover:bg-white/10 hover:text-white"
onClick={onRefresh}
aria-label="刷新后台任务"
>
<RotateCcw className={cn('h-4 w-4', backgroundTasksLoading ? 'animate-spin' : '')} />
</button>
</div>
</CardHeader>
<CardContent className="space-y-3 p-4">
{backgroundTaskNotice ? (
<div
className={cn(
'rounded-xl border px-3 py-2 text-xs leading-relaxed',
backgroundTaskNotice.kind === 'error'
? 'border-red-500/20 bg-red-500/10 text-red-200'
: 'border-emerald-500/20 bg-emerald-500/10 text-emerald-200',
)}
aria-live="polite"
>
{backgroundTaskNotice.message}
</div>
) : null}
{backgroundTasksError ? (
<div className="rounded-xl border border-red-500/20 bg-red-500/10 px-3 py-2 text-xs text-red-200">
{backgroundTasksError}
</div>
) : null}
{backgroundTasksLoading ? (
<div className="rounded-xl border border-white/10 bg-white/[0.02] px-3 py-4 text-sm text-slate-400">
...
</div>
) : backgroundTasks.length === 0 ? (
<div className="rounded-xl border border-white/10 bg-white/[0.02] px-3 py-4 text-sm text-slate-400">
</div>
) : (
<div className="max-h-[32rem] space-y-3 overflow-y-auto pr-1">
{backgroundTasks.map((task) => {
const canCancel = task.status === 'QUEUED' || task.status === 'RUNNING';
return (
<div key={task.id} className="rounded-xl border border-white/10 bg-white/[0.03] p-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="truncate text-sm font-medium text-white">{getBackgroundTaskTypeLabel(task.type)}</p>
<p className={cn('text-xs', getBackgroundTaskStatusClassName(task.status))}>
{getBackgroundTaskStatusLabel(task.status)}
</p>
</div>
{canCancel ? (
<Button
type="button"
variant="outline"
className="shrink-0 border-white/10 bg-white/5 px-3 text-xs text-slate-200 hover:bg-white/10"
onClick={() => onCancelTask(task.id)}
disabled={backgroundTaskActionId === task.id}
>
{backgroundTaskActionId === task.id ? '取消中...' : '取消'}
</Button>
) : null}
</div>
<div className="mt-3 grid grid-cols-2 gap-2 text-xs">
<div className="min-w-0">
<p className="text-slate-500"></p>
<p className="truncate text-slate-300">{formatTaskDateTime(task.createdAt)}</p>
</div>
<div className="min-w-0">
<p className="text-slate-500"></p>
<p className="truncate text-slate-300">{task.finishedAt ? formatTaskDateTime(task.finishedAt) : '未完成'}</p>
</div>
</div>
{task.errorMessage ? (
<div className="mt-3 break-words rounded-lg border border-red-500/20 bg-red-500/10 px-2 py-1 text-xs leading-relaxed text-red-200">
{task.errorMessage}
</div>
) : null}
</div>
);
})}
</div>
)}
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,94 @@
import React from 'react';
import { ChevronRight, List, LayoutGrid, Upload, FolderUp, Plus } from 'lucide-react';
import { Button } from '@/src/components/ui/button';
import { cn } from '@/src/lib/utils';
export function FilesToolbar({
currentPath,
shareStatus,
viewMode,
onNavigateToRoot,
onBreadcrumbClick,
onViewModeChange,
onUploadClick,
onUploadFolderClick,
onCreateFolder,
fileInputRef,
directoryInputRef,
onFileChange,
onFolderChange,
}: {
currentPath: string[];
shareStatus: string;
viewMode: 'list' | 'grid';
onNavigateToRoot: () => void;
onBreadcrumbClick: (index: number) => void;
onViewModeChange: (mode: 'list' | 'grid') => void;
onUploadClick: () => void;
onUploadFolderClick: () => void;
onCreateFolder: () => void;
fileInputRef: React.RefObject<HTMLInputElement>;
directoryInputRef: React.RefObject<HTMLInputElement>;
onFileChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
onFolderChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
}) {
return (
<>
<div className="p-4 border-b border-white/10 flex items-center justify-between shrink-0">
<div className="flex items-center text-sm text-slate-400">
<button className="hover:text-white transition-colors" onClick={onNavigateToRoot}>
</button>
{currentPath.map((pathItem, index) => (
<React.Fragment key={index}>
<ChevronRight className="w-4 h-4 mx-1" />
<button
onClick={() => onBreadcrumbClick(index)}
className={cn('transition-colors', index === currentPath.length - 1 ? 'text-white font-medium' : 'hover:text-white')}
>
{pathItem}
</button>
</React.Fragment>
))}
</div>
{shareStatus ? (
<div className="hidden max-w-xs truncate text-xs text-emerald-300 md:block">{shareStatus}</div>
) : null}
<div className="flex items-center gap-2 bg-black/20 p-1 rounded-lg">
<button
onClick={() => onViewModeChange('list')}
className={cn(
'p-1.5 rounded-md transition-colors',
viewMode === 'list' ? 'bg-white/10 text-white' : 'text-slate-400 hover:text-white',
)}
>
<List className="w-4 h-4" />
</button>
<button
onClick={() => onViewModeChange('grid')}
className={cn(
'p-1.5 rounded-md transition-colors',
viewMode === 'grid' ? 'bg-white/10 text-white' : 'text-slate-400 hover:text-white',
)}
>
<LayoutGrid className="w-4 h-4" />
</button>
</div>
</div>
<div className="p-4 border-t border-white/10 flex items-center gap-3 shrink-0 bg-white/[0.01]">
<Button variant="default" className="gap-2" onClick={onUploadClick}>
<Upload className="w-4 h-4" />
</Button>
<Button variant="outline" className="gap-2" onClick={onUploadFolderClick}>
<FolderUp className="w-4 h-4" />
</Button>
<Button variant="outline" className="gap-2" onClick={onCreateFolder}>
<Plus className="w-4 h-4" />
</Button>
<input ref={fileInputRef} type="file" multiple className="hidden" onChange={onFileChange} />
{/* @ts-ignore - directory attributes are non-standard but work */}
<input ref={directoryInputRef} type="file" multiple directory="" webkitdirectory="" className="hidden" onChange={onFolderChange} />
</div>
</>
);
}

View File

@@ -0,0 +1,46 @@
import { resolveStoredFileType, type FileTypeKind } from '@/src/lib/file-type';
import type { FileMetadata } from '@/src/lib/types';
export interface UiFile {
id: FileMetadata['id'];
modified: string;
name: string;
size: string;
type: FileTypeKind;
typeLabel: string;
}
export function formatFileSize(size: number) {
if (size <= 0) return '—';
const units = ['B', 'KB', 'MB', 'GB'];
const index = Math.min(Math.floor(Math.log(size) / Math.log(1024)), units.length - 1);
const value = size / 1024 ** index;
return `${value.toFixed(value >= 10 || index === 0 ? 0 : 1)} ${units[index]}`;
}
export function formatDateTime(value: string) {
return new Intl.DateTimeFormat('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
}).format(new Date(value));
}
export function toUiFile(file: FileMetadata): UiFile {
const resolvedType = resolveStoredFileType({
filename: file.filename,
contentType: file.contentType,
directory: file.directory,
});
return {
id: file.id,
name: file.filename,
type: resolvedType.kind,
typeLabel: resolvedType.label,
size: file.directory ? '—' : formatFileSize(file.size),
modified: formatDateTime(file.createdAt),
};
}

View File

@@ -0,0 +1,98 @@
import { useState, useCallback } from 'react';
import {
cancelBackgroundTask,
createMediaMetadataTask,
listBackgroundTasks,
type BackgroundTask,
} from '@/src/lib/background-tasks';
import { toBackendPath } from './useFilesDirectoryState';
export function useBackgroundTasksState() {
const [backgroundTasks, setBackgroundTasks] = useState<BackgroundTask[]>([]);
const [backgroundTasksLoading, setBackgroundTasksLoading] = useState(false);
const [backgroundTasksError, setBackgroundTasksError] = useState('');
const [backgroundTaskNotice, setBackgroundTaskNotice] = useState<{ kind: 'success' | 'error'; message: string } | null>(null);
const [backgroundTaskActionId, setBackgroundTaskActionId] = useState<number | null>(null);
const loadBackgroundTasks = useCallback(async () => {
setBackgroundTasksLoading(true);
setBackgroundTasksError('');
try {
const response = await listBackgroundTasks({ page: 0, size: 10 });
setBackgroundTasks(response.items);
} catch (error) {
setBackgroundTasksError(error instanceof Error ? error.message : '获取后台任务失败');
} finally {
setBackgroundTasksLoading(false);
}
}, []);
const handleCreateMediaMetadataTask = async (
fileId: number,
fileName: string,
isDirectory: boolean,
currentPath: string[]
) => {
if (isDirectory) return;
const taskPath = currentPath.length === 0 ? `/${fileName}` : `${toBackendPath(currentPath)}/${fileName}`;
const correlationId = `media-meta:${fileId}:${Date.now()}`;
setBackgroundTaskNotice(null);
setBackgroundTaskActionId(fileId);
try {
await createMediaMetadataTask({
fileId,
path: taskPath,
correlationId,
});
setBackgroundTaskNotice({
kind: 'success',
message: '已创建媒体信息提取任务,可在右侧后台任务面板查看状态。',
});
await loadBackgroundTasks();
} catch (error) {
setBackgroundTaskNotice({
kind: 'error',
message: error instanceof Error ? error.message : '创建媒体信息提取任务失败',
});
} finally {
setBackgroundTaskActionId(null);
}
};
const handleCancelBackgroundTask = async (taskId: number) => {
setBackgroundTaskNotice(null);
setBackgroundTaskActionId(taskId);
try {
await cancelBackgroundTask(taskId);
setBackgroundTaskNotice({
kind: 'success',
message: `已取消任务 ${taskId},后台列表已刷新。`,
});
await loadBackgroundTasks();
} catch (error) {
setBackgroundTaskNotice({
kind: 'error',
message: error instanceof Error ? error.message : '取消任务失败',
});
} finally {
setBackgroundTaskActionId(null);
}
};
return {
backgroundTasks,
backgroundTasksLoading,
backgroundTasksError,
backgroundTaskNotice,
backgroundTaskActionId,
loadBackgroundTasks,
handleCreateMediaMetadataTask,
handleCancelBackgroundTask,
setBackgroundTaskNotice,
};
}

View File

@@ -0,0 +1,187 @@
import { useEffect, useRef, useState } from 'react';
import { apiRequest } from '@/src/lib/api';
import { readCachedValue, removeCachedValue, writeCachedValue } from '@/src/lib/cache';
import { subscribeFileEvents } from '@/src/lib/file-events';
import { getFilesLastPathCacheKey, getFilesListCacheKey } from '@/src/lib/page-cache';
import type { FileMetadata, PageResponse } from '@/src/lib/types';
import {
createExpandedDirectorySet,
getMissingDirectoryListingPaths,
hasLoadedDirectoryListing,
mergeDirectoryChildren,
toDirectoryPath,
type DirectoryChildrenMap,
} from '../files-tree';
import { toUiFile, type UiFile } from './file-types';
export function toBackendPath(pathParts: string[]) {
return toDirectoryPath(pathParts);
}
export function splitBackendPath(path: string) {
return path.split('/').filter(Boolean);
}
export function useFilesDirectoryState() {
const initialPath = readCachedValue<string[]>(getFilesLastPathCacheKey()) ?? [];
const initialCachedFiles = readCachedValue<FileMetadata[]>(getFilesListCacheKey(toBackendPath(initialPath))) ?? [];
const [currentPath, setCurrentPath] = useState<string[]>(initialPath);
const currentPathRef = useRef(currentPath);
const [directoryChildren, setDirectoryChildren] = useState<DirectoryChildrenMap>(() => {
if (initialCachedFiles.length === 0) return {};
return mergeDirectoryChildren(
{},
toBackendPath(initialPath),
initialCachedFiles.filter((file) => file.directory).map((file) => file.filename),
);
});
const [loadedDirectoryPaths, setLoadedDirectoryPaths] = useState<Set<string>>(
() => new Set(initialCachedFiles.length === 0 ? [] : [toBackendPath(initialPath)]),
);
const [expandedDirectories, setExpandedDirectories] = useState(() => createExpandedDirectorySet(initialPath));
const [currentFiles, setCurrentFiles] = useState<UiFile[]>(initialCachedFiles.map(toUiFile));
const recordDirectoryChildren = (pathParts: string[], items: FileMetadata[]) => {
setDirectoryChildren((previous) => {
let next = mergeDirectoryChildren(
previous,
toBackendPath(pathParts),
items.filter((file) => file.directory).map((file) => file.filename),
);
for (let index = 0; index < pathParts.length; index += 1) {
next = mergeDirectoryChildren(
next,
toBackendPath(pathParts.slice(0, index)),
[pathParts[index]],
);
}
return next;
});
};
const markDirectoryLoaded = (pathParts: string[]) => {
const path = toBackendPath(pathParts);
setLoadedDirectoryPaths((previous) => {
if (previous.has(path)) return previous;
const next = new Set(previous);
next.add(path);
return next;
});
};
const loadCurrentPath = async (pathParts: string[]) => {
const response = await apiRequest<PageResponse<FileMetadata>>(
`/files/list?path=${encodeURIComponent(toBackendPath(pathParts))}&page=0&size=100`
);
writeCachedValue(getFilesListCacheKey(toBackendPath(pathParts)), response.items);
writeCachedValue(getFilesLastPathCacheKey(), pathParts);
recordDirectoryChildren(pathParts, response.items);
markDirectoryLoaded(pathParts);
setCurrentFiles(response.items.map(toUiFile));
};
useEffect(() => {
currentPathRef.current = currentPath;
setExpandedDirectories((previous) => {
const next = new Set(previous);
for (const path of createExpandedDirectorySet(currentPath)) {
next.add(path);
}
return next;
});
const cachedFiles = readCachedValue<FileMetadata[]>(getFilesListCacheKey(toBackendPath(currentPath)));
writeCachedValue(getFilesLastPathCacheKey(), currentPath);
if (cachedFiles) {
recordDirectoryChildren(currentPath, cachedFiles);
setCurrentFiles(cachedFiles.map(toUiFile));
}
loadCurrentPath(currentPath).catch(() => {
if (!cachedFiles) {
setCurrentFiles([]);
}
});
}, [currentPath]);
useEffect(() => {
const missingAncestors = getMissingDirectoryListingPaths(currentPath, loadedDirectoryPaths);
if (missingAncestors.length === 0) return;
let cancelled = false;
Promise.all(
missingAncestors.map(async (pathParts) => {
const path = toBackendPath(pathParts);
const response = await apiRequest<PageResponse<FileMetadata>>(
`/files/list?path=${encodeURIComponent(path)}&page=0&size=100`
);
writeCachedValue(getFilesListCacheKey(path), response.items);
return { pathParts, items: response.items };
}),
).then((responses) => {
if (cancelled) return;
for (const response of responses) {
recordDirectoryChildren(response.pathParts, response.items);
markDirectoryLoaded(response.pathParts);
}
}).catch(() => {});
return () => { cancelled = true; };
}, [currentPath, loadedDirectoryPaths]);
useEffect(() => {
const subscription = subscribeFileEvents({
path: toBackendPath(currentPath),
onFileEvent: () => {
const activePath = currentPathRef.current;
removeCachedValue(getFilesListCacheKey(toBackendPath(activePath)));
loadCurrentPath(activePath).catch(() => undefined);
},
onError: () => undefined,
});
return () => { subscription.close(); };
}, [currentPath]);
const handleDirectoryToggle = async (pathParts: string[]) => {
const path = toBackendPath(pathParts);
let shouldLoadChildren = false;
setExpandedDirectories((previous) => {
const next = new Set(previous);
if (next.has(path)) {
next.delete(path);
return next;
}
next.add(path);
shouldLoadChildren = !hasLoadedDirectoryListing(pathParts, loadedDirectoryPaths);
return next;
});
if (!shouldLoadChildren) return;
try {
const response = await apiRequest<PageResponse<FileMetadata>>(
`/files/list?path=${encodeURIComponent(path)}&page=0&size=100`
);
writeCachedValue(getFilesListCacheKey(path), response.items);
recordDirectoryChildren(pathParts, response.items);
markDirectoryLoaded(pathParts);
} catch {}
};
return {
currentPath,
setCurrentPath,
directoryChildren,
expandedDirectories,
currentFiles,
setCurrentFiles,
handleDirectoryToggle,
loadCurrentPath,
};
}

View File

@@ -0,0 +1,61 @@
import { useState } from 'react';
import type { UiFile } from './file-types';
export type NetdiskTargetAction = 'move' | 'copy';
export function useFilesOverlayState() {
const [renameModalOpen, setRenameModalOpen] = useState(false);
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
const [fileToRename, setFileToRename] = useState<UiFile | null>(null);
const [fileToDelete, setFileToDelete] = useState<UiFile | null>(null);
const [targetActionFile, setTargetActionFile] = useState<UiFile | null>(null);
const [targetAction, setTargetAction] = useState<NetdiskTargetAction | null>(null);
const [newFileName, setNewFileName] = useState('');
const [activeDropdown, setActiveDropdown] = useState<number | null>(null);
const [renameError, setRenameError] = useState('');
const [isRenaming, setIsRenaming] = useState(false);
const openRenameModal = (file: UiFile) => {
setFileToRename(file);
setNewFileName(file.name);
setRenameError('');
setRenameModalOpen(true);
};
const openDeleteModal = (file: UiFile) => {
setFileToDelete(file);
setDeleteModalOpen(true);
};
const openTargetActionModal = (file: UiFile, action: NetdiskTargetAction) => {
setTargetAction(action);
setTargetActionFile(file);
setActiveDropdown(null);
};
return {
renameModalOpen,
setRenameModalOpen,
deleteModalOpen,
setDeleteModalOpen,
fileToRename,
setFileToRename,
fileToDelete,
setFileToDelete,
targetActionFile,
setTargetActionFile,
targetAction,
setTargetAction,
newFileName,
setNewFileName,
activeDropdown,
setActiveDropdown,
renameError,
setRenameError,
isRenaming,
setIsRenaming,
openRenameModal,
openDeleteModal,
openTargetActionModal,
};
}

View File

@@ -0,0 +1,81 @@
import { useRef, useState } from 'react';
import { searchFiles } from '@/src/lib/file-search';
import type { FileMetadata } from '@/src/lib/types';
export function useFilesSearchState() {
const [searchQuery, setSearchQuery] = useState('');
const [searchAppliedQuery, setSearchAppliedQuery] = useState('');
const [searchResults, setSearchResults] = useState<FileMetadata[] | null>(null);
const [searchLoading, setSearchLoading] = useState(false);
const [searchError, setSearchError] = useState('');
const [selectedSearchFile, setSelectedSearchFile] = useState<FileMetadata | null>(null);
const searchRequestIdRef = useRef(0);
const clearSearchState = () => {
searchRequestIdRef.current += 1;
setSearchQuery('');
setSearchAppliedQuery('');
setSearchResults(null);
setSearchLoading(false);
setSearchError('');
setSelectedSearchFile(null);
};
const executeSearch = async (query: string, onStart?: () => void) => {
const nextQuery = query.trim();
if (!nextQuery) {
clearSearchState();
return;
}
const requestId = searchRequestIdRef.current + 1;
searchRequestIdRef.current = requestId;
setSearchAppliedQuery(nextQuery);
setSearchLoading(true);
setSearchError('');
setSearchResults(null);
setSelectedSearchFile(null);
onStart?.();
try {
const response = await searchFiles({
name: nextQuery,
type: 'all',
page: 0,
size: 100,
});
if (searchRequestIdRef.current !== requestId) return;
setSearchResults(response.items);
} catch (error) {
if (searchRequestIdRef.current !== requestId) return;
setSearchResults([]);
setSearchError(error instanceof Error ? error.message : '搜索失败');
} finally {
if (searchRequestIdRef.current === requestId) {
setSearchLoading(false);
}
}
};
const handleSearchSubmit = async (event: React.FormEvent<HTMLFormElement>, onStart?: () => void) => {
event.preventDefault();
await executeSearch(searchQuery, onStart);
};
const isSearchActive = searchAppliedQuery.trim().length > 0;
return {
searchQuery,
setSearchQuery,
searchAppliedQuery,
searchResults,
searchLoading,
searchError,
selectedSearchFile,
setSelectedSearchFile,
clearSearchState,
handleSearchSubmit,
isSearchActive,
};
}

View File

@@ -183,3 +183,6 @@
- 2026-04-09 阶段 6 第八步:后台任务补了运行期 heartbeat 与多实例 lease。`BackgroundTask` 现在持久化 `leaseOwner/leaseExpiresAt/heartbeatAt`worker 每次 claim 会写入唯一 `workerOwner` 并续租,运行中 progress/完成/失败都会刷新 heartbeat。`ARCHIVE/EXTRACT` 的公开 state 现已附带真实 `progressPercent``MEDIA_META` 会暴露 `metadataStage`;多实例下会先回收 lease 过期的 `RUNNING` 任务,再领取 `QUEUED` 任务,旧 worker 若丢失 owner 则不会再覆盖新状态。 - 2026-04-09 阶段 6 第八步:后台任务补了运行期 heartbeat 与多实例 lease。`BackgroundTask` 现在持久化 `leaseOwner/leaseExpiresAt/heartbeatAt`worker 每次 claim 会写入唯一 `workerOwner` 并续租,运行中 progress/完成/失败都会刷新 heartbeat。`ARCHIVE/EXTRACT` 的公开 state 现已附带真实 `progressPercent``MEDIA_META` 会暴露 `metadataStage`;多实例下会先回收 lease 过期的 `RUNNING` 任务,再领取 `QUEUED` 任务,旧 worker 若丢失 owner 则不会再覆盖新状态。
- 2026-04-09 桌面端 `Files` 已补最近 10 条后台任务面板,支持查看状态、取消 `QUEUED/RUNNING` 任务,并可为当前选中文件创建媒体信息提取任务;移动端和 archive/extract 的前端入口暂未接入。 - 2026-04-09 桌面端 `Files` 已补最近 10 条后台任务面板,支持查看状态、取消 `QUEUED/RUNNING` 任务,并可为当前选中文件创建媒体信息提取任务;移动端和 archive/extract 的前端入口暂未接入。
- 2026-04-09 files 后端结构清理:`backend/src/main/java/com/yoyuzh/files` 不再平铺大部分领域类,现已按职责重组为 `core/upload/share/search/events/tasks/storage/policy` 八个子包;类名、接口路径、数据库表名/字段名和现有测试语义保持不变,主要是通过 package 重组、import 修正和测试路径同步降低后续继续演进 upload/share/search/events/tasks/storage-policy 的维护摩擦。 - 2026-04-09 files 后端结构清理:`backend/src/main/java/com/yoyuzh/files` 不再平铺大部分领域类,现已按职责重组为 `core/upload/share/search/events/tasks/storage/policy` 八个子包;类名、接口路径、数据库表名/字段名和现有测试语义保持不变,主要是通过 package 重组、import 修正和测试路径同步降低后续继续演进 upload/share/search/events/tasks/storage-policy 的维护摩擦。
- 2026-04-09 存储策略管理后端继续收口:管理员接口已从只读 `GET /api/admin/storage-policies` 扩展到 `POST /api/admin/storage-policies``PUT /api/admin/storage-policies/{policyId}``PATCH /api/admin/storage-policies/{policyId}/status``POST /api/admin/storage-policies/migrations`。当前支持新增、编辑、启停非默认策略,并可创建 `STORAGE_POLICY_MIGRATION` 后台任务;默认策略不能停用,仍不支持删除策略或切换默认策略。
- 2026-04-09 存储策略与上传路径后端继续推进:`STORAGE_POLICY_MIGRATION` 现已从 skeleton 升级为“当前活动存储后端内的真实迁移”。worker 会限制源/目标策略必须同类型,读取旧 `FileBlob` 对象字节,写入新的 `policies/{targetPolicyId}/blobs/...` object key同步更新 `FileBlob.objectKey``FileEntity.VERSION(objectKey, storagePolicyId)`并在事务提交后异步清理旧对象若处理中失败会删除本轮新写对象并依赖事务回滚元数据。与此同时v2 upload session 现在会按默认策略能力决策 `uploadMode=PROXY|DIRECT_SINGLE|DIRECT_MULTIPART``directUpload=false` 时走 `POST /api/v2/files/upload-sessions/{sessionId}/content` 代理上传,`directUpload=true && multipartUpload=false` 时走 `GET /api/v2/files/upload-sessions/{sessionId}/prepare` 单请求直传,`multipartUpload=true` 时继续走现有分片 prepare/record/complete 链路;会话响应还会附带 `strategy`,把当前模式下的后续后端入口模板显式返回给前端;旧 `/api/files/upload/initiate` 也会尊重默认策略的 `directUpload/maxObjectSize`
- 2026-04-09 前端 files 上传链路已切到 v2 upload session桌面端 `FilesPage`、移动端 `MobileFilesPage``saveFileToNetdisk()` 现在统一通过 `front/src/lib/upload-session.ts``create/get/cancel/prepare/content/part-prepare/part-record/complete` 全套 helper并按后端返回的 `uploadMode + strategy` 自动选择 `PROXY / DIRECT_SINGLE / DIRECT_MULTIPART`。旧 `/api/files/upload/**` 当前仍保留给头像等非 files 子系统入口使用。