feat(portal): land files platform and frontend workspace refresh

This commit is contained in:
yoyuzh
2026-04-09 18:35:03 +08:00
parent 67cd0f6e6f
commit 99e00cd7f7
68 changed files with 5795 additions and 2911 deletions

View File

@@ -0,0 +1,220 @@
import { apiBinaryUploadRequest, apiUploadRequest, apiV2Request, ApiError } from './api';
import type {
PreparedUploadResponse,
UploadSessionResponse,
UploadSessionStrategy,
} from './types';
export interface UploadFileToNetdiskOptions {
onProgress?: (progress: {loaded: number; total: number}) => void;
signal?: AbortSignal;
}
export interface UploadedNetdiskFileRef {
sessionId: string;
filename: string;
path: string;
}
interface CreateUploadSessionRequest {
path: string;
filename: string;
contentType: string | null;
size: number;
}
interface UploadSessionPartRecordRequest {
etag: string;
size: number;
}
function replacePartIndex(template: string, partIndex: number) {
return template.replace('{partIndex}', String(partIndex));
}
function toInternalApiPath(path: string) {
return path.startsWith('/api/') ? path.slice('/api'.length) : path;
}
function getRequiredStrategyValue(value: string | null | undefined, key: keyof UploadSessionStrategy) {
if (!value) {
throw new Error(`上传会话缺少 ${key},无法继续上传`);
}
return value;
}
export function createUploadSession(request: CreateUploadSessionRequest) {
return apiV2Request<UploadSessionResponse>('/files/upload-sessions', {
method: 'POST',
body: request,
});
}
export function getUploadSession(sessionId: string) {
return apiV2Request<UploadSessionResponse>(`/files/upload-sessions/${sessionId}`);
}
export function cancelUploadSession(sessionId: string) {
return apiV2Request<UploadSessionResponse>(`/files/upload-sessions/${sessionId}`, {
method: 'DELETE',
});
}
export function prepareSingleUploadSession(sessionId: string) {
return apiV2Request<PreparedUploadResponse>(`/files/upload-sessions/${sessionId}/prepare`);
}
export function uploadUploadSessionContent(
sessionId: string,
file: File,
options: UploadFileToNetdiskOptions = {},
) {
const formData = new FormData();
formData.append('file', file);
return apiUploadRequest<UploadSessionResponse>(`/v2/files/upload-sessions/${sessionId}/content`, {
body: formData,
onProgress: options.onProgress,
signal: options.signal,
});
}
export function prepareUploadSessionPart(sessionId: string, partIndex: number) {
return apiV2Request<PreparedUploadResponse>(`/files/upload-sessions/${sessionId}/parts/${partIndex}/prepare`);
}
export function recordUploadSessionPart(
sessionId: string,
partIndex: number,
request: UploadSessionPartRecordRequest,
) {
return apiV2Request<UploadSessionResponse>(`/files/upload-sessions/${sessionId}/parts/${partIndex}`, {
method: 'PUT',
body: request,
});
}
export function completeUploadSession(sessionId: string) {
return apiV2Request<UploadSessionResponse>(`/files/upload-sessions/${sessionId}/complete`, {
method: 'POST',
});
}
async function runProxyUpload(session: UploadSessionResponse, file: File, options: UploadFileToNetdiskOptions) {
const proxyFormField = getRequiredStrategyValue(session.strategy.proxyFormField, 'proxyFormField');
const formData = new FormData();
formData.append(proxyFormField, file);
await apiUploadRequest<UploadSessionResponse>(
toInternalApiPath(getRequiredStrategyValue(session.strategy.proxyContentUrl, 'proxyContentUrl')),
{
body: formData,
onProgress: options.onProgress,
signal: options.signal,
},
);
}
async function runDirectSingleUpload(session: UploadSessionResponse, file: File, options: UploadFileToNetdiskOptions) {
const prepared = await prepareSingleUploadSession(session.sessionId);
await apiBinaryUploadRequest(prepared.uploadUrl, {
method: prepared.method,
headers: prepared.headers,
body: file,
onProgress: options.onProgress,
signal: options.signal,
});
}
async function runMultipartUpload(session: UploadSessionResponse, file: File, options: UploadFileToNetdiskOptions) {
const partPrepareUrlTemplate = getRequiredStrategyValue(session.strategy.partPrepareUrlTemplate, 'partPrepareUrlTemplate');
const partRecordUrlTemplate = getRequiredStrategyValue(session.strategy.partRecordUrlTemplate, 'partRecordUrlTemplate');
let uploadedBytes = 0;
for (let partIndex = 0; partIndex < session.chunkCount; partIndex += 1) {
const partStart = partIndex * session.chunkSize;
const partEnd = Math.min(file.size, partStart + session.chunkSize);
const partBlob = file.slice(partStart, partEnd);
const prepared = await apiV2Request<PreparedUploadResponse>(
toInternalApiPath(replacePartIndex(partPrepareUrlTemplate, partIndex)).replace('/v2', ''),
);
const uploadResult = await apiBinaryUploadRequest(prepared.uploadUrl, {
method: prepared.method,
headers: prepared.headers,
body: partBlob,
responseHeaders: ['etag'],
signal: options.signal,
onProgress: options.onProgress
? ({loaded, total}) => {
options.onProgress?.({
loaded: uploadedBytes + loaded,
total: Math.max(file.size, uploadedBytes + total),
});
}
: undefined,
});
const etag = uploadResult.headers.etag;
if (!etag) {
throw new Error('分片上传成功但未返回 etag无法完成上传');
}
await apiV2Request<UploadSessionResponse>(
toInternalApiPath(replacePartIndex(partRecordUrlTemplate, partIndex)).replace('/v2', ''),
{
method: 'PUT',
body: {
etag,
size: partBlob.size,
},
},
);
uploadedBytes += partBlob.size;
options.onProgress?.({
loaded: uploadedBytes,
total: file.size,
});
}
}
export async function uploadFileToNetdiskViaSession(
file: File,
path: string,
options: UploadFileToNetdiskOptions = {},
): Promise<UploadedNetdiskFileRef> {
const session = await createUploadSession({
path,
filename: file.name,
contentType: file.type || null,
size: file.size,
});
let shouldCancelSession = true;
try {
switch (session.uploadMode) {
case 'PROXY':
await runProxyUpload(session, file, options);
break;
case 'DIRECT_SINGLE':
await runDirectSingleUpload(session, file, options);
break;
case 'DIRECT_MULTIPART':
await runMultipartUpload(session, file, options);
break;
default:
throw new Error(`不支持的上传模式:${String(session.uploadMode)}`);
}
await completeUploadSession(session.sessionId);
shouldCancelSession = false;
return {
sessionId: session.sessionId,
filename: file.name,
path,
};
} catch (error) {
if (shouldCancelSession && !(error instanceof ApiError && error.message === '上传已取消')) {
await cancelUploadSession(session.sessionId).catch(() => undefined);
}
if (shouldCancelSession && error instanceof ApiError && error.message === '上传已取消') {
await cancelUploadSession(session.sessionId).catch(() => undefined);
}
throw error;
}
}