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

View File

@@ -7,3 +7,12 @@ GEMINI_API_KEY="MY_GEMINI_API_KEY"
# AI Studio automatically injects this at runtime with the Cloud Run service URL.
# Used for self-referential links, OAuth callbacks, and API endpoints.
APP_URL="MY_APP_URL"
# Optional: direct API base path used by the frontend.
VITE_API_BASE_URL="/api"
# Optional: backend origin used by the Vite dev proxy.
VITE_BACKEND_URL="http://localhost:8080"
# Enable the dev-login button when the backend runs with the dev profile.
VITE_ENABLE_DEV_LOGIN="true"

View File

@@ -8,7 +8,8 @@
"build": "vite build",
"preview": "vite preview",
"clean": "rm -rf dist",
"lint": "tsc --noEmit"
"lint": "tsc --noEmit",
"test": "node --import tsx --test src/**/*.test.ts"
},
"dependencies": {
"@google/genai": "^1.29.0",

View File

@@ -0,0 +1,168 @@
import React, { createContext, useContext, useEffect, useState } from 'react';
import { apiRequest } from '@/src/lib/api';
import {
clearStoredSession,
readStoredSession,
saveStoredSession,
SESSION_EVENT_NAME,
} from '@/src/lib/session';
import type { AuthResponse, AuthSession, UserProfile } from '@/src/lib/types';
interface LoginPayload {
username: string;
password: string;
}
interface AuthContextValue {
ready: boolean;
session: AuthSession | null;
user: UserProfile | null;
login: (payload: LoginPayload) => Promise<void>;
devLogin: (username?: string) => Promise<void>;
logout: () => void;
refreshProfile: () => Promise<void>;
}
const AuthContext = createContext<AuthContextValue | null>(null);
function buildSession(auth: AuthResponse): AuthSession {
return {
token: auth.token,
user: auth.user,
};
}
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [session, setSession] = useState<AuthSession | null>(() => readStoredSession());
const [ready, setReady] = useState(false);
useEffect(() => {
const syncSession = () => {
setSession(readStoredSession());
};
window.addEventListener('storage', syncSession);
window.addEventListener(SESSION_EVENT_NAME, syncSession);
return () => {
window.removeEventListener('storage', syncSession);
window.removeEventListener(SESSION_EVENT_NAME, syncSession);
};
}, []);
useEffect(() => {
let active = true;
async function hydrate() {
const storedSession = readStoredSession();
if (!storedSession) {
if (active) {
setSession(null);
setReady(true);
}
return;
}
try {
const user = await apiRequest<UserProfile>('/user/profile');
if (!active) {
return;
}
const nextSession = {
...storedSession,
user,
};
saveStoredSession(nextSession);
setSession(nextSession);
} catch {
clearStoredSession();
if (active) {
setSession(null);
}
} finally {
if (active) {
setReady(true);
}
}
}
hydrate();
return () => {
active = false;
};
}, []);
async function refreshProfile() {
const currentSession = readStoredSession();
if (!currentSession) {
return;
}
const user = await apiRequest<UserProfile>('/user/profile');
const nextSession = {
...currentSession,
user,
};
saveStoredSession(nextSession);
setSession(nextSession);
}
async function login(payload: LoginPayload) {
const auth = await apiRequest<AuthResponse>('/auth/login', {
method: 'POST',
body: payload,
});
const nextSession = buildSession(auth);
saveStoredSession(nextSession);
setSession(nextSession);
}
async function devLogin(username?: string) {
const params = new URLSearchParams();
if (username?.trim()) {
params.set('username', username.trim());
}
const auth = await apiRequest<AuthResponse>(
`/auth/dev-login${params.size ? `?${params.toString()}` : ''}`,
{
method: 'POST',
},
);
const nextSession = buildSession(auth);
saveStoredSession(nextSession);
setSession(nextSession);
}
function logout() {
clearStoredSession();
setSession(null);
}
return (
<AuthContext.Provider
value={{
ready,
session,
user: session?.user || null,
login,
devLogin,
logout,
refreshProfile,
}}
>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used inside AuthProvider');
}
return context;
}

View File

