Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ node_modules
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.env.local
31 changes: 31 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,12 @@ mysql -u <user> -p <database> < db/schema.sql
mysql -u <user> -p <database> < db/seed.sql
```

首次初始化后,后续结构变更不要再手工补表,统一执行 migration:

```bash
npm run db:migrate
```

创建 `.env.local` 并配置:

```bash
Expand Down Expand Up @@ -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` 直接执行“重新导入”。

## 📖 设计原则

- **高校适配**:字段设计深度参考了保研推免和校内竞赛的实际评价指标。
Expand Down
51 changes: 51 additions & 0 deletions app/api/admin/teachers/route.ts
Original file line number Diff line number Diff line change
@@ -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, '重新导入教师数据失败')
}
}
72 changes: 31 additions & 41 deletions app/api/attachments/route.ts
Original file line number Diff line number Diff line change
@@ -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, '删除附件失败')
}
}
59 changes: 10 additions & 49 deletions app/api/join-requests/route.ts
Original file line number Diff line number Diff line change
@@ -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, '加载申请失败')
}
}
59 changes: 16 additions & 43 deletions app/api/notifications/route.ts
Original file line number Diff line number Diff line change
@@ -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, '更新通知失败')
}
}
Loading