From 8b0f77fa21c051c123ebc5e2d7258ca94f71b018 Mon Sep 17 00:00:00 2001 From: yoyuzh Date: Wed, 18 Mar 2026 11:50:03 +0800 Subject: [PATCH] add sign in page --- .env.oss.example | 5 + .gitignore | 3 + NEXT_CODEX_HANDOFF.md | 280 ++++++++++++++++++ front/src/lib/api.test.ts | 32 +- front/src/lib/api.ts | 90 +++++- front/src/lib/session.ts | 25 ++ front/src/pages/Login.tsx | 389 ++++++++++++++++++------- front/src/pages/Overview.tsx | 102 +++++-- front/src/pages/overview-state.test.ts | 18 ++ front/src/pages/overview-state.ts | 7 + scripts/deploy-front-oss.mjs | 154 ++++++++++ scripts/oss-deploy-lib.mjs | 126 ++++++++ scripts/oss-deploy-lib.test.mjs | 46 +++ 模板/Login.tsx | 261 +++++++++++++++++ 14 files changed, 1408 insertions(+), 130 deletions(-) create mode 100644 .env.oss.example create mode 100644 NEXT_CODEX_HANDOFF.md create mode 100644 front/src/pages/overview-state.test.ts create mode 100644 front/src/pages/overview-state.ts create mode 100755 scripts/deploy-front-oss.mjs create mode 100644 scripts/oss-deploy-lib.mjs create mode 100644 scripts/oss-deploy-lib.test.mjs create mode 100644 模板/Login.tsx diff --git a/.env.oss.example b/.env.oss.example new file mode 100644 index 0000000..1ee86c3 --- /dev/null +++ b/.env.oss.example @@ -0,0 +1,5 @@ +YOYUZH_OSS_ENDPOINT=https://oss-ap-northeast-1.aliyuncs.com +YOYUZH_OSS_BUCKET=yoyuzh-2026 +YOYUZH_OSS_PREFIX= +YOYUZH_OSS_ACCESS_KEY_ID= +YOYUZH_OSS_ACCESS_KEY_SECRET= diff --git a/.gitignore b/.gitignore index 1231c0e..c58ff45 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,6 @@ backend-dev.err.log frontend-dev.out.log frontend-dev.err.log vue/dist/ +.env.oss.local +账号密码.txt +.history/ \ No newline at end of file diff --git a/NEXT_CODEX_HANDOFF.md b/NEXT_CODEX_HANDOFF.md new file mode 100644 index 0000000..d22840c --- /dev/null +++ b/NEXT_CODEX_HANDOFF.md @@ -0,0 +1,280 @@ +# 项目交接说明 + +更新时间:2026-03-18 +项目根目录:`/Users/mac/Documents/my_site` + +## 1. 项目概况 + +这是一个前后端分离的个人门户项目: + +- 前端:`front/` +- 后端:`backend/` +- 线上主站:`https://yoyuzh.xyz` +- 线上 API:`https://api.yoyuzh.xyz` + +主要功能: + +- 登录 / 注册 +- 网盘文件列表与最近文件 +- 教务相关接口(课表 / 成绩) + +## 2. 当前线上架构 + +当前建议保持的生产架构是: + +- `yoyuzh.xyz`:继续走 OSS / ESA,负责静态站点 +- `api.yoyuzh.xyz`:直接指向后端服务器,不要继续走 ESA 代理 + +原因: + +- 主站静态资源走 ESA 没问题 +- API 一旦走 ESA,之前出现过: + - `525 Origin SSL Handshake Error` + - `ERR_CONNECTION_CLOSED` + - `ERR_EMPTY_RESPONSE` +- 当前最稳方案是“静态站加速,API 直连” + +## 3. 当前前端生产配置 + +文件: + +- `front/.env.production` + +当前应保持为: + +```env +VITE_API_BASE_URL="https://api.yoyuzh.xyz/api" +VITE_ROUTER_MODE="hash" +VITE_ENABLE_DEV_LOGIN="false" +``` + +说明: + +- 不要再切回同域 `/api`,除非以后重新正确配置边缘转发 +- 目前生产前端已经恢复为直连 `api.yoyuzh.xyz` + +## 4. 当前已完成的前端改动 + +### 4.1 登录页 + +`front/src/pages/Login.tsx` + +- 已替换为模板版登录 / 注册页 +- 登录调用:`POST /auth/login` +- 注册调用:`POST /auth/register` +- 登录成功后写入 session 并跳转 `/overview` + +### 4.2 网络错误处理 + +`front/src/lib/api.ts` + +- 对网络错误统一包装为更清晰的前端错误 +- 登录和只读接口有轻量重试机制 +- 当前策略是“尽量兜底,但不要把登录拖到 7-8 秒” + +### 4.3 登录成功与总览初始化失败拆分提示 + +相关文件: + +- `front/src/lib/session.ts` +- `front/src/pages/Overview.tsx` +- `front/src/pages/overview-state.ts` + +已做: + +- 登录成功后会标记一次“post-login pending” +- `/overview` 初始化失败时,会提示“登录已成功,但总览加载失败” +- 避免把 overview 并发初始化失败误判成登录失败 + +## 5. 当前后端与服务器状态 + +通过 SSH 已确认: + +- `my-site-api.service` 正常运行 +- 后端本机 `127.0.0.1:8080` 正常 +- 服务器本机直打登录接口返回 `200` +- Nginx 正常反代 `api.yoyuzh.xyz -> 127.0.0.1:8080` + +服务器上关键配置: + +- Nginx:`/etc/nginx/sites-enabled/my-site-api` +- 后端配置:`/opt/yoyuzh/application-prod.yml` +- 服务名:`my-site-api.service` + +## 6. 线上排查结论 + +### 6.1 已确认不是后端业务慢 + +在服务器本机测试结果: + +- `POST http://127.0.0.1:8080/api/auth/login` 约 `95ms` +- `POST https://api.yoyuzh.xyz/api/auth/login` 从服务器发起约 `681ms` + +所以: + +- 后端本身不是“登录 5 秒”的根因 + +### 6.2 之前登录很慢的主要原因 + +更像是以下问题叠加: + +- 旧 DNS / 旧代理链路未收敛 +- API 域名曾被 ESA 代理,导致 TLS / 回源问题 +- 浏览器前链路偶发 `ERR_CONNECTION_CLOSED` + +### 6.3 当前更可信的状态 + +服务器日志已经看到真实浏览器请求成功: + +- `OPTIONS /api/auth/login` => `200` +- `POST /api/auth/login` => `200` +- `/api/user/profile` => `200` +- `/api/files/recent` => `200` +- `/api/files/list` => `200` +- `/api/cqu/*` => `200` + +因此: + +- 现在如果仍有个别客户端不稳定,优先怀疑本地 DNS / 浏览器缓存 / 本地网络链路 + +## 7. DNS / ESA 方面的重要结论 + +### 7.1 过去踩过的坑 + +不要再轻易做下面这件事: + +- 让 `yoyuzh.xyz/api/*` 通过 ESA 回源到 API + +之前已经明确踩到: + +- `/api/*` 误回 OSS,报 `403 NonCnameForbidden` +- 回源 HTTPS 握手失败,报 `525 Origin SSL Handshake Error` + +### 7.2 当前建议 + +- `yoyuzh.xyz`:可以继续 ESA +- `api.yoyuzh.xyz`:不要走 ESA 代理 + +### 7.3 用户侧现象 + +曾出现: + +- 无痕模式能登录 +- 本机 `dig` 还查到旧的 `198.18.0.148` + +这说明某一阶段存在 DNS 传播不一致。 +如果下一个 Codex 遇到“浏览器能用,命令行不行”的情况,先查 DNS 链路,不要直接改代码。 + +## 8. OSS 前端部署方式 + +已经写好自动部署脚本: + +- `scripts/deploy-front-oss.mjs` +- 配套库:`scripts/oss-deploy-lib.mjs` +- 配置模板:`.env.oss.example` + +### 8.1 本地使用方式 + +先准备: + +```bash +cp .env.oss.example .env.oss.local +``` + +然后填入 OSS 参数。 + +### 8.2 发布命令 + +```bash +./scripts/deploy-front-oss.mjs +``` + +### 8.3 只看将要上传什么 + +```bash +./scripts/deploy-front-oss.mjs --skip-build --dry-run +``` + +### 8.4 部署逻辑 + +脚本会: + +- 读取 `.env.oss.local` +- 构建 `front/dist` +- 上传到 OSS +- 自动设置缓存头 + +缓存策略: + +- `index.html` => `no-cache` +- `assets/*` => `public,max-age=31536000,immutable` + +## 9. 测试账号 + +开发测试账号文档: + +- `开发测试账号.md` + +常用账号: + +- `portal-demo / portal123456` + +注意: + +- 这些开发账号只在特定环境下才会自动初始化 +- 如果线上账号密码不对,不要默认认为后端坏了 + +## 10. SSH 与敏感信息 + +有 SSH 凭据文件: + +- `账号密码.txt` + +下一个 Codex 可以读取该文件用于 SSH,但不要在普通交互回复里直接回显其中的明文密码。 + +## 11. 推荐的排查顺序 + +如果后续又出现“登录失败 / 网络连接异常”,按这个顺序排: + +1. 先查前端当前生产包是否正确 + - 看 `https://yoyuzh.xyz/` 的 `index.html` + - 确认引用的是最新构建产物 + +2. 再查 API 域名是否直连服务器 + - `dig +short api.yoyuzh.xyz` + - `curl -vkI https://api.yoyuzh.xyz/` + +3. 再查服务器本机和 systemd + - `systemctl status my-site-api` + - `curl http://127.0.0.1:8080/...` + +4. 最后查 Nginx access/error log + - `/var/log/nginx/access.log` + - `/var/log/nginx/error.log` + +不要上来就改前端逻辑。 + +## 12. 当前最重要的改进建议 + +### 短期建议 + +- 保持 API 直连,不再给 `api.yoyuzh.xyz` 套 ESA +- 用现有自动部署脚本发布前端 + +### 中期建议 + +- 给后端加一个明确的健康检查接口,比如 `/api/healthz` +- 给 Nginx access log 加 upstream timing 和 request id + +### 长期建议 + +- 如果未来还想做同域 `/api`,要单独做一轮边缘转发设计 +- 先确保: + - 源站类型正确 + - 不会回 OSS + - 不会再发生 `525` + +## 13. 给下一个 Codex 的一句话总结 + +当前项目已经从“链路混乱”恢复到“后端基本正常、主站正常、前端直连 API”的状态。 +接手时优先维持现状,不要贸然重新启用 ESA 的 `/api` 回源方案。 diff --git a/front/src/lib/api.test.ts b/front/src/lib/api.test.ts index 6b0bac2..978ff38 100644 --- a/front/src/lib/api.test.ts +++ b/front/src/lib/api.test.ts @@ -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/); +}); diff --git a/front/src/lib/api.ts b/front/src/lib/api.ts index 08167be..817f638 100644 --- a/front/src/lib/api.ts +++ b/front/src/lib/api.ts @@ -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(); diff --git a/front/src/lib/session.ts b/front/src/lib/session.ts index 405d153..6b214d7 100644 --- a/front/src/lib/session.ts +++ b/front/src/lib/session.ts @@ -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); +} diff --git a/front/src/pages/Login.tsx b/front/src/pages/Login.tsx index 6ac0a7f..8a5cfe8 100644 --- a/front/src/pages/Login.tsx +++ b/front/src/pages/Login.tsx @@ -1,25 +1,36 @@ import React, { useState } from 'react'; import { useNavigate } from 'react-router-dom'; -import { motion } from 'motion/react'; -import { LogIn, User, Lock } from 'lucide-react'; +import { motion, AnimatePresence } from 'motion/react'; +import { LogIn, User, Lock, UserPlus, Mail, ArrowLeft } from 'lucide-react'; -import { Button } from '@/src/components/ui/button'; 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 { apiRequest, ApiError } from '@/src/lib/api'; -import { saveStoredSession } from '@/src/lib/session'; +import { cn } from '@/src/lib/utils'; +import { markPostLoginPending, 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 [isLogin, setIsLogin] = useState(true); const [loading, setLoading] = useState(false); const [error, setError] = useState(''); + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + const [registerUsername, setRegisterUsername] = useState(''); + const [registerEmail, setRegisterEmail] = useState(''); + const [registerPassword, setRegisterPassword] = useState(''); - const handleLogin = async (e: React.FormEvent) => { + function switchMode(nextIsLogin: boolean) { + setIsLogin(nextIsLogin); + setError(''); + setLoading(false); + } + + async function handleLoginSubmit(e: React.FormEvent) { e.preventDefault(); setLoading(true); setError(''); @@ -52,119 +63,287 @@ export default function Login() { token: auth.token, user: auth.user, }); + markPostLoginPending(); setLoading(false); navigate('/overview'); } catch (requestError) { setLoading(false); setError(requestError instanceof Error ? requestError.message : '登录失败,请稍后重试'); } - }; + } + + async function handleRegisterSubmit(e: React.FormEvent) { + e.preventDefault(); + setLoading(true); + setError(''); + + try { + const auth = await apiRequest('/auth/register', { + method: 'POST', + body: { + username: registerUsername.trim(), + email: registerEmail.trim(), + password: registerPassword, + }, + }); + + saveStoredSession({ + token: auth.token, + user: auth.user, + }); + markPostLoginPending(); + setLoading(false); + navigate('/overview'); + } catch (requestError) { + setLoading(false); + setError(requestError instanceof Error ? requestError.message : '注册失败,请稍后重试'); + } + } return (
- {/* Background Glow */}
-
- {/* Left Side: Brand Info */} +
+ + {isLogin && ( + +
+ + Access Portal +
+ +
+

YOYUZH.XYZ

+

+ 个人网站 +
+ 统一入口 +

+
+ +

+ 欢迎来到 YOYUZH 的个人门户。在这里,你可以集中管理个人网盘文件、查询教务成绩课表,以及体验轻量级小游戏。 +

+
+ )} +
+ -
- - Access Portal -
- -
-

