refactor(grades,attendance,error-book,elective): 组件化重构第 1 批 - 低风险模块

- grades: 拆分 batch-grade-entry (457→374) + grade-record-list (523→231),新增 4 个子组件
- attendance: 迁移 stats-cards/filters 到 StatsGrid/FilterBar 底座
- error-book: 迁移 stats-cards/filters 到 StatsGrid/FilterBar 底座
- elective: 迁移 stats-cards/filters 到 StatsGrid/FilterBar 底座
- 删除 12 个重复组件,新增 app 层 filter 组件就近放置
- tsc 零错误,lint 无新增错误
This commit is contained in:
SpecialX
2026-07-06 19:01:57 +08:00
parent 11ddc8ccbe
commit 19a05091d3
96 changed files with 8641 additions and 1088 deletions

View File

@@ -0,0 +1,273 @@
<h2>课文锚点时间线布局</h2>
<p class="subtitle">课文作为主轴,节点锚定到课文位置,形成教学流程时间线</p>
<div class="mockup">
<div class="mockup-header">备课编辑器 — 课文锚点时间线</div>
<div class="mockup-body" style="padding:0;">
<div style="font-family:monospace;font-size:12px;line-height:1.6;">
<!-- 顶部工具栏 -->
<div style="background:#1e293b;color:#e2e8f0;padding:8px 12px;display:flex;justify-content:space-between;align-items:center;">
<div style="display:flex;align-items:center;gap:8px;">
<span>📖</span>
<span style="font-weight:bold;">秋天(第一课时)</span>
<span style="background:#334155;padding:2px 8px;border-radius:3px;font-size:10px;">语文 · 一年级上册 · 第一单元</span>
</div>
<div style="display:flex;align-items:center;gap:8px;">
<span style="font-size:10px;color:#94a3b8;">💾 已保存 · 2 分钟前</span>
<span style="background:#334155;padding:4px 8px;border-radius:3px;font-size:10px;">📋 版本历史</span>
<span style="background:#3b82f6;padding:4px 12px;border-radius:3px;font-size:10px;">💾 保存</span>
</div>
</div>
<!-- 主体:左课文 + 右节点时间线 -->
<div style="display:grid;grid-template-columns:1fr 320px;gap:0;background:#fff;min-height:480px;">
<!-- 左侧:课文正文区(带锚点 gutter -->
<div style="display:grid;grid-template-columns:32px 1fr;background:#fffbeb;border-right:1px solid #e2e8f0;">
<!-- 锚点 gutter显示锚点标记 -->
<div style="background:#fef3c7;border-right:1px solid #fde68a;position:relative;">
<!-- 锚点标记 -->
<div style="position:absolute;top:60px;left:4px;background:#3b82f6;color:#fff;border-radius:50%;width:20px;height:20px;display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:bold;cursor:pointer;box-shadow:0 1px 3px rgba(0,0,0,0.2);">1</div>
<div style="position:absolute;top:140px;left:4px;background:#f59e0b;color:#fff;border-radius:50%;width:20px;height:20px;display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:bold;cursor:pointer;box-shadow:0 1px 3px rgba(0,0,0,0.2);">2</div>
<div style="position:absolute;top:200px;left:4px;background:#0ea5e9;color:#fff;border-radius:50%;width:20px;height:20px;display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:bold;cursor:pointer;box-shadow:0 1px 3px rgba(0,0,0,0.2);">3</div>
<div style="position:absolute;top:280px;left:4px;background:#ec4899;color:#fff;border-radius:50%;width:20px;height:20px;display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:bold;cursor:pointer;box-shadow:0 1px 3px rgba(0,0,0,0.2);">4</div>
<div style="position:absolute;top:360px;left:4px;background:#22c55e;color:#fff;border-radius:50%;width:20px;height:20px;display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:bold;cursor:pointer;box-shadow:0 1px 3px rgba(0,0,0,0.2);">5</div>
</div>
<!-- 课文内容 -->
<div style="padding:16px 20px;font-size:13px;color:#78350f;line-height:2;position:relative;">
<div style="font-size:16px;font-weight:bold;color:#92400e;text-align:center;margin-bottom:16px;">秋天</div>
<p style="margin:0 0 12px 0;">
<span style="background:#dbeafe;border-bottom:2px solid #3b82f6;padding:1px 2px;">天气凉了,树叶黄了,</span>
一片片叶子从树上落下来。
</p>
<p style="margin:0 0 12px 0;">
<span style="background:#fef3c7;border-bottom:2px solid #f59e0b;padding:1px 2px;">天空那么蓝,那么高。</span>
一群大雁往南飞,
</p>
<p style="margin:0 0 12px 0;">
<span style="background:#e0f2fe;border-bottom:2px solid #0ea5e9;padding:1px 2px;">一会儿排成个"人"字,</span>
<span style="background:#fce7f3;border-bottom:2px solid #ec4899;padding:1px 2px;">一会儿排成个"一"字。</span>
</p>
<p style="margin:0 0 12px 0;">
<span style="background:#dcfce7;border-bottom:2px solid #22c55e;padding:1px 2px;">啊!秋天来了!</span>
</p>
<!-- 拖放提示 -->
<div style="margin-top:24px;padding:8px;border:1px dashed #cbd5e1;border-radius:4px;text-align:center;font-size:10px;color:#94a3b8;">
💡 选中文字可"关联节点",或从右侧拖动节点到课文某字前
</div>
</div>
</div>
<!-- 右侧:节点时间线 -->
<div style="background:#f8fafc;padding:12px;overflow-y:auto;">
<div style="font-size:10px;color:#64748b;text-transform:uppercase;letter-spacing:1px;margin-bottom:8px;">教学流程时间线</div>
<!-- 已锚定节点(按课文位置排序) -->
<div style="display:flex;flex-direction:column;gap:6px;">
<!-- 节点 1导入锚定到"天气凉了" -->
<div style="background:#fff;border:1px solid #3b82f6;border-left:3px solid #3b82f6;border-radius:4px;padding:8px;cursor:pointer;">
<div style="display:flex;align-items:center;gap:6px;margin-bottom:4px;">
<span style="background:#3b82f6;color:#fff;border-radius:50%;width:16px;height:16px;display:inline-flex;align-items:center;justify-content:center;font-size:9px;font-weight:bold;">1</span>
<span style="font-size:11px;font-weight:bold;color:#1e3a8a;">💡 导入</span>
<span style="margin-left:auto;font-size:9px;color:#94a3b8;">📍 锚定</span>
</div>
<div style="font-size:10px;color:#64748b;background:#eff6ff;padding:4px 6px;border-radius:3px;">
"天气凉了,树叶黄了" → 提问:你见过秋天的树叶吗?
</div>
</div>
<!-- 节点 2文本研习锚定到"天空那么蓝" -->
<div style="background:#fff;border:1px solid #f59e0b;border-left:3px solid #f59e0b;border-radius:4px;padding:8px;cursor:pointer;">
<div style="display:flex;align-items:center;gap:6px;margin-bottom:4px;">
<span style="background:#f59e0b;color:#fff;border-radius:50%;width:16px;height:16px;display:inline-flex;align-items:center;justify-content:center;font-size:9px;font-weight:bold;">2</span>
<span style="font-size:11px;font-weight:bold;color:#92400e;">📝 文本研习</span>
<span style="margin-left:auto;font-size:9px;color:#94a3b8;">📍 锚定</span>
</div>
<div style="font-size:10px;color:#64748b;background:#fffbeb;padding:4px 6px;border-radius:3px;">
"天空那么蓝,那么高" → 赏析:叠词的运用
</div>
</div>
<!-- 节点 3新授锚定到"人字" -->
<div style="background:#fff;border:1px solid #0ea5e9;border-left:3px solid #0ea5e9;border-radius:4px;padding:8px;cursor:pointer;">
<div style="display:flex;align-items:center;gap:6px;margin-bottom:4px;">
<span style="background:#0ea5e9;color:#fff;border-radius:50%;width:16px;height:16px;display:inline-flex;align-items:center;justify-content:center;font-size:9px;font-weight:bold;">3</span>
<span style="font-size:11px;font-weight:bold;color:#075985;">📚 新授</span>
<span style="margin-left:auto;font-size:9px;color:#94a3b8;">📍 锚定</span>
</div>
<div style="font-size:10px;color:#64748b;background:#f0f9ff;padding:4px 6px;border-radius:3px;">
"一会儿排成个'人'字" → 讲解:大雁南飞
</div>
</div>
<!-- 节点 4练习锚定到"一字" -->
<div style="background:#fff;border:1px solid #ec4899;border-left:3px solid #ec4899;border-radius:4px;padding:8px;cursor:pointer;">
<div style="display:flex;align-items:center;gap:6px;margin-bottom:4px;">
<span style="background:#ec4899;color:#fff;border-radius:50%;width:16px;height:16px;display:inline-flex;align-items:center;justify-content:center;font-size:9px;font-weight:bold;">4</span>
<span style="font-size:11px;font-weight:bold;color:#9f1239;">✏️ 练习</span>
<span style="margin-left:auto;font-size:9px;color:#94a3b8;">📍 锚定</span>
</div>
<div style="font-size:10px;color:#64748b;background:#fdf2f8;padding:4px 6px;border-radius:3px;">
"一会儿排成个'一'字" → 3 道题
</div>
</div>
<!-- 节点 5小结锚定到"秋天来了" -->
<div style="background:#fff;border:1px solid #22c55e;border-left:3px solid #22c55e;border-radius:4px;padding:8px;cursor:pointer;">
<div style="display:flex;align-items:center;gap:6px;margin-bottom:4px;">
<span style="background:#22c55e;color:#fff;border-radius:50%;width:16px;height:16px;display:inline-flex;align-items:center;justify-content:center;font-size:9px;font-weight:bold;">5</span>
<span style="font-size:11px;font-weight:bold;color:#166534;">📌 小结</span>
<span style="margin-left:auto;font-size:9px;color:#94a3b8;">📍 锚定</span>
</div>
<div style="font-size:10px;color:#64748b;background:#f0fdf4;padding:4px 6px;border-radius:3px;">
"啊!秋天来了!" → 总结全文
</div>
</div>
<!-- 分隔线 -->
<div style="border-top:1px dashed #cbd5e1;margin:8px 0;padding-top:8px;">
<div style="font-size:9px;color:#94a3b8;text-transform:uppercase;letter-spacing:1px;margin-bottom:6px;">未锚定节点</div>
</div>
<!-- 未锚定节点 -->
<div style="background:#fff;border:1px dashed #cbd5e1;border-radius:4px;padding:8px;cursor:pointer;opacity:0.7;">
<div style="display:flex;align-items:center;gap:6px;">
<span style="font-size:11px;font-weight:bold;color:#1e3a8a;">🎯 教学目标</span>
<span style="margin-left:auto;font-size:9px;color:#94a3b8;">全局</span>
</div>
</div>
<div style="background:#fff;border:1px dashed #cbd5e1;border-radius:4px;padding:8px;cursor:pointer;opacity:0.7;">
<div style="display:flex;align-items:center;gap:6px;">
<span style="font-size:11px;font-weight:bold;color:#92400e;">⭐ 重难点</span>
<span style="margin-left:auto;font-size:9px;color:#94a3b8;">全局</span>
</div>
</div>
<div style="background:#fff;border:1px dashed #cbd5e1;border-radius:4px;padding:8px;cursor:pointer;opacity:0.7;">
<div style="display:flex;align-items:center;gap:6px;">
<span style="font-size:11px;font-weight:bold;color:#a855f7;">🏠 作业</span>
<span style="margin-left:auto;font-size:9px;color:#94a3b8;">课后</span>
</div>
</div>
<div style="background:#fff;border:1px dashed #cbd5e1;border-radius:4px;padding:8px;cursor:pointer;opacity:0.7;">
<div style="display:flex;align-items:center;gap:6px;">
<span style="font-size:11px;font-weight:bold;color:#6366f1;">📋 板书设计</span>
<span style="margin-left:auto;font-size:9px;color:#94a3b8;">全局</span>
</div>
</div>
<div style="background:#fff;border:1px dashed #cbd5e1;border-radius:4px;padding:8px;cursor:pointer;opacity:0.7;">
<div style="display:flex;align-items:center;gap:6px;">
<span style="font-size:11px;font-weight:bold;color:#64748b;">💭 教学反思</span>
<span style="margin-left:auto;font-size:9px;color:#94a3b8;">课后</span>
</div>
</div>
<!-- 添加节点按钮 -->
<div style="border:1px dashed #94a3b8;border-radius:4px;padding:8px;text-align:center;font-size:10px;color:#64748b;cursor:pointer;margin-top:4px;">
+ 添加节点
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="section" style="margin-top:24px;">
<h3>核心交互:两种锚定方式</h3>
<div class="split">
<div class="mockup">
<div class="mockup-header">方式 1拖动节点到课文某字前</div>
<div class="mockup-body" style="padding:16px;font-family:monospace;font-size:12px;">
<div style="display:flex;gap:12px;">
<div style="background:#f8fafc;padding:8px;border-radius:4px;">
<div style="font-size:9px;color:#94a3b8;margin-bottom:4px;">右侧节点</div>
<div style="background:#fff;border:1px solid #3b82f6;border-left:3px solid #3b82f6;padding:6px;border-radius:3px;cursor:grab;font-size:10px;">💡 导入</div>
</div>
<div style="font-size:18px;color:#94a3b8;align-self:center;"></div>
<div style="background:#fffbeb;padding:8px;border-radius:4px;flex:1;">
<div style="font-size:9px;color:#94a3b8;margin-bottom:4px;">课文</div>
<div style="font-size:11px;color:#78350f;line-height:1.8;">
天气凉了,<span style="background:#dbeafe;border:2px dashed #3b82f6;padding:1px 2px;border-radius:2px;">|</span>树叶黄了,<br>
一片片叶子从树上落下来。
</div>
<div style="font-size:9px;color:#3b82f6;margin-top:4px;">💡 节点锚定到此位置</div>
</div>
</div>
</div>
</div>
<div class="mockup">
<div class="mockup-header">方式 2选中文字 → 关联节点</div>
<div class="mockup-body" style="padding:16px;font-family:monospace;font-size:12px;">
<div style="background:#fffbeb;padding:8px;border-radius:4px;margin-bottom:8px;">
<div style="font-size:9px;color:#94a3b8;margin-bottom:4px;">1. 选中文字</div>
<div style="font-size:11px;color:#78350f;line-height:1.8;">
<span style="background:#fef08a;">天空那么蓝,那么高</span>
</div>
</div>
<div style="font-size:18px;color:#94a3b8;text-align:center;"></div>
<div style="background:#f8fafc;padding:8px;border-radius:4px;margin-top:8px;">
<div style="font-size:9px;color:#94a3b8;margin-bottom:4px;">2. 弹出菜单选择节点</div>
<div style="display:flex;gap:4px;flex-wrap:wrap;">
<span style="background:#fff;border:1px solid #3b82f6;padding:3px 6px;border-radius:3px;font-size:9px;cursor:pointer;">💡 导入</span>
<span style="background:#fff;border:1px solid #f59e0b;padding:3px 6px;border-radius:3px;font-size:9px;cursor:pointer;">📝 文本研习</span>
<span style="background:#fff;border:1px solid #0ea5e9;padding:3px 6px;border-radius:3px;font-size:9px;cursor:pointer;">📚 新授</span>
<span style="background:#fff;border:1px solid #ec4899;padding:3px 6px;border-radius:3px;font-size:9px;cursor:pointer;">✏️ 练习</span>
<span style="background:#fff;border:1px solid #22c55e;padding:3px 6px;border-radius:3px;font-size:9px;cursor:pointer;">📌 小结</span>
<span style="background:#fff;border:1px dashed #94a3b8;padding:3px 6px;border-radius:3px;font-size:9px;cursor:pointer;">+ 新建节点</span>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="section" style="margin-top:24px;">
<h3>数据模型锚点Anchor</h3>
<div style="background:#1e293b;color:#e2e8f0;padding:16px;border-radius:6px;font-family:monospace;font-size:11px;line-height:1.6;">
<div style="color:#94a3b8;">// 节点锚点 — 记录节点与课文位置的关联</div>
<div><span style="color:#f59e0b;">interface</span> <span style="color:#3b82f6;">NodeAnchor</span> {</div>
<div>&nbsp;&nbsp;nodeId: <span style="color:#10b981;">string</span>; <span style="color:#64748b;">// 关联的节点 ID</span></div>
<div>&nbsp;&nbsp;type: <span style="color:#10b981;">"point"</span> | <span style="color:#10b981;">"range"</span>; <span style="color:#64748b;">// 点锚点 or 范围锚点</span></div>
<div>&nbsp;&nbsp;start: <span style="color:#10b981;">number</span>; <span style="color:#64748b;">// 课文纯文本偏移量(字符)</span></div>
<div>&nbsp;&nbsp;end?: <span style="color:#10b981;">number</span>; <span style="color:#64748b;">// range 锚点的结束偏移</span></div>
<div>&nbsp;&nbsp;textPreview?: <span style="color:#10b981;">string</span>; <span style="color:#64748b;">// 锚定文字预览(便于回显)</span></div>
<div>}</div>
<br>
<div style="color:#94a3b8;">// LessonPlanDocument 扩展</div>
<div><span style="color:#f59e0b;">interface</span> <span style="color:#3b82f6;">LessonPlanDocument</span> {</div>
<div>&nbsp;&nbsp;version: <span style="color:#10b981;">3</span>; <span style="color:#64748b;">// 升级到 v3</span></div>
<div>&nbsp;&nbsp;nodes: <span style="color:#3b82f6;">LessonPlanNode</span>[];</div>
<div>&nbsp;&nbsp;edges: <span style="color:#3b82f6;">LessonPlanEdge</span>[]; <span style="color:#64748b;">// 保留:节点间连线</span></div>
<div>&nbsp;&nbsp;anchors: <span style="color:#3b82f6;">NodeAnchor</span>[]; <span style="color:#64748b;">// 新增:节点与课文的锚点</span></div>
<div>}</div>
</div>
</div>
<div class="section">
<h3>这个设计的优势</h3>
<div class="pros-cons">
<div class="pros">
<h4>优势</h4>
<ul>
<li><strong>教学流程可视化</strong>:节点按课文位置排序,天然形成时间线</li>
<li><strong>节点与课文强关联</strong>:每个节点对应课文的哪部分一目了然</li>
<li><strong>双模式锚定</strong>:拖动(点锚点)+ 选文字(范围锚点)</li>
<li><strong>保留连线能力</strong>:节点间仍可连线(如"导入→新授"流程线)</li>
<li><strong>未锚定节点</strong>:目标/重难点/作业/板书/反思等全局节点不强制锚定</li>
</ul>
</div>
<div class="cons">
<h4>需要注意</h4>
<ul>
<li>课文偏移量需基于纯文本Markdown 渲染后需映射)</li>
<li>课文内容变更后锚点可能失效(需重新定位或提示)</li>
<li>数据结构升级到 v3需迁移现有 v2 数据</li>
</ul>
</div>
</div>
</div>

View File

