first runnable version
This commit is contained in:
111
front/src/lib/api.test.ts
Normal file
111
front/src/lib/api.test.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { afterEach, beforeEach, test } from 'node:test';
|
||||
|
||||
import { apiRequest } from './api';
|
||||
import { clearStoredSession, saveStoredSession } from './session';
|
||||
|
||||
class MemoryStorage implements Storage {
|
||||
private store = new Map<string, string>();
|
||||
|
||||
get length() {
|
||||
return this.store.size;
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.store.clear();
|
||||
}
|
||||
|
||||
getItem(key: string) {
|
||||
return this.store.has(key) ? this.store.get(key)! : null;
|
||||
}
|
||||
|
||||
key(index: number) {
|
||||
return Array.from(this.store.keys())[index] ?? null;
|
||||
}
|
||||
|
||||
removeItem(key: string) {
|
||||
this.store.delete(key);
|
||||
}
|
||||
|
||||
setItem(key: string, value: string) {
|
||||
this.store.set(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
const originalStorage = globalThis.localStorage;
|
||||
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
configurable: true,
|
||||
value: new MemoryStorage(),
|
||||
});
|
||||
clearStoredSession();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
configurable: true,
|
||||
value: originalStorage,
|
||||
});
|
||||
});
|
||||
|
||||
test('apiRequest attaches bearer token and unwraps response payload', async () => {
|
||||
let request: Request | URL | string | undefined;
|
||||
saveStoredSession({
|
||||
token: 'token-123',
|
||||
user: {
|
||||
id: 1,
|
||||
username: 'tester',
|
||||
email: 'tester@example.com',
|
||||
createdAt: '2026-03-14T10:00:00',
|
||||
},
|
||||
});
|
||||
|
||||
globalThis.fetch = async (input, init) => {
|
||||
request =
|
||||
input instanceof Request
|
||||
? input
|
||||
: new Request(new URL(String(input), 'http://localhost'), init);
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
code: 0,
|
||||
msg: 'success',
|
||||
data: {
|
||||
ok: true,
|
||||
},
|
||||
}),
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const payload = await apiRequest<{ok: boolean}>('/files/recent');
|
||||
|
||||
assert.deepEqual(payload, {ok: true});
|
||||
assert.ok(request instanceof Request);
|
||||
assert.equal(request.headers.get('Authorization'), 'Bearer token-123');
|
||||
assert.equal(request.url, 'http://localhost/api/files/recent');
|
||||
});
|
||||
|
||||
test('apiRequest throws backend message on business error', async () => {
|
||||
globalThis.fetch = async () =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
code: 40101,
|
||||
msg: 'login required',
|
||||
data: null,
|
||||
}),
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
await assert.rejects(() => apiRequest('/user/profile'), /login required/);
|
||||
});
|
||||
126
front/src/lib/api.ts
Normal file
126
front/src/lib/api.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { clearStoredSession, readStoredSession } from './session';
|
||||
|
||||
interface ApiEnvelope<T> {
|
||||
code: number;
|
||||
msg: string;
|
||||
data: T;
|
||||
}
|
||||
|
||||
interface ApiRequestInit extends Omit<RequestInit, 'body'> {
|
||||
body?: unknown;
|
||||
}
|
||||
|
||||
const API_BASE_URL = (import.meta.env?.VITE_API_BASE_URL || '/api').replace(/\/$/, '');
|
||||
|
||||
export class ApiError extends Error {
|
||||
code?: number;
|
||||
status: number;
|
||||
|
||||
constructor(message: string, status = 500, code?: number) {
|
||||
super(message);
|
||||
this.name = 'ApiError';
|
||||
this.status = status;
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveUrl(path: string) {
|
||||
if (/^https?:\/\//.test(path)) {
|
||||
return path;
|
||||
}
|
||||
|
||||
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
|
||||
return `${API_BASE_URL}${normalizedPath}`;
|
||||
}
|
||||
|
||||
function buildRequestBody(body: ApiRequestInit['body']) {
|
||||
if (body == null) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (
|
||||
body instanceof FormData ||
|
||||
body instanceof Blob ||
|
||||
body instanceof URLSearchParams ||
|
||||
typeof body === 'string' ||
|
||||
body instanceof ArrayBuffer
|
||||
) {
|
||||
return body;
|
||||
}
|
||||
|
||||
return JSON.stringify(body);
|
||||
}
|
||||
|
||||
async function parseApiError(response: Response) {
|
||||
const contentType = response.headers.get('content-type') || '';
|
||||
if (!contentType.includes('application/json')) {
|
||||
return new ApiError(`请求失败 (${response.status})`, response.status);
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as ApiEnvelope<null>;
|
||||
return new ApiError(payload.msg || `请求失败 (${response.status})`, response.status, payload.code);
|
||||
}
|
||||
|
||||
async function performRequest(path: string, init: ApiRequestInit = {}) {
|
||||
const session = readStoredSession();
|
||||
const headers = new Headers(init.headers);
|
||||
const requestBody = buildRequestBody(init.body);
|
||||
|
||||
if (session?.token) {
|
||||
headers.set('Authorization', `Bearer ${session.token}`);
|
||||
}
|
||||
if (requestBody && !(requestBody instanceof FormData) && !headers.has('Content-Type')) {
|
||||
headers.set('Content-Type', 'application/json');
|
||||
}
|
||||
if (!headers.has('Accept')) {
|
||||
headers.set('Accept', 'application/json');
|
||||
}
|
||||
|
||||
const response = await fetch(resolveUrl(path), {
|
||||
...init,
|
||||
headers,
|
||||
body: requestBody,
|
||||
});
|
||||
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
clearStoredSession();
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
export async function apiRequest<T>(path: string, init?: ApiRequestInit) {
|
||||
const response = await performRequest(path, init);
|
||||
const contentType = response.headers.get('content-type') || '';
|
||||
|
||||
if (!contentType.includes('application/json')) {
|
||||
if (!response.ok) {
|
||||
throw new ApiError(`请求失败 (${response.status})`, response.status);
|
||||
}
|
||||
return undefined as T;
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as ApiEnvelope<T>;
|
||||
if (!response.ok || payload.code !== 0) {
|
||||
if (response.status === 401 || payload.code === 401) {
|
||||
clearStoredSession();
|
||||
}
|
||||
throw new ApiError(payload.msg || `请求失败 (${response.status})`, response.status, payload.code);
|
||||
}
|
||||
|
||||
return payload.data;
|
||||
}
|
||||
|
||||
export async function apiDownload(path: string) {
|
||||
const response = await performRequest(path, {
|
||||
headers: {
|
||||
Accept: '*/*',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw await parseApiError(response);
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
99
front/src/lib/cache.test.ts
Normal file
99
front/src/lib/cache.test.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { afterEach, beforeEach, test } from 'node:test';
|
||||
|
||||
import { clearStoredSession, saveStoredSession } from './session';
|
||||
import { buildScopedCacheKey, readCachedValue, writeCachedValue } from './cache';
|
||||
|
||||
class MemoryStorage implements Storage {
|
||||
private store = new Map<string, string>();
|
||||
|
||||
get length() {
|
||||
return this.store.size;
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.store.clear();
|
||||
}
|
||||
|
||||
getItem(key: string) {
|
||||
return this.store.has(key) ? this.store.get(key)! : null;
|
||||
}
|
||||
|
||||
key(index: number) {
|
||||
return Array.from(this.store.keys())[index] ?? null;
|
||||
}
|
||||
|
||||
removeItem(key: string) {
|
||||
this.store.delete(key);
|
||||
}
|
||||
|
||||
setItem(key: string, value: string) {
|
||||
this.store.set(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
const originalStorage = globalThis.localStorage;
|
||||
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
configurable: true,
|
||||
value: new MemoryStorage(),
|
||||
});
|
||||
clearStoredSession();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
configurable: true,
|
||||
value: originalStorage,
|
||||
});
|
||||
});
|
||||
|
||||
test('scoped cache key includes current user identity', () => {
|
||||
saveStoredSession({
|
||||
token: 'token-1',
|
||||
user: {
|
||||
id: 7,
|
||||
username: 'alice',
|
||||
email: 'alice@example.com',
|
||||
createdAt: '2026-03-14T12:00:00',
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(buildScopedCacheKey('school', '2023123456', '2025-spring'), 'portal-cache:user:7:school:2023123456:2025-spring');
|
||||
});
|
||||
|
||||
test('cached values are isolated between users', () => {
|
||||
saveStoredSession({
|
||||
token: 'token-1',
|
||||
user: {
|
||||
id: 7,
|
||||
username: 'alice',
|
||||
email: 'alice@example.com',
|
||||
createdAt: '2026-03-14T12:00:00',
|
||||
},
|
||||
});
|
||||
writeCachedValue(buildScopedCacheKey('school', '2023123456', '2025-spring'), {
|
||||
queried: true,
|
||||
grades: [95],
|
||||
});
|
||||
|
||||
saveStoredSession({
|
||||
token: 'token-2',
|
||||
user: {
|
||||
id: 8,
|
||||
username: 'bob',
|
||||
email: 'bob@example.com',
|
||||
createdAt: '2026-03-14T12:00:00',
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(readCachedValue(buildScopedCacheKey('school', '2023123456', '2025-spring')), null);
|
||||
});
|
||||
|
||||
test('invalid cached json is ignored safely', () => {
|
||||
localStorage.setItem('portal-cache:user:7:school:2023123456:2025-spring', '{broken-json');
|
||||
|
||||
assert.equal(readCachedValue('portal-cache:user:7:school:2023123456:2025-spring'), null);
|
||||
assert.equal(localStorage.getItem('portal-cache:user:7:school:2023123456:2025-spring'), null);
|
||||
});
|
||||
61
front/src/lib/cache.ts
Normal file
61
front/src/lib/cache.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { readStoredSession } from './session';
|
||||
|
||||
interface CacheEnvelope<T> {
|
||||
value: T;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
const CACHE_PREFIX = 'portal-cache';
|
||||
|
||||
function getCacheScope() {
|
||||
const session = readStoredSession();
|
||||
if (session?.user?.id != null) {
|
||||
return `user:${session.user.id}`;
|
||||
}
|
||||
|
||||
return 'guest';
|
||||
}
|
||||
|
||||
export function buildScopedCacheKey(namespace: string, ...parts: Array<string | number>) {
|
||||
const normalizedParts = parts.map((part) => String(part).replace(/:/g, '_'));
|
||||
return [CACHE_PREFIX, getCacheScope(), namespace, ...normalizedParts].join(':');
|
||||
}
|
||||
|
||||
export function readCachedValue<T>(key: string): T | null {
|
||||
if (typeof localStorage === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rawValue = localStorage.getItem(key);
|
||||
if (!rawValue) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(rawValue) as CacheEnvelope<T>;
|
||||
return parsed.value;
|
||||
} catch {
|
||||
localStorage.removeItem(key);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function writeCachedValue<T>(key: string, value: T) {
|
||||
if (typeof localStorage === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload: CacheEnvelope<T> = {
|
||||
value,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
localStorage.setItem(key, JSON.stringify(payload));
|
||||
}
|
||||
|
||||
export function removeCachedValue(key: string) {
|
||||
if (typeof localStorage === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
localStorage.removeItem(key);
|
||||
}
|
||||
51
front/src/lib/page-cache.ts
Normal file
51
front/src/lib/page-cache.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { buildScopedCacheKey, readCachedValue, writeCachedValue } from './cache';
|
||||
import type { CourseResponse, FileMetadata, GradeResponse, UserProfile } from './types';
|
||||
|
||||
export interface SchoolQueryCache {
|
||||
studentId: string;
|
||||
semester: string;
|
||||
}
|
||||
|
||||
export interface SchoolResultsCache {
|
||||
queried: boolean;
|
||||
schedule: CourseResponse[];
|
||||
grades: GradeResponse[];
|
||||
studentId: string;
|
||||
semester: string;
|
||||
}
|
||||
|
||||
export interface OverviewCache {
|
||||
profile: UserProfile | null;
|
||||
recentFiles: FileMetadata[];
|
||||
rootFiles: FileMetadata[];
|
||||
schedule: CourseResponse[];
|
||||
grades: GradeResponse[];
|
||||
}
|
||||
|
||||
function getSchoolQueryCacheKey() {
|
||||
return buildScopedCacheKey('school-query');
|
||||
}
|
||||
|
||||
export function readStoredSchoolQuery() {
|
||||
return readCachedValue<SchoolQueryCache>(getSchoolQueryCacheKey());
|
||||
}
|
||||
|
||||
export function writeStoredSchoolQuery(query: SchoolQueryCache) {
|
||||
writeCachedValue(getSchoolQueryCacheKey(), query);
|
||||
}
|
||||
|
||||
export function getSchoolResultsCacheKey(studentId: string, semester: string) {
|
||||
return buildScopedCacheKey('school-results', studentId, semester);
|
||||
}
|
||||
|
||||
export function getOverviewCacheKey() {
|
||||
return buildScopedCacheKey('overview');
|
||||
}
|
||||
|
||||
export function getFilesLastPathCacheKey() {
|
||||
return buildScopedCacheKey('files-last-path');
|
||||
}
|
||||
|
||||
export function getFilesListCacheKey(path: string) {
|
||||
return buildScopedCacheKey('files-list', path || 'root');
|
||||
}
|
||||
46
front/src/lib/session.ts
Normal file
46
front/src/lib/session.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import type { AuthSession } from './types';
|
||||
|
||||
const SESSION_STORAGE_KEY = 'portal-session';
|
||||
export const SESSION_EVENT_NAME = 'portal-session-change';
|
||||
|
||||
function notifySessionChanged() {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.dispatchEvent(new Event(SESSION_EVENT_NAME));
|
||||
}
|
||||
}
|
||||
|
||||
export function readStoredSession(): AuthSession | null {
|
||||
if (typeof localStorage === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rawValue = localStorage.getItem(SESSION_STORAGE_KEY);
|
||||
if (!rawValue) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(rawValue) as AuthSession;
|
||||
} catch {
|
||||
localStorage.removeItem(SESSION_STORAGE_KEY);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function saveStoredSession(session: AuthSession) {
|
||||
if (typeof localStorage === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
localStorage.setItem(SESSION_STORAGE_KEY, JSON.stringify(session));
|
||||
notifySessionChanged();
|
||||
}
|
||||
|
||||
export function clearStoredSession() {
|
||||
if (typeof localStorage === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
localStorage.removeItem(SESSION_STORAGE_KEY);
|
||||
notifySessionChanged();
|
||||
}
|
||||
48
front/src/lib/types.ts
Normal file
48
front/src/lib/types.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
export interface UserProfile {
|
||||
id: number;
|
||||
username: string;
|
||||
email: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface AuthSession {
|
||||
token: string;
|
||||
user: UserProfile;
|
||||
}
|
||||
|
||||
export interface AuthResponse {
|
||||
token: string;
|
||||
user: UserProfile;
|
||||
}
|
||||
|
||||
export interface PageResponse<T> {
|
||||
items: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export interface FileMetadata {
|
||||
id: number;
|
||||
filename: string;
|
||||
path: string;
|
||||
size: number;
|
||||
contentType: string | null;
|
||||
directory: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface CourseResponse {
|
||||
courseName: string;
|
||||
teacher: string | null;
|
||||
classroom: string | null;
|
||||
dayOfWeek: number | null;
|
||||
startTime: number | null;
|
||||
endTime: number | null;
|
||||
}
|
||||
|
||||
export interface GradeResponse {
|
||||
courseName: string;
|
||||
grade: number | null;
|
||||
semester: string | null;
|
||||
}
|
||||
Reference in New Issue
Block a user