YOYUZH.XYZ

-

- 个人网站 -
- 统一入口 -

-
- -

- 欢迎来到 YOYUZH 的个人门户。在这里,你可以集中管理个人网盘文件、查询教务成绩课表,以及体验轻量级小游戏。 -

-
- - {/* Right Side: Login Form */} - - - - - - 登录 - - - 请输入您的账号和密码以继续 - - - -
-
-
- -
- - setUsername(event.target.value)} - required - /> -
-
-
- -
- - setPassword(event.target.value)} - required - /> -
-
-
- - {error && ( -
- {error} -
- )} - - -
-
+ + + + 登录 + + + 请输入您的账号和密码以继续 + + + +
+
+
+ +
+ + setUsername(event.target.value)} + required + /> +
+
+
+ +
+ + setPassword(event.target.value)} + required + /> +
+
+
+ + {error && ( +
+ {error} +
+ )} + +
+ +
+ +
+
+
+
+
+ ) : ( + + +
+ + + 注册账号 + + +
+ + 创建一个新账号以开启您的门户体验 + +
+ +
+
+
+ +
+ + setRegisterUsername(event.target.value)} + required + minLength={3} + maxLength={64} + /> +
+
+
+ +
+ + setRegisterEmail(event.target.value)} + required + /> +
+
+
+ +
+ + setRegisterPassword(event.target.value)} + required + minLength={6} + maxLength={64} + /> +
+
+
+ + {error && ( +
+ {error} +
+ )} + +
+ +
+ +
+
+
+
+
+ )} +
diff --git a/front/src/pages/Overview.tsx b/front/src/pages/Overview.tsx index f476bc1..dd55bdf 100644 --- a/front/src/pages/Overview.tsx +++ b/front/src/pages/Overview.tsx @@ -20,8 +20,9 @@ import { apiRequest } from '@/src/lib/api'; import { readCachedValue, writeCachedValue } from '@/src/lib/cache'; import { getOverviewCacheKey, getSchoolResultsCacheKey, readStoredSchoolQuery, writeStoredSchoolQuery } from '@/src/lib/page-cache'; import { cacheLatestSchoolData, fetchLatestSchoolData } from '@/src/lib/school'; -import { readStoredSession } from '@/src/lib/session'; +import { clearPostLoginPending, hasPostLoginPending, readStoredSession } from '@/src/lib/session'; import type { CourseResponse, FileMetadata, GradeResponse, PageResponse, UserProfile } from '@/src/lib/types'; +import { getOverviewLoadErrorMessage } from './overview-state'; function formatFileSize(size: number) { if (size <= 0) { @@ -71,6 +72,8 @@ export default function Overview() { const [rootFiles, setRootFiles] = useState(cachedOverview?.rootFiles ?? []); const [schedule, setSchedule] = useState(cachedOverview?.schedule ?? cachedSchoolResults?.schedule ?? []); const [grades, setGrades] = useState(cachedOverview?.grades ?? cachedSchoolResults?.grades ?? []); + const [loadingError, setLoadingError] = useState(''); + const [retryToken, setRetryToken] = useState(0); const currentHour = new Date().getHours(); let greeting = '晚上好'; @@ -93,24 +96,38 @@ export default function Overview() { let cancelled = false; async function loadOverview() { + const pendingAfterLogin = hasPostLoginPending(); + setLoadingError(''); + try { - const [user, filesRecent, filesRoot] = await Promise.all([ + const [userResult, recentResult, rootResult] = await Promise.allSettled([ apiRequest('/user/profile'), apiRequest('/files/recent'), apiRequest>('/files/list?path=%2F&page=0&size=100'), ]); + const primaryFailures = [userResult, recentResult, rootResult].filter( + (result) => result.status === 'rejected' + ); + if (cancelled) { return; } - setProfile(user); - setRecentFiles(filesRecent); - setRootFiles(filesRoot.items); + if (userResult.status === 'fulfilled') { + setProfile(userResult.value); + } + if (recentResult.status === 'fulfilled') { + setRecentFiles(recentResult.value); + } + if (rootResult.status === 'fulfilled') { + setRootFiles(rootResult.value.items); + } let scheduleData: CourseResponse[] = []; let gradesData: GradeResponse[] = []; const schoolQuery = readStoredSchoolQuery(); + let schoolFailed = false; if (schoolQuery?.studentId && schoolQuery?.semester) { const queryString = new URLSearchParams({ @@ -118,20 +135,35 @@ export default function Overview() { semester: schoolQuery.semester, }).toString(); - [scheduleData, gradesData] = await Promise.all([ + const [scheduleResult, gradesResult] = await Promise.allSettled([ apiRequest(`/cqu/schedule?${queryString}`), apiRequest(`/cqu/grades?${queryString}`), ]); + + if (scheduleResult.status === 'fulfilled') { + scheduleData = scheduleResult.value; + } else { + schoolFailed = true; + } + if (gradesResult.status === 'fulfilled') { + gradesData = gradesResult.value; + } else { + schoolFailed = true; + } } else { - const latest = await fetchLatestSchoolData(); - if (latest) { - cacheLatestSchoolData(latest); - writeStoredSchoolQuery({ - studentId: latest.studentId, - semester: latest.semester, - }); - scheduleData = latest.schedule; - gradesData = latest.grades; + try { + const latest = await fetchLatestSchoolData(); + if (latest) { + cacheLatestSchoolData(latest); + writeStoredSchoolQuery({ + studentId: latest.studentId, + semester: latest.semester, + }); + scheduleData = latest.schedule; + gradesData = latest.grades; + } + } catch { + schoolFailed = true; } } @@ -139,12 +171,27 @@ export default function Overview() { setSchedule(scheduleData); setGrades(gradesData); writeCachedValue(getOverviewCacheKey(), { - profile: user, - recentFiles: filesRecent, - rootFiles: filesRoot.items, + profile: + userResult.status === 'fulfilled' + ? userResult.value + : profile, + recentFiles: + recentResult.status === 'fulfilled' + ? recentResult.value + : recentFiles, + rootFiles: + rootResult.status === 'fulfilled' + ? rootResult.value.items + : rootFiles, schedule: scheduleData, grades: gradesData, }); + + if (primaryFailures.length > 0 || schoolFailed) { + setLoadingError(getOverviewLoadErrorMessage(pendingAfterLogin)); + } else { + clearPostLoginPending(); + } } } catch { const schoolQuery = readStoredSchoolQuery(); @@ -159,6 +206,10 @@ export default function Overview() { setGrades(cachedSchoolResults.grades); } } + + if (!cancelled) { + setLoadingError(getOverviewLoadErrorMessage(pendingAfterLogin)); + } } } @@ -166,7 +217,7 @@ export default function Overview() { return () => { cancelled = true; }; - }, []); + }, [retryToken]); const latestSemester = grades[0]?.semester ?? '--'; const previewCourses = schedule.slice(0, 3); @@ -191,6 +242,19 @@ export default function Overview() {
+ {loadingError && ( + + + + {loadingError} + + + + + )} + {/* Metrics Cards */}
diff --git a/front/src/pages/overview-state.test.ts b/front/src/pages/overview-state.test.ts new file mode 100644 index 0000000..0cef8f3 --- /dev/null +++ b/front/src/pages/overview-state.test.ts @@ -0,0 +1,18 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; + +import { getOverviewLoadErrorMessage } from './overview-state'; + +test('post-login failures are presented as overview initialization issues', () => { + assert.equal( + getOverviewLoadErrorMessage(true), + '登录已成功,但总览数据加载失败,请稍后重试。' + ); +}); + +test('generic overview failures stay generic when not coming right after login', () => { + assert.equal( + getOverviewLoadErrorMessage(false), + '总览数据加载失败,请稍后重试。' + ); +}); diff --git a/front/src/pages/overview-state.ts b/front/src/pages/overview-state.ts new file mode 100644 index 0000000..4fcc3af --- /dev/null +++ b/front/src/pages/overview-state.ts @@ -0,0 +1,7 @@ +export function getOverviewLoadErrorMessage(isPostLoginFailure: boolean) { + if (isPostLoginFailure) { + return '登录已成功,但总览数据加载失败,请稍后重试。'; + } + + return '总览数据加载失败,请稍后重试。'; +} diff --git a/scripts/deploy-front-oss.mjs b/scripts/deploy-front-oss.mjs new file mode 100755 index 0000000..4327e61 --- /dev/null +++ b/scripts/deploy-front-oss.mjs @@ -0,0 +1,154 @@ +#!/usr/bin/env node + +import fs from 'node:fs/promises'; +import path from 'node:path'; +import process from 'node:process'; +import {spawnSync} from 'node:child_process'; + +import { + buildObjectKey, + createAuthorizationHeader, + encodeObjectKey, + getCacheControl, + getContentType, + listFiles, + normalizeEndpoint, + parseSimpleEnv, +} from './oss-deploy-lib.mjs'; + +const repoRoot = process.cwd(); +const frontDir = path.join(repoRoot, 'front'); +const distDir = path.join(frontDir, 'dist'); +const envFilePath = path.join(repoRoot, '.env.oss.local'); + +function parseArgs(argv) { + return { + dryRun: argv.includes('--dry-run'), + skipBuild: argv.includes('--skip-build'), + }; +} + +async function loadEnvFileIfPresent() { + try { + const raw = await fs.readFile(envFilePath, 'utf-8'); + const values = parseSimpleEnv(raw); + for (const [key, value] of Object.entries(values)) { + if (!process.env[key]) { + process.env[key] = value; + } + } + } catch (error) { + if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') { + return; + } + + throw error; + } +} + +function requireEnv(name) { + const value = process.env[name]; + if (!value) { + throw new Error(`Missing required environment variable: ${name}`); + } + + return value; +} + +function runBuild() { + const result = spawnSync('npm', ['run', 'build'], { + cwd: frontDir, + stdio: 'inherit', + shell: process.platform === 'win32', + }); + + if (result.status !== 0) { + throw new Error('Frontend build failed'); + } +} + +async function uploadFile({ + bucket, + endpoint, + objectKey, + filePath, + accessKeyId, + accessKeySecret, +}) { + const body = await fs.readFile(filePath); + const contentType = getContentType(objectKey); + const date = new Date().toUTCString(); + const url = `https://${bucket}.${normalizeEndpoint(endpoint)}/${encodeObjectKey(objectKey)}`; + const authorization = createAuthorizationHeader({ + method: 'PUT', + bucket, + objectKey, + contentType, + date, + accessKeyId, + accessKeySecret, + }); + + const response = await fetch(url, { + method: 'PUT', + headers: { + Authorization: authorization, + 'Cache-Control': getCacheControl(objectKey), + 'Content-Length': String(body.byteLength), + 'Content-Type': contentType, + Date: date, + }, + body, + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`Upload failed for ${objectKey}: ${response.status} ${response.statusText}\n${text}`); + } +} + +async function main() { + const {dryRun, skipBuild} = parseArgs(process.argv.slice(2)); + + await loadEnvFileIfPresent(); + + const accessKeyId = requireEnv('YOYUZH_OSS_ACCESS_KEY_ID'); + const accessKeySecret = requireEnv('YOYUZH_OSS_ACCESS_KEY_SECRET'); + const endpoint = requireEnv('YOYUZH_OSS_ENDPOINT'); + const bucket = requireEnv('YOYUZH_OSS_BUCKET'); + const remotePrefix = process.env.YOYUZH_OSS_PREFIX || ''; + + if (!skipBuild) { + runBuild(); + } + + const files = await listFiles(distDir); + if (files.length === 0) { + throw new Error('No files found in front/dist. Run the frontend build first.'); + } + + for (const filePath of files) { + const relativePath = path.relative(distDir, filePath).split(path.sep).join('/'); + const objectKey = buildObjectKey(remotePrefix, relativePath); + + if (dryRun) { + console.log(`[dry-run] upload ${relativePath} -> ${objectKey}`); + continue; + } + + await uploadFile({ + bucket, + endpoint, + objectKey, + filePath, + accessKeyId, + accessKeySecret, + }); + console.log(`uploaded ${objectKey}`); + } +} + +main().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; +}); diff --git a/scripts/oss-deploy-lib.mjs b/scripts/oss-deploy-lib.mjs new file mode 100644 index 0000000..7e20762 --- /dev/null +++ b/scripts/oss-deploy-lib.mjs @@ -0,0 +1,126 @@ +import crypto from 'node:crypto'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +const CONTENT_TYPES = new Map([ + ['.css', 'text/css; charset=utf-8'], + ['.html', 'text/html; charset=utf-8'], + ['.ico', 'image/x-icon'], + ['.jpeg', 'image/jpeg'], + ['.jpg', 'image/jpeg'], + ['.js', 'text/javascript; charset=utf-8'], + ['.json', 'application/json; charset=utf-8'], + ['.map', 'application/json; charset=utf-8'], + ['.png', 'image/png'], + ['.svg', 'image/svg+xml'], + ['.txt', 'text/plain; charset=utf-8'], + ['.webmanifest', 'application/manifest+json; charset=utf-8'], +]); + +export function normalizeEndpoint(endpoint) { + return endpoint.replace(/^https?:\/\//, '').replace(/\/+$/, ''); +} + +export function buildObjectKey(prefix, relativePath) { + const cleanPrefix = prefix.replace(/^\/+|\/+$/g, ''); + const cleanRelativePath = relativePath.replace(/^\/+/, ''); + return cleanPrefix ? `${cleanPrefix}/${cleanRelativePath}` : cleanRelativePath; +} + +export function getCacheControl(relativePath) { + if (relativePath === 'index.html') { + return 'no-cache'; + } + + if (relativePath.startsWith('assets/')) { + return 'public,max-age=31536000,immutable'; + } + + return 'public,max-age=300'; +} + +export function getContentType(relativePath) { + const ext = path.extname(relativePath).toLowerCase(); + return CONTENT_TYPES.get(ext) || 'application/octet-stream'; +} + +export function createAuthorizationHeader({ + method, + bucket, + objectKey, + contentType, + date, + accessKeyId, + accessKeySecret, +}) { + const stringToSign = [ + method.toUpperCase(), + '', + contentType, + date, + `/${bucket}/${objectKey}`, + ].join('\n'); + + const signature = crypto + .createHmac('sha1', accessKeySecret) + .update(stringToSign) + .digest('base64'); + + return `OSS ${accessKeyId}:${signature}`; +} + +export function encodeObjectKey(objectKey) { + return objectKey + .split('/') + .map((part) => encodeURIComponent(part)) + .join('/'); +} + +export async function listFiles(rootDir) { + const entries = await fs.readdir(rootDir, {withFileTypes: true}); + const files = []; + + for (const entry of entries) { + const absolutePath = path.join(rootDir, entry.name); + if (entry.isDirectory()) { + files.push(...(await listFiles(absolutePath))); + continue; + } + + if (entry.isFile()) { + files.push(absolutePath); + } + } + + return files.sort(); +} + +export function parseSimpleEnv(rawText) { + const parsed = {}; + + for (const line of rawText.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) { + continue; + } + + const separatorIndex = trimmed.indexOf('='); + if (separatorIndex === -1) { + continue; + } + + const key = trimmed.slice(0, separatorIndex).trim(); + let value = trimmed.slice(separatorIndex + 1).trim(); + + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1); + } + + parsed[key] = value; + } + + return parsed; +} diff --git a/scripts/oss-deploy-lib.test.mjs b/scripts/oss-deploy-lib.test.mjs new file mode 100644 index 0000000..7310297 --- /dev/null +++ b/scripts/oss-deploy-lib.test.mjs @@ -0,0 +1,46 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + buildObjectKey, + createAuthorizationHeader, + getCacheControl, + getContentType, + normalizeEndpoint, +} from './oss-deploy-lib.mjs'; + +test('normalizeEndpoint strips scheme and trailing slashes', () => { + assert.equal(normalizeEndpoint('https://oss-ap-northeast-1.aliyuncs.com/'), 'oss-ap-northeast-1.aliyuncs.com'); +}); + +test('buildObjectKey joins optional prefix with relative path', () => { + assert.equal(buildObjectKey('', 'assets/index.js'), 'assets/index.js'); + assert.equal(buildObjectKey('portal', 'assets/index.js'), 'portal/assets/index.js'); +}); + +test('getCacheControl keeps index uncached and assets immutable', () => { + assert.equal(getCacheControl('index.html'), 'no-cache'); + assert.equal(getCacheControl('assets/index.js'), 'public,max-age=31536000,immutable'); + assert.equal(getCacheControl('race/index.html'), 'public,max-age=300'); +}); + +test('getContentType resolves common frontend asset types', () => { + assert.equal(getContentType('index.html'), 'text/html; charset=utf-8'); + assert.equal(getContentType('assets/app.css'), 'text/css; charset=utf-8'); + assert.equal(getContentType('assets/app.js'), 'text/javascript; charset=utf-8'); + assert.equal(getContentType('favicon.png'), 'image/png'); +}); + +test('createAuthorizationHeader is stable for a known request', () => { + const header = createAuthorizationHeader({ + method: 'PUT', + bucket: 'demo-bucket', + objectKey: 'assets/index.js', + contentType: 'text/javascript; charset=utf-8', + date: 'Tue, 17 Mar 2026 12:00:00 GMT', + accessKeyId: 'test-id', + accessKeySecret: 'test-secret', + }); + + assert.equal(header, 'OSS test-id:JgyH7mTiSILGGWsnXJwg4KIBRO4='); +}); diff --git a/模板/Login.tsx b/模板/Login.tsx new file mode 100644 index 0000000..f21cd78 --- /dev/null +++ b/模板/Login.tsx @@ -0,0 +1,261 @@ +import React, { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { motion, AnimatePresence } 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, UserPlus, Mail, ArrowLeft } from 'lucide-react'; +import { cn } from '@/src/lib/utils'; + +export default function Login() { + const navigate = useNavigate(); + const [isLogin, setIsLogin] = useState(true); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + setLoading(true); + setError(''); + + // Simulate auth + setTimeout(() => { + setLoading(false); + if (isLogin) { + navigate('/overview'); + } else { + setIsLogin(true); // Switch back to login after "registering" + } + }, 1000); + }; + + return ( +
+ {/* Background Glow */} +
+
+ +
+ {/* Left Side: Brand Info (Only visible in Login mode) */} + + {isLogin && ( + +
+ + Access Portal +
+ +
+

YOYUZH.XYZ

+

+ 个人网站
统一入口 +

+
+ +

+ 欢迎来到 YOYUZH 的个人门户。在这里,你可以集中管理个人网盘文件、查询教务成绩课表,以及体验轻量级小游戏。 +

+
+ )} +
+ + {/* Form Container */} + + + + {isLogin ? ( + + + + + 登录 + + + 请输入您的账号和密码以继续 + + + +
+
+
+ +
+ + +
+
+
+ +
+ + +
+
+
+ + {error && ( +
+ {error} +
+ )} + +
+ +
+ +
+
+
+
+
+ ) : ( + + +
+ + + 注册账号 + + +
+ + 创建一个新账号以开启您的门户体验 + +
+ +
+
+
+ +
+ + +
+
+
+ +
+ + +
+
+
+ +
+ + +
+
+
+ +
+ +
+ +
+
+
+
+
+ )} +
+
+
+
+
+ ); +}