feat(portal): land files platform and frontend workspace refresh
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
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"]
|
||||
sandbox_mode = "workspace-write"
|
||||
include_apply_patch_tool = false
|
||||
|
||||
@@ -22,7 +22,7 @@ config_file = ".codex/agents/implementer.toml"
|
||||
nickname_candidates = ["implementer", "impl", "builder"]
|
||||
|
||||
[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"
|
||||
nickname_candidates = ["tester", "qa", "verify"]
|
||||
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
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.PageResponse;
|
||||
import com.yoyuzh.files.tasks.BackgroundTask;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
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.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PatchMapping;
|
||||
@@ -25,6 +31,7 @@ import java.util.List;
|
||||
public class AdminController {
|
||||
|
||||
private final AdminService adminService;
|
||||
private final CustomUserDetailsService userDetailsService;
|
||||
|
||||
@GetMapping("/summary")
|
||||
public ApiResponse<AdminSummaryResponse> summary() {
|
||||
@@ -59,6 +66,34 @@ public class AdminController {
|
||||
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}")
|
||||
public ApiResponse<Void> deleteFile(@PathVariable Long fileId) {
|
||||
adminService.deleteFile(fileId);
|
||||
@@ -99,4 +134,19 @@ public class AdminController {
|
||||
public ApiResponse<AdminPasswordResetResponse> resetUserPassword(@PathVariable Long 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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,12 +10,18 @@ import com.yoyuzh.common.BusinessException;
|
||||
import com.yoyuzh.common.ErrorCode;
|
||||
import com.yoyuzh.common.PageResponse;
|
||||
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.StoredFile;
|
||||
import com.yoyuzh.files.core.StoredFileEntityRepository;
|
||||
import com.yoyuzh.files.core.StoredFileRepository;
|
||||
import com.yoyuzh.files.policy.StoragePolicy;
|
||||
import com.yoyuzh.files.policy.StoragePolicyRepository;
|
||||
import com.yoyuzh.files.policy.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 lombok.RequiredArgsConstructor;
|
||||
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.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.security.SecureRandom;
|
||||
import java.time.Instant;
|
||||
@@ -45,6 +52,9 @@ public class AdminService {
|
||||
private final AdminMetricsService adminMetricsService;
|
||||
private final StoragePolicyRepository storagePolicyRepository;
|
||||
private final StoragePolicyService storagePolicyService;
|
||||
private final FileEntityRepository fileEntityRepository;
|
||||
private final StoredFileEntityRepository storedFileEntityRepository;
|
||||
private final BackgroundTaskService backgroundTaskService;
|
||||
private final SecureRandom secureRandom = new SecureRandom();
|
||||
|
||||
public AdminSummaryResponse getSummary() {
|
||||
@@ -97,6 +107,75 @@ public class AdminService {
|
||||
.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
|
||||
public void deleteFile(Long 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) {
|
||||
return userRepository.findById(userId)
|
||||
.orElseThrow(() -> new BusinessException(ErrorCode.UNKNOWN, "用户不存在"));
|
||||
}
|
||||
|
||||
private StoragePolicy getRequiredStoragePolicy(Long policyId) {
|
||||
return storagePolicyRepository.findById(policyId)
|
||||
.orElseThrow(() -> new BusinessException(ErrorCode.UNKNOWN, "存储策略不存在"));
|
||||
}
|
||||
|
||||
private String normalizeQuery(String query) {
|
||||
if (query == null) {
|
||||
return "";
|
||||
@@ -226,6 +328,31 @@ public class AdminService {
|
||||
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() {
|
||||
String lowers = "abcdefghjkmnpqrstuvwxyz";
|
||||
String uppers = "ABCDEFGHJKMNPQRSTUVWXYZ";
|
||||
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.yoyuzh.admin;
|
||||
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
|
||||
public record AdminStoragePolicyStatusUpdateRequest(
|
||||
@NotNull(message = "enabled 不能为空")
|
||||
Boolean enabled
|
||||
) {
|
||||
}
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import com.yoyuzh.auth.CustomUserDetailsService;
|
||||
import com.yoyuzh.auth.User;
|
||||
import com.yoyuzh.files.upload.UploadSession;
|
||||
import com.yoyuzh.files.upload.UploadSessionCreateCommand;
|
||||
import com.yoyuzh.files.upload.UploadSessionUploadMode;
|
||||
import com.yoyuzh.files.upload.UploadSessionPartCommand;
|
||||
import com.yoyuzh.files.upload.UploadSessionService;
|
||||
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.RequestBody;
|
||||
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.multipart.MultipartFile;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v2/files/upload-sessions")
|
||||
@@ -49,6 +52,20 @@ public class UploadSessionV2Controller {
|
||||
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}")
|
||||
public ApiV2Response<UploadSessionV2Response> cancelSession(@AuthenticationPrincipal UserDetails userDetails,
|
||||
@PathVariable String sessionId) {
|
||||
@@ -78,6 +95,14 @@ public class UploadSessionV2Controller {
|
||||
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")
|
||||
public ApiV2Response<PreparedUploadV2Response> preparePartUpload(@AuthenticationPrincipal UserDetails userDetails,
|
||||
@PathVariable String sessionId,
|
||||
@@ -94,10 +119,18 @@ public class UploadSessionV2Controller {
|
||||
}
|
||||
|
||||
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(
|
||||
session.getSessionId(),
|
||||
session.getObjectKey(),
|
||||
session.getMultipartUploadId() != null,
|
||||
uploadMode != UploadSessionUploadMode.PROXY,
|
||||
uploadMode == UploadSessionUploadMode.DIRECT_MULTIPART,
|
||||
uploadMode.name(),
|
||||
session.getTargetPath(),
|
||||
session.getFilename(),
|
||||
session.getContentType(),
|
||||
@@ -108,7 +141,38 @@ public class UploadSessionV2Controller {
|
||||
session.getChunkCount(),
|
||||
session.getExpiresAt(),
|
||||
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
|
||||
);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,9 @@ import java.time.LocalDateTime;
|
||||
public record UploadSessionV2Response(
|
||||
String sessionId,
|
||||
String objectKey,
|
||||
boolean directUpload,
|
||||
boolean multipartUpload,
|
||||
String uploadMode,
|
||||
String path,
|
||||
String filename,
|
||||
String contentType,
|
||||
@@ -16,6 +18,7 @@ public record UploadSessionV2Response(
|
||||
int chunkCount,
|
||||
LocalDateTime expiresAt,
|
||||
LocalDateTime createdAt,
|
||||
LocalDateTime updatedAt
|
||||
LocalDateTime updatedAt,
|
||||
UploadSessionV2StrategyResponse strategy
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
@@ -2,9 +2,14 @@ package com.yoyuzh.files.core;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface FileEntityRepository extends JpaRepository<FileEntity, Long> {
|
||||
|
||||
Optional<FileEntity> findByObjectKeyAndEntityType(String objectKey, FileEntityType entityType);
|
||||
|
||||
long countByStoragePolicyIdAndEntityType(Long storagePolicyId, FileEntityType entityType);
|
||||
|
||||
List<FileEntity> findByStoragePolicyIdAndEntityTypeOrderByIdAsc(Long storagePolicyId, FileEntityType entityType);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ import com.yoyuzh.common.PageResponse;
|
||||
import com.yoyuzh.config.FileStorageProperties;
|
||||
import com.yoyuzh.files.events.FileEventService;
|
||||
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.share.CreateFileShareLinkResponse;
|
||||
import com.yoyuzh.files.share.FileShareDetailsResponse;
|
||||
@@ -159,6 +161,10 @@ public class FileService {
|
||||
validateUpload(user, normalizedPath, filename, request.size());
|
||||
|
||||
String objectKey = createBlobObjectKey();
|
||||
StoragePolicyCapabilities capabilities = resolveDefaultStoragePolicyCapabilities();
|
||||
if (capabilities != null && !capabilities.directUpload()) {
|
||||
return new InitiateUploadResponse(false, "", "POST", Map.of(), objectKey);
|
||||
}
|
||||
PreparedUpload preparedUpload = fileContentStorage.prepareBlobUpload(
|
||||
normalizedPath,
|
||||
filename,
|
||||
@@ -856,6 +862,13 @@ public class FileService {
|
||||
return storagePolicyService.ensureDefaultPolicy().getId();
|
||||
}
|
||||
|
||||
private StoragePolicyCapabilities resolveDefaultStoragePolicyCapabilities() {
|
||||
if (storagePolicyService == null) {
|
||||
return null;
|
||||
}
|
||||
return storagePolicyService.readCapabilities(storagePolicyService.ensureDefaultPolicy());
|
||||
}
|
||||
|
||||
private void savePrimaryEntityRelation(StoredFile storedFile, FileEntity primaryEntity) {
|
||||
if (storedFileEntityRepository == null) {
|
||||
return;
|
||||
@@ -927,6 +940,14 @@ public class FileService {
|
||||
|
||||
private void validateUpload(User user, String normalizedPath, String filename, long size) {
|
||||
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) {
|
||||
throw new BusinessException(ErrorCode.UNKNOWN, "文件大小超出限制");
|
||||
}
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
package com.yoyuzh.files.core;
|
||||
|
||||
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> {
|
||||
|
||||
@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);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package com.yoyuzh.files.policy;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.yoyuzh.common.BusinessException;
|
||||
import com.yoyuzh.common.ErrorCode;
|
||||
import com.yoyuzh.config.FileStorageProperties;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
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() {
|
||||
if ("s3".equalsIgnoreCase(properties.getProvider())) {
|
||||
return createDefaultS3Policy();
|
||||
@@ -95,14 +110,6 @@ public class StoragePolicyService implements CommandLineRunner {
|
||||
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) {
|
||||
if (!StringUtils.hasText(scope)) {
|
||||
return null;
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.yoyuzh.files.tasks;
|
||||
public enum BackgroundTaskType {
|
||||
ARCHIVE,
|
||||
EXTRACT,
|
||||
STORAGE_POLICY_MIGRATION,
|
||||
THUMBNAIL,
|
||||
MEDIA_META,
|
||||
REMOTE_DOWNLOAD,
|
||||
|
||||
@@ -98,6 +98,7 @@ public class BackgroundTaskWorker {
|
||||
case ARCHIVE -> "archiving";
|
||||
case EXTRACT -> "extracting";
|
||||
case MEDIA_META -> "extracting-metadata";
|
||||
case STORAGE_POLICY_MIGRATION -> "planning-storage-policy-migration";
|
||||
default -> "running";
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import com.yoyuzh.config.FileStorageProperties;
|
||||
import com.yoyuzh.files.core.FileService;
|
||||
import com.yoyuzh.files.core.StoredFileRepository;
|
||||
import com.yoyuzh.files.policy.StoragePolicy;
|
||||
import com.yoyuzh.files.policy.StoragePolicyCapabilities;
|
||||
import com.yoyuzh.files.policy.StoragePolicyService;
|
||||
import com.yoyuzh.files.storage.FileContentStorage;
|
||||
import com.yoyuzh.files.storage.MultipartCompletedPart;
|
||||
@@ -18,6 +19,7 @@ import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.LocalDateTime;
|
||||
@@ -78,7 +80,10 @@ public class UploadSessionService {
|
||||
public UploadSession createSession(User user, UploadSessionCreateCommand command) {
|
||||
String normalizedPath = normalizeDirectoryPath(command.path());
|
||||
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();
|
||||
session.setSessionId(UUID.randomUUID().toString());
|
||||
@@ -88,17 +93,18 @@ public class UploadSessionService {
|
||||
session.setContentType(command.contentType());
|
||||
session.setSize(command.size());
|
||||
session.setObjectKey(createBlobObjectKey());
|
||||
StoragePolicy policy = storagePolicyService.ensureDefaultPolicy();
|
||||
session.setStoragePolicyId(policy.getId());
|
||||
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.setStatus(UploadSessionStatus.CREATED);
|
||||
LocalDateTime now = LocalDateTime.ofInstant(clock.instant(), clock.getZone());
|
||||
session.setCreatedAt(now);
|
||||
session.setUpdatedAt(now);
|
||||
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()));
|
||||
}
|
||||
return uploadSessionRepository.save(session);
|
||||
@@ -121,12 +127,30 @@ public class UploadSessionService {
|
||||
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)
|
||||
public PreparedUpload prepareOwnedPartUpload(User user, String sessionId, int partIndex) {
|
||||
UploadSession session = getOwnedSession(user, sessionId);
|
||||
LocalDateTime now = LocalDateTime.ofInstant(clock.instant(), clock.getZone());
|
||||
ensureSessionCanReceivePart(session, now);
|
||||
if (!StringUtils.hasText(session.getMultipartUploadId())) {
|
||||
if (resolveUploadMode(session) != UploadSessionUploadMode.DIRECT_MULTIPART
|
||||
|| !StringUtils.hasText(session.getMultipartUploadId())) {
|
||||
throw new BusinessException(ErrorCode.UNKNOWN, "上传会话未启用 multipart");
|
||||
}
|
||||
if (partIndex < 0 || partIndex >= session.getChunkCount()) {
|
||||
@@ -149,6 +173,9 @@ public class UploadSessionService {
|
||||
UploadSession session = getOwnedSession(user, sessionId);
|
||||
LocalDateTime now = LocalDateTime.ofInstant(clock.instant(), clock.getZone());
|
||||
ensureSessionCanReceivePart(session, now);
|
||||
if (resolveUploadMode(session) != UploadSessionUploadMode.DIRECT_MULTIPART) {
|
||||
throw new BusinessException(ErrorCode.UNKNOWN, "上传会话未启用 multipart");
|
||||
}
|
||||
if (partIndex < 0 || partIndex >= session.getChunkCount()) {
|
||||
throw new BusinessException(ErrorCode.UNKNOWN, "分片序号不合法");
|
||||
}
|
||||
@@ -172,6 +199,28 @@ public class UploadSessionService {
|
||||
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
|
||||
public UploadSession completeOwnedSession(User user, String sessionId) {
|
||||
UploadSession session = getOwnedSession(user, sessionId);
|
||||
@@ -194,7 +243,8 @@ public class UploadSessionService {
|
||||
uploadSessionRepository.save(session);
|
||||
|
||||
try {
|
||||
if (StringUtils.hasText(session.getMultipartUploadId())) {
|
||||
if (resolveUploadMode(session) == UploadSessionUploadMode.DIRECT_MULTIPART
|
||||
&& StringUtils.hasText(session.getMultipartUploadId())) {
|
||||
fileContentStorage.completeMultipartUpload(
|
||||
session.getObjectKey(),
|
||||
session.getMultipartUploadId(),
|
||||
@@ -246,8 +296,40 @@ public class UploadSessionService {
|
||||
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());
|
||||
if (policy.getMaxSizeBytes() > 0) {
|
||||
effectiveMaxUploadSize = Math.min(effectiveMaxUploadSize, policy.getMaxSizeBytes());
|
||||
}
|
||||
if (capabilities.maxObjectSize() > 0) {
|
||||
effectiveMaxUploadSize = Math.min(effectiveMaxUploadSize, capabilities.maxObjectSize());
|
||||
}
|
||||
if (size > effectiveMaxUploadSize) {
|
||||
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) {
|
||||
if (session.getStatus() == UploadSessionStatus.CANCELLED
|
||||
|| session.getStatus() == UploadSessionStatus.FAILED
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.yoyuzh.files.upload;
|
||||
|
||||
public enum UploadSessionUploadMode {
|
||||
PROXY,
|
||||
DIRECT_SINGLE,
|
||||
DIRECT_MULTIPART
|
||||
}
|
||||
@@ -8,6 +8,9 @@ import com.yoyuzh.files.core.FileBlob;
|
||||
import com.yoyuzh.files.core.FileBlobRepository;
|
||||
import com.yoyuzh.files.core.StoredFile;
|
||||
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 org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
@@ -41,7 +44,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
|
||||
"spring.datasource.password=",
|
||||
"spring.jpa.hibernate.ddl-auto=create-drop",
|
||||
"app.jwt.secret=0123456789abcdef0123456789abcdef",
|
||||
"app.admin.usernames=admin",
|
||||
"app.admin.usernames=admin,alice",
|
||||
"app.storage.root-dir=./target/test-storage-admin"
|
||||
}
|
||||
)
|
||||
@@ -66,6 +69,8 @@ class AdminControllerIntegrationTest {
|
||||
private AdminMetricsStateRepository adminMetricsStateRepository;
|
||||
@Autowired
|
||||
private AdminMetricsService adminMetricsService;
|
||||
@Autowired
|
||||
private StoragePolicyRepository storagePolicyRepository;
|
||||
|
||||
private User portalUser;
|
||||
private User secondaryUser;
|
||||
@@ -338,6 +343,149 @@ class AdminControllerIntegrationTest {
|
||||
.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
|
||||
@WithMockUser(username = "portal-user")
|
||||
void shouldRejectNonAdminUser() throws Exception {
|
||||
|
||||
@@ -9,11 +9,21 @@ import com.yoyuzh.auth.UserRole;
|
||||
import com.yoyuzh.common.BusinessException;
|
||||
import com.yoyuzh.common.PageResponse;
|
||||
import com.yoyuzh.files.core.FileBlobRepository;
|
||||
import com.yoyuzh.files.core.FileEntityRepository;
|
||||
import com.yoyuzh.files.core.FileService;
|
||||
import com.yoyuzh.files.core.StoredFile;
|
||||
import com.yoyuzh.files.core.StoredFileEntityRepository;
|
||||
import com.yoyuzh.files.core.StoredFileRepository;
|
||||
import com.yoyuzh.files.policy.StoragePolicy;
|
||||
import com.yoyuzh.files.policy.StoragePolicyCapabilities;
|
||||
import com.yoyuzh.files.policy.StoragePolicyCredentialMode;
|
||||
import com.yoyuzh.files.policy.StoragePolicyRepository;
|
||||
import com.yoyuzh.files.policy.StoragePolicyService;
|
||||
import com.yoyuzh.files.policy.StoragePolicyType;
|
||||
import com.yoyuzh.files.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 org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
@@ -62,6 +72,12 @@ class AdminServiceTest {
|
||||
private StoragePolicyRepository storagePolicyRepository;
|
||||
@Mock
|
||||
private StoragePolicyService storagePolicyService;
|
||||
@Mock
|
||||
private FileEntityRepository fileEntityRepository;
|
||||
@Mock
|
||||
private StoredFileEntityRepository storedFileEntityRepository;
|
||||
@Mock
|
||||
private BackgroundTaskService backgroundTaskService;
|
||||
|
||||
private AdminService adminService;
|
||||
|
||||
@@ -71,7 +87,8 @@ class AdminServiceTest {
|
||||
userRepository, storedFileRepository, fileBlobRepository, fileService,
|
||||
passwordEncoder, refreshTokenService, registrationInviteService,
|
||||
offlineTransferSessionRepository, adminMetricsService,
|
||||
storagePolicyRepository, storagePolicyService);
|
||||
storagePolicyRepository, storagePolicyService,
|
||||
fileEntityRepository, storedFileEntityRepository, backgroundTaskService);
|
||||
}
|
||||
|
||||
// --- getSummary ---
|
||||
@@ -161,6 +178,133 @@ class AdminServiceTest {
|
||||
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 ---
|
||||
|
||||
@Test
|
||||
@@ -297,4 +441,38 @@ class AdminServiceTest {
|
||||
file.setCreatedAt(LocalDateTime.now());
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.yoyuzh.api.v2.files;
|
||||
import com.yoyuzh.auth.CustomUserDetailsService;
|
||||
import com.yoyuzh.auth.User;
|
||||
import com.yoyuzh.files.upload.UploadSession;
|
||||
import com.yoyuzh.files.upload.UploadSessionUploadMode;
|
||||
import com.yoyuzh.files.upload.UploadSessionService;
|
||||
import com.yoyuzh.files.upload.UploadSessionStatus;
|
||||
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.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.multipart;
|
||||
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.result.MockMvcResultMatchers.jsonPath;
|
||||
@@ -70,7 +72,12 @@ class UploadSessionV2ControllerTest {
|
||||
.andExpect(jsonPath("$.data.sessionId").value("session-1"))
|
||||
.andExpect(jsonPath("$.data.objectKey").value("blobs/session-1"))
|
||||
.andExpect(jsonPath("$.data.status").value("CREATED"))
|
||||
.andExpect(jsonPath("$.data.directUpload").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.chunkCount").value(3));
|
||||
}
|
||||
@@ -88,7 +95,76 @@ class UploadSessionV2ControllerTest {
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.sessionId").value("session-1"))
|
||||
.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
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@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
|
||||
void shouldCompleteDirectUploadAndPersistMetadata() {
|
||||
User user = createUser(7L);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import com.yoyuzh.files.policy.StoragePolicyService;
|
||||
import com.yoyuzh.files.policy.StoragePolicyType;
|
||||
import com.yoyuzh.files.storage.FileContentStorage;
|
||||
import com.yoyuzh.files.storage.PreparedUpload;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
@@ -134,6 +135,111 @@ class UploadSessionServiceTest {
|
||||
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
|
||||
void shouldOnlyReturnSessionOwnedByCurrentUser() {
|
||||
User user = createUser(7L);
|
||||
@@ -153,6 +259,19 @@ class UploadSessionServiceTest {
|
||||
void shouldRejectDuplicateTargetWhenCreatingSession() {
|
||||
User user = createUser(7L);
|
||||
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(
|
||||
user,
|
||||
|
||||
@@ -151,27 +151,36 @@ npm run test
|
||||
- v2 upload session 后端已补齐创建、查询、取消、prepare-part、record-part、complete 和过期清理
|
||||
- `FileContentStorage` 已新增 multipart 抽象;`S3FileContentStorage` 已实现 create/upload-part/complete/abort
|
||||
- 默认 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()` 落库
|
||||
- 过期清理已从普通 `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:存储策略继续推进
|
||||
|
||||
当前状态:
|
||||
|
||||
- `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`
|
||||
|
||||
后续未完成:
|
||||
|
||||
- 管理台新增/编辑/停用策略
|
||||
- 多策略迁移任务
|
||||
- 按策略能力决定上传路径与前端上传策略
|
||||
- 跨不同运行时后端类型的真正 provider 级迁移
|
||||
- 继续把按策略能力的上传体验外扩到其他非 files 子系统上传入口
|
||||
|
||||
## 当前本地运行状态
|
||||
|
||||
|
||||
@@ -251,6 +251,8 @@
|
||||
- `POST /api/v2/files/upload-sessions`
|
||||
- `GET /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`
|
||||
- `PUT /api/v2/files/upload-sessions/{sessionId}/parts/{partIndex}`
|
||||
- `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`
|
||||
- 默认 S3 存储策略下,创建会话时会立即初始化 multipart upload,并把 `multipartUpload=true` 返回给客户端;本地策略仍会返回 `multipartUpload=false`
|
||||
- 会话响应返回 `sessionId`、`objectKey`、`directUpload`、`multipartUpload`、`uploadMode`、`path`、`filename`、`contentType`、`size`、`storagePolicyId`、`status`、`chunkSize`、`chunkCount`、`expiresAt`、`createdAt`、`updatedAt`,以及一个面向前端消费的 `strategy` 对象
|
||||
- `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`
|
||||
- `PUT /parts/{partIndex}` 请求体仍为 `{ "etag": "...", "size": 8388608 }`,只负责记录 part 元数据,不直接接收字节流
|
||||
- `POST /complete` 会先按已记录的 part 元数据提交 multipart complete,再复用旧上传完成链路写入 `FileBlob + StoredFile + FileEntity.VERSION`
|
||||
- 后端每小时清理过期且未完成的会话;若会话已绑定 multipart upload,会优先向对象存储发送 abort
|
||||
- 当前前端网盘上传主链路已经消费这套 v2 接口:桌面/移动文件页和“存入网盘”入口都会按 `uploadMode + strategy` 自动选择代理上传、单请求直传或 multipart 分片上传
|
||||
|
||||
## 4. 快传模块
|
||||
|
||||
@@ -401,13 +408,21 @@
|
||||
|
||||
### 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`
|
||||
|
||||
## 6. 前端公开路由与接口关系
|
||||
|
||||
@@ -166,7 +166,8 @@
|
||||
- 定时清理任务会删除超过 10 天的回收站条目;只有当某个 `FileBlob` 的最后一个逻辑引用随之消失时,才真正删除底层对象
|
||||
- 应用启动时会把旧 `portal_file.storage_name` 行自动回填到新的 `blob_id` 引用,保证存量数据能继续读取
|
||||
- 当前线上网盘文件存储已切到多吉云对象存储,后端先通过多吉云临时密钥 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` 与恢复接口
|
||||
|
||||
@@ -240,6 +241,7 @@ Android 壳补充说明:
|
||||
- 当前邀请码由后端返回给管理台展示
|
||||
- 用户列表会展示每个用户的已用空间 / 配额
|
||||
- 管理员修改用户密码后,旧密码应立即失效,新密码可直接重新登录
|
||||
- 管理台当前已可查看、新增、编辑并启停非默认 `StoragePolicy`,也可创建 `STORAGE_POLICY_MIGRATION` 后台任务;策略能力继续以结构化 `StoragePolicyCapabilities` 持久化和回显。当前迁移任务会在“当前活动存储后端”内复制对象数据到新的 target-policy object key、更新 `FileBlob/FileEntity.VERSION` 元数据,并在事务提交后清理旧对象;但仍不支持跨不同运行时后端类型的真正 provider 级迁移。默认策略切换和策略删除仍未落地
|
||||
- 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 阶段 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-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-08 阶段 5 文件搜索第一小步
|
||||
|
||||
@@ -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 user’s 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” 中看到结果。
|
||||
- 审计日志能覆盖空间、权限、分享、删除/恢复等关键动作。
|
||||
- 旧公开分享、快传、上传会话、后台任务和管理台文件列表不被打断。
|
||||
|
||||
@@ -11,7 +11,8 @@ import MobileOverview from './mobile-pages/MobileOverview';
|
||||
import MobileFiles from './mobile-pages/MobileFiles';
|
||||
import MobileTransfer from './mobile-pages/MobileTransfer';
|
||||
import MobileFileShare from './mobile-pages/MobileFileShare';
|
||||
import RecycleBin from './pages/RecycleBin';
|
||||
import MobileRecycleBin from './mobile-pages/MobileRecycleBin';
|
||||
import MobileAdminUnavailable from './mobile-pages/MobileAdminUnavailable';
|
||||
|
||||
function LegacyTransferRedirect() {
|
||||
const location = useLocation();
|
||||
@@ -54,16 +55,16 @@ function MobileAppRoutes() {
|
||||
<Route index element={<Navigate to="/overview" replace />} />
|
||||
<Route path="overview" element={<MobileOverview />} />
|
||||
<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>
|
||||
|
||||
<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
|
||||
path="/admin/*"
|
||||
element={isAuthenticated ? <Navigate to="/overview" replace /> : <Navigate to="/login" replace />}
|
||||
element={isAuthenticated ? <MobileAdminUnavailable /> : <Navigate to="/login" replace />}
|
||||
/>
|
||||
|
||||
<Route
|
||||
|
||||
@@ -50,7 +50,6 @@ export function Layout({ children }: LayoutProps = {}) {
|
||||
const navItems = getVisibleNavItems(isAdmin);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||
const [activeModal, setActiveModal] = useState<ActiveModal>(null);
|
||||
const [avatarPreviewUrl, setAvatarPreviewUrl] = useState<string | null>(null);
|
||||
const [selectedAvatarFile, setSelectedAvatarFile] = useState<File | null>(null);
|
||||
@@ -192,7 +191,6 @@ export function Layout({ children }: LayoutProps = {}) {
|
||||
if (!currentSession) {
|
||||
return;
|
||||
}
|
||||
|
||||
saveStoredSession({
|
||||
...currentSession,
|
||||
user: nextProfile,
|
||||
@@ -326,113 +324,69 @@ export function Layout({ children }: LayoutProps = {}) {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col bg-[#07101D] text-white relative overflow-hidden">
|
||||
<div className="fixed inset-0 z-0 pointer-events-none">
|
||||
<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="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="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>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<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 className="flex bg-[#07101D] text-white overflow-hidden w-full h-screen">
|
||||
<aside className="h-full w-16 md:w-56 flex flex-col shrink-0 border-r border-white/10 bg-[#0f172a]/50">
|
||||
<div className="h-14 flex items-center md:px-4 justify-center md:justify-start border-b border-white/10">
|
||||
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-[#336EFF] to-blue-400 flex items-center justify-center shadow-lg shadow-[#336EFF]/20 shrink-0">
|
||||
<span className="text-white font-bold text-lg leading-none">Y</span>
|
||||
</div>
|
||||
<div className="hidden md:flex flex-col ml-3">
|
||||
<span className="text-white font-bold text-sm tracking-wider">YOYUZH.XYZ</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className="hidden md:flex items-center gap-2">
|
||||
{navItems.map((item) => (
|
||||
<NavLink
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
'flex items-center gap-2 px-4 py-2 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 }) => (
|
||||
<>
|
||||
{isActive && <div className="absolute inset-0 bg-[#336EFF] opacity-100 z-0" />}
|
||||
<item.icon className="w-4 h-4 relative z-10" />
|
||||
<span className="relative z-10">{item.name}</span>
|
||||
</>
|
||||
)}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="flex items-center gap-4 relative">
|
||||
<button
|
||||
onClick={() => setIsDropdownOpen((current) => !current)}
|
||||
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"
|
||||
aria-label="Account"
|
||||
<nav className="flex-1 flex flex-col gap-2 p-2 relative overflow-y-auto overflow-x-hidden">
|
||||
{navItems.map((item) => (
|
||||
<NavLink
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
'flex items-center gap-3 px-0 md:px-4 justify-center md:justify-start h-10 rounded-xl text-sm font-medium transition-all duration-200 relative overflow-hidden group',
|
||||
isActive ? 'text-white shadow-md shadow-[#336EFF]/20' : 'text-slate-400 hover:text-white hover:bg-white/5',
|
||||
)
|
||||
}
|
||||
>
|
||||
{({ isActive }) => (
|
||||
<>
|
||||
{isActive && <div className="absolute inset-0 bg-[#336EFF] opacity-100 z-0" />}
|
||||
<item.icon className="w-[18px] h-[18px] relative z-10 shrink-0" />
|
||||
<span className="relative z-10 hidden md:block">{item.name}</span>
|
||||
</>
|
||||
)}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="p-4 border-t border-white/10 shrink-0 flex flex-col gap-2 relative">
|
||||
<button
|
||||
onClick={() => setActiveModal('settings')}
|
||||
className="w-full flex items-center justify-center md:justify-start gap-3 p-2 rounded-xl text-slate-400 hover:text-white hover:bg-white/5 transition-colors"
|
||||
>
|
||||
<div className="w-8 h-8 rounded-full border border-white/10 flex items-center justify-center bg-slate-800 text-slate-300 relative z-10 overflow-hidden shrink-0">
|
||||
{displayedAvatarUrl ? (
|
||||
<img src={displayedAvatarUrl} alt="Avatar" className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<span className="text-sm font-semibold">{avatarFallback}</span>
|
||||
<span className="text-xs font-semibold">{avatarFallback}</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<AnimatePresence>
|
||||
{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>
|
||||
</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
|
||||
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
|
||||
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"
|
||||
>
|
||||
<LogOut className="w-4 h-4" /> 退出登录
|
||||
</button>
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
<div className="hidden md:block flex-1 min-w-0 text-left">
|
||||
<p className="text-sm font-medium text-white truncate">{displayName}</p>
|
||||
<p className="text-xs text-slate-400 truncate">{email}</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="w-full flex items-center justify-center md:justify-start gap-3 md:px-4 h-10 rounded-xl text-sm text-red-400 hover:bg-red-500/10 hover:text-red-300 transition-colors"
|
||||
>
|
||||
<LogOut className="w-[18px] h-[18px]" />
|
||||
<span className="hidden md:block font-medium">退出登录</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
</aside>
|
||||
|
||||
<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 />}
|
||||
</main>
|
||||
|
||||
@@ -460,80 +414,80 @@ export function Layout({ children }: LayoutProps = {}) {
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 rounded-xl bg-white/5 border border-white/10 space-y-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-10 h-10 rounded-full bg-blue-500/20 flex items-center justify-center">
|
||||
<Key className="w-5 h-5 text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-white">登录密码</p>
|
||||
<p className="text-xs text-slate-400 mt-0.5">密码修改后会刷新当前登录凭据并使旧 refresh token 失效</p>
|
||||
</div>
|
||||
<div className="w-10 h-10 rounded-full bg-blue-500/20 flex items-center justify-center">
|
||||
<Key className="w-5 h-5 text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-white">登录密码</p>
|
||||
<p className="text-xs text-slate-400 mt-0.5">密码修改后会刷新当前登录凭据并使旧 refresh token 失效</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3">
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="当前密码"
|
||||
value={currentPassword}
|
||||
onChange={(event) => setCurrentPassword(event.target.value)}
|
||||
className="bg-black/20 border-white/10"
|
||||
/>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="新密码"
|
||||
value={newPassword}
|
||||
onChange={(event) => setNewPassword(event.target.value)}
|
||||
className="bg-black/20 border-white/10"
|
||||
/>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="确认新密码"
|
||||
value={confirmPassword}
|
||||
onChange={(event) => setConfirmPassword(event.target.value)}
|
||||
className="bg-black/20 border-white/10"
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<Button variant="outline" disabled={passwordSubmitting} onClick={() => void handleChangePassword()}>
|
||||
{passwordSubmitting ? '保存中...' : '修改'}
|
||||
</Button>
|
||||
</div>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="当前密码"
|
||||
value={currentPassword}
|
||||
onChange={(event) => setCurrentPassword(event.target.value)}
|
||||
className="bg-black/20 border-white/10"
|
||||
/>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="新密码"
|
||||
value={newPassword}
|
||||
onChange={(event) => setNewPassword(event.target.value)}
|
||||
className="bg-black/20 border-white/10"
|
||||
/>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="确认新密码"
|
||||
value={confirmPassword}
|
||||
onChange={(event) => setConfirmPassword(event.target.value)}
|
||||
className="bg-black/20 border-white/10"
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<Button variant="outline" disabled={passwordSubmitting} onClick={() => void handleChangePassword()}>
|
||||
{passwordSubmitting ? '保存中...' : '修改'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-4 rounded-xl bg-white/5 border border-white/10">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-10 h-10 rounded-full bg-emerald-500/20 flex items-center justify-center">
|
||||
<Smartphone className="w-5 h-5 text-emerald-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-white">手机绑定</p>
|
||||
<p className="text-xs text-slate-400 mt-0.5">当前手机号:{phoneNumber}</p>
|
||||
</div>
|
||||
<div className="w-10 h-10 rounded-full bg-emerald-500/20 flex items-center justify-center">
|
||||
<Smartphone className="w-5 h-5 text-emerald-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-white">手机绑定</p>
|
||||
<p className="text-xs text-slate-400 mt-0.5">当前手机号:{phoneNumber}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-white/10 hover:bg-white/10 text-slate-300"
|
||||
onClick={() => setActiveModal('settings')}
|
||||
variant="outline"
|
||||
className="border-white/10 hover:bg-white/10 text-slate-300"
|
||||
onClick={() => setActiveModal('settings')}
|
||||
>
|
||||
更改
|
||||
更改
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-4 rounded-xl bg-white/5 border border-white/10">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-10 h-10 rounded-full bg-purple-500/20 flex items-center justify-center">
|
||||
<Mail className="w-5 h-5 text-purple-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-white">邮箱绑定</p>
|
||||
<p className="text-xs text-slate-400 mt-0.5">当前邮箱:{email}</p>
|
||||
</div>
|
||||
<div className="w-10 h-10 rounded-full bg-purple-500/20 flex items-center justify-center">
|
||||
<Mail className="w-5 h-5 text-purple-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-white">邮箱绑定</p>
|
||||
<p className="text-xs text-slate-400 mt-0.5">当前邮箱:{email}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-white/10 hover:bg-white/10 text-slate-300"
|
||||
onClick={() => setActiveModal('settings')}
|
||||
variant="outline"
|
||||
className="border-white/10 hover:bg-white/10 text-slate-300"
|
||||
onClick={() => setActiveModal('settings')}
|
||||
>
|
||||
更改
|
||||
更改
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -547,103 +501,124 @@ export function Layout({ children }: LayoutProps = {}) {
|
||||
|
||||
{activeModal === 'settings' && (
|
||||
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
className="bg-[#0f172a] border border-white/10 rounded-2xl shadow-2xl w-full max-w-lg overflow-hidden flex flex-col max-h-[80vh]"
|
||||
>
|
||||
<div className="p-5 border-b border-white/10 flex justify-between items-center bg-white/5">
|
||||
<h3 className="text-lg font-semibold text-white flex items-center gap-2">
|
||||
<Settings className="w-5 h-5 text-[#336EFF]" />
|
||||
账户设置
|
||||
</h3>
|
||||
<button onClick={closeModal} className="text-slate-400 hover:text-white transition-colors p-1 rounded-md hover:bg-white/10">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-6 overflow-y-auto space-y-6">
|
||||
<div className="flex items-center gap-6 pb-6 border-b border-white/10">
|
||||
<div className="relative group cursor-pointer" onClick={handleAvatarClick}>
|
||||
<div className="w-20 h-20 rounded-full bg-gradient-to-br from-[#336EFF] to-blue-400 flex items-center justify-center text-2xl font-bold text-white shadow-lg overflow-hidden">
|
||||
{displayedAvatarUrl ? <img src={displayedAvatarUrl} alt="Avatar" className="w-full h-full object-cover" /> : avatarFallback}
|
||||
</div>
|
||||
<div className="absolute inset-0 bg-black/50 rounded-full opacity-0 group-hover:opacity-100 flex items-center justify-center transition-opacity">
|
||||
<span className="text-xs text-white">{selectedAvatarFile ? '等待保存' : '更换头像'}</span>
|
||||
</div>
|
||||
<input type="file" ref={fileInputRef} onChange={handleFileChange} accept="image/*" className="hidden" />
|
||||
</div>
|
||||
<div className="flex-1 space-y-1">
|
||||
<h4 className="text-lg font-medium text-white">{displayName}</h4>
|
||||
<p className="text-sm text-slate-400">{roleLabel}</p>
|
||||
</div>
|
||||
</div>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
className="bg-[#0f172a] border border-white/10 rounded-2xl shadow-2xl w-full max-w-lg overflow-hidden flex flex-col max-h-[80vh]"
|
||||
>
|
||||
<div className="p-5 border-b border-white/10 flex justify-between items-center bg-white/5">
|
||||
<h3 className="text-lg font-semibold text-white flex items-center gap-2">
|
||||
<Settings className="w-5 h-5 text-[#336EFF]" />
|
||||
账户设置
|
||||
</h3>
|
||||
<button onClick={closeModal} className="text-slate-400 hover:text-white transition-colors p-1 rounded-md hover:bg-white/10">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-6 overflow-y-auto space-y-6">
|
||||
<div className="flex items-center gap-6 pb-6 border-b border-white/10">
|
||||
<div className="relative group cursor-pointer" onClick={handleAvatarClick}>
|
||||
<div className="w-20 h-20 rounded-full bg-gradient-to-br from-[#336EFF] to-blue-400 flex items-center justify-center text-2xl font-bold text-white shadow-lg overflow-hidden">
|
||||
{displayedAvatarUrl ? <img src={displayedAvatarUrl} alt="Avatar" className="w-full h-full object-cover" /> : avatarFallback}
|
||||
</div>
|
||||
<div className="absolute inset-0 bg-black/50 rounded-full opacity-0 group-hover:opacity-100 flex items-center justify-center transition-opacity">
|
||||
<span className="text-xs text-white">{selectedAvatarFile ? '等待保存' : '更换头像'}</span>
|
||||
</div>
|
||||
<input type="file" ref={fileInputRef} onChange={handleFileChange} accept="image/*" className="hidden" />
|
||||
</div>
|
||||
<div className="flex-1 space-y-1">
|
||||
<h4 className="text-lg font-medium text-white">{displayName}</h4>
|
||||
<p className="text-sm text-slate-400">{roleLabel}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-300">昵称</label>
|
||||
<Input
|
||||
value={profileDraft.displayName}
|
||||
onChange={(event) => handleProfileDraftChange('displayName', event.target.value)}
|
||||
className="bg-black/20 border-white/10 text-white focus-visible:ring-[#336EFF]"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-300">昵称</label>
|
||||
<Input
|
||||
value={profileDraft.displayName}
|
||||
onChange={(event) => handleProfileDraftChange('displayName', event.target.value)}
|
||||
className="bg-black/20 border-white/10 text-white focus-visible:ring-[#336EFF]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-300">邮箱</label>
|
||||
<Input
|
||||
type="email"
|
||||
value={profileDraft.email}
|
||||
onChange={(event) => handleProfileDraftChange('email', event.target.value)}
|
||||
className="bg-black/20 border-white/10 text-white focus-visible:ring-[#336EFF]"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-300">邮箱</label>
|
||||
<Input
|
||||
type="email"
|
||||
value={profileDraft.email}
|
||||
onChange={(event) => handleProfileDraftChange('email', event.target.value)}
|
||||
className="bg-black/20 border-white/10 text-white focus-visible:ring-[#336EFF]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-300">手机号</label>
|
||||
<Input
|
||||
type="tel"
|
||||
value={profileDraft.phoneNumber}
|
||||
onChange={(event) => handleProfileDraftChange('phoneNumber', event.target.value)}
|
||||
className="bg-black/20 border-white/10 text-white focus-visible:ring-[#336EFF]"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-300">手机号</label>
|
||||
<Input
|
||||
type="tel"
|
||||
value={profileDraft.phoneNumber}
|
||||
onChange={(event) => handleProfileDraftChange('phoneNumber', event.target.value)}
|
||||
className="bg-black/20 border-white/10 text-white focus-visible:ring-[#336EFF]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-300">个人简介</label>
|
||||
<textarea
|
||||
className="w-full min-h-[100px] rounded-md bg-black/20 border border-white/10 text-white p-3 text-sm focus:outline-none focus:ring-2 focus:ring-[#336EFF] resize-none"
|
||||
value={profileDraft.bio}
|
||||
onChange={(event) => handleProfileDraftChange('bio', event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-300">个人简介</label>
|
||||
<textarea
|
||||
className="w-full min-h-[100px] rounded-md bg-black/20 border border-white/10 text-white p-3 text-sm focus:outline-none focus:ring-2 focus:ring-[#336EFF] resize-none"
|
||||
value={profileDraft.bio}
|
||||
onChange={(event) => handleProfileDraftChange('bio', event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-300">语言偏好</label>
|
||||
<select
|
||||
className="w-full rounded-md bg-black/20 border border-white/10 text-white p-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-[#336EFF] appearance-none"
|
||||
value={profileDraft.preferredLanguage}
|
||||
onChange={(event) => handleProfileDraftChange('preferredLanguage', event.target.value)}
|
||||
>
|
||||
<option value="zh-CN">简体中文</option>
|
||||
<option value="en-US">English</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-300">语言偏好</label>
|
||||
<select
|
||||
className="w-full rounded-md bg-black/20 border border-white/10 text-white p-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-[#336EFF] appearance-none"
|
||||
value={profileDraft.preferredLanguage}
|
||||
onChange={(event) => handleProfileDraftChange('preferredLanguage', event.target.value)}
|
||||
>
|
||||
<option value="zh-CN">简体中文</option>
|
||||
<option value="en-US">English</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{profileError && <p className="text-sm text-rose-300">{profileError}</p>}
|
||||
{profileMessage && <p className="text-sm text-emerald-300">{profileMessage}</p>}
|
||||
<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 className="pt-4 flex justify-end gap-3">
|
||||
<Button variant="outline" onClick={closeModal} className="border-white/10 hover:bg-white/10 text-slate-300">
|
||||
取消
|
||||
</Button>
|
||||
<Button variant="default" disabled={profileSubmitting} onClick={() => void handleSaveProfile()}>
|
||||
{profileSubmitting ? '保存中...' : '保存更改'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
{profileError && <p className="text-sm text-rose-300">{profileError}</p>}
|
||||
{profileMessage && <p className="text-sm text-emerald-300">{profileMessage}</p>}
|
||||
|
||||
<div className="pt-4 flex justify-end gap-3">
|
||||
<Button variant="outline" onClick={closeModal} className="border-white/10 hover:bg-white/10 text-slate-300">
|
||||
取消
|
||||
</Button>
|
||||
<Button variant="default" disabled={profileSubmitting} onClick={() => void handleSaveProfile()}>
|
||||
{profileSubmitting ? '保存中...' : '保存更改'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
42
front/src/components/ui/AppPageShell.tsx
Normal file
42
front/src/components/ui/AppPageShell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
25
front/src/components/ui/PageToolbar.tsx
Normal file
25
front/src/components/ui/PageToolbar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -436,15 +436,41 @@ test('apiBinaryUploadRequest sends raw file body to signed upload url', async ()
|
||||
|
||||
request.triggerProgress(64, 128);
|
||||
request.triggerProgress(128, 128);
|
||||
request.responseHeaders.set('etag', '"etag-1"');
|
||||
request.respond('', 200, 'text/plain');
|
||||
|
||||
await uploadPromise;
|
||||
const payload = await uploadPromise;
|
||||
assert.deepEqual(payload, {
|
||||
status: 200,
|
||||
headers: {},
|
||||
});
|
||||
assert.deepEqual(progressCalls, [
|
||||
{loaded: 64, 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 () => {
|
||||
const controller = new AbortController();
|
||||
const formData = new FormData();
|
||||
|
||||
@@ -25,9 +25,15 @@ interface ApiBinaryUploadRequestInit {
|
||||
headers?: HeadersInit;
|
||||
method?: 'PUT' | 'POST';
|
||||
onProgress?: (progress: {loaded: number; total: number}) => void;
|
||||
responseHeaders?: string[];
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export interface ApiBinaryUploadResponse {
|
||||
status: number;
|
||||
headers: Record<string, string>;
|
||||
}
|
||||
|
||||
const AUTH_REFRESH_PATH = '/auth/refresh';
|
||||
const DEFAULT_API_BASE_URL = '/api';
|
||||
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) {
|
||||
const headers = new Headers(init.headers);
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
return new Promise<ApiBinaryUploadResponse>((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
let settled = false;
|
||||
|
||||
@@ -545,14 +551,14 @@ export function apiBinaryUploadRequest(path: string, init: ApiBinaryUploadReques
|
||||
init.signal?.removeEventListener('abort', handleAbortSignal);
|
||||
};
|
||||
|
||||
const resolveOnce = () => {
|
||||
const resolveOnce = (value: ApiBinaryUploadResponse) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
|
||||
settled = true;
|
||||
detachAbortSignal();
|
||||
resolve();
|
||||
resolve(value);
|
||||
};
|
||||
|
||||
const rejectOnce = (error: unknown) => {
|
||||
@@ -613,7 +619,18 @@ export function apiBinaryUploadRequest(path: string, init: ApiBinaryUploadReques
|
||||
|
||||
xhr.onload = () => {
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { apiBinaryUploadRequest, apiRequest, apiUploadRequest, ApiError } from './api';
|
||||
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 = '/下载') {
|
||||
const rawPath = path?.trim();
|
||||
@@ -17,44 +16,5 @@ export function resolveNetdiskSaveDirectory(relativePath: string | null | undefi
|
||||
|
||||
export async function saveFileToNetdisk(file: File, path: string) {
|
||||
const normalizedPath = normalizeNetdiskTargetPath(path);
|
||||
const initiated = await apiRequest<InitiateUploadResponse>('/files/upload/initiate', {
|
||||
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,
|
||||
});
|
||||
return uploadFileToNetdiskViaSession(file, normalizedPath);
|
||||
}
|
||||
|
||||
@@ -159,6 +159,45 @@ export interface InitiateUploadResponse {
|
||||
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 {
|
||||
url: string;
|
||||
}
|
||||
|
||||
425
front/src/lib/upload-session.test.ts
Normal file
425
front/src/lib/upload-session.test.ts
Normal 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',
|
||||
]);
|
||||
});
|
||||
220
front/src/lib/upload-session.ts
Normal file
220
front/src/lib/upload-session.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
41
front/src/mobile-components/ResponsiveSheet.tsx
Normal file
41
front/src/mobile-components/ResponsiveSheet.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
26
front/src/mobile-pages/MobileAdminUnavailable.tsx
Normal file
26
front/src/mobile-pages/MobileAdminUnavailable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,650 +1 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
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>
|
||||
);
|
||||
}
|
||||
export { MobileFilesPage as default, getMobileFilesLayoutClassNames } from './files/MobileFilesPage';
|
||||
|
||||
128
front/src/mobile-pages/MobileRecycleBin.tsx
Normal file
128
front/src/mobile-pages/MobileRecycleBin.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
68
front/src/mobile-pages/files/MobileFileActionSheet.tsx
Normal file
68
front/src/mobile-pages/files/MobileFileActionSheet.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
49
front/src/mobile-pages/files/MobileFilesList.tsx
Normal file
49
front/src/mobile-pages/files/MobileFilesList.tsx
Normal 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>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
449
front/src/mobile-pages/files/MobileFilesPage.tsx
Normal file
449
front/src/mobile-pages/files/MobileFilesPage.tsx
Normal 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;
|
||||
@@ -8,6 +8,8 @@ import { Button } from '@/src/components/ui/button';
|
||||
import { getFileShareDetails, importSharedFile } from '@/src/lib/file-share';
|
||||
import { normalizeNetdiskTargetPath } from '@/src/lib/netdisk-upload';
|
||||
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) {
|
||||
if (size <= 0) {
|
||||
@@ -97,15 +99,8 @@ export default function FileShare() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#07101D] px-4 py-10 text-white">
|
||||
<div className="mx-auto w-full max-w-3xl">
|
||||
<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>
|
||||
<AppPageShell toolbar={<PageToolbar title="网盘分享导入" />}>
|
||||
<div className="p-4 md:p-6 mx-auto w-full max-w-3xl h-full">
|
||||
|
||||
<div className="rounded-3xl border border-white/10 bg-[#0f172a]/80 p-8 shadow-2xl backdrop-blur-xl">
|
||||
{loading ? (
|
||||
@@ -204,6 +199,6 @@ export default function FileShare() {
|
||||
onClose={() => setPathPickerOpen(false)}
|
||||
onConfirm={handleImportToPath}
|
||||
/>
|
||||
</div>
|
||||
</AppPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,8 @@ import { Gamepad2, Cat, Car, ExternalLink, Play } from 'lucide-react';
|
||||
import { cn } from '@/src/lib/utils';
|
||||
import { calculateCardTilt } from './games-card-tilt';
|
||||
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<{
|
||||
id: GameId;
|
||||
@@ -137,34 +139,20 @@ export default function Games() {
|
||||
const [activeTab, setActiveTab] = useState<'featured' | 'all'>('featured');
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Hero Section */}
|
||||
<motion.div
|
||||
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-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" />
|
||||
{MORE_GAMES_LABEL}
|
||||
</a>
|
||||
</div>
|
||||
</motion.div>
|
||||
<AppPageShell
|
||||
toolbar={
|
||||
<PageToolbar
|
||||
title="游戏"
|
||||
actions={
|
||||
<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" />
|
||||
{MORE_GAMES_LABEL}
|
||||
</a>
|
||||
}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className="p-4 md:p-6 space-y-8 h-full">
|
||||
|
||||
{/* Category Tabs */}
|
||||
<div className="flex bg-black/20 p-1 rounded-xl w-fit">
|
||||
@@ -194,6 +182,7 @@ export default function Games() {
|
||||
<GameCard key={game.id} game={game} index={index} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AppPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,6 +19,8 @@ import { shouldLoadAvatarWithAuth } from '@/src/components/layout/account-utils'
|
||||
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 { AppPageShell } from '@/src/components/ui/AppPageShell';
|
||||
import { PageToolbar } from '@/src/components/ui/PageToolbar';
|
||||
import { apiDownload, apiRequest } from '@/src/lib/api';
|
||||
import { readCachedValue, writeCachedValue } from '@/src/lib/cache';
|
||||
import { resolveStoredFileType } from '@/src/lib/file-type';
|
||||
@@ -197,23 +199,8 @@ export default function Overview() {
|
||||
}, [profile?.avatarUrl]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<motion.div
|
||||
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>
|
||||
<AppPageShell toolbar={<PageToolbar title={`总览 · ${greeting},${profile?.username ?? '访客'}`} />}>
|
||||
<div className="p-4 md:p-6 space-y-6 relative z-10">
|
||||
|
||||
{loadingError ? (
|
||||
<motion.div initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }}>
|
||||
@@ -441,7 +428,8 @@ export default function Overview() {
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AppPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ import { Button } from '@/src/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/src/components/ui/card';
|
||||
import { apiRequest } from '@/src/lib/api';
|
||||
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';
|
||||
|
||||
@@ -68,38 +70,35 @@ export default function RecycleBin() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex h-full w-full max-w-6xl flex-col gap-6">
|
||||
<Card className="overflow-hidden">
|
||||
<CardHeader className="flex flex-col gap-4 border-b border-white/10 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div className="space-y-2">
|
||||
<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">
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
回收站保留 {RECYCLE_BIN_RETENTION_DAYS} 天
|
||||
<AppPageShell
|
||||
toolbar={
|
||||
<PageToolbar
|
||||
title={
|
||||
<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" />
|
||||
回收站保留 {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 className="flex items-center gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-white/10 bg-white/5 text-slate-200 hover:bg-white/10"
|
||||
onClick={() => void loadRecycleBin()}
|
||||
disabled={loading}
|
||||
>
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
刷新
|
||||
</Button>
|
||||
<Link
|
||||
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>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-6">
|
||||
}
|
||||
actions={
|
||||
<>
|
||||
<Button variant="outline" className="h-9 border-white/10 bg-white/5 text-slate-200 hover:bg-white/10" onClick={() => void loadRecycleBin()} disabled={loading}>
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
刷新
|
||||
</Button>
|
||||
<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">
|
||||
返回网盘
|
||||
</Link>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<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 ? (
|
||||
<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}
|
||||
@@ -160,6 +159,7 @@ export default function RecycleBin() {
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</AppPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -65,6 +65,8 @@ import {
|
||||
resolveInitialTransferTab,
|
||||
} from './transfer-state';
|
||||
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';
|
||||
|
||||
@@ -576,16 +578,8 @@ export default function Transfer() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 py-6 md:py-10">
|
||||
<div className="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>
|
||||
|
||||
<AppPageShell toolbar={<PageToolbar title="文件快传" />}>
|
||||
<div className="p-4 md:p-6 mx-auto w-full max-w-4xl">
|
||||
<div className="glass-panel border border-white/10 rounded-3xl overflow-hidden bg-[#0f172a]/80 backdrop-blur-xl shadow-2xl">
|
||||
{allowSend ? (
|
||||
<div className="flex border-b border-white/10">
|
||||
@@ -1046,6 +1040,6 @@ export default function Transfer() {
|
||||
</motion.div>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</AppPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
131
front/src/pages/files/FileActionMenu.tsx
Normal file
131
front/src/pages/files/FileActionMenu.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
88
front/src/pages/files/FileGridView.tsx
Normal file
88
front/src/pages/files/FileGridView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
125
front/src/pages/files/FileListView.tsx
Normal file
125
front/src/pages/files/FileListView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
124
front/src/pages/files/FilesDirectoryRail.tsx
Normal file
124
front/src/pages/files/FilesDirectoryRail.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
124
front/src/pages/files/FilesInspector.tsx
Normal file
124
front/src/pages/files/FilesInspector.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
175
front/src/pages/files/FilesMainPane.tsx
Normal file
175
front/src/pages/files/FilesMainPane.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
425
front/src/pages/files/FilesPage.tsx
Normal file
425
front/src/pages/files/FilesPage.tsx
Normal 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;
|
||||
50
front/src/pages/files/FilesSearchPanel.tsx
Normal file
50
front/src/pages/files/FilesSearchPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
148
front/src/pages/files/FilesTaskPanel.tsx
Normal file
148
front/src/pages/files/FilesTaskPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
94
front/src/pages/files/FilesToolbar.tsx
Normal file
94
front/src/pages/files/FilesToolbar.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
46
front/src/pages/files/file-types.ts
Normal file
46
front/src/pages/files/file-types.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
98
front/src/pages/files/useBackgroundTasksState.ts
Normal file
98
front/src/pages/files/useBackgroundTasksState.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
187
front/src/pages/files/useFilesDirectoryState.ts
Normal file
187
front/src/pages/files/useFilesDirectoryState.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
61
front/src/pages/files/useFilesOverlayState.ts
Normal file
61
front/src/pages/files/useFilesOverlayState.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
81
front/src/pages/files/useFilesSearchState.ts
Normal file
81
front/src/pages/files/useFilesSearchState.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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 桌面端 `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 存储策略管理后端继续收口:管理员接口已从只读 `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 子系统入口使用。
|
||||
|
||||
Reference in New Issue
Block a user