Files
NextEdu/docs/superpowers/plans/2026-07-06-component-refactoring.md
2026-07-06 17:49:14 +08:00

43 KiB
Raw Blame History

组件化重构专项实施计划

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: 拆分 9 个巨型文件、收敛 49 个重复组件到共享底座、补全 ErrorBoundary/StatsGrid/SkeletonCard 底座

Architecture: 两阶段 — Phase 1 收敛+补全底座ErrorBoundary 抽取 + StatsGrid/SkeletonCard 新增Phase 2 按模块垂直闭环3 批风险分级:低/中/高)

Tech Stack: Next.js 16 / React 19 / TypeScript strict / Tailwind v4 / next-intl / shadcn/ui

Spec: docs/superpowers/specs/2026-07-06-component-refactoring-design.md


文件结构总览

Phase 1 新增/修改文件

文件 操作 职责
src/shared/components/ui/error-boundary.tsx 新增 基础 ErrorBoundary 类组件(从 section-error-boundary.tsx 抽取)
src/shared/components/ui/stats-grid.tsx 新增 StatsGrid 容器,基于 StatCard 渲染网格
src/shared/components/ui/skeleton.tsx 扩展 新增 SkeletonCard variant保留原 Skeleton 原语)
src/shared/components/section-error-boundary.tsx 修改 内部改用 ErrorBoundary 基础组件
src/shared/components/route-error.tsx 修改 内部改用 ErrorBoundary 基础组件
src/shared/components/widget-boundary.tsx 修改 内部改用 ErrorBoundary 基础组件

Phase 2 模块层(按批次)

每批次删除模块层 *-filters.tsx/*-stats-cards.tsx/*-skeleton.tsx/*-error-boundary.tsx,迁移到底座。巨型文件拆为容器+子组件。


Phase 1底座收敛与补全

Task 1: 抽取 ErrorBoundary 基础组件

Files:

  • Create: src/shared/components/ui/error-boundary.tsx

  • Step 1: 创建 error-boundary.tsx

"use client"

import { Component, type ErrorInfo, type ReactNode } from "react"

/**
 * 基础 ErrorBoundary 类组件。
 * 不直接使用,请使用 SectionErrorBoundary / RouteErrorBoundary / WidgetBoundary 等 preset。
 *
 * 支持两种降级形式:
 * 1. `fallback`(函数 `(error, reset) => ReactNode`)— 完全自定义降级 UI优先级最高
 * 2. `fallback`ReactNode— 静态降级 UI
 */
interface ErrorBoundaryProps {
  children: ReactNode
  /** 自定义降级 UI可为静态 ReactNode 或函数式 (error, reset) => ReactNode */
  fallback?: ReactNode | ((error: Error, reset: () => void) => ReactNode)
  /** 错误回调(用于埋点/监控) */
  onError?: (error: Error, info: ErrorInfo) => void
}

interface ErrorBoundaryState {
  hasError: boolean
  error: Error | null
}

export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
  constructor(props: ErrorBoundaryProps) {
    super(props)
    this.state = { hasError: false, error: null }
  }

  static getDerivedStateFromError(error: Error): ErrorBoundaryState {
    return { hasError: true, error }
  }

  componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
    console.error("[ErrorBoundary]", error, errorInfo)
    this.props.onError?.(error, errorInfo)
  }

  private handleReset = (): void => {
    this.setState({ hasError: false, error: null })
  }

  render(): ReactNode {
    if (this.state.hasError && this.state.error) {
      const { fallback } = this.props
      if (typeof fallback === "function") {
        return fallback(this.state.error, this.handleReset)
      }
      if (fallback !== undefined) {
        return fallback
      }
      return null
    }
    return this.props.children
  }
}
  • Step 2: 验证 tsc + lint

Run: npx tsc --noEmit Expected: 零错误

Run: npm run lint -- --filter src/shared/components/ui/error-boundary.tsx Expected: 零错误

  • Step 3: Commit
git add src/shared/components/ui/error-boundary.tsx
git commit -m "feat(shared): 新增 ErrorBoundary 基础组件"

Task 2: 收敛 SectionErrorBoundary 为 preset

Files:

  • Modify: src/shared/components/section-error-boundary.tsx

  • Step 1: 重写 section-error-boundary.tsx

将原文件148 行)改写为:删除内部 SectionErrorBoundaryBase 类,改用 ErrorBoundary 基础组件。保留外部 APISectionErrorBoundary 函数式 + i18n不变。

"use client"

import type { JSX } from "react"
import type { ReactNode, ErrorInfo } from "react"
import { useTranslations } from "next-intl"
import { AlertCircle, RefreshCw } from "lucide-react"

import { Button } from "@/shared/components/ui/button"
import { ErrorBoundary } from "@/shared/components/ui/error-boundary"

interface SectionErrorBoundaryProps {
  children: ReactNode
  /** i18n namespace默认 "common" */
  namespace?: string
  /** 自定义降级 UI函数形式优先级最高 */
  fallback?: (error: Error, reset: () => void) => ReactNode
  /** 显式覆盖标题 */
  title?: string
  /** 显式覆盖描述 */
  description?: string
  /** 显式覆盖重试按钮文案 */
  retryLabel?: string
  /** 错误回调 */
  onError?: (error: Error, info: ErrorInfo) => void
}

/**
 * 组件级 Error Boundary preset包裹独立数据区块防止单区块错误导致整页崩溃。
 * 自动使用 next-intl 提供本地化文案;如需完全自定义可传入 `fallback` 函数。
 *
 * @example
 * <SectionErrorBoundary namespace="coursePlans">
 *   <CoursePlanProgress plan={plan} />
 * </SectionErrorBoundary>
 */
