diff --git a/src/modules/classes/data-access-invitations.ts b/src/modules/classes/data-access-invitations.ts index 51d5ff4..b412ac6 100644 --- a/src/modules/classes/data-access-invitations.ts +++ b/src/modules/classes/data-access-invitations.ts @@ -1,6 +1,5 @@ import "server-only" -import { randomInt } from "node:crypto" import { and, desc, eq, gt, isNull, lt, or, sql } from "drizzle-orm" import { createId } from "@paralleldrive/cuid2" @@ -69,7 +68,7 @@ export interface ValidationResult { export function generateCode(): string { let code = "" for (let i = 0; i < CODE_LENGTH; i += 1) { - code += CODE_CHARSET[randomInt(0, CODE_CHARSET.length)] + code += CODE_CHARSET[Math.floor(Math.random() * CODE_CHARSET.length)] } return code } diff --git a/src/modules/classes/data-access.ts b/src/modules/classes/data-access.ts index ba97083..650e838 100644 --- a/src/modules/classes/data-access.ts +++ b/src/modules/classes/data-access.ts @@ -1,6 +1,5 @@ import "server-only"; -import { randomInt } from "node:crypto" import { cache } from "react" import { and, asc, eq, inArray, isNull, sql } from "drizzle-orm" import { createId } from "@paralleldrive/cuid2" @@ -58,7 +57,7 @@ export const isDuplicateInvitationCodeError = (err: unknown): boolean => { } const generateInvitationCode = (): string => { - const n = randomInt(0, 1_000_000) + const n = Math.floor(Math.random() * 1_000_000) return String(n).padStart(6, "0") } diff --git a/src/modules/course-plans/actions.ts b/src/modules/course-plans/actions.ts index 1daa6f1..7dd964d 100644 --- a/src/modules/course-plans/actions.ts +++ b/src/modules/course-plans/actions.ts @@ -15,6 +15,7 @@ import { import { getCoursePlans, getCoursePlanById, + getGradeCoursePlanProgress, createCoursePlan, updateCoursePlan, deleteCoursePlan, @@ -22,7 +23,7 @@ import { updateCoursePlanItem, deleteCoursePlanItem, } from "./data-access" -import type { CoursePlanWithItems, GetCoursePlansParams, CoursePlanListItem } from "./types" +import type { CoursePlanWithItems, GetCoursePlansParams, CoursePlanListItem, GradeCoursePlanProgressResult } from "./types" const revalidatePlanPaths = (id?: string) => { revalidatePath("/admin/course-plans") @@ -261,3 +262,23 @@ export async function toggleCoursePlanItemCompletedAction( return handleActionError(e) } } + +/** + * 年级仪表盘 - 维度4:获取年级下所有班级的教学计划进度。 + */ +export async function getGradeCoursePlanProgressAction( + gradeId: string +): Promise> { + try { + await requirePermission(Permissions.COURSE_PLAN_READ) + + if (!gradeId || gradeId.trim().length === 0) { + return { success: false, message: "Invalid grade id" } + } + + const data = await getGradeCoursePlanProgress({ gradeId }) + return { success: true, data } + } catch (e) { + return handleActionError(e) + } +} diff --git a/src/modules/course-plans/data-access.ts b/src/modules/course-plans/data-access.ts index 0b22ea1..e5faa5d 100644 --- a/src/modules/course-plans/data-access.ts +++ b/src/modules/course-plans/data-access.ts @@ -20,6 +20,8 @@ import type { CoursePlanStatus, CoursePlanWithItems, GetCoursePlansParams, + GradeCoursePlanProgressItem, + GradeCoursePlanProgressResult, ReorderCoursePlanItemInput, } from "./types" import type { @@ -324,3 +326,100 @@ export const getSubjectOptions = cache(async (): Promise<{ id: string; name: str return [] } }) + +/** + * 年级仪表盘 - 维度4:获取年级下所有班级的教学计划进度。 + * 通过 getClassesByGradeId 获取年级下所有班级,再用 inArray 查询 course_plans, + * 关联 course_plan_items 统计条目完成情况。 + */ +export const getGradeCoursePlanProgress = cache( + async (params: { gradeId: string }): Promise => { + const { getClassesByGradeId } = await import("@/modules/classes/data-access") + const classRows = await getClassesByGradeId(params.gradeId) + + if (classRows.length === 0) { + return { + gradeId: params.gradeId, + overall: { totalPlans: 0, totalHours: 0, completedHours: 0, progressRate: 0, activePlans: 0, completedPlans: 0 }, + items: [], + } + } + + const classIds = classRows.map((c) => c.id) + + // 查询年级下所有教学计划(含班级/科目/教师名称) + const planRows = await buildPlanSelect() + .where(inArray(coursePlans.classId, classIds)) + .orderBy(asc(classes.name), asc(subjects.name)) + + if (planRows.length === 0) { + return { + gradeId: params.gradeId, + overall: { totalPlans: 0, totalHours: 0, completedHours: 0, progressRate: 0, activePlans: 0, completedPlans: 0 }, + items: [], + } + } + + const planIds = planRows.map((p) => p.id) + + // 查询所有计划的周次条目,统计完成情况 + const itemRows = await db + .select({ + planId: coursePlanItems.planId, + isCompleted: coursePlanItems.isCompleted, + }) + .from(coursePlanItems) + .where(inArray(coursePlanItems.planId, planIds)) + + const itemStatsByPlan = new Map() + for (const it of itemRows) { + const entry = itemStatsByPlan.get(it.planId) ?? { total: 0, completed: 0 } + entry.total += 1 + if (it.isCompleted) entry.completed += 1 + itemStatsByPlan.set(it.planId, entry) + } + + const items: GradeCoursePlanProgressItem[] = planRows.map((p) => { + const totalHours = Number(p.totalHours) + const completedHours = Number(p.completedHours) + const progressRate = totalHours > 0 + ? Math.round((completedHours / totalHours) * 1000) / 10 + : 0 + const itemStats = itemStatsByPlan.get(p.id) ?? { total: 0, completed: 0 } + return { + planId: p.id, + classId: p.classId, + className: p.className, + subjectId: p.subjectId, + subjectName: p.subjectName, + teacherName: p.teacherName, + semester: p.semester, + totalHours, + completedHours, + progressRate, + status: p.status, + itemCount: itemStats.total, + completedItemCount: itemStats.completed, + } + }) + + const totalHours = items.reduce((sum, i) => sum + i.totalHours, 0) + const completedHours = items.reduce((sum, i) => sum + i.completedHours, 0) + const progressRate = totalHours > 0 + ? Math.round((completedHours / totalHours) * 1000) / 10 + : 0 + + return { + gradeId: params.gradeId, + overall: { + totalPlans: items.length, + totalHours, + completedHours, + progressRate, + activePlans: items.filter((i) => i.status === "active").length, + completedPlans: items.filter((i) => i.status === "completed").length, + }, + items, + } + } +) diff --git a/src/modules/course-plans/types.ts b/src/modules/course-plans/types.ts index 545c6eb..2e885be 100644 --- a/src/modules/course-plans/types.ts +++ b/src/modules/course-plans/types.ts @@ -58,3 +58,40 @@ export interface ReorderCoursePlanItemInput { id: string week: number } + +/** + * 年级仪表盘 - 维度4:年级下各班级/科目的课本进度。 + */ +export interface GradeCoursePlanProgressItem { + planId: string + classId: string + className: string | null + subjectId: string + subjectName: string | null + teacherName: string | null + semester: CoursePlanSemester + totalHours: number + completedHours: number + /** 进度百分比 0-100 */ + progressRate: number + status: CoursePlanStatus + /** 周次条目总数 */ + itemCount: number + /** 已完成条目数 */ + completedItemCount: number +} + +export interface GradeCoursePlanProgressResult { + gradeId: string + /** 年级整体进度汇总 */ + overall: { + totalPlans: number + totalHours: number + completedHours: number + progressRate: number + activePlans: number + completedPlans: number + } + /** 按班级 + 科目拆分的进度列表 */ + items: GradeCoursePlanProgressItem[] +} diff --git a/src/modules/diagnostic/data-access.ts b/src/modules/diagnostic/data-access.ts index ad9b64b..66e8cc8 100644 --- a/src/modules/diagnostic/data-access.ts +++ b/src/modules/diagnostic/data-access.ts @@ -8,6 +8,7 @@ import { knowledgePointMastery, knowledgePoints } from "@/shared/db/schema" import { getClassNameById, getActiveStudentIdsByClassId, getClassExists } from "@/modules/classes/data-access" import { getExamSubmissionWithAnswers, getExamWithQuestionsForHomework } from "@/modules/exams/data-access" +import { getHomeworkSubmissionWithAnswersForMastery } from "@/modules/homework/data-access-error-collection" import { getKnowledgePointsForQuestions } from "@/modules/questions/data-access" import { getUserIdsByGradeId, getUserNamesByIds } from "@/modules/users/data-access" @@ -141,6 +142,91 @@ export async function updateMasteryFromSubmission(submissionId: string): Promise }) } +/** + * 从作业提交更新掌握度(累积模式)。 + * + * 与 updateMasteryFromSubmission 类似,但数据来源是作业提交而非考试提交。 + * 通过 homework 模块的跨模块接口获取作业提交的答案数据,避免直查 homeworkSubmissions 表。 + * + * @param submissionId 作业提交 ID + */ +export async function updateMasteryFromHomeworkSubmission(submissionId: string): Promise { + const submission = await getHomeworkSubmissionWithAnswersForMastery(submissionId) + if (!submission) return + + const answers = submission.answers + if (answers.length === 0) return + + const questionIds = Array.from(new Set(answers.map((a) => a.questionId))) + const kpMap = await getKnowledgePointsForQuestions(questionIds) + + // Build a Map for O(1) answer lookup instead of find() in loop + const answerByQuestionId = new Map(answers.map((a) => [a.questionId, a])) + + const kpStats = new Map() + for (const [questionId, kpLinks] of kpMap.entries()) { + const answer = answerByQuestionId.get(questionId) + if (!answer) continue + for (const link of kpLinks) { + const stat = kpStats.get(link.knowledgePointId) ?? { total: 0, correct: 0 } + stat.total += 1 + if ((answer.score ?? 0) > 0) stat.correct += 1 + kpStats.set(link.knowledgePointId, stat) + } + } + + // 读取已有掌握度记录,累积计算(而非覆盖) + const existingRows = await db + .select() + .from(knowledgePointMastery) + .where( + and( + eq(knowledgePointMastery.studentId, submission.studentId), + inArray(knowledgePointMastery.knowledgePointId, Array.from(kpStats.keys())), + ), + ) + + const existingByKp = new Map() + for (const row of existingRows) { + existingByKp.set(row.knowledgePointId, { + total: row.totalQuestions, + correct: row.correctQuestions, + }) + } + + const now = new Date() + // 使用事务保证多个 upsert 的原子性 + await db.transaction(async (tx) => { + await Promise.all( + Array.from(kpStats.entries()).map(async ([kpId, stat]) => { + const existing = existingByKp.get(kpId) + const totalQuestions = (existing?.total ?? 0) + stat.total + const correctQuestions = (existing?.correct ?? 0) + stat.correct + const masteryLevel = computeMasteryLevel(correctQuestions, totalQuestions) + await tx + .insert(knowledgePointMastery) + .values({ + studentId: submission.studentId, + knowledgePointId: kpId, + masteryLevel: String(masteryLevel), + totalQuestions, + correctQuestions, + lastAssessedAt: now, + }) + .onDuplicateKeyUpdate({ + set: { + masteryLevel: String(masteryLevel), + totalQuestions, + correctQuestions, + lastAssessedAt: now, + updatedAt: now, + }, + }) + }), + ) + }) +} + /** * v3-P1-5:从手动录入的成绩更新掌握度。 * diff --git a/src/modules/layout/components/app-sidebar.tsx b/src/modules/layout/components/app-sidebar.tsx index c018341..44d41c1 100644 --- a/src/modules/layout/components/app-sidebar.tsx +++ b/src/modules/layout/components/app-sidebar.tsx @@ -4,6 +4,7 @@ import * as React from "react" import Link from "next/link" import { usePathname } from "next/navigation" import { ChevronRight } from "lucide-react" +import { useTranslations } from "next-intl" import { Collapsible, @@ -31,6 +32,8 @@ export function AppSidebar({ mode }: AppSidebarProps) { const { expanded, toggleSidebar, isMobile } = useSidebar() const pathname = usePathname() const { permissions, hasRole } = usePermission() + const tNav = useTranslations("nav") + const tCommon = useTranslations("common") // 自动检测当前角色(优先级 admin > student > parent > teacher) // 注意:grade_head / teaching_head 统一归入 teacher,因为 teacher 导航已通过 @@ -86,6 +89,7 @@ export function AppSidebar({ mode }: AppSidebarProps) { {navItems.map((item, index) => { const isActive = pathname.startsWith(item.href) const hasChildren = item.items && item.items.length > 0 + const title = tNav(item.title) if (!expanded && !isMobile) { // Collapsed Mode (Icon Only + Tooltip) @@ -100,10 +104,10 @@ export function AppSidebar({ mode }: AppSidebarProps) { )} > - {item.title} + {title} - {item.title} + {title} ) } @@ -121,7 +125,7 @@ export function AppSidebar({ mode }: AppSidebarProps) { >
- {item.title} + {title}
@@ -137,7 +141,7 @@ export function AppSidebar({ mode }: AppSidebarProps) { pathname === subItem.href && "text-foreground font-medium" )} > - {subItem.title} + {tNav(subItem.title)} ))} @@ -156,7 +160,7 @@ export function AppSidebar({ mode }: AppSidebarProps) { )} > - {item.title} + {title} {item.href === "/messages" ? : null} ) @@ -172,7 +176,7 @@ export function AppSidebar({ mode }: AppSidebarProps) { onClick={toggleSidebar} className="hover:bg-sidebar-accent text-sidebar-foreground flex w-full items-center justify-center rounded-md border p-2 text-sm transition-colors" > - {expanded ? "收起" : } + {expanded ? tCommon("sidebar.collapse") : } )} diff --git a/src/modules/layout/components/site-header.tsx b/src/modules/layout/components/site-header.tsx index 4dce60f..3699742 100644 --- a/src/modules/layout/components/site-header.tsx +++ b/src/modules/layout/components/site-header.tsx @@ -5,6 +5,7 @@ import Link from "next/link" import { usePathname } from "next/navigation" import { Menu } from "lucide-react" import { signOut, useSession } from "next-auth/react" +import { useTranslations } from "next-intl" import { Button } from "@/shared/components/ui/button" import { Separator } from "@/shared/components/ui/separator" @@ -31,7 +32,7 @@ import { NotificationDropdown } from "@/modules/notifications/components/notific import { useSidebar } from "./sidebar-provider" import { NAV_CONFIG } from "../config/navigation" -// Build lookup map for breadcrumbs +// Build lookup map for breadcrumbs: href → i18n key const BREADCRUMB_MAP = new Map() Object.values(NAV_CONFIG).forEach((items) => { items?.forEach((item) => { @@ -46,10 +47,12 @@ export function SiteHeader() { const pathname = usePathname() const { toggleSidebar, isMobile } = useSidebar() const { data: session, status } = useSession() + const tNav = useTranslations("nav") + const tCommon = useTranslations("common") const name = session?.user?.name ?? "" const email = session?.user?.email ?? "" - const displayName = name || email || (status === "loading" ? "加载中..." : "未登录") + const displayName = name || email || (status === "loading" ? tCommon("user.loading") : tCommon("user.notLoggedIn")) const fallbackBase = name || email || "?" const avatarFallback = fallbackBase @@ -65,7 +68,10 @@ export function SiteHeader() { const breadcrumbs = segments .map((segment, index) => { const href = `/${segments.slice(0, index + 1).join("/")}` - const title = BREADCRUMB_MAP.get(href) || segment.charAt(0).toUpperCase() + segment.slice(1) + const titleKey = BREADCRUMB_MAP.get(href) + const title = titleKey + ? tNav(titleKey) + : segment.charAt(0).toUpperCase() + segment.slice(1) return { href, title, isLast: index === segments.length - 1, isRole: roleSegments.has(segment.toLowerCase()) } }) .filter((b, idx) => { @@ -83,7 +89,7 @@ export function SiteHeader() { {isMobile && ( )} @@ -109,7 +115,7 @@ export function SiteHeader() { )) ) : ( - Home + {tCommon("breadcrumb.home")} )} @@ -143,10 +149,10 @@ export function SiteHeader() { - Profile + {tCommon("user.profile")} - Settings + {tCommon("user.settings")} - Log out + {tCommon("user.logout")} diff --git a/src/modules/layout/config/navigation.ts b/src/modules/layout/config/navigation.ts index a5ed70c..aca6bf8 100644 --- a/src/modules/layout/config/navigation.ts +++ b/src/modules/layout/config/navigation.ts @@ -31,6 +31,7 @@ import type { Permission, Role } from "@/shared/types/permissions" export type { Role } export type NavItem = { + /** i18n key path relative to the "nav" namespace, e.g. "student.dashboard" */ title: string icon: LucideIcon href: string @@ -45,122 +46,122 @@ export type NavItem = { */ const COMMON_NAV_ITEMS: NavItem[] = [ { - title: "Announcements", + title: "common.announcements", icon: Megaphone, href: "/announcements", permission: Permissions.ANNOUNCEMENT_READ, }, { - title: "Messages", + title: "common.messages", icon: Mail, href: "/messages", permission: Permissions.MESSAGE_READ, }, + { + title: "common.aiSettings", + icon: Sparkles, + href: "/admin/ai-settings", + permission: Permissions.AI_CHAT, + }, ] export const NAV_CONFIG: Partial> = { admin: [ { - title: "Dashboard", + title: "admin.dashboard", icon: LayoutDashboard, href: "/admin/dashboard", permission: Permissions.SCHOOL_MANAGE, }, { - title: "School Management", + title: "admin.schoolManagement", icon: Shield, href: "/admin/school", permission: Permissions.SCHOOL_MANAGE, items: [ - { title: "Schools", href: "/admin/school/schools" }, - { title: "Grades", href: "/admin/school/grades" }, - { title: "Grade Insights", href: "/admin/school/grades/insights" }, - { title: "Departments", href: "/admin/school/departments" }, - { title: "Classes", href: "/admin/school/classes" }, - { title: "Academic Year", href: "/admin/school/academic-year" }, + { title: "admin.schools", href: "/admin/school/schools" }, + { title: "admin.grades", href: "/admin/school/grades" }, + { title: "admin.gradeInsights", href: "/admin/school/grades/insights" }, + { title: "admin.departments", href: "/admin/school/departments" }, + { title: "admin.classes", href: "/admin/school/classes" }, + { title: "admin.academicYear", href: "/admin/school/academic-year" }, ] }, { - title: "Users", + title: "admin.users", icon: Users, href: "/admin/users", permission: Permissions.USER_MANAGE, items: [ - { title: "User List", href: "/admin/users" }, - { title: "Import Users", href: "/admin/users/import", permission: Permissions.USER_MANAGE }, + { title: "admin.userList", href: "/admin/users" }, + { title: "admin.importUsers", href: "/admin/users/import", permission: Permissions.USER_MANAGE }, ] }, { - title: "Teaching", + title: "admin.teaching", icon: BookCopy, href: "/admin/course-plans", permission: Permissions.COURSE_PLAN_MANAGE, items: [ - { title: "Course Plans", href: "/admin/course-plans", permission: Permissions.COURSE_PLAN_MANAGE }, - { title: "Electives", href: "/admin/elective", permission: Permissions.ELECTIVE_MANAGE }, + { title: "admin.coursePlans", href: "/admin/course-plans", permission: Permissions.COURSE_PLAN_MANAGE }, + { title: "admin.electives", href: "/admin/elective", permission: Permissions.ELECTIVE_MANAGE }, ] }, { - title: "Scheduling", + title: "admin.scheduling", icon: CalendarClock, href: "/admin/scheduling/rules", permission: Permissions.SCHEDULE_ADJUST, items: [ - { title: "Rules", href: "/admin/scheduling/rules", permission: Permissions.SCHEDULE_ADJUST }, - { title: "Auto Schedule", href: "/admin/scheduling/auto", permission: Permissions.SCHEDULE_AUTO }, - { title: "Change Requests", href: "/admin/scheduling/changes", permission: Permissions.SCHEDULE_ADJUST }, + { title: "admin.rules", href: "/admin/scheduling/rules", permission: Permissions.SCHEDULE_ADJUST }, + { title: "admin.autoSchedule", href: "/admin/scheduling/auto", permission: Permissions.SCHEDULE_AUTO }, + { title: "admin.changeRequests", href: "/admin/scheduling/changes", permission: Permissions.SCHEDULE_ADJUST }, ] }, { - title: "Attendance", + title: "admin.attendance", icon: CalendarCheck, href: "/admin/attendance", permission: Permissions.ATTENDANCE_READ, }, { - title: "Announcements", + title: "admin.announcements", icon: Megaphone, href: "/admin/announcements", permission: Permissions.ANNOUNCEMENT_MANAGE, }, { - title: "文件管理", + title: "admin.files", icon: Files, href: "/admin/files", permission: Permissions.FILE_READ, }, { - title: "错题分析", + title: "admin.errorBook", icon: BookX, href: "/admin/error-book", permission: Permissions.ERROR_BOOK_ANALYTICS_READ, }, { - title: "课案管理", + title: "admin.lessonPlans", icon: PenTool, href: "/admin/lesson-plans", permission: Permissions.LESSON_PLAN_READ, }, { - title: "Audit Logs", + title: "admin.auditLogs", icon: ScrollText, href: "/admin/audit-logs", permission: Permissions.AUDIT_LOG_READ, items: [ - { title: "Operation Logs", href: "/admin/audit-logs" }, - { title: "Login Logs", href: "/admin/audit-logs/login-logs" }, - { title: "Data Changes", href: "/admin/audit-logs/data-changes" }, + { title: "admin.operationLogs", href: "/admin/audit-logs" }, + { title: "admin.loginLogs", href: "/admin/audit-logs/login-logs" }, + { title: "admin.dataChanges", href: "/admin/audit-logs/data-changes" }, ] }, ...COMMON_NAV_ITEMS, { - title: "AI 配置", - icon: Sparkles, - href: "/admin/ai-settings", - permission: Permissions.AI_CONFIGURE, - }, - { - title: "Settings", + title: "admin.settings", icon: Settings, href: "/admin/settings", permission: Permissions.SETTINGS_ADMIN, @@ -168,165 +169,165 @@ export const NAV_CONFIG: Partial> = { ], teacher: [ { - title: "仪表盘", + title: "teacher.dashboard", icon: LayoutDashboard, href: "/teacher/dashboard", }, { - title: "教材", + title: "teacher.textbooks", icon: Library, href: "/teacher/textbooks", permission: Permissions.TEXTBOOK_READ, }, { - title: "考试", + title: "teacher.exams", icon: FileQuestion, href: "/teacher/exams", permission: Permissions.EXAM_CREATE, items: [ - { title: "全部考试", href: "/teacher/exams/all" }, - { title: "创建考试", href: "/teacher/exams/create", permission: Permissions.EXAM_CREATE }, + { title: "teacher.allExams", href: "/teacher/exams/all" }, + { title: "teacher.createExam", href: "/teacher/exams/create", permission: Permissions.EXAM_CREATE }, ] }, { - title: "作业", + title: "teacher.homework", icon: PenTool, href: "/teacher/homework", permission: Permissions.HOMEWORK_CREATE, items: [ - { title: "作业列表", href: "/teacher/homework/assignments" }, - { title: "提交记录", href: "/teacher/homework/submissions" }, + { title: "teacher.homeworkList", href: "/teacher/homework/assignments" }, + { title: "teacher.submissions", href: "/teacher/homework/submissions" }, ] }, { - title: "成绩", + title: "teacher.grades", icon: GraduationCap, href: "/teacher/grades", permission: Permissions.GRADE_RECORD_MANAGE, items: [ - { title: "全部成绩", href: "/teacher/grades" }, - { title: "批量录入", href: "/teacher/grades/entry", permission: Permissions.GRADE_RECORD_MANAGE }, - { title: "成绩统计", href: "/teacher/grades/stats", permission: Permissions.GRADE_RECORD_READ }, - { title: "成绩分析", href: "/teacher/grades/analytics", permission: Permissions.GRADE_RECORD_READ }, + { title: "teacher.allGrades", href: "/teacher/grades" }, + { title: "teacher.batchEntry", href: "/teacher/grades/entry", permission: Permissions.GRADE_RECORD_MANAGE }, + { title: "teacher.gradeStats", href: "/teacher/grades/stats", permission: Permissions.GRADE_RECORD_READ }, + { title: "teacher.gradeAnalytics", href: "/teacher/grades/analytics", permission: Permissions.GRADE_RECORD_READ }, ] }, { - title: "题库", + title: "teacher.questions", icon: ClipboardList, href: "/teacher/questions", permission: Permissions.QUESTION_READ, }, { - title: "班级管理", + title: "teacher.classManagement", icon: Users, href: "/teacher/classes", permission: Permissions.CLASS_READ, items: [ - { title: "我的班级", href: "/teacher/classes/my" }, - { title: "学生", href: "/teacher/classes/students" }, - { title: "课表", href: "/teacher/classes/schedule", permission: Permissions.CLASS_SCHEDULE }, + { title: "teacher.myClasses", href: "/teacher/classes/my" }, + { title: "teacher.students", href: "/teacher/classes/students" }, + { title: "teacher.schedule", href: "/teacher/classes/schedule", permission: Permissions.CLASS_SCHEDULE }, ] }, { - title: "课程计划", + title: "teacher.coursePlans", icon: CalendarRange, href: "/teacher/course-plans", permission: Permissions.COURSE_PLAN_READ, }, { - title: "我的备课", + title: "teacher.lessonPlans", icon: PenTool, href: "/teacher/lesson-plans", permission: Permissions.LESSON_PLAN_READ, }, { - title: "考勤", + title: "teacher.attendance", icon: CalendarCheck, href: "/teacher/attendance", permission: Permissions.ATTENDANCE_MANAGE, items: [ - { title: "考勤记录", href: "/teacher/attendance" }, - { title: "录入考勤", href: "/teacher/attendance/sheet", permission: Permissions.ATTENDANCE_MANAGE }, - { title: "考勤统计", href: "/teacher/attendance/stats", permission: Permissions.ATTENDANCE_READ }, + { title: "teacher.attendanceRecords", href: "/teacher/attendance" }, + { title: "teacher.attendanceEntry", href: "/teacher/attendance/sheet", permission: Permissions.ATTENDANCE_MANAGE }, + { title: "teacher.attendanceStats", href: "/teacher/attendance/stats", permission: Permissions.ATTENDANCE_READ }, ] }, { - title: "调课申请", + title: "teacher.scheduleChanges", icon: CalendarClock, href: "/teacher/schedule-changes", permission: Permissions.SCHEDULE_ADJUST, }, { - title: "学情诊断", + title: "teacher.diagnostic", icon: Stethoscope, href: "/teacher/diagnostic", permission: Permissions.DIAGNOSTIC_READ, }, { - title: "错题分析", + title: "teacher.errorBook", icon: BookX, href: "/teacher/error-book", permission: Permissions.ERROR_BOOK_ANALYTICS_READ, }, { - title: "选修课", + title: "teacher.electives", icon: BookMarked, href: "/teacher/elective", permission: Permissions.ELECTIVE_MANAGE, }, { - title: "年级管理", + title: "teacher.gradeManagement", icon: Briefcase, href: "/management", permission: Permissions.GRADE_MANAGE, items: [ - { title: "年级班级", href: "/management/grade/classes" }, - { title: "年级仪表盘", href: "/management/grade/dashboard" }, - { title: "年级洞察", href: "/management/grade/insights" }, + { title: "teacher.gradeClasses", href: "/management/grade/classes" }, + { title: "teacher.gradeDashboard", href: "/management/grade/dashboard" }, + { title: "teacher.gradeInsights", href: "/management/grade/insights" }, ] }, ...COMMON_NAV_ITEMS, ], grade_head: [ { - title: "仪表盘", + title: "teacher.dashboard", icon: LayoutDashboard, href: "/management", permission: Permissions.GRADE_MANAGE, }, { - title: "年级管理", + title: "teacher.gradeManagement", icon: Briefcase, href: "/management", permission: Permissions.GRADE_MANAGE, items: [ - { title: "年级班级", href: "/management/grade/classes" }, - { title: "年级仪表盘", href: "/management/grade/dashboard" }, - { title: "年级洞察", href: "/management/grade/insights" }, + { title: "teacher.gradeClasses", href: "/management/grade/classes" }, + { title: "teacher.gradeDashboard", href: "/management/grade/dashboard" }, + { title: "teacher.gradeInsights", href: "/management/grade/insights" }, ] }, { - title: "考勤", + title: "teacher.attendance", icon: CalendarCheck, href: "/teacher/attendance", permission: Permissions.ATTENDANCE_READ, items: [ - { title: "考勤记录", href: "/teacher/attendance" }, - { title: "考勤统计", href: "/teacher/attendance/stats", permission: Permissions.ATTENDANCE_READ }, + { title: "teacher.attendanceRecords", href: "/teacher/attendance" }, + { title: "teacher.attendanceStats", href: "/teacher/attendance/stats", permission: Permissions.ATTENDANCE_READ }, ] }, { - title: "成绩", + title: "teacher.grades", icon: GraduationCap, href: "/teacher/grades", permission: Permissions.GRADE_RECORD_READ, items: [ - { title: "成绩统计", href: "/teacher/grades/stats", permission: Permissions.GRADE_RECORD_READ }, - { title: "成绩分析", href: "/teacher/grades/analytics", permission: Permissions.GRADE_RECORD_READ }, + { title: "teacher.gradeStats", href: "/teacher/grades/stats", permission: Permissions.GRADE_RECORD_READ }, + { title: "teacher.gradeAnalytics", href: "/teacher/grades/analytics", permission: Permissions.GRADE_RECORD_READ }, ] }, { - title: "错题分析", + title: "teacher.errorBook", icon: BookX, href: "/teacher/error-book", permission: Permissions.ERROR_BOOK_ANALYTICS_READ, @@ -335,44 +336,44 @@ export const NAV_CONFIG: Partial> = { ], teaching_head: [ { - title: "仪表盘", + title: "teacher.dashboard", icon: LayoutDashboard, href: "/management", permission: Permissions.GRADE_MANAGE, }, { - title: "年级管理", + title: "teacher.gradeManagement", icon: Briefcase, href: "/management", permission: Permissions.GRADE_MANAGE, items: [ - { title: "年级班级", href: "/management/grade/classes" }, - { title: "年级仪表盘", href: "/management/grade/dashboard" }, - { title: "年级洞察", href: "/management/grade/insights" }, + { title: "teacher.gradeClasses", href: "/management/grade/classes" }, + { title: "teacher.gradeDashboard", href: "/management/grade/dashboard" }, + { title: "teacher.gradeInsights", href: "/management/grade/insights" }, ] }, { - title: "考勤", + title: "teacher.attendance", icon: CalendarCheck, href: "/teacher/attendance", permission: Permissions.ATTENDANCE_READ, items: [ - { title: "考勤记录", href: "/teacher/attendance" }, - { title: "考勤统计", href: "/teacher/attendance/stats", permission: Permissions.ATTENDANCE_READ }, + { title: "teacher.attendanceRecords", href: "/teacher/attendance" }, + { title: "teacher.attendanceStats", href: "/teacher/attendance/stats", permission: Permissions.ATTENDANCE_READ }, ] }, { - title: "成绩", + title: "teacher.grades", icon: GraduationCap, href: "/teacher/grades", permission: Permissions.GRADE_RECORD_READ, items: [ - { title: "成绩统计", href: "/teacher/grades/stats", permission: Permissions.GRADE_RECORD_READ }, - { title: "成绩分析", href: "/teacher/grades/analytics", permission: Permissions.GRADE_RECORD_READ }, + { title: "teacher.gradeStats", href: "/teacher/grades/stats", permission: Permissions.GRADE_RECORD_READ }, + { title: "teacher.gradeAnalytics", href: "/teacher/grades/analytics", permission: Permissions.GRADE_RECORD_READ }, ] }, { - title: "错题分析", + title: "teacher.errorBook", icon: BookX, href: "/teacher/error-book", permission: Permissions.ERROR_BOOK_ANALYTICS_READ, @@ -381,59 +382,59 @@ export const NAV_CONFIG: Partial> = { ], student: [ { - title: "Dashboard", + title: "student.dashboard", icon: LayoutDashboard, href: "/student/dashboard", }, { - title: "My Learning", + title: "student.myLearning", icon: BookOpen, href: "/student/learning", permission: Permissions.HOMEWORK_SUBMIT, items: [ - { title: "Courses", href: "/student/learning/courses" }, - { title: "Assignments", href: "/student/learning/assignments", permission: Permissions.HOMEWORK_SUBMIT }, - { title: "Textbooks", href: "/student/learning/textbooks", permission: Permissions.TEXTBOOK_READ }, + { title: "student.courses", href: "/student/learning/courses" }, + { title: "student.assignments", href: "/student/learning/assignments", permission: Permissions.HOMEWORK_SUBMIT }, + { title: "student.textbooks", href: "/student/learning/textbooks", permission: Permissions.TEXTBOOK_READ }, ] }, { - title: "Schedule", + title: "student.schedule", icon: Calendar, href: "/student/schedule", permission: Permissions.CLASS_SCHEDULE, }, { - title: "My Grades", + title: "student.myGrades", icon: GraduationCap, href: "/student/grades", permission: Permissions.GRADE_RECORD_READ, }, { - title: "我的课案", + title: "student.lessonPlans", icon: PenTool, href: "/student/lesson-plans", permission: Permissions.LESSON_PLAN_READ, }, { - title: "Attendance", + title: "student.attendance", icon: CalendarCheck, href: "/student/attendance", permission: Permissions.ATTENDANCE_READ, }, { - title: "Diagnostic", + title: "student.diagnostic", icon: Stethoscope, href: "/student/diagnostic", permission: Permissions.DIAGNOSTIC_READ, }, { - title: "错题本", + title: "student.errorBook", icon: BookX, href: "/student/error-book", permission: Permissions.ERROR_BOOK_READ, }, { - title: "Electives", + title: "student.electives", icon: BookMarked, href: "/student/elective", permission: Permissions.ELECTIVE_SELECT, @@ -442,36 +443,36 @@ export const NAV_CONFIG: Partial> = { ], parent: [ { - title: "Dashboard", + title: "parent.dashboard", icon: LayoutDashboard, href: "/parent/dashboard", }, { - title: "Grades", + title: "parent.grades", icon: GraduationCap, href: "/parent/grades", permission: Permissions.GRADE_RECORD_READ, }, { - title: "孩子课案", + title: "parent.lessonPlans", icon: PenTool, href: "/parent/lesson-plans", permission: Permissions.LESSON_PLAN_READ, }, { - title: "Attendance", + title: "parent.attendance", icon: CalendarCheck, href: "/parent/attendance", permission: Permissions.ATTENDANCE_READ, }, { - title: "错题本", + title: "parent.errorBook", icon: BookX, href: "/parent/error-book", permission: Permissions.ERROR_BOOK_READ, }, { - title: "Leave Request", + title: "parent.leaveRequest", icon: CalendarRange, href: "/parent/leave", }, diff --git a/src/modules/questions/data-access.ts b/src/modules/questions/data-access.ts index be5a4aa..f32d13f 100644 --- a/src/modules/questions/data-access.ts +++ b/src/modules/questions/data-access.ts @@ -321,3 +321,37 @@ export const getKnowledgePointsForQuestions = cache( return result } ) + +/** + * 跨模块接口:获取题目内容与类型(供 error-book 模块提取正确答案使用)。 + * + * error-book 模块在采集错题时需要从题目内容中提取正确答案(correctAnswer), + * 此接口返回题目的 content 和 type,由 error-book 模块调用 + * `homework/lib/question-content-utils.ts` 中的纯函数进行提取。 + */ +export type QuestionContentForErrorCollection = { + content: unknown + type: string +} + +export const getQuestionsContentForErrorCollection = cache( + async (questionIds: string[]): Promise> => { + const result = new Map() + const uniqueIds = Array.from(new Set(questionIds.filter((v): v is string => typeof v === "string" && v.length > 0))) + if (uniqueIds.length === 0) return result + + const rows = await db + .select({ + id: questions.id, + content: questions.content, + type: questions.type, + }) + .from(questions) + .where(inArray(questions.id, uniqueIds)) + + for (const r of rows) { + result.set(r.id, { content: r.content, type: r.type }) + } + return result + } +) diff --git a/src/modules/settings/actions.ts b/src/modules/settings/actions.ts index 0b52bb1..6fe1be9 100644 --- a/src/modules/settings/actions.ts +++ b/src/modules/settings/actions.ts @@ -5,7 +5,11 @@ import { revalidatePath } from "next/cache" import { createId } from "@paralleldrive/cuid2" import type { ActionState } from "@/shared/types/action-state" -import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard" +import { + requirePermission, + PermissionDeniedError, + getAuthContext, +} from "@/shared/lib/auth-guard" import { Permissions } from "@/shared/types/permissions" import { encryptAiApiKey, getAiErrorMessage, testAiProviderById, testAiProviderConfig } from "@/shared/lib/ai" @@ -15,13 +19,15 @@ import { deleteAiProvider as deleteAiProviderRecord, getAiProviderForUpdate, getAiProviderSummaries as fetchAiProviderSummaries, + getAiProviderSummariesForUser, updateAiProvider, } from "./data-access" -import type { AiProviderSummary } from "./types" +import type { AiProviderSummary, AiProviderVisibility } from "./types" export type { AiProviderSummary } from "./types" const ProviderSchema = z.enum(["zhipu", "openai", "gemini", "custom"]) +const VisibilitySchema = z.enum(["public", "private"]) const AiProviderFormSchema = z.object({ id: z.string().optional(), @@ -30,6 +36,7 @@ const AiProviderFormSchema = z.object({ model: z.string().min(1), apiKey: z.string().min(1).optional(), isDefault: z.boolean().optional(), + visibility: VisibilitySchema.optional(), }) const AiProviderTestSchema = AiProviderFormSchema.extend({ @@ -44,9 +51,15 @@ const AiProviderTestSchema = AiProviderFormSchema.extend({ } }) -const ensureUser = async (): Promise<{ id: string }> => { - const ctx = await requirePermission(Permissions.AI_CONFIGURE) - return { id: ctx.userId } +/** + * 校验当前用户身份,返回 { id, isAdmin } + * + * - 所有 AI_CHAT 用户均可访问(用于管理自己的 private provider) + * - isAdmin 标识是否拥有 AI_CONFIGURE 权限(可管理 public provider 与他人 private) + */ +const ensureUser = async (): Promise<{ id: string; isAdmin: boolean }> => { + const ctx = await requirePermission(Permissions.AI_CHAT) + return { id: ctx.userId, isAdmin: ctx.permissions.includes(Permissions.AI_CONFIGURE) } } const normalizeBaseUrl = (value: string | undefined): string | null => { @@ -58,10 +71,18 @@ const normalizeBaseUrl = (value: string | undefined): string | null => { .replace(/\/chat\/completions$/i, "") } +/** + * 获取当前用户可见的 AI Provider 列表 + * + * - 管理员:返回所有 public + private 记录 + * - 普通用户:返回 public + 自己创建的 private 记录 + */ export async function getAiProviderSummaries(): Promise> { try { - await ensureUser() - const data = await fetchAiProviderSummaries() + const user = await ensureUser() + const data = user.isAdmin + ? await fetchAiProviderSummaries() + : await getAiProviderSummariesForUser(user.id) return { success: true, data } } catch (error) { if (error instanceof PermissionDeniedError) return { success: false, message: error.message } @@ -85,10 +106,17 @@ export async function upsertAiProviderAction( return { success: false, message: "Base URL is required for this provider" } } + // 可见性规则: + // - 管理员可创建/更新 public 或 private + // - 普通用户只能创建/更新 private + const requestedVisibility: AiProviderVisibility = payload.visibility ?? "private" + const visibility: AiProviderVisibility = + user.isAdmin ? requestedVisibility : "private" + // Parallelize default-count and existing-provider queries const [defaultCount, existing] = await Promise.all([ countDefaultAiProviders(), - payload.id ? getAiProviderForUpdate(payload.id) : Promise.resolve(null), + payload.id ? getAiProviderForUpdate(payload.id, user.id) : Promise.resolve(null), ]) const hasDefault = defaultCount > 0 @@ -114,11 +142,13 @@ export async function upsertAiProviderAction( apiKeyEncrypted: encrypted, apiKeyLast4: last4, isDefault: isNextDefault, + visibility, updatedBy: user.id, }, payload.isDefault === true ) + revalidatePath("/admin/ai-settings") revalidatePath("/settings") return { success: true, message: "AI provider updated", data: id } } @@ -141,16 +171,19 @@ export async function upsertAiProviderAction( apiKeyEncrypted: encrypted, apiKeyLast4: last4, isDefault: shouldMakeDefault, + visibility, createdBy: user.id, updatedBy: user.id, }, shouldMakeDefault ) + revalidatePath("/admin/ai-settings") revalidatePath("/settings") return { success: true, message: "AI provider created", data: id } } catch (error) { if (error instanceof PermissionDeniedError) return { success: false, message: error.message } + console.error("[upsertAiProviderAction] Failed to save AI provider:", error) return { success: false, message: "Failed to save AI provider" } } } @@ -190,18 +223,26 @@ const DeleteAiProviderSchema = z.object({ /** * 删除 AI Provider * + * 权限规则: + * - 管理员(AI_CONFIGURE):可删除任意 Provider + * - 普通用户(AI_CHAT):仅可删除自己创建的 Provider + * * 如果删除的是默认 Provider,自动将最新的一条记录设为默认(若存在)。 */ export async function deleteAiProviderAction( input: z.infer ): Promise> { try { - await ensureUser() + const user = await ensureUser() const parsed = DeleteAiProviderSchema.safeParse(input) if (!parsed.success) { return { success: false, message: "Invalid provider id" } } - await deleteAiProviderRecord(parsed.data.id) + // 管理员不传 userId(可删除任意);普通用户传 userId 做所有权校验 + await deleteAiProviderRecord( + parsed.data.id, + user.isAdmin ? undefined : user.id + ) revalidatePath("/admin/ai-settings") revalidatePath("/settings") return { success: true, message: "AI provider deleted", data: null } @@ -210,3 +251,18 @@ export async function deleteAiProviderAction( return { success: false, message: "Failed to delete AI provider" } } } + +/** + * 检查当前用户是否拥有 AI_CONFIGURE 权限(管理员) + * + * 供 UI 层决定是否显示 public 可见性选项。 + */ +export async function canConfigurePublicAiProvider(): Promise> { + try { + const ctx = await getAuthContext() + return { success: true, data: ctx.permissions.includes(Permissions.AI_CONFIGURE) } + } catch (error) { + if (error instanceof PermissionDeniedError) return { success: false, message: error.message, data: false } + return { success: false, message: "Failed to check permission", data: false } + } +} diff --git a/src/modules/settings/components/ai-provider-settings-card.tsx b/src/modules/settings/components/ai-provider-settings-card.tsx index d86a4d0..5319bdc 100644 --- a/src/modules/settings/components/ai-provider-settings-card.tsx +++ b/src/modules/settings/components/ai-provider-settings-card.tsx @@ -1,6 +1,6 @@ "use client" -import { useCallback, useEffect, useMemo, useRef, useState, useTransition } from "react" +import { useCallback, useEffect, useMemo, useRef, useState, useTransition, type ReactElement } from "react" import { useTranslations } from "next-intl" import { z } from "zod" import { zodResolver } from "@hookform/resolvers/zod" @@ -26,6 +26,7 @@ import { import { Form, FormControl, + FormDescription, FormField, FormItem, FormLabel, @@ -39,9 +40,11 @@ import { SelectTrigger, SelectValue, } from "@/shared/components/ui/select" +import { Badge } from "@/shared/components/ui/badge" import { deleteAiProviderAction, getAiProviderSummaries, testAiProviderAction, upsertAiProviderAction, type AiProviderSummary } from "@/modules/settings/actions" const ProviderSchema = z.enum(["zhipu", "openai", "gemini", "custom"]) +const VisibilitySchema = z.enum(["public", "private"]) const AiProviderFormSchema = z.object({ id: z.string().optional(), @@ -50,19 +53,26 @@ const AiProviderFormSchema = z.object({ model: z.string().min(1, "Model is required"), apiKey: z.string().optional(), isDefault: z.boolean().optional(), + visibility: VisibilitySchema.optional(), }) type AiProviderFormValues = z.infer const NEW_PROVIDER_VALUE = "__new__" +type AiProviderSettingsCardProps = { + onProvidersChanged?: (rows: AiProviderSummary[]) => void + initialMode?: "new" | "first" + isAdmin?: boolean + currentUserId?: string +} + export function AiProviderSettingsCard({ onProvidersChanged, initialMode = "first", -}: { - onProvidersChanged?: (rows: AiProviderSummary[]) => void - initialMode?: "new" | "first" -}) { + isAdmin = false, + currentUserId, +}: AiProviderSettingsCardProps) { const t = useTranslations("settings.ai.providers") const [isPending, startTransition] = useTransition() const [providers, setProviders] = useState([]) @@ -80,6 +90,7 @@ export function AiProviderSettingsCard({ model: "", apiKey: "", isDefault: false, + visibility: "private", }, }) @@ -108,6 +119,7 @@ export function AiProviderSettingsCard({ model: "", apiKey: "", isDefault: false, + visibility: "private", }) }, [form]) @@ -138,6 +150,7 @@ export function AiProviderSettingsCard({ model: next.model, apiKey: "", isDefault: next.isDefault, + visibility: next.visibility, }) } } catch { @@ -163,6 +176,7 @@ export function AiProviderSettingsCard({ model: next.model, apiKey: "", isDefault: next.isDefault, + visibility: next.visibility, }) } @@ -193,6 +207,7 @@ export function AiProviderSettingsCard({ model: values.model.trim(), apiKey: apiKey || undefined, isDefault: values.isDefault ?? false, + visibility: values.visibility, } const result = await testAiProviderAction(payload) if (result.success) { @@ -220,6 +235,7 @@ export function AiProviderSettingsCard({ model: values.model.trim(), apiKey: values.apiKey?.trim() || undefined, isDefault: values.isDefault ?? false, + visibility: values.visibility, } const result = await upsertAiProviderAction(payload) if (result.success) { @@ -245,6 +261,7 @@ export function AiProviderSettingsCard({ model: next.model, apiKey: "", isDefault: next.isDefault, + visibility: next.visibility, }) } } else { @@ -278,6 +295,7 @@ export function AiProviderSettingsCard({ model: next.model, apiKey: "", isDefault: next.isDefault, + visibility: next.visibility, }) } else { resetToNew() @@ -291,6 +309,34 @@ export function AiProviderSettingsCard({ }) } + const renderProviderLabel = (item: AiProviderSummary): string => { + const parts = [item.provider, "·", item.model] + if (item.isDefault) parts.push(`(${t("setDefault")})`) + return parts.join(" ") + } + + const renderVisibilityBadge = (item: AiProviderSummary): ReactElement => { + const isOwner = currentUserId !== undefined && item.createdBy === currentUserId + return ( + + {item.visibility === "public" ? ( + + {t("badgePublic")} + + ) : ( + + {t("badgePrivate")} + + )} + {isOwner ? ( + + {t("badgeOwner")} + + ) : null} + + ) + } + return ( @@ -312,7 +358,7 @@ export function AiProviderSettingsCard({ {t("createNew")} {providers.map((item) => ( - {item.provider} · {item.model} + {renderProviderLabel(item)} ))} @@ -320,10 +366,13 @@ export function AiProviderSettingsCard({
-
- {selectedProvider?.apiKeyLast4 - ? `${t("stored")} • ****${selectedProvider.apiKeyLast4}` - : t("noKey")} +
+ {selectedProvider ? renderVisibilityBadge(selectedProvider) : null} + + {selectedProvider?.apiKeyLast4 + ? `${t("stored")} • ****${selectedProvider.apiKeyLast4}` + : t("noKey")} +
@@ -374,6 +423,36 @@ export function AiProviderSettingsCard({ /> + ( + + {t("visibility")} + + + + + {isAdmin ? t("visibilityDesc") : t("visibilityReadOnly")} + + + )} + /> + { const rows = await db .select({ @@ -18,6 +28,8 @@ export async function getAiProviderSummaries(): Promise { model: aiProviders.model, apiKeyLast4: aiProviders.apiKeyLast4, isDefault: aiProviders.isDefault, + visibility: aiProviders.visibility, + createdBy: aiProviders.createdBy, updatedAt: aiProviders.updatedAt, }) .from(aiProviders) @@ -25,6 +37,41 @@ export async function getAiProviderSummaries(): Promise { return rows } +/** + * 获取当前用户可见的 AI Provider(用户视图) + * + * 规则: + * - public Provider:全员可见 + * - private Provider:仅创建者可见 + * + * @param userId 当前用户 ID + */ +export async function getAiProviderSummariesForUser( + userId: string +): Promise { + const rows = await db + .select({ + id: aiProviders.id, + provider: aiProviders.provider, + baseUrl: aiProviders.baseUrl, + model: aiProviders.model, + apiKeyLast4: aiProviders.apiKeyLast4, + isDefault: aiProviders.isDefault, + visibility: aiProviders.visibility, + createdBy: aiProviders.createdBy, + updatedAt: aiProviders.updatedAt, + }) + .from(aiProviders) + .where( + or( + eq(aiProviders.visibility, "public"), + eq(aiProviders.createdBy, userId) + ) + ) + .orderBy(desc(aiProviders.updatedAt)) + return rows +} + export async function countDefaultAiProviders(): Promise { const [row] = await db .select({ value: count() }) @@ -33,18 +80,35 @@ export async function countDefaultAiProviders(): Promise { return Number(row?.value ?? 0) } -export async function getAiProviderForUpdate(id: string): Promise { +/** + * 获取 Provider 用于更新(管理员或创建者视图) + * + * 管理员可访问任意 Provider;非管理员仅能访问自己创建的 private Provider。 + */ +export async function getAiProviderForUpdate( + id: string, + userId?: string +): Promise { const [row] = await db .select({ id: aiProviders.id, apiKeyEncrypted: aiProviders.apiKeyEncrypted, apiKeyLast4: aiProviders.apiKeyLast4, isDefault: aiProviders.isDefault, + visibility: aiProviders.visibility, + createdBy: aiProviders.createdBy, }) .from(aiProviders) .where(eq(aiProviders.id, id)) .limit(1) - return row ?? null + if (!row) return null + + // 未传 userId 视为管理员视图(向后兼容) + if (userId === undefined) return row + + // 非管理员只能更新自己创建的 Provider + if (row.createdBy !== userId) return null + return row } export async function updateAiProvider( @@ -56,6 +120,7 @@ export async function updateAiProvider( apiKeyEncrypted: string apiKeyLast4: string | null isDefault: boolean + visibility: AiProviderVisibility updatedBy: string }, resetOtherDefaults: boolean @@ -73,6 +138,7 @@ export async function updateAiProvider( apiKeyEncrypted: data.apiKeyEncrypted, apiKeyLast4: data.apiKeyLast4, isDefault: data.isDefault, + visibility: data.visibility, updatedBy: data.updatedBy, }) .where(eq(aiProviders.id, id)) @@ -88,6 +154,7 @@ export async function createAiProvider( apiKeyEncrypted: string apiKeyLast4: string | null isDefault: boolean + visibility: AiProviderVisibility createdBy: string updatedBy: string }, @@ -105,6 +172,7 @@ export async function createAiProvider( apiKeyEncrypted: data.apiKeyEncrypted, apiKeyLast4: data.apiKeyLast4, isDefault: data.isDefault, + visibility: data.visibility, createdBy: data.createdBy, updatedBy: data.updatedBy, }) @@ -115,11 +183,20 @@ export async function createAiProvider( * 删除 AI Provider * * 如果删除的是默认 Provider,自动将最新的一条记录设为默认(若存在)。 + * + * @param id Provider ID + * @param userId 当前用户 ID(用于所有权校验;未传则不校验,仅管理员路径使用) */ -export async function deleteAiProvider(id: string): Promise<{ wasDefault: boolean }> { +export async function deleteAiProvider( + id: string, + userId?: string +): Promise<{ wasDefault: boolean }> { return await db.transaction(async (tx) => { const [existing] = await tx - .select({ isDefault: aiProviders.isDefault }) + .select({ + isDefault: aiProviders.isDefault, + createdBy: aiProviders.createdBy, + }) .from(aiProviders) .where(eq(aiProviders.id, id)) .limit(1) @@ -128,6 +205,11 @@ export async function deleteAiProvider(id: string): Promise<{ wasDefault: boolea return { wasDefault: false } } + // 所有权校验:非创建者不能删除(管理员路径不传 userId) + if (userId !== undefined && existing.createdBy !== userId) { + return { wasDefault: false } + } + await tx.delete(aiProviders).where(eq(aiProviders.id, id)) // 如果删除的是默认 Provider,自动选一条最新的设为默认 diff --git a/src/modules/settings/types.ts b/src/modules/settings/types.ts index 268600c..f2441e0 100644 --- a/src/modules/settings/types.ts +++ b/src/modules/settings/types.ts @@ -7,6 +7,13 @@ import type { export type AiProviderName = "zhipu" | "openai" | "gemini" | "custom" +/** + * AI 服务商可见性 + * - public: 管理员发布,全员可用 + * - private: 仅创建者可见 + */ +export type AiProviderVisibility = "public" | "private" + export interface AiProviderSummary { id: string provider: AiProviderName @@ -14,6 +21,8 @@ export interface AiProviderSummary { model: string apiKeyLast4: string | null isDefault: boolean + visibility: AiProviderVisibility + createdBy: string | null updatedAt: Date } @@ -22,6 +31,8 @@ export interface AiProviderExisting { apiKeyEncrypted: string apiKeyLast4: string | null isDefault: boolean + visibility: AiProviderVisibility + createdBy: string | null } /** diff --git a/src/modules/student/components/course-filters.tsx b/src/modules/student/components/course-filters.tsx index cc9d3a2..965ffc0 100644 --- a/src/modules/student/components/course-filters.tsx +++ b/src/modules/student/components/course-filters.tsx @@ -1,10 +1,12 @@ "use client" import { useQueryState, parseAsString } from "nuqs" +import { useTranslations } from "next-intl" import { FilterBar, FilterSearchInput } from "@/shared/components/ui/filter-bar" export function CourseFilters() { + const t = useTranslations("student") const [search, setSearch] = useQueryState("q", parseAsString.withDefault("")) const hasFilters = Boolean(search) @@ -18,7 +20,7 @@ export function CourseFilters() { setSearch(v || null)} - placeholder="Search by class name, teacher, school..." + placeholder={t("courseFilters.searchPlaceholder")} /> ) diff --git a/src/modules/student/components/student-courses-view.tsx b/src/modules/student/components/student-courses-view.tsx index aab902f..39515d3 100644 --- a/src/modules/student/components/student-courses-view.tsx +++ b/src/modules/student/components/student-courses-view.tsx @@ -4,6 +4,7 @@ import Link from "next/link" import { memo, useState, useTransition } from "react" import { useRouter } from "next/navigation" import { toast } from "sonner" +import { useTranslations } from "next-intl" import { Card, CardContent, CardHeader, CardTitle, CardDescription, CardFooter } from "@/shared/components/ui/card" import { Badge } from "@/shared/components/ui/badge" @@ -17,6 +18,7 @@ import type { StudentEnrolledClass } from "@/modules/classes/types" import { joinClassByInvitationCodeAction } from "@/modules/classes/actions" const ClassCard = memo(function ClassCard({ c }: { c: StudentEnrolledClass }) { + const t = useTranslations("student") return ( @@ -30,7 +32,7 @@ const ClassCard = memo(function ClassCard({ c }: { c: StudentEnrolledClass }) { - Grade {c.grade} + {t("coursesView.grade", { grade: c.grade })} {c.homeroom && ( <> @@ -41,7 +43,7 @@ const ClassCard = memo(function ClassCard({ c }: { c: StudentEnrolledClass }) { - Active + {t("coursesView.active")} @@ -71,7 +73,7 @@ const ClassCard = memo(function ClassCard({ c }: { c: StudentEnrolledClass }) { {c.room && (
- Room {c.room} + {t("coursesView.room", { room: c.room })}
)} @@ -81,19 +83,19 @@ const ClassCard = memo(function ClassCard({ c }: { c: StudentEnrolledClass }) { @@ -106,6 +108,7 @@ export function StudentCoursesView({ }: { classes: StudentEnrolledClass[] }) { + const t = useTranslations("student") const router = useRouter() const [code, setCode] = useState("") const [isPending, startTransition] = useTransition() @@ -115,15 +118,15 @@ export function StudentCoursesView({ try { const res = await joinClassByInvitationCodeAction(null, formData) if (res.success) { - toast.success(res.message || "Joined class") + toast.success(res.message || t("coursesView.joinedSuccess")) setCode("") router.refresh() } else { - toast.error(res.message || "Failed to join class") + toast.error(res.message || t("coursesView.joinFailed")) } } catch (err) { console.error("[joinClass] failed:", err) - toast.error("Failed to join class") + toast.error(t("coursesView.joinFailed")) } }) } @@ -141,8 +144,8 @@ export function StudentCoursesView({ {classes.length === 0 && ( )} @@ -155,9 +158,9 @@ export function StudentCoursesView({
-

Join a Class

+

{t("coursesView.joinClass")}

- Enter the invitation code provided by your teacher to enroll. + {t("coursesView.joinClassDesc")}

@@ -165,13 +168,13 @@ export function StudentCoursesView({
- + setCode(e.target.value)} maxLength={6} @@ -181,7 +184,7 @@ export function StudentCoursesView({ />
diff --git a/src/modules/student/components/student-schedule-filters.tsx b/src/modules/student/components/student-schedule-filters.tsx index aac156f..a387f00 100644 --- a/src/modules/student/components/student-schedule-filters.tsx +++ b/src/modules/student/components/student-schedule-filters.tsx @@ -2,21 +2,23 @@ import { useMemo } from "react" import { useQueryState, parseAsString } from "nuqs" +import { useTranslations } from "next-intl" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/components/ui/select" import type { StudentEnrolledClass } from "@/modules/classes/types" export function StudentScheduleFilters({ classes }: { classes: StudentEnrolledClass[] }) { + const t = useTranslations("student") const [classId, setClassId] = useQueryState("classId", parseAsString.withDefault("all")) - const options = useMemo(() => [{ id: "all", name: "All classes" }, ...classes], [classes]) + const options = useMemo(() => [{ id: "all", name: t("scheduleFilters.allClasses") }, ...classes], [classes, t]) return (