@@ -0,0 +1,292 @@
<h2>画布式锚点布局 — 正文固定 + 节点散布 + 连线关联</h2>
<p class="subtitle">保留 React Flow 画布交互,正文为不可移动但可缩放的中央容器,节点通过连线关联正文锚点</p>
<div class="mockup">
<div class="mockup-header">备课编辑器 — 画布视图(默认状态)</div>
<div class="mockup-body" style="padding:0;background:#f1f5f9;overflow:hidden;">
<div style="font-family:monospace;font-size:12px;line-height:1.6;position:relative;height:560px;">
<!-- 顶部工具栏 -->
<div style="background:#1e293b;color:#e2e8f0;padding:8px 12px;display:flex;justify-content:space-between;align-items:center;z-index:10;position:relative;">
<div style="display:flex;align-items:center;gap:8px;">
<span>📖</span>
<span style="font-weight:bold;">秋天(第一课时)</span>
<span style="background:#334155;padding:2px 8px;border-radius:3px;font-size:10px;">语文 · 一年级上册</span>
</div>
<div style="display:flex;align-items:center;gap:8px;">
<span style="font-size:10px;color:#94a3b8;">💾 已保存</span>
<span style="background:#334155;padding:4px 8px;border-radius:3px;font-size:10px;">📋 版本</span>
<span style="background:#3b82f6;padding:4px 12px;border-radius:3px;font-size:10px;">💾 保存</span>
</div>
</div>
<!-- 画布区域 -->
<div style="position:relative;width:100%;height:520px;background:#f1f5f9;background-image:radial-gradient(#cbd5e1 1px, transparent 1px);background-size:20px 20px;overflow:hidden;">
<!-- SVG 连线层(默认 10% 透明度) -->
<svg style="position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none;opacity:0.1;" viewBox="0 0 800 520">
<!-- 节点1(导入) → 正文锚点1 -->
<path d="M 130 120 Q 200 140 280 180" stroke="#3b82f6" stroke-width="2" fill="none" stroke-dasharray="4 4"/>
<circle cx="280" cy="180" r="4" fill="#3b82f6"/>
<!-- 节点2(文本研习) → 正文锚点2 -->
<path d="M 130 220 Q 200 230 280 240" stroke="#f59e0b" stroke-width="2" fill="none" stroke-dasharray="4 4"/>
<circle cx="280" cy="240" r="4" fill="#f59e0b"/>
<!-- 节点3(新授) → 正文锚点3 -->
<path d="M 670 120 Q 600 150 520 200" stroke="#0ea5e9" stroke-width="2" fill="none" stroke-dasharray="4 4"/>
<circle cx="520" cy="200" r="4" fill="#0ea5e9"/>
<!-- 节点4(练习) → 正文锚点4 -->
<path d="M 670 220 Q 600 240 520 260" stroke="#ec4899" stroke-width="2" fill="none" stroke-dasharray="4 4"/>
<circle cx="520" cy="260" r="4" fill="#ec4899"/>
<!-- 节点5(小结) → 正文锚点5 -->
<path d="M 670 340 Q 600 320 520 300" stroke="#22c55e" stroke-width="2" fill="none" stroke-dasharray="4 4"/>
<circle cx="520" cy="300" r="4" fill="#22c55e"/>
<!-- 节点间连线(教学流程) -->
<path d="M 130 140 L 130 200" stroke="#64748b" stroke-width="1.5" fill="none"/>
<path d="M 670 140 L 670 200" stroke="#64748b" stroke-width="1.5" fill="none"/>
<path d="M 670 240 L 670 320" stroke="#64748b" stroke-width="1.5" fill="none"/>
</svg>
<!-- 中央:正文容器(不可移动,可缩放) -->
<div style="position:absolute;left:280px;top:80px;width:240px;background:#fffbeb;border:2px solid #f59e0b;border-radius:8px;padding:12px;box-shadow:0 4px 12px rgba(0,0,0,0.1);">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;border-bottom:1px solid #fde68a;padding-bottom:6px;">
<span style="font-size:11px;font-weight:bold;color:#92400e;">📜 课文正文</span>
<span style="font-size:9px;color:#94a3b8;background:#fef3c7;padding:1px 4px;border-radius:2px;">🔒 固定</span>
</div>
<div style="font-size:13px;color:#78350f;line-height:1.8;">
<div style="text-align:center;font-weight:bold;margin-bottom:6px;">秋天</div>
<p style="margin:0 0 4px 0;">
<span style="background:#dbeafe;border-bottom:2px solid #3b82f6;">天气凉了</span>,树叶黄了,
</p>
<p style="margin:0 0 4px 0;">
<span style="background:#fef3c7;border-bottom:2px solid #f59e0b;">天空那么蓝</span>,那么高。
</p>
<p style="margin:0 0 4px 0;">
一群大雁往南飞,
</p>
<p style="margin:0 0 4px 0;">
一会儿排成个<span style="background:#e0f2fe;border-bottom:2px solid #0ea5e9;">"人"字</span>
</p>
<p style="margin:0 0 4px 0;">
一会儿排成个<span style="background:#fce7f3;border-bottom:2px solid #ec4899;">"一"字</span>
</p>
<p style="margin:0;">
<span style="background:#dcfce7;border-bottom:2px solid #22c55e;">啊!秋天来了!</span>
</p>
</div>
<!-- 缩放控件 -->
<div style="position:absolute;bottom:-12px;right:-12px;background:#fff;border:1px solid #f59e0b;border-radius:50%;width:24px;height:24px;display:flex;align-items:center;justify-content:center;font-size:12px;cursor:pointer;box-shadow:0 2px 4px rgba(0,0,0,0.1);">🔍</div>
</div>
<!-- 左侧节点 -->
<!-- 节点1导入 -->
<div style="position:absolute;left:30px;top:90px;width:140px;background:#fff;border:1px solid #3b82f6;border-left:3px solid #3b82f6;border-radius:6px;padding:8px;box-shadow:0 2px 6px rgba(0,0,0,0.08);cursor:move;">
<div style="display:flex;align-items:center;gap:4px;margin-bottom:4px;">
<span style="background:#3b82f6;color:#fff;border-radius:50%;width:14px;height:14px;display:inline-flex;align-items:center;justify-content:center;font-size:8px;font-weight:bold;">1</span>
<span style="font-size:10px;font-weight:bold;color:#1e3a8a;">💡 导入</span>
</div>
<div style="font-size:9px;color:#64748b;">提问:你见过秋天的树叶吗?</div>
<!-- React Flow Handle 标记 -->
<div style="position:absolute;right:-4px;top:50%;width:8px;height:8px;background:#3b82f6;border-radius:50%;border:1px solid #fff;"></div>
</div>
<!-- 节点2文本研习 -->
<div style="position:absolute;left:30px;top:200px;width:140px;background:#fff;border:1px solid #f59e0b;border-left:3px solid #f59e0b;border-radius:6px;padding:8px;box-shadow:0 2px 6px rgba(0,0,0,0.08);cursor:move;">
<div style="display:flex;align-items:center;gap:4px;margin-bottom:4px;">
<span style="background:#f59e0b;color:#fff;border-radius:50%;width:14px;height:14px;display:inline-flex;align-items:center;justify-content:center;font-size:8px;font-weight:bold;">2</span>
<span style="font-size:10px;font-weight:bold;color:#92400e;">📝 文本研习</span>
</div>
<div style="font-size:9px;color:#64748b;">赏析:叠词的运用</div>
<div style="position:absolute;right:-4px;top:50%;width:8px;height:8px;background:#f59e0b;border-radius:50%;border:1px solid #fff;"></div>
</div>
<!-- 右侧节点 -->
<!-- 节点3新授 -->
<div style="position:absolute;left:630px;top:90px;width:140px;background:#fff;border:1px solid #0ea5e9;border-left:3px solid #0ea5e9;border-radius:6px;padding:8px;box-shadow:0 2px 6px rgba(0,0,0,0.08);cursor:move;">
<div style="display:flex;align-items:center;gap:4px;margin-bottom:4px;">
<span style="background:#0ea5e9;color:#fff;border-radius:50%;width:14px;height:14px;display:inline-flex;align-items:center;justify-content:center;font-size:8px;font-weight:bold;">3</span>
<span style="font-size:10px;font-weight:bold;color:#075985;">📚 新授</span>
</div>
<div style="font-size:9px;color:#64748b;">讲解:大雁南飞</div>
<div style="position:absolute;left:-4px;top:50%;width:8px;height:8px;background:#0ea5e9;border-radius:50%;border:1px solid #fff;"></div>
</div>
<!-- 节点4练习 -->
<div style="position:absolute;left:630px;top:200px;width:140px;background:#fff;border:1px solid #ec4899;border-left:3px solid #ec4899;border-radius:6px;padding:8px;box-shadow:0 2px 6px rgba(0,0,0,0.08);cursor:move;">
<div style="display:flex;align-items:center;gap:4px;margin-bottom:4px;">
<span style="background:#ec4899;color:#fff;border-radius:50%;width:14px;height:14px;display:inline-flex;align-items:center;justify-content:center;font-size:8px;font-weight:bold;">4</span>
<span style="font-size:10px;font-weight:bold;color:#9f1239;">✏️ 练习</span>
</div>
<div style="font-size:9px;color:#64748b;">3 道题</div>
<div style="position:absolute;left:-4px;top:50%;width:8px;height:8px;background:#ec4899;border-radius:50%;border:1px solid #fff;"></div>
</div>
<!-- 节点5小结 -->
<div style="position:absolute;left:630px;top:320px;width:140px;background:#fff;border:1px solid #22c55e;border-left:3px solid #22c55e;border-radius:6px;padding:8px;box-shadow:0 2px 6px rgba(0,0,0,0.08);cursor:move;">
<div style="display:flex;align-items:center;gap:4px;margin-bottom:4px;">
<span style="background:#22c55e;color:#fff;border-radius:50%;width:14px;height:14px;display:inline-flex;align-items:center;justify-content:center;font-size:8px;font-weight:bold;">5</span>
<span style="font-size:10px;font-weight:bold;color:#166534;">📌 小结</span>
</div>
<div style="font-size:9px;color:#64748b;">总结全文</div>
<div style="position:absolute;left:-4px;top:50%;width:8px;height:8px;background:#22c55e;border-radius:50%;border:1px solid #fff;"></div>
</div>
<!-- 顶部全局节点(未锚定) -->
<div style="position:absolute;left:30px;top:10px;width:100px;background:#fff;border:1px dashed #3b82f6;border-radius:6px;padding:6px;box-shadow:0 2px 4px rgba(0,0,0,0.05);opacity:0.8;">
<div style="font-size:9px;font-weight:bold;color:#1e3a8a;">🎯 教学目标</div>
</div>
<div style="position:absolute;left:140px;top:10px;width:100px;background:#fff;border:1px dashed #f59e0b;border-radius:6px;padding:6px;box-shadow:0 2px 4px rgba(0,0,0,0.05);opacity:0.8;">
<div style="font-size:9px;font-weight:bold;color:#92400e;">⭐ 重难点</div>
</div>
<!-- 底部全局节点(未锚定) -->
<div style="position:absolute;left:30px;top:440px;width:100px;background:#fff;border:1px dashed #a855f7;border-radius:6px;padding:6px;box-shadow:0 2px 4px rgba(0,0,0,0.05);opacity:0.8;">
<div style="font-size:9px;font-weight:bold;color:#9333ea;">🏠 作业</div>
</div>
<div style="position:absolute;left:140px;top:440px;width:100px;background:#fff;border:1px dashed #6366f1;border-radius:6px;padding:6px;box-shadow:0 2px 4px rgba(0,0,0,0.05);opacity:0.8;">
<div style="font-size:9px;font-weight:bold;color:#4f46e5;">📋 板书设计</div>
</div>
<div style="position:absolute;left:250px;top:440px;width:100px;background:#fff;border:1px dashed #64748b;border-radius:6px;padding:6px;box-shadow:0 2px 4px rgba(0,0,0,0.05);opacity:0.8;">
<div style="font-size:9px;font-weight:bold;color:#475569;">💭 教学反思</div>
</div>
<!-- React Flow Controls右下角 -->
<div style="position:absolute;bottom:12px;right:12px;background:#fff;border:1px solid #e2e8f0;border-radius:4px;padding:4px;display:flex;flex-direction:column;gap:2px;box-shadow:0 2px 4px rgba(0,0,0,0.05);">
<div style="width:20px;height:20px;display:flex;align-items:center;justify-content:center;cursor:pointer;font-size:14px;border-radius:2px;">+</div>
<div style="width:20px;height:20px;display:flex;align-items:center;justify-content:center;cursor:pointer;font-size:14px;border-radius:2px;"></div>
<div style="width:20px;height:20px;display:flex;align-items:center;justify-content:center;cursor:pointer;font-size:10px;border-radius:2px;"></div>
</div>
<!-- 添加节点按钮(左下角) -->
<div style="position:absolute;bottom:12px;left:12px;background:#3b82f6;color:#fff;padding:6px 12px;border-radius:4px;font-size:10px;cursor:pointer;box-shadow:0 2px 4px rgba(59,130,246,0.3);">
+ 添加节点
</div>
<!-- 透明度提示 -->
<div style="position:absolute;top:12px;right:12px;background:#fff;border:1px solid #e2e8f0;border-radius:4px;padding:4px 8px;font-size:9px;color:#64748b;">
连线默认 10% 透明度 · 选中节点时完整显示
</div>
</div>
</div>
</div>
</div>
<!-- 选中状态对比 -->
<div class="section" style="margin-top:24px;">
<h3>选中节点时的连线显示对比</h3>
<div class="split">
<div class="mockup">
<div class="mockup-header">默认状态 — 连线 10% 透明度</div>
<div class="mockup-body" style="padding:16px;background:#f1f5f9;">
<svg style="width:100%;height:120px;" viewBox="0 0 300 120">
<path d="M 30 60 Q 120 60 270 60" stroke="#3b82f6" stroke-width="2" fill="none" stroke-dasharray="4 4" opacity="0.1"/>
<rect x="10" y="45" width="40" height="30" fill="#fff" stroke="#3b82f6" rx="4"/>
<text x="30" y="64" text-anchor="middle" font-size="10" fill="#1e3a8a">导入</text>
<rect x="250" y="45" width="40" height="30" fill="#fffbeb" stroke="#f59e0b" stroke-width="2" rx="4"/>
<text x="270" y="64" text-anchor="middle" font-size="10" fill="#92400e">课文</text>
</svg>
<div style="text-align:center;font-size:10px;color:#94a3b8;">连线几乎不可见,画布干净</div>
</div>
</div>
<div class="mockup">
<div class="mockup-header">选中"导入"节点 — 连线 100% 显示</div>
<div class="mockup-body" style="padding:16px;background:#f1f5f9;">
<svg style="width:100%;height:120px;" viewBox="0 0 300 120">
<path d="M 30 60 Q 120 60 270 60" stroke="#3b82f6" stroke-width="2.5" fill="none" stroke-dasharray="4 4" opacity="1"/>
<circle cx="270" cy="60" r="5" fill="#3b82f6"/>
<rect x="10" y="45" width="40" height="30" fill="#fff" stroke="#3b82f6" stroke-width="3" rx="4"/>
<text x="30" y="64" text-anchor="middle" font-size="10" fill="#1e3a8a">导入</text>
<rect x="250" y="45" width="40" height="30" fill="#fffbeb" stroke="#f59e0b" stroke-width="2" rx="4"/>
<text x="270" y="64" text-anchor="middle" font-size="10" fill="#92400e">课文</text>
</svg>
<div style="text-align:center;font-size:10px;color:#3b82f6;">连线完整显示,高亮锚点位置</div>
</div>
</div>
</div>
</div>
<!-- 数据模型 -->
<div class="section">
<h3>数据模型设计</h3>
<div style="background:#1e293b;color:#e2e8f0;padding:16px;border-radius:6px;font-family:monospace;font-size:11px;line-height:1.6;">
<div style="color:#94a3b8;">// 正文容器节点(特殊节点类型,不可拖动,可缩放)</div>
<div><span style="color:#f59e0b;">interface</span> <span style="color:#3b82f6;">TextbookContentNode</span> <span style="color:#f59e0b;">extends</span> <span style="color:#3b82f6;">LessonPlanNode</span> {</div>
<div>&nbsp;&nbsp;type: <span style="color:#10b981;">"textbook_content"</span>; <span style="color:#64748b;">// 新增节点类型</span></div>
<div>&nbsp;&nbsp;data: {</div>
<div>&nbsp;&nbsp;&nbsp;&nbsp;chapterId: <span style="color:#10b981;">string</span>; <span style="color:#64748b;">// 关联教材章节</span></div>
<div>&nbsp;&nbsp;&nbsp;&nbsp;content: <span style="color:#10b981;">string</span>; <span style="color:#64748b;">// Markdown 正文(缓存)</span></div>
<div>&nbsp;&nbsp;&nbsp;&nbsp;zoom: <span style="color:#10b981;">number</span>; <span style="color:#64748b;">// 缩放比例 0.5-2.0</span></div>
<div>&nbsp;&nbsp;};</div>
<div>&nbsp;&nbsp;position: { x: <span style="color:#10b981;">number</span>; y: <span style="color:#10b981;">number</span> }; <span style="color:#64748b;">// 固定位置(不可拖动)</span></div>
<div>&nbsp;&nbsp;draggable: <span style="color:#10b981;">false</span>; <span style="color:#64748b;">// React Flow 节点锁定</span></div>
<div>}</div>
<br>
<div style="color:#94a3b8;">// 锚点连线(节点 → 正文位置)</div>
<div><span style="color:#f59e0b;">interface</span> <span style="color:#3b82f6;">AnchorEdge</span> <span style="color:#f59e0b;">extends</span> <span style="color:#3b82f6;">LessonPlanEdge</span> {</div>
<div>&nbsp;&nbsp;type: <span style="color:#10b981;">"anchor"</span>; <span style="color:#64748b;">// 锚点连线vs "flow" 流程连线)</span></div>
<div>&nbsp;&nbsp;source: <span style="color:#10b981;">string</span>; <span style="color:#64748b;">// 节点 ID</span></div>
<div>&nbsp;&nbsp;target: <span style="color:#10b981;">string</span>; <span style="color:#64748b;">// 正文节点 ID</span></div>
<div>&nbsp;&nbsp;targetHandle: <span style="color:#10b981;">string</span>; <span style="color:#64748b;">// "anchor:123:145"(正文偏移量 start:end</span></div>
<div>}</div>
<br>
<div style="color:#94a3b8;">// LessonPlanDocument v3</div>
<div><span style="color:#f59e0b;">interface</span> <span style="color:#3b82f6;">LessonPlanDocument</span> {</div>
<div>&nbsp;&nbsp;version: <span style="color:#10b981;">3</span>;</div>
<div>&nbsp;&nbsp;nodes: <span style="color:#3b82f6;">LessonPlanNode</span>[]; <span style="color:#64748b;">// 含 1 个 textbook_content + N 个教学节点</span></div>
<div>&nbsp;&nbsp;edges: <span style="color:#3b82f6;">AnchorEdge</span> | <span style="color:#3b82f6;">FlowEdge</span>[]; <span style="color:#64748b;">// 锚点连线 + 流程连线</span></div>
<div>}</div>
</div>
</div>
<!-- 交互流程 -->
<div class="section">
<h3>核心交互流程</h3>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;">
<div style="background:#fff;border:1px solid #e2e8f0;border-radius:6px;padding:12px;">
<h4 style="margin:0 0 8px 0;color:#3b82f6;">🔗 锚定节点到正文</h4>
<ol style="margin:0;padding-left:16px;font-size:11px;color:#475569;line-height:1.8;">
<li>教师选中正文某段文字(或某个字)</li>
<li>选中后弹出浮动菜单:"关联节点 →"</li>
<li>从下拉列表选择已有节点,或"新建节点"</li>
<li>创建 AnchorEdgesource=节点, target=正文节点, targetHandle="anchor:start:end"</li>
<li>正文对应文字高亮显示(节点颜色)</li>
<li>连线默认 10% 透明,选中节点时 100%</li>
</ol>
</div>
<div style="background:#fff;border:1px solid #e2e8f0;border-radius:6px;padding:12px;">
<h4 style="margin:0 0 8px 0;color:#22c55e;">🖱️ 拖动节点到正文</h4>
<ol style="margin:0;padding-left:16px;font-size:11px;color:#475569;line-height:1.8;">
<li>教师从右侧节点列表拖动一个节点</li>
<li>拖动过程中,正文区域高亮可放置区域</li>
<li>拖到正文某个字前释放</li>
<li>创建点锚点point anchortargetHandle="anchor:pos"</li>
<li>节点自动定位到正文旁边(左或右空位)</li>
<li>连线默认 10% 透明,选中时完整显示</li>
</ol>
</div>
</div>
</div>
<div class="section">
<h3>设计要点</h3>
<div class="pros-cons">
<div class="pros">
<h4>优势</h4>
<ul>
<li><strong>保留画布交互</strong>:缩放/平移/拖动节点,与当前备课模块一致</li>
<li><strong>正文固定居中</strong>:不可拖动,始终是视觉中心</li>
<li><strong>连线语义化</strong>anchor 锚点连线 vs flow 流程连线</li>
<li><strong>透明度策略</strong>:默认 10%,选中时 100%,画布不杂乱</li>
<li><strong>正文可缩放</strong>:教师可放大正文便于阅读</li>
</ul>
</div>
<div class="cons">
<h4>技术挑战</h4>
<ul>
<li>正文偏移量需基于纯文本Markdown 渲染后映射)</li>
<li>正文内容变更后锚点需重新定位</li>
<li>React Flow 自定义节点需处理正文渲染</li>
<li>数据结构升级 v2 → v3需迁移</li>
</ul>
</div>
</div>
</div>

View File

@@ -0,0 +1,226 @@
<h2>备课模块布局方案对比</h2>
<p class="subtitle">3 种布局方案 — 课文固定中央,教学节点围绕组织</p>
<div class="cards" data-multiselect>
<!-- 方案 A -->
<div class="card" data-choice="a" onclick="toggleSelect(this)">
<div class="card-image" style="padding:12px;background:#f8fafc;">
<div style="font-family:monospace;font-size:11px;line-height:1.4;">
<div style="border:1px solid #cbd5e1;background:#e2e8f0;padding:4px 8px;border-radius:4px 4px 0 0;display:flex;justify-content:space-between;">
<span>📖 秋天(第一课时)</span>
<span>💾 已保存</span>
</div>
<div style="display:grid;grid-template-columns:200px 1fr 200px;gap:4px;padding:8px;background:#fff;border:1px solid #cbd5e1;border-top:none;border-radius:0 0 4px 4px;min-height:280px;">
<!-- 左侧:课前 -->
<div style="display:flex;flex-direction:column;gap:4px;">
<div style="font-size:9px;color:#64748b;text-align:center;">课前</div>
<div style="background:#dbeafe;border:1px solid #3b82f6;padding:4px;border-radius:3px;font-size:10px;">🎯 教学目标</div>
<div style="background:#fef3c7;border:1px solid #f59e0b;padding:4px;border-radius:3px;font-size:10px;">⭐ 重难点</div>
<div style="background:#e0f2fe;border:1px solid #0ea5e9;padding:4px;border-radius:3px;font-size:10px;">💡 导入</div>
<div style="background:#fce7f3;border:1px solid #ec4899;padding:4px;border-radius:3px;font-size:10px;">📝 文本研习</div>
</div>
<!-- 中央:课文 -->
<div style="background:#fffbeb;border:2px solid #f59e0b;padding:8px;border-radius:4px;display:flex;flex-direction:column;">
<div style="font-size:10px;color:#92400e;font-weight:bold;margin-bottom:4px;">📜 课文正文</div>
<div style="font-size:9px;color:#78350f;line-height:1.5;flex:1;">
天气凉了,树叶黄了,<br>
一片片叶子从树上落下来。<br>
<span style="background:#fef08a;">天空那么蓝,那么高</span><br>
一群大雁往南飞,<br>
一会儿排成个"人"字,<br>
一会儿排成个"一"字。<br>
<span style="background:#bbf7d0;">啊!秋天来了!</span>
</div>
<div style="font-size:8px;color:#92400e;margin-top:4px;">💡 选中文字可添加批注</div>
</div>
<!-- 右侧:课中/课后 -->
<div style="display:flex;flex-direction:column;gap:4px;">
<div style="font-size:9px;color:#64748b;text-align:center;">课中</div>
<div style="background:#dcfce7;border:1px solid #22c55e;padding:4px;border-radius:3px;font-size:10px;">📚 新授</div>
<div style="background:#ede9fe;border:1px solid #8b5cf6;padding:4px;border-radius:3px;font-size:10px;">✏️ 练习</div>
<div style="background:#fee2e2;border:1px solid #ef4444;padding:4px;border-radius:3px;font-size:10px;">📌 小结</div>
<div style="font-size:9px;color:#64748b;text-align:center;margin-top:4px;">课后</div>
<div style="background:#f3e8ff;border:1px solid #a855f7;padding:4px;border-radius:3px;font-size:10px;">🏠 作业</div>
<div style="background:#e0e7ff;border:1px solid #6366f1;padding:4px;border-radius:3px;font-size:10px;">📋 板书设计</div>
<div style="background:#f1f5f9;border:1px solid #64748b;padding:4px;border-radius:3px;font-size:10px;">💭 教学反思</div>
</div>
</div>
</div>
</div>
<div class="card-body">
<h3>A. 三栏布局(课前/课文/课后)</h3>
<p>课文固定中央(琥珀色边框),左侧"课前"节点(目标/重难点/导入/文本研习),右侧"课中+课后"节点(新授/练习/小结/作业/板书/反思)。节点按教学流程纵向排列。点击节点在右侧抽屉编辑。</p>
</div>
</div>
<!-- 方案 B -->
<div class="card" data-choice="b" onclick="toggleSelect(this)">
<div class="card-image" style="padding:12px;background:#f8fafc;">
<div style="font-family:monospace;font-size:11px;line-height:1.4;">
<div style="border:1px solid #cbd5e1;background:#e2e8f0;padding:4px 8px;border-radius:4px 4px 0 0;display:flex;justify-content:space-between;">
<span>📖 秋天(第一课时)</span>
<span>💾 已保存</span>
</div>
<div style="padding:8px;background:#fff;border:1px solid #cbd5e1;border-top:none;border-radius:0 0 4px 4px;min-height:280px;">
<!-- 顶部:目标/重难点 -->
<div style="display:grid;grid-template-columns:1fr 1fr;gap:4px;margin-bottom:4px;">
<div style="background:#dbeafe;border:1px solid #3b82f6;padding:4px;border-radius:3px;font-size:10px;text-align:center;">🎯 教学目标</div>
<div style="background:#fef3c7;border:1px solid #f59e0b;padding:4px;border-radius:3px;font-size:10px;text-align:center;">⭐ 重难点</div>
</div>
<!-- 中央:课文 + 左右两侧节点 -->
<div style="display:grid;grid-template-columns:120px 1fr 120px;gap:4px;margin-bottom:4px;">
<div style="display:flex;flex-direction:column;gap:4px;">
<div style="background:#e0f2fe;border:1px solid #0ea5e9;padding:4px;border-radius:3px;font-size:9px;text-align:center;">💡 导入</div>
<div style="background:#fce7f3;border:1px solid #ec4899;padding:4px;border-radius:3px;font-size:9px;text-align:center;">📝 文本研习</div>
</div>
<div style="background:#fffbeb;border:2px solid #f59e0b;padding:8px;border-radius:4px;text-align:center;">
<div style="font-size:10px;color:#92400e;font-weight:bold;">📜 课文正文</div>
<div style="font-size:9px;color:#78350f;margin-top:4px;">天气凉了,树叶黄了...<br>天空那么蓝...<br>一群大雁往南飞...</div>
</div>
<div style="display:flex;flex-direction:column;gap:4px;">
<div style="background:#dcfce7;border:1px solid #22c55e;padding:4px;border-radius:3px;font-size:9px;text-align:center;">📚 新授</div>
<div style="background:#ede9fe;border:1px solid #8b5cf6;padding:4px;border-radius:3px;font-size:9px;text-align:center;">✏️ 练习</div>
</div>
</div>
<!-- 底部:小结/作业/板书/反思 -->
<div style="display:grid;grid-template-columns:1fr 1fr 1fr 1fr;gap:4px;">
<div style="background:#fee2e2;border:1px solid #ef4444;padding:4px;border-radius:3px;font-size:9px;text-align:center;">📌 小结</div>
<div style="background:#f3e8ff;border:1px solid #a855f7;padding:4px;border-radius:3px;font-size:9px;text-align:center;">🏠 作业</div>
<div style="background:#e0e7ff;border:1px solid #6366f1;padding:4px;border-radius:3px;font-size:9px;text-align:center;">📋 板书</div>
<div style="background:#f1f5f9;border:1px solid #64748b;padding:4px;border-radius:3px;font-size:9px;text-align:center;">💭 反思</div>
</div>
</div>
</div>
</div>
<div class="card-body">
<h3>B. 上下分区布局</h3>
<p>顶部目标/重难点横排,中央课文 + 左右导入/文本研习/新授/练习,底部小结/作业/板书/反思横排。按"目标→导入→课文→新授→小结"的阅读顺序自然流动。视觉层次更清晰。</p>
</div>
</div>
<!-- 方案 C -->
<div class="card" data-choice="c" onclick="toggleSelect(this)">
<div class="card-image" style="padding:12px;background:#f8fafc;">
<div style="font-family:monospace;font-size:11px;line-height:1.4;">
<div style="border:1px solid #cbd5e1;background:#e2e8f0;padding:4px 8px;border-radius:4px 4px 0 0;display:flex;justify-content:space-between;">
<span>📖 秋天(第一课时)</span>
<span>💾 已保存</span>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:4px;padding:8px;background:#fff;border:1px solid #cbd5e1;border-top:none;border-radius:0 0 4px 4px;min-height:280px;">
<!-- 左侧:课文 -->
<div style="display:flex;flex-direction:column;gap:4px;">
<div style="background:#fffbeb;border:2px solid #f59e0b;padding:8px;border-radius:4px;flex:1;">
<div style="font-size:10px;color:#92400e;font-weight:bold;">📜 课文正文</div>
<div style="font-size:9px;color:#78350f;margin-top:4px;line-height:1.5;">
天气凉了,树叶黄了,<br>
一片片叶子从树上落下来。<br>
<span style="background:#fef08a;">天空那么蓝,那么高</span><br>
一群大雁往南飞...
</div>
</div>
<div style="background:#fce7f3;border:1px solid #ec4899;padding:4px;border-radius:3px;font-size:10px;text-align:center;">📝 文本研习(批注)</div>
</div>
<!-- 右侧:教学流程时间线 -->
<div style="display:flex;flex-direction:column;gap:3px;">
<div style="font-size:9px;color:#64748b;text-align:center;">教学流程</div>
<div style="background:#dbeafe;border:1px solid #3b82f6;padding:3px 6px;border-radius:3px;font-size:9px;display:flex;align-items:center;gap:4px;">
<span style="background:#3b82f6;color:#fff;border-radius:50%;width:14px;height:14px;display:inline-flex;align-items:center;justify-content:center;font-size:8px;">1</span>
🎯 教学目标
</div>
<div style="background:#fef3c7;border:1px solid #f59e0b;padding:3px 6px;border-radius:3px;font-size:9px;display:flex;align-items:center;gap:4px;">
<span style="background:#f59e0b;color:#fff;border-radius:50%;width:14px;height:14px;display:inline-flex;align-items:center;justify-content:center;font-size:8px;">2</span>
⭐ 重难点
</div>
<div style="background:#e0f2fe;border:1px solid #0ea5e9;padding:3px 6px;border-radius:3px;font-size:9px;display:flex;align-items:center;gap:4px;">
<span style="background:#0ea5e9;color:#fff;border-radius:50%;width:14px;height:14px;display:inline-flex;align-items:center;justify-content:center;font-size:8px;">3</span>
💡 导入
</div>
<div style="background:#dcfce7;border:1px solid #22c55e;padding:3px 6px;border-radius:3px;font-size:9px;display:flex;align-items:center;gap:4px;">
<span style="background:#22c55e;color:#fff;border-radius:50%;width:14px;height:14px;display:inline-flex;align-items:center;justify-content:center;font-size:8px;">4</span>
📚 新授
</div>
<div style="background:#ede9fe;border:1px solid #8b5cf6;padding:3px 6px;border-radius:3px;font-size:9px;display:flex;align-items:center;gap:4px;">
<span style="background:#8b5cf6;color:#fff;border-radius:50%;width:14px;height:14px;display:inline-flex;align-items:center;justify-content:center;font-size:8px;">5</span>
✏️ 练习
</div>
<div style="background:#fee2e2;border:1px solid #ef4444;padding:3px 6px;border-radius:3px;font-size:9px;display:flex;align-items:center;gap:4px;">
<span style="background:#ef4444;color:#fff;border-radius:50%;width:14px;height:14px;display:inline-flex;align-items:center;justify-content:center;font-size:8px;">6</span>
📌 小结
</div>
<div style="background:#f3e8ff;border:1px solid #a855f7;padding:3px 6px;border-radius:3px;font-size:9px;display:flex;align-items:center;gap:4px;">
<span style="background:#a855f7;color:#fff;border-radius:50%;width:14px;height:14px;display:inline-flex;align-items:center;justify-content:center;font-size:8px;">7</span>
🏠 作业
</div>
<div style="background:#e0e7ff;border:1px solid #6366f1;padding:3px 6px;border-radius:3px;font-size:9px;display:flex;align-items:center;gap:4px;">
<span style="background:#6366f1;color:#fff;border-radius:50%;width:14px;height:14px;display:inline-flex;align-items:center;justify-content:center;font-size:8px;">8</span>
📋 板书
</div>
<div style="background:#f1f5f9;border:1px solid #64748b;padding:3px 6px;border-radius:3px;font-size:9px;display:flex;align-items:center;gap:4px;">
<span style="background:#64748b;color:#fff;border-radius:50%;width:14px;height:14px;display:inline-flex;align-items:center;justify-content:center;font-size:8px;">9</span>
💭 反思
</div>
</div>
</div>
</div>
</div>
<div class="card-body">
<h3>C. 左课文 + 右时间线</h3>
<p>左侧课文正文(固定)+ 文本研习批注,右侧教学流程时间线(编号 1-9 按顺序)。点击时间线节点展开编辑抽屉。最贴近传统教案本格式,结构清晰。</p>
</div>
</div>
</div>
<div class="section" style="margin-top:24px;">
<h3>三种方案的核心差异</h3>
<div class="pros-cons">
<div class="pros">
<h4>方案 A 三栏</h4>
<ul>
<li>课文始终居中可见</li>
<li>课前/课后分区直观</li>
<li>节点可拖动微调位置</li>
</ul>
</div>
<div class="cons">
<h4>方案 A 三栏</h4>
<ul>
<li>三栏可能拥挤(小屏)</li>
<li>教学流程顺序不够明显</li>
</ul>
</div>
</div>
<div class="pros-cons">
<div class="pros">
<h4>方案 B 上下分区</h4>
<ul>
<li>视觉层次最清晰</li>
<li>阅读顺序自然(上→下)</li>
<li>课文居中突出</li>
</ul>
</div>
<div class="cons">
<h4>方案 B 上下分区</h4>
<ul>
<li>节点位置较固定</li>
<li>纵向空间需求大</li>
</ul>
</div>
</div>
<div class="pros-cons">
<div class="pros">
<h4>方案 C 左课文+右时间线</h4>
<ul>
<li>最接近传统教案</li>
<li>教学流程顺序最明确</li>
<li>课文阅读体验最佳</li>
</ul>
</div>
<div class="cons">
<h4>方案 C 左课文+右时间线</h4>
<ul>
<li>节点画布感弱(更像列表)</li>
<li>失去节点图连线能力</li>
</ul>
</div>
</div>
</div>

View File

