Files
NextEdu/src/modules/settings/components/settings-view.tsx
SpecialX a4d096a6fc fix: patch P0 security vulnerabilities and critical UX issues across 6 modules
Security: Add admin/layout.tsx auth guard; Add requirePermission() to 12 admin pages

Dashboard: Fix StudentStatsGrid rendering; Fix teacher greeting; Add loading/error boundaries; Fix col-span; Add metadata

Announcements: Fix audience filtering; Add user detail page; Trigger notifications on publish; Pass classes data; Add loading.tsx

Messages: Implement soft delete; Add unread badge with polling; Add notification dropdown polling; Add keyword search; Add quiet hours DND

Management: Add loading/error for 9 admin routes; Fix admin-classes-view to use Select for school/grade

Profile/Settings: Add loading/error; Fix parent role routing; Create ParentSettingsView; Integrate AiProviderSettingsCard; Add Tab URL persistence; Add logout confirm; Add avatar; Fix Progress arbitrary class

Schema: Add senderDeletedAt/receiverDeletedAt to messages; Add quietHours to notificationPreferences; Add uniqueIndex import

Docs: Update architecture docs 004/005
2026-06-22 13:57:31 +08:00

197 lines
7.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client"
import Link from "next/link"
import { useRouter, useSearchParams } from "next/navigation"
import { Suspense, type ReactNode } from "react"
import { User, Palette, Lock, Bell, Sparkles } from "lucide-react"
import { signOut } from "next-auth/react"
import { ThemePreferencesCard } from "@/modules/settings/components/theme-preferences-card"
import { ProfileSettingsForm } from "@/modules/settings/components/profile-settings-form"
import { PasswordChangeForm } from "@/modules/settings/components/password-change-form"
import { NotificationPreferencesForm } from "@/modules/settings/components/notification-preferences-form"
import { AiProviderSettingsCard } from "@/modules/settings/components/ai-provider-settings-card"
import { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/shared/components/ui/tabs"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/shared/components/ui/alert-dialog"
import { UserProfile } from "@/modules/users/data-access"
import type { NotificationPreferences } from "@/modules/notifications/types"
import { usePermission } from "@/shared/hooks/use-permission"
import { Permissions } from "@/shared/types/permissions"
interface SettingsViewProps {
/** 页面副标题描述 */
description: string
/** 返回仪表盘的链接 */
backHref: string
/** 当前用户 */
user: UserProfile
/** 通知偏好 */
notificationPreferences: NotificationPreferences
/** General 标签页中 ProfileSettingsForm 下方的内容(角色专属快捷链接/组织信息等) */
generalExtra?: ReactNode
}
const VALID_TABS = ["general", "notifications", "appearance", "security", "ai"] as const
type TabValue = (typeof VALID_TABS)[number]
function isTabValue(value: string | null): value is TabValue {
return value !== null && (VALID_TABS as readonly string[]).includes(value)
}
/**
* 统一设置页视图
*
* 消除 admin / teacher / student / parent 四个设置视图的重复布局:
* - 相同的页面头部(标题 + 描述 + 返回按钮)
* - 相同的标签页General / Notifications / Appearance / Security / AI
* - 相同的 Notifications / Appearance / Security 标签页内容
* - 相同的 Session 卡片(登出)
*
* 角色差异通过 `description`、`backHref` 和 `generalExtra` 三个 props 注入。
* 当前激活的标签页通过 URL `?tab=` 参数持久化。
*/
function SettingsViewInner({
description,
backHref,
user,
notificationPreferences,
generalExtra,
}: SettingsViewProps) {
const router = useRouter()
const searchParams = useSearchParams()
const { hasPermission } = usePermission()
const tabParam = searchParams.get("tab")
const activeTab: TabValue = isTabValue(tabParam) ? tabParam : "general"
const handleTabChange = (value: string) => {
const params = new URLSearchParams(searchParams.toString())
if (value === "general") {
params.delete("tab")
} else {
params.set("tab", value)
}
const query = params.toString()
router.push(query ? `?${query}` : "?", { scroll: false })
}
const canConfigureAi = hasPermission(Permissions.AI_CONFIGURE)
return (
<div className="flex h-full flex-col gap-8 p-8">
<div className="flex flex-col justify-between gap-4 md:flex-row md:items-center">
<div className="space-y-1">
<h1 className="text-3xl font-bold tracking-tight">Settings</h1>
<div className="text-sm text-muted-foreground">{description}</div>
</div>
<div className="flex items-center gap-2">
<Button asChild variant="outline">
<Link href={backHref}>Back to dashboard</Link>
</Button>
</div>
</div>
<Tabs value={activeTab} onValueChange={handleTabChange} className="w-full">
<TabsList className="w-full justify-start">
<TabsTrigger value="general" className="gap-2">
<User className="h-4 w-4" />
General
</TabsTrigger>
<TabsTrigger value="notifications" className="gap-2">
<Bell className="h-4 w-4" />
Notifications
</TabsTrigger>
<TabsTrigger value="appearance" className="gap-2">
<Palette className="h-4 w-4" />
Appearance
</TabsTrigger>
<TabsTrigger value="security" className="gap-2">
<Lock className="h-4 w-4" />
Security
</TabsTrigger>
{canConfigureAi ? (
<TabsTrigger value="ai" className="gap-2">
<Sparkles className="h-4 w-4" />
AI
</TabsTrigger>
) : null}
</TabsList>
<TabsContent value="general" className="mt-6 space-y-6">
<ProfileSettingsForm user={user} />
{generalExtra}
</TabsContent>
<TabsContent value="notifications" className="mt-6 space-y-6">
<NotificationPreferencesForm preferences={notificationPreferences} />
</TabsContent>
<TabsContent value="appearance" className="mt-6 space-y-6">
<ThemePreferencesCard />
</TabsContent>
<TabsContent value="security" className="mt-6 space-y-6">
<PasswordChangeForm />
<Card>
<CardHeader>
<CardTitle>Session</CardTitle>
<CardDescription>Account access and session controls.</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="space-y-1">
<div className="text-sm font-medium">Sign out</div>
<div className="text-sm text-muted-foreground">Return to the login screen.</div>
</div>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="outline">Log out</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Confirm sign out</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to sign out? You will be returned to the login screen.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={() => signOut({ callbackUrl: "/login" })}>
Sign out
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</CardContent>
</Card>
</TabsContent>
{canConfigureAi ? (
<TabsContent value="ai" className="mt-6 space-y-6">
<AiProviderSettingsCard />
</TabsContent>
) : null}
</Tabs>
</div>
)
}
export function SettingsView(props: SettingsViewProps) {
return (
<Suspense fallback={null}>
<SettingsViewInner {...props} />
</Suspense>
)
}