export function SectionErrorBoundary({
  children,
  namespace = "common",
  fallback,
  title,
  description,
  retryLabel,
  onError,
}: SectionErrorBoundaryProps): JSX.Element {
  const t = useTranslations(namespace)
  const effectiveTitle = title ?? t("error.boundaryTitle")
  const effectiveDescription = description ?? t("error.boundaryDescription")
  const effectiveRetryLabel = retryLabel ?? t("error.retry")

  const defaultFallback = (_error: Error, reset: () => void): ReactNode => (
    <div
      role="alert"
      aria-live="assertive"
      className="flex flex-col items-center justify-center space-y-3 rounded-lg border border-dashed p-8 text-center"
    >
      <AlertCircle className="h-8 w-8 text-muted-foreground" aria-hidden="true" />
      <div className="space-y-1">
        <p className="text-sm font-medium">{effectiveTitle}</p>
        <p className="text-xs text-muted-foreground">{effectiveDescription}</p>
      </div>
      <Button
        variant="outline"
        size="sm"
        onClick={reset}
        aria-label={effectiveRetryLabel}
      >
        <RefreshCw className="mr-1.5 h-3.5 w-3.5" aria-hidden="true" />
        {effectiveRetryLabel}
      </Button>
    </div>
  )

  return (
    <ErrorBoundary fallback={fallback ?? defaultFallback} onError={onError}>
      {children}
    </ErrorBoundary>
  )
}
  • Step 2: 验证外部 API 不变

Run: npx tsc --noEmit Expected: 零错误

Run: npm run lint Expected: 零新增错误

  • Step 3: Grep 确认引用未受影响

Run Grep: SectionErrorBoundary in src/ Expected: 所有引用文件modules 层 error-boundary preset、各模块页面类型检查通过

  • Step 4: Commit
git add src/shared/components/section-error-boundary.tsx
git commit -m "refactor(shared): SectionErrorBoundary 收敛为 ErrorBoundary preset"

Task 3: 收敛 RouteErrorBoundary 为 preset

Files:

  • Modify: src/shared/components/route-error.tsx

  • Step 1: 重写 route-error.tsx

RouteErrorBoundary 当前不使用类组件(仅函数式 + EmptyState但其语义是路由级错误边界。为保持一致性改为基于 ErrorBoundary 的 preset但保留 reset prop来自 Next.js error.tsxreset() 函数)。

"use client"

import type { ReactNode } from "react"
import { AlertCircle } from "lucide-react"
import { useTranslations } from "next-intl"

import { EmptyState } from "@/shared/components/ui/empty-state"
import { ErrorBoundary } from "@/shared/components/ui/error-boundary"

/**
 * 通用路由错误边界组件 preset。
 * 供各模块 error.tsx 复用。
 *
 * @example
 * // app/(dashboard)/admin/attendance/error.tsx
 * "use client"
 * import { RouteErrorBoundary } from "@/shared/components/route-error"
 * export default function Error({ reset }: { error: Error; reset: () => void }) {
 *   return <RouteErrorBoundary reset={reset} namespace="attendance" />
 * }
 */
export function RouteErrorBoundary({
  reset,
  namespace = "attendance",
}: {
  reset: () => void
  namespace?: string
}): ReactNode {
  const t = useTranslations(namespace)
  return (
    <ErrorBoundary
      fallback={() => (
        <div className="flex h-full flex-col items-center justify-center space-y-4 p-8">
          <EmptyState
            icon={AlertCircle}
            title={t("errors.unexpected")}
            description={t("errors.unexpected")}
            action={{
              label: t("actions.retry"),
              onClick: () => reset(),
            }}
            className="border-none shadow-none h-auto"
          />
        </div>
      )}
    >
      {/* RouteErrorBoundary 作为 error.tsx 的渲染组件children 不适用,直接渲染 fallback */}
      {null}
    </ErrorBoundary>
  )
}

注意:RouteErrorBoundary 实际上是在 error.tsx 中被调用渲染错误 UI而非包裹 children。因此这里 childrennull,仅用 ErrorBoundary 渲染 fallback。若未来需要包裹式用法可扩展。

  • Step 2: 验证

Run: npx tsc --noEmit Expected: 零错误

Run: npm run lint Expected: 零新增错误

  • Step 3: Commit
git add src/shared/components/route-error.tsx
git commit -m "refactor(shared): RouteErrorBoundary 收敛为 ErrorBoundary preset"

Task 4: 收敛 WidgetBoundary 为 preset

Files:

  • Modify: src/shared/components/widget-boundary.tsx

  • Step 1: 重写 widget-boundary.tsx

删除内部 WidgetErrorBoundary 类,改用 ErrorBoundary。保留 WidgetSkeletonSuspense 三合一能力。

"use client"

import { Suspense, type ReactNode } from "react"
import { AlertCircle } from "lucide-react"
import { useTranslations } from "next-intl"

import { Button } from "@/shared/components/ui/button"
import { Skeleton } from "@/shared/components/ui/skeleton"
import { ErrorBoundary } from "@/shared/components/ui/error-boundary"

interface WidgetBoundaryProps {
  children: ReactNode
  title?: string
  skeletonHeight?: number
  fallbackDescription?: string
  retryLabel?: string
}

function WidgetSkeleton({
  height,
  loadingAriaLabel,
}: {
  height: number
  loadingAriaLabel: string
}): ReactNode {
  return (
    <div
      role="status"
      aria-label={loadingAriaLabel}
      aria-live="polite"
      className="space-y-3 p-4"
      style={{ minHeight: height }}
    >
      <Skeleton className="h-6 w-1/3" />
      <Skeleton className="h-4 w-full" />
      <Skeleton className="h-4 w-2/3" />
      <Skeleton className="h-32 w-full" />
    </div>
  )
}