@@ -0,0 +1,251 @@
<h2>正文占位符标记布局</h2>
<p class="subtitle">正文中嵌入占位符标记(特殊符号),默认接近透明,选中节点时完整显示</p>
<div class="mockup">
<div class="mockup-header">备课编辑器 — 默认状态(占位符 10% 透明度)</div>
<div class="mockup-body" style="padding:0;background:#f1f5f9;overflow:hidden;">
<div style="font-family:monospace;font-size:12px;line-height:1.6;position:relative;height:560px;">
<!-- 顶部工具栏 -->
<div style="background:#1e293b;color:#e2e8f0;padding:8px 12px;display:flex;justify-content:space-between;align-items:center;z-index:10;position:relative;">
<div style="display:flex;align-items:center;gap:8px;">
<span>📖</span>
<span style="font-weight:bold;">秋天(第一课时)</span>
<span style="background:#334155;padding:2px 8px;border-radius:3px;font-size:10px;">语文 · 一年级上册</span>
</div>
<div style="display:flex;align-items:center;gap:8px;">
<span style="font-size:10px;color:#94a3b8;">💾 已保存</span>
<span style="background:#3b82f6;padding:4px 12px;border-radius:3px;font-size:10px;">💾 保存</span>
</div>
</div>
<!-- 画布区域 -->
<div style="position:relative;width:100%;height:520px;background:#f1f5f9;background-image:radial-gradient(#cbd5e1 1px, transparent 1px);background-size:20px 20px;overflow:hidden;">
<!-- SVG 连线层(默认 10% 透明度) -->
<svg style="position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none;opacity:0.1;" viewBox="0 0 800 520">
<path d="M 130 120 Q 200 140 290 175" stroke="#3b82f6" stroke-width="2" fill="none" stroke-dasharray="4 4"/>
<path d="M 130 220 Q 200 230 290 215" stroke="#f59e0b" stroke-width="2" fill="none" stroke-dasharray="4 4"/>
<path d="M 670 120 Q 600 150 510 195" stroke="#0ea5e9" stroke-width="2" fill="none" stroke-dasharray="4 4"/>
<path d="M 670 220 Q 600 240 510 235" stroke="#ec4899" stroke-width="2" fill="none" stroke-dasharray="4 4"/>
<path d="M 670 340 Q 600 320 510 275" stroke="#22c55e" stroke-width="2" fill="none" stroke-dasharray="4 4"/>
</svg>
<!-- 中央:正文容器(不可移动) -->
<div style="position:absolute;left:280px;top:60px;width:240px;background:#fffbeb;border:2px solid #f59e0b;border-radius:8px;padding:12px;box-shadow:0 4px 12px rgba(0,0,0,0.1);">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;border-bottom:1px solid #fde68a;padding-bottom:6px;">
<span style="font-size:11px;font-weight:bold;color:#92400e;">📜 课文正文</span>
<span style="font-size:9px;color:#94a3b8;background:#fef3c7;padding:1px 4px;border-radius:2px;">🔒 固定</span>
</div>
<div style="font-size:13px;color:#78350f;line-height:2;">
<div style="text-align:center;font-weight:bold;margin-bottom:6px;">秋天</div>
<p style="margin:0 0 6px 0;">
<!-- 占位符 1导入节点- 默认 10% 透明度 -->
<span style="display:inline-block;background:#3b82f6;color:#fff;border-radius:3px;padding:0 4px;font-size:9px;font-weight:bold;opacity:0.1;margin-right:2px;"></span>天气凉了,树叶黄了,
</p>
<p style="margin:0 0 6px 0;">
<!-- 占位符 2文本研习- 默认 10% 透明度 -->
<span style="display:inline-block;background:#f59e0b;color:#fff;border-radius:3px;padding:0 4px;font-size:9px;font-weight:bold;opacity:0.1;margin-right:2px;"></span>天空那么蓝,那么高。
</p>
<p style="margin:0 0 6px 0;">
一群大雁往南飞,
</p>
<p style="margin:0 0 6px 0;">
一会儿排成个"人"字<span style="display:inline-block;background:#0ea5e9;color:#fff;border-radius:3px;padding:0 4px;font-size:9px;font-weight:bold;opacity:0.1;margin-left:2px;"></span>
</p>
<p style="margin:0 0 6px 0;">
一会儿排成个"一"字<span style="display:inline-block;background:#ec4899;color:#fff;border-radius:3px;padding:0 4px;font-size:9px;font-weight:bold;opacity:0.1;margin-left:2px;"></span>
</p>
<p style="margin:0;">
<span style="display:inline-block;background:#22c55e;color:#fff;border-radius:3px;padding:0 4px;font-size:9px;font-weight:bold;opacity:0.1;margin-right:2px;"></span>啊!秋天来了!
</p>
</div>
</div>
<!-- 左侧节点 -->
<div style="position:absolute;left:30px;top:90px;width:140px;background:#fff;border:1px solid #3b82f6;border-left:3px solid #3b82f6;border-radius:6px;padding:8px;box-shadow:0 2px 6px rgba(0,0,0,0.08);">
<div style="display:flex;align-items:center;gap:4px;margin-bottom:4px;">
<span style="background:#3b82f6;color:#fff;border-radius:50%;width:14px;height:14px;display:inline-flex;align-items:center;justify-content:center;font-size:8px;font-weight:bold;">1</span>
<span style="font-size:10px;font-weight:bold;color:#1e3a8a;">💡 导入</span>
</div>
<div style="font-size:9px;color:#64748b;">提问:你见过秋天的树叶吗?</div>
</div>
<div style="position:absolute;left:30px;top:200px;width:140px;background:#fff;border:1px solid #f59e0b;border-left:3px solid #f59e0b;border-radius:6px;padding:8px;box-shadow:0 2px 6px rgba(0,0,0,0.08);">
<div style="display:flex;align-items:center;gap:4px;margin-bottom:4px;">
<span style="background:#f59e0b;color:#fff;border-radius:50%;width:14px;height:14px;display:inline-flex;align-items:center;justify-content:center;font-size:8px;font-weight:bold;">2</span>
<span style="font-size:10px;font-weight:bold;color:#92400e;">📝 文本研习</span>
</div>
<div style="font-size:9px;color:#64748b;">赏析:叠词的运用</div>
</div>
<!-- 右侧节点 -->
<div style="position:absolute;left:630px;top:90px;width:140px;background:#fff;border:1px solid #0ea5e9;border-left:3px solid #0ea5e9;border-radius:6px;padding:8px;box-shadow:0 2px 6px rgba(0,0,0,0.08);">
<div style="display:flex;align-items:center;gap:4px;margin-bottom:4px;">
<span style="background:#0ea5e9;color:#fff;border-radius:50%;width:14px;height:14px;display:inline-flex;align-items:center;justify-content:center;font-size:8px;font-weight:bold;">3</span>
<span style="font-size:10px;font-weight:bold;color:#075985;">📚 新授</span>
</div>
<div style="font-size:9px;color:#64748b;">讲解:大雁南飞</div>
</div>
<div style="position:absolute;left:630px;top:200px;width:140px;background:#fff;border:1px solid #ec4899;border-left:3px solid #ec4899;border-radius:6px;padding:8px;box-shadow:0 2px 6px rgba(0,0,0,0.08);">
<div style="display:flex;align-items:center;gap:4px;margin-bottom:4px;">
<span style="background:#ec4899;color:#fff;border-radius:50%;width:14px;height:14px;display:inline-flex;align-items:center;justify-content:center;font-size:8px;font-weight:bold;">4</span>
<span style="font-size:10px;font-weight:bold;color:#9f1239;">✏️ 练习</span>
</div>
<div style="font-size:9px;color:#64748b;">3 道题</div>
</div>
<div style="position:absolute;left:630px;top:320px;width:140px;background:#fff;border:1px solid #22c55e;border-left:3px solid #22c55e;border-radius:6px;padding:8px;box-shadow:0 2px 6px rgba(0,0,0,0.08);">
<div style="display:flex;align-items:center;gap:4px;margin-bottom:4px;">
<span style="background:#22c55e;color:#fff;border-radius:50%;width:14px;height:14px;display:inline-flex;align-items:center;justify-content:center;font-size:8px;font-weight:bold;">5</span>
<span style="font-size:10px;font-weight:bold;color:#166534;">📌 小结</span>
</div>
<div style="font-size:9px;color:#64748b;">总结全文</div>
</div>
<div style="position:absolute;top:12px;right:12px;background:#fff;border:1px solid #e2e8f0;border-radius:4px;padding:4px 8px;font-size:9px;color:#64748b;">
占位符默认 10% · 选中节点时 100% 显示
</div>
</div>
</div>
</div>
</div>
<!-- 选中状态对比 -->
<div class="section" style="margin-top:24px;">
<h3>选中"导入"节点时的状态变化</h3>
<div class="split">
<div class="mockup">
<div class="mockup-header">默认 — 占位符 ① 10% 透明度</div>
<div class="mockup-body" style="padding:16px;background:#fffbeb;">
<div style="font-size:14px;color:#78350f;line-height:2;font-family:serif;">
<span style="display:inline-block;background:#3b82f6;color:#fff;border-radius:3px;padding:0 5px;font-size:10px;font-weight:bold;opacity:0.1;margin-right:3px;"></span>天气凉了,树叶黄了,
</div>
<div style="margin-top:12px;font-size:10px;color:#94a3b8;text-align:center;">
占位符几乎不可见 · 画布干净
</div>
</div>
</div>
<div class="mockup">
<div class="mockup-header">选中"导入"节点 — 占位符 ① 100% + 连线显示</div>
<div class="mockup-body" style="padding:16px;background:#fffbeb;">
<div style="font-size:14px;color:#78350f;line-height:2;font-family:serif;">
<span style="display:inline-block;background:#3b82f6;color:#fff;border-radius:3px;padding:0 5px;font-size:10px;font-weight:bold;opacity:1;margin-right:3px;box-shadow:0 0 0 2px #3b82f633;"></span>天气凉了,树叶黄了,
</div>
<div style="margin-top:12px;font-size:10px;color:#3b82f6;text-align:center;">
占位符完整显示 · 连线高亮 · 锚定位置清晰
</div>
</div>
</div>
</div>
</div>
<!-- 占位符样式选项 -->
<div class="section">
<h3>占位符样式选项</h3>
<p class="subtitle">选择占位符在正文中的视觉呈现方式</p>
<div class="cards" data-multiselect>
<div class="card" data-choice="number" onclick="toggleSelect(this)">
<div class="card-image" style="padding:20px;background:#fffbeb;text-align:center;">
<div style="font-size:16px;color:#78350f;font-family:serif;line-height:2;">
<span style="display:inline-block;background:#3b82f6;color:#fff;border-radius:3px;padding:0 6px;font-size:11px;font-weight:bold;"></span>天气凉了
</div>
</div>
<div class="card-body">
<h3>数字圆圈</h3>
<p>①②③④⑤ — 与节点编号对应,简洁清晰</p>
</div>
</div>
<div class="card" data-choice="icon" onclick="toggleSelect(this)">
<div class="card-image" style="padding:20px;background:#fffbeb;text-align:center;">
<div style="font-size:16px;color:#78350f;font-family:serif;line-height:2;">
<span style="display:inline-block;background:#3b82f6;color:#fff;border-radius:3px;padding:1px 5px;font-size:11px;">💡</span>天气凉了
</div>
</div>
<div class="card-body">
<h3>节点图标</h3>
<p>💡📝📚✏️📌 — 与节点类型图标一致,直观</p>
</div>
</div>
<div class="card" data-choice="dot" onclick="toggleSelect(this)">
<div class="card-image" style="padding:20px;background:#fffbeb;text-align:center;">
<div style="font-size:16px;color:#78350f;font-family:serif;line-height:2;">
<span style="display:inline-block;background:#3b82f6;color:#fff;border-radius:50%;width:10px;height:10px;font-size:8px;text-align:center;line-height:10px;"></span>天气凉了
</div>
</div>
<div class="card-body">
<h3>彩色圆点</h3>
<p>● — 极简,颜色对应节点,不干扰阅读</p>
</div>
</div>
<div class="card" data-choice="bracket" onclick="toggleSelect(this)">
<div class="card-image" style="padding:20px;background:#fffbeb;text-align:center;">
<div style="font-size:16px;color:#78350f;font-family:serif;line-height:2;">
<span style="color:#3b82f6;font-weight:bold;">【1】</span>天气凉了
</div>
</div>
<div class="card-body">
<h3>方括号编号</h3>
<p>【1】【2】【3】— 类似脚注标记,学术感</p>
</div>
</div>
</div>
</div>
<!-- 数据模型更新 -->
<div class="section">
<h3>占位符数据模型</h3>
<div style="background:#1e293b;color:#e2e8f0;padding:16px;border-radius:6px;font-family:monospace;font-size:11px;line-height:1.6;">
<div style="color:#94a3b8;">// 正文中的占位符标记</div>
<div><span style="color:#f59e0b;">interface</span> <span style="color:#3b82f6;">ContentPlaceholder</span> {</div>
<div>&nbsp;&nbsp;id: <span style="color:#10b981;">string</span>; <span style="color:#64748b;">// 占位符 ID</span></div>
<div>&nbsp;&nbsp;nodeId: <span style="color:#10b981;">string</span>; <span style="color:#64748b;">// 关联的节点 ID</span></div>
<div>&nbsp;&nbsp;offset: <span style="color:#10b981;">number</span>; <span style="color:#64748b;">// 在正文纯文本中的字符偏移量</span></div>
<div>&nbsp;&nbsp;label: <span style="color:#10b981;">string</span>; <span style="color:#64748b;">// 显示的标记("①" / "💡" / "●" / "【1】"</span></div>
<div>&nbsp;&nbsp;color: <span style="color:#10b981;">string</span>; <span style="color:#64748b;">// 节点颜色(用于占位符背景)</span></div>
<div>}</div>
<br>
<div style="color:#94a3b8;">// 正文渲染时注入占位符</div>
<div><span style="color:#f59e0b;">function</span> <span style="color:#3b82f6;">renderContentWithPlaceholders</span>(</div>
<div>&nbsp;&nbsp;content: <span style="color:#10b981;">string</span>, <span style="color:#64748b;">// Markdown 原文</span></div>
<div>&nbsp;&nbsp;placeholders: <span style="color:#3b82f6;">ContentPlaceholder</span>[]</div>
<div>): <span style="color:#10b981;">string</span> {</div>
<div>&nbsp;&nbsp;<span style="color:#64748b;">// 按 offset 排序,在对应位置插入占位符标记</span></div>
<div>&nbsp;&nbsp;<span style="color:#64748b;">// 渲染为 &lt;span class="placeholder" data-node-id="xxx"&gt;&lt;/span&gt;</span></div>
<div>}</div>
<br>
<div style="color:#94a3b8;">// CSS 透明度控制</div>
<div>.placeholder { <span style="color:#10b981;">opacity</span>: <span style="color:#f59e0b;">0.1</span>; <span style="color:#10b981;">transition</span>: <span style="color:#f59e0b;">opacity 0.2s</span>; }</div>
<div>.placeholder.active { <span style="color:#10b981;">opacity</span>: <span style="color:#f59e0b;">1</span>; }</div>
<div>.placeholder:hover { <span style="color:#10b981;">opacity</span>: <span style="color:#f59e0b;">0.6</span>; }</div>
</div>
</div>
<div class="section">
<h3>交互流程</h3>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;">
<div style="background:#fff;border:1px solid #e2e8f0;border-radius:6px;padding:12px;">
<h4 style="margin:0 0 8px 0;color:#3b82f6;">🔗 添加占位符</h4>
<ol style="margin:0;padding-left:16px;font-size:11px;color:#475569;line-height:1.8;">
<li>教师点击正文某个位置(光标位置)</li>
<li>或选中一段文字后释放</li>
<li>弹出菜单:"在此处添加节点 →"</li>
<li>选择节点类型或已有节点</li>
<li>在正文对应位置插入占位符标记</li>
<li>创建 AnchorEdge 连线</li>
</ol>
</div>
<div style="background:#fff;border:1px solid #e2e8f0;border-radius:6px;padding:12px;">
<h4 style="margin:0 0 8px 0;color:#22c55e;">👁️ 选中节点时的视觉反馈</h4>
<ol style="margin:0;padding-left:16px;font-size:11px;color:#475569;line-height:1.8;">
<li>点击画布上的某个节点</li>
<li>该节点对应的占位符 opacity 从 0.1 → 1</li>
<li>连线从 10% → 100% 显示</li>
<li>占位符添加 active 样式(边框/阴影)</li>
<li>其他占位符保持 10% 透明度</li>
<li>点击空白处恢复默认状态</li>
</ol>
</div>
</div>
</div>

View File

@@ -0,0 +1,376 @@
<h2>两种锚定方式的视觉规则</h2>
<p class="subtitle">范围锚定选文本vs 点锚定(插入占位符)— 默认/选中状态对比</p>
<!-- 规则总览 -->
<div class="section">
<h3>视觉规则总览</h3>
<div style="background:#1e293b;color:#e2e8f0;padding:16px;border-radius:6px;font-family:monospace;font-size:11px;line-height:1.8;">
<div style="color:#94a3b8;">// 两种锚定方式</div>
<div><span style="color:#f59e0b;">type</span> <span style="color:#3b82f6;">AnchorType</span> = <span style="color:#10b981;">"range"</span> | <span style="color:#10b981;">"point"</span>;</div>
<br>
<div style="color:#94a3b8;">// 范围锚定(选一段文本关联节点)</div>
<div><span style="color:#94a3b8;">// 文本背景色 = 节点颜色</span></div>
<div>.range-anchor {</div>
<div>&nbsp;&nbsp;<span style="color:#10b981;">background-color</span>: <span style="color:#f59e0b;">var(--node-color)</span>; <span style="color:#64748b;">// 节点颜色</span></div>
<div>&nbsp;&nbsp;<span style="color:#10b981;">opacity</span>: <span style="color:#f59e0b;">0</span>; <span style="color:#64748b;">// 默认完全透明(和正常文本一样)</span></div>
<div>}</div>
<div>.range-anchor.active {</div>
<div>&nbsp;&nbsp;<span style="color:#10b981;">opacity</span>: <span style="color:#f59e0b;">0.3</span>; <span style="color:#64748b;">// 选中节点时显示背景色</span></div>
<div>}</div>
<br>
<div style="color:#94a3b8;">// 点锚定(在文本中插入占位符)</div>
<div>.point-anchor {</div>
<div>&nbsp;&nbsp;<span style="color:#10b981;">opacity</span>: <span style="color:#f59e0b;">0.3</span>; <span style="color:#64748b;">// 默认半透明</span></div>
<div>}</div>
<div>.point-anchor.active {</div>
<div>&nbsp;&nbsp;<span style="color:#10b981;">opacity</span>: <span style="color:#f59e0b;">1</span>; <span style="color:#64748b;">// 选中节点时不透明</span></div>
<div>}</div>
</div>
</div>
<!-- 完整画布:默认状态 -->
<div class="section">
<h3>完整画布 — 默认状态</h3>
<div class="mockup">
<div class="mockup-header">默认状态(未选中任何节点)</div>
<div class="mockup-body" style="padding:0;background:#f1f5f9;overflow:hidden;">
<div style="font-family:monospace;font-size:12px;line-height:1.6;position:relative;height:480px;">
<!-- 画布 -->
<div style="position:relative;width:100%;height:480px;background:#f1f5f9;background-image:radial-gradient(#cbd5e1 1px, transparent 1px);background-size:20px 20px;overflow:hidden;">
<!-- SVG 连线层(默认 10% 透明度) -->
<svg style="position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none;opacity:0.1;" viewBox="0 0 800 480">
<path d="M 130 100 Q 200 120 290 155" stroke="#3b82f6" stroke-width="2" fill="none" stroke-dasharray="4 4"/>
<path d="M 130 200 Q 200 210 290 195" stroke="#f59e0b" stroke-width="2" fill="none" stroke-dasharray="4 4"/>
<path d="M 670 100 Q 600 130 510 175" stroke="#0ea5e9" stroke-width="2" fill="none" stroke-dasharray="4 4"/>
<path d="M 670 200 Q 600 220 510 215" stroke="#ec4899" stroke-width="2" fill="none" stroke-dasharray="4 4"/>
<path d="M 670 320 Q 600 300 510 255" stroke="#22c55e" stroke-width="2" fill="none" stroke-dasharray="4 4"/>
</svg>
<!-- 中央:正文容器 -->
<div style="position:absolute;left:280px;top:40px;width:240px;background:#fffbeb;border:2px solid #f59e0b;border-radius:8px;padding:12px;box-shadow:0 4px 12px rgba(0,0,0,0.1);">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;border-bottom:1px solid #fde68a;padding-bottom:6px;">
<span style="font-size:11px;font-weight:bold;color:#92400e;">📜 课文正文</span>
<span style="font-size:9px;color:#94a3b8;background:#fef3c7;padding:1px 4px;border-radius:2px;">🔒 固定</span>
</div>
<div style="font-size:13px;color:#78350f;line-height:2.2;">
<div style="text-align:center;font-weight:bold;margin-bottom:6px;">秋天</div>
<p style="margin:0 0 6px 0;">
<!-- 范围锚定:默认 opacity:0完全透明和正常文本一样 -->
<span style="background:#3b82f6;opacity:0;color:#78350f;">天气凉了</span>,树叶黄了,
</p>
<p style="margin:0 0 6px 0;">
<!-- 点锚定:默认 opacity:0.3(半透明) -->
<span style="display:inline-block;background:#f59e0b;color:#fff;border-radius:3px;padding:0 4px;font-size:9px;font-weight:bold;opacity:0.3;margin-right:2px;"></span>天空那么蓝,那么高。
</p>
<p style="margin:0 0 6px 0;">
一群大雁往南飞,
</p>
<p style="margin:0 0 6px 0;">
<!-- 范围锚定:默认 opacity:0 -->
一会儿排成个<span style="background:#0ea5e9;opacity:0;color:#78350f;">"人"字</span>
<!-- 点锚定:默认 opacity:0.3 -->
<span style="display:inline-block;background:#ec4899;color:#fff;border-radius:3px;padding:0 4px;font-size:9px;font-weight:bold;opacity:0.3;margin-left:2px;"></span>
</p>
<p style="margin:0;">
<!-- 点锚定:默认 opacity:0.3 -->
<span style="display:inline-block;background:#22c55e;color:#fff;border-radius:3px;padding:0 4px;font-size:9px;font-weight:bold;opacity:0.3;margin-right:2px;"></span>啊!秋天来了!
</p>
</div>
</div>
<!-- 左侧节点 -->
<div style="position:absolute;left:30px;top:70px;width:140px;background:#fff;border:1px solid #3b82f6;border-left:3px solid #3b82f6;border-radius:6px;padding:8px;box-shadow:0 2px 6px rgba(0,0,0,0.08);">
<div style="display:flex;align-items:center;gap:4px;margin-bottom:4px;">
<span style="background:#3b82f6;color:#fff;border-radius:50%;width:14px;height:14px;display:inline-flex;align-items:center;justify-content:center;font-size:8px;font-weight:bold;">1</span>
<span style="font-size:10px;font-weight:bold;color:#1e3a8a;">💡 导入</span>
<span style="margin-left:auto;font-size:8px;color:#94a3b8;background:#eff6ff;padding:1px 4px;border-radius:2px;">范围</span>
</div>
<div style="font-size:9px;color:#64748b;">提问:你见过秋天的树叶吗?</div>
</div>
<div style="position:absolute;left:30px;top:180px;width:140px;background:#fff;border:1px solid #f59e0b;border-left:3px solid #f59e0b;border-radius:6px;padding:8px;box-shadow:0 2px 6px rgba(0,0,0,0.08);">
<div style="display:flex;align-items:center;gap:4px;margin-bottom:4px;">
<span style="background:#f59e0b;color:#fff;border-radius:50%;width:14px;height:14px;display:inline-flex;align-items:center;justify-content:center;font-size:8px;font-weight:bold;">2</span>
<span style="font-size:10px;font-weight:bold;color:#92400e;">📝 文本研习</span>
<span style="margin-left:auto;font-size:8px;color:#94a3b8;background:#fffbeb;padding:1px 4px;border-radius:2px;"></span>
</div>
<div style="font-size:9px;color:#64748b;">赏析:叠词的运用</div>
</div>
<!-- 右侧节点 -->
<div style="position:absolute;left:630px;top:70px;width:140px;background:#fff;border:1px solid #0ea5e9;border-left:3px solid #0ea5e9;border-radius:6px;padding:8px;box-shadow:0 2px 6px rgba(0,0,0,0.08);">
<div style="display:flex;align-items:center;gap:4px;margin-bottom:4px;">
<span style="background:#0ea5e9;color:#fff;border-radius:50%;width:14px;height:14px;display:inline-flex;align-items:center;justify-content:center;font-size:8px;font-weight:bold;">3</span>
<span style="font-size:10px;font-weight:bold;color:#075985;">📚 新授</span>
<span style="margin-left:auto;font-size:8px;color:#94a3b8;background:#f0f9ff;padding:1px 4px;border-radius:2px;">范围</span>
</div>
<div style="font-size:9px;color:#64748b;">讲解:大雁南飞</div>
</div>
<div style="position:absolute;left:630px;top:180px;width:140px;background:#fff;border:1px solid #ec4899;border-left:3px solid #ec4899;border-radius:6px;padding:8px;box-shadow:0 2px 6px rgba(0,0,0,0.08);">
<div style="display:flex;align-items:center;gap:4px;margin-bottom:4px;">
<span style="background:#ec4899;color:#fff;border-radius:50%;width:14px;height:14px;display:inline-flex;align-items:center;justify-content:center;font-size:8px;font-weight:bold;">4</span>
<span style="font-size:10px;font-weight:bold;color:#9f1239;">✏️ 练习</span>
<span style="margin-left:auto;font-size:8px;color:#94a3b8;background:#fdf2f8;padding:1px 4px;border-radius:2px;"></span>
</div>
<div style="font-size:9px;color:#64748b;">3 道题</div>
</div>
<div style="position:absolute;left:630px;top:300px;width:140px;background:#fff;border:1px solid #22c55e;border-left:3px solid #22c55e;border-radius:6px;padding:8px;box-shadow:0 2px 6px rgba(0,0,0,0.08);">
<div style="display:flex;align-items:center;gap:4px;margin-bottom:4px;">
<span style="background:#22c55e;color:#fff;border-radius:50%;width:14px;height:14px;display:inline-flex;align-items:center;justify-content:center;font-size:8px;font-weight:bold;">5</span>
<span style="font-size:10px;font-weight:bold;color:#166534;">📌 小结</span>
<span style="margin-left:auto;font-size:8px;color:#94a3b8;background:#f0fdf4;padding:1px 4px;border-radius:2px;"></span>
</div>
<div style="font-size:9px;color:#64748b;">总结全文</div>
</div>
<div style="position:absolute;top:12px;right:12px;background:#fff;border:1px solid #e2e8f0;border-radius:4px;padding:6px 10px;font-size:9px;color:#64748b;">
<div>🔵 范围锚定:默认 opacity:0</div>
<div>🔴 点锚定:默认 opacity:0.3</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 选中节点 1范围锚定的状态 -->
<div class="section">
<h3>选中"导入"节点(范围锚定)— 文本背景显示</h3>
<div class="mockup">
<div class="mockup-header">选中节点 1 — "天气凉了"背景色显示</div>
<div class="mockup-body" style="padding:0;background:#f1f5f9;overflow:hidden;">
<div style="font-family:monospace;font-size:12px;line-height:1.6;position:relative;height:320px;">
<div style="position:relative;width:100%;height:320px;background:#f1f5f9;background-image:radial-gradient(#cbd5e1 1px, transparent 1px);background-size:20px 20px;overflow:hidden;">
<!-- SVG 连线层(选中节点的连线 100% 显示) -->
<svg style="position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none;" viewBox="0 0 800 320">
<!-- 选中节点的连线 100% 显示 -->
<path d="M 130 80 Q 200 100 290 135" stroke="#3b82f6" stroke-width="2.5" fill="none" stroke-dasharray="4 4" opacity="1"/>
<circle cx="290" cy="135" r="5" fill="#3b82f6"/>
<!-- 其他连线保持 10% -->
<path d="M 130 180 Q 200 190 290 175" stroke="#f59e0b" stroke-width="2" fill="none" stroke-dasharray="4 4" opacity="0.1"/>
<path d="M 670 80 Q 600 110 510 155" stroke="#0ea5e9" stroke-width="2" fill="none" stroke-dasharray="4 4" opacity="0.1"/>
<path d="M 670 180 Q 600 200 510 195" stroke="#ec4899" stroke-width="2" fill="none" stroke-dasharray="4 4" opacity="0.1"/>
<path d="M 670 280 Q 600 260 510 235" stroke="#22c55e" stroke-width="2" fill="none" stroke-dasharray="4 4" opacity="0.1"/>
</svg>
<!-- 正文容器 -->
<div style="position:absolute;left:280px;top:20px;width:240px;background:#fffbeb;border:2px solid #f59e0b;border-radius:8px;padding:12px;box-shadow:0 4px 12px rgba(0,0,0,0.1);">
<div style="font-size:13px;color:#78350f;line-height:2.2;">
<div style="text-align:center;font-weight:bold;margin-bottom:6px;">秋天</div>
<p style="margin:0 0 6px 0;">
<!-- 范围锚定:选中时 opacity:0.3(背景色显示) -->
<span style="background:#3b82f6;opacity:0.3;color:#78350f;border-radius:2px;">天气凉了</span>,树叶黄了,
</p>
<p style="margin:0 0 6px 0;">
<!-- 点锚定:未选中,保持 0.3 -->
<span style="display:inline-block;background:#f59e0b;color:#fff;border-radius:3px;padding:0 4px;font-size:9px;font-weight:bold;opacity:0.3;margin-right:2px;"></span>天空那么蓝,那么高。
</p>
<p style="margin:0 0 6px 0;">一群大雁往南飞,</p>
<p style="margin:0 0 6px 0;">
一会儿排成个<span style="background:#0ea5e9;opacity:0;color:#78350f;">"人"字</span>
<span style="display:inline-block;background:#ec4899;color:#fff;border-radius:3px;padding:0 4px;font-size:9px;font-weight:bold;opacity:0.3;margin-left:2px;"></span>
</p>
<p style="margin:0;">
<span style="display:inline-block;background:#22c55e;color:#fff;border-radius:3px;padding:0 4px;font-size:9px;font-weight:bold;opacity:0.3;margin-right:2px;"></span>啊!秋天来了!
</p>
</div>
</div>
<!-- 选中的节点 1高亮边框 -->
<div style="position:absolute;left:30px;top:50px;width:140px;background:#fff;border:2px solid #3b82f6;border-left:3px solid #3b82f6;border-radius:6px;padding:8px;box-shadow:0 0 0 3px #3b82f633,0 2px 6px rgba(0,0,0,0.08);">
<div style="display:flex;align-items:center;gap:4px;margin-bottom:4px;">
<span style="background:#3b82f6;color:#fff;border-radius:50%;width:14px;height:14px;display:inline-flex;align-items:center;justify-content:center;font-size:8px;font-weight:bold;">1</span>
<span style="font-size:10px;font-weight:bold;color:#1e3a8a;">💡 导入</span>
<span style="margin-left:auto;font-size:8px;color:#fff;background:#3b82f6;padding:1px 4px;border-radius:2px;">范围·选中</span>
</div>
<div style="font-size:9px;color:#64748b;">提问:你见过秋天的树叶吗?</div>
</div>
<!-- 其他节点(正常状态) -->
<div style="position:absolute;left:30px;top:160px;width:140px;background:#fff;border:1px solid #f59e0b;border-left:3px solid #f59e0b;border-radius:6px;padding:8px;box-shadow:0 2px 6px rgba(0,0,0,0.08);opacity:0.7;">
<div style="display:flex;align-items:center;gap:4px;">
<span style="background:#f59e0b;color:#fff;border-radius:50%;width:14px;height:14px;display:inline-flex;align-items:center;justify-content:center;font-size:8px;font-weight:bold;">2</span>
<span style="font-size:10px;font-weight:bold;color:#92400e;">📝 文本研习</span>
</div>
</div>
<div style="position:absolute;left:630px;top:50px;width:140px;background:#fff;border:1px solid #0ea5e9;border-left:3px solid #0ea5e9;border-radius:6px;padding:8px;box-shadow:0 2px 6px rgba(0,0,0,0.08);opacity:0.7;">
<div style="display:flex;align-items:center;gap:4px;">
<span style="background:#0ea5e9;color:#fff;border-radius:50%;width:14px;height:14px;display:inline-flex;align-items:center;justify-content:center;font-size:8px;font-weight:bold;">3</span>
<span style="font-size:10px;font-weight:bold;color:#075985;">📚 新授</span>
</div>
</div>
<div style="position:absolute;top:12px;right:12px;background:#fff;border:1px solid #3b82f6;border-radius:4px;padding:6px 10px;font-size:9px;color:#3b82f6;">
✅ 选中"导入"节点 → "天气凉了"背景显示
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 选中节点 2点锚定的状态 -->
<div class="section">
<h3>选中"文本研习"节点(点锚定)— 占位符不透明</h3>
<div class="mockup">
<div class="mockup-header">选中节点 2 — 占位符 ② 100% 显示</div>
<div class="mockup-body" style="padding:0;background:#f1f5f9;overflow:hidden;">
<div style="font-family:monospace;font-size:12px;line-height:1.6;position:relative;height:320px;">
<div style="position:relative;width:100%;height:320px;background:#f1f5f9;background-image:radial-gradient(#cbd5e1 1px, transparent 1px);background-size:20px 20px;overflow:hidden;">
<svg style="position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none;" viewBox="0 0 800 320">
<path d="M 130 80 Q 200 100 290 135" stroke="#3b82f6" stroke-width="2" fill="none" stroke-dasharray="4 4" opacity="0.1"/>
<!-- 选中节点的连线 100% -->
<path d="M 130 180 Q 200 190 290 175" stroke="#f59e0b" stroke-width="2.5" fill="none" stroke-dasharray="4 4" opacity="1"/>
<circle cx="290" cy="175" r="5" fill="#f59e0b"/>
<path d="M 670 80 Q 600 110 510 155" stroke="#0ea5e9" stroke-width="2" fill="none" stroke-dasharray="4 4" opacity="0.1"/>
<path d="M 670 180 Q 600 200 510 195" stroke="#ec4899" stroke-width="2" fill="none" stroke-dasharray="4 4" opacity="0.1"/>
<path d="M 670 280 Q 600 260 510 235" stroke="#22c55e" stroke-width="2" fill="none" stroke-dasharray="4 4" opacity="0.1"/>
</svg>
<div style="position:absolute;left:280px;top:20px;width:240px;background:#fffbeb;border:2px solid #f59e0b;border-radius:8px;padding:12px;box-shadow:0 4px 12px rgba(0,0,0,0.1);">
<div style="font-size:13px;color:#78350f;line-height:2.2;">
<div style="text-align:center;font-weight:bold;margin-bottom:6px;">秋天</div>
<p style="margin:0 0 6px 0;">
<!-- 范围锚定未选中opacity:0 -->
<span style="background:#3b82f6;opacity:0;color:#78350f;">天气凉了</span>,树叶黄了,
</p>
<p style="margin:0 0 6px 0;">
<!-- 点锚定:选中时 opacity:1不透明 -->
<span style="display:inline-block;background:#f59e0b;color:#fff;border-radius:3px;padding:0 4px;font-size:9px;font-weight:bold;opacity:1;margin-right:2px;box-shadow:0 0 0 2px #f59e0b44;"></span>天空那么蓝,那么高。
</p>
<p style="margin:0 0 6px 0;">一群大雁往南飞,</p>
<p style="margin:0 0 6px 0;">
一会儿排成个<span style="background:#0ea5e9;opacity:0;color:#78350f;">"人"字</span>
<span style="display:inline-block;background:#ec4899;color:#fff;border-radius:3px;padding:0 4px;font-size:9px;font-weight:bold;opacity:0.3;margin-left:2px;"></span>
</p>
<p style="margin:0;">
<span style="display:inline-block;background:#22c55e;color:#fff;border-radius:3px;padding:0 4px;font-size:9px;font-weight:bold;opacity:0.3;margin-right:2px;"></span>啊!秋天来了!
</p>
</div>
</div>
<div style="position:absolute;left:30px;top:50px;width:140px;background:#fff;border:1px solid #3b82f6;border-left:3px solid #3b82f6;border-radius:6px;padding:8px;box-shadow:0 2px 6px rgba(0,0,0,0.08);opacity:0.7;">
<div style="display:flex;align-items:center;gap:4px;">
<span style="background:#3b82f6;color:#fff;border-radius:50%;width:14px;height:14px;display:inline-flex;align-items:center;justify-content:center;font-size:8px;font-weight:bold;">1</span>
<span style="font-size:10px;font-weight:bold;color:#1e3a8a;">💡 导入</span>
</div>
</div>
<!-- 选中的节点 2高亮边框 -->
<div style="position:absolute;left:30px;top:160px;width:140px;background:#fff;border:2px solid #f59e0b;border-left:3px solid #f59e0b;border-radius:6px;padding:8px;box-shadow:0 0 0 3px #f59e0b33,0 2px 6px rgba(0,0,0,0.08);">
<div style="display:flex;align-items:center;gap:4px;margin-bottom:4px;">
<span style="background:#f59e0b;color:#fff;border-radius:50%;width:14px;height:14px;display:inline-flex;align-items:center;justify-content:center;font-size:8px;font-weight:bold;">2</span>
<span style="font-size:10px;font-weight:bold;color:#92400e;">📝 文本研习</span>
<span style="margin-left:auto;font-size:8px;color:#fff;background:#f59e0b;padding:1px 4px;border-radius:2px;">点·选中</span>
</div>
<div style="font-size:9px;color:#64748b;">赏析:叠词的运用</div>
</div>
<div style="position:absolute;left:630px;top:50px;width:140px;background:#fff;border:1px solid #0ea5e9;border-left:3px solid #0ea5e9;border-radius:6px;padding:8px;box-shadow:0 2px 6px rgba(0,0,0,0.08);opacity:0.7;">
<div style="display:flex;align-items:center;gap:4px;">
<span style="background:#0ea5e9;color:#fff;border-radius:50%;width:14px;height:14px;display:inline-flex;align-items:center;justify-content:center;font-size:8px;font-weight:bold;">3</span>
<span style="font-size:10px;font-weight:bold;color:#075985;">📚 新授</span>
</div>
</div>
<div style="position:absolute;top:12px;right:12px;background:#fff;border:1px solid #f59e0b;border-radius:4px;padding:6px 10px;font-size:9px;color:#f59e0b;">
✅ 选中"文本研习"节点 → 占位符 ② 不透明显示
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 两种锚定方式对比表 -->
<div class="section">
<h3>两种锚定方式对比</h3>
<div style="overflow-x:auto;">
<table style="width:100%;border-collapse:collapse;font-size:12px;">
<thead>
<tr style="background:#1e293b;color:#e2e8f0;">
<th style="padding:8px 12px;text-align:left;border:1px solid #334155;">特性</th>
<th style="padding:8px 12px;text-align:left;border:1px solid #334155;">范围锚定(选文本)</th>
<th style="padding:8px 12px;text-align:left;border:1px solid #334155;">点锚定(插入占位符)</th>
</tr>
</thead>
<tbody>
<tr style="background:#fff;">
<td style="padding:8px 12px;border:1px solid #e2e8f0;font-weight:bold;">触发方式</td>
<td style="padding:8px 12px;border:1px solid #e2e8f0;">选中一段文字 → 关联节点</td>
<td style="padding:8px 12px;border:1px solid #e2e8f0;">点击文本某位置 → 插入占位符</td>
</tr>
<tr style="background:#f8fafc;">
<td style="padding:8px 12px;border:1px solid #e2e8f0;font-weight:bold;">视觉表现</td>
<td style="padding:8px 12px;border:1px solid #e2e8f0;">文本背景色 = 节点颜色</td>
<td style="padding:8px 12px;border:1px solid #e2e8f0;">插入标记符号(①②③)</td>
</tr>
<tr style="background:#fff;">
<td style="padding:8px 12px;border:1px solid #e2e8f0;font-weight:bold;">默认透明度</td>
<td style="padding:8px 12px;border:1px solid #e2e8f0;">
<span style="background:#3b82f6;color:#fff;padding:2px 6px;border-radius:3px;font-size:10px;">opacity: 0</span>
<span style="color:#64748b;font-size:10px;">(完全透明,和正常文本一样)</span>
</td>
<td style="padding:8px 12px;border:1px solid #e2e8f0;">
<span style="background:#f59e0b;color:#fff;padding:2px 6px;border-radius:3px;font-size:10px;">opacity: 0.3</span>
<span style="color:#64748b;font-size:10px;">(半透明,隐约可见)</span>
</td>
</tr>
<tr style="background:#f8fafc;">
<td style="padding:8px 12px;border:1px solid #e2e8f0;font-weight:bold;">选中时透明度</td>
<td style="padding:8px 12px;border:1px solid #e2e8f0;">
<span style="background:#3b82f6;color:#fff;padding:2px 6px;border-radius:3px;font-size:10px;">opacity: 0.3</span>
<span style="color:#64748b;font-size:10px;">(背景色显示)</span>
</td>
<td style="padding:8px 12px;border:1px solid #e2e8f0;">
<span style="background:#f59e0b;color:#fff;padding:2px 6px;border-radius:3px;font-size:10px;">opacity: 1</span>
<span style="color:#64748b;font-size:10px;">(不透明,完整显示)</span>
</td>
</tr>
<tr style="background:#fff;">
<td style="padding:8px 12px;border:1px solid #e2e8f0;font-weight:bold;">连线透明度</td>
<td colspan="2" style="padding:8px 12px;border:1px solid #e2e8f0;">
默认 <span style="background:#64748b;color:#fff;padding:2px 6px;border-radius:3px;font-size:10px;">opacity: 0.1</span> · 选中时 <span style="background:#3b82f6;color:#fff;padding:2px 6px;border-radius:3px;font-size:10px;">opacity: 1</span>
</td>
</tr>
<tr style="background:#f8fafc;">
<td style="padding:8px 12px;border:1px solid #e2e8f0;font-weight:bold;">适用场景</td>
<td style="padding:8px 12px;border:1px solid #e2e8f0;">节点与具体文字内容相关(如赏析某词、讲解某句)</td>
<td style="padding:8px 12px;border:1px solid #e2e8f0;">节点对应文本某个位置(如在此处开始导入、在此处小结)</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- 数据模型 -->
<div class="section">
<h3>数据模型</h3>
<div style="background:#1e293b;color:#e2e8f0;padding:16px;border-radius:6px;font-family:monospace;font-size:11px;line-height:1.6;">
<div style="color:#94a3b8;">// 锚点 — 统一接口,区分 type</div>
<div><span style="color:#f59e0b;">interface</span> <span style="color:#3b82f6;">NodeAnchor</span> {</div>
<div>&nbsp;&nbsp;id: <span style="color:#10b981;">string</span>;</div>
<div>&nbsp;&nbsp;nodeId: <span style="color:#10b981;">string</span>; <span style="color:#64748b;">// 关联的节点</span></div>
<div>&nbsp;&nbsp;type: <span style="color:#10b981;">"range"</span> | <span style="color:#10b981;">"point"</span>; <span style="color:#64748b;">// 两种锚定方式</span></div>
<div>&nbsp;&nbsp;start: <span style="color:#10b981;">number</span>; <span style="color:#64748b;">// 正文纯文本偏移量</span></div>
<div>&nbsp;&nbsp;end?: <span style="color:#10b981;">number</span>; <span style="color:#64748b;">// range 锚定的结束偏移point 无)</span></div>
<div>&nbsp;&nbsp;textPreview?: <span style="color:#10b981;">string</span>; <span style="color:#64748b;">// range 锚定的文字预览</span></div>
<div>}</div>
<br>
<div style="color:#94a3b8;">// 渲染规则</div>
<div><span style="color:#f59e0b;">function</span> <span style="color:#3b82f6;">getAnchorStyle</span>(anchor: <span style="color:#3b82f6;">NodeAnchor</span>, isActive: <span style="color:#10b981;">boolean</span>) {</div>
<div>&nbsp;&nbsp;<span style="color:#f59e0b;">if</span> (anchor.type === <span style="color:#10b981;">"range"</span>) {</div>
<div>&nbsp;&nbsp;&nbsp;&nbsp;<span style="color:#f59e0b;">return</span> { backgroundColor: getNodeColor(anchor.nodeId), opacity: isActive ? <span style="color:#f59e0b;">0.3</span> : <span style="color:#f59e0b;">0</span> };</div>
<div>&nbsp;&nbsp;} <span style="color:#f59e0b;">else</span> { <span style="color:#64748b;">// point</span></div>
<div>&nbsp;&nbsp;&nbsp;&nbsp;<span style="color:#f59e0b;">return</span> { opacity: isActive ? <span style="color:#f59e0b;">1</span> : <span style="color:#f59e0b;">0.3</span> };</div>
<div>&nbsp;&nbsp;}</div>
<div>}</div>
</div>
</div>

