first runnable version

This commit is contained in:
yoyuzh
2026-03-14 12:28:46 +08:00
parent 8db2fa2aab
commit 6cff15f8dc
35 changed files with 2118 additions and 256 deletions

111
front/src/lib/api.test.ts Normal file
View 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
View 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;
}

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

View 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
View 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
View 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;
}