export function WidgetBoundary({
  children,
  title,
  skeletonHeight = 200,
  fallbackDescription,
  retryLabel,
}: WidgetBoundaryProps): ReactNode {
  const t = useTranslations("common")
  const effectiveTitle = title ?? t("widget.block")
  const effectiveFallbackDescription = fallbackDescription ?? t("widget.defaultFallback")
  const effectiveRetryLabel = retryLabel ?? t("widget.retry")
  const loadFailedMessage = t("widget.loadFailed", { title: effectiveTitle })
  const retryAriaLabel = t("widget.retryAriaLabel", { title: effectiveTitle })
  const loadingAriaLabel = t("widget.loadingAriaLabel", { title: effectiveTitle })

  const errorFallback = (_error: Error, reset: () => void): ReactNode => (
    <div
      role="alert"
      aria-live="assertive"
      className="flex h-full min-h-[200px] flex-col items-center justify-center gap-3 rounded-lg border border-destructive/30 bg-destructive/5 p-6 text-center"
    >
      <AlertCircle className="h-8 w-8 text-destructive" aria-hidden="true" />
      <div className="space-y-1">
        <p className="text-sm font-medium text-foreground">{loadFailedMessage}</p>
        <p className="text-xs text-muted-foreground">{effectiveFallbackDescription}</p>
      </div>
      <Button
        size="sm"
        variant="outline"
        onClick={reset}
        aria-label={retryAriaLabel}
      >
        {effectiveRetryLabel}
      </Button>
    </div>
  )

  return (
    <ErrorBoundary fallback={errorFallback}>
      <Suspense
        fallback={<WidgetSkeleton height={skeletonHeight} loadingAriaLabel={loadingAriaLabel} />}
      >
        {children}
      </Suspense>
    </ErrorBoundary>
  )
}
  • Step 2: 验证

Run: npx tsc --noEmit Expected: 零错误

Run: npm run lint Expected: 零新增错误

  • Step 3: Commit
git add src/shared/components/widget-boundary.tsx
git commit -m "refactor(shared): WidgetBoundary 收敛为 ErrorBoundary preset"

Task 5: 新增 StatsGrid 容器

Files:

  • Create: src/shared/components/ui/stats-grid.tsx

  • Step 1: 创建 stats-grid.tsx

import type { ComponentType, ReactNode } from "react"

import { cn } from "@/shared/lib/utils"
import { StatCard } from "@/shared/components/ui/stat-card"

/**
 * 统计卡片网格容器:基于 StatCard 渲染响应式网格。
 *
 * 覆盖以下重复模式:
 * - attendance-stats-cards / error-book-stats-cards / elective-stats-cards
 * - practice-stats-cards / practice-overview-stats-cards
 * - grade-stats-card / analytics-stats-cards
 */
interface StatItem {
  /** 卡片标题 */
  label: string
  /** 统计数值 */
  value: string | number
  /** 图标组件 */
  icon?: ComponentType<{ className?: string }>
  /** 描述文本 */
  description?: string
  /** 图标颜色类名 */
  color?: string
  /** 是否高亮 */
  highlight?: boolean
  /** 点击跳转链接 */
  href?: string
  /** 数值自定义类名 */
  valueClassName?: string
}

interface StatsGridProps {
  /** 统计项列表 */
  items: StatItem[]
  /** 列数默认响应式mobile=1, md=2, lg=4 */
  columns?: 2 | 3 | 4
  /** 加载态 */
  isLoading?: boolean
  /** 容器额外类名 */
  className?: string
}

const columnsClass: Record<2 | 3 | 4, string> = {
  2: "grid-cols-1 md:grid-cols-2",
  3: "grid-cols-1 md:grid-cols-2 lg:grid-cols-3",
  4: "grid-cols-1 md:grid-cols-2 lg:grid-cols-4",
}

export function StatsGrid({
  items,
  columns = 4,
  isLoading = false,
  className,
}: StatsGridProps): ReactNode {
  return (
    <div className={cn("grid gap-4", columnsClass[columns], className)}>
      {items.map((item) => (
        <StatCard
          key={item.label}
          title={item.label}
          value={item.value}
          icon={item.icon}
          description={item.description}
          color={item.color}
          highlight={item.highlight}
          href={item.href}
          isLoading={isLoading}
          valueClassName={item.valueClassName}
        />
      ))}
    </div>
  )
}
  • Step 2: 验证

Run: npx tsc --noEmit Expected: 零错误

Run: npm run lint Expected: 零新增错误

  • Step 3: Commit
git add src/shared/components/ui/stats-grid.tsx
git commit -m "feat(shared): 新增 StatsGrid 容器组件"

Task 6: 扩展 Skeleton新增 SkeletonCard variant

Files:

  • Modify: src/shared/components/ui/skeleton.tsx

  • Step 1: 扩展 skeleton.tsx

保留原 Skeleton 原语,新增 SkeletonCard 配置驱动变体。

import type { ReactNode } from "react"

import { cn } from "@/shared/lib/utils"
import { Card, CardContent, CardHeader } from "@/shared/components/ui/card"

function Skeleton({
  className,
  ...props
}: React.HTMLAttributes<HTMLDivElement>) {
  return (
    <div
      className={cn("animate-pulse rounded-md bg-muted", className)}
      {...props}
    />
  )
}

interface SkeletonCardProps {
  variant: "table" | "list" | "chart" | "form" | "stats-grid"
  rows?: number
  className?: string
}

/**
 * 配置驱动骨架卡片:按 variant 渲染不同布局的骨架占位。
 *
 * 覆盖以下重复模式:
 * - message-list-skeleton / announcement-list-skeleton → variant="list"
 * - audit-log-table-skeleton → variant="table"
 * - class-skeleton / school-skeleton → variant="form"
 * - ai-skeleton → variant="chart"
 * - dashboard-loading-skeleton → variant="stats-grid"
 * - lesson-plan-skeleton → variant="form"(备课编辑器布局)
 */
