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