Compare commits

...
13 Commits
Author SHA1 Message Date
seaheart 7c4a1431c4 upgrade version 2026-02-14 23:10:07 +08:00
seaheart 81712dd62d Support chunk upload and breakpoint resume feature 2026-02-08 16:45:08 +08:00
seaheart 8371133228 merge v4.1.0 2025-02-23 16:49:20 +08:00
yinyuchen d7fe343455 upgrade version 2024-10-19 14:57:53 +08:00
yinyuchen 0533dd2c56 change log 2024-07-27 23:54:38 +08:00
yinyuchen 447eb14200 feat: search file dfs 2024-07-27 23:39:32 +08:00
yinyuchen b0b5a5ca35 feat: add space bin 2024-07-27 18:05:11 +08:00
yinyuchen 19e6503da0 feat: add space recycle bin 2024-07-24 22:58:51 +08:00
yinyuchen bc7737cd0e feat: space member batch create 2024-07-20 15:25:06 +08:00
yinyuchen db31b4f204 fix: USER role file size error 2024-07-20 12:41:25 +08:00
Simba fbfc9b7c83 Merge pull request #49 from shih6/bugfix/upload-progress-freeze
fix upload Progress freeze
2024-06-30 22:12:37 +08:00
yinyuchen dd7fbe762e update dingding qrcode 2024-06-26 22:50:51 +08:00
zhenbin e92b1f374b fix upload Progress freeze 2023-10-27 11:06:34 +08:00
36 changed files with 1841 additions and 320 deletions
-24
View File
@@ -1,24 +0,0 @@
# 4.0.4
1. 修复已知问题
# 4.0.3
1. 修复已知问题
# 4.0.2
1. 修复文件夹跳转列表获取问题
# 4.0.1
1. 修复一些已知问题
# 4.0.0
1. 共享空间
# 3.2.0
1. PC端不展示左侧关于菜单
# 3.1.6
1. 增加云盘剩余容量显示
2. 优化拖拽文件夹进行上传的错误提示
3. 优化超出限制大小的错误提示
4. 文件列表页右键展示操作目录
5. 修复一些已知问题
+5 -3
View File
@@ -2,12 +2,14 @@
[English Version](https://tank-doc.eyeblue.cn/en)
# 蓝眼云盘(4.0.4)
[在线Demo](https://tank.eyeblue.cn) (体验账号: demo 密码:123456)
# 蓝眼云盘(4.2.0)
[在线Demo](https://tank.eyeblue.cn) (体验账号:demo 密码:123456)
[更新文档 CHANGELOG](https://github.com/eyebluecn/tank/blob/master/CHANGELOG)
蓝眼云盘是蓝眼开源系列代表作品之一,致力于打造精致,优雅,简约的云盘。核心功能如下:
1. 文件列表
2. 文件/文件夹上传 + 拖拽上传
2. 文件/文件夹上传 + 拖拽上传 + 分片/断点续传
3. 文件分享
4. 文件监控统计
5. 回收站
Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

After

Width:  |  Height:  |  Size: 74 KiB

+5 -2
View File
@@ -1,6 +1,6 @@
{
"name": "tank-front",
"version": "4.0.4",
"version": "4.1.1",
"private": true,
"dependencies": {
"@ant-design/icons": "4.7.0",
@@ -24,6 +24,7 @@
"react-router": "^5.2.0",
"react-router-dom": "^5.2.0",
"react-scripts": "5.0.0",
"spark-md5": "^3.0.2",
"typescript": "4.4.2",
"velocity-react": "^1.4.3"
},
@@ -44,6 +45,7 @@
]
},
"devDependencies": {
"@types/spark-md5": "^3.0.5",
"husky": "^8.0.3",
"lint-staged": "^13.2.2",
"prettier": "^2.8.8"
@@ -58,5 +60,6 @@
"prettier --write",
"git add"
]
}
},
"packageManager": "yarn@1.22.19+sha1.4ba7fc5c6e704fce2066ecbfb0b0d8976fe62447"
}
-2
View File
@@ -256,8 +256,6 @@ export default class Pager<T> extends HttpBase {
return params;
}
console.log("this.filters",this.filters)
for (let i = 0; i < this.filters.length; i++) {
let filter = this.filters[i];
+733
View File
@@ -0,0 +1,733 @@
import axios, { CancelTokenSource } from 'axios';
import HttpUtil from '../../util/HttpUtil';
import { calculateChunkMd5, isMd5Available } from '../../util/Md5Util';
// Default chunk size: 50MB
const DEFAULT_CHUNK_SIZE = 50 * 1024 * 1024;
// Minimum chunk size: 5MB (aligned with AWS S3 standard)
const MIN_CHUNK_SIZE = 5 * 1024 * 1024;
// Maximum chunk size: 100MB
const MAX_CHUNK_SIZE = 100 * 1024 * 1024;
// Minimum file size to use chunked upload: 100MB
const MIN_FILE_SIZE_FOR_CHUNK = 100 * 1024 * 1024;
// Maximum concurrent chunk uploads
const MAX_CONCURRENT_UPLOADS = 6;
// Maximum retry attempts for failed chunk uploads
const MAX_RETRY_ATTEMPTS = 3;
// Base delay for retry backoff (ms)
const RETRY_BASE_DELAY = 1000;
// Session expiration time for localStorage cleanup (24 hours in ms, matching backend)
const SESSION_MAX_AGE_MS = 24 * 60 * 60 * 1000;
// Upload timeout for large chunks (10 minutes in ms)
const UPLOAD_TIMEOUT_MS = 10 * 60 * 1000;
// Upload session status
export enum UploadSessionStatus {
UPLOADING = 'UPLOADING',
MERGING = 'MERGING',
COMPLETED = 'COMPLETED',
ERROR = 'ERROR',
CANCELLED = 'CANCELLED',
PAUSED = 'PAUSED',
}
// Upload session info from server
export interface UploadSession {
uuid: string;
userUuid: string;
spaceUuid: string;
matterUuid: string;
puuid: string;
filename: string;
totalSize: number;
chunkSize: number;
totalChunks: number;
privacy: boolean;
fileMd5: string;
status: string;
expireTime: string;
}
// Session info with uploaded chunks
export interface UploadSessionInfo {
session: UploadSession;
uploadedChunks: number[];
missingChunks: number[];
}
// Chunk upload progress callback
export interface ChunkUploadProgress {
sessionUuid: string;
filename: string;
totalSize: number;
uploadedSize: number;
progress: number; // 0-1
speed: number; // bytes per second
uploadedChunks: number;
totalChunks: number;
status: UploadSessionStatus;
}
// Local storage key for saving upload sessions
const UPLOAD_SESSION_STORAGE_KEY = 'tank_chunk_upload_sessions';
export default class ChunkUploader {
// API endpoints
static URL_CREATE_SESSION = '/api/chunk/create/session';
static URL_UPLOAD_CHUNK = '/api/chunk/upload';
static URL_MERGE_CHUNKS = '/api/chunk/merge';
static URL_SESSION_INFO = '/api/chunk/session/info';
static URL_CANCEL_SESSION = '/api/chunk/cancel';
// File to upload
file: File;
// Parent directory UUID
puuid: string;
// Space UUID
spaceUuid: string;
// Privacy flag
privacy: boolean;
// Chunk size in bytes
chunkSize: number;
// Upload session info
session: UploadSession | null = null;
// Current status
status: UploadSessionStatus = UploadSessionStatus.UPLOADING;
// Uploaded chunk indices
uploadedChunks: Set<number> = new Set();
// Uploading chunk indices (currently in progress)
uploadingChunks: Set<number> = new Set();
// Total chunks
totalChunks: number = 0;
// Cancel token sources for each chunk
cancelSources: Map<number, CancelTokenSource> = new Map();
// Progress callback
onProgress?: (progress: ChunkUploadProgress) => void;
// Complete callback
onComplete?: (matter: any) => void;
// Error callback
onError?: (error: string) => void;
// React component for UI updates
reactComponent?: React.Component;
// Speed calculation
private lastUpdateTime: number = 0;
private lastUploadedSize: number = 0;
private currentSpeed: number = 0;
// Track progress of each chunk (chunkIndex -> bytes uploaded)
private chunkProgress: Map<number, number> = new Map();
// Quick file fingerprint for session matching (not full MD5)
private fileFingerprint: string = '';
// Whether chunk-level MD5 verification is available (spark-md5 installed)
private chunkMd5Available: boolean = false;
constructor(
file: File,
puuid: string,
spaceUuid: string,
privacy: boolean = true,
chunkSize?: number,
reactComponent?: React.Component
) {
this.file = file;
this.puuid = puuid;
this.spaceUuid = spaceUuid;
this.privacy = privacy;
this.reactComponent = reactComponent;
// Validate and set chunk size
if (chunkSize) {
this.chunkSize = Math.max(MIN_CHUNK_SIZE, Math.min(MAX_CHUNK_SIZE, chunkSize));
} else {
this.chunkSize = DEFAULT_CHUNK_SIZE;
}
// Calculate total chunks
this.totalChunks = Math.ceil(file.size / this.chunkSize);
}
// Check if file should use chunked upload
static shouldUseChunkedUpload(fileSize: number): boolean {
return fileSize >= MIN_FILE_SIZE_FOR_CHUNK;
}
// Start the upload process
async start(): Promise<void> {
try {
this.status = UploadSessionStatus.UPLOADING;
this.lastUpdateTime = Date.now();
this.lastUploadedSize = 0;
// Calculate file fingerprint for better session matching
this.fileFingerprint = await this.calculateFileFingerprint();
// Try to resume from existing session
const existingSession = await this.loadSessionFromStorage();
if (existingSession) {
// Verify session with server
const sessionInfo = await this.getSessionInfo(existingSession.uuid);
if (sessionInfo && sessionInfo.session.status === 'UPLOADING') {
this.session = sessionInfo.session;
this.uploadedChunks = new Set(sessionInfo.uploadedChunks);
this.totalChunks = this.session.totalChunks;
this.updateProgress();
await this.uploadMissingChunks(sessionInfo.missingChunks);
return;
} else {
// Session expired or completed, remove from storage
this.removeSessionFromStorage();
}
}
// Check if chunk-level MD5 verification is available
this.chunkMd5Available = await isMd5Available();
if (this.chunkMd5Available) {
console.info('Chunk-level MD5 verification enabled');
}
// Create new session
await this.createSession();
// Save session to storage for resume capability
this.saveSessionToStorage();
// Upload all chunks
const missingChunks = Array.from({ length: this.totalChunks }, (_, i) => i);
await this.uploadMissingChunks(missingChunks);
} catch (error: any) {
// Don't change status if already PAUSED (pause() was called)
if (this.status === UploadSessionStatus.PAUSED) {
return;
}
if (axios.isCancel(error)) {
this.status = UploadSessionStatus.CANCELLED;
} else {
this.status = UploadSessionStatus.ERROR;
this.onError?.(error.message || 'Upload failed');
}
this.updateProgress();
}
}
// Create upload session
private async createSession(): Promise<void> {
return new Promise((resolve, reject) => {
const formData = new FormData();
formData.append('puuid', this.puuid);
formData.append('filename', this.file.name);
formData.append('totalSize', this.file.size.toString());
formData.append('chunkSize', this.chunkSize.toString());
formData.append('privacy', this.privacy.toString());
formData.append('spaceUuid', this.spaceUuid);
HttpUtil.httpPost(
ChunkUploader.URL_CREATE_SESSION,
formData,
(response: any) => {
this.session = response.data.data;
this.totalChunks = this.session!.totalChunks;
resolve();
},
(error: any) => {
const msg = error.response?.data?.msg || 'Failed to create upload session';
reject(new Error(msg));
}
);
});
}
// Get session info from server
private async getSessionInfo(sessionUuid: string): Promise<UploadSessionInfo | null> {
return new Promise((resolve) => {
HttpUtil.httpGet(
ChunkUploader.URL_SESSION_INFO,
{ sessionUuid },
(response: any) => {
resolve(response.data.data);
},
() => {
resolve(null);
}
);
});
}
// Upload missing chunks with concurrency control
private async uploadMissingChunks(missingChunks: number[]): Promise<void> {
const queue = [...missingChunks];
const uploading: Promise<void>[] = [];
while (queue.length > 0 || uploading.length > 0) {
// Check if cancelled or paused
if (this.status === UploadSessionStatus.CANCELLED || this.status === UploadSessionStatus.PAUSED) {
// Wait for ongoing uploads to finish
await Promise.all(uploading);
return;
}
// Start new uploads up to max concurrent
while (queue.length > 0 && this.uploadingChunks.size < MAX_CONCURRENT_UPLOADS) {
const chunkIndex = queue.shift()!;
const promise = this.uploadChunk(chunkIndex).then(() => {
const index = uploading.indexOf(promise);
if (index > -1) {
uploading.splice(index, 1);
}
});
uploading.push(promise);
}
// Wait for at least one upload to complete
if (uploading.length > 0) {
await Promise.race(uploading);
}
}
// All chunks uploaded, merge them
if (this.status === UploadSessionStatus.UPLOADING) {
await this.mergeChunks();
}
}
// Upload a single chunk with retry support and chunk-level MD5 verification
private async uploadChunk(chunkIndex: number, retryCount: number = 0): Promise<void> {
if (this.uploadedChunks.has(chunkIndex)) {
return;
}
this.uploadingChunks.add(chunkIndex);
this.chunkProgress.set(chunkIndex, 0);
const start = chunkIndex * this.chunkSize;
const end = Math.min(start + this.chunkSize, this.file.size);
const chunk = this.file.slice(start, end);
const chunkActualSize = end - start;
// Calculate chunk MD5 if available (this is fast since chunk is small)
let chunkMd5 = '';
if (this.chunkMd5Available) {
try {
chunkMd5 = await calculateChunkMd5(chunk);
} catch (e) {
console.warn(`Failed to calculate MD5 for chunk ${chunkIndex}:`, e);
}
}
const formData = new FormData();
formData.append('sessionUuid', this.session!.uuid);
formData.append('chunkIndex', chunkIndex.toString());
formData.append('file', chunk);
formData.append('spaceUuid', this.spaceUuid);
if (chunkMd5) {
formData.append('chunkMd5', chunkMd5);
}
const cancelSource = axios.CancelToken.source();
this.cancelSources.set(chunkIndex, cancelSource);
return new Promise((resolve, reject) => {
HttpUtil.httpPostFile(
ChunkUploader.URL_UPLOAD_CHUNK,
formData,
() => {
this.uploadingChunks.delete(chunkIndex);
this.uploadedChunks.add(chunkIndex);
this.chunkProgress.set(chunkIndex, chunkActualSize);
this.cancelSources.delete(chunkIndex);
this.updateProgress();
resolve();
},
(error: any) => {
this.uploadingChunks.delete(chunkIndex);
this.cancelSources.delete(chunkIndex);
this.chunkProgress.delete(chunkIndex);
if (axios.isCancel(error)) {
reject(error);
} else if (retryCount < MAX_RETRY_ATTEMPTS) {
// Determine if error is retryable (network errors, timeouts)
const isRetryable =
error.code === 'ECONNABORTED' || // timeout
error.code === 'ERR_NETWORK' || // network error
!error.response || // no response (network issue)
(error.response?.status >= 500 && error.response?.status < 600); // server errors
if (isRetryable) {
// Retry with exponential backoff
const delay = RETRY_BASE_DELAY * Math.pow(2, retryCount);
console.warn(
`Chunk ${chunkIndex} upload failed (${error.code || error.response?.status || 'unknown'}), retrying in ${delay}ms (attempt ${retryCount + 1}/${MAX_RETRY_ATTEMPTS})`
);
setTimeout(() => {
this.uploadChunk(chunkIndex, retryCount + 1).then(resolve).catch(reject);
}, delay);
} else {
// Non-retryable error (4xx client errors)
const msg = error.response?.data?.msg || `Failed to upload chunk ${chunkIndex}: ${error.message}`;
reject(new Error(msg));
}
} else {
const msg = error.response?.data?.msg || `Failed to upload chunk ${chunkIndex} after ${MAX_RETRY_ATTEMPTS} attempts`;
reject(new Error(msg));
}
},
undefined,
(progressEvent: any) => {
// Track real-time progress for this chunk
if (progressEvent.loaded) {
this.chunkProgress.set(chunkIndex, progressEvent.loaded);
this.updateProgress();
}
},
{ cancelToken: cancelSource.token, timeout: UPLOAD_TIMEOUT_MS }
);
});
}
// Merge all chunks
private async mergeChunks(): Promise<void> {
this.status = UploadSessionStatus.MERGING;
this.updateProgress();
return new Promise((resolve, reject) => {
const formData = new FormData();
formData.append('sessionUuid', this.session!.uuid);
formData.append('spaceUuid', this.spaceUuid);
HttpUtil.httpPost(
ChunkUploader.URL_MERGE_CHUNKS,
formData,
(response: any) => {
this.status = UploadSessionStatus.COMPLETED;
this.removeSessionFromStorage();
this.updateProgress();
this.onComplete?.(response.data.data);
resolve();
},
(error: any) => {
this.status = UploadSessionStatus.ERROR;
const msg = error.response?.data?.msg || 'Failed to merge chunks';
this.onError?.(msg);
reject(new Error(msg));
}
);
});
}
// Update progress with precise chunk-level tracking
private updateProgress(): void {
// Calculate precise uploaded size including in-progress chunks
let uploadedSize = 0;
// Add completed chunks (use actual size for last chunk)
Array.from(this.uploadedChunks).forEach((chunkIndex) => {
const start = chunkIndex * this.chunkSize;
const end = Math.min(start + this.chunkSize, this.file.size);
uploadedSize += end - start;
});
// Add in-progress chunk bytes
this.chunkProgress.forEach((bytes, chunkIndex) => {
if (!this.uploadedChunks.has(chunkIndex)) {
uploadedSize += bytes;
}
});
const actualUploadedSize = Math.min(uploadedSize, this.file.size);
// Calculate speed
const now = Date.now();
const timeDelta = now - this.lastUpdateTime;
if (timeDelta > 500) {
const sizeDelta = actualUploadedSize - this.lastUploadedSize;
this.currentSpeed = Math.round(sizeDelta / (timeDelta / 1000));
this.lastUpdateTime = now;
this.lastUploadedSize = actualUploadedSize;
}
const progress: ChunkUploadProgress = {
sessionUuid: this.session?.uuid || '',
filename: this.file.name,
totalSize: this.file.size,
uploadedSize: actualUploadedSize,
progress: actualUploadedSize / this.file.size,
speed: this.currentSpeed,
uploadedChunks: this.uploadedChunks.size,
totalChunks: this.totalChunks,
status: this.status,
};
this.onProgress?.(progress);
// Update React component if provided
if (this.reactComponent) {
this.reactComponent.setState({});
}
}
// Pause upload
pause(): void {
this.status = UploadSessionStatus.PAUSED;
// Cancel ongoing chunk uploads
this.cancelSources.forEach((source) => {
source.cancel('Paused');
});
this.uploadingChunks.clear();
this.cancelSources.clear();
// Don't clear chunkProgress or call updateProgress() here
// to avoid progress bar jumping back. The progress will be
// recalculated when upload resumes.
}
// Resume upload
async resume(): Promise<void> {
if (this.status !== UploadSessionStatus.PAUSED) {
return;
}
this.status = UploadSessionStatus.UPLOADING;
// Clear stale progress data from cancelled chunks
this.chunkProgress.clear();
// Get missing chunks
const missingChunks: number[] = [];
for (let i = 0; i < this.totalChunks; i++) {
if (!this.uploadedChunks.has(i)) {
missingChunks.push(i);
}
}
try {
await this.uploadMissingChunks(missingChunks);
} catch (error: any) {
// Don't change status if paused during resume (status may change during async operation)
const currentStatus = this.status as UploadSessionStatus;
if (currentStatus === UploadSessionStatus.PAUSED) {
return;
}
if (!axios.isCancel(error)) {
this.status = UploadSessionStatus.ERROR;
this.onError?.(error.message || 'Upload failed');
}
this.updateProgress();
}
}
// Cancel upload
cancel(): void {
this.status = UploadSessionStatus.CANCELLED;
this.cancelSources.forEach((source) => {
source.cancel('Cancelled');
});
// Cancel session on server
if (this.session) {
const formData = new FormData();
formData.append('sessionUuid', this.session.uuid);
formData.append('spaceUuid', this.spaceUuid);
HttpUtil.httpPost(ChunkUploader.URL_CANCEL_SESSION, formData);
}
this.removeSessionFromStorage();
this.updateProgress();
}
// Save session to local storage for resume capability
private saveSessionToStorage(): void {
if (!this.session) return;
const storageKey = this.getStorageKey();
const sessions = this.getAllSessionsFromStorage();
sessions[storageKey] = {
sessionUuid: this.session.uuid,
filename: this.file.name,
fileSize: this.file.size,
puuid: this.puuid,
spaceUuid: this.spaceUuid,
privacy: this.privacy,
lastModified: this.file.lastModified,
fingerprint: this.fileFingerprint, // Store fingerprint for better matching
savedAt: Date.now(),
};
try {
localStorage.setItem(UPLOAD_SESSION_STORAGE_KEY, JSON.stringify(sessions));
} catch (e) {
console.warn('Failed to save upload session to localStorage', e);
}
}
// Load session from local storage with improved file matching
private async loadSessionFromStorage(): Promise<{ uuid: string } | null> {
const storageKey = this.getStorageKey();
const sessions = this.getAllSessionsFromStorage();
const saved = sessions[storageKey];
if (!saved) {
return null;
}
// Basic checks: size and lastModified must match
if (saved.fileSize !== this.file.size || saved.lastModified !== this.file.lastModified) {
return null;
}
// Enhanced check: if fingerprint exists in saved session, verify it matches
if (saved.fingerprint && this.fileFingerprint && saved.fingerprint !== this.fileFingerprint) {
console.warn('File fingerprint mismatch, file content may have changed');
// Remove stale session
delete sessions[storageKey];
localStorage.setItem(UPLOAD_SESSION_STORAGE_KEY, JSON.stringify(sessions));
return null;
}
return { uuid: saved.sessionUuid };
}
// Remove session from local storage
private removeSessionFromStorage(): void {
const storageKey = this.getStorageKey();
const sessions = this.getAllSessionsFromStorage();
delete sessions[storageKey];
try {
localStorage.setItem(UPLOAD_SESSION_STORAGE_KEY, JSON.stringify(sessions));
} catch (e) {
console.warn('Failed to remove upload session from localStorage', e);
}
}
// Get all sessions from storage with automatic cleanup of expired sessions
private getAllSessionsFromStorage(): Record<string, any> {
try {
const data = localStorage.getItem(UPLOAD_SESSION_STORAGE_KEY);
if (!data) return {};
const sessions = JSON.parse(data);
const now = Date.now();
let hasExpired = false;
// Clean up expired sessions
for (const key of Object.keys(sessions)) {
const session = sessions[key];
if (session.savedAt && now - session.savedAt > SESSION_MAX_AGE_MS) {
delete sessions[key];
hasExpired = true;
console.info(`Cleaned up expired upload session: ${key}`);
}
}
// Save back if any sessions were removed
if (hasExpired) {
localStorage.setItem(UPLOAD_SESSION_STORAGE_KEY, JSON.stringify(sessions));
}
return sessions;
} catch (e) {
return {};
}
}
// Generate storage key based on file properties
private getStorageKey(): string {
return `${this.spaceUuid}_${this.puuid}_${this.file.name}_${this.file.size}_${this.file.lastModified}`;
}
// Get current progress value (0-1)
getProgress(): number {
return this.uploadedChunks.size / this.totalChunks;
}
// Get current speed in bytes per second
getSpeed(): number {
return this.currentSpeed;
}
// Get uploaded size with precise calculation
getUploadedSize(): number {
let uploadedSize = 0;
// Add completed chunks
Array.from(this.uploadedChunks).forEach((chunkIndex) => {
const start = chunkIndex * this.chunkSize;
const end = Math.min(start + this.chunkSize, this.file.size);
uploadedSize += end - start;
});
// Add in-progress chunk bytes
this.chunkProgress.forEach((bytes, chunkIndex) => {
if (!this.uploadedChunks.has(chunkIndex)) {
uploadedSize += bytes;
}
});
return Math.min(uploadedSize, this.file.size);
}
// Check if upload is complete
isComplete(): boolean {
return this.status === UploadSessionStatus.COMPLETED;
}
// Check if upload is in progress
isUploading(): boolean {
return this.status === UploadSessionStatus.UPLOADING || this.status === UploadSessionStatus.MERGING;
}
// Check if upload is paused
isPaused(): boolean {
return this.status === UploadSessionStatus.PAUSED;
}
// Check if upload failed
isFailed(): boolean {
return this.status === UploadSessionStatus.ERROR;
}
// Check if upload is cancelled
isCancelled(): boolean {
return this.status === UploadSessionStatus.CANCELLED;
}
// Calculate a quick fingerprint from file's first and last bytes
// This provides better uniqueness than just size+lastModified without reading the whole file
private async calculateFileFingerprint(): Promise<string> {
const SAMPLE_SIZE = 64 * 1024; // 64KB sample from start and end
try {
// Read first 64KB
const firstChunk = this.file.slice(0, Math.min(SAMPLE_SIZE, this.file.size));
const firstBuffer = await firstChunk.arrayBuffer();
const firstView = new Uint8Array(firstBuffer);
// Read last 64KB (if file is large enough)
let lastView = new Uint8Array(0);
if (this.file.size > SAMPLE_SIZE * 2) {
const lastChunk = this.file.slice(this.file.size - SAMPLE_SIZE);
const lastBuffer = await lastChunk.arrayBuffer();
lastView = new Uint8Array(lastBuffer);
}
// Simple hash: combine samples into a fingerprint string
// Using a simple checksum approach for speed
let hash = 0;
for (let i = 0; i < firstView.length; i++) {
hash = ((hash << 5) - hash + firstView[i]) | 0;
}
for (let i = 0; i < lastView.length; i++) {
hash = ((hash << 5) - hash + lastView[i]) | 0;
}
// Convert to hex string
return (hash >>> 0).toString(16).padStart(8, '0');
} catch (e) {
console.warn('Failed to calculate file fingerprint:', e);
return '';
}
}
}
+21
View File
@@ -98,6 +98,7 @@ let LangEn = {
delete: 'Delete',
hardDelete: 'Hard delete',
recovery: 'Recovery',
recoverySuccess: 'Recovery Succeed',
more: 'More',
share: 'Share',
close: 'Close',
@@ -170,6 +171,20 @@ let LangEn = {
crawlSuccessTip: 'Crawl successfully',
crawlDescription:
'Crawl files refers to the backend downloading directly from the specified resource link to the current directory of EyeblueTank',
// Chunked upload related
chunks: 'chunks',
chunkedUpload: 'Chunked Upload',
mergingChunks: 'Merging chunks...',
paused: 'Paused',
completed: 'Completed',
uploadFailed: 'Upload Failed',
cancelled: 'Cancelled',
pause: 'Pause',
resume: 'Resume',
cancel: 'Cancel',
// MD5 verification related
calculatingMd5: 'Calculating MD5...',
md5VerificationEnabled: 'MD5 verification enabled',
},
router: {
allFiles: 'All Files',
@@ -208,6 +223,9 @@ let LangEn = {
docLink: 'Document Link',
tankDocLink: 'https://tank-doc.eyeblue.cn',
allowRegister: 'Allow register',
allowChunkUpload: 'Allow chunk upload',
allowChunkUploadTip:
'Chunked upload is for large files (≥100MB) with resume support. Recommended when server supports HTTP/2, otherwise concurrent connection limits may slow down uploads.',
systemCleanup: 'System Cleanup',
systemCleanupDescription:
"This operation will cleanup everything except administrators' data",
@@ -378,6 +396,9 @@ let LangEn = {
emptyHint: 'No Space Yet',
allSpaces: 'All Spaces',
partialSpaces: 'Partial Spaces',
bin: 'Space recycle bin',
edit: 'Edit Space',
delete: 'Delete Space',
},
model: {
usernameRule: 'only lowercase letter and number and _ is permitted.',
+21
View File
@@ -94,6 +94,7 @@ let LangZh = {
delete: '删除',
hardDelete: '彻底删除',
recovery: '恢复',
recoverySuccess: '恢复成功',
more: '更多',
share: '分享',
close: '关闭',
@@ -164,6 +165,20 @@ let LangZh = {
crawlSuccessTip: '抓取成功',
crawlDescription:
'抓取文件指后台将从指定资源链接直接下载到蓝眼云盘当前目录下',
// Chunked upload related
chunks: '分片',
chunkedUpload: '分片上传',
mergingChunks: '合并分片中...',
paused: '已暂停',
completed: '已完成',
uploadFailed: '上传失败',
cancelled: '已取消',
pause: '暂停',
resume: '继续',
cancel: '取消',
// MD5 verification related
calculatingMd5: '计算MD5校验中...',
md5VerificationEnabled: 'MD5校验已启用',
},
router: {
allFiles: '全部文件',
@@ -202,6 +217,9 @@ let LangZh = {
docLink: '文档链接',
tankDocLink: 'https://tank-doc.eyeblue.cn/zh',
allowRegister: '允许自主注册',
allowChunkUpload: '允许分片上传',
allowChunkUploadTip:
'分片上传适用于大文件(≥100MB),支持断点续传。建议在服务端支持HTTP/2协议时开启,否则可能因并发连接数限制导致上传变慢。',
systemCleanup: '重置系统',
systemCleanupDescription: '重置系统将清空除管理员账号外所有数据',
systemCleanupPrompt:
@@ -370,6 +388,9 @@ let LangZh = {
emptyHint: '暂无任何空间',
allSpaces: '全部空间',
partialSpaces: '局部空间',
bin: '空间回收站',
edit: '编辑空间',
delete: '删除空间',
},
model: {
usernameRule: '用户名只能包含字母,数字和"_"',
+166 -4
View File
@@ -17,7 +17,12 @@ import ImagePreviewer from '../../../pages/widget/previewer/ImagePreviewer';
import MessageBoxUtil from '../../util/MessageBoxUtil';
import PreviewerHelper from '../../../pages/widget/previewer/PreviewerHelper';
import HttpBase from '../base/HttpBase';
import User from "../user/User";
import User from '../user/User';
import ChunkUploader, {
UploadSessionStatus,
ChunkUploadProgress,
} from '../chunk/ChunkUploader';
import Moon from '../global/Moon';
export default class Matter extends BaseEntity {
puuid: string = '';
@@ -60,6 +65,17 @@ export default class Matter extends BaseEntity {
//取消上传source
cancelSource: CancelTokenSource = axios.CancelToken.source();
// Chunked upload related
chunkUploader: ChunkUploader | null = null;
// Uploaded chunks count (for UI display)
uploadedChunks: number = 0;
// Total chunks count (for UI display)
totalChunks: number = 0;
// Whether using chunked upload
useChunkedUpload: boolean = false;
// Upload status for chunked upload
uploadStatus: UploadSessionStatus = UploadSessionStatus.UPLOADING;
static URL_MATTER_CREATE_DIRECTORY = '/api/matter/create/directory';
static URL_MATTER_SOFT_DELETE = '/api/matter/soft/delete';
static URL_MATTER_SOFT_DELETE_BATCH = '/api/matter/soft/delete/batch';
@@ -73,6 +89,7 @@ export default class Matter extends BaseEntity {
static URL_MATTER_UPLOAD = '/api/matter/upload';
static URL_MATTER_ZIP = '/api/matter/zip';
static URL_MATTER_CRAWL = '/api/matter/crawl';
static URL_MATTER_SEARCH = '/api/matter/search';
static MATTER_ROOT = 'root';
@@ -247,6 +264,28 @@ export default class Matter extends BaseEntity {
);
}
static httpSearch(
params: {
puuid: string;
spaceUuid: string;
keyword?: string;
},
successCallback?: any,
errorCallback?: any,
finallyCallback?: any
) {
new HttpBase().httpGet(
Matter.URL_MATTER_SEARCH,
params,
(res: any) => {
const list = BaseEntity.renderList(res.data.data, Matter);
successCallback?.(list);
},
errorCallback,
finallyCallback
);
}
static httpCrawl(
body: {
url: string;
@@ -288,7 +327,7 @@ export default class Matter extends BaseEntity {
httpRecovery(successCallback?: any, errorCallback?: any) {
this.httpPost(
Matter.URL_MATTER_RECOVERY,
{ uuid: this.uuid },
{ uuid: this.uuid, spaceUuid: this.spaceUuid },
function (response: any) {
typeof successCallback === 'function' && successCallback(response);
},
@@ -298,12 +337,13 @@ export default class Matter extends BaseEntity {
static httpRecoveryBatch(
uuids: string,
spaceUuid: string,
successCallback?: any,
errorCallback?: any
) {
new HttpBase().httpPost(
Matter.URL_MATTER_RECOVERY_BATCH,
{ uuids: uuids },
{ uuids, spaceUuid },
function (response: any) {
typeof successCallback === 'function' && successCallback(response);
},
@@ -604,7 +644,129 @@ export default class Matter extends BaseEntity {
}
cancelUpload() {
this.cancelSource.cancel();
if (this.chunkUploader) {
this.chunkUploader.cancel();
this.loading = false;
this.uploadStatus = UploadSessionStatus.CANCELLED;
this.updateUI();
} else {
this.cancelSource.cancel();
}
}
// Pause chunked upload
pauseUpload() {
if (this.chunkUploader && this.uploadStatus === UploadSessionStatus.UPLOADING) {
this.chunkUploader.pause();
this.uploadStatus = UploadSessionStatus.PAUSED;
// Force UI update to show resume button
if (this.reactComponent) {
this.reactComponent.setState({ _forceUpdate: Date.now() });
}
}
}
// Resume chunked upload
resumeUpload() {
if (this.chunkUploader && this.uploadStatus === UploadSessionStatus.PAUSED) {
this.uploadStatus = UploadSessionStatus.UPLOADING;
this.chunkUploader.resume();
// Force UI update to show pause button
if (this.reactComponent) {
this.reactComponent.setState({ _forceUpdate: Date.now() });
}
}
}
// Check if using chunked upload and is paused
canResume(): boolean {
return this.useChunkedUpload && this.uploadStatus === UploadSessionStatus.PAUSED;
}
// Check if using chunked upload and is uploading (not merging, as merging cannot be paused)
canPause(): boolean {
return this.useChunkedUpload && this.uploadStatus === UploadSessionStatus.UPLOADING;
}
// Smart upload: use chunked upload for large files, regular upload for small files
httpSmartUpload(successCallback?: any, failureCallback?: any) {
if (!this.file) {
this.errorMessage = '请选择上传文件';
return;
}
// Check if chunk upload is enabled in preference and file is large enough
const allowChunkUpload = Moon.getSingleton().preference.allowChunkUpload;
if (allowChunkUpload && ChunkUploader.shouldUseChunkedUpload(this.file.size)) {
this.httpChunkedUpload(successCallback, failureCallback);
} else {
this.httpUpload(successCallback, failureCallback);
}
}
// Chunked upload for large files
// MD5 verification is always enabled when spark-md5 is available
httpChunkedUpload(successCallback?: any, failureCallback?: any) {
let that = this;
// Validate
if (!this.validate()) {
return;
}
if (!this.validateFilter()) {
return;
}
if (!this.validateFileType()) {
MessageBoxUtil.error('文件类型不满足,请重试');
return;
}
this.useChunkedUpload = true;
this.loading = true;
this.uploadStatus = UploadSessionStatus.UPLOADING;
// Create chunk uploader
this.chunkUploader = new ChunkUploader(
this.file!,
this.puuid,
this.spaceUuid!,
this.privacy,
undefined,
this.reactComponent!
);
// Set callbacks
this.chunkUploader.onProgress = (progress: ChunkUploadProgress) => {
that.progress = progress.progress;
that.speed = progress.speed;
that.uploadedChunks = progress.uploadedChunks;
that.totalChunks = progress.totalChunks;
// Only update status if not manually paused (avoid race condition with progress events)
if (that.uploadStatus !== UploadSessionStatus.PAUSED) {
that.uploadStatus = progress.status;
}
that.updateUI();
};
this.chunkUploader.onComplete = (matter: any) => {
that.uuid = matter.uuid;
that.loading = false;
that.uploadStatus = UploadSessionStatus.COMPLETED;
SafeUtil.safeCallback(successCallback)({ data: { data: matter } });
};
this.chunkUploader.onError = (error: string) => {
that.errorMessage = error;
that.loading = false;
that.uploadStatus = UploadSessionStatus.ERROR;
that.defaultErrorHandler(error);
SafeUtil.safeCallback(failureCallback)(error);
};
// Start upload
this.chunkUploader.start();
}
//清除文件
@@ -34,6 +34,8 @@ export default class Preference extends BaseEntity {
deletedKeepDays: number = 7;
//是否允许自主注册
allowRegister: boolean = false;
//是否允许分片上传
allowChunkUpload: boolean = false;
//预览配置
previewConfig: PreviewConfig = new PreviewConfig();
//扫描磁盘配置
@@ -76,6 +78,7 @@ export default class Preference extends BaseEntity {
defaultTotalSizeLimit: this.defaultTotalSizeLimit,
deletedKeepDays: this.deletedKeepDays,
allowRegister: this.allowRegister,
allowChunkUpload: this.allowChunkUpload,
previewConfig: JsonUtil.toJson(this.previewConfig.getForm()),
scanConfig: JsonUtil.toJson(this.scanConfig.getForm()),
};
+24 -8
View File
@@ -2,16 +2,9 @@ import BaseEntity from '../../base/BaseEntity';
import User from '../../user/User';
import { SpaceMemberRole } from './SpaceMemberRole';
import Filter from '../../base/filter/Filter';
import SortFilter from '../../base/filter/SortFilter';
import InputFilter from '../../base/filter/InputFilter';
import SafeUtil from '../../../util/SafeUtil';
export interface SpaceMemberFormValues {
spaceUuid: string;
userUuid: string;
role: SpaceMemberRole;
uuid: string | null;
}
export default class SpaceMember extends BaseEntity {
spaceUuid: string | null = null;
userUuid: string | null = null;
@@ -40,7 +33,7 @@ export default class SpaceMember extends BaseEntity {
];
}
getForm(): SpaceMemberFormValues {
getForm() {
return {
spaceUuid: this.spaceUuid!,
userUuid: this.userUuid!,
@@ -49,6 +42,29 @@ export default class SpaceMember extends BaseEntity {
};
}
httpCreate(
body: {
spaceUuid: string;
userUuids: string;
role: SpaceMemberRole;
},
successCallback?: any
) {
this.httpPost(this.getUrlCreate(), body, successCallback);
}
httpEdit(
body: {
uuid: string;
spaceUuid: string;
userUuid: string;
role: SpaceMemberRole;
},
successCallback?: any
) {
this.httpPost(this.getUrlEdit(), body, successCallback);
}
httpMine(spaceUuid: string, successCallback?: any, errorCallback?: any) {
this.httpGet(
SpaceMember.URL_SPACE_MEMBER_MINE,
+19 -4
View File
@@ -9,6 +9,7 @@ import SortFilter from '../base/filter/SortFilter';
import InputFilter from '../base/filter/InputFilter';
import SelectionFilter from '../base/filter/SelectionFilter';
import Space from '../space/Space';
import HttpBase from '../base/HttpBase';
export default class User extends BaseEntity {
//获取当前登录者的信息
@@ -22,6 +23,7 @@ export default class User extends BaseEntity {
static URL_USER_TOGGLE_STATUS = '/api/user/toggle/status';
static URL_USER_TRANSFIGURATION = '/api/user/transfiguration';
static URL_USER_SCAN = '/api/user/scan';
static URL_USER_SEARCH = '/api/user/search';
role: UserRole = UserRole.GUEST;
username: string | null = null;
@@ -226,8 +228,7 @@ export default class User extends BaseEntity {
oldPassword: string,
newPassword: string,
successCallback?: any,
errorCallback?: any,
finalCallback?: any
errorCallback?: any
) {
let that = this;
this.httpPost(
@@ -279,11 +280,25 @@ export default class User extends BaseEntity {
);
}
static httpSearch(
keyword?: string,
successCallback?: any,
errorCallback?: any
) {
new HttpBase().httpGet(
User.URL_USER_SEARCH,
{
keyword,
},
successCallback,
errorCallback
);
}
httpAuthenticationLogin(
authentication: string,
successCallback?: any,
errorCallback?: any,
finalCallback?: any
errorCallback?: any
) {
let that = this;
let form = { authentication };
+10 -1
View File
@@ -49,6 +49,13 @@ export default class HttpUtil {
/**
* 上传文件的请求
* @param url 请求地址
* @param formData 表单数据
* @param successCallback 成功回调
* @param errorCallback 失败回调
* @param finallyCallback 最终回调
* @param processCallback 进度回调
* @param opts 可选配置,支持 cancelToken 和 timeout
*/
static httpPostFile(
url: string,
@@ -63,8 +70,10 @@ export default class HttpUtil {
headers: {
'Content-Type': 'multipart/form-data',
},
cancelToken: opts ? opts.cancelToken : '',
cancelToken: opts?.cancelToken || undefined,
onUploadProgress: processCallback,
// Support custom timeout, default to 10 minutes for large file uploads
timeout: opts?.timeout || 10 * 60 * 1000,
};
axios
+57
View File
@@ -0,0 +1,57 @@
/**
* MD5 utility for calculating chunk checksums.
*
* This module provides optional MD5 calculation for chunk integrity verification.
* For full MD5 support, install spark-md5: npm install spark-md5 @types/spark-md5
*
* Without spark-md5 installed, this will return an empty string and skip MD5 verification.
*/
/**
* Check if MD5 calculation is available (spark-md5 is installed)
*/
export async function isMd5Available(): Promise<boolean> {
try {
await import('spark-md5');
return true;
} catch {
return false;
}
}
/**
* Calculate MD5 hash of a Blob (chunk).
* This is fast since the chunk is already in memory.
*
* @param blob The blob/chunk to calculate MD5 for
* @returns Promise<string> MD5 hash as hex string, or empty string if spark-md5 is not available
*/
export async function calculateChunkMd5(blob: Blob): Promise<string> {
try {
// @ts-ignore - dynamic import may not have types
const SparkMD5 = (await import('spark-md5')).default;
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (e: ProgressEvent<FileReader>) => {
if (e.target?.result) {
const spark = new SparkMD5.ArrayBuffer();
spark.append(e.target.result as ArrayBuffer);
resolve(spark.end());
} else {
resolve('');
}
};
reader.onerror = () => {
reject(new Error('Failed to read chunk for MD5 calculation'));
};
reader.readAsArrayBuffer(blob);
});
} catch (e) {
// spark-md5 is not installed, return empty string
return '';
}
}
+5
View File
@@ -193,6 +193,11 @@ class RawFrame extends TankComponent<IProps, IState> {
exact
component={MatterDetail}
/>
<Route
path="/space/:spaceUuid/bin/list"
exact
component={BinList}
/>
</div>
) : (
<div className="pages-content">
+6 -2
View File
@@ -1,3 +1,7 @@
.page-bin {
.page-bin-list {
.buttons {
.ant-btn {
margin-bottom: 10px;
}
}
}
+140 -49
View File
@@ -6,7 +6,16 @@ import Pager from '../../common/model/base/Pager';
import Matter from '../../common/model/matter/Matter';
import Moon from '../../common/model/global/Moon';
import SortDirection from '../../common/model/base/SortDirection';
import { Button, Col, Empty, Input, Modal, Pagination, Row, Space } from 'antd';
import {
Button,
Col,
Empty,
Input,
Modal,
Pagination,
Row,
Space as AntdSpace,
} from 'antd';
import MessageBoxUtil from '../../common/util/MessageBoxUtil';
import {
CloseCircleOutlined,
@@ -19,15 +28,26 @@ import {
import ImagePreviewer from '../widget/previewer/ImagePreviewer';
import { UserRole } from '../../common/model/user/UserRole';
import Lang from '../../common/model/global/Lang';
import TankTitle from '../widget/TankTitle';
import BinMatterPanel from './widget/BinMatterPanel';
import Sun from '../../common/model/global/Sun';
import Space from '../../common/model/space/Space';
import BreadcrumbPanel from '../widget/BreadcrumbPanel';
import BreadcrumbModel from '../../common/model/base/option/BreadcrumbModel';
import SpaceMember from '../../common/model/space/member/SpaceMember';
import { SpaceMemberRole } from '../../common/model/space/member/SpaceMemberRole';
interface IProps extends RouteComponentProps {}
interface IProps
extends RouteComponentProps<{
spaceUuid?: string;
}> {}
interface IState {}
export default class List extends TankComponent<IProps, IState> {
//当前空间信息
space = new Space();
// 当前用户在当前空间下的成员信息
spaceMember = new SpaceMember();
//当前文件夹信息。
matter = new Matter();
//当前选中的文件
@@ -41,7 +61,26 @@ export default class List extends TankComponent<IProps, IState> {
super(props);
}
initSpace() {
if (this.props.match.params.spaceUuid) {
this.space.uuid = this.props.match.params.spaceUuid;
this.space.httpDetail(() => this.updateUI());
this.spaceMember.httpMine(this.props.match.params.spaceUuid, () =>
this.updateUI()
);
}
}
// 如果在空间下,有些操作权限只对管理员和读写成员开放
checkHandlePermission() {
if (!this.props.match.params.spaceUuid) return true;
return [SpaceMemberRole.ADMIN, SpaceMemberRole.READ_WRITE].includes(
this.spaceMember.role!
);
}
componentDidMount() {
this.initSpace();
//刷新一下列表
if (this.user.role === UserRole.ADMINISTRATOR) {
this.pager.getFilter('userUuid')!.visible = true;
@@ -73,6 +112,11 @@ export default class List extends TankComponent<IProps, IState> {
this.pager.setFilterValue('orderDir', SortDirection.DESC);
}
// 如果在共享空间中
if (this.props.match.params.spaceUuid) {
this.pager.setFilterValue('spaceUuid', this.props.match.params.spaceUuid);
}
// 过滤掉被软删除的文件
this.pager.setFilterValue('deleted', true);
@@ -115,13 +159,20 @@ export default class List extends TankComponent<IProps, IState> {
this.checkMatter();
}
getSpaceUuid() {
if (this.props.match.params.spaceUuid) {
return this.props.match.params.spaceUuid;
}
return this.user.spaceUuid!;
}
deleteBatch() {
Modal.confirm({
title: Lang.t('actionCanNotRevertConfirm'),
icon: <ExclamationCircleFilled twoToneColor="#FFDC00" />,
onOk: () => {
const uuids = this.selectedMatters.map((i) => i.uuid).toString();
Matter.httpDeleteBatch(uuids, this.user.spaceUuid!, () => {
Matter.httpDeleteBatch(uuids, this.getSpaceUuid(), () => {
MessageBoxUtil.success(Lang.t('operationSuccess'));
this.refresh();
});
@@ -135,7 +186,7 @@ export default class List extends TankComponent<IProps, IState> {
icon: <ExclamationCircleFilled twoToneColor="#FFDC00" />,
onOk: () => {
const uuids = this.selectedMatters.map((i) => i.uuid).toString();
Matter.httpRecoveryBatch(uuids, () => {
Matter.httpRecoveryBatch(uuids, this.getSpaceUuid(), () => {
MessageBoxUtil.success(Lang.t('operationSuccess'));
this.refresh();
});
@@ -150,6 +201,12 @@ export default class List extends TankComponent<IProps, IState> {
this.pager.setFilterValue('orderDir', SortDirection.DESC);
this.pager.setFilterValue('name', value);
this.pager.setFilterValue('deleted', true);
if (this.props.match.params.spaceUuid) {
this.pager.setFilterValue(
'spaceUuid',
this.props.match.params.spaceUuid
);
}
this.pager.httpList();
} else {
this.refresh();
@@ -182,59 +239,87 @@ export default class List extends TankComponent<IProps, IState> {
}
goDetail(matter: Matter) {
Sun.navigateTo(`/matter/detail/${matter.uuid}`);
const prefix = this.props.match.params.spaceUuid
? `/space/${this.props.match.params.spaceUuid}`
: '';
Sun.navigateTo(`${prefix}/matter/detail/${matter.uuid}`);
}
getBreadcrumbModels(): BreadcrumbModel[] {
const breadcrumbModels: BreadcrumbModel[] = [
{
name: Lang.t('layout.bin'),
path: '',
query: {},
displayDirect: true,
},
];
if (this.space.name) {
breadcrumbModels.unshift({
name: this.space.name,
path: '/space',
query: {},
displayDirect: false,
});
}
return breadcrumbModels;
}
render() {
const { pager, selectedMatters } = this;
return (
<div className="matter-list">
<TankTitle name={Lang.t('layout.bin')} />
<div className="page-bin-list">
<BreadcrumbPanel breadcrumbModels={this.getBreadcrumbModels()} />
<Row className="mt10">
<Col xs={24} sm={24} md={14} lg={16}>
<Space className="buttons">
{selectedMatters.length !== pager.data.length ? (
<Button
type="primary"
className="mb10"
onClick={() => this.checkAll()}
>
<PlusSquareOutlined />
{Lang.t('selectAll')}
</Button>
) : null}
{pager.data.length &&
selectedMatters.length === pager.data.length ? (
<Button
type="primary"
className="mb10"
onClick={() => this.checkNone()}
>
<MinusSquareOutlined />
{Lang.t('cancel')}
</Button>
) : null}
{selectedMatters.length ? (
<AntdSpace className="buttons">
{this.checkHandlePermission() && (
<>
<Button
type="primary"
className="mb10"
onClick={() => this.recoverBatch()}
>
<RedoOutlined />
{Lang.t('matter.recovery')}
</Button>
<Button
type="primary"
className="mb10"
onClick={() => this.deleteBatch()}
>
<CloseCircleOutlined />
{Lang.t('matter.hardDelete')}
</Button>
{selectedMatters.length !== pager.data.length ? (
<Button
type="primary"
className="mb10"
onClick={() => this.checkAll()}
>
<PlusSquareOutlined />
{Lang.t('selectAll')}
</Button>
) : null}
{pager.data.length &&
selectedMatters.length === pager.data.length ? (
<Button
type="primary"
className="mb10"
onClick={() => this.checkNone()}
>
<MinusSquareOutlined />
{Lang.t('cancel')}
</Button>
) : null}
{selectedMatters.length ? (
<>
<Button
type="primary"
className="mb10"
onClick={() => this.recoverBatch()}
>
<RedoOutlined />
{Lang.t('matter.recovery')}
</Button>
<Button
type="primary"
className="mb10"
onClick={() => this.deleteBatch()}
>
<CloseCircleOutlined />
{Lang.t('matter.hardDelete')}
</Button>
</>
) : null}
</>
) : null}
)}
<Button
type="primary"
@@ -244,7 +329,7 @@ export default class List extends TankComponent<IProps, IState> {
<SyncOutlined />
{Lang.t('refresh')}
</Button>
</Space>
</AntdSpace>
</Col>
<Col xs={24} sm={24} md={10} lg={8}>
<Input.Search
@@ -262,7 +347,13 @@ export default class List extends TankComponent<IProps, IState> {
pager.data.map((matter) => (
<BinMatterPanel
key={matter.uuid}
mode={this.props.match.params.spaceUuid ? 'space' : 'normal'}
matter={matter}
spaceMemberRole={
this.props.match.params.spaceUuid
? this.spaceMember.role!
: undefined
}
onDeleteSuccess={() => this.refresh()}
onRecoverySuccess={() => this.refresh()}
onCheckMatter={(m) => this.checkMatter(m)}
+56 -33
View File
@@ -18,9 +18,12 @@ import { Checkbox, Dropdown, Menu, Modal, Tooltip } from 'antd';
import { CheckboxChangeEvent } from 'antd/es/checkbox';
import SafeUtil from '../../../common/util/SafeUtil';
import Lang from '../../../common/model/global/Lang';
import { SpaceMemberRole } from '../../../common/model/space/member/SpaceMemberRole';
interface IProps {
matter: Matter;
mode: 'normal' | 'space'; // normal:正常模式,space:空间模式
spaceMemberRole?: SpaceMemberRole; // 用户在当前空间下的角色,只有在space模式下才有值
onDeleteSuccess: () => any;
onRecoverySuccess: () => any;
onCheckMatter: (matter?: Matter) => any;
@@ -83,20 +86,20 @@ export default class BinMatterPanel extends TankComponent<IProps, IState> {
this.updateUI();
}
// 如果在空间下,有些操作权限只对管理员和读写成员开放
checkHandlePermission() {
if (this.props.mode === 'normal') return true;
return [SpaceMemberRole.ADMIN, SpaceMemberRole.READ_WRITE].includes(
this.props.spaceMemberRole!
);
}
renderPcOperation() {
const { matter } = this.props;
return (
<div className="right-part">
<span className="matter-operation text-theme">
<Tooltip title={Lang.t('matter.recovery')}>
<RedoOutlined
className="btn-action"
onClick={(e) =>
SafeUtil.stopPropagationWrap(e)(this.recoveryMatter())
}
/>
</Tooltip>
<Tooltip title={Lang.t('matter.fileDetail')}>
<InfoCircleOutlined
className="btn-action"
@@ -107,14 +110,26 @@ export default class BinMatterPanel extends TankComponent<IProps, IState> {
}
/>
</Tooltip>
<Tooltip title={Lang.t('matter.hardDelete')}>
<CloseCircleOutlined
className="btn-action text-danger"
onClick={(e) =>
SafeUtil.stopPropagationWrap(e)(this.hardDeleteMatter())
}
/>
</Tooltip>
{this.checkHandlePermission() && (
<>
<Tooltip title={Lang.t('matter.recovery')}>
<RedoOutlined
className="btn-action"
onClick={(e) =>
SafeUtil.stopPropagationWrap(e)(this.recoveryMatter())
}
/>
</Tooltip>
<Tooltip title={Lang.t('matter.hardDelete')}>
<CloseCircleOutlined
className="btn-action text-danger"
onClick={(e) =>
SafeUtil.stopPropagationWrap(e)(this.hardDeleteMatter())
}
/>
</Tooltip>
</>
)}
</span>
<Tooltip title={Lang.t('matter.size')}>
<span className="matter-size">
@@ -131,7 +146,7 @@ export default class BinMatterPanel extends TankComponent<IProps, IState> {
}
getHandles() {
return [
const handles = [
<div
className="cell-btn navy"
onClick={(e) =>
@@ -143,23 +158,31 @@ export default class BinMatterPanel extends TankComponent<IProps, IState> {
<InfoCircleOutlined className="btn-action mr5" />
{Lang.t('matter.fileDetail')}
</div>,
<div
className="cell-btn navy"
onClick={(e) => SafeUtil.stopPropagationWrap(e)(this.recoveryMatter())}
>
<RedoOutlined className="btn-action mr5" />
{Lang.t('matter.recovery')}
</div>,
<div
className="cell-btn text-danger"
onClick={(e) =>
SafeUtil.stopPropagationWrap(e)(this.hardDeleteMatter())
}
>
<CloseCircleOutlined className="btn-action mr5" />
{Lang.t('matter.hardDelete')}
</div>,
];
if (this.checkHandlePermission()) {
handles.push(
<div
className="cell-btn navy"
onClick={(e) =>
SafeUtil.stopPropagationWrap(e)(this.recoveryMatter())
}
>
<RedoOutlined className="btn-action mr5" />
{Lang.t('matter.recovery')}
</div>,
<div
className="cell-btn text-danger"
onClick={(e) =>
SafeUtil.stopPropagationWrap(e)(this.hardDeleteMatter())
}
>
<CloseCircleOutlined className="btn-action mr5" />
{Lang.t('matter.hardDelete')}
</div>
);
}
return handles;
}
renderMobileOperation() {
+19 -4
View File
@@ -15,6 +15,8 @@ import Lang from '../../common/model/global/Lang';
import InfoCell from '../widget/InfoCell';
import TankContentCard from '../widget/TankContentCard';
import Color from '../../common/model/base/option/Color';
import SpaceMember from '../../common/model/space/member/SpaceMember';
import { SpaceMemberRole } from '../../common/model/space/member/SpaceMemberRole';
interface RouteParam {
spaceUuid?: string;
@@ -27,6 +29,8 @@ interface IState {}
export default class Detail extends TankComponent<IProps, IState> {
matter = new Matter();
// 当前用户在当前空间下的成员信息
spaceMember = new SpaceMember();
downloadToken = new DownloadToken();
constructor(props: IProps) {
@@ -44,6 +48,9 @@ export default class Detail extends TankComponent<IProps, IState> {
this.downloadToken.httpFetchDownloadToken(this.matter.uuid!);
}
});
if (spaceUuid) {
this.spaceMember.httpMine(spaceUuid, () => this.updateUI());
}
}
copyLink() {
@@ -61,10 +68,18 @@ export default class Detail extends TankComponent<IProps, IState> {
this.matter.httpDetail(() => {
this.updateUI();
});
MessageBoxUtil.success(Lang.t('copySuccess'));
MessageBoxUtil.success(Lang.t('matter.recoverySuccess'));
});
}
// 如果在空间下,有些操作权限只对管理员和读写成员开放
checkHandlePermission() {
if (!this.props.match.params.spaceUuid) return true;
return [SpaceMemberRole.ADMIN, SpaceMemberRole.READ_WRITE].includes(
this.spaceMember.role!
);
}
render() {
const { matter } = this;
return (
@@ -123,7 +138,7 @@ export default class Detail extends TankComponent<IProps, IState> {
? Lang.t('matter.oneTimeLink')
: Lang.t('matter.copyPath')}
</a>
{matter.deleted ? (
{matter.deleted && this.checkHandlePermission() ? (
<a onClick={() => this.recovery()}>
{Lang.t('matter.recovery')}
</a>
@@ -132,13 +147,13 @@ export default class Detail extends TankComponent<IProps, IState> {
</InfoCell>
</>
) : null}
{matter.dir && matter.deleted && (
{matter.dir && matter.deleted && this.checkHandlePermission() ? (
<InfoCell name={Lang.t('matter.operations')}>
<a onClick={() => this.recovery()}>
{Lang.t('matter.recovery')}
</a>
</InfoCell>
)}
) : null}
</div>
</TankContentCard>
</div>
+5 -2
View File
@@ -24,7 +24,10 @@
display: flex;
flex-wrap: wrap;
}
.matter-pagination {
margin-top: 10px;
.matter-list-content {
.ant-list-pagination {
margin-top: 10px;
}
}
}
+151 -70
View File
@@ -20,6 +20,7 @@ import {
Row,
Space as AntdSpace,
Upload,
List as AntdList,
} from 'antd';
import MessageBoxUtil from '../../common/util/MessageBoxUtil';
import {
@@ -33,6 +34,7 @@ import {
MenuOutlined,
MinusSquareOutlined,
PlusSquareOutlined,
SearchOutlined,
ShareAltOutlined,
SyncOutlined,
} from '@ant-design/icons';
@@ -56,6 +58,7 @@ import Space from '../../common/model/space/Space';
import MatterCrawlModal from './widget/MatterCrawlModal';
import SpaceMember from '../../common/model/space/member/SpaceMember';
import { SpaceMemberRole } from '../../common/model/space/member/SpaceMemberRole';
import { debounce } from '../../common/util/OptimizeUtil';
interface IProps {
spaceUuid?: string;
@@ -76,8 +79,6 @@ export default class List extends TankComponent<IProps, IState> {
//当前选中的文件
selectedMatters: Matter[] = [];
//搜索的文字
searchText: string | null = null;
//获取分页的一个帮助器
pager = new Pager<Matter>(this, Matter, Pager.MAX_PAGE_SIZE);
user = Moon.getSingleton().user;
@@ -111,6 +112,21 @@ export default class List extends TankComponent<IProps, IState> {
// 抓取模态窗状态
crawlModalVisible = false;
// 搜索
searchState: {
keyword?: string;
loading: boolean;
data: Matter[];
} = {
loading: false,
data: [],
};
// 当前是否有关键词是搜索状态,逻辑上有不一样
isSearch() {
return !!this.searchState.keyword;
}
//持有全局唯一的实例。
static instance: List | null = null;
@@ -203,6 +219,7 @@ export default class List extends TankComponent<IProps, IState> {
componentWillReceiveProps() {
this.pager.enableHistory();
this.resetSearch();
this.refresh();
this.refreshBreadcrumbs();
}
@@ -218,8 +235,15 @@ export default class List extends TankComponent<IProps, IState> {
refresh() {
// 清空暂存区
this.selectedMatters = [];
// 刷新文件列表
this.refreshPager();
if (this.isSearch()) {
// 刷新搜索列表
console.log('search this2', this.searchState.keyword);
this.refreshSearch(this.searchState.keyword);
} else {
// 刷新分页列表
this.refreshPager();
}
}
refreshPager() {
@@ -244,6 +268,15 @@ export default class List extends TankComponent<IProps, IState> {
this.pager.httpList();
}
getList() {
if (this.isSearch()) return this.searchState;
return this.pager;
}
getListData() {
return this.getList().data;
}
checkMatter(matter?: Matter) {
if (matter) {
if (matter.check) {
@@ -257,7 +290,7 @@ export default class List extends TankComponent<IProps, IState> {
} else {
//统计所有的勾选
this.selectedMatters = [];
this.pager.data.forEach((matter) => {
this.getListData().forEach((matter) => {
if (matter.check) {
this.selectedMatters.push(matter);
}
@@ -267,14 +300,14 @@ export default class List extends TankComponent<IProps, IState> {
}
checkAll() {
this.pager.data.forEach((i) => {
this.getListData().forEach((i) => {
i.check = true;
});
this.checkMatter();
}
checkNone() {
this.pager.data.forEach((i) => {
this.getListData().forEach((i) => {
i.check = false;
});
this.checkMatter();
@@ -283,7 +316,6 @@ export default class List extends TankComponent<IProps, IState> {
deleteBatch() {
const uuids = this.selectedMatters.map((i) => i.uuid).toString();
MatterDeleteModal.open(
!!this.props.spaceUuid,
() => {
Matter.httpSoftDeleteBatch(uuids, this.getSpaceUuid(), () => {
MessageBoxUtil.success(Lang.t('operationSuccess'));
@@ -440,7 +472,8 @@ export default class List extends TankComponent<IProps, IState> {
}
}
m.file = file;
m.httpUpload(
// Use smart upload: automatically choose chunked upload for large files
m.httpSmartUpload(
() => {
const index = this.uploadMattersMap[this.getSpaceUuid()].findIndex(
(matter) => matter === m
@@ -474,16 +507,6 @@ export default class List extends TankComponent<IProps, IState> {
});
}
searchFile(value?: string) {
this.pager.setFilterValue('name', value);
this.pager.setFilterValue('page', 0);
this.refresh();
}
changeSearch(e: any) {
if (!e.currentTarget.value) this.searchFile();
}
changePage(page: number) {
this.pager.page = page - 1; // page的页数0基
this.pager.httpList();
@@ -510,7 +533,7 @@ export default class List extends TankComponent<IProps, IState> {
previewImage(matter: Matter) {
let imageArray: string[] = [];
let startIndex = -1;
this.pager.data.forEach((item) => {
this.getListData().forEach((item) => {
if (item.isImage()) {
imageArray.push(item.getPreviewUrl());
if (item.uuid === matter.uuid) {
@@ -546,11 +569,7 @@ export default class List extends TankComponent<IProps, IState> {
}
goToDirectory(id: string) {
this.searchText = null;
this.pager.setFilterValues({
puuid: id,
name: '',
});
this.pager.setFilterValue('puuid', id);
this.pager.page = 0;
const query = this.pager.getParams();
Sun.navigateQueryTo({ path: this.getMatterListPath(), query });
@@ -645,6 +664,52 @@ export default class List extends TankComponent<IProps, IState> {
);
}
handleChangeSearch = (value?: string) => {
// 清空暂存区
this.selectedMatters = [];
this.searchState.keyword = value;
this.updateUI();
if (value) {
// search
console.log('search this', value);
this.refreshSearch(value);
} else {
// reset
this.resetSearch();
}
};
refreshSearch = debounce((value: string) => {
this.searchState.loading = true;
this.updateUI();
console.log('this.searchState.keyword', this.searchState.keyword);
Matter.httpSearch(
{
keyword: value,
puuid: this.currentDirectory.uuid || Matter.MATTER_ROOT,
spaceUuid: this.getSpaceUuid(),
},
(data: Matter[]) => {
this.searchState.data = data;
},
null,
() => {
this.searchState.loading = false;
this.updateUI();
}
);
}, 200);
resetSearch() {
this.searchState = {
keyword: undefined,
loading: false,
data: [],
};
this.updateUI();
}
render() {
const { pager, director, selectedMatters, dragEnterCount } = this;
return (
@@ -659,7 +724,7 @@ export default class List extends TankComponent<IProps, IState> {
<Row>
<Col xs={24} sm={24} md={14} lg={16} className="mt10">
<AntdSpace className="buttons">
{selectedMatters.length !== pager.data.length ? (
{selectedMatters.length !== this.getListData().length ? (
<Button
type="primary"
className="mb10"
@@ -669,8 +734,8 @@ export default class List extends TankComponent<IProps, IState> {
{Lang.t('selectAll')}
</Button>
) : null}
{pager.data.length &&
selectedMatters.length === pager.data.length ? (
{this.getListData().length &&
selectedMatters.length === this.getListData().length ? (
<Button
type="primary"
className="mb10"
@@ -682,14 +747,16 @@ export default class List extends TankComponent<IProps, IState> {
) : null}
{selectedMatters.length ? (
<>
<Button
type="primary"
className="mb10"
onClick={() => this.downloadZip()}
>
<DownloadOutlined />
{Lang.t('download')}
</Button>
{!this.isSearch() && (
<Button
type="primary"
className="mb10"
onClick={() => this.downloadZip()}
>
<DownloadOutlined />
{Lang.t('download')}
</Button>
)}
{this.checkHandlePermission() && (
<>
<Button
@@ -712,7 +779,7 @@ export default class List extends TankComponent<IProps, IState> {
)}
{/*共享空间下暂不支持分享功能*/}
{!this.isInSpace && (
{!this.isInSpace && !this.isSearch() && (
<Button
type="primary"
className="mb10"
@@ -725,7 +792,7 @@ export default class List extends TankComponent<IProps, IState> {
</>
) : null}
{this.checkHandlePermission() && (
{this.checkHandlePermission() && !this.isSearch() && (
<>
<Upload
className="ant-upload"
@@ -781,24 +848,29 @@ export default class List extends TankComponent<IProps, IState> {
</AntdSpace>
</Col>
<Col xs={24} sm={24} md={10} lg={8} className="mt10">
<Input.Search
<Input
className="mb10"
placeholder={Lang.t('matter.searchFile')}
onSearch={(value) => this.searchFile(value)}
onChange={(e) => this.changeSearch(e)}
enterButton
prefix={<SearchOutlined />}
value={this.searchState.keyword}
onChange={(e) => this.handleChangeSearch(e.target.value)}
allowClear
/>
</Col>
</Row>
{Children.toArray(
this.uploadMattersMap[this.getSpaceUuid()]?.map((m) => (
<UploadMatterPanel matter={m} />
<UploadMatterPanel key={m.autoId} matter={m} />
))
)}
{pager.data.length ? (
<MatterSortPanel pager={pager} refresh={() => this.refresh()} />
{this.getListData().length ? (
<MatterSortPanel
pager={pager}
refresh={() => this.refresh()}
disabled={this.isSearch()}
/>
) : null}
{director.createMode ? (
@@ -811,35 +883,44 @@ export default class List extends TankComponent<IProps, IState> {
/>
) : null}
<div>
{pager.loading || pager.data.length ? (
pager.data.map((matter) => (
<MatterPanel
mode={this.isInSpace ? 'space' : 'normal'}
spaceMemberRole={
this.isInSpace ? this.spaceMember.role! : undefined
}
key={matter.uuid!}
director={director}
matter={matter}
onGoToDirectory={(id) => this.goToDirectory(id)}
onDeleteSuccess={() => this.pagerAndCapacityRefresh()}
onCheckMatter={(m) => this.checkMatter(m)}
onPreviewImage={(m) => this.previewImage(m)}
onGoDetail={(m) => this.goDetail(m)}
/>
))
{this.getList().loading || this.getList().data.length > 0 ? (
<AntdList
rowKey="uuid"
className="matter-list-content"
loading={this.getList().loading}
dataSource={this.getList().data}
renderItem={(matter) => (
<MatterPanel
mode={this.isInSpace ? 'space' : 'normal'}
spaceMemberRole={
this.isInSpace ? this.spaceMember.role! : undefined
}
key={matter.uuid}
director={director}
matter={matter}
onGoToDirectory={(id) => this.goToDirectory(id)}
onDeleteSuccess={() => this.pagerAndCapacityRefresh()}
onCheckMatter={(m) => this.checkMatter(m)}
onPreviewImage={(m) => this.previewImage(m)}
onGoDetail={(m) => this.goDetail(m)}
/>
)}
pagination={
this.isSearch()
? false
: {
onChange: (page) => this.changePage(page),
current: pager.page + 1,
total: pager.totalItems,
pageSize: pager.pageSize,
hideOnSinglePage: true,
}
}
/>
) : (
<Empty description={Lang.t('matter.noContentYet')} />
)}
</div>
<Pagination
className="matter-pagination pull-right"
onChange={(page) => this.changePage(page)}
current={pager.page + 1}
total={pager.totalItems}
pageSize={pager.pageSize}
hideOnSinglePage
/>
{this.crawlModalVisible && (
<MatterCrawlModal
spaceUuid={this.getSpaceUuid()}
+10 -13
View File
@@ -8,7 +8,6 @@ import './MatterDeleteModal.less';
import Preference from '../../../common/model/preference/Preference';
interface IProps {
isSpace: boolean; // 是否是空间,空间下暂不展示放入回收站逻辑
onSoftDel: () => void;
onHardDel: () => void;
onClose: () => void;
@@ -23,7 +22,7 @@ export default class MatterDeleteModal extends TankComponent<IProps, IState> {
static preference: Preference = Moon.getSingleton().preference;
static open(isSpace: boolean, onSoftDel: () => void, onHardDel: () => void) {
static open(onSoftDel: () => void, onHardDel: () => void) {
let modal = Modal.warning({
className: 'delete-modal custom-handle-modal',
title: Lang.t('delete'),
@@ -31,7 +30,6 @@ export default class MatterDeleteModal extends TankComponent<IProps, IState> {
okCancel: false,
content: (
<MatterDeleteModal
isSpace={isSpace}
onSoftDel={() => {
onSoftDel();
modal.destroy();
@@ -59,16 +57,15 @@ export default class MatterDeleteModal extends TankComponent<IProps, IState> {
<Button type="primary" danger onClick={this.props.onHardDel}>
{Lang.t('deleteDirectly')}
</Button>
{!this.props.isSpace &&
MatterDeleteModal.preference.getRecycleBinStatus() && (
<Button
type="primary"
className="ml10"
onClick={this.props.onSoftDel}
>
{Lang.t('matter.intoRecycleBin')}
</Button>
)}
{MatterDeleteModal.preference.getRecycleBinStatus() && (
<Button
type="primary"
className="ml10"
onClick={this.props.onSoftDel}
>
{Lang.t('matter.intoRecycleBin')}
</Button>
)}
</div>
</div>
);
-1
View File
@@ -101,7 +101,6 @@ export default class MatterPanel extends TankComponent<IProps, IState> {
deleteMatter() {
MatterDeleteModal.open(
this.props.mode === 'space',
() => {
this.props.matter.httpSoftDelete(() => {
MessageBoxUtil.success(Lang.t('operationSuccess'));
@@ -6,6 +6,9 @@
margin: 0;
line-height: 48px;
color: #999;
&-disabled {
pointer-events: none;
}
.sort-part {
&:hover {
+7 -2
View File
@@ -9,6 +9,7 @@ import Pager from '../../../common/model/base/Pager';
interface IProps {
pager: Pager<Matter>;
refresh: () => any;
disabled?: boolean;
}
interface IState {}
@@ -38,10 +39,14 @@ export default class MatterSortPanel extends TankComponent<IProps, IState> {
}
render() {
const { pager } = this.props;
const { pager, disabled } = this.props;
return (
<div className="matter-sort-panel">
<div
className={`matter-sort-panel ${
disabled ? 'matter-sort-panel-disabled' : ''
}`}
>
<div
className="sort-part sort-part-name"
onClick={() => this.changeFilterOrder('orderName')}
+63 -2
View File
@@ -1,5 +1,4 @@
.upload-matter-panel {
.huge-block {
background-color: white;
border-radius: 5px;
@@ -15,6 +14,8 @@
.media {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 5px;
.media-body {
cursor: pointer;
@@ -23,9 +24,69 @@
font-weight: bold;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
flex: 1;
display: flex;
align-items: center;
gap: 8px;
.filename {
overflow: hidden;
text-overflow: ellipsis;
}
.merging-icon {
color: #52c41a;
font-size: 14px;
}
}
.media-actions {
flex-shrink: 0;
margin-left: 10px;
.action-icon {
font-size: 18px;
cursor: pointer;
color: #666;
transition: color 0.3s;
&:hover {
color: #1890ff;
}
}
.resume-icon {
color: #52c41a;
&:hover {
color: #73d13d;
}
}
.cancel-icon {
&:hover {
color: #ff4d4f;
}
}
}
}
}
.upload-info {
font-size: 12px;
color: #888;
.uploaded-size {
color: #555;
}
.upload-speed {
color: #1890ff;
}
.status-text {
color: #888;
}
}
}
}
+145 -19
View File
@@ -2,10 +2,17 @@ import React from 'react';
import TankComponent from '../../../common/component/TankComponent';
import Matter from '../../../common/model/matter/Matter';
import StringUtil from '../../../common/util/StringUtil';
import { Progress } from 'antd';
import { CloseOutlined } from '@ant-design/icons';
import { Progress, Button, Space, Tooltip } from 'antd';
import {
CloseOutlined,
PauseCircleOutlined,
PlayCircleOutlined,
CloudUploadOutlined,
SyncOutlined,
} from '@ant-design/icons';
import './UploadMatterPanel.less';
import Lang from '../../../common/model/global/Lang';
import { UploadSessionStatus } from '../../../common/model/chunk/ChunkUploader';
interface IProps {
matter: Matter;
@@ -33,38 +40,157 @@ export default class UploadMatterPanel extends TankComponent<IProps, IState> {
this.props.matter.reactComponent = null;
}
// Get status text based on upload status
getStatusText(matter: Matter): string {
if (!matter.useChunkedUpload) {
if (matter.progress < 1) {
return '';
}
return Lang.t('matter.finishingTip');
}
switch (matter.uploadStatus) {
case UploadSessionStatus.UPLOADING:
return `${matter.uploadedChunks}/${matter.totalChunks} ${Lang.t(
'matter.chunks'
)}`;
case UploadSessionStatus.MERGING:
return Lang.t('matter.mergingChunks');
case UploadSessionStatus.PAUSED:
return Lang.t('matter.paused');
case UploadSessionStatus.COMPLETED:
return Lang.t('matter.completed');
case UploadSessionStatus.ERROR:
return Lang.t('matter.uploadFailed');
case UploadSessionStatus.CANCELLED:
return Lang.t('matter.cancelled');
default:
return '';
}
}
// Get progress bar status
getProgressStatus(
matter: Matter
): 'active' | 'success' | 'exception' | 'normal' {
if (!matter.useChunkedUpload) {
return 'active';
}
switch (matter.uploadStatus) {
case UploadSessionStatus.UPLOADING:
return 'active';
case UploadSessionStatus.MERGING:
return 'active';
case UploadSessionStatus.PAUSED:
return 'normal';
case UploadSessionStatus.COMPLETED:
return 'success';
case UploadSessionStatus.ERROR:
return 'exception';
case UploadSessionStatus.CANCELLED:
return 'exception';
default:
return 'active';
}
}
// Get progress bar color
getStrokeColor(matter: Matter): any {
if (!matter.useChunkedUpload) {
return {
from: '#108ee9',
to: '#87d068',
};
}
switch (matter.uploadStatus) {
case UploadSessionStatus.PAUSED:
return {
from: '#faad14',
to: '#faad14',
};
case UploadSessionStatus.ERROR:
case UploadSessionStatus.CANCELLED:
return {
from: '#ff4d4f',
to: '#ff4d4f',
};
default:
return {
from: '#108ee9',
to: '#87d068',
};
}
}
render() {
const { matter } = this.props;
const statusText = this.getStatusText(matter);
const progressStatus = this.getProgressStatus(matter);
const strokeColor = this.getStrokeColor(matter);
return (
<div className="upload-matter-panel">
{matter.loading ? (
<div className="huge-block clearfix">
<div className="media">
<div className="media-body">{matter.file!.name}</div>
<div className="media-cancel">
<CloseOutlined onClick={() => matter.cancelUpload()} />
<div className="media-body">
<span className="filename">{matter.file!.name}</span>
</div>
<div className="media-actions">
<Space>
{/* Pause/Resume button for chunked upload */}
{matter.canPause() && (
<Tooltip title={Lang.t('matter.pause')}>
<PauseCircleOutlined
className="action-icon"
onClick={() => matter.pauseUpload()}
/>
</Tooltip>
)}
{matter.canResume() && (
<Tooltip title={Lang.t('matter.resume')}>
<PlayCircleOutlined
className="action-icon resume-icon"
onClick={() => matter.resumeUpload()}
/>
</Tooltip>
)}
{/* Cancel button */}
<Tooltip title={Lang.t('matter.cancel')}>
<CloseOutlined
className="action-icon cancel-icon"
onClick={() => matter.cancelUpload()}
/>
</Tooltip>
</Space>
</div>
</div>
<Progress
className="progress"
strokeColor={{
from: '#108ee9',
to: '#87d068',
}}
strokeColor={strokeColor}
format={() => this.format(matter.progress * 100)}
percent={matter.progress * 100}
status="active"
status={progressStatus}
/>
<div>
{`${Lang.t('matter.uploaded')}: ${StringUtil.humanFileSize(
matter.file!.size * matter.progress
)}/${StringUtil.humanFileSize(matter.file!.size)} ${
matter.progress < 1
? `${Lang.t('matter.speed')}:${StringUtil.humanFileSize(
<div className="upload-info">
<span className="uploaded-size">
{`${Lang.t('matter.uploaded')}: ${StringUtil.humanFileSize(
matter.file!.size * matter.progress
)}/${StringUtil.humanFileSize(matter.file!.size)}`}
</span>
{matter.progress < 1 &&
matter.uploadStatus === UploadSessionStatus.UPLOADING && (
<span className="upload-speed">
{` ${Lang.t('matter.speed')}: ${StringUtil.humanFileSize(
matter.speed
)}s`
: Lang.t('matter.finishingTip')
}`}
)}/s`}
</span>
)}
{statusText && (
<span className="status-text">{` - ${statusText}`}</span>
)}
</div>
</div>
) : null}
+19 -3
View File
@@ -5,8 +5,8 @@ import TankComponent from '../../common/component/TankComponent';
import User from '../../common/model/user/User';
import Moon from '../../common/model/global/Moon';
import TankTitle from '../widget/TankTitle';
import { Button, Form, Input, InputNumber, Switch } from 'antd';
import { SaveOutlined } from '@ant-design/icons';
import { Button, Form, Input, InputNumber, Switch, Tooltip } from 'antd';
import { QuestionCircleOutlined, SaveOutlined } from '@ant-design/icons';
import TankContentCard from '../widget/TankContentCard';
import Preference from '../../common/model/preference/Preference';
import { FormInstance } from 'antd/lib/form';
@@ -191,7 +191,23 @@ export default class Edit extends TankComponent<IProps, IState> {
<Form.Item
label={Lang.t('preference.allowRegister')}
name="allowRegister"
required={true}
required
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.Item
label={
<span>
{Lang.t('preference.allowChunkUpload')}
<Tooltip title={Lang.t('preference.allowChunkUploadTip')}>
<QuestionCircleOutlined className="ml5 text-gray" />
</Tooltip>
</span>
}
name="allowChunkUpload"
required
valuePropName="checked"
>
<Switch />
+15 -1
View File
@@ -5,9 +5,10 @@ import TankComponent from '../../common/component/TankComponent';
import User from '../../common/model/user/User';
import Moon from '../../common/model/global/Moon';
import TankTitle from '../widget/TankTitle';
import { Button, Divider, Empty, message } from 'antd';
import { Button, Divider, Empty, message, Tooltip } from 'antd';
import {
EditOutlined,
QuestionCircleOutlined,
ScanOutlined,
ThunderboltOutlined,
} from '@ant-design/icons';
@@ -174,6 +175,19 @@ export default class Index extends TankComponent<IProps, IState> {
{this.preference.allowRegister ? Lang.t('yes') : Lang.t('no')}
</InfoCell>
<InfoCell
name={
<span>
{Lang.t('preference.allowChunkUpload')}
<Tooltip title={Lang.t('preference.allowChunkUploadTip')}>
<QuestionCircleOutlined className="ml5 text-gray" />
</Tooltip>
</span>
}
>
{this.preference.allowChunkUpload ? Lang.t('yes') : Lang.t('no')}
</InfoCell>
<InfoCell name={Lang.t('preference.docLink')}>
<a href={'https://tank-doc.eyeblue.cn/zh'} target="_blank">
https://tank-doc.eyeblue.cn/zh
+1 -1
View File
@@ -20,7 +20,7 @@
flex-shrink: 0;
}
.btn {
&-member, &-edit {
&-member, &-edit, &-bin {
color: @primary-color;
}
&-del {
+49 -24
View File
@@ -13,6 +13,7 @@ import {
Space as AntdSpace,
Modal,
Empty,
Tooltip,
} from 'antd';
import './List.less';
import Lang from '../../common/model/global/Lang';
@@ -22,6 +23,7 @@ import MessageBoxUtil from '../../common/util/MessageBoxUtil';
import {
EditOutlined,
ExclamationCircleFilled,
RestOutlined,
TeamOutlined,
} from '@ant-design/icons';
import { DeleteOutlined } from '@ant-design/icons/lib';
@@ -116,6 +118,10 @@ export default class List extends TankComponent<IProps, IState> {
this.props.history.push(`/space/${space.uuid}/member`);
}
handleSpaceRecycleBin(space: Space) {
this.props.history.push(`/space/${space.uuid}/bin/list`);
}
handleSpaceMatterList(space: Space) {
this.props.history.push(`/space/${space.uuid}/matter/list`);
}
@@ -150,32 +156,51 @@ export default class List extends TankComponent<IProps, IState> {
{space.name}
</div>
<AntdSpace className="space-item-icons">
<TeamOutlined
className="btn-action btn-member"
onClick={(e) =>
SafeUtil.stopPropagationWrap(e)(
this.handleSpaceMember(space)
)
}
/>
<Tooltip title={Lang.t('space.memberManage')}>
<TeamOutlined
className="btn-action btn-member"
onClick={(e) =>
SafeUtil.stopPropagationWrap(e)(
this.handleSpaceMember(space)
)
}
/>
</Tooltip>
<Tooltip title={Lang.t('space.bin')}>
<RestOutlined
className="btn-action btn-bin"
onClick={(e) =>
SafeUtil.stopPropagationWrap(e)(
this.handleSpaceRecycleBin(space)
)
}
/>
</Tooltip>
{this.user.isAdmin() && (
<>
<EditOutlined
className="btn-action btn-edit"
onClick={(e) =>
SafeUtil.stopPropagationWrap(e)(
this.handleEdit(space)
)
}
/>
<DeleteOutlined
className="btn-action btn-del"
onClick={(e) =>
SafeUtil.stopPropagationWrap(e)(
this.handleDelete(space)
)
}
/>
<Tooltip title={Lang.t('space.edit')}>
<EditOutlined
className="btn-action btn-edit"
onClick={(e) =>
SafeUtil.stopPropagationWrap(e)(
this.handleEdit(space)
)
}
/>
</Tooltip>
<Tooltip title={Lang.t('space.delete')}>
<DeleteOutlined
className="btn-action btn-del"
onClick={(e) =>
SafeUtil.stopPropagationWrap(e)(
this.handleDelete(space)
)
}
/>
</Tooltip>
</>
)}
</AntdSpace>
+36 -18
View File
@@ -1,9 +1,7 @@
import TankComponent from '../../../common/component/TankComponent';
import { Link, RouteComponentProps } from 'react-router-dom';
import Pager from '../../../common/model/base/Pager';
import SpaceMember, {
SpaceMemberFormValues,
} from '../../../common/model/space/member/SpaceMember';
import SpaceMember from '../../../common/model/space/member/SpaceMember';
import Lang from '../../../common/model/global/Lang';
import { Button, Modal, Table, Tag, Tooltip, Typography } from 'antd';
import TankTitle from '../../widget/TankTitle';
@@ -25,7 +23,7 @@ import {
} from '@ant-design/icons';
import Color from '../../../common/model/base/option/Color';
import MessageBoxUtil from '../../../common/util/MessageBoxUtil';
import ModalForm from './widget/ModalForm';
import ModalForm, { SpaceMemberModalFormValues } from './widget/ModalForm';
interface IProps
extends RouteComponentProps<{
@@ -41,7 +39,7 @@ export default class SpaceMemberList extends TankComponent<IProps, IState> {
modalState: {
visible: boolean;
mode: 'create' | 'edit';
initialValues?: SpaceMemberFormValues;
targetSpaceMember?: SpaceMember;
} = {
visible: false,
mode: 'create',
@@ -74,7 +72,7 @@ export default class SpaceMemberList extends TankComponent<IProps, IState> {
this.modalState = {
visible: true,
mode: 'edit',
initialValues: spaceMember.getForm(),
targetSpaceMember: spaceMember,
};
this.updateUI();
}
@@ -94,18 +92,38 @@ export default class SpaceMemberList extends TankComponent<IProps, IState> {
});
}
handleConfirmModalForm(values: SpaceMemberFormValues) {
handleConfirmModalForm(values: SpaceMemberModalFormValues) {
const spaceMember = new SpaceMember();
spaceMember.spaceUuid = this.spaceUuid;
spaceMember.assign(values);
spaceMember.httpSave(
() => {
MessageBoxUtil.success(Lang.t('operationSuccess'));
this.refresh();
},
null,
() => this.handleHideModalForm()
);
const body = {
spaceUuid: this.spaceUuid,
role: values.role,
};
if (this.modalState.mode === 'edit') {
spaceMember.httpEdit(
{
...body,
uuid: this.modalState.targetSpaceMember?.uuid!,
userUuid: values.userUuids.join(','),
},
() => {
MessageBoxUtil.success(Lang.t('operationSuccess'));
this.handleHideModalForm();
this.refresh();
}
);
} else {
spaceMember.httpCreate(
{
...body,
userUuids: values.userUuids.join(','),
},
() => {
MessageBoxUtil.success(Lang.t('operationSuccess'));
this.handleHideModalForm();
this.refresh();
}
);
}
}
handleHideModalForm() {
@@ -218,7 +236,7 @@ export default class SpaceMemberList extends TankComponent<IProps, IState> {
{modalState.visible && (
<ModalForm
mode={modalState.mode}
initialValues={modalState.initialValues}
targetSpaceMember={modalState.targetSpaceMember}
onOk={this.handleConfirmModalForm.bind(this)}
onCancel={() => this.handleHideModalForm()}
/>
+30 -25
View File
@@ -2,37 +2,45 @@ import Lang from '../../../../common/model/global/Lang';
import { Form, Input, Modal, Select } from 'antd';
import React, { useEffect, useRef, useState } from 'react';
import { FORM_LAYOUT } from '../../../../common/const/Form';
import { SpaceMemberFormValues } from '../../../../common/model/space/member/SpaceMember';
import { SpaceMemberRoleList } from '../../../../common/model/space/member/SpaceMemberRole';
import Pager from '../../../../common/model/base/Pager';
import SpaceMember from '../../../../common/model/space/member/SpaceMember';
import {
SpaceMemberRole,
SpaceMemberRoleList,
} from '../../../../common/model/space/member/SpaceMemberRole';
import User from '../../../../common/model/user/User';
export interface SpaceMemberModalFormValues {
userUuids: string[];
role: SpaceMemberRole;
}
interface Props {
mode: 'create' | 'edit';
initialValues?: SpaceMemberFormValues;
onOk: (values: SpaceMemberFormValues) => void;
targetSpaceMember?: SpaceMember;
onOk: (values: SpaceMemberModalFormValues) => void;
onCancel: () => void;
}
const ModalForm = ({ mode, initialValues, onOk, onCancel }: Props) => {
const ModalForm = ({ mode, targetSpaceMember, onOk, onCancel }: Props) => {
const isCreate = mode === 'create';
const userPager = useRef(new Pager<User>(null, User, 100));
const [form] = Form.useForm<SpaceMemberFormValues>();
const [form] = Form.useForm<SpaceMemberModalFormValues>();
const [userList, setUserList] = useState<User[]>([]); // 用户列表
const initialValues: SpaceMemberModalFormValues | undefined =
targetSpaceMember
? {
userUuids: [targetSpaceMember.userUuid!],
role: targetSpaceMember.role!,
}
: undefined;
useEffect(() => {
refresh();
}, []);
const refresh = () => {
userPager.current.httpList(() => {
setUserList(userPager.current.data);
const refreshUserList = (keyword?: string) => {
User.httpSearch(keyword, (res: any) => {
setUserList(res.data.data);
});
};
const handleSearchUser = (value?: string) => {
userPager.current.setFilterValue('username', value);
refresh();
};
useEffect(() => {
refreshUserList();
}, []);
return (
<Modal
@@ -49,12 +57,8 @@ const ModalForm = ({ mode, initialValues, onOk, onCancel }: Props) => {
onFinish={onOk}
initialValues={initialValues}
>
{/*编辑时暂存uuid*/}
<Form.Item name="uuid" hidden>
<Input />
</Form.Item>
<Form.Item
name="userUuid"
name="userUuids"
label={Lang.t('space.member')}
rules={[{ required: true, message: Lang.t('selectRequired') }]}
>
@@ -63,12 +67,13 @@ const ModalForm = ({ mode, initialValues, onOk, onCancel }: Props) => {
label: item.username,
value: item.uuid,
}))}
mode="multiple"
placeholder={Lang.t('space.memberTip')}
disabled={!isCreate}
showSearch
allowClear
filterOption={false}
onSearch={handleSearchUser}
onSearch={refreshUserList}
/>
</Form.Item>
<Form.Item
+2 -2
View File
@@ -3,7 +3,7 @@ import './InfoCell.less';
import { Col, Row } from 'antd';
interface IProps {
name: string;
name: React.ReactNode;
firstSpan?: number;
}
@@ -19,7 +19,7 @@ export default class InfoCell extends React.Component<IProps, IState> {
render() {
let that = this;
let name: string = this.props.name;
let name = this.props.name;
let firstSpan: number = 8;
if (this.props.firstSpan !== undefined) {
+5 -1
View File
@@ -20,7 +20,11 @@ const InputSize = ({
const renderAfter = () => {
return (
<Select value={unit} onChange={handleChangeUnit}>
<Select
value={unit}
disabled={otherProps.disabled}
onChange={handleChangeUnit}
>
{FileUtil.sizeUnits.map((u) => (
<Select.Option key={u} value={u}>
{u}
+10
View File
@@ -1964,6 +1964,11 @@
dependencies:
"@types/node" "*"
"@types/spark-md5@^3.0.5":
version "3.0.5"
resolved "https://registry.npmmirror.com/@types/spark-md5/-/spark-md5-3.0.5.tgz#eddec8639217e518c26e9e221ff56bf5f5f5c900"
integrity sha512-lWf05dnD42DLVKQJZrDHtWFidcLrHuip01CtnC2/S6AMhX4t9ZlEUj4iuRlAnts0PQk7KESOqKxeGE/b6sIPGg==
"@types/stack-utils@^2.0.0":
version "2.0.1"
resolved "https://registry.npmmirror.com/@types/stack-utils/-/stack-utils-2.0.1.tgz#20f18294f797f2209b5f65c8e3b5c8e8261d127c"
@@ -8626,6 +8631,11 @@ sourcemap-codec@^1.4.8:
resolved "https://registry.npmmirror.com/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz#ea804bd94857402e6992d05a38ef1ae35a9ab4c4"
integrity sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==
spark-md5@^3.0.2:
version "3.0.2"
resolved "https://registry.npmmirror.com/spark-md5/-/spark-md5-3.0.2.tgz#7952c4a30784347abcee73268e473b9c0167e3fc"
integrity sha512-wcFzz9cDfbuqe0FZzfi2or1sgyIrsDwmPwfZC4hiNidPdPINjeUwNfv5kldczoEAcjl9Y1L3SM7Uz2PUEQzxQw==
spdy-transport@^3.0.0:
version "3.0.0"
resolved "https://registry.npmmirror.com/spdy-transport/-/spdy-transport-3.0.0.tgz#00d4863a6400ad75df93361a1608605e5dcdcf31"