export function SkeletonCard({
  variant,
  rows = 5,
  className,
}: SkeletonCardProps): ReactNode {
  switch (variant) {
    case "table":
      return (
        <Card className={className}>
          <CardHeader>
            <Skeleton className="h-5 w-1/4" />
          </CardHeader>
          <CardContent className="space-y-3">
            {Array.from({ length: rows }).map((_, i) => (
              <div key={i} className="flex gap-4">
                <Skeleton className="h-4 flex-1" />
                <Skeleton className="h-4 w-20" />
                <Skeleton className="h-4 w-16" />
              </div>
            ))}
          </CardContent>
        </Card>
      )
    case "list":
      return (
        <div className={cn("space-y-3", className)}>
          {Array.from({ length: rows }).map((_, i) => (
            <Card key={i}>
              <CardContent className="flex items-center gap-4 p-4">
                <Skeleton className="h-10 w-10 rounded-full" />
                <div className="flex-1 space-y-2">
                  <Skeleton className="h-4 w-1/3" />
                  <Skeleton className="h-3 w-1/2" />
                </div>
                <Skeleton className="h-8 w-20" />
              </CardContent>
            </Card>
          ))}
        </div>
      )
    case "chart":
      return (
        <Card className={className}>
          <CardHeader>
            <Skeleton className="h-5 w-1/3" />
          </CardHeader>
          <CardContent>
            <Skeleton className="h-[200px] w-full" />
          </CardContent>
        </Card>
      )
    case "form":
      return (
        <Card className={className}>
          <CardContent className="space-y-4 p-6">
            {Array.from({ length: rows }).map((_, i) => (
              <div key={i} className="space-y-2">
                <Skeleton className="h-4 w-1/4" />
                <Skeleton className="h-10 w-full" />
              </div>
            ))}
            <Skeleton className="h-10 w-32" />
          </CardContent>
        </Card>
      )
    case "stats-grid":
      return (
        <div className={cn("grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4", className)}>
          {Array.from({ length: 4 }).map((_, i) => (
            <Card key={i}>
              <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
                <Skeleton className="h-4 w-[100px]" />
                <Skeleton className="h-4 w-4 rounded-full" />
              </CardHeader>
              <CardContent>
                <Skeleton className="mb-2 h-8 w-[60px]" />
                <Skeleton className="h-3 w-[140px]" />
              </CardContent>
            </Card>
          ))}
        </div>
      )
  }
}

export { Skeleton, SkeletonCard }
  • Step 2: 验证

Run: npx tsc --noEmit Expected: 零错误

Run: npm run lint Expected: 零新增错误

  • Step 3: Grep 确认原 Skeleton 引用未受影响

Run Grep: from "@/shared/components/ui/skeleton" in src/ Expected: 所有引用仍可正常导入 Skeleton(原 named export 保留)

  • Step 4: Commit
git add src/shared/components/ui/skeleton.tsx
git commit -m "feat(shared): Skeleton 新增 SkeletonCard variant"

Task 7: Phase 1 整体验证 + 架构文档同步

Files:

  • Modify: docs/architecture/004_architecture_impact_map.md

  • Modify: docs/architecture/005_architecture_data.json

  • Modify: docs/troubleshooting/known-issues.md

  • Step 1: 全量验证

Run: npx tsc --noEmit Expected: 零错误

Run: npm run lint Expected: 零新增错误

  • Step 2: 同步 004 架构图

docs/architecture/004_architecture_impact_map.mdshared/components 章节,新增条目:

| 组件 | 路径 | 职责 |
|------|------|------|
| ErrorBoundary | ui/error-boundary.tsx | 基础 ErrorBoundary 类组件preset 的底座 |
| StatsGrid | ui/stats-grid.tsx | 统计卡片网格容器,基于 StatCard |
| SkeletonCard | ui/skeleton.tsx | 配置驱动骨架卡片table/list/chart/form/stats-grid |

并在文末新增"组件化重构专项"章节标题(内容随批次补充)。

  • Step 3: 同步 005 JSON

docs/architecture/005_architecture_data.jsonsharedComponents 节点新增:

{
  "errorBoundary": {
    "path": "shared/components/ui/error-boundary.tsx",
    "type": "class",
    "exports": ["ErrorBoundary"],
    "role": "基础 ErrorBoundarypreset 底座"
  },
  "statsGrid": {
    "path": "shared/components/ui/stats-grid.tsx",
    "type": "function",
    "exports": ["StatsGrid"],
    "role": "统计卡片网格容器"
  },
  "skeletonCard": {
    "path": "shared/components/ui/skeleton.tsx",
    "type": "function",
    "exports": ["Skeleton", "SkeletonCard"],
    "role": "骨架原语 + 配置驱动变体"
  }
}

更新 lastUpdate 字段,追加"组件化重构 Phase 1底座收敛与补全"。

  • Step 4: known-issues.md 新增章节

docs/troubleshooting/known-issues.md 末尾新增:

## 组件化重构专项 - Phase 12026-07-06

### 底座组件使用规则

| 规则 | 正确写法 | 错误写法 |
|------|---------|---------|
| 错误边界必须用 preset | `<SectionErrorBoundary namespace="x">` | 自行实现类组件 |
| 统计卡片必须用 StatsGrid | `<StatsGrid items={[...]} />` | 手写 grid + StatCard 循环 |
| 骨架卡片必须用 SkeletonCard | `<SkeletonCard variant="table" />` | 手写 Card + Skeleton 布局 |
| 新增 *-filters.tsx 禁止 | 用 `<FilterBar>` 组合 | 新建模块专属筛选器文件 |
  • Step 5: Commit
