Add offline transfer history and tighten anonymous access
This commit is contained in:
@@ -36,6 +36,16 @@ public interface OfflineTransferSessionRepository extends JpaRepository<OfflineT
|
|||||||
""")
|
""")
|
||||||
List<OfflineTransferSession> findAllExpiredWithFiles(@Param("now") Instant now);
|
List<OfflineTransferSession> findAllExpiredWithFiles(@Param("now") Instant now);
|
||||||
|
|
||||||
|
@Query("""
|
||||||
|
select distinct session
|
||||||
|
from OfflineTransferSession session
|
||||||
|
left join fetch session.files
|
||||||
|
where session.senderUserId = :senderUserId and session.expiresAt >= :now
|
||||||
|
order by session.expiresAt desc
|
||||||
|
""")
|
||||||
|
List<OfflineTransferSession> findActiveWithFilesBySenderUserId(@Param("senderUserId") Long senderUserId,
|
||||||
|
@Param("now") Instant now);
|
||||||
|
|
||||||
@Query("""
|
@Query("""
|
||||||
select coalesce(sum(file.size), 0)
|
select coalesce(sum(file.size), 0)
|
||||||
from OfflineTransferFile file
|
from OfflineTransferFile file
|
||||||
|
|||||||
@@ -35,21 +35,31 @@ public class TransferController {
|
|||||||
@PostMapping("/sessions")
|
@PostMapping("/sessions")
|
||||||
public ApiResponse<TransferSessionResponse> createSession(@AuthenticationPrincipal UserDetails userDetails,
|
public ApiResponse<TransferSessionResponse> createSession(@AuthenticationPrincipal UserDetails userDetails,
|
||||||
@Valid @RequestBody CreateTransferSessionRequest request) {
|
@Valid @RequestBody CreateTransferSessionRequest request) {
|
||||||
requireAuthenticatedUser(userDetails);
|
User sender = loadAuthenticatedUser(userDetails);
|
||||||
User sender = userDetailsService.loadDomainUser(userDetails.getUsername());
|
|
||||||
return ApiResponse.success(transferService.createSession(sender, request));
|
return ApiResponse.success(transferService.createSession(sender, request));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Operation(summary = "通过取件码查找快传会话")
|
@Operation(summary = "通过取件码查找快传会话")
|
||||||
@GetMapping("/sessions/lookup")
|
@GetMapping("/sessions/lookup")
|
||||||
public ApiResponse<LookupTransferSessionResponse> lookupSession(@RequestParam String pickupCode) {
|
public ApiResponse<LookupTransferSessionResponse> lookupSession(@AuthenticationPrincipal UserDetails userDetails,
|
||||||
return ApiResponse.success(transferService.lookupSession(pickupCode));
|
@RequestParam String pickupCode) {
|
||||||
|
return ApiResponse.success(transferService.lookupSession(userDetails != null, pickupCode));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Operation(summary = "加入快传会话")
|
@Operation(summary = "加入快传会话")
|
||||||
@PostMapping("/sessions/{sessionId}/join")
|
@PostMapping("/sessions/{sessionId}/join")
|
||||||
public ApiResponse<TransferSessionResponse> joinSession(@PathVariable String sessionId) {
|
public ApiResponse<TransferSessionResponse> joinSession(@AuthenticationPrincipal UserDetails userDetails,
|
||||||
return ApiResponse.success(transferService.joinSession(sessionId));
|
@PathVariable String sessionId) {
|
||||||
|
return ApiResponse.success(transferService.joinSession(userDetails != null, sessionId));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "查看当前用户的离线快传列表")
|
||||||
|
@GetMapping("/sessions/offline/mine")
|
||||||
|
public ApiResponse<java.util.List<TransferSessionResponse>> listOfflineSessions(@AuthenticationPrincipal UserDetails userDetails) {
|
||||||
|
requireAuthenticatedUser(userDetails);
|
||||||
|
return ApiResponse.success(transferService.listOfflineSessions(
|
||||||
|
userDetailsService.loadDomainUser(userDetails.getUsername())
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Operation(summary = "上传离线快传文件")
|
@Operation(summary = "上传离线快传文件")
|
||||||
@@ -70,9 +80,10 @@ public class TransferController {
|
|||||||
|
|
||||||
@Operation(summary = "下载离线快传文件")
|
@Operation(summary = "下载离线快传文件")
|
||||||
@GetMapping("/sessions/{sessionId}/files/{fileId}/download")
|
@GetMapping("/sessions/{sessionId}/files/{fileId}/download")
|
||||||
public ResponseEntity<?> downloadOfflineFile(@PathVariable String sessionId,
|
public ResponseEntity<?> downloadOfflineFile(@AuthenticationPrincipal UserDetails userDetails,
|
||||||
|
@PathVariable String sessionId,
|
||||||
@PathVariable String fileId) {
|
@PathVariable String fileId) {
|
||||||
return transferService.downloadOfflineFile(sessionId, fileId);
|
return transferService.downloadOfflineFile(userDetails != null, sessionId, fileId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Operation(summary = "把离线快传文件存入网盘")
|
@Operation(summary = "把离线快传文件存入网盘")
|
||||||
@@ -112,4 +123,11 @@ public class TransferController {
|
|||||||
throw new BusinessException(ErrorCode.NOT_LOGGED_IN, "用户未登录");
|
throw new BusinessException(ErrorCode.NOT_LOGGED_IN, "用户未登录");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private User loadAuthenticatedUser(UserDetails userDetails) {
|
||||||
|
if (userDetails == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return userDetailsService.loadDomainUser(userDetails.getUsername());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,12 +59,15 @@ public class TransferService {
|
|||||||
pruneExpiredSessions();
|
pruneExpiredSessions();
|
||||||
adminMetricsService.recordTransferUsage(request.files().stream().mapToLong(TransferFileItem::size).sum());
|
adminMetricsService.recordTransferUsage(request.files().stream().mapToLong(TransferFileItem::size).sum());
|
||||||
if (request.mode() == TransferMode.OFFLINE) {
|
if (request.mode() == TransferMode.OFFLINE) {
|
||||||
|
if (sender == null) {
|
||||||
|
throw new BusinessException(ErrorCode.NOT_LOGGED_IN, "离线快传需要登录后使用");
|
||||||
|
}
|
||||||
return createOfflineSession(sender, request);
|
return createOfflineSession(sender, request);
|
||||||
}
|
}
|
||||||
return createOnlineSession(request);
|
return createOnlineSession(request);
|
||||||
}
|
}
|
||||||
|
|
||||||
public LookupTransferSessionResponse lookupSession(String pickupCode) {
|
public LookupTransferSessionResponse lookupSession(boolean authenticated, String pickupCode) {
|
||||||
pruneExpiredSessions();
|
pruneExpiredSessions();
|
||||||
String normalizedPickupCode = normalizePickupCode(pickupCode);
|
String normalizedPickupCode = normalizePickupCode(pickupCode);
|
||||||
|
|
||||||
@@ -73,11 +76,14 @@ public class TransferService {
|
|||||||
return onlineSession.toLookupResponse();
|
return onlineSession.toLookupResponse();
|
||||||
}
|
}
|
||||||
|
|
||||||
OfflineTransferSession offlineSession = getRequiredOfflineReadySessionByPickupCode(normalizedPickupCode);
|
OfflineTransferSession offlineSession = offlineTransferSessionRepository.findWithFilesByPickupCode(normalizedPickupCode)
|
||||||
|
.orElseThrow(() -> new BusinessException(ErrorCode.FILE_NOT_FOUND, "取件码不存在或已失效"));
|
||||||
|
ensureAuthenticatedForOfflineTransfer(authenticated);
|
||||||
|
validateOfflineReadySession(offlineSession, "取件码不存在或已失效");
|
||||||
return toLookupResponse(offlineSession);
|
return toLookupResponse(offlineSession);
|
||||||
}
|
}
|
||||||
|
|
||||||
public TransferSessionResponse joinSession(String sessionId) {
|
public TransferSessionResponse joinSession(boolean authenticated, String sessionId) {
|
||||||
pruneExpiredSessions();
|
pruneExpiredSessions();
|
||||||
|
|
||||||
TransferSession onlineSession = sessionStore.findById(sessionId).orElse(null);
|
TransferSession onlineSession = sessionStore.findById(sessionId).orElse(null);
|
||||||
@@ -90,10 +96,20 @@ public class TransferService {
|
|||||||
return onlineSession.toSessionResponse();
|
return onlineSession.toSessionResponse();
|
||||||
}
|
}
|
||||||
|
|
||||||
OfflineTransferSession offlineSession = getRequiredOfflineReadySession(sessionId);
|
OfflineTransferSession offlineSession = offlineTransferSessionRepository.findWithFilesBySessionId(sessionId)
|
||||||
|
.orElseThrow(() -> new BusinessException(ErrorCode.FILE_NOT_FOUND, "快传会话不存在或已失效"));
|
||||||
|
ensureAuthenticatedForOfflineTransfer(authenticated);
|
||||||
|
validateOfflineReadySession(offlineSession, "离线快传会话不存在或已失效");
|
||||||
return toSessionResponse(offlineSession);
|
return toSessionResponse(offlineSession);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public List<TransferSessionResponse> listOfflineSessions(User sender) {
|
||||||
|
pruneExpiredSessions();
|
||||||
|
return offlineTransferSessionRepository.findActiveWithFilesBySenderUserId(sender.getId(), Instant.now()).stream()
|
||||||
|
.map(this::toSessionResponse)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public void uploadOfflineFile(User sender, String sessionId, String fileId, MultipartFile multipartFile) {
|
public void uploadOfflineFile(User sender, String sessionId, String fileId, MultipartFile multipartFile) {
|
||||||
pruneExpiredSessions();
|
pruneExpiredSessions();
|
||||||
@@ -155,8 +171,9 @@ public class TransferService {
|
|||||||
return session.poll(TransferRole.from(role), Math.max(0, after));
|
return session.poll(TransferRole.from(role), Math.max(0, after));
|
||||||
}
|
}
|
||||||
|
|
||||||
public ResponseEntity<?> downloadOfflineFile(String sessionId, String fileId) {
|
public ResponseEntity<?> downloadOfflineFile(boolean authenticated, String sessionId, String fileId) {
|
||||||
pruneExpiredSessions();
|
pruneExpiredSessions();
|
||||||
|
ensureAuthenticatedForOfflineTransfer(authenticated);
|
||||||
OfflineTransferSession session = getRequiredOfflineReadySession(sessionId);
|
OfflineTransferSession session = getRequiredOfflineReadySession(sessionId);
|
||||||
OfflineTransferFile file = getRequiredOfflineFile(session, fileId);
|
OfflineTransferFile file = getRequiredOfflineFile(session, fileId);
|
||||||
ensureOfflineFileUploaded(file);
|
ensureOfflineFileUploaded(file);
|
||||||
@@ -314,24 +331,14 @@ public class TransferService {
|
|||||||
private OfflineTransferSession getRequiredOfflineReadySession(String sessionId) {
|
private OfflineTransferSession getRequiredOfflineReadySession(String sessionId) {
|
||||||
OfflineTransferSession session = offlineTransferSessionRepository.findWithFilesBySessionId(sessionId)
|
OfflineTransferSession session = offlineTransferSessionRepository.findWithFilesBySessionId(sessionId)
|
||||||
.orElseThrow(() -> new BusinessException(ErrorCode.FILE_NOT_FOUND, "离线快传会话不存在或已失效"));
|
.orElseThrow(() -> new BusinessException(ErrorCode.FILE_NOT_FOUND, "离线快传会话不存在或已失效"));
|
||||||
if (session.isExpired(Instant.now())) {
|
validateOfflineReadySession(session, "离线快传会话不存在或已失效");
|
||||||
throw new BusinessException(ErrorCode.FILE_NOT_FOUND, "离线快传会话不存在或已失效");
|
|
||||||
}
|
|
||||||
if (!session.isReady()) {
|
|
||||||
throw new BusinessException(ErrorCode.UNKNOWN, "离线快传仍在上传中,请稍后再试");
|
|
||||||
}
|
|
||||||
return session;
|
return session;
|
||||||
}
|
}
|
||||||
|
|
||||||
private OfflineTransferSession getRequiredOfflineReadySessionByPickupCode(String pickupCode) {
|
private OfflineTransferSession getRequiredOfflineReadySessionByPickupCode(String pickupCode) {
|
||||||
OfflineTransferSession session = offlineTransferSessionRepository.findWithFilesByPickupCode(pickupCode)
|
OfflineTransferSession session = offlineTransferSessionRepository.findWithFilesByPickupCode(pickupCode)
|
||||||
.orElseThrow(() -> new BusinessException(ErrorCode.FILE_NOT_FOUND, "取件码不存在或已失效"));
|
.orElseThrow(() -> new BusinessException(ErrorCode.FILE_NOT_FOUND, "取件码不存在或已失效"));
|
||||||
if (session.isExpired(Instant.now())) {
|
validateOfflineReadySession(session, "取件码不存在或已失效");
|
||||||
throw new BusinessException(ErrorCode.FILE_NOT_FOUND, "取件码不存在或已失效");
|
|
||||||
}
|
|
||||||
if (!session.isReady()) {
|
|
||||||
throw new BusinessException(ErrorCode.UNKNOWN, "离线快传仍在上传中,请稍后再试");
|
|
||||||
}
|
|
||||||
return session;
|
return session;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -365,6 +372,21 @@ public class TransferService {
|
|||||||
return normalized;
|
return normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void ensureAuthenticatedForOfflineTransfer(boolean authenticated) {
|
||||||
|
if (!authenticated) {
|
||||||
|
throw new BusinessException(ErrorCode.NOT_LOGGED_IN, "离线快传需要登录后使用");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void validateOfflineReadySession(OfflineTransferSession session, String notFoundMessage) {
|
||||||
|
if (session.isExpired(Instant.now())) {
|
||||||
|
throw new BusinessException(ErrorCode.FILE_NOT_FOUND, notFoundMessage);
|
||||||
|
}
|
||||||
|
if (!session.isReady()) {
|
||||||
|
throw new BusinessException(ErrorCode.UNKNOWN, "离线快传仍在上传中,请稍后再试");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private String normalizeContentType(String contentType) {
|
private String normalizeContentType(String contentType) {
|
||||||
String normalized = Objects.requireNonNullElse(contentType, "").trim();
|
String normalized = Objects.requireNonNullElse(contentType, "").trim();
|
||||||
return normalized.isEmpty() ? "application/octet-stream" : normalized;
|
return normalized.isEmpty() ? "application/octet-stream" : normalized;
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import static org.assertj.core.api.Assertions.assertThat;
|
|||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
|
||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||||
|
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.anonymous;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||||
@@ -120,18 +121,70 @@ class TransferControllerIntegrationTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void shouldRejectAnonymousSessionCreationButAllowPublicJoinEndpoints() throws Exception {
|
void shouldAllowAnonymousOnlineSessionCreationButKeepOfflineRestricted() throws Exception {
|
||||||
mockMvc.perform(post("/api/transfer/sessions")
|
mockMvc.perform(post("/api/transfer/sessions")
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("""
|
.content("""
|
||||||
{"mode":"ONLINE","files":[{"name":"demo.txt","relativePath":"demo.txt","size":12,"contentType":"text/plain"}]}
|
{"mode":"ONLINE","files":[{"name":"demo.txt","relativePath":"demo.txt","size":12,"contentType":"text/plain"}]}
|
||||||
"""))
|
"""))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.data.mode").value("ONLINE"))
|
||||||
|
.andExpect(jsonPath("$.data.sessionId").isNotEmpty());
|
||||||
|
|
||||||
|
mockMvc.perform(post("/api/transfer/sessions")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("""
|
||||||
|
{"mode":"OFFLINE","files":[{"name":"demo.txt","relativePath":"demo.txt","size":12,"contentType":"text/plain"}]}
|
||||||
|
"""))
|
||||||
.andExpect(status().isUnauthorized());
|
.andExpect(status().isUnauthorized());
|
||||||
|
|
||||||
mockMvc.perform(post("/api/transfer/sessions/{sessionId}/join", "missing-session"))
|
mockMvc.perform(post("/api/transfer/sessions/{sessionId}/join", "missing-session"))
|
||||||
.andExpect(status().isNotFound());
|
.andExpect(status().isNotFound());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(username = "alice")
|
||||||
|
void shouldRejectAnonymousOfflineLookupJoinAndDownload() throws Exception {
|
||||||
|
String response = mockMvc.perform(post("/api/transfer/sessions")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("""
|
||||||
|
{
|
||||||
|
"mode": "OFFLINE",
|
||||||
|
"files": [
|
||||||
|
{"name": "offline.txt", "relativePath": "资料/offline.txt", "size": 13, "contentType": "text/plain"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
"""))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andReturn()
|
||||||
|
.getResponse()
|
||||||
|
.getContentAsString();
|
||||||
|
|
||||||
|
String sessionId = com.jayway.jsonpath.JsonPath.read(response, "$.data.sessionId");
|
||||||
|
String pickupCode = com.jayway.jsonpath.JsonPath.read(response, "$.data.pickupCode");
|
||||||
|
String fileId = com.jayway.jsonpath.JsonPath.read(response, "$.data.files[0].id");
|
||||||
|
|
||||||
|
MockMultipartFile offlineFile = new MockMultipartFile(
|
||||||
|
"file",
|
||||||
|
"offline.txt",
|
||||||
|
MediaType.TEXT_PLAIN_VALUE,
|
||||||
|
"hello offline".getBytes(StandardCharsets.UTF_8)
|
||||||
|
);
|
||||||
|
|
||||||
|
mockMvc.perform(multipart("/api/transfer/sessions/{sessionId}/files/{fileId}/content", sessionId, fileId)
|
||||||
|
.file(offlineFile))
|
||||||
|
.andExpect(status().isOk());
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/transfer/sessions/lookup").with(anonymous()).param("pickupCode", pickupCode))
|
||||||
|
.andExpect(status().isUnauthorized());
|
||||||
|
|
||||||
|
mockMvc.perform(post("/api/transfer/sessions/{sessionId}/join", sessionId).with(anonymous()))
|
||||||
|
.andExpect(status().isUnauthorized());
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/transfer/sessions/{sessionId}/files/{fileId}/download", sessionId, fileId).with(anonymous()))
|
||||||
|
.andExpect(status().isUnauthorized());
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@WithMockUser(username = "alice")
|
@WithMockUser(username = "alice")
|
||||||
void shouldPersistOfflineTransfersForSevenDaysAndAllowRepeatedDownloads() throws Exception {
|
void shouldPersistOfflineTransfersForSevenDaysAndAllowRepeatedDownloads() throws Exception {
|
||||||
@@ -191,4 +244,37 @@ class TransferControllerIntegrationTest {
|
|||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
.andExpect(content().bytes("hello offline".getBytes(StandardCharsets.UTF_8)));
|
.andExpect(content().bytes("hello offline".getBytes(StandardCharsets.UTF_8)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(username = "alice")
|
||||||
|
void shouldListCurrentUsersOfflineTransferSessions() throws Exception {
|
||||||
|
User otherUser = new User();
|
||||||
|
otherUser.setUsername("bob");
|
||||||
|
otherUser.setEmail("bob@example.com");
|
||||||
|
otherUser.setPhoneNumber("13900139000");
|
||||||
|
otherUser.setPasswordHash("encoded-password");
|
||||||
|
otherUser.setCreatedAt(LocalDateTime.now());
|
||||||
|
userRepository.save(otherUser);
|
||||||
|
|
||||||
|
mockMvc.perform(post("/api/transfer/sessions")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("""
|
||||||
|
{
|
||||||
|
"mode": "OFFLINE",
|
||||||
|
"files": [
|
||||||
|
{"name": "cover.png", "relativePath": "活动图/cover.png", "size": 2048, "contentType": "image/png"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
"""))
|
||||||
|
.andExpect(status().isOk());
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/transfer/sessions/offline/mine"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.data.length()").value(1))
|
||||||
|
.andExpect(jsonPath("$.data[0].mode").value("OFFLINE"))
|
||||||
|
.andExpect(jsonPath("$.data[0].pickupCode").isString())
|
||||||
|
.andExpect(jsonPath("$.data[0].files[0].name").value("cover.png"))
|
||||||
|
.andExpect(jsonPath("$.data[0].files[0].relativePath").value("活动图/cover.png"))
|
||||||
|
.andExpect(jsonPath("$.data[0].files[0].uploaded").value(false));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -217,7 +217,8 @@
|
|||||||
|
|
||||||
说明:
|
说明:
|
||||||
|
|
||||||
- 创建快传会话需要发送端登录
|
- 在线快传会话允许未登录用户创建
|
||||||
|
- 离线快传会话仍要求发送端登录
|
||||||
- 请求体必须区分 `mode`
|
- 请求体必须区分 `mode`
|
||||||
- `ONLINE`: 在线快传,15 分钟有效,只能被接收一次
|
- `ONLINE`: 在线快传,15 分钟有效,只能被接收一次
|
||||||
- `OFFLINE`: 离线快传,7 天有效,文件会落到站点存储并可被重复接收
|
- `OFFLINE`: 离线快传,7 天有效,文件会落到站点存储并可被重复接收
|
||||||
@@ -230,6 +231,7 @@
|
|||||||
说明:
|
说明:
|
||||||
|
|
||||||
- 接收端通过 6 位取件码查找会话
|
- 接收端通过 6 位取件码查找会话
|
||||||
|
- 未登录用户只能查找在线快传
|
||||||
|
|
||||||
### 4.3 加入会话
|
### 4.3 加入会话
|
||||||
|
|
||||||
@@ -239,6 +241,7 @@
|
|||||||
|
|
||||||
- 在线快传会占用一次性会话
|
- 在线快传会占用一次性会话
|
||||||
- 离线快传返回可下载文件清单,不需要建立 P2P 通道
|
- 离线快传返回可下载文件清单,不需要建立 P2P 通道
|
||||||
|
- 未登录用户只能加入在线快传
|
||||||
|
|
||||||
### 4.4 信令交换
|
### 4.4 信令交换
|
||||||
|
|
||||||
@@ -252,7 +255,17 @@
|
|||||||
- 实际文件通过浏览器 DataChannel 进行 P2P 传输
|
- 实际文件通过浏览器 DataChannel 进行 P2P 传输
|
||||||
- 该组接口仅用于 `ONLINE` 模式
|
- 该组接口仅用于 `ONLINE` 模式
|
||||||
|
|
||||||
### 4.5 上传离线快传文件
|
### 4.5 查看我的离线快传记录
|
||||||
|
|
||||||
|
`GET /api/transfer/sessions/offline/mine`
|
||||||
|
|
||||||
|
说明:
|
||||||
|
|
||||||
|
- 需要登录
|
||||||
|
- 返回当前用户未过期的离线快传会话列表
|
||||||
|
- 每个会话包含取件码、有效期和文件清单,前端可据此重新展示二维码与分享链接
|
||||||
|
|
||||||
|
### 4.6 上传离线快传文件
|
||||||
|
|
||||||
`POST /api/transfer/sessions/{sessionId}/files/{fileId}/content`
|
`POST /api/transfer/sessions/{sessionId}/files/{fileId}/content`
|
||||||
|
|
||||||
@@ -260,7 +273,7 @@
|
|||||||
|
|
||||||
- 需要发送端登录
|
- 需要发送端登录
|
||||||
- 发送端把离线文件内容上传到站点存储
|
- 发送端把离线文件内容上传到站点存储
|
||||||
- 线上环境会把离线文件落到 OSS
|
- 线上环境会把离线文件落到对象存储
|
||||||
|
|
||||||
### 4.6 下载离线快传文件
|
### 4.6 下载离线快传文件
|
||||||
|
|
||||||
@@ -268,7 +281,7 @@
|
|||||||
|
|
||||||
说明:
|
说明:
|
||||||
|
|
||||||
- 公开接口
|
- 需要登录
|
||||||
- 离线文件在有效期内可以被重复下载
|
- 离线文件在有效期内可以被重复下载
|
||||||
|
|
||||||
### 4.7 存入网盘
|
### 4.7 存入网盘
|
||||||
|
|||||||
@@ -163,6 +163,8 @@
|
|||||||
- 多文件或文件夹可走 ZIP 下载
|
- 多文件或文件夹可走 ZIP 下载
|
||||||
- 在线快传是一次性浏览器 P2P 传输,首个接收者进入后即占用该会话
|
- 在线快传是一次性浏览器 P2P 传输,首个接收者进入后即占用该会话
|
||||||
- 离线快传会把文件内容落到站点存储,线上环境使用多吉云对象存储,默认保留 7 天并支持重复接收
|
- 离线快传会把文件内容落到站点存储,线上环境使用多吉云对象存储,默认保留 7 天并支持重复接收
|
||||||
|
- 登录页提供直达快传入口;匿名用户只允许创建在线快传并接收在线快传,离线快传相关操作仍要求登录
|
||||||
|
- 已登录发送端可在快传页查看自己未过期的离线快传记录,并重新打开取件码 / 二维码 / 分享链接详情弹层
|
||||||
|
|
||||||
### 3.4 管理台模块
|
### 3.4 管理台模块
|
||||||
|
|
||||||
@@ -229,7 +231,7 @@
|
|||||||
|
|
||||||
### 4.5 快传流程
|
### 4.5 快传流程
|
||||||
|
|
||||||
1. 发送端登录后创建快传会话
|
1. 发送端可在登录后或未登录状态下创建在线快传会话
|
||||||
2. 若是在线模式,后端返回 `sessionId + pickupCode` 并保留 15 分钟的一次性会话
|
2. 若是在线模式,后端返回 `sessionId + pickupCode` 并保留 15 分钟的一次性会话
|
||||||
3. 接收端通过取件码或分享链接加入在线会话
|
3. 接收端通过取件码或分享链接加入在线会话
|
||||||
4. 双方通过 `/api/transfer/.../signals` 交换 offer / answer / ice
|
4. 双方通过 `/api/transfer/.../signals` 交换 offer / answer / ice
|
||||||
@@ -246,6 +248,12 @@
|
|||||||
6. 接收端可直接下载离线文件,也可登录后存入网盘
|
6. 接收端可直接下载离线文件,也可登录后存入网盘
|
||||||
7. 文件在有效期内不会因一次接收而被删除,过期后由后端清理任务自动销毁
|
7. 文件在有效期内不会因一次接收而被删除,过期后由后端清理任务自动销毁
|
||||||
|
|
||||||
|
补充说明:
|
||||||
|
|
||||||
|
- 离线快传的创建、查找、加入和下载都要求登录
|
||||||
|
- 匿名用户进入 `/transfer` 时默认落在发送页,但仅会看到在线模式
|
||||||
|
- 登录用户可通过 `/api/transfer/sessions/offline/mine` 拉取自己仍在有效期内的离线快传会话,用于在快传页回看历史取件信息
|
||||||
|
|
||||||
### 4.7 管理员改密流程
|
### 4.7 管理员改密流程
|
||||||
|
|
||||||
1. 管理台调用 `PUT /api/admin/users/{userId}/password`
|
1. 管理台调用 `PUT /api/admin/users/{userId}/password`
|
||||||
|
|||||||
@@ -3,11 +3,10 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>My Google AI Studio App</title>
|
<title>优立云盘</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
||||||
|
|||||||
@@ -44,6 +44,10 @@ export function joinTransferSession(sessionId: string) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function listMyOfflineTransferSessions() {
|
||||||
|
return apiRequest<TransferSessionResponse[]>('/transfer/sessions/offline/mine');
|
||||||
|
}
|
||||||
|
|
||||||
export function uploadOfflineTransferFile(
|
export function uploadOfflineTransferFile(
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
fileId: string,
|
fileId: string,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
import { motion, AnimatePresence } from 'motion/react';
|
import { motion, AnimatePresence } from 'motion/react';
|
||||||
import { LogIn, User, Lock, UserPlus, Mail, ArrowLeft, Phone } from 'lucide-react';
|
import { LogIn, User, Lock, UserPlus, Mail, ArrowLeft, Phone, Send } from 'lucide-react';
|
||||||
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/src/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/src/components/ui/card';
|
||||||
import { Button } from '@/src/components/ui/button';
|
import { Button } from '@/src/components/ui/button';
|
||||||
@@ -15,6 +15,28 @@ import { buildRegisterPayload, validateRegisterForm } from './login-state';
|
|||||||
|
|
||||||
const DEV_LOGIN_ENABLED = import.meta.env.DEV || import.meta.env.VITE_ENABLE_DEV_LOGIN === 'true';
|
const DEV_LOGIN_ENABLED = import.meta.env.DEV || import.meta.env.VITE_ENABLE_DEV_LOGIN === 'true';
|
||||||
|
|
||||||
|
function BlurRevealTitle({ text }: { text: string }) {
|
||||||
|
return (
|
||||||
|
<span className="inline-flex flex-wrap">
|
||||||
|
{Array.from(text).map((char, index) => (
|
||||||
|
<motion.span
|
||||||
|
key={`${char}-${index}`}
|
||||||
|
initial={{ opacity: 0, y: 18, filter: 'blur(18px)' }}
|
||||||
|
animate={{ opacity: 1, y: 0, filter: 'blur(0px)' }}
|
||||||
|
transition={{
|
||||||
|
duration: 0.7,
|
||||||
|
ease: 'easeOut',
|
||||||
|
delay: 0.08 + index * 0.06,
|
||||||
|
}}
|
||||||
|
className="inline-block will-change-transform"
|
||||||
|
>
|
||||||
|
{char === ' ' ? '\u00A0' : char}
|
||||||
|
</motion.span>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function Login() {
|
export default function Login() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
@@ -134,20 +156,27 @@ export default function Login() {
|
|||||||
>
|
>
|
||||||
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full glass-panel border-white/10 w-fit">
|
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full glass-panel border-white/10 w-fit">
|
||||||
<span className="w-2 h-2 rounded-full bg-[#336EFF] animate-pulse" />
|
<span className="w-2 h-2 rounded-full bg-[#336EFF] animate-pulse" />
|
||||||
<span className="text-sm text-slate-300 font-medium tracking-wide uppercase">Access Portal</span>
|
<span className="text-sm text-slate-300 font-medium tracking-wide">优立云盘</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<h2 className="text-xl text-[#336EFF] font-bold tracking-widest uppercase">YOYUZH.XYZ</h2>
|
<h2 className="text-xl text-[#336EFF] font-bold tracking-[0.3em]">YOULI CLOUD</h2>
|
||||||
<h1 className="text-5xl md:text-6xl font-bold text-white leading-tight">
|
<motion.div
|
||||||
个人网站
|
initial={{ opacity: 0, y: 18, scale: 0.985 }}
|
||||||
<br />
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||||
统一入口
|
transition={{ duration: 0.65, ease: 'easeOut', delay: 0.05 }}
|
||||||
</h1>
|
className="relative inline-flex w-fit max-w-fit self-start overflow-hidden rounded-[2rem] border border-white/15 bg-white/8 px-7 py-5 shadow-[0_20px_70px_rgba(9,18,36,0.38)] backdrop-blur-3xl"
|
||||||
|
>
|
||||||
|
<div className="pointer-events-none absolute inset-0 bg-[linear-gradient(135deg,rgba(255,255,255,0.22),rgba(255,255,255,0.04)_45%,rgba(51,110,255,0.12))]" />
|
||||||
|
<div className="pointer-events-none absolute inset-x-6 top-0 h-px bg-white/45" />
|
||||||
|
<h1 className="relative text-center text-5xl font-bold text-white leading-none md:text-6xl">
|
||||||
|
<BlurRevealTitle text="优立云盘" />
|
||||||
|
</h1>
|
||||||
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-lg text-slate-400 leading-relaxed">
|
<p className="text-lg text-slate-400 leading-relaxed">
|
||||||
欢迎来到 YOYUZH 的个人门户。在这里,你可以集中管理个人网盘文件、使用跨设备快传能力,以及体验轻量级小游戏。
|
欢迎来到优立云盘。在这里,你可以集中管理个人网盘文件、使用跨设备快传能力,以及体验轻量级小游戏。
|
||||||
</p>
|
</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
@@ -234,6 +263,18 @@ export default function Login() {
|
|||||||
'进入系统'
|
'进入系统'
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="w-full h-12 border-white/10 bg-white/[0.03] text-slate-200 hover:bg-white/10"
|
||||||
|
onClick={() => navigate('/transfer')}
|
||||||
|
>
|
||||||
|
<Send className="mr-2 h-4 w-4" />
|
||||||
|
直接进入快传
|
||||||
|
</Button>
|
||||||
|
<p className="text-center text-xs text-slate-500">
|
||||||
|
无需登录,仅支持在线发送和在线接收
|
||||||
|
</p>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import React, { useEffect, useRef, useState } from 'react';
|
|||||||
import { AnimatePresence, motion } from 'motion/react';
|
import { AnimatePresence, motion } from 'motion/react';
|
||||||
import {
|
import {
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
|
ChevronRight,
|
||||||
|
Clock3,
|
||||||
Copy,
|
Copy,
|
||||||
DownloadCloud,
|
DownloadCloud,
|
||||||
File as FileIcon,
|
File as FileIcon,
|
||||||
@@ -17,8 +19,9 @@ import {
|
|||||||
Trash2,
|
Trash2,
|
||||||
UploadCloud,
|
UploadCloud,
|
||||||
X,
|
X,
|
||||||
|
LogIn,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
|
|
||||||
import { useAuth } from '@/src/auth/AuthProvider';
|
import { useAuth } from '@/src/auth/AuthProvider';
|
||||||
import { Button } from '@/src/components/ui/button';
|
import { Button } from '@/src/components/ui/button';
|
||||||
@@ -39,6 +42,7 @@ import { flushPendingRemoteIceCandidates, handleRemoteIceCandidate } from '@/src
|
|||||||
import {
|
import {
|
||||||
DEFAULT_TRANSFER_ICE_SERVERS,
|
DEFAULT_TRANSFER_ICE_SERVERS,
|
||||||
createTransferSession,
|
createTransferSession,
|
||||||
|
listMyOfflineTransferSessions,
|
||||||
pollTransferSignals,
|
pollTransferSignals,
|
||||||
postTransferSignal,
|
postTransferSignal,
|
||||||
uploadOfflineTransferFile,
|
uploadOfflineTransferFile,
|
||||||
@@ -49,7 +53,10 @@ import { cn } from '@/src/lib/utils';
|
|||||||
import {
|
import {
|
||||||
buildQrImageUrl,
|
buildQrImageUrl,
|
||||||
canSendTransferFiles,
|
canSendTransferFiles,
|
||||||
|
getAvailableTransferModes,
|
||||||
formatTransferSize,
|
formatTransferSize,
|
||||||
|
getOfflineTransferSessionLabel,
|
||||||
|
getOfflineTransferSessionSize,
|
||||||
getTransferModeSummary,
|
getTransferModeSummary,
|
||||||
resolveInitialTransferTab,
|
resolveInitialTransferTab,
|
||||||
} from './transfer-state';
|
} from './transfer-state';
|
||||||
@@ -100,10 +107,13 @@ function getPhaseMessage(mode: TransferMode, phase: SendPhase, errorMessage: str
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function Transfer() {
|
export default function Transfer() {
|
||||||
|
const navigate = useNavigate();
|
||||||
const { session: authSession } = useAuth();
|
const { session: authSession } = useAuth();
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const sessionId = searchParams.get('session');
|
const sessionId = searchParams.get('session');
|
||||||
const allowSend = canSendTransferFiles(Boolean(authSession?.token));
|
const isAuthenticated = Boolean(authSession?.token);
|
||||||
|
const allowSend = canSendTransferFiles(isAuthenticated);
|
||||||
|
const availableTransferModes = getAvailableTransferModes(isAuthenticated);
|
||||||
const [activeTab, setActiveTab] = useState(() => resolveInitialTransferTab(allowSend, sessionId));
|
const [activeTab, setActiveTab] = useState(() => resolveInitialTransferTab(allowSend, sessionId));
|
||||||
|
|
||||||
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
|
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
|
||||||
@@ -113,10 +123,16 @@ export default function Transfer() {
|
|||||||
const [sendProgress, setSendProgress] = useState(0);
|
const [sendProgress, setSendProgress] = useState(0);
|
||||||
const [sendError, setSendError] = useState('');
|
const [sendError, setSendError] = useState('');
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
|
const [offlineHistory, setOfflineHistory] = useState<TransferSessionResponse[]>([]);
|
||||||
|
const [offlineHistoryLoading, setOfflineHistoryLoading] = useState(false);
|
||||||
|
const [offlineHistoryError, setOfflineHistoryError] = useState('');
|
||||||
|
const [selectedOfflineSession, setSelectedOfflineSession] = useState<TransferSessionResponse | null>(null);
|
||||||
|
const [historyCopiedSessionId, setHistoryCopiedSessionId] = useState<string | null>(null);
|
||||||
|
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const folderInputRef = useRef<HTMLInputElement>(null);
|
const folderInputRef = useRef<HTMLInputElement>(null);
|
||||||
const copiedTimerRef = useRef<number | null>(null);
|
const copiedTimerRef = useRef<number | null>(null);
|
||||||
|
const historyCopiedTimerRef = useRef<number | null>(null);
|
||||||
const pollTimerRef = useRef<number | null>(null);
|
const pollTimerRef = useRef<number | null>(null);
|
||||||
const peerConnectionRef = useRef<RTCPeerConnection | null>(null);
|
const peerConnectionRef = useRef<RTCPeerConnection | null>(null);
|
||||||
const dataChannelRef = useRef<RTCDataChannel | null>(null);
|
const dataChannelRef = useRef<RTCDataChannel | null>(null);
|
||||||
@@ -143,6 +159,9 @@ export default function Transfer() {
|
|||||||
if (copiedTimerRef.current) {
|
if (copiedTimerRef.current) {
|
||||||
window.clearTimeout(copiedTimerRef.current);
|
window.clearTimeout(copiedTimerRef.current);
|
||||||
}
|
}
|
||||||
|
if (historyCopiedTimerRef.current) {
|
||||||
|
window.clearTimeout(historyCopiedTimerRef.current);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -152,6 +171,12 @@ export default function Transfer() {
|
|||||||
}
|
}
|
||||||
}, [allowSend, sessionId]);
|
}, [allowSend, sessionId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!availableTransferModes.includes(transferMode)) {
|
||||||
|
setTransferMode('ONLINE');
|
||||||
|
}
|
||||||
|
}, [availableTransferModes, transferMode]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedFiles.length === 0) {
|
if (selectedFiles.length === 0) {
|
||||||
return;
|
return;
|
||||||
@@ -160,12 +185,66 @@ export default function Transfer() {
|
|||||||
void bootstrapTransfer(selectedFiles);
|
void bootstrapTransfer(selectedFiles);
|
||||||
}, [transferMode]);
|
}, [transferMode]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
setOfflineHistory([]);
|
||||||
|
setOfflineHistoryError('');
|
||||||
|
setSelectedOfflineSession(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void loadOfflineHistory();
|
||||||
|
}, [isAuthenticated]);
|
||||||
|
|
||||||
const totalSize = selectedFiles.reduce((sum, file) => sum + file.size, 0);
|
const totalSize = selectedFiles.reduce((sum, file) => sum + file.size, 0);
|
||||||
const shareLink = session
|
const shareLink = session
|
||||||
? buildTransferShareUrl(window.location.origin, session.sessionId, getTransferRouterMode())
|
? buildTransferShareUrl(window.location.origin, session.sessionId, getTransferRouterMode())
|
||||||
: '';
|
: '';
|
||||||
const qrImageUrl = shareLink ? buildQrImageUrl(shareLink) : '';
|
const qrImageUrl = shareLink ? buildQrImageUrl(shareLink) : '';
|
||||||
const transferModeSummary = getTransferModeSummary(transferMode);
|
const transferModeSummary = getTransferModeSummary(transferMode);
|
||||||
|
const selectedOfflineSessionShareLink = selectedOfflineSession
|
||||||
|
? buildTransferShareUrl(window.location.origin, selectedOfflineSession.sessionId, getTransferRouterMode())
|
||||||
|
: '';
|
||||||
|
const selectedOfflineSessionQrImageUrl = selectedOfflineSessionShareLink
|
||||||
|
? buildQrImageUrl(selectedOfflineSessionShareLink)
|
||||||
|
: '';
|
||||||
|
|
||||||
|
function navigateBackToLogin() {
|
||||||
|
const nextPath = `${window.location.pathname}${window.location.search}`;
|
||||||
|
navigate(`/login?next=${encodeURIComponent(nextPath)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isOfflineSessionReady(sessionToCheck: TransferSessionResponse) {
|
||||||
|
return sessionToCheck.files.every((file) => file.uploaded);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadOfflineHistory(options?: {silent?: boolean}) {
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!options?.silent) {
|
||||||
|
setOfflineHistoryLoading(true);
|
||||||
|
}
|
||||||
|
setOfflineHistoryError('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const sessions = await listMyOfflineTransferSessions();
|
||||||
|
setOfflineHistory(sessions);
|
||||||
|
setSelectedOfflineSession((current) => {
|
||||||
|
if (!current) {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
return sessions.find((item) => item.sessionId === current.sessionId) ?? null;
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
setOfflineHistoryError(error instanceof Error ? error.message : '离线快传记录加载失败');
|
||||||
|
} finally {
|
||||||
|
if (!options?.silent) {
|
||||||
|
setOfflineHistoryLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function cleanupCurrentTransfer() {
|
function cleanupCurrentTransfer() {
|
||||||
if (pollTimerRef.current) {
|
if (pollTimerRef.current) {
|
||||||
@@ -268,6 +347,7 @@ export default function Transfer() {
|
|||||||
|
|
||||||
setSession(createdSession);
|
setSession(createdSession);
|
||||||
if (createdSession.mode === 'OFFLINE') {
|
if (createdSession.mode === 'OFFLINE') {
|
||||||
|
void loadOfflineHistory({silent: true});
|
||||||
await uploadOfflineFiles(createdSession, files, bootstrapId);
|
await uploadOfflineFiles(createdSession, files, bootstrapId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -317,6 +397,7 @@ export default function Transfer() {
|
|||||||
|
|
||||||
setSendProgress(100);
|
setSendProgress(100);
|
||||||
setSendPhase('completed');
|
setSendPhase('completed');
|
||||||
|
void loadOfflineHistory({silent: true});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function setupSenderPeer(createdSession: TransferSessionResponse, files: File[], bootstrapId: number) {
|
async function setupSenderPeer(createdSession: TransferSessionResponse, files: File[], bootstrapId: number) {
|
||||||
@@ -505,9 +586,25 @@ export default function Transfer() {
|
|||||||
setSendPhase('completed');
|
setSendPhase('completed');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function copyOfflineSessionLink(sessionToCopy: TransferSessionResponse) {
|
||||||
|
const sessionShareLink = buildTransferShareUrl(
|
||||||
|
window.location.origin,
|
||||||
|
sessionToCopy.sessionId,
|
||||||
|
getTransferRouterMode(),
|
||||||
|
);
|
||||||
|
await navigator.clipboard.writeText(sessionShareLink);
|
||||||
|
setHistoryCopiedSessionId(sessionToCopy.sessionId);
|
||||||
|
if (historyCopiedTimerRef.current) {
|
||||||
|
window.clearTimeout(historyCopiedTimerRef.current);
|
||||||
|
}
|
||||||
|
historyCopiedTimerRef.current = window.setTimeout(() => {
|
||||||
|
setHistoryCopiedSessionId((current) => (current === sessionToCopy.sessionId ? null : current));
|
||||||
|
}, 1800);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 flex flex-col items-center py-6 md:py-10">
|
<div className="flex-1 py-6 md:py-10">
|
||||||
<div className="w-full max-w-4xl">
|
<div className="mx-auto w-full max-w-4xl">
|
||||||
<div className="text-center mb-10">
|
<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">
|
<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" />
|
<Send className="w-8 h-8 text-white" />
|
||||||
@@ -553,6 +650,23 @@ export default function Transfer() {
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="p-8 min-h-[420px] flex flex-col relative min-w-0">
|
<div className="p-8 min-h-[420px] flex flex-col relative min-w-0">
|
||||||
|
{!isAuthenticated ? (
|
||||||
|
<div className="mb-6 flex flex-col gap-3 rounded-2xl border border-blue-400/15 bg-blue-500/10 px-4 py-4 text-sm text-blue-100 md:flex-row md:items-center md:justify-between">
|
||||||
|
<p className="leading-6">
|
||||||
|
当前无需登录即可使用快传,但仅支持在线发送和在线接收。离线快传仍需登录后使用。
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={navigateBackToLogin}
|
||||||
|
className="shrink-0 border border-white/10 bg-white/10 text-white hover:bg-white/15"
|
||||||
|
>
|
||||||
|
<LogIn className="mr-2 h-4 w-4" />
|
||||||
|
返回登录
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<AnimatePresence mode="wait">
|
<AnimatePresence mode="wait">
|
||||||
{activeTab === 'send' ? (
|
{activeTab === 'send' ? (
|
||||||
<motion.div
|
<motion.div
|
||||||
@@ -563,37 +677,39 @@ export default function Transfer() {
|
|||||||
transition={{ duration: 0.2 }}
|
transition={{ duration: 0.2 }}
|
||||||
className="flex-1 flex flex-col h-full min-w-0"
|
className="flex-1 flex flex-col h-full min-w-0"
|
||||||
>
|
>
|
||||||
<div className="mb-6 grid gap-3 md:grid-cols-2">
|
{availableTransferModes.length > 1 ? (
|
||||||
{(['ONLINE', 'OFFLINE'] as TransferMode[]).map((mode) => {
|
<div className="mb-6 grid gap-3 md:grid-cols-2">
|
||||||
const summary = getTransferModeSummary(mode);
|
{availableTransferModes.map((mode) => {
|
||||||
const active = transferMode === mode;
|
const summary = getTransferModeSummary(mode);
|
||||||
|
const active = transferMode === mode;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={mode}
|
key={mode}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setTransferMode(mode)}
|
onClick={() => setTransferMode(mode)}
|
||||||
className={cn(
|
className={cn(
|
||||||
'rounded-2xl border p-4 text-left transition-colors',
|
'rounded-2xl border p-4 text-left transition-colors',
|
||||||
active
|
active
|
||||||
? 'border-blue-400/40 bg-blue-500/10'
|
? 'border-blue-400/40 bg-blue-500/10'
|
||||||
: 'border-white/10 bg-white/[0.03] hover:bg-white/[0.05]',
|
: 'border-white/10 bg-white/[0.03] hover:bg-white/[0.05]',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<p className="text-sm font-semibold text-white">{summary.title}</p>
|
<p className="text-sm font-semibold text-white">{summary.title}</p>
|
||||||
<span className={cn(
|
<span className={cn(
|
||||||
'rounded-full px-2.5 py-1 text-[11px] font-medium',
|
'rounded-full px-2.5 py-1 text-[11px] font-medium',
|
||||||
active ? 'bg-blue-400/15 text-blue-100' : 'bg-white/10 text-slate-300',
|
active ? 'bg-blue-400/15 text-blue-100' : 'bg-white/10 text-slate-300',
|
||||||
)}>
|
)}>
|
||||||
{mode === 'ONLINE' ? '一次接收' : '7 天多次'}
|
{mode === 'ONLINE' ? '一次接收' : '7 天多次'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-2 text-sm leading-6 text-slate-400">{summary.description}</p>
|
<p className="mt-2 text-sm leading-6 text-slate-400">{summary.description}</p>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{selectedFiles.length === 0 ? (
|
{selectedFiles.length === 0 ? (
|
||||||
<div
|
<div
|
||||||
@@ -742,6 +858,85 @@ export default function Transfer() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{isAuthenticated ? (
|
||||||
|
<div className="mt-8 rounded-2xl border border-white/10 bg-white/[0.03] p-5">
|
||||||
|
<div className="mb-4 flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-base font-semibold text-white">我的离线快传</h3>
|
||||||
|
<p className="mt-1 text-sm text-slate-400">
|
||||||
|
这里只保留未过期的离线快传记录,点击即可重新查看取件码和分享链接。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => void loadOfflineHistory()}
|
||||||
|
className="h-8 shrink-0 border-white/10 bg-transparent px-3 text-slate-300 hover:bg-white/10"
|
||||||
|
>
|
||||||
|
刷新
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{offlineHistoryLoading && offlineHistory.length === 0 ? (
|
||||||
|
<div className="rounded-2xl border border-white/5 bg-black/10 px-4 py-10 text-center text-sm text-slate-400">
|
||||||
|
正在加载离线快传记录...
|
||||||
|
</div>
|
||||||
|
) : offlineHistoryError ? (
|
||||||
|
<div className="rounded-2xl border border-rose-500/20 bg-rose-500/10 px-4 py-4 text-sm text-rose-200">
|
||||||
|
{offlineHistoryError}
|
||||||
|
</div>
|
||||||
|
) : offlineHistory.length === 0 ? (
|
||||||
|
<div className="rounded-2xl border border-white/5 bg-black/10 px-4 py-10 text-center text-sm text-slate-400">
|
||||||
|
你还没有发过离线快传。
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-3">
|
||||||
|
{offlineHistory.map((historySession) => {
|
||||||
|
const ready = isOfflineSessionReady(historySession);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={historySession.sessionId}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSelectedOfflineSession(historySession)}
|
||||||
|
className="group flex w-full items-center justify-between gap-4 rounded-2xl border border-white/8 bg-black/10 px-4 py-4 text-left transition-colors hover:border-[#336EFF]/30 hover:bg-white/[0.04]"
|
||||||
|
>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<p className="truncate text-sm font-semibold text-white">
|
||||||
|
{getOfflineTransferSessionLabel(historySession)}
|
||||||
|
</p>
|
||||||
|
<span className={cn(
|
||||||
|
'rounded-full px-2.5 py-1 text-[11px] font-medium',
|
||||||
|
ready ? 'bg-emerald-500/15 text-emerald-200' : 'bg-amber-500/15 text-amber-100',
|
||||||
|
)}>
|
||||||
|
{ready ? '可接收' : '上传中'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-slate-400">
|
||||||
|
<span>取件码 {historySession.pickupCode}</span>
|
||||||
|
<span>{historySession.files.length} 个项目</span>
|
||||||
|
<span>{getOfflineTransferSessionSize(historySession)}</span>
|
||||||
|
<span>
|
||||||
|
到期于 {new Date(historySession.expiresAt).toLocaleString('zh-CN', {
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ChevronRight className="h-5 w-5 shrink-0 text-slate-500 transition-colors group-hover:text-white" />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<input type="file" multiple className="hidden" ref={fileInputRef} onChange={handleFileSelect} />
|
<input type="file" multiple className="hidden" ref={fileInputRef} onChange={handleFileSelect} />
|
||||||
<input type="file" multiple className="hidden" ref={folderInputRef} onChange={handleFileSelect} />
|
<input type="file" multiple className="hidden" ref={folderInputRef} onChange={handleFileSelect} />
|
||||||
</motion.div>
|
</motion.div>
|
||||||
@@ -791,6 +986,93 @@ export default function Transfer() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{selectedOfflineSession ? (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
className="fixed inset-0 z-[70] flex items-center justify-center bg-[#020817]/75 px-4 py-6 backdrop-blur-md"
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.96, y: 16 }}
|
||||||
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.96, y: 16 }}
|
||||||
|
transition={{ duration: 0.18 }}
|
||||||
|
className="relative w-full max-w-[34rem] overflow-hidden rounded-[2rem] border border-white/10 bg-[#0d1528]/95 p-8 shadow-[0_30px_120px_rgba(0,0,0,0.45)]"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSelectedOfflineSession(null)}
|
||||||
|
className="absolute right-5 top-5 rounded-full p-2 text-slate-500 transition-colors hover:bg-white/5 hover:text-white"
|
||||||
|
aria-label="关闭离线快传详情"
|
||||||
|
>
|
||||||
|
<X className="h-7 w-7" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-sm tracking-[0.3em] text-slate-400">取件码</p>
|
||||||
|
<div className="mt-5 font-mono text-[4.5rem] font-bold leading-none tracking-[0.32em] text-white">
|
||||||
|
{selectedOfflineSession.pickupCode}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedOfflineSessionQrImageUrl ? (
|
||||||
|
<div className="mx-auto mt-10 w-fit rounded-[2rem] bg-white p-5 shadow-[0_18px_60px_rgba(15,23,42,0.32)]">
|
||||||
|
<img
|
||||||
|
src={selectedOfflineSessionQrImageUrl}
|
||||||
|
alt="离线快传二维码"
|
||||||
|
className="h-64 w-64 rounded-2xl"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="mt-8 rounded-[1.7rem] border border-white/10 bg-[#0a1122] px-5 py-4">
|
||||||
|
<div className="mb-3 flex items-center gap-2 text-xs uppercase tracking-[0.24em] text-slate-500">
|
||||||
|
<LinkIcon className="h-4 w-4" />
|
||||||
|
分享链接
|
||||||
|
</div>
|
||||||
|
<div className="truncate text-[1.05rem] text-slate-100">
|
||||||
|
{selectedOfflineSessionShareLink}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="mt-5 h-16 w-full rounded-[1.35rem] border-white/10 bg-transparent text-xl text-white hover:bg-white/5"
|
||||||
|
onClick={() => void copyOfflineSessionLink(selectedOfflineSession)}
|
||||||
|
>
|
||||||
|
<Copy className="mr-3 h-6 w-6" />
|
||||||
|
{historyCopiedSessionId === selectedOfflineSession.sessionId ? '已复制链接' : '复制链接'}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="mt-6 flex flex-wrap items-center gap-x-4 gap-y-2 text-sm text-slate-400">
|
||||||
|
<span>{getOfflineTransferSessionLabel(selectedOfflineSession)}</span>
|
||||||
|
<span>{selectedOfflineSession.files.length} 个项目</span>
|
||||||
|
<span>{getOfflineTransferSessionSize(selectedOfflineSession)}</span>
|
||||||
|
<span className={cn(
|
||||||
|
isOfflineSessionReady(selectedOfflineSession) ? 'text-emerald-300' : 'text-amber-200',
|
||||||
|
)}>
|
||||||
|
{isOfflineSessionReady(selectedOfflineSession) ? '文件已就绪,可重复接收' : '文件仍在上传中'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 flex items-center gap-2 text-sm text-slate-500">
|
||||||
|
<Clock3 className="h-4 w-4" />
|
||||||
|
有效期至 {new Date(selectedOfflineSession.expiresAt).toLocaleString('zh-CN', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
) : null}
|
||||||
|
</AnimatePresence>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ import test from 'node:test';
|
|||||||
|
|
||||||
import { buildTransferShareUrl } from '../lib/transfer-links';
|
import { buildTransferShareUrl } from '../lib/transfer-links';
|
||||||
import {
|
import {
|
||||||
|
getAvailableTransferModes,
|
||||||
|
getOfflineTransferSessionLabel,
|
||||||
|
getOfflineTransferSessionSize,
|
||||||
canArchiveTransferSelection,
|
canArchiveTransferSelection,
|
||||||
buildQrImageUrl,
|
buildQrImageUrl,
|
||||||
canSendTransferFiles,
|
canSendTransferFiles,
|
||||||
@@ -51,14 +54,19 @@ test('buildQrImageUrl encodes the share url as a QR image endpoint', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('resolveInitialTransferTab prefers receive mode for public visitors and shared sessions', () => {
|
test('resolveInitialTransferTab prefers receive mode for public visitors and shared sessions', () => {
|
||||||
assert.equal(resolveInitialTransferTab(false, null), 'receive');
|
assert.equal(resolveInitialTransferTab(false, null), 'send');
|
||||||
assert.equal(resolveInitialTransferTab(true, '849201'), 'receive');
|
assert.equal(resolveInitialTransferTab(true, '849201'), 'receive');
|
||||||
assert.equal(resolveInitialTransferTab(true, null), 'send');
|
assert.equal(resolveInitialTransferTab(true, null), 'send');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('canSendTransferFiles requires an authenticated session', () => {
|
test('canSendTransferFiles allows public online transfer entry', () => {
|
||||||
assert.equal(canSendTransferFiles(true), true);
|
assert.equal(canSendTransferFiles(true), true);
|
||||||
assert.equal(canSendTransferFiles(false), false);
|
assert.equal(canSendTransferFiles(false), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getAvailableTransferModes keeps offline mode behind login', () => {
|
||||||
|
assert.deepEqual(getAvailableTransferModes(false), ['ONLINE']);
|
||||||
|
assert.deepEqual(getAvailableTransferModes(true), ['ONLINE', 'OFFLINE']);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('getTransferModeSummary describes the offline seven-day retention rule', () => {
|
test('getTransferModeSummary describes the offline seven-day retention rule', () => {
|
||||||
@@ -68,6 +76,31 @@ test('getTransferModeSummary describes the offline seven-day retention rule', ()
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('offline transfer history helpers summarize the session title and total size', () => {
|
||||||
|
const singleFileSession = {
|
||||||
|
sessionId: 'session-1',
|
||||||
|
pickupCode: '723325',
|
||||||
|
mode: 'OFFLINE' as const,
|
||||||
|
expiresAt: '2026-04-09T08:00:00Z',
|
||||||
|
files: [
|
||||||
|
{name: 'cover.png', relativePath: '活动图/cover.png', size: 2048, contentType: 'image/png', uploaded: true},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const multiFileSession = {
|
||||||
|
...singleFileSession,
|
||||||
|
sessionId: 'session-2',
|
||||||
|
files: [
|
||||||
|
{name: 'cover.png', relativePath: '活动图/cover.png', size: 2048, contentType: 'image/png', uploaded: true},
|
||||||
|
{name: 'notes.pdf', relativePath: '活动图/notes.pdf', size: 1024, contentType: 'application/pdf', uploaded: true},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
assert.equal(getOfflineTransferSessionLabel(singleFileSession), 'cover.png');
|
||||||
|
assert.equal(getOfflineTransferSessionLabel(multiFileSession), 'cover.png 等 2 项');
|
||||||
|
assert.equal(getOfflineTransferSessionSize(multiFileSession), '3 KB');
|
||||||
|
});
|
||||||
|
|
||||||
test('canArchiveTransferSelection is enabled for multi-file or folder downloads', () => {
|
test('canArchiveTransferSelection is enabled for multi-file or folder downloads', () => {
|
||||||
assert.equal(canArchiveTransferSelection([
|
assert.equal(canArchiveTransferSelection([
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { TransferMode } from '../lib/types';
|
import type { TransferMode, TransferSessionResponse } from '../lib/types';
|
||||||
import type { TransferFileDescriptor } from '../lib/transfer-protocol';
|
import type { TransferFileDescriptor } from '../lib/transfer-protocol';
|
||||||
|
|
||||||
export type TransferTab = 'send' | 'receive';
|
export type TransferTab = 'send' | 'receive';
|
||||||
@@ -34,7 +34,14 @@ export function buildQrImageUrl(shareUrl: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function canSendTransferFiles(isAuthenticated: boolean) {
|
export function canSendTransferFiles(isAuthenticated: boolean) {
|
||||||
return isAuthenticated;
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAvailableTransferModes(isAuthenticated: boolean): TransferMode[] {
|
||||||
|
if (isAuthenticated) {
|
||||||
|
return ['ONLINE', 'OFFLINE'];
|
||||||
|
}
|
||||||
|
return ['ONLINE'];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getTransferModeSummary(mode: TransferMode) {
|
export function getTransferModeSummary(mode: TransferMode) {
|
||||||
@@ -51,11 +58,26 @@ export function getTransferModeSummary(mode: TransferMode) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getOfflineTransferSessionLabel(session: Pick<TransferSessionResponse, 'files'>) {
|
||||||
|
const firstFile = session.files[0];
|
||||||
|
if (!firstFile) {
|
||||||
|
return '未命名离线快传';
|
||||||
|
}
|
||||||
|
if (session.files.length === 1) {
|
||||||
|
return firstFile.name;
|
||||||
|
}
|
||||||
|
return `${firstFile.name} 等 ${session.files.length} 项`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getOfflineTransferSessionSize(session: Pick<TransferSessionResponse, 'files'>) {
|
||||||
|
return formatTransferSize(session.files.reduce((sum, file) => sum + file.size, 0));
|
||||||
|
}
|
||||||
|
|
||||||
export function resolveInitialTransferTab(
|
export function resolveInitialTransferTab(
|
||||||
isAuthenticated: boolean,
|
isAuthenticated: boolean,
|
||||||
sessionId: string | null,
|
sessionId: string | null,
|
||||||
): TransferTab {
|
): TransferTab {
|
||||||
if (!canSendTransferFiles(isAuthenticated) || sessionId) {
|
if (sessionId) {
|
||||||
return 'receive';
|
return 'receive';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,8 @@
|
|||||||
- 管理台用户列表已显示每个用户的已用空间 / 配额,表格也已收紧
|
- 管理台用户列表已显示每个用户的已用空间 / 配额,表格也已收紧
|
||||||
- 游戏页已接入 `/race/`、`/t_race/`,带站内播放器、退出按钮和友情链接
|
- 游戏页已接入 `/race/`、`/t_race/`,带站内播放器、退出按钮和友情链接
|
||||||
- 2026-04-02 已统一密码策略为“至少 8 位且包含大写字母”,并补测试确认管理员改密后旧密码失效、新密码生效
|
- 2026-04-02 已统一密码策略为“至少 8 位且包含大写字母”,并补测试确认管理员改密后旧密码失效、新密码生效
|
||||||
|
- 2026-04-02 已放开未登录直达快传:登录页可直接进入快传,匿名用户可发在线快传和接收在线快传,但离线快传仍要求登录
|
||||||
|
- 2026-04-02 快传发送页已新增“我的离线快传”区域:登录用户可查看自己未过期的离线快传记录,并点开弹层重新查看取件码、二维码和分享链接
|
||||||
- 根目录 README 已重写为中文公开版 GitHub 风格
|
- 根目录 README 已重写为中文公开版 GitHub 风格
|
||||||
- VS Code 工作区已补 `.vscode/settings.json`、`.vscode/extensions.json`、`lombok.config`,并在 `backend/pom.xml` 显式声明了 Lombok annotation processor
|
- VS Code 工作区已补 `.vscode/settings.json`、`.vscode/extensions.json`、`lombok.config`,并在 `backend/pom.xml` 显式声明了 Lombok annotation processor
|
||||||
- 进行中:
|
- 进行中:
|
||||||
@@ -36,6 +38,8 @@
|
|||||||
| 主站 CORS 默认放行 `https://yoyuzh.xyz` 与 `https://www.yoyuzh.xyz` | 前端生产环境托管在独立静态站域名下,必须允许主站跨域调用后端 API | 仅保留 localhost: 会导致生产站调用 API 时被浏览器拦截 |
|
| 主站 CORS 默认放行 `https://yoyuzh.xyz` 与 `https://www.yoyuzh.xyz` | 前端生产环境托管在独立静态站域名下,必须允许主站跨域调用后端 API | 仅保留 localhost: 会导致生产站调用 API 时被浏览器拦截 |
|
||||||
| 文件存储切到多吉云对象存储并使用临时密钥 | 后端、前端发布和迁移脚本都可统一走 S3 兼容协议,同时减少长期静态密钥暴露 | 继续使用阿里云 OSS 固定密钥: 已不符合当前多吉云接入方式 |
|
| 文件存储切到多吉云对象存储并使用临时密钥 | 后端、前端发布和迁移脚本都可统一走 S3 兼容协议,同时减少长期静态密钥暴露 | 继续使用阿里云 OSS 固定密钥: 已不符合当前多吉云接入方式 |
|
||||||
| 密码策略放宽到“至少 8 位且包含大写字母” | 降低注册和管理员改密阻力,同时保留最基础的复杂度门槛 | 继续要求大小写 + 数字 + 特殊字符: 对当前站点用户而言过重,且已导致后台改密体验不一致 |
|
| 密码策略放宽到“至少 8 位且包含大写字母” | 降低注册和管理员改密阻力,同时保留最基础的复杂度门槛 | 继续要求大小写 + 数字 + 特殊字符: 对当前站点用户而言过重,且已导致后台改密体验不一致 |
|
||||||
|
| 匿名用户仅开放在线快传,不开放离线快传 | 允许登录页直接进入快传,同时避免匿名用户占用站点持久存储 | 匿名也开放离线快传: 会增加滥用风险和存储成本 |
|
||||||
|
| 已登录用户可以在快传页回看自己的离线快传记录 | 离线快传有效期长达 7 天,用户需要在不重新上传的情况下再次查看取件码和分享链接 | 只在刚创建成功时展示一次取件信息: 用户丢失取件码后无法自助找回 |
|
||||||
|
|
||||||
## 待解决问题
|
## 待解决问题
|
||||||
- [ ] VS Code 若仍报 `final 字段未在构造器初始化` 之类错误,优先判断为 Lombok / Java Language Server 误报,而不是源码真实错误
|
- [ ] VS Code 若仍报 `final 字段未在构造器初始化` 之类错误,优先判断为 Lombok / Java Language Server 误报,而不是源码真实错误
|
||||||
@@ -73,5 +77,7 @@
|
|||||||
- 密码策略: `backend/src/main/java/com/yoyuzh/auth/PasswordPolicy.java`
|
- 密码策略: `backend/src/main/java/com/yoyuzh/auth/PasswordPolicy.java`
|
||||||
- 网盘树状目录: `front/src/pages/Files.tsx`、`front/src/pages/files-tree.ts`
|
- 网盘树状目录: `front/src/pages/Files.tsx`、`front/src/pages/files-tree.ts`
|
||||||
- 快传接收页: `front/src/pages/TransferReceive.tsx`
|
- 快传接收页: `front/src/pages/TransferReceive.tsx`
|
||||||
|
- 未登录快传权限: `backend/src/main/java/com/yoyuzh/transfer/TransferController.java`、`backend/src/main/java/com/yoyuzh/transfer/TransferService.java`
|
||||||
|
- 离线快传历史与详情弹层: `front/src/pages/Transfer.tsx`、`front/src/pages/transfer-state.ts`
|
||||||
- 管理员改密接口: `backend/src/main/java/com/yoyuzh/admin/AdminService.java`
|
- 管理员改密接口: `backend/src/main/java/com/yoyuzh/admin/AdminService.java`
|
||||||
- 前端生产 API 基址: `front/.env.production`
|
- 前端生产 API 基址: `front/.env.production`
|
||||||
|
|||||||
Reference in New Issue
Block a user