- Add error.tsx and loading.tsx boundaries for admin, parent, student, teacher routes - Add dashboard-error-fallback and dashboard-loading-skeleton components - Add student/learning page, parent/leave routes, teacher textbook components - Update existing app routes across auth, dashboard, and API endpoints - Update proxy middleware and next-auth type declarations
280 lines
7.3 KiB
TypeScript
280 lines
7.3 KiB
TypeScript
import { NextResponse } from "next/server"
|
||
import { and, desc, eq, like, or, sql } from "drizzle-orm"
|
||
|
||
import { getAuthContext } from "@/shared/lib/auth-guard"
|
||
import { db } from "@/shared/db"
|
||
import {
|
||
announcements,
|
||
exams,
|
||
questions,
|
||
textbooks,
|
||
} from "@/shared/db/schema"
|
||
|
||
export const dynamic = "force-dynamic"
|
||
|
||
type SearchType = "all" | "question" | "textbook" | "exam" | "announcement"
|
||
|
||
const isSearchType = (v: string): v is SearchType =>
|
||
v === "all" || v === "question" || v === "textbook" || v === "exam" || v === "announcement"
|
||
|
||
const DEFAULT_PAGE_SIZE = 10
|
||
|
||
interface SearchResultItem {
|
||
id: string
|
||
title: string
|
||
snippet: string
|
||
type: "question" | "textbook" | "exam" | "announcement"
|
||
href: string
|
||
createdAt: string
|
||
}
|
||
|
||
interface SearchResponse {
|
||
success: boolean
|
||
query: string
|
||
type: SearchType
|
||
results: SearchResultItem[]
|
||
total: number
|
||
page: number
|
||
pageSize: number
|
||
}
|
||
|
||
/**
|
||
* GET /api/search?q=keyword&type=all&page=1
|
||
* 全文检索:questions / textbooks / exams / announcements
|
||
* 按角色过滤:学生只能搜索 textbook 和 announcement
|
||
*/
|
||
export async function GET(req: Request) {
|
||
try {
|
||
const ctx = await getAuthContext()
|
||
const isStudent = ctx.roles.includes("student") && !ctx.roles.includes("admin") && !ctx.roles.includes("teacher")
|
||
|
||
const { searchParams } = new URL(req.url)
|
||
const q = (searchParams.get("q") ?? "").trim()
|
||
const typeRaw = (searchParams.get("type") ?? "all").trim()
|
||
const type: SearchType = isSearchType(typeRaw) ? typeRaw : "all"
|
||
const page = Math.max(1, Number(searchParams.get("page") ?? "1") || 1)
|
||
const pageSize = Math.min(
|
||
50,
|
||
Math.max(1, Number(searchParams.get("pageSize") ?? String(DEFAULT_PAGE_SIZE)) || DEFAULT_PAGE_SIZE)
|
||
)
|
||
|
||
if (!q) {
|
||
return NextResponse.json<SearchResponse>({
|
||
success: true,
|
||
query: q,
|
||
type,
|
||
results: [],
|
||
total: 0,
|
||
page,
|
||
pageSize,
|
||
})
|
||
}
|
||
|
||
const kw = `%${q}%`
|
||
const offset = (page - 1) * pageSize
|
||
const results: SearchResultItem[] = []
|
||
|
||
// 并行查询各类型(按角色过滤)
|
||
const tasks: Promise<SearchResultItem[]>[] = []
|
||
|
||
// 学生不能搜索题目和考试
|
||
if (!isStudent && (type === "all" || type === "question")) {
|
||
tasks.push(searchQuestions(kw, pageSize))
|
||
}
|
||
if (type === "all" || type === "textbook") {
|
||
tasks.push(searchTextbooks(kw, pageSize))
|
||
}
|
||
// 学生不能搜索考试
|
||
if (!isStudent && (type === "all" || type === "exam")) {
|
||
tasks.push(searchExams(kw, pageSize))
|
||
}
|
||
if (type === "all" || type === "announcement") {
|
||
tasks.push(searchAnnouncements(kw, pageSize))
|
||
}
|
||
|
||
const grouped = await Promise.all(tasks)
|
||
for (const group of grouped) {
|
||
results.push(...group)
|
||
}
|
||
|
||
// 按 createdAt 降序排序后分页
|
||
results.sort((a, b) => b.createdAt.localeCompare(a.createdAt))
|
||
const total = results.length
|
||
const paged = results.slice(offset, offset + pageSize)
|
||
|
||
return NextResponse.json<SearchResponse>({
|
||
success: true,
|
||
query: q,
|
||
type,
|
||
results: paged,
|
||
total,
|
||
page,
|
||
pageSize,
|
||
})
|
||
} catch (e) {
|
||
const message = e instanceof Error ? e.message : "Search failed"
|
||
const status = message.includes("auth_required") ? 401 : 500
|
||
return NextResponse.json({ success: false, message }, { status })
|
||
}
|
||
}
|
||
|
||
async function searchQuestions(kw: string, limit: number): Promise<SearchResultItem[]> {
|
||
try {
|
||
const rows = await db
|
||
.select({
|
||
id: questions.id,
|
||
content: questions.content,
|
||
type: questions.type,
|
||
createdAt: questions.createdAt,
|
||
})
|
||
.from(questions)
|
||
.where(
|
||
or(
|
||
// JSON 内容字段转换为文本进行模糊匹配
|
||
sql`CAST(${questions.content} AS CHAR) LIKE ${kw}`,
|
||
like(sql`CAST(${questions.content} AS CHAR)`, kw)
|
||
)!
|
||
)
|
||
.orderBy(desc(questions.createdAt))
|
||
.limit(limit)
|
||
|
||
return rows.map((r) => {
|
||
const text = extractTextFromJson(r.content)
|
||
return {
|
||
id: r.id,
|
||
title: truncate(text, 80) || `Question (${r.type})`,
|
||
snippet: truncate(text, 200),
|
||
type: "question" as const,
|
||
href: `/admin/questions?id=${r.id}`,
|
||
createdAt: r.createdAt.toISOString(),
|
||
}
|
||
})
|
||
} catch {
|
||
return []
|
||
}
|
||
}
|
||
|
||
async function searchTextbooks(kw: string, limit: number): Promise<SearchResultItem[]> {
|
||
try {
|
||
const rows = await db
|
||
.select({
|
||
id: textbooks.id,
|
||
title: textbooks.title,
|
||
subject: textbooks.subject,
|
||
grade: textbooks.grade,
|
||
publisher: textbooks.publisher,
|
||
createdAt: textbooks.createdAt,
|
||
})
|
||
.from(textbooks)
|
||
.where(
|
||
or(
|
||
like(textbooks.title, kw),
|
||
like(textbooks.subject, kw),
|
||
like(textbooks.publisher, kw)
|
||
)!
|
||
)
|
||
.orderBy(desc(textbooks.createdAt))
|
||
.limit(limit)
|
||
|
||
return rows.map((r) => ({
|
||
id: r.id,
|
||
title: r.title,
|
||
snippet: [r.subject, r.grade, r.publisher].filter(Boolean).join(" · "),
|
||
type: "textbook" as const,
|
||
href: `/admin/textbooks?id=${r.id}`,
|
||
createdAt: r.createdAt.toISOString(),
|
||
}))
|
||
} catch {
|
||
return []
|
||
}
|
||
}
|
||
|
||
async function searchExams(kw: string, limit: number): Promise<SearchResultItem[]> {
|
||
try {
|
||
const rows = await db
|
||
.select({
|
||
id: exams.id,
|
||
title: exams.title,
|
||
description: exams.description,
|
||
status: exams.status,
|
||
createdAt: exams.createdAt,
|
||
})
|
||
.from(exams)
|
||
.where(
|
||
or(
|
||
like(exams.title, kw),
|
||
like(exams.description, kw)
|
||
)!
|
||
)
|
||
.orderBy(desc(exams.createdAt))
|
||
.limit(limit)
|
||
|
||
return rows.map((r) => ({
|
||
id: r.id,
|
||
title: r.title,
|
||
snippet: r.description ?? `Status: ${r.status ?? "draft"}`,
|
||
type: "exam" as const,
|
||
href: `/admin/exams?id=${r.id}`,
|
||
createdAt: r.createdAt.toISOString(),
|
||
}))
|
||
} catch {
|
||
return []
|
||
}
|
||
}
|
||
|
||
async function searchAnnouncements(kw: string, limit: number): Promise<SearchResultItem[]> {
|
||
try {
|
||
const rows = await db
|
||
.select({
|
||
id: announcements.id,
|
||
title: announcements.title,
|
||
content: announcements.content,
|
||
type: announcements.type,
|
||
status: announcements.status,
|
||
createdAt: announcements.createdAt,
|
||
})
|
||
.from(announcements)
|
||
.where(
|
||
and(
|
||
eq(announcements.status, "published"),
|
||
or(
|
||
like(announcements.title, kw),
|
||
like(announcements.content, kw)
|
||
)!
|
||
)
|
||
)
|
||
.orderBy(desc(announcements.createdAt))
|
||
.limit(limit)
|
||
|
||
return rows.map((r) => ({
|
||
id: r.id,
|
||
title: r.title,
|
||
snippet: truncate(stripHtml(r.content), 200),
|
||
type: "announcement" as const,
|
||
href: `/announcements/${r.id}`,
|
||
createdAt: r.createdAt.toISOString(),
|
||
}))
|
||
} catch {
|
||
return []
|
||
}
|
||
}
|
||
|
||
function extractTextFromJson(content: unknown): string {
|
||
if (typeof content === "string") return content
|
||
try {
|
||
return JSON.stringify(content)
|
||
} catch {
|
||
return ""
|
||
}
|
||
}
|
||
|
||
function stripHtml(html: string): string {
|
||
return html.replace(/<[^>]*>/g, "").replace(/\s+/g, " ").trim()
|
||
}
|
||
|
||
function truncate(s: string, max: number): string {
|
||
const t = s.trim()
|
||
if (t.length <= max) return t
|
||
return t.slice(0, max) + "..."
|
||
}
|