git add docs/architecture/004_architecture_impact_map.md docs/architecture/005_architecture_data.json docs/troubleshooting/known-issues.md
git commit -m "docs(architecture): 同步组件化重构 Phase 1 底座节点"

Phase 2 - 第 1 批低风险模块grades / attendance / error-book / elective

Task 8: grades 模块闭环

Files:

  • Modify: src/modules/grades/components/batch-grade-entry.tsx (>450 行)

  • Modify: src/modules/grades/components/grade-record-list.tsx (>450 行)

  • Delete: src/modules/grades/components/grade-stats-card.tsx

  • Delete: src/modules/grades/components/grade-filters.tsx

  • Delete: src/modules/grades/components/grade-query-filters.tsx

  • Delete: src/modules/grades/components/analytics-filters.tsx

  • Step 1: 模块审计

Read 以下文件,记录外部 API 与依赖关系:

  • src/modules/grades/components/batch-grade-entry.tsx
  • src/modules/grades/components/grade-record-list.tsx
  • src/modules/grades/components/grade-stats-card.tsx
  • src/modules/grades/components/grade-filters.tsx
  • src/modules/grades/components/grade-query-filters.tsx
  • src/modules/grades/components/analytics-filters.tsx

Grep 所有引用上述文件的 import 语句,记录调用方清单。

  • Step 2: 拆分 batch-grade-entry.tsx

按职责拆为容器 + 子组件(保持 batch-grade-entry.tsx 作为容器,外部 API 不变):

  • batch-grade-entry.tsx — 容器,负责状态编排与 Server Action 调用
  • batch-grade-entry-form.tsx — 表单子组件(学生列表 + 分数输入网格)
  • batch-grade-entry-toolbar.tsx — 工具栏子组件(保存/取消/导入按钮)

目标:容器 ≤300 行,子组件各 ≤200 行。

  • Step 3: 拆分 grade-record-list.tsx

按职责拆为:

  • grade-record-list.tsx — 容器,列表查询与分页
  • grade-record-row.tsx — 行渲染子组件
  • grade-record-detail.tsx — 详情弹窗子组件

目标:容器 ≤300 行,子组件各 ≤200 行。

  • Step 4: 迁移 grade-stats-card 到 StatsGrid

在调用 grade-stats-card.tsx 的页面中,替换为:

import { StatsGrid } from "@/shared/components/ui/stats-grid"

const items = [
  { label: "平均分", value: avg, icon: TrendingUp, color: "text-emerald-500" },
  { label: "最高分", value: max, icon: ArrowUp, color: "text-blue-500" },
  // ...
]

<StatsGrid items={items} isLoading={isLoading} />

删除 grade-stats-card.tsx

  • Step 5: 迁移 grade-filters / grade-query-filters / analytics-filters 到 FilterBar

在调用方页面中,替换为 <FilterBar> + <FilterSearchInput> + Select 组件组合:

import { FilterBar, FilterSearchInput } from "@/shared/components/ui/filter-bar"
import { Select } from "@/shared/components/ui/select"

<FilterBar hasFilters={hasFilters} onReset={handleReset} layout="wrap">
  <FilterSearchInput value={search} onChange={setSearch} placeholder="搜索学生..." />
  <Select value={classId} onValueChange={setClassId}>
    {/* options */}
  </Select>
</FilterBar>

删除 grade-filters.tsxgrade-query-filters.tsxanalytics-filters.tsx

  • Step 6: 验证

Run: npx tsc --noEmit Expected: 零错误

Run: npm run lint Expected: 零新增错误

Run Grep: grade-stats-card|grade-filters|grade-query-filters|analytics-filters in src/ Expected: 仅在删除的文件中出现(已删除则无匹配)

手动回归:访问成绩列表页、批量录入页、分析页,确认筛选/统计/列表行为正常。

  • Step 7: Commit
git add src/modules/grades/
git commit -m "refactor(grades): 组件化重构 - 拆分巨型文件 + 迁移到底座"

Task 9: attendance 模块闭环

Files:

  • Delete: src/modules/attendance/components/attendance-stats-cards.tsx

  • Delete: src/modules/attendance/components/attendance-stats-card.tsx(单数,与复数重复)

  • Delete: src/modules/attendance/components/attendance-filters.tsx

  • Step 1: 模块审计

Read 上述 3 个文件 + 调用方页面,记录外部 API。

  • Step 2: 迁移 attendance-stats-cards / attendance-stats-card 到 StatsGrid

合并单数与复数版本,统一替换为 <StatsGrid>。删除 attendance-stats-cards.tsxattendance-stats-card.tsx

  • Step 3: 迁移 attendance-filters 到 FilterBar

调用方替换为 <FilterBar> 组合。删除 attendance-filters.tsx

  • Step 4: 验证

Run: npx tsc --noEmit + npm run lint Expected: 零新增错误

Run Grep: attendance-stats-cards|attendance-stats-card|attendance-filters in src/ Expected: 无匹配

手动回归:考勤列表页、统计页。

  • Step 5: Commit
git add src/modules/attendance/
git commit -m "refactor(attendance): 组件化重构 - 迁移到 StatsGrid/FilterBar 底座"

Task 10: error-book 模块闭环

Files:

  • Delete: src/modules/error-book/components/error-book-stats-cards.tsx

  • Delete: src/modules/error-book/components/analytics-stats-cards.tsx

  • Delete: src/modules/error-book/components/error-book-filters.tsx

  • Step 1: 模块审计

Read 上述 3 个文件 + 调用方页面。

  • Step 2: 迁移 stats-cards 到 StatsGrid

