refactor(lesson-preparation,ai,dashboard): 组件化重构第 3 批 - 高风险模块

- lesson-preparation: 拆分 lesson-plan-editor (594→273),新增 toolbar/dialogs/publish-button 子组件 + 持久化 Hook
- ai: 拆分 ai-chat-panel (417→218),新增 messages/input 子组件,保持流式响应状态稳定
- dashboard: 新增 DashboardShell 布局壳,4 个角色 dashboard 统一使用
- 删除 6 个重复组件(filters/skeletons/error-boundaries)
- tsc 零错误
This commit is contained in:
SpecialX
2026-07-06 20:49:54 +08:00
parent 025d4de50d
commit fa68ec0b34
48 changed files with 1457 additions and 1326 deletions

View File

@@ -1,5 +1,5 @@
import { DashboardLoadingSkeleton } from "@/modules/dashboard/components/dashboard-loading-skeleton"
import { SkeletonCard } from "@/shared/components/ui/skeleton"
export default function AdminDashboardLoading() {
return <DashboardLoadingSkeleton />
return <SkeletonCard variant="stats-grid" />
}

View File

@@ -1,5 +1,5 @@
import { DashboardLoadingSkeleton } from "@/modules/dashboard/components/dashboard-loading-skeleton"
import { SkeletonCard } from "@/shared/components/ui/skeleton"
export default function DashboardLoading() {
return <DashboardLoadingSkeleton />
return <SkeletonCard variant="stats-grid" />
}

View File

@@ -1,5 +1,5 @@
import { DashboardLoadingSkeleton } from "@/modules/dashboard/components/dashboard-loading-skeleton"
import { SkeletonCard } from "@/shared/components/ui/skeleton"
export default function StudentDashboardLoading() {
return <DashboardLoadingSkeleton />
return <SkeletonCard variant="stats-grid" />
}

View File

@@ -1,5 +1,5 @@
import { DashboardLoadingSkeleton } from "@/modules/dashboard/components/dashboard-loading-skeleton"
import { SkeletonCard } from "@/shared/components/ui/skeleton"
export default function TeacherDashboardLoading() {
return <DashboardLoadingSkeleton />
return <SkeletonCard variant="stats-grid" />
}

View File

@@ -0,0 +1,128 @@
"use client"
import { useTranslations } from "next-intl"
import { Send, Square } from "lucide-react"
import { Button } from "@/shared/components/ui/button"
import { Textarea } from "@/shared/components/ui/textarea"
type AiChatInputProps = {
value: string
onChange: (value: string) => void
onKeyDown: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void
streaming: boolean
onSend: () => void
onStop: () => void
maxReached: boolean
variant: "card" | "widget"
placeholder?: string
}
/**
* AI 聊天输入框
*
* 渲染文本输入区域与发送/停止按钮,支持 card / widget 两种视觉变体。
* 纯展示组件,所有状态由容器通过 props 注入。
*/
export function AiChatInput({
value,
onChange,
onKeyDown,
streaming,
onSend,
onStop,
maxReached,
variant,
placeholder,
}: AiChatInputProps): React.ReactNode {
const t = useTranslations("ai")
const isWidget = variant === "widget"
if (isWidget) {
return (
<div className="border-t p-3">
<div className="flex items-end gap-2">
<Textarea
value={value}
onChange={(e) => onChange(e.target.value)}
onKeyDown={onKeyDown}
placeholder={placeholder ?? t("chat.placeholder")}
className="min-h-10 max-h-32 resize-none rounded-2xl"
disabled={streaming || maxReached}
aria-label={t("chat.inputLabel")}
/>
{streaming ? (
<Button
type="button"
size="icon"
variant="destructive"
className="rounded-full shrink-0"
onClick={onStop}
aria-label={t("chat.stopGeneration")}
>
<Square className="h-4 w-4" />
</Button>
) : (
<Button
type="button"
size="icon"
className="rounded-full shrink-0"
onClick={onSend}
disabled={!value.trim() || maxReached}
aria-label={t("chat.send")}
>
<Send className="h-4 w-4" />
</Button>
)}
</div>
{maxReached ? (
<p className="text-xs text-muted-foreground text-center mt-2">
{t("chat.maxReached")}
</p>
) : null}
</div>
)
}
return (
<>
<div className="flex gap-2">
<Textarea
value={value}
onChange={(e) => onChange(e.target.value)}
onKeyDown={onKeyDown}
placeholder={placeholder ?? t("chat.placeholder")}
className="min-h-16 resize-none"
disabled={streaming || maxReached}
aria-label={t("chat.inputLabel")}
/>
{streaming ? (
<Button
type="button"
size="icon"
variant="destructive"
onClick={onStop}
aria-label={t("chat.stopGeneration")}
>
<Square className="h-4 w-4" />
</Button>
) : (
<Button
type="button"
size="icon"
onClick={onSend}
disabled={!value.trim() || maxReached}
aria-label={t("chat.send")}
>
<Send className="h-4 w-4" />
</Button>
)}
</div>
{maxReached ? (
<p className="text-xs text-muted-foreground text-center">
{t("chat.maxReached")}
</p>
) : null}
</>
)
}

View File

@@ -0,0 +1,199 @@
"use client"
import type { RefObject } from "react"
import { useTranslations } from "next-intl"
import { Bot, User, Sparkles } from "lucide-react"
import { cn } from "@/shared/lib/utils"
import { Button } from "@/shared/components/ui/button"
import { ScrollArea } from "@/shared/components/ui/scroll-area"
import { AiMarkdownRenderer } from "./ai-markdown-renderer"
import type { AiChatMessage } from "../types"
type AiChatMessagesProps = {
messages: AiChatMessage[]
streaming: boolean
variant: "card" | "widget"
scrollRef: RefObject<HTMLDivElement | null>
suggestedPrompts: string[]
onSuggestedPrompt: (prompt: string) => void
}
/**
* AI 聊天消息列表:渲染消息气泡、流式指示器、空状态建议提示词。
* 支持 card / widget 两种视觉变体。纯展示组件,状态由容器注入。
*/
export function AiChatMessages({
messages,
streaming,
variant,
scrollRef,
suggestedPrompts,
onSuggestedPrompt,
}: AiChatMessagesProps): React.ReactNode {
const t = useTranslations("ai")
const isWidget = variant === "widget"
if (messages.length > 0) {
if (isWidget) {
return (
<div
ref={scrollRef}
className="flex-1 overflow-y-auto px-4 py-4"
aria-live="polite"
aria-relevant="additions text"
>
<div className="space-y-4">
{messages.map((message, index) => (
<div
key={index}
className={cn(
"flex gap-2.5",
message.role === "user" ? "justify-end" : "justify-start"
)}
>
{message.role === "assistant" ? (
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-gradient-to-br from-primary to-primary/80 text-primary-foreground">
<Bot className="h-4 w-4" />
</span>
) : (
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground">
<User className="h-4 w-4" />
</span>
)}
<div
className={cn(
"rounded-2xl px-3.5 py-2 text-sm max-w-[80%]",
message.role === "user"
? "bg-primary text-primary-foreground rounded-tr-sm"
: "bg-muted rounded-tl-sm"
)}
>
{message.role === "assistant" ? (
<AiMarkdownRenderer content={message.content} />
) : (
<p className="whitespace-pre-wrap">{message.content}</p>
)}
</div>
</div>
))}
{streaming ? (
<div className="flex gap-2.5 justify-start">
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-gradient-to-br from-primary to-primary/80 text-primary-foreground">
<Bot className="h-4 w-4" />
</span>
<div className="rounded-2xl rounded-tl-sm px-3.5 py-2 text-sm bg-muted">
<span className="inline-flex gap-1">
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-muted-foreground/60 [animation-delay:-0.3s]" />
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-muted-foreground/60 [animation-delay:-0.15s]" />
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-muted-foreground/60" />
</span>
</div>
</div>
) : null}
</div>
</div>
)
}
return (
<ScrollArea
className="h-72 w-full rounded-md border p-3"
aria-live="polite"
aria-relevant="additions text"
>
<div className="space-y-3" ref={scrollRef}>
{messages.map((message, index) => (
<div
key={index}
className={cn(
"flex gap-2",
message.role === "user" ? "justify-end" : "justify-start"
)}
>
{message.role === "assistant" ? (
<Bot className="h-5 w-5 shrink-0 text-primary mt-0.5" />
) : (
<User className="h-5 w-5 shrink-0 text-muted-foreground mt-0.5" />
)}
<div
className={cn(
// 任意值:聊天气泡需按容器宽度百分比限制,固定 max-w-* 无法适应不同面板宽度
"rounded-md px-3 py-2 text-sm max-w-[80%]",
message.role === "user"
? "bg-primary text-primary-foreground"
: "bg-muted"
)}
>
{message.role === "assistant" ? (
<AiMarkdownRenderer content={message.content} />
) : (
<p className="whitespace-pre-wrap">{message.content}</p>
)}
</div>
</div>
))}
{streaming ? (
<div className="flex gap-2 justify-start">
<Bot className="h-5 w-5 shrink-0 text-primary mt-0.5" />
<div className="rounded-md px-3 py-2 text-sm bg-muted">
<span className="animate-pulse">{t("chat.streaming")}</span>
<span className="inline-block w-1 h-4 ml-1 bg-primary animate-pulse" />
</div>
</div>
) : null}
</div>
</ScrollArea>
)
}
// 空状态:建议提示词
if (isWidget) {
return (
<div className="flex flex-1 flex-col items-center justify-center px-6 py-8">
<span className="flex h-14 w-14 items-center justify-center rounded-full bg-gradient-to-br from-primary to-primary/80 text-primary-foreground shadow-md mb-3">
<Sparkles className="h-7 w-7" />
</span>
<p className="text-base font-medium mb-1">{t("widget.welcome")}</p>
<p className="text-sm text-muted-foreground mb-4 text-center">{t("widget.welcomeDesc")}</p>
<div className="flex flex-wrap gap-2 justify-center max-w-sm">
{suggestedPrompts.map((prompt, index) => (
<Button
key={index}
type="button"
variant="outline"
size="sm"
className="text-xs rounded-full"
onClick={() => onSuggestedPrompt(prompt)}
>
{prompt}
</Button>
))}
</div>
</div>
)
}
return (
<div className="rounded-md border border-dashed p-6 text-center">
<Sparkles className="h-8 w-8 text-primary mx-auto mb-2" />
<p className="text-sm text-muted-foreground mb-3">
{t("chat.suggestedPrompts.title")}
</p>
<div className="flex flex-wrap gap-2 justify-center">
{suggestedPrompts.map((prompt, index) => (
<Button
key={index}
type="button"
variant="outline"
size="sm"
className="text-xs"
onClick={() => onSuggestedPrompt(prompt)}
>
{prompt}
</Button>
))}
</div>
</div>
)
}

View File

