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:
@@ -1,5 +1,5 @@
|
||||
import { DashboardLoadingSkeleton } from "@/modules/dashboard/components/dashboard-loading-skeleton"
|
||||
import { SkeletonCard } from "@/shared/components/ui/skeleton"
|
||||
|
||||
export default function AdminDashboardLoading() {
|
||||
return <DashboardLoadingSkeleton />
|
||||
return <SkeletonCard variant="stats-grid" />
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { DashboardLoadingSkeleton } from "@/modules/dashboard/components/dashboard-loading-skeleton"
|
||||
import { SkeletonCard } from "@/shared/components/ui/skeleton"
|
||||
|
||||
export default function DashboardLoading() {
|
||||
return <DashboardLoadingSkeleton />
|
||||
return <SkeletonCard variant="stats-grid" />
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { DashboardLoadingSkeleton } from "@/modules/dashboard/components/dashboard-loading-skeleton"
|
||||
import { SkeletonCard } from "@/shared/components/ui/skeleton"
|
||||
|
||||
export default function StudentDashboardLoading() {
|
||||
return <DashboardLoadingSkeleton />
|
||||
return <SkeletonCard variant="stats-grid" />
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { DashboardLoadingSkeleton } from "@/modules/dashboard/components/dashboard-loading-skeleton"
|
||||
import { SkeletonCard } from "@/shared/components/ui/skeleton"
|
||||
|
||||
export default function TeacherDashboardLoading() {
|
||||
return <DashboardLoadingSkeleton />
|
||||
return <SkeletonCard variant="stats-grid" />
|
||||
}
|
||||
|
||||
128
src/modules/ai/components/ai-chat-input.tsx
Normal file
128
src/modules/ai/components/ai-chat-input.tsx
Normal 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}
|
||||
</>
|
||||
)
|
||||
}
|
||||
199
src/modules/ai/components/ai-chat-messages.tsx
Normal file
199
src/modules/ai/components/ai-chat-messages.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -2,22 +2,20 @@
|
||||
|
||||
import { useState, useRef, useEffect, useCallback } from "react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { Send, Bot, User, Square, Trash2, Sparkles } from "lucide-react"
|
||||
import { Bot, Trash2 } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import { Textarea } from "@/shared/components/ui/textarea"
|
||||
import { ScrollArea } from "@/shared/components/ui/scroll-area"
|
||||
import { SkeletonCard } from "@/shared/components/ui/skeleton"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/shared/components/ui/tooltip"
|
||||
import { AiChatSkeleton } from "./ai-skeleton"
|
||||
import { AiMarkdownRenderer } from "./ai-markdown-renderer"
|
||||
import { AiChatMessages } from "./ai-chat-messages"
|
||||
import { AiChatInput } from "./ai-chat-input"
|
||||
import { useAiChatStream } from "../hooks/use-ai-chat-stream"
|
||||
import type { AiChatMessage } from "../types"
|
||||
|
||||
@@ -51,6 +49,9 @@ type AiChatPanelProps = {
|
||||
* - 建议提示词
|
||||
* - aria-live 无障碍
|
||||
* - 对话历史持久化(localStorage,防抖写入)
|
||||
*
|
||||
* 容器职责:持有 useAiChatStream(流式状态机 + AbortController ref)、
|
||||
* 输入状态、事件编排。渲染委托给 AiChatMessages / AiChatInput 子组件。
|
||||
*/
|
||||
export function AiChatPanel({
|
||||
systemPrompt,
|
||||
@@ -66,6 +67,7 @@ export function AiChatPanel({
|
||||
const [input, setInput] = useState("")
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
const isWidget = variant === "widget"
|
||||
const maxReached = messages.length >= maxMessages
|
||||
|
||||
// 自动滚动到底部
|
||||
useEffect(() => {
|
||||
@@ -77,7 +79,7 @@ export function AiChatPanel({
|
||||
const handleSend = useCallback(
|
||||
async (content?: string): Promise<void> => {
|
||||
const trimmed = (content ?? input).trim()
|
||||
if (!trimmed || streaming || messages.length >= maxMessages) return
|
||||
if (!trimmed || streaming || maxReached) return
|
||||
|
||||
// 上下文信息合并到 systemPrompt 发送给 AI,用户气泡只显示真实输入
|
||||
const fullSystemPrompt = contextMessage
|
||||
@@ -92,7 +94,7 @@ export function AiChatPanel({
|
||||
setInput("")
|
||||
await send(requestMessages, { systemPrompt: fullSystemPrompt })
|
||||
},
|
||||
[input, streaming, messages, maxMessages, systemPrompt, contextMessage, send]
|
||||
[input, streaming, messages, maxReached, systemPrompt, contextMessage, send]
|
||||
)
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>): void => {
|
||||
@@ -109,12 +111,8 @@ export function AiChatPanel({
|
||||
}
|
||||
}
|
||||
|
||||
const handleSuggestedPrompt = (prompt: string): void => {
|
||||
void handleSend(prompt)
|
||||
}
|
||||
|
||||
if (streaming && messages.length === 0) {
|
||||
return <AiChatSkeleton />
|
||||
return <SkeletonCard variant="chart" />
|
||||
}
|
||||
|
||||
const defaultSuggestedPrompts = suggestedPrompts ?? [
|
||||
@@ -123,7 +121,6 @@ export function AiChatPanel({
|
||||
t("chat.suggestedPrompts.teacher.2"),
|
||||
]
|
||||
|
||||
// widget 变体:无边框、撑满容器、消息区自适应高度
|
||||
if (isWidget) {
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
@@ -135,129 +132,25 @@ export function AiChatPanel({
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{messages.length > 0 ? (
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="flex-1 overflow-y-auto px-4 py-4"
|
||||
aria-live="polite"
|
||||
aria-relevant="additions text"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{messages.map((message, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={cn(
|
||||
"flex gap-2.5",
|
||||
message.role === "user" ? "justify-end" : "justify-start"
|
||||
)}
|
||||
>
|
||||
{message.role === "assistant" ? (
|
||||
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-gradient-to-br from-primary to-primary/80 text-primary-foreground">
|
||||
<Bot className="h-4 w-4" />
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground">
|
||||
<User className="h-4 w-4" />
|
||||
</span>
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-2xl px-3.5 py-2 text-sm max-w-[80%]",
|
||||
message.role === "user"
|
||||
? "bg-primary text-primary-foreground rounded-tr-sm"
|
||||
: "bg-muted rounded-tl-sm"
|
||||
)}
|
||||
>
|
||||
{message.role === "assistant" ? (
|
||||
<AiMarkdownRenderer content={message.content} />
|
||||
) : (
|
||||
<p className="whitespace-pre-wrap">{message.content}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{streaming ? (
|
||||
<div className="flex gap-2.5 justify-start">
|
||||
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-gradient-to-br from-primary to-primary/80 text-primary-foreground">
|
||||
<Bot className="h-4 w-4" />
|
||||
</span>
|
||||
<div className="rounded-2xl rounded-tl-sm px-3.5 py-2 text-sm bg-muted">
|
||||
<span className="inline-flex gap-1">
|
||||
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-muted-foreground/60 [animation-delay:-0.3s]" />
|
||||
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-muted-foreground/60 [animation-delay:-0.15s]" />
|
||||
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-muted-foreground/60" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-1 flex-col items-center justify-center px-6 py-8">
|
||||
<span className="flex h-14 w-14 items-center justify-center rounded-full bg-gradient-to-br from-primary to-primary/80 text-primary-foreground shadow-md mb-3">
|
||||
<Sparkles className="h-7 w-7" />
|
||||
</span>
|
||||
<p className="text-base font-medium mb-1">{t("widget.welcome")}</p>
|
||||
<p className="text-sm text-muted-foreground mb-4 text-center">{t("widget.welcomeDesc")}</p>
|
||||
<div className="flex flex-wrap gap-2 justify-center max-w-sm">
|
||||
{defaultSuggestedPrompts.map((prompt, index) => (
|
||||
<Button
|
||||
key={index}
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-xs rounded-full"
|
||||
onClick={() => handleSuggestedPrompt(prompt)}
|
||||
>
|
||||
{prompt}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="border-t p-3">
|
||||
<div className="flex items-end gap-2">
|
||||
<Textarea
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={placeholder ?? t("chat.placeholder")}
|
||||
className="min-h-10 max-h-32 resize-none rounded-2xl"
|
||||
disabled={streaming || messages.length >= maxMessages}
|
||||
aria-label={t("chat.inputLabel")}
|
||||
<AiChatMessages
|
||||
messages={messages}
|
||||
streaming={streaming}
|
||||
variant={variant}
|
||||
scrollRef={scrollRef}
|
||||
suggestedPrompts={defaultSuggestedPrompts}
|
||||
onSuggestedPrompt={(prompt) => void handleSend(prompt)}
|
||||
/>
|
||||
<AiChatInput
|
||||
value={input}
|
||||
onChange={setInput}
|
||||
onKeyDown={handleKeyDown}
|
||||
streaming={streaming}
|
||||
onSend={() => void handleSend()}
|
||||
onStop={stop}
|
||||
maxReached={maxReached}
|
||||
variant={variant}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
{streaming ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="destructive"
|
||||
className="rounded-full shrink-0"
|
||||
onClick={stop}
|
||||
aria-label={t("chat.stopGeneration")}
|
||||
>
|
||||
<Square className="h-4 w-4" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
className="rounded-full shrink-0"
|
||||
onClick={() => void handleSend()}
|
||||
disabled={!input.trim() || messages.length >= maxMessages}
|
||||
aria-label={t("chat.send")}
|
||||
>
|
||||
<Send className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{messages.length >= maxMessages ? (
|
||||
<p className="text-xs text-muted-foreground text-center mt-2">
|
||||
{t("chat.maxReached")}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -300,117 +193,25 @@ export function AiChatPanel({
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{messages.length > 0 ? (
|
||||
<ScrollArea
|
||||
className="h-72 w-full rounded-md border p-3"
|
||||
aria-live="polite"
|
||||
aria-relevant="additions text"
|
||||
>
|
||||
<div className="space-y-3" ref={scrollRef}>
|
||||
{messages.map((message, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={cn(
|
||||
"flex gap-2",
|
||||
message.role === "user" ? "justify-end" : "justify-start"
|
||||
)}
|
||||
>
|
||||
{message.role === "assistant" ? (
|
||||
<Bot className="h-5 w-5 shrink-0 text-primary mt-0.5" />
|
||||
) : (
|
||||
<User className="h-5 w-5 shrink-0 text-muted-foreground mt-0.5" />
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
// 任意值:聊天气泡需按容器宽度百分比限制,固定 max-w-* 无法适应不同面板宽度
|
||||
"rounded-md px-3 py-2 text-sm max-w-[80%]",
|
||||
message.role === "user"
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted"
|
||||
)}
|
||||
>
|
||||
{message.role === "assistant" ? (
|
||||
<AiMarkdownRenderer content={message.content} />
|
||||
) : (
|
||||
<p className="whitespace-pre-wrap">{message.content}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{streaming ? (
|
||||
<div className="flex gap-2 justify-start">
|
||||
<Bot className="h-5 w-5 shrink-0 text-primary mt-0.5" />
|
||||
<div className="rounded-md px-3 py-2 text-sm bg-muted">
|
||||
<span className="animate-pulse">{t("chat.streaming")}</span>
|
||||
<span className="inline-block w-1 h-4 ml-1 bg-primary animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-md border border-dashed p-6 text-center">
|
||||
<Sparkles className="h-8 w-8 text-primary mx-auto mb-2" />
|
||||
<p className="text-sm text-muted-foreground mb-3">
|
||||
{t("chat.suggestedPrompts.title")}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2 justify-center">
|
||||
{defaultSuggestedPrompts.map((prompt, index) => (
|
||||
<Button
|
||||
key={index}
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-xs"
|
||||
onClick={() => handleSuggestedPrompt(prompt)}
|
||||
>
|
||||
{prompt}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Textarea
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={placeholder ?? t("chat.placeholder")}
|
||||
className="min-h-16 resize-none"
|
||||
disabled={streaming || messages.length >= maxMessages}
|
||||
aria-label={t("chat.inputLabel")}
|
||||
<AiChatMessages
|
||||
messages={messages}
|
||||
streaming={streaming}
|
||||
variant={variant}
|
||||
scrollRef={scrollRef}
|
||||
suggestedPrompts={defaultSuggestedPrompts}
|
||||
onSuggestedPrompt={(prompt) => void handleSend(prompt)}
|
||||
/>
|
||||
<AiChatInput
|
||||
value={input}
|
||||
onChange={setInput}
|
||||
onKeyDown={handleKeyDown}
|
||||
streaming={streaming}
|
||||
onSend={() => void handleSend()}
|
||||
onStop={stop}
|
||||
maxReached={maxReached}
|
||||
variant={variant}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
{streaming ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="destructive"
|
||||
onClick={stop}
|
||||
aria-label={t("chat.stopGeneration")}
|
||||
>
|
||||
<Square className="h-4 w-4" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
onClick={() => void handleSend()}
|
||||
disabled={!input.trim() || messages.length >= maxMessages}
|
||||
aria-label={t("chat.send")}
|
||||
>
|
||||
<Send className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{messages.length >= maxMessages ? (
|
||||
<p className="text-xs text-muted-foreground text-center">
|
||||
{t("chat.maxReached")}
|
||||
</p>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
|
||||
@@ -8,8 +8,8 @@ import { toast } from "sonner"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/shared/components/ui/card"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { AiErrorBoundary } from "@/modules/ai/components/ai-error-boundary"
|
||||
import { AiSuggestionSkeleton } from "@/modules/ai/components/ai-skeleton"
|
||||
import { SectionErrorBoundary } from "@/shared/components/section-error-boundary"
|
||||
import { SkeletonCard } from "@/shared/components/ui/skeleton"
|
||||
import { AiMarkdownRenderer } from "@/modules/ai/components/ai-markdown-renderer"
|
||||
import { useAiClient } from "@/modules/ai/context/ai-client-provider"
|
||||
import type { ChildSummaryResult, ChildSummaryInput } from "@/modules/ai/types"
|
||||
@@ -76,7 +76,7 @@ export function AiChildSummary({
|
||||
}
|
||||
|
||||
return (
|
||||
<AiErrorBoundary>
|
||||
<SectionErrorBoundary namespace="ai">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
@@ -87,7 +87,7 @@ export function AiChildSummary({
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{loading ? (
|
||||
<AiSuggestionSkeleton />
|
||||
<SkeletonCard variant="chart" />
|
||||
) : summary ? (
|
||||
<div className="space-y-4">
|
||||
{/* 整体评估 */}
|
||||
@@ -181,6 +181,6 @@ export function AiChildSummary({
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AiErrorBoundary>
|
||||
</SectionErrorBoundary>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -8,8 +8,8 @@ import { toast } from "sonner"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/shared/components/ui/card"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { AiErrorBoundary } from "@/modules/ai/components/ai-error-boundary"
|
||||
import { AiSuggestionSkeleton } from "@/modules/ai/components/ai-skeleton"
|
||||
import { SectionErrorBoundary } from "@/shared/components/section-error-boundary"
|
||||
import { SkeletonCard } from "@/shared/components/ui/skeleton"
|
||||
import { useAiClient } from "@/modules/ai/context/ai-client-provider"
|
||||
import type { WeaknessAnalysisResult, SimilarQuestionResult } from "@/modules/ai/types"
|
||||
|
||||
@@ -110,7 +110,7 @@ export function AiErrorBookAnalysis({
|
||||
}
|
||||
|
||||
return (
|
||||
<AiErrorBoundary>
|
||||
<SectionErrorBoundary namespace="ai">
|
||||
<div className="space-y-4">
|
||||
{/* 相似题推荐 */}
|
||||
{currentQuestionText ? (
|
||||
@@ -124,7 +124,7 @@ export function AiErrorBookAnalysis({
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{similarLoading ? (
|
||||
<AiSuggestionSkeleton />
|
||||
<SkeletonCard variant="chart" />
|
||||
) : similarQuestions.length > 0 ? (
|
||||
<>
|
||||
{similarQuestions.map((question, index) => (
|
||||
@@ -184,7 +184,7 @@ export function AiErrorBookAnalysis({
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{weaknessLoading ? (
|
||||
<AiSuggestionSkeleton />
|
||||
<SkeletonCard variant="chart" />
|
||||
) : weaknessResult ? (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
@@ -241,6 +241,6 @@ export function AiErrorBookAnalysis({
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</AiErrorBoundary>
|
||||
</SectionErrorBoundary>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -9,8 +9,8 @@ import { Button } from "@/shared/components/ui/button"
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/shared/components/ui/card"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { Progress } from "@/shared/components/ui/progress"
|
||||
import { AiErrorBoundary } from "@/modules/ai/components/ai-error-boundary"
|
||||
import { AiSuggestionSkeleton } from "@/modules/ai/components/ai-skeleton"
|
||||
import { SectionErrorBoundary } from "@/shared/components/section-error-boundary"
|
||||
import { SkeletonCard } from "@/shared/components/ui/skeleton"
|
||||
import { useAiClient } from "@/modules/ai/context/ai-client-provider"
|
||||
import type { GradingSuggestion } from "@/modules/ai/types"
|
||||
|
||||
@@ -87,7 +87,7 @@ export function AiGradingAssist({
|
||||
const confidencePercent = suggestion ? Math.round(suggestion.confidence * 100) : 0
|
||||
|
||||
return (
|
||||
<AiErrorBoundary>
|
||||
<SectionErrorBoundary namespace="ai">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
@@ -98,7 +98,7 @@ export function AiGradingAssist({
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{loading ? (
|
||||
<AiSuggestionSkeleton />
|
||||
<SkeletonCard variant="chart" />
|
||||
) : suggestion ? (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
@@ -168,6 +168,6 @@ export function AiGradingAssist({
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AiErrorBoundary>
|
||||
</SectionErrorBoundary>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -9,8 +9,8 @@ import { Button } from "@/shared/components/ui/button"
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/shared/components/ui/card"
|
||||
import { Textarea } from "@/shared/components/ui/textarea"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { AiErrorBoundary } from "@/modules/ai/components/ai-error-boundary"
|
||||
import { AiSuggestionSkeleton } from "@/modules/ai/components/ai-skeleton"
|
||||
import { SectionErrorBoundary } from "@/shared/components/section-error-boundary"
|
||||
import { SkeletonCard } from "@/shared/components/ui/skeleton"
|
||||
import { useAiClient } from "@/modules/ai/context/ai-client-provider"
|
||||
import type { LessonContentResult } from "@/modules/ai/types"
|
||||
|
||||
@@ -91,7 +91,7 @@ export function AiLessonContentGenerator({
|
||||
]
|
||||
|
||||
return (
|
||||
<AiErrorBoundary>
|
||||
<SectionErrorBoundary namespace="ai">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
@@ -146,7 +146,7 @@ export function AiLessonContentGenerator({
|
||||
|
||||
{/* 生成结果 */}
|
||||
{loading ? (
|
||||
<AiSuggestionSkeleton />
|
||||
<SkeletonCard variant="chart" />
|
||||
) : result ? (
|
||||
<div className="space-y-3 rounded-md border p-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
@@ -175,6 +175,6 @@ export function AiLessonContentGenerator({
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AiErrorBoundary>
|
||||
</SectionErrorBoundary>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -15,8 +15,8 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/shared/components/ui/select"
|
||||
import { AiErrorBoundary } from "@/modules/ai/components/ai-error-boundary"
|
||||
import { AiSuggestionSkeleton } from "@/modules/ai/components/ai-skeleton"
|
||||
import { SectionErrorBoundary } from "@/shared/components/section-error-boundary"
|
||||
import { SkeletonCard } from "@/shared/components/ui/skeleton"
|
||||
import { useAiClient } from "@/modules/ai/context/ai-client-provider"
|
||||
import type { QuestionVariantResult } from "@/modules/ai/types"
|
||||
|
||||
@@ -96,7 +96,7 @@ export function AiQuestionVariantGenerator({
|
||||
}
|
||||
|
||||
return (
|
||||
<AiErrorBoundary>
|
||||
<SectionErrorBoundary namespace="ai">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
@@ -149,7 +149,7 @@ export function AiQuestionVariantGenerator({
|
||||
|
||||
{/* 生成结果 */}
|
||||
{loading ? (
|
||||
<AiSuggestionSkeleton />
|
||||
<SkeletonCard variant="chart" />
|
||||
) : variant ? (
|
||||
<div className="space-y-3 rounded-md border p-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
@@ -213,6 +213,6 @@ export function AiQuestionVariantGenerator({
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AiErrorBoundary>
|
||||
</SectionErrorBoundary>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -8,8 +8,8 @@ import { toast } from "sonner"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/shared/components/ui/card"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { AiErrorBoundary } from "@/modules/ai/components/ai-error-boundary"
|
||||
import { AiSuggestionSkeleton } from "@/modules/ai/components/ai-skeleton"
|
||||
import { SectionErrorBoundary } from "@/shared/components/section-error-boundary"
|
||||
import { SkeletonCard } from "@/shared/components/ui/skeleton"
|
||||
import { useAiClient } from "@/modules/ai/context/ai-client-provider"
|
||||
import type { StudyPathResult, StudyPathInput } from "@/modules/ai/types"
|
||||
|
||||
@@ -98,7 +98,7 @@ export function AiStudyPath({
|
||||
}
|
||||
|
||||
return (
|
||||
<AiErrorBoundary>
|
||||
<SectionErrorBoundary namespace="ai">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
@@ -109,7 +109,7 @@ export function AiStudyPath({
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{loading ? (
|
||||
<AiSuggestionSkeleton />
|
||||
<SkeletonCard variant="chart" />
|
||||
) : path ? (
|
||||
<div className="space-y-4">
|
||||
{/* 当前水平 */}
|
||||
@@ -195,6 +195,6 @@ export function AiStudyPath({
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AiErrorBoundary>
|
||||
</SectionErrorBoundary>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/sha
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { Progress } from "@/shared/components/ui/progress"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { AiErrorBoundary } from "@/modules/ai/components/ai-error-boundary"
|
||||
import { SectionErrorBoundary } from "@/shared/components/section-error-boundary"
|
||||
import { useAiClient } from "@/modules/ai/context/ai-client-provider"
|
||||
import type { AiUsageStats } from "@/modules/ai/types"
|
||||
|
||||
@@ -84,7 +84,7 @@ export function AiUsageDashboard(): React.ReactNode {
|
||||
: []
|
||||
|
||||
return (
|
||||
<AiErrorBoundary>
|
||||
<SectionErrorBoundary namespace="ai">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -215,6 +215,6 @@ export function AiUsageDashboard(): React.ReactNode {
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AiErrorBoundary>
|
||||
</SectionErrorBoundary>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,16 +5,21 @@ import { getTranslations } from "next-intl/server"
|
||||
import {
|
||||
CalendarCheck,
|
||||
CalendarClock,
|
||||
ClipboardList,
|
||||
FileText,
|
||||
FolderOpen,
|
||||
LayoutDashboard,
|
||||
Megaphone,
|
||||
Upload,
|
||||
Users,
|
||||
} from "lucide-react"
|
||||
|
||||
import type { StatItem } from "@/shared/components/ui/stats-grid"
|
||||
import { Card, CardContent } from "@/shared/components/ui/card"
|
||||
import { PageHeader } from "@/shared/components/ui/page-header"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||
import { DashboardShell } from "../dashboard-shell"
|
||||
import { DashboardSection } from "../dashboard-section"
|
||||
import { DashboardTimeRangeFilter } from "../dashboard-time-range-filter"
|
||||
import type { AdminDashboardStreams } from "../../streams"
|
||||
@@ -23,7 +28,6 @@ import {
|
||||
AdminHeaderBadges,
|
||||
AdminHomeworkActivityCard,
|
||||
AdminRecentUsersTable,
|
||||
AdminStatsBar,
|
||||
AdminTrendCharts,
|
||||
AdminUserRolesCard,
|
||||
} from "./admin-sections"
|
||||
@@ -31,17 +35,58 @@ import {
|
||||
/**
|
||||
* 管理员仪表盘视图(P2-1 流式架构)
|
||||
*
|
||||
* 页面外壳(标题 + 快捷操作)立即渲染;各数据分区用 React `use()` 独立消费 streams 中的 Promise,
|
||||
* 页面外壳(标题 + 快捷操作 + 统计卡片)立即渲染;各数据分区用 React `use()` 独立消费 streams 中的 Promise,
|
||||
* 在各自的 `<DashboardSection>` Suspense 边界内流式填充,互不阻塞。
|
||||
*/
|
||||
export async function AdminDashboardView({ streams }: { streams: AdminDashboardStreams }) {
|
||||
export async function AdminDashboardView({ streams }: { streams: AdminDashboardStreams }): Promise<ReactNode> {
|
||||
const t = await getTranslations("dashboard")
|
||||
|
||||
const [usersStats, classesStats, homeworkStats] = await Promise.all([
|
||||
streams.usersStats,
|
||||
streams.classesStats,
|
||||
streams.homeworkStats,
|
||||
])
|
||||
|
||||
const stats: StatItem[] = [
|
||||
{
|
||||
label: t("stats.users"),
|
||||
value: usersStats.userCount,
|
||||
icon: Users,
|
||||
color: "text-blue-500",
|
||||
valueClassName: "tabular-nums",
|
||||
href: "/admin/users",
|
||||
},
|
||||
{
|
||||
label: t("stats.classes"),
|
||||
value: classesStats.classCount,
|
||||
icon: LayoutDashboard,
|
||||
color: "text-emerald-500",
|
||||
valueClassName: "tabular-nums",
|
||||
href: "/admin/school/classes",
|
||||
},
|
||||
{
|
||||
label: t("stats.homeworkPublished"),
|
||||
value: homeworkStats.homeworkAssignmentPublishedCount,
|
||||
icon: ClipboardList,
|
||||
color: "text-purple-500",
|
||||
valueClassName: "tabular-nums",
|
||||
href: "/admin/homework/assignments",
|
||||
},
|
||||
{
|
||||
label: t("stats.toGrade"),
|
||||
value: homeworkStats.homeworkSubmissionToGradeCount,
|
||||
icon: FileText,
|
||||
color: "text-amber-500",
|
||||
valueClassName: "tabular-nums",
|
||||
href: "/admin/homework/submissions?status=submitted",
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-8 p-8">
|
||||
<PageHeader
|
||||
<DashboardShell
|
||||
title={t("title.admin")}
|
||||
description={t("description.admin")}
|
||||
stats={stats}
|
||||
actions={
|
||||
<>
|
||||
<Button asChild variant="outline" size="sm" className="gap-2">
|
||||
@@ -61,12 +106,7 @@ export async function AdminDashboardView({ streams }: { streams: AdminDashboardS
|
||||
</Suspense>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<DashboardSection variant="stats" ariaLabel={t("sections.quickStats")}>
|
||||
<AdminStatsBar t={t} streams={streams} />
|
||||
</DashboardSection>
|
||||
|
||||
>
|
||||
{/* L2: 时间范围筛选器 — 当前为 UI 占位,趋势数据接入后生效 */}
|
||||
<div className="flex justify-end">
|
||||
<DashboardTimeRangeFilter />
|
||||
@@ -131,7 +171,7 @@ export async function AdminDashboardView({ streams }: { streams: AdminDashboardS
|
||||
<DashboardSection variant="table" ariaLabel={t("sections.recentUsers")}>
|
||||
<AdminRecentUsersTable t={t} streams={streams} />
|
||||
</DashboardSection>
|
||||
</div>
|
||||
</DashboardShell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -160,7 +200,7 @@ function QuickActionCard({
|
||||
icon: React.ComponentType<{ className?: string }>
|
||||
title: string
|
||||
description: string
|
||||
}) {
|
||||
}): ReactNode {
|
||||
return (
|
||||
<Link href={href}>
|
||||
<Card className="transition-colors hover:border-primary/50 hover:bg-accent/50">
|
||||
|
||||
@@ -6,14 +6,12 @@ import {
|
||||
BookOpen,
|
||||
ClipboardList,
|
||||
FileText,
|
||||
LayoutDashboard,
|
||||
Library,
|
||||
Users,
|
||||
ChevronRight,
|
||||
} from "lucide-react"
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import { StatCard } from "@/shared/components/ui/stat-card"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
@@ -24,25 +22,6 @@ import { UserGrowthChart } from "./user-growth-chart"
|
||||
|
||||
type TranslationFunction = Awaited<ReturnType<typeof import("next-intl/server").getTranslations>>
|
||||
|
||||
// ─── 顶部统计栏 ────────────────────────────────────────────
|
||||
|
||||
export function AdminStatsBar({ t, streams }: { t: TranslationFunction; streams: AdminDashboardStreams }) {
|
||||
const [usersStats, classesStats, homeworkStats] = use(Promise.all([
|
||||
streams.usersStats,
|
||||
streams.classesStats,
|
||||
streams.homeworkStats,
|
||||
]))
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<StatCard title={t("stats.users")} value={usersStats.userCount} icon={Users} color="text-blue-500" valueClassName="tabular-nums" href="/admin/users" />
|
||||
<StatCard title={t("stats.classes")} value={classesStats.classCount} icon={LayoutDashboard} color="text-emerald-500" valueClassName="tabular-nums" href="/admin/school/classes" />
|
||||
<StatCard title={t("stats.homeworkPublished")} value={homeworkStats.homeworkAssignmentPublishedCount} icon={ClipboardList} color="text-purple-500" valueClassName="tabular-nums" href="/admin/homework/assignments" />
|
||||
<StatCard title={t("stats.toGrade")} value={homeworkStats.homeworkSubmissionToGradeCount} icon={FileText} color="text-amber-500" valueClassName="tabular-nums" href="/admin/homework/submissions?status=submitted" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── 页头徽章(活跃会话 + 用户数) ─────────────────────────
|
||||
|
||||
export function AdminHeaderBadges({ t, streams }: { t: TranslationFunction; streams: AdminDashboardStreams }) {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
36
src/modules/dashboard/components/dashboard-shell.tsx
Normal file
36
src/modules/dashboard/components/dashboard-shell.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import { Card, CardContent } from "@/shared/components/ui/card"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import { getGreetingKey } from "@/modules/dashboard/lib/dashboard-utils"
|
||||
import { DashboardShell } from "../dashboard-shell"
|
||||
|
||||
/**
|
||||
* 家长仪表盘视图组件(V4 P1-4 从 parent 模块迁移至 dashboard 模块)。
|
||||
@@ -37,7 +38,7 @@ export async function ParentDashboard({
|
||||
childrenSlot: ReactNode
|
||||
attentionBannerSlot: ReactNode
|
||||
aiSummarySlot?: ReactNode
|
||||
}) {
|
||||
}): Promise<ReactNode> {
|
||||
const t = await getTranslations("dashboard")
|
||||
const hasChildren = childrenCount > 0
|
||||
const greetingKey = getGreetingKey(new Date())
|
||||
@@ -49,16 +50,10 @@ export async function ParentDashboard({
|
||||
{ href: "/parent/leave", label: t("quickActions.leaveRequest"), icon: CalendarDays },
|
||||
] as const
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-1">
|
||||
<h1 className="text-2xl font-bold tracking-tight">{t("title.parent")}</h1>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t(`greeting.${greetingKey}`)}
|
||||
{parentName ? `, ${parentName}` : ""}. {t("description.parent")}
|
||||
</div>
|
||||
</div>
|
||||
const description = `${t(`greeting.${greetingKey}`)}${parentName ? `, ${parentName}` : ""}. ${t("description.parent")}`
|
||||
|
||||
return (
|
||||
<DashboardShell title={t("title.parent")} description={description} stats={[]}>
|
||||
{hasChildren ? (
|
||||
<>
|
||||
{attentionBannerSlot}
|
||||
@@ -110,6 +105,6 @@ export async function ParentDashboard({
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</DashboardShell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
import { DashboardGreetingHeader } from "../dashboard-greeting-header"
|
||||
|
||||
interface StudentDashboardHeaderProps {
|
||||
studentName: string
|
||||
}
|
||||
|
||||
export function StudentDashboardHeader({ studentName }: StudentDashboardHeaderProps) {
|
||||
return (
|
||||
<DashboardGreetingHeader userName={studentName} />
|
||||
)
|
||||
}
|
||||
@@ -1,15 +1,26 @@
|
||||
import { use } from "react"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
import { UserX } from "lucide-react"
|
||||
import type { ReactNode } from "react"
|
||||
import { getLocale, getTranslations } from "next-intl/server"
|
||||
import {
|
||||
BookOpen,
|
||||
CheckCircle,
|
||||
PenTool,
|
||||
TriangleAlert,
|
||||
TrendingUp,
|
||||
Trophy,
|
||||
UserX,
|
||||
} from "lucide-react"
|
||||
|
||||
import type { ActionState } from "@/shared/types/action-state"
|
||||
import type { StudentDashboardProps } from "@/modules/dashboard/types"
|
||||
import type { StatItem } from "@/shared/components/ui/stats-grid"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import { getGreetingKey } from "@/modules/dashboard/lib/dashboard-utils"
|
||||
import { formatLongDate } from "@/shared/lib/utils"
|
||||
|
||||
import { DashboardSection } from "../dashboard-section"
|
||||
import { StudentDashboardHeader } from "./student-dashboard-header"
|
||||
import { DashboardShell } from "../dashboard-shell"
|
||||
import { StudentGradesCard } from "./student-grades-card"
|
||||
import { StudentStatsGrid } from "./student-stats-grid"
|
||||
import { StudentTodayScheduleCard } from "./student-today-schedule-card"
|
||||
import { StudentUpcomingAssignmentsCard } from "./student-upcoming-assignments-card"
|
||||
|
||||
@@ -37,7 +48,7 @@ async function StudentDashboardBody({
|
||||
result,
|
||||
}: {
|
||||
result: NonNullable<StudentDashboardResult["data"]>
|
||||
}) {
|
||||
}): Promise<ReactNode> {
|
||||
const t = await getTranslations("dashboard")
|
||||
|
||||
if (!result.student || !result.dashboardProps) {
|
||||
@@ -52,23 +63,76 @@ async function StudentDashboardBody({
|
||||
}
|
||||
|
||||
const { student, dashboardProps } = result
|
||||
const locale = await getLocale()
|
||||
const today = formatLongDate(new Date(), locale)
|
||||
const greetingKey = getGreetingKey(new Date())
|
||||
const title = student.name
|
||||
? `${t(`greeting.${greetingKey}`)},${student.name}`
|
||||
: t(`greeting.${greetingKey}`)
|
||||
const description = t("greeting.todayIs", { date: today })
|
||||
|
||||
const ranking = dashboardProps.grades.ranking
|
||||
const stats: StatItem[] = [
|
||||
{
|
||||
label: t("stats.enrolledClasses"),
|
||||
value: String(dashboardProps.enrolledClassCount),
|
||||
description: t("stats.activeEnrollments"),
|
||||
icon: BookOpen,
|
||||
href: "/student/learning/courses",
|
||||
color: "text-emerald-500",
|
||||
valueClassName: "text-emerald-500 tabular-nums",
|
||||
},
|
||||
{
|
||||
label: t("stats.averageScore"),
|
||||
value: ranking ? `${Math.round(ranking.percentage)}%` : "-",
|
||||
description: ranking ? t("stats.overallPerformance") : t("stats.noGradesYet"),
|
||||
icon: TrendingUp,
|
||||
href: "/student/grades",
|
||||
color: "text-blue-500",
|
||||
valueClassName: ranking ? "text-blue-500 tabular-nums" : "tabular-nums",
|
||||
},
|
||||
{
|
||||
label: t("stats.classRank"),
|
||||
value: ranking ? `${ranking.rank}/${ranking.classSize}` : "-",
|
||||
description: ranking ? t("stats.currentPosition") : t("stats.noRankingYet"),
|
||||
icon: Trophy,
|
||||
href: "/student/grades",
|
||||
color: "text-purple-500",
|
||||
valueClassName: ranking ? "text-purple-500 tabular-nums" : "tabular-nums",
|
||||
},
|
||||
{
|
||||
label: t("stats.graded"),
|
||||
value: String(dashboardProps.gradedCount),
|
||||
description: t("stats.completedAssignments"),
|
||||
icon: CheckCircle,
|
||||
href: "/student/learning/assignments",
|
||||
color: "text-green-500",
|
||||
valueClassName: "text-green-500 tabular-nums",
|
||||
},
|
||||
{
|
||||
label: t("stats.dueSoon"),
|
||||
value: String(dashboardProps.dueSoonCount),
|
||||
description: t("stats.next7Days"),
|
||||
icon: PenTool,
|
||||
href: "/student/learning/assignments",
|
||||
color: dashboardProps.dueSoonCount > 0 ? "text-orange-500" : undefined,
|
||||
valueClassName:
|
||||
dashboardProps.dueSoonCount > 0 ? "text-orange-500 tabular-nums" : "tabular-nums",
|
||||
},
|
||||
{
|
||||
label: t("stats.overdue"),
|
||||
value: String(dashboardProps.overdueCount),
|
||||
description: t("stats.needsAttention"),
|
||||
icon: TriangleAlert,
|
||||
href: "/student/learning/assignments",
|
||||
color: dashboardProps.overdueCount > 0 ? "text-red-500" : undefined,
|
||||
valueClassName:
|
||||
dashboardProps.overdueCount > 0 ? "text-red-500 tabular-nums" : "tabular-nums",
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<header>
|
||||
<StudentDashboardHeader studentName={student.name} />
|
||||
</header>
|
||||
|
||||
<DashboardSection variant="stats" ariaLabel={t("sections.quickStats")}>
|
||||
<StudentStatsGrid
|
||||
enrolledClassCount={dashboardProps.enrolledClassCount}
|
||||
dueSoonCount={dashboardProps.dueSoonCount}
|
||||
overdueCount={dashboardProps.overdueCount}
|
||||
gradedCount={dashboardProps.gradedCount}
|
||||
ranking={dashboardProps.grades.ranking}
|
||||
/>
|
||||
</DashboardSection>
|
||||
|
||||
<DashboardShell title={title} description={description} stats={stats}>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<section
|
||||
aria-label={t("sections.upcomingAssignments")}
|
||||
@@ -87,6 +151,6 @@ async function StudentDashboardBody({
|
||||
</DashboardSection>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</DashboardShell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 />}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,17 +1,23 @@
|
||||
import { use } from "react"
|
||||
import type { ReactNode } from "react"
|
||||
import { getLocale, getTranslations } from "next-intl/server"
|
||||
import { BarChart, FileCheck, PenTool, TrendingUp } from "lucide-react"
|
||||
|
||||
import type { ActionState } from "@/shared/types/action-state"
|
||||
import type { StatItem } from "@/shared/components/ui/stats-grid"
|
||||
import type { TeacherDashboardData } from "@/modules/dashboard/types"
|
||||
import type { TeacherDashboardMetrics } from "@/modules/dashboard/lib/dashboard-utils"
|
||||
import type { ActionState } from "@/shared/types/action-state"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
import { getGreetingKey } from "@/modules/dashboard/lib/dashboard-utils"
|
||||
import { formatLongDate } from "@/shared/lib/utils"
|
||||
import type { TeacherTodoItem } from "./teacher-todo-card"
|
||||
|
||||
import { DashboardSection } from "../dashboard-section"
|
||||
import { DashboardShell } from "../dashboard-shell"
|
||||
import { TeacherClassesCard } from "./teacher-classes-card"
|
||||
import { TeacherDashboardHeader } from "./teacher-dashboard-header"
|
||||
import { TeacherHomeworkCard } from "./teacher-homework-card"
|
||||
import { RecentSubmissions } from "./recent-submissions"
|
||||
import { TeacherSchedule } from "./teacher-schedule"
|
||||
import { TeacherStats } from "./teacher-stats"
|
||||
import { TeacherQuickActions } from "./teacher-quick-actions"
|
||||
import { TeacherGradeTrends } from "./teacher-grade-trends"
|
||||
import { TeacherTodoCard } from "./teacher-todo-card"
|
||||
|
||||
@@ -32,10 +38,22 @@ export function TeacherDashboardView({ dataPromise }: { dataPromise: Promise<Tea
|
||||
return <TeacherDashboardContent data={result.data} />
|
||||
}
|
||||
|
||||
async function TeacherDashboardContent({ data }: { data: TeacherDashboardData & { metrics: TeacherDashboardMetrics } }) {
|
||||
async function TeacherDashboardContent({
|
||||
data,
|
||||
}: {
|
||||
data: TeacherDashboardData & { metrics: TeacherDashboardMetrics }
|
||||
}): Promise<ReactNode> {
|
||||
const t = await getTranslations("dashboard")
|
||||
const { metrics } = data
|
||||
|
||||
const locale = await getLocale()
|
||||
const today = formatLongDate(new Date(), locale)
|
||||
const greetingKey = getGreetingKey(new Date())
|
||||
const title = data.teacherName
|
||||
? `${t(`greeting.${greetingKey}`)},${data.teacherName}`
|
||||
: t(`greeting.${greetingKey}`)
|
||||
const description = t("greeting.todayIs", { date: today })
|
||||
|
||||
// 待办聚合(使用预计算指标)
|
||||
const todoItems: TeacherTodoItem[] = [
|
||||
{
|
||||
@@ -58,21 +76,53 @@ async function TeacherDashboardContent({ data }: { data: TeacherDashboardData &
|
||||
},
|
||||
]
|
||||
|
||||
const stats: StatItem[] = [
|
||||
{
|
||||
label: t("stats.needsGrading"),
|
||||
value: String(metrics.toGradeCount),
|
||||
description: t("stats.submissionsPendingReview"),
|
||||
icon: FileCheck,
|
||||
href: "/teacher/homework/submissions?status=submitted",
|
||||
highlight: metrics.toGradeCount > 0,
|
||||
color: "text-amber-500",
|
||||
valueClassName: "tabular-nums",
|
||||
},
|
||||
{
|
||||
label: t("stats.activeAssignments"),
|
||||
value: String(metrics.activeAssignmentsCount),
|
||||
description: t("stats.publishedAndOngoing"),
|
||||
icon: PenTool,
|
||||
href: "/teacher/homework/assignments?status=published",
|
||||
color: "text-blue-500",
|
||||
valueClassName: "tabular-nums",
|
||||
},
|
||||
{
|
||||
label: t("stats.averageScore"),
|
||||
value: `${Math.round(metrics.averageScore)}%`,
|
||||
description: t("stats.acrossRecentAssignments"),
|
||||
icon: TrendingUp,
|
||||
href: "#grade-trends",
|
||||
color: "text-emerald-500",
|
||||
valueClassName: "tabular-nums",
|
||||
},
|
||||
{
|
||||
label: t("stats.submissionRate"),
|
||||
value: `${Math.round(metrics.submissionRate)}%`,
|
||||
description: t("stats.overallCompletionRate"),
|
||||
icon: BarChart,
|
||||
href: "#grade-trends",
|
||||
color: "text-purple-500",
|
||||
valueClassName: "tabular-nums",
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-6 p-8">
|
||||
<header>
|
||||
<TeacherDashboardHeader teacherName={data.teacherName} />
|
||||
</header>
|
||||
|
||||
<DashboardSection variant="stats" ariaLabel={t("sections.quickStats")}>
|
||||
<TeacherStats
|
||||
toGradeCount={metrics.toGradeCount}
|
||||
activeAssignmentsCount={metrics.activeAssignmentsCount}
|
||||
averageScore={metrics.averageScore}
|
||||
submissionRate={metrics.submissionRate}
|
||||
/>
|
||||
</DashboardSection>
|
||||
|
||||
<DashboardShell
|
||||
title={title}
|
||||
description={description}
|
||||
stats={stats}
|
||||
actions={<TeacherQuickActions />}
|
||||
>
|
||||
<div className="flex flex-col gap-6 lg:grid lg:grid-cols-12">
|
||||
{/* 课表:移动端首位,桌面端右上 — 仅渲染一次(P2-9 修复,原为双实例) */}
|
||||
<div className="order-1 lg:col-start-9 lg:col-span-4 lg:row-start-1">
|
||||
@@ -81,7 +131,10 @@ async function TeacherDashboardContent({ data }: { data: TeacherDashboardData &
|
||||
</DashboardSection>
|
||||
</div>
|
||||
|
||||
<section aria-label={t("sections.pendingGrading")} className="flex flex-col gap-6 order-2 lg:col-start-1 lg:col-span-8 lg:row-start-1 lg:row-span-2">
|
||||
<section
|
||||
aria-label={t("sections.pendingGrading")}
|
||||
className="flex flex-col gap-6 order-2 lg:col-start-1 lg:col-span-8 lg:row-start-1 lg:row-span-2"
|
||||
>
|
||||
<DashboardSection variant="card" ariaLabel={t("todo.title")}>
|
||||
<TeacherTodoCard items={todoItems} />
|
||||
</DashboardSection>
|
||||
@@ -98,7 +151,10 @@ async function TeacherDashboardContent({ data }: { data: TeacherDashboardData &
|
||||
</DashboardSection>
|
||||
</section>
|
||||
|
||||
<aside aria-label={t("sections.myClasses")} className="flex flex-col gap-6 order-3 lg:col-start-9 lg:col-span-4 lg:row-start-2">
|
||||
<aside
|
||||
aria-label={t("sections.myClasses")}
|
||||
className="flex flex-col gap-6 order-3 lg:col-start-9 lg:col-span-4 lg:row-start-2"
|
||||
>
|
||||
<DashboardSection variant="list" ariaLabel={t("sections.homework")}>
|
||||
<TeacherHomeworkCard assignments={data.assignments} />
|
||||
</DashboardSection>
|
||||
@@ -107,6 +163,6 @@ async function TeacherDashboardContent({ data }: { data: TeacherDashboardData &
|
||||
</DashboardSection>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</DashboardShell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import { Tag, Eye, Pencil } from "lucide-react";
|
||||
import type { BlackboardBlockData } from "../../types";
|
||||
import { isBlackboardLayout } from "../../lib/type-guards";
|
||||
import { KnowledgePointPicker } from "../knowledge-point-picker";
|
||||
import { LessonPlanErrorBoundary } from "../lesson-plan-error-boundary";
|
||||
import { SectionErrorBoundary } from "@/shared/components/section-error-boundary";
|
||||
|
||||
interface Props {
|
||||
data: BlackboardBlockData;
|
||||
@@ -125,7 +125,7 @@ export function BlackboardBlock({ data, textbookId, chapterId, onUpdate }: Props
|
||||
</button>
|
||||
</div>
|
||||
{showKpPicker && (
|
||||
<LessonPlanErrorBoundary>
|
||||
<SectionErrorBoundary namespace="lessonPreparation">
|
||||
<KnowledgePointPicker
|
||||
textbookId={textbookId}
|
||||
chapterId={chapterId}
|
||||
@@ -133,7 +133,7 @@ export function BlackboardBlock({ data, textbookId, chapterId, onUpdate }: Props
|
||||
onChange={(ids) => onUpdate({ ...data, knowledgePointIds: ids })}
|
||||
onClose={() => setShowKpPicker(false)}
|
||||
/>
|
||||
</LessonPlanErrorBoundary>
|
||||
</SectionErrorBoundary>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -8,7 +8,7 @@ import { useLessonPlanEditor } from "../../hooks/use-lesson-plan-editor";
|
||||
import { QuestionBankPicker } from "../question-bank-picker";
|
||||
import { InlineQuestionEditor } from "../inline-question-editor";
|
||||
import { PublishHomeworkDialog } from "../publish-homework-dialog";
|
||||
import { LessonPlanErrorBoundary } from "../lesson-plan-error-boundary";
|
||||
import { SectionErrorBoundary } from "@/shared/components/section-error-boundary";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import type {
|
||||
@@ -145,16 +145,16 @@ export function ExerciseBlock({ blockId, data, classes, textbookId, chapterId }:
|
||||
)}
|
||||
</div>
|
||||
{showBank && (
|
||||
<LessonPlanErrorBoundary>
|
||||
<SectionErrorBoundary namespace="lessonPreparation">
|
||||
<QuestionBankPicker
|
||||
existingIds={data.items.map((i) => i.questionId)}
|
||||
onPick={addItems}
|
||||
onClose={() => setShowBank(false)}
|
||||
/>
|
||||
</LessonPlanErrorBoundary>
|
||||
</SectionErrorBoundary>
|
||||
)}
|
||||
{showInline && (
|
||||
<LessonPlanErrorBoundary>
|
||||
<SectionErrorBoundary namespace="lessonPreparation">
|
||||
<InlineQuestionEditor
|
||||
textbookId={textbookId}
|
||||
chapterId={chapterId}
|
||||
@@ -164,10 +164,10 @@ export function ExerciseBlock({ blockId, data, classes, textbookId, chapterId }:
|
||||
}}
|
||||
onClose={() => setShowInline(false)}
|
||||
/>
|
||||
</LessonPlanErrorBoundary>
|
||||
</SectionErrorBoundary>
|
||||
)}
|
||||
{showPublish && (
|
||||
<LessonPlanErrorBoundary>
|
||||
<SectionErrorBoundary namespace="lessonPreparation">
|
||||
<PublishHomeworkDialog
|
||||
planId={planId}
|
||||
blockId={blockId}
|
||||
@@ -176,7 +176,7 @@ export function ExerciseBlock({ blockId, data, classes, textbookId, chapterId }:
|
||||
onClose={() => setShowPublish(false)}
|
||||
onPublished={() => router.refresh()}
|
||||
/>
|
||||
</LessonPlanErrorBoundary>
|
||||
</SectionErrorBoundary>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Plus, Trash2, Tag } from "lucide-react";
|
||||
import type { NewTeachingBlockData, NewTeachingPoint } from "../../types";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import { KnowledgePointPicker } from "../knowledge-point-picker";
|
||||
import { LessonPlanErrorBoundary } from "../lesson-plan-error-boundary";
|
||||
import { SectionErrorBoundary } from "@/shared/components/section-error-boundary";
|
||||
|
||||
interface Props {
|
||||
data: NewTeachingBlockData;
|
||||
@@ -108,7 +108,7 @@ export function NewTeachingBlock({ data, textbookId, chapterId, onUpdate }: Prop
|
||||
{t("newTeaching.addPoint")}
|
||||
</Button>
|
||||
{pickerFor !== null && (
|
||||
<LessonPlanErrorBoundary>
|
||||
<SectionErrorBoundary namespace="lessonPreparation">
|
||||
<KnowledgePointPicker
|
||||
textbookId={textbookId}
|
||||
chapterId={chapterId}
|
||||
@@ -116,7 +116,7 @@ export function NewTeachingBlock({ data, textbookId, chapterId, onUpdate }: Prop
|
||||
onChange={(ids) => updatePoint(pickerFor, { knowledgePointIds: ids })}
|
||||
onClose={() => setPickerFor(null)}
|
||||
/>
|
||||
</LessonPlanErrorBoundary>
|
||||
</SectionErrorBoundary>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -9,7 +9,7 @@ import { useTranslations } from "next-intl";
|
||||
import type { RichTextBlockData, RichTextAttachment } from "../../types";
|
||||
import { KnowledgePointPicker } from "../knowledge-point-picker";
|
||||
import { AttachmentPicker } from "../attachment-picker";
|
||||
import { LessonPlanErrorBoundary } from "../lesson-plan-error-boundary";
|
||||
import { SectionErrorBoundary } from "@/shared/components/section-error-boundary";
|
||||
import { Tag, Paperclip, Trash2 } from "lucide-react";
|
||||
|
||||
export interface RichTextBlockProps {
|
||||
@@ -187,7 +187,7 @@ export function RichTextBlockInner({
|
||||
)}
|
||||
|
||||
{showKpPicker && (
|
||||
<LessonPlanErrorBoundary>
|
||||
<SectionErrorBoundary namespace="lessonPreparation">
|
||||
<KnowledgePointPicker
|
||||
textbookId={textbookId}
|
||||
chapterId={chapterId}
|
||||
@@ -195,11 +195,11 @@ export function RichTextBlockInner({
|
||||
onChange={(ids) => onUpdate({ ...data, knowledgePointIds: ids })}
|
||||
onClose={() => setShowKpPicker(false)}
|
||||
/>
|
||||
</LessonPlanErrorBoundary>
|
||||
</SectionErrorBoundary>
|
||||
)}
|
||||
{/* V5-5:素材库 picker */}
|
||||
{showAttachmentPicker && planId && (
|
||||
<LessonPlanErrorBoundary>
|
||||
<SectionErrorBoundary namespace="lessonPreparation">
|
||||
<AttachmentPicker
|
||||
planId={planId}
|
||||
blockId={blockId}
|
||||
@@ -207,7 +207,7 @@ export function RichTextBlockInner({
|
||||
onSelect={handleSelectAttachment}
|
||||
onClose={() => setShowAttachmentPicker(false)}
|
||||
/>
|
||||
</LessonPlanErrorBoundary>
|
||||
</SectionErrorBoundary>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -9,7 +9,7 @@ import { useTranslations } from "next-intl";
|
||||
import type { RichTextBlockData, RichTextAttachment } from "../../types";
|
||||
import { KnowledgePointPicker } from "../knowledge-point-picker";
|
||||
import { AttachmentPicker } from "../attachment-picker";
|
||||
import { LessonPlanErrorBoundary } from "../lesson-plan-error-boundary";
|
||||
import { SectionErrorBoundary } from "@/shared/components/section-error-boundary";
|
||||
import { Tag, Paperclip, Trash2 } from "lucide-react";
|
||||
|
||||
interface Props {
|
||||
@@ -187,7 +187,7 @@ export function RichTextBlock({
|
||||
)}
|
||||
|
||||
{showKpPicker && (
|
||||
<LessonPlanErrorBoundary>
|
||||
<SectionErrorBoundary namespace="lessonPreparation">
|
||||
<KnowledgePointPicker
|
||||
textbookId={textbookId}
|
||||
chapterId={chapterId}
|
||||
@@ -195,11 +195,11 @@ export function RichTextBlock({
|
||||
onChange={(ids) => onUpdate({ ...data, knowledgePointIds: ids })}
|
||||
onClose={() => setShowKpPicker(false)}
|
||||
/>
|
||||
</LessonPlanErrorBoundary>
|
||||
</SectionErrorBoundary>
|
||||
)}
|
||||
{/* V5-5:素材库 picker */}
|
||||
{showAttachmentPicker && planId && (
|
||||
<LessonPlanErrorBoundary>
|
||||
<SectionErrorBoundary namespace="lessonPreparation">
|
||||
<AttachmentPicker
|
||||
planId={planId}
|
||||
blockId={blockId}
|
||||
@@ -207,7 +207,7 @@ export function RichTextBlock({
|
||||
onSelect={handleSelectAttachment}
|
||||
onClose={() => setShowAttachmentPicker(false)}
|
||||
/>
|
||||
</LessonPlanErrorBoundary>
|
||||
</SectionErrorBoundary>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -8,7 +8,7 @@ import { Button } from "@/shared/components/ui/button";
|
||||
import { FocusTrap } from "@/shared/components/a11y/focus-trap";
|
||||
import { X, Tag } from "lucide-react";
|
||||
import { KnowledgePointPicker } from "./knowledge-point-picker";
|
||||
import { LessonPlanErrorBoundary } from "./lesson-plan-error-boundary";
|
||||
import { SectionErrorBoundary } from "@/shared/components/section-error-boundary";
|
||||
import type { ExerciseItem, InlineQuestionContent } from "../types";
|
||||
import { VALID_QUESTION_TYPES } from "../lib/type-guards";
|
||||
|
||||
@@ -236,7 +236,7 @@ export function InlineQuestionEditor({ onAdd, onClose, textbookId, chapterId }:
|
||||
</FocusTrap>
|
||||
</div>
|
||||
{showKpPicker && (
|
||||
<LessonPlanErrorBoundary>
|
||||
<SectionErrorBoundary namespace="lessonPreparation">
|
||||
<KnowledgePointPicker
|
||||
textbookId={textbookId}
|
||||
chapterId={chapterId}
|
||||
@@ -244,7 +244,7 @@ export function InlineQuestionEditor({ onAdd, onClose, textbookId, chapterId }:
|
||||
onChange={setKpIds}
|
||||
onClose={() => setShowKpPicker(false)}
|
||||
/>
|
||||
</LessonPlanErrorBoundary>
|
||||
</SectionErrorBoundary>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useEffect, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import { FocusTrap } from "@/shared/components/a11y/focus-trap";
|
||||
import { KnowledgePointSkeleton } from "./lesson-plan-skeleton";
|
||||
import { SkeletonCard } from "@/shared/components/ui/skeleton";
|
||||
import { X } from "lucide-react";
|
||||
import { useLessonPlanContextSafe } from "../providers/lesson-plan-provider";
|
||||
import type { KnowledgePointOption } from "../providers/lesson-plan-provider";
|
||||
@@ -92,7 +92,7 @@ export function KnowledgePointPicker({
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
{loading ? (
|
||||
<KnowledgePointSkeleton />
|
||||
<SkeletonCard variant="list" rows={8} />
|
||||
) : error ? (
|
||||
<p className="text-error text-sm">{error}</p>
|
||||
) : options.length === 0 ? (
|
||||
|
||||
@@ -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/A2:AI 反馈对话框 */}
|
||||
{showAiFeedback && (
|
||||
<SectionErrorBoundary namespace="lessonPreparation">
|
||||
<AiFeedbackDialog doc={doc} onClose={onCloseAiFeedback} />
|
||||
</SectionErrorBoundary>
|
||||
)}
|
||||
|
||||
{/* V5-21 A3/A4/A5:AI 差异化与课标核对对话框 */}
|
||||
{showAiDifferentiation && (
|
||||
<SectionErrorBoundary namespace="lessonPreparation">
|
||||
<AiDifferentiationDialog
|
||||
doc={doc}
|
||||
textbookId={textbookId}
|
||||
onClose={onCloseAiDifferentiation}
|
||||
/>
|
||||
</SectionErrorBoundary>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,44 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { JSX } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { toast } from "sonner";
|
||||
import { useLessonPlanEditor } from "../hooks/use-lesson-plan-editor";
|
||||
import { useLessonPlanPersistence } from "../hooks/use-lesson-plan-persistence";
|
||||
import { StructureTree } from "./structure-tree/structure-tree";
|
||||
import { PaperEditor } from "./paper-editor/paper-editor";
|
||||
import { DetailPanel } from "./detail-panel/detail-panel";
|
||||
import { AnchorMigrationBanner } from "./anchor-migration-banner";
|
||||
import { VersionHistoryDrawer } from "./version-history-drawer";
|
||||
import { LessonPlanErrorBoundary } from "./lesson-plan-error-boundary";
|
||||
import { LessonPlanToolbar } from "./lesson-plan-toolbar";
|
||||
import { LessonPlanDialogs } from "./lesson-plan-dialogs";
|
||||
import { SectionErrorBoundary } from "@/shared/components/section-error-boundary";
|
||||
import {
|
||||
useLessonPlanContextSafe,
|
||||
useLessonPlanTrackerSafe,
|
||||
} from "../providers/lesson-plan-provider";
|
||||
import type { LessonPlanStatus } from "../types";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/shared/components/ui/alert-dialog";
|
||||
import { Save, History, Book, FileText, Send, Undo2, RotateCw, WifiOff, Printer, Undo, Redo, CalendarClock, ClipboardCheck, Sparkles, Layers } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { PrintView } from "./print-view";
|
||||
import { ScheduleDialog } from "./schedule-dialog";
|
||||
import { ConsistencyCheckDialog } from "./consistency-check-dialog";
|
||||
import { AiFeedbackDialog } from "./ai-feedback-dialog";
|
||||
import { AiDifferentiationDialog } from "./ai-differentiation-dialog";
|
||||
import type { LessonPlan } from "../types";
|
||||
import type { LessonPlan, LessonPlanDocument, LessonPlanStatus } from "../types";
|
||||
|
||||
interface Props {
|
||||
planId: string;
|
||||
initialTitle: string;
|
||||
initialDoc: import("../types").LessonPlanDocument;
|
||||
initialDoc: LessonPlanDocument;
|
||||
initialStatus?: LessonPlanStatus;
|
||||
textbookId?: string;
|
||||
chapterId?: string;
|
||||
@@ -57,9 +41,9 @@ export function LessonPlanEditor({
|
||||
textbookTitle,
|
||||
chapterTitle,
|
||||
classes,
|
||||
}: Props) {
|
||||
}: Props): JSX.Element {
|
||||
const t = useTranslations("lessonPreparation");
|
||||
// Phase 4.2: 拆分为细粒度 selector,避免任一字段变化都触发整体 re-render
|
||||
// Phase 4.2:拆分为细粒度 selector,避免任一字段变化都触发整体 re-render
|
||||
const isDirty = useLessonPlanEditor((s) => s.isDirty);
|
||||
const isOnline = useLessonPlanEditor((s) => s.isOnline);
|
||||
const doc = useLessonPlanEditor((s) => s.doc);
|
||||
@@ -68,26 +52,28 @@ export function LessonPlanEditor({
|
||||
const saveError = useLessonPlanEditor((s) => s.saveError);
|
||||
const lastSavedAt = useLessonPlanEditor((s) => s.lastSavedAt);
|
||||
const setTitle = useLessonPlanEditor((s) => s.setTitle);
|
||||
// 订阅派生 boolean,仅在结果变化时触发 re-render(rerender-derived-state)
|
||||
// 订阅派生 boolean,仅在结果变化时触发 re-render
|
||||
const canUndo = useLessonPlanEditor((s) => s.canUndo());
|
||||
const canRedo = useLessonPlanEditor((s) => s.canRedo());
|
||||
const tracker = useLessonPlanTrackerSafe();
|
||||
const ctx = useLessonPlanContextSafe();
|
||||
const service = ctx?.service ?? null;
|
||||
const [showVersions, setShowVersions] = useState(false);
|
||||
const [showPrint, setShowPrint] = useState(false); // V5-4:打印视图
|
||||
const [showSchedule, setShowSchedule] = useState(false); // V5-7:安排课时对话框
|
||||
const [showConsistency, setShowConsistency] = useState(false); // V5-19:一致性校验对话框
|
||||
const [showAiFeedback, setShowAiFeedback] = useState(false); // V5-17:AI 反馈对话框
|
||||
const [showAiDifferentiation, setShowAiDifferentiation] = useState(false); // V5-21:AI 差异化对话框
|
||||
const [showPrint, setShowPrint] = useState(false);
|
||||
const [showSchedule, setShowSchedule] = useState(false);
|
||||
const [showConsistency, setShowConsistency] = useState(false);
|
||||
const [showAiFeedback, setShowAiFeedback] = useState(false);
|
||||
const [showAiDifferentiation, setShowAiDifferentiation] = useState(false);
|
||||
const [planStatus, setPlanStatus] = useState<LessonPlanStatus>(initialStatus);
|
||||
const [publishing, setPublishing] = useState(false);
|
||||
const autoSaveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const versionTimer = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
const { handleRetrySave, handleManualSave, handleReverted } = useLessonPlanPersistence({
|
||||
planId,
|
||||
service,
|
||||
tracker,
|
||||
});
|
||||
|
||||
// 初始化:仅在 planId 变化时 hydrate(修复 P1-3)
|
||||
// hydrate 是 zustand store action,引用稳定
|
||||
// initialTitle/initialDoc 通过 ref 保存,避免作为 effect 依赖导致重复 hydrate
|
||||
const hydrate = useLessonPlanEditor((s) => s.hydrate);
|
||||
const initialRef = useRef({ initialTitle, initialDoc });
|
||||
useEffect(() => {
|
||||
@@ -95,44 +81,6 @@ export function LessonPlanEditor({
|
||||
hydrate(planId, t0, d0);
|
||||
}, [planId, hydrate]);
|
||||
|
||||
// 自动保存(debounce 3s)- 用 getState() 获取最新值(修复 P1-4)
|
||||
// V3 修复:完全通过 service 调用,不直接 import actions
|
||||
// V5-1 修复:保存失败显示 toast + 设置 saveError;断网时不触发保存
|
||||
useEffect(() => {
|
||||
if (!isDirty) return;
|
||||
if (!service) return;
|
||||
if (!isOnline) return; // V5-1:断网期间不触发保存请求
|
||||
if (autoSaveTimer.current) clearTimeout(autoSaveTimer.current);
|
||||
autoSaveTimer.current = setTimeout(async () => {
|
||||
const state = useLessonPlanEditor.getState();
|
||||
state.setSaving(true);
|
||||
try {
|
||||
const res = await service.updateLessonPlan({
|
||||
planId: state.planId,
|
||||
title: state.title,
|
||||
content: state.doc,
|
||||
});
|
||||
if (res.success) {
|
||||
// V5-1:从失败恢复到成功时提示
|
||||
if (state.saveError) toast.success(t("status.recovered"));
|
||||
state.markSaved();
|
||||
} else {
|
||||
state.setSaveError(true);
|
||||
toast.error(t("status.saveFailed"), { description: t("status.saveFailedHint") });
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("[LessonPlanEditor] auto-save failed", e);
|
||||
state.setSaveError(true);
|
||||
toast.error(t("status.saveFailed"), { description: t("status.saveFailedHint") });
|
||||
} finally {
|
||||
state.setSaving(false);
|
||||
}
|
||||
}, 3000);
|
||||
return () => {
|
||||
if (autoSaveTimer.current) clearTimeout(autoSaveTimer.current);
|
||||
};
|
||||
}, [isDirty, doc, planId, service, isOnline, t]);
|
||||
|
||||
// V5-1:监听网络在线/离线状态
|
||||
useEffect(() => {
|
||||
function handleOnline() {
|
||||
@@ -151,31 +99,6 @@ export function LessonPlanEditor({
|
||||
};
|
||||
}, [t]);
|
||||
|
||||
// V5-1:手动重试保存(saveError 时显示按钮)
|
||||
const handleRetrySave = useCallback(async () => {
|
||||
if (!service) return;
|
||||
const state = useLessonPlanEditor.getState();
|
||||
state.setSaving(true);
|
||||
try {
|
||||
const res = await service.updateLessonPlan({
|
||||
planId: state.planId,
|
||||
title: state.title,
|
||||
content: state.doc,
|
||||
});
|
||||
if (res.success) {
|
||||
toast.success(t("status.recovered"));
|
||||
state.markSaved();
|
||||
} else {
|
||||
toast.error(t("status.saveFailed"));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("[LessonPlanEditor] retry save failed", e);
|
||||
toast.error(t("status.saveFailed"));
|
||||
} finally {
|
||||
state.setSaving(false);
|
||||
}
|
||||
}, [service, t]);
|
||||
|
||||
// V5-2:撤销/重做快捷键(Cmd/Ctrl+Z, Cmd/Ctrl+Shift+Z 或 Cmd/Ctrl+Y)
|
||||
useEffect(() => {
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
@@ -196,11 +119,10 @@ export function LessonPlanEditor({
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, []);
|
||||
|
||||
// V5-2:撤销/重做按钮处理(canUndo/canRedo 已通过 selector 订阅)
|
||||
const handleUndo = useCallback(() => useLessonPlanEditor.getState().undo(), []);
|
||||
const handleRedo = useCallback(() => useLessonPlanEditor.getState().redo(), []);
|
||||
|
||||
// V5-4:构造打印用的 LessonPlan 对象(编辑器仅持有 planId/title/doc,其余字段填默认值)
|
||||
// V5-4:构造打印用的 LessonPlan 对象
|
||||
const printablePlan: LessonPlan = useMemo(() => {
|
||||
return {
|
||||
id: planId,
|
||||
@@ -221,27 +143,6 @@ export function LessonPlanEditor({
|
||||
};
|
||||
}, [planId, title, doc, lastSavedAt, textbookId, chapterId, planStatus]);
|
||||
|
||||
// 定时自动版本(30min)
|
||||
useEffect(() => {
|
||||
if (!service) return;
|
||||
versionTimer.current = setInterval(async () => {
|
||||
const state = useLessonPlanEditor.getState();
|
||||
if (!state.isDirty) return;
|
||||
try {
|
||||
await service.saveLessonPlanVersion({
|
||||
planId: state.planId,
|
||||
content: state.doc,
|
||||
label: t("version.autoLabel"),
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("[LessonPlanEditor] auto-version failed", e);
|
||||
}
|
||||
}, 30 * 60 * 1000);
|
||||
return () => {
|
||||
if (versionTimer.current) clearInterval(versionTimer.current);
|
||||
};
|
||||
}, [planId, t, service]);
|
||||
|
||||
// 离开未保存提示(P3-1)
|
||||
useEffect(() => {
|
||||
function handleBeforeUnload(e: BeforeUnloadEvent) {
|
||||
@@ -254,40 +155,6 @@ export function LessonPlanEditor({
|
||||
return () => window.removeEventListener("beforeunload", handleBeforeUnload);
|
||||
}, []);
|
||||
|
||||
const handleManualSave = useCallback(async () => {
|
||||
if (!service) return;
|
||||
const state = useLessonPlanEditor.getState();
|
||||
state.setSaving(true);
|
||||
try {
|
||||
const res = await service.saveLessonPlanVersion({
|
||||
planId: state.planId,
|
||||
content: state.doc,
|
||||
});
|
||||
if (res.success) {
|
||||
state.markSaved();
|
||||
tracker.track("lesson_plan.save", { planId: state.planId, source: "manual" });
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("[LessonPlanEditor] manual save failed", e);
|
||||
} finally {
|
||||
state.setSaving(false);
|
||||
}
|
||||
}, [tracker, service]);
|
||||
|
||||
// 版本回退后刷新内容(修复 P1-1)
|
||||
const handleReverted = useCallback(async () => {
|
||||
if (!service) return;
|
||||
const state = useLessonPlanEditor.getState();
|
||||
try {
|
||||
const res = await service.getLessonPlanById(state.planId);
|
||||
if (res.success && res.data?.plan) {
|
||||
state.hydrate(state.planId, res.data.plan.title, res.data.plan.content);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("[LessonPlanEditor] reload after revert failed", e);
|
||||
}
|
||||
}, [service]);
|
||||
|
||||
// 发布课案(P0-1 修复)
|
||||
const handlePublish = useCallback(async () => {
|
||||
if (!service) return;
|
||||
@@ -332,186 +199,39 @@ export function LessonPlanEditor({
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* 顶部工具栏 */}
|
||||
<div className="flex items-center gap-2 px-4 py-2 border-b border-outline-variant bg-surface">
|
||||
<input
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
className="flex-1 bg-transparent font-headline-md text-headline-md focus:outline-none"
|
||||
<LessonPlanToolbar
|
||||
title={title}
|
||||
onTitleChange={setTitle}
|
||||
textbookTitle={textbookTitle}
|
||||
chapterTitle={chapterTitle}
|
||||
isSaving={isSaving}
|
||||
isDirty={isDirty}
|
||||
saveError={saveError}
|
||||
isOnline={isOnline}
|
||||
canUndo={canUndo}
|
||||
canRedo={canRedo}
|
||||
onRetrySave={handleRetrySave}
|
||||
onUndo={handleUndo}
|
||||
onRedo={handleRedo}
|
||||
onManualSave={handleManualSave}
|
||||
onShowVersions={() => setShowVersions(true)}
|
||||
onShowPrint={() => setShowPrint(true)}
|
||||
onShowSchedule={() => setShowSchedule(true)}
|
||||
onShowConsistency={() => setShowConsistency(true)}
|
||||
onShowAiFeedback={() => setShowAiFeedback(true)}
|
||||
onShowAiDifferentiation={() => setShowAiDifferentiation(true)}
|
||||
planStatus={planStatus}
|
||||
publishing={publishing}
|
||||
onPublish={handlePublish}
|
||||
onUnpublish={handleUnpublish}
|
||||
showScheduleButton={!!classes && classes.length > 0}
|
||||
/>
|
||||
{/* 教材/章节指示器 */}
|
||||
{textbookTitle && (
|
||||
<div className="flex items-center gap-1 text-xs text-on-surface-variant px-2 py-1 rounded bg-surface-container-high">
|
||||
<Book className="w-3 h-3" />
|
||||
{/* arbitrary-value: layout fixed size */}
|
||||
<span className="max-w-[120px] truncate">{textbookTitle}</span>
|
||||
{chapterTitle && (
|
||||
<>
|
||||
<span className="text-on-surface-variant/50">/</span>
|
||||
<FileText className="w-3 h-3" />
|
||||
{/* arbitrary-value: layout fixed size */}
|
||||
<span className="max-w-[120px] truncate">{chapterTitle}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<span className="text-on-surface-variant text-sm">
|
||||
{isSaving
|
||||
? t("status.saving")
|
||||
: isDirty
|
||||
? t("status.unsaved")
|
||||
: t("status.saved")}
|
||||
</span>
|
||||
{/* V5-1:保存失败/离线提示与重试按钮 */}
|
||||
{saveError && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleRetrySave}
|
||||
disabled={isSaving}
|
||||
className="text-error border-error"
|
||||
>
|
||||
<RotateCw className="w-3 h-3 mr-1" />
|
||||
{t("status.retrySave")}
|
||||
</Button>
|
||||
)}
|
||||
{!isOnline && (
|
||||
<span className="text-xs text-error inline-flex items-center gap-1">
|
||||
<WifiOff className="w-3 h-3" />
|
||||
{t("status.offlineBadge")}
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowVersions(true)}
|
||||
>
|
||||
<History className="w-4 h-4 mr-1" /> {t("action.versions")}
|
||||
</Button>
|
||||
{/* V5-2:撤销/重做按钮 */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleUndo}
|
||||
disabled={!canUndo}
|
||||
aria-label={t("action.undo")}
|
||||
title={t("action.undoShortcut")}
|
||||
>
|
||||
<Undo className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleRedo}
|
||||
disabled={!canRedo}
|
||||
aria-label={t("action.redo")}
|
||||
title={t("action.redoShortcut")}
|
||||
>
|
||||
<Redo className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleManualSave} disabled={isSaving}>
|
||||
<Save className="w-4 h-4 mr-1" /> {t("action.saveVersion")}
|
||||
</Button>
|
||||
{/* V5-4:导出/打印按钮 */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowPrint(true)}
|
||||
aria-label={t("export.title")}
|
||||
>
|
||||
<Printer className="w-4 h-4 mr-1" /> {t("export.button")}
|
||||
</Button>
|
||||
{/* V5-7:安排课时按钮 */}
|
||||
{classes && classes.length > 0 && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowSchedule(true)}
|
||||
aria-label={t("action.scheduleLesson")}
|
||||
>
|
||||
<CalendarClock className="w-4 h-4 mr-1" /> {t("action.scheduleLesson")}
|
||||
</Button>
|
||||
)}
|
||||
{/* V5-19 T3:一致性校验按钮 */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowConsistency(true)}
|
||||
aria-label={t("editor.consistencyOpen")}
|
||||
>
|
||||
<ClipboardCheck className="w-4 h-4 mr-1" /> {t("editor.consistencyOpen")}
|
||||
</Button>
|
||||
{/* V5-17 A1/A2:AI 反馈按钮 */}
|
||||
<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/A5:AI 差异化与课标核对按钮 */}
|
||||
<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>
|
||||
|
||||
{/* V4:v3 锚点失效提示 banner */}
|
||||
<AnchorMigrationBanner />
|
||||
|
||||
{/* V4:三栏布局(结构树 + 纸区 + 详情面板) */}
|
||||
<LessonPlanErrorBoundary>
|
||||
<SectionErrorBoundary namespace="lessonPreparation">
|
||||
<main
|
||||
style={{
|
||||
display: "grid",
|
||||
@@ -524,71 +244,30 @@ export function LessonPlanEditor({
|
||||
<PaperEditor />
|
||||
<DetailPanel />
|
||||
</main>
|
||||
</LessonPlanErrorBoundary>
|
||||
</SectionErrorBoundary>
|
||||
|
||||
<LessonPlanErrorBoundary>
|
||||
<VersionHistoryDrawer
|
||||
open={showVersions}
|
||||
onClose={() => setShowVersions(false)}
|
||||
<LessonPlanDialogs
|
||||
planId={planId}
|
||||
onReverted={handleReverted}
|
||||
currentDoc={doc}
|
||||
/>
|
||||
</LessonPlanErrorBoundary>
|
||||
|
||||
{/* V5-4:导出/打印视图 */}
|
||||
{showPrint && (
|
||||
<LessonPlanErrorBoundary>
|
||||
<PrintView
|
||||
plan={printablePlan}
|
||||
doc={doc}
|
||||
printablePlan={printablePlan}
|
||||
textbookId={textbookId}
|
||||
textbookTitle={textbookTitle}
|
||||
chapterTitle={chapterTitle}
|
||||
onClose={() => setShowPrint(false)}
|
||||
/>
|
||||
</LessonPlanErrorBoundary>
|
||||
)}
|
||||
|
||||
{/* V5-7:安排课时对话框 */}
|
||||
{showSchedule && classes && classes.length > 0 && (
|
||||
<LessonPlanErrorBoundary>
|
||||
<ScheduleDialog
|
||||
planId={planId}
|
||||
classes={classes}
|
||||
onClose={() => setShowSchedule(false)}
|
||||
showVersions={showVersions}
|
||||
onCloseVersions={() => setShowVersions(false)}
|
||||
onReverted={handleReverted}
|
||||
showPrint={showPrint}
|
||||
onClosePrint={() => setShowPrint(false)}
|
||||
showSchedule={showSchedule}
|
||||
onCloseSchedule={() => setShowSchedule(false)}
|
||||
showConsistency={showConsistency}
|
||||
onCloseConsistency={() => setShowConsistency(false)}
|
||||
showAiFeedback={showAiFeedback}
|
||||
onCloseAiFeedback={() => setShowAiFeedback(false)}
|
||||
showAiDifferentiation={showAiDifferentiation}
|
||||
onCloseAiDifferentiation={() => setShowAiDifferentiation(false)}
|
||||
/>
|
||||
</LessonPlanErrorBoundary>
|
||||
)}
|
||||
|
||||
{/* V5-19 T3:一致性校验对话框 */}
|
||||
{showConsistency && (
|
||||
<LessonPlanErrorBoundary>
|
||||
<ConsistencyCheckDialog
|
||||
doc={doc}
|
||||
onClose={() => setShowConsistency(false)}
|
||||
/>
|
||||
</LessonPlanErrorBoundary>
|
||||
)}
|
||||
|
||||
{/* V5-17 A1/A2:AI 反馈对话框 */}
|
||||
{showAiFeedback && (
|
||||
<LessonPlanErrorBoundary>
|
||||
<AiFeedbackDialog
|
||||
doc={doc}
|
||||
onClose={() => setShowAiFeedback(false)}
|
||||
/>
|
||||
</LessonPlanErrorBoundary>
|
||||
)}
|
||||
|
||||
{/* V5-21 A3/A4/A5:AI 差异化与课标核对对话框 */}
|
||||
{showAiDifferentiation && (
|
||||
<LessonPlanErrorBoundary>
|
||||
<AiDifferentiationDialog
|
||||
doc={doc}
|
||||
textbookId={textbookId}
|
||||
onClose={() => setShowAiDifferentiation(false)}
|
||||
/>
|
||||
</LessonPlanErrorBoundary>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { JSX } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { LessonPlanCard } from "./lesson-plan-card";
|
||||
import { LessonPlanFilters } from "./lesson-plan-filters";
|
||||
import { FilterBar, FilterSearchInput } from "@/shared/components/ui/filter-bar";
|
||||
import { useDebounce } from "@/shared/hooks/use-debounce";
|
||||
import { useLessonPlanContextSafe } from "../providers/lesson-plan-provider";
|
||||
import type { LessonPlanListItem } from "../types";
|
||||
|
||||
@@ -20,17 +22,22 @@ interface Props {
|
||||
viewMode?: "teacher" | "student" | "parent" | "admin" | "gradeHead";
|
||||
}
|
||||
|
||||
export function LessonPlanList({ initialItems, subjects, viewMode = "teacher" }: Props) {
|
||||
export function LessonPlanList({ initialItems, subjects, viewMode = "teacher" }: Props): JSX.Element {
|
||||
const t = useTranslations("lessonPreparation");
|
||||
const [items, setItems] = useState(initialItems);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// P2 修复:增加筛选加载状态,避免用户重复点击
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
// 筛选状态
|
||||
const [query, setQuery] = useState("");
|
||||
const [subjectId, setSubjectId] = useState("");
|
||||
const [status, setStatus] = useState("");
|
||||
// 修复 P1-6:搜索 debounce 300ms
|
||||
const debouncedQuery = useDebounce(query, 300);
|
||||
const ctx = useLessonPlanContextSafe();
|
||||
const service = ctx?.service ?? null;
|
||||
|
||||
// V3 修复:完全通过 service 调用,不直接 import actions
|
||||
// 若未在 Provider 内使用,则不执行任何服务端调用(强制要求 Provider 包裹)
|
||||
const handleFilter = useCallback(
|
||||
async (params: {
|
||||
query?: string;
|
||||
@@ -57,9 +64,72 @@ export function LessonPlanList({ initialItems, subjects, viewMode = "teacher" }:
|
||||
[service, t],
|
||||
);
|
||||
|
||||
// 使用 ref 存储 handleFilter,避免其引用变化触发 useEffect 无限循环
|
||||
const handleFilterRef = useRef(handleFilter);
|
||||
useEffect(() => {
|
||||
handleFilterRef.current = handleFilter;
|
||||
}, [handleFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
handleFilterRef.current({
|
||||
query: debouncedQuery || undefined,
|
||||
subjectId: subjectId || undefined,
|
||||
status: status || undefined,
|
||||
});
|
||||
}, [debouncedQuery, subjectId, status]);
|
||||
|
||||
const hasFilters = !!(query || subjectId || status);
|
||||
function handleReset(): void {
|
||||
setQuery("");
|
||||
setSubjectId("");
|
||||
setStatus("");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<LessonPlanFilters onFilter={handleFilter} subjects={subjects} isLoading={isLoading} />
|
||||
<FilterBar hasFilters={hasFilters} onReset={handleReset} layout="wrap">
|
||||
<label htmlFor="lesson-plan-search" className="sr-only">
|
||||
{t("filters.searchPlaceholder")}
|
||||
</label>
|
||||
<FilterSearchInput
|
||||
value={query}
|
||||
onChange={setQuery}
|
||||
placeholder={t("filters.searchPlaceholder")}
|
||||
/>
|
||||
<label htmlFor="lesson-plan-subject" className="sr-only">
|
||||
{t("filters.allSubjects")}
|
||||
</label>
|
||||
<select
|
||||
id="lesson-plan-subject"
|
||||
value={subjectId}
|
||||
onChange={(e) => setSubjectId(e.target.value)}
|
||||
disabled={isLoading}
|
||||
aria-busy={isLoading}
|
||||
className="border border-outline-variant rounded-lg px-3 py-1.5 text-sm disabled:opacity-50 h-9"
|
||||
>
|
||||
<option value="">{t("filters.allSubjects")}</option>
|
||||
{subjects.map((s) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<label htmlFor="lesson-plan-status" className="sr-only">
|
||||
{t("filters.allStatus")}
|
||||
</label>
|
||||
<select
|
||||
id="lesson-plan-status"
|
||||
value={status}
|
||||
onChange={(e) => setStatus(e.target.value)}
|
||||
disabled={isLoading}
|
||||
aria-busy={isLoading}
|
||||
className="border border-outline-variant rounded-lg px-3 py-1.5 text-sm disabled:opacity-50 h-9"
|
||||
>
|
||||
<option value="">{t("filters.allStatus")}</option>
|
||||
<option value="draft">{t("status.draft")}</option>
|
||||
<option value="published">{t("status.published")}</option>
|
||||
</select>
|
||||
</FilterBar>
|
||||
{error && (
|
||||
<p className="text-error text-sm bg-error-container/10 px-3 py-2 rounded">
|
||||
{error}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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/A2:AI 反馈按钮 */}
|
||||
<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/A5:AI 差异化与课标核对按钮 */}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import { useQuestionService } from "../providers/lesson-plan-provider"
|
||||
import type { QuestionPickerItem, QuestionPickerParams } from "../providers/lesson-plan-provider"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { FocusTrap } from "@/shared/components/a11y/focus-trap"
|
||||
import { QuestionBankSkeleton } from "./lesson-plan-skeleton"
|
||||
import { SkeletonCard } from "@/shared/components/ui/skeleton"
|
||||
import { useDebounce } from "@/shared/hooks/use-debounce"
|
||||
import { X, ChevronDown, ChevronRight } from "lucide-react"
|
||||
import { QuestionBankFilters } from "@/shared/components/question/question-bank-filters"
|
||||
@@ -196,7 +196,7 @@ export function QuestionBankPicker({ onPick, onClose, existingIds }: Props) {
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
{loading ? (
|
||||
<QuestionBankSkeleton />
|
||||
<SkeletonCard variant="list" rows={6} />
|
||||
) : error ? (
|
||||
<p className="text-error text-sm text-center py-8">{error}</p>
|
||||
) : questions.length === 0 ? (
|
||||
|
||||
@@ -6,7 +6,7 @@ import { toast } from "sonner";
|
||||
import { useLessonPlanContextSafe, useLessonPlanTrackerSafe } from "../providers/lesson-plan-provider";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import { FocusTrap } from "@/shared/components/a11y/focus-trap";
|
||||
import { VersionListSkeleton } from "./lesson-plan-skeleton";
|
||||
import { SkeletonCard } from "@/shared/components/ui/skeleton";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -128,7 +128,7 @@ export function VersionHistoryDrawer({
|
||||
<>
|
||||
<h3 className="font-headline-md text-headline-md mb-4">{t("version.title")}</h3>
|
||||
{loading ? (
|
||||
<VersionListSkeleton />
|
||||
<SkeletonCard variant="list" rows={5} />
|
||||
) : versions.length === 0 ? (
|
||||
<p className="text-on-surface-variant">{t("version.empty")}</p>
|
||||
) : (
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import { StatCard } from "@/shared/components/ui/stat-card"
|
||||
* - practice-stats-cards / practice-overview-stats-cards
|
||||
* - analytics-stats-cards
|
||||
*/
|
||||
interface StatItem {
|
||||
export interface StatItem {
|
||||
/** 卡片标题 */
|
||||
label: string
|
||||
/** 统计数值 */
|
||||
|
||||
@@ -591,6 +591,8 @@
|
||||
"createFailed": "Creation failed",
|
||||
"loadFailed": "Page load failed",
|
||||
"loadFailedDesc": "Sorry, an unexpected error occurred. Please try again later.",
|
||||
"boundaryTitle": "Lesson preparation section failed to load",
|
||||
"boundaryDescription": "An error occurred while loading lesson preparation data. Please retry.",
|
||||
"retry": "Retry",
|
||||
"invalidQuestionType": "Invalid question type",
|
||||
"duplicateSuffix": " - Copy",
|
||||
|
||||
@@ -591,6 +591,8 @@
|
||||
"createFailed": "创建失败",
|
||||
"loadFailed": "页面加载失败",
|
||||
"loadFailedDesc": "抱歉,页面加载时发生了意外错误。请稍后重试。",
|
||||
"boundaryTitle": "备课区块加载失败",
|
||||
"boundaryDescription": "加载备课数据时发生错误,请重试。",
|
||||
"retry": "重试",
|
||||
"invalidQuestionType": "无效的题目类型",
|
||||
"duplicateSuffix": " - 副本",
|
||||
|
||||
Reference in New Issue
Block a user