From c880aa87a80c43913cccfa38d81434ae7668870e Mon Sep 17 00:00:00 2001 From: Cao150702 <15048094700@163.com> Date: Fri, 6 Mar 2026 10:09:48 +0800 Subject: [PATCH 1/3] Harden app foundation and session architecture --- .gitignore | 1 + app/api/attachments/route.ts | 72 +++++----- app/api/join-requests/route.ts | 59 ++------ app/api/notifications/route.ts | 59 +++----- app/api/profile/route.ts | 87 ++++++++++++ app/api/projects/route.ts | 155 +-------------------- app/api/ratings/route.ts | 123 ++++------------- app/api/session/route.ts | 54 ++++++++ app/dashboard/page.tsx | 22 +-- app/globals.css | 21 +++ app/login/page.tsx | 35 +++-- app/profile/page.tsx | 246 ++++++++++++++++++++++----------- app/projects/page.tsx | 116 +++++++++++++--- components/Navbar.tsx | 48 ++++++- db/schema.sql | 13 ++ db/seed.sql | 26 ++-- lib/api.ts | 27 ++++ lib/auth.ts | 45 +++++- lib/config.ts | 10 ++ lib/db.ts | 1 + lib/parse.ts | 10 ++ lib/schemas.ts | 43 ++++++ lib/services/attachments.ts | 34 +++++ lib/services/joinRequests.ts | 47 +++++++ lib/services/notifications.ts | 41 ++++++ lib/services/profile.ts | 84 +++++++++++ lib/services/projects.ts | 144 +++++++++++++++++++ lib/services/ratings.ts | 81 +++++++++++ package-lock.json | 6 +- package.json | 3 +- proxy.ts | 25 ++++ 31 files changed, 1218 insertions(+), 520 deletions(-) create mode 100644 app/api/profile/route.ts create mode 100644 app/api/session/route.ts create mode 100644 lib/api.ts create mode 100644 lib/config.ts create mode 100644 lib/parse.ts create mode 100644 lib/schemas.ts create mode 100644 lib/services/attachments.ts create mode 100644 lib/services/joinRequests.ts create mode 100644 lib/services/notifications.ts create mode 100644 lib/services/profile.ts create mode 100644 lib/services/projects.ts create mode 100644 lib/services/ratings.ts create mode 100644 proxy.ts diff --git a/.gitignore b/.gitignore index bd51be7..6a62d0c 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ node_modules npm-debug.log* yarn-debug.log* yarn-error.log* +.env.local diff --git a/app/api/attachments/route.ts b/app/api/attachments/route.ts index 56783ab..3fbfe05 100644 --- a/app/api/attachments/route.ts +++ b/app/api/attachments/route.ts @@ -1,94 +1,84 @@ import { NextResponse, type NextRequest } from 'next/server' -import { getDbConfig, query } from '@/lib/db' +import { getDbConfig } from '@/lib/db' import { getRequestUser } from '@/lib/auth' -import { isNonEmptyString, isValidUrl } from '@/lib/validate' +import { ApiError, handleApiError, ok } from '@/lib/api' +import { parseBody } from '@/lib/parse' +import { attachmentCreateSchema, attachmentDeleteSchema } from '@/lib/schemas' +import { canTeacherAccessStudentAttachments, createAttachment, deleteAttachment, listAttachmentsByUserId } from '@/lib/services/attachments' export async function GET(request: NextRequest) { try { if (!getDbConfig()) { - return NextResponse.json({ error: 'MySQL 未配置' }, { status: 503 }) + throw new ApiError('MySQL 未配置', 503) } const user = getRequestUser(request) if (!user) { - return NextResponse.json({ error: '未登录' }, { status: 401 }) + throw new ApiError('未登录', 401) } const { searchParams } = new URL(request.url) const userId = searchParams.get('userId') || user.id + const projectId = Number(searchParams.get('projectId') || 0) if (userId !== user.id) { - return NextResponse.json({ error: '无权查看该用户附件' }, { status: 403 }) + if (user.role !== 'teacher' || !projectId) { + throw new ApiError('无权查看该用户附件', 403) + } + + const allowed = await canTeacherAccessStudentAttachments(projectId, user.id, userId) + if (!allowed) { + throw new ApiError('无权查看该用户附件', 403) + } } - const rows = await query<{ - id: number - title: string - url: string - created_at: string - }>( - 'SELECT id, title, url, created_at FROM attachments WHERE user_id = ? ORDER BY created_at DESC', - [userId] - ) - - return NextResponse.json({ attachments: rows }) + const attachments = await listAttachmentsByUserId(userId) + return ok({ attachments }) } catch (error) { - console.error('GET /api/attachments failed', error) - return NextResponse.json({ error: '加载附件失败' }, { status: 500 }) + return handleApiError(error, '加载附件失败') } } export async function POST(request: NextRequest) { try { if (!getDbConfig()) { - return NextResponse.json({ error: 'MySQL 未配置' }, { status: 503 }) + throw new ApiError('MySQL 未配置', 503) } const user = getRequestUser(request) if (!user) { - return NextResponse.json({ error: '未登录' }, { status: 401 }) + throw new ApiError('未登录', 401) } const body = await request.json().catch(() => null) - const title = body?.title - const url = body?.url + const { title, url } = parseBody(attachmentCreateSchema, body) - if (!isNonEmptyString(title, 120) || !isValidUrl(url, 255)) { - return NextResponse.json({ error: '标题或链接不合法' }, { status: 400 }) - } - - await query('INSERT INTO attachments (user_id, title, url) VALUES (?, ?, ?)', [user.id, title, url]) + await createAttachment(user.id, title, url) - return NextResponse.json({ ok: true }) + return ok({ ok: true }) } catch (error) { - console.error('POST /api/attachments failed', error) - return NextResponse.json({ error: '保存附件失败' }, { status: 500 }) + return handleApiError(error, '保存附件失败') } } export async function DELETE(request: NextRequest) { try { if (!getDbConfig()) { - return NextResponse.json({ error: 'MySQL 未配置' }, { status: 503 }) + throw new ApiError('MySQL 未配置', 503) } const user = getRequestUser(request) if (!user) { - return NextResponse.json({ error: '未登录' }, { status: 401 }) + throw new ApiError('未登录', 401) } const body = await request.json().catch(() => null) - const id = Number(body?.id) - - if (!id) { - return NextResponse.json({ error: '缺少附件ID' }, { status: 400 }) - } + const { id } = parseBody(attachmentDeleteSchema, body) - await query('DELETE FROM attachments WHERE id = ? AND user_id = ?', [id, user.id]) + await deleteAttachment(user.id, id) - return NextResponse.json({ ok: true }) + return ok({ ok: true }) } catch (error) { - console.error('DELETE /api/attachments failed', error) - return NextResponse.json({ error: '删除附件失败' }, { status: 500 }) + return handleApiError(error, '删除附件失败') } } diff --git a/app/api/join-requests/route.ts b/app/api/join-requests/route.ts index 5b3be63..002f70c 100644 --- a/app/api/join-requests/route.ts +++ b/app/api/join-requests/route.ts @@ -1,67 +1,28 @@ import { NextResponse, type NextRequest } from 'next/server' -import { getDbConfig, query } from '@/lib/db' +import { getDbConfig } from '@/lib/db' import { getRequestUser } from '@/lib/auth' +import { ApiError, handleApiError, ok } from '@/lib/api' +import { listStudentJoinRequests, listTeacherPendingJoinRequests } from '@/lib/services/joinRequests' export async function GET(request: NextRequest) { try { if (!getDbConfig()) { - return NextResponse.json({ error: 'MySQL 未配置' }, { status: 503 }) + throw new ApiError('MySQL 未配置', 503) } const user = getRequestUser(request) if (!user) { - return NextResponse.json({ error: '未登录' }, { status: 401 }) + throw new ApiError('未登录', 401) } if (user.role === 'student') { - const rows = await query<{ - id: number - project_id: number - status: string - created_at: string - decided_at: string | null - project_title: string - teacher_name: string - }>( - `SELECT r.id, r.project_id, r.status, r.created_at, r.decided_at, - p.title AS project_title, u.name AS teacher_name - FROM project_join_requests r - JOIN projects p ON p.id = r.project_id - JOIN users u ON u.id = p.teacher_id - WHERE r.student_id = ? - ORDER BY r.created_at DESC`, - [user.id] - ) - - return NextResponse.json({ requests: rows }) + const requests = await listStudentJoinRequests(user.id) + return ok({ requests }) } - const rows = await query<{ - id: number - project_id: number - student_id: string - status: string - created_at: string - project_title: string - student_name: string - student_major: string | null - student_grade: string | null - }>( - `SELECT r.id, r.project_id, r.student_id, r.status, r.created_at, - p.title AS project_title, - u.name AS student_name, u.major AS student_major, u.grade AS student_grade - FROM project_join_requests r - JOIN projects p ON p.id = r.project_id - JOIN users t ON t.id = p.teacher_id - JOIN users u ON u.id = r.student_id - WHERE t.id = ? AND r.status = 'pending' - ORDER BY r.created_at DESC`, - [user.id] - ) - - return NextResponse.json({ requests: rows }) + const requests = await listTeacherPendingJoinRequests(user.id) + return ok({ requests }) } catch (error) { - console.error('GET /api/join-requests failed', error) - return NextResponse.json({ error: '加载申请失败' }, { status: 500 }) + return handleApiError(error, '加载申请失败') } } diff --git a/app/api/notifications/route.ts b/app/api/notifications/route.ts index 908859e..54f0551 100644 --- a/app/api/notifications/route.ts +++ b/app/api/notifications/route.ts @@ -1,75 +1,48 @@ import { NextResponse, type NextRequest } from 'next/server' -import { getDbConfig, query } from '@/lib/db' +import { getDbConfig } from '@/lib/db' import { getRequestUser } from '@/lib/auth' +import { ApiError, handleApiError, ok } from '@/lib/api' +import { parseBody } from '@/lib/parse' +import { notificationMarkReadSchema } from '@/lib/schemas' +import { listNotifications, markNotificationsRead } from '@/lib/services/notifications' export async function GET(request: NextRequest) { try { if (!getDbConfig()) { - return NextResponse.json({ error: 'MySQL 未配置' }, { status: 503 }) + throw new ApiError('MySQL 未配置', 503) } const user = getRequestUser(request) if (!user) { - return NextResponse.json({ error: '未登录' }, { status: 401 }) + throw new ApiError('未登录', 401) } const { searchParams } = new URL(request.url) const limit = Math.min(Number(searchParams.get('limit') || 10), 50) - - const notifications = await query<{ - id: number - title: string - body: string - is_read: number - created_at: string - }>( - `SELECT id, title, body, is_read, created_at - FROM notifications - WHERE user_id = ? - ORDER BY created_at DESC - LIMIT ?`, - [user.id, limit] - ) - - const unreadRows = await query<{ count: number }>( - 'SELECT COUNT(*) AS count FROM notifications WHERE user_id = ? AND is_read = 0', - [user.id] - ) - - return NextResponse.json({ notifications, unread: unreadRows[0]?.count || 0 }) + const data = await listNotifications(user.id, limit) + return ok(data) } catch (error) { - console.error('GET /api/notifications failed', error) - return NextResponse.json({ error: '加载通知失败' }, { status: 500 }) + return handleApiError(error, '加载通知失败') } } export async function POST(request: NextRequest) { try { if (!getDbConfig()) { - return NextResponse.json({ error: 'MySQL 未配置' }, { status: 503 }) + throw new ApiError('MySQL 未配置', 503) } const user = getRequestUser(request) if (!user) { - return NextResponse.json({ error: '未登录' }, { status: 401 }) + throw new ApiError('未登录', 401) } const body = await request.json().catch(() => null) - const ids = Array.isArray(body?.ids) ? body.ids.map(Number).filter(Boolean).slice(0, 100) : [] - - if (ids.length === 0) { - await query('UPDATE notifications SET is_read = 1 WHERE user_id = ?', [user.id]) - } else { - const placeholders = ids.map(() => '?').join(',') - await query( - `UPDATE notifications SET is_read = 1 WHERE user_id = ? AND id IN (${placeholders})`, - [user.id, ...ids] - ) - } + const { ids } = parseBody(notificationMarkReadSchema, body) + await markNotificationsRead(user.id, ids) - return NextResponse.json({ ok: true }) + return ok({ ok: true }) } catch (error) { - console.error('POST /api/notifications failed', error) - return NextResponse.json({ error: '更新通知失败' }, { status: 500 }) + return handleApiError(error, '更新通知失败') } } diff --git a/app/api/profile/route.ts b/app/api/profile/route.ts new file mode 100644 index 0000000..125c2f5 --- /dev/null +++ b/app/api/profile/route.ts @@ -0,0 +1,87 @@ +import { NextResponse, type NextRequest } from 'next/server' +import { getDbConfig } from '@/lib/db' +import { getRequestUser } from '@/lib/auth' +import { isNonEmptyString, isOptionalString } from '@/lib/validate' +import { getProfileById, updateProfileById } from '@/lib/services/profile' +import { ApiError, handleApiError, ok } from '@/lib/api' + +export async function GET(request: NextRequest) { + try { + if (!getDbConfig()) { + throw new ApiError('MySQL 未配置', 503) + } + + const user = getRequestUser(request) + if (!user) { + throw new ApiError('未登录', 401) + } + + const profile = await getProfileById(user.id) + if (!profile) { + throw new ApiError('用户不存在', 404) + } + + return ok({ profile }) + } catch (error) { + return handleApiError(error, '加载个人资料失败') + } +} + +export async function PUT(request: NextRequest) { + try { + if (!getDbConfig()) { + throw new ApiError('MySQL 未配置', 503) + } + + const user = getRequestUser(request) + if (!user) { + throw new ApiError('未登录', 401) + } + + const body = await request.json().catch(() => null) + const department = body?.department + const major = body?.major + const grade = body?.grade + const gpa = body?.gpa + const cet = body?.cet + const campus = body?.campus + const email = body?.email + const bio = body?.bio + const researchFocus = body?.researchFocus + const skills = Array.isArray(body?.skills) ? body.skills : [] + + if (!isOptionalString(department, 64) || !isOptionalString(major, 64) || !isOptionalString(grade, 32)) { + throw new ApiError('基础字段长度不合法') + } + + if (!isOptionalString(gpa, 16) || !isOptionalString(cet, 32) || !isOptionalString(campus, 32)) { + throw new ApiError('学业字段长度不合法') + } + + if (!isOptionalString(email, 128) || !isOptionalString(bio, 4000) || !isOptionalString(researchFocus, 4000)) { + throw new ApiError('简介字段长度不合法') + } + + const normalizedSkills = skills + .filter((item: unknown) => isNonEmptyString(item, 64)) + .map((item: string) => item.trim()) + .slice(0, 30) + + await updateProfileById(user.id, { + department: department?.trim() || null, + major: major?.trim() || null, + grade: grade?.trim() || null, + gpa: gpa?.trim() || null, + cet: cet?.trim() || null, + campus: campus?.trim() || null, + email: email?.trim() || null, + bio: bio?.trim() || null, + researchFocus: researchFocus?.trim() || null, + skills: normalizedSkills + }) + + return ok({ ok: true }) + } catch (error) { + return handleApiError(error, '保存个人资料失败') + } +} diff --git a/app/api/projects/route.ts b/app/api/projects/route.ts index e9ebed9..0901bb7 100644 --- a/app/api/projects/route.ts +++ b/app/api/projects/route.ts @@ -1,163 +1,22 @@ import { NextResponse, type NextRequest } from 'next/server' -import { getDbConfig, query } from '@/lib/db' +import { getDbConfig } from '@/lib/db' +import { listProjects } from '@/lib/services/projects' +import { ApiError, handleApiError, ok } from '@/lib/api' const MAX_LIMIT = 50 export async function GET(request: NextRequest) { try { if (!getDbConfig()) { - return NextResponse.json( - { error: 'MySQL 未配置,请设置 MYSQL_HOST / MYSQL_USER / MYSQL_PASSWORD / MYSQL_DATABASE' }, - { status: 503 } - ) + throw new ApiError('MySQL 未配置,请设置 MYSQL_HOST / MYSQL_USER / MYSQL_PASSWORD / MYSQL_DATABASE', 503) } const { searchParams } = new URL(request.url) const limit = Math.min(Number(searchParams.get('limit') || 20), MAX_LIMIT) const offset = Math.max(Number(searchParams.get('offset') || 0), 0) - - const projects = await query<{ - id: number - title: string - description: string - tags: string | null - department: string - level: string - status: string - campus: string - capacity: number - deadline: string | null - teacher_id: string - teacher_name: string - teacher_email: string | null - }>( - `SELECT p.id, p.title, p.description, p.tags, p.department, p.level, p.status, p.campus, - p.capacity, p.deadline, - p.teacher_id, u.name AS teacher_name, u.email AS teacher_email - FROM projects p - JOIN users u ON u.id = p.teacher_id - ORDER BY p.id DESC - LIMIT ? OFFSET ?`, - [limit, offset] - ) - - const projectIds = projects.map((project) => project.id) - if (projectIds.length === 0) { - return NextResponse.json({ projects: [] }) - } - - const inClause = projectIds.map(() => '?').join(',') - - const members = await query<{ - project_id: number - student_id: string - name: string - major: string | null - grade: string | null - campus: string | null - email: string | null - }>( - `SELECT pm.project_id, u.id AS student_id, u.name, u.major, u.grade, u.campus, u.email - FROM project_members pm - JOIN users u ON u.id = pm.student_id - WHERE pm.project_id IN (${inClause}) - ORDER BY pm.joined_at DESC`, - projectIds - ) - - const ratingStats = await query<{ - project_id: number - ratee_id: string - avg_score: number - rating_count: number - }>( - `SELECT project_id, ratee_id, AVG(score) AS avg_score, COUNT(*) AS rating_count - FROM ratings - WHERE project_id IN (${inClause}) - GROUP BY project_id, ratee_id`, - projectIds - ) - - const pendingStats = await query<{ - project_id: number - pending_count: number - }>( - `SELECT project_id, COUNT(*) AS pending_count - FROM project_join_requests - WHERE status = 'pending' AND project_id IN (${inClause}) - GROUP BY project_id`, - projectIds - ) - - const membersByProject = new Map() - members.forEach((member) => { - if (!membersByProject.has(member.project_id)) { - membersByProject.set(member.project_id, []) - } - membersByProject.get(member.project_id)!.push(member) - }) - - const ratingByProject = new Map>() - ratingStats.forEach((stat) => { - if (!ratingByProject.has(stat.project_id)) { - ratingByProject.set(stat.project_id, new Map()) - } - ratingByProject.get(stat.project_id)!.set(stat.ratee_id, { - avg: Number(stat.avg_score), - count: Number(stat.rating_count) - }) - }) - - const pendingByProject = new Map() - pendingStats.forEach((stat) => { - pendingByProject.set(stat.project_id, Number(stat.pending_count)) - }) - - const payload = projects.map((project) => { - let tags: string[] = [] - if (project.tags) { - try { - tags = JSON.parse(project.tags) - } catch (_) { - tags = [] - } - } - const projectMembers = membersByProject.get(project.id) || [] - const ratingMap = ratingByProject.get(project.id) || new Map() - - return { - id: project.id, - title: project.title, - description: project.description, - tags, - department: project.department, - level: project.level, - status: project.status, - campus: project.campus, - capacity: project.capacity, - deadline: project.deadline, - pendingCount: pendingByProject.get(project.id) || 0, - teacher: { - id: project.teacher_id, - name: project.teacher_name, - email: project.teacher_email, - rating: ratingMap.get(project.teacher_id) || null - }, - members: projectMembers.map((member) => ({ - id: member.student_id, - name: member.name, - major: member.major, - grade: member.grade, - campus: member.campus, - email: member.email, - rating: ratingMap.get(member.student_id) || null - })) - } - }) - - return NextResponse.json({ projects: payload }) + const projects = await listProjects(limit, offset) + return ok({ projects }) } catch (error) { - console.error('GET /api/projects failed', error) - return NextResponse.json({ error: '加载项目失败' }, { status: 500 }) + return handleApiError(error, '加载项目失败') } } diff --git a/app/api/ratings/route.ts b/app/api/ratings/route.ts index e600fea..43f4693 100644 --- a/app/api/ratings/route.ts +++ b/app/api/ratings/route.ts @@ -1,122 +1,47 @@ import { NextResponse, type NextRequest } from 'next/server' -import { getDbConfig, query } from '@/lib/db' +import { getDbConfig } from '@/lib/db' import { getRequestUser } from '@/lib/auth' -import { isOptionalScore, isOptionalString, isScore, isSafeId } from '@/lib/validate' +import { ApiError, handleApiError, ok } from '@/lib/api' +import { parseBody } from '@/lib/parse' +import { ratingCreateSchema } from '@/lib/schemas' +import { createOrUpdateRating } from '@/lib/services/ratings' export async function POST(request: NextRequest) { try { if (!getDbConfig()) { - return NextResponse.json({ error: 'MySQL 未配置' }, { status: 503 }) + throw new ApiError('MySQL 未配置', 503) } const user = getRequestUser(request) if (!user) { - return NextResponse.json({ error: '未登录' }, { status: 401 }) + throw new ApiError('未登录', 401) } const body = await request.json().catch(() => null) - const projectId = Number(body?.projectId) - const rateeId = body?.rateeId - const scoreAttitude = body?.scoreAttitude - const scoreAbility = body?.scoreAbility - const scoreContribution = body?.scoreContribution - const score = body?.score - const comment = typeof body?.comment === 'string' ? body.comment.trim() : null + const parsed = parseBody(ratingCreateSchema, body) - if (!projectId || !isSafeId(rateeId)) { - return NextResponse.json({ error: '参数不完整' }, { status: 400 }) - } - - if (!isOptionalScore(scoreAttitude) || !isOptionalScore(scoreAbility) || !isOptionalScore(scoreContribution)) { - return NextResponse.json({ error: '维度评分必须在 1-5 之间' }, { status: 400 }) - } - - if (!isScore(score) && !(isScore(scoreAttitude) || isScore(scoreAbility) || isScore(scoreContribution))) { - return NextResponse.json({ error: '评分不能为空' }, { status: 400 }) - } - - const scores = [scoreAttitude, scoreAbility, scoreContribution] - .filter((value) => isScore(value)) - .map((value) => Number(value)) + const scores = [parsed.scoreAttitude, parsed.scoreAbility, parsed.scoreContribution] + .filter((value): value is number => typeof value === 'number') const resolvedScore = scores.length > 0 ? Math.round(scores.reduce((sum, value) => sum + value, 0) / scores.length) - : Number(score) - - if (resolvedScore < 1 || resolvedScore > 5) { - return NextResponse.json({ error: '评分必须在 1-5 之间' }, { status: 400 }) - } - - if (!isOptionalString(comment, 500)) { - return NextResponse.json({ error: '评语过长' }, { status: 400 }) - } - - const projectRows = await query<{ teacher_id: string }>( - 'SELECT teacher_id FROM projects WHERE id = ? LIMIT 1', - [projectId] - ) - - if (projectRows.length === 0) { - return NextResponse.json({ error: '项目不存在' }, { status: 404 }) - } - - const teacherId = projectRows[0].teacher_id - - if (user.role === 'teacher') { - if (user.id !== teacherId) { - return NextResponse.json({ error: '你不是该项目管理员' }, { status: 403 }) - } - - if (rateeId === teacherId) { - return NextResponse.json({ error: '不能给自己评分' }, { status: 400 }) - } - - const membership = await query<{ id: number }>( - 'SELECT id FROM project_members WHERE project_id = ? AND student_id = ? LIMIT 1', - [projectId, rateeId] - ) - - if (membership.length === 0) { - return NextResponse.json({ error: '该学生不在科研组内' }, { status: 404 }) - } - } else { - if (rateeId !== teacherId) { - return NextResponse.json({ error: '学生只能评价项目导师' }, { status: 403 }) - } - - const membership = await query<{ id: number }>( - 'SELECT id FROM project_members WHERE project_id = ? AND student_id = ? LIMIT 1', - [projectId, user.id] - ) + : parsed.score - if (membership.length === 0) { - return NextResponse.json({ error: '你不在科研组内' }, { status: 403 }) - } + if (typeof resolvedScore !== 'number') { + throw new ApiError('评分不能为空') } - await query( - `INSERT INTO ratings (project_id, rater_id, ratee_id, score, score_attitude, score_ability, score_contribution, comment) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) - ON DUPLICATE KEY UPDATE - score = VALUES(score), - score_attitude = VALUES(score_attitude), - score_ability = VALUES(score_ability), - score_contribution = VALUES(score_contribution), - comment = VALUES(comment), - updated_at = NOW()` - , [ - projectId, - user.id, - String(rateeId), - resolvedScore, - isScore(scoreAttitude) ? Number(scoreAttitude) : null, - isScore(scoreAbility) ? Number(scoreAbility) : null, - isScore(scoreContribution) ? Number(scoreContribution) : null, - comment - ]) + await createOrUpdateRating(user, { + projectId: parsed.projectId, + rateeId: parsed.rateeId, + score: resolvedScore, + scoreAttitude: parsed.scoreAttitude ?? null, + scoreAbility: parsed.scoreAbility ?? null, + scoreContribution: parsed.scoreContribution ?? null, + comment: parsed.comment ?? null + }) - return NextResponse.json({ ok: true }) + return ok({ ok: true }) } catch (error) { - console.error('POST /api/ratings failed', error) - return NextResponse.json({ error: '评分失败' }, { status: 500 }) + return handleApiError(error, '评分失败') } } diff --git a/app/api/session/route.ts b/app/api/session/route.ts new file mode 100644 index 0000000..daf78f1 --- /dev/null +++ b/app/api/session/route.ts @@ -0,0 +1,54 @@ +import { NextResponse, type NextRequest } from 'next/server' +import { applySessionCookie, clearSessionCookie, getRequestUser, type RequestUser } from '@/lib/auth' +import { getDbConfig, query } from '@/lib/db' +import { handleApiError, ok, ApiError } from '@/lib/api' +import { parseBody } from '@/lib/parse' +import { sessionCreateSchema } from '@/lib/schemas' + +async function resolveDemoUser(role: 'student' | 'teacher'): Promise { + const userId = role === 'teacher' ? 'T001' : '20240101' + + if (!getDbConfig()) { + return { + id: userId, + role, + name: role === 'teacher' ? '张教授' : '测试学生' + } + } + + const rows = await query<{ id: string; name: string; role: 'student' | 'teacher' }>( + 'SELECT id, name, role FROM users WHERE id = ? LIMIT 1', + [userId] + ) + + if (rows.length === 0) { + throw new ApiError('演示账号不存在', 404) + } + + return rows[0] +} + +export async function GET(request: NextRequest) { + const user = getRequestUser(request) + return ok({ user }) +} + +export async function POST(request: NextRequest) { + try { + const body = await request.json().catch(() => null) + const { role } = parseBody(sessionCreateSchema, body) + + const user = await resolveDemoUser(role) + const response = ok({ user }) + applySessionCookie(response, user) + return response + } catch (error) { + return handleApiError(error, '创建会话失败') + } +} + +export async function DELETE() { + const response = ok({ ok: true }) + clearSessionCookie(response) + return response +} diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx index 7f9372b..5ad1182 100644 --- a/app/dashboard/page.tsx +++ b/app/dashboard/page.tsx @@ -57,16 +57,20 @@ export default function DashboardPage() { } useEffect(() => { - if (typeof window === 'undefined') return - const raw = localStorage.getItem('user') - if (!raw) return - try { - const parsed = JSON.parse(raw) - if (parsed?.id && parsed?.role) { - setCurrentUser(parsed) + let ignore = false + + const loadSession = async () => { + const response = await fetch('/api/session', { cache: 'no-store' }) + const data = await response.json().catch(() => ({})) + if (!ignore) { + setCurrentUser(data?.user || null) } - } catch (_) { - setCurrentUser(null) + } + + loadSession() + + return () => { + ignore = true } }, []) diff --git a/app/globals.css b/app/globals.css index 57b5088..d3b914b 100644 --- a/app/globals.css +++ b/app/globals.css @@ -524,6 +524,27 @@ p { border-top: 1px dashed rgba(255, 255, 255, 0.12); } +.attachment-panel { + display: grid; + gap: 0.45rem; + background: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 10px; + padding: 0.7rem; +} + +.attachment-link { + display: inline-flex; + align-items: center; + gap: 0.4rem; + font-size: 0.82rem; + color: #cbd5f5; +} + +.attachment-link:hover { + color: #f7e4a9; +} + .dashboard-filters { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); diff --git a/app/login/page.tsx b/app/login/page.tsx index 5648c40..6af4966 100644 --- a/app/login/page.tsx +++ b/app/login/page.tsx @@ -1,26 +1,31 @@ 'use client' import Navbar from '@/components/Navbar' -import { School, ShieldCheck, ArrowRight, UserCheck, GraduationCap } from 'lucide-react' +import { School, ShieldCheck, UserCheck, GraduationCap } from 'lucide-react' import { useRouter } from 'next/navigation' import { useState } from 'react' export default function LoginPage() { const router = useRouter() const [loadingRole, setLoadingRole] = useState<'student' | 'teacher' | null>(null) + const [error, setError] = useState(null) - const handleLogin = (role: 'student' | 'teacher') => { + const handleLogin = async (role: 'student' | 'teacher') => { setLoadingRole(role) - // 模拟统一身份认证跳转与回调 - setTimeout(() => { - if (role === 'teacher') { - localStorage.setItem('user', JSON.stringify({ name: '张教授', role: 'teacher', id: 'T001' })) - } else { - localStorage.setItem('user', JSON.stringify({ name: '测试学生', role: 'student', id: '20240101' })) - } - router.push('/profile') - router.refresh() - }, 1200) + setError(null) + const response = await fetch('/api/session', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ role }) + }) + const data = await response.json().catch(() => ({})) + if (!response.ok) { + setError(data?.error || '登录失败') + setLoadingRole(null) + return + } + router.push('/dashboard') + router.refresh() } return ( @@ -45,6 +50,12 @@ export default function LoginPage() { + {error && ( +
+
{error}
+
+ )} +
- + +
+ {profileHint &&
{profileHint}
} +
-

+

基本信息

@@ -192,75 +272,79 @@ export default function ProfilePage() {
- + updateForm('department', event.target.value)} />
- updateForm('campus', event.target.value)}> + + +
- + updateForm('email', event.target.value)} />
+ {role === 'student' && ( + <> +
+ + updateForm('major', event.target.value)} /> +
+
+ + updateForm('grade', event.target.value)} /> +
+
+ + updateForm('gpa', event.target.value)} /> +
+
+ + updateForm('cet', event.target.value)} /> +
+ + )}
-

- {role === 'student' ? '能力标签' : '研究关键词'} +

+ {role === 'student' ? '能力标签与经历' : '研究关键词与招募信息'}

-
- - +
+ +
-
- -