feat(parent,auth,onboarding,files,notifications,adaptive-practice,ai): add module updates

parent:

- Add parent-student-attendance-detail component

auth:

- Add actions, data-access, schema, services, types

onboarding:

- Add parent-children-form and hooks directory

files:

- Add actions, schema, hooks directory

notifications:

- Add schema and schema test

adaptive-practice:

- Add answer-input, answer-result, practice-result-view, practice-starter-with-nav

- Add question-card, question-content, lib and services directories

ai:

- Add context/create-ai-client-service, hooks/use-drag-position, hooks/use-position-persistence
This commit is contained in:
SpecialX
2026-07-03 10:26:12 +08:00
parent f3c223d914
commit e9a5264fe7
84 changed files with 6060 additions and 2530 deletions

View File

@@ -18,6 +18,7 @@ import {
WeaknessAnalysisInputSchema,
ChildSummaryInputSchema,
StudyPathInputSchema,
ExplainErrorInputSchema,
} from "./schema"
import type {
AiChatMessage,
@@ -37,6 +38,8 @@ import type {
StudyPathInput,
StudyPathResult,
AiUsageStats,
ExplainErrorInput,
ExplainErrorResult,
} from "./types"
// ---------------------------------------------------------------------------
@@ -327,16 +330,15 @@ export async function recommendStudyPathAction(
// 同步填充 currentMastery若未传入
if (!serviceInput.currentMastery || serviceInput.currentMastery.length === 0) {
serviceInput.currentMastery = kps
.filter((kp) => masteryMap.has(kp.id))
.map((kp) => {
const m = masteryMap.get(kp.id)!
return {
knowledgePoint: kp.name,
masteryLevel: Math.round((m.masteryLevel / 100) * 5),
errorCount: m.totalQuestions - m.correctQuestions,
}
})
serviceInput.currentMastery = kps.flatMap((kp) => {
const m = masteryMap.get(kp.id)
if (!m) return []
return [{
knowledgePoint: kp.name,
masteryLevel: Math.round((m.masteryLevel / 100) * 5),
errorCount: m.totalQuestions - m.correctQuestions,
}]
})
}
}
} catch {
@@ -376,6 +378,38 @@ export async function getAiUsageStatsAction(): Promise<ActionState<AiUsageStats>
if (error instanceof PermissionDeniedError) {
return { success: false, message: error.message }
}
return { success: false, message: t("error.chatFailed") }
return { success: false, message: t("error.statsFailed") }
}
}
// ---------------------------------------------------------------------------
// 错题 AI 解释P2-1 新增)
// ---------------------------------------------------------------------------
export async function explainErrorAction(
input: ExplainErrorInput
): Promise<ActionState<ExplainErrorResult>> {
const t = await getTranslations("ai")
try {
const ctx = await requireAiPermission(
Permissions.AI_CHAT,
Permissions.ERROR_BOOK_READ
)
const parsed = ExplainErrorInputSchema.safeParse(input)
if (!parsed.success) {
return { success: false, message: t("error.invalidInput") }
}
const service = createAiService(ctx.userId)
const result = await safeAiCall(() => service.explainError(parsed.data))
if (!result.ok) {
return { success: false, message: result.message }
}
return { success: true, data: result.data }
} catch (error) {
if (error instanceof PermissionDeniedError) {
return { success: false, message: error.message }
}
return { success: false, message: t("error.analysisFailed") }
}
}

View File

