修复部分显示问题
This commit is contained in:
@@ -48,6 +48,8 @@ class FakeXMLHttpRequest {
|
||||
responseHeaders = new Map<string, string>();
|
||||
onload: null | (() => void) = null;
|
||||
onerror: null | (() => void) = null;
|
||||
onabort: null | (() => void) = null;
|
||||
aborted = false;
|
||||
|
||||
upload = {
|
||||
addEventListener: (type: string, listener: EventListenerOrEventListenerObject) => {
|
||||
@@ -82,6 +84,11 @@ class FakeXMLHttpRequest {
|
||||
this.requestBody = body;
|
||||
}
|
||||
|
||||
abort() {
|
||||
this.aborted = true;
|
||||
this.onabort?.();
|
||||
}
|
||||
|
||||
triggerProgress(loaded: number, total: number) {
|
||||
const event = {
|
||||
lengthComputable: true,
|
||||
@@ -312,6 +319,25 @@ test('apiBinaryUploadRequest sends raw file body to signed upload url', async ()
|
||||
]);
|
||||
});
|
||||
|
||||
test('apiUploadRequest supports aborting a single upload task', async () => {
|
||||
const controller = new AbortController();
|
||||
const formData = new FormData();
|
||||
formData.append('file', new Blob(['hello']), 'hello.txt');
|
||||
|
||||
const uploadPromise = apiUploadRequest<{id: number}>('/files/upload?path=%2F', {
|
||||
body: formData,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
const request = FakeXMLHttpRequest.latest;
|
||||
assert.ok(request);
|
||||
|
||||
controller.abort();
|
||||
|
||||
await assert.rejects(uploadPromise, /上传已取消/);
|
||||
assert.equal(request.aborted, true);
|
||||
});
|
||||
|
||||
test('apiRequest refreshes expired access token once and retries the original request', async () => {
|
||||
const calls: Array<{url: string; authorization: string | null; body: string | null}> = [];
|
||||
saveStoredSession({
|
||||
|
||||
@@ -16,6 +16,7 @@ interface ApiUploadRequestInit {
|
||||
headers?: HeadersInit;
|
||||
method?: 'POST' | 'PUT' | 'PATCH';
|
||||
onProgress?: (progress: {loaded: number; total: number}) => void;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
interface ApiBinaryUploadRequestInit {
|
||||
@@ -23,6 +24,7 @@ interface ApiBinaryUploadRequestInit {
|
||||
headers?: HeadersInit;
|
||||
method?: 'PUT' | 'POST';
|
||||
onProgress?: (progress: {loaded: number; total: number}) => void;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
const API_BASE_URL = (import.meta.env?.VITE_API_BASE_URL || '/api').replace(/\/$/, '');
|
||||
@@ -197,6 +199,10 @@ export function toNetworkApiError(error: unknown) {
|
||||
return new ApiError(message === 'Failed to fetch' ? fallbackMessage : message, 0);
|
||||
}
|
||||
|
||||
function toUploadAbortApiError() {
|
||||
return new ApiError('上传已取消', 0);
|
||||
}
|
||||
|
||||
export function shouldRetryRequest(
|
||||
path: string,
|
||||
init: ApiRequestInit = {},
|
||||
@@ -296,6 +302,46 @@ function apiUploadRequestInternal<T>(path: string, init: ApiUploadRequestInit, a
|
||||
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
let settled = false;
|
||||
|
||||
const detachAbortSignal = () => {
|
||||
init.signal?.removeEventListener('abort', handleAbortSignal);
|
||||
};
|
||||
|
||||
const resolveOnce = (value: T | PromiseLike<T>) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
|
||||
settled = true;
|
||||
detachAbortSignal();
|
||||
resolve(value);
|
||||
};
|
||||
|
||||
const rejectOnce = (error: unknown) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
|
||||
settled = true;
|
||||
detachAbortSignal();
|
||||
reject(error);
|
||||
};
|
||||
|
||||
const handleAbortSignal = () => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
|
||||
xhr.abort();
|
||||
rejectOnce(toUploadAbortApiError());
|
||||
};
|
||||
|
||||
if (init.signal?.aborted) {
|
||||
rejectOnce(toUploadAbortApiError());
|
||||
return;
|
||||
}
|
||||
|
||||
xhr.open(init.method || 'POST', resolveUrl(path));
|
||||
|
||||
headers.forEach((value, key) => {
|
||||
@@ -316,7 +362,16 @@ function apiUploadRequestInternal<T>(path: string, init: ApiUploadRequestInit, a
|
||||
}
|
||||
|
||||
xhr.onerror = () => {
|
||||
reject(toNetworkApiError(new TypeError('Failed to fetch')));
|
||||
if (init.signal?.aborted) {
|
||||
rejectOnce(toUploadAbortApiError());
|
||||
return;
|
||||
}
|
||||
|
||||
rejectOnce(toNetworkApiError(new TypeError('Failed to fetch')));
|
||||
};
|
||||
|
||||
xhr.onabort = () => {
|
||||
rejectOnce(toUploadAbortApiError());
|
||||
};
|
||||
|
||||
xhr.onload = () => {
|
||||
@@ -326,26 +381,26 @@ function apiUploadRequestInternal<T>(path: string, init: ApiUploadRequestInit, a
|
||||
refreshAccessToken()
|
||||
.then((refreshed) => {
|
||||
if (refreshed) {
|
||||
resolve(apiUploadRequestInternal<T>(path, init, false));
|
||||
resolveOnce(apiUploadRequestInternal<T>(path, init, false));
|
||||
return;
|
||||
}
|
||||
clearStoredSession();
|
||||
reject(new ApiError('登录状态已失效,请重新登录', 401));
|
||||
rejectOnce(new ApiError('登录状态已失效,请重新登录', 401));
|
||||
})
|
||||
.catch((error) => {
|
||||
clearStoredSession();
|
||||
reject(error instanceof ApiError ? error : toNetworkApiError(error));
|
||||
rejectOnce(error instanceof ApiError ? error : toNetworkApiError(error));
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!contentType.includes('application/json')) {
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
resolve(undefined as T);
|
||||
resolveOnce(undefined as T);
|
||||
return;
|
||||
}
|
||||
|
||||
reject(new ApiError(`请求失败 (${xhr.status})`, xhr.status));
|
||||
rejectOnce(new ApiError(`请求失败 (${xhr.status})`, xhr.status));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -354,13 +409,17 @@ function apiUploadRequestInternal<T>(path: string, init: ApiUploadRequestInit, a
|
||||
if (xhr.status === 401) {
|
||||
clearStoredSession();
|
||||
}
|
||||
reject(new ApiError(payload.msg || `请求失败 (${xhr.status})`, xhr.status, payload.code));
|
||||
rejectOnce(new ApiError(payload.msg || `请求失败 (${xhr.status})`, xhr.status, payload.code));
|
||||
return;
|
||||
}
|
||||
|
||||
resolve(payload.data);
|
||||
resolveOnce(payload.data);
|
||||
};
|
||||
|
||||
if (init.signal) {
|
||||
init.signal.addEventListener('abort', handleAbortSignal, {once: true});
|
||||
}
|
||||
|
||||
xhr.send(init.body);
|
||||
});
|
||||
}
|
||||
@@ -374,6 +433,46 @@ export function apiBinaryUploadRequest(path: string, init: ApiBinaryUploadReques
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
let settled = false;
|
||||
|
||||
const detachAbortSignal = () => {
|
||||
init.signal?.removeEventListener('abort', handleAbortSignal);
|
||||
};
|
||||
|
||||
const resolveOnce = () => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
|
||||
settled = true;
|
||||
detachAbortSignal();
|
||||
resolve();
|
||||
};
|
||||
|
||||
const rejectOnce = (error: unknown) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
|
||||
settled = true;
|
||||
detachAbortSignal();
|
||||
reject(error);
|
||||
};
|
||||
|
||||
const handleAbortSignal = () => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
|
||||
xhr.abort();
|
||||
rejectOnce(toUploadAbortApiError());
|
||||
};
|
||||
|
||||
if (init.signal?.aborted) {
|
||||
rejectOnce(toUploadAbortApiError());
|
||||
return;
|
||||
}
|
||||
|
||||
xhr.open(init.method || 'PUT', resolveUrl(path));
|
||||
|
||||
headers.forEach((value, key) => {
|
||||
@@ -394,18 +493,31 @@ export function apiBinaryUploadRequest(path: string, init: ApiBinaryUploadReques
|
||||
}
|
||||
|
||||
xhr.onerror = () => {
|
||||
reject(toNetworkApiError(new TypeError('Failed to fetch')));
|
||||
if (init.signal?.aborted) {
|
||||
rejectOnce(toUploadAbortApiError());
|
||||
return;
|
||||
}
|
||||
|
||||
rejectOnce(toNetworkApiError(new TypeError('Failed to fetch')));
|
||||
};
|
||||
|
||||
xhr.onabort = () => {
|
||||
rejectOnce(toUploadAbortApiError());
|
||||
};
|
||||
|
||||
xhr.onload = () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
resolve();
|
||||
resolveOnce();
|
||||
return;
|
||||
}
|
||||
|
||||
reject(new ApiError(`请求失败 (${xhr.status})`, xhr.status));
|
||||
rejectOnce(new ApiError(`请求失败 (${xhr.status})`, xhr.status));
|
||||
};
|
||||
|
||||
if (init.signal) {
|
||||
init.signal.addEventListener('abort', handleAbortSignal, {once: true});
|
||||
}
|
||||
|
||||
xhr.send(init.body);
|
||||
});
|
||||
}
|
||||
|
||||
22
front/src/lib/file-name.test.ts
Normal file
22
front/src/lib/file-name.test.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { ellipsizeFileName } from './file-name';
|
||||
|
||||
test('ellipsizeFileName keeps short names unchanged', () => {
|
||||
assert.equal(ellipsizeFileName('report.pdf', 24), 'report.pdf');
|
||||
});
|
||||
|
||||
test('ellipsizeFileName truncates long file names and preserves extension when possible', () => {
|
||||
assert.equal(
|
||||
ellipsizeFileName('2026-very-long-course-material-final-version.pdf', 24),
|
||||
'2026-very-long-co....pdf',
|
||||
);
|
||||
});
|
||||
|
||||
test('ellipsizeFileName truncates long names without extension', () => {
|
||||
assert.equal(
|
||||
ellipsizeFileName('this-is-a-very-long-folder-name-without-extension', 20),
|
||||
'this-is-a-very-lo...',
|
||||
);
|
||||
});
|
||||
23
front/src/lib/file-name.ts
Normal file
23
front/src/lib/file-name.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
export function ellipsizeFileName(fileName: string, maxLength = 36) {
|
||||
if (fileName.length <= maxLength) {
|
||||
return fileName;
|
||||
}
|
||||
|
||||
if (maxLength <= 3) {
|
||||
return '.'.repeat(Math.max(0, maxLength));
|
||||
}
|
||||
|
||||
const extensionIndex = fileName.lastIndexOf('.');
|
||||
const hasUsableExtension = extensionIndex > 0 && extensionIndex < fileName.length - 1;
|
||||
|
||||
if (hasUsableExtension) {
|
||||
const extension = fileName.slice(extensionIndex);
|
||||
const prefixLength = maxLength - extension.length - 3;
|
||||
|
||||
if (prefixLength > 0) {
|
||||
return `${fileName.slice(0, prefixLength)}...${extension}`;
|
||||
}
|
||||
}
|
||||
|
||||
return `${fileName.slice(0, maxLength - 3)}...`;
|
||||
}
|
||||
Reference in New Issue
Block a user