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) [English Version](https://tank-doc.eyeblue.cn/en)
# 蓝眼云盘(3.1.6) # 蓝眼云盘(4.2.0)
[在线Demo](https://tank.eyeblue.cn) (体验账号: demo 密码:123456) [在线Demo](https://tank.eyeblue.cn) (体验账号:demo 密码:123456)
[更新文档 CHANGELOG](https://github.com/eyebluecn/tank/blob/master/CHANGELOG)
蓝眼云盘是蓝眼开源系列代表作品之一,致力于打造精致,优雅,简约的云盘。核心功能如下: 蓝眼云盘是蓝眼开源系列代表作品之一,致力于打造精致,优雅,简约的云盘。核心功能如下:
1. 文件列表 1. 文件列表
2. 文件/文件夹上传 + 拖拽上传 2. 文件/文件夹上传 + 拖拽上传 + 分片/断点续传
3. 文件分享 3. 文件分享
4. 文件监控统计 4. 文件监控统计
5. 回收站 5. 回收站
+7 -6
View File
@@ -9,25 +9,26 @@ module.exports = {
target: 'http://localhost:6010', target: 'http://localhost:6010',
changeOrigin: true, changeOrigin: true,
pathRewrite: { pathRewrite: {
'^/api': '/api' '^/api': '/api',
} },
} },
}, },
}, },
plugins: [ plugins: [
{ {
// less loader. // less loader.
plugin: CracoLessPlugin, plugin: CracoLessPlugin,
options: { options: {
// resolve-url-loader只对sass生效,craco-less默认使用sass配置,所以这里手动过滤掉resolve-url-loader // resolve-url-loader只对sass生效,craco-less默认使用sass配置,所以这里手动过滤掉resolve-url-loader
modifyLessRule: (lessRule) => { 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; return lessRule;
}, },
lessLoaderOptions: { lessLoaderOptions: {
lessOptions: { lessOptions: {
modifyVars: {'@primary-color': '#215891'}, modifyVars: { '@primary-color': '#215891' },
javascriptEnabled: true, 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", "name": "tank-front",
"version": "3.1.6", "version": "4.1.1",
"private": true, "private": true,
"dependencies": { "dependencies": {
"@ant-design/icons": "4.7.0", "@ant-design/icons": "4.7.0",
"@craco/craco": "6.4.3", "@craco/craco": "6.4.3",
"@types/echarts": "4.6.1", "@types/echarts": "4.6.1",
"@types/node": "16.7.13", "@types/node": "16.7.13",
"@types/photoswipe": "^4.1.2",
"@types/qs": "^6.9.7", "@types/qs": "^6.9.7",
"@types/react": "17.0.2", "@types/react": "17.0.2",
"@types/react-dom": "17.0.2", "@types/react-dom": "17.0.2",
@@ -18,13 +17,14 @@
"craco-less": "2.0.0", "craco-less": "2.0.0",
"echarts": "4.8.0", "echarts": "4.8.0",
"echarts-for-react": "2.0.16", "echarts-for-react": "2.0.16",
"photoswipe": "^4.1.3",
"qs": "^6.10.3", "qs": "^6.10.3",
"react": "17.0.2", "react": "17.0.2",
"react-dom": "17.0.2", "react-dom": "17.0.2",
"react-photo-view": "^1.2.3",
"react-router": "^5.2.0", "react-router": "^5.2.0",
"react-router-dom": "^5.2.0", "react-router-dom": "^5.2.0",
"react-scripts": "5.0.0", "react-scripts": "5.0.0",
"spark-md5": "^3.0.2",
"typescript": "4.4.2", "typescript": "4.4.2",
"velocity-react": "^1.4.3" "velocity-react": "^1.4.3"
}, },
@@ -45,9 +45,10 @@
] ]
}, },
"devDependencies": { "devDependencies": {
"prettier": "^2.8.8", "@types/spark-md5": "^3.0.5",
"husky": "^8.0.3", "husky": "^8.0.3",
"lint-staged": "^13.2.2" "lint-staged": "^13.2.2",
"prettier": "^2.8.8"
}, },
"husky": { "husky": {
"hooks": { "hooks": {
@@ -59,5 +60,6 @@
"prettier --write", "prettier --write",
"git add" "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中而已。 //隐藏某个Filter,实际上我们可以根据这个filter来筛选,只不过不出现在NbFilter中而已。
showFilter(key: string, visible = true) { showFilter(key: string, visible = true) {
if (!this.filters || !this.filters.length) { 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', delete: 'Delete',
hardDelete: 'Hard delete', hardDelete: 'Hard delete',
recovery: 'Recovery', recovery: 'Recovery',
recoverySuccess: 'Recovery Succeed',
more: 'More', more: 'More',
share: 'Share', share: 'Share',
close: 'Close', close: 'Close',
@@ -170,6 +171,20 @@ let LangEn = {
crawlSuccessTip: 'Crawl successfully', crawlSuccessTip: 'Crawl successfully',
crawlDescription: crawlDescription:
'Crawl files refers to the backend downloading directly from the specified resource link to the current directory of EyeblueTank', '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: { router: {
allFiles: 'All Files', allFiles: 'All Files',
@@ -208,6 +223,9 @@ let LangEn = {
docLink: 'Document Link', docLink: 'Document Link',
tankDocLink: 'https://tank-doc.eyeblue.cn', tankDocLink: 'https://tank-doc.eyeblue.cn',
allowRegister: 'Allow register', 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', systemCleanup: 'System Cleanup',
systemCleanupDescription: systemCleanupDescription:
"This operation will cleanup everything except administrators' data", "This operation will cleanup everything except administrators' data",
@@ -242,19 +260,22 @@ let LangEn = {
enableScan: 'Enable Scan disk', enableScan: 'Enable Scan disk',
disabledScan: 'Disabled Scan disk', disabledScan: 'Disabled Scan disk',
scanCron: 'Regularly Scan disk', scanCron: 'Regularly Scan disk',
scanCronValidate: 'Regularly scan disk can not be null',
cron: 'Cron expression', cron: 'Cron expression',
cronValidate: 'Cron expression can not be null', cronValidate: 'Cron expression can not be null',
scanScope: 'Scan scope', scanScope: 'Scan scope',
scanUsers: 'Scan users', scanScopeValidate: 'Scan scope can not be null',
scanSpaces: 'Scan spaces',
scanPerTenSeconds: 'Scan every ten seconds', scanPerTenSeconds: 'Scan every ten seconds',
scanPerThirtySeconds: 'Scan every thirty seconds', scanPerThirtySeconds: 'Scan every thirty seconds',
scanPerMinute: 'Scan every minute', scanPerMinute: 'Scan every minute',
scanPerHour: 'Scan every hour', scanPerHour: 'Scan every hour',
scanCustom: 'Scan custom', scanCustom: 'Scan custom',
chooseUsers: 'Fuzzy search users', chooseSpaces: 'Fuzzy search spaces',
chooseUsersValidate: 'choose one more user', chooseSpacesValidate: 'choose one more space',
scanLoading: 'waiting...', scanLoading: 'waiting...',
matterBinDefaultTip: 'set zero means to turn off the recycle bin', matterBinDefaultTip: 'set zero means to turn off the recycle bin',
scanImmediately: 'Scan immediately',
}, },
share: { share: {
shareDetail: 'Share Detail', shareDetail: 'Share Detail',
@@ -347,8 +368,6 @@ let LangEn = {
transfigurationPromptText: 'Transfiguration Prompt', transfigurationPromptText: 'Transfiguration Prompt',
transfigurationPrompt: transfigurationPrompt:
'You will login as this user.Please visit this link in other browser, if in current browser, you will logout.', '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', searchUser: 'Search user',
}, },
space: { space: {
@@ -375,6 +394,11 @@ let LangEn = {
memberRoleReadWrite: 'Read and write', memberRoleReadWrite: 'Read and write',
memberRoleAdmin: 'Admin', memberRoleAdmin: 'Admin',
emptyHint: 'No Space Yet', emptyHint: 'No Space Yet',
allSpaces: 'All Spaces',
partialSpaces: 'Partial Spaces',
bin: 'Space recycle bin',
edit: 'Edit Space',
delete: 'Delete Space',
}, },
model: { model: {
usernameRule: 'only lowercase letter and number and _ is permitted.', usernameRule: 'only lowercase letter and number and _ is permitted.',
+29 -5
View File
@@ -94,6 +94,7 @@ let LangZh = {
delete: '删除', delete: '删除',
hardDelete: '彻底删除', hardDelete: '彻底删除',
recovery: '恢复', recovery: '恢复',
recoverySuccess: '恢复成功',
more: '更多', more: '更多',
share: '分享', share: '分享',
close: '关闭', close: '关闭',
@@ -164,6 +165,20 @@ let LangZh = {
crawlSuccessTip: '抓取成功', crawlSuccessTip: '抓取成功',
crawlDescription: crawlDescription:
'抓取文件指后台将从指定资源链接直接下载到蓝眼云盘当前目录下', '抓取文件指后台将从指定资源链接直接下载到蓝眼云盘当前目录下',
// Chunked upload related
chunks: '分片',
chunkedUpload: '分片上传',
mergingChunks: '合并分片中...',
paused: '已暂停',
completed: '已完成',
uploadFailed: '上传失败',
cancelled: '已取消',
pause: '暂停',
resume: '继续',
cancel: '取消',
// MD5 verification related
calculatingMd5: '计算MD5校验中...',
md5VerificationEnabled: 'MD5校验已启用',
}, },
router: { router: {
allFiles: '全部文件', allFiles: '全部文件',
@@ -202,6 +217,9 @@ let LangZh = {
docLink: '文档链接', docLink: '文档链接',
tankDocLink: 'https://tank-doc.eyeblue.cn/zh', tankDocLink: 'https://tank-doc.eyeblue.cn/zh',
allowRegister: '允许自主注册', allowRegister: '允许自主注册',
allowChunkUpload: '允许分片上传',
allowChunkUploadTip:
'分片上传适用于大文件(≥100MB),支持断点续传。建议在服务端支持HTTP/2协议时开启,否则可能因并发连接数限制导致上传变慢。',
systemCleanup: '重置系统', systemCleanup: '重置系统',
systemCleanupDescription: '重置系统将清空除管理员账号外所有数据', systemCleanupDescription: '重置系统将清空除管理员账号外所有数据',
systemCleanupPrompt: systemCleanupPrompt:
@@ -235,19 +253,22 @@ let LangZh = {
enableScan: '开启磁盘扫描', enableScan: '开启磁盘扫描',
disabledScan: '暂未开启磁盘扫描', disabledScan: '暂未开启磁盘扫描',
scanCron: '定时扫描', scanCron: '定时扫描',
scanCronValidate: '定时扫描不能为空',
cron: 'cron表达式', cron: 'cron表达式',
cronValidate: 'cron表达式不能为空', cronValidate: 'cron表达式不能为空',
scanScope: '扫描范围', scanScope: '扫描范围',
scanUsers: '扫描用户', scanScopeValidate: '扫描范围不能为空',
scanSpaces: '扫描空间',
scanPerTenSeconds: '每十秒', scanPerTenSeconds: '每十秒',
scanPerThirtySeconds: '每三十秒', scanPerThirtySeconds: '每三十秒',
scanPerMinute: '每分钟', scanPerMinute: '每分钟',
scanPerHour: '每小时', scanPerHour: '每小时',
scanCustom: '自定义', scanCustom: '自定义',
chooseUsers: '模糊搜索用户', chooseSpaces: '模糊搜索空间',
chooseUsersValidate: '请选择至少一个用户', chooseSpacesValidate: '请选择至少一个空间',
scanLoading: '扫描中,请稍后...', scanLoading: '扫描中,请稍后...',
matterBinDefaultTip: '设置0表示关闭回收站功能', matterBinDefaultTip: '设置0表示关闭回收站功能',
scanImmediately: '立即扫描',
}, },
share: { share: {
shareDetail: '分享详情', shareDetail: '分享详情',
@@ -340,8 +361,6 @@ let LangZh = {
transfigurationPromptText: '变身提示', transfigurationPromptText: '变身提示',
transfigurationPrompt: transfigurationPrompt:
'您将使用该用户的身份登录。请复制以下链接到其他浏览器访问,在当前浏览器访问会导致当前用户登录信息失效。', '您将使用该用户的身份登录。请复制以下链接到其他浏览器访问,在当前浏览器访问会导致当前用户登录信息失效。',
allUsers: '全部用户',
partialUsers: '局部用户',
searchUser: '搜索用户', searchUser: '搜索用户',
}, },
space: { space: {
@@ -367,6 +386,11 @@ let LangZh = {
memberRoleReadWrite: '可读写', memberRoleReadWrite: '可读写',
memberRoleAdmin: '管理员', memberRoleAdmin: '管理员',
emptyHint: '暂无任何空间', emptyHint: '暂无任何空间',
allSpaces: '全部空间',
partialSpaces: '局部空间',
bin: '空间回收站',
edit: '编辑空间',
delete: '删除空间',
}, },
model: { model: {
usernameRule: '用户名只能包含字母,数字和"_"', usernameRule: '用户名只能包含字母,数字和"_"',
+166 -4
View File
@@ -17,7 +17,12 @@ import ImagePreviewer from '../../../pages/widget/previewer/ImagePreviewer';
import MessageBoxUtil from '../../util/MessageBoxUtil'; import MessageBoxUtil from '../../util/MessageBoxUtil';
import PreviewerHelper from '../../../pages/widget/previewer/PreviewerHelper'; import PreviewerHelper from '../../../pages/widget/previewer/PreviewerHelper';
import HttpBase from '../base/HttpBase'; 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 { export default class Matter extends BaseEntity {
puuid: string = ''; puuid: string = '';
@@ -60,6 +65,17 @@ export default class Matter extends BaseEntity {
//取消上传source //取消上传source
cancelSource: CancelTokenSource = axios.CancelToken.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_CREATE_DIRECTORY = '/api/matter/create/directory';
static URL_MATTER_SOFT_DELETE = '/api/matter/soft/delete'; static URL_MATTER_SOFT_DELETE = '/api/matter/soft/delete';
static URL_MATTER_SOFT_DELETE_BATCH = '/api/matter/soft/delete/batch'; 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_UPLOAD = '/api/matter/upload';
static URL_MATTER_ZIP = '/api/matter/zip'; static URL_MATTER_ZIP = '/api/matter/zip';
static URL_MATTER_CRAWL = '/api/matter/crawl'; static URL_MATTER_CRAWL = '/api/matter/crawl';
static URL_MATTER_SEARCH = '/api/matter/search';
static MATTER_ROOT = 'root'; 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( static httpCrawl(
body: { body: {
url: string; url: string;
@@ -288,7 +327,7 @@ export default class Matter extends BaseEntity {
httpRecovery(successCallback?: any, errorCallback?: any) { httpRecovery(successCallback?: any, errorCallback?: any) {
this.httpPost( this.httpPost(
Matter.URL_MATTER_RECOVERY, Matter.URL_MATTER_RECOVERY,
{ uuid: this.uuid }, { uuid: this.uuid, spaceUuid: this.spaceUuid },
function (response: any) { function (response: any) {
typeof successCallback === 'function' && successCallback(response); typeof successCallback === 'function' && successCallback(response);
}, },
@@ -298,12 +337,13 @@ export default class Matter extends BaseEntity {
static httpRecoveryBatch( static httpRecoveryBatch(
uuids: string, uuids: string,
spaceUuid: string,
successCallback?: any, successCallback?: any,
errorCallback?: any errorCallback?: any
) { ) {
new HttpBase().httpPost( new HttpBase().httpPost(
Matter.URL_MATTER_RECOVERY_BATCH, Matter.URL_MATTER_RECOVERY_BATCH,
{ uuids: uuids }, { uuids, spaceUuid },
function (response: any) { function (response: any) {
typeof successCallback === 'function' && successCallback(response); typeof successCallback === 'function' && successCallback(response);
}, },
@@ -604,7 +644,129 @@ export default class Matter extends BaseEntity {
} }
cancelUpload() { 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; deletedKeepDays: number = 7;
//是否允许自主注册 //是否允许自主注册
allowRegister: boolean = false; allowRegister: boolean = false;
//是否允许分片上传
allowChunkUpload: boolean = false;
//预览配置 //预览配置
previewConfig: PreviewConfig = new PreviewConfig(); previewConfig: PreviewConfig = new PreviewConfig();
//扫描磁盘配置 //扫描磁盘配置
@@ -76,6 +78,7 @@ export default class Preference extends BaseEntity {
defaultTotalSizeLimit: this.defaultTotalSizeLimit, defaultTotalSizeLimit: this.defaultTotalSizeLimit,
deletedKeepDays: this.deletedKeepDays, deletedKeepDays: this.deletedKeepDays,
allowRegister: this.allowRegister, allowRegister: this.allowRegister,
allowChunkUpload: this.allowChunkUpload,
previewConfig: JsonUtil.toJson(this.previewConfig.getForm()), previewConfig: JsonUtil.toJson(this.previewConfig.getForm()),
scanConfig: JsonUtil.toJson(this.scanConfig.getForm()), scanConfig: JsonUtil.toJson(this.scanConfig.getForm()),
}; };
@@ -13,12 +13,11 @@ export default class ScanConfig extends Base {
cron: string | null = null; cron: string | null = null;
// 扫描磁盘开启范围(全部用户 or 局部用户) // 扫描磁盘开启范围(全部用户 or 局部用户)
scope: ScanScopeType = ScanScopeType.ALL; scope: ScanScopeType = ScanScopeType.ALL;
// 开启扫描磁盘功能的用户们 // 开启扫描磁盘功能的空间
usernames: string[] = []; spaceNames: string[] = [];
// 前端辅助字段 // 前端辅助字段
cronType: string | null = null; cronType: string | null = null;
users: LabeledValue[] = [];
constructor() { constructor() {
super(); super();
@@ -33,7 +32,7 @@ export default class ScanConfig extends Base {
enable: this.enable, enable: this.enable,
scope: this.scope, scope: this.scope,
cron: this.cronType === ScanCronType.CUSTOM ? this.cron : this.cronType, 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; [key in keyof typeof ScanScopeType]: SelectionOption;
} = { } = {
ALL: { ALL: {
name: Lang.t('user.allUsers'), name: Lang.t('space.allSpaces'),
value: 'ALL', value: 'ALL',
}, },
CUSTOM: { CUSTOM: {
name: Lang.t('user.partialUsers'), name: Lang.t('space.partialSpaces'),
value: 'CUSTOM', value: 'CUSTOM',
}, },
}; };
+10
View File
@@ -1,6 +1,8 @@
import BaseEntity from '../base/BaseEntity'; import BaseEntity from '../base/BaseEntity';
import User from '../user/User'; import User from '../user/User';
import FileUtil from '../../util/FileUtil'; import FileUtil from '../../util/FileUtil';
import Filter from '../base/filter/Filter';
import InputFilter from '../base/filter/InputFilter';
export interface SpaceFormValues { export interface SpaceFormValues {
name: string; name: string;
@@ -16,6 +18,7 @@ export default class Space extends BaseEntity {
totalSizeLimit: number = 0; totalSizeLimit: number = 0;
userUuid: string | null = null; userUuid: string | null = null;
user: User | null = null; user: User | null = null;
type: 'PRIVATE' | 'SHARED' | null = null;
constructor(reactComponent?: React.Component) { constructor(reactComponent?: React.Component) {
super(reactComponent); super(reactComponent);
@@ -25,6 +28,13 @@ export default class Space extends BaseEntity {
return 'space'; return 'space';
} }
getFilters(): Filter[] {
return [
...super.getFilters(),
new InputFilter('空间名称', 'name'),
new InputFilter('空间类型', 'type'),
];
}
assign(obj: any) { assign(obj: any) {
super.assign(obj); super.assign(obj);
+24 -8
View File
@@ -2,16 +2,9 @@ import BaseEntity from '../../base/BaseEntity';
import User from '../../user/User'; import User from '../../user/User';
import { SpaceMemberRole } from './SpaceMemberRole'; import { SpaceMemberRole } from './SpaceMemberRole';
import Filter from '../../base/filter/Filter'; import Filter from '../../base/filter/Filter';
import SortFilter from '../../base/filter/SortFilter';
import InputFilter from '../../base/filter/InputFilter'; import InputFilter from '../../base/filter/InputFilter';
import SafeUtil from '../../../util/SafeUtil'; import SafeUtil from '../../../util/SafeUtil';
export interface SpaceMemberFormValues {
spaceUuid: string;
userUuid: string;
role: SpaceMemberRole;
uuid: string | null;
}
export default class SpaceMember extends BaseEntity { export default class SpaceMember extends BaseEntity {
spaceUuid: string | null = null; spaceUuid: string | null = null;
userUuid: string | null = null; userUuid: string | null = null;
@@ -40,7 +33,7 @@ export default class SpaceMember extends BaseEntity {
]; ];
} }
getForm(): SpaceMemberFormValues { getForm() {
return { return {
spaceUuid: this.spaceUuid!, spaceUuid: this.spaceUuid!,
userUuid: this.userUuid!, 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) { httpMine(spaceUuid: string, successCallback?: any, errorCallback?: any) {
this.httpGet( this.httpGet(
SpaceMember.URL_SPACE_MEMBER_MINE, 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 InputFilter from '../base/filter/InputFilter';
import SelectionFilter from '../base/filter/SelectionFilter'; import SelectionFilter from '../base/filter/SelectionFilter';
import Space from '../space/Space'; import Space from '../space/Space';
import HttpBase from '../base/HttpBase';
export default class User extends BaseEntity { 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_TOGGLE_STATUS = '/api/user/toggle/status';
static URL_USER_TRANSFIGURATION = '/api/user/transfiguration'; static URL_USER_TRANSFIGURATION = '/api/user/transfiguration';
static URL_USER_SCAN = '/api/user/scan'; static URL_USER_SCAN = '/api/user/scan';
static URL_USER_SEARCH = '/api/user/search';
role: UserRole = UserRole.GUEST; role: UserRole = UserRole.GUEST;
username: string | null = null; username: string | null = null;
@@ -226,8 +228,7 @@ export default class User extends BaseEntity {
oldPassword: string, oldPassword: string,
newPassword: string, newPassword: string,
successCallback?: any, successCallback?: any,
errorCallback?: any, errorCallback?: any
finalCallback?: any
) { ) {
let that = this; let that = this;
this.httpPost( 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( httpAuthenticationLogin(
authentication: string, authentication: string,
successCallback?: any, successCallback?: any,
errorCallback?: any, errorCallback?: any
finalCallback?: any
) { ) {
let that = this; let that = this;
let form = { authentication }; 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( static httpPostFile(
url: string, url: string,
@@ -63,8 +70,10 @@ export default class HttpUtil {
headers: { headers: {
'Content-Type': 'multipart/form-data', 'Content-Type': 'multipart/form-data',
}, },
cancelToken: opts ? opts.cancelToken : '', cancelToken: opts?.cancelToken || undefined,
onUploadProgress: processCallback, onUploadProgress: processCallback,
// Support custom timeout, default to 10 minutes for large file uploads
timeout: opts?.timeout || 10 * 60 * 1000,
}; };
axios 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 exact
component={MatterDetail} component={MatterDetail}
/> />
<Route
path="/space/:spaceUuid/bin/list"
exact
component={BinList}
/>
</div> </div>
) : ( ) : (
<div className="pages-content"> <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 Matter from '../../common/model/matter/Matter';
import Moon from '../../common/model/global/Moon'; import Moon from '../../common/model/global/Moon';
import SortDirection from '../../common/model/base/SortDirection'; 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 MessageBoxUtil from '../../common/util/MessageBoxUtil';
import { import {
CloseCircleOutlined, CloseCircleOutlined,
@@ -19,15 +28,26 @@ import {
import ImagePreviewer from '../widget/previewer/ImagePreviewer'; import ImagePreviewer from '../widget/previewer/ImagePreviewer';
import { UserRole } from '../../common/model/user/UserRole'; import { UserRole } from '../../common/model/user/UserRole';
import Lang from '../../common/model/global/Lang'; import Lang from '../../common/model/global/Lang';
import TankTitle from '../widget/TankTitle';
import BinMatterPanel from './widget/BinMatterPanel'; import BinMatterPanel from './widget/BinMatterPanel';
import Sun from '../../common/model/global/Sun'; 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 {} interface IState {}
export default class List extends TankComponent<IProps, IState> { export default class List extends TankComponent<IProps, IState> {
//当前空间信息
space = new Space();
// 当前用户在当前空间下的成员信息
spaceMember = new SpaceMember();
//当前文件夹信息。 //当前文件夹信息。
matter = new Matter(); matter = new Matter();
//当前选中的文件 //当前选中的文件
@@ -41,7 +61,26 @@ export default class List extends TankComponent<IProps, IState> {
super(props); 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() { componentDidMount() {
this.initSpace();
//刷新一下列表 //刷新一下列表
if (this.user.role === UserRole.ADMINISTRATOR) { if (this.user.role === UserRole.ADMINISTRATOR) {
this.pager.getFilter('userUuid')!.visible = true; this.pager.getFilter('userUuid')!.visible = true;
@@ -73,6 +112,11 @@ export default class List extends TankComponent<IProps, IState> {
this.pager.setFilterValue('orderDir', SortDirection.DESC); 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); this.pager.setFilterValue('deleted', true);
@@ -115,13 +159,20 @@ export default class List extends TankComponent<IProps, IState> {
this.checkMatter(); this.checkMatter();
} }
getSpaceUuid() {
if (this.props.match.params.spaceUuid) {
return this.props.match.params.spaceUuid;
}
return this.user.spaceUuid!;
}
deleteBatch() { deleteBatch() {
Modal.confirm({ Modal.confirm({
title: Lang.t('actionCanNotRevertConfirm'), title: Lang.t('actionCanNotRevertConfirm'),
icon: <ExclamationCircleFilled twoToneColor="#FFDC00" />, icon: <ExclamationCircleFilled twoToneColor="#FFDC00" />,
onOk: () => { onOk: () => {
const uuids = this.selectedMatters.map((i) => i.uuid).toString(); 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')); MessageBoxUtil.success(Lang.t('operationSuccess'));
this.refresh(); this.refresh();
}); });
@@ -135,7 +186,7 @@ export default class List extends TankComponent<IProps, IState> {
icon: <ExclamationCircleFilled twoToneColor="#FFDC00" />, icon: <ExclamationCircleFilled twoToneColor="#FFDC00" />,
onOk: () => { onOk: () => {
const uuids = this.selectedMatters.map((i) => i.uuid).toString(); const uuids = this.selectedMatters.map((i) => i.uuid).toString();
Matter.httpRecoveryBatch(uuids, () => { Matter.httpRecoveryBatch(uuids, this.getSpaceUuid(), () => {
MessageBoxUtil.success(Lang.t('operationSuccess')); MessageBoxUtil.success(Lang.t('operationSuccess'));
this.refresh(); this.refresh();
}); });
@@ -150,6 +201,12 @@ export default class List extends TankComponent<IProps, IState> {
this.pager.setFilterValue('orderDir', SortDirection.DESC); this.pager.setFilterValue('orderDir', SortDirection.DESC);
this.pager.setFilterValue('name', value); this.pager.setFilterValue('name', value);
this.pager.setFilterValue('deleted', true); this.pager.setFilterValue('deleted', true);
if (this.props.match.params.spaceUuid) {
this.pager.setFilterValue(
'spaceUuid',
this.props.match.params.spaceUuid
);
}
this.pager.httpList(); this.pager.httpList();
} else { } else {
this.refresh(); this.refresh();
@@ -182,59 +239,87 @@ export default class List extends TankComponent<IProps, IState> {
} }
goDetail(matter: Matter) { 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() { render() {
const { pager, selectedMatters } = this; const { pager, selectedMatters } = this;
return ( return (
<div className="matter-list"> <div className="page-bin-list">
<TankTitle name={Lang.t('layout.bin')} /> <BreadcrumbPanel breadcrumbModels={this.getBreadcrumbModels()} />
<Row className="mt10"> <Row className="mt10">
<Col xs={24} sm={24} md={14} lg={16}> <Col xs={24} sm={24} md={14} lg={16}>
<Space className="buttons"> <AntdSpace className="buttons">
{selectedMatters.length !== pager.data.length ? ( {this.checkHandlePermission() && (
<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 {selectedMatters.length !== pager.data.length ? (
type="primary" <Button
className="mb10" type="primary"
onClick={() => this.recoverBatch()} className="mb10"
> onClick={() => this.checkAll()}
<RedoOutlined /> >
{Lang.t('matter.recovery')} <PlusSquareOutlined />
</Button> {Lang.t('selectAll')}
<Button </Button>
type="primary" ) : null}
className="mb10" {pager.data.length &&
onClick={() => this.deleteBatch()} selectedMatters.length === pager.data.length ? (
> <Button
<CloseCircleOutlined /> type="primary"
{Lang.t('matter.hardDelete')} className="mb10"
</Button> 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 <Button
type="primary" type="primary"
@@ -244,7 +329,7 @@ export default class List extends TankComponent<IProps, IState> {
<SyncOutlined /> <SyncOutlined />
{Lang.t('refresh')} {Lang.t('refresh')}
</Button> </Button>
</Space> </AntdSpace>
</Col> </Col>
<Col xs={24} sm={24} md={10} lg={8}> <Col xs={24} sm={24} md={10} lg={8}>
<Input.Search <Input.Search
@@ -262,7 +347,13 @@ export default class List extends TankComponent<IProps, IState> {
pager.data.map((matter) => ( pager.data.map((matter) => (
<BinMatterPanel <BinMatterPanel
key={matter.uuid} key={matter.uuid}
mode={this.props.match.params.spaceUuid ? 'space' : 'normal'}
matter={matter} matter={matter}
spaceMemberRole={
this.props.match.params.spaceUuid
? this.spaceMember.role!
: undefined
}
onDeleteSuccess={() => this.refresh()} onDeleteSuccess={() => this.refresh()}
onRecoverySuccess={() => this.refresh()} onRecoverySuccess={() => this.refresh()}
onCheckMatter={(m) => this.checkMatter(m)} 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 { CheckboxChangeEvent } from 'antd/es/checkbox';
import SafeUtil from '../../../common/util/SafeUtil'; import SafeUtil from '../../../common/util/SafeUtil';
import Lang from '../../../common/model/global/Lang'; import Lang from '../../../common/model/global/Lang';
import { SpaceMemberRole } from '../../../common/model/space/member/SpaceMemberRole';
interface IProps { interface IProps {
matter: Matter; matter: Matter;
mode: 'normal' | 'space'; // normal:正常模式,space:空间模式
spaceMemberRole?: SpaceMemberRole; // 用户在当前空间下的角色,只有在space模式下才有值
onDeleteSuccess: () => any; onDeleteSuccess: () => any;
onRecoverySuccess: () => any; onRecoverySuccess: () => any;
onCheckMatter: (matter?: Matter) => any; onCheckMatter: (matter?: Matter) => any;
@@ -83,20 +86,20 @@ export default class BinMatterPanel extends TankComponent<IProps, IState> {
this.updateUI(); this.updateUI();
} }
// 如果在空间下,有些操作权限只对管理员和读写成员开放
checkHandlePermission() {
if (this.props.mode === 'normal') return true;
return [SpaceMemberRole.ADMIN, SpaceMemberRole.READ_WRITE].includes(
this.props.spaceMemberRole!
);
}
renderPcOperation() { renderPcOperation() {
const { matter } = this.props; const { matter } = this.props;
return ( return (
<div className="right-part"> <div className="right-part">
<span className="matter-operation text-theme"> <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')}> <Tooltip title={Lang.t('matter.fileDetail')}>
<InfoCircleOutlined <InfoCircleOutlined
className="btn-action" className="btn-action"
@@ -107,14 +110,26 @@ export default class BinMatterPanel extends TankComponent<IProps, IState> {
} }
/> />
</Tooltip> </Tooltip>
<Tooltip title={Lang.t('matter.hardDelete')}> {this.checkHandlePermission() && (
<CloseCircleOutlined <>
className="btn-action text-danger" <Tooltip title={Lang.t('matter.recovery')}>
onClick={(e) => <RedoOutlined
SafeUtil.stopPropagationWrap(e)(this.hardDeleteMatter()) className="btn-action"
} onClick={(e) =>
/> SafeUtil.stopPropagationWrap(e)(this.recoveryMatter())
</Tooltip> }
/>
</Tooltip>
<Tooltip title={Lang.t('matter.hardDelete')}>
<CloseCircleOutlined
className="btn-action text-danger"
onClick={(e) =>
SafeUtil.stopPropagationWrap(e)(this.hardDeleteMatter())
}
/>
</Tooltip>
</>
)}
</span> </span>
<Tooltip title={Lang.t('matter.size')}> <Tooltip title={Lang.t('matter.size')}>
<span className="matter-size"> <span className="matter-size">
@@ -131,7 +146,7 @@ export default class BinMatterPanel extends TankComponent<IProps, IState> {
} }
getHandles() { getHandles() {
return [ const handles = [
<div <div
className="cell-btn navy" className="cell-btn navy"
onClick={(e) => onClick={(e) =>
@@ -143,23 +158,31 @@ export default class BinMatterPanel extends TankComponent<IProps, IState> {
<InfoCircleOutlined className="btn-action mr5" /> <InfoCircleOutlined className="btn-action mr5" />
{Lang.t('matter.fileDetail')} {Lang.t('matter.fileDetail')}
</div>, </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() { renderMobileOperation() {
+19 -4
View File
@@ -15,6 +15,8 @@ import Lang from '../../common/model/global/Lang';
import InfoCell from '../widget/InfoCell'; import InfoCell from '../widget/InfoCell';
import TankContentCard from '../widget/TankContentCard'; import TankContentCard from '../widget/TankContentCard';
import Color from '../../common/model/base/option/Color'; 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 { interface RouteParam {
spaceUuid?: string; spaceUuid?: string;
@@ -27,6 +29,8 @@ interface IState {}
export default class Detail extends TankComponent<IProps, IState> { export default class Detail extends TankComponent<IProps, IState> {
matter = new Matter(); matter = new Matter();
// 当前用户在当前空间下的成员信息
spaceMember = new SpaceMember();
downloadToken = new DownloadToken(); downloadToken = new DownloadToken();
constructor(props: IProps) { constructor(props: IProps) {
@@ -44,6 +48,9 @@ export default class Detail extends TankComponent<IProps, IState> {
this.downloadToken.httpFetchDownloadToken(this.matter.uuid!); this.downloadToken.httpFetchDownloadToken(this.matter.uuid!);
} }
}); });
if (spaceUuid) {
this.spaceMember.httpMine(spaceUuid, () => this.updateUI());
}
} }
copyLink() { copyLink() {
@@ -61,10 +68,18 @@ export default class Detail extends TankComponent<IProps, IState> {
this.matter.httpDetail(() => { this.matter.httpDetail(() => {
this.updateUI(); 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() { render() {
const { matter } = this; const { matter } = this;
return ( return (
@@ -123,7 +138,7 @@ export default class Detail extends TankComponent<IProps, IState> {
? Lang.t('matter.oneTimeLink') ? Lang.t('matter.oneTimeLink')
: Lang.t('matter.copyPath')} : Lang.t('matter.copyPath')}
</a> </a>
{matter.deleted ? ( {matter.deleted && this.checkHandlePermission() ? (
<a onClick={() => this.recovery()}> <a onClick={() => this.recovery()}>
{Lang.t('matter.recovery')} {Lang.t('matter.recovery')}
</a> </a>
@@ -132,13 +147,13 @@ export default class Detail extends TankComponent<IProps, IState> {
</InfoCell> </InfoCell>
</> </>
) : null} ) : null}
{matter.dir && matter.deleted && ( {matter.dir && matter.deleted && this.checkHandlePermission() ? (
<InfoCell name={Lang.t('matter.operations')}> <InfoCell name={Lang.t('matter.operations')}>
<a onClick={() => this.recovery()}> <a onClick={() => this.recovery()}>
{Lang.t('matter.recovery')} {Lang.t('matter.recovery')}
</a> </a>
</InfoCell> </InfoCell>
)} ) : null}
</div> </div>
</TankContentCard> </TankContentCard>
</div> </div>
+5 -2
View File
@@ -24,7 +24,10 @@
display: flex; display: flex;
flex-wrap: wrap; 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, Row,
Space as AntdSpace, Space as AntdSpace,
Upload, Upload,
List as AntdList,
} from 'antd'; } from 'antd';
import MessageBoxUtil from '../../common/util/MessageBoxUtil'; import MessageBoxUtil from '../../common/util/MessageBoxUtil';
import { import {
@@ -33,6 +34,7 @@ import {
MenuOutlined, MenuOutlined,
MinusSquareOutlined, MinusSquareOutlined,
PlusSquareOutlined, PlusSquareOutlined,
SearchOutlined,
ShareAltOutlined, ShareAltOutlined,
SyncOutlined, SyncOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
@@ -56,6 +58,7 @@ import Space from '../../common/model/space/Space';
import MatterCrawlModal from './widget/MatterCrawlModal'; import MatterCrawlModal from './widget/MatterCrawlModal';
import SpaceMember from '../../common/model/space/member/SpaceMember'; import SpaceMember from '../../common/model/space/member/SpaceMember';
import { SpaceMemberRole } from '../../common/model/space/member/SpaceMemberRole'; import { SpaceMemberRole } from '../../common/model/space/member/SpaceMemberRole';
import { debounce } from '../../common/util/OptimizeUtil';
interface IProps { interface IProps {
spaceUuid?: string; spaceUuid?: string;
@@ -76,8 +79,6 @@ export default class List extends TankComponent<IProps, IState> {
//当前选中的文件 //当前选中的文件
selectedMatters: Matter[] = []; selectedMatters: Matter[] = [];
//搜索的文字
searchText: string | null = null;
//获取分页的一个帮助器 //获取分页的一个帮助器
pager = new Pager<Matter>(this, Matter, Pager.MAX_PAGE_SIZE); pager = new Pager<Matter>(this, Matter, Pager.MAX_PAGE_SIZE);
user = Moon.getSingleton().user; user = Moon.getSingleton().user;
@@ -111,6 +112,21 @@ export default class List extends TankComponent<IProps, IState> {
// 抓取模态窗状态 // 抓取模态窗状态
crawlModalVisible = false; crawlModalVisible = false;
// 搜索
searchState: {
keyword?: string;
loading: boolean;
data: Matter[];
} = {
loading: false,
data: [],
};
// 当前是否有关键词是搜索状态,逻辑上有不一样
isSearch() {
return !!this.searchState.keyword;
}
//持有全局唯一的实例。 //持有全局唯一的实例。
static instance: List | null = null; static instance: List | null = null;
@@ -203,6 +219,7 @@ export default class List extends TankComponent<IProps, IState> {
componentWillReceiveProps() { componentWillReceiveProps() {
this.pager.enableHistory(); this.pager.enableHistory();
this.resetSearch();
this.refresh(); this.refresh();
this.refreshBreadcrumbs(); this.refreshBreadcrumbs();
} }
@@ -218,8 +235,15 @@ export default class List extends TankComponent<IProps, IState> {
refresh() { refresh() {
// 清空暂存区 // 清空暂存区
this.selectedMatters = []; this.selectedMatters = [];
// 刷新文件列表
this.refreshPager(); if (this.isSearch()) {
// 刷新搜索列表
console.log('search this2', this.searchState.keyword);
this.refreshSearch(this.searchState.keyword);
} else {
// 刷新分页列表
this.refreshPager();
}
} }
refreshPager() { refreshPager() {
@@ -244,6 +268,15 @@ export default class List extends TankComponent<IProps, IState> {
this.pager.httpList(); this.pager.httpList();
} }
getList() {
if (this.isSearch()) return this.searchState;
return this.pager;
}
getListData() {
return this.getList().data;
}
checkMatter(matter?: Matter) { checkMatter(matter?: Matter) {
if (matter) { if (matter) {
if (matter.check) { if (matter.check) {
@@ -257,7 +290,7 @@ export default class List extends TankComponent<IProps, IState> {
} else { } else {
//统计所有的勾选 //统计所有的勾选
this.selectedMatters = []; this.selectedMatters = [];
this.pager.data.forEach((matter) => { this.getListData().forEach((matter) => {
if (matter.check) { if (matter.check) {
this.selectedMatters.push(matter); this.selectedMatters.push(matter);
} }
@@ -267,14 +300,14 @@ export default class List extends TankComponent<IProps, IState> {
} }
checkAll() { checkAll() {
this.pager.data.forEach((i) => { this.getListData().forEach((i) => {
i.check = true; i.check = true;
}); });
this.checkMatter(); this.checkMatter();
} }
checkNone() { checkNone() {
this.pager.data.forEach((i) => { this.getListData().forEach((i) => {
i.check = false; i.check = false;
}); });
this.checkMatter(); this.checkMatter();
@@ -283,7 +316,6 @@ export default class List extends TankComponent<IProps, IState> {
deleteBatch() { deleteBatch() {
const uuids = this.selectedMatters.map((i) => i.uuid).toString(); const uuids = this.selectedMatters.map((i) => i.uuid).toString();
MatterDeleteModal.open( MatterDeleteModal.open(
!!this.props.spaceUuid,
() => { () => {
Matter.httpSoftDeleteBatch(uuids, this.getSpaceUuid(), () => { Matter.httpSoftDeleteBatch(uuids, this.getSpaceUuid(), () => {
MessageBoxUtil.success(Lang.t('operationSuccess')); MessageBoxUtil.success(Lang.t('operationSuccess'));
@@ -440,7 +472,8 @@ export default class List extends TankComponent<IProps, IState> {
} }
} }
m.file = file; m.file = file;
m.httpUpload( // Use smart upload: automatically choose chunked upload for large files
m.httpSmartUpload(
() => { () => {
const index = this.uploadMattersMap[this.getSpaceUuid()].findIndex( const index = this.uploadMattersMap[this.getSpaceUuid()].findIndex(
(matter) => matter === m (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) { changePage(page: number) {
this.pager.page = page - 1; // page的页数0基 this.pager.page = page - 1; // page的页数0基
this.pager.httpList(); this.pager.httpList();
@@ -510,7 +533,7 @@ export default class List extends TankComponent<IProps, IState> {
previewImage(matter: Matter) { previewImage(matter: Matter) {
let imageArray: string[] = []; let imageArray: string[] = [];
let startIndex = -1; let startIndex = -1;
this.pager.data.forEach((item) => { this.getListData().forEach((item) => {
if (item.isImage()) { if (item.isImage()) {
imageArray.push(item.getPreviewUrl()); imageArray.push(item.getPreviewUrl());
if (item.uuid === matter.uuid) { if (item.uuid === matter.uuid) {
@@ -546,9 +569,7 @@ export default class List extends TankComponent<IProps, IState> {
} }
goToDirectory(id: string) { goToDirectory(id: string) {
this.searchText = null;
this.pager.setFilterValue('puuid', id); this.pager.setFilterValue('puuid', id);
this.pager.removeFilter('name');
this.pager.page = 0; this.pager.page = 0;
const query = this.pager.getParams(); const query = this.pager.getParams();
Sun.navigateQueryTo({ path: this.getMatterListPath(), query }); 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() { render() {
const { pager, director, selectedMatters, dragEnterCount } = this; const { pager, director, selectedMatters, dragEnterCount } = this;
return ( return (
@@ -657,7 +724,7 @@ export default class List extends TankComponent<IProps, IState> {
<Row> <Row>
<Col xs={24} sm={24} md={14} lg={16} className="mt10"> <Col xs={24} sm={24} md={14} lg={16} className="mt10">
<AntdSpace className="buttons"> <AntdSpace className="buttons">
{selectedMatters.length !== pager.data.length ? ( {selectedMatters.length !== this.getListData().length ? (
<Button <Button
type="primary" type="primary"
className="mb10" className="mb10"
@@ -667,8 +734,8 @@ export default class List extends TankComponent<IProps, IState> {
{Lang.t('selectAll')} {Lang.t('selectAll')}
</Button> </Button>
) : null} ) : null}
{pager.data.length && {this.getListData().length &&
selectedMatters.length === pager.data.length ? ( selectedMatters.length === this.getListData().length ? (
<Button <Button
type="primary" type="primary"
className="mb10" className="mb10"
@@ -680,14 +747,16 @@ export default class List extends TankComponent<IProps, IState> {
) : null} ) : null}
{selectedMatters.length ? ( {selectedMatters.length ? (
<> <>
<Button {!this.isSearch() && (
type="primary" <Button
className="mb10" type="primary"
onClick={() => this.downloadZip()} className="mb10"
> onClick={() => this.downloadZip()}
<DownloadOutlined /> >
{Lang.t('download')} <DownloadOutlined />
</Button> {Lang.t('download')}
</Button>
)}
{this.checkHandlePermission() && ( {this.checkHandlePermission() && (
<> <>
<Button <Button
@@ -710,7 +779,7 @@ export default class List extends TankComponent<IProps, IState> {
)} )}
{/*共享空间下暂不支持分享功能*/} {/*共享空间下暂不支持分享功能*/}
{!this.isInSpace && ( {!this.isInSpace && !this.isSearch() && (
<Button <Button
type="primary" type="primary"
className="mb10" className="mb10"
@@ -723,7 +792,7 @@ export default class List extends TankComponent<IProps, IState> {
</> </>
) : null} ) : null}
{this.checkHandlePermission() && ( {this.checkHandlePermission() && !this.isSearch() && (
<> <>
<Upload <Upload
className="ant-upload" className="ant-upload"
@@ -779,24 +848,29 @@ export default class List extends TankComponent<IProps, IState> {
</AntdSpace> </AntdSpace>
</Col> </Col>
<Col xs={24} sm={24} md={10} lg={8} className="mt10"> <Col xs={24} sm={24} md={10} lg={8} className="mt10">
<Input.Search <Input
className="mb10" className="mb10"
placeholder={Lang.t('matter.searchFile')} placeholder={Lang.t('matter.searchFile')}
onSearch={(value) => this.searchFile(value)} prefix={<SearchOutlined />}
onChange={(e) => this.changeSearch(e)} value={this.searchState.keyword}
enterButton onChange={(e) => this.handleChangeSearch(e.target.value)}
allowClear
/> />
</Col> </Col>
</Row> </Row>
{Children.toArray( {Children.toArray(
this.uploadMattersMap[this.getSpaceUuid()]?.map((m) => ( this.uploadMattersMap[this.getSpaceUuid()]?.map((m) => (
<UploadMatterPanel matter={m} /> <UploadMatterPanel key={m.autoId} matter={m} />
)) ))
)} )}
{pager.data.length ? ( {this.getListData().length ? (
<MatterSortPanel pager={pager} refresh={() => this.refresh()} /> <MatterSortPanel
pager={pager}
refresh={() => this.refresh()}
disabled={this.isSearch()}
/>
) : null} ) : null}
{director.createMode ? ( {director.createMode ? (
@@ -809,35 +883,44 @@ export default class List extends TankComponent<IProps, IState> {
/> />
) : null} ) : null}
<div> <div>
{pager.loading || pager.data.length ? ( {this.getList().loading || this.getList().data.length > 0 ? (
pager.data.map((matter) => ( <AntdList
<MatterPanel rowKey="uuid"
mode={this.isInSpace ? 'space' : 'normal'} className="matter-list-content"
spaceMemberRole={ loading={this.getList().loading}
this.isInSpace ? this.spaceMember.role! : undefined dataSource={this.getList().data}
} renderItem={(matter) => (
key={matter.uuid!} <MatterPanel
director={director} mode={this.isInSpace ? 'space' : 'normal'}
matter={matter} spaceMemberRole={
onGoToDirectory={(id) => this.goToDirectory(id)} this.isInSpace ? this.spaceMember.role! : undefined
onDeleteSuccess={() => this.pagerAndCapacityRefresh()} }
onCheckMatter={(m) => this.checkMatter(m)} key={matter.uuid}
onPreviewImage={(m) => this.previewImage(m)} director={director}
onGoDetail={(m) => this.goDetail(m)} 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')} /> <Empty description={Lang.t('matter.noContentYet')} />
)} )}
</div> </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 && ( {this.crawlModalVisible && (
<MatterCrawlModal <MatterCrawlModal
spaceUuid={this.getSpaceUuid()} spaceUuid={this.getSpaceUuid()}
+10 -13
View File
@@ -8,7 +8,6 @@ import './MatterDeleteModal.less';
import Preference from '../../../common/model/preference/Preference'; import Preference from '../../../common/model/preference/Preference';
interface IProps { interface IProps {
isSpace: boolean; // 是否是空间,空间下暂不展示放入回收站逻辑
onSoftDel: () => void; onSoftDel: () => void;
onHardDel: () => void; onHardDel: () => void;
onClose: () => void; onClose: () => void;
@@ -23,7 +22,7 @@ export default class MatterDeleteModal extends TankComponent<IProps, IState> {
static preference: Preference = Moon.getSingleton().preference; static preference: Preference = Moon.getSingleton().preference;
static open(isSpace: boolean, onSoftDel: () => void, onHardDel: () => void) { static open(onSoftDel: () => void, onHardDel: () => void) {
let modal = Modal.warning({ let modal = Modal.warning({
className: 'delete-modal custom-handle-modal', className: 'delete-modal custom-handle-modal',
title: Lang.t('delete'), title: Lang.t('delete'),
@@ -31,7 +30,6 @@ export default class MatterDeleteModal extends TankComponent<IProps, IState> {
okCancel: false, okCancel: false,
content: ( content: (
<MatterDeleteModal <MatterDeleteModal
isSpace={isSpace}
onSoftDel={() => { onSoftDel={() => {
onSoftDel(); onSoftDel();
modal.destroy(); modal.destroy();
@@ -59,16 +57,15 @@ export default class MatterDeleteModal extends TankComponent<IProps, IState> {
<Button type="primary" danger onClick={this.props.onHardDel}> <Button type="primary" danger onClick={this.props.onHardDel}>
{Lang.t('deleteDirectly')} {Lang.t('deleteDirectly')}
</Button> </Button>
{!this.props.isSpace && {MatterDeleteModal.preference.getRecycleBinStatus() && (
MatterDeleteModal.preference.getRecycleBinStatus() && ( <Button
<Button type="primary"
type="primary" className="ml10"
className="ml10" onClick={this.props.onSoftDel}
onClick={this.props.onSoftDel} >
> {Lang.t('matter.intoRecycleBin')}
{Lang.t('matter.intoRecycleBin')} </Button>
</Button> )}
)}
</div> </div>
</div> </div>
); );
+22 -14
View File
@@ -101,7 +101,6 @@ export default class MatterPanel extends TankComponent<IProps, IState> {
deleteMatter() { deleteMatter() {
MatterDeleteModal.open( MatterDeleteModal.open(
this.props.mode === 'space',
() => { () => {
this.props.matter.httpSoftDelete(() => { this.props.matter.httpSoftDelete(() => {
MessageBoxUtil.success(Lang.t('operationSuccess')); MessageBoxUtil.success(Lang.t('operationSuccess'));
@@ -274,12 +273,17 @@ export default class MatterPanel extends TankComponent<IProps, IState> {
/> />
</Tooltip> </Tooltip>
)} )}
<Tooltip title={Lang.t('matter.copyPath')}> {/*文件夹不支持复制路径 安全问题*/}
<LinkOutlined {!matter.dir && (
className="btn-action" <Tooltip title={Lang.t('matter.copyPath')}>
onClick={(e) => SafeUtil.stopPropagationWrap(e)(this.clipboard())} <LinkOutlined
/> className="btn-action"
</Tooltip> onClick={(e) =>
SafeUtil.stopPropagationWrap(e)(this.clipboard())
}
/>
</Tooltip>
)}
<Tooltip title={Lang.t('matter.download')}> <Tooltip title={Lang.t('matter.download')}>
<DownloadOutlined <DownloadOutlined
className="btn-action" className="btn-action"
@@ -340,13 +344,17 @@ export default class MatterPanel extends TankComponent<IProps, IState> {
</div>, </div>,
] ]
: []), : []),
<div ...(matter.dir
className="cell-btn navy" ? []
onClick={(e) => SafeUtil.stopPropagationWrap(e)(this.clipboard())} : [
> <div
<LinkOutlined className="btn-action mr5" /> className="cell-btn navy"
{Lang.t('matter.copyLink')} onClick={(e) => SafeUtil.stopPropagationWrap(e)(this.clipboard())}
</div>, >
<LinkOutlined className="btn-action mr5" />
{Lang.t('matter.copyLink')}
</div>,
]),
<div <div
className="cell-btn navy" className="cell-btn navy"
onClick={(e) => SafeUtil.stopPropagationWrap(e)(matter.download())} onClick={(e) => SafeUtil.stopPropagationWrap(e)(matter.download())}
@@ -6,6 +6,9 @@
margin: 0; margin: 0;
line-height: 48px; line-height: 48px;
color: #999; color: #999;
&-disabled {
pointer-events: none;
}
.sort-part { .sort-part {
&:hover { &:hover {
+7 -2
View File
@@ -9,6 +9,7 @@ import Pager from '../../../common/model/base/Pager';
interface IProps { interface IProps {
pager: Pager<Matter>; pager: Pager<Matter>;
refresh: () => any; refresh: () => any;
disabled?: boolean;
} }
interface IState {} interface IState {}
@@ -38,10 +39,14 @@ export default class MatterSortPanel extends TankComponent<IProps, IState> {
} }
render() { render() {
const { pager } = this.props; const { pager, disabled } = this.props;
return ( return (
<div className="matter-sort-panel"> <div
className={`matter-sort-panel ${
disabled ? 'matter-sort-panel-disabled' : ''
}`}
>
<div <div
className="sort-part sort-part-name" className="sort-part sort-part-name"
onClick={() => this.changeFilterOrder('orderName')} onClick={() => this.changeFilterOrder('orderName')}
+63 -2
View File
@@ -1,5 +1,4 @@
.upload-matter-panel { .upload-matter-panel {
.huge-block { .huge-block {
background-color: white; background-color: white;
border-radius: 5px; border-radius: 5px;
@@ -15,6 +14,8 @@
.media { .media {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center;
margin-bottom: 5px;
.media-body { .media-body {
cursor: pointer; cursor: pointer;
@@ -23,9 +24,69 @@
font-weight: bold; font-weight: bold;
white-space: nowrap; white-space: nowrap;
text-overflow: ellipsis; 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 TankComponent from '../../../common/component/TankComponent';
import Matter from '../../../common/model/matter/Matter'; import Matter from '../../../common/model/matter/Matter';
import StringUtil from '../../../common/util/StringUtil'; import StringUtil from '../../../common/util/StringUtil';
import { Progress } from 'antd'; import { Progress, Button, Space, Tooltip } from 'antd';
import { CloseOutlined } from '@ant-design/icons'; import {
CloseOutlined,
PauseCircleOutlined,
PlayCircleOutlined,
CloudUploadOutlined,
SyncOutlined,
} from '@ant-design/icons';
import './UploadMatterPanel.less'; import './UploadMatterPanel.less';
import Lang from '../../../common/model/global/Lang'; import Lang from '../../../common/model/global/Lang';
import { UploadSessionStatus } from '../../../common/model/chunk/ChunkUploader';
interface IProps { interface IProps {
matter: Matter; matter: Matter;
@@ -33,38 +40,157 @@ export default class UploadMatterPanel extends TankComponent<IProps, IState> {
this.props.matter.reactComponent = null; 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() { render() {
const { matter } = this.props; const { matter } = this.props;
const statusText = this.getStatusText(matter);
const progressStatus = this.getProgressStatus(matter);
const strokeColor = this.getStrokeColor(matter);
return ( return (
<div className="upload-matter-panel"> <div className="upload-matter-panel">
{matter.loading ? ( {matter.loading ? (
<div className="huge-block clearfix"> <div className="huge-block clearfix">
<div className="media"> <div className="media">
<div className="media-body">{matter.file!.name}</div> <div className="media-body">
<div className="media-cancel"> <span className="filename">{matter.file!.name}</span>
<CloseOutlined onClick={() => matter.cancelUpload()} /> </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>
</div> </div>
<Progress <Progress
className="progress" className="progress"
strokeColor={{ strokeColor={strokeColor}
from: '#108ee9',
to: '#87d068',
}}
format={() => this.format(matter.progress * 100)} format={() => this.format(matter.progress * 100)}
percent={matter.progress * 100} percent={matter.progress * 100}
status="active" status={progressStatus}
/> />
<div> <div className="upload-info">
{`${Lang.t('matter.uploaded')}: ${StringUtil.humanFileSize( <span className="uploaded-size">
matter.file!.size * matter.progress {`${Lang.t('matter.uploaded')}: ${StringUtil.humanFileSize(
)}/${StringUtil.humanFileSize(matter.file!.size)} ${ matter.file!.size * matter.progress
matter.progress < 1 )}/${StringUtil.humanFileSize(matter.file!.size)}`}
? `${Lang.t('matter.speed')}:${StringUtil.humanFileSize( </span>
{matter.progress < 1 &&
matter.uploadStatus === UploadSessionStatus.UPLOADING && (
<span className="upload-speed">
{` ${Lang.t('matter.speed')}: ${StringUtil.humanFileSize(
matter.speed matter.speed
)}s` )}/s`}
: Lang.t('matter.finishingTip') </span>
}`} )}
{statusText && (
<span className="status-text">{` - ${statusText}`}</span>
)}
</div> </div>
</div> </div>
) : null} ) : null}
+19 -3
View File
@@ -5,8 +5,8 @@ import TankComponent from '../../common/component/TankComponent';
import User from '../../common/model/user/User'; import User from '../../common/model/user/User';
import Moon from '../../common/model/global/Moon'; import Moon from '../../common/model/global/Moon';
import TankTitle from '../widget/TankTitle'; import TankTitle from '../widget/TankTitle';
import { Button, Form, Input, InputNumber, Switch } from 'antd'; import { Button, Form, Input, InputNumber, Switch, Tooltip } from 'antd';
import { SaveOutlined } from '@ant-design/icons'; import { QuestionCircleOutlined, SaveOutlined } from '@ant-design/icons';
import TankContentCard from '../widget/TankContentCard'; import TankContentCard from '../widget/TankContentCard';
import Preference from '../../common/model/preference/Preference'; import Preference from '../../common/model/preference/Preference';
import { FormInstance } from 'antd/lib/form'; import { FormInstance } from 'antd/lib/form';
@@ -191,7 +191,23 @@ export default class Edit extends TankComponent<IProps, IState> {
<Form.Item <Form.Item
label={Lang.t('preference.allowRegister')} label={Lang.t('preference.allowRegister')}
name="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" valuePropName="checked"
> >
<Switch /> <Switch />
+18 -4
View File
@@ -5,9 +5,10 @@ import TankComponent from '../../common/component/TankComponent';
import User from '../../common/model/user/User'; import User from '../../common/model/user/User';
import Moon from '../../common/model/global/Moon'; import Moon from '../../common/model/global/Moon';
import TankTitle from '../widget/TankTitle'; import TankTitle from '../widget/TankTitle';
import { Button, Divider, Empty, message } from 'antd'; import { Button, Divider, Empty, message, Tooltip } from 'antd';
import { import {
EditOutlined, EditOutlined,
QuestionCircleOutlined,
ScanOutlined, ScanOutlined,
ThunderboltOutlined, ThunderboltOutlined,
} from '@ant-design/icons'; } 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')} {this.preference.allowRegister ? Lang.t('yes') : Lang.t('no')}
</InfoCell> </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')}> <InfoCell name={Lang.t('preference.docLink')}>
<a href={'https://tank-doc.eyeblue.cn/zh'} target="_blank"> <a href={'https://tank-doc.eyeblue.cn/zh'} target="_blank">
https://tank-doc.eyeblue.cn/zh https://tank-doc.eyeblue.cn/zh
@@ -221,7 +235,7 @@ export default class Index extends TankComponent<IProps, IState> {
icon={<ScanOutlined />} icon={<ScanOutlined />}
onClick={() => this.immediateScan()} onClick={() => this.immediateScan()}
> >
立即扫描 {Lang.t('preference.scanImmediately')}
</Button> </Button>
) : ( ) : (
'' ''
@@ -244,8 +258,8 @@ export default class Index extends TankComponent<IProps, IState> {
{ScanScopeTypeMap[scanConfig.scope].name} {ScanScopeTypeMap[scanConfig.scope].name}
</InfoCell> </InfoCell>
{scanConfig.scope === ScanScopeType.CUSTOM && ( {scanConfig.scope === ScanScopeType.CUSTOM && (
<InfoCell name={Lang.t('preference.scanUsers')}> <InfoCell name={Lang.t('preference.scanSpaces')}>
{scanConfig.usernames.join(', ')} {scanConfig.spaceNames.join(', ')}
</InfoCell> </InfoCell>
)} )}
</> </>
+186 -193
View File
@@ -1,215 +1,208 @@
import React from 'react'; import React, { useEffect, useRef, useState } 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 Lang from '../../common/model/global/Lang'; import Lang from '../../common/model/global/Lang';
import TankTitle from '../widget/TankTitle';
import TankContentCard from '../widget/TankContentCard'; 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 { Button, Form, Input, Select, Spin, Switch } from 'antd';
import { FormInstance } from 'antd/lib/form';
import { SaveOutlined } from '@ant-design/icons'; 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 { import {
ScanCronType, ScanCronType,
ScanCronTypeList, ScanCronTypeList,
ScanCronTypeValueList, ScanCronTypeValueList,
} from '../../common/model/preference/model/ScanCronType'; } 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 Pager from '../../common/model/base/Pager';
import User from '../../common/model/user/User';
import SortDirection from '../../common/model/base/SortDirection'; 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 {} const handleFinish = async () => {
const values = await form.validateFields();
export default class ScanEdit extends TankComponent<IProps, IState> { preference.current.scanConfig.assign(values);
formRef = React.createRef<FormInstance>(); preference.current.httpSaveScan(function () {
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 () {
MessageBoxUtil.success(Lang.t('operationSuccess')); MessageBoxUtil.success(Lang.t('operationSuccess'));
Sun.updateFrame(); Sun.updateFrame();
Sun.navigateTo('/preference/index'); Sun.navigateTo('/preference/index');
}); });
} };
changeEnableScan(value: boolean) { const handleRefreshSpace = debounce((keyword?: string) => {
this.preference.scanConfig.enable = value; pager.current.setFilterValue('orderCreateTime', SortDirection.DESC);
this.updateUI(); pager.current.setFilterValue('name', keyword);
} pager.current.httpList(() => setSpaceList(pager.current.data));
}, 200);
changeCronType(value: ScanCronType) { useEffect(() => {
this.preference.scanConfig.cronType = value; preference.current.detailLoading = true;
this.updateUI(); 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) { return (
this.preference.scanConfig.scope = value; <div className="preference-scan-edit">
this.updateUI(); <TankTitle name={Lang.t('preference.editScan')} />
} <TankContentCard loading={loading}>
<Form
fetchUser(value: any) { {...layout}
this.pager.resetFilter(); form={form}
this.pager.setFilterValue('orderCreateTime', SortDirection.DESC); initialValues={preference.current.scanConfig}
this.pager.setFilterValue('username', value); onFinish={handleFinish}
this.pager.httpList(); >
} <Form.Item
label={Lang.t('preference.enableScan')}
render() { valuePropName="checked"
const { preference, pager } = this; name="enable"
required
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}
> >
<Form.Item <Switch />
label={Lang.t('preference.enableScan')} </Form.Item>
valuePropName="checked"
name="enable" <Form.Item dependencies={['enable']} noStyle>
> {({ getFieldValue }) => {
<Switch onChange={this.changeEnableScan.bind(this)} /> return (
</Form.Item> getFieldValue('enable') && (
{preference.scanConfig.enable && ( <>
<> <Form.Item
<Form.Item label={Lang.t('preference.scanCron')}
label={Lang.t('preference.scanCron')} name="cronType"
name="cronType" required
> rules={[
<Select onChange={this.changeCronType.bind(this)}> {
{ScanCronTypeList.map((option) => ( required: true,
<Select.Option key={option.value} value={option.value}> message: Lang.t('preference.scanCronValidate'),
{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%' }}
> >
{pager.data.map((user: User) => ( <Select>
<Select.Option {ScanCronTypeList.map((option) => (
key={user.username!} <Select.Option
value={user.username!} key={option.value}
> value={option.value}
{user.username} >
</Select.Option> {option.name}
))} </Select.Option>
</Select> ))}
</Form.Item> </Select>
)} </Form.Item>
</>
)} <Form.Item dependencies={['cronType']} noStyle>
<div className="text-right"> {({ getFieldValue }) => {
<Button type="primary" htmlType="submit" icon={<SaveOutlined />}> return (
{Lang.t('save')} getFieldValue('cronType') === ScanCronType.CUSTOM && (
</Button> <Form.Item
</div> label={Lang.t('preference.cron')}
</Form> name="cron"
</TankContentCard> rules={[
</div> {
); 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.share.uuid = this.props.match.params.uuid;
this.pager.urlPage = Share.URL_MATTER_PAGE; 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.pager.enableHistory();
this.refresh(); this.refresh();
} }
+2
View File
@@ -9,6 +9,7 @@ import ShareBar from './widget/ShareBar';
import './List.less'; import './List.less';
import Lang from '../../common/model/global/Lang'; import Lang from '../../common/model/global/Lang';
import SortDirection from '../../common/model/base/SortDirection';
interface IProps extends RouteComponentProps {} interface IProps extends RouteComponentProps {}
@@ -23,6 +24,7 @@ export default class List extends TankComponent<IProps, IState> {
componentDidMount() { componentDidMount() {
this.pager.enableHistory(); this.pager.enableHistory();
this.pager.setFilterValue('orderCreateTime', SortDirection.DESC);
this.refresh(); this.refresh();
} }
+2 -4
View File
@@ -30,10 +30,8 @@ export default class ShareDialogModal extends TankComponent<IProps, IState> {
copyAll() { copyAll() {
const { share } = this.props; const { share } = this.props;
const text = `${Lang.t('share.link')}:${share.getLink()} ${Lang.t( const url = `${share.getLink()}?code=${share.code}`;
'share.code' this.clipboard(url);
)}:${share.code}`;
this.clipboard(text);
} }
static open = (share: Share) => { static open = (share: Share) => {
+1 -1
View File
@@ -20,7 +20,7 @@
flex-shrink: 0; flex-shrink: 0;
} }
.btn { .btn {
&-member, &-edit { &-member, &-edit, &-bin {
color: @primary-color; color: @primary-color;
} }
&-del { &-del {
+50 -24
View File
@@ -13,6 +13,7 @@ import {
Space as AntdSpace, Space as AntdSpace,
Modal, Modal,
Empty, Empty,
Tooltip,
} from 'antd'; } from 'antd';
import './List.less'; import './List.less';
import Lang from '../../common/model/global/Lang'; import Lang from '../../common/model/global/Lang';
@@ -22,6 +23,7 @@ import MessageBoxUtil from '../../common/util/MessageBoxUtil';
import { import {
EditOutlined, EditOutlined,
ExclamationCircleFilled, ExclamationCircleFilled,
RestOutlined,
TeamOutlined, TeamOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { DeleteOutlined } from '@ant-design/icons/lib'; import { DeleteOutlined } from '@ant-design/icons/lib';
@@ -50,6 +52,7 @@ export default class List extends TankComponent<IProps, IState> {
componentDidMount() { componentDidMount() {
this.pager.enableHistory(); this.pager.enableHistory();
this.pager.setFilterValue('type', 'SHARED'); // 列表只展示共享空间
this.pager.httpList(); this.pager.httpList();
} }
@@ -115,6 +118,10 @@ export default class List extends TankComponent<IProps, IState> {
this.props.history.push(`/space/${space.uuid}/member`); this.props.history.push(`/space/${space.uuid}/member`);
} }
handleSpaceRecycleBin(space: Space) {
this.props.history.push(`/space/${space.uuid}/bin/list`);
}
handleSpaceMatterList(space: Space) { handleSpaceMatterList(space: Space) {
this.props.history.push(`/space/${space.uuid}/matter/list`); this.props.history.push(`/space/${space.uuid}/matter/list`);
} }
@@ -149,32 +156,51 @@ export default class List extends TankComponent<IProps, IState> {
{space.name} {space.name}
</div> </div>
<AntdSpace className="space-item-icons"> <AntdSpace className="space-item-icons">
<TeamOutlined <Tooltip title={Lang.t('space.memberManage')}>
className="btn-action btn-member" <TeamOutlined
onClick={(e) => className="btn-action btn-member"
SafeUtil.stopPropagationWrap(e)( onClick={(e) =>
this.handleSpaceMember(space) 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() && ( {this.user.isAdmin() && (
<> <>
<EditOutlined <Tooltip title={Lang.t('space.edit')}>
className="btn-action btn-edit" <EditOutlined
onClick={(e) => className="btn-action btn-edit"
SafeUtil.stopPropagationWrap(e)( onClick={(e) =>
this.handleEdit(space) SafeUtil.stopPropagationWrap(e)(
) this.handleEdit(space)
} )
/> }
<DeleteOutlined />
className="btn-action btn-del" </Tooltip>
onClick={(e) =>
SafeUtil.stopPropagationWrap(e)( <Tooltip title={Lang.t('space.delete')}>
this.handleDelete(space) <DeleteOutlined
) className="btn-action btn-del"
} onClick={(e) =>
/> SafeUtil.stopPropagationWrap(e)(
this.handleDelete(space)
)
}
/>
</Tooltip>
</> </>
)} )}
</AntdSpace> </AntdSpace>
+36 -18
View File
@@ -1,9 +1,7 @@
import TankComponent from '../../../common/component/TankComponent'; import TankComponent from '../../../common/component/TankComponent';
import { Link, RouteComponentProps } from 'react-router-dom'; import { Link, RouteComponentProps } from 'react-router-dom';
import Pager from '../../../common/model/base/Pager'; import Pager from '../../../common/model/base/Pager';
import SpaceMember, { import SpaceMember from '../../../common/model/space/member/SpaceMember';
SpaceMemberFormValues,
} from '../../../common/model/space/member/SpaceMember';
import Lang from '../../../common/model/global/Lang'; import Lang from '../../../common/model/global/Lang';
import { Button, Modal, Table, Tag, Tooltip, Typography } from 'antd'; import { Button, Modal, Table, Tag, Tooltip, Typography } from 'antd';
import TankTitle from '../../widget/TankTitle'; import TankTitle from '../../widget/TankTitle';
@@ -25,7 +23,7 @@ import {
} from '@ant-design/icons'; } from '@ant-design/icons';
import Color from '../../../common/model/base/option/Color'; import Color from '../../../common/model/base/option/Color';
import MessageBoxUtil from '../../../common/util/MessageBoxUtil'; import MessageBoxUtil from '../../../common/util/MessageBoxUtil';
import ModalForm from './widget/ModalForm'; import ModalForm, { SpaceMemberModalFormValues } from './widget/ModalForm';
interface IProps interface IProps
extends RouteComponentProps<{ extends RouteComponentProps<{
@@ -41,7 +39,7 @@ export default class SpaceMemberList extends TankComponent<IProps, IState> {
modalState: { modalState: {
visible: boolean; visible: boolean;
mode: 'create' | 'edit'; mode: 'create' | 'edit';
initialValues?: SpaceMemberFormValues; targetSpaceMember?: SpaceMember;
} = { } = {
visible: false, visible: false,
mode: 'create', mode: 'create',
@@ -74,7 +72,7 @@ export default class SpaceMemberList extends TankComponent<IProps, IState> {
this.modalState = { this.modalState = {
visible: true, visible: true,
mode: 'edit', mode: 'edit',
initialValues: spaceMember.getForm(), targetSpaceMember: spaceMember,
}; };
this.updateUI(); this.updateUI();
} }
@@ -94,18 +92,38 @@ export default class SpaceMemberList extends TankComponent<IProps, IState> {
}); });
} }
handleConfirmModalForm(values: SpaceMemberFormValues) { handleConfirmModalForm(values: SpaceMemberModalFormValues) {
const spaceMember = new SpaceMember(); const spaceMember = new SpaceMember();
spaceMember.spaceUuid = this.spaceUuid; const body = {
spaceMember.assign(values); spaceUuid: this.spaceUuid,
spaceMember.httpSave( role: values.role,
() => { };
MessageBoxUtil.success(Lang.t('operationSuccess')); if (this.modalState.mode === 'edit') {
this.refresh(); spaceMember.httpEdit(
}, {
null, ...body,
() => this.handleHideModalForm() 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() { handleHideModalForm() {
@@ -218,7 +236,7 @@ export default class SpaceMemberList extends TankComponent<IProps, IState> {
{modalState.visible && ( {modalState.visible && (
<ModalForm <ModalForm
mode={modalState.mode} mode={modalState.mode}
initialValues={modalState.initialValues} targetSpaceMember={modalState.targetSpaceMember}
onOk={this.handleConfirmModalForm.bind(this)} onOk={this.handleConfirmModalForm.bind(this)}
onCancel={() => this.handleHideModalForm()} 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 { Form, Input, Modal, Select } from 'antd';
import React, { useEffect, useRef, useState } from 'react'; import React, { useEffect, useRef, useState } from 'react';
import { FORM_LAYOUT } from '../../../../common/const/Form'; import { FORM_LAYOUT } from '../../../../common/const/Form';
import { SpaceMemberFormValues } from '../../../../common/model/space/member/SpaceMember'; import SpaceMember from '../../../../common/model/space/member/SpaceMember';
import { SpaceMemberRoleList } from '../../../../common/model/space/member/SpaceMemberRole'; import {
import Pager from '../../../../common/model/base/Pager'; SpaceMemberRole,
SpaceMemberRoleList,
} from '../../../../common/model/space/member/SpaceMemberRole';
import User from '../../../../common/model/user/User'; import User from '../../../../common/model/user/User';
export interface SpaceMemberModalFormValues {
userUuids: string[];
role: SpaceMemberRole;
}
interface Props { interface Props {
mode: 'create' | 'edit'; mode: 'create' | 'edit';
initialValues?: SpaceMemberFormValues; targetSpaceMember?: SpaceMember;
onOk: (values: SpaceMemberFormValues) => void; onOk: (values: SpaceMemberModalFormValues) => void;
onCancel: () => void; onCancel: () => void;
} }
const ModalForm = ({ mode, initialValues, onOk, onCancel }: Props) => { const ModalForm = ({ mode, targetSpaceMember, onOk, onCancel }: Props) => {
const isCreate = mode === 'create'; const isCreate = mode === 'create';
const userPager = useRef(new Pager<User>(null, User, 100)); const [form] = Form.useForm<SpaceMemberModalFormValues>();
const [form] = Form.useForm<SpaceMemberFormValues>();
const [userList, setUserList] = useState<User[]>([]); // 用户列表 const [userList, setUserList] = useState<User[]>([]); // 用户列表
const initialValues: SpaceMemberModalFormValues | undefined =
targetSpaceMember
? {
userUuids: [targetSpaceMember.userUuid!],
role: targetSpaceMember.role!,
}
: undefined;
useEffect(() => { const refreshUserList = (keyword?: string) => {
refresh(); User.httpSearch(keyword, (res: any) => {
}, []); setUserList(res.data.data);
const refresh = () => {
userPager.current.httpList(() => {
setUserList(userPager.current.data);
}); });
}; };
const handleSearchUser = (value?: string) => { useEffect(() => {
userPager.current.setFilterValue('username', value); refreshUserList();
refresh(); }, []);
};
return ( return (
<Modal <Modal
@@ -49,12 +57,8 @@ const ModalForm = ({ mode, initialValues, onOk, onCancel }: Props) => {
onFinish={onOk} onFinish={onOk}
initialValues={initialValues} initialValues={initialValues}
> >
{/*编辑时暂存uuid*/}
<Form.Item name="uuid" hidden>
<Input />
</Form.Item>
<Form.Item <Form.Item
name="userUuid" name="userUuids"
label={Lang.t('space.member')} label={Lang.t('space.member')}
rules={[{ required: true, message: Lang.t('selectRequired') }]} rules={[{ required: true, message: Lang.t('selectRequired') }]}
> >
@@ -63,12 +67,13 @@ const ModalForm = ({ mode, initialValues, onOk, onCancel }: Props) => {
label: item.username, label: item.username,
value: item.uuid, value: item.uuid,
}))} }))}
mode="multiple"
placeholder={Lang.t('space.memberTip')} placeholder={Lang.t('space.memberTip')}
disabled={!isCreate} disabled={!isCreate}
showSearch showSearch
allowClear allowClear
filterOption={false} filterOption={false}
onSearch={handleSearchUser} onSearch={refreshUserList}
/> />
</Form.Item> </Form.Item>
<Form.Item <Form.Item
+2 -2
View File
@@ -3,7 +3,7 @@ import './InfoCell.less';
import { Col, Row } from 'antd'; import { Col, Row } from 'antd';
interface IProps { interface IProps {
name: string; name: React.ReactNode;
firstSpan?: number; firstSpan?: number;
} }
@@ -19,7 +19,7 @@ export default class InfoCell extends React.Component<IProps, IState> {
render() { render() {
let that = this; let that = this;
let name: string = this.props.name; let name = this.props.name;
let firstSpan: number = 8; let firstSpan: number = 8;
if (this.props.firstSpan !== undefined) { if (this.props.firstSpan !== undefined) {
+5 -1
View File
@@ -20,7 +20,11 @@ const InputSize = ({
const renderAfter = () => { const renderAfter = () => {
return ( return (
<Select value={unit} onChange={handleChangeUnit}> <Select
value={unit}
disabled={otherProps.disabled}
onChange={handleChangeUnit}
>
{FileUtil.sizeUnits.map((u) => ( {FileUtil.sizeUnits.map((u) => (
<Select.Option key={u} value={u}> <Select.Option key={u} value={u}>
{u} {u}
@@ -1,16 +1,22 @@
.browser-previewer { .browser-previewer-wrapper {
position: fixed; position: fixed;
top: 0; top: 0;
right: 0; right: 0;
bottom: 0; bottom: 0;
left: 0; left: 0;
padding: 24px;
background: rgba(0,0,0,0.6);
z-index: 1000; z-index: 1000;
background: white;
display: flex; .browser-previewer {
flex-direction: column; height: 100%;
background: white;
display: flex;
flex-direction: column;
}
.title-bar { .title-bar {
height: 40px; height: 40px;
+24 -19
View File
@@ -51,28 +51,33 @@ export default class BrowserPreviewer extends React.Component<IProps, IState> {
render() { render() {
return ( return (
<div className="browser-previewer"> <div
<div className="title-bar"> className="browser-previewer-wrapper"
<div className="left-part"></div> 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"> <div className="middle-part">
{this.props.name}({FileUtil.humanFileSize(this.props.size)}) {this.props.name}({FileUtil.humanFileSize(this.props.size)})
</div> </div>
<div className="right-part"> <div className="right-part">
<span className="close btn-action"> <span className="close btn-action">
<CloseOutlined <CloseOutlined
onClick={() => { onClick={() => {
this.props.onClose(); this.props.onClose();
}} }}
/> />
</span> </span>
</div>
</div>
<div className="frame-area">
<iframe width="100%" height="100%" src={this.props.url}>
iframe may not supported.
</iframe>
</div> </div>
</div>
<div className="frame-area">
<iframe width="100%" height="100%" src={this.props.url}>
iframe may not supported.
</iframe>
</div> </div>
</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 './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 { export default class ImagePreviewer extends TankComponent<any, any> {
private static pswpDom(): HTMLElement { static initialized: boolean = false;
let innerHTML = `
<!-- Background of PhotoSwipe.
It's a separate element as animating opacity is faster than rgba(). -->
<div class="pswp__bg"></div>
<!-- Slides wrapper with overflow:hidden. --> static singletonRef = React.createRef<ImagePreviewer>();
<div class="pswp__scroll-wrap">
<!-- Container that holds slides. //组件的私有变量
PhotoSwipe keeps only 3 of them in the DOM to save memory. visible: boolean = true;
Don't modify these 3 pswp__item elements, data is added later on. --> urls: string[] = [];
<div class="pswp__container"> index: number = 0;
<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>
`;
//初始化
private static init() {
let div: HTMLElement = document.createElement('div'); let div: HTMLElement = document.createElement('div');
div.className = 'pswp'; div.id = 'photo-container';
div.setAttribute('tabIndex', '-1');
div.setAttribute('role', 'dialog');
div.setAttribute('aria-hidden', 'true');
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) { static showMultiPhoto(urls: string[], index: number = 0) {
let that = this; if (!ImagePreviewer.initialized) {
let items: PhotoSwipe.Item[] = []; ImagePreviewer.initialized = true;
urls.forEach((url) => { ImagePreviewer.init();
items.push({ }
src: url,
w: 0,
h: 0,
});
});
let options = { ImagePreviewer.singletonRef.current?.show(urls, index);
closeEl: false, }
//不需要历史纪录
history: false, show(urls: string[], index: number = 0) {
//不需要全屏按钮 this.urls = urls;
fullscreenEl: false, this.index = index;
//不需要分享按钮 this.visible = true;
shareEl: false, this.updateUI();
//当前从第0张展示。 }
index: index,
tapToClose: true, 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节点 return (
let div = ImagePreviewer.pswpDom(); <PhotoSlider
//添加到body className="widget-image-previewer"
document.body.appendChild(div); images={this.urls.map((item: string) => ({ src: item, key: item }))}
visible={this.visible}
let photoSwipe = new PhotoSwipe(div, PhotoSwipeUIDefault, items, options); onClose={() => {
this.visible = false;
photoSwipe.listen( this.updateUI();
'gettingData', }}
function (index: number, item: PhotoSwipe.Item) { index={this.index}
if (!item.w || !item.h || item.w < 1 || item.h < 1) { onIndexChange={(index: number) => {
//这里为了获取图片真实大小 this.index = index;
const img: HTMLImageElement = new Image(); this.updateUI();
img.onload = function () { }}
item.w = img.width; toolbarRender={toolBar}
item.h = img.height; />
photoSwipe.updateSize(true); // reinit Items
};
img.src = item.src!;
}
}
); );
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 MimeUtil from '../../../common/util/MimeUtil';
import MessageBoxUtil from '../../../common/util/MessageBoxUtil'; import MessageBoxUtil from '../../../common/util/MessageBoxUtil';
import Moon from '../../../common/model/global/Moon'; import Moon from '../../../common/model/global/Moon';
@@ -106,8 +105,8 @@ export default class PreviewerHelper {
if (previewUrl) { if (previewUrl) {
targetUrl = targetUrl.replace('{originUrl}', previewUrl); targetUrl = targetUrl.replace('{originUrl}', previewUrl);
targetUrl = targetUrl.replace('{b64Url}', previewUrl); targetUrl = targetUrl.replace('{b64Url}', encodeURIComponent(PreviewerHelper.b64EncodeUnicode(previewUrl)));
targetUrl = targetUrl.replace('{url}', previewUrl); targetUrl = targetUrl.replace('{url}', encodeURIComponent(previewUrl));
if (engine.previewInSite) { if (engine.previewInSite) {
BrowserPreviewer.show(fileName, targetUrl, matter.size); BrowserPreviewer.show(fileName, targetUrl, matter.size);
} else { } else {
+15 -10
View File
@@ -1859,11 +1859,6 @@
resolved "https://registry.npmmirror.com/@types/parse-json/-/parse-json-4.0.0.tgz#2f8bb441434d163b35fb8ffdccd7138927ffb8c0" resolved "https://registry.npmmirror.com/@types/parse-json/-/parse-json-4.0.0.tgz#2f8bb441434d163b35fb8ffdccd7138927ffb8c0"
integrity sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA== 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": "@types/prettier@^2.1.5":
version "2.4.4" version "2.4.4"
resolved "https://registry.npmmirror.com/@types/prettier/-/prettier-2.4.4.tgz#5d9b63132df54d8909fce1c3f8ca260fdd693e17" resolved "https://registry.npmmirror.com/@types/prettier/-/prettier-2.4.4.tgz#5d9b63132df54d8909fce1c3f8ca260fdd693e17"
@@ -1969,6 +1964,11 @@
dependencies: dependencies:
"@types/node" "*" "@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": "@types/stack-utils@^2.0.0":
version "2.0.1" version "2.0.1"
resolved "https://registry.npmmirror.com/@types/stack-utils/-/stack-utils-2.0.1.tgz#20f18294f797f2209b5f65c8e3b5c8e8261d127c" 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" resolved "https://registry.npmmirror.com/performance-now/-/performance-now-2.1.0.tgz#6309f4e0e5fa913ec1c69307ae364b4b377c9e7b"
integrity sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow== 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: picocolors@^0.2.1:
version "0.2.1" version "0.2.1"
resolved "https://registry.npmmirror.com/picocolors/-/picocolors-0.2.1.tgz#570670f793646851d1ba135996962abad587859f" 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" resolved "https://registry.npmmirror.com/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz#4f1a273afdfc8f3488a8c516bfda78f872352362"
integrity sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA== 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: react-refresh@^0.11.0:
version "0.11.0" version "0.11.0"
resolved "https://registry.npmmirror.com/react-refresh/-/react-refresh-0.11.0.tgz#77198b944733f0f1f1a90e791de4541f9f074046" 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" resolved "https://registry.npmmirror.com/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz#ea804bd94857402e6992d05a38ef1ae35a9ab4c4"
integrity sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA== 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: spdy-transport@^3.0.0:
version "3.0.0" version "3.0.0"
resolved "https://registry.npmmirror.com/spdy-transport/-/spdy-transport-3.0.0.tgz#00d4863a6400ad75df93361a1608605e5dcdcf31" resolved "https://registry.npmmirror.com/spdy-transport/-/spdy-transport-3.0.0.tgz#00d4863a6400ad75df93361a1608605e5dcdcf31"