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() { 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() { 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() { 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() { 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 { useState, useRef, useEffect, useCallback } from "react"
import { useTranslations } from "next-intl" 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 { toast } from "sonner"
import { cn } from "@/shared/lib/utils"
import { Button } from "@/shared/components/ui/button" import { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card" import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Textarea } from "@/shared/components/ui/textarea" import { SkeletonCard } from "@/shared/components/ui/skeleton"
import { ScrollArea } from "@/shared/components/ui/scroll-area"
import { import {
Tooltip, Tooltip,
TooltipContent, TooltipContent,
TooltipProvider, TooltipProvider,
TooltipTrigger, TooltipTrigger,
} from "@/shared/components/ui/tooltip" } from "@/shared/components/ui/tooltip"
import { AiChatSkeleton } from "./ai-skeleton" import { AiChatMessages } from "./ai-chat-messages"
import { AiMarkdownRenderer } from "./ai-markdown-renderer" import { AiChatInput } from "./ai-chat-input"
import { useAiChatStream } from "../hooks/use-ai-chat-stream" import { useAiChatStream } from "../hooks/use-ai-chat-stream"
import type { AiChatMessage } from "../types" import type { AiChatMessage } from "../types"
@@ -51,6 +49,9 @@ type AiChatPanelProps = {
* - 建议提示词 * - 建议提示词
* - aria-live 无障碍 * - aria-live 无障碍
* - 对话历史持久化localStorage防抖写入 * - 对话历史持久化localStorage防抖写入
*
* 容器职责:持有 useAiChatStream流式状态机 + AbortController ref
* 输入状态、事件编排。渲染委托给 AiChatMessages / AiChatInput 子组件。
*/ */
export function AiChatPanel({ export function AiChatPanel({
systemPrompt, systemPrompt,
@@ -66,6 +67,7 @@ export function AiChatPanel({
const [input, setInput] = useState("") const [input, setInput] = useState("")
const scrollRef = useRef<HTMLDivElement>(null) const scrollRef = useRef<HTMLDivElement>(null)
const isWidget = variant === "widget" const isWidget = variant === "widget"
const maxReached = messages.length >= maxMessages
// 自动滚动到底部 // 自动滚动到底部
useEffect(() => { useEffect(() => {
@@ -77,7 +79,7 @@ export function AiChatPanel({
const handleSend = useCallback( const handleSend = useCallback(
async (content?: string): Promise<void> => { async (content?: string): Promise<void> => {
const trimmed = (content ?? input).trim() const trimmed = (content ?? input).trim()
if (!trimmed || streaming || messages.length >= maxMessages) return if (!trimmed || streaming || maxReached) return
// 上下文信息合并到 systemPrompt 发送给 AI用户气泡只显示真实输入 // 上下文信息合并到 systemPrompt 发送给 AI用户气泡只显示真实输入
const fullSystemPrompt = contextMessage const fullSystemPrompt = contextMessage
@@ -92,7 +94,7 @@ export function AiChatPanel({
setInput("") setInput("")
await send(requestMessages, { systemPrompt: fullSystemPrompt }) 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 => { 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) { if (streaming && messages.length === 0) {
return <AiChatSkeleton /> return <SkeletonCard variant="chart" />
} }
const defaultSuggestedPrompts = suggestedPrompts ?? [ const defaultSuggestedPrompts = suggestedPrompts ?? [
@@ -123,7 +121,6 @@ export function AiChatPanel({
t("chat.suggestedPrompts.teacher.2"), t("chat.suggestedPrompts.teacher.2"),
] ]
// widget 变体:无边框、撑满容器、消息区自适应高度
if (isWidget) { if (isWidget) {
return ( return (
<div className="flex h-full flex-col"> <div className="flex h-full flex-col">
@@ -135,129 +132,25 @@ export function AiChatPanel({
{error} {error}
</div> </div>
) : null} ) : null}
<AiChatMessages
{messages.length > 0 ? ( messages={messages}
<div streaming={streaming}
ref={scrollRef} variant={variant}
className="flex-1 overflow-y-auto px-4 py-4" scrollRef={scrollRef}
aria-live="polite" suggestedPrompts={defaultSuggestedPrompts}
aria-relevant="additions text" onSuggestedPrompt={(prompt) => void handleSend(prompt)}
> />
<div className="space-y-4"> <AiChatInput
{messages.map((message, index) => ( value={input}
<div onChange={setInput}
key={index} onKeyDown={handleKeyDown}
className={cn( streaming={streaming}
"flex gap-2.5", onSend={() => void handleSend()}
message.role === "user" ? "justify-end" : "justify-start" onStop={stop}
)} maxReached={maxReached}
> variant={variant}
{message.role === "assistant" ? ( placeholder={placeholder}
<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")}
/>
{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> </div>
) )
} }
@@ -300,117 +193,25 @@ export function AiChatPanel({
{error} {error}
</div> </div>
) : null} ) : null}
<AiChatMessages
{messages.length > 0 ? ( messages={messages}
<ScrollArea streaming={streaming}
className="h-72 w-full rounded-md border p-3" variant={variant}
aria-live="polite" scrollRef={scrollRef}
aria-relevant="additions text" suggestedPrompts={defaultSuggestedPrompts}
> onSuggestedPrompt={(prompt) => void handleSend(prompt)}
<div className="space-y-3" ref={scrollRef}> />
{messages.map((message, index) => ( <AiChatInput
<div value={input}
key={index} onChange={setInput}
className={cn( onKeyDown={handleKeyDown}
"flex gap-2", streaming={streaming}
message.role === "user" ? "justify-end" : "justify-start" onSend={() => void handleSend()}
)} onStop={stop}
> maxReached={maxReached}
{message.role === "assistant" ? ( variant={variant}
<Bot className="h-5 w-5 shrink-0 text-primary mt-0.5" /> placeholder={placeholder}
) : ( />
<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")}
/>
{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> </CardContent>
</Card> </Card>
) )

View File

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

View File

@@ -8,8 +8,8 @@ import { toast } from "sonner"
import { Button } from "@/shared/components/ui/button" import { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/shared/components/ui/card" import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/shared/components/ui/card"
import { Badge } from "@/shared/components/ui/badge" import { Badge } from "@/shared/components/ui/badge"
import { AiErrorBoundary } from "@/modules/ai/components/ai-error-boundary" import { SectionErrorBoundary } from "@/shared/components/section-error-boundary"
import { AiSuggestionSkeleton } from "@/modules/ai/components/ai-skeleton" import { SkeletonCard } from "@/shared/components/ui/skeleton"
import { useAiClient } from "@/modules/ai/context/ai-client-provider" import { useAiClient } from "@/modules/ai/context/ai-client-provider"
import type { WeaknessAnalysisResult, SimilarQuestionResult } from "@/modules/ai/types" import type { WeaknessAnalysisResult, SimilarQuestionResult } from "@/modules/ai/types"
@@ -110,7 +110,7 @@ export function AiErrorBookAnalysis({
} }
return ( return (
<AiErrorBoundary> <SectionErrorBoundary namespace="ai">
<div className="space-y-4"> <div className="space-y-4">
{/* 相似题推荐 */} {/* 相似题推荐 */}
{currentQuestionText ? ( {currentQuestionText ? (
@@ -124,7 +124,7 @@ export function AiErrorBookAnalysis({
</CardHeader> </CardHeader>
<CardContent className="space-y-3"> <CardContent className="space-y-3">
{similarLoading ? ( {similarLoading ? (
<AiSuggestionSkeleton /> <SkeletonCard variant="chart" />
) : similarQuestions.length > 0 ? ( ) : similarQuestions.length > 0 ? (
<> <>
{similarQuestions.map((question, index) => ( {similarQuestions.map((question, index) => (
@@ -184,7 +184,7 @@ export function AiErrorBookAnalysis({
</CardHeader> </CardHeader>
<CardContent className="space-y-3"> <CardContent className="space-y-3">
{weaknessLoading ? ( {weaknessLoading ? (
<AiSuggestionSkeleton /> <SkeletonCard variant="chart" />
) : weaknessResult ? ( ) : weaknessResult ? (
<div className="space-y-4"> <div className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
@@ -241,6 +241,6 @@ export function AiErrorBookAnalysis({
</CardContent> </CardContent>
</Card> </Card>
</div> </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 { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/shared/components/ui/card"
import { Badge } from "@/shared/components/ui/badge" import { Badge } from "@/shared/components/ui/badge"
import { Progress } from "@/shared/components/ui/progress" import { Progress } from "@/shared/components/ui/progress"
import { AiErrorBoundary } from "@/modules/ai/components/ai-error-boundary" import { SectionErrorBoundary } from "@/shared/components/section-error-boundary"
import { AiSuggestionSkeleton } from "@/modules/ai/components/ai-skeleton" import { SkeletonCard } from "@/shared/components/ui/skeleton"
import { useAiClient } from "@/modules/ai/context/ai-client-provider" import { useAiClient } from "@/modules/ai/context/ai-client-provider"
import type { GradingSuggestion } from "@/modules/ai/types" import type { GradingSuggestion } from "@/modules/ai/types"
@@ -87,7 +87,7 @@ export function AiGradingAssist({
const confidencePercent = suggestion ? Math.round(suggestion.confidence * 100) : 0 const confidencePercent = suggestion ? Math.round(suggestion.confidence * 100) : 0
return ( return (
<AiErrorBoundary> <SectionErrorBoundary namespace="ai">
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
@@ -98,7 +98,7 @@ export function AiGradingAssist({
</CardHeader> </CardHeader>
<CardContent className="space-y-3"> <CardContent className="space-y-3">
{loading ? ( {loading ? (
<AiSuggestionSkeleton /> <SkeletonCard variant="chart" />
) : suggestion ? ( ) : suggestion ? (
<div className="space-y-4"> <div className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
@@ -168,6 +168,6 @@ export function AiGradingAssist({
)} )}
</CardContent> </CardContent>
</Card> </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 { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/shared/components/ui/card"
import { Textarea } from "@/shared/components/ui/textarea" import { Textarea } from "@/shared/components/ui/textarea"
import { Badge } from "@/shared/components/ui/badge" import { Badge } from "@/shared/components/ui/badge"
import { AiErrorBoundary } from "@/modules/ai/components/ai-error-boundary" import { SectionErrorBoundary } from "@/shared/components/section-error-boundary"
import { AiSuggestionSkeleton } from "@/modules/ai/components/ai-skeleton" import { SkeletonCard } from "@/shared/components/ui/skeleton"
import { useAiClient } from "@/modules/ai/context/ai-client-provider" import { useAiClient } from "@/modules/ai/context/ai-client-provider"
import type { LessonContentResult } from "@/modules/ai/types" import type { LessonContentResult } from "@/modules/ai/types"
@@ -91,7 +91,7 @@ export function AiLessonContentGenerator({
] ]
return ( return (
<AiErrorBoundary> <SectionErrorBoundary namespace="ai">
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
@@ -146,7 +146,7 @@ export function AiLessonContentGenerator({
{/* 生成结果 */} {/* 生成结果 */}
{loading ? ( {loading ? (
<AiSuggestionSkeleton /> <SkeletonCard variant="chart" />
) : result ? ( ) : result ? (
<div className="space-y-3 rounded-md border p-3"> <div className="space-y-3 rounded-md border p-3">
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
@@ -175,6 +175,6 @@ export function AiLessonContentGenerator({
) : null} ) : null}
</CardContent> </CardContent>
</Card> </Card>
</AiErrorBoundary> </SectionErrorBoundary>
) )
} }

View File

@@ -15,8 +15,8 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/shared/components/ui/select" } from "@/shared/components/ui/select"
import { AiErrorBoundary } from "@/modules/ai/components/ai-error-boundary" import { SectionErrorBoundary } from "@/shared/components/section-error-boundary"
import { AiSuggestionSkeleton } from "@/modules/ai/components/ai-skeleton" import { SkeletonCard } from "@/shared/components/ui/skeleton"
import { useAiClient } from "@/modules/ai/context/ai-client-provider" import { useAiClient } from "@/modules/ai/context/ai-client-provider"
import type { QuestionVariantResult } from "@/modules/ai/types" import type { QuestionVariantResult } from "@/modules/ai/types"
@@ -96,7 +96,7 @@ export function AiQuestionVariantGenerator({
} }
return ( return (
<AiErrorBoundary> <SectionErrorBoundary namespace="ai">
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
@@ -149,7 +149,7 @@ export function AiQuestionVariantGenerator({
{/* 生成结果 */} {/* 生成结果 */}
{loading ? ( {loading ? (
<AiSuggestionSkeleton /> <SkeletonCard variant="chart" />
) : variant ? ( ) : variant ? (
<div className="space-y-3 rounded-md border p-3"> <div className="space-y-3 rounded-md border p-3">
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
@@ -213,6 +213,6 @@ export function AiQuestionVariantGenerator({
) : null} ) : null}
</CardContent> </CardContent>
</Card> </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 { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/shared/components/ui/card" import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/shared/components/ui/card"
import { Badge } from "@/shared/components/ui/badge" import { Badge } from "@/shared/components/ui/badge"
import { AiErrorBoundary } from "@/modules/ai/components/ai-error-boundary" import { SectionErrorBoundary } from "@/shared/components/section-error-boundary"
import { AiSuggestionSkeleton } from "@/modules/ai/components/ai-skeleton" import { SkeletonCard } from "@/shared/components/ui/skeleton"
import { useAiClient } from "@/modules/ai/context/ai-client-provider" import { useAiClient } from "@/modules/ai/context/ai-client-provider"
import type { StudyPathResult, StudyPathInput } from "@/modules/ai/types" import type { StudyPathResult, StudyPathInput } from "@/modules/ai/types"
@@ -98,7 +98,7 @@ export function AiStudyPath({
} }
return ( return (
<AiErrorBoundary> <SectionErrorBoundary namespace="ai">
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
@@ -109,7 +109,7 @@ export function AiStudyPath({
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
{loading ? ( {loading ? (
<AiSuggestionSkeleton /> <SkeletonCard variant="chart" />
) : path ? ( ) : path ? (
<div className="space-y-4"> <div className="space-y-4">
{/* 当前水平 */} {/* 当前水平 */}
@@ -195,6 +195,6 @@ export function AiStudyPath({
)} )}
</CardContent> </CardContent>
</Card> </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 { Badge } from "@/shared/components/ui/badge"
import { Progress } from "@/shared/components/ui/progress" import { Progress } from "@/shared/components/ui/progress"
import { Button } from "@/shared/components/ui/button" 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 { useAiClient } from "@/modules/ai/context/ai-client-provider"
import type { AiUsageStats } from "@/modules/ai/types" import type { AiUsageStats } from "@/modules/ai/types"
@@ -84,7 +84,7 @@ export function AiUsageDashboard(): React.ReactNode {
: [] : []
return ( return (
<AiErrorBoundary> <SectionErrorBoundary namespace="ai">
<Card> <Card>
<CardHeader> <CardHeader>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@@ -215,6 +215,6 @@ export function AiUsageDashboard(): React.ReactNode {
)} )}
</CardContent> </CardContent>
</Card> </Card>
</AiErrorBoundary> </SectionErrorBoundary>
) )
} }

View File

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

View File

@@ -6,14 +6,12 @@ import {
BookOpen, BookOpen,
ClipboardList, ClipboardList,
FileText, FileText,
LayoutDashboard,
Library, Library,
Users, Users,
ChevronRight, ChevronRight,
} from "lucide-react" } from "lucide-react"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card" 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 { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button" import { Button } from "@/shared/components/ui/button"
import { EmptyState } from "@/shared/components/ui/empty-state" 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>> 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 }) { 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 { Card, CardContent } from "@/shared/components/ui/card"
import { EmptyState } from "@/shared/components/ui/empty-state" import { EmptyState } from "@/shared/components/ui/empty-state"
import { getGreetingKey } from "@/modules/dashboard/lib/dashboard-utils" import { getGreetingKey } from "@/modules/dashboard/lib/dashboard-utils"
import { DashboardShell } from "../dashboard-shell"
/** /**
* 家长仪表盘视图组件V4 P1-4 从 parent 模块迁移至 dashboard 模块)。 * 家长仪表盘视图组件V4 P1-4 从 parent 模块迁移至 dashboard 模块)。
@@ -37,7 +38,7 @@ export async function ParentDashboard({
childrenSlot: ReactNode childrenSlot: ReactNode
attentionBannerSlot: ReactNode attentionBannerSlot: ReactNode
aiSummarySlot?: ReactNode aiSummarySlot?: ReactNode
}) { }): Promise<ReactNode> {
const t = await getTranslations("dashboard") const t = await getTranslations("dashboard")
const hasChildren = childrenCount > 0 const hasChildren = childrenCount > 0
const greetingKey = getGreetingKey(new Date()) const greetingKey = getGreetingKey(new Date())
@@ -49,16 +50,10 @@ export async function ParentDashboard({
{ href: "/parent/leave", label: t("quickActions.leaveRequest"), icon: CalendarDays }, { href: "/parent/leave", label: t("quickActions.leaveRequest"), icon: CalendarDays },
] as const ] as const
return ( const description = `${t(`greeting.${greetingKey}`)}${parentName ? `, ${parentName}` : ""}. ${t("description.parent")}`
<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>
return (
<DashboardShell title={t("title.parent")} description={description} stats={[]}>
{hasChildren ? ( {hasChildren ? (
<> <>
{attentionBannerSlot} {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 { use } from "react"
import { getTranslations } from "next-intl/server" import type { ReactNode } from "react"
import { UserX } from "lucide-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 { ActionState } from "@/shared/types/action-state"
import type { StudentDashboardProps } from "@/modules/dashboard/types" 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 { 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 { DashboardSection } from "../dashboard-section"
import { StudentDashboardHeader } from "./student-dashboard-header" import { DashboardShell } from "../dashboard-shell"
import { StudentGradesCard } from "./student-grades-card" import { StudentGradesCard } from "./student-grades-card"
import { StudentStatsGrid } from "./student-stats-grid"
import { StudentTodayScheduleCard } from "./student-today-schedule-card" import { StudentTodayScheduleCard } from "./student-today-schedule-card"
import { StudentUpcomingAssignmentsCard } from "./student-upcoming-assignments-card" import { StudentUpcomingAssignmentsCard } from "./student-upcoming-assignments-card"
@@ -37,7 +48,7 @@ async function StudentDashboardBody({
result, result,
}: { }: {
result: NonNullable<StudentDashboardResult["data"]> result: NonNullable<StudentDashboardResult["data"]>
}) { }): Promise<ReactNode> {
const t = await getTranslations("dashboard") const t = await getTranslations("dashboard")
if (!result.student || !result.dashboardProps) { if (!result.student || !result.dashboardProps) {
@@ -52,23 +63,76 @@ async function StudentDashboardBody({
} }
const { student, dashboardProps } = result 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 ( return (
<div className="space-y-6"> <DashboardShell title={title} description={description} stats={stats}>
<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>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6"> <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<section <section
aria-label={t("sections.upcomingAssignments")} aria-label={t("sections.upcomingAssignments")}
@@ -87,6 +151,6 @@ async function StudentDashboardBody({
</DashboardSection> </DashboardSection>
</aside> </aside>
</div> </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 { 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 { TeacherDashboardData } from "@/modules/dashboard/types"
import type { TeacherDashboardMetrics } from "@/modules/dashboard/lib/dashboard-utils" import type { TeacherDashboardMetrics } from "@/modules/dashboard/lib/dashboard-utils"
import type { ActionState } from "@/shared/types/action-state" import { getGreetingKey } from "@/modules/dashboard/lib/dashboard-utils"
import { getTranslations } from "next-intl/server" import { formatLongDate } from "@/shared/lib/utils"
import type { TeacherTodoItem } from "./teacher-todo-card" import type { TeacherTodoItem } from "./teacher-todo-card"
import { DashboardSection } from "../dashboard-section" import { DashboardSection } from "../dashboard-section"
import { DashboardShell } from "../dashboard-shell"
import { TeacherClassesCard } from "./teacher-classes-card" import { TeacherClassesCard } from "./teacher-classes-card"
import { TeacherDashboardHeader } from "./teacher-dashboard-header"
import { TeacherHomeworkCard } from "./teacher-homework-card" import { TeacherHomeworkCard } from "./teacher-homework-card"
import { RecentSubmissions } from "./recent-submissions" import { RecentSubmissions } from "./recent-submissions"
import { TeacherSchedule } from "./teacher-schedule" import { TeacherSchedule } from "./teacher-schedule"
import { TeacherStats } from "./teacher-stats" import { TeacherQuickActions } from "./teacher-quick-actions"
import { TeacherGradeTrends } from "./teacher-grade-trends" import { TeacherGradeTrends } from "./teacher-grade-trends"
import { TeacherTodoCard } from "./teacher-todo-card" import { TeacherTodoCard } from "./teacher-todo-card"
@@ -32,10 +38,22 @@ export function TeacherDashboardView({ dataPromise }: { dataPromise: Promise<Tea
return <TeacherDashboardContent data={result.data} /> 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 t = await getTranslations("dashboard")
const { metrics } = data 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[] = [ 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 ( return (
<div className="flex h-full flex-col space-y-6 p-8"> <DashboardShell
<header> title={title}
<TeacherDashboardHeader teacherName={data.teacherName} /> description={description}
</header> stats={stats}
actions={<TeacherQuickActions />}
<DashboardSection variant="stats" ariaLabel={t("sections.quickStats")}> >
<TeacherStats
toGradeCount={metrics.toGradeCount}
activeAssignmentsCount={metrics.activeAssignmentsCount}
averageScore={metrics.averageScore}
submissionRate={metrics.submissionRate}
/>
</DashboardSection>
<div className="flex flex-col gap-6 lg:grid lg:grid-cols-12"> <div className="flex flex-col gap-6 lg:grid lg:grid-cols-12">
{/* 课表:移动端首位,桌面端右上 — 仅渲染一次P2-9 修复,原为双实例) */} {/* 课表:移动端首位,桌面端右上 — 仅渲染一次P2-9 修复,原为双实例) */}
<div className="order-1 lg:col-start-9 lg:col-span-4 lg:row-start-1"> <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> </DashboardSection>
</div> </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")}> <DashboardSection variant="card" ariaLabel={t("todo.title")}>
<TeacherTodoCard items={todoItems} /> <TeacherTodoCard items={todoItems} />
</DashboardSection> </DashboardSection>
@@ -98,7 +151,10 @@ async function TeacherDashboardContent({ data }: { data: TeacherDashboardData &
</DashboardSection> </DashboardSection>
</section> </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")}> <DashboardSection variant="list" ariaLabel={t("sections.homework")}>
<TeacherHomeworkCard assignments={data.assignments} /> <TeacherHomeworkCard assignments={data.assignments} />
</DashboardSection> </DashboardSection>
@@ -107,6 +163,6 @@ async function TeacherDashboardContent({ data }: { data: TeacherDashboardData &
</DashboardSection> </DashboardSection>
</aside> </aside>
</div> </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 type { BlackboardBlockData } from "../../types";
import { isBlackboardLayout } from "../../lib/type-guards"; import { isBlackboardLayout } from "../../lib/type-guards";
import { KnowledgePointPicker } from "../knowledge-point-picker"; import { KnowledgePointPicker } from "../knowledge-point-picker";
import { LessonPlanErrorBoundary } from "../lesson-plan-error-boundary"; import { SectionErrorBoundary } from "@/shared/components/section-error-boundary";
interface Props { interface Props {
data: BlackboardBlockData; data: BlackboardBlockData;
@@ -125,7 +125,7 @@ export function BlackboardBlock({ data, textbookId, chapterId, onUpdate }: Props
</button> </button>
</div> </div>
{showKpPicker && ( {showKpPicker && (
<LessonPlanErrorBoundary> <SectionErrorBoundary namespace="lessonPreparation">
<KnowledgePointPicker <KnowledgePointPicker
textbookId={textbookId} textbookId={textbookId}
chapterId={chapterId} chapterId={chapterId}
@@ -133,7 +133,7 @@ export function BlackboardBlock({ data, textbookId, chapterId, onUpdate }: Props
onChange={(ids) => onUpdate({ ...data, knowledgePointIds: ids })} onChange={(ids) => onUpdate({ ...data, knowledgePointIds: ids })}
onClose={() => setShowKpPicker(false)} onClose={() => setShowKpPicker(false)}
/> />
</LessonPlanErrorBoundary> </SectionErrorBoundary>
)} )}
</div> </div>
); );

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,7 +4,7 @@ import { useEffect, useState } from "react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { Button } from "@/shared/components/ui/button"; import { Button } from "@/shared/components/ui/button";
import { FocusTrap } from "@/shared/components/a11y/focus-trap"; 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 { X } from "lucide-react";
import { useLessonPlanContextSafe } from "../providers/lesson-plan-provider"; import { useLessonPlanContextSafe } from "../providers/lesson-plan-provider";
import type { KnowledgePointOption } from "../providers/lesson-plan-provider"; import type { KnowledgePointOption } from "../providers/lesson-plan-provider";
@@ -92,7 +92,7 @@ export function KnowledgePointPicker({
</div> </div>
<div className="flex-1 overflow-y-auto p-4"> <div className="flex-1 overflow-y-auto p-4">
{loading ? ( {loading ? (
<KnowledgePointSkeleton /> <SkeletonCard variant="list" rows={8} />
) : error ? ( ) : error ? (
<p className="text-error text-sm">{error}</p> <p className="text-error text-sm">{error}</p>
) : options.length === 0 ? ( ) : 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"; "use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { JSX } from "react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { toast } from "sonner";
import { useLessonPlanEditor } from "../hooks/use-lesson-plan-editor"; import { useLessonPlanEditor } from "../hooks/use-lesson-plan-editor";
import { useLessonPlanPersistence } from "../hooks/use-lesson-plan-persistence";
import { StructureTree } from "./structure-tree/structure-tree"; import { StructureTree } from "./structure-tree/structure-tree";
import { PaperEditor } from "./paper-editor/paper-editor"; import { PaperEditor } from "./paper-editor/paper-editor";
import { DetailPanel } from "./detail-panel/detail-panel"; import { DetailPanel } from "./detail-panel/detail-panel";
import { AnchorMigrationBanner } from "./anchor-migration-banner"; import { AnchorMigrationBanner } from "./anchor-migration-banner";
import { VersionHistoryDrawer } from "./version-history-drawer"; import { LessonPlanToolbar } from "./lesson-plan-toolbar";
import { LessonPlanErrorBoundary } from "./lesson-plan-error-boundary"; import { LessonPlanDialogs } from "./lesson-plan-dialogs";
import { SectionErrorBoundary } from "@/shared/components/section-error-boundary";
import { import {
useLessonPlanContextSafe, useLessonPlanContextSafe,
useLessonPlanTrackerSafe, useLessonPlanTrackerSafe,
} from "../providers/lesson-plan-provider"; } from "../providers/lesson-plan-provider";
import type { LessonPlanStatus } from "../types"; import type { LessonPlan, LessonPlanDocument, 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";
interface Props { interface Props {
planId: string; planId: string;
initialTitle: string; initialTitle: string;
initialDoc: import("../types").LessonPlanDocument; initialDoc: LessonPlanDocument;
initialStatus?: LessonPlanStatus; initialStatus?: LessonPlanStatus;
textbookId?: string; textbookId?: string;
chapterId?: string; chapterId?: string;
@@ -57,9 +41,9 @@ export function LessonPlanEditor({
textbookTitle, textbookTitle,
chapterTitle, chapterTitle,
classes, classes,
}: Props) { }: Props): JSX.Element {
const t = useTranslations("lessonPreparation"); const t = useTranslations("lessonPreparation");
// Phase 4.2: 拆分为细粒度 selector避免任一字段变化都触发整体 re-render // Phase 4.2拆分为细粒度 selector避免任一字段变化都触发整体 re-render
const isDirty = useLessonPlanEditor((s) => s.isDirty); const isDirty = useLessonPlanEditor((s) => s.isDirty);
const isOnline = useLessonPlanEditor((s) => s.isOnline); const isOnline = useLessonPlanEditor((s) => s.isOnline);
const doc = useLessonPlanEditor((s) => s.doc); const doc = useLessonPlanEditor((s) => s.doc);
@@ -68,26 +52,28 @@ export function LessonPlanEditor({
const saveError = useLessonPlanEditor((s) => s.saveError); const saveError = useLessonPlanEditor((s) => s.saveError);
const lastSavedAt = useLessonPlanEditor((s) => s.lastSavedAt); const lastSavedAt = useLessonPlanEditor((s) => s.lastSavedAt);
const setTitle = useLessonPlanEditor((s) => s.setTitle); const setTitle = useLessonPlanEditor((s) => s.setTitle);
// 订阅派生 boolean仅在结果变化时触发 re-renderrerender-derived-state // 订阅派生 boolean仅在结果变化时触发 re-render
const canUndo = useLessonPlanEditor((s) => s.canUndo()); const canUndo = useLessonPlanEditor((s) => s.canUndo());
const canRedo = useLessonPlanEditor((s) => s.canRedo()); const canRedo = useLessonPlanEditor((s) => s.canRedo());
const tracker = useLessonPlanTrackerSafe(); const tracker = useLessonPlanTrackerSafe();
const ctx = useLessonPlanContextSafe(); const ctx = useLessonPlanContextSafe();
const service = ctx?.service ?? null; const service = ctx?.service ?? null;
const [showVersions, setShowVersions] = useState(false); const [showVersions, setShowVersions] = useState(false);
const [showPrint, setShowPrint] = useState(false); // V5-4打印视图 const [showPrint, setShowPrint] = useState(false);
const [showSchedule, setShowSchedule] = useState(false); // V5-7安排课时对话框 const [showSchedule, setShowSchedule] = useState(false);
const [showConsistency, setShowConsistency] = useState(false); // V5-19一致性校验对话框 const [showConsistency, setShowConsistency] = useState(false);
const [showAiFeedback, setShowAiFeedback] = useState(false); // V5-17AI 反馈对话框 const [showAiFeedback, setShowAiFeedback] = useState(false);
const [showAiDifferentiation, setShowAiDifferentiation] = useState(false); // V5-21AI 差异化对话框 const [showAiDifferentiation, setShowAiDifferentiation] = useState(false);
const [planStatus, setPlanStatus] = useState<LessonPlanStatus>(initialStatus); const [planStatus, setPlanStatus] = useState<LessonPlanStatus>(initialStatus);
const [publishing, setPublishing] = useState(false); 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 // 初始化:仅在 planId 变化时 hydrate修复 P1-3
// hydrate 是 zustand store action引用稳定
// initialTitle/initialDoc 通过 ref 保存,避免作为 effect 依赖导致重复 hydrate
const hydrate = useLessonPlanEditor((s) => s.hydrate); const hydrate = useLessonPlanEditor((s) => s.hydrate);
const initialRef = useRef({ initialTitle, initialDoc }); const initialRef = useRef({ initialTitle, initialDoc });
useEffect(() => { useEffect(() => {
@@ -95,44 +81,6 @@ export function LessonPlanEditor({
hydrate(planId, t0, d0); hydrate(planId, t0, d0);
}, [planId, hydrate]); }, [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监听网络在线/离线状态 // V5-1监听网络在线/离线状态
useEffect(() => { useEffect(() => {
function handleOnline() { function handleOnline() {
@@ -151,31 +99,6 @@ export function LessonPlanEditor({
}; };
}, [t]); }, [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 // V5-2撤销/重做快捷键Cmd/Ctrl+Z, Cmd/Ctrl+Shift+Z 或 Cmd/Ctrl+Y
useEffect(() => { useEffect(() => {
function handleKeyDown(e: KeyboardEvent) { function handleKeyDown(e: KeyboardEvent) {
@@ -196,11 +119,10 @@ export function LessonPlanEditor({
return () => window.removeEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown);
}, []); }, []);
// V5-2撤销/重做按钮处理canUndo/canRedo 已通过 selector 订阅)
const handleUndo = useCallback(() => useLessonPlanEditor.getState().undo(), []); const handleUndo = useCallback(() => useLessonPlanEditor.getState().undo(), []);
const handleRedo = useCallback(() => useLessonPlanEditor.getState().redo(), []); const handleRedo = useCallback(() => useLessonPlanEditor.getState().redo(), []);
// V5-4构造打印用的 LessonPlan 对象(编辑器仅持有 planId/title/doc其余字段填默认值 // V5-4构造打印用的 LessonPlan 对象
const printablePlan: LessonPlan = useMemo(() => { const printablePlan: LessonPlan = useMemo(() => {
return { return {
id: planId, id: planId,
@@ -221,27 +143,6 @@ export function LessonPlanEditor({
}; };
}, [planId, title, doc, lastSavedAt, textbookId, chapterId, planStatus]); }, [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 // 离开未保存提示P3-1
useEffect(() => { useEffect(() => {
function handleBeforeUnload(e: BeforeUnloadEvent) { function handleBeforeUnload(e: BeforeUnloadEvent) {
@@ -254,40 +155,6 @@ export function LessonPlanEditor({
return () => window.removeEventListener("beforeunload", handleBeforeUnload); 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 修复) // 发布课案P0-1 修复)
const handlePublish = useCallback(async () => { const handlePublish = useCallback(async () => {
if (!service) return; if (!service) return;
@@ -332,186 +199,39 @@ export function LessonPlanEditor({
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
{/* 顶部工具栏 */} <LessonPlanToolbar
<div className="flex items-center gap-2 px-4 py-2 border-b border-outline-variant bg-surface"> title={title}
<input onTitleChange={setTitle}
value={title} textbookTitle={textbookTitle}
onChange={(e) => setTitle(e.target.value)} chapterTitle={chapterTitle}
className="flex-1 bg-transparent font-headline-md text-headline-md focus:outline-none" isSaving={isSaving}
/> isDirty={isDirty}
{/* 教材/章节指示器 */} saveError={saveError}
{textbookTitle && ( isOnline={isOnline}
<div className="flex items-center gap-1 text-xs text-on-surface-variant px-2 py-1 rounded bg-surface-container-high"> canUndo={canUndo}
<Book className="w-3 h-3" /> canRedo={canRedo}
{/* arbitrary-value: layout fixed size */} onRetrySave={handleRetrySave}
<span className="max-w-[120px] truncate">{textbookTitle}</span> onUndo={handleUndo}
{chapterTitle && ( onRedo={handleRedo}
<> onManualSave={handleManualSave}
<span className="text-on-surface-variant/50">/</span> onShowVersions={() => setShowVersions(true)}
<FileText className="w-3 h-3" /> onShowPrint={() => setShowPrint(true)}
{/* arbitrary-value: layout fixed size */} onShowSchedule={() => setShowSchedule(true)}
<span className="max-w-[120px] truncate">{chapterTitle}</span> onShowConsistency={() => setShowConsistency(true)}
</> onShowAiFeedback={() => setShowAiFeedback(true)}
)} onShowAiDifferentiation={() => setShowAiDifferentiation(true)}
</div> planStatus={planStatus}
)} publishing={publishing}
<span className="text-on-surface-variant text-sm"> onPublish={handlePublish}
{isSaving onUnpublish={handleUnpublish}
? t("status.saving") showScheduleButton={!!classes && classes.length > 0}
: 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 */} {/* V4v3 锚点失效提示 banner */}
<AnchorMigrationBanner /> <AnchorMigrationBanner />
{/* V4三栏布局结构树 + 纸区 + 详情面板) */} {/* V4三栏布局结构树 + 纸区 + 详情面板) */}
<LessonPlanErrorBoundary> <SectionErrorBoundary namespace="lessonPreparation">
<main <main
style={{ style={{
display: "grid", display: "grid",
@@ -524,71 +244,30 @@ export function LessonPlanEditor({
<PaperEditor /> <PaperEditor />
<DetailPanel /> <DetailPanel />
</main> </main>
</LessonPlanErrorBoundary> </SectionErrorBoundary>
<LessonPlanErrorBoundary> <LessonPlanDialogs
<VersionHistoryDrawer planId={planId}
open={showVersions} doc={doc}
onClose={() => setShowVersions(false)} printablePlan={printablePlan}
planId={planId} textbookId={textbookId}
onReverted={handleReverted} textbookTitle={textbookTitle}
currentDoc={doc} chapterTitle={chapterTitle}
/> classes={classes}
</LessonPlanErrorBoundary> showVersions={showVersions}
onCloseVersions={() => setShowVersions(false)}
{/* V5-4导出/打印视图 */} onReverted={handleReverted}
{showPrint && ( showPrint={showPrint}
<LessonPlanErrorBoundary> onClosePrint={() => setShowPrint(false)}
<PrintView showSchedule={showSchedule}
plan={printablePlan} onCloseSchedule={() => setShowSchedule(false)}
textbookTitle={textbookTitle} showConsistency={showConsistency}
chapterTitle={chapterTitle} onCloseConsistency={() => setShowConsistency(false)}
onClose={() => setShowPrint(false)} showAiFeedback={showAiFeedback}
/> onCloseAiFeedback={() => setShowAiFeedback(false)}
</LessonPlanErrorBoundary> showAiDifferentiation={showAiDifferentiation}
)} onCloseAiDifferentiation={() => setShowAiDifferentiation(false)}
/>
{/* V5-7安排课时对话框 */}
{showSchedule && classes && classes.length > 0 && (
<LessonPlanErrorBoundary>
<ScheduleDialog
planId={planId}
classes={classes}
onClose={() => setShowSchedule(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> </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"; "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 { useTranslations } from "next-intl";
import { LessonPlanCard } from "./lesson-plan-card"; 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 { useLessonPlanContextSafe } from "../providers/lesson-plan-provider";
import type { LessonPlanListItem } from "../types"; import type { LessonPlanListItem } from "../types";
@@ -20,17 +22,22 @@ interface Props {
viewMode?: "teacher" | "student" | "parent" | "admin" | "gradeHead"; 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 t = useTranslations("lessonPreparation");
const [items, setItems] = useState(initialItems); const [items, setItems] = useState(initialItems);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
// P2 修复:增加筛选加载状态,避免用户重复点击 // P2 修复:增加筛选加载状态,避免用户重复点击
const [isLoading, setIsLoading] = useState(false); 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 ctx = useLessonPlanContextSafe();
const service = ctx?.service ?? null; const service = ctx?.service ?? null;
// V3 修复:完全通过 service 调用,不直接 import actions // V3 修复:完全通过 service 调用,不直接 import actions
// 若未在 Provider 内使用,则不执行任何服务端调用(强制要求 Provider 包裹)
const handleFilter = useCallback( const handleFilter = useCallback(
async (params: { async (params: {
query?: string; query?: string;
@@ -57,9 +64,72 @@ export function LessonPlanList({ initialItems, subjects, viewMode = "teacher" }:
[service, t], [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 ( return (
<div className="space-y-4"> <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 && ( {error && (
<p className="text-error text-sm bg-error-container/10 px-3 py-2 rounded"> <p className="text-error text-sm bg-error-container/10 px-3 py-2 rounded">
{error} {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 type { QuestionPickerItem, QuestionPickerParams } from "../providers/lesson-plan-provider"
import { Button } from "@/shared/components/ui/button" import { Button } from "@/shared/components/ui/button"
import { FocusTrap } from "@/shared/components/a11y/focus-trap" 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 { useDebounce } from "@/shared/hooks/use-debounce"
import { X, ChevronDown, ChevronRight } from "lucide-react" import { X, ChevronDown, ChevronRight } from "lucide-react"
import { QuestionBankFilters } from "@/shared/components/question/question-bank-filters" import { QuestionBankFilters } from "@/shared/components/question/question-bank-filters"
@@ -196,7 +196,7 @@ export function QuestionBankPicker({ onPick, onClose, existingIds }: Props) {
</div> </div>
<div className="flex-1 overflow-y-auto p-4"> <div className="flex-1 overflow-y-auto p-4">
{loading ? ( {loading ? (
<QuestionBankSkeleton /> <SkeletonCard variant="list" rows={6} />
) : error ? ( ) : error ? (
<p className="text-error text-sm text-center py-8">{error}</p> <p className="text-error text-sm text-center py-8">{error}</p>
) : questions.length === 0 ? ( ) : questions.length === 0 ? (

View File

@@ -6,7 +6,7 @@ import { toast } from "sonner";
import { useLessonPlanContextSafe, useLessonPlanTrackerSafe } from "../providers/lesson-plan-provider"; import { useLessonPlanContextSafe, useLessonPlanTrackerSafe } from "../providers/lesson-plan-provider";
import { Button } from "@/shared/components/ui/button"; import { Button } from "@/shared/components/ui/button";
import { FocusTrap } from "@/shared/components/a11y/focus-trap"; import { FocusTrap } from "@/shared/components/a11y/focus-trap";
import { VersionListSkeleton } from "./lesson-plan-skeleton"; import { SkeletonCard } from "@/shared/components/ui/skeleton";
import { import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
@@ -128,7 +128,7 @@ export function VersionHistoryDrawer({
<> <>
<h3 className="font-headline-md text-headline-md mb-4">{t("version.title")}</h3> <h3 className="font-headline-md text-headline-md mb-4">{t("version.title")}</h3>
{loading ? ( {loading ? (
<VersionListSkeleton /> <SkeletonCard variant="list" rows={5} />
) : versions.length === 0 ? ( ) : versions.length === 0 ? (
<p className="text-on-surface-variant">{t("version.empty")}</p> <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 * - practice-stats-cards / practice-overview-stats-cards
* - analytics-stats-cards * - analytics-stats-cards
*/ */
interface StatItem { export interface StatItem {
/** 卡片标题 */ /** 卡片标题 */
label: string label: string
/** 统计数值 */ /** 统计数值 */

View File

@@ -591,6 +591,8 @@
"createFailed": "Creation failed", "createFailed": "Creation failed",
"loadFailed": "Page load failed", "loadFailed": "Page load failed",
"loadFailedDesc": "Sorry, an unexpected error occurred. Please try again later.", "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", "retry": "Retry",
"invalidQuestionType": "Invalid question type", "invalidQuestionType": "Invalid question type",
"duplicateSuffix": " - Copy", "duplicateSuffix": " - Copy",

View File

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