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 无新增错误
@@ -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> nodeId: <span style="color:#10b981;">string</span>; <span style="color:#64748b;">// 关联的节点 ID</span></div>
|
||||
<div> type: <span style="color:#10b981;">"point"</span> | <span style="color:#10b981;">"range"</span>; <span style="color:#64748b;">// 点锚点 or 范围锚点</span></div>
|
||||
<div> start: <span style="color:#10b981;">number</span>; <span style="color:#64748b;">// 课文纯文本偏移量(字符)</span></div>
|
||||
<div> end?: <span style="color:#10b981;">number</span>; <span style="color:#64748b;">// range 锚点的结束偏移</span></div>
|
||||
<div> 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> version: <span style="color:#10b981;">3</span>; <span style="color:#64748b;">// 升级到 v3</span></div>
|
||||
<div> nodes: <span style="color:#3b82f6;">LessonPlanNode</span>[];</div>
|
||||
<div> edges: <span style="color:#3b82f6;">LessonPlanEdge</span>[]; <span style="color:#64748b;">// 保留:节点间连线</span></div>
|
||||
<div> 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>
|
||||
@@ -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> type: <span style="color:#10b981;">"textbook_content"</span>; <span style="color:#64748b;">// 新增节点类型</span></div>
|
||||
<div> data: {</div>
|
||||
<div> chapterId: <span style="color:#10b981;">string</span>; <span style="color:#64748b;">// 关联教材章节</span></div>
|
||||
<div> content: <span style="color:#10b981;">string</span>; <span style="color:#64748b;">// Markdown 正文(缓存)</span></div>
|
||||
<div> zoom: <span style="color:#10b981;">number</span>; <span style="color:#64748b;">// 缩放比例 0.5-2.0</span></div>
|
||||
<div> };</div>
|
||||
<div> position: { x: <span style="color:#10b981;">number</span>; y: <span style="color:#10b981;">number</span> }; <span style="color:#64748b;">// 固定位置(不可拖动)</span></div>
|
||||
<div> 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> type: <span style="color:#10b981;">"anchor"</span>; <span style="color:#64748b;">// 锚点连线(vs "flow" 流程连线)</span></div>
|
||||
<div> source: <span style="color:#10b981;">string</span>; <span style="color:#64748b;">// 节点 ID</span></div>
|
||||
<div> target: <span style="color:#10b981;">string</span>; <span style="color:#64748b;">// 正文节点 ID</span></div>
|
||||
<div> 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> version: <span style="color:#10b981;">3</span>;</div>
|
||||
<div> nodes: <span style="color:#3b82f6;">LessonPlanNode</span>[]; <span style="color:#64748b;">// 含 1 个 textbook_content + N 个教学节点</span></div>
|
||||
<div> 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>创建 AnchorEdge,source=节点, 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 anchor),targetHandle="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>
|
||||
@@ -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>
|
||||
@@ -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> id: <span style="color:#10b981;">string</span>; <span style="color:#64748b;">// 占位符 ID</span></div>
|
||||
<div> nodeId: <span style="color:#10b981;">string</span>; <span style="color:#64748b;">// 关联的节点 ID</span></div>
|
||||
<div> offset: <span style="color:#10b981;">number</span>; <span style="color:#64748b;">// 在正文纯文本中的字符偏移量</span></div>
|
||||
<div> label: <span style="color:#10b981;">string</span>; <span style="color:#64748b;">// 显示的标记("①" / "💡" / "●" / "【1】")</span></div>
|
||||
<div> 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> content: <span style="color:#10b981;">string</span>, <span style="color:#64748b;">// Markdown 原文</span></div>
|
||||
<div> placeholders: <span style="color:#3b82f6;">ContentPlaceholder</span>[]</div>
|
||||
<div>): <span style="color:#10b981;">string</span> {</div>
|
||||
<div> <span style="color:#64748b;">// 按 offset 排序,在对应位置插入占位符标记</span></div>
|
||||
<div> <span style="color:#64748b;">// 渲染为 <span class="placeholder" data-node-id="xxx">①</span></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>
|
||||
@@ -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> <span style="color:#10b981;">background-color</span>: <span style="color:#f59e0b;">var(--node-color)</span>; <span style="color:#64748b;">// 节点颜色</span></div>
|
||||
<div> <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> <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> <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> <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> id: <span style="color:#10b981;">string</span>;</div>
|
||||
<div> nodeId: <span style="color:#10b981;">string</span>; <span style="color:#64748b;">// 关联的节点</span></div>
|
||||
<div> type: <span style="color:#10b981;">"range"</span> | <span style="color:#10b981;">"point"</span>; <span style="color:#64748b;">// 两种锚定方式</span></div>
|
||||
<div> start: <span style="color:#10b981;">number</span>; <span style="color:#64748b;">// 正文纯文本偏移量</span></div>
|
||||
<div> end?: <span style="color:#10b981;">number</span>; <span style="color:#64748b;">// range 锚定的结束偏移(point 无)</span></div>
|
||||
<div> 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> <span style="color:#f59e0b;">if</span> (anchor.type === <span style="color:#10b981;">"range"</span>) {</div>
|
||||
<div> <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> } <span style="color:#f59e0b;">else</span> { <span style="color:#64748b;">// point</span></div>
|
||||
<div> <span style="color:#f59e0b;">return</span> { opacity: isActive ? <span style="color:#f59e0b;">1</span> : <span style="color:#f59e0b;">0.3</span> };</div>
|
||||
<div> }</div>
|
||||
<div>}</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1 @@
|
||||
{"reason":"idle timeout","timestamp":1782143663726}
|
||||
@@ -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
@@ -0,0 +1 @@
|
||||
src/app/(dashboard)/teacher/textbooks/error.tsx(3,10): error TS2305: Module '"@/shared/components/route-error"' has no exported member 'RouteError'.
|
||||
BIN
bugs/screenshots/announcements.png
Normal file
|
After Width: | Height: | Size: 63 KiB |
BIN
bugs/screenshots/login_failure.png
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
bugs/screenshots/messages.png
Normal file
|
After Width: | Height: | Size: 84 KiB |
BIN
bugs/screenshots/teacher_attendance_error.png
Normal file
|
After Width: | Height: | Size: 82 KiB |
BIN
bugs/screenshots/teacher_attendance_sheet_error.png
Normal file
|
After Width: | Height: | Size: 75 KiB |
BIN
bugs/screenshots/teacher_attendance_stats_error.png
Normal file
|
After Width: | Height: | Size: 107 KiB |
BIN
bugs/screenshots/teacher_classes.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
bugs/screenshots/teacher_classes_error.png
Normal file
|
After Width: | Height: | Size: 76 KiB |
BIN
bugs/screenshots/teacher_classes_my_error.png
Normal file
|
After Width: | Height: | Size: 76 KiB |
BIN
bugs/screenshots/teacher_classes_schedule_error.png
Normal file
|
After Width: | Height: | Size: 75 KiB |
BIN
bugs/screenshots/teacher_classes_students_error.png
Normal file
|
After Width: | Height: | Size: 80 KiB |
BIN
bugs/screenshots/teacher_course-plans_error.png
Normal file
|
After Width: | Height: | Size: 72 KiB |
BIN
bugs/screenshots/teacher_dashboard_error.png
Normal file
|
After Width: | Height: | Size: 125 KiB |
BIN
bugs/screenshots/teacher_diagnostic.png
Normal file
|
After Width: | Height: | Size: 56 KiB |
BIN
bugs/screenshots/teacher_diagnostic_error.png
Normal file
|
After Width: | Height: | Size: 76 KiB |
BIN
bugs/screenshots/teacher_elective_error.png
Normal file
|
After Width: | Height: | Size: 76 KiB |
BIN
bugs/screenshots/teacher_error-book.png
Normal file
|
After Width: | Height: | Size: 78 KiB |
BIN
bugs/screenshots/teacher_error-book_error.png
Normal file
|
After Width: | Height: | Size: 71 KiB |
BIN
bugs/screenshots/teacher_exams_all_error.png
Normal file
|
After Width: | Height: | Size: 114 KiB |
BIN
bugs/screenshots/teacher_exams_create_error.png
Normal file
|
After Width: | Height: | Size: 129 KiB |
BIN
bugs/screenshots/teacher_exams_error.png
Normal file
|
After Width: | Height: | Size: 114 KiB |
BIN
bugs/screenshots/teacher_grades.png
Normal file
|
After Width: | Height: | Size: 81 KiB |
BIN
bugs/screenshots/teacher_grades_analytics_error.png
Normal file
|
After Width: | Height: | Size: 98 KiB |
BIN
bugs/screenshots/teacher_grades_entry.png
Normal file
|
After Width: | Height: | Size: 81 KiB |
BIN
bugs/screenshots/teacher_grades_stats.png
Normal file
|
After Width: | Height: | Size: 81 KiB |
BIN
bugs/screenshots/teacher_homework_assignments_create_error.png
Normal file
|
After Width: | Height: | Size: 71 KiB |
BIN
bugs/screenshots/teacher_homework_assignments_error.png
Normal file
|
After Width: | Height: | Size: 58 KiB |
BIN
bugs/screenshots/teacher_homework_error.png
Normal file
|
After Width: | Height: | Size: 115 KiB |
BIN
bugs/screenshots/teacher_homework_submissions.png
Normal file
|
After Width: | Height: | Size: 56 KiB |
BIN
bugs/screenshots/teacher_homework_submissions_error.png
Normal file
|
After Width: | Height: | Size: 56 KiB |
BIN
bugs/screenshots/teacher_lesson-plans_error.png
Normal file
|
After Width: | Height: | Size: 101 KiB |
BIN
bugs/screenshots/teacher_lesson-plans_new_error.png
Normal file
|
After Width: | Height: | Size: 86 KiB |
BIN
bugs/screenshots/teacher_questions_error.png
Normal file
|
After Width: | Height: | Size: 137 KiB |
BIN
bugs/screenshots/teacher_schedule-changes.png
Normal file
|
After Width: | Height: | Size: 49 KiB |
BIN
bugs/screenshots/teacher_schedule-changes_error.png
Normal file
|
After Width: | Height: | Size: 76 KiB |
BIN
bugs/screenshots/teacher_textbooks_error.png
Normal file
|
After Width: | Height: | Size: 133 KiB |
1734
build-output.txt
Normal file
4
cookies.txt
Normal 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
|
After Width: | Height: | Size: 62 KiB |
@@ -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 修复:a11y;v2-P1-4:i18n;P3 修复:NaN 日期检查 + fullScore > 0 守卫) |
|
||||
| `components/grade-record-list.tsx` | 200+ | 成绩记录列表(v2-P1-4:i18n;P3 修复:safeActionCall 包装删除操作;v4-P1-7:使用 ScoreCell;v4-P1-10:overflow-x-auto;v3-P3-2 新增:多选复选框 + 全选 + 批量删除工具栏 + 批量删除确认对话框,接入 bulkDeleteGradeRecordsAction) |
|
||||
| `components/grade-distribution-chart.tsx` | 100 | 分数分布图(v2-P1-4:i18n) |
|
||||
| `components/subject-comparison-chart.tsx` | 62 | 科目对比图(v2-P1-4:i18n) |
|
||||
| `components/class-comparison-chart.tsx` | 194 | 班级对比图(v2-P1-4:i18n;v3-P3-5 新增:显著性分析区域,基于极差和样本量的经验规则判断班级间差异,含可折叠详细分析) |
|
||||
| `components/grade-trend-chart.tsx` | 59 | 趋势图(v2-P1-4:i18n) |
|
||||
| `components/grade-record-form.tsx` | 177 | 录入表单(v2-P2-7 修复:Label htmlFor;v2-P1-4:i18n;P3 修复: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 个 Hook(BatchGradeEntryTable + 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 htmlFor;v2-P1-4 i18n;P3 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 个 Hook(BatchGradeEntryTable + 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-4:i18n;**P2-2(2026-06-25)**:补全 : JSX.Element 返回值类型) |
|
||||
| `components/student-grade-summary.tsx` | 107 | 学生成绩摘要(v2-P1-4:i18n) |
|
||||
| `components/export-button.tsx` | 79 | 导出按钮(v2-P1-4:i18n;P3 修复:safeActionCall 包装导出操作;**P2-2(2026-06-25)**:handleExport 补全 : Promise<void> 返回值类型) |
|
||||
| `components/analytics-filters.tsx` | 86+ | 分析过滤器(v2-P1-4:i18n;v3-P2 新增:exams/currentExamId/currentSemester props,添加学期和考试筛选 ChipNav) |
|
||||
| `components/stats-class-selector.tsx` | 40 | 统计班级选择器(v2-P1-4:i18n) |
|
||||
| `components/grade-stats-card.tsx` | 74 | 统计卡片(v2-P1-4:i18n) |
|
||||
| `components/class-grade-report.tsx` | 90 | 班级成绩报告(v2-P1-4:i18n) |
|
||||
| `components/grade-query-filters.tsx` | 96 | 查询过滤器(v2-P2-7 修复:Label htmlFor;v2-P1-4:i18n;**P2-2(2026-06-25)**:补全 : JSX.Element 返回值类型) |
|
||||
| `components/class-grade-report.tsx` | 130 | 班级成绩报告(v2-P1-4:i18n;**Phase 4.6 重构(2026-07-06)**:内联原 GradeStatsCard 实现,使用 StatsGrid + 8 个 StatCard(average/median/max/min/stdDev/passRate/excellentRate/count),含 count=0 空态 Card) |
|
||||
| `components/grade-record-list.tsx` | 232 | 成绩记录列表(v2-P1-4:i18n;P3 修复:safeActionCall 包装删除操作;v4-P1-7:使用 ScoreCell;v4-P1-10:overflow-x-auto;v3-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-06,Phase 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(班级模块)— 耦合最严重
|
||||
|
||||
@@ -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 × N(Next.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 + 包装版本,单测可 mock);actions 43 个文件 247 处 revalidatePath 替换为 invalidateFor;INVALIDATION_MAP 覆盖全部 28 个模块。Hook 双模式:useActionQuery 传 queryKey 走 useQuery 跨页共享缓存,不传走旧 useEffect+useState;useActionMutation 传 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.ts);grep 确认旧组件名(GradeStatsCard/GradeFilters/GradeQueryFilters/AnalyticsFilters)无业务代码残留。前次更新:缓存策略落地专项重构完成(2026-07-05),覆盖全部 28 个模块,新增 shared/lib/cache/ 9 个文件 + INVALIDATION_MAP 203 个 actionId,data-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.6:grades 模块 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.6:grades 模块 analytics-filters.tsx 已删除,analytics-grade-filters.tsx(app 层)改用 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.6:grades 模块 3 个 filter 组件迁移至 app 层并复用 FilterBar(student-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 图标的 Input),P3-b 重构从 exam/textbook/question filters 三处重复搜索框抽取",
|
||||
"purpose": "筛选栏搜索框(带 Search 图标的 Input),P3-b 重构从 exam/textbook/question filters 三处重复搜索框抽取。Phase 4.6:grades 模块 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 个自定义 Hook(BatchGradeEntryTable + 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 个自定义 Hook(BatchGradeEntryTable + 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/count),count=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
BIN
public/uploads/2026-06/ulthqxd89p6d4ikjv7m7mf3l.png
Normal file
|
After Width: | Height: | Size: 41 KiB |
BIN
public/uploads/2026-06/v86mvtnjoejz3l189hrf01ty.png
Normal file
|
After Width: | Height: | Size: 706 KiB |
BIN
public/uploads/2026-06/zofz1jxqbx3z7te0mbknw9j9.png
Normal file
|
After Width: | Height: | Size: 3.2 MiB |
BIN
screenshots/01-login.png
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
screenshots/02-form-filled.png
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
screenshots/03-after-login.png
Normal file
|
After Width: | Height: | Size: 31 KiB |
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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} />
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
{/* 学科错题分布图(仅在"全部学科"视图下显示) */}
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
// 多子女:每个子女一张卡片
|
||||
|
||||
@@ -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"
|
||||
|
||||
/**
|
||||
|
||||
@@ -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>
|
||||
@@ -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">
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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-1:subjects 改为通过 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"))
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
{/* 班级错题对比图(仅在"全部班级"视图下显示) */}
|
||||
|
||||
@@ -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>
|
||||
@@ -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" }))}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
124
src/modules/grades/components/batch-grade-entry-selectors.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
56
src/modules/grades/components/batch-grade-entry-toolbar.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
238
src/modules/grades/components/grade-record-detail.tsx
Normal 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 ?? "",
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
199
src/modules/grades/components/grade-record-row.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -7,7 +7,6 @@ import { cn } from "@/shared/lib/utils"
|
||||
*
|
||||
* 覆盖以下重复模式:
|
||||
* - AttendanceStatsCard 内部 StatItem
|
||||
* - GradeStatsCard 内部 StatItem
|
||||
*
|
||||
* 结构:rounded-lg border bg-card 容器,含 label + icon + value + hint
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
/** 列数(默认 4:mobile=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
@@ -0,0 +1,5 @@
|
||||
test backslash: \\
|
||||
test newline: \n
|
||||
test quotes: " hello\
|
||||
test backtick: \
|
||||
end
|
||||
1
tsc-p3-10.txt
Normal file
@@ -0,0 +1 @@
|
||||
src/shared/db/schema.ts(1078,14): error TS2304: Cannot find name 'integer'.
|
||||