修复部分显示问题

This commit is contained in:
yoyuzh
2026-03-26 14:29:30 +08:00
parent b2d9db7be9
commit 448e2a6886
14 changed files with 630 additions and 163 deletions

View File

@@ -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({

View File

@@ -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);
});
}

View 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...',
);
});

View 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)}...`;
}