error-book-stats-cards.tsxanalytics-stats-cards.tsx 均替换为 <StatsGrid>。删除原文件。

  • Step 3: 迁移 error-book-filters 到 FilterBar

调用方替换为 <FilterBar> 组合。删除 error-book-filters.tsx

  • Step 4: 验证

Run: npx tsc --noEmit + npm run lint Expected: 零新增错误

Run Grep: error-book-stats-cards|analytics-stats-cards|error-book-filters in src/ Expected: 无匹配

手动回归:错题本列表页、分析页。

  • Step 5: Commit
git add src/modules/error-book/
git commit -m "refactor(error-book): 组件化重构 - 迁移到 StatsGrid/FilterBar 底座"

Task 11: elective 模块闭环

Files:

  • Delete: src/modules/elective/components/elective-stats-cards.tsx

  • Delete: src/modules/elective/components/elective-filters.tsx

  • Step 1: 模块审计

Read 上述 2 个文件 + 调用方页面。

  • Step 2: 迁移 elective-stats-cards 到 StatsGrid

调用方替换为 <StatsGrid>。删除 elective-stats-cards.tsx

  • Step 3: 迁移 elective-filters 到 FilterBar

调用方替换为 <FilterBar> 组合。删除 elective-filters.tsx

  • Step 4: 验证

Run: npx tsc --noEmit + npm run lint Expected: 零新增错误

Run Grep: elective-stats-cards|elective-filters in src/ Expected: 无匹配

手动回归:选修课列表页、统计页。

  • Step 5: Commit
git add src/modules/elective/
git commit -m "refactor(elective): 组件化重构 - 迁移到 StatsGrid/FilterBar 底座"

Task 12: 第 1 批收尾 - 文档同步

Files:

  • Modify: docs/architecture/004_architecture_impact_map.md

  • Modify: docs/architecture/005_architecture_data.json

  • Modify: docs/troubleshooting/known-issues.md

  • Step 1: 同步 004 / 005

更新 grades/attendance/error-book/elective 模块的 exports 清单(删除旧组件、新增子组件)。更新 lastUpdate

  • Step 2: known-issues.md 补充第 1 批章节

记录第 1 批遇到的拆分模式与底座使用规则(速查手册风格)。

  • Step 3: Commit
git add docs/
git commit -m "docs(architecture): 同步组件化重构第 1 批grades/attendance/error-book/elective"

Phase 2 - 第 2 批中风险模块homework / exams / textbooks

Task 13: homework 模块闭环

Files:

  • Modify: src/modules/homework/components/homework-take-view.tsx (428 行)

  • Modify: src/modules/homework/components/homework-grading-view.tsx (>450 行)

  • Delete: src/modules/homework/components/assignment-filters.tsx

  • Step 1: 模块审计

Read 上述 3 个文件 + 调用方页面。

  • Step 2: 拆分 homework-take-view.tsx

按作答流程拆为:

  • homework-take-view.tsx — 容器(题目导航 + 自动保存 + 提交)
  • homework-take-question.tsx — 单题作答子组件
  • homework-take-sidebar.tsx — 题目导航侧边栏

目标:容器 ≤300 行,子组件各 ≤200 行。

  • Step 3: 拆分 homework-grading-view.tsx

按批改流程拆为:

  • homework-grading-view.tsx — 容器(学生切换 + 评分 + 保存)

  • homework-grading-panel.tsx — 单题评分面板

  • homework-grading-toolbar.tsx — 工具栏(上/下学生、保存、批改进度)

  • Step 4: 迁移 assignment-filters 到 FilterBar

删除 assignment-filters.tsx,调用方替换为 <FilterBar> 组合。

  • Step 5: 验证

Run: npx tsc --noEmit + npm run lint Expected: 零新增错误

Run Grep: assignment-filters in src/ Expected: 无匹配

手动回归:作业作答页、批改页、列表页。

  • Step 6: Commit
git add src/modules/homework/
git commit -m "refactor(homework): 组件化重构 - 拆分作答/批改视图 + 迁移 FilterBar"

Task 14: exams 模块闭环

Files:

  • Modify: src/modules/exams/components/exam-assembly.tsx (>450 行)

  • Delete: src/modules/exams/components/exam-filters.tsx

  • Step 1: 模块审计

Read 上述 2 个文件 + 调用方页面。

  • Step 2: 拆分 exam-assembly.tsx

按组卷流程拆为:

  • exam-assembly.tsx — 容器(题目池 + 已选题目 + 试卷配置)
  • exam-assembly-question-pool.tsx — 题目池子组件
  • exam-assembly-selected.tsx — 已选题目子组件
  • exam-assembly-config.tsx — 试卷配置子组件

目标:容器 ≤300 行,子组件各 ≤200 行。

  • Step 3: 迁移 exam-filters 到 FilterBar

删除 exam-filters.tsx,调用方替换为 <FilterBar> 组合。

  • Step 4: 验证

Run: npx tsc --noEmit + npm run lint Expected: 零新增错误

手动回归:组卷页、试卷列表页。

  • Step 5: Commit
git add src/modules/exams/
git commit -m "refactor(exams): 组件化重构 - 拆分组卷组件 + 迁移 FilterBar"

Task 15: textbooks 模块闭环

Files:

  • Modify: src/modules/textbooks/components/textbook-reader.tsx (>450 行)

  • Modify: src/modules/textbooks/components/knowledge-graph-inner.tsx (411 行)

  • Delete: src/modules/textbooks/components/textbook-filters.tsx

  • Delete: src/modules/textbooks/components/section-error-boundary.tsx(与 shared 同名)

  • Step 1: 模块审计

Read 上述 4 个文件 + 调用方页面。参考 docs/architecture/003_ui_refactoring_plan.md 的拆分方案。

  • Step 2: 拆分 textbook-reader.tsx

