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