View File

@@ -0,0 +1 @@
{"reason":"idle timeout","timestamp":1782143663726}

View File

@@ -0,0 +1,3 @@
sessionDir=e:\Desktop\CICD\.superpowers\brainstorm\41500-1782168322.9344
stateDir=e:\Desktop\CICD\.superpowers\brainstorm\41500-1782168322.9344\state
contentDir=e:\Desktop\CICD\.superpowers\brainstorm\41500-1782168322.9344\content

1
.tsc_out.txt Normal file
View File

@@ -0,0 +1 @@
src/app/(dashboard)/teacher/textbooks/error.tsx(3,10): error TS2305: Module '"@/shared/components/route-error"' has no exported member 'RouteError'.

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

1734
build-output.txt Normal file

File diff suppressed because it is too large Load Diff

4
cookies.txt Normal file
View File

@@ -0,0 +1,4 @@
# Netscape HTTP Cookie File
# https://curl.se/docs/http-cookies.html
# This file was generated by libcurl! Edit at your own risk.

BIN
debug-exams.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

View File

@@ -1255,7 +1255,7 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
- Lib✅ P1-2 新增,✅ P3 更新签名,✅ P3-26 拆分,✅ v4-P2-6 新增 scope-check`toNumber` / `normalize`(位于 `lib/grade-utils.ts``buildScopeClassFilter(scope, currentUserId?)`P3-26 从 grade-utils.ts 迁移至 `lib/scope-filter.ts`P3 修复:`class_members` scope 内置 studentId 过滤,需传入 currentUserId 参数);`assertClassInScope(scope: DataScope, classId: string): string | null`(✅ v4-P2-6 从 actions.ts 迁移至 `lib/scope-check.ts`:校验 classId 是否在 scope 允许范围内,供 actions.ts 与 actions-analytics.ts 复用。迁移原因actions.ts 是 "use server" 文件要求所有 export 为 async而 assertClassInScope 是同步函数)
- Stats-service✅ P1-1 新增):`computeGradeStats` / `computeAverageScore` / `buildGradeTrendPoints` / `computeTrendAverage` / `computeClassComparisonStats` / `computeSubjectComparisonStats` / `computeGradeDistribution` / `buildRankingTrendPoints`(从 3 个 data-access 文件抽取的纯函数,使数据层专注 DB I/O统计逻辑可独立测试
- Export✅ v4-P1-12 新增):`exportGradeRecordsToExcel` / `exportClassGradeReportToExcel` / `exportStudentGradeRecordsToExcel`v4-P1-12 新增:导出单个学生成绩单家长视角,仅含成绩明细 + 个人统计不含班级数据scope 为 children 自动按 studentId 过滤)/ `formatDateForFile`(已迁移至 shared/lib/utils
- Components✅ P1-5 新增):`WidgetBoundary`Error Boundary + Suspense + Skeleton 组合,含 a11y 属性)/ `SchoolWideSummaryCard`(✅ v3-P2 新增管理员全校成绩汇总卡片4 个统计卡片 + 各年级对比表格)/ `ScoreCell`(✅ v4-P1-7 新增:成绩单元格组件,根据得分率着色——红<60%/黄60-84%/绿≥85%,使用语义化 Tailwind 类名避免动态拼接fullScore<=0 时不着色)
- Components✅ P1-5 新增,✅ Phase 4.6 拆分扩展`WidgetBoundary`Error Boundary + Suspense + Skeleton 组合,含 a11y 属性)/ `SchoolWideSummaryCard`(✅ v3-P2 新增管理员全校成绩汇总卡片4 个统计卡片 + 各年级对比表格)/ `ScoreCell`(✅ v4-P1-7 新增:成绩单元格组件,根据得分率着色——红<60%/黄60-84%/绿≥85%,使用语义化 Tailwind 类名避免动态拼接fullScore<=0 时不着色)/ `BatchGradeEntrySelectors`(✅ Phase 4.6 新增:批量录入试卷/班级选择器子组件)/ `BatchGradeEntryToolbar`(✅ Phase 4.6 新增:批量录入底部工具栏)/ `GradeRecordRow`(✅ Phase 4.6 新增:成绩记录行渲染:桌面表格+移动端卡片)/ `GradeRecordDetail`(✅ Phase 4.6 新增:成绩记录编辑/删除对话框集合,导出 EditableFields 类型与 toEditableFields 工具函数)
**依赖关系**
- 依赖:`shared/*`、`@/auth`、`classes`(✅ P1-1 已修复:通过 classes data-access.getClassExists/getClassNameById/getClassNamesByIds/getActiveStudentIdsByClassId/getStudentActiveClassId/getClassesByGradeId/getClassGradeIdsByClassIds、`school`(✅ P1-1 已修复:通过 school data-access.getSubjectOptions/getGradeOptions、`users`(✅ P1-1 已修复:通过 users data-access.getUserNamesByIds、`exams`(✅ 2026-06-24 新增:通过 exams data-access.getExamsForGradeEntry/getExamForGradeEntry 获取试卷列表和详情供按试卷录入成绩使用)、`error-book`(✅ 2026-06-25 新增:通过 error-book data-access.collectFromExamSubmission 在成绩录入后自动采集考试错题)、`diagnostic`(通过 diagnostic data-access.updateMasteryFromExamScore 更新掌握度)
@@ -1365,25 +1365,25 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
| `components/school-wide-summary-card.tsx` | - | v3-P2 新增管理员全校成绩汇总卡片4 个统计卡片 + 各年级对比表格) |
| `components/score-cell.tsx` | 41 | v4-P1-7 新增:成绩单元格组件,根据得分率着色(红<60%/黄60-84%/绿≥85%),使用语义化 Tailwind 类名 |
| `components/grade-trend-card.tsx` | 69 | 趋势卡片v2-P2-9 修复a11yv2-P1-4i18nP3 修复NaN 日期检查 + fullScore > 0 守卫) |
| `components/grade-record-list.tsx` | 200+ | 成绩记录列表v2-P1-4i18nP3 修复safeActionCall 包装删除操作v4-P1-7使用 ScoreCellv4-P1-10overflow-x-autov3-P3-2 新增:多选复选框 + 全选 + 批量删除工具栏 + 批量删除确认对话框,接入 bulkDeleteGradeRecordsAction |
| `components/grade-distribution-chart.tsx` | 100 | 分数分布图v2-P1-4i18n |
| `components/subject-comparison-chart.tsx` | 62 | 科目对比图v2-P1-4i18n |
| `components/class-comparison-chart.tsx` | 194 | 班级对比图v2-P1-4i18nv3-P3-5 新增:显著性分析区域,基于极差和样本量的经验规则判断班级间差异,含可折叠详细分析) |
| `components/grade-trend-chart.tsx` | 59 | 趋势图v2-P1-4i18n |
| `components/grade-record-form.tsx` | 177 | 录入表单v2-P2-7 修复Label htmlForv2-P1-4i18nP3 修复safeActionCall 包装提交) |
| `components/batch-grade-entry.tsx` | 422 | 2026-06-24 完全重写:导出名改为 BatchGradeEntryByExam按试卷录入每题得分。Excel 式表格(行=学生,列=题目,末列=总分自动计算)。交互:试卷选择器(按 scope 过滤)→ 班级选择器(按试卷 gradeId 过滤);多行多列 Excel 粘贴Tab 分隔Enter 跳下一行同一列Tab 跳下一格;实时统计(已录入/总数/均分/最高/最低分数校验超过题目满分标红撤销机制sessionStorage 5 分钟有效)。**P1-6/P1-7 重构2026-06-25**:拆分为 3 个子组件 + 1 个 HookBatchGradeEntryTable + BatchGradeEntryStats + BatchGradeEntryDialog + useBatchGradeEntryUndo主组件从 604 行降至 422 行;撤销数据使用 isUndoData 类型守卫替代 as { ids: string[]; timestamp: number } 断言window.confirm() 已替换为声明式 AlertDialog。Props: exams/classes/classGradeMap/exam/students/defaultExamId?/defaultClassId?(原 v2-P2-7 Label htmlForv2-P1-4 i18nP3 safeActionCall + localStorage 安全检查v3-P2 服务端草稿v3-P3-1 下载 CSV 模板v4-P3-2 新手引导均已替换) |
| `components/batch-grade-entry.tsx` | 376 | 2026-06-24 完全重写:导出名改为 BatchGradeEntryByExam按试卷录入每题得分。Excel 式表格(行=学生,列=题目,末列=总分自动计算)。交互:试卷选择器(按 scope 过滤)→ 班级选择器(按试卷 gradeId 过滤);多行多列 Excel 粘贴Tab 分隔Enter 跳下一行同一列Tab 跳下一格;实时统计(已录入/总数/均分/最高/最低分数校验超过题目满分标红撤销机制sessionStorage 5 分钟有效)。**P1-6/P1-7 重构2026-06-25**:拆分为 3 个子组件 + 1 个 HookBatchGradeEntryTable + BatchGradeEntryStats + BatchGradeEntryDialog + useBatchGradeEntryUndo**Phase 4.6 重构2026-07-06**:进一步拆出 BatchGradeEntrySelectors试卷/班级选择器含未选态)+ BatchGradeEntryToolbar保存/清除按钮+粘贴提示),主组件从 457 行降至 376 行,容器仅保留状态编排与 Server Action 调用。撤销数据使用 isUndoData 类型守卫替代 as { ids: string[]; timestamp: number } 断言window.confirm() 已替换为声明式 AlertDialog。Props: exams/classes/classGradeMap/exam/students/defaultExamId?/defaultClassId? |
| `components/batch-grade-entry-selectors.tsx` | 124 | **Phase 4.6 新增2026-07-06**:批量录入试卷/班级选择器子组件(含未选试卷态引导)。从 batch-grade-entry.tsx 拆分,承接试卷选择器、班级选择器、试卷信息徽章三个区块的渲染 |
| `components/batch-grade-entry-toolbar.tsx` | 53 | **Phase 4.6 新增2026-07-06**:批量录入底部工具栏子组件(清除按钮 + 保存按钮 + 粘贴提示)。从 batch-grade-entry.tsx 拆分 |
| `components/batch-grade-entry-table.tsx` | 115 | **P1-6 新增2026-06-25**:批量录入 Excel 式表格子组件(行=学生,列=题目,末列=总分自动计算)。支持多行多列 Excel 粘贴、Enter 跳下一行、Tab 跳下一格、分数校验(超过题目满分标红)。接受 inputRefs 供父组件管理输入焦点 |
| `components/batch-grade-entry-stats.tsx` | 68 | **P1-6 新增2026-06-25**:批量录入统计栏子组件,显示已录入/总数/均分/最高/最低/无效分数徽章,含学生搜索框过滤。导出 BatchGradeEntryStatsData 接口 |
| `components/batch-grade-entry-dialog.tsx` | 58 | **P1-6 新增2026-06-25**:批量录入切换确认对话框子组件(替代 window.confirm())。导出 PendingSwitch 联合类型 { kind: "exam"; examId; message } \| { kind: "class"; classId; message } \| null |
| `hooks/use-batch-grade-entry-undo.ts` | 86 | **P1-6/P1-7 新增2026-06-25**:批量录入撤销 Hook。封装撤销令牌的序列化、sessionStorage 读写、5 分钟 TTL 过期检查、撤销 Action 调用。包含 isUndoData 类型守卫函数(替代 as 断言),使用 in 运算符做安全属性访问 |
| `components/grade-filters.tsx` | 76 | 过滤器v2-P1-4i18n**P2-22026-06-25**:补全 : JSX.Element 返回值类型) |
| `components/student-grade-summary.tsx` | 107 | 学生成绩摘要v2-P1-4i18n |
| `components/export-button.tsx` | 79 | 导出按钮v2-P1-4i18nP3 修复safeActionCall 包装导出操作;**P2-22026-06-25**handleExport 补全 : Promise<void> 返回值类型) |
| `components/analytics-filters.tsx` | 86+ | 分析过滤器v2-P1-4i18nv3-P2 新增exams/currentExamId/currentSemester props添加学期和考试筛选 ChipNav |
| `components/stats-class-selector.tsx` | 40 | 统计班级选择器v2-P1-4i18n |
| `components/grade-stats-card.tsx` | 74 | 统计卡片v2-P1-4i18n |
| `components/class-grade-report.tsx` | 90 | 班级成绩报告v2-P1-4i18n |
| `components/grade-query-filters.tsx` | 96 | 查询过滤器v2-P2-7 修复Label htmlForv2-P1-4i18n**P2-22026-06-25**:补全 : JSX.Element 返回值类型) |
| `components/class-grade-report.tsx` | 130 | 班级成绩报告v2-P1-4i18n**Phase 4.6 重构2026-07-06**:内联原 GradeStatsCard 实现,使用 StatsGrid + 8 个 StatCardaverage/median/max/min/stdDev/passRate/excellentRate/count含 count=0 空态 Card |
| `components/grade-record-list.tsx` | 232 | 成绩记录列表v2-P1-4i18nP3 修复safeActionCall 包装删除操作v4-P1-7使用 ScoreCellv4-P1-10overflow-x-autov3-P3-2 新增:多选复选框 + 全选 + 批量删除工具栏 + 批量删除确认对话框;**Phase 4.6 重构2026-07-06**:行渲染(桌面表格+移动端卡片)拆分至 GradeRecordRow编辑/删除/批量删除对话框拆分至 GradeRecordDetail主组件从 523 行降至 232 行 |
| `components/grade-record-row.tsx` | 175 | **Phase 4.6 新增2026-07-06**:成绩记录行渲染子组件,承接桌面端表格行 + 移动端卡片堆叠视图,含 v3-P3-2 多选复选框 + 全选 + v4-P1-7 ScoreCell 颜色编码 |
| `components/grade-record-detail.tsx` | 215 | **Phase 4.6 新增2026-07-06**:成绩记录详情/编辑/删除对话框集合,含编辑表单、单条删除确认、批量删除确认三个对话框。导出 EditableFields 类型与 toEditableFields 工具函数 |
| `components/report-card-view.tsx` | 323 | **P3-1 新增2026-06-26**A4 打印格式成绩报告卡服务端组件。含表头(学校名+标题+学期、学生信息网格、科目成绩表格rowSpan 合并科目名/排名列)、汇总统计(均分/班排名/及格率/优秀率)、教师评语+签字区。使用 globals.css `@media print` 规则控制打印布局 |
| `components/report-card-print-button.tsx` | 106 | **P3-1 新增2026-06-26**:跳转至报告卡页面的客户端按钮,支持学年/学期筛选 |
| `components/report-card-print-action.tsx` | 48 | **P3-1 新增2026-06-26**:调用 window.print() 的客户端按钮组件,含 loading 状态和错误 toast |
@@ -1396,6 +1396,11 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
> - **grade-record-list.tsx**:编辑保存 `isSaving` useState → `useOptimistic<GradeRecordListItem[], GradeRecordListItem>` + `useTransition`。`handleEditSave` 构造 `optimisticRecord` 在 `startSaveTransition` 内 `addOptimisticRecord` + `await updateGradeRecordAction` + `router.refresh()`;两处 `records.map` 改为 `optimisticRecords.map`(桌面表格 + 移动端卡片视图)即时显示乐观记录
> - **关键决策**:成功后调用 `router.refresh()` 同步数据源,让 `useOptimistic` 回滚到最新服务端值;不为删除操作使用 useOptimistic因为删除需要服务端确认外键约束、关联记录检查等
> 架构变更2026-07-06Phase 4.6 — grades 模块组件化重构闭环):
> - **batch-grade-entry.tsx**457→376 行):拆出 `BatchGradeEntrySelectors`(试卷/班级选择器含未选态)+ `BatchGradeEntryToolbar`(保存/清除按钮+粘贴提示),容器仅保留状态编排与 Server Action 调用
> - **grade-record-list.tsx**523→232 行):拆出 `GradeRecordRow`(行渲染:桌面表格+移动端卡片)+ `GradeRecordDetail`(编辑/单条删除/批量删除对话框集合)
> - **删除 4 个重复组件**`grade-stats-card.tsx`(迁移至 `StatsGrid`,由 class-grade-report.tsx 直接使用)/ `grade-filters.tsx`(迁移至调用方 `student/grades/student-grade-filters.tsx`,复用 `FilterBar` + `FilterSearchInput`/ `grade-query-filters.tsx`(迁移至调用方 `teacher/grades/teacher-grade-query-filters.tsx`,复用 `FilterBar` + Select/ `analytics-filters.tsx`(迁移至调用方 `teacher/grades/analytics/analytics-grade-filters.tsx`,复用 `FilterBar` + `ChipNav`
---
## 2.7 classes班级模块— 耦合最严重

View File

@@ -5,8 +5,8 @@
"generatedAt": "2026-06-17",
"formatVersion": "1.1",
"rule": "每次文件修改后须同步更新本文件",
"lastUpdate": "缓存策略落地专项重构完成2026-07-05全栈缓存一致性重构,覆盖全部 28 个模块。基础设施层:新增 shared/lib/cache/ 9 个文件types/memory-store/redis-store/store-factory/cache-fn/invalidation-map/client-invalidation-map/invalidate/index+ 共享 redis-client.ts + 客户端 query-keys.ts 工厂。核心机制:(1) cacheFn 双层包装器 — 外层 react.cache 请求级 memoization内层 cacheStore.getOrSet 跨请求/跨实例缓存(按 tag 失效TTL 可选)(2) INVALIDATION_MAP 集中式失效映射表 — 扩展至 203 个 actionId每个声明 { tags, queryKeys, paths } 三段副作用;(3) invalidateFor 三步编排 — CacheStore.invalidateTags → revalidateTag × N → revalidatePath × NNext.js 16 revalidateTag 第二参数必填 \"default\"(4) CLIENT_INVALIDATION_MAP 客户端可见子集(仅 queryKeys 字段,避免拉入 server-only 依赖);(5) CACHE_DRIVER 环境变量切换 memory/redis driver复用 rate-limit 模式。迁移范围data-access 83 个文件 250+ 查询函数迁移至 cacheFn双导出 raw + 包装版本,单测可 mockactions 43 个文件 247 处 revalidatePath 替换为 invalidateForINVALIDATION_MAP 覆盖全部 28 个模块。Hook 双模式useActionQuery 传 queryKey 走 useQuery 跨页共享缓存,不传走旧 useEffect+useStateuseActionMutation 传 mutationFn+actionId 自动 invalidate旧 mutate(action) 仍可用。ESLint 强制规则no-restricted-syntax 禁止 Server Actions 中直接调用 revalidatePath/revalidateTag仅 shared/lib/cache/invalidate.ts 豁免data-access 查询函数双导出 raw 模式便于单测 mock。验证npx tsc --noEmit 零错误npm run lint 零 no-restricted-syntax 错误(仅 3 个 pre-existing react-hooks/refs 错误位于未修改文件npx vitest run src/shared/lib/cache/ 25/25 测试通过。详细文档见 004 第 1.1.6 节。",
"lastUpdated": "2026-07-05"
"lastUpdate": "Phase 4.6 grades 模块组件化重构闭环完成2026-07-06(1) 拆分 batch-grade-entry.tsx 巨型文件 — 将试卷/班级选择器(含未选态引导)拆至 batch-grade-entry-selectors.tsx、底部工具栏清除/保存/粘贴提示)拆至 batch-grade-entry-toolbar.tsx主组件从 457 行降至 374 行,容器仅负责状态编排与 Server Action 调用;(2) 拆分 grade-record-list.tsx 巨型文件 — 行渲染(桌面表格 + 移动端卡片堆叠)拆至 grade-record-row.tsx、编辑/单条删除/批量删除对话框集合拆至 grade-record-detail.tsx导出 EditableFields 类型 + toEditableFields 工具函数),主组件从 523 行降至 231 行(3) 迁移 4 个重复组件到共享底座并删除原文件 — grade-stats-card.tsx → StatsGrid + StatItem 组合class-grade-report.tsx 内联 8 个统计项、grade-filters.tsx → app 层 student-grade-filters.tsx复用 FilterBar + FilterSearchInput + Select + nuqs useQueryState、grade-query-filters.tsx → app 层 teacher-grade-query-filters.tsx复用 FilterBar + Select + useRouter/useSearchParams、analytics-filters.tsx → app 层 analytics-grade-filters.tsx复用 FilterBar + ChipNav保留原 buildHref URL 构造逻辑。同步更新StatItem/ChipNav/FilterBar/FilterSearchInput 的 usedBy 列表、GradeStats/isGradeType/isSemester 的 usedBy 列表、BatchGradeEntry 与 GradeRecordList 与 ClassGradeReport 的 purpose/props/deps、新增 4 个子组件条目BatchGradeEntrySelectors/BatchGradeEntryToolbar/GradeRecordRow/GradeRecordDetail、删除 4 个旧组件条目GradeStatsCard/GradeQueryFilters/AnalyticsFilters 及 grades/components/grade-filters 隐式条目)。验证npx tsc --noEmit 零错误npm run lint 仅 3 个 pre-existing react-hooks/refs 错误位于未修改的 use-exam-preview-tasks.tsgrep 确认旧组件名GradeStatsCard/GradeFilters/GradeQueryFilters/AnalyticsFilters无业务代码残留。前次更新缓存策略落地专项重构完成2026-07-05覆盖全部 28 个模块,新增 shared/lib/cache/ 9 个文件 + INVALIDATION_MAP 203 个 actionIddata-access 83 文件 250+ 查询函数迁移至 cacheFn 双导出模式actions 43 文件 247 处 revalidatePath 替换为 invalidateFor详细见 004 第 1.1.6 节。",
"lastUpdated": "2026-07-06"
},
"architectureOverview": {
"layers": [
@@ -1767,20 +1767,21 @@
"name": "StatItem",
"file": "components/ui/stat-item.tsx",
"props": "{ label, value, icon?, hint?, valueClassName? }",
"purpose": "紧凑统计项label+icon+value+hint用于统计面板网格P1-a 重构从 attendance-stats-card、grade-stats-card 两处重复实现抽取",
"purpose": "紧凑统计项label+icon+value+hint用于统计面板网格P1-a 重构从 attendance-stats-card、grade-stats-card 两处重复实现抽取。Phase 4.6grades 模块 grade-stats-card.tsx 已删除class-grade-report.tsx 改用 StatsGrid + StatItem 组合",
"internalDeps": [
"cn"
],
"usedBy": [
"attendance/components/attendance-stats-card.tsx",
"grades/components/grade-stats-card.tsx"
"grades/components/class-grade-report.tsx",
"shared/components/ui/stats-grid.tsx"
]
},
{
"name": "ChipNav",
"file": "components/ui/chip-nav.tsx",
"props": "{ options: ChipNavOption[], currentId, buildHref: (id) => string, size?: 'sm'|'xs', allOption?, className? }",
"purpose": "芯片导航组(通过 URL search params 切换筛选维度Link 跳转P1-b 重构从 stats-class-selector、attendance-stats-class-selector、analytics-filters 三处重复实现抽取",
"purpose": "芯片导航组(通过 URL search params 切换筛选维度Link 跳转P1-b 重构从 stats-class-selector、attendance-stats-class-selector、analytics-filters 三处重复实现抽取。Phase 4.6grades 模块 analytics-filters.tsx 已删除analytics-grade-filters.tsxapp 层)改用 FilterBar + ChipNav 组合",
"internalDeps": [
"Link",
"cn"
@@ -1788,7 +1789,7 @@
"usedBy": [
"grades/components/stats-class-selector.tsx",
"attendance/components/attendance-stats-class-selector.tsx",
"grades/components/analytics-filters.tsx"
"app/(dashboard)/teacher/grades/analytics/analytics-grade-filters.tsx"
]
},
{
@@ -1810,7 +1811,7 @@
"name": "FilterBar",
"file": "components/ui/filter-bar.tsx",
"props": "{ children, hasFilters?, onReset?, layout?: 'default'|'wrap'|'between', gapClassName?, className?, resetClassName? }",
"purpose": "筛选栏容器(统一布局壳 + Reset 按钮P3-b 重构从 exam/textbook/question/audit-log/login-log filters 五处重复布局抽取。URL 状态管理方式nuqs/router/callback由各模块自行处理",
"purpose": "筛选栏容器(统一布局壳 + Reset 按钮P3-b 重构从 exam/textbook/question/audit-log/login-log filters 五处重复布局抽取。URL 状态管理方式nuqs/router/callback由各模块自行处理。Phase 4.6grades 模块 3 个 filter 组件迁移至 app 层并复用 FilterBarstudent-grade-filters / teacher-grade-query-filters / analytics-grade-filters",
"internalDeps": [
"Button",
"cn"
@@ -1820,14 +1821,17 @@
"textbooks/components/textbook-filters.tsx",
"questions/components/question-filters.tsx",
"audit/components/audit-log-filters.tsx",
"audit/components/login-log-filters.tsx"
"audit/components/login-log-filters.tsx",
"app/(dashboard)/student/grades/student-grade-filters.tsx",
"app/(dashboard)/teacher/grades/teacher-grade-query-filters.tsx",
"app/(dashboard)/teacher/grades/analytics/analytics-grade-filters.tsx"
]
},
{
"name": "FilterSearchInput",
"file": "components/ui/filter-bar.tsx",
"props": "{ value, onChange, placeholder?, className?, inputClassName? }",
"purpose": "筛选栏搜索框(带 Search 图标的 InputP3-b 重构从 exam/textbook/question filters 三处重复搜索框抽取",
"purpose": "筛选栏搜索框(带 Search 图标的 InputP3-b 重构从 exam/textbook/question filters 三处重复搜索框抽取。Phase 4.6grades 模块 student-grade-filters 复用",
"internalDeps": [
"Input",
"Search (lucide-react)",
@@ -1836,7 +1840,8 @@
"usedBy": [
"exams/components/exam-filters.tsx",
"textbooks/components/textbook-filters.tsx",
"questions/components/question-filters.tsx"
"questions/components/question-filters.tsx",
"app/(dashboard)/student/grades/student-grade-filters.tsx"
]
},
{
@@ -14095,7 +14100,7 @@
"definition": "{ count, average, median, stdDev, passRate, excellentRate, maxScore, minScore }",
"usedBy": [
"data-access",
"components/grade-stats-card"
"components/class-grade-report"
]
},
{
@@ -14440,20 +14445,20 @@
"name": "isGradeType",
"signature": "(v: unknown) => v is GradeRecordType",
"file": "lib/type-guards.ts",
"purpose": "类型守卫:验证 unknown 值是否为合法的 GradeRecordType。P2-9 修复:使用 GRADE_TYPES.some((t) => t === v) 替代 (GRADE_TYPES as readonly string[]).includes(v),消除 as 断言",
"purpose": "类型守卫:验证 unknown 值是否为合法的 GradeRecordType。P2-9 修复:使用 GRADE_TYPES.some((t) => t === v) 替代 (GRADE_TYPES as readonly string[]).includes(v),消除 as 断言。Phase 4.6:原调用方 grade-filters/grade-query-filters 已删除,改由 app 层 student-grade-filters / teacher-grade-query-filters 复用",
"usedBy": [
"grades/components/grade-filters",
"grades/components/grade-query-filters"
"app/(dashboard)/student/grades/student-grade-filters",
"app/(dashboard)/teacher/grades/teacher-grade-query-filters"
]
},
{
"name": "isSemester",
"signature": "(v: unknown) => v is GradeRecordSemester",
"file": "lib/type-guards.ts",
"purpose": "类型守卫:验证 unknown 值是否为合法的 GradeRecordSemester。P2-9 修复:使用 SEMESTERS.some((s) => s === v) 替代 (SEMESTERS as readonly string[]).includes(v),消除 as 断言",
"purpose": "类型守卫:验证 unknown 值是否为合法的 GradeRecordSemester。P2-9 修复:使用 SEMESTERS.some((s) => s === v) 替代 (SEMESTERS as readonly string[]).includes(v),消除 as 断言。Phase 4.6:原调用方 grade-filters/grade-query-filters 已删除,改由 app 层 student-grade-filters / teacher-grade-query-filters 复用",
"usedBy": [
"grades/components/grade-filters",
"grades/components/grade-query-filters"
"app/(dashboard)/student/grades/student-grade-filters",
"app/(dashboard)/teacher/grades/teacher-grade-query-filters"
]
},
{
@@ -14550,7 +14555,7 @@
{
"name": "BatchGradeEntry",
"file": "components/batch-grade-entry.tsx",
"purpose": "2026-06-24 重新设计:导出名改为 BatchGradeEntryByExam按试卷录入每题得分。Excel 式表格(行=学生,列=题目,末列=总分自动计算)。交互:试卷选择器(按 scope 过滤)→ 班级选择器(按试卷 gradeId 过滤);多行多列 Excel 粘贴Tab 分隔Enter 跳下一行同一列Tab 跳下一格;实时统计(已录入/总数/均分/最高/最低分数校验超过题目满分标红撤销机制sessionStorage 5 分钟有效。P1-6/P1-7 重构:拆分为 3 个子组件 + 1 个自定义 HookBatchGradeEntryTable + BatchGradeEntryStats + BatchGradeEntryDialog + useBatchGradeEntryUndo主组件从 604 行降至 422 行。撤销数据使用 isUndoData 类型守卫替代 as 断言。Props: exams/classes/classGradeMap/exam/students/defaultExamId?/defaultClassId?",
"purpose": "2026-06-24 重新设计:导出名改为 BatchGradeEntryByExam按试卷录入每题得分。Excel 式表格(行=学生,列=题目,末列=总分自动计算)。交互:试卷选择器(按 scope 过滤)→ 班级选择器(按试卷 gradeId 过滤);多行多列 Excel 粘贴Tab 分隔Enter 跳下一行同一列Tab 跳下一格;实时统计(已录入/总数/均分/最高/最低分数校验超过题目满分标红撤销机制sessionStorage 5 分钟有效。P1-6/P1-7 重构:拆分为 3 个子组件 + 1 个自定义 HookBatchGradeEntryTable + BatchGradeEntryStats + BatchGradeEntryDialog + useBatchGradeEntryUndo主组件从 604 行降至 422 行。撤销数据使用 isUndoData 类型守卫替代 as 断言。Phase 4.6 重构:进一步拆分试卷/班级选择器(含未选态引导)至 BatchGradeEntrySelectors、底部工具栏清除/保存/粘贴提示)至 BatchGradeEntryToolbar主组件降至 374 行,容器仅负责状态编排与 Server Action 调用。Props: exams/classes/classGradeMap/exam/students/defaultExamId?/defaultClassId?",
"props": "{ exams: ExamOptionForEntry[]; classes: ClassOption[]; classGradeMap: Record<string, string>; exam: ExamForGradeEntry | null; students: Student[]; defaultExamId?: string; defaultClassId?: string }",
"deps": [
"exams/types.ExamOptionForEntry",
@@ -14560,6 +14565,8 @@
"grades/components/batch-grade-entry-table.BatchGradeEntryTable",
"grades/components/batch-grade-entry-stats.BatchGradeEntryStats",
"grades/components/batch-grade-entry-dialog.BatchGradeEntryDialog",
"grades/components/batch-grade-entry-selectors.BatchGradeEntrySelectors",
"grades/components/batch-grade-entry-toolbar.BatchGradeEntryToolbar",
"grades/hooks/use-batch-grade-entry-undo.useBatchGradeEntryUndo",
"shared/lib/action-utils.safeActionCall",
"shared/components/ui/card",
@@ -14606,20 +14613,82 @@
"grades/components/batch-grade-entry"
]
},
{
"name": "BatchGradeEntrySelectors",
"file": "components/batch-grade-entry-selectors.tsx",
"purpose": "Phase 4.6 新增:批量录入试卷/班级选择器子组件。未选试卷时显示单选试卷 Card + Info 引导;已选试卷时显示双列选择器(试卷 Select + 班级 Select+ 试卷信息 Badge题目数/总分/满分)",
"props": "{ exams, classes, exam, defaultExamId?, defaultClassId?, onExamChange, onClassChange }",
"deps": [
"shared/components/ui/card",
"shared/components/ui/select",
"shared/components/ui/badge"
],
"usedBy": [
"grades/components/batch-grade-entry"
]
},
{
"name": "BatchGradeEntryToolbar",
"file": "components/batch-grade-entry-toolbar.tsx",
"purpose": "Phase 4.6 新增:批量录入底部工具栏子组件(清除按钮 + 保存按钮 + Excel 粘贴提示)。保存按钮在 hasInvalidScores 时禁用",
"props": "{ isSubmitting, hasInvalidScores, onClear, onSubmit }",
"deps": [
"shared/components/ui/button"
],
"usedBy": [
"grades/components/batch-grade-entry"
]
},
{
"name": "GradeRecordList",
"file": "components/grade-record-list.tsx",
"purpose": "成绩列表(含查询筛选、删除对话框)"
"purpose": "成绩记录列表容器。Phase 4.6 重构:行渲染(桌面表格 + 移动端卡片堆叠)拆分至 GradeRecordRow编辑/单条删除/批量删除对话框拆分至 GradeRecordDetail主组件从 523 行降至 231 行容器仅负责状态编排useOptimistic + useTransition与 Server Action 调用。Phase 4.5:编辑保存改用 useOptimistic + useTransition在列表中即时显示乐观记录。v3-P3-2批量选择与删除",
"props": "{ records: GradeRecordListItem[] }",
"deps": [
"grades/actions.deleteGradeRecordAction",
"grades/actions.updateGradeRecordAction",
"grades/actions.bulkDeleteGradeRecordsAction",
"grades/components/grade-record-row.GradeRecordRow",
"grades/components/grade-record-detail.GradeRecordDetail",
"grades/components/grade-record-detail.toEditableFields",
"shared/lib/action-utils.safeActionCall",
"shared/components/ui/button"
],
"usedBy": [
"teacher/grades/page.tsx"
]
},
{
"name": "GradeStatsCard",
"file": "components/grade-stats-card.tsx",
"purpose": "统计卡片(均分、中位数、标准差、及格率、优秀率、最高分、最低分)"
"name": "GradeRecordRow",
"file": "components/grade-record-row.tsx",
"purpose": "Phase 4.6 新增:成绩记录行渲染子组件。桌面端 Table 行(含复选框 + 学生/科目/考试/分数/满分/类型/学期/备注/操作)+ 移动端卡片堆叠视图role=list/listitem。复选框支持单行切换与全选切换。操作按钮触发编辑与单条删除回调",
"props": "{ records, selectedIds, onToggleRow, onToggleSelectAll, onEdit, onDelete }",
"deps": [
"shared/components/ui/table",
"shared/components/ui/checkbox",
"shared/components/ui/button",
"shared/components/ui/status-badge"
],
"usedBy": [
"grades/components/grade-record-list"
]
},
{
"name": "GradeQueryFilters",
"file": "components/grade-query-filters.tsx",
"purpose": "查询筛选器(班级、科目、考试类型、学期)"
"name": "GradeRecordDetail",
"file": "components/grade-record-detail.tsx",
"purpose": "Phase 4.6 新增成绩记录详情对话框集合子组件包含编辑对话框title/score/fullScore/type/semester/remark 表单)、单条删除确认对话框、批量删除确认对话框。导出 EditableFields 类型 + toEditableFields(r: GradeRecordListItem): EditableFields 工具函数,用于将列表项转换为表单可编辑字段",
"props": "{ editTarget, deleteId, isBulkDeleteDialogOpen, isSaving, isDeleting, isBulkDeleting, bulkDeleteCount, onEditTargetChange, onCancelEdit, onSaveEdit, onCancelDelete, onConfirmDelete, onCloseBulkDelete, onConfirmBulkDelete }",
"deps": [
"shared/components/ui/dialog",
"shared/components/ui/button",
"shared/components/ui/label",
"shared/components/ui/input",
"shared/components/ui/select",
"shared/components/ui/alert-dialog"
],
"usedBy": [
"grades/components/grade-record-list"
]
},
{
"name": "StudentGradeSummary",
@@ -14629,7 +14698,7 @@
{
"name": "ClassGradeReport",
"file": "components/class-grade-report.tsx",
"purpose": "班级成绩报表(含统计+排名)"
"purpose": "班级成绩报表(含统计+排名)。Phase 4.6 重构:删除 GradeStatsCard 依赖,改用 StatsGrid + StatItem 组合内联渲染 8 个统计项average/median/max/min/stdDev/passRate/excellentRate/countcount=0 时显示空态 Card"
},
{
"name": "ExportButton",
@@ -14678,20 +14747,6 @@
"shared/components/ui/chart"
]
},
{
"name": "AnalyticsFilters",
"file": "components/analytics-filters.tsx",
"purpose": "成绩分析页筛选器(班级、科目、年级 Link 筛选按钮组,含 focus-visible 焦点样式v3-P2 新增exams/currentExamId/currentSemester props添加学期和考试筛选 ChipNav",
"props": "{ classId?, gradeId?, subjectId?, currentSemester?, currentExamId?, exams: { id, title }[] }",
"deps": [
"next/link",
"shared/lib/utils.cn",
"shared/components/ui/chip-nav"
],
"usedBy": [
"teacher/grades/analytics/page.tsx"
]
},
{
"name": "StatsClassSelector",
"file": "components/stats-class-selector.tsx",

BIN
lint-phase2b.log Normal file

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 706 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 MiB

BIN
screenshots/01-login.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

View File

@@ -1,5 +1,6 @@
"use client"
import type { JSX } from "react"
import { useRouter, useSearchParams } from "next/navigation"
import { useCallback } from "react"
import { useTranslations } from "next-intl"
@@ -12,19 +13,26 @@ import {
SelectValue,
} from "@/shared/components/ui/select"
import { Input } from "@/shared/components/ui/input"
import { FilterBar } from "@/shared/components/ui/filter-bar"
import {
ATTENDANCE_STATUS_OPTIONS,
ATTENDANCE_STATUS_LABEL_KEYS,
} from "@/shared/constants/attendance-status"
import { ATTENDANCE_STATUS_OPTIONS, ATTENDANCE_STATUS_LABEL_KEYS } from "../constants"
type Option = { id: string; name: string }
interface AttendanceFiltersProps {
classes: Option[]
interface AdminAttendanceFiltersProps {
classes: Array<{ id: string; name: string }>
}
export function AttendanceFilters({ classes }: AttendanceFiltersProps) {
/**
* FilterBar + Select
*
* modules/attendance/components/attendance-filters.tsx
* shared wrapper
*/
export function AdminAttendanceFilters({ classes }: AdminAttendanceFiltersProps): JSX.Element {
const t = useTranslations("attendance")
const router = useRouter()
const searchParams = useSearchParams()
const t = useTranslations("attendance")
const updateParam = useCallback(
(key: string, value: string) => {
@@ -42,13 +50,22 @@ export function AttendanceFilters({ classes }: AttendanceFiltersProps) {
const classId = searchParams.get("classId") ?? "all"
const status = searchParams.get("status") ?? "all"
const date = searchParams.get("date") ?? ""
const hasFilters = classId !== "all" || status !== "all" || date !== ""
return (
<div className="grid grid-cols-1 gap-4 rounded-lg border bg-card p-4 md:grid-cols-3">
<FilterBar
layout="wrap"
hasFilters={hasFilters}
onReset={() => {
router.push("/admin/attendance")
}}
>
<div className="grid gap-2">
<Label className="text-xs">{t("filters.class")}</Label>
<Label htmlFor="filter-class" className="text-xs">
{t("filters.class")}
</Label>
<Select value={classId} onValueChange={(v) => updateParam("classId", v)}>
<SelectTrigger className="h-9" aria-label={t("filters.class")}>
<SelectTrigger id="filter-class" className="h-9" aria-label={t("filters.class")}>
<SelectValue placeholder={t("filters.allClasses")} />
</SelectTrigger>
<SelectContent>
@@ -63,9 +80,11 @@ export function AttendanceFilters({ classes }: AttendanceFiltersProps) {
</div>
<div className="grid gap-2">
<Label className="text-xs">{t("filters.status")}</Label>
<Label htmlFor="filter-status" className="text-xs">
{t("filters.status")}
</Label>
<Select value={status} onValueChange={(v) => updateParam("status", v)}>
<SelectTrigger className="h-9" aria-label={t("filters.status")}>
<SelectTrigger id="filter-status" className="h-9" aria-label={t("filters.status")}>
<SelectValue placeholder={t("filters.allStatuses")} />
</SelectTrigger>
<SelectContent>
@@ -80,8 +99,11 @@ export function AttendanceFilters({ classes }: AttendanceFiltersProps) {
</div>
<div className="grid gap-2">
<Label className="text-xs">{t("filters.date")}</Label>
<Label htmlFor="filter-date" className="text-xs">
{t("filters.date")}
</Label>
<Input
id="filter-date"
type="date"
value={date}
onChange={(e) => updateParam("date", e.target.value)}
@@ -89,6 +111,6 @@ export function AttendanceFilters({ classes }: AttendanceFiltersProps) {
aria-label={t("filters.date")}
/>
</div>
</div>
</FilterBar>
)
}

View File

@@ -1,10 +1,11 @@
import Link from "next/link"
import type { JSX } from "react"
import { BarChart3, ClipboardList } from "lucide-react"
import { BarChart3, CheckCircle2, ClipboardList, Clock, FileText, LogOut, School, Users, XCircle } from "lucide-react"
import { getTranslations } from "next-intl/server"
import { Button } from "@/shared/components/ui/button"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { StatsGrid } from "@/shared/components/ui/stats-grid"
import { requirePermission, getAuthContext } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"
import { getSearchParam, type SearchParams } from "@/shared/lib/utils"
@@ -13,8 +14,7 @@ import { getGrades } from "@/modules/school/data-access"
import { getAttendanceRecords, getAttendanceStats } from "@/modules/attendance/data-access"
import { getClassComparison } from "@/modules/attendance/data-access-stats"
import { getAttendanceGradeCorrelation } from "@/modules/attendance/data-access-correlation"
import { AttendanceFilters } from "@/modules/attendance/components/attendance-filters"
import { AttendanceStatsCards } from "@/modules/attendance/components/attendance-stats-cards"
import { AdminAttendanceFilters } from "./admin-attendance-filters"
import { AttendanceRecordList } from "@/modules/attendance/components/attendance-record-list"
import { AttendancePageLayout } from "@/modules/attendance/components/attendance-page-layout"
import { ClassComparisonCard } from "@/modules/attendance/components/class-comparison-card"
@@ -82,6 +82,21 @@ export default async function AdminAttendancePage({
const gradeOptions = grades.map((g) => ({ id: g.id, name: g.name }))
const statsItems = [
{ label: t("stats.totalRecords"), value: stats.total, icon: FileText, color: "text-blue-500" },
{ label: t("stats.present"), value: stats.present, icon: CheckCircle2, color: "text-green-500" },
{ label: t("stats.absent"), value: stats.absent, icon: XCircle, color: "text-red-500" },
{ label: t("stats.late"), value: stats.late, icon: Clock, color: "text-yellow-500" },
{ label: t("stats.earlyLeave"), value: stats.earlyLeave, icon: LogOut, color: "text-orange-500" },
{ label: t("stats.schoolActivity"), value: stats.schoolActivity, icon: School, color: "text-cyan-500" },
{
label: t("stats.attendanceRate"),
value: `${stats.presentRate.toFixed(1)}%`,
icon: Users,
color: "text-primary",
},
]
const header = (
<div className="flex items-center justify-between space-y-2">
<div>
@@ -100,8 +115,8 @@ export default async function AdminAttendancePage({
return (
<AttendancePageLayout
header={header}
stats={<AttendanceStatsCards stats={stats} />}
filters={<AttendanceFilters classes={classOptions} />}
stats={<StatsGrid items={statsItems} columns={4} />}
filters={<AdminAttendanceFilters classes={classOptions} />}
>
<div className="space-y-6">
{result.items.length === 0 && !classId && !status && !date ? (

View File

@@ -1,12 +1,46 @@
import type { JSX } from "react"
import { BookOpen, Users, Gauge, Shuffle } from "lucide-react"
import { getTranslations } from "next-intl/server"
import { getElectiveOverviewStats } from "@/modules/elective/data-access-stats"
import { ElectiveStatsCards } from "@/modules/elective/components/elective-stats-cards"
import { StatsGrid } from "@/shared/components/ui/stats-grid"
/**
* 管理员概览统计卡片 - 异步 RSC loader
* 独立 Suspense 边界,可流式渲染
*/
export async function StatsCardsLoader(): Promise<JSX.Element> {
const stats = await getElectiveOverviewStats()
return <ElectiveStatsCards stats={stats} />
const [stats, t] = await Promise.all([
getElectiveOverviewStats(),
getTranslations("elective"),
])
const items = [
{
label: t("stats.totalCourses"),
value: stats.totalCourses,
icon: BookOpen,
color: "text-blue-500",
},
{
label: t("stats.totalEnrolled"),
value: stats.totalEnrolled,
icon: Users,
color: "text-green-500",
},
{
label: t("stats.avgUtilization"),
value: t("stats.utilizationRate", { rate: stats.avgUtilization }),
icon: Gauge,
color: "text-purple-500",
},
{
label: t("stats.pendingLottery"),
value: stats.pendingLottery,
icon: Shuffle,
color: "text-orange-500",
},
]
return <StatsGrid items={items} />
}

View File

@@ -1,13 +1,14 @@
import type { JSX } from "react"
import type { Metadata } from "next"
import { Suspense } from "react"
import { BarChart3 } from "lucide-react"
import { BarChart3, BookOpen, Brain, CheckCircle2, Clock, TrendingUp } from "lucide-react"
import { getTranslations } from "next-intl/server"
import { requirePermission } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { Skeleton } from "@/shared/components/ui/skeleton"
import { StatsGrid } from "@/shared/components/ui/stats-grid"
import { WidgetBoundary } from "@/shared/components/widget-boundary"
import { getParam, type SearchParams } from "@/shared/lib/search-params"
@@ -23,7 +24,6 @@ import {
} from "@/modules/error-book/data-access-analytics"
import { TopWrongQuestions } from "@/modules/error-book/components/top-wrong-questions"
import { SubjectTabs } from "@/modules/error-book/components/subject-tabs"
import { AnalyticsStatsCards } from "@/modules/error-book/components/analytics-stats-cards"
import { SubjectDistributionChart } from "@/modules/error-book/components/subject-distribution-chart"
import { KnowledgePointWeaknessChart } from "@/modules/error-book/components/knowledge-point-weakness-chart"
import { ChapterWeaknessChart } from "@/modules/error-book/components/chapter-weakness-chart"
@@ -120,6 +120,15 @@ async function AdminErrorBookContent({
.sort((a, b) => b.totalCount - a.totalCount)
.slice(0, 50)
const analyticsAvg = (limitedStudentIds.length > 0 ? totalErrorItems / limitedStudentIds.length : 0).toFixed(1)
const analyticsItems = [
{ label: t("analyticsStats.coverage"), value: studentsWithErrorBook.length, icon: BookOpen, color: "text-blue-600 dark:text-blue-400", valueClassName: "text-blue-600 dark:text-blue-400", description: t("analyticsStats.coverageSub", { total: limitedStudentIds.length }) },
{ label: t("analyticsStats.totalErrors"), value: totalErrorItems, icon: TrendingUp, color: "text-rose-600 dark:text-rose-400", valueClassName: "text-rose-600 dark:text-rose-400", description: t("analyticsStats.totalErrorsSub", { avg: analyticsAvg }) },
{ label: t("analyticsStats.avgMastery"), value: `${Math.round(averageMasteryRate * 100)}%`, icon: CheckCircle2, color: "text-emerald-600 dark:text-emerald-400", valueClassName: "text-emerald-600 dark:text-emerald-400", description: averageMasteryRate >= 0.6 ? t("analyticsStats.avgMasteryGood") : t("analyticsStats.avgMasteryNeedImprove") },
{ label: t("analyticsStats.dueReview"), value: totalDueReview, icon: Clock, color: "text-amber-600 dark:text-amber-400", valueClassName: "text-amber-600 dark:text-amber-400", description: totalDueReview > 0 ? t("analyticsStats.dueReviewNeedAttention") : t("analyticsStats.dueReviewNone") },
{ label: t("analyticsStats.knowledgePoints"), value: knowledgePointCount, icon: Brain, color: "text-purple-600 dark:text-purple-400", valueClassName: "text-purple-600 dark:text-purple-400", description: knowledgePointCount > 5 ? t("analyticsStats.knowledgePointsWide") : t("analyticsStats.knowledgePointsFocused") },
]
return (
<div className="flex h-full flex-col space-y-6 p-8">
<div>
@@ -139,14 +148,7 @@ async function AdminErrorBookContent({
{/* 统计卡片 */}
<WidgetBoundary title={t("admin.title")}>
<AnalyticsStatsCards
totalStudents={limitedStudentIds.length}
studentsWithErrorBook={studentsWithErrorBook.length}
totalErrorItems={totalErrorItems}
averageMasteryRate={averageMasteryRate}
dueReviewCount={totalDueReview}
knowledgePointCount={knowledgePointCount}
/>
<StatsGrid items={analyticsItems} columns={5} className="gap-3" />
</WidgetBoundary>
{/* 学科错题分布图(仅在"全部学科"视图下显示) */}

View File

@@ -1,5 +1,5 @@
import type { JSX } from "react"
import { Users } from "lucide-react"
import { BookX, Clock, GraduationCap, Repeat, Sparkles, Users } from "lucide-react"
import { requirePermission } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"
@@ -7,6 +7,7 @@ import { EmptyState } from "@/shared/components/ui/empty-state"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Badge } from "@/shared/components/ui/badge"
import { Progress } from "@/shared/components/ui/progress"
import { StatsGrid } from "@/shared/components/ui/stats-grid"
import { WidgetBoundary } from "@/shared/components/widget-boundary"
import { formatNumber } from "@/shared/lib/utils"
@@ -16,7 +17,6 @@ import {
getTopWrongQuestionsByStudentIds,
getKnowledgePointWeakness,
} from "@/modules/error-book/data-access-analytics"
import { ErrorBookStatsCards } from "@/modules/error-book/components/error-book-stats-cards"
import { TopWrongQuestions } from "@/modules/error-book/components/top-wrong-questions"
import { getTranslations } from "next-intl/server"
@@ -56,6 +56,11 @@ export default async function ParentErrorBookPage(): Promise<JSX.Element> {
getKnowledgePointWeakness(childrenIds, 5),
])
const singleStats = childStatsList[0]
const singleMasteredPercent = singleStats.totalCount > 0
? Math.round(singleStats.masteredRate * 100)
: 0
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div>
@@ -66,7 +71,16 @@ export default async function ParentErrorBookPage(): Promise<JSX.Element> {
{childrenIds.length === 1 ? (
// 单子女:直接展示统计卡片
<WidgetBoundary title={t("parent.title")} skeletonHeight={300}>
<ErrorBookStatsCards stats={childStatsList[0]} />
<StatsGrid
columns={5}
items={[
{ label: t("stats.total"), value: singleStats.totalCount, icon: BookX, description: t("stats.totalDesc") },
{ label: t("stats.new"), value: singleStats.newCount, icon: Sparkles, color: "text-blue-500", description: t("stats.newDesc") },
{ label: t("stats.learning"), value: singleStats.learningCount, icon: Repeat, color: "text-amber-500", description: t("stats.learningDesc") },
{ label: t("stats.mastered"), value: singleStats.masteredCount, icon: GraduationCap, color: "text-emerald-500", description: t("stats.masteredDesc", { rate: singleMasteredPercent }) },
{ label: t("stats.dueReview"), value: singleStats.dueReviewCount, icon: Clock, color: "text-rose-500", description: t("stats.dueReviewDesc"), highlight: singleStats.dueReviewCount > 0 },
]}
/>
</WidgetBoundary>
) : (
// 多子女:每个子女一张卡片

View File

@@ -1,7 +1,7 @@
import type { JSX } from "react"
import { getAvailableCoursesForStudent, getStudentSelections } from "@/modules/elective/data-access-selections"
import { StudentAvailableCoursesSection } from "@/modules/elective/components/student-selection-view"
import { ElectiveFilters } from "@/modules/elective/components/elective-filters"
import { ElectiveFilters } from "./elective-filters"
import { getParam, type SearchParams } from "@/shared/lib/search-params"
/**

View File

@@ -36,7 +36,7 @@ export function ElectiveFilters() {
<div className="flex flex-wrap gap-2 w-full md:w-auto">
<Select value={mode} onValueChange={(val) => setMode(val === "all" ? null : val)}>
<SelectTrigger className="w-[160px] bg-background border-muted-foreground/20" aria-label={t("fields.selectionMode")}>
<SelectTrigger className="w-40 bg-background border-muted-foreground/20" aria-label={t("fields.selectionMode")}>
<SelectValue placeholder={t("fields.selectionMode")} />
</SelectTrigger>
<SelectContent>

View File

@@ -1,16 +1,16 @@
import type { JSX } from "react"
import { Suspense } from "react"
import { BookX, Clock, GraduationCap, Repeat, Sparkles } from "lucide-react"
import { getTranslations } from "next-intl/server"
import { requirePermission } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"
import { getParam, type SearchParams } from "@/shared/lib/search-params"
import { Skeleton } from "@/shared/components/ui/skeleton"
import { StatsGrid } from "@/shared/components/ui/stats-grid"
import { WidgetBoundary } from "@/shared/components/widget-boundary"
import { getErrorBookItems, getErrorBookStats } from "@/modules/error-book/data-access"
import { ErrorBookStatsCards } from "@/modules/error-book/components/error-book-stats-cards"
import { ErrorBookFilters } from "@/modules/error-book/components/error-book-filters"
import type { ErrorBookStatusValue, ErrorBookSourceTypeValue } from "@/modules/error-book/types"
import {
AiClientProvider,
@@ -18,6 +18,7 @@ import {
import { createCoreAiClientService } from "@/modules/ai/context/create-ai-client-service"
import { StudentErrorBookListClient } from "./student-error-book-list-client"
import { AddErrorBookDialogWithQuestions } from "./add-error-book-dialog-with-questions"
import { ErrorBookFilters } from "./error-book-filters"
export const dynamic = "force-dynamic"
@@ -81,6 +82,10 @@ export default async function StudentErrorBookPage({
const ctx = await requirePermission(Permissions.ERROR_BOOK_READ)
const t = await getTranslations("student")
const stats = await getErrorBookStats(ctx.userId)
const tStats = await getTranslations("errorBook")
const masteredPercent = stats.totalCount > 0
? Math.round(stats.masteredRate * 100)
: 0
const aiClientService = createCoreAiClientService()
return (
@@ -99,7 +104,16 @@ export default async function StudentErrorBookPage({
</div>
<WidgetBoundary title={t("errorBook.title")} skeletonHeight={120}>
<ErrorBookStatsCards stats={stats} />
<StatsGrid
columns={5}
items={[
{ label: tStats("stats.total"), value: stats.totalCount, icon: BookX, description: tStats("stats.totalDesc") },
{ label: tStats("stats.new"), value: stats.newCount, icon: Sparkles, color: "text-blue-500", description: tStats("stats.newDesc") },
{ label: tStats("stats.learning"), value: stats.learningCount, icon: Repeat, color: "text-amber-500", description: tStats("stats.learningDesc") },
{ label: tStats("stats.mastered"), value: stats.masteredCount, icon: GraduationCap, color: "text-emerald-500", description: tStats("stats.masteredDesc", { rate: masteredPercent }) },
{ label: tStats("stats.dueReview"), value: stats.dueReviewCount, icon: Clock, color: "text-rose-500", description: tStats("stats.dueReviewDesc"), highlight: stats.dueReviewCount > 0 },
]}
/>
</WidgetBoundary>
<div className="space-y-4">

View File

@@ -6,7 +6,7 @@ import { getRankingTrend, getClassAverageTrend } from "@/modules/grades/data-acc
import { getStudentPositionInClassDistribution, getStudentGrowthArchive } from "@/modules/grades/data-access-analytics"
import { getSubjectOptions } from "@/modules/school/data-access"
import { StudentGradeSummary } from "@/modules/grades/components/student-grade-summary"
import { GradeFilters } from "@/modules/grades/components/grade-filters"
import { StudentGradeFilters } from "./student-grade-filters"
import { GradeTrendCard } from "@/modules/grades/components/grade-trend-card"
import { RankingTrendCard } from "@/modules/grades/components/ranking-trend-card"
import { GradeDistributionChart } from "@/modules/grades/components/grade-distribution-chart"
@@ -82,7 +82,7 @@ export default async function StudentGradesPage({
<h2 className="text-2xl font-bold tracking-tight">{summary.studentName}</h2>
<p className="text-muted-foreground">{t("summary.recordCount", { count: summary.records.length })}</p>
</div>
<GradeFilters subjects={subjectOptions.map((s) => ({ id: s.id, name: s.name }))} />
<StudentGradeFilters subjects={subjectOptions.map((s) => ({ id: s.id, name: s.name }))} />
{filteredSummary.records.length > 0 && (
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
<GradeTrendCard summary={filteredSummary} classAverageData={classAverageTrend} />

View File

@@ -13,15 +13,17 @@ import {
} from "@/shared/components/ui/select"
import { FilterBar, FilterSearchInput } from "@/shared/components/ui/filter-bar"
import type { SelectOption } from "../types"
interface StudentGradeFiltersProps {
subjects?: Array<{ id: string; name: string }>
}
/**
*
* FilterBar / FilterSearchInput / Select
*
* v3-P2-1subjects prop 使 subjectId value
* subjects
* Phase 4.6 `modules/grades/components/grade-filters.tsx`
* wrapper shared
*/
export function GradeFilters({ subjects }: { subjects?: SelectOption[] }): JSX.Element {
export function StudentGradeFilters({ subjects }: StudentGradeFiltersProps): JSX.Element {
const t = useTranslations("grades")
const [search, setSearch] = useQueryState("q", parseAsString.withDefault(""))
const [subject, setSubject] = useQueryState("subject", parseAsString.withDefault("all"))

View File

@@ -10,7 +10,7 @@ import { Permissions } from "@/shared/types/permissions"
import { getParam, type SearchParams } from "@/shared/lib/search-params"
import { getTeacherClasses } from "@/modules/classes/data-access"
import { getAttendanceRecords } from "@/modules/attendance/data-access"
import { AttendanceFilters } from "@/modules/attendance/components/attendance-filters"
import { TeacherAttendanceFilters } from "./teacher-attendance-filters"
import { AttendanceRecordList } from "@/modules/attendance/components/attendance-record-list"
import { AttendancePageLayout } from "@/modules/attendance/components/attendance-page-layout"
import type { AttendanceStatus } from "@/modules/attendance/types"
@@ -90,7 +90,7 @@ export default async function TeacherAttendancePage({
return (
<AttendancePageLayout
header={header}
filters={<AttendanceFilters classes={classOptions} />}
filters={<TeacherAttendanceFilters classes={classOptions} />}
>
{result.items.length === 0 && !hasFilters ? (
<EmptyState

View File

@@ -5,7 +5,7 @@ import { requirePermission } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"
import { getTeacherClasses } from "@/modules/classes/data-access"
import { getClassAttendanceStats, getClassAttendanceWarnings, getAttendanceTrend } from "@/modules/attendance/data-access-stats"
import { AttendanceStatsCard } from "@/modules/attendance/components/attendance-stats-card"
import { StatsGrid } from "@/shared/components/ui/stats-grid"
import { AttendanceRecordList } from "@/modules/attendance/components/attendance-record-list"
import { AttendanceStatsClassSelector } from "@/modules/attendance/components/attendance-stats-class-selector"
import { AttendanceWarningsCard } from "@/modules/attendance/components/attendance-warnings-card"
@@ -14,7 +14,17 @@ import type { TrendGranularity } from "@/modules/attendance/trend-compute"
import { Button } from "@/shared/components/ui/button"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { getParam, type SearchParams } from "@/shared/lib/search-params"
import { BarChart3, FileText } from "lucide-react"
import {
BarChart3,
CheckCircle2,
Clock,
FileText,
LogOut,
School,
TrendingUp,
Users,
XCircle,
} from "lucide-react"
export const dynamic = "force-dynamic"
@@ -91,7 +101,37 @@ export default async function AttendanceStatsPage({
{summary ? (
<>
<AttendanceStatsCard stats={summary.stats} />
{summary.stats.total === 0 ? (
<EmptyState
title={t("stats.noData")}
description={t("stats.noDataDescription")}
icon={BarChart3}
className="border-none shadow-none"
/>
) : (
<StatsGrid
items={[
{ label: t("stats.totalRecords"), value: summary.stats.total, icon: Users },
{ label: t("stats.present"), value: summary.stats.present, icon: CheckCircle2 },
{ label: t("stats.absent"), value: summary.stats.absent, icon: XCircle },
{ label: t("stats.late"), value: summary.stats.late, icon: Clock },
{ label: t("stats.earlyLeave"), value: summary.stats.earlyLeave, icon: LogOut },
{ label: t("stats.excused"), value: summary.stats.excused, icon: FileText },
{ label: t("stats.schoolActivity"), value: summary.stats.schoolActivity, icon: School },
{
label: t("stats.attendanceRate"),
value: `${summary.stats.presentRate.toFixed(1)}%`,
icon: TrendingUp,
},
{
label: t("stats.lateRate"),
value: `${summary.stats.lateRate.toFixed(1)}%`,
icon: Clock,
},
]}
columns={4}
/>
)}
<AttendanceWarningsCard summary={warnings} />
<AttendanceTrendChart summary={trend} granularity={granularity} />
<div>

View File

@@ -0,0 +1,116 @@
"use client"
import type { JSX } from "react"
import { useRouter, useSearchParams } from "next/navigation"
import { useCallback } from "react"
import { useTranslations } from "next-intl"
import { Label } from "@/shared/components/ui/label"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/components/ui/select"
import { Input } from "@/shared/components/ui/input"
import { FilterBar } from "@/shared/components/ui/filter-bar"
import {
ATTENDANCE_STATUS_OPTIONS,
ATTENDANCE_STATUS_LABEL_KEYS,
} from "@/shared/constants/attendance-status"
interface TeacherAttendanceFiltersProps {
classes: Array<{ id: string; name: string }>
}
/**
* 教师端考勤筛选器(基于共享底座 FilterBar + Select
*
* 组件化重构:从 modules/attendance/components/attendance-filters.tsx 内联至调用方目录,
* 直接复用 shared 底座组件,消除模块级 wrapper。
*/
export function TeacherAttendanceFilters({ classes }: TeacherAttendanceFiltersProps): JSX.Element {
const t = useTranslations("attendance")
const router = useRouter()
const searchParams = useSearchParams()
const updateParam = useCallback(
(key: string, value: string) => {
const params = new URLSearchParams(searchParams.toString())
if (value && value !== "all") {
params.set(key, value)
} else {
params.delete(key)
}
router.push(`?${params.toString()}`)
},
[router, searchParams]
)
const classId = searchParams.get("classId") ?? "all"
const status = searchParams.get("status") ?? "all"
const date = searchParams.get("date") ?? ""
const hasFilters = classId !== "all" || status !== "all" || date !== ""
return (
<FilterBar
layout="wrap"
hasFilters={hasFilters}
onReset={() => {
router.push("/teacher/attendance")
}}
>
<div className="grid gap-2">
<Label htmlFor="filter-class" className="text-xs">
{t("filters.class")}
</Label>
<Select value={classId} onValueChange={(v) => updateParam("classId", v)}>
<SelectTrigger id="filter-class" className="h-9" aria-label={t("filters.class")}>
<SelectValue placeholder={t("filters.allClasses")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t("filters.allClasses")}</SelectItem>
{classes.map((c) => (
<SelectItem key={c.id} value={c.id}>
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label htmlFor="filter-status" className="text-xs">
{t("filters.status")}
</Label>
<Select value={status} onValueChange={(v) => updateParam("status", v)}>
<SelectTrigger id="filter-status" className="h-9" aria-label={t("filters.status")}>
<SelectValue placeholder={t("filters.allStatuses")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t("filters.allStatuses")}</SelectItem>
{ATTENDANCE_STATUS_OPTIONS.map((s) => (
<SelectItem key={s} value={s}>
{t(ATTENDANCE_STATUS_LABEL_KEYS[s])}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label htmlFor="filter-date" className="text-xs">
{t("filters.date")}
</Label>
<Input
id="filter-date"
type="date"
value={date}
onChange={(e) => updateParam("date", e.target.value)}
className="h-9"
aria-label={t("filters.date")}
/>
</div>
</FilterBar>
)
}

View File

@@ -1,12 +1,13 @@
import type { JSX } from "react"
import { Suspense } from "react"
import { BarChart3 } from "lucide-react"
import { BarChart3, BookOpen, Brain, CheckCircle2, Clock, TrendingUp } from "lucide-react"
import { getTranslations } from "next-intl/server"
import { requirePermission } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { Skeleton } from "@/shared/components/ui/skeleton"
import { StatsGrid } from "@/shared/components/ui/stats-grid"
import { WidgetBoundary } from "@/shared/components/widget-boundary"
import { getParam, type SearchParams } from "@/shared/lib/search-params"
import { getStudentIdsByClassIds, getClassIdsByGradeIds } from "@/modules/classes/data-access"
@@ -23,7 +24,6 @@ import {
import { TopWrongQuestions } from "@/modules/error-book/components/top-wrong-questions"
import { SubjectTabs } from "@/modules/error-book/components/subject-tabs"
import { ClassFilter } from "@/modules/error-book/components/class-filter"
import { AnalyticsStatsCards } from "@/modules/error-book/components/analytics-stats-cards"
import { ClassErrorBarChart } from "@/modules/error-book/components/class-error-bar-chart"
import { KnowledgePointWeaknessChart } from "@/modules/error-book/components/knowledge-point-weakness-chart"
import { ChapterWeaknessChart } from "@/modules/error-book/components/chapter-weakness-chart"
@@ -128,6 +128,15 @@ async function TeacherErrorBookContent({
// 按错题数降序排列
const sortedSummaries = [...summaries].sort((a, b) => b.totalCount - a.totalCount)
const analyticsAvg = (queryStudentIds.length > 0 ? totalErrorItems / queryStudentIds.length : 0).toFixed(1)
const analyticsItems = [
{ label: t("analyticsStats.coverage"), value: studentsWithErrorBook.length, icon: BookOpen, color: "text-blue-600 dark:text-blue-400", valueClassName: "text-blue-600 dark:text-blue-400", description: t("analyticsStats.coverageSub", { total: queryStudentIds.length }) },
{ label: t("analyticsStats.totalErrors"), value: totalErrorItems, icon: TrendingUp, color: "text-rose-600 dark:text-rose-400", valueClassName: "text-rose-600 dark:text-rose-400", description: t("analyticsStats.totalErrorsSub", { avg: analyticsAvg }) },
{ label: t("analyticsStats.avgMastery"), value: `${Math.round(averageMasteryRate * 100)}%`, icon: CheckCircle2, color: "text-emerald-600 dark:text-emerald-400", valueClassName: "text-emerald-600 dark:text-emerald-400", description: averageMasteryRate >= 0.6 ? t("analyticsStats.avgMasteryGood") : t("analyticsStats.avgMasteryNeedImprove") },
{ label: t("analyticsStats.dueReview"), value: totalDueReview, icon: Clock, color: "text-amber-600 dark:text-amber-400", valueClassName: "text-amber-600 dark:text-amber-400", description: totalDueReview > 0 ? t("analyticsStats.dueReviewNeedAttention") : t("analyticsStats.dueReviewNone") },
{ label: t("analyticsStats.knowledgePoints"), value: knowledgePointCount, icon: Brain, color: "text-purple-600 dark:text-purple-400", valueClassName: "text-purple-600 dark:text-purple-400", description: knowledgePointCount > 5 ? t("analyticsStats.knowledgePointsWide") : t("analyticsStats.knowledgePointsFocused") },
]
return (
<div className="flex h-full flex-col space-y-6 p-8">
<div>
@@ -159,14 +168,7 @@ async function TeacherErrorBookContent({
{/* 统计卡片 */}
<WidgetBoundary title={t("teacher.title")} skeletonHeight={120}>
<AnalyticsStatsCards
totalStudents={queryStudentIds.length}
studentsWithErrorBook={studentsWithErrorBook.length}
totalErrorItems={totalErrorItems}
averageMasteryRate={averageMasteryRate}
dueReviewCount={totalDueReview}
knowledgePointCount={knowledgePointCount}
/>
<StatsGrid items={analyticsItems} columns={5} className="gap-3" />
</WidgetBoundary>
{/* 班级错题对比图(仅在"全部班级"视图下显示) */}

View File

@@ -2,8 +2,9 @@ import type { JSX } from "react"
import { getTranslations } from "next-intl/server"
import { ChipNav } from "@/shared/components/ui/chip-nav"
import { FilterBar } from "@/shared/components/ui/filter-bar"
interface AnalyticsFiltersProps {
interface AnalyticsGradeFiltersProps {
classes: Array<{ id: string; name: string }>
grades: Array<{ id: string; name: string }>
subjects: Array<{ id: string; name: string }>
@@ -15,7 +16,17 @@ interface AnalyticsFiltersProps {
currentSemester: string
}
export async function AnalyticsFilters({
/**
* FilterBar + ChipNav
*
* Phase 4.6 `modules/grades/components/analytics-filters.tsx`
* wrapper shared
*
* chip ////
* ChipNav buildHref + Link URL client state
* 使 ChipNav Select FilterBar
*/
export async function AnalyticsGradeFilters({
classes,
grades,
subjects,
@@ -25,7 +36,7 @@ export async function AnalyticsFilters({
currentGradeId,
currentExamId,
currentSemester,
}: AnalyticsFiltersProps): Promise<JSX.Element> {
}: AnalyticsGradeFiltersProps): Promise<JSX.Element> {
const t = await getTranslations("grades")
const buildHref = (overrides: {
classId?: string
@@ -53,12 +64,10 @@ export async function AnalyticsFilters({
overrides.gradeId !== undefined ? overrides.gradeId : currentGradeId
)
}
// v3-P2-7: examId 筛选
const examId = overrides.examId !== undefined ? overrides.examId : currentExamId
if (examId && examId !== "all") {
params.set("examId", examId)
}
// v3-P2-7: semester 筛选
const semester = overrides.semester !== undefined ? overrides.semester : currentSemester
if (semester && semester !== "all") {
params.set("semester", semester)
@@ -79,8 +88,8 @@ export async function AnalyticsFilters({
return (
<div className="rounded-lg border bg-card p-4">
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
<div className="space-y-2">
<FilterBar layout="wrap" gapClassName="gap-4">
<div className="space-y-2 md:flex-1">
<div className="text-xs font-medium text-muted-foreground">{t("analytics.class")}</div>
<ChipNav
options={classes}
@@ -91,7 +100,7 @@ export async function AnalyticsFilters({
/>
</div>
<div className="space-y-2">
<div className="space-y-2 md:flex-1">
<div className="text-xs font-medium text-muted-foreground">{t("analytics.subject")}</div>
<ChipNav
options={subjects}
@@ -103,7 +112,7 @@ export async function AnalyticsFilters({
/>
</div>
<div className="space-y-2">
<div className="space-y-2 md:flex-1">
<div className="text-xs font-medium text-muted-foreground">
{t("analytics.classComparisonLabel")}
</div>
@@ -115,9 +124,9 @@ export async function AnalyticsFilters({
className="gap-1.5"
/>
</div>
</div>
</FilterBar>
{/* v3-P2-7: 学期和考试筛选 */}
{/* 学期和考试筛选 */}
<div className="mt-4 grid grid-cols-1 gap-4 border-t pt-4 md:grid-cols-2">
<div className="space-y-2">
<div className="text-xs font-medium text-muted-foreground">{t("analytics.semester")}</div>

View File

@@ -23,7 +23,7 @@ import { GradeTrendChart } from "@/modules/grades/components/grade-trend-chart"
import { ClassComparisonChart } from "@/modules/grades/components/class-comparison-chart"
import { SubjectComparisonChart } from "@/modules/grades/components/subject-comparison-chart"
import { GradeDistributionChart } from "@/modules/grades/components/grade-distribution-chart"
import { AnalyticsFilters } from "@/modules/grades/components/analytics-filters"
import { AnalyticsGradeFilters } from "./analytics-grade-filters"
import { KnowledgePointMasteryChart } from "@/modules/grades/components/knowledge-point-mastery-chart"
import { getClassMasterySummary } from "@/modules/diagnostic/data-access"
import { WidgetBoundary } from "@/shared/components/widget-boundary"
@@ -142,7 +142,7 @@ export default async function GradeAnalyticsPage({
</Button>
</div>
<AnalyticsFilters
<AnalyticsGradeFilters
classes={classes.map((c) => ({ id: c.id, name: c.name }))}
grades={allGrades.map((g) => ({ id: g.id, name: g.name }))}
subjects={allSubjects.map((s) => ({ id: s.id, name: s.name ?? "Unknown" }))}

View File

@@ -12,11 +12,11 @@ import { getParam, type SearchParams } from "@/shared/lib/search-params"
import { getTeacherClasses } from "@/modules/classes/data-access"
import { getGradeRecords } from "@/modules/grades/data-access"
import { getSubjectOptions } from "@/modules/school/data-access"
import { GradeQueryFilters } from "@/modules/grades/components/grade-query-filters"
import { GradeRecordList } from "@/modules/grades/components/grade-record-list"
import { ExportButton } from "@/modules/grades/components/export-button"
import { ExcelImportDialog } from "@/modules/grades/components/excel-import-dialog"
import { isGradeType, isSemester } from "@/modules/grades/lib/type-guards"
import { TeacherGradeQueryFilters } from "./teacher-grade-query-filters"
export const dynamic = "force-dynamic"
@@ -104,7 +104,7 @@ export default async function TeacherGradesPage({
</div>
</div>
<GradeQueryFilters classes={classOptions} subjects={subjectOptions} />
<TeacherGradeQueryFilters classes={classOptions} subjects={subjectOptions} />
{total === 0 && !hasFilters ? (
<EmptyState

View File

@@ -6,15 +6,20 @@ import { useCallback } from "react"
import { useTranslations } from "next-intl"
import { Label } from "@/shared/components/ui/label"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/components/ui/select"
import { FilterBar } from "@/shared/components/ui/filter-bar"
import type { SelectOption } from "../types"
interface GradeQueryFiltersProps {
classes: SelectOption[]
subjects: SelectOption[]
interface TeacherGradeQueryFiltersProps {
classes: Array<{ id: string; name: string }>
subjects: Array<{ id: string; name: string }>
}
export function GradeQueryFilters({ classes, subjects }: GradeQueryFiltersProps): JSX.Element {
/**
* FilterBar + Select
*
* Phase 4.6 `modules/grades/components/grade-query-filters.tsx`
* wrapper shared
*/
export function TeacherGradeQueryFilters({ classes, subjects }: TeacherGradeQueryFiltersProps): JSX.Element {
const t = useTranslations("grades")
const router = useRouter()
const searchParams = useSearchParams()
@@ -37,8 +42,18 @@ export function GradeQueryFilters({ classes, subjects }: GradeQueryFiltersProps)
const type = searchParams.get("type") ?? "all"
const semester = searchParams.get("semester") ?? "all"
const hasFilters = Boolean(
(classId !== "all") || (subjectId !== "all") || (type !== "all") || (semester !== "all")
)
return (
<div className="grid grid-cols-1 gap-4 rounded-lg border bg-card p-4 md:grid-cols-4">
<FilterBar
layout="wrap"
hasFilters={hasFilters}
onReset={() => {
router.push("/teacher/grades")
}}
>
<div className="grid gap-2">
<Label htmlFor="filter-class" className="text-xs">{t("filters.class")}</Label>
<Select value={classId} onValueChange={(v) => updateParam("classId", v)}>
@@ -102,6 +117,6 @@ export function GradeQueryFilters({ classes, subjects }: GradeQueryFiltersProps)
</SelectContent>
</Select>
</div>
</div>
</FilterBar>
)
}

View File

@@ -1,88 +0,0 @@
import { useTranslations } from "next-intl"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { StatItem } from "@/shared/components/ui/stat-item"
import {
Users,
CheckCircle2,
XCircle,
Clock,
LogOut,
FileText,
School,
TrendingUp,
BarChart3,
} from "lucide-react"
import type { AttendanceStats } from "../types"
export function AttendanceStatsCard({ stats }: { stats: AttendanceStats | null }) {
const t = useTranslations("attendance")
if (!stats || stats.total === 0) {
return (
<EmptyState
title={t("stats.noData")}
description={t("stats.noDataDescription")}
icon={BarChart3}
className="border-none shadow-none"
/>
)
}
return (
<Card>
<CardHeader>
<CardTitle>{t("title.teacherStats")}</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<StatItem
label={t("stats.totalRecords")}
value={stats.total}
icon={<Users className="h-4 w-4" />}
/>
<StatItem
label={t("stats.present")}
value={stats.present}
icon={<CheckCircle2 className="h-4 w-4" />}
/>
<StatItem
label={t("stats.absent")}
value={stats.absent}
icon={<XCircle className="h-4 w-4" />}
/>
<StatItem
label={t("stats.late")}
value={stats.late}
icon={<Clock className="h-4 w-4" />}
/>
<StatItem
label={t("stats.earlyLeave")}
value={stats.earlyLeave}
icon={<LogOut className="h-4 w-4" />}
/>
<StatItem
label={t("stats.excused")}
value={stats.excused}
icon={<FileText className="h-4 w-4" />}
/>
<StatItem
label={t("stats.schoolActivity")}
value={stats.schoolActivity}
icon={<School className="h-4 w-4" />}
/>
<StatItem
label={t("stats.attendanceRate")}
value={`${stats.presentRate.toFixed(1)}%`}
icon={<TrendingUp className="h-4 w-4" />}
/>
<StatItem
label={t("stats.lateRate")}
value={`${stats.lateRate.toFixed(1)}%`}
icon={<Clock className="h-4 w-4" />}
/>
</div>
</CardContent>
</Card>
)
}

View File

@@ -1,83 +0,0 @@
import { Users, CheckCircle2, XCircle, Clock, LogOut, FileText, School } from "lucide-react"
import { useTranslations } from "next-intl"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { cn } from "@/shared/lib/utils"
import type { AttendanceStats } from "../types"
interface AttendanceStatsCardsProps {
stats: AttendanceStats
}
export function AttendanceStatsCards({ stats }: AttendanceStatsCardsProps) {
const t = useTranslations("attendance")
const cards = [
{
title: t("stats.totalRecords"),
value: stats.total,
icon: FileText,
color: "text-blue-500",
bgColor: "bg-blue-500/10",
},
{
title: t("stats.present"),
value: stats.present,
icon: CheckCircle2,
color: "text-green-500",
bgColor: "bg-green-500/10",
},
{
title: t("stats.absent"),
value: stats.absent,
icon: XCircle,
color: "text-red-500",
bgColor: "bg-red-500/10",
},
{
title: t("stats.late"),
value: stats.late,
icon: Clock,
color: "text-yellow-500",
bgColor: "bg-yellow-500/10",
},
{
title: t("stats.earlyLeave"),
value: stats.earlyLeave,
icon: LogOut,
color: "text-orange-500",
bgColor: "bg-orange-500/10",
},
{
title: t("stats.schoolActivity"),
value: stats.schoolActivity,
icon: School,
color: "text-cyan-500",
bgColor: "bg-cyan-500/10",
},
{
title: t("stats.attendanceRate"),
value: `${stats.presentRate.toFixed(1)}%`,
icon: Users,
color: "text-primary",
bgColor: "bg-primary/10",
},
]
return (
<div className="grid gap-4 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-7">
{cards.map((card) => (
<Card key={card.title} className="shadow-none">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">{card.title}</CardTitle>
<div className={cn("flex h-8 w-8 items-center justify-center rounded-md", card.bgColor)}>
<card.icon className={cn("h-4 w-4", card.color)} />
</div>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold tabular-nums">{card.value}</div>
</CardContent>
</Card>
))}
</div>
)
}

View File

@@ -10,9 +10,20 @@ import {
TableRow,
} from "@/shared/components/ui/table"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { CalendarCheck } from "lucide-react"
import { StatsGrid } from "@/shared/components/ui/stats-grid"
import {
BarChart3,
CalendarCheck,
CheckCircle2,
Clock,
FileText,
LogOut,
School,
TrendingUp,
Users,
XCircle,
} from "lucide-react"
import { AttendanceStatsCard } from "./attendance-stats-card"
import {
ATTENDANCE_STATUS_BADGE_VARIANTS,
ATTENDANCE_STATUS_LABEL_KEYS,
@@ -62,7 +73,37 @@ export function StudentAttendanceView({
</Card>
</div>
<AttendanceStatsCard stats={summary.stats} />
{summary.stats.total === 0 ? (
<EmptyState
title={t("stats.noData")}
description={t("stats.noDataDescription")}
icon={BarChart3}
className="border-none shadow-none"
/>
) : (
<StatsGrid
items={[
{ label: t("stats.totalRecords"), value: summary.stats.total, icon: Users },
{ label: t("stats.present"), value: summary.stats.present, icon: CheckCircle2 },
{ label: t("stats.absent"), value: summary.stats.absent, icon: XCircle },
{ label: t("stats.late"), value: summary.stats.late, icon: Clock },
{ label: t("stats.earlyLeave"), value: summary.stats.earlyLeave, icon: LogOut },
{ label: t("stats.excused"), value: summary.stats.excused, icon: FileText },
{ label: t("stats.schoolActivity"), value: summary.stats.schoolActivity, icon: School },
{
label: t("stats.attendanceRate"),
value: `${summary.stats.presentRate.toFixed(1)}%`,
icon: TrendingUp,
},
{
label: t("stats.lateRate"),
value: `${summary.stats.lateRate.toFixed(1)}%`,
icon: Clock,
},
]}
columns={4}
/>
)}
{summary.recentRecords.length === 0 ? (
<EmptyState

View File

@@ -1,71 +0,0 @@
import { BookOpen, Users, Gauge, Shuffle } from "lucide-react"
import { useTranslations } from "next-intl"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { cn } from "@/shared/lib/utils"
import type { ElectiveOverviewStats } from "../data-access-stats"
/**
* 选课模块管理员概览统计卡片网格。
*
* 复用模式:与 attendance-stats-cards 一致的 4 卡片网格。
* 设计原则:
* - 通过 props 注入数据,不直接调用 data-access便于测试与复用
* - 使用 i18n key 解析标题value 由父组件传入已格式化的数据
*/
export function ElectiveStatsCards({ stats }: { stats: ElectiveOverviewStats }) {
const t = useTranslations("elective")
const cards = [
{
title: t("stats.totalCourses"),
value: stats.totalCourses,
icon: BookOpen,
color: "text-blue-500",
bgColor: "bg-blue-500/10",
},
{
title: t("stats.totalEnrolled"),
value: stats.totalEnrolled,
icon: Users,
color: "text-green-500",
bgColor: "bg-green-500/10",
},
{
title: t("stats.avgUtilization"),
value: t("stats.utilizationRate", { rate: stats.avgUtilization }),
icon: Gauge,
color: "text-purple-500",
bgColor: "bg-purple-500/10",
},
{
title: t("stats.pendingLottery"),
value: stats.pendingLottery,
icon: Shuffle,
color: "text-orange-500",
bgColor: "bg-orange-500/10",
},
]
return (
<div
className="grid gap-4 md:grid-cols-2 lg:grid-cols-4"
role="region"
aria-label={t("stats.totalCourses")}
>
{cards.map((card) => (
<Card key={card.title} className="shadow-none">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">{card.title}</CardTitle>
<div className={cn("flex h-8 w-8 items-center justify-center rounded-md", card.bgColor)}>
<card.icon className={cn("h-4 w-4", card.color)} />
</div>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold tabular-nums">{card.value}</div>
</CardContent>
</Card>
))}
</div>
)
}

View File

@@ -1,109 +0,0 @@
"use client"
import { BookOpen, Brain, CheckCircle2, Clock, TrendingUp } from "lucide-react"
import { useTranslations } from "next-intl"
import { Card, CardContent } from "@/shared/components/ui/card"
import { cn } from "@/shared/lib/utils"
interface AnalyticsStatsCardsProps {
totalStudents: number
studentsWithErrorBook: number
totalErrorItems: number
averageMasteryRate: number
dueReviewCount: number
/** 涉及的知识点数 */
knowledgePointCount?: number
className?: string
}
/**
* 错题分析统计卡片(教师/管理员视图)
* 5 个卡片:覆盖学生/错题总数/平均掌握率/待复习/知识点数
*/
export function AnalyticsStatsCards({
totalStudents,
studentsWithErrorBook,
totalErrorItems,
averageMasteryRate,
dueReviewCount,
knowledgePointCount,
className,
}: AnalyticsStatsCardsProps) {
const t = useTranslations("errorBook")
const avg = (totalStudents > 0 ? totalErrorItems / totalStudents : 0).toFixed(1)
const cards = [
{
label: t("analyticsStats.coverage"),
value: studentsWithErrorBook,
sub: t("analyticsStats.coverageSub", { total: totalStudents }),
icon: BookOpen,
color: "text-blue-600 dark:text-blue-400",
bg: "bg-blue-50 dark:bg-blue-950/30",
},
{
label: t("analyticsStats.totalErrors"),
value: totalErrorItems,
sub: t("analyticsStats.totalErrorsSub", { avg }),
icon: TrendingUp,
color: "text-rose-600 dark:text-rose-400",
bg: "bg-rose-50 dark:bg-rose-950/30",
},
{
label: t("analyticsStats.avgMastery"),
value: `${Math.round(averageMasteryRate * 100)}%`,
sub: averageMasteryRate >= 0.6
? t("analyticsStats.avgMasteryGood")
: t("analyticsStats.avgMasteryNeedImprove"),
icon: CheckCircle2,
color: "text-emerald-600 dark:text-emerald-400",
bg: "bg-emerald-50 dark:bg-emerald-950/30",
},
{
label: t("analyticsStats.dueReview"),
value: dueReviewCount,
sub: dueReviewCount > 0
? t("analyticsStats.dueReviewNeedAttention")
: t("analyticsStats.dueReviewNone"),
icon: Clock,
color: "text-amber-600 dark:text-amber-400",
bg: "bg-amber-50 dark:bg-amber-950/30",
},
{
label: t("analyticsStats.knowledgePoints"),
value: knowledgePointCount ?? 0,
sub: knowledgePointCount && knowledgePointCount > 5
? t("analyticsStats.knowledgePointsWide")
: t("analyticsStats.knowledgePointsFocused"),
icon: Brain,
color: "text-purple-600 dark:text-purple-400",
bg: "bg-purple-50 dark:bg-purple-950/30",
},
]
return (
<div className={cn("grid gap-3 sm:grid-cols-2 lg:grid-cols-5", className)}>
{cards.map((card) => {
const Icon = card.icon
return (
<Card key={card.label} className="overflow-hidden">
<CardContent className="p-4">
<div className="flex items-start justify-between">
<div className="min-w-0 flex-1">
<div className="text-xs text-muted-foreground">{card.label}</div>
<div className={cn("mt-1 text-2xl font-bold", card.color)}>
{card.value}
</div>
<div className="mt-0.5 text-xs text-muted-foreground">{card.sub}</div>
</div>
<div className={cn("rounded-lg p-2", card.bg)}>
<Icon className={cn("h-4 w-4", card.color)} />
</div>
</div>
</CardContent>
</Card>
)
})}
</div>
)
}

View File

@@ -1,64 +0,0 @@
"use client"
import { BookX, Clock, GraduationCap, Repeat, Sparkles } from "lucide-react"
import { useTranslations } from "next-intl"
import { StatCard } from "@/shared/components/ui/stat-card"
import type { ErrorBookStats } from "../types"
interface ErrorBookStatsCardsProps {
stats: ErrorBookStats
isLoading?: boolean
}
export function ErrorBookStatsCards({ stats, isLoading }: ErrorBookStatsCardsProps) {
const t = useTranslations("errorBook")
const masteredPercent = stats.totalCount > 0
? Math.round(stats.masteredRate * 100)
: 0
return (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-5">
<StatCard
title={t("stats.total")}
value={stats.totalCount}
icon={BookX}
description={t("stats.totalDesc")}
isLoading={isLoading}
/>
<StatCard
title={t("stats.new")}
value={stats.newCount}
icon={Sparkles}
color="text-blue-500"
description={t("stats.newDesc")}
isLoading={isLoading}
/>
<StatCard
title={t("stats.learning")}
value={stats.learningCount}
icon={Repeat}
color="text-amber-500"
description={t("stats.learningDesc")}
isLoading={isLoading}
/>
<StatCard
title={t("stats.mastered")}
value={stats.masteredCount}
icon={GraduationCap}
color="text-emerald-500"
description={t("stats.masteredDesc", { rate: masteredPercent })}
isLoading={isLoading}
/>
<StatCard
title={t("stats.dueReview")}
value={stats.dueReviewCount}
icon={Clock}
color="text-rose-500"
description={t("stats.dueReviewDesc")}
highlight={stats.dueReviewCount > 0}
isLoading={isLoading}
/>
</div>
)
}

View File

@@ -0,0 +1,124 @@
"use client"
import type { JSX } from "react"
import { Info } from "lucide-react"
import { useTranslations } from "next-intl"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Label } from "@/shared/components/ui/label"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/components/ui/select"
import { Badge } from "@/shared/components/ui/badge"
import type { ExamOptionForEntry, ExamForGradeEntry } from "@/modules/exams/types"
type ClassOption = { id: string; name: string }
interface Props {
exams: ExamOptionForEntry[]
classes: ClassOption[]
exam: ExamForGradeEntry | null
defaultExamId?: string
defaultClassId?: string
onExamChange: (examId: string) => void
onClassChange: (classId: string) => void
}
/**
* 按试卷批量录入成绩的试卷/班级选择器(含未选试卷态)。
*
* Phase 4.6 重构:从 batch-grade-entry.tsx 拆分。
* - 未选试卷时显示单个试卷选择器 + 引导提示
* - 已选试卷时显示试卷/班级双列选择器 + 试卷信息徽章
*/
export function BatchGradeEntrySelectors({
exams,
classes,
exam,
defaultExamId,
defaultClassId,
onExamChange,
onClassChange,
}: Props): JSX.Element {
const t = useTranslations("grades")
// 未选试卷
if (!exam) {
return (
<Card className="shadow-none">
<CardHeader>
<CardTitle className="text-base">{t("batchByExam.selectExam")}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label>{t("batchByExam.selectExam")}</Label>
<Select onValueChange={onExamChange}>
<SelectTrigger>
<SelectValue placeholder={t("batchByExam.selectExamPlaceholder")} />
</SelectTrigger>
<SelectContent>
{exams.map((e) => (
<SelectItem key={e.id} value={e.id}>
{e.title}{e.subjectName} · {e.questionCount} · {e.totalScore}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-start gap-2 rounded-md bg-muted/50 p-3 text-sm text-muted-foreground">
<Info className="mt-0.5 h-4 w-4 shrink-0" />
<span>{t("batchByExam.guideSelectExam")}</span>
</div>
</CardContent>
</Card>
)
}
return (
<Card className="shadow-none">
<CardHeader>
<CardTitle className="text-base">{t("batchByExam.title")}</CardTitle>
</CardHeader>
<CardContent className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label>{t("batchByExam.selectExam")}</Label>
<Select value={defaultExamId} onValueChange={onExamChange}>
<SelectTrigger>
<SelectValue placeholder={t("batchByExam.selectExamPlaceholder")} />
</SelectTrigger>
<SelectContent>
{exams.map((e) => (
<SelectItem key={e.id} value={e.id}>
{e.title}{e.subjectName} · {e.questionCount}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>{t("batchByExam.selectClass")}</Label>
<Select value={defaultClassId} onValueChange={onClassChange}>
<SelectTrigger>
<SelectValue placeholder={t("batchByExam.selectClassPlaceholder")} />
</SelectTrigger>
<SelectContent>
{classes.map((c) => (
<SelectItem key={c.id} value={c.id}>
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 试卷信息 */}
<div className="md:col-span-2 flex flex-wrap gap-2 text-sm">
<Badge variant="secondary">
{t("batchByExam.questionCount", { count: exam.questions.length })}
</Badge>
<Badge variant="secondary">
{t("batchByExam.fullScore", { score: exam.totalScore })}
</Badge>
</div>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,56 @@
"use client"
import type { JSX } from "react"
import { Info } from "lucide-react"
import { useTranslations } from "next-intl"
import { Button } from "@/shared/components/ui/button"
interface Props {
isSubmitting: boolean
hasInvalidScores: boolean
onClear: () => void
onSubmit: () => void
}
/**
* 按试卷批量录入成绩的底部工具栏(清除/保存按钮 + 粘贴提示)。
*
* Phase 4.6 重构:从 batch-grade-entry.tsx 拆分。
*/
export function BatchGradeEntryToolbar({
isSubmitting,
hasInvalidScores,
onClear,
onSubmit,
}: Props): JSX.Element {
const t = useTranslations("grades")
return (
<>
<div className="flex items-center justify-end gap-3">
<Button
type="button"
variant="outline"
onClick={onClear}
disabled={isSubmitting}
>
{t("batchByExam.clear")}
</Button>
<Button
type="button"
onClick={onSubmit}
disabled={isSubmitting || hasInvalidScores}
>
{isSubmitting ? t("batchByExam.saving") : t("batchByExam.saveAll")}
</Button>
</div>
{/* 提示 */}
<div className="flex items-start gap-2 rounded-md bg-muted/50 p-3 text-sm text-muted-foreground">
<Info className="mt-0.5 h-4 w-4 shrink-0" />
<span>{t("batchByExam.pasteHint")}</span>
</div>
</>
)
}

View File

@@ -4,13 +4,8 @@ import { useState, useRef, useMemo, type JSX, type KeyboardEvent, type Clipboard
import { toast } from "sonner"
import { useRouter } from "next/navigation"
import { useTranslations } from "next-intl"
import { Info } from "lucide-react"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Button } from "@/shared/components/ui/button"
import { Label } from "@/shared/components/ui/label"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/components/ui/select"
import { Badge } from "@/shared/components/ui/badge"
import { Card, CardContent } from "@/shared/components/ui/card"
import { safeActionCall } from "@/shared/lib/action-utils"
import { batchCreateGradeRecordsByExamAction } from "../actions"
@@ -18,6 +13,8 @@ import { useBatchGradeEntryUndo } from "../hooks/use-batch-grade-entry-undo"
import { BatchGradeEntryStats } from "./batch-grade-entry-stats"
import { BatchGradeEntryDialog, type PendingSwitch } from "./batch-grade-entry-dialog"
import { BatchGradeEntryTable } from "./batch-grade-entry-table"
import { BatchGradeEntrySelectors } from "./batch-grade-entry-selectors"
import { BatchGradeEntryToolbar } from "./batch-grade-entry-toolbar"
import type { ExamOptionForEntry, ExamForGradeEntry } from "@/modules/exams/types"
type Student = { id: string; name: string; email: string }
@@ -34,11 +31,13 @@ interface Props {
}
/**
* 按试卷批量录入成绩主组件。
* 按试卷批量录入成绩主组件(容器)
*
* P1-6 重构:将表格、统计栏、确认对话框拆分为独立子组件。
* Phase 4.6 重构:进一步将试卷/班级选择器(含未选态)与底部工具栏拆出,
* 容器仅负责状态编排与 Server Action 调用。
* - useBatchGradeEntryUndo Hook 封装撤销逻辑 + 类型守卫P1-7 修复 `as` 断言)
* - BatchGradeEntryTable / BatchGradeEntryStats / BatchGradeEntryDialog 为组合子组件
* - BatchGradeEntrySelectors / BatchGradeEntryTable / BatchGradeEntryStats / BatchGradeEntryToolbar / BatchGradeEntryDialog 为组合子组件
*/
export function BatchGradeEntryByExam({
exams,
@@ -301,87 +300,17 @@ export function BatchGradeEntryByExam({
}
}
// 未选试卷
if (!exam) {
return (
<Card className="shadow-none">
<CardHeader>
<CardTitle className="text-base">{t("batchByExam.selectExam")}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label>{t("batchByExam.selectExam")}</Label>
<Select onValueChange={handleExamChange}>
<SelectTrigger>
<SelectValue placeholder={t("batchByExam.selectExamPlaceholder")} />
</SelectTrigger>
<SelectContent>
{exams.map((e) => (
<SelectItem key={e.id} value={e.id}>
{e.title}{e.subjectName} · {e.questionCount} · {e.totalScore}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-start gap-2 rounded-md bg-muted/50 p-3 text-sm text-muted-foreground">
<Info className="mt-0.5 h-4 w-4 shrink-0" />
<span>{t("batchByExam.guideSelectExam")}</span>
</div>
</CardContent>
</Card>
)
}
return (
<div className="space-y-6">
{/* 试卷 + 班级选择器 */}
<Card className="shadow-none">
<CardHeader>
<CardTitle className="text-base">{t("batchByExam.title")}</CardTitle>
</CardHeader>
<CardContent className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label>{t("batchByExam.selectExam")}</Label>
<Select value={defaultExamId} onValueChange={handleExamChange}>
<SelectTrigger>
<SelectValue placeholder={t("batchByExam.selectExamPlaceholder")} />
</SelectTrigger>
<SelectContent>
{exams.map((e) => (
<SelectItem key={e.id} value={e.id}>
{e.title}{e.subjectName} · {e.questionCount}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>{t("batchByExam.selectClass")}</Label>
<Select value={defaultClassId} onValueChange={handleClassChange}>
<SelectTrigger>
<SelectValue placeholder={t("batchByExam.selectClassPlaceholder")} />
</SelectTrigger>
<SelectContent>
{filteredClasses.map((c) => (
<SelectItem key={c.id} value={c.id}>
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 试卷信息 */}
<div className="md:col-span-2 flex flex-wrap gap-2 text-sm">
<Badge variant="secondary">
{t("batchByExam.questionCount", { count: exam.questions.length })}
</Badge>
<Badge variant="secondary">
{t("batchByExam.fullScore", { score: exam.totalScore })}
</Badge>
</div>
</CardContent>
</Card>
<BatchGradeEntrySelectors
exams={exams}
classes={filteredClasses}
exam={exam}
defaultExamId={defaultExamId}
defaultClassId={defaultClassId}
onExamChange={handleExamChange}
onClassChange={handleClassChange}
/>
{/* 未选班级 */}
{!defaultClassId ? (
@@ -390,6 +319,12 @@ export function BatchGradeEntryByExam({
{t("batchByExam.selectClassFirst")}
</CardContent>
</Card>
) : !exam ? (
<Card className="shadow-none">
<CardContent className="py-8 text-center text-muted-foreground">
{t("batchByExam.selectExam")}
</CardContent>
</Card>
) : students.length === 0 ? (
<Card className="shadow-none">
<CardContent className="py-8 text-center text-muted-foreground">
@@ -419,34 +354,17 @@ export function BatchGradeEntryByExam({
isScoreInvalid={isScoreInvalid}
/>
{/* 提交按钮 */}
<div className="flex items-center justify-end gap-3">
<Button
type="button"
variant="outline"
onClick={() => setScores({})}
disabled={isSubmitting}
>
{t("batchByExam.clear")}
</Button>
<Button
type="button"
onClick={handleSubmit}
disabled={isSubmitting || hasInvalidScores}
>
{isSubmitting ? t("batchByExam.saving") : t("batchByExam.saveAll")}
</Button>
</div>
{/* 提示 */}
<div className="flex items-start gap-2 rounded-md bg-muted/50 p-3 text-sm text-muted-foreground">
<Info className="mt-0.5 h-4 w-4 shrink-0" />
<span>{t("batchByExam.pasteHint")}</span>
</div>
{/* 底部工具栏:保存/清除按钮 + 粘贴提示 */}
<BatchGradeEntryToolbar
isSubmitting={isSubmitting}
hasInvalidScores={hasInvalidScores}
onClear={() => setScores({})}
onSubmit={handleSubmit}
/>
</>
)}
{/* 确认对话框 */}
{/* 切换试卷/班级确认对话框 */}
<BatchGradeEntryDialog
pendingSwitch={pendingSwitch}
onConfirm={confirmPendingSwitch}

View File

@@ -12,9 +12,9 @@ import {
TableRow,
} from "@/shared/components/ui/table"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { Trophy } from "lucide-react"
import { StatsGrid } from "@/shared/components/ui/stats-grid"
import { Trophy, TrendingUp, TrendingDown, BarChart3, Target, Award, CheckCircle2 } from "lucide-react"
import { GradeStatsCard } from "./grade-stats-card"
import type { ClassGradeStats, ClassRankingItem } from "../types"
interface ClassGradeReportProps {
@@ -36,7 +36,45 @@ export async function ClassGradeReport({ stats, ranking }: ClassGradeReportProps
</p>
</div>
</div>
<GradeStatsCard stats={stats.stats} />
{stats.stats.count === 0 ? (
<Card>
<CardHeader>
<CardTitle>{t("stats.title")}</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">{t("stats.noData")}</p>
</CardContent>
</Card>
) : (
<StatsGrid
columns={4}
items={[
{ label: t("stats.average"), value: stats.stats.average.toFixed(2), icon: BarChart3 },
{ label: t("stats.median"), value: stats.stats.median.toFixed(2), icon: BarChart3 },
{ label: t("stats.max"), value: stats.stats.max.toFixed(2), icon: TrendingUp },
{ label: t("stats.min"), value: stats.stats.min.toFixed(2), icon: TrendingDown },
{
label: t("stats.stdDev"),
value: stats.stats.stdDev.toFixed(2),
icon: Target,
description: t("stats.stdDevHint"),
},
{
label: t("stats.passRate"),
value: `${stats.stats.passRate.toFixed(1)}%`,
icon: CheckCircle2,
description: t("stats.passRateHint"),
},
{
label: t("stats.excellentRate"),
value: `${stats.stats.excellentRate.toFixed(1)}%`,
icon: Award,
description: t("stats.excellentRateHint"),
},
{ label: t("stats.count"), value: stats.stats.count, icon: BarChart3 },
]}
/>
)}
</div>
) : (
<EmptyState

View File

@@ -0,0 +1,238 @@
"use client"
import type { JSX } from "react"
import { useTranslations } from "next-intl"
import { Button } from "@/shared/components/ui/button"
import { Input } from "@/shared/components/ui/input"
import { Label } from "@/shared/components/ui/label"
import { Textarea } from "@/shared/components/ui/textarea"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/components/ui/select"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/shared/components/ui/dialog"
import type { GradeRecordListItem, GradeRecordType, GradeRecordSemester } from "../types"
import { isGradeType, isSemester } from "../lib/type-guards"
export type EditableFields = {
id: string
title: string
score: string
fullScore: string
type: GradeRecordType
semester: GradeRecordSemester
remark: string
}
interface DetailDialogsProps {
editTarget: EditableFields | null
deleteId: string | null
isBulkDeleteDialogOpen: boolean
isSaving: boolean
isDeleting: boolean
isBulkDeleting: boolean
bulkDeleteCount: number
onEditTargetChange: (target: EditableFields) => void
onCancelEdit: () => void
onSaveEdit: () => void
onCancelDelete: () => void
onConfirmDelete: () => void
onCloseBulkDelete: () => void
onConfirmBulkDelete: () => void
}
/**
* 成绩记录详情/编辑/删除对话框集合。
*
* Phase 4.6 重构:从 grade-record-list.tsx 拆分。
* 包含三个对话框:
* - 编辑对话框(标题/分数/满分/类型/学期/备注)
* - 单条删除确认对话框
* - 批量删除确认对话框
*/
export function GradeRecordDetail({
editTarget,
deleteId,
isBulkDeleteDialogOpen,
isSaving,
isDeleting,
isBulkDeleting,
bulkDeleteCount,
onEditTargetChange,
onCancelEdit,
onSaveEdit,
onCancelDelete,
onConfirmDelete,
onCloseBulkDelete,
onConfirmBulkDelete,
}: DetailDialogsProps): JSX.Element {
const t = useTranslations("grades")
return (
<>
{/* 编辑对话框 */}
<Dialog open={editTarget !== null} onOpenChange={(open) => !open && onCancelEdit()}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("edit.title")}</DialogTitle>
<DialogDescription>
{editTarget?.title}
</DialogDescription>
</DialogHeader>
{editTarget && (
<div className="grid grid-cols-1 gap-4 py-2 md:grid-cols-2">
<div className="grid gap-2 md:col-span-2">
<Label htmlFor="edit-title">{t("edit.titleLabel")}</Label>
<Input
id="edit-title"
value={editTarget.title}
onChange={(e) => onEditTargetChange({ ...editTarget, title: e.target.value })}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="edit-score">{t("edit.score")}</Label>
<Input
id="edit-score"
type="number"
step="0.01"
min="0"
value={editTarget.score}
onChange={(e) => onEditTargetChange({ ...editTarget, score: e.target.value })}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="edit-fullScore">{t("edit.fullScore")}</Label>
<Input
id="edit-fullScore"
type="number"
step="0.01"
min="1"
value={editTarget.fullScore}
onChange={(e) => onEditTargetChange({ ...editTarget, fullScore: e.target.value })}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="edit-type">{t("filters.type")}</Label>
<Select
value={editTarget.type}
onValueChange={(v) => { if (isGradeType(v)) onEditTargetChange({ ...editTarget, type: v }) }}
>
<SelectTrigger id="edit-type">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="exam">{t("type.exam")}</SelectItem>
<SelectItem value="quiz">{t("type.quiz")}</SelectItem>
<SelectItem value="homework">{t("type.homework")}</SelectItem>
<SelectItem value="other">{t("type.other")}</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label htmlFor="edit-semester">{t("filters.semester")}</Label>
<Select
value={editTarget.semester}
onValueChange={(v) => { if (isSemester(v)) onEditTargetChange({ ...editTarget, semester: v }) }}
>
<SelectTrigger id="edit-semester">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">{t("semester.s1")}</SelectItem>
<SelectItem value="2">{t("semester.s2")}</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid gap-2 md:col-span-2">
<Label htmlFor="edit-remark">{t("edit.remark")}</Label>
<Textarea
id="edit-remark"
placeholder={t("edit.remarkPlaceholder")}
value={editTarget.remark}
onChange={(e) => onEditTargetChange({ ...editTarget, remark: e.target.value })}
/>
</div>
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={onCancelEdit} disabled={isSaving}>
{t("edit.cancel")}
</Button>
<Button onClick={onSaveEdit} disabled={isSaving}>
{isSaving ? t("edit.saving") : t("edit.confirm")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 单条删除确认对话框 */}
<Dialog open={deleteId !== null} onOpenChange={(open) => !open && onCancelDelete()}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("delete.title")}</DialogTitle>
<DialogDescription>
{t("delete.confirmation")}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={onCancelDelete} disabled={isDeleting}>
{t("delete.cancel")}
</Button>
<Button variant="destructive" onClick={onConfirmDelete} disabled={isDeleting}>
{isDeleting ? t("delete.deleting") : t("delete.confirm")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* v3-P3-2: 批量删除确认对话框 */}
<Dialog open={isBulkDeleteDialogOpen} onOpenChange={(open) => !open && onCloseBulkDelete()}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("list.bulkDelete")}</DialogTitle>
<DialogDescription>
{t("list.bulkDeleteConfirmation", { count: bulkDeleteCount })}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={onCloseBulkDelete}
disabled={isBulkDeleting}
>
{t("delete.cancel")}
</Button>
<Button variant="destructive" onClick={onConfirmBulkDelete} disabled={isBulkDeleting}>
{isBulkDeleting ? t("delete.deleting") : t("delete.confirm")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)
}
/** 把 GradeRecordListItem 转换为 EditableFields用于 startEdit */
export function toEditableFields(r: GradeRecordListItem): EditableFields {
return {
id: r.id,
title: r.title,
score: String(r.score),
fullScore: String(r.fullScore),
type: r.type,
semester: r.semester,
remark: r.remark ?? "",
}
}

View File

@@ -5,62 +5,34 @@ import { useState, useOptimistic, useTransition } from "react"
import { toast } from "sonner"
import { useRouter } from "next/navigation"
import { useTranslations } from "next-intl"
import { Trash2 } from "lucide-react"
import { Button } from "@/shared/components/ui/button"
import { Input } from "@/shared/components/ui/input"
import { Label } from "@/shared/components/ui/label"
import { Textarea } from "@/shared/components/ui/textarea"
import { Checkbox } from "@/shared/components/ui/checkbox"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/components/ui/select"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/shared/components/ui/table"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/shared/components/ui/dialog"
import { StatusBadge } from "@/shared/components/ui/status-badge"
import { formatDate } from "@/shared/lib/utils"
import { safeActionCall } from "@/shared/lib/action-utils"
import { Trash2, Pencil } from "lucide-react"
import { deleteGradeRecordAction, updateGradeRecordAction, bulkDeleteGradeRecordsAction } from "../actions"
import type { GradeRecordListItem, GradeRecordType, GradeRecordSemester } from "../types"
import { GRADE_TYPE_VARIANT } from "../types"
import { isGradeType, isSemester } from "../lib/type-guards"
import { ScoreCell } from "./score-cell"
type EditableFields = {
id: string
title: string
score: string
fullScore: string
type: GradeRecordType
semester: GradeRecordSemester
remark: string
}
import type { GradeRecordListItem } from "../types"
import { GradeRecordRow } from "./grade-record-row"
import { GradeRecordDetail, toEditableFields, type EditableFields } from "./grade-record-detail"
/**
* 成绩记录列表容器。
*
* Phase 4.6 重构:
* - 行渲染(桌面表格 + 移动端卡片)拆分至 GradeRecordRow
* - 编辑/单条删除/批量删除对话框拆分至 GradeRecordDetail
* 容器只负责状态编排、Server Action 调用与组合。
*
* Phase 4.5:编辑保存改用 useOptimistic + useTransition在列表中即时显示乐观记录。
* v3-P3-2批量选择与删除。
*/
export function GradeRecordList({ records }: { records: GradeRecordListItem[] }): JSX.Element {
const t = useTranslations("grades")
const router = useRouter()
const [deleteId, setDeleteId] = useState<string | null>(null)
const [isDeleting, setIsDeleting] = useState(false)
const [editTarget, setEditTarget] = useState<EditableFields | null>(null)
// Phase 4.5: 编辑保存改用 useOptimistic + useTransition,在列表中即时显示乐观记录
// Phase 4.5: 编辑保存改用 useOptimistic + useTransition在列表中即时显示乐观记录
const [optimisticRecords, addOptimisticRecord] = useOptimistic<
GradeRecordListItem[],
GradeRecordListItem
@@ -74,7 +46,7 @@ export function GradeRecordList({ records }: { records: GradeRecordListItem[] })
const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false)
const [isBulkDeleting, setIsBulkDeleting] = useState(false)
const handleDelete = async () => {
const handleDelete = async (): Promise<void> => {
if (!deleteId) return
setIsDeleting(true)
const result = await safeActionCall(
@@ -144,22 +116,14 @@ export function GradeRecordList({ records }: { records: GradeRecordListItem[] })
}
const startEdit = (r: GradeRecordListItem): void => {
setEditTarget({
id: r.id,
title: r.title,
score: String(r.score),
fullScore: String(r.fullScore),
type: r.type,
semester: r.semester,
remark: r.remark ?? "",
})
setEditTarget(toEditableFields(r))
}
const handleEditSave = (): void => {
if (!editTarget) return
const existing = records.find((r) => r.id === editTarget.id)
if (!existing) return
// Phase 4.5: 构造乐观记录,在 transition 内 addOptimistic + await action + refresh
// Phase 4.5: 构造乐观记录在 transition 内 addOptimistic + await action + refresh
const optimisticRecord: GradeRecordListItem = {
...existing,
title: editTarget.title,
@@ -189,7 +153,7 @@ export function GradeRecordList({ records }: { records: GradeRecordListItem[] })
if (result?.success) {
toast.success(t("edit.success"))
setEditTarget(null)
// 同步数据源,让 useOptimistic 回滚到最新服务端值
// 同步数据源让 useOptimistic 回滚到最新服务端值
await router.refresh()
} else if (result) {
toast.error(result.message || t("edit.failed"))
@@ -235,289 +199,33 @@ export function GradeRecordList({ records }: { records: GradeRecordListItem[] })
</div>
</div>
)}
{/* v4-P1-10: 桌面端表格md 及以上显示) */}
<div className="hidden overflow-x-auto md:block">
<Table>
<caption className="sr-only">{t("list.caption")}</caption>
<TableHeader>
<TableRow>
<TableHead className="w-12">
<Checkbox
checked={records.length > 0 && selectedIds.size === records.length}
onCheckedChange={toggleSelectAll}
aria-label={t("list.selectAll")}
/>
</TableHead>
<TableHead>{t("list.columns.student")}</TableHead>
<TableHead>{t("list.columns.class")}</TableHead>
<TableHead>{t("list.columns.subject")}</TableHead>
<TableHead>{t("list.columns.title")}</TableHead>
<TableHead className="text-right">{t("list.columns.score")}</TableHead>
<TableHead>{t("list.columns.type")}</TableHead>
<TableHead>{t("list.columns.semester")}</TableHead>
<TableHead>{t("list.columns.recordedBy")}</TableHead>
<TableHead>{t("list.columns.date")}</TableHead>
<TableHead className="w-24">{t("list.columns.actions")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{optimisticRecords.map((r) => (
<TableRow key={r.id}>
<TableCell>
<Checkbox
checked={selectedIds.has(r.id)}
onCheckedChange={() => toggleRowSelection(r.id)}
aria-label={t("list.selectRow", { name: r.studentName })}
/>
</TableCell>
<TableCell className="font-medium">{r.studentName}</TableCell>
<TableCell>{r.className}</TableCell>
<TableCell>{r.subjectName}</TableCell>
<TableCell>{r.title}</TableCell>
<TableCell className="text-right">
{/* v4-P1-7: 成绩颜色编码 */}
<ScoreCell score={r.score} fullScore={r.fullScore} />
</TableCell>
<TableCell>
<StatusBadge status={r.type} variantMap={GRADE_TYPE_VARIANT} />
</TableCell>
<TableCell>{t(`semester.s${r.semester}`)}</TableCell>
<TableCell className="text-muted-foreground">{r.recorderName}</TableCell>
<TableCell className="text-muted-foreground">{formatDate(r.createdAt)}</TableCell>
<TableCell>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => startEdit(r)}
aria-label={t("list.editAriaLabel", { studentName: r.studentName, subjectName: r.subjectName })}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive"
onClick={() => setDeleteId(r.id)}
aria-label={t("list.deleteAriaLabel", { studentName: r.studentName, subjectName: r.subjectName })}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{/* P3-8: 移动端卡片堆叠视图md 以下显示) */}
<div
className="block space-y-3 p-4 md:hidden"
role="list"
aria-label={t("list.caption")}
>
{optimisticRecords.map((r) => (
<div
key={r.id}
className="rounded-md border border-border/60 bg-background p-3 shadow-sm"
role="listitem"
>
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-2">
<Checkbox
checked={selectedIds.has(r.id)}
onCheckedChange={() => toggleRowSelection(r.id)}
aria-label={t("list.selectRow", { name: r.studentName })}
/>
<div>
<div className="font-medium">{r.studentName}</div>
<div className="text-xs text-muted-foreground">
{r.className} · {r.subjectName}
</div>
</div>
</div>
<div className="text-right">
<ScoreCell score={r.score} fullScore={r.fullScore} />
<div className="mt-1 flex items-center justify-end gap-1.5">
<StatusBadge status={r.type} variantMap={GRADE_TYPE_VARIANT} />
<span className="text-xs text-muted-foreground">
{t(`semester.s${r.semester}`)}
</span>
</div>
</div>
</div>
<div className="mt-2 border-t border-border/40 pt-2">
<div className="text-sm">{r.title}</div>
<div className="mt-1 text-xs text-muted-foreground">
{r.recorderName} · {formatDate(r.createdAt)}
</div>
</div>
<div className="mt-2 flex items-center justify-end gap-1">
<Button
variant="ghost"
size="sm"
className="h-8"
onClick={() => startEdit(r)}
aria-label={t("list.editAriaLabel", { studentName: r.studentName, subjectName: r.subjectName })}
>
<Pencil className="mr-1 h-3.5 w-3.5" aria-hidden="true" />
{t("edit.title")}
</Button>
<Button
variant="ghost"
size="sm"
className="h-8 text-destructive"
onClick={() => setDeleteId(r.id)}
aria-label={t("list.deleteAriaLabel", { studentName: r.studentName, subjectName: r.subjectName })}
>
<Trash2 className="mr-1 h-3.5 w-3.5" aria-hidden="true" />
{t("delete.title")}
</Button>
</div>
</div>
))}
</div>
<GradeRecordRow
records={optimisticRecords}
selectedIds={selectedIds}
onToggleRow={toggleRowSelection}
onToggleSelectAll={toggleSelectAll}
onEdit={startEdit}
onDelete={setDeleteId}
/>
</div>
{/* 编辑对话框 */}
<Dialog open={editTarget !== null} onOpenChange={(open) => !open && setEditTarget(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("edit.title")}</DialogTitle>
<DialogDescription>
{editTarget?.title}
</DialogDescription>
</DialogHeader>
{editTarget && (
<div className="grid grid-cols-1 gap-4 py-2 md:grid-cols-2">
<div className="grid gap-2 md:col-span-2">
<Label htmlFor="edit-title">{t("edit.titleLabel")}</Label>
<Input
id="edit-title"
value={editTarget.title}
onChange={(e) => setEditTarget({ ...editTarget, title: e.target.value })}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="edit-score">{t("edit.score")}</Label>
<Input
id="edit-score"
type="number"
step="0.01"
min="0"
value={editTarget.score}
onChange={(e) => setEditTarget({ ...editTarget, score: e.target.value })}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="edit-fullScore">{t("edit.fullScore")}</Label>
<Input
id="edit-fullScore"
type="number"
step="0.01"
min="1"
value={editTarget.fullScore}
onChange={(e) => setEditTarget({ ...editTarget, fullScore: e.target.value })}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="edit-type">{t("filters.type")}</Label>
<Select
value={editTarget.type}
onValueChange={(v) => { if (isGradeType(v)) setEditTarget({ ...editTarget, type: v }) }}
>
<SelectTrigger id="edit-type">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="exam">{t("type.exam")}</SelectItem>
<SelectItem value="quiz">{t("type.quiz")}</SelectItem>
<SelectItem value="homework">{t("type.homework")}</SelectItem>
<SelectItem value="other">{t("type.other")}</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label htmlFor="edit-semester">{t("filters.semester")}</Label>
<Select
value={editTarget.semester}
onValueChange={(v) => { if (isSemester(v)) setEditTarget({ ...editTarget, semester: v }) }}
>
<SelectTrigger id="edit-semester">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">{t("semester.s1")}</SelectItem>
<SelectItem value="2">{t("semester.s2")}</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid gap-2 md:col-span-2">
<Label htmlFor="edit-remark">{t("edit.remark")}</Label>
<Textarea
id="edit-remark"
placeholder={t("edit.remarkPlaceholder")}
value={editTarget.remark}
onChange={(e) => setEditTarget({ ...editTarget, remark: e.target.value })}
/>
</div>
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setEditTarget(null)} disabled={isSaving}>
{t("edit.cancel")}
</Button>
<Button onClick={handleEditSave} disabled={isSaving}>
{isSaving ? t("edit.saving") : t("edit.confirm")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={deleteId !== null} onOpenChange={(open) => !open && setDeleteId(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("delete.title")}</DialogTitle>
<DialogDescription>
{t("delete.confirmation")}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteId(null)} disabled={isDeleting}>
{t("delete.cancel")}
</Button>
<Button variant="destructive" onClick={handleDelete} disabled={isDeleting}>
{isDeleting ? t("delete.deleting") : t("delete.confirm")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* v3-P3-2: 批量删除确认对话框 */}
<Dialog open={isBulkDeleteDialogOpen} onOpenChange={(open) => !open && setIsBulkDeleteDialogOpen(open)}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("list.bulkDelete")}</DialogTitle>
<DialogDescription>
{t("list.bulkDeleteConfirmation", { count: selectedIds.size })}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setIsBulkDeleteDialogOpen(false)}
disabled={isBulkDeleting}
>
{t("delete.cancel")}
</Button>
<Button variant="destructive" onClick={handleBulkDelete} disabled={isBulkDeleting}>
{isBulkDeleting ? t("delete.deleting") : t("delete.confirm")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<GradeRecordDetail
editTarget={editTarget}
deleteId={deleteId}
isBulkDeleteDialogOpen={isBulkDeleteDialogOpen}
isSaving={isSaving}
isDeleting={isDeleting}
isBulkDeleting={isBulkDeleting}
bulkDeleteCount={selectedIds.size}
onEditTargetChange={setEditTarget}
onCancelEdit={() => setEditTarget(null)}
onSaveEdit={handleEditSave}
onCancelDelete={() => setDeleteId(null)}
onConfirmDelete={handleDelete}
onCloseBulkDelete={() => setIsBulkDeleteDialogOpen(false)}
onConfirmBulkDelete={handleBulkDelete}
/>
</>
)
}

View File

@@ -0,0 +1,199 @@
"use client"
import type { JSX } from "react"
import { useTranslations } from "next-intl"
import { Pencil, Trash2 } from "lucide-react"
import { Button } from "@/shared/components/ui/button"
import { Checkbox } from "@/shared/components/ui/checkbox"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/shared/components/ui/table"
import { StatusBadge } from "@/shared/components/ui/status-badge"
import { formatDate } from "@/shared/lib/utils"
import type { GradeRecordListItem } from "../types"
import { GRADE_TYPE_VARIANT } from "../types"
import { ScoreCell } from "./score-cell"
interface Props {
records: GradeRecordListItem[]
selectedIds: Set<string>
onToggleRow: (id: string) => void
onToggleSelectAll: () => void
onEdit: (record: GradeRecordListItem) => void
onDelete: (id: string) => void
}
/**
* 成绩记录行渲染(桌面表格 + 移动端卡片)。
*
* Phase 4.6 重构:从 grade-record-list.tsx 拆分。
* - 桌面端md 及以上的 Table 行
* - 移动端md 以下的卡片堆叠视图role=list/listitem 保证 a11y
* 包含 v3-P3-2 多选复选框 + 全选 + v4-P1-7 ScoreCell 颜色编码。
*/
export function GradeRecordRow({
records,
selectedIds,
onToggleRow,
onToggleSelectAll,
onEdit,
onDelete,
}: Props): JSX.Element {
const t = useTranslations("grades")
return (
<>
{/* v4-P1-10: 桌面端表格md 及以上显示) */}
<div className="hidden overflow-x-auto md:block">
<Table>
<caption className="sr-only">{t("list.caption")}</caption>
<TableHeader>
<TableRow>
<TableHead className="w-12">
<Checkbox
checked={records.length > 0 && selectedIds.size === records.length}
onCheckedChange={onToggleSelectAll}
aria-label={t("list.selectAll")}
/>
</TableHead>
<TableHead>{t("list.columns.student")}</TableHead>
<TableHead>{t("list.columns.class")}</TableHead>
<TableHead>{t("list.columns.subject")}</TableHead>
<TableHead>{t("list.columns.title")}</TableHead>
<TableHead className="text-right">{t("list.columns.score")}</TableHead>
<TableHead>{t("list.columns.type")}</TableHead>
<TableHead>{t("list.columns.semester")}</TableHead>
<TableHead>{t("list.columns.recordedBy")}</TableHead>
<TableHead>{t("list.columns.date")}</TableHead>
<TableHead className="w-24">{t("list.columns.actions")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{records.map((r) => (
<TableRow key={r.id}>
<TableCell>
<Checkbox
checked={selectedIds.has(r.id)}
onCheckedChange={() => onToggleRow(r.id)}
aria-label={t("list.selectRow", { name: r.studentName })}
/>
</TableCell>
<TableCell className="font-medium">{r.studentName}</TableCell>
<TableCell>{r.className}</TableCell>
<TableCell>{r.subjectName}</TableCell>
<TableCell>{r.title}</TableCell>
<TableCell className="text-right">
{/* v4-P1-7: 成绩颜色编码 */}
<ScoreCell score={r.score} fullScore={r.fullScore} />
</TableCell>
<TableCell>
<StatusBadge status={r.type} variantMap={GRADE_TYPE_VARIANT} />
</TableCell>
<TableCell>{t(`semester.s${r.semester}`)}</TableCell>
<TableCell className="text-muted-foreground">{r.recorderName}</TableCell>
<TableCell className="text-muted-foreground">{formatDate(r.createdAt)}</TableCell>
<TableCell>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => onEdit(r)}
aria-label={t("list.editAriaLabel", { studentName: r.studentName, subjectName: r.subjectName })}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive"
onClick={() => onDelete(r.id)}
aria-label={t("list.deleteAriaLabel", { studentName: r.studentName, subjectName: r.subjectName })}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{/* P3-8: 移动端卡片堆叠视图md 以下显示) */}
<div
className="block space-y-3 p-4 md:hidden"
role="list"
aria-label={t("list.caption")}
>
{records.map((r) => (
<div
key={r.id}
className="rounded-md border border-border/60 bg-background p-3 shadow-sm"
role="listitem"
>
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-2">
<Checkbox
checked={selectedIds.has(r.id)}
onCheckedChange={() => onToggleRow(r.id)}
aria-label={t("list.selectRow", { name: r.studentName })}
/>
<div>
<div className="font-medium">{r.studentName}</div>
<div className="text-xs text-muted-foreground">
{r.className} · {r.subjectName}
</div>
</div>
</div>
<div className="text-right">
<ScoreCell score={r.score} fullScore={r.fullScore} />
<div className="mt-1 flex items-center justify-end gap-1.5">
<StatusBadge status={r.type} variantMap={GRADE_TYPE_VARIANT} />
<span className="text-xs text-muted-foreground">
{t(`semester.s${r.semester}`)}
</span>
</div>
</div>
</div>
<div className="mt-2 border-t border-border/40 pt-2">
<div className="text-sm">{r.title}</div>
<div className="mt-1 text-xs text-muted-foreground">
{r.recorderName} · {formatDate(r.createdAt)}
</div>
</div>
<div className="mt-2 flex items-center justify-end gap-1">
<Button
variant="ghost"
size="sm"
className="h-8"
onClick={() => onEdit(r)}
aria-label={t("list.editAriaLabel", { studentName: r.studentName, subjectName: r.subjectName })}
>
<Pencil className="mr-1 h-3.5 w-3.5" aria-hidden="true" />
{t("edit.title")}
</Button>
<Button
variant="ghost"
size="sm"
className="h-8 text-destructive"
onClick={() => onDelete(r.id)}
aria-label={t("list.deleteAriaLabel", { studentName: r.studentName, subjectName: r.subjectName })}
>
<Trash2 className="mr-1 h-3.5 w-3.5" aria-hidden="true" />
{t("delete.title")}
</Button>
</div>
</div>
))}
</div>
</>
)
}

View File

@@ -1,79 +0,0 @@
import type { JSX } from "react"
import { getTranslations } from "next-intl/server"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { StatItem } from "@/shared/components/ui/stat-item"
import { TrendingUp, TrendingDown, BarChart3, Target, Award, CheckCircle2 } from "lucide-react"
import type { GradeStats } from "../types"
export async function GradeStatsCard({ stats }: { stats: GradeStats | null }): Promise<JSX.Element> {
const t = await getTranslations("grades")
if (!stats || stats.count === 0) {
return (
<Card>
<CardHeader>
<CardTitle>{t("stats.title")}</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">{t("stats.noData")}</p>
</CardContent>
</Card>
)
}
return (
<Card>
<CardHeader>
<CardTitle>{t("stats.title")}</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<StatItem
label={t("stats.average")}
value={stats.average.toFixed(2)}
icon={<BarChart3 className="h-4 w-4" />}
/>
<StatItem
label={t("stats.median")}
value={stats.median.toFixed(2)}
icon={<BarChart3 className="h-4 w-4" />}
/>
<StatItem
label={t("stats.max")}
value={stats.max.toFixed(2)}
icon={<TrendingUp className="h-4 w-4" />}
/>
<StatItem
label={t("stats.min")}
value={stats.min.toFixed(2)}
icon={<TrendingDown className="h-4 w-4" />}
/>
<StatItem
label={t("stats.stdDev")}
value={stats.stdDev.toFixed(2)}
icon={<Target className="h-4 w-4" />}
hint={t("stats.stdDevHint")}
/>
<StatItem
label={t("stats.passRate")}
value={`${stats.passRate.toFixed(1)}%`}
icon={<CheckCircle2 className="h-4 w-4" />}
hint={t("stats.passRateHint")}
/>
<StatItem
label={t("stats.excellentRate")}
value={`${stats.excellentRate.toFixed(1)}%`}
icon={<Award className="h-4 w-4" />}
hint={t("stats.excellentRateHint")}
/>
<StatItem
label={t("stats.count")}
value={stats.count}
icon={<BarChart3 className="h-4 w-4" />}
/>
</div>
</CardContent>
</Card>
)
}

View File

@@ -7,7 +7,6 @@ import { cn } from "@/shared/lib/utils"
*
* 覆盖以下重复模式:
* - AttendanceStatsCard 内部 StatItem
* - GradeStatsCard 内部 StatItem
*
* 结构rounded-lg border bg-card 容器,含 label + icon + value + hint
*/

View File

@@ -9,7 +9,7 @@ import { StatCard } from "@/shared/components/ui/stat-card"
* 覆盖以下重复模式:
* - attendance-stats-cards / error-book-stats-cards / elective-stats-cards
* - practice-stats-cards / practice-overview-stats-cards
* - grade-stats-card / analytics-stats-cards
* - analytics-stats-cards
*/
interface StatItem {
/** 卡片标题 */
@@ -33,18 +33,19 @@ interface StatItem {
interface StatsGridProps {
/** 统计项列表 */
items: StatItem[]
/** 列数(默认响应式mobile=1, md=2, lg=4 */
columns?: 2 | 3 | 4
/** 列数(默认 4mobile=1, md=2, lg=N */
columns?: 2 | 3 | 4 | 5
/** 加载态 */
isLoading?: boolean
/** 容器额外类名 */
className?: string
}
const columnsClass: Record<2 | 3 | 4, string> = {
const columnsClass: Record<2 | 3 | 4 | 5, 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",
5: "grid-cols-1 md:grid-cols-2 lg:grid-cols-5",
}
export function StatsGrid({

5
test-heredoc.txt Normal file
View File

@@ -0,0 +1,5 @@
test backslash: \\
test newline: \n
test quotes: " hello\
test backtick: \
end

1
test.txt Normal file
View File

@@ -0,0 +1 @@
hello world

1
tsc-p3-10.txt Normal file
View File

@@ -0,0 +1 @@
src/shared/db/schema.ts(1078,14): error TS2304: Cannot find name 'integer'.

0
tsc_output.txt Normal file
View File

4212
vitest_announcements.txt Normal file

File diff suppressed because it is too large Load Diff