按 003 计划拆为:

  • textbook-reader.tsx — 容器(目录 + 内容区 + 知识点面板)
  • textbook-reader-content.tsx — 内容区子组件
  • textbook-reader-toc.tsx — 目录子组件

目标:容器 ≤300 行,子组件各 ≤200 行。

  • Step 3: 拆分 knowledge-graph-inner.tsx

按职责拆为:

  • knowledge-graph-inner.tsx — 容器(图渲染 + 节点交互)

  • knowledge-graph-node.tsx — 节点渲染子组件

  • knowledge-graph-controls.tsx — 控制面板子组件

  • Step 4: 迁移 textbook-filters 到 FilterBar

删除 textbook-filters.tsx,调用方替换为 <FilterBar> 组合。

  • Step 5: 删除 textbooks 同名 section-error-boundary.tsx

src/modules/textbooks/components/section-error-boundary.tsxshared/components/section-error-boundary.tsx 同名。删除模块层版本,调用方改为直接 import shared 层 SectionErrorBoundary

  • Step 6: 验证

Run: npx tsc --noEmit + npm run lint Expected: 零新增错误

Run Grep: textbook-filters|modules/textbooks/components/section-error-boundary in src/ Expected: 无匹配

手动回归:教材阅读页、知识图谱页、教材列表页。

  • Step 7: Commit
git add src/modules/textbooks/
git commit -m "refactor(textbooks): 组件化重构 - 拆分阅读器/知识图谱 + 迁移底座"

Task 16: 第 2 批收尾 - 文档同步

Files:

  • Modify: docs/architecture/004_architecture_impact_map.md

  • Modify: docs/architecture/005_architecture_data.json

  • Modify: docs/troubleshooting/known-issues.md

  • Step 1: 同步 004 / 005

更新 homework/exams/textbooks 模块的 exports 清单。更新 lastUpdate

  • Step 2: known-issues.md 补充第 2 批章节

记录拆分模式(编辑器/组卷类组件拆分注意事项)。

  • Step 3: Commit
git add docs/
git commit -m "docs(architecture): 同步组件化重构第 2 批homework/exams/textbooks"

Phase 2 - 第 3 批高风险模块lesson-preparation / ai / dashboard

Task 17: lesson-preparation 模块闭环

Files:

  • Modify: src/modules/lesson-preparation/components/lesson-plan-editor.tsx (>450 行)

  • Delete: src/modules/lesson-preparation/components/lesson-plan-filters.tsx

  • Delete: src/modules/lesson-preparation/components/lesson-plan-skeleton.tsx

  • Delete: src/modules/lesson-preparation/components/lesson-plan-error-boundary.tsx

  • Step 1: 模块审计

Read 上述 4 个文件 + 调用方页面。lesson-plan-editor 是核心交互组件,需特别关注 Tiptap 编辑器集成。

  • Step 2: 拆分 lesson-plan-editor.tsx

按三列布局拆为:

  • lesson-plan-editor.tsx — 容器(三列布局编排 + 状态管理)
  • lesson-plan-sidebar.tsx — 左侧结构树子组件
  • lesson-plan-paper.tsx — 中间纸张区子组件(含 Tiptap 编辑器)
  • lesson-plan-detail-panel.tsx — 右侧详情面板子组件

目标:容器 ≤300 行,子组件各 ≤200 行。注意 immediatelyRender: false SSR 配置必须保留。

  • Step 3: 迁移 lesson-plan-filters 到 FilterBar

删除 lesson-plan-filters.tsx,调用方替换为 <FilterBar> 组合。

  • Step 4: 迁移 lesson-plan-skeleton 到 SkeletonCard

调用方替换为 <SkeletonCard variant="form" />。删除 lesson-plan-skeleton.tsx

  • Step 5: 迁移 lesson-plan-error-boundary 到 SectionErrorBoundary

调用方替换为 <SectionErrorBoundary namespace="lessonPreparation">。删除 lesson-plan-error-boundary.tsx

  • Step 6: 验证

Run: npx tsc --noEmit + npm run lint Expected: 零新增错误

Run Grep: lesson-plan-filters|lesson-plan-skeleton|lesson-plan-error-boundary in src/ Expected: 无匹配

手动回归:备课编辑器(节点展开/折叠、右键菜单、AI 辅助、保存重载)。

  • Step 7: Commit
git add src/modules/lesson-preparation/
git commit -m "refactor(lesson-preparation): 组件化重构 - 拆分编辑器 + 迁移底座"

Task 18: ai 模块闭环

Files:

  • Modify: src/modules/ai/components/ai-chat-panel.tsx (417 行)

  • Delete: src/modules/ai/components/ai-skeleton.tsx

  • Delete: src/modules/ai/components/ai-error-boundary.tsx

  • Step 1: 模块审计

Read 上述 3 个文件 + 调用方页面。

  • Step 2: 拆分 ai-chat-panel.tsx

按对话交互拆为:

  • ai-chat-panel.tsx — 容器(消息列表 + 输入框 + 状态管理)
  • ai-chat-messages.tsx — 消息列表子组件
  • ai-chat-input.tsx — 输入框子组件(含发送、停止、附件)

目标:容器 ≤300 行,子组件各 ≤200 行。注意对话状态保持,拆分后不能破坏流式响应。

  • Step 3: 迁移 ai-skeleton 到 SkeletonCard

调用方替换为 <SkeletonCard variant="chart" />。删除 ai-skeleton.tsx

  • Step 4: 迁移 ai-error-boundary 到 SectionErrorBoundary

