Compare commits

...
25 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
yinyuchen 2a78050f6b fix: share overview problem 2024-04-29 22:57:05 +08:00
yinyuchen 16e79ac4cf fix: dir problrm 2024-03-26 23:15:50 +08:00
seaheart db2efc5ab7 change version 2023-11-12 15:35:09 +08:00
seaheart dd29b890a8 fix: search file bug 2023-11-12 15:33:58 +08:00
seaheart 1809bc4e09 fix some safety problem and add copy share feature 2023-11-09 23:40:44 +08:00
seaheart 59a4bfcbb3 feat: remove matter dir copy link change 2023-11-09 22:43:45 +08:00
zhenbin e92b1f374b fix upload Progress freeze 2023-10-27 11:06:34 +08:00
seaheart 7c1c379bcb add previewer change 2023-08-12 21:22:14 +08:00
seaheart 714c506b49 fix: space list bug 2023-08-12 21:12:42 +08:00
seaheart 83d3db1000 change preference scan edit 2023-08-12 21:09:08 +08:00
seaheart 648eb9429a feat: change img previewer 2023-08-12 16:36:17 +08:00
seaheart 35b0c8194b feat: add preview style change 2023-08-12 14:33:20 +08:00
seaheart 2787c95916 upgrade image previewer 2023-08-12 12:42:00 +08:00
49 changed files with 2251 additions and 718 deletions
-9
View File
@@ -1,9 +0,0 @@
# 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)
# 蓝眼云盘(3.1.6)
[在线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. 回收站
+7 -6
View File
@@ -9,25 +9,26 @@ module.exports = {
target: 'http://localhost:6010',
changeOrigin: true,
pathRewrite: {
'^/api': '/api'
}
}
'^/api': '/api',
},
},
},
},
plugins: [
{
// less loader.
plugin: CracoLessPlugin,
options: {
// resolve-url-loader只对sass生效,craco-less默认使用sass配置,所以这里手动过滤掉resolve-url-loader
modifyLessRule: (lessRule) => {
lessRule.use = lessRule.use.filter(i => !i.loader.includes('resolve-url-loader'));
lessRule.use = lessRule.use.filter(
(i) => !i.loader.includes('resolve-url-loader')
);
return lessRule;
},
lessLoaderOptions: {
lessOptions: {
modifyVars: {'@primary-color': '#215891'},
modifyVars: { '@primary-color': '#215891' },
javascriptEnabled: true,
},
},
Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

After

Width:  |  Height:  |  Size: 74 KiB

+8 -6
View File
@@ -1,13 +1,12 @@
{
"name": "tank-front",
"version": "3.1.6",
"version": "4.1.1",
"private": true,
"dependencies": {
"@ant-design/icons": "4.7.0",
"@craco/craco": "6.4.3",
"@types/echarts": "4.6.1",
"@types/node": "16.7.13",
"@types/photoswipe": "^4.1.2",
"@types/qs": "^6.9.7",
"@types/react": "17.0.2",
"@types/react-dom": "17.0.2",
@@ -18,13 +17,14 @@
"craco-less": "2.0.0",
"echarts": "4.8.0",
"echarts-for-react": "2.0.16",
"photoswipe": "^4.1.3",
"qs": "^6.10.3",
"react": "17.0.2",
"react-dom": "17.0.2",
"react-photo-view": "^1.2.3",
"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"
},
@@ -45,9 +45,10 @@
]
},
"devDependencies": {
"prettier": "^2.8.8",
"@types/spark-md5": "^3.0.5",
"husky": "^8.0.3",
"lint-staged": "^13.2.2"
"lint-staged": "^13.2.2",
"prettier": "^2.8.8"
},
"husky": {
"hooks": {
@@ -59,5 +60,6 @@
"prettier --write",
"git add"
]
}
},
"packageManager": "yarn@1.22.19+sha1.4ba7fc5c6e704fce2066ecbfb0b0d8976fe62447"
}
-14
View File
@@ -175,20 +175,6 @@ export default class Pager<T> extends HttpBase {
}
}
//根据key来删除某个Filter
removeFilter(key: string) {
if (!this.filters || !this.filters.length) {
return;
}
for (let i = 0; i < this.filters.length; i++) {
let filter = this.filters[i];
if (filter.key === key) {
this.filters.splice(i, 1);
break;
}
}
}
//隐藏某个Filter,实际上我们可以根据这个filter来筛选,只不过不出现在NbFilter中而已。
showFilter(key: string, visible = true) {
if (!this.filters || !this.filters.length) {
+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 '';
}
}
}
+29 -5
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",
@@ -242,19 +260,22 @@ let LangEn = {
enableScan: 'Enable Scan disk',
disabledScan: 'Disabled Scan disk',
scanCron: 'Regularly Scan disk',
scanCronValidate: 'Regularly scan disk can not be null',
cron: 'Cron expression',
cronValidate: 'Cron expression can not be null',
scanScope: 'Scan scope',
scanUsers: 'Scan users',
scanScopeValidate: 'Scan scope can not be null',
scanSpaces: 'Scan spaces',
scanPerTenSeconds: 'Scan every ten seconds',
scanPerThirtySeconds: 'Scan every thirty seconds',
scanPerMinute: 'Scan every minute',
scanPerHour: 'Scan every hour',
scanCustom: 'Scan custom',
chooseUsers: 'Fuzzy search users',
chooseUsersValidate: 'choose one more user',
chooseSpaces: 'Fuzzy search spaces',
chooseSpacesValidate: 'choose one more space',
scanLoading: 'waiting...',
matterBinDefaultTip: 'set zero means to turn off the recycle bin',
scanImmediately: 'Scan immediately',
},
share: {
shareDetail: 'Share Detail',
@@ -347,8 +368,6 @@ let LangEn = {
transfigurationPromptText: 'Transfiguration Prompt',
transfigurationPrompt:
'You will login as this user.Please visit this link in other browser, if in current browser, you will logout.',
allUsers: 'All users',
partialUsers: 'Partial users',
searchUser: 'Search user',
},
space: {
@@ -375,6 +394,11 @@ let LangEn = {
memberRoleReadWrite: 'Read and write',
memberRoleAdmin: 'Admin',
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.',
+29 -5
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:
@@ -235,19 +253,22 @@ let LangZh = {
enableScan: '开启磁盘扫描',
disabledScan: '暂未开启磁盘扫描',
scanCron: '定时扫描',
scanCronValidate: '定时扫描不能为空',
cron: 'cron表达式',
cronValidate: 'cron表达式不能为空',
scanScope: '扫描范围',
scanUsers: '扫描用户',
scanScopeValidate: '扫描范围不能为空',
scanSpaces: '扫描空间',
scanPerTenSeconds: '每十秒',
scanPerThirtySeconds: '每三十秒',
scanPerMinute: '每分钟',
scanPerHour: '每小时',
scanCustom: '自定义',
chooseUsers: '模糊搜索用户',
chooseUsersValidate: '请选择至少一个用户',
chooseSpaces: '模糊搜索空间',
chooseSpacesValidate: '请选择至少一个空间',
scanLoading: '扫描中,请稍后...',
matterBinDefaultTip: '设置0表示关闭回收站功能',
scanImmediately: '立即扫描',
},
share: {
shareDetail: '分享详情',
@@ -340,8 +361,6 @@ let LangZh = {
transfigurationPromptText: '变身提示',
transfigurationPrompt:
'您将使用该用户的身份登录。请复制以下链接到其他浏览器访问,在当前浏览器访问会导致当前用户登录信息失效。',
allUsers: '全部用户',
partialUsers: '局部用户',
searchUser: '搜索用户',
},
space: {
@@ -367,6 +386,11 @@ let LangZh = {
memberRoleReadWrite: '可读写',
memberRoleAdmin: '管理员',
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()),
};
@@ -13,12 +13,11 @@ export default class ScanConfig extends Base {
cron: string | null = null;
// 扫描磁盘开启范围(全部用户 or 局部用户)
scope: ScanScopeType = ScanScopeType.ALL;
// 开启扫描磁盘功能的用户们
usernames: string[] = [];
// 开启扫描磁盘功能的空间
spaceNames: string[] = [];
// 前端辅助字段
cronType: string | null = null;
users: LabeledValue[] = [];
constructor() {
super();
@@ -33,7 +32,7 @@ export default class ScanConfig extends Base {
enable: this.enable,
scope: this.scope,
cron: this.cronType === ScanCronType.CUSTOM ? this.cron : this.cronType,
usernames: this.usernames,
spaceNames: this.spaceNames,
};
}
}
@@ -14,11 +14,11 @@ const ScanScopeTypeMap: {
[key in keyof typeof ScanScopeType]: SelectionOption;
} = {
ALL: {
name: Lang.t('user.allUsers'),
name: Lang.t('space.allSpaces'),
value: 'ALL',
},
CUSTOM: {
name: Lang.t('user.partialUsers'),
name: Lang.t('space.partialSpaces'),
value: 'CUSTOM',
},
};
+10
View File
@@ -1,6 +1,8 @@
import BaseEntity from '../base/BaseEntity';
import User from '../user/User';
import FileUtil from '../../util/FileUtil';
import Filter from '../base/filter/Filter';
import InputFilter from '../base/filter/InputFilter';
export interface SpaceFormValues {
name: string;
@@ -16,6 +18,7 @@ export default class Space extends BaseEntity {
totalSizeLimit: number = 0;
userUuid: string | null = null;
user: User | null = null;
type: 'PRIVATE' | 'SHARED' | null = null;
constructor(reactComponent?: React.Component) {
super(reactComponent);
@@ -25,6 +28,13 @@ export default class Space extends BaseEntity {
return 'space';
}
getFilters(): Filter[] {
return [
...super.getFilters(),
new InputFilter('空间名称', 'name'),
new InputFilter('空间类型', 'type'),
];
}
assign(obj: any) {
super.assign(obj);
+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;
}
}
}
+150 -67
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,9 +569,7 @@ export default class List extends TankComponent<IProps, IState> {
}
goToDirectory(id: string) {
this.searchText = null;
this.pager.setFilterValue('puuid', id);
this.pager.removeFilter('name');
this.pager.page = 0;
const query = this.pager.getParams();
Sun.navigateQueryTo({ path: this.getMatterListPath(), query });
@@ -643,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 (
@@ -657,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"
@@ -667,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"
@@ -680,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
@@ -710,7 +779,7 @@ export default class List extends TankComponent<IProps, IState> {
)}
{/*共享空间下暂不支持分享功能*/}
{!this.isInSpace && (
{!this.isInSpace && !this.isSearch() && (
<Button
type="primary"
className="mb10"
@@ -723,7 +792,7 @@ export default class List extends TankComponent<IProps, IState> {
</>
) : null}
{this.checkHandlePermission() && (
{this.checkHandlePermission() && !this.isSearch() && (
<>
<Upload
className="ant-upload"
@@ -779,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 ? (
@@ -809,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>
);
+22 -14
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'));
@@ -274,12 +273,17 @@ export default class MatterPanel extends TankComponent<IProps, IState> {
/>
</Tooltip>
)}
<Tooltip title={Lang.t('matter.copyPath')}>
<LinkOutlined
className="btn-action"
onClick={(e) => SafeUtil.stopPropagationWrap(e)(this.clipboard())}
/>
</Tooltip>
{/*文件夹不支持复制路径 安全问题*/}
{!matter.dir && (
<Tooltip title={Lang.t('matter.copyPath')}>
<LinkOutlined
className="btn-action"
onClick={(e) =>
SafeUtil.stopPropagationWrap(e)(this.clipboard())
}
/>
</Tooltip>
)}
<Tooltip title={Lang.t('matter.download')}>
<DownloadOutlined
className="btn-action"
@@ -340,13 +344,17 @@ export default class MatterPanel extends TankComponent<IProps, IState> {
</div>,
]
: []),
<div
className="cell-btn navy"
onClick={(e) => SafeUtil.stopPropagationWrap(e)(this.clipboard())}
>
<LinkOutlined className="btn-action mr5" />
{Lang.t('matter.copyLink')}
</div>,
...(matter.dir
? []
: [
<div
className="cell-btn navy"
onClick={(e) => SafeUtil.stopPropagationWrap(e)(this.clipboard())}
>
<LinkOutlined className="btn-action mr5" />
{Lang.t('matter.copyLink')}
</div>,
]),
<div
className="cell-btn navy"
onClick={(e) => SafeUtil.stopPropagationWrap(e)(matter.download())}
@@ -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 />
+18 -4
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
@@ -221,7 +235,7 @@ export default class Index extends TankComponent<IProps, IState> {
icon={<ScanOutlined />}
onClick={() => this.immediateScan()}
>
立即扫描
{Lang.t('preference.scanImmediately')}
</Button>
) : (
''
@@ -244,8 +258,8 @@ export default class Index extends TankComponent<IProps, IState> {
{ScanScopeTypeMap[scanConfig.scope].name}
</InfoCell>
{scanConfig.scope === ScanScopeType.CUSTOM && (
<InfoCell name={Lang.t('preference.scanUsers')}>
{scanConfig.usernames.join(', ')}
<InfoCell name={Lang.t('preference.scanSpaces')}>
{scanConfig.spaceNames.join(', ')}
</InfoCell>
)}
</>
+186 -193
View File
@@ -1,215 +1,208 @@
import React from 'react';
import { RouteComponentProps } from 'react-router-dom';
import './ScanEdit.less';
import TankComponent from '../../common/component/TankComponent';
import Preference from '../../common/model/preference/Preference';
import Moon from '../../common/model/global/Moon';
import React, { useEffect, useRef, useState } from 'react';
import Lang from '../../common/model/global/Lang';
import TankTitle from '../widget/TankTitle';
import TankContentCard from '../widget/TankContentCard';
import TankTitle from '../widget/TankTitle';
import Preference from '../../common/model/preference/Preference';
import { Button, Form, Input, Select, Spin, Switch } from 'antd';
import { FormInstance } from 'antd/lib/form';
import { SaveOutlined } from '@ant-design/icons';
import MessageBoxUtil from '../../common/util/MessageBoxUtil';
import Sun from '../../common/model/global/Sun';
import {
ScanScopeType,
ScanScopeTypeList,
} from '../../common/model/preference/model/ScanScopeType';
import {
ScanCronType,
ScanCronTypeList,
ScanCronTypeValueList,
} from '../../common/model/preference/model/ScanCronType';
import {
ScanScopeType,
ScanScopeTypeList,
} from '../../common/model/preference/model/ScanScopeType';
import Space from '../../common/model/space/Space';
import Pager from '../../common/model/base/Pager';
import User from '../../common/model/user/User';
import SortDirection from '../../common/model/base/SortDirection';
import MessageBoxUtil from '../../common/util/MessageBoxUtil';
import Sun from '../../common/model/global/Sun';
import { debounce } from '../../common/util/OptimizeUtil';
interface IProps extends RouteComponentProps {}
const ScanEdit = () => {
const layout = {
labelCol: { span: 6 },
wrapperCol: { span: 18 },
};
const pager = useRef(new Pager<Space>(null, Space, Pager.MAX_PAGE_SIZE));
const preference = useRef(new Preference());
const [loading, setLoading] = useState(true);
const [spaceList, setSpaceList] = useState<Space[]>([]);
const [form] = Form.useForm();
interface IState {}
export default class ScanEdit extends TankComponent<IProps, IState> {
formRef = React.createRef<FormInstance>();
preference: Preference = Moon.getSingleton().preference;
pager: Pager<User> = new Pager<User>(this, User, 10);
constructor(props: IProps) {
super(props);
this.preference.detailLoading = true;
}
componentDidMount() {
this.preference.httpFetch(() => {
this.preference.detailLoading = false;
// 给前端辅助字段赋值
this.preference.scanConfig.cronType = this.preference.scanConfig.cron;
if (
!ScanCronTypeValueList.includes(this.preference.scanConfig.cronType!)
) {
this.preference.scanConfig.cronType = ScanCronType.CUSTOM;
}
if (this.preference.scanConfig.scope !== ScanScopeType.ALL) {
this.preference.scanConfig.users =
this.preference.scanConfig.usernames.map((name: string) => ({
label: name,
value: name,
}));
}
this.updateUI();
});
}
finish(values: any) {
if (values.scope === ScanScopeType.CUSTOM) {
this.preference.scanConfig.assign({
...values,
usernames: values.users.map((item: any) => item.value),
});
} else {
this.preference.scanConfig.assign(values);
}
this.preference.httpSaveScan(function () {
const handleFinish = async () => {
const values = await form.validateFields();
preference.current.scanConfig.assign(values);
preference.current.httpSaveScan(function () {
MessageBoxUtil.success(Lang.t('operationSuccess'));
Sun.updateFrame();
Sun.navigateTo('/preference/index');
});
}
};
changeEnableScan(value: boolean) {
this.preference.scanConfig.enable = value;
this.updateUI();
}
const handleRefreshSpace = debounce((keyword?: string) => {
pager.current.setFilterValue('orderCreateTime', SortDirection.DESC);
pager.current.setFilterValue('name', keyword);
pager.current.httpList(() => setSpaceList(pager.current.data));
}, 200);
changeCronType(value: ScanCronType) {
this.preference.scanConfig.cronType = value;
this.updateUI();
}
useEffect(() => {
preference.current.detailLoading = true;
preference.current.httpFetch(() => {
preference.current.scanConfig.cronType = ScanCronTypeValueList.includes(
preference.current.scanConfig.cron!
)
? preference.current.scanConfig.cron
: ScanCronType.CUSTOM;
setLoading(false);
});
handleRefreshSpace();
}, []);
changeScopeType(value: ScanScopeType) {
this.preference.scanConfig.scope = value;
this.updateUI();
}
fetchUser(value: any) {
this.pager.resetFilter();
this.pager.setFilterValue('orderCreateTime', SortDirection.DESC);
this.pager.setFilterValue('username', value);
this.pager.httpList();
}
render() {
const { preference, pager } = this;
const layout = {
labelCol: { span: 6 },
wrapperCol: { span: 18 },
};
return (
<div className="preference-scan-edit">
<TankTitle name={Lang.t('preference.editScan')} />
<TankContentCard loading={preference.detailLoading}>
<Form
{...layout}
name="preview-engine"
ref={this.formRef}
initialValues={preference.scanConfig}
onFinish={this.finish.bind(this)}
onValuesChange={() => this.updateUI}
return (
<div className="preference-scan-edit">
<TankTitle name={Lang.t('preference.editScan')} />
<TankContentCard loading={loading}>
<Form
{...layout}
form={form}
initialValues={preference.current.scanConfig}
onFinish={handleFinish}
>
<Form.Item
label={Lang.t('preference.enableScan')}
valuePropName="checked"
name="enable"
required
>
<Form.Item
label={Lang.t('preference.enableScan')}
valuePropName="checked"
name="enable"
>
<Switch onChange={this.changeEnableScan.bind(this)} />
</Form.Item>
{preference.scanConfig.enable && (
<>
<Form.Item
label={Lang.t('preference.scanCron')}
name="cronType"
>
<Select onChange={this.changeCronType.bind(this)}>
{ScanCronTypeList.map((option) => (
<Select.Option key={option.value} value={option.value}>
{option.name}
</Select.Option>
))}
</Select>
</Form.Item>
{preference.scanConfig.cronType === ScanCronType.CUSTOM && (
<Form.Item
label={Lang.t('preference.cron')}
name="cron"
rules={[
{
required: true,
message: Lang.t('preference.cronValidate'),
},
]}
>
<Input />
</Form.Item>
)}
<Form.Item label={Lang.t('preference.scanScope')} name="scope">
<Select onChange={this.changeScopeType.bind(this)}>
{ScanScopeTypeList.map((option) => (
<Select.Option key={option.value} value={option.value}>
{option.name}
</Select.Option>
))}
</Select>
</Form.Item>
{preference.scanConfig.scope === ScanScopeType.CUSTOM && (
<Form.Item
label={Lang.t('preference.scanUsers')}
name="users"
rules={[
{
required: true,
message: Lang.t('preference.chooseUsersValidate'),
},
]}
>
<Select
mode="multiple"
labelInValue
placeholder={Lang.t('preference.chooseUsers')}
notFoundContent={
pager.loading ? <Spin size="small" /> : null
}
filterOption={false}
onSearch={this.fetchUser.bind(this)}
style={{ width: '100%' }}
<Switch />
</Form.Item>
<Form.Item dependencies={['enable']} noStyle>
{({ getFieldValue }) => {
return (
getFieldValue('enable') && (
<>
<Form.Item
label={Lang.t('preference.scanCron')}
name="cronType"
required
rules={[
{
required: true,
message: Lang.t('preference.scanCronValidate'),
},
]}
>
{pager.data.map((user: User) => (
<Select.Option
key={user.username!}
value={user.username!}
>
{user.username}
</Select.Option>
))}
</Select>
</Form.Item>
)}
</>
)}
<div className="text-right">
<Button type="primary" htmlType="submit" icon={<SaveOutlined />}>
{Lang.t('save')}
</Button>
</div>
</Form>
</TankContentCard>
</div>
);
}
}
<Select>
{ScanCronTypeList.map((option) => (
<Select.Option
key={option.value}
value={option.value}
>
{option.name}
</Select.Option>
))}
</Select>
</Form.Item>
<Form.Item dependencies={['cronType']} noStyle>
{({ getFieldValue }) => {
return (
getFieldValue('cronType') === ScanCronType.CUSTOM && (
<Form.Item
label={Lang.t('preference.cron')}
name="cron"
rules={[
{
required: true,
message: Lang.t('preference.cronValidate'),
},
]}
>
<Input />
</Form.Item>
)
);
}}
</Form.Item>
<Form.Item
label={Lang.t('preference.scanScope')}
name="scope"
required
rules={[
{
required: true,
message: Lang.t('preference.scanScopeValidate'),
},
]}
>
<Select>
{ScanScopeTypeList.map((option) => (
<Select.Option
key={option.value}
value={option.value}
>
{option.name}
</Select.Option>
))}
</Select>
</Form.Item>
<Form.Item dependencies={['scope']} noStyle>
{({ getFieldValue }) => {
return (
getFieldValue('scope') === ScanScopeType.CUSTOM && (
<Form.Item
label={Lang.t('preference.scanSpaces')}
name="spaceNames"
rules={[
{
required: true,
message: Lang.t(
'preference.chooseSpacesValidate'
),
},
]}
>
<Select
mode="multiple"
placeholder={Lang.t('preference.chooseSpaces')}
className="wp100"
filterOption={false}
onSearch={handleRefreshSpace}
>
{spaceList.map((space: Space) => (
<Select.Option
key={space.name}
value={space.name}
>
{space.name}
</Select.Option>
))}
</Select>
</Form.Item>
)
);
}}
</Form.Item>
</>
)
);
}}
</Form.Item>
<div className="text-right">
<Button type="primary" htmlType="submit" icon={<SaveOutlined />}>
{Lang.t('save')}
</Button>
</div>
</Form>
</TankContentCard>
</div>
);
};
export default ScanEdit;
+5 -1
View File
@@ -59,7 +59,11 @@ export default class Detail extends TankComponent<IProps, IState> {
this.share.uuid = this.props.match.params.uuid;
this.pager.urlPage = Share.URL_MATTER_PAGE;
if (this.user.hasLogin()) {
if (!!BrowserUtil.getQueryByName('code')) {
this.getFiles(BrowserUtil.getQueryByName('code')!);
// 去除url中的code query
window.history.replaceState({}, '', window.location.pathname);
} else if (this.user.hasLogin()) {
this.pager.enableHistory();
this.refresh();
}
+2
View File
@@ -9,6 +9,7 @@ import ShareBar from './widget/ShareBar';
import './List.less';
import Lang from '../../common/model/global/Lang';
import SortDirection from '../../common/model/base/SortDirection';
interface IProps extends RouteComponentProps {}
@@ -23,6 +24,7 @@ export default class List extends TankComponent<IProps, IState> {
componentDidMount() {
this.pager.enableHistory();
this.pager.setFilterValue('orderCreateTime', SortDirection.DESC);
this.refresh();
}
+2 -4
View File
@@ -30,10 +30,8 @@ export default class ShareDialogModal extends TankComponent<IProps, IState> {
copyAll() {
const { share } = this.props;
const text = `${Lang.t('share.link')}:${share.getLink()} ${Lang.t(
'share.code'
)}:${share.code}`;
this.clipboard(text);
const url = `${share.getLink()}?code=${share.code}`;
this.clipboard(url);
}
static open = (share: Share) => {
+1 -1
View File
@@ -20,7 +20,7 @@
flex-shrink: 0;
}
.btn {
&-member, &-edit {
&-member, &-edit, &-bin {
color: @primary-color;
}
&-del {
+50 -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';
@@ -50,6 +52,7 @@ export default class List extends TankComponent<IProps, IState> {
componentDidMount() {
this.pager.enableHistory();
this.pager.setFilterValue('type', 'SHARED'); // 列表只展示共享空间
this.pager.httpList();
}
@@ -115,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`);
}
@@ -149,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}
@@ -1,16 +1,22 @@
.browser-previewer {
.browser-previewer-wrapper {
position: fixed;
top: 0;
right: 0;
bottom: 0;
left: 0;
padding: 24px;
background: rgba(0,0,0,0.6);
z-index: 1000;
background: white;
display: flex;
flex-direction: column;
.browser-previewer {
height: 100%;
background: white;
display: flex;
flex-direction: column;
}
.title-bar {
height: 40px;
+24 -19
View File
@@ -51,28 +51,33 @@ export default class BrowserPreviewer extends React.Component<IProps, IState> {
render() {
return (
<div className="browser-previewer">
<div className="title-bar">
<div className="left-part"></div>
<div
className="browser-previewer-wrapper"
onClick={() => this.props.onClose()}
>
<div className="browser-previewer" onClick={(e) => e.stopPropagation()}>
<div className="title-bar">
<div className="left-part"></div>
<div className="middle-part">
{this.props.name}({FileUtil.humanFileSize(this.props.size)})
</div>
<div className="middle-part">
{this.props.name}({FileUtil.humanFileSize(this.props.size)})
</div>
<div className="right-part">
<span className="close btn-action">
<CloseOutlined
onClick={() => {
this.props.onClose();
}}
/>
</span>
<div className="right-part">
<span className="close btn-action">
<CloseOutlined
onClick={() => {
this.props.onClose();
}}
/>
</span>
</div>
</div>
<div className="frame-area">
<iframe width="100%" height="100%" src={this.props.url}>
iframe may not supported.
</iframe>
</div>
</div>
<div className="frame-area">
<iframe width="100%" height="100%" src={this.props.url}>
iframe may not supported.
</iframe>
</div>
</div>
);
+16 -2
View File
@@ -1,3 +1,17 @@
.widget-image-previewer {
.widget-image-previewer{
.PhotoView-Slider__toolbarIcon {
display: none;
}
}
.toolbar-icon {
width: 44px;
height: 44px;
display: flex;
align-items: center;
justify-content: center;
opacity: 0.7;
&:hover {
opacity: 1;
}
}
}
+91 -125
View File
@@ -1,90 +1,45 @@
import React from 'react';
import { PhotoSlider } from 'react-photo-view';
import 'react-photo-view/dist/react-photo-view.css';
import ReactDOM from 'react-dom';
import { OverlayRenderProps } from 'react-photo-view/dist/types';
import {
CloseOutlined,
DownloadOutlined,
RotateRightOutlined,
} from '@ant-design/icons';
import TankComponent from '../../../common/component/TankComponent';
import { Space } from 'antd';
import './ImagePreviewer.less';
import PhotoSwipe from 'photoswipe';
import PhotoSwipeUIDefault from 'photoswipe/dist/photoswipe-ui-default';
//PhotoSwipe的样式
import 'photoswipe/dist/photoswipe.css';
import 'photoswipe/dist/default-skin/default-skin.css';
/**
* 图片预览器
* 支持预览一张图片或者多张图片
* 参考文档:https://zhuanlan.zhihu.com/p/473554342
* https://gitee.com/MinJieLiu/react-photo-view
*/
export default class ImagePreviewer {
private static pswpDom(): HTMLElement {
let innerHTML = `
<!-- Background of PhotoSwipe.
It's a separate element as animating opacity is faster than rgba(). -->
<div class="pswp__bg"></div>
export default class ImagePreviewer extends TankComponent<any, any> {
static initialized: boolean = false;
<!-- Slides wrapper with overflow:hidden. -->
<div class="pswp__scroll-wrap">
static singletonRef = React.createRef<ImagePreviewer>();
<!-- Container that holds slides.
PhotoSwipe keeps only 3 of them in the DOM to save memory.
Don't modify these 3 pswp__item elements, data is added later on. -->
<div class="pswp__container">
<div class="pswp__item"></div>
<div class="pswp__item"></div>
<div class="pswp__item"></div>
</div>
<!-- Default (PhotoSwipeUI_Default) interface on top of sliding area. Can be changed. -->
<div class="pswp__ui pswp__ui--hidden">
<div class="pswp__top-bar">
<!-- Controls are self-explanatory. Order can be changed. -->
<div class="pswp__counter"></div>
<button class="pswp__button pswp__button--close" title="Close (Esc)"></button>
<button class="pswp__button pswp__button--share" title="Share"></button>
<button class="pswp__button pswp__button--fs" title="Toggle fullscreen"></button>
<button class="pswp__button pswp__button--zoom" title="Zoom in/out"></button>
<!-- Preloader demo https://codepen.io/dimsemenov/pen/yyBWoR -->
<!-- element will get class pswp__preloader--active when preloader is running -->
<div class="pswp__preloader">
<div class="pswp__preloader__icn">
<div class="pswp__preloader__cut">
<div class="pswp__preloader__donut"></div>
</div>
</div>
</div>
</div>
<div class="pswp__share-modal pswp__share-modal--hidden pswp__single-tap">
<div class="pswp__share-tooltip"></div>
</div>
<button class="pswp__button pswp__button--arrow--left" title="Previous (arrow left)">
</button>
<button class="pswp__button pswp__button--arrow--right" title="Next (arrow right)">
</button>
<div class="pswp__caption">
<div class="pswp__caption__center"></div>
</div>
</div>
</div>
`;
//组件的私有变量
visible: boolean = true;
urls: string[] = [];
index: number = 0;
//初始化
private static init() {
let div: HTMLElement = document.createElement('div');
div.className = 'pswp';
div.setAttribute('tabIndex', '-1');
div.setAttribute('role', 'dialog');
div.setAttribute('aria-hidden', 'true');
div.id = 'photo-container';
div.innerHTML = innerHTML;
//添加到body
document.body.appendChild(div);
return div;
ReactDOM.render(
<ImagePreviewer ref={ImagePreviewer.singletonRef} />,
document.getElementById('photo-container')
);
}
//展示一张图片
@@ -94,59 +49,70 @@ export default class ImagePreviewer {
//展示一系列图片
static showMultiPhoto(urls: string[], index: number = 0) {
let that = this;
let items: PhotoSwipe.Item[] = [];
urls.forEach((url) => {
items.push({
src: url,
w: 0,
h: 0,
});
});
if (!ImagePreviewer.initialized) {
ImagePreviewer.initialized = true;
ImagePreviewer.init();
}
let options = {
closeEl: false,
//不需要历史纪录
history: false,
//不需要全屏按钮
fullscreenEl: false,
//不需要分享按钮
shareEl: false,
//当前从第0张展示。
index: index,
tapToClose: true,
ImagePreviewer.singletonRef.current?.show(urls, index);
}
show(urls: string[], index: number = 0) {
this.urls = urls;
this.index = index;
this.visible = true;
this.updateUI();
}
render() {
let toolBar: (overlayProps: OverlayRenderProps) => React.ReactNode = (
overlayProps: OverlayRenderProps
) => {
return (
<Space>
<DownloadOutlined
className="f18 toolbar-icon btn-action"
onClick={() => {
const a = document.createElement('a');
a.setAttribute('download', '');
a.setAttribute('target', '_blank');
a.setAttribute('rel', 'noopener');
a.href = overlayProps.images[overlayProps.index].src!;
document.body.appendChild(a);
a.click();
a.remove();
}}
/>
<RotateRightOutlined
className="f18 toolbar-icon btn-action"
onClick={() => {
overlayProps.onRotate(overlayProps.rotate + 90);
}}
/>
<CloseOutlined
className="f18 toolbar-icon btn-action"
onClick={() => overlayProps.onClose()}
/>
</Space>
);
};
//获取dom节点
let div = ImagePreviewer.pswpDom();
//添加到body
document.body.appendChild(div);
let photoSwipe = new PhotoSwipe(div, PhotoSwipeUIDefault, items, options);
photoSwipe.listen(
'gettingData',
function (index: number, item: PhotoSwipe.Item) {
if (!item.w || !item.h || item.w < 1 || item.h < 1) {
//这里为了获取图片真实大小
const img: HTMLImageElement = new Image();
img.onload = function () {
item.w = img.width;
item.h = img.height;
photoSwipe.updateSize(true); // reinit Items
};
img.src = item.src!;
}
}
return (
<PhotoSlider
className="widget-image-previewer"
images={this.urls.map((item: string) => ({ src: item, key: item }))}
visible={this.visible}
onClose={() => {
this.visible = false;
this.updateUI();
}}
index={this.index}
onIndexChange={(index: number) => {
this.index = index;
this.updateUI();
}}
toolbarRender={toolBar}
/>
);
photoSwipe.init();
photoSwipe.listen('close', () => {
if (div.parentNode) {
//移除节点
div.parentNode.removeChild(div);
}
});
photoSwipe.listen('afterChange', () => {});
}
}
@@ -1,4 +1,3 @@
import './ImagePreviewer.less';
import MimeUtil from '../../../common/util/MimeUtil';
import MessageBoxUtil from '../../../common/util/MessageBoxUtil';
import Moon from '../../../common/model/global/Moon';
@@ -106,8 +105,8 @@ export default class PreviewerHelper {
if (previewUrl) {
targetUrl = targetUrl.replace('{originUrl}', previewUrl);
targetUrl = targetUrl.replace('{b64Url}', previewUrl);
targetUrl = targetUrl.replace('{url}', previewUrl);
targetUrl = targetUrl.replace('{b64Url}', encodeURIComponent(PreviewerHelper.b64EncodeUnicode(previewUrl)));
targetUrl = targetUrl.replace('{url}', encodeURIComponent(previewUrl));
if (engine.previewInSite) {
BrowserPreviewer.show(fileName, targetUrl, matter.size);
} else {
+15 -10
View File
@@ -1859,11 +1859,6 @@
resolved "https://registry.npmmirror.com/@types/parse-json/-/parse-json-4.0.0.tgz#2f8bb441434d163b35fb8ffdccd7138927ffb8c0"
integrity sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA==
"@types/photoswipe@^4.1.2":
version "4.1.2"
resolved "https://registry.npmmirror.com/@types/photoswipe/-/photoswipe-4.1.2.tgz#3ddafb668bf6800f8dfab5bea2ba82dda879cd84"
integrity sha512-HA9TtCAQKToldgxRiyJ1DbsElg/cQV/SQ8COVjqIqghjy60Zxfh78E1WiFotthquqkS86nz13Za9wEbToe0svQ==
"@types/prettier@^2.1.5":
version "2.4.4"
resolved "https://registry.npmmirror.com/@types/prettier/-/prettier-2.4.4.tgz#5d9b63132df54d8909fce1c3f8ca260fdd693e17"
@@ -1969,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"
@@ -6766,11 +6766,6 @@ performance-now@^2.1.0:
resolved "https://registry.npmmirror.com/performance-now/-/performance-now-2.1.0.tgz#6309f4e0e5fa913ec1c69307ae364b4b377c9e7b"
integrity sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==
photoswipe@^4.1.3:
version "4.1.3"
resolved "https://registry.npmmirror.com/photoswipe/-/photoswipe-4.1.3.tgz#59f49494eeb9ddab5888d03392926a19bc197550"
integrity sha512-89Z43IRUyw7ycTolo+AaiDn3W1EEIfox54hERmm9bI12IB9cvRfHSHez3XhAyU8XW2EAFrC+2sKMhh7SJwn0bA==
picocolors@^0.2.1:
version "0.2.1"
resolved "https://registry.npmmirror.com/picocolors/-/picocolors-0.2.1.tgz#570670f793646851d1ba135996962abad587859f"
@@ -7933,6 +7928,11 @@ react-lifecycles-compat@^3.0.4:
resolved "https://registry.npmmirror.com/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz#4f1a273afdfc8f3488a8c516bfda78f872352362"
integrity sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==
react-photo-view@^1.2.3:
version "1.2.3"
resolved "https://registry.npmmirror.com/react-photo-view/-/react-photo-view-1.2.3.tgz#436f81ad0a611dfaa43bbc72a4fef26a057b5aef"
integrity sha512-dtaFjU8nAB/rD8mxBaaJcvESlDOweAd1tmvXujS/d3oh1QyGew3xD4C02l46E6Dhn3Tshzl2+IM1h0MWV9nG7g==
react-refresh@^0.11.0:
version "0.11.0"
resolved "https://registry.npmmirror.com/react-refresh/-/react-refresh-0.11.0.tgz#77198b944733f0f1f1a90e791de4541f9f074046"
@@ -8631,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"