add sign in page

This commit is contained in:
yoyuzh
2026-03-18 11:50:03 +08:00
parent 7518dc158f
commit 8b0f77fa21
14 changed files with 1408 additions and 130 deletions

View File

@@ -1,7 +1,7 @@
import assert from 'node:assert/strict';
import { afterEach, beforeEach, test } from 'node:test';
import { apiRequest } from './api';
import { apiRequest, shouldRetryRequest, toNetworkApiError } from './api';
import { clearStoredSession, saveStoredSession } from './session';
class MemoryStorage implements Storage {
@@ -109,3 +109,33 @@ test('apiRequest throws backend message on business error', async () => {
await assert.rejects(() => apiRequest('/user/profile'), /login required/);
});
test('network login failures are retried a limited number of times for auth login', () => {
const error = new TypeError('Failed to fetch');
assert.equal(shouldRetryRequest('/auth/login', {method: 'POST'}, error, 0), true);
assert.equal(shouldRetryRequest('/auth/login', {method: 'POST'}, error, 1), true);
assert.equal(shouldRetryRequest('/auth/login', {method: 'POST'}, error, 2), false);
});
test('network register failures are not retried automatically', () => {
const error = new TypeError('Failed to fetch');
assert.equal(shouldRetryRequest('/auth/register', {method: 'POST'}, error, 0), false);
});
test('network get failures are retried up to two times after the first attempt', () => {
const error = new TypeError('Failed to fetch');
assert.equal(shouldRetryRequest('/files/list', {method: 'GET'}, error, 0), true);
assert.equal(shouldRetryRequest('/files/list', {method: 'GET'}, error, 1), true);
assert.equal(shouldRetryRequest('/files/list', {method: 'GET'}, error, 2), true);
assert.equal(shouldRetryRequest('/files/list', {method: 'GET'}, error, 3), false);
});
test('network fetch failures are converted to readable api errors', () => {
const apiError = toNetworkApiError(new TypeError('Failed to fetch'));
assert.equal(apiError.status, 0);
assert.match(apiError.message, /网络连接异常|Failed to fetch/);
});

View File

@@ -15,15 +15,57 @@ const API_BASE_URL = (import.meta.env?.VITE_API_BASE_URL || '/api').replace(/\/$
export class ApiError extends Error {
code?: number;
status: number;
isNetworkError: boolean;
constructor(message: string, status = 500, code?: number) {
super(message);
this.name = 'ApiError';
this.status = status;
this.code = code;
this.isNetworkError = status === 0;
}
}
function isNetworkFailure(error: unknown) {
return error instanceof TypeError || error instanceof DOMException;
}
function sleep(ms: number) {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
function getRetryDelayMs(attempt: number) {
const schedule = [500, 1200, 2200];
return schedule[Math.min(attempt, schedule.length - 1)];
}
function getMaxRetryAttempts(path: string, init: ApiRequestInit = {}) {
const method = (init.method || 'GET').toUpperCase();
if (method === 'POST' && path === '/auth/login') {
return 1;
}
if (method === 'GET' || method === 'HEAD' || method === 'OPTIONS') {
return 2;
}
return -1;
}
function getRetryDelayForRequest(path: string, init: ApiRequestInit = {}, attempt: number) {
const method = (init.method || 'GET').toUpperCase();
if (method === 'POST' && path === '/auth/login') {
const loginSchedule = [350, 800];
return loginSchedule[Math.min(attempt, loginSchedule.length - 1)];
}
return getRetryDelayMs(attempt);
}
function resolveUrl(path: string) {
if (/^https?:\/\//.test(path)) {
return path;
@@ -61,6 +103,25 @@ async function parseApiError(response: Response) {
return new ApiError(payload.msg || `请求失败 (${response.status})`, response.status, payload.code);
}
export function toNetworkApiError(error: unknown) {
const fallbackMessage = '网络连接异常,请稍后重试';
const message = error instanceof Error && error.message ? error.message : fallbackMessage;
return new ApiError(message === 'Failed to fetch' ? fallbackMessage : message, 0);
}
export function shouldRetryRequest(
path: string,
init: ApiRequestInit = {},
error: unknown,
attempt: number,
) {
if (!isNetworkFailure(error)) {
return false;
}
return attempt <= getMaxRetryAttempts(path, init);
}
async function performRequest(path: string, init: ApiRequestInit = {}) {
const session = readStoredSession();
const headers = new Headers(init.headers);
@@ -76,11 +137,30 @@ async function performRequest(path: string, init: ApiRequestInit = {}) {
headers.set('Accept', 'application/json');
}
const response = await fetch(resolveUrl(path), {
...init,
headers,
body: requestBody,
});
let response: Response;
let lastError: unknown;
for (let attempt = 0; attempt <= 3; attempt += 1) {
try {
response = await fetch(resolveUrl(path), {
...init,
headers,
body: requestBody,
});
break;
} catch (error) {
lastError = error;
if (!shouldRetryRequest(path, init, error, attempt)) {
throw toNetworkApiError(error);
}
await sleep(getRetryDelayForRequest(path, init, attempt));
}
}
if (!response!) {
throw toNetworkApiError(lastError);
}
if (response.status === 401 || response.status === 403) {
clearStoredSession();

View File

@@ -1,6 +1,7 @@
import type { AuthSession } from './types';
const SESSION_STORAGE_KEY = 'portal-session';
const POST_LOGIN_PENDING_KEY = 'portal-post-login-pending';
export const SESSION_EVENT_NAME = 'portal-session-change';
function notifySessionChanged() {
@@ -44,3 +45,27 @@ export function clearStoredSession() {
localStorage.removeItem(SESSION_STORAGE_KEY);
notifySessionChanged();
}
export function markPostLoginPending() {
if (typeof sessionStorage === 'undefined') {
return;
}
sessionStorage.setItem(POST_LOGIN_PENDING_KEY, String(Date.now()));
}
export function hasPostLoginPending() {
if (typeof sessionStorage === 'undefined') {
return false;
}
return sessionStorage.getItem(POST_LOGIN_PENDING_KEY) != null;
}
export function clearPostLoginPending() {
if (typeof sessionStorage === 'undefined') {
return;
}
sessionStorage.removeItem(POST_LOGIN_PENDING_KEY);
}