@@ -1,8 +1,10 @@
import React from 'react';
import { NavLink, Outlet, useNavigate } from 'react-router-dom';
import { cn } from '@/src/lib/utils';
import { LayoutDashboard, FolderOpen, GraduationCap, Gamepad2, LogOut } from 'lucide-react';
import { clearStoredSession } from '@/src/lib/session';
import { cn } from '@/src/lib/utils';
const NAV_ITEMS = [
{ name: '总览', path: '/overview', icon: LayoutDashboard },
{ name: '网盘', path: '/files', icon: FolderOpen },
@@ -14,6 +16,7 @@ export function Layout() {
const navigate = useNavigate();
const handleLogout = () => {
clearStoredSession();
navigate('/login');
};
@@ -88,4 +91,3 @@ export function Layout() {
</div>
);
}

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

View File

@@ -1,69 +1,120 @@
import React, { useState } from 'react';
import React, { useEffect, useRef, useState } from 'react';
import { motion } from 'motion/react';
import { Card, CardContent, CardHeader, CardTitle } from '@/src/components/ui/card';
import { Button } from '@/src/components/ui/button';
import {
Folder, FileText, Image as ImageIcon, Download, Monitor,
Star, ChevronRight, Upload, Plus, LayoutGrid, List, File,
MoreVertical
import {
Folder,
FileText,
Image as ImageIcon,
Download,
Monitor,
ChevronRight,
Upload,
Plus,
LayoutGrid,
List,
MoreVertical,
} from 'lucide-react';
import { Button } from '@/src/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/src/components/ui/card';
import { apiDownload, apiRequest } from '@/src/lib/api';
import { readCachedValue, writeCachedValue } from '@/src/lib/cache';
import { getFilesLastPathCacheKey, getFilesListCacheKey } from '@/src/lib/page-cache';
import type { FileMetadata, PageResponse } from '@/src/lib/types';
import { cn } from '@/src/lib/utils';
const QUICK_ACCESS = [
{ name: '桌面', icon: Monitor },
{ name: '下载', icon: Download },
{ name: '文档', icon: FileText },
{ name: '图片', icon: ImageIcon },
{ name: '桌面', icon: Monitor, path: [] as string[] },
{ name: '下载', icon: Download, path: ['下载'] },
{ name: '文档', icon: FileText, path: ['文档'] },
{ name: '图片', icon: ImageIcon, path: ['图片'] },
];
const DIRECTORIES = [
{ name: '我的文件', icon: Folder },
{ name: '课程资料', icon: Folder },
{ name: '项目归档', icon: Folder },
{ name: '收藏夹', icon: Star },
{ name: '下载', icon: Folder },
{ name: '文档', icon: Folder },
{ name: '图片', icon: Folder },
];
const MOCK_FILES_DB: Record<string, any[]> = {
'我的文件': [
{ id: 1, name: '软件工程期末复习资料.pdf', type: 'pdf', size: '2.4 MB', modified: '2025-01-15 14:30' },
{ id: 2, name: '2025春季学期课表.xlsx', type: 'excel', size: '156 KB', modified: '2025-02-28 09:15' },
{ id: 3, name: '项目架构设计图.png', type: 'image', size: '4.1 MB', modified: '2025-03-01 16:45' },
{ id: 4, name: '实验报告模板.docx', type: 'word', size: '45 KB', modified: '2025-03-05 10:20' },
{ id: 5, name: '前端学习笔记', type: 'folder', size: '—', modified: '2025-03-10 11:00' },
],
'课程资料': [
{ id: 6, name: '高等数学', type: 'folder', size: '—', modified: '2025-02-20 10:00' },
{ id: 7, name: '大学物理', type: 'folder', size: '—', modified: '2025-02-21 11:00' },
{ id: 8, name: '软件工程', type: 'folder', size: '—', modified: '2025-02-22 14:00' },
],
'项目归档': [
{ id: 9, name: '2024秋季学期项目', type: 'folder', size: '—', modified: '2024-12-20 15:30' },
{ id: 10, name: '个人博客源码.zip', type: 'archive', size: '15.2 MB', modified: '2025-01-05 09:45' },
],
'收藏夹': [
{ id: 11, name: '常用工具网站.txt', type: 'document', size: '2 KB', modified: '2025-03-01 10:00' },
],
'我的文件/前端学习笔记': [
{ id: 12, name: 'React Hooks 详解.md', type: 'document', size: '12 KB', modified: '2025-03-08 09:00' },
{ id: 13, name: 'Tailwind 技巧.md', type: 'document', size: '8 KB', modified: '2025-03-09 14:20' },
{ id: 14, name: '示例代码', type: 'folder', size: '—', modified: '2025-03-10 10:00' },
],
'课程资料/软件工程': [
{ id: 15, name: '需求规格说明书.pdf', type: 'pdf', size: '1.2 MB', modified: '2025-03-05 16:00' },
{ id: 16, name: '系统设计文档.docx', type: 'word', size: '850 KB', modified: '2025-03-06 11:30' },
]
};
function toBackendPath(pathParts: string[]) {
return pathParts.length === 0 ? '/' : `/${pathParts.join('/')}`;
}
function formatFileSize(size: number) {
if (size <= 0) {
return '—';
}
const units = ['B', 'KB', 'MB', 'GB'];
const index = Math.min(Math.floor(Math.log(size) / Math.log(1024)), units.length - 1);
const value = size / 1024 ** index;
return `${value.toFixed(value >= 10 || index === 0 ? 0 : 1)} ${units[index]}`;
}
function formatDateTime(value: string) {
return new Intl.DateTimeFormat('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
}).format(new Date(value));
}
function toUiFile(file: FileMetadata) {
const extension = file.filename.includes('.') ? file.filename.split('.').pop()?.toLowerCase() : '';
let type = extension || 'document';
if (file.directory) {
type = 'folder';
} else if (file.contentType?.startsWith('image/')) {
type = 'image';
} else if (file.contentType?.includes('pdf')) {
type = 'pdf';
}
return {
id: file.id,
name: file.filename,
type,
size: file.directory ? '—' : formatFileSize(file.size),
modified: formatDateTime(file.createdAt),
};
}
export default function Files() {
const [currentPath, setCurrentPath] = useState<string[]>(['我的文件']);
const initialPath = readCachedValue<string[]>(getFilesLastPathCacheKey()) ?? [];
const initialCachedFiles = readCachedValue<FileMetadata[]>(getFilesListCacheKey(toBackendPath(initialPath))) ?? [];
const fileInputRef = useRef<HTMLInputElement | null>(null);
const [currentPath, setCurrentPath] = useState<string[]>(initialPath);
const [selectedFile, setSelectedFile] = useState<any | null>(null);
const [currentFiles, setCurrentFiles] = useState<any[]>(initialCachedFiles.map(toUiFile));
const activeDir = currentPath[currentPath.length - 1];
const pathKey = currentPath.join('/');
const currentFiles = MOCK_FILES_DB[pathKey] || [];
const loadCurrentPath = async (pathParts: string[]) => {
const response = await apiRequest<PageResponse<FileMetadata>>(
`/files/list?path=${encodeURIComponent(toBackendPath(pathParts))}&page=0&size=100`
);
writeCachedValue(getFilesListCacheKey(toBackendPath(pathParts)), response.items);
writeCachedValue(getFilesLastPathCacheKey(), pathParts);
setCurrentFiles(response.items.map(toUiFile));
};
const handleSidebarClick = (name: string) => {
setCurrentPath([name]);
useEffect(() => {
const cachedFiles = readCachedValue<FileMetadata[]>(getFilesListCacheKey(toBackendPath(currentPath)));
writeCachedValue(getFilesLastPathCacheKey(), currentPath);
if (cachedFiles) {
setCurrentFiles(cachedFiles.map(toUiFile));
}
loadCurrentPath(currentPath).catch(() => {
if (!cachedFiles) {
setCurrentFiles([]);
}
});
}, [currentPath]);
const handleSidebarClick = (pathParts: string[]) => {
setCurrentPath(pathParts);
setSelectedFile(null);
};
@@ -79,6 +130,65 @@ export default function Files() {
setSelectedFile(null);
};
const handleUploadClick = () => {
fileInputRef.current?.click();
};
const handleFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) {
return;
}
const formData = new FormData();
formData.append('file', file);
await apiRequest(`/files/upload?path=${encodeURIComponent(toBackendPath(currentPath))}`, {
method: 'POST',
body: formData,
});
await loadCurrentPath(currentPath);
event.target.value = '';
};
const handleCreateFolder = async () => {
const folderName = window.prompt('请输入新文件夹名称');
if (!folderName?.trim()) {
return;
}
const basePath = toBackendPath(currentPath).replace(/\/$/, '');
const fullPath = `${basePath}/${folderName.trim()}` || '/';
await apiRequest('/files/mkdir', {
method: 'POST',
body: new URLSearchParams({
path: fullPath.startsWith('/') ? fullPath : `/${fullPath}`,
}),
headers: {
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8',
},
});
await loadCurrentPath(currentPath);
};
const handleDownload = async () => {
if (!selectedFile || selectedFile.type === 'folder') {
return;
}
const response = await apiDownload(`/files/download/${selectedFile.id}`);
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = selectedFile.name;
link.click();
window.URL.revokeObjectURL(url);
};
return (
<div className="flex flex-col lg:flex-row gap-6 h-[calc(100vh-8rem)]">
{/* Left Sidebar */}
@@ -89,9 +199,15 @@ export default function Files() {
{QUICK_ACCESS.map((item) => (
<button
key={item.name}
className="w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium text-slate-300 hover:text-white hover:bg-white/5 transition-colors"
onClick={() => handleSidebarClick(item.path)}
className={cn(
'w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors',
currentPath.join('/') === item.path.join('/')
? 'bg-[#336EFF]/20 text-[#336EFF]'
: 'text-slate-300 hover:text-white hover:bg-white/5'
)}
>
<item.icon className="w-4 h-4 text-slate-400" />
<item.icon className={cn('w-4 h-4', currentPath.join('/') === item.path.join('/') ? 'text-[#336EFF]' : 'text-slate-400')} />
{item.name}
</button>
))}
@@ -102,15 +218,15 @@ export default function Files() {
{DIRECTORIES.map((item) => (
<button
key={item.name}
onClick={() => handleSidebarClick(item.name)}
onClick={() => handleSidebarClick([item.name])}
className={cn(
"w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors",
currentPath.length === 1 && currentPath[0] === item.name
? "bg-[#336EFF]/20 text-[#336EFF]"
: "text-slate-300 hover:text-white hover:bg-white/5"
'w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors',
currentPath.length === 1 && currentPath[0] === item.name
? 'bg-[#336EFF]/20 text-[#336EFF]'
: 'text-slate-300 hover:text-white hover:bg-white/5'
)}
>
<item.icon className={cn("w-4 h-4", currentPath.length === 1 && currentPath[0] === item.name ? "text-[#336EFF]" : "text-slate-400")} />
<item.icon className={cn('w-4 h-4', currentPath.length === 1 && currentPath[0] === item.name ? 'text-[#336EFF]' : 'text-slate-400')} />
{item.name}
</button>
))}
@@ -123,13 +239,15 @@ export default function Files() {
{/* Header / Breadcrumbs */}
<div className="p-4 border-b border-white/10 flex items-center justify-between shrink-0">
<div className="flex items-center text-sm text-slate-400">
<button className="hover:text-white transition-colors"></button>
<button className="hover:text-white transition-colors" onClick={() => handleSidebarClick([])}>
</button>
{currentPath.map((pathItem, index) => (
<React.Fragment key={index}>
<ChevronRight className="w-4 h-4 mx-1" />
<button
<button
onClick={() => handleBreadcrumbClick(index)}
className={cn("transition-colors", index === currentPath.length - 1 ? "text-white font-medium" : "hover:text-white")}
className={cn('transition-colors', index === currentPath.length - 1 ? 'text-white font-medium' : 'hover:text-white')}
>
{pathItem}
</button>
@@ -157,13 +275,13 @@ export default function Files() {
<tbody>
{currentFiles.length > 0 ? (
currentFiles.map((file) => (
<tr
<tr
key={file.id}
onClick={() => setSelectedFile(file)}
onDoubleClick={() => handleFolderDoubleClick(file)}
className={cn(
"group cursor-pointer transition-colors border-b border-white/5 last:border-0",
selectedFile?.id === file.id ? "bg-[#336EFF]/10" : "hover:bg-white/[0.02]"
'group cursor-pointer transition-colors border-b border-white/5 last:border-0',
selectedFile?.id === file.id ? 'bg-[#336EFF]/10' : 'hover:bg-white/[0.02]'
)}
>
<td className="py-3 pl-4">
@@ -175,7 +293,7 @@ export default function Files() {
) : (
<FileText className="w-5 h-5 text-blue-400" />
)}
<span className={cn("text-sm font-medium", selectedFile?.id === file.id ? "text-[#336EFF]" : "text-slate-200")}>
<span className={cn('text-sm font-medium', selectedFile?.id === file.id ? 'text-[#336EFF]' : 'text-slate-200')}>
{file.name}
</span>
</div>
@@ -206,18 +324,19 @@ export default function Files() {
{/* Bottom Actions */}
<div className="p-4 border-t border-white/10 flex items-center gap-3 shrink-0 bg-white/[0.01]">
<Button variant="default" className="gap-2">
<Button variant="default" className="gap-2" onClick={handleUploadClick}>
<Upload className="w-4 h-4" />
</Button>
<Button variant="outline" className="gap-2">
<Button variant="outline" className="gap-2" onClick={handleCreateFolder}>
<Plus className="w-4 h-4" />
</Button>
<input ref={fileInputRef} type="file" className="hidden" onChange={handleFileChange} />
</div>
</Card>
{/* Right Sidebar (Details) */}
{selectedFile && (
<motion.div
<motion.div
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
className="w-full lg:w-72 shrink-0"
@@ -241,14 +360,14 @@ export default function Files() {
</div>
<div className="space-y-4">
<DetailItem label="位置" value={`网盘 > ${currentPath.join(' > ')}`} />
<DetailItem label="位置" value={`网盘 > ${currentPath.length === 0 ? '根目录' : currentPath.join(' > ')}`} />
<DetailItem label="大小" value={selectedFile.size} />
<DetailItem label="修改时间" value={selectedFile.modified} />
<DetailItem label="类型" value={selectedFile.type.toUpperCase()} />
</div>
{selectedFile.type !== 'folder' && (
<Button variant="outline" className="w-full gap-2 mt-4">
<Button variant="outline" className="w-full gap-2 mt-4" onClick={handleDownload}>
<Download className="w-4 h-4" />
</Button>
)}
@@ -265,7 +384,7 @@ export default function Files() {
);
}
function DetailItem({ label, value }: { label: string, value: string }) {
function DetailItem({ label, value }: { label: string; value: string }) {
return (
<div>
<p className="text-xs font-medium text-slate-500 mb-1">{label}</p>

View File

@@ -1,26 +1,63 @@
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { motion } from 'motion/react';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/src/components/ui/card';
import { Button } from '@/src/components/ui/button';
import { Input } from '@/src/components/ui/input';
import { LogIn, User, Lock } from 'lucide-react';
import { Button } from '@/src/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/src/components/ui/card';
import { Input } from '@/src/components/ui/input';
import { apiRequest, ApiError } from '@/src/lib/api';
import { saveStoredSession } from '@/src/lib/session';
import type { AuthResponse } from '@/src/lib/types';
const DEV_LOGIN_ENABLED = import.meta.env.DEV || import.meta.env.VITE_ENABLE_DEV_LOGIN === 'true';
export default function Login() {
const navigate = useNavigate();
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const handleLogin = (e: React.FormEvent) => {
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setError('');
// Simulate login
setTimeout(() => {
try {
let auth: AuthResponse;
try {
auth = await apiRequest<AuthResponse>('/auth/login', {
method: 'POST',
body: { username, password },
});
} catch (requestError) {
if (
DEV_LOGIN_ENABLED &&
username.trim() &&
requestError instanceof ApiError &&
requestError.status === 401
) {
auth = await apiRequest<AuthResponse>(
`/auth/dev-login?username=${encodeURIComponent(username.trim())}`,
{ method: 'POST' }
);
} else {
throw requestError;
}
}
saveStoredSession({
token: auth.token,
user: auth.user,
});
setLoading(false);
navigate('/overview');
}, 1000);
} catch (requestError) {
setLoading(false);
setError(requestError instanceof Error ? requestError.message : '登录失败,请稍后重试');
}
};
return (
@@ -41,14 +78,16 @@ export default function Login() {
<span className="w-2 h-2 rounded-full bg-[#336EFF] animate-pulse" />
<span className="text-sm text-slate-300 font-medium tracking-wide uppercase">Access Portal</span>
</div>
<div className="space-y-2">
<h2 className="text-xl text-[#336EFF] font-bold tracking-widest uppercase">YOYUZH.XYZ</h2>
<h1 className="text-5xl md:text-6xl font-bold text-white leading-tight">
<br />
<br />
</h1>
</div>
<p className="text-lg text-slate-400 leading-relaxed">
YOYUZH
</p>
@@ -82,6 +121,8 @@ export default function Login() {
type="text"
placeholder="账号 / 用户名 / 学号"
className="pl-10 bg-black/20 border-white/10 focus-visible:ring-[#336EFF]"
value={username}
onChange={(event) => setUsername(event.target.value)}
required
/>
</div>
@@ -94,6 +135,8 @@ export default function Login() {
type="password"
placeholder="••••••••"
className="pl-10 bg-black/20 border-white/10 focus-visible:ring-[#336EFF]"
value={password}
onChange={(event) => setPassword(event.target.value)}
required
/>
</div>

View File

@@ -1,16 +1,76 @@
import React from 'react';
import React, { useEffect, useMemo, useState } from 'react';
import { motion } from 'motion/react';
import { useNavigate } from 'react-router-dom';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/src/components/ui/card';
import { Button } from '@/src/components/ui/button';
import {
FileText, Upload, FolderPlus, Database,
GraduationCap, BookOpen, Clock, HardDrive,
User, Mail, ChevronRight
import {
FileText,
Upload,
FolderPlus,
Database,
GraduationCap,
BookOpen,
Clock,
User,
Mail,
ChevronRight,
} from 'lucide-react';
import { Button } from '@/src/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/src/components/ui/card';
import { apiRequest } from '@/src/lib/api';
import { readCachedValue, writeCachedValue } from '@/src/lib/cache';
import { getOverviewCacheKey, getSchoolResultsCacheKey, readStoredSchoolQuery } from '@/src/lib/page-cache';
import { readStoredSession } from '@/src/lib/session';
import type { CourseResponse, FileMetadata, GradeResponse, PageResponse, UserProfile } from '@/src/lib/types';
function formatFileSize(size: number) {
if (size <= 0) {
return '0 B';
}
const units = ['B', 'KB', 'MB', 'GB'];
const index = Math.min(Math.floor(Math.log(size) / Math.log(1024)), units.length - 1);
const value = size / 1024 ** index;
return `${value.toFixed(value >= 10 || index === 0 ? 0 : 1)} ${units[index]}`;
}
function formatRecentTime(value: string) {
const date = new Date(value);
const diffHours = Math.floor((Date.now() - date.getTime()) / (1000 * 60 * 60));
if (diffHours < 24) {
return `${Math.max(diffHours, 0)}小时前`;
}
return new Intl.DateTimeFormat('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
}).format(date);
}
export default function Overview() {
const navigate = useNavigate();
const storedSchoolQuery = readStoredSchoolQuery();
const cachedSchoolResults =
storedSchoolQuery?.studentId && storedSchoolQuery?.semester
? readCachedValue<{
schedule: CourseResponse[];
grades: GradeResponse[];
}>(getSchoolResultsCacheKey(storedSchoolQuery.studentId, storedSchoolQuery.semester))
: null;
const cachedOverview = readCachedValue<{
profile: UserProfile | null;
recentFiles: FileMetadata[];
rootFiles: FileMetadata[];
schedule: CourseResponse[];
grades: GradeResponse[];
}>(getOverviewCacheKey());
const [profile, setProfile] = useState<UserProfile | null>(cachedOverview?.profile ?? readStoredSession()?.user ?? null);
const [recentFiles, setRecentFiles] = useState<FileMetadata[]>(cachedOverview?.recentFiles ?? []);
const [rootFiles, setRootFiles] = useState<FileMetadata[]>(cachedOverview?.rootFiles ?? []);
const [schedule, setSchedule] = useState<CourseResponse[]>(cachedOverview?.schedule ?? cachedSchoolResults?.schedule ?? []);
const [grades, setGrades] = useState<GradeResponse[]>(cachedOverview?.grades ?? cachedSchoolResults?.grades ?? []);
const currentHour = new Date().getHours();
let greeting = '晚上好';
if (currentHour < 6) greeting = '凌晨好';
@@ -18,18 +78,106 @@ export default function Overview() {
else if (currentHour < 18) greeting = '下午好';
const currentTime = new Date().toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' });
const recentWeekUploads = recentFiles.filter(
(file) => Date.now() - new Date(file.createdAt).getTime() <= 7 * 24 * 60 * 60 * 1000
).length;
const usedBytes = useMemo(
() => rootFiles.filter((file) => !file.directory).reduce((sum, file) => sum + file.size, 0),
[rootFiles]
);
const usedGb = usedBytes / 1024 / 1024 / 1024;
const storagePercent = Math.min((usedGb / 50) * 100, 100);
useEffect(() => {
let cancelled = false;
async function loadOverview() {
try {
const [user, filesRecent, filesRoot] = await Promise.all([
apiRequest<UserProfile>('/user/profile'),
apiRequest<FileMetadata[]>('/files/recent'),
apiRequest<PageResponse<FileMetadata>>('/files/list?path=%2F&page=0&size=100'),
]);
if (cancelled) {
return;
}
setProfile(user);
setRecentFiles(filesRecent);
setRootFiles(filesRoot.items);
const schoolQuery = readStoredSchoolQuery();
if (!schoolQuery?.studentId || !schoolQuery?.semester) {
writeCachedValue(getOverviewCacheKey(), {
profile: user,
recentFiles: filesRecent,
rootFiles: filesRoot.items,
schedule: [],
grades: [],
});
return;
}
const queryString = new URLSearchParams({
studentId: schoolQuery.studentId,
semester: schoolQuery.semester,
}).toString();
const [scheduleData, gradesData] = await Promise.all([
apiRequest<CourseResponse[]>(`/cqu/schedule?${queryString}`),
apiRequest<GradeResponse[]>(`/cqu/grades?${queryString}`),
]);
if (!cancelled) {
setSchedule(scheduleData);
setGrades(gradesData);
writeCachedValue(getOverviewCacheKey(), {
profile: user,
recentFiles: filesRecent,
rootFiles: filesRoot.items,
schedule: scheduleData,
grades: gradesData,
});
}
} catch {
const schoolQuery = readStoredSchoolQuery();
if (!cancelled && schoolQuery?.studentId && schoolQuery?.semester) {
const cachedSchoolResults = readCachedValue<{
schedule: CourseResponse[];
grades: GradeResponse[];
}>(getSchoolResultsCacheKey(schoolQuery.studentId, schoolQuery.semester));
if (cachedSchoolResults) {
setSchedule(cachedSchoolResults.schedule);
setGrades(cachedSchoolResults.grades);
}
}
}
}
loadOverview();
return () => {
cancelled = true;
};
}, []);
const latestSemester = grades[0]?.semester ?? '--';
const previewCourses = schedule.slice(0, 3);
return (
<div className="space-y-6">
{/* Hero Section */}
<motion.div
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="glass-panel rounded-3xl p-8 relative overflow-hidden"
>
<div className="absolute top-0 right-0 w-64 h-64 bg-[#336EFF] rounded-full mix-blend-screen filter blur-[100px] opacity-20" />
<div className="relative z-10 space-y-2">
<h1 className="text-3xl md:text-4xl font-bold text-white tracking-tight">tester5595</h1>
<h1 className="text-3xl md:text-4xl font-bold text-white tracking-tight">
{profile?.username ?? '访客'}
</h1>
<p className="text-[#336EFF] font-medium"> {currentTime} · {greeting}</p>
<p className="text-sm text-slate-400 mt-4 max-w-xl leading-relaxed">
@@ -39,10 +187,28 @@ export default function Overview() {
{/* Metrics Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<MetricCard title="网盘文件总数" value="128" desc="包含 4 个分类" icon={FileText} delay={0.1} />
<MetricCard title="最近 7 天上传" value="6" desc="最新更新于 2 小时前" icon={Upload} delay={0.2} />
<MetricCard title="本周课程" value="18" desc="今日还有 2 节课" icon={BookOpen} delay={0.3} />
<MetricCard title="已录入成绩" value="42" desc="最近学期2025 秋" icon={GraduationCap} delay={0.4} />
<MetricCard title="网盘文件总数" value={`${rootFiles.length}`} desc="当前根目录统计" icon={FileText} delay={0.1} />
<MetricCard
title="最近 7 天上传"
value={`${recentWeekUploads}`}
desc={recentFiles[0] ? `最新更新于 ${formatRecentTime(recentFiles[0].createdAt)}` : '暂无最近上传'}
icon={Upload}
delay={0.2}
/>
<MetricCard
title="本周课程"
value={`${schedule.length}`}
desc={schedule.length > 0 ? `当前已同步 ${schedule.length} 节课` : '请先前往教务页查询'}
icon={BookOpen}
delay={0.3}
/>
<MetricCard
title="已录入成绩"
value={`${grades.length}`}
desc={`最近学期:${latestSemester}`}
icon={GraduationCap}
delay={0.4}
/>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
@@ -58,24 +224,25 @@ export default function Overview() {
</CardHeader>
<CardContent>
<div className="space-y-2">
{[
{ name: '软件工程期末复习资料.pdf', size: '2.4 MB', time: '2小时前' },
{ name: '2025春季学期课表.xlsx', size: '156 KB', time: '昨天 14:30' },
{ name: '项目架构设计图.png', size: '4.1 MB', time: '3天前' },
].map((file, i) => (
{recentFiles.slice(0, 3).map((file, i) => (
<div key={i} className="flex items-center justify-between p-3 rounded-xl hover:bg-white/5 transition-colors cursor-pointer group" onClick={() => navigate('/files')}>
<div className="flex items-center gap-4 overflow-hidden">
<div className="w-10 h-10 rounded-xl bg-[#336EFF]/10 flex items-center justify-center shrink-0 group-hover:bg-[#336EFF]/20 transition-colors">
<FileText className="w-5 h-5 text-[#336EFF]" />
</div>
<div className="truncate">
<p className="text-sm font-medium text-white truncate">{file.name}</p>
<p className="text-xs text-slate-400 mt-0.5">{file.time}</p>
<p className="text-sm font-medium text-white truncate">{file.filename}</p>
<p className="text-xs text-slate-400 mt-0.5">{formatRecentTime(file.createdAt)}</p>
</div>
</div>
<span className="text-xs text-slate-500 font-mono shrink-0 ml-4">{file.size}</span>
<span className="text-xs text-slate-500 font-mono shrink-0 ml-4">{formatFileSize(file.size)}</span>
</div>
))}
{recentFiles.length === 0 && (
<div className="p-3 rounded-xl border border-dashed border-white/10 text-sm text-slate-500">
</div>
)}
</div>
</CardContent>
</Card>
@@ -91,21 +258,24 @@ export default function Overview() {
</CardHeader>
<CardContent>
<div className="space-y-3">
{[
{ time: '08:00 - 09:35', name: '高等数学 (下)', room: '教1-204' },
{ time: '10:00 - 11:35', name: '大学物理', room: '教2-101' },
{ time: '14:00 - 15:35', name: '软件工程', room: '计科楼 302' },
].map((course, i) => (
{previewCourses.map((course, i) => (
<div key={i} className="flex items-center gap-4 p-4 rounded-xl border border-white/5 bg-white/[0.02] hover:bg-white/[0.04] transition-colors">
<div className="w-28 shrink-0 text-sm font-mono text-[#336EFF] bg-[#336EFF]/10 px-2 py-1 rounded-md text-center">{course.time}</div>
<div className="w-28 shrink-0 text-sm font-mono text-[#336EFF] bg-[#336EFF]/10 px-2 py-1 rounded-md text-center">
{course.startTime ?? '--'} - {course.endTime ?? '--'}
</div>
<div className="flex-1 truncate">
<p className="text-sm font-medium text-white truncate">{course.name}</p>
<p className="text-sm font-medium text-white truncate">{course.courseName}</p>
<p className="text-xs text-slate-400 flex items-center gap-1.5 mt-1">
<Clock className="w-3.5 h-3.5" /> {course.room}
<Clock className="w-3.5 h-3.5" /> {course.classroom ?? '教室待定'}
</p>
</div>
</div>
))}
{previewCourses.length === 0 && (
<div className="p-4 rounded-xl border border-dashed border-white/10 text-sm text-slate-500">
</div>
)}
</div>
</CardContent>
</Card>
@@ -136,13 +306,15 @@ export default function Overview() {
<CardContent className="space-y-5">
<div className="flex justify-between items-end">
<div className="space-y-1">
<p className="text-3xl font-bold text-white tracking-tight">12.6 <span className="text-sm text-slate-400 font-normal">GB</span></p>
<p className="text-3xl font-bold text-white tracking-tight">
{usedGb.toFixed(2)} <span className="text-sm text-slate-400 font-normal">GB</span>
</p>
<p className="text-xs text-slate-500 uppercase tracking-wider">使 / 50 GB</p>
</div>
<span className="text-xl font-mono text-[#336EFF] font-medium">25%</span>
<span className="text-xl font-mono text-[#336EFF] font-medium">{storagePercent.toFixed(1)}%</span>
</div>
<div className="h-2.5 w-full bg-black/40 rounded-full overflow-hidden shadow-inner">
<div className="h-full bg-gradient-to-r from-[#336EFF] to-blue-400 rounded-full" style={{ width: '25%' }} />
<div className="h-full bg-gradient-to-r from-[#336EFF] to-blue-400 rounded-full" style={{ width: `${storagePercent}%` }} />
</div>
</CardContent>
</Card>
@@ -155,11 +327,11 @@ export default function Overview() {
<CardContent>
<div className="flex items-center gap-4 p-4 rounded-xl bg-white/[0.02] border border-white/5">
<div className="w-12 h-12 rounded-full bg-gradient-to-br from-indigo-500 to-purple-500 flex items-center justify-center text-white font-bold text-xl shadow-lg">
T
{(profile?.username?.[0] ?? 'T').toUpperCase()}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold text-white truncate">tester5595</p>
<p className="text-xs text-slate-400 truncate mt-0.5">tester5595@example.com</p>
<p className="text-sm font-semibold text-white truncate">{profile?.username ?? '未登录'}</p>
<p className="text-xs text-slate-400 truncate mt-0.5">{profile?.email ?? '暂无邮箱'}</p>
</div>
</div>
</CardContent>

View File

@@ -1,23 +1,119 @@
import React, { useState } from 'react';
import React, { useEffect, useMemo, useState } from 'react';
import { motion } from 'motion/react';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/src/components/ui/card';
import { Button } from '@/src/components/ui/button';
import { Input } from '@/src/components/ui/input';
import { GraduationCap, Calendar, User, Lock, Search, BookOpen, ChevronRight, Award } from 'lucide-react';
import { Button } from '@/src/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/src/components/ui/card';
import { Input } from '@/src/components/ui/input';
import { apiRequest } from '@/src/lib/api';
import { readCachedValue, writeCachedValue } from '@/src/lib/cache';
import { getSchoolResultsCacheKey, readStoredSchoolQuery, writeStoredSchoolQuery } from '@/src/lib/page-cache';
import type { CourseResponse, GradeResponse } from '@/src/lib/types';
import { cn } from '@/src/lib/utils';
export default function School() {
const [activeTab, setActiveTab] = useState<'schedule' | 'grades'>('schedule');
const [loading, setLoading] = useState(false);
const [queried, setQueried] = useState(false);
function formatSections(startTime?: number | null, endTime?: number | null) {
if (!startTime || !endTime) {
return '节次待定';
}
return `${startTime}-${endTime}`;
}
export default function School() {
const storedQuery = readStoredSchoolQuery();
const initialStudentId = storedQuery?.studentId ?? '2023123456';
const initialSemester = storedQuery?.semester ?? '2025-spring';
const initialCachedResults = readCachedValue<{
queried: boolean;
schedule: CourseResponse[];
grades: GradeResponse[];
}>(getSchoolResultsCacheKey(initialStudentId, initialSemester));
const [activeTab, setActiveTab] = useState<'schedule' | 'grades'>('schedule');
const [studentId, setStudentId] = useState(initialStudentId);
const [password, setPassword] = useState('password123');
const [semester, setSemester] = useState(initialSemester);
const [loading, setLoading] = useState(false);
const [queried, setQueried] = useState(initialCachedResults?.queried ?? false);
const [schedule, setSchedule] = useState<CourseResponse[]>(initialCachedResults?.schedule ?? []);
const [grades, setGrades] = useState<GradeResponse[]>(initialCachedResults?.grades ?? []);
const averageGrade = useMemo(() => {
if (grades.length === 0) {
return '0.0';
}
const sum = grades.reduce((total, item) => total + (item.grade ?? 0), 0);
return (sum / grades.length).toFixed(1);
}, [grades]);
const loadSchoolData = async (
nextStudentId: string,
nextSemester: string,
options: { background?: boolean } = {}
) => {
const cacheKey = getSchoolResultsCacheKey(nextStudentId, nextSemester);
const cachedResults = readCachedValue<{
queried: boolean;
schedule: CourseResponse[];
grades: GradeResponse[];
}>(cacheKey);
if (!options.background) {
setLoading(true);
}
writeStoredSchoolQuery({
studentId: nextStudentId,
semester: nextSemester,
});
try {
const queryString = new URLSearchParams({
studentId: nextStudentId,
semester: nextSemester,
}).toString();
const [scheduleData, gradeData] = await Promise.all([
apiRequest<CourseResponse[]>(`/cqu/schedule?${queryString}`),
apiRequest<GradeResponse[]>(`/cqu/grades?${queryString}`),
]);
const handleQuery = (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setTimeout(() => {
setLoading(false);
setQueried(true);
}, 1500);
setSchedule(scheduleData);
setGrades(gradeData);
writeCachedValue(cacheKey, {
queried: true,
studentId: nextStudentId,
semester: nextSemester,
schedule: scheduleData,
grades: gradeData,
});
} catch {
if (!cachedResults) {
setQueried(false);
setSchedule([]);
setGrades([]);
}
} finally {
if (!options.background) {
setLoading(false);
}
}
};
useEffect(() => {
if (!storedQuery) {
return;
}
loadSchoolData(storedQuery.studentId, storedQuery.semester, {
background: true,
}).catch(() => undefined);
}, []);
const handleQuery = async (e: React.FormEvent) => {
e.preventDefault();
await loadSchoolData(studentId, semester);
};
return (
@@ -38,19 +134,19 @@ export default function School() {
<label className="text-xs font-medium text-slate-400 ml-1"></label>
<div className="relative">
<User className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" />
<Input defaultValue="2023123456" className="pl-9 bg-black/20" required />
<Input value={studentId} onChange={(event) => setStudentId(event.target.value)} className="pl-9 bg-black/20" required />
</div>
</div>
<div className="space-y-2">
<label className="text-xs font-medium text-slate-400 ml-1"></label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" />
<Input type="password" defaultValue="password123" className="pl-9 bg-black/20" required />
<Input type="password" value={password} onChange={(event) => setPassword(event.target.value)} className="pl-9 bg-black/20" required />
</div>
</div>
<div className="space-y-2">
<label className="text-xs font-medium text-slate-400 ml-1"></label>
<select className="flex h-11 w-full rounded-xl border border-white/10 bg-black/20 px-3 py-2 text-sm text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#336EFF]">
<select value={semester} onChange={(event) => setSemester(event.target.value)} className="flex h-11 w-full rounded-xl border border-white/10 bg-black/20 px-3 py-2 text-sm text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#336EFF]">
<option value="2025-spring">2025 </option>
<option value="2024-fall">2024 </option>
<option value="2024-spring">2024 </option>
@@ -81,9 +177,9 @@ export default function School() {
<CardContent>
{queried ? (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<SummaryItem label="当前缓存账号" value="2023123456" icon={User} />
<SummaryItem label="已保存课表学期" value="2025 春" icon={Calendar} />
<SummaryItem label="已保存成绩" value="3 个学期" icon={Award} />
<SummaryItem label="当前缓存账号" value={studentId} icon={User} />
<SummaryItem label="已保存课表学期" value={semester} icon={Calendar} />
<SummaryItem label="已保存成绩" value={`${averageGrade}`} icon={Award} />
</div>
) : (
<div className="h-40 flex flex-col items-center justify-center text-slate-500 space-y-3 border border-dashed border-white/10 rounded-xl bg-white/[0.01]">
@@ -100,8 +196,8 @@ export default function School() {
<button
onClick={() => setActiveTab('schedule')}
className={cn(
"px-6 py-2 text-sm font-medium rounded-lg transition-all",
activeTab === 'schedule' ? "bg-[#336EFF] text-white shadow-md" : "text-slate-400 hover:text-white"
'px-6 py-2 text-sm font-medium rounded-lg transition-all',
activeTab === 'schedule' ? 'bg-[#336EFF] text-white shadow-md' : 'text-slate-400 hover:text-white'
)}
>
@@ -109,8 +205,8 @@ export default function School() {
<button
onClick={() => setActiveTab('grades')}
className={cn(
"px-6 py-2 text-sm font-medium rounded-lg transition-all",
activeTab === 'grades' ? "bg-[#336EFF] text-white shadow-md" : "text-slate-400 hover:text-white"
'px-6 py-2 text-sm font-medium rounded-lg transition-all',
activeTab === 'grades' ? 'bg-[#336EFF] text-white shadow-md' : 'text-slate-400 hover:text-white'
)}
>
@@ -124,7 +220,7 @@ export default function School() {
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
>
{activeTab === 'schedule' ? <ScheduleView queried={queried} /> : <GradesView queried={queried} />}
{activeTab === 'schedule' ? <ScheduleView queried={queried} schedule={schedule} /> : <GradesView queried={queried} grades={grades} />}
</motion.div>
</div>
);
@@ -165,7 +261,7 @@ function SummaryItem({ label, value, icon: Icon }: any) {
);
}
function ScheduleView({ queried }: { queried: boolean }) {
function ScheduleView({ queried, schedule }: { queried: boolean; schedule: CourseResponse[] }) {
if (!queried) {
return (
<Card>
@@ -178,14 +274,6 @@ function ScheduleView({ queried }: { queried: boolean }) {
}
const days = ['周一', '周二', '周三', '周四', '周五'];
const mockSchedule = [
{ day: 0, time: '08:00 - 09:35', name: '高等数学 (下)', room: '教1-204' },
{ day: 0, time: '10:00 - 11:35', name: '大学物理', room: '教2-101' },
{ day: 1, time: '14:00 - 15:35', name: '软件工程', room: '计科楼 302' },
{ day: 2, time: '08:00 - 09:35', name: '数据结构', room: '教1-105' },
{ day: 3, time: '16:00 - 17:35', name: '计算机网络', room: '计科楼 401' },
{ day: 4, time: '10:00 - 11:35', name: '操作系统', room: '教3-202' },
];
return (
<Card>
@@ -194,36 +282,39 @@ function ScheduleView({ queried }: { queried: boolean }) {
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-5 gap-4">
{days.map((day, index) => (
<div key={day} className="space-y-3">
<div className="text-center py-2 bg-white/5 rounded-lg text-sm font-medium text-slate-300">
{day}
{days.map((day, index) => {
const dayCourses = schedule.filter((item) => (item.dayOfWeek ?? 0) - 1 === index);
return (
<div key={day} className="space-y-3">
<div className="text-center py-2 bg-white/5 rounded-lg text-sm font-medium text-slate-300">
{day}
</div>
<div className="space-y-2">
{dayCourses.map((course, i) => (
<div key={i} className="p-3 rounded-xl bg-[#336EFF]/10 border border-[#336EFF]/20 hover:bg-[#336EFF]/20 transition-colors">
<p className="text-xs font-mono text-[#336EFF] mb-1">{formatSections(course.startTime, course.endTime)}</p>
<p className="text-sm font-medium text-white leading-tight mb-2">{course.courseName}</p>
<p className="text-xs text-slate-400 flex items-center gap-1">
<ChevronRight className="w-3 h-3" /> {course.classroom ?? '教室待定'}
</p>
</div>
))}
{dayCourses.length === 0 && (
<div className="h-24 rounded-xl border border-dashed border-white/10 flex items-center justify-center text-xs text-slate-500">
</div>
)}
</div>
</div>
<div className="space-y-2">
{mockSchedule.filter(s => s.day === index).map((course, i) => (
<div key={i} className="p-3 rounded-xl bg-[#336EFF]/10 border border-[#336EFF]/20 hover:bg-[#336EFF]/20 transition-colors">
<p className="text-xs font-mono text-[#336EFF] mb-1">{course.time}</p>
<p className="text-sm font-medium text-white leading-tight mb-2">{course.name}</p>
<p className="text-xs text-slate-400 flex items-center gap-1">
<ChevronRight className="w-3 h-3" /> {course.room}
</p>
</div>
))}
{mockSchedule.filter(s => s.day === index).length === 0 && (
<div className="h-24 rounded-xl border border-dashed border-white/10 flex items-center justify-center text-xs text-slate-500">
</div>
)}
</div>
</div>
))}
);
})}
</div>
</CardContent>
</Card>
);
}
function GradesView({ queried }: { queried: boolean }) {
function GradesView({ queried, grades }: { queried: boolean; grades: GradeResponse[] }) {
if (!queried) {
return (
<Card>
@@ -235,20 +326,14 @@ function GradesView({ queried }: { queried: boolean }) {
);
}
const terms = [
{
name: '2024 秋',
grades: [75, 78, 80, 83, 85, 88, 89, 96]
},
{
name: '2025 春',
grades: [70, 78, 82, 84, 85, 85, 86, 88, 93]
},
{
name: '2025 秋',
grades: [68, 70, 76, 80, 85, 86, 90, 94, 97]
const terms = grades.reduce<Record<string, number[]>>((accumulator, grade) => {
const semester = grade.semester ?? '未分类';
if (!accumulator[semester]) {
accumulator[semester] = [];
}
];
accumulator[semester].push(grade.grade ?? 0);
return accumulator;
}, {});
const getScoreStyle = (score: number) => {
if (score >= 95) return 'bg-[#336EFF]/50 text-white';
@@ -266,15 +351,15 @@ function GradesView({ queried }: { queried: boolean }) {
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
{terms.map((term, i) => (
{Object.entries(terms).map(([term, scores], i) => (
<div key={i} className="flex flex-col">
<h3 className="text-sm font-bold text-white border-b border-white/5 pb-3 mb-4">{term.name}</h3>
<h3 className="text-sm font-bold text-white border-b border-white/5 pb-3 mb-4">{term}</h3>
<div className="flex flex-col gap-2">
{term.grades.map((score, j) => (
<div
key={j}
{scores.map((score, j) => (
<div
key={j}
className={cn(
"w-full py-1.5 rounded-full text-xs font-mono font-medium text-center transition-colors",
'w-full py-1.5 rounded-full text-xs font-mono font-medium text-center transition-colors',
getScoreStyle(score)
)}
>
@@ -284,6 +369,7 @@ function GradesView({ queried }: { queried: boolean }) {
</div>
</div>
))}
{Object.keys(terms).length === 0 && <div className="text-sm text-slate-500"></div>}
</div>
</CardContent>
</Card>

1
front/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@@ -1,10 +1,12 @@
import tailwindcss from '@tailwindcss/vite';
import react from '@vitejs/plugin-react';
import path from 'path';
import {defineConfig, loadEnv} from 'vite';
import { defineConfig, loadEnv } from 'vite';
export default defineConfig(({mode}) => {
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, '.', '');
const backendUrl = env.VITE_BACKEND_URL || 'http://localhost:8080';
return {
plugins: [react(), tailwindcss()],
define: {
@@ -16,9 +18,13 @@ export default defineConfig(({mode}) => {
},
},
server: {
// HMR is disabled in AI Studio via DISABLE_HMR env var.
// Do not modify—file watching is disabled to prevent flickering during agent edits.
hmr: process.env.DISABLE_HMR !== 'true',
proxy: {
'/api': {
target: backendUrl,
changeOrigin: true,
},
},
},
};
});