@@ -2,22 +2,20 @@
import { useState, useRef, useEffect, useCallback } from "react"
import { useTranslations } from "next-intl"
import { Send, Bot, User, Square, Trash2, Sparkles } from "lucide-react"
import { Bot, Trash2 } from "lucide-react"
import { toast } from "sonner"
import { cn } from "@/shared/lib/utils"
import { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Textarea } from "@/shared/components/ui/textarea"
import { ScrollArea } from "@/shared/components/ui/scroll-area"
import { SkeletonCard } from "@/shared/components/ui/skeleton"
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/shared/components/ui/tooltip"
import { AiChatSkeleton } from "./ai-skeleton"
import { AiMarkdownRenderer } from "./ai-markdown-renderer"
import { AiChatMessages } from "./ai-chat-messages"
import { AiChatInput } from "./ai-chat-input"
import { useAiChatStream } from "../hooks/use-ai-chat-stream"
import type { AiChatMessage } from "../types"
@@ -51,6 +49,9 @@ type AiChatPanelProps = {
* - 建议提示词
* - aria-live 无障碍
* - 对话历史持久化localStorage防抖写入
*
* 容器职责:持有 useAiChatStream流式状态机 + AbortController ref
* 输入状态、事件编排。渲染委托给 AiChatMessages / AiChatInput 子组件。
*/
export function AiChatPanel({
systemPrompt,
@@ -66,6 +67,7 @@ export function AiChatPanel({
const [input, setInput] = useState("")
const scrollRef = useRef<HTMLDivElement>(null)
const isWidget = variant === "widget"
const maxReached = messages.length >= maxMessages
// 自动滚动到底部
useEffect(() => {
@@ -77,7 +79,7 @@ export function AiChatPanel({
const handleSend = useCallback(
async (content?: string): Promise<void> => {
const trimmed = (content ?? input).trim()
if (!trimmed || streaming || messages.length >= maxMessages) return
if (!trimmed || streaming || maxReached) return
// 上下文信息合并到 systemPrompt 发送给 AI用户气泡只显示真实输入
const fullSystemPrompt = contextMessage
@@ -92,7 +94,7 @@ export function AiChatPanel({
setInput("")
await send(requestMessages, { systemPrompt: fullSystemPrompt })
},
[input, streaming, messages, maxMessages, systemPrompt, contextMessage, send]
[input, streaming, messages, maxReached, systemPrompt, contextMessage, send]
)
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>): void => {
@@ -109,12 +111,8 @@ export function AiChatPanel({
}
}
const handleSuggestedPrompt = (prompt: string): void => {
void handleSend(prompt)
}
if (streaming && messages.length === 0) {
return <AiChatSkeleton />
return <SkeletonCard variant="chart" />
}
const defaultSuggestedPrompts = suggestedPrompts ?? [
@@ -123,7 +121,6 @@ export function AiChatPanel({
t("chat.suggestedPrompts.teacher.2"),
]
// widget 变体:无边框、撑满容器、消息区自适应高度
if (isWidget) {
return (
<div className="flex h-full flex-col">
@@ -135,129 +132,25 @@ export function AiChatPanel({
{error}
</div>
) : null}
{messages.length > 0 ? (
<div
ref={scrollRef}
className="flex-1 overflow-y-auto px-4 py-4"
aria-live="polite"
aria-relevant="additions text"
>
<div className="space-y-4">
{messages.map((message, index) => (
<div
key={index}
className={cn(
"flex gap-2.5",
message.role === "user" ? "justify-end" : "justify-start"
)}
>
{message.role === "assistant" ? (
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-gradient-to-br from-primary to-primary/80 text-primary-foreground">
<Bot className="h-4 w-4" />
</span>
) : (
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground">
<User className="h-4 w-4" />
</span>
)}
<div
className={cn(
"rounded-2xl px-3.5 py-2 text-sm max-w-[80%]",
message.role === "user"
? "bg-primary text-primary-foreground rounded-tr-sm"
: "bg-muted rounded-tl-sm"
)}
>
{message.role === "assistant" ? (
<AiMarkdownRenderer content={message.content} />
) : (
<p className="whitespace-pre-wrap">{message.content}</p>
)}
</div>
</div>
))}
{streaming ? (
<div className="flex gap-2.5 justify-start">
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-gradient-to-br from-primary to-primary/80 text-primary-foreground">
<Bot className="h-4 w-4" />
</span>
<div className="rounded-2xl rounded-tl-sm px-3.5 py-2 text-sm bg-muted">
<span className="inline-flex gap-1">
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-muted-foreground/60 [animation-delay:-0.3s]" />
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-muted-foreground/60 [animation-delay:-0.15s]" />
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-muted-foreground/60" />
</span>
</div>
</div>
) : null}
</div>
</div>
) : (
<div className="flex flex-1 flex-col items-center justify-center px-6 py-8">
<span className="flex h-14 w-14 items-center justify-center rounded-full bg-gradient-to-br from-primary to-primary/80 text-primary-foreground shadow-md mb-3">
<Sparkles className="h-7 w-7" />
</span>
<p className="text-base font-medium mb-1">{t("widget.welcome")}</p>
<p className="text-sm text-muted-foreground mb-4 text-center">{t("widget.welcomeDesc")}</p>
<div className="flex flex-wrap gap-2 justify-center max-w-sm">
{defaultSuggestedPrompts.map((prompt, index) => (
<Button
key={index}
type="button"
variant="outline"
size="sm"
className="text-xs rounded-full"
onClick={() => handleSuggestedPrompt(prompt)}
>
{prompt}
</Button>
))}
</div>
</div>
)}
<div className="border-t p-3">
<div className="flex items-end gap-2">
<Textarea
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={placeholder ?? t("chat.placeholder")}
className="min-h-10 max-h-32 resize-none rounded-2xl"
disabled={streaming || messages.length >= maxMessages}
aria-label={t("chat.inputLabel")}
<AiChatMessages
messages={messages}
streaming={streaming}
variant={variant}
scrollRef={scrollRef}
suggestedPrompts={defaultSuggestedPrompts}
onSuggestedPrompt={(prompt) => void handleSend(prompt)}
/>
<AiChatInput
value={input}
onChange={setInput}
onKeyDown={handleKeyDown}
streaming={streaming}
onSend={() => void handleSend()}
onStop={stop}
maxReached={maxReached}
variant={variant}
placeholder={placeholder}
/>
{streaming ? (
<Button
type="button"
size="icon"
variant="destructive"
className="rounded-full shrink-0"
onClick={stop}
aria-label={t("chat.stopGeneration")}
>
<Square className="h-4 w-4" />
</Button>
) : (
<Button
type="button"
size="icon"
className="rounded-full shrink-0"
onClick={() => void handleSend()}
disabled={!input.trim() || messages.length >= maxMessages}
aria-label={t("chat.send")}
>
<Send className="h-4 w-4" />
</Button>
)}
</div>
{messages.length >= maxMessages ? (
<p className="text-xs text-muted-foreground text-center mt-2">
{t("chat.maxReached")}
</p>
) : null}
</div>
</div>
)
}
@@ -300,117 +193,25 @@ export function AiChatPanel({
{error}
</div>
) : null}
{messages.length > 0 ? (
<ScrollArea
className="h-72 w-full rounded-md border p-3"
aria-live="polite"
aria-relevant="additions text"
>
<div className="space-y-3" ref={scrollRef}>
{messages.map((message, index) => (
<div
key={index}
className={cn(
"flex gap-2",
message.role === "user" ? "justify-end" : "justify-start"
)}
>
{message.role === "assistant" ? (
<Bot className="h-5 w-5 shrink-0 text-primary mt-0.5" />
) : (
<User className="h-5 w-5 shrink-0 text-muted-foreground mt-0.5" />
)}
<div
className={cn(
// 任意值:聊天气泡需按容器宽度百分比限制,固定 max-w-* 无法适应不同面板宽度
"rounded-md px-3 py-2 text-sm max-w-[80%]",
message.role === "user"
? "bg-primary text-primary-foreground"
: "bg-muted"
)}
>
{message.role === "assistant" ? (
<AiMarkdownRenderer content={message.content} />
) : (
<p className="whitespace-pre-wrap">{message.content}</p>
)}
</div>
</div>
))}
{streaming ? (
<div className="flex gap-2 justify-start">
<Bot className="h-5 w-5 shrink-0 text-primary mt-0.5" />
<div className="rounded-md px-3 py-2 text-sm bg-muted">
<span className="animate-pulse">{t("chat.streaming")}</span>
<span className="inline-block w-1 h-4 ml-1 bg-primary animate-pulse" />
</div>
</div>
) : null}
</div>
</ScrollArea>
) : (
<div className="space-y-3">
<div className="rounded-md border border-dashed p-6 text-center">
<Sparkles className="h-8 w-8 text-primary mx-auto mb-2" />
<p className="text-sm text-muted-foreground mb-3">
{t("chat.suggestedPrompts.title")}
</p>
<div className="flex flex-wrap gap-2 justify-center">
{defaultSuggestedPrompts.map((prompt, index) => (
<Button
key={index}
type="button"
variant="outline"
size="sm"
className="text-xs"
onClick={() => handleSuggestedPrompt(prompt)}
>
{prompt}
</Button>
))}
</div>
</div>
</div>
)}
<div className="flex gap-2">
<Textarea
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={placeholder ?? t("chat.placeholder")}
className="min-h-16 resize-none"
disabled={streaming || messages.length >= maxMessages}
aria-label={t("chat.inputLabel")}
<AiChatMessages
messages={messages}
streaming={streaming}
variant={variant}
scrollRef={scrollRef}
suggestedPrompts={defaultSuggestedPrompts}
onSuggestedPrompt={(prompt) => void handleSend(prompt)}
/>
<AiChatInput
value={input}
onChange={setInput}
onKeyDown={handleKeyDown}
streaming={streaming}
onSend={() => void handleSend()}
onStop={stop}
maxReached={maxReached}
variant={variant}
placeholder={placeholder}
/>
{streaming ? (
<Button
type="button"
size="icon"
variant="destructive"
onClick={stop}
aria-label={t("chat.stopGeneration")}
>
<Square className="h-4 w-4" />
</Button>
) : (
<Button
type="button"
size="icon"
onClick={() => void handleSend()}
disabled={!input.trim() || messages.length >= maxMessages}
aria-label={t("chat.send")}
>
<Send className="h-4 w-4" />
</Button>
)}
</div>
{messages.length >= maxMessages ? (
<p className="text-xs text-muted-foreground text-center">
{t("chat.maxReached")}
</p>
) : null}
</CardContent>
</Card>
)

View File

@@ -8,8 +8,8 @@ import { toast } from "sonner"
import { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/shared/components/ui/card"
import { Badge } from "@/shared/components/ui/badge"
import { AiErrorBoundary } from "@/modules/ai/components/ai-error-boundary"
import { AiSuggestionSkeleton } from "@/modules/ai/components/ai-skeleton"
import { SectionErrorBoundary } from "@/shared/components/section-error-boundary"
import { SkeletonCard } from "@/shared/components/ui/skeleton"
import { AiMarkdownRenderer } from "@/modules/ai/components/ai-markdown-renderer"
import { useAiClient } from "@/modules/ai/context/ai-client-provider"
import type { ChildSummaryResult, ChildSummaryInput } from "@/modules/ai/types"
@@ -76,7 +76,7 @@ export function AiChildSummary({
}
return (
<AiErrorBoundary>
<SectionErrorBoundary namespace="ai">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
@@ -87,7 +87,7 @@ export function AiChildSummary({
</CardHeader>
<CardContent className="space-y-4">
{loading ? (
<AiSuggestionSkeleton />
<SkeletonCard variant="chart" />
) : summary ? (
<div className="space-y-4">
{/* 整体评估 */}
@@ -181,6 +181,6 @@ export function AiChildSummary({
)}
</CardContent>
</Card>
</AiErrorBoundary>
</SectionErrorBoundary>
)
}

View File

@@ -8,8 +8,8 @@ import { toast } from "sonner"
import { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/shared/components/ui/card"
import { Badge } from "@/shared/components/ui/badge"
import { AiErrorBoundary } from "@/modules/ai/components/ai-error-boundary"
import { AiSuggestionSkeleton } from "@/modules/ai/components/ai-skeleton"
import { SectionErrorBoundary } from "@/shared/components/section-error-boundary"
import { SkeletonCard } from "@/shared/components/ui/skeleton"
import { useAiClient } from "@/modules/ai/context/ai-client-provider"
import type { WeaknessAnalysisResult, SimilarQuestionResult } from "@/modules/ai/types"
@@ -110,7 +110,7 @@ export function AiErrorBookAnalysis({
}
return (
<AiErrorBoundary>
<SectionErrorBoundary namespace="ai">
<div className="space-y-4">
{/* 相似题推荐 */}
{currentQuestionText ? (
@@ -124,7 +124,7 @@ export function AiErrorBookAnalysis({
</CardHeader>
<CardContent className="space-y-3">
{similarLoading ? (
<AiSuggestionSkeleton />
<SkeletonCard variant="chart" />
) : similarQuestions.length > 0 ? (
<>
{similarQuestions.map((question, index) => (
@@ -184,7 +184,7 @@ export function AiErrorBookAnalysis({
</CardHeader>
<CardContent className="space-y-3">
{weaknessLoading ? (
<AiSuggestionSkeleton />
<SkeletonCard variant="chart" />
) : weaknessResult ? (
<div className="space-y-4">
<div className="space-y-2">
@@ -241,6 +241,6 @@ export function AiErrorBookAnalysis({
</CardContent>
</Card>
</div>
</AiErrorBoundary>
</SectionErrorBoundary>
)
}

View File

@@ -1,31 +0,0 @@
"use client"
/**
* AI 专用 Error Boundary
*
* 薄包装:委托给共享 SectionErrorBoundary使用 ai 命名空间。
* 保留同名导出以兼容现有 import。
*/
import type { ReactNode } from "react"
import { SectionErrorBoundary } from "@/shared/components/section-error-boundary"
type AiErrorBoundaryProps = {
children: ReactNode
/** 自定义 fallback 渲染 */
fallback?: (error: Error, reset: () => void) => ReactNode
/** 错误回调(用于埋点) */
onError?: (error: Error, info: unknown) => void
}
export function AiErrorBoundary({
children,
fallback,
onError,
}: AiErrorBoundaryProps): ReactNode {
return (
<SectionErrorBoundary namespace="ai" fallback={fallback} onError={onError}>
{children}
</SectionErrorBoundary>
)
}

View File

@@ -9,8 +9,8 @@ import { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/shared/components/ui/card"
import { Badge } from "@/shared/components/ui/badge"
import { Progress } from "@/shared/components/ui/progress"
import { AiErrorBoundary } from "@/modules/ai/components/ai-error-boundary"
import { AiSuggestionSkeleton } from "@/modules/ai/components/ai-skeleton"
import { SectionErrorBoundary } from "@/shared/components/section-error-boundary"
import { SkeletonCard } from "@/shared/components/ui/skeleton"
import { useAiClient } from "@/modules/ai/context/ai-client-provider"
import type { GradingSuggestion } from "@/modules/ai/types"
@@ -87,7 +87,7 @@ export function AiGradingAssist({
const confidencePercent = suggestion ? Math.round(suggestion.confidence * 100) : 0
return (
<AiErrorBoundary>
<SectionErrorBoundary namespace="ai">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
@@ -98,7 +98,7 @@ export function AiGradingAssist({
</CardHeader>
<CardContent className="space-y-3">
{loading ? (
<AiSuggestionSkeleton />
<SkeletonCard variant="chart" />
) : suggestion ? (
<div className="space-y-4">
<div className="space-y-2">
@@ -168,6 +168,6 @@ export function AiGradingAssist({
)}
</CardContent>
</Card>
</AiErrorBoundary>
</SectionErrorBoundary>
)
}

View File

@@ -9,8 +9,8 @@ import { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/shared/components/ui/card"
import { Textarea } from "@/shared/components/ui/textarea"
import { Badge } from "@/shared/components/ui/badge"
import { AiErrorBoundary } from "@/modules/ai/components/ai-error-boundary"
import { AiSuggestionSkeleton } from "@/modules/ai/components/ai-skeleton"
import { SectionErrorBoundary } from "@/shared/components/section-error-boundary"
import { SkeletonCard } from "@/shared/components/ui/skeleton"
import { useAiClient } from "@/modules/ai/context/ai-client-provider"
import type { LessonContentResult } from "@/modules/ai/types"
@@ -91,7 +91,7 @@ export function AiLessonContentGenerator({
]
return (
<AiErrorBoundary>
<SectionErrorBoundary namespace="ai">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
@@ -146,7 +146,7 @@ export function AiLessonContentGenerator({
{/* 生成结果 */}
{loading ? (
<AiSuggestionSkeleton />
<SkeletonCard variant="chart" />
) : result ? (
<div className="space-y-3 rounded-md border p-3">
<div className="flex items-center justify-between gap-2">
@@ -175,6 +175,6 @@ export function AiLessonContentGenerator({
) : null}
</CardContent>
</Card>
</AiErrorBoundary>
</SectionErrorBoundary>
)
}

View File

@@ -15,8 +15,8 @@ import {
SelectTrigger,
SelectValue,
} from "@/shared/components/ui/select"
import { AiErrorBoundary } from "@/modules/ai/components/ai-error-boundary"
import { AiSuggestionSkeleton } from "@/modules/ai/components/ai-skeleton"
import { SectionErrorBoundary } from "@/shared/components/section-error-boundary"
import { SkeletonCard } from "@/shared/components/ui/skeleton"
import { useAiClient } from "@/modules/ai/context/ai-client-provider"
import type { QuestionVariantResult } from "@/modules/ai/types"
@@ -96,7 +96,7 @@ export function AiQuestionVariantGenerator({
}
return (
<AiErrorBoundary>
<SectionErrorBoundary namespace="ai">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
@@ -149,7 +149,7 @@ export function AiQuestionVariantGenerator({
{/* 生成结果 */}
{loading ? (
<AiSuggestionSkeleton />
<SkeletonCard variant="chart" />
) : variant ? (
<div className="space-y-3 rounded-md border p-3">
<div className="flex items-center justify-between gap-2">
@@ -213,6 +213,6 @@ export function AiQuestionVariantGenerator({
) : null}
</CardContent>
</Card>
</AiErrorBoundary>
</SectionErrorBoundary>
)
}

View File

@@ -1,47 +0,0 @@
import { Card, CardContent, CardHeader } from "@/shared/components/ui/card"
import { Skeleton } from "@/shared/components/ui/skeleton"
/**
* AI 建议加载骨架屏
*
* 在 AI 异步调用期间显示,提供视觉反馈。
*/
export function AiSuggestionSkeleton(): React.ReactNode {
return (
<Card>
<CardHeader>
<Skeleton className="h-5 w-32" />
</CardHeader>
<CardContent className="space-y-3">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-4 w-5/6" />
<div className="flex gap-2 pt-2">
<Skeleton className="h-8 w-20" />
<Skeleton className="h-8 w-20" />
</div>
</CardContent>
</Card>
)
}
/**
* AI 聊天加载骨架屏
*/
export function AiChatSkeleton(): React.ReactNode {
return (
<Card>
<CardHeader>
<Skeleton className="h-5 w-24" />
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-5/6" />
<Skeleton className="h-4 w-4/6" />
</div>
<Skeleton className="h-10 w-full" />
</CardContent>
</Card>
)
}

View File

@@ -8,8 +8,8 @@ import { toast } from "sonner"
import { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/shared/components/ui/card"
import { Badge } from "@/shared/components/ui/badge"
import { AiErrorBoundary } from "@/modules/ai/components/ai-error-boundary"
import { AiSuggestionSkeleton } from "@/modules/ai/components/ai-skeleton"
import { SectionErrorBoundary } from "@/shared/components/section-error-boundary"
import { SkeletonCard } from "@/shared/components/ui/skeleton"
import { useAiClient } from "@/modules/ai/context/ai-client-provider"
import type { StudyPathResult, StudyPathInput } from "@/modules/ai/types"
@@ -98,7 +98,7 @@ export function AiStudyPath({
}
return (
<AiErrorBoundary>
<SectionErrorBoundary namespace="ai">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
@@ -109,7 +109,7 @@ export function AiStudyPath({
</CardHeader>
<CardContent className="space-y-4">
{loading ? (
<AiSuggestionSkeleton />
<SkeletonCard variant="chart" />
) : path ? (
<div className="space-y-4">
{/* 当前水平 */}
@@ -195,6 +195,6 @@ export function AiStudyPath({
)}
</CardContent>
</Card>
</AiErrorBoundary>
</SectionErrorBoundary>
)
}

View File

@@ -9,7 +9,7 @@ import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/sha
import { Badge } from "@/shared/components/ui/badge"
import { Progress } from "@/shared/components/ui/progress"
import { Button } from "@/shared/components/ui/button"
import { AiErrorBoundary } from "@/modules/ai/components/ai-error-boundary"
import { SectionErrorBoundary } from "@/shared/components/section-error-boundary"
import { useAiClient } from "@/modules/ai/context/ai-client-provider"
import type { AiUsageStats } from "@/modules/ai/types"
@@ -84,7 +84,7 @@ export function AiUsageDashboard(): React.ReactNode {
: []
return (
<AiErrorBoundary>
<SectionErrorBoundary namespace="ai">
<Card>
<CardHeader>
<div className="flex items-center justify-between">
@@ -215,6 +215,6 @@ export function AiUsageDashboard(): React.ReactNode {
)}
</CardContent>
</Card>
</AiErrorBoundary>
</SectionErrorBoundary>
)
}

View File

@@ -5,16 +5,21 @@ import { getTranslations } from "next-intl/server"
import {
CalendarCheck,
CalendarClock,
ClipboardList,
FileText,
FolderOpen,
LayoutDashboard,
Megaphone,
Upload,
Users,
} from "lucide-react"
import type { StatItem } from "@/shared/components/ui/stats-grid"
import { Card, CardContent } from "@/shared/components/ui/card"
import { PageHeader } from "@/shared/components/ui/page-header"
import { Button } from "@/shared/components/ui/button"
import { Badge } from "@/shared/components/ui/badge"
import { Skeleton } from "@/shared/components/ui/skeleton"
import { DashboardShell } from "../dashboard-shell"
import { DashboardSection } from "../dashboard-section"
import { DashboardTimeRangeFilter } from "../dashboard-time-range-filter"
import type { AdminDashboardStreams } from "../../streams"
@@ -23,7 +28,6 @@ import {
AdminHeaderBadges,
AdminHomeworkActivityCard,
AdminRecentUsersTable,
AdminStatsBar,
AdminTrendCharts,
AdminUserRolesCard,
} from "./admin-sections"
@@ -31,17 +35,58 @@ import {
/**
* 管理员仪表盘视图P2-1 流式架构)
*
* 页面外壳(标题 + 快捷操作)立即渲染;各数据分区用 React `use()` 独立消费 streams 中的 Promise
* 页面外壳(标题 + 快捷操作 + 统计卡片)立即渲染;各数据分区用 React `use()` 独立消费 streams 中的 Promise
* 在各自的 `<DashboardSection>` Suspense 边界内流式填充,互不阻塞。
*/
export async function AdminDashboardView({ streams }: { streams: AdminDashboardStreams }) {
export async function AdminDashboardView({ streams }: { streams: AdminDashboardStreams }): Promise<ReactNode> {
const t = await getTranslations("dashboard")
const [usersStats, classesStats, homeworkStats] = await Promise.all([
streams.usersStats,
streams.classesStats,
streams.homeworkStats,
])
const stats: StatItem[] = [
{
label: t("stats.users"),
value: usersStats.userCount,
icon: Users,
color: "text-blue-500",
valueClassName: "tabular-nums",
href: "/admin/users",
},
{
label: t("stats.classes"),
value: classesStats.classCount,
icon: LayoutDashboard,
color: "text-emerald-500",
valueClassName: "tabular-nums",
href: "/admin/school/classes",
},
{
label: t("stats.homeworkPublished"),
value: homeworkStats.homeworkAssignmentPublishedCount,
icon: ClipboardList,
color: "text-purple-500",
valueClassName: "tabular-nums",
href: "/admin/homework/assignments",
},
{
label: t("stats.toGrade"),
value: homeworkStats.homeworkSubmissionToGradeCount,
icon: FileText,
color: "text-amber-500",
valueClassName: "tabular-nums",
href: "/admin/homework/submissions?status=submitted",
},
]
return (
<div className="flex h-full flex-col space-y-8 p-8">
<PageHeader
<DashboardShell
title={t("title.admin")}
description={t("description.admin")}
stats={stats}
actions={
<>
<Button asChild variant="outline" size="sm" className="gap-2">
@@ -61,12 +106,7 @@ export async function AdminDashboardView({ streams }: { streams: AdminDashboardS
</Suspense>
</>
}
/>
<DashboardSection variant="stats" ariaLabel={t("sections.quickStats")}>
<AdminStatsBar t={t} streams={streams} />
</DashboardSection>
>
{/* L2: 时间范围筛选器 — 当前为 UI 占位,趋势数据接入后生效 */}
<div className="flex justify-end">
<DashboardTimeRangeFilter />
@@ -131,7 +171,7 @@ export async function AdminDashboardView({ streams }: { streams: AdminDashboardS
<DashboardSection variant="table" ariaLabel={t("sections.recentUsers")}>
<AdminRecentUsersTable t={t} streams={streams} />
</DashboardSection>
</div>
</DashboardShell>
)
}
@@ -160,7 +200,7 @@ function QuickActionCard({
icon: React.ComponentType<{ className?: string }>
title: string
description: string
}) {
}): ReactNode {
return (
<Link href={href}>
<Card className="transition-colors hover:border-primary/50 hover:bg-accent/50">

View File

@@ -6,14 +6,12 @@ import {
BookOpen,
ClipboardList,
FileText,
LayoutDashboard,
Library,
Users,
ChevronRight,
} from "lucide-react"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { StatCard } from "@/shared/components/ui/stat-card"
import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button"
import { EmptyState } from "@/shared/components/ui/empty-state"
@@ -24,25 +22,6 @@ import { UserGrowthChart } from "./user-growth-chart"
type TranslationFunction = Awaited<ReturnType<typeof import("next-intl/server").getTranslations>>
// ─── 顶部统计栏 ────────────────────────────────────────────
export function AdminStatsBar({ t, streams }: { t: TranslationFunction; streams: AdminDashboardStreams }) {
const [usersStats, classesStats, homeworkStats] = use(Promise.all([
streams.usersStats,
streams.classesStats,
streams.homeworkStats,
]))
return (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<StatCard title={t("stats.users")} value={usersStats.userCount} icon={Users} color="text-blue-500" valueClassName="tabular-nums" href="/admin/users" />
<StatCard title={t("stats.classes")} value={classesStats.classCount} icon={LayoutDashboard} color="text-emerald-500" valueClassName="tabular-nums" href="/admin/school/classes" />
<StatCard title={t("stats.homeworkPublished")} value={homeworkStats.homeworkAssignmentPublishedCount} icon={ClipboardList} color="text-purple-500" valueClassName="tabular-nums" href="/admin/homework/assignments" />
<StatCard title={t("stats.toGrade")} value={homeworkStats.homeworkSubmissionToGradeCount} icon={FileText} color="text-amber-500" valueClassName="tabular-nums" href="/admin/homework/submissions?status=submitted" />
</div>
)
}
// ─── 页头徽章(活跃会话 + 用户数) ─────────────────────────
export function AdminHeaderBadges({ t, streams }: { t: TranslationFunction; streams: AdminDashboardStreams }) {

View File

@@ -1,35 +0,0 @@
import type { ReactNode } from "react"
import { getLocale, getTranslations } from "next-intl/server"
import { formatLongDate } from "@/shared/lib/utils"
import { getGreetingKey } from "@/modules/dashboard/lib/dashboard-utils"
/**
* 仪表盘问候语头部(共享组件)
*
* 教师与学生仪表盘头部 90% 重复,统一抽象为此组件。
* 通过 `actions` slot 注入角色专属快捷操作。
*/
export async function DashboardGreetingHeader({
userName,
actions,
}: {
userName: string
actions?: ReactNode
}) {
const t = await getTranslations("dashboard")
const locale = await getLocale()
const today = formatLongDate(new Date(), locale)
const greetingKey = getGreetingKey(new Date())
return (
<div className="flex flex-col justify-between space-y-4 md:flex-row md:items-center md:space-y-0">
<div>
<h2 className="text-2xl font-bold tracking-tight">
{userName ? `${t(`greeting.${greetingKey}`)}${userName}` : t(`greeting.${greetingKey}`)}
</h2>
<p className="text-muted-foreground">{t("greeting.todayIs", { date: today })}</p>
</div>
{actions}
</div>
)
}

View File

@@ -1,44 +0,0 @@
import { Card, CardContent, CardHeader } from "@/shared/components/ui/card"
import { Skeleton } from "@/shared/components/ui/skeleton"
/**
* 仪表盘通用加载骨架屏。
*
* 用于各角色 dashboard 路由的 `loading.tsx`,消除重复代码。
* 布局:页头 + 4 列统计卡片 + 列表骨架。
*/
export function DashboardLoadingSkeleton() {
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div className="space-y-2">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-4 w-64" />
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
{Array.from({ length: 4 }).map((_, i) => (
<Card key={i}>
<CardHeader className="pb-2">
<Skeleton className="h-4 w-24" />
</CardHeader>
<CardContent>
<Skeleton className="h-8 w-16" />
<Skeleton className="mt-2 h-3 w-28" />
</CardContent>
</Card>
))}
</div>
<Card>
<CardHeader>
<Skeleton className="h-5 w-32" />
</CardHeader>
<CardContent className="space-y-3">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,36 @@
import type { ReactNode } from "react"
import { PageHeader } from "@/shared/components/ui/page-header"
import { StatsGrid, type StatItem } from "@/shared/components/ui/stats-grid"
/**
* 仪表盘布局壳:统一的 PageHeader + StatsGrid + 内容区结构。
*
* 各角色 dashboard 通过此组件复用布局外壳业务逻辑stats 计算、内容区)保持独立。
* stats 为空时不渲染统计区,适配无统计指标的页面(如家长仪表盘)。
*/
interface DashboardShellProps {
title: string
description?: string
stats: StatItem[]
statsLoading?: boolean
children: ReactNode
actions?: ReactNode
}
export function DashboardShell({
title,
description,
stats,
statsLoading,
children,
actions,
}: DashboardShellProps): ReactNode {
return (
<div className="space-y-6">
<PageHeader title={title} description={description} actions={actions} />
{stats.length > 0 ? <StatsGrid items={stats} isLoading={statsLoading} /> : null}
{children}
</div>
)
}

View File

@@ -12,6 +12,7 @@ import {
import { Card, CardContent } from "@/shared/components/ui/card"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { getGreetingKey } from "@/modules/dashboard/lib/dashboard-utils"
import { DashboardShell } from "../dashboard-shell"
/**
* 家长仪表盘视图组件V4 P1-4 从 parent 模块迁移至 dashboard 模块)。
@@ -37,7 +38,7 @@ export async function ParentDashboard({
childrenSlot: ReactNode
attentionBannerSlot: ReactNode
aiSummarySlot?: ReactNode
}) {
}): Promise<ReactNode> {
const t = await getTranslations("dashboard")
const hasChildren = childrenCount > 0
const greetingKey = getGreetingKey(new Date())
@@ -49,16 +50,10 @@ export async function ParentDashboard({
{ href: "/parent/leave", label: t("quickActions.leaveRequest"), icon: CalendarDays },
] as const
return (
<div className="space-y-6">
<div className="space-y-1">
<h1 className="text-2xl font-bold tracking-tight">{t("title.parent")}</h1>
<div className="text-sm text-muted-foreground">
{t(`greeting.${greetingKey}`)}
{parentName ? `, ${parentName}` : ""}. {t("description.parent")}
</div>
</div>
const description = `${t(`greeting.${greetingKey}`)}${parentName ? `, ${parentName}` : ""}. ${t("description.parent")}`
return (
<DashboardShell title={t("title.parent")} description={description} stats={[]}>
{hasChildren ? (
<>
{attentionBannerSlot}
@@ -110,6 +105,6 @@ export async function ParentDashboard({
}}
/>
)}
</div>
</DashboardShell>
)
}

View File

@@ -1,11 +0,0 @@
import { DashboardGreetingHeader } from "../dashboard-greeting-header"
interface StudentDashboardHeaderProps {
studentName: string
}
export function StudentDashboardHeader({ studentName }: StudentDashboardHeaderProps) {
return (
<DashboardGreetingHeader userName={studentName} />
)
}

View File

@@ -1,15 +1,26 @@
import { use } from "react"
import { getTranslations } from "next-intl/server"
import { UserX } from "lucide-react"
import type { ReactNode } from "react"
import { getLocale, getTranslations } from "next-intl/server"
import {
BookOpen,
CheckCircle,
PenTool,
TriangleAlert,
TrendingUp,
Trophy,
UserX,
} from "lucide-react"
import type { ActionState } from "@/shared/types/action-state"
import type { StudentDashboardProps } from "@/modules/dashboard/types"
import type { StatItem } from "@/shared/components/ui/stats-grid"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { getGreetingKey } from "@/modules/dashboard/lib/dashboard-utils"
import { formatLongDate } from "@/shared/lib/utils"
import { DashboardSection } from "../dashboard-section"
import { StudentDashboardHeader } from "./student-dashboard-header"
import { DashboardShell } from "../dashboard-shell"
import { StudentGradesCard } from "./student-grades-card"
import { StudentStatsGrid } from "./student-stats-grid"
import { StudentTodayScheduleCard } from "./student-today-schedule-card"
import { StudentUpcomingAssignmentsCard } from "./student-upcoming-assignments-card"
@@ -37,7 +48,7 @@ async function StudentDashboardBody({
result,
}: {
result: NonNullable<StudentDashboardResult["data"]>
}) {
}): Promise<ReactNode> {
const t = await getTranslations("dashboard")
if (!result.student || !result.dashboardProps) {
@@ -52,23 +63,76 @@ async function StudentDashboardBody({
}
const { student, dashboardProps } = result
const locale = await getLocale()
const today = formatLongDate(new Date(), locale)
const greetingKey = getGreetingKey(new Date())
const title = student.name
? `${t(`greeting.${greetingKey}`)}${student.name}`
: t(`greeting.${greetingKey}`)
const description = t("greeting.todayIs", { date: today })
const ranking = dashboardProps.grades.ranking
const stats: StatItem[] = [
{
label: t("stats.enrolledClasses"),
value: String(dashboardProps.enrolledClassCount),
description: t("stats.activeEnrollments"),
icon: BookOpen,
href: "/student/learning/courses",
color: "text-emerald-500",
valueClassName: "text-emerald-500 tabular-nums",
},
{
label: t("stats.averageScore"),
value: ranking ? `${Math.round(ranking.percentage)}%` : "-",
description: ranking ? t("stats.overallPerformance") : t("stats.noGradesYet"),
icon: TrendingUp,
href: "/student/grades",
color: "text-blue-500",
valueClassName: ranking ? "text-blue-500 tabular-nums" : "tabular-nums",
},
{
label: t("stats.classRank"),
value: ranking ? `${ranking.rank}/${ranking.classSize}` : "-",
description: ranking ? t("stats.currentPosition") : t("stats.noRankingYet"),
icon: Trophy,
href: "/student/grades",
color: "text-purple-500",
valueClassName: ranking ? "text-purple-500 tabular-nums" : "tabular-nums",
},
{
label: t("stats.graded"),
value: String(dashboardProps.gradedCount),
description: t("stats.completedAssignments"),
icon: CheckCircle,
href: "/student/learning/assignments",
color: "text-green-500",
valueClassName: "text-green-500 tabular-nums",
},
{
label: t("stats.dueSoon"),
value: String(dashboardProps.dueSoonCount),
description: t("stats.next7Days"),
icon: PenTool,
href: "/student/learning/assignments",
color: dashboardProps.dueSoonCount > 0 ? "text-orange-500" : undefined,
valueClassName:
dashboardProps.dueSoonCount > 0 ? "text-orange-500 tabular-nums" : "tabular-nums",
},
{
label: t("stats.overdue"),
value: String(dashboardProps.overdueCount),
description: t("stats.needsAttention"),
icon: TriangleAlert,
href: "/student/learning/assignments",
color: dashboardProps.overdueCount > 0 ? "text-red-500" : undefined,
valueClassName:
dashboardProps.overdueCount > 0 ? "text-red-500 tabular-nums" : "tabular-nums",
},
]
return (
<div className="space-y-6">
<header>
<StudentDashboardHeader studentName={student.name} />
</header>
<DashboardSection variant="stats" ariaLabel={t("sections.quickStats")}>
<StudentStatsGrid
enrolledClassCount={dashboardProps.enrolledClassCount}
dueSoonCount={dashboardProps.dueSoonCount}
overdueCount={dashboardProps.overdueCount}
gradedCount={dashboardProps.gradedCount}
ranking={dashboardProps.grades.ranking}
/>
</DashboardSection>
<DashboardShell title={title} description={description} stats={stats}>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<section
aria-label={t("sections.upcomingAssignments")}
@@ -87,6 +151,6 @@ async function StudentDashboardBody({
</DashboardSection>
</aside>
</div>
</div>
</DashboardShell>
)
}

View File

@@ -1,15 +0,0 @@
import { DashboardGreetingHeader } from "../dashboard-greeting-header"
import { TeacherQuickActions } from "./teacher-quick-actions"
interface TeacherDashboardHeaderProps {
teacherName: string
}
export function TeacherDashboardHeader({ teacherName }: TeacherDashboardHeaderProps) {
return (
<DashboardGreetingHeader
userName={teacherName}
actions={<TeacherQuickActions />}
/>
)
}

View File

@@ -1,17 +1,23 @@
import { use } from "react"
import type { ReactNode } from "react"
import { getLocale, getTranslations } from "next-intl/server"
import { BarChart, FileCheck, PenTool, TrendingUp } from "lucide-react"
import type { ActionState } from "@/shared/types/action-state"
import type { StatItem } from "@/shared/components/ui/stats-grid"
import type { TeacherDashboardData } from "@/modules/dashboard/types"
import type { TeacherDashboardMetrics } from "@/modules/dashboard/lib/dashboard-utils"
import type { ActionState } from "@/shared/types/action-state"
import { getTranslations } from "next-intl/server"
import { getGreetingKey } from "@/modules/dashboard/lib/dashboard-utils"
import { formatLongDate } from "@/shared/lib/utils"
import type { TeacherTodoItem } from "./teacher-todo-card"
import { DashboardSection } from "../dashboard-section"
import { DashboardShell } from "../dashboard-shell"
import { TeacherClassesCard } from "./teacher-classes-card"
import { TeacherDashboardHeader } from "./teacher-dashboard-header"
import { TeacherHomeworkCard } from "./teacher-homework-card"
import { RecentSubmissions } from "./recent-submissions"
import { TeacherSchedule } from "./teacher-schedule"
import { TeacherStats } from "./teacher-stats"
import { TeacherQuickActions } from "./teacher-quick-actions"
import { TeacherGradeTrends } from "./teacher-grade-trends"
import { TeacherTodoCard } from "./teacher-todo-card"
@@ -32,10 +38,22 @@ export function TeacherDashboardView({ dataPromise }: { dataPromise: Promise<Tea
return <TeacherDashboardContent data={result.data} />
}
async function TeacherDashboardContent({ data }: { data: TeacherDashboardData & { metrics: TeacherDashboardMetrics } }) {
async function TeacherDashboardContent({
data,
}: {
data: TeacherDashboardData & { metrics: TeacherDashboardMetrics }
}): Promise<ReactNode> {
const t = await getTranslations("dashboard")
const { metrics } = data
const locale = await getLocale()
const today = formatLongDate(new Date(), locale)
const greetingKey = getGreetingKey(new Date())
const title = data.teacherName
? `${t(`greeting.${greetingKey}`)}${data.teacherName}`
: t(`greeting.${greetingKey}`)
const description = t("greeting.todayIs", { date: today })
// 待办聚合(使用预计算指标)
const todoItems: TeacherTodoItem[] = [
{
@@ -58,21 +76,53 @@ async function TeacherDashboardContent({ data }: { data: TeacherDashboardData &
},
]
const stats: StatItem[] = [
{
label: t("stats.needsGrading"),
value: String(metrics.toGradeCount),
description: t("stats.submissionsPendingReview"),
icon: FileCheck,
href: "/teacher/homework/submissions?status=submitted",
highlight: metrics.toGradeCount > 0,
color: "text-amber-500",
valueClassName: "tabular-nums",
},
{
label: t("stats.activeAssignments"),
value: String(metrics.activeAssignmentsCount),
description: t("stats.publishedAndOngoing"),
icon: PenTool,
href: "/teacher/homework/assignments?status=published",
color: "text-blue-500",
valueClassName: "tabular-nums",
},
{
label: t("stats.averageScore"),
value: `${Math.round(metrics.averageScore)}%`,
description: t("stats.acrossRecentAssignments"),
icon: TrendingUp,
href: "#grade-trends",
color: "text-emerald-500",
valueClassName: "tabular-nums",
},
{
label: t("stats.submissionRate"),
value: `${Math.round(metrics.submissionRate)}%`,
description: t("stats.overallCompletionRate"),
icon: BarChart,
href: "#grade-trends",
color: "text-purple-500",
valueClassName: "tabular-nums",
},
]
return (
<div className="flex h-full flex-col space-y-6 p-8">
<header>
<TeacherDashboardHeader teacherName={data.teacherName} />
</header>
<DashboardSection variant="stats" ariaLabel={t("sections.quickStats")}>
<TeacherStats
toGradeCount={metrics.toGradeCount}
activeAssignmentsCount={metrics.activeAssignmentsCount}
averageScore={metrics.averageScore}
submissionRate={metrics.submissionRate}
/>
</DashboardSection>
<DashboardShell
title={title}
description={description}
stats={stats}
actions={<TeacherQuickActions />}
>
<div className="flex flex-col gap-6 lg:grid lg:grid-cols-12">
{/* 课表:移动端首位,桌面端右上 — 仅渲染一次P2-9 修复,原为双实例) */}
<div className="order-1 lg:col-start-9 lg:col-span-4 lg:row-start-1">
@@ -81,7 +131,10 @@ async function TeacherDashboardContent({ data }: { data: TeacherDashboardData &
</DashboardSection>
</div>
<section aria-label={t("sections.pendingGrading")} className="flex flex-col gap-6 order-2 lg:col-start-1 lg:col-span-8 lg:row-start-1 lg:row-span-2">
<section
aria-label={t("sections.pendingGrading")}
className="flex flex-col gap-6 order-2 lg:col-start-1 lg:col-span-8 lg:row-start-1 lg:row-span-2"
>
<DashboardSection variant="card" ariaLabel={t("todo.title")}>
<TeacherTodoCard items={todoItems} />
</DashboardSection>
@@ -98,7 +151,10 @@ async function TeacherDashboardContent({ data }: { data: TeacherDashboardData &
</DashboardSection>
</section>
<aside aria-label={t("sections.myClasses")} className="flex flex-col gap-6 order-3 lg:col-start-9 lg:col-span-4 lg:row-start-2">
<aside
aria-label={t("sections.myClasses")}
className="flex flex-col gap-6 order-3 lg:col-start-9 lg:col-span-4 lg:row-start-2"
>
<DashboardSection variant="list" ariaLabel={t("sections.homework")}>
<TeacherHomeworkCard assignments={data.assignments} />
</DashboardSection>
@@ -107,6 +163,6 @@ async function TeacherDashboardContent({ data }: { data: TeacherDashboardData &
</DashboardSection>
</aside>
</div>
</div>
</DashboardShell>
)
}

View File

@@ -1,61 +0,0 @@
import { FileCheck, PenTool, TrendingUp, BarChart } from "lucide-react"
import { getTranslations } from "next-intl/server"
import { StatCard } from "@/shared/components/ui/stat-card"
interface TeacherStatsProps {
toGradeCount: number
activeAssignmentsCount: number
averageScore: number
submissionRate: number
}
export async function TeacherStats({
toGradeCount,
activeAssignmentsCount,
averageScore,
submissionRate,
}: TeacherStatsProps) {
const t = await getTranslations("dashboard")
return (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<StatCard
title={t("stats.needsGrading")}
value={String(toGradeCount)}
description={t("stats.submissionsPendingReview")}
icon={FileCheck}
href="/teacher/homework/submissions?status=submitted"
highlight={toGradeCount > 0}
color="text-amber-500"
valueClassName="tabular-nums"
/>
<StatCard
title={t("stats.activeAssignments")}
value={String(activeAssignmentsCount)}
description={t("stats.publishedAndOngoing")}
icon={PenTool}
href="/teacher/homework/assignments?status=published"
color="text-blue-500"
valueClassName="tabular-nums"
/>
<StatCard
title={t("stats.averageScore")}
value={`${Math.round(averageScore)}%`}
description={t("stats.acrossRecentAssignments")}
icon={TrendingUp}
href="#grade-trends"
color="text-emerald-500"
valueClassName="tabular-nums"
/>
<StatCard
title={t("stats.submissionRate")}
value={`${Math.round(submissionRate)}%`}
description={t("stats.overallCompletionRate")}
icon={BarChart}
href="#grade-trends"
color="text-purple-500"
valueClassName="tabular-nums"
/>
</div>
)
}

View File

@@ -6,7 +6,7 @@ import { Tag, Eye, Pencil } from "lucide-react";
import type { BlackboardBlockData } from "../../types";
import { isBlackboardLayout } from "../../lib/type-guards";
import { KnowledgePointPicker } from "../knowledge-point-picker";
import { LessonPlanErrorBoundary } from "../lesson-plan-error-boundary";
import { SectionErrorBoundary } from "@/shared/components/section-error-boundary";
interface Props {
data: BlackboardBlockData;
@@ -125,7 +125,7 @@ export function BlackboardBlock({ data, textbookId, chapterId, onUpdate }: Props
</button>
</div>
{showKpPicker && (
<LessonPlanErrorBoundary>
<SectionErrorBoundary namespace="lessonPreparation">
<KnowledgePointPicker
textbookId={textbookId}
chapterId={chapterId}
@@ -133,7 +133,7 @@ export function BlackboardBlock({ data, textbookId, chapterId, onUpdate }: Props
onChange={(ids) => onUpdate({ ...data, knowledgePointIds: ids })}
onClose={() => setShowKpPicker(false)}
/>
</LessonPlanErrorBoundary>
</SectionErrorBoundary>
)}
</div>
);

View File

@@ -8,7 +8,7 @@ import { useLessonPlanEditor } from "../../hooks/use-lesson-plan-editor";
import { QuestionBankPicker } from "../question-bank-picker";
import { InlineQuestionEditor } from "../inline-question-editor";
import { PublishHomeworkDialog } from "../publish-homework-dialog";
import { LessonPlanErrorBoundary } from "../lesson-plan-error-boundary";
import { SectionErrorBoundary } from "@/shared/components/section-error-boundary";
import { Button } from "@/shared/components/ui/button";
import { Plus, Trash2 } from "lucide-react";
import type {
@@ -145,16 +145,16 @@ export function ExerciseBlock({ blockId, data, classes, textbookId, chapterId }:
)}
</div>
{showBank && (
<LessonPlanErrorBoundary>
<SectionErrorBoundary namespace="lessonPreparation">
<QuestionBankPicker
existingIds={data.items.map((i) => i.questionId)}
onPick={addItems}
onClose={() => setShowBank(false)}
/>
</LessonPlanErrorBoundary>
</SectionErrorBoundary>
)}
{showInline && (
<LessonPlanErrorBoundary>
<SectionErrorBoundary namespace="lessonPreparation">
<InlineQuestionEditor
textbookId={textbookId}
chapterId={chapterId}
@@ -164,10 +164,10 @@ export function ExerciseBlock({ blockId, data, classes, textbookId, chapterId }:
}}
onClose={() => setShowInline(false)}
/>
</LessonPlanErrorBoundary>
</SectionErrorBoundary>
)}
{showPublish && (
<LessonPlanErrorBoundary>
<SectionErrorBoundary namespace="lessonPreparation">
<PublishHomeworkDialog
planId={planId}
blockId={blockId}
@@ -176,7 +176,7 @@ export function ExerciseBlock({ blockId, data, classes, textbookId, chapterId }:
onClose={() => setShowPublish(false)}
onPublished={() => router.refresh()}
/>
</LessonPlanErrorBoundary>
</SectionErrorBoundary>
)}
</div>
);

View File

@@ -6,7 +6,7 @@ import { Plus, Trash2, Tag } from "lucide-react";
import type { NewTeachingBlockData, NewTeachingPoint } from "../../types";
import { Button } from "@/shared/components/ui/button";
import { KnowledgePointPicker } from "../knowledge-point-picker";
import { LessonPlanErrorBoundary } from "../lesson-plan-error-boundary";
import { SectionErrorBoundary } from "@/shared/components/section-error-boundary";
interface Props {
data: NewTeachingBlockData;
@@ -108,7 +108,7 @@ export function NewTeachingBlock({ data, textbookId, chapterId, onUpdate }: Prop
{t("newTeaching.addPoint")}
</Button>
{pickerFor !== null && (
<LessonPlanErrorBoundary>
<SectionErrorBoundary namespace="lessonPreparation">
<KnowledgePointPicker
textbookId={textbookId}
chapterId={chapterId}
@@ -116,7 +116,7 @@ export function NewTeachingBlock({ data, textbookId, chapterId, onUpdate }: Prop
onChange={(ids) => updatePoint(pickerFor, { knowledgePointIds: ids })}
onClose={() => setPickerFor(null)}
/>
</LessonPlanErrorBoundary>
</SectionErrorBoundary>
)}
</div>
);

View File

@@ -9,7 +9,7 @@ import { useTranslations } from "next-intl";
import type { RichTextBlockData, RichTextAttachment } from "../../types";
import { KnowledgePointPicker } from "../knowledge-point-picker";
import { AttachmentPicker } from "../attachment-picker";
import { LessonPlanErrorBoundary } from "../lesson-plan-error-boundary";
import { SectionErrorBoundary } from "@/shared/components/section-error-boundary";
import { Tag, Paperclip, Trash2 } from "lucide-react";
export interface RichTextBlockProps {
@@ -187,7 +187,7 @@ export function RichTextBlockInner({
)}
{showKpPicker && (
<LessonPlanErrorBoundary>
<SectionErrorBoundary namespace="lessonPreparation">
<KnowledgePointPicker
textbookId={textbookId}
chapterId={chapterId}
@@ -195,11 +195,11 @@ export function RichTextBlockInner({
onChange={(ids) => onUpdate({ ...data, knowledgePointIds: ids })}
onClose={() => setShowKpPicker(false)}
/>
</LessonPlanErrorBoundary>
</SectionErrorBoundary>
)}
{/* V5-5素材库 picker */}
{showAttachmentPicker && planId && (
<LessonPlanErrorBoundary>
<SectionErrorBoundary namespace="lessonPreparation">
<AttachmentPicker
planId={planId}
blockId={blockId}
@@ -207,7 +207,7 @@ export function RichTextBlockInner({
onSelect={handleSelectAttachment}
onClose={() => setShowAttachmentPicker(false)}
/>
</LessonPlanErrorBoundary>
</SectionErrorBoundary>
)}
</div>
);

View File

@@ -9,7 +9,7 @@ import { useTranslations } from "next-intl";
import type { RichTextBlockData, RichTextAttachment } from "../../types";
import { KnowledgePointPicker } from "../knowledge-point-picker";
import { AttachmentPicker } from "../attachment-picker";
import { LessonPlanErrorBoundary } from "../lesson-plan-error-boundary";
import { SectionErrorBoundary } from "@/shared/components/section-error-boundary";
import { Tag, Paperclip, Trash2 } from "lucide-react";
interface Props {
@@ -187,7 +187,7 @@ export function RichTextBlock({
)}
{showKpPicker && (
<LessonPlanErrorBoundary>
<SectionErrorBoundary namespace="lessonPreparation">
<KnowledgePointPicker
textbookId={textbookId}
chapterId={chapterId}
@@ -195,11 +195,11 @@ export function RichTextBlock({
onChange={(ids) => onUpdate({ ...data, knowledgePointIds: ids })}
onClose={() => setShowKpPicker(false)}
/>
</LessonPlanErrorBoundary>
</SectionErrorBoundary>
)}
{/* V5-5素材库 picker */}
{showAttachmentPicker && planId && (
<LessonPlanErrorBoundary>
<SectionErrorBoundary namespace="lessonPreparation">
<AttachmentPicker
planId={planId}
blockId={blockId}
@@ -207,7 +207,7 @@ export function RichTextBlock({
onSelect={handleSelectAttachment}
onClose={() => setShowAttachmentPicker(false)}
/>
</LessonPlanErrorBoundary>
</SectionErrorBoundary>
)}
</div>
);

View File

@@ -8,7 +8,7 @@ import { Button } from "@/shared/components/ui/button";
import { FocusTrap } from "@/shared/components/a11y/focus-trap";
import { X, Tag } from "lucide-react";
import { KnowledgePointPicker } from "./knowledge-point-picker";
import { LessonPlanErrorBoundary } from "./lesson-plan-error-boundary";
import { SectionErrorBoundary } from "@/shared/components/section-error-boundary";
import type { ExerciseItem, InlineQuestionContent } from "../types";
import { VALID_QUESTION_TYPES } from "../lib/type-guards";
@@ -236,7 +236,7 @@ export function InlineQuestionEditor({ onAdd, onClose, textbookId, chapterId }:
</FocusTrap>
</div>
{showKpPicker && (
<LessonPlanErrorBoundary>
<SectionErrorBoundary namespace="lessonPreparation">
<KnowledgePointPicker
textbookId={textbookId}
chapterId={chapterId}
@@ -244,7 +244,7 @@ export function InlineQuestionEditor({ onAdd, onClose, textbookId, chapterId }:
onChange={setKpIds}
onClose={() => setShowKpPicker(false)}
/>
</LessonPlanErrorBoundary>
</SectionErrorBoundary>
)}
</div>
);

View File

@@ -4,7 +4,7 @@ import { useEffect, useState } from "react";
import { useTranslations } from "next-intl";
import { Button } from "@/shared/components/ui/button";
import { FocusTrap } from "@/shared/components/a11y/focus-trap";
import { KnowledgePointSkeleton } from "./lesson-plan-skeleton";
import { SkeletonCard } from "@/shared/components/ui/skeleton";
import { X } from "lucide-react";
import { useLessonPlanContextSafe } from "../providers/lesson-plan-provider";
import type { KnowledgePointOption } from "../providers/lesson-plan-provider";
@@ -92,7 +92,7 @@ export function KnowledgePointPicker({
</div>
<div className="flex-1 overflow-y-auto p-4">
{loading ? (
<KnowledgePointSkeleton />
<SkeletonCard variant="list" rows={8} />
) : error ? (
<p className="text-error text-sm">{error}</p>
) : options.length === 0 ? (

View File

@@ -0,0 +1,124 @@
"use client";
import type { JSX } from "react";
import { SectionErrorBoundary } from "@/shared/components/section-error-boundary";
import { VersionHistoryDrawer } from "./version-history-drawer";
import { PrintView } from "./print-view";
import { ScheduleDialog } from "./schedule-dialog";
import { ConsistencyCheckDialog } from "./consistency-check-dialog";
import { AiFeedbackDialog } from "./ai-feedback-dialog";
import { AiDifferentiationDialog } from "./ai-differentiation-dialog";
import type { LessonPlan, LessonPlanDocument } from "../types";
interface ClassItem {
id: string;
name: string;
}
interface Props {
planId: string;
doc: LessonPlanDocument;
printablePlan: LessonPlan;
textbookId?: string;
textbookTitle?: string;
chapterTitle?: string;
classes?: ClassItem[];
showVersions: boolean;
onCloseVersions: () => void;
onReverted: () => void;
showPrint: boolean;
onClosePrint: () => void;
showSchedule: boolean;
onCloseSchedule: () => void;
showConsistency: boolean;
onCloseConsistency: () => void;
showAiFeedback: boolean;
onCloseAiFeedback: () => void;
showAiDifferentiation: boolean;
onCloseAiDifferentiation: () => void;
}
export function LessonPlanDialogs({
planId,
doc,
printablePlan,
textbookId,
textbookTitle,
chapterTitle,
classes,
showVersions,
onCloseVersions,
onReverted,
showPrint,
onClosePrint,
showSchedule,
onCloseSchedule,
showConsistency,
onCloseConsistency,
showAiFeedback,
onCloseAiFeedback,
showAiDifferentiation,
onCloseAiDifferentiation,
}: Props): JSX.Element {
return (
<>
<SectionErrorBoundary namespace="lessonPreparation">
<VersionHistoryDrawer
open={showVersions}
onClose={onCloseVersions}
planId={planId}
onReverted={onReverted}
currentDoc={doc}
/>
</SectionErrorBoundary>
{/* V5-4导出/打印视图 */}
{showPrint && (
<SectionErrorBoundary namespace="lessonPreparation">
<PrintView
plan={printablePlan}
textbookTitle={textbookTitle}
chapterTitle={chapterTitle}
onClose={onClosePrint}
/>
</SectionErrorBoundary>
)}
{/* V5-7安排课时对话框 */}
{showSchedule && classes && classes.length > 0 && (
<SectionErrorBoundary namespace="lessonPreparation">
<ScheduleDialog
planId={planId}
classes={classes}
onClose={onCloseSchedule}
/>
</SectionErrorBoundary>
)}
{/* V5-19 T3一致性校验对话框 */}
{showConsistency && (
<SectionErrorBoundary namespace="lessonPreparation">
<ConsistencyCheckDialog doc={doc} onClose={onCloseConsistency} />
</SectionErrorBoundary>
)}
{/* V5-17 A1/A2AI 反馈对话框 */}
{showAiFeedback && (
<SectionErrorBoundary namespace="lessonPreparation">
<AiFeedbackDialog doc={doc} onClose={onCloseAiFeedback} />
</SectionErrorBoundary>
)}
{/* V5-21 A3/A4/A5AI 差异化与课标核对对话框 */}
{showAiDifferentiation && (
<SectionErrorBoundary namespace="lessonPreparation">
<AiDifferentiationDialog
doc={doc}
textbookId={textbookId}
onClose={onCloseAiDifferentiation}
/>
</SectionErrorBoundary>
)}
</>
);
}

View File

@@ -1,44 +1,28 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { JSX } from "react";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
import { useLessonPlanEditor } from "../hooks/use-lesson-plan-editor";
import { useLessonPlanPersistence } from "../hooks/use-lesson-plan-persistence";
import { StructureTree } from "./structure-tree/structure-tree";
import { PaperEditor } from "./paper-editor/paper-editor";
import { DetailPanel } from "./detail-panel/detail-panel";
import { AnchorMigrationBanner } from "./anchor-migration-banner";
import { VersionHistoryDrawer } from "./version-history-drawer";
import { LessonPlanErrorBoundary } from "./lesson-plan-error-boundary";
import { LessonPlanToolbar } from "./lesson-plan-toolbar";
import { LessonPlanDialogs } from "./lesson-plan-dialogs";
import { SectionErrorBoundary } from "@/shared/components/section-error-boundary";
import {
useLessonPlanContextSafe,
useLessonPlanTrackerSafe,
} from "../providers/lesson-plan-provider";
import type { LessonPlanStatus } from "../types";
import { Button } from "@/shared/components/ui/button";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/shared/components/ui/alert-dialog";
import { Save, History, Book, FileText, Send, Undo2, RotateCw, WifiOff, Printer, Undo, Redo, CalendarClock, ClipboardCheck, Sparkles, Layers } from "lucide-react";
import { toast } from "sonner";
import { PrintView } from "./print-view";
import { ScheduleDialog } from "./schedule-dialog";
import { ConsistencyCheckDialog } from "./consistency-check-dialog";
import { AiFeedbackDialog } from "./ai-feedback-dialog";
import { AiDifferentiationDialog } from "./ai-differentiation-dialog";
import type { LessonPlan } from "../types";
import type { LessonPlan, LessonPlanDocument, LessonPlanStatus } from "../types";
interface Props {
planId: string;
initialTitle: string;
initialDoc: import("../types").LessonPlanDocument;
initialDoc: LessonPlanDocument;
initialStatus?: LessonPlanStatus;
textbookId?: string;
chapterId?: string;
@@ -57,9 +41,9 @@ export function LessonPlanEditor({
textbookTitle,
chapterTitle,
classes,
}: Props) {
}: Props): JSX.Element {
const t = useTranslations("lessonPreparation");
// Phase 4.2: 拆分为细粒度 selector避免任一字段变化都触发整体 re-render
// Phase 4.2拆分为细粒度 selector避免任一字段变化都触发整体 re-render
const isDirty = useLessonPlanEditor((s) => s.isDirty);
const isOnline = useLessonPlanEditor((s) => s.isOnline);
const doc = useLessonPlanEditor((s) => s.doc);
@@ -68,26 +52,28 @@ export function LessonPlanEditor({
const saveError = useLessonPlanEditor((s) => s.saveError);
const lastSavedAt = useLessonPlanEditor((s) => s.lastSavedAt);
const setTitle = useLessonPlanEditor((s) => s.setTitle);
// 订阅派生 boolean仅在结果变化时触发 re-renderrerender-derived-state
// 订阅派生 boolean仅在结果变化时触发 re-render
const canUndo = useLessonPlanEditor((s) => s.canUndo());
const canRedo = useLessonPlanEditor((s) => s.canRedo());
const tracker = useLessonPlanTrackerSafe();
const ctx = useLessonPlanContextSafe();
const service = ctx?.service ?? null;
const [showVersions, setShowVersions] = useState(false);
const [showPrint, setShowPrint] = useState(false); // V5-4打印视图
const [showSchedule, setShowSchedule] = useState(false); // V5-7安排课时对话框
const [showConsistency, setShowConsistency] = useState(false); // V5-19一致性校验对话框
const [showAiFeedback, setShowAiFeedback] = useState(false); // V5-17AI 反馈对话框
const [showAiDifferentiation, setShowAiDifferentiation] = useState(false); // V5-21AI 差异化对话框
const [showPrint, setShowPrint] = useState(false);
const [showSchedule, setShowSchedule] = useState(false);
const [showConsistency, setShowConsistency] = useState(false);
const [showAiFeedback, setShowAiFeedback] = useState(false);
const [showAiDifferentiation, setShowAiDifferentiation] = useState(false);
const [planStatus, setPlanStatus] = useState<LessonPlanStatus>(initialStatus);
const [publishing, setPublishing] = useState(false);
const autoSaveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const versionTimer = useRef<ReturnType<typeof setInterval> | null>(null);
const { handleRetrySave, handleManualSave, handleReverted } = useLessonPlanPersistence({
planId,
service,
tracker,
});
// 初始化:仅在 planId 变化时 hydrate修复 P1-3
// hydrate 是 zustand store action引用稳定
// initialTitle/initialDoc 通过 ref 保存,避免作为 effect 依赖导致重复 hydrate
const hydrate = useLessonPlanEditor((s) => s.hydrate);
const initialRef = useRef({ initialTitle, initialDoc });
useEffect(() => {
@@ -95,44 +81,6 @@ export function LessonPlanEditor({
hydrate(planId, t0, d0);
}, [planId, hydrate]);
// 自动保存debounce 3s- 用 getState() 获取最新值(修复 P1-4
// V3 修复:完全通过 service 调用,不直接 import actions
// V5-1 修复:保存失败显示 toast + 设置 saveError断网时不触发保存
useEffect(() => {
if (!isDirty) return;
if (!service) return;
if (!isOnline) return; // V5-1断网期间不触发保存请求
if (autoSaveTimer.current) clearTimeout(autoSaveTimer.current);
autoSaveTimer.current = setTimeout(async () => {
const state = useLessonPlanEditor.getState();
state.setSaving(true);
try {
const res = await service.updateLessonPlan({
planId: state.planId,
title: state.title,
content: state.doc,
});
if (res.success) {
// V5-1从失败恢复到成功时提示
if (state.saveError) toast.success(t("status.recovered"));
state.markSaved();
} else {
state.setSaveError(true);
toast.error(t("status.saveFailed"), { description: t("status.saveFailedHint") });
}
} catch (e) {
console.error("[LessonPlanEditor] auto-save failed", e);
state.setSaveError(true);
toast.error(t("status.saveFailed"), { description: t("status.saveFailedHint") });
} finally {
state.setSaving(false);
}
}, 3000);
return () => {
if (autoSaveTimer.current) clearTimeout(autoSaveTimer.current);
};
}, [isDirty, doc, planId, service, isOnline, t]);
// V5-1监听网络在线/离线状态
useEffect(() => {
function handleOnline() {
@@ -151,31 +99,6 @@ export function LessonPlanEditor({
};
}, [t]);
// V5-1手动重试保存saveError 时显示按钮)
const handleRetrySave = useCallback(async () => {
if (!service) return;
const state = useLessonPlanEditor.getState();
state.setSaving(true);
try {
const res = await service.updateLessonPlan({
planId: state.planId,
title: state.title,
content: state.doc,
});
if (res.success) {
toast.success(t("status.recovered"));
state.markSaved();
} else {
toast.error(t("status.saveFailed"));
}
} catch (e) {
console.error("[LessonPlanEditor] retry save failed", e);
toast.error(t("status.saveFailed"));
} finally {
state.setSaving(false);
}
}, [service, t]);
// V5-2撤销/重做快捷键Cmd/Ctrl+Z, Cmd/Ctrl+Shift+Z 或 Cmd/Ctrl+Y
useEffect(() => {
function handleKeyDown(e: KeyboardEvent) {
@@ -196,11 +119,10 @@ export function LessonPlanEditor({
return () => window.removeEventListener("keydown", handleKeyDown);
}, []);
// V5-2撤销/重做按钮处理canUndo/canRedo 已通过 selector 订阅)
const handleUndo = useCallback(() => useLessonPlanEditor.getState().undo(), []);
const handleRedo = useCallback(() => useLessonPlanEditor.getState().redo(), []);
// V5-4构造打印用的 LessonPlan 对象(编辑器仅持有 planId/title/doc其余字段填默认值
// V5-4构造打印用的 LessonPlan 对象
const printablePlan: LessonPlan = useMemo(() => {
return {
id: planId,
@@ -221,27 +143,6 @@ export function LessonPlanEditor({
};
}, [planId, title, doc, lastSavedAt, textbookId, chapterId, planStatus]);
// 定时自动版本30min
useEffect(() => {
if (!service) return;
versionTimer.current = setInterval(async () => {
const state = useLessonPlanEditor.getState();
if (!state.isDirty) return;
try {
await service.saveLessonPlanVersion({
planId: state.planId,
content: state.doc,
label: t("version.autoLabel"),
});
} catch (e) {
console.error("[LessonPlanEditor] auto-version failed", e);
}
}, 30 * 60 * 1000);
return () => {
if (versionTimer.current) clearInterval(versionTimer.current);
};
}, [planId, t, service]);
// 离开未保存提示P3-1
useEffect(() => {
function handleBeforeUnload(e: BeforeUnloadEvent) {
@@ -254,40 +155,6 @@ export function LessonPlanEditor({
return () => window.removeEventListener("beforeunload", handleBeforeUnload);
}, []);
const handleManualSave = useCallback(async () => {
if (!service) return;
const state = useLessonPlanEditor.getState();
state.setSaving(true);
try {
const res = await service.saveLessonPlanVersion({
planId: state.planId,
content: state.doc,
});
if (res.success) {
state.markSaved();
tracker.track("lesson_plan.save", { planId: state.planId, source: "manual" });
}
} catch (e) {
console.error("[LessonPlanEditor] manual save failed", e);
} finally {
state.setSaving(false);
}
}, [tracker, service]);
// 版本回退后刷新内容(修复 P1-1
const handleReverted = useCallback(async () => {
if (!service) return;
const state = useLessonPlanEditor.getState();
try {
const res = await service.getLessonPlanById(state.planId);
if (res.success && res.data?.plan) {
state.hydrate(state.planId, res.data.plan.title, res.data.plan.content);
}
} catch (e) {
console.error("[LessonPlanEditor] reload after revert failed", e);
}
}, [service]);
// 发布课案P0-1 修复)
const handlePublish = useCallback(async () => {
if (!service) return;
@@ -332,186 +199,39 @@ export function LessonPlanEditor({
return (
<div className="flex flex-col h-full">
{/* 顶部工具栏 */}
<div className="flex items-center gap-2 px-4 py-2 border-b border-outline-variant bg-surface">
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
className="flex-1 bg-transparent font-headline-md text-headline-md focus:outline-none"
<LessonPlanToolbar
title={title}
onTitleChange={setTitle}
textbookTitle={textbookTitle}
chapterTitle={chapterTitle}
isSaving={isSaving}
isDirty={isDirty}
saveError={saveError}
isOnline={isOnline}
canUndo={canUndo}
canRedo={canRedo}
onRetrySave={handleRetrySave}
onUndo={handleUndo}
onRedo={handleRedo}
onManualSave={handleManualSave}
onShowVersions={() => setShowVersions(true)}
onShowPrint={() => setShowPrint(true)}
onShowSchedule={() => setShowSchedule(true)}
onShowConsistency={() => setShowConsistency(true)}
onShowAiFeedback={() => setShowAiFeedback(true)}
onShowAiDifferentiation={() => setShowAiDifferentiation(true)}
planStatus={planStatus}
publishing={publishing}
onPublish={handlePublish}
onUnpublish={handleUnpublish}
showScheduleButton={!!classes && classes.length > 0}
/>
{/* 教材/章节指示器 */}
{textbookTitle && (
<div className="flex items-center gap-1 text-xs text-on-surface-variant px-2 py-1 rounded bg-surface-container-high">
<Book className="w-3 h-3" />
{/* arbitrary-value: layout fixed size */}
<span className="max-w-[120px] truncate">{textbookTitle}</span>
{chapterTitle && (
<>
<span className="text-on-surface-variant/50">/</span>
<FileText className="w-3 h-3" />
{/* arbitrary-value: layout fixed size */}
<span className="max-w-[120px] truncate">{chapterTitle}</span>
</>
)}
</div>
)}
<span className="text-on-surface-variant text-sm">
{isSaving
? t("status.saving")
: isDirty
? t("status.unsaved")
: t("status.saved")}
</span>
{/* V5-1保存失败/离线提示与重试按钮 */}
{saveError && (
<Button
variant="outline"
size="sm"
onClick={handleRetrySave}
disabled={isSaving}
className="text-error border-error"
>
<RotateCw className="w-3 h-3 mr-1" />
{t("status.retrySave")}
</Button>
)}
{!isOnline && (
<span className="text-xs text-error inline-flex items-center gap-1">
<WifiOff className="w-3 h-3" />
{t("status.offlineBadge")}
</span>
)}
<Button
variant="outline"
size="sm"
onClick={() => setShowVersions(true)}
>
<History className="w-4 h-4 mr-1" /> {t("action.versions")}
</Button>
{/* V5-2撤销/重做按钮 */}
<Button
variant="outline"
size="sm"
onClick={handleUndo}
disabled={!canUndo}
aria-label={t("action.undo")}
title={t("action.undoShortcut")}
>
<Undo className="w-4 h-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={handleRedo}
disabled={!canRedo}
aria-label={t("action.redo")}
title={t("action.redoShortcut")}
>
<Redo className="w-4 h-4" />
</Button>
<Button size="sm" onClick={handleManualSave} disabled={isSaving}>
<Save className="w-4 h-4 mr-1" /> {t("action.saveVersion")}
</Button>
{/* V5-4导出/打印按钮 */}
<Button
variant="outline"
size="sm"
onClick={() => setShowPrint(true)}
aria-label={t("export.title")}
>
<Printer className="w-4 h-4 mr-1" /> {t("export.button")}
</Button>
{/* V5-7安排课时按钮 */}
{classes && classes.length > 0 && (
<Button
variant="outline"
size="sm"
onClick={() => setShowSchedule(true)}
aria-label={t("action.scheduleLesson")}
>
<CalendarClock className="w-4 h-4 mr-1" /> {t("action.scheduleLesson")}
</Button>
)}
{/* V5-19 T3一致性校验按钮 */}
<Button
variant="outline"
size="sm"
onClick={() => setShowConsistency(true)}
aria-label={t("editor.consistencyOpen")}
>
<ClipboardCheck className="w-4 h-4 mr-1" /> {t("editor.consistencyOpen")}
</Button>
{/* V5-17 A1/A2AI 反馈按钮 */}
<Button
variant="outline"
size="sm"
onClick={() => setShowAiFeedback(true)}
aria-label={t("feedback.open")}
>
<Sparkles className="w-4 h-4 mr-1" /> {t("feedback.open")}
</Button>
{/* V5-21 A3/A4/A5AI 差异化与课标核对按钮 */}
<Button
variant="outline"
size="sm"
onClick={() => setShowAiDifferentiation(true)}
aria-label={t("aiDifferentiation.open")}
>
<Layers className="w-4 h-4 mr-1" /> {t("aiDifferentiation.open")}
</Button>
{/* 发布/撤回发布按钮P0-1 修复)*/}
{planStatus === "published" ? (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="outline" size="sm" disabled={publishing}>
<Undo2 className="w-4 h-4 mr-1" /> {t("action.unpublishPlan")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("action.unpublishPlan")}</AlertDialogTitle>
<AlertDialogDescription>
{t("action.unpublishPlanConfirm")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("action.cancel")}</AlertDialogCancel>
<AlertDialogAction onClick={handleUnpublish}>
{t("action.confirm")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
) : (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button size="sm" disabled={publishing}>
<Send className="w-4 h-4 mr-1" /> {t("action.publishPlan")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("action.publishPlan")}</AlertDialogTitle>
<AlertDialogDescription>
{t("action.publishPlanConfirm")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("action.cancel")}</AlertDialogCancel>
<AlertDialogAction onClick={handlePublish}>
{t("action.confirm")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</div>
{/* V4v3 锚点失效提示 banner */}
<AnchorMigrationBanner />
{/* V4三栏布局结构树 + 纸区 + 详情面板) */}
<LessonPlanErrorBoundary>
<SectionErrorBoundary namespace="lessonPreparation">
<main
style={{
display: "grid",
@@ -524,71 +244,30 @@ export function LessonPlanEditor({
<PaperEditor />
<DetailPanel />
</main>
</LessonPlanErrorBoundary>
</SectionErrorBoundary>
<LessonPlanErrorBoundary>
<VersionHistoryDrawer
open={showVersions}
onClose={() => setShowVersions(false)}
<LessonPlanDialogs
planId={planId}
onReverted={handleReverted}
currentDoc={doc}
/>
</LessonPlanErrorBoundary>
{/* V5-4导出/打印视图 */}
{showPrint && (
<LessonPlanErrorBoundary>
<PrintView
plan={printablePlan}
doc={doc}
printablePlan={printablePlan}
textbookId={textbookId}
textbookTitle={textbookTitle}
chapterTitle={chapterTitle}
onClose={() => setShowPrint(false)}
/>
</LessonPlanErrorBoundary>
)}
{/* V5-7安排课时对话框 */}
{showSchedule && classes && classes.length > 0 && (
<LessonPlanErrorBoundary>
<ScheduleDialog
planId={planId}
classes={classes}
onClose={() => setShowSchedule(false)}
showVersions={showVersions}
onCloseVersions={() => setShowVersions(false)}
onReverted={handleReverted}
showPrint={showPrint}
onClosePrint={() => setShowPrint(false)}
showSchedule={showSchedule}
onCloseSchedule={() => setShowSchedule(false)}
showConsistency={showConsistency}
onCloseConsistency={() => setShowConsistency(false)}
showAiFeedback={showAiFeedback}
onCloseAiFeedback={() => setShowAiFeedback(false)}
showAiDifferentiation={showAiDifferentiation}
onCloseAiDifferentiation={() => setShowAiDifferentiation(false)}
/>
</LessonPlanErrorBoundary>
)}
{/* V5-19 T3一致性校验对话框 */}
{showConsistency && (
<LessonPlanErrorBoundary>
<ConsistencyCheckDialog
doc={doc}
onClose={() => setShowConsistency(false)}
/>
</LessonPlanErrorBoundary>
)}
{/* V5-17 A1/A2AI 反馈对话框 */}
{showAiFeedback && (
<LessonPlanErrorBoundary>
<AiFeedbackDialog
doc={doc}
onClose={() => setShowAiFeedback(false)}
/>
</LessonPlanErrorBoundary>
)}
{/* V5-21 A3/A4/A5AI 差异化与课标核对对话框 */}
{showAiDifferentiation && (
<LessonPlanErrorBoundary>
<AiDifferentiationDialog
doc={doc}
textbookId={textbookId}
onClose={() => setShowAiDifferentiation(false)}
/>
</LessonPlanErrorBoundary>
)}
</div>
);
}

View File

@@ -1,81 +0,0 @@
"use client";
import { Component, type ReactNode, type ErrorInfo } from "react";
import { useTranslations } from "next-intl";
import { Button } from "@/shared/components/ui/button";
interface Props {
children: ReactNode;
fallback?: ReactNode;
/** 错误时的回调,用于上报埋点 */
onError?: (error: Error, info: ErrorInfo) => void;
/** 错误提示文案V3 i18n由包装组件注入*/
errorText?: string;
/** 重试按钮文案V3 i18n由包装组件注入*/
retryText?: string;
}
interface State {
hasError: boolean;
error: Error | null;
}
/**
* 备课模块错误边界(内部类组件)。
* 包裹独立数据区块(版本抽屉/题库选择器/知识点选择器/发布对话框),
* 单个区块异常不影响整页。
*
* V3 修复i18n 文案由外层 LessonPlanErrorBoundary 包装组件通过 useTranslations 注入。
*/
class LessonPlanErrorBoundaryBase extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
componentDidCatch(error: Error, info: ErrorInfo): void {
if (this.props.onError) {
this.props.onError(error, info);
}
}
handleRetry = (): void => {
this.setState({ hasError: false, error: null });
};
render(): ReactNode {
if (this.state.hasError) {
if (this.props.fallback) return this.props.fallback;
return (
<div className="flex flex-col items-center justify-center p-8 gap-3 text-center">
<p className="text-sm text-on-surface-variant">
{this.state.error?.message ?? this.props.errorText}
</p>
<Button variant="outline" size="sm" onClick={this.handleRetry}>
{this.props.retryText}
</Button>
</div>
);
}
return this.props.children;
}
}
/**
* 备课模块错误边界V3 i18n 包装组件)。
* 使用 useTranslations 注入错误文案,对外保持原有 API 不变。
*/
export function LessonPlanErrorBoundary(props: Props): ReactNode {
const t = useTranslations("lessonPreparation");
return (
<LessonPlanErrorBoundaryBase
errorText={t("error.loadFailed")}
retryText={t("error.retry")}
{...props}
/>
);
}

View File

@@ -1,89 +0,0 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useTranslations } from "next-intl";
import { useDebounce } from "@/shared/hooks/use-debounce";
interface Props {
onFilter: (params: {
query?: string;
subjectId?: string;
status?: string;
}) => void;
subjects: { id: string; name: string }[];
// P2 修复:加载状态,禁用输入避免重复请求
isLoading?: boolean;
}
export function LessonPlanFilters({ onFilter, subjects, isLoading = false }: Props) {
const t = useTranslations("lessonPreparation");
const [query, setQuery] = useState("");
const [subjectId, setSubjectId] = useState<string>("");
const [status, setStatus] = useState<string>("");
// 修复 P1-6搜索 debounce 300ms
const debouncedQuery = useDebounce(query, 300);
// 使用 ref 存储 onFilter避免其引用变化触发 useEffect 无限循环
const onFilterRef = useRef(onFilter);
useEffect(() => {
onFilterRef.current = onFilter;
}, [onFilter]);
useEffect(() => {
onFilterRef.current({
query: debouncedQuery || undefined,
subjectId: subjectId || undefined,
status: status || undefined,
});
}, [debouncedQuery, subjectId, status]);
return (
<div className="flex gap-2 flex-wrap items-center">
<label htmlFor="lesson-plan-search" className="sr-only">
{t("filters.searchPlaceholder")}
</label>
<input
id="lesson-plan-search"
placeholder={t("filters.searchPlaceholder")}
value={query}
onChange={(e) => setQuery(e.target.value)}
disabled={isLoading}
aria-busy={isLoading}
className="border border-outline-variant rounded-lg px-3 py-1.5 text-sm disabled:opacity-50"
/>
<label htmlFor="lesson-plan-subject" className="sr-only">
{t("filters.allSubjects")}
</label>
<select
id="lesson-plan-subject"
value={subjectId}
onChange={(e) => setSubjectId(e.target.value)}
disabled={isLoading}
aria-busy={isLoading}
className="border border-outline-variant rounded-lg px-3 py-1.5 text-sm disabled:opacity-50"
>
<option value="">{t("filters.allSubjects")}</option>
{subjects.map((s) => (
<option key={s.id} value={s.id}>
{s.name}
</option>
))}
</select>
<label htmlFor="lesson-plan-status" className="sr-only">
{t("filters.allStatus")}
</label>
<select
id="lesson-plan-status"
value={status}
onChange={(e) => setStatus(e.target.value)}
disabled={isLoading}
aria-busy={isLoading}
className="border border-outline-variant rounded-lg px-3 py-1.5 text-sm disabled:opacity-50"
>
<option value="">{t("filters.allStatus")}</option>
<option value="draft">{t("status.draft")}</option>
<option value="published">{t("status.published")}</option>
</select>
</div>
);
}

View File

@@ -1,9 +1,11 @@
"use client";
import { useCallback, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import type { JSX } from "react";
import { useTranslations } from "next-intl";
import { LessonPlanCard } from "./lesson-plan-card";
import { LessonPlanFilters } from "./lesson-plan-filters";
import { FilterBar, FilterSearchInput } from "@/shared/components/ui/filter-bar";
import { useDebounce } from "@/shared/hooks/use-debounce";
import { useLessonPlanContextSafe } from "../providers/lesson-plan-provider";
import type { LessonPlanListItem } from "../types";
@@ -20,17 +22,22 @@ interface Props {
viewMode?: "teacher" | "student" | "parent" | "admin" | "gradeHead";
}
export function LessonPlanList({ initialItems, subjects, viewMode = "teacher" }: Props) {
export function LessonPlanList({ initialItems, subjects, viewMode = "teacher" }: Props): JSX.Element {
const t = useTranslations("lessonPreparation");
const [items, setItems] = useState(initialItems);
const [error, setError] = useState<string | null>(null);
// P2 修复:增加筛选加载状态,避免用户重复点击
const [isLoading, setIsLoading] = useState(false);
// 筛选状态
const [query, setQuery] = useState("");
const [subjectId, setSubjectId] = useState("");
const [status, setStatus] = useState("");
// 修复 P1-6搜索 debounce 300ms
const debouncedQuery = useDebounce(query, 300);
const ctx = useLessonPlanContextSafe();
const service = ctx?.service ?? null;
// V3 修复:完全通过 service 调用,不直接 import actions
// 若未在 Provider 内使用,则不执行任何服务端调用(强制要求 Provider 包裹)
const handleFilter = useCallback(
async (params: {
query?: string;
@@ -57,9 +64,72 @@ export function LessonPlanList({ initialItems, subjects, viewMode = "teacher" }:
[service, t],
);
// 使用 ref 存储 handleFilter避免其引用变化触发 useEffect 无限循环
const handleFilterRef = useRef(handleFilter);
useEffect(() => {
handleFilterRef.current = handleFilter;
}, [handleFilter]);
useEffect(() => {
handleFilterRef.current({
query: debouncedQuery || undefined,
subjectId: subjectId || undefined,
status: status || undefined,
});
}, [debouncedQuery, subjectId, status]);
const hasFilters = !!(query || subjectId || status);
function handleReset(): void {
setQuery("");
setSubjectId("");
setStatus("");
}
return (
<div className="space-y-4">
<LessonPlanFilters onFilter={handleFilter} subjects={subjects} isLoading={isLoading} />
<FilterBar hasFilters={hasFilters} onReset={handleReset} layout="wrap">
<label htmlFor="lesson-plan-search" className="sr-only">
{t("filters.searchPlaceholder")}
</label>
<FilterSearchInput
value={query}
onChange={setQuery}
placeholder={t("filters.searchPlaceholder")}
/>
<label htmlFor="lesson-plan-subject" className="sr-only">
{t("filters.allSubjects")}
</label>
<select
id="lesson-plan-subject"
value={subjectId}
onChange={(e) => setSubjectId(e.target.value)}
disabled={isLoading}
aria-busy={isLoading}
className="border border-outline-variant rounded-lg px-3 py-1.5 text-sm disabled:opacity-50 h-9"
>
<option value="">{t("filters.allSubjects")}</option>
{subjects.map((s) => (
<option key={s.id} value={s.id}>
{s.name}
</option>
))}
</select>
<label htmlFor="lesson-plan-status" className="sr-only">
{t("filters.allStatus")}
</label>
<select
id="lesson-plan-status"
value={status}
onChange={(e) => setStatus(e.target.value)}
disabled={isLoading}
aria-busy={isLoading}
className="border border-outline-variant rounded-lg px-3 py-1.5 text-sm disabled:opacity-50 h-9"
>
<option value="">{t("filters.allStatus")}</option>
<option value="draft">{t("status.draft")}</option>
<option value="published">{t("status.published")}</option>
</select>
</FilterBar>
{error && (
<p className="text-error text-sm bg-error-container/10 px-3 py-2 rounded">
{error}

View File

@@ -0,0 +1,82 @@
"use client";
import type { JSX } from "react";
import { useTranslations } from "next-intl";
import { Send, Undo2 } from "lucide-react";
import type { LessonPlanStatus } from "../types";
import { Button } from "@/shared/components/ui/button";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/shared/components/ui/alert-dialog";
interface Props {
planStatus: LessonPlanStatus;
publishing: boolean;
onPublish: () => void;
onUnpublish: () => void;
}
export function LessonPlanPublishButton({
planStatus,
publishing,
onPublish,
onUnpublish,
}: Props): JSX.Element {
const t = useTranslations("lessonPreparation");
if (planStatus === "published") {
return (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="outline" size="sm" disabled={publishing}>
<Undo2 className="w-4 h-4 mr-1" /> {t("action.unpublishPlan")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("action.unpublishPlan")}</AlertDialogTitle>
<AlertDialogDescription>
{t("action.unpublishPlanConfirm")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("action.cancel")}</AlertDialogCancel>
<AlertDialogAction onClick={onUnpublish}>
{t("action.confirm")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
return (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button size="sm" disabled={publishing}>
<Send className="w-4 h-4 mr-1" /> {t("action.publishPlan")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("action.publishPlan")}</AlertDialogTitle>
<AlertDialogDescription>
{t("action.publishPlanConfirm")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("action.cancel")}</AlertDialogCancel>
<AlertDialogAction onClick={onPublish}>
{t("action.confirm")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}

View File

@@ -1,79 +0,0 @@
import type { JSX } from "react";
import { Skeleton } from "@/shared/components/ui/skeleton";
/** 版本列表骨架屏 */
export function VersionListSkeleton(): JSX.Element {
return (
<div className="flex flex-col gap-2">
{Array.from({ length: 5 }).map((_, i) => (
<div
key={i}
className="border border-outline-variant rounded-lg p-3 space-y-2"
>
<div className="flex justify-between items-center">
<Skeleton className="h-4 w-12" />
<Skeleton className="h-3 w-8" />
</div>
<Skeleton className="h-3 w-32" />
<Skeleton className="h-3 w-24" />
<Skeleton className="h-6 w-28" />
</div>
))}
</div>
);
}
/** 题库列表骨架屏 */
export function QuestionBankSkeleton(): JSX.Element {
return (
<div className="space-y-2">
{Array.from({ length: 6 }).map((_, i) => (
<div
key={i}
className="border rounded p-2 flex justify-between items-center"
>
<Skeleton className="h-4 flex-1 mr-2" />
<Skeleton className="h-3 w-16 mr-2" />
<Skeleton className="h-6 w-12" />
</div>
))}
</div>
);
}
/** 知识点列表骨架屏 */
export function KnowledgePointSkeleton(): JSX.Element {
return (
<div className="space-y-1">
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="flex items-center gap-2 p-2">
<Skeleton className="h-4 w-4" />
<Skeleton className="h-4 flex-1" />
</div>
))}
</div>
);
}
/** 课案列表骨架屏 */
export function LessonPlanListSkeleton(): JSX.Element {
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{Array.from({ length: 6 }).map((_, i) => (
<div
key={i}
className="border border-outline-variant rounded-lg p-4 space-y-3"
>
<Skeleton className="h-5 w-3/4" />
<Skeleton className="h-3 w-1/2" />
<Skeleton className="h-3 w-2/3" />
<Skeleton className="h-3 w-1/3" />
<div className="flex gap-2 mt-3">
<Skeleton className="h-7 w-16" />
<Skeleton className="h-7 w-16" />
</div>
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,212 @@
"use client";
import type { JSX } from "react";
import { useTranslations } from "next-intl";
import {
Save,
History,
Book,
FileText,
RotateCw,
WifiOff,
Printer,
Undo,
Redo,
CalendarClock,
ClipboardCheck,
Sparkles,
Layers,
} from "lucide-react";
import type { LessonPlanStatus } from "../types";
import { Button } from "@/shared/components/ui/button";
import { LessonPlanPublishButton } from "./lesson-plan-publish-button";
interface Props {
title: string;
onTitleChange: (value: string) => void;
textbookTitle?: string;
chapterTitle?: string;
isSaving: boolean;
isDirty: boolean;
saveError: boolean;
isOnline: boolean;
canUndo: boolean;
canRedo: boolean;
onRetrySave: () => void;
onUndo: () => void;
onRedo: () => void;
onManualSave: () => void;
onShowVersions: () => void;
onShowPrint: () => void;
onShowSchedule: () => void;
onShowConsistency: () => void;
onShowAiFeedback: () => void;
onShowAiDifferentiation: () => void;
planStatus: LessonPlanStatus;
publishing: boolean;
onPublish: () => void;
onUnpublish: () => void;
showScheduleButton: boolean;
}
export function LessonPlanToolbar({
title,
onTitleChange,
textbookTitle,
chapterTitle,
isSaving,
isDirty,
saveError,
isOnline,
canUndo,
canRedo,
onRetrySave,
onUndo,
onRedo,
onManualSave,
onShowVersions,
onShowPrint,
onShowSchedule,
onShowConsistency,
onShowAiFeedback,
onShowAiDifferentiation,
planStatus,
publishing,
onPublish,
onUnpublish,
showScheduleButton,
}: Props): JSX.Element {
const t = useTranslations("lessonPreparation");
return (
<div className="flex items-center gap-2 px-4 py-2 border-b border-outline-variant bg-surface">
<input
value={title}
onChange={(e) => onTitleChange(e.target.value)}
className="flex-1 bg-transparent font-headline-md text-headline-md focus:outline-none"
/>
{/* 教材/章节指示器 */}
{textbookTitle && (
<div className="flex items-center gap-1 text-xs text-on-surface-variant px-2 py-1 rounded bg-surface-container-high">
<Book className="w-3 h-3" />
{/* arbitrary-value: layout fixed size */}
<span className="max-w-[120px] truncate">{textbookTitle}</span>
{chapterTitle && (
<>
<span className="text-on-surface-variant/50">/</span>
<FileText className="w-3 h-3" />
{/* arbitrary-value: layout fixed size */}
<span className="max-w-[120px] truncate">{chapterTitle}</span>
</>
)}
</div>
)}
<span className="text-on-surface-variant text-sm">
{isSaving
? t("status.saving")
: isDirty
? t("status.unsaved")
: t("status.saved")}
</span>
{/* V5-1保存失败/离线提示与重试按钮 */}
{saveError && (
<Button
variant="outline"
size="sm"
onClick={onRetrySave}
disabled={isSaving}
className="text-error border-error"
>
<RotateCw className="w-3 h-3 mr-1" />
{t("status.retrySave")}
</Button>
)}
{!isOnline && (
<span className="text-xs text-error inline-flex items-center gap-1">
<WifiOff className="w-3 h-3" />
{t("status.offlineBadge")}
</span>
)}
<Button variant="outline" size="sm" onClick={onShowVersions}>
<History className="w-4 h-4 mr-1" /> {t("action.versions")}
</Button>
{/* V5-2撤销/重做按钮 */}
<Button
variant="outline"
size="sm"
onClick={onUndo}
disabled={!canUndo}
aria-label={t("action.undo")}
title={t("action.undoShortcut")}
>
<Undo className="w-4 h-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={onRedo}
disabled={!canRedo}
aria-label={t("action.redo")}
title={t("action.redoShortcut")}
>
<Redo className="w-4 h-4" />
</Button>
<Button size="sm" onClick={onManualSave} disabled={isSaving}>
<Save className="w-4 h-4 mr-1" /> {t("action.saveVersion")}
</Button>
{/* V5-4导出/打印按钮 */}
<Button
variant="outline"
size="sm"
onClick={onShowPrint}
aria-label={t("export.title")}
>
<Printer className="w-4 h-4 mr-1" /> {t("export.button")}
</Button>
{/* V5-7安排课时按钮 */}
{showScheduleButton && (
<Button
variant="outline"
size="sm"
onClick={onShowSchedule}
aria-label={t("action.scheduleLesson")}
>
<CalendarClock className="w-4 h-4 mr-1" /> {t("action.scheduleLesson")}
</Button>
)}
{/* V5-19 T3一致性校验按钮 */}
<Button
variant="outline"
size="sm"
onClick={onShowConsistency}
aria-label={t("editor.consistencyOpen")}
>
<ClipboardCheck className="w-4 h-4 mr-1" /> {t("editor.consistencyOpen")}
</Button>
{/* V5-17 A1/A2AI 反馈按钮 */}
<Button
variant="outline"
size="sm"
onClick={onShowAiFeedback}
aria-label={t("feedback.open")}
>
<Sparkles className="w-4 h-4 mr-1" /> {t("feedback.open")}
</Button>
{/* V5-21 A3/A4/A5AI 差异化与课标核对按钮 */}
<Button
variant="outline"
size="sm"
onClick={onShowAiDifferentiation}
aria-label={t("aiDifferentiation.open")}
>
<Layers className="w-4 h-4 mr-1" /> {t("aiDifferentiation.open")}
</Button>
{/* 发布/撤回发布按钮P0-1 修复)*/}
<LessonPlanPublishButton
planStatus={planStatus}
publishing={publishing}
onPublish={onPublish}
onUnpublish={onUnpublish}
/>
</div>
);
}

View File

@@ -6,7 +6,7 @@ import { useQuestionService } from "../providers/lesson-plan-provider"
import type { QuestionPickerItem, QuestionPickerParams } from "../providers/lesson-plan-provider"
import { Button } from "@/shared/components/ui/button"
import { FocusTrap } from "@/shared/components/a11y/focus-trap"
import { QuestionBankSkeleton } from "./lesson-plan-skeleton"
import { SkeletonCard } from "@/shared/components/ui/skeleton"
import { useDebounce } from "@/shared/hooks/use-debounce"
import { X, ChevronDown, ChevronRight } from "lucide-react"
import { QuestionBankFilters } from "@/shared/components/question/question-bank-filters"
@@ -196,7 +196,7 @@ export function QuestionBankPicker({ onPick, onClose, existingIds }: Props) {
</div>
<div className="flex-1 overflow-y-auto p-4">
{loading ? (
<QuestionBankSkeleton />
<SkeletonCard variant="list" rows={6} />
) : error ? (
<p className="text-error text-sm text-center py-8">{error}</p>
) : questions.length === 0 ? (

View File

@@ -6,7 +6,7 @@ import { toast } from "sonner";
import { useLessonPlanContextSafe, useLessonPlanTrackerSafe } from "../providers/lesson-plan-provider";
import { Button } from "@/shared/components/ui/button";
import { FocusTrap } from "@/shared/components/a11y/focus-trap";
import { VersionListSkeleton } from "./lesson-plan-skeleton";
import { SkeletonCard } from "@/shared/components/ui/skeleton";
import {
AlertDialog,
AlertDialogAction,
@@ -128,7 +128,7 @@ export function VersionHistoryDrawer({
<>
<h3 className="font-headline-md text-headline-md mb-4">{t("version.title")}</h3>
{loading ? (
<VersionListSkeleton />
<SkeletonCard variant="list" rows={5} />
) : versions.length === 0 ? (
<p className="text-on-surface-variant">{t("version.empty")}</p>
) : (

View File

@@ -0,0 +1,155 @@
"use client";
import { useCallback, useEffect, useRef } from "react";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
import { useLessonPlanEditor } from "./use-lesson-plan-editor";
import type {
LessonPlanDataService,
LessonPlanTracker,
} from "../providers/lesson-plan-provider";
interface Params {
planId: string;
service: LessonPlanDataService | null;
tracker: LessonPlanTracker;
}
interface PersistenceActions {
handleRetrySave: () => Promise<void>;
handleManualSave: () => Promise<void>;
handleReverted: () => Promise<void>;
}
/**
* 课案持久化逻辑自动保存debounce 3s、定时版本30min、手动保存/重试/回退刷新。
* 从 LessonPlanEditor 容器提取,避免容器超过 300 行。
*/
export function useLessonPlanPersistence({
planId,
service,
tracker,
}: Params): PersistenceActions {
const t = useTranslations("lessonPreparation");
const isDirty = useLessonPlanEditor((s) => s.isDirty);
const isOnline = useLessonPlanEditor((s) => s.isOnline);
const doc = useLessonPlanEditor((s) => s.doc);
const autoSaveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const versionTimer = useRef<ReturnType<typeof setInterval> | null>(null);
// 自动保存debounce 3s- 用 getState() 获取最新值(修复 P1-4
// V5-1保存失败显示 toast + 设置 saveError断网时不触发保存
useEffect(() => {
if (!isDirty || !service || !isOnline) return;
if (autoSaveTimer.current) clearTimeout(autoSaveTimer.current);
autoSaveTimer.current = setTimeout(async () => {
const state = useLessonPlanEditor.getState();
state.setSaving(true);
try {
const res = await service.updateLessonPlan({
planId: state.planId,
title: state.title,
content: state.doc,
});
if (res.success) {
if (state.saveError) toast.success(t("status.recovered"));
state.markSaved();
} else {
state.setSaveError(true);
toast.error(t("status.saveFailed"), { description: t("status.saveFailedHint") });
}
} catch (e) {
console.error("[LessonPlanEditor] auto-save failed", e);
state.setSaveError(true);
toast.error(t("status.saveFailed"), { description: t("status.saveFailedHint") });
} finally {
state.setSaving(false);
}
}, 3000);
return () => {
if (autoSaveTimer.current) clearTimeout(autoSaveTimer.current);
};
}, [isDirty, doc, planId, service, isOnline, t]);
// 定时自动版本30min
useEffect(() => {
if (!service) return;
versionTimer.current = setInterval(async () => {
const state = useLessonPlanEditor.getState();
if (!state.isDirty) return;
try {
await service.saveLessonPlanVersion({
planId: state.planId,
content: state.doc,
label: t("version.autoLabel"),
});
} catch (e) {
console.error("[LessonPlanEditor] auto-version failed", e);
}
}, 30 * 60 * 1000);
return () => {
if (versionTimer.current) clearInterval(versionTimer.current);
};
}, [planId, t, service]);
// V5-1手动重试保存saveError 时显示按钮)
const handleRetrySave = useCallback(async () => {
if (!service) return;
const state = useLessonPlanEditor.getState();
state.setSaving(true);
try {
const res = await service.updateLessonPlan({
planId: state.planId,
title: state.title,
content: state.doc,
});
if (res.success) {
toast.success(t("status.recovered"));
state.markSaved();
} else {
toast.error(t("status.saveFailed"));
}
} catch (e) {
console.error("[LessonPlanEditor] retry save failed", e);
toast.error(t("status.saveFailed"));
} finally {
state.setSaving(false);
}
}, [service, t]);
const handleManualSave = useCallback(async () => {
if (!service) return;
const state = useLessonPlanEditor.getState();
state.setSaving(true);
try {
const res = await service.saveLessonPlanVersion({
planId: state.planId,
content: state.doc,
});
if (res.success) {
state.markSaved();
tracker.track("lesson_plan.save", { planId: state.planId, source: "manual" });
}
} catch (e) {
console.error("[LessonPlanEditor] manual save failed", e);
} finally {
state.setSaving(false);
}
}, [tracker, service]);
// 版本回退后刷新内容(修复 P1-1
const handleReverted = useCallback(async () => {
if (!service) return;
const state = useLessonPlanEditor.getState();
try {
const res = await service.getLessonPlanById(state.planId);
if (res.success && res.data?.plan) {
state.hydrate(state.planId, res.data.plan.title, res.data.plan.content);
}
} catch (e) {
console.error("[LessonPlanEditor] reload after revert failed", e);
}
}, [service]);
return { handleRetrySave, handleManualSave, handleReverted };
}

View File

@@ -11,7 +11,7 @@ import { StatCard } from "@/shared/components/ui/stat-card"
* - practice-stats-cards / practice-overview-stats-cards
* - analytics-stats-cards
*/
interface StatItem {
export interface StatItem {
/** 卡片标题 */
label: string
/** 统计数值 */

View File

@@ -591,6 +591,8 @@
"createFailed": "Creation failed",
"loadFailed": "Page load failed",
"loadFailedDesc": "Sorry, an unexpected error occurred. Please try again later.",
"boundaryTitle": "Lesson preparation section failed to load",
"boundaryDescription": "An error occurred while loading lesson preparation data. Please retry.",
"retry": "Retry",
"invalidQuestionType": "Invalid question type",
"duplicateSuffix": " - Copy",

View File

@@ -591,6 +591,8 @@
"createFailed": "创建失败",
"loadFailed": "页面加载失败",
"loadFailedDesc": "抱歉,页面加载时发生了意外错误。请稍后重试。",
"boundaryTitle": "备课区块加载失败",
"boundaryDescription": "加载备课数据时发生错误,请重试。",
"retry": "重试",
"invalidQuestionType": "无效的题目类型",
"duplicateSuffix": " - 副本",