@@ -229,11 +229,11 @@ function inferContextFromPath(
return {
systemPrompt:
"You are an AI grading assistant for teachers. Help with evaluating student submissions, providing feedback suggestions, and identifying common mistakes. Be concise and constructive.",
contextMessage: "Current page: Homework grading view",
contextMessage: t("chat.contextMessage.teacherGrading"),
suggestedPrompts: [
t("chat.suggestedPrompts.teacher.0"),
"What are common mistakes in this type of question?",
"How should I give constructive feedback?",
t("chat.suggestedPrompts.context.teacherGrading.0"),
t("chat.suggestedPrompts.context.teacherGrading.1"),
],
}
}
@@ -243,11 +243,11 @@ function inferContextFromPath(
return {
systemPrompt:
"You are an AI lesson planning assistant. Help teachers design lessons, create activities, generate discussion questions, and align with curriculum standards.",
contextMessage: "Current page: Lesson plan editor",
contextMessage: t("chat.contextMessage.teacherLesson"),
suggestedPrompts: [
t("chat.suggestedPrompts.teacher.1"),
"Suggest a hook for this lesson",
"What are some differentiation strategies?",
t("chat.suggestedPrompts.context.teacherLesson.0"),
t("chat.suggestedPrompts.context.teacherLesson.1"),
],
}
}
@@ -257,11 +257,11 @@ function inferContextFromPath(
return {
systemPrompt:
"You are an AI exam design assistant. Help create questions, generate variants, analyze difficulty distribution, and ensure knowledge point coverage.",
contextMessage: "Current page: Exam builder",
contextMessage: t("chat.contextMessage.teacherExam"),
suggestedPrompts: [
t("chat.suggestedPrompts.teacher.2"),
"Generate a question on this topic",
"Analyze the difficulty distribution",
t("chat.suggestedPrompts.context.teacherExam.0"),
t("chat.suggestedPrompts.context.teacherExam.1"),
],
}
}
@@ -271,7 +271,7 @@ function inferContextFromPath(
return {
systemPrompt:
"You are a Socratic tutor for K12 students. Guide the student to find answers themselves. Do NOT give direct answers. Use questions and hints to help them understand their mistakes.",
contextMessage: "Current page: Error book (student view)",
contextMessage: t("chat.contextMessage.studentErrorBook"),
suggestedPrompts: [
t("chat.suggestedPrompts.student.0"),
t("chat.suggestedPrompts.student.1"),
@@ -285,11 +285,11 @@ function inferContextFromPath(
return {
systemPrompt:
"You are a homework helper for K12 students. Use the Socratic method. Do NOT give direct answers. Guide the student through hints and questions.",
contextMessage: "Current page: Student homework view",
contextMessage: t("chat.contextMessage.studentHomework"),
suggestedPrompts: [
t("chat.suggestedPrompts.student.0"),
"Give me a hint, not the answer",
"Help me understand this concept",
t("chat.suggestedPrompts.context.studentHomework.0"),
t("chat.suggestedPrompts.context.studentHomework.1"),
],
}
}
@@ -299,7 +299,7 @@ function inferContextFromPath(
return {
systemPrompt:
"You are a family education advisor. Help parents understand their child's learning progress, suggest home tutoring strategies, and provide educational guidance.",
contextMessage: "Current page: Parent dashboard",
contextMessage: t("chat.contextMessage.parent"),
suggestedPrompts: [
t("chat.suggestedPrompts.parent.0"),
t("chat.suggestedPrompts.parent.1"),
@@ -312,7 +312,7 @@ function inferContextFromPath(
return {
systemPrompt:
"You are an AI education administration assistant. Help administrators monitor AI usage, analyze school-wide trends, and optimize resource allocation.",
contextMessage: "Current page: Admin dashboard",
contextMessage: t("chat.contextMessage.admin"),
suggestedPrompts: [
t("chat.suggestedPrompts.admin.0"),
t("chat.suggestedPrompts.admin.1"),

View File

@@ -1,6 +1,7 @@
"use client"
import { useMemo } from "react"
import { useTranslations } from "next-intl"
import {
Bar,
BarChart,
@@ -27,6 +28,7 @@ import {
type ChartConfig,
} from "@/shared/components/ui/chart"
import { cn } from "@/shared/lib/utils"
import { AiChartSpecSchema } from "../schema"
/**
* AI 图表渲染器
@@ -111,14 +113,15 @@ interface AiChartRendererProps {
/**
* 解析 JSON 规格,失败时返回 null
*
* 使用 Zod schema 校验,避免 as 断言。
*/
function parseSpec(spec: string): AiChartSpec | null {
try {
const parsed = JSON.parse(spec) as AiChartSpec
if (!parsed || !Array.isArray(parsed.data) || !Array.isArray(parsed.series)) {
return null
}
return parsed
const parsed: unknown = JSON.parse(spec)
const result = AiChartSpecSchema.safeParse(parsed)
if (!result.success) return null
return result.data
} catch {
return null
}
@@ -139,12 +142,13 @@ export function AiChartRenderer({
spec,
className,
}: AiChartRendererProps): React.ReactNode {
const t = useTranslations("ai")
const parsed = useMemo(() => parseSpec(spec), [spec])
if (!parsed) {
return (
<div className="rounded-md border border-destructive/30 bg-destructive/5 p-3 text-xs text-destructive">
{t("chart.parseError")}
</div>
)
}

View File

@@ -1,11 +1,14 @@
"use client"
import { Component, type ReactNode } from "react"
import { AlertCircle, RefreshCw } from "lucide-react"
import { useTranslations } from "next-intl"
/**
* AI 专用 Error Boundary
*
* 薄包装:委托给共享 SectionErrorBoundary使用 ai 命名空间。
* 保留同名导出以兼容现有 import。
*/
import { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import type { ReactNode } from "react"
import { SectionErrorBoundary } from "@/shared/components/section-error-boundary"
type AiErrorBoundaryProps = {
children: ReactNode
@@ -15,74 +18,14 @@ type AiErrorBoundaryProps = {
onError?: (error: Error, info: unknown) => void
}
type AiErrorBoundaryState = {
error: Error | null
}
/**
* AI 专用 Error Boundary
*
* 包裹所有 AI 数据区块,防止单个 AI 调用失败导致整页崩溃。
* 提供重试按钮与友好的错误提示。
*/
export class AiErrorBoundary extends Component<
AiErrorBoundaryProps,
AiErrorBoundaryState
> {
constructor(props: AiErrorBoundaryProps) {
super(props)
this.state = { error: null }
}
static getDerivedStateFromError(error: Error): AiErrorBoundaryState {
return { error }
}
componentDidCatch(error: Error, info: unknown): void {
if (this.props.onError) {
this.props.onError(error, info)
}
}
private handleReset = (): void => {
this.setState({ error: null })
}
render(): ReactNode {
if (this.state.error) {
if (this.props.fallback) {
return this.props.fallback(this.state.error, this.handleReset)
}
return <DefaultAiErrorFallback error={this.state.error} onReset={this.handleReset} />
}
return this.props.children
}
}
function DefaultAiErrorFallback({
error,
onReset,
}: {
error: Error
onReset: () => void
}): ReactNode {
const t = useTranslations("ai")
export function AiErrorBoundary({
children,
fallback,
onError,
}: AiErrorBoundaryProps): ReactNode {
return (
<Card className="border-destructive/30">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-destructive">
<AlertCircle className="h-4 w-4" />
{t("error.boundaryTitle")}
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-sm text-muted-foreground">{t("error.boundaryDescription")}</p>
<p className="text-xs text-muted-foreground/70 font-mono">{error.message}</p>
<Button type="button" variant="outline" size="sm" onClick={onReset}>
<RefreshCw className="mr-1 h-3.5 w-3.5" />
{t("error.retry")}
</Button>
</CardContent>
</Card>
<SectionErrorBoundary namespace="ai" fallback={fallback} onError={onError}>
{children}
</SectionErrorBoundary>
)
}

View File

@@ -28,6 +28,28 @@ const CHART_TYPES: Record<string, AiChartType> = {
"chart:radar": "radar",
}
/**
* 类型守卫:判断字符串是否为合法的 AiChartType
*/
function isAiChartType(value: string): value is AiChartType {
return value === "bar" || value === "line" || value === "pie" || value === "radar"
}
/**
* 从 language 标识中解析图表类型
*/
function resolveChartType(lang: string): AiChartType | undefined {
// 优先查表(兼容 "chart:bar" 形式)
const fromTable = CHART_TYPES[`${CHART_LANG_PREFIX}${lang}`]
if (fromTable) return fromTable
// 兼容 "chart:bar" 前缀形式
if (lang.startsWith(CHART_LANG_PREFIX)) {
const suffix = lang.slice(CHART_LANG_PREFIX.length)
return isAiChartType(suffix) ? suffix : undefined
}
return undefined
}
/**
* AI Markdown 渲染器
*
@@ -87,12 +109,9 @@ function AiMarkdownRendererImpl({
// 检测图表代码块language-chart:bar / chart:line / chart:pie / chart:radar
const lang = codeClass?.replace("language-", "").trim() ?? ""
const chartType = CHART_TYPES[`${CHART_LANG_PREFIX}${lang}`]
?? (lang.startsWith(CHART_LANG_PREFIX)
? (lang.slice(CHART_LANG_PREFIX.length) as AiChartType)
: undefined)
const chartType = resolveChartType(lang)
if (chartType && (chartType === "bar" || chartType === "line" || chartType === "pie" || chartType === "radar")) {
if (chartType) {
const raw = String(children).replace(/\n$/, "")
return <AiChartRenderer type={chartType} spec={raw} />
}

View File

@@ -63,7 +63,7 @@ export function AiProviderSelector({
render={({ field }) => (
<FormItem>
<FormLabel>{t("provider.label")}</FormLabel>
<Select value={field.value as string} onValueChange={field.onChange} disabled={loading}>
<Select value={typeof field.value === "string" ? field.value : ""} onValueChange={field.onChange} disabled={loading}>
<FormControl>
<SelectTrigger>
<SelectValue

View File

@@ -1,164 +0,0 @@
"use client"
import { useState } from "react"
import { useTranslations } from "next-intl"
import { Sparkles, Check, RefreshCw } from "lucide-react"
import { toast } from "sonner"
import { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Badge } from "@/shared/components/ui/badge"
import { AiSuggestionSkeleton } from "./ai-skeleton"
import { useAiClient } from "../context/ai-client-provider"
import type { SimilarQuestionResult } from "../types"
type AiSuggestionCardProps = {
/** 原始题目文本 */
questionText: string
/** 题目类型 */
questionType: string
/** 学科 */
subject?: string
/** 知识点 ID 列表 */
knowledgePointIds?: string[]
/** 需要生成的题目数量 */
count?: number
/** 选中题目后的回调 */
onSelectQuestion?: (question: SimilarQuestionResult) => void
}
/**
* AI 相似题建议卡片
*
* 可复用组件,展示 AI 生成的相似练习题。
* 用于错题本、作业练习等场景。
*/
export function AiSuggestionCard({
questionText,
questionType,
subject,
knowledgePointIds,
count = 3,
onSelectQuestion,
}: AiSuggestionCardProps): React.ReactNode {
const t = useTranslations("ai")
const aiClient = useAiClient()
const [loading, setLoading] = useState(false)
const [questions, setQuestions] = useState<SimilarQuestionResult[]>([])
const [hasLoaded, setHasLoaded] = useState(false)
const handleGenerate = async (): Promise<void> => {
setLoading(true)
try {
const result = await aiClient.suggestSimilarQuestions({
questionText,
questionType,
subject,
knowledgePointIds,
count,
})
if (result.success && result.data) {
setQuestions(result.data)
setHasLoaded(true)
toast.success(t("suggestion.loaded"))
} else {
toast.error(result.message ?? t("suggestion.error"))
}
} catch {
toast.error(t("suggestion.error"))
} finally {
setLoading(false)
}
}
const handleSelect = (question: SimilarQuestionResult): void => {
onSelectQuestion?.(question)
toast.success(t("suggestion.selected"))
}
if (loading) {
return <AiSuggestionSkeleton />
}
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Sparkles className="h-4 w-4 text-primary" />
{t("suggestion.title")}
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{hasLoaded && questions.length === 0 ? (
<p className="text-sm text-muted-foreground">{t("suggestion.empty")}</p>
) : questions.length > 0 ? (
<>
{questions.map((question, index) => (
<div
key={index}
className="rounded-md border p-3 space-y-2"
>
<div className="flex items-start justify-between gap-2">
<p className="text-sm flex-1">{question.text}</p>
{question.difficulty ? (
<Badge variant="outline" className="shrink-0">
{t("suggestion.difficulty")}: {question.difficulty}
</Badge>
) : null}
</div>
{question.options && question.options.length > 0 ? (
<ul className="text-xs text-muted-foreground space-y-1">
{question.options.map((opt, optIndex) => (
<li key={optIndex}>
<span className="font-medium">{opt.id}.</span> {opt.text}
</li>
))}
</ul>
) : null}
{question.explanation ? (
<p className="text-xs text-muted-foreground italic">
{question.explanation}
</p>
) : null}
{onSelectQuestion ? (
<div className="flex justify-end gap-2 pt-1">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => handleSelect(question)}
>
<Check className="mr-1 h-3.5 w-3.5" />
{t("suggestion.select")}
</Button>
</div>
) : null}
</div>
))}
<Button
type="button"
variant="outline"
size="sm"
onClick={handleGenerate}
className="w-full"
>
<RefreshCw className="mr-1 h-3.5 w-3.5" />
{t("suggestion.regenerate")}
</Button>
</>
) : (
<Button
type="button"
variant="outline"
size="sm"
onClick={handleGenerate}
className="w-full"
>
<Sparkles className="mr-1 h-3.5 w-3.5" />
{t("suggestion.generate")}
</Button>
)}
</CardContent>
</Card>
)
}

View File

@@ -1,6 +1,6 @@
"use client"
import { useState, useEffect } from "react"
import { useState, useEffect, useCallback } from "react"
import { useTranslations } from "next-intl"
import { Activity, Users, AlertTriangle, Clock } from "lucide-react"
import { toast } from "sonner"
@@ -33,7 +33,7 @@ export function AiUsageDashboard(): React.ReactNode {
const [stats, setStats] = useState<AiUsageStats | null>(null)
const [loading, setLoading] = useState(false)
const loadStats = async (): Promise<void> => {
const loadStats = useCallback(async (): Promise<void> => {
if (!aiClient.getAiUsageStats) return
setLoading(true)
try {
@@ -41,19 +41,18 @@ export function AiUsageDashboard(): React.ReactNode {
if (result.success && result.data) {
setStats(result.data)
} else {
toast.error(result.message ?? t("error.chatFailed"))
toast.error(result.message ?? t("error.statsFailed"))
}
} catch {
toast.error(t("error.chatFailed"))
toast.error(t("error.statsFailed"))
} finally {
setLoading(false)
}
}
}, [aiClient, t])
useEffect(() => {
void loadStats()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
}, [loadStats])
const statCards = stats
? [

View File

@@ -0,0 +1,54 @@
import "server-only"
import {
aiChatAction,
suggestSimilarQuestionsAction,
suggestGradingAction,
generateLessonContentAction,
generateQuestionVariantAction,
analyzeWeaknessAction,
generateChildSummaryAction,
recommendStudyPathAction,
getAiUsageStatsAction,
explainErrorAction,
} from "../actions"
import type { AiClientService } from "../types"
/**
* 创建完整的 AI 客户端服务(含全部 10 个 Action
*
* 用于全局 layout 或需要全部 AI 能力的页面。
* 通过 React Context 注入,客户端组件通过 useAiClient() 消费。
*/
export function createFullAiClientService(): AiClientService {
return {
chat: aiChatAction,
suggestSimilarQuestions: suggestSimilarQuestionsAction,
suggestGrading: suggestGradingAction,
generateLessonContent: generateLessonContentAction,
generateQuestionVariant: generateQuestionVariantAction,
analyzeWeakness: analyzeWeaknessAction,
generateChildSummary: generateChildSummaryAction,
recommendStudyPath: recommendStudyPathAction,
getAiUsageStats: getAiUsageStatsAction,
explainError: explainErrorAction,
}
}
/**
* 创建核心 AI 客户端服务(仅 6 个常用 Action
*
* 用于只需要 AI 业务能力(不含家长摘要/学习路径/统计/错题解释)的页面。
* 可选字段generateChildSummary/recommendStudyPath/getAiUsageStats/explainError不注入
* 调用方组件需自行处理 undefined 情况。
*/
export function createCoreAiClientService(): AiClientService {
return {
chat: aiChatAction,
suggestSimilarQuestions: suggestSimilarQuestionsAction,
suggestGrading: suggestGradingAction,
generateLessonContent: generateLessonContentAction,
generateQuestionVariant: generateQuestionVariantAction,
analyzeWeakness: analyzeWeaknessAction,
}
}

View File

@@ -1,57 +0,0 @@
"use client"
import { useState, useCallback } from "react"
import { useAiClient } from "../context/ai-client-provider"
import type { AiChatMessage, AiChatResult } from "../types"
/**
* AI 聊天 Hook
*
* 封装 AI 聊天逻辑,与 UI 分离。
* 通过 useAiClient() 获取 Server Action 引用。
*/
export function useAiChat(): {
messages: AiChatMessage[]
loading: boolean
error: string | null
send: (messages: AiChatMessage[], providerId?: string) => Promise<AiChatResult | null>
clear: () => void
} {
const aiClient = useAiClient()
const [messages, setMessages] = useState<AiChatMessage[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const send = useCallback(
async (input: AiChatMessage[], providerId?: string): Promise<AiChatResult | null> => {
setLoading(true)
setError(null)
try {
const result = await aiClient.chat({ messages: input, providerId })
if (result.success && result.data) {
const assistantContent = result.data.content
setMessages((prev) => [...prev, ...input, {
role: "assistant",
content: assistantContent,
}])
return result.data
}
setError(result.message ?? "AI request failed")
return null
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
return null
} finally {
setLoading(false)
}
},
[aiClient]
)
const clear = useCallback((): void => {
setMessages([])
setError(null)
}, [])
return { messages, loading, error, send, clear }
}

View File

@@ -0,0 +1,130 @@
"use client"
import { useCallback, useEffect, useRef, useState } from "react"
import type { Position } from "./use-position-persistence"
import { clampPosition } from "./use-position-persistence"
type DragState = {
active: boolean
moved: boolean
startX: number
startY: number
originX: number
originY: number
pointerId: number
}
type DragCallbacks = {
/** 拖拽开始时触发pointer down 后) */
onDragStart: () => void
/** 拖拽释放时触发moved 表示是否发生了实际移动 */
onRelease: (moved: boolean) => void
}
/**
* 拖拽位置 Hook
*
* 处理 pointer 事件,跟踪拖拽状态与位置变化。
* 不处理边缘吸附、持久化等业务逻辑,通过回调委托给调用方。
*/
export function useDragPosition(
position: Position,
setPosition: React.Dispatch<React.SetStateAction<Position>>,
callbacks: DragCallbacks
): {
dragging: boolean
handlers: {
onPointerDown: (e: React.PointerEvent<HTMLButtonElement>) => void
onPointerMove: (e: React.PointerEvent<HTMLButtonElement>) => void
onPointerUp: (e: React.PointerEvent<HTMLButtonElement>) => void
onPointerCancel: () => void
}
} {
const [dragging, setDragging] = useState(false)
const dragStateRef = useRef<DragState>({
active: false,
moved: false,
startX: 0,
startY: 0,
originX: 0,
originY: 0,
pointerId: -1,
})
const callbacksRef = useRef(callbacks)
useEffect(() => {
callbacksRef.current = callbacks
}, [callbacks])
const onPointerDown = useCallback(
(e: React.PointerEvent<HTMLButtonElement>): void => {
// 仅主键响应拖拽
if (e.button !== 0 && e.pointerType === "mouse") return
const s = dragStateRef.current
s.active = true
s.moved = false
s.startX = e.clientX
s.startY = e.clientY
s.originX = position.x
s.originY = position.y
s.pointerId = e.pointerId
try {
e.currentTarget.setPointerCapture(e.pointerId)
} catch {
// ignore
}
setDragging(true)
callbacksRef.current.onDragStart()
},
[position]
)
const onPointerMove = useCallback(
(e: React.PointerEvent<HTMLButtonElement>): void => {
const s = dragStateRef.current
if (!s.active || e.pointerId !== s.pointerId) return
const dx = e.clientX - s.startX
const dy = e.clientY - s.startY
// 阈值过滤微抖动
if (!s.moved && Math.abs(dx) + Math.abs(dy) < 4) return
s.moved = true
const next = clampPosition({
x: s.originX + dx,
y: s.originY + dy,
})
setPosition(next)
},
[setPosition]
)
const onPointerUp = useCallback(
(e: React.PointerEvent<HTMLButtonElement>): void => {
const s = dragStateRef.current
if (!s.active || e.pointerId !== s.pointerId) return
s.active = false
setDragging(false)
try {
e.currentTarget.releasePointerCapture(e.pointerId)
} catch {
// ignore
}
callbacksRef.current.onRelease(s.moved)
},
[]
)
const onPointerCancel = useCallback((): void => {
dragStateRef.current.active = false
setDragging(false)
}, [])
return {
dragging,
handlers: {
onPointerDown,
onPointerMove,
onPointerUp,
onPointerCancel,
},
}
}

View File

@@ -2,50 +2,43 @@
import { useCallback, useEffect, useRef, useState } from "react"
type Position = { x: number; y: number }
import {
BALL_SIZE,
HIDE_THRESHOLD,
MARGIN,
type Position,
clampPosition,
getDefaultPosition,
savePosition,
usePositionPersistence,
} from "./use-position-persistence"
import { useDragPosition } from "./use-drag-position"
const STORAGE_KEY = "ai-widget-position"
const HIDE_THRESHOLD = 0.55
const BALL_SIZE = 56
const MARGIN = 16
function clampPosition(pos: Position): Position {
if (typeof window === "undefined") return pos
const maxX = window.innerWidth - BALL_SIZE - MARGIN
const maxY = window.innerHeight - BALL_SIZE - MARGIN
return {
x: Math.min(Math.max(pos.x, MARGIN), Math.max(maxX, MARGIN)),
y: Math.min(Math.max(pos.y, MARGIN), Math.max(maxY, MARGIN)),
}
/**
* 计算吸附到最近边缘后的 X 坐标
*/
function snapToEdge(x: number): number {
if (typeof window === "undefined") return x
const w = window.innerWidth
const centerX = x + BALL_SIZE / 2
const distanceToLeft = centerX
const distanceToRight = w - centerX
return distanceToLeft < distanceToRight ? MARGIN : w - BALL_SIZE - MARGIN
}
function loadPosition(): Position {
if (typeof window === "undefined") {
return { x: 9999, y: 9999 }
}
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (raw) {
const parsed = JSON.parse(raw) as Partial<Position>
if (typeof parsed.x === "number" && typeof parsed.y === "number") {
return clampPosition({ x: parsed.x, y: parsed.y })
}
}
} catch {
// ignore
}
const x = window.innerWidth - BALL_SIZE - MARGIN * 2
const y = window.innerHeight - BALL_SIZE - MARGIN * 4
return clampPosition({ x, y })
}
function savePosition(pos: Position): void {
if (typeof window === "undefined") return
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(pos))
} catch {
// ignore
}
/**
* 计算半隐藏时的视觉偏移量
*/
function calculateHiddenOffset(
position: Position,
hidden: boolean,
hovered: boolean,
dragging: boolean
): number {
if (!hidden || hovered || dragging) return 0
return position.x <= MARGIN + 2
? -(BALL_SIZE * HIDE_THRESHOLD)
: BALL_SIZE * HIDE_THRESHOLD
}
/**
@@ -57,135 +50,69 @@ function savePosition(pos: Position): void {
* - 单击(未发生拖动)触发 onClick
* - 位置持久化到 localStorage
* - 窗口 resize 时自动校正位置
*
* V3拆分为 use-position-persistence + use-drag-position + 本 hook 组合
*/
export function useFloatingBall(onClick: () => void) {
// 服务端与客户端首次渲染一致position 在屏幕外,不渲染按钮)
// 在 useEffect 中加载真实位置,避免 hydration mismatch
const [position, setPosition] = useState<Position>({ x: 9999, y: 9999 })
export function useFloatingBall(onClick: () => void): {
position: Position
hidden: boolean
dragging: boolean
hovered: boolean
hiddenOffset: number
handlers: {
onPointerDown: (e: React.PointerEvent<HTMLButtonElement>) => void
onPointerMove: (e: React.PointerEvent<HTMLButtonElement>) => void
onPointerUp: (e: React.PointerEvent<HTMLButtonElement>) => void
onPointerCancel: () => void
onMouseEnter: () => void
onMouseLeave: () => void
}
show: () => void
resetPosition: () => void
} {
const { position, setPosition } = usePositionPersistence()
const [hidden, setHidden] = useState(false)
const [dragging, setDragging] = useState(false)
const [hovered, setHovered] = useState(false)
// 拖拽释放后标记"刚隐藏",阻止 mouseEnter 立即展开
const justHiddenRef = useRef(false)
const dragStateRef = useRef({
active: false,
moved: false,
startX: 0,
startY: 0,
originX: 0,
originY: 0,
pointerId: -1,
})
const onClickRef = useRef(onClick)
useEffect(() => {
onClickRef.current = onClick
}, [onClick])
// 初始化位置:在客户端 mount 后加载真实位置
// 避免 hydration mismatch服务端与客户端位置不同
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
setPosition(loadPosition())
const handleDragStart = useCallback((): void => {
setHidden(false)
}, [])
// 窗口 resize 时校正
useEffect(() => {
const handleResize = () => {
setPosition((prev) => clampPosition(prev))
}
window.addEventListener("resize", handleResize)
return () => window.removeEventListener("resize", handleResize)
}, [])
const handlePointerDown = useCallback(
(e: React.PointerEvent<HTMLButtonElement>) => {
// 仅主键响应拖拽
if (e.button !== 0 && e.pointerType === "mouse") return
const state = dragStateRef.current
state.active = true
state.moved = false
state.startX = e.clientX
state.startY = e.clientY
state.originX = position.x
state.originY = position.y
state.pointerId = e.pointerId
try {
e.currentTarget.setPointerCapture(e.pointerId)
} catch {
// ignore
}
setHidden(false)
setDragging(true)
},
[position]
)
const handlePointerMove = useCallback(
(e: React.PointerEvent<HTMLButtonElement>) => {
const state = dragStateRef.current
if (!state.active || e.pointerId !== state.pointerId) return
const dx = e.clientX - state.startX
const dy = e.clientY - state.startY
if (!state.moved && Math.abs(dx) + Math.abs(dy) < 4) return
state.moved = true
const next = clampPosition({
x: state.originX + dx,
y: state.originY + dy,
})
setPosition(next)
},
[]
)
const handlePointerUp = useCallback(
(e: React.PointerEvent<HTMLButtonElement>) => {
const state = dragStateRef.current
if (!state.active || e.pointerId !== state.pointerId) return
state.active = false
setDragging(false)
try {
e.currentTarget.releasePointerCapture(e.pointerId)
} catch {
// ignore
}
const handleRelease = useCallback(
(moved: boolean): void => {
// 未移动 → 视为点击
if (!state.moved) {
if (!moved) {
onClickRef.current()
return
}
// 移动了 → 吸附到最近边缘
const w = window.innerWidth
const centerX = position.x + BALL_SIZE / 2
const distanceToLeft = centerX
const distanceToRight = w - centerX
const snapLeft = distanceToLeft < distanceToRight
const snappedX = snapLeft ? MARGIN : w - BALL_SIZE - MARGIN
// 判断是否半隐藏:吸附后位置贴近边缘
const shouldHide = true
const snappedX = snapToEdge(position.x)
const finalPos = clampPosition({ x: snappedX, y: position.y })
setPosition(finalPos)
savePosition(finalPos)
setHidden(shouldHide)
setHidden(true)
// 标记刚隐藏,阻止后续 mouseEnter 立即展开
justHiddenRef.current = shouldHide
justHiddenRef.current = true
// 清除 hovered确保 hiddenOffset 生效
setHovered(false)
},
[position]
[position, setPosition]
)
const handlePointerCancel = useCallback(() => {
const state = dragStateRef.current
state.active = false
setDragging(false)
}, [])
const { dragging, handlers: dragHandlers } = useDragPosition(
position,
setPosition,
{ onDragStart: handleDragStart, onRelease: handleRelease }
)
const handleMouseEnter = useCallback(() => {
const handleMouseEnter = useCallback((): void => {
// 如果刚通过拖拽隐藏,不立即展开(需先离开再进入才展开)
if (justHiddenRef.current) {
justHiddenRef.current = false
@@ -195,33 +122,26 @@ export function useFloatingBall(onClick: () => void) {
if (hidden) setHidden(false)
}, [hidden])
const handleMouseLeave = useCallback(() => {
const handleMouseLeave = useCallback((): void => {
setHovered(false)
// 离开后清除 justHidden 标记,下次进入可正常展开
justHiddenRef.current = false
}, [])
const show = useCallback(() => {
const show = useCallback((): void => {
justHiddenRef.current = false
setHidden(false)
}, [])
const resetPosition = useCallback(() => {
justHiddenRef.current = false
const fresh = typeof window === "undefined"
? loadPosition()
: clampPosition({
x: window.innerWidth - BALL_SIZE - MARGIN * 2,
y: window.innerHeight - BALL_SIZE - MARGIN * 4,
})
setPosition(fresh)
savePosition(fresh)
setHidden(false)
}, [])
// 半隐藏时的视觉偏移量
const hiddenOffset = hidden && !hovered && !dragging
? (position.x <= MARGIN + 2 ? -(BALL_SIZE * HIDE_THRESHOLD) : BALL_SIZE * HIDE_THRESHOLD)
: 0
const resetPosition = useCallback((): void => {
justHiddenRef.current = false
const fresh = getDefaultPosition()
setPosition(fresh)
savePosition(fresh)
setHidden(false)
}, [setPosition])
const hiddenOffset = calculateHiddenOffset(position, hidden, hovered, dragging)
return {
position,
@@ -230,10 +150,7 @@ export function useFloatingBall(onClick: () => void) {
hovered,
hiddenOffset,
handlers: {
onPointerDown: handlePointerDown,
onPointerMove: handlePointerMove,
onPointerUp: handlePointerUp,
onPointerCancel: handlePointerCancel,
...dragHandlers,
onMouseEnter: handleMouseEnter,
onMouseLeave: handleMouseLeave,
},

View File

@@ -0,0 +1,99 @@
"use client"
import { useEffect, useState } from "react"
export type Position = { x: number; y: number }
export const STORAGE_KEY = "ai-widget-position"
export const HIDE_THRESHOLD = 0.55
export const BALL_SIZE = 56
export const MARGIN = 16
/**
* 将位置限制在视口内
*/
export function clampPosition(pos: Position): Position {
if (typeof window === "undefined") return pos
const maxX = window.innerWidth - BALL_SIZE - MARGIN
const maxY = window.innerHeight - BALL_SIZE - MARGIN
return {
x: Math.min(Math.max(pos.x, MARGIN), Math.max(maxX, MARGIN)),
y: Math.min(Math.max(pos.y, MARGIN), Math.max(maxY, MARGIN)),
}
}
/**
* 从 localStorage 加载位置,失败时返回默认右下角位置
*/
export function loadPosition(): Position {
if (typeof window === "undefined") {
return { x: 9999, y: 9999 }
}
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (raw) {
const parsed = JSON.parse(raw) as Partial<Position>
if (typeof parsed.x === "number" && typeof parsed.y === "number") {
return clampPosition({ x: parsed.x, y: parsed.y })
}
}
} catch {
// ignore
}
const x = window.innerWidth - BALL_SIZE - MARGIN * 2
const y = window.innerHeight - BALL_SIZE - MARGIN * 4
return clampPosition({ x, y })
}
/**
* 持久化位置到 localStorage
*/
export function savePosition(pos: Position): void {
if (typeof window === "undefined") return
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(pos))
} catch {
// ignore
}
}
/**
* 默认位置(右下角)
*/
export function getDefaultPosition(): Position {
if (typeof window === "undefined") return { x: 9999, y: 9999 }
return clampPosition({
x: window.innerWidth - BALL_SIZE - MARGIN * 2,
y: window.innerHeight - BALL_SIZE - MARGIN * 4,
})
}
/**
* 位置持久化 Hook
*
* 管理 position 状态mount 时从 localStorage 加载resize 时校正。
* 服务端与客户端首次渲染一致position 在屏幕外),避免 hydration mismatch。
*/
export function usePositionPersistence(): {
position: Position
setPosition: React.Dispatch<React.SetStateAction<Position>>
} {
const [position, setPosition] = useState<Position>({ x: 9999, y: 9999 })
// 初始化位置:在客户端 mount 后加载真实位置
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
setPosition(loadPosition())
}, [])
// 窗口 resize 时校正
useEffect(() => {
const handleResize = (): void => {
setPosition((prev) => clampPosition(prev))
}
window.addEventListener("resize", handleResize)
return () => window.removeEventListener("resize", handleResize)
}, [])
return { position, setPosition }
}

View File

@@ -225,3 +225,50 @@ export const StudyPathResultSchema = z.object({
summary: z.string().min(1),
motivation: z.string().min(1),
})
// ---------------------------------------------------------------------------
// 错题 AI 解释校验
// ---------------------------------------------------------------------------
export const ExplainErrorInputSchema = z.object({
questionText: z.string().min(1).max(4000),
questionType: z.string().min(1),
studentAnswer: z.string().min(1).max(8000),
correctAnswer: z.string().optional(),
subject: z.string().optional(),
knowledgePointIds: z.array(z.string()).optional(),
})
export const ExplainErrorResultSchema = z.object({
errorAnalysis: z.string().min(1),
correctApproach: z.string().min(1),
keyConcepts: z.array(z.string().min(1)),
preventionTips: z.array(z.string().min(1)),
practiceSuggestion: z.string().min(1),
})
// ---------------------------------------------------------------------------
// AI 图表规格校验(用于 ai-chart-renderer.tsx 解析 AI 返回的图表 JSON
// ---------------------------------------------------------------------------
export const AiChartSeriesSchema = z.object({
dataKey: z.string().min(1),
name: z.string().min(1),
color: z.string().optional(),
fillOpacity: z.number().optional(),
strokeWidth: z.number().optional(),
strokeDasharray: z.string().optional(),
})
export const AiChartSpecSchema = z.object({
title: z.string().optional(),
description: z.string().optional(),
type: z.enum(["bar", "line", "pie", "radar"]).optional(),
data: z.array(z.record(z.string(), z.union([z.string(), z.number()]))),
xKey: z.string().optional(),
angleKey: z.string().optional(),
series: z.array(AiChartSeriesSchema),
yDomain: z.tuple([z.number(), z.number()]).optional(),
height: z.number().optional(),
showLegend: z.boolean().optional(),
})

View File

@@ -11,6 +11,7 @@ import {
WEAKNESS_ANALYSIS_SYSTEM_PROMPT,
CHILD_SUMMARY_SYSTEM_PROMPT,
STUDY_PATH_SYSTEM_PROMPT,
EXPLAIN_ERROR_SYSTEM_PROMPT,
} from "./prompt-templates"
import { withAiTracking } from "./usage-tracker"
import {
@@ -21,6 +22,7 @@ import {
WeaknessAnalysisResultSchema,
ChildSummaryResultSchema,
StudyPathResultSchema,
ExplainErrorResultSchema,
} from "../schema"
import type {
AiChatMessage,
@@ -41,6 +43,8 @@ import type {
ChildSummaryResult,
StudyPathInput,
StudyPathResult,
ExplainErrorInput,
ExplainErrorResult,
} from "../types"
// ---------------------------------------------------------------------------
@@ -144,9 +148,10 @@ const callAi = async (
...(typeof options?.maxTokens === "number" ? { maxTokens: options.maxTokens } : {}),
...(options?.providerId ? { providerId: options.providerId } : {}),
})
// 从 unknown 类型安全提取 total_tokens避免 as 断言)
const tokenUsage =
result.usage && typeof result.usage === "object" && "total_tokens" in result.usage
? Number((result.usage as unknown as Record<string, unknown>).total_tokens ?? 0)
? Number(result.usage.total_tokens ?? 0)
: undefined
return { content: result.content, tokenUsage }
}
@@ -169,7 +174,11 @@ export class DefaultAiService implements AiService {
...options,
temperature: options?.temperature ?? 0.7,
})
return { result: { content, usage: null }, tokenUsage }
// usage 字段返回 token 用量对象unknown 类型),便于调用方按需类型缩小
return {
result: { content, usage: tokenUsage !== undefined ? { total_tokens: tokenUsage } : null },
tokenUsage,
}
})
}
@@ -192,9 +201,10 @@ export class DefaultAiService implements AiService {
{ temperature: 0.5, maxTokens: 3000 }
)
const parsed = extractJson(content)
// 安全提取 questions 字段(使用 in 操作符类型缩小,无需 as 断言)
const list =
parsed && typeof parsed === "object" && "questions" in parsed
? (parsed as Record<string, unknown>).questions
? parsed.questions
: parsed
const validated = SimilarQuestionListSchema.safeParse(list)
if (!validated.success) return { result: [] }
@@ -413,6 +423,39 @@ export class DefaultAiService implements AiService {
return { result: validated.data }
})
}
async explainError(input: ExplainErrorInput): Promise<ExplainErrorResult> {
return withAiTracking(this.userId, "explain_error", undefined, async () => {
const userLines = [
`Question Type: ${input.questionType}`,
input.subject ? `Subject: ${input.subject}` : "",
input.knowledgePointIds?.length
? `Knowledge Points: ${input.knowledgePointIds.join(", ")}`
: "",
`Question:\n${input.questionText}`,
`Student Answer:\n${input.studentAnswer}`,
input.correctAnswer ? `Correct Answer:\n${input.correctAnswer}` : "",
].filter((line) => line.length > 0)
const { content } = await callAi(
buildChatMessages(EXPLAIN_ERROR_SYSTEM_PROMPT, userLines.join("\n\n")),
{ temperature: 0.4, maxTokens: 2000 }
)
const parsed = extractJson(content)
const validated = ExplainErrorResultSchema.safeParse(parsed)
if (!validated.success) {
return {
result: {
errorAnalysis: "Unable to analyze the error at this time.",
correctApproach: "Please consult your teacher for help.",
keyConcepts: [],
preventionTips: [],
practiceSuggestion: "Review the relevant chapter and try again.",
},
}
}
return { result: validated.data }
})
}
}
/**

View File

@@ -275,3 +275,26 @@ export const STUDY_PATH_SYSTEM_PROMPT = [
"- motivation should be age-appropriate and encouraging.",
"Never output placeholders.",
].join("\n")
export const EXPLAIN_ERROR_SYSTEM_PROMPT = [
"You are an expert K12 tutor specializing in helping students understand their mistakes.",
"Analyze the student's error and provide a clear, encouraging explanation.",
"Return JSON only without markdown.",
"Output schema:",
"{",
' "errorAnalysis": "detailed analysis of why the student made this error",',
' "correctApproach": "step-by-step correct solution approach",',
' "keyConcepts": ["list of key concepts the student needs to review"],',
' "preventionTips": ["tips to avoid similar mistakes in the future"],',
' "practiceSuggestion": "specific practice recommendation"',
"}",
"Rules:",
"- Use age-appropriate language for K12 students.",
"- Be encouraging and constructive, never dismissive.",
"- errorAnalysis should identify the specific misconception, not just say 'wrong'.",
"- correctApproach should be step-by-step and easy to follow.",
"- keyConcepts should list 2-5 fundamental concepts.",
"- preventionTips should be actionable and specific.",
"- practiceSuggestion should recommend a specific type of practice problem.",
"Never output placeholders.",
].join("\n")

View File

@@ -5,7 +5,7 @@ import { recordAiEvent } from "../data-access"
export type AiUsageEvent = {
userId: string
capability: "chat" | "similar_question" | "grading_assist" | "lesson_content" | "question_variant" | "weakness_analysis" | "child_summary" | "study_path"
capability: "chat" | "similar_question" | "grading_assist" | "lesson_content" | "question_variant" | "weakness_analysis" | "child_summary" | "study_path" | "explain_error"
providerId?: string
model?: string
success: boolean
@@ -23,6 +23,7 @@ const AI_EVENT_MAP: Record<AiUsageEvent["capability"], EventName> = {
weakness_analysis: "ai.weakness_analysis",
child_summary: "ai.child_summary",
study_path: "ai.study_path",
explain_error: "ai.explain_error",
}
/**

View File

@@ -221,6 +221,36 @@ export type AiUsageStats = {
}>
}
/** 错题 AI 解释输入 */
export type ExplainErrorInput = {
/** 题目文本 */
questionText: string
/** 题目类型 */
questionType: string
/** 学生错误答案 */
studentAnswer: string
/** 正确答案 */
correctAnswer?: string
/** 学科 */
subject?: string
/** 知识点 ID 列表 */
knowledgePointIds?: string[]
}
/** 错题 AI 解释结果 */
export type ExplainErrorResult = {
/** 错误原因分析 */
errorAnalysis: string
/** 正确解题思路 */
correctApproach: string
/** 关键知识点 */
keyConcepts: string[]
/** 类似错误防范建议 */
preventionTips: string[]
/** 练习建议 */
practiceSuggestion: string
}
// ---------------------------------------------------------------------------
// AI 能力配置(角色驱动)
// ---------------------------------------------------------------------------
@@ -236,6 +266,7 @@ export type AiCapability =
| "study-path"
| "child-summary"
| "usage-stats"
| "explain-error"
// ---------------------------------------------------------------------------
// 服务接口
@@ -257,6 +288,7 @@ export interface AiService {
analyzeWeakness(input: WeaknessAnalysisInput): Promise<WeaknessAnalysisResult>
generateChildSummary(input: ChildSummaryInput): Promise<ChildSummaryResult>
recommendStudyPath(input: StudyPathInput): Promise<StudyPathResult>
explainError(input: ExplainErrorInput): Promise<ExplainErrorResult>
}
/**
@@ -290,6 +322,9 @@ export interface AiClientService {
input: StudyPathInput
) => Promise<ActionState<StudyPathResult>>
getAiUsageStats?: () => Promise<ActionState<AiUsageStats>>
explainError?: (
input: ExplainErrorInput
) => Promise<ActionState<ExplainErrorResult>>
/** 预留埋点接口 */
trackEvent?: (event: string, payload?: Record<string, unknown>) => void
}