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/README.md b/README.md index 63022b9..142bbd8 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,12 @@ mysql -u -p < db/schema.sql mysql -u -p < db/seed.sql ``` +首次初始化后,后续结构变更不要再手工补表,统一执行 migration: + +```bash +npm run db:migrate +``` + 创建 `.env.local` 并配置: ```bash @@ -85,6 +91,31 @@ npm run dev ``` 访问 [http://localhost:3000](http://localhost:3000) 即可查看。 +### 5. 教师目录数据清洗与导入 +`teacher_data.json` 作为原始抓取数据保留,不直接在页面中使用。 + +先执行清洗: + +```bash +npm run teachers:clean +``` + +输出文件为 `data/teachers.cleaned.json`。 + +再执行导入: + +```bash +npm run teachers:import +``` + +导入逻辑包含: +- 按教师主页 URL、姓名、学院去重 +- `teacher_profiles` 使用 upsert 覆盖更新 +- 同步删除本次源数据中已不存在的旧记录 +- 生成 `search_pinyin` 字段,支持拼音检索 + +教师登录后也可以在 `/dashboard/teachers` 直接执行“重新导入”。 + ## 📖 设计原则 - **高校适配**:字段设计深度参考了保研推免和校内竞赛的实际评价指标。 diff --git a/app/api/admin/teachers/route.ts b/app/api/admin/teachers/route.ts new file mode 100644 index 0000000..4262aa3 --- /dev/null +++ b/app/api/admin/teachers/route.ts @@ -0,0 +1,51 @@ +import { NextRequest } from 'next/server' +import { ApiError, handleApiError, ok } from '@/lib/api' +import { getRequestUser } from '@/lib/auth' +import { getDbConfig } from '@/lib/db' +import { parseBody } from '@/lib/parse' +import { teacherImportSchema } from '@/lib/schemas' +import { getTeacherImportStats, reimportTeachersFromSource } from '@/lib/services/teachers' + +function requireTeacherAdmin(request: NextRequest) { + const user = getRequestUser(request) + if (!user) { + throw new ApiError('未登录', 401) + } + if (user.role !== 'teacher') { + throw new ApiError('仅教师可执行该操作', 403) + } + return user +} + +export async function GET(request: NextRequest) { + try { + requireTeacherAdmin(request) + + if (!getDbConfig()) { + throw new ApiError('MySQL 未配置', 503) + } + + const stats = await getTeacherImportStats() + return ok({ stats }) + } catch (error) { + return handleApiError(error, '加载教师导入状态失败') + } +} + +export async function POST(request: NextRequest) { + try { + requireTeacherAdmin(request) + + if (!getDbConfig()) { + throw new ApiError('MySQL 未配置', 503) + } + + const body = await request.json().catch(() => null) + parseBody(teacherImportSchema, body) + + const result = await reimportTeachersFromSource() + return ok({ ok: true, result }) + } catch (error) { + return handleApiError(error, '重新导入教师数据失败') + } +} 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..dcc297b 100644 --- a/app/dashboard/page.tsx +++ b/app/dashboard/page.tsx @@ -2,7 +2,8 @@ import { useEffect, useState } from 'react' import Navbar from '@/components/Navbar' -import { Bell, CheckCircle2, ClipboardList, GraduationCap, UserCheck } from 'lucide-react' +import Link from 'next/link' +import { Bell, CheckCircle2, ClipboardList, Database, GraduationCap, UserCheck } from 'lucide-react' type CurrentUser = { id: string @@ -57,16 +58,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 } }, []) @@ -187,6 +192,24 @@ export default function DashboardPage() { {hint &&
{hint}
}
+ {currentUser?.role === 'teacher' && ( +
+
+
+

+ 教师数据后台 +

+

+ 管理官网抓取后的教师目录数据,支持重新清洗、去重和覆盖更新。 +

+
+ + 进入后台 + +
+
+ )} +

diff --git a/app/dashboard/teachers/page.tsx b/app/dashboard/teachers/page.tsx new file mode 100644 index 0000000..9e0fc34 --- /dev/null +++ b/app/dashboard/teachers/page.tsx @@ -0,0 +1,233 @@ +'use client' + +import { useEffect, useMemo, useState } from 'react' +import Link from 'next/link' +import Navbar from '@/components/Navbar' +import { CheckCircle2, Database, RefreshCcw, School } from 'lucide-react' + +type SessionUser = { + id: string + name?: string + role: 'student' | 'teacher' +} + +type ImportStats = { + count: number + updatedAt: string | null + departments: Array<{ department: string; count: number }> + sources: Array<{ source: string; count: number }> +} + +type ImportResult = { + imported: number + deduped: number + created: number + updated: number + removed: number + cleanedCount: number + sync: boolean + stats: ImportStats +} + +export default function TeacherAdminPage() { + const [user, setUser] = useState(null) + const [stats, setStats] = useState(null) + const [result, setResult] = useState(null) + const [loading, setLoading] = useState(true) + const [running, setRunning] = useState(false) + const [error, setError] = useState(null) + const [hint, setHint] = useState(null) + + const headers = useMemo(() => { + const value = new Headers({ 'Content-Type': 'application/json' }) + if (user) { + value.set('x-user-id', user.id) + value.set('x-user-role', user.role) + value.set('x-user-name', user.name || '') + } + return value + }, [user]) + + useEffect(() => { + let ignore = false + + const loadSession = async () => { + const response = await fetch('/api/session', { cache: 'no-store' }) + const data = await response.json().catch(() => ({})) + if (!ignore) { + setUser(data?.user || null) + } + } + + loadSession() + return () => { + ignore = true + } + }, []) + + useEffect(() => { + if (!user) return + if (user.role !== 'teacher') { + setLoading(false) + setError('仅教师可访问教师数据后台。') + return + } + + const loadStats = async () => { + setLoading(true) + setError(null) + const response = await fetch('/api/admin/teachers', { headers, cache: 'no-store' }) + const data = await response.json().catch(() => ({})) + if (!response.ok) { + setError(data?.error || '加载教师导入状态失败') + setLoading(false) + return + } + setStats(data.stats) + setLoading(false) + } + + loadStats() + }, [user, headers]) + + const handleImport = async () => { + if (running || !user) return + setRunning(true) + setError(null) + setHint(null) + + const response = await fetch('/api/admin/teachers', { + method: 'POST', + headers, + body: JSON.stringify({ force: true }) + }) + const data = await response.json().catch(() => ({})) + + if (!response.ok) { + setError(data?.error || '重新导入失败') + setRunning(false) + return + } + + setResult(data.result || null) + setStats(data.result?.stats || null) + setHint('已重新清洗原始抓取数据,并覆盖同步到 teacher_profiles。') + setRunning(false) + } + + return ( +
+ + +
+
+ 返回工作台 +

+ 教师数据 后台 +

+

+ 对 `teacher_data.json` 进行清洗、去重、覆盖更新,并同步至 `teacher_profiles`。 +

+
+ + {error &&
{error}
} + {hint &&
{hint}
} + +
+
+
+
+

+ 导入状态 +

+

+ 当前流程会重新清洗原始数据,按姓名/学院/主页去重,使用 upsert 覆盖更新,并删除本批源数据中已不存在的教师记录。 +

+
+ +
+ + {loading ? ( +
正在加载统计...
+ ) : stats ? ( +
+
+
+
{stats.count}
+
教师总数
+
+
+
{stats.departments.length}
+
学院/系数量
+
+
+
{stats.sources[0]?.source || '-'}
+
主数据源
+
+
+ +
+
+
按学院统计
+
+ {stats.departments.map((item) => ( +
+ {item.department} + {item.count} +
+ ))} +
+
+ +
+
数据源与更新时间
+
+ {stats.sources.map((item) => ( +
+ {item.source} + {item.count} +
+ ))} +
+ 最后更新时间 + {stats.updatedAt ? new Date(stats.updatedAt).toLocaleString('zh-CN') : '暂无'} +
+
+
+
+
+ ) : null} +
+ + {result && ( +
+

+ 本次导入结果 +

+
+
+
{result.cleanedCount}
+
清洗后记录
+
+
+
{result.created}
+
新增
+
+
+
{result.updated}
+
覆盖更新
+
+
+
{result.removed}
+
移除旧记录
+
+
+
+ )} +
+
+
+ ) +} diff --git a/app/globals.css b/app/globals.css index 57b5088..192b11f 100644 --- a/app/globals.css +++ b/app/globals.css @@ -524,6 +524,233 @@ 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; +} + +.teacher-toolbar { + padding: 1rem; + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: 1rem; + align-items: start; +} + +.teacher-search { + min-width: 0; +} + +.teacher-filter-block { + display: grid; + gap: 0.7rem; +} + +.teacher-filter-title { + font-size: 0.82rem; + color: #9aa4b2; +} + +.teacher-filter-chips { + display: flex; + flex-wrap: wrap; + gap: 0.55rem; +} + +.teacher-filter-chip { + border: 1px solid rgba(255, 255, 255, 0.12); + background: rgba(255, 255, 255, 0.03); + color: #dbe4f3; + border-radius: 999px; + padding: 0.45rem 0.8rem; + font-size: 0.82rem; +} + +.teacher-filter-chip.active { + background: rgba(224, 111, 73, 0.2); + border-color: rgba(224, 111, 73, 0.45); + color: #ffd6c8; +} + +.teacher-summary { + padding: 0.9rem 1rem; + display: flex; + justify-content: space-between; + gap: 1rem; + flex-wrap: wrap; + color: #dbe4f3; + font-size: 0.9rem; +} + +.teacher-summary span { + display: inline-flex; + align-items: center; + gap: 0.45rem; +} + +.teacher-group { + display: grid; + gap: 1rem; +} + +.teacher-group-head { + display: flex; + justify-content: space-between; + align-items: center; + gap: 1rem; +} + +.teacher-group-head h3 { + font-size: 1.05rem; +} + +.teacher-card { + display: grid; + gap: 1rem; +} + +.teacher-card-head { + display: flex; + justify-content: space-between; + gap: 1rem; + align-items: flex-start; +} + +.teacher-card-head h3 { + font-size: 1.2rem; + margin-bottom: 0.35rem; +} + +.teacher-name-link { + transition: color 0.2s ease; +} + +.teacher-name-link:hover { + color: #f7e4a9; +} + +.teacher-meta-line { + color: #94a3b8; + font-size: 0.82rem; +} + +.teacher-badges { + display: flex; + gap: 0.45rem; + flex-wrap: wrap; + justify-content: flex-end; +} + +.teacher-section { + display: grid; + gap: 0.55rem; +} + +.teacher-section-title { + color: #dbe4f3; + font-size: 0.84rem; + font-weight: 700; +} + +.teacher-project-list { + display: grid; + gap: 0.45rem; +} + +.teacher-project-list p { + font-size: 0.82rem; + color: #cdd6e5; +} + +.teacher-contact { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + font-size: 0.82rem; + color: #b8c4d9; +} + +.teacher-contact span { + display: inline-flex; + align-items: center; + gap: 0.35rem; +} + +.teacher-highlight { + background: rgba(247, 228, 169, 0.3); + color: #fff1bc; + padding: 0 0.1rem; + border-radius: 4px; +} + +.teacher-detail-hero { + display: flex; + justify-content: space-between; + gap: 1rem; + align-items: flex-start; + flex-wrap: wrap; +} + +.teacher-detail-name { + font-family: 'ZCOOL XiaoWei', 'Noto Sans SC', serif; + font-size: 2rem; + line-height: 1.2; +} + +.teacher-admin-head { + display: flex; + justify-content: space-between; + gap: 1rem; + align-items: flex-start; + flex-wrap: wrap; + margin-bottom: 1rem; +} + +.teacher-admin-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + gap: 1rem; +} + +.teacher-admin-panel { + display: grid; + gap: 0.75rem; + padding: 1rem; + border-radius: 18px; + background: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(255, 255, 255, 0.08); +} + +.teacher-admin-list { + display: grid; + gap: 0.55rem; +} + +.teacher-admin-row { + display: flex; + justify-content: space-between; + gap: 1rem; + align-items: center; + color: #dbe4f3; + font-size: 0.86rem; +} + .dashboard-filters { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); @@ -770,4 +997,25 @@ p { flex-direction: column; align-items: flex-start; } + + .teacher-toolbar { + grid-template-columns: 1fr; + } + + .teacher-card-head { + flex-direction: column; + } + + .teacher-badges { + justify-content: flex-start; + } + + .teacher-detail-name { + font-size: 1.65rem; + } + + .teacher-admin-row { + align-items: flex-start; + flex-direction: column; + } } 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}
+
+ )} +
diff --git a/app/profile/page.tsx b/app/profile/page.tsx index 5c14e71..a471af7 100644 --- a/app/profile/page.tsx +++ b/app/profile/page.tsx @@ -18,42 +18,77 @@ type Attachment = { url: string } +type ProfileForm = { + department: string + major: string + grade: string + gpa: string + cet: string + campus: string + email: string + bio: string + researchFocus: string +} + +const INITIAL_FORM: ProfileForm = { + department: '', + major: '', + grade: '', + gpa: '', + cet: '', + campus: '', + email: '', + bio: '', + researchFocus: '' +} + export default function ProfilePage() { const router = useRouter() const [role, setRole] = useState<'student' | 'teacher'>('student') - const [selectedSkills, setSelectedSkills] = useState(['Python', '计算机视觉']) + const [selectedSkills, setSelectedSkills] = useState([]) const [currentUser, setCurrentUser] = useState(null) const [isAuth, setIsAuth] = useState(false) + const [authChecked, setAuthChecked] = useState(false) + const [isOnline, setIsOnline] = useState(true) + const [form, setForm] = useState(INITIAL_FORM) + const [profileHint, setProfileHint] = useState(null) + const [isSaving, setIsSaving] = useState(false) + const [attachments, setAttachments] = useState([]) const [attachmentDraft, setAttachmentDraft] = useState({ title: '', url: '' }) const [attachmentHint, setAttachmentHint] = useState(null) - const [isOnline, setIsOnline] = useState(true) useEffect(() => { - if (typeof window === 'undefined') return - const raw = localStorage.getItem('user') - if (!raw) { - setIsAuth(false) - return - } - try { - const parsed = JSON.parse(raw) - if (parsed?.id && parsed?.role) { - setCurrentUser(parsed) - setRole(parsed.role) + let ignore = false + + const loadSession = async () => { + const response = await fetch('/api/session', { cache: 'no-store' }) + const data = await response.json().catch(() => ({})) + if (ignore) return + + if (data?.user?.id && data?.user?.role) { + setCurrentUser(data.user) + setRole(data.user.role) setIsAuth(true) - return + } else { + setIsAuth(false) } - } catch (_) { - setIsAuth(false) + + setAuthChecked(true) + } + + loadSession() + + return () => { + ignore = true } }, []) useEffect(() => { - if (!isAuth) { + if (authChecked && !isAuth) { router.push('/login') } - }, [isAuth, router]) + }, [authChecked, isAuth, router]) useEffect(() => { if (typeof window === 'undefined') return @@ -73,7 +108,7 @@ export default function ProfilePage() { headers.set('Content-Type', 'application/json') if (currentUser) { headers.set('x-user-id', currentUser.id) - headers.set('x-user-role', currentUser.role) + headers.set('x-user-role', role) headers.set('x-user-name', currentUser.name || '') } const controller = new AbortController() @@ -85,8 +120,32 @@ export default function ProfilePage() { } } - const loadAttachments = async () => { + const loadProfile = async () => { if (!currentUser) return + const response = await apiFetch('/api/profile') + const data = await response.json().catch(() => ({})) + if (!response.ok || !data?.profile) { + setProfileHint(data?.error || '加载个人资料失败') + return + } + + const profile = data.profile + setForm({ + department: profile.department || '', + major: profile.major || '', + grade: profile.grade || '', + gpa: profile.gpa || '', + cet: profile.cet || '', + campus: profile.campus || '', + email: profile.email || '', + bio: profile.bio || '', + researchFocus: profile.research_focus || '' + }) + setSelectedSkills(Array.isArray(profile.skills) ? profile.skills : []) + } + + const loadAttachments = async () => { + if (!currentUser || role !== 'student') return const response = await apiFetch('/api/attachments') const data = await response.json().catch(() => ({})) if (response.ok) { @@ -94,15 +153,44 @@ export default function ProfilePage() { } } + useEffect(() => { + loadProfile() + }, [currentUser, role]) + useEffect(() => { loadAttachments() - }, [currentUser]) + }, [currentUser, role]) + + const updateForm = (key: keyof ProfileForm, value: string) => { + setForm((prev) => ({ ...prev, [key]: value })) + } - const handleSaveProfile = () => { + const handleSaveProfile = async () => { if (!currentUser) return + if (!isOnline) { + setProfileHint('当前离线,无法保存') + return + } + + setIsSaving(true) + const response = await apiFetch('/api/profile', { + method: 'PUT', + body: JSON.stringify({ + ...form, + skills: selectedSkills + }) + }) + const data = await response.json().catch(() => ({})) + if (!response.ok) { + setProfileHint(data?.error || '保存失败') + setIsSaving(false) + return + } + const nextUser = { ...currentUser, role } - localStorage.setItem('user', JSON.stringify(nextUser)) setCurrentUser(nextUser) + setProfileHint('个人资料已更新') + setIsSaving(false) } const handleAddAttachment = async () => { @@ -140,7 +228,7 @@ export default function ProfilePage() {
-
+
@@ -165,24 +253,16 @@ export default function ProfilePage() {
- - + +
+ {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' ? '能力标签与经历' : '研究关键词与招募信息'}

-
- - +
+ +
-
- -