ai-error-boundary.tsx 已是薄包装(委托给 SectionErrorBoundary。直接删除调用方改为 <SectionErrorBoundary namespace="ai">

  • Step 5: 验证

Run: npx tsc --noEmit + npm run lint Expected: 零新增错误

Run Grep: ai-skeleton|ai-error-boundary in src/ Expected: 无匹配

手动回归AI 对话面板(发送消息、流式响应、停止、错误重试)。

  • Step 6: Commit
git add src/modules/ai/
git commit -m "refactor(ai): 组件化重构 - 拆分聊天面板 + 迁移底座"

Task 19: dashboard 模块闭环

Files:

  • Delete: src/modules/dashboard/components/dashboard-loading-skeleton.tsx

  • Modify: src/modules/dashboard/components/admin-dashboard/admin-dashboard.tsx

  • Modify: src/modules/dashboard/components/parent-dashboard/parent-dashboard.tsx

  • Create: src/modules/dashboard/components/dashboard-shell.tsx(仅布局壳抽象)

  • Step 1: 模块审计

Read 4 个角色 dashboard 子组件 + dashboard-loading-skeleton。记录各角色布局差异。

  • Step 2: 抽象 DashboardShell 布局壳

仅抽象布局壳PageHeader + StatsGrid 区 + 内容区),业务逻辑保持各角色独立:

import type { ReactNode, ComponentType } from "react"
import { PageHeader } from "@/shared/components/ui/page-header"
import { StatsGrid, type StatItem } from "@/shared/components/ui/stats-grid"

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} />
      <StatsGrid items={stats} isLoading={statsLoading} />
      {children}
    </div>
  )
}
  • Step 3: 改造 4 个角色 dashboard 使用 DashboardShell

各角色 dashboard 子组件改为:

<DashboardShell title="..." stats={stats} statsLoading={isLoading}>
  {/* 角色专属内容区 */}
</DashboardShell>
  • Step 4: 迁移 dashboard-loading-skeleton 到 SkeletonCard

调用方替换为 <SkeletonCard variant="stats-grid" />。删除 dashboard-loading-skeleton.tsx

  • Step 5: 验证

Run: npx tsc --noEmit + npm run lint Expected: 零新增错误

Run Grep: dashboard-loading-skeleton in src/ Expected: 无匹配

手动回归4 个角色admin/teacher/student/parent分别登录确认 dashboard 卡片渲染与数据正确。

  • Step 6: Commit
git add src/modules/dashboard/
git commit -m "refactor(dashboard): 组件化重构 - 抽象 DashboardShell + 迁移底座"

Task 20: 第 3 批收尾 - 文档同步 + 专项完成

Files:

  • Modify: docs/architecture/004_architecture_impact_map.md

  • Modify: docs/architecture/005_architecture_data.json

  • Modify: docs/troubleshooting/known-issues.md

  • Step 1: 同步 004 / 005

更新 lesson-preparation/ai/dashboard 模块的 exports 清单。更新 lastUpdate。在 004 文末"组件化重构专项"章节写完成总结。

  • Step 2: known-issues.md 补充第 3 批章节

记录高风险模块拆分注意事项(编辑器 SSR 配置、AI 流式响应状态保持、dashboard 布局壳抽象边界)。

  • Step 3: 全量验证

Run: npx tsc --noEmit Expected: 零错误

Run: npm run lint Expected: 零新增错误

Run Grep: 验证 9 个巨型文件行数:

  • src/modules/textbooks/components/textbook-reader.tsx
  • src/modules/lesson-preparation/components/lesson-plan-editor.tsx
  • src/modules/grades/components/batch-grade-entry.tsx
  • src/modules/grades/components/grade-record-list.tsx
  • src/modules/homework/components/homework-take-view.tsx
  • src/modules/homework/components/homework-grading-view.tsx
  • src/modules/exams/components/exam-assembly.tsx
  • src/modules/ai/components/ai-chat-panel.tsx
  • src/modules/textbooks/components/knowledge-graph-inner.tsx

Expected: 全部 ≤500 行

Run Grep: 验证 49 个重复组件已删除(按附录清单逐项 grep

  • Step 4: Commit
git add docs/
git commit -m "docs(architecture): 组件化重构专项完成 - 同步全部文档"

自审清单

Spec 覆盖

  • Phase 1 底座补全Task 1-6 覆盖 ErrorBoundary/StatsGrid/SkeletonCard
  • Phase 1 文档同步Task 7
  • 第 1 批低风险Task 8-12 覆盖 grades/attendance/error-book/elective
  • 第 2 批中风险Task 13-16 覆盖 homework/exams/textbooks
  • 第 3 批高风险Task 17-20 覆盖 lesson-preparation/ai/dashboard
  • 9 个巨型文件拆分Task 8/13/14/15/17/18/19
  • 49 个重复组件迁移:分布在各批次任务中

类型一致性

  • StatItem 在 Task 5 定义Task 19 DashboardShellProps.stats: StatItem[] 一致
  • ErrorBoundaryProps 在 Task 1 定义Task 2/3/4 preset 使用一致
  • SkeletonCardProps.variant 在 Task 6 定义为联合类型Task 17/18/19 使用 variant="form"/"chart"/"stats-grid" 一致

占位符扫描

  • 无 TBD/TODO
  • Phase 2 各任务的拆分代码示例为策略性描述(拆分方向 + 目标行数),实际代码需在审计后编写。这是必要的,因为巨型文件的具体内容需先 Read 才能给出精确拆分代码。每个 Step 都有具体动作Read/拆分/删除/Grep 验证)。

执行选择

Plan complete and saved to docs/superpowers/plans/2026-07-06-component-refactoring.md. Two execution options:

1. Subagent-Driven (recommended) - 每个 Task 派发独立 subagentTask 间 review快速迭代

2. Inline Execution - 在当前会话顺序执行,批量执行 + 检查点 review

Which approach?