導師的數位分身!「任務指派」與「表現追蹤」自製雙神器,開啟班級管理新紀元
- 1天前
- 讀畢需時 33 分鐘
一、 前言:導師的三頭六臂,從數位轉型開始
在繁忙的國小教學現場,導師不僅要準備教學內容,更要管理班級的大小庶務與追蹤數十位學生的學習狀態。過去,我們習慣用密密麻麻的紙本登記簿、便利貼或口頭交辦,但資訊往往破碎、難以即時彙整,且容易造成遺漏。
為了徹底解決這些痛點,我結合 AI 技術(Gemini) 開發了兩款互補的班級經營 APP:一款負責「事」的指派與掌握(The Delegator),一款負責「人」的表現與追蹤(The Tracker)。這套「雙核心系統」將瑣事自動化,讓班級經營變得透明且高效。

二、 系統深蹲:兩款 APP 核心功能全解析
1. 【The Delegator 班級小幫手】—— 班級任務的智慧管家
這款 APP 的設計核心是「高效、公平與透明」,專門處理班級的例行公事與臨時任務。
老師儀表板: 老師的中央指揮中心,即時顯示各幹部(如班長、圖書股長)的進度條。透過顏色標記(已完成、未完成),瞬間掌握全班做事進度。

學生打卡區: 學生端介面簡潔,清楚列出個人負責任務(如:整理圖書櫃),完成後點擊勾選即可即時回報。

常態任務設定: 老師能彈性建立每日或每週的例行任務,並分派給指定學生,系統會自動重置進度。

命運轉盤: 針對臨時公事(如:搬器材),內建篩選功能(自動排除請假或已有職務者),杜絕「每次都找我」的爭議。

2. 【The Tracker 班級主控台】—— 表現與作業的全方位追蹤
如果 Delegator 是處理「事務」,那麼 The Tracker 就是老師的「眼與耳」,負責記錄學生在各科目的課堂表現與作業品質。
導師主控台: 採用卡片式設計顯示學生頭像與總點數,右側設有「未交總覽快訊」與「最新動態」,誰表現好、誰缺作業,一目了然。

全班狀況統整表: 將數據表格化,完整呈現總分、缺交清單(如:生字乙本)及行為紀錄,是親師溝通與期末評語的最佳依據。

靈活系統設定: 老師可自定義學生名單、新增教學情境(如:國語課、數學課、早自修),確保紀錄與教學進度百分之百契合。


模擬科任老師視角: 貼心的行動化介面,支援「批次給分」,讓科任老師或班級幹部能快速紀錄,課堂互動不中斷。


三、 如何將這套工具帶進「你的」教室?
這就是 AI 時代最迷人的地方:你不必懂程式碼,也能成為開發者! 請按照以下步驟,將這兩款 APP 變為你的專屬版本:
第一步:獲取原始碼
點擊我分享的 Gemini 對話連結(The Delegator 連結 / The Tracker 連結),找到包含程式碼的區塊,點擊右上角的「複製」。
第二步:利用 Gemini 進行「無痛修改」
開啟你的 Gemini 介面,貼上程式碼並輸入你的需求,讓 AI 當你的工程師:
範例指令:「這是我同事分享的班級經營 APP 代碼,請幫我把名單改為:王小明、林小華...,並將科目名稱改為:國語、數學、英文。」
第三步:存檔為網頁格式 (關鍵!)
在電腦桌面按右鍵,新增一個「文字文件 (.txt)」。
打開文件,貼上 Gemini 修改後的代碼。
點擊「另存新檔」,將檔名改為 班級主控台.html(副檔名一定要是 .html)。
編碼選擇 「UTF-8」 後存檔。
第四步:設置桌面捷徑
直接雙擊桌面上的這個圖示,APP 就會在瀏覽器中運行。你可以把它存在隨身碟或 Google Drive,在任何教室電腦打開就能立刻開始一天的管理!
四、 結語:讓科技賦能,把時間還給教育
開發這兩款 APP 的初衷,是為了將老師從瑣碎的「行政登記」中解放出來。當數據追蹤自動化,我們就有更多的心力去觀察學生的情緒、引導他們的行為,並專注於教學內容的深度。
科技不應是負擔,而應是老師最強大的後盾。 歡迎大家嘗試自製最適合你班級的管理神器,讓我們在數位教學的路上一起進化!
第一個程式碼:
import React, { useState, useEffect, useRef } from 'react';
import { CheckCircle2, Circle, AlertCircle, ChevronDown, ChevronUp, Settings2, Users, Check, RotateCcw, X, Volume2, Trash2, Plus, UserPlus, ClipboardList, LayoutGrid, List } from 'lucide-react';
// --- Mock Data 假資料 ---
const initialStudents = [
{ id: 1, name: '王小明', avatar: '👦🏻', role: '班長', absent: false },
{ id: 2, name: '林小華', avatar: '👧🏻', role: '圖書股長', absent: false },
{ id: 3, name: '陳阿強', avatar: '👦🏽', role: '風紀股長', absent: false },
{ id: 4, name: '李美美', avatar: '👧🏼', role: '衛生股長', absent: false },
{ id: 5, name: '張胖虎', avatar: '👦🏻', role: '器材股長', absent: false },
{ id: 6, name: '黃小夫', avatar: '👦🏼', role: '無', absent: false },
{ id: 7, name: '吳靜香', avatar: '👧🏻', role: '英文小老師', absent: false },
{ id: 8, name: '劉大雄', avatar: '👦🏻', role: '無', absent: false },
{ id: 9, name: '蔡杉木', avatar: '👦🏻', role: '數學小老師', absent: false },
{ id: 10, name: '周小貓', avatar: '👧🏽', role: '無', absent: true }, // 請假
];
const initialTasks = [
{ id: 101, studentId: 1, title: '帶領全班喊起立敬禮', freq: '每日', completed: true },
{ id: 102, studentId: 2, title: '整理後方圖書櫃', freq: '每週', completed: false },
{ id: 103, studentId: 3, title: '登記午休講話名單', freq: '每日', completed: true },
{ id: 104, studentId: 4, title: '檢查垃圾桶是否需清理', freq: '每日', completed: false },
{ id: 105, studentId: 5, title: '去體育室借體育器材', freq: '每日', completed: false },
{ id: 106, studentId: 7, title: '發放單字小考考卷', freq: '每日', completed: false },
{ id: 107, studentId: 9, title: '收齊數學作業本', freq: '每日', completed: true },
];
const colors = ['#FF9AA2', '#FFB7B2', '#FFDAC1', '#E2F0CB', '#B5EAD7', '#C7CEEA', '#FCE2C6', '#D4F0F0', '#E8DFF5', '#F1CBFF'];
export default function App() {
const [view, setView] = useState('teacher'); // 'teacher', 'student', 'wheel', 'manage-students', 'manage-tasks'
const [students, setStudents] = useState(initialStudents);
const [tasks, setTasks] = useState(initialTasks);
// 播放成功音效 (微互動)
const playSuccessSound = () => {
try {
const audioCtx = new (window.AudioContext || window.webkitAudioContext)();
const oscillator = audioCtx.createOscillator();
const gainNode = audioCtx.createGain();
oscillator.type = 'sine';
oscillator.frequency.setValueAtTime(523.25, audioCtx.currentTime); // C5
oscillator.frequency.exponentialRampToValueAtTime(880, audioCtx.currentTime + 0.1); // A5
gainNode.gain.setValueAtTime(0.5, audioCtx.currentTime);
gainNode.gain.exponentialRampToValueAtTime(0.01, audioCtx.currentTime + 0.3);
oscillator.connect(gainNode);
gainNode.connect(audioCtx.destination);
oscillator.start();
oscillator.stop(audioCtx.currentTime + 0.3);
} catch (e) {
console.log('Audio not supported', e);
}
};
const toggleTask = (taskId) => {
setTasks(tasks.map(t => {
if (t.id === taskId) {
if (!t.completed) playSuccessSound(); // 只有完成時播放音效
return { ...t, completed: !t.completed };
}
return t;
}));
};
return (
<div className="min-h-screen bg-slate-50 font-sans text-slate-800">
{/* 頂部導航列 */}
<header className="bg-white shadow-sm px-6 py-4 flex flex-col xl:flex-row items-center justify-between sticky top-0 z-10">
<div className="flex items-center gap-3 mb-4 xl:mb-0">
<div className="bg-blue-500 text-white p-2 rounded-xl">
<Users size={28} />
</div>
<h1 className="text-2xl font-bold text-slate-800 tracking-wide whitespace-nowrap">
The Delegator <span className="text-lg text-slate-500 font-normal">班級小幫手</span>
</h1>
</div>
<div className="flex flex-wrap justify-center gap-2 bg-slate-100 p-1 rounded-2xl">
<button
onClick={() => setView('teacher')}
className={`px-4 py-2 rounded-xl font-bold transition-all text-sm sm:text-base ${view === 'teacher' ? 'bg-white shadow text-blue-600' : 'text-slate-500 hover:bg-slate-200'}`}
>
老師儀表板
</button>
<button
onClick={() => setView('student')}
className={`px-4 py-2 rounded-xl font-bold transition-all text-sm sm:text-base ${view === 'student' ? 'bg-white shadow text-green-600' : 'text-slate-500 hover:bg-slate-200'}`}
>
學生打卡區
</button>
<button
onClick={() => setView('wheel')}
className={`px-4 py-2 rounded-xl font-bold transition-all text-sm sm:text-base ${view === 'wheel' ? 'bg-white shadow text-purple-600' : 'text-slate-500 hover:bg-slate-200'}`}
>
命運轉盤
</button>
<div className="w-px bg-slate-300 mx-1 hidden sm:block"></div>
<button
onClick={() => setView('manage-students')}
className={`px-4 py-2 rounded-xl font-bold transition-all text-sm sm:text-base flex items-center gap-1 ${view === 'manage-students' ? 'bg-white shadow text-orange-600' : 'text-slate-500 hover:bg-slate-200'}`}
>
<UserPlus size={18} /> 名單設定
</button>
<button
onClick={() => setView('manage-tasks')}
className={`px-4 py-2 rounded-xl font-bold transition-all text-sm sm:text-base flex items-center gap-1 ${view === 'manage-tasks' ? 'bg-white shadow text-pink-600' : 'text-slate-500 hover:bg-slate-200'}`}
>
<ClipboardList size={18} /> 任務設定
</button>
</div>
</header>
{/* 主內容區 */}
<main className="max-w-6xl mx-auto p-6">
{view === 'teacher' && <TeacherDashboard students={students} tasks={tasks} />}
{view === 'student' && <StudentDashboard students={students} tasks={tasks} onToggleTask={toggleTask} />}
{view === 'wheel' && <WheelOfFortune students={students} />}
{view === 'manage-students' && <ManageStudents students={students} setStudents={setStudents} />}
{view === 'manage-tasks' && <ManageTasks tasks={tasks} setTasks={setTasks} students={students} />}
</main>
</div>
);
}
// ==========================================
// 模組 1: 老師儀表板 (Teacher Dashboard)
// ==========================================
function TeacherDashboard({ students, tasks }) {
const [expandedId, setExpandedId] = useState(null);
const [viewMode, setViewMode] = useState('grid'); // 'grid' | 'table'
// 計算每個學生的任務狀態
const studentStatus = students.map(student => {
const studentTasks = tasks.filter(t => t.studentId === student.id);
const total = studentTasks.length;
const completed = studentTasks.filter(t => t.completed).length;
let status = 'none'; // 無任務
if (total > 0) {
status = completed === total ? 'done' : 'pending';
}
if (student.absent) status = 'absent';
return { ...student, tasks: studentTasks, status, total, completed };
});
return (
<div className="animate-in fade-in duration-500">
<div className="flex flex-col md:flex-row justify-between items-start md:items-end mb-6 gap-4">
<div>
<h2 className="text-3xl font-extrabold text-slate-800 mb-2">幹部任務狀態</h2>
<p className="text-slate-500">一眼掌握今日全班幹部的做事進度!</p>
</div>
<div className="flex flex-col sm:flex-row gap-3 items-start sm:items-center">
{/* 圖例 */}
<div className="flex gap-4 text-sm font-bold bg-white p-3 rounded-2xl shadow-sm border border-slate-100">
<div className="flex items-center gap-2"><span className="w-4 h-4 rounded-full bg-green-500"></span> 已完成</div>
<div className="flex items-center gap-2"><span className="w-4 h-4 rounded-full bg-red-400"></span> 未完成</div>
<div className="flex items-center gap-2"><span className="w-4 h-4 rounded-full bg-slate-300"></span> 無任務/請假</div>
</div>
{/* 視圖切換按鈕 */}
<div className="flex bg-white p-1 rounded-2xl shadow-sm border border-slate-100">
<button
onClick={() => setViewMode('grid')}
className={`p-2 rounded-xl transition-all flex items-center justify-center ${viewMode === 'grid' ? 'bg-blue-50 text-blue-600' : 'text-slate-400 hover:bg-slate-50'}`}
title="卡片模式"
>
<LayoutGrid size={20} />
</button>
<button
onClick={() => setViewMode('table')}
className={`p-2 rounded-xl transition-all flex items-center justify-center ${viewMode === 'table' ? 'bg-blue-50 text-blue-600' : 'text-slate-400 hover:bg-slate-50'}`}
title="表格模式"
>
<List size={20} />
</button>
</div>
</div>
</div>
{viewMode === 'grid' ? (
/* 卡片檢視模式 (Grid View) */
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{studentStatus.map(student => (
<div
key={student.id}
className={`bg-white rounded-3xl p-5 shadow-sm border-2 transition-all ${
student.status === 'pending' ? 'border-red-100 hover:border-red-200 hover:shadow-md' :
student.status === 'done' ? 'border-green-100' : 'border-slate-100'
}`}
>
<div className="flex justify-between items-start mb-3">
<div className="flex items-center gap-3">
<div className="text-4xl bg-slate-100 rounded-full p-2">{student.avatar}</div>
<div>
<h3 className="text-xl font-bold text-slate-800">{student.name}</h3>
<p className="text-sm font-bold text-slate-500 bg-slate-100 px-2 py-0.5 rounded-lg inline-block mt-1">
{student.role}
</p>
</div>
</div>
{/* 紅綠燈指示器 */}
<div className={`w-6 h-6 rounded-full flex items-center justify-center ${
student.status === 'done' ? 'bg-green-500 text-white shadow-inner' :
student.status === 'pending' ? 'bg-red-400 animate-pulse text-white' :
'bg-slate-200'
}`}>
{student.status === 'done' && <Check size={14} strokeWidth={4} />}
{student.status === 'pending' && <AlertCircle size={14} strokeWidth={3} />}
</div>
</div>
{student.status === 'absent' ? (
<div className="mt-4 p-3 bg-slate-50 rounded-xl text-center text-slate-400 font-bold">
今日請假
</div>
) : student.total > 0 ? (
<div className="mt-4">
<div className="flex justify-between text-sm font-bold text-slate-500 mb-1">
<span>進度: {student.completed}/{student.total}</span>
<span>{Math.round((student.completed / student.total) * 100)}%</span>
</div>
<div className="w-full bg-slate-100 rounded-full h-3 mb-3 overflow-hidden">
<div
className={`h-3 rounded-full transition-all duration-1000 ${student.status === 'done' ? 'bg-green-500' : 'bg-red-400'}`}
style={{ width: `${(student.completed / student.total) * 100}%` }}
></div>
</div>
{student.status === 'pending' && (
<button
onClick={() => setExpandedId(expandedId === student.id ? null : student.id)}
className="w-full text-center py-2 text-sm font-bold text-blue-500 hover:bg-blue-50 rounded-xl transition-colors flex items-center justify-center gap-1"
>
{expandedId === student.id ? '收起明細' : '查看缺漏任務'}
{expandedId === student.id ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
</button>
)}
{/* 展開未完成任務明細 */}
{expandedId === student.id && student.status === 'pending' && (
<ul className="mt-2 space-y-2 animate-in slide-in-from-top-2">
{student.tasks.filter(t => !t.completed).map(task => (
<li key={task.id} className="text-sm bg-red-50 text-red-700 p-2 rounded-lg flex items-start gap-2">
<AlertCircle size={16} className="shrink-0 mt-0.5" />
<span>{task.title}</span>
</li>
))}
</ul>
)}
</div>
) : (
<div className="mt-4 p-3 bg-slate-50 rounded-xl text-center text-slate-400 font-bold">
無指派任務
</div>
)}
</div>
))}
</div>
) : (
/* 表格檢視模式 (Table View) */
<div className="bg-white rounded-3xl shadow-sm border border-slate-200 overflow-x-auto animate-in slide-in-from-bottom-2">
<table className="w-full text-left border-collapse min-w-[800px]">
<thead>
<tr className="bg-slate-50 border-b border-slate-200 text-slate-500 text-sm">
<th className="py-4 px-6 font-bold w-20 text-center">座號</th>
<th className="py-4 px-6 font-bold w-48">學生</th>
<th className="py-4 px-6 font-bold w-32">職位</th>
<th className="py-4 px-6 font-bold w-32 text-center">狀態</th>
<th className="py-4 px-6 font-bold">任務進度與缺漏</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{studentStatus.map(student => (
<tr key={student.id} className="hover:bg-slate-50 transition-colors">
<td className="py-3 px-6 text-center text-slate-500 font-bold">{student.id}</td>
<td className="py-3 px-6">
<div className="flex items-center gap-3">
<span className="text-2xl">{student.avatar}</span>
<span className="font-bold text-slate-800 text-lg">{student.name}</span>
</div>
</td>
<td className="py-3 px-6">
<span className="text-sm font-bold text-slate-600 bg-slate-100 px-2 py-1 rounded-lg">
{student.role}
</span>
</td>
<td className="py-3 px-6 text-center">
{student.status === 'done' && <span className="inline-flex items-center gap-1 bg-green-100 text-green-700 px-3 py-1 rounded-full text-sm font-bold"><CheckCircle2 size={16}/> 已完成</span>}
{student.status === 'pending' && <span className="inline-flex items-center gap-1 bg-red-100 text-red-600 px-3 py-1 rounded-full text-sm font-bold animate-pulse"><AlertCircle size={16}/> 未完成</span>}
{student.status === 'absent' && <span className="inline-flex items-center gap-1 bg-amber-100 text-amber-700 px-3 py-1 rounded-full text-sm font-bold">已請假</span>}
{student.status === 'none' && <span className="inline-flex items-center gap-1 bg-slate-100 text-slate-500 px-3 py-1 rounded-full text-sm font-bold">無任務</span>}
</td>
<td className="py-3 px-6">
{student.status === 'absent' || student.status === 'none' ? (
<span className="text-slate-400 font-bold">-</span>
) : (
<div className="flex items-center gap-4">
<div className="flex items-center gap-2 min-w-[60px]">
<span className="text-sm font-bold text-slate-700">{student.completed}/{student.total}</span>
</div>
{/* 顯示未完成的任務標籤 */}
{student.status === 'pending' && (
<div className="flex flex-wrap gap-2">
{student.tasks.filter(t => !t.completed).map(task => (
<span key={task.id} className="bg-red-50 border border-red-100 text-red-600 text-xs px-2 py-1 rounded-md font-bold flex items-center gap-1">
<X size={12} /> {task.title}
</span>
))}
</div>
)}
{/* 顯示已完成狀態 */}
{student.status === 'done' && (
<span className="text-sm text-green-500 font-bold flex items-center gap-1">
<Check size={16} /> 任務皆已完成
</span>
)}
</div>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}
// ==========================================
// 模組 2: 學生打卡區 (Student Dashboard)
// ==========================================
function StudentDashboard({ students, tasks, onToggleTask }) {
// 建立包含學生資訊的任務列表
const enrichedTasks = tasks.map(task => {
const student = students.find(s => s.id === task.studentId);
return { ...task, student };
}).filter(t => !t.student?.absent); // 排除請假的學生任務
return (
<div className="animate-in fade-in duration-500 max-w-3xl mx-auto">
<div className="text-center mb-8">
<h2 className="text-4xl font-extrabold text-slate-800 mb-3">任務打卡看板</h2>
<p className="text-lg text-slate-500">做完自己的工作了嗎?點擊圓圈完成打卡!</p>
</div>
<div className="space-y-4">
{enrichedTasks.map(task => (
<div
key={task.id}
onClick={() => onToggleTask(task.id)}
className={`flex items-center p-4 sm:p-6 rounded-3xl border-2 cursor-pointer transition-all duration-300 transform hover:scale-[1.01] ${
task.completed
? 'bg-slate-50 border-slate-200 opacity-60'
: 'bg-white border-blue-100 shadow-sm hover:shadow-md hover:border-blue-300'
}`}
>
{/* 打勾按鈕區 */}
<div className="shrink-0 mr-4 sm:mr-6">
<button className={`w-10 h-10 sm:w-14 sm:h-14 rounded-full flex items-center justify-center transition-all duration-300 ${
task.completed ? 'bg-green-500 scale-110' : 'bg-slate-100 hover:bg-slate-200'
}`}>
{task.completed ? (
<CheckCircle2 className="text-white w-8 h-8 sm:w-10 sm:h-10 animate-in zoom-in" />
) : (
<Circle className="text-slate-300 w-8 h-8 sm:w-10 sm:h-10" />
)}
</button>
</div>
{/* 內容區 */}
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<span className="text-2xl">{task.student?.avatar}</span>
<span className={`font-bold text-sm sm:text-base px-2 py-0.5 rounded-lg ${task.completed ? 'bg-slate-200 text-slate-500' : 'bg-blue-100 text-blue-700'}`}>
{task.student?.role} - {task.student?.name}
</span>
<span className="text-xs font-bold text-slate-400 border border-slate-200 px-2 py-0.5 rounded-full hidden sm:inline-block">
{task.freq}
</span>
</div>
<h3 className={`text-xl sm:text-2xl font-bold transition-all duration-300 ${
task.completed ? 'line-through text-slate-400' : 'text-slate-800'
}`}>
{task.title}
</h3>
</div>
</div>
))}
</div>
</div>
);
}
// ==========================================
// 模組 3: 命運轉盤 (Random Draw Module)
// ==========================================
function WheelOfFortune({ students }) {
const [taskName, setTaskName] = useState('去學務處搬器材');
const [excludeAbsent, setExcludeAbsent] = useState(true);
const [excludeBusy, setExcludeBusy] = useState(true);
const [isSpinning, setIsSpinning] = useState(false);
const [rotation, setRotation] = useState(0);
const [winner, setWinner] = useState(null);
const wheelRef = useRef(null);
// 決定候選人名單
const candidates = students.filter(s => {
if (excludeAbsent && s.absent) return false;
if (excludeBusy && s.role !== '無') return false;
return true;
});
const numSlices = candidates.length;
const sliceAngle = 360 / numSlices;
// 產生轉盤背景色 (CSS conic-gradient)
const getGradient = () => {
if (numSlices === 0) return 'gray';
let gradient = 'conic-gradient(';
for (let i = 0; i < numSlices; i++) {
const color = colors[i % colors.length];
gradient += `${color} ${i * sliceAngle}deg ${(i + 1) * sliceAngle}deg`;
if (i < numSlices - 1) gradient += ', ';
}
gradient += ')';
return gradient;
};
const spinWheel = () => {
if (isSpinning || numSlices === 0) return;
setIsSpinning(true);
setWinner(null);
// 隨機旋轉角度:至少轉 5 圈 (1800度) + 隨機角度
const spinDegrees = 1800 + Math.random() * 360;
const newRotation = rotation + spinDegrees;
setRotation(newRotation);
// 播放模擬音效
const audioCtx = new (window.AudioContext || window.webkitAudioContext)();
const playTick = (time) => {
const osc = audioCtx.createOscillator();
osc.frequency.value = 800;
osc.connect(audioCtx.destination);
osc.start(time);
osc.stop(time + 0.02);
};
// 簡單的音效節奏
for(let i=0; i<15; i++) {
try { playTick(audioCtx.currentTime + i * 0.2); } catch(e){}
}
// 動畫結束後計算得獎者 (CSS transition 設定為 4 秒)
setTimeout(() => {
setIsSpinning(false);
// 計算最終停在哪個區塊。
// 指針固定在最上方 (即 0 度)。
// 輪盤順時針轉了 newRotation 度,這表示畫面上 0 度的位置,對應到輪盤內部原本的 360 - (newRotation % 360) 度。
const normalizedRotation = newRotation % 360;
// 因為 slice 是從 0 度開始畫,所以我們要找哪個 slice 包含了 360 - normalizedRotation。
// 修正偏移:因為我們繪製文字時讓文字置中在 slice 中間,且可能存在偏移。
// 最簡單的算法:
const actualDeg = (360 - normalizedRotation) % 360;
let winningIndex = Math.floor(actualDeg / sliceAngle);
setWinner(candidates[winningIndex]);
// 歡呼音效
try {
const osc = audioCtx.createOscillator();
osc.frequency.setValueAtTime(400, audioCtx.currentTime);
osc.frequency.exponentialRampToValueAtTime(1200, audioCtx.currentTime + 0.5);
osc.connect(audioCtx.destination);
osc.start();
osc.stop(audioCtx.currentTime + 0.5);
} catch(e){}
}, 4000); // 對應 CSS 的 duration-4000
};
return (
<div className="animate-in fade-in duration-500 max-w-4xl mx-auto flex flex-col md:flex-row gap-8 items-center">
{/* 左側:設定區 */}
<div className="w-full md:w-1/3 bg-white p-6 rounded-3xl shadow-sm border border-slate-100 flex flex-col gap-6">
<div>
<h2 className="text-3xl font-extrabold text-slate-800 mb-2">命運轉盤</h2>
<p className="text-slate-500 font-bold">誰是今天的幸運兒?</p>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm font-bold text-slate-500 mb-2">臨時任務名稱</label>
<input
type="text"
value={taskName}
onChange={(e) => setTaskName(e.target.value)}
className="w-full bg-slate-50 border-2 border-slate-200 rounded-xl px-4 py-3 font-bold text-slate-800 focus:outline-none focus:border-purple-400 transition-colors"
placeholder="例如:倒垃圾、搬課本..."
/>
</div>
<div className="bg-slate-50 p-4 rounded-2xl space-y-3 border border-slate-100">
<h3 className="font-bold text-slate-700 mb-2 flex items-center gap-2"><Settings2 size={18}/> 篩選條件</h3>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={excludeAbsent}
onChange={(e) => setExcludeAbsent(e.target.checked)}
className="w-5 h-5 rounded text-purple-600 focus:ring-purple-500"
/>
<span className="font-bold text-slate-600">排除「請假未到」</span>
</label>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={excludeBusy}
onChange={(e) => setExcludeBusy(e.target.checked)}
className="w-5 h-5 rounded text-purple-600 focus:ring-purple-500"
/>
<span className="font-bold text-slate-600">排除「已有職務者」</span>
</label>
</div>
<div className="text-sm font-bold text-purple-600 bg-purple-50 p-3 rounded-xl text-center">
目前符合條件人數:{numSlices} 人
</div>
</div>
<button
onClick={spinWheel}
disabled={isSpinning || numSlices === 0}
className={`mt-auto w-full py-4 rounded-2xl font-extrabold text-xl transition-all transform active:scale-95 ${
isSpinning || numSlices === 0
? 'bg-slate-200 text-slate-400 cursor-not-allowed'
: 'bg-gradient-to-r from-purple-500 to-pink-500 text-white shadow-lg hover:shadow-xl hover:from-purple-400 hover:to-pink-400'
}`}
>
{isSpinning ? '轉動中...' : '開始抽取!'}
</button>
</div>
{/* 右側:轉盤區 */}
<div className="w-full md:w-2/3 flex justify-center items-center relative py-10">
{/* 指針 (置頂) */}
<div className="absolute top-4 left-1/2 -translate-x-1/2 z-10 drop-shadow-lg">
<div className="w-0 h-0 border-l-[20px] border-r-[20px] border-t-[40px] border-l-transparent border-r-transparent border-t-slate-800"></div>
</div>
{/* 轉盤本體 */}
<div className="relative w-80 h-80 sm:w-96 sm:h-96">
{numSlices > 0 ? (
<div
ref={wheelRef}
className="w-full h-full rounded-full border-8 border-white shadow-2xl relative overflow-hidden"
style={{
background: getGradient(),
transform: `rotate(${rotation}deg)`,
transition: 'transform 4s cubic-bezier(0.15, 0.85, 0.35, 1)'
}}
>
{candidates.map((candidate, index) => {
// 計算每個名字在輪盤上的旋轉角度
const textRotation = (index * sliceAngle) + (sliceAngle / 2);
return (
<div
key={candidate.id}
className="absolute w-full h-full top-0 left-0 flex items-start justify-center pt-8"
style={{ transform: `rotate(${textRotation}deg)` }}
>
<span className="font-black text-xl text-slate-800 drop-shadow-md whitespace-nowrap" style={{ transform: 'rotate(90deg)', transformOrigin: 'center 40px' }}>
{candidate.avatar} {candidate.name}
</span>
</div>
);
})}
</div>
) : (
<div className="w-full h-full rounded-full border-8 border-white shadow-xl bg-slate-100 flex items-center justify-center text-slate-400 font-bold text-xl">
無人符合條件
</div>
)}
{/* 中心圓點 */}
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-16 h-16 bg-white rounded-full shadow-lg border-4 border-purple-100 flex items-center justify-center">
<span className="text-2xl">🎯</span>
</div>
</div>
</div>
{/* 恭喜中獎彈窗 (Modal) */}
{winner && (
<div className="fixed inset-0 bg-slate-900/40 backdrop-blur-sm flex items-center justify-center z-50 p-4 animate-in fade-in">
<div className="bg-white rounded-3xl p-8 max-w-sm w-full text-center shadow-2xl animate-in zoom-in-95 bounce">
<div className="text-6xl mb-4 animate-bounce">{winner.avatar}</div>
<h3 className="text-3xl font-extrabold text-slate-800 mb-2">恭喜 {winner.name}!</h3>
<p className="text-lg font-bold text-purple-600 mb-6 bg-purple-50 py-3 rounded-xl border border-purple-100">
獲得任務:<br/>「{taskName}」
</p>
<button
onClick={() => setWinner(null)}
className="w-full bg-slate-800 text-white font-bold py-3 rounded-xl hover:bg-slate-700 transition-colors"
>
確定
</button>
</div>
</div>
)}
</div>
);
}
// ==========================================
// 模組 4: 學生名單管理 (Manage Students)
// ==========================================
function ManageStudents({ students, setStudents }) {
const [newName, setNewName] = useState('');
const [newAvatar, setNewAvatar] = useState('👦🏻');
const [newRole, setNewRole] = useState('無');
const handleAddStudent = (e) => {
e.preventDefault();
if (!newName.trim()) return;
const newId = students.length > 0 ? Math.max(...students.map(s => s.id)) + 1 : 1;
setStudents([...students, { id: newId, name: newName, avatar: newAvatar, role: newRole, absent: false }]);
setNewName('');
setNewRole('無');
};
const handleDelete = (id) => {
setStudents(students.filter(s => s.id !== id));
};
const toggleAbsent = (id) => {
setStudents(students.map(s => s.id === id ? { ...s, absent: !s.absent } : s));
};
return (
<div className="animate-in fade-in duration-500 max-w-4xl mx-auto">
<div className="mb-8">
<h2 className="text-3xl font-extrabold text-slate-800 mb-2">班級名單設定</h2>
<p className="text-slate-500">在這裡新增學生、指派幹部職位,或標記請假狀態。</p>
</div>
{/* 新增學生表單 */}
<form onSubmit={handleAddStudent} className="bg-white p-6 rounded-3xl shadow-sm border border-slate-100 flex flex-col sm:flex-row gap-4 mb-8 items-end">
<div className="w-full sm:w-1/4">
<label className="block text-sm font-bold text-slate-500 mb-2">頭像 (Emoji)</label>
<input type="text" value={newAvatar} onChange={(e) => setNewAvatar(e.target.value)} className="w-full bg-slate-50 border-2 border-slate-200 rounded-xl px-4 py-2 font-bold focus:border-orange-400 focus:outline-none" />
</div>
<div className="w-full sm:w-1/3">
<label className="block text-sm font-bold text-slate-500 mb-2">姓名</label>
<input type="text" placeholder="例如:林小華" value={newName} onChange={(e) => setNewName(e.target.value)} className="w-full bg-slate-50 border-2 border-slate-200 rounded-xl px-4 py-2 font-bold focus:border-orange-400 focus:outline-none" />
</div>
<div className="w-full sm:w-1/3">
<label className="block text-sm font-bold text-slate-500 mb-2">幹部職位</label>
<input type="text" placeholder="例如:班長、無" value={newRole} onChange={(e) => setNewRole(e.target.value)} className="w-full bg-slate-50 border-2 border-slate-200 rounded-xl px-4 py-2 font-bold focus:border-orange-400 focus:outline-none" />
</div>
<button type="submit" className="w-full sm:w-auto px-6 py-2.5 bg-orange-500 hover:bg-orange-600 text-white font-bold rounded-xl transition-colors flex items-center justify-center gap-2">
<Plus size={20} /> 新增
</button>
</form>
{/* 學生名單列表 */}
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
{students.map(student => (
<div key={student.id} className={`bg-white p-4 rounded-2xl border-2 flex items-center justify-between transition-all ${student.absent ? 'border-slate-200 opacity-60 bg-slate-50' : 'border-slate-100 shadow-sm'}`}>
<div className="flex items-center gap-3">
<div className="text-3xl">{student.avatar}</div>
<div>
<div className="font-bold text-slate-800">{student.name}</div>
<div className="text-xs font-bold text-slate-500 bg-slate-100 px-2 py-0.5 rounded-md inline-block mt-1">{student.role}</div>
</div>
</div>
<div className="flex gap-2">
<button onClick={() => toggleAbsent(student.id)} className={`px-2 py-1 text-xs font-bold rounded-lg transition-colors ${student.absent ? 'bg-amber-100 text-amber-700' : 'bg-slate-100 text-slate-500 hover:bg-slate-200'}`}>
{student.absent ? '已請假' : '設為請假'}
</button>
<button onClick={() => handleDelete(student.id)} className="p-1.5 text-red-400 hover:bg-red-50 hover:text-red-600 rounded-lg transition-colors">
<Trash2 size={18} />
</button>
</div>
</div>
))}
</div>
</div>
);
}
// ==========================================
// 模組 5: 任務清單管理 (Manage Tasks)
// ==========================================
function ManageTasks({ tasks, setTasks, students }) {
const [newTitle, setNewTitle] = useState('');
const [newStudentId, setNewStudentId] = useState('');
const [newFreq, setNewFreq] = useState('每日');
const [startTime, setStartTime] = useState('08:00');
const [endTime, setEndTime] = useState('08:30');
const handleAddTask = (e) => {
e.preventDefault();
if (!newTitle.trim() || !newStudentId) return;
const newId = tasks.length > 0 ? Math.max(...tasks.map(t => t.id)) + 1 : 1;
// 如果選擇自訂時間,將頻率文字轉換為具體時間段
const finalFreq = newFreq === '自訂時間' ? `${startTime} ~ ${endTime}` : newFreq;
setTasks([...tasks, {
id: newId,
title: newTitle,
studentId: Number(newStudentId),
freq: finalFreq,
completed: false
}]);
setNewTitle('');
};
const handleDelete = (id) => {
setTasks(tasks.filter(t => t.id !== id));
};
return (
<div className="animate-in fade-in duration-500 max-w-4xl mx-auto">
<div className="mb-8">
<h2 className="text-3xl font-extrabold text-slate-800 mb-2">常態任務設定</h2>
<p className="text-slate-500">建立班級的例行公事,並分配給指定的學生或幹部。</p>
</div>
{/* 新增任務表單 */}
<form onSubmit={handleAddTask} className="bg-white p-6 rounded-3xl shadow-sm border border-slate-100 flex flex-col gap-4 mb-8">
<div className="flex flex-col md:flex-row gap-4 items-end">
<div className="w-full md:w-2/5">
<label className="block text-sm font-bold text-slate-500 mb-2">任務內容</label>
<input type="text" placeholder="例如:擦黑板、倒垃圾..." value={newTitle} onChange={(e) => setNewTitle(e.target.value)} className="w-full bg-slate-50 border-2 border-slate-200 rounded-xl px-4 py-2 font-bold focus:border-pink-400 focus:outline-none" />
</div>
<div className="w-full md:w-1/4">
<label className="block text-sm font-bold text-slate-500 mb-2">負責學生</label>
<select value={newStudentId} onChange={(e) => setNewStudentId(e.target.value)} className="w-full bg-slate-50 border-2 border-slate-200 rounded-xl px-4 py-2 font-bold text-slate-700 focus:border-pink-400 focus:outline-none appearance-none">
<option value="" disabled>請選擇...</option>
{students.map(s => (
<option key={s.id} value={s.id}>{s.name} ({s.role})</option>
))}
</select>
</div>
<div className="w-full md:w-1/4">
<label className="block text-sm font-bold text-slate-500 mb-2">執行頻率</label>
<select value={newFreq} onChange={(e) => setNewFreq(e.target.value)} className="w-full bg-slate-50 border-2 border-slate-200 rounded-xl px-4 py-2 font-bold text-slate-700 focus:border-pink-400 focus:outline-none appearance-none">
<option value="每日">每日</option>
<option value="每週">每週</option>
<option value="早自修">早自修</option>
<option value="午休">午休</option>
<option value="自訂時間">自訂時間...</option>
<option value="單次">單次</option>
</select>
</div>
<button type="submit" className="w-full md:w-auto px-6 py-2.5 bg-pink-500 hover:bg-pink-600 text-white font-bold rounded-xl transition-colors flex items-center justify-center gap-2">
<Plus size={20} /> 新增
</button>
</div>
{/* 條件渲染:自訂時間選取器 */}
{newFreq === '自訂時間' && (
<div className="flex items-center gap-3 animate-in fade-in slide-in-from-top-2 pt-2 border-t border-slate-100">
<div className="flex items-center gap-2 bg-slate-50 border-2 border-slate-200 rounded-xl px-4 py-2">
<span className="text-sm font-bold text-slate-500">從</span>
<input type="time" value={startTime} onChange={e => setStartTime(e.target.value)} className="bg-transparent font-bold text-slate-700 focus:outline-none" />
</div>
<span className="font-bold text-slate-300">~</span>
<div className="flex items-center gap-2 bg-slate-50 border-2 border-slate-200 rounded-xl px-4 py-2">
<span className="text-sm font-bold text-slate-500">到</span>
<input type="time" value={endTime} onChange={e => setEndTime(e.target.value)} className="bg-transparent font-bold text-slate-700 focus:outline-none" />
</div>
</div>
)}
</form>
{/* 任務列表 */}
<div className="bg-white rounded-3xl border border-slate-100 shadow-sm overflow-hidden">
{tasks.length === 0 ? (
<div className="p-8 text-center text-slate-400 font-bold">目前沒有任何任務</div>
) : (
<div className="divide-y divide-slate-100">
{tasks.map(task => {
const assignedStudent = students.find(s => s.id === task.studentId);
return (
<div key={task.id} className="p-4 sm:p-5 flex items-center justify-between hover:bg-slate-50 transition-colors">
<div className="flex items-center gap-4">
<div className="w-10 h-10 rounded-full bg-pink-100 flex items-center justify-center text-pink-500 shrink-0">
<ClipboardList size={20} />
</div>
<div>
<h4 className="font-bold text-slate-800 sm:text-lg">{task.title}</h4>
<div className="flex items-center gap-2 mt-1">
<span className="text-xs font-bold text-slate-500 border border-slate-200 px-2 py-0.5 rounded-md">
{task.freq}
</span>
{assignedStudent ? (
<span className="text-sm text-slate-500">
負責人: <span className="font-bold text-slate-700">{assignedStudent.name}</span>
</span>
) : (
<span className="text-sm text-red-400 font-bold">負責人已被刪除</span>
)}
</div>
</div>
</div>
<button onClick={() => handleDelete(task.id)} className="p-2 text-red-400 hover:bg-red-50 hover:text-red-600 rounded-xl transition-colors shrink-0">
<Trash2 size={20} />
</button>
</div>
);
})}
</div>
)}
</div>
</div>
);
}第二個程式碼
import React, { useState, useEffect } from 'react';
import {
Users, LayoutGrid, ListChecks, Award, AlertCircle,
X, Check, Clock, ChevronRight, UserCircle2, BookOpen,
MessageSquare, Smartphone, Settings, Plus, Trash2,
Calendar, CheckCircle2, ClipboardList, LayoutList
} from 'lucide-react';
// --- Mock Data ---
const INITIAL_STUDENTS = [
{ id: 's1', no: 1, name: '王小明', points: 12, avatar: '👦🏻' },
{ id: 's2', no: 2, name: '陳小華', points: 8, avatar: '👧🏻' },
{ id: 's3', no: 3, name: '林雅婷', points: 15, avatar: '👧🏽' },
{ id: 's4', no: 4, name: '張建國', points: 5, avatar: '👦🏽' },
{ id: 's5', no: 5, name: '吳美麗', points: 20, avatar: '👧🏻' },
{ id: 's6', no: 6, name: '李志明', points: -2, avatar: '👦🏻' },
{ id: 's7', no: 7, name: '周杰倫', points: 10, avatar: '👦🏼' },
{ id: 's8', no: 8, name: '蔡依林', points: 18, avatar: '👧🏼' },
];
const INITIAL_ASSIGNMENTS = [
{ id: 'a1', name: '國語習作 CH5', subject: '國語課' },
{ id: 'a2', name: '數學演練 P.20-21', subject: '數學' },
{ id: 'a3', name: '家庭聯絡簿', subject: '其他' },
];
const BEHAVIOR_TAGS = [
{ id: 't1', name: '踴躍發言', type: 'positive', context: ['國語課', '綜合活動'] },
{ id: 't2', name: '干擾上課', type: 'needs_improvement', context: ['國語課', '綜合活動', '科任課', '通用'] },
{ id: 't3', name: '專心聽講', type: 'positive', context: ['國語課', '科任課', '通用'] },
{ id: 't4', name: '遲到', type: 'needs_improvement', context: ['早自修'] },
{ id: 't5', name: '主動打掃', type: 'positive', context: ['早自修', '打掃'] },
{ id: 't6', name: '未交聯絡簿', type: 'needs_improvement', context: ['早自修'] },
{ id: 't7', name: '團隊合作佳', type: 'positive', context: ['綜合活動', '通用'] },
{ id: 't8', name: '作業優良', type: 'positive', context: ['科任課', '通用'] },
{ id: 't9', name: '忘記帶學具', type: 'needs_improvement', context: ['科任課', '通用'] },
{ id: 't10', name: '安靜午休', type: 'positive', context: ['午休'] },
{ id: 't11', name: '走廊奔跑', type: 'needs_improvement', context: ['下課', '放學'] },
{ id: 't12', name: '餐具未收', type: 'needs_improvement', context: ['午餐'] },
];
const INITIAL_CONTEXTS = [
'早自修', '國語課', '數學', '英文', '生活',
'自然', '社會', '音樂', '體育', '綜合活動',
'彈性', '午餐', '午休', '下課', '放學', '打掃', '其他'
];
const ROUTINES = ['早自修', '午餐', '午休', '下課', '放學', '打掃', '其他'];
// Helper: Get today's date string YYYY-MM-DD
const getTodayString = () => {
const tzoffset = (new Date()).getTimezoneOffset() * 60000;
return (new Date(Date.now() - tzoffset)).toISOString().split('T')[0];
};
export default function App() {
// --- Global State ---
const [view, setView] = useState('teacher'); // 'teacher' | 'collaborator'
const [students, setStudents] = useState(INITIAL_STUDENTS);
const [contexts, setContexts] = useState(INITIAL_CONTEXTS);
const [assignments, setAssignments] = useState(INITIAL_ASSIGNMENTS);
const todayStr = getTodayString();
// Timeline Records
const [records, setRecords] = useState([
{ id: 'r1', studentId: 's3', type: 'tag', tagName: '主動打掃', tagType: 'positive', context: '早自修', time: '07:50', recorder: '導師' },
{ id: 'r2', studentId: 's6', type: 'tag', tagName: '遲到', tagType: 'needs_improvement', context: '早自修', time: '08:05', recorder: '導師' }
]);
// Missing Records State
const [missingRecords, setMissingRecords] = useState([
{ id: 'm1', studentId: 's2', assignmentId: null, name: '生字乙本', subject: '國語課', date: '2026-03-17' },
{ id: 'm2', studentId: 's6', assignmentId: null, name: '自然觀察紀錄表', subject: '自然', date: '2026-03-18' },
]);
// --- UI State ---
const [teacherViewMode, setTeacherViewMode] = useState('grid'); // 'grid' | 'summary'
const [activeContext, setActiveContext] = useState(contexts[0]);
const [selectedStudentId, setSelectedStudentId] = useState(null);
const [showMissingTable, setShowMissingTable] = useState(false);
// Student Panel Specific State
const [studentPanelContext, setStudentPanelContext] = useState('');
useEffect(() => {
if (selectedStudentId) {
setStudentPanelContext(activeContext);
}
}, [selectedStudentId, activeContext]);
// Settings State
const [showSettings, setShowSettings] = useState(false);
const [settingsTab, setSettingsTab] = useState('students');
const [newStudent, setNewStudent] = useState({ no: '', name: '', avatar: '👦🏻' });
const [newContext, setNewContext] = useState('');
const [newAssignment, setNewAssignment] = useState({ name: '', subject: '國語課' });
// Manual Missing Record Form State
const [manualMissing, setManualMissing] = useState({ date: todayStr, subject: '國語課', name: '' });
// Collaborator State
const [collabSelectedStudents, setCollabSelectedStudents] = useState([]);
const [collabContext, setCollabContext] = useState('科任課');
const [showToast, setShowToast] = useState(false);
// --- Actions ---
const addRecord = (studentId, type, content, isCollaborator = false, contextOverride = null) => {
const contextToUse = contextOverride || (isCollaborator ? collabContext : activeContext);
const newRecord = {
id: Date.now().toString() + Math.random(),
studentId,
type,
tagName: type === 'tag' ? content.name : null,
tagType: type === 'tag' ? content.type : null,
assignmentName: type === 'assignment' ? content.name : null,
context: contextToUse,
time: new Date().toLocaleTimeString('zh-TW', { hour: '2-digit', minute: '2-digit' }),
recorder: isCollaborator ? `${collabContext}老師` : '導師'
};
setRecords(prev => [newRecord, ...prev]);
if (type === 'tag') {
setStudents(prev => prev.map(s => s.id === studentId ? { ...s, points: s.points + (content.type === 'positive' ? 1 : -1) } : s));
}
};
const handleCollabSubmit = (tag) => {
collabSelectedStudents.forEach(studentId => {
addRecord(studentId, 'tag', tag, true);
});
setCollabSelectedStudents([]);
setShowToast(true);
setTimeout(() => setShowToast(false), 3000);
};
const handleCustomTag = (type, isCollaborator = false) => {
const customText = prompt(`請輸入自訂${type === 'positive' ? '正向' : '待改進'}行為:`);
if (customText && customText.trim()) {
if (isCollaborator) {
handleCollabSubmit({ id: `c_${Date.now()}`, name: customText.trim(), type });
} else {
addRecord(selectedStudentId, 'tag', { name: customText.trim(), type }, false, studentPanelContext);
}
}
};
// --- Missing Assignment Actions ---
const isMissingToday = (studentId, assignmentId) => {
return missingRecords.some(r => r.studentId === studentId && r.assignmentId === assignmentId && r.date === todayStr);
};
const toggleTodayAssignment = (studentId, assignment) => {
const isMissing = isMissingToday(studentId, assignment.id);
if (isMissing) {
setMissingRecords(prev => prev.filter(r => !(r.studentId === studentId && r.assignmentId === assignment.id && r.date === todayStr)));
} else {
const newMissing = { id: Date.now().toString() + Math.random(), studentId, assignmentId: assignment.id, name: assignment.name, subject: assignment.subject, date: todayStr };
setMissingRecords(prev => [...prev, newMissing]);
addRecord(studentId, 'assignment', assignment, false, studentPanelContext);
}
};
const handleAddManualMissing = (studentId) => {
if (!manualMissing.name.trim() || !manualMissing.date) return;
const newMissing = { id: Date.now().toString() + Math.random(), studentId, assignmentId: null, name: manualMissing.name.trim(), subject: manualMissing.subject, date: manualMissing.date };
setMissingRecords(prev => [...prev, newMissing]);
setManualMissing({ date: todayStr, subject: contexts[0] || '其他', name: '' });
};
const markMissingAsDone = (recordId) => {
setMissingRecords(prev => prev.filter(r => r.id !== recordId));
};
// --- Settings Actions ---
const handleAddStudent = () => {
if (!newStudent.no || !newStudent.name) return;
setStudents(prev => [...prev, { ...newStudent, id: `s${Date.now()}`, points: 0 }].sort((a, b) => a.no - b.no));
setNewStudent({ no: '', name: '', avatar: '👦🏻' });
};
const handleDeleteStudent = (id) => { if(window.confirm('確定要刪除這位學生嗎?')) setStudents(prev => prev.filter(s => s.id !== id)); };
const handleAddContext = () => {
if (!newContext.trim() || contexts.includes(newContext.trim())) return;
setContexts(prev => [...prev, newContext.trim()]);
setNewContext('');
};
const handleDeleteContext = (ctx) => {
if(window.confirm(`確定要刪除情境「${ctx}」嗎?`)) {
setContexts(prev => prev.filter(c => c !== ctx));
if (activeContext === ctx) setActiveContext(contexts[0] !== ctx ? contexts[0] : contexts[1]);
}
};
const handleAddAssignment = () => {
if (!newAssignment.name.trim()) return;
setAssignments(prev => [...prev, { id: `a${Date.now()}`, name: newAssignment.name.trim(), subject: newAssignment.subject }]);
setNewAssignment({ name: '', subject: contexts.includes('國語課') ? '國語課' : contexts[0] });
};
const handleDeleteAssignment = (id) => { if(window.confirm('確定要刪除這個作業項目嗎?')) setAssignments(prev => prev.filter(a => a.id !== id)); };
// --- Derived Data ---
const selectedStudent = students.find(s => s.id === selectedStudentId);
const getTagsForContext = (ctx) => {
const specificTags = BEHAVIOR_TAGS.filter(t => t.context.includes(ctx));
return specificTags.length > 0 ? specificTags : BEHAVIOR_TAGS.filter(t => t.context.includes('通用'));
};
const contextTags = getTagsForContext(studentPanelContext || activeContext);
const collabTags = getTagsForContext(collabContext);
const routineContexts = contexts.filter(c => ROUTINES.includes(c));
const subjectContexts = contexts.filter(c => !ROUTINES.includes(c));
const missingSummaryByStudent = missingRecords.reduce((acc, record) => {
if (!acc[record.studentId]) acc[record.studentId] = [];
acc[record.studentId].push(record);
return acc;
}, {});
// ==========================================
// COMPONENT: Missing Assignments Table Modal
// ==========================================
const renderMissingTableModal = () => {
const sortedRecords = [...missingRecords].sort((a, b) => {
if (a.date !== b.date) return new Date(a.date) - new Date(b.date);
const studentA = students.find(s => s.id === a.studentId)?.no || 999;
const studentB = students.find(s => s.id === b.studentId)?.no || 999;
return studentA - studentB;
});
return (
<div className="fixed inset-0 z-[100] flex justify-center items-center bg-slate-900/60 backdrop-blur-sm p-4">
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-4xl max-h-[90vh] flex flex-col overflow-hidden animate-in fade-in zoom-in-95 duration-200">
<div className="px-4 md:px-6 py-4 md:py-5 border-b border-slate-200 flex justify-between items-center bg-indigo-50">
<h2 className="text-xl md:text-2xl font-bold flex items-center gap-2 text-indigo-900">
<ClipboardList className="text-indigo-600" /> 缺交總表
</h2>
<button onClick={() => setShowMissingTable(false)} className="p-2 hover:bg-indigo-100 rounded-full text-indigo-400 hover:text-indigo-700 transition-colors"><X size={24} /></button>
</div>
<div className="p-4 md:p-6 flex-1 overflow-y-auto bg-slate-50">
{sortedRecords.length > 0 ? (
<div className="bg-white border border-slate-200 rounded-xl overflow-x-auto shadow-sm">
<table className="w-full text-left text-sm whitespace-nowrap min-w-[600px]">
<thead className="bg-slate-100 border-b border-slate-200 text-slate-600 font-bold">
<tr><th className="px-4 md:px-6 py-4 w-24 text-center">日期</th><th className="px-4 md:px-6 py-4 w-32">學生</th><th className="px-4 md:px-6 py-4 w-32">科目</th><th className="px-4 md:px-6 py-4">作業名稱</th><th className="px-4 md:px-6 py-4 w-32 text-center">操作</th></tr>
</thead>
<tbody className="divide-y divide-slate-100">
{sortedRecords.map((record) => {
const student = students.find(s => s.id === record.studentId);
return (
<tr key={record.id} className="hover:bg-slate-50 transition-colors group">
<td className="px-4 md:px-6 py-4 text-center"><span className={`font-medium ${record.date === todayStr ? 'text-indigo-600' : 'text-slate-500'}`}>{record.date}</span></td>
<td className="px-4 md:px-6 py-4">{student ? <div className="flex items-center gap-2"><span className="text-xl">{student.avatar}</span><span className="font-bold text-slate-800">{student.no}. {student.name}</span></div> : <span className="text-slate-400">未知</span>}</td>
<td className="px-4 md:px-6 py-4"><span className="bg-slate-100 text-slate-600 px-2.5 py-1 rounded-md text-xs font-bold border border-slate-200">{record.subject}</span></td>
<td className="px-4 md:px-6 py-4 font-bold text-red-600">{record.name}</td>
<td className="px-4 md:px-6 py-4 text-center"><button onClick={() => markMissingAsDone(record.id)} className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-green-50 hover:bg-green-500 text-green-700 hover:text-white border border-green-200 hover:border-green-500 rounded-lg text-sm font-bold transition-all"><CheckCircle2 size={16} /> <span className="hidden sm:inline">已完成</span></button></td>
</tr>
);
})}
</tbody>
</table>
</div>
) : (
<div className="flex flex-col items-center justify-center h-40 md:h-64 text-slate-400 gap-4"><CheckCircle2 size={48} className="text-green-400" /><p className="text-base md:text-lg font-bold text-slate-600">目前全班沒有任何缺交紀錄。</p></div>
)}
</div>
</div>
</div>
);
};
// ==========================================
// COMPONENT: Settings Modal
// ==========================================
const renderSettingsModal = () => (
<div className="fixed inset-0 z-[100] flex justify-center items-center bg-slate-900/40 backdrop-blur-sm p-4">
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-2xl max-h-[90vh] flex flex-col overflow-hidden animate-in fade-in zoom-in-95 duration-200">
<div className="px-4 md:px-6 py-4 border-b border-slate-100 flex justify-between items-center bg-slate-50">
<h2 className="text-lg md:text-xl font-bold flex items-center gap-2 text-slate-800"><Settings size={20} className="text-indigo-600" /> 系統設定</h2>
<button onClick={() => setShowSettings(false)} className="p-2 hover:bg-slate-200 rounded-full text-slate-500 transition-colors"><X size={20} /></button>
</div>
<div className="flex px-2 md:px-6 border-b border-slate-200 bg-white overflow-x-auto">
<button onClick={() => setSettingsTab('students')} className={`shrink-0 px-4 py-3 font-semibold text-sm border-b-2 transition-colors ${settingsTab === 'students' ? 'border-indigo-600 text-indigo-600' : 'border-transparent text-slate-500 hover:text-slate-700'}`}>學生名單管理</button>
<button onClick={() => setSettingsTab('contexts')} className={`shrink-0 px-4 py-3 font-semibold text-sm border-b-2 transition-colors ${settingsTab === 'contexts' ? 'border-indigo-600 text-indigo-600' : 'border-transparent text-slate-500 hover:text-slate-700'}`}>情境/科目管理</button>
<button onClick={() => setSettingsTab('assignments')} className={`shrink-0 px-4 py-3 font-semibold text-sm border-b-2 transition-colors ${settingsTab === 'assignments' ? 'border-indigo-600 text-indigo-600' : 'border-transparent text-slate-500 hover:text-slate-700'}`}>今日作業管理</button>
</div>
<div className="p-4 md:p-6 flex-1 overflow-y-auto bg-slate-50">
{settingsTab === 'students' && (
<div className="space-y-6">
<div className="bg-white p-4 rounded-xl border border-slate-200 shadow-sm flex flex-wrap md:flex-nowrap items-end gap-3">
<div className="w-[45%] md:w-20"><label className="block text-xs font-bold text-slate-500 mb-1">座號</label><input type="number" value={newStudent.no} onChange={e => setNewStudent({...newStudent, no: e.target.value})} className="w-full bg-slate-50 border border-slate-200 rounded-lg px-3 py-2 text-sm focus:ring-2 ring-indigo-500 outline-none" placeholder="1" /></div>
<div className="w-[45%] md:w-24"><label className="block text-xs font-bold text-slate-500 mb-1">大頭貼</label><input type="text" value={newStudent.avatar} onChange={e => setNewStudent({...newStudent, avatar: e.target.value})} className="w-full bg-slate-50 border border-slate-200 rounded-lg px-3 py-2 text-sm focus:ring-2 ring-indigo-500 outline-none text-center" /></div>
<div className="w-full md:flex-1"><label className="block text-xs font-bold text-slate-500 mb-1">姓名</label><input type="text" value={newStudent.name} onChange={e => setNewStudent({...newStudent, name: e.target.value})} className="w-full bg-slate-50 border border-slate-200 rounded-lg px-3 py-2 text-sm focus:ring-2 ring-indigo-500 outline-none" placeholder="輸入姓名" /></div>
<button onClick={handleAddStudent} className="w-full md:w-auto bg-indigo-600 hover:bg-indigo-700 text-white px-4 py-2 rounded-lg text-sm font-bold flex justify-center items-center gap-1 transition-colors h-[38px]"><Plus size={16} /> 新增</button>
</div>
<div className="bg-white border border-slate-200 rounded-xl overflow-x-auto shadow-sm">
<table className="w-full text-left text-sm min-w-[300px]">
<thead className="bg-slate-50 border-b border-slate-200 text-slate-500"><tr><th className="px-4 py-3 w-16 text-center">座號</th><th className="px-4 py-3">學生</th><th className="px-4 py-3 w-20 text-center">操作</th></tr></thead>
<tbody className="divide-y divide-slate-100">{students.map(s => (
<tr key={s.id} className="hover:bg-slate-50"><td className="px-4 py-3 text-center font-bold text-slate-400">{s.no}</td><td className="px-4 py-3 flex items-center gap-3"><span className="text-2xl">{s.avatar}</span><span className="font-bold text-slate-700">{s.name}</span></td><td className="px-4 py-3 text-center"><button onClick={() => handleDeleteStudent(s.id)} className="text-slate-400 hover:text-red-500 p-1.5"><Trash2 size={16} /></button></td></tr>
))}</tbody>
</table>
</div>
</div>
)}
{settingsTab === 'contexts' && (
<div className="space-y-6">
<div className="bg-white p-4 rounded-xl border border-slate-200 shadow-sm flex flex-col md:flex-row items-end gap-3">
<div className="w-full md:flex-1"><label className="block text-xs font-bold text-slate-500 mb-1">新增情境 / 科目名稱</label><input type="text" value={newContext} onChange={e => setNewContext(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAddContext()} className="w-full bg-slate-50 border border-slate-200 rounded-lg px-3 py-2 text-sm focus:ring-2 ring-indigo-500 outline-none" placeholder="例如:音樂課..." /></div>
<button onClick={handleAddContext} className="w-full md:w-auto bg-indigo-600 hover:bg-indigo-700 text-white px-4 py-2 rounded-lg text-sm font-bold flex justify-center items-center gap-1 transition-colors h-[38px]"><Plus size={16} /> 新增</button>
</div>
<div className="bg-white border border-slate-200 rounded-xl overflow-hidden shadow-sm">
<ul className="divide-y divide-slate-100">{contexts.map((ctx, index) => (
<li key={index} className="px-5 py-3 flex justify-between items-center hover:bg-slate-50"><span className="font-bold text-slate-700 flex items-center gap-2"><BookOpen size={16} className="text-indigo-400" />{ctx}</span><button onClick={() => handleDeleteContext(ctx)} className="text-slate-400 hover:text-red-500 p-1.5"><Trash2 size={16} /></button></li>
))}</ul>
</div>
</div>
)}
{settingsTab === 'assignments' && (
<div className="space-y-6">
<div className="bg-white p-4 rounded-xl border border-slate-200 shadow-sm flex flex-col md:flex-row items-end gap-3">
<div className="w-full md:w-1/3">
<label className="block text-xs font-bold text-slate-500 mb-1">關聯科目</label>
<select value={newAssignment.subject} onChange={e => setNewAssignment({...newAssignment, subject: e.target.value})} className="w-full bg-slate-50 border border-slate-200 rounded-lg px-3 py-2 text-sm focus:ring-2 ring-indigo-500 outline-none">
<optgroup label="日常作息">{routineContexts.map(ctx => <option key={ctx} value={ctx}>{ctx}</option>)}</optgroup>
<optgroup label="上課科目">{subjectContexts.map(ctx => <option key={ctx} value={ctx}>{ctx}</option>)}</optgroup>
</select>
</div>
<div className="w-full md:flex-1">
<label className="block text-xs font-bold text-slate-500 mb-1">作業項目</label>
<input type="text" value={newAssignment.name} onChange={e => setNewAssignment({...newAssignment, name: e.target.value})} onKeyDown={e => e.key === 'Enter' && handleAddAssignment()} className="w-full bg-slate-50 border border-slate-200 rounded-lg px-3 py-2 text-sm focus:ring-2 ring-indigo-500 outline-none" placeholder="例如:數學習作..." />
</div>
<button onClick={handleAddAssignment} className="w-full md:w-auto bg-indigo-600 hover:bg-indigo-700 text-white px-4 py-2 rounded-lg text-sm font-bold flex justify-center items-center gap-1 h-[38px]"><Plus size={16} /> 新增</button>
</div>
<div className="bg-white border border-slate-200 rounded-xl overflow-hidden shadow-sm">
<ul className="divide-y divide-slate-100">
{assignments.map((assignment) => (
<li key={assignment.id} className="px-5 py-3 flex justify-between items-center hover:bg-slate-50">
<div className="flex flex-col sm:flex-row sm:items-center gap-1 sm:gap-3">
<span className="bg-slate-100 text-slate-600 px-2 py-1 rounded text-xs font-bold w-fit">{assignment.subject}</span>
<span className="font-bold text-slate-700 text-sm">{assignment.name}</span>
</div>
<button onClick={() => handleDeleteAssignment(assignment.id)} className="text-slate-400 hover:text-red-500 p-1.5"><Trash2 size={16} /></button>
</li>
))}
</ul>
</div>
</div>
)}
</div>
</div>
</div>
);
// ==========================================
// VIEW: Teacher Dashboard (Responsive)
// ==========================================
const renderTeacherView = () => (
<div className="flex h-screen bg-slate-50 overflow-hidden font-sans">
{showSettings && renderSettingsModal()}
{showMissingTable && renderMissingTableModal()}
{/* Main Content Area */}
<div className="flex-1 flex flex-col h-full overflow-y-auto relative scroll-smooth">
{/* Responsive Header */}
<header className="bg-white border-b border-slate-200 px-4 md:px-6 py-3 flex flex-col sm:flex-row sm:justify-between sm:items-center gap-3 sticky top-0 z-10">
<div className="flex justify-between items-center w-full sm:w-auto">
<div>
<h1 className="text-xl md:text-2xl font-bold text-slate-800 flex items-center gap-2">
<LayoutGrid className="text-indigo-600" /> The Tracker
</h1>
<p className="text-xs md:text-sm text-slate-500 mt-1">三年二班 - 導師主控台</p>
</div>
{/* View Mode Toggle (Mobile right, Desktop next to title) */}
<div className="flex sm:ml-6 bg-slate-100 p-1 rounded-lg border border-slate-200">
<button onClick={() => setTeacherViewMode('grid')} className={`flex items-center gap-1 md:gap-2 px-3 md:px-4 py-1.5 rounded-md text-xs md:text-sm font-bold transition-all ${teacherViewMode === 'grid' ? 'bg-white shadow-sm text-indigo-700' : 'text-slate-500 hover:text-slate-700'}`}>
<LayoutGrid size={16} /> <span className="hidden lg:inline">登記區</span>
</button>
<button onClick={() => setTeacherViewMode('summary')} className={`flex items-center gap-1 md:gap-2 px-3 md:px-4 py-1.5 rounded-md text-xs md:text-sm font-bold transition-all ${teacherViewMode === 'summary' ? 'bg-white shadow-sm text-indigo-700' : 'text-slate-500 hover:text-slate-700'}`}>
<LayoutList size={16} /> <span className="hidden lg:inline">學生總表</span>
</button>
</div>
</div>
<div className="flex items-center gap-2 overflow-x-auto pb-1 sm:pb-0 hide-scrollbar">
<button onClick={() => setShowMissingTable(true)} className="flex shrink-0 items-center gap-1.5 px-3 md:px-4 py-2 bg-orange-50 hover:bg-orange-100 text-orange-700 rounded-lg font-bold transition-colors shadow-sm border border-orange-200 text-sm">
<ClipboardList size={18} /> <span className="hidden md:inline">缺交總表</span>
{missingRecords.length > 0 && <span className="bg-orange-500 text-white text-xs px-2 py-0.5 rounded-full ml-1">{missingRecords.length}</span>}
</button>
<button onClick={() => setShowSettings(true)} className="shrink-0 p-2 md:px-3 text-slate-500 hover:text-slate-700 hover:bg-slate-100 rounded-full md:rounded-lg transition-colors bg-white shadow-sm border border-slate-200 sm:border-transparent sm:shadow-none sm:bg-transparent">
<Settings size={20} className="md:hidden" />
<span className="hidden md:flex items-center gap-2 font-bold text-sm"><Settings size={18} /> 設定</span>
</button>
<button onClick={() => setView('collaborator')} className="flex shrink-0 items-center gap-1.5 px-3 md:px-4 py-2 bg-indigo-50 hover:bg-indigo-100 text-indigo-700 rounded-lg font-bold transition-colors shadow-sm text-sm">
<Smartphone size={18} /> <span className="hidden sm:inline">科任視角</span>
</button>
</div>
</header>
{teacherViewMode === 'grid' ? (
<>
{/* Context Tabs (Scrollable on mobile) */}
<div className="px-4 md:px-6 py-3 md:py-4 border-b border-slate-200 bg-white sticky top-[68px] sm:top-[76px] z-10 flex gap-2 overflow-x-auto hide-scrollbar">
{contexts.map(ctx => (
<button
key={ctx} onClick={() => setActiveContext(ctx)}
className={`shrink-0 px-4 py-1.5 md:py-2 rounded-full text-xs md:text-sm font-semibold transition-colors ${activeContext === ctx ? 'bg-slate-800 text-white shadow-md' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'}`}
>{ctx}</button>
))}
</div>
{/* Content Area (Grid + Sidebar stack on mobile) */}
<div className="p-4 md:p-6 flex flex-col lg:flex-row gap-6 items-start">
{/* Student Grid */}
<div className="w-full lg:flex-1 grid grid-cols-2 sm:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-3 md:gap-4">
{students.map(student => {
const hasMissing = missingRecords.some(r => r.studentId === student.id);
return (
<div key={student.id} onClick={() => setSelectedStudentId(student.id)} className={`bg-white rounded-xl p-3 md:p-4 shadow-sm border-2 cursor-pointer transition-all hover:shadow-md hover:-translate-y-1 ${selectedStudentId === student.id ? 'border-indigo-500 ring-2 ring-indigo-200' : 'border-transparent'}`}>
<div className="flex justify-between items-start mb-2 md:mb-3">
<span className="text-[10px] md:text-xs font-bold text-slate-400 bg-slate-100 px-1.5 md:px-2 py-0.5 md:py-1 rounded-md">{student.no}</span>
<span className={`text-[10px] md:text-xs font-bold px-1.5 md:px-2 py-0.5 md:py-1 rounded-full ${student.points >= 0 ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'}`}>{student.points >= 0 ? '+' : ''}{student.points}</span>
</div>
<div className="flex flex-col items-center text-center">
<div className="text-3xl md:text-4xl mb-1 md:mb-2 select-none relative">
{student.avatar}
{hasMissing && <div className="absolute -top-1 -right-1 bg-red-500 rounded-full w-3 h-3 md:w-4 md:h-4 border-2 border-white"></div>}
</div>
<h3 className="font-bold text-slate-800 text-sm md:text-lg">{student.name}</h3>
</div>
</div>
);
})}
</div>
{/* Master Dashboard / Right Sidebar (Stacks bottom on mobile) */}
<div className="w-full lg:w-80 shrink-0 flex flex-col gap-6 lg:sticky lg:top-6 pb-6 lg:pb-0">
{/* Missing Summary Sidebar */}
<div className="bg-white rounded-xl shadow-sm border border-slate-200 overflow-hidden">
<div className="bg-red-50 px-4 py-3 border-b border-red-100 flex justify-between items-center"><div className="flex items-center gap-2"><AlertCircle className="text-red-500" size={18} /><h3 className="font-bold text-red-800 text-sm md:text-base">未交總覽快訊</h3></div></div>
<div className="p-4 max-h-48 lg:max-h-60 overflow-y-auto">
{Object.keys(missingSummaryByStudent).length > 0 ? (
<ul className="space-y-3">
{Object.entries(missingSummaryByStudent).map(([sId, records]) => {
const student = students.find(s => s.id === sId);
if (!student) return null;
return (
<li key={sId} className="text-sm border-b border-slate-100 pb-2 last:border-0 last:pb-0">
<div className="flex justify-between items-center"><span className="font-bold text-slate-700">{student.no}. {student.name}</span><span className="text-red-600 font-medium text-xs bg-red-50 px-2 py-1 rounded">共 {records.length} 項缺交</span></div>
</li>
);
})}
</ul>
) : (
<p className="text-sm text-slate-500 text-center py-4">目前全班作業皆已繳齊 🎉</p>
)}
</div>
</div>
{/* Timeline */}
<div className="bg-white rounded-xl shadow-sm border border-slate-200 overflow-hidden flex-1 flex flex-col max-h-[40vh] lg:max-h-[50vh]">
<div className="bg-slate-50 px-4 py-3 border-b border-slate-200 flex items-center gap-2"><Clock className="text-slate-500" size={18} /><h3 className="font-bold text-slate-800 text-sm md:text-base">最新動態</h3></div>
<div className="p-4 flex-1 overflow-y-auto">
<div className="relative border-l-2 border-slate-100 ml-3 space-y-6">
{records.map((record) => {
const student = students.find(s => s.id === record.studentId);
const isPositive = record.tagType === 'positive';
return (
<div key={record.id} className="relative pl-5 md:pl-6">
<div className={`absolute -left-[9px] top-1 w-4 h-4 rounded-full border-2 border-white ${record.type === 'assignment' ? 'bg-red-500' : isPositive ? 'bg-green-500' : 'bg-orange-500'}`}></div>
<div className="text-[10px] md:text-xs text-slate-400 mb-0.5 font-medium">{record.time} · {record.recorder} ({record.context})</div>
<div className="text-xs md:text-sm text-slate-700">
<span className="font-bold">{student ? student.name : '未知'}</span>
{record.type === 'assignment' ? (
<span> 未交 <span className="text-red-600 font-medium">{record.assignmentName}</span></span>
) : (
<span> 標記了 <span className={`font-medium ${isPositive ? 'text-green-600' : 'text-orange-600'}`}>{record.tagName}</span></span>
)}
</div>
</div>
)
})}
</div>
</div>
</div>
</div>
</div>
</>
) : (
/* Summary Table View */
<div className="p-4 md:p-6">
<div className="bg-white rounded-xl shadow-sm border border-slate-200 overflow-hidden">
<div className="bg-slate-50 px-4 md:px-6 py-4 border-b border-slate-200 flex items-center justify-between">
<h2 className="text-base md:text-lg font-bold text-slate-800 flex items-center gap-2">
<Users size={20} className="text-indigo-600" /> 全班狀況統整表
</h2>
</div>
<div className="overflow-x-auto">
<table className="w-full text-left text-sm whitespace-nowrap min-w-[700px]">
<thead className="bg-slate-50 border-b border-slate-200 text-slate-600 font-bold">
<tr>
<th className="px-4 md:px-6 py-4 w-16 text-center">座號</th>
<th className="px-4 md:px-6 py-4 w-32">學生姓名</th>
<th className="px-4 md:px-6 py-4 w-24 text-center">總點數</th>
<th className="px-4 md:px-6 py-4 w-1/3">目前缺交作業</th>
<th className="px-4 md:px-6 py-4">近期行為紀錄</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{students.map(student => {
const missing = missingSummaryByStudent[student.id] || [];
const studentRecords = records.filter(r => r.studentId === student.id && r.type === 'tag');
return (
<tr key={student.id} onClick={() => setSelectedStudentId(student.id)} className="hover:bg-indigo-50/50 transition-colors cursor-pointer group">
<td className="px-4 md:px-6 py-4 text-center font-bold text-slate-400 group-hover:text-indigo-500">{student.no}</td>
<td className="px-4 md:px-6 py-4"><div className="flex items-center gap-2"><span className="text-2xl">{student.avatar}</span><span className="font-bold text-slate-800">{student.name}</span></div></td>
<td className="px-4 md:px-6 py-4 text-center"><span className={`px-3 py-1.5 rounded-full font-bold text-xs ${student.points >= 0 ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'}`}>{student.points >= 0 ? '+' : ''}{student.points}</span></td>
<td className="px-4 md:px-6 py-4 whitespace-normal">
{missing.length > 0 ? (
<div className="flex flex-wrap gap-1.5">
{missing.map(m => (<span key={m.id} className="bg-red-50 text-red-600 border border-red-100 px-2 py-1 rounded text-[10px] md:text-xs font-bold flex items-center gap-1"><AlertCircle size={12} /> {m.name}</span>))}
</div>
) : (<span className="text-slate-300 text-xs font-medium flex items-center gap-1"><CheckCircle2 size={14} /> 無缺交</span>)}
</td>
<td className="px-4 md:px-6 py-4 whitespace-normal">
{studentRecords.length > 0 ? (
<div className="flex flex-wrap gap-1.5">
{studentRecords.map(r => {
const isPos = r.tagType === 'positive';
return (<span key={r.id} className={`px-2 py-1 rounded text-[10px] md:text-xs font-bold border flex items-center gap-1 ${isPos ? 'bg-green-50 text-green-700 border-green-200' : 'bg-orange-50 text-orange-700 border-orange-200'}`}>{isPos ? '👍' : '⚠️'} {r.tagName}</span>);
})}
</div>
) : (<span className="text-slate-300 text-xs font-medium">無紀錄</span>)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
</div>
)}
</div>
{/* Slide-over Panel for Selected Student (Responsive Width) */}
{selectedStudent && (
<div className="fixed inset-0 z-50 flex justify-end">
<div className="absolute inset-0 bg-slate-900/40 backdrop-blur-sm transition-opacity" onClick={() => setSelectedStudentId(null)}></div>
<div className="relative w-[85vw] sm:w-[400px] bg-white shadow-2xl h-full flex flex-col transform transition-transform duration-300 border-l border-slate-200">
<div className="px-4 md:px-6 py-4 md:py-5 border-b border-slate-100 flex items-center justify-between bg-slate-50">
<div className="flex items-center gap-3 md:gap-4">
<div className="text-3xl md:text-4xl">{selectedStudent.avatar}</div>
<div>
<h2 className="text-lg md:text-xl font-bold text-slate-800">{selectedStudent.no}. {selectedStudent.name}</h2>
<div className={`text-xs md:text-sm font-semibold mt-0.5 md:mt-1 ${selectedStudent.points >= 0 ? 'text-green-600' : 'text-red-600'}`}>
總點數: {selectedStudent.points}
</div>
</div>
</div>
<button onClick={() => setSelectedStudentId(null)} className="p-2 text-slate-400 hover:text-slate-600 hover:bg-slate-200 rounded-full transition-colors"><X size={20} /></button>
</div>
<div className="p-4 md:p-6 overflow-y-auto flex-1 flex flex-col gap-6 md:gap-8 pb-10">
<section>
<h3 className="text-xs md:text-sm font-bold text-slate-400 uppercase tracking-wider mb-3 md:mb-4 flex items-center gap-2">
<ListChecks size={16} /> 今日預設作業
</h3>
<div className="space-y-2 md:space-y-3">
{assignments.map(assignment => {
const isMissing = isMissingToday(selectedStudent.id, assignment.id);
return (
<label key={assignment.id} className={`flex items-center justify-between p-2.5 md:p-3 rounded-lg border-2 cursor-pointer transition-all ${isMissing ? 'border-red-200 bg-red-50/50' : 'border-slate-100 hover:border-indigo-200 bg-white'}`}>
<div><span className="block text-[9px] md:text-[10px] text-slate-400 font-bold mb-0.5">{assignment.subject}</span><span className={`text-sm md:text-base font-medium ${isMissing ? 'text-red-600 line-through opacity-70' : 'text-slate-700'}`}>{assignment.name}</span></div>
<div className={`w-5 h-5 md:w-6 md:h-6 rounded flex items-center justify-center transition-colors ${!isMissing ? 'bg-indigo-600 text-white' : 'bg-slate-200 text-transparent'}`}><Check size={14} strokeWidth={3} /></div>
<input type="checkbox" className="hidden" checked={!isMissing} onChange={() => toggleTodayAssignment(selectedStudent.id, assignment)} />
</label>
);
})}
</div>
</section>
<section className="bg-red-50/50 rounded-xl border border-red-100 p-3 md:p-4">
<h3 className="text-xs md:text-sm font-bold text-red-800 uppercase tracking-wider mb-3 md:mb-4 flex items-center gap-2">
<Calendar size={16} /> 缺交作業清單與補登
</h3>
<div className="space-y-2 mb-3 md:mb-4">
{(missingSummaryByStudent[selectedStudent.id] || []).map(record => (
<div key={record.id} className="flex flex-col sm:flex-row sm:items-center justify-between bg-white p-2.5 rounded-lg border border-red-200 shadow-sm gap-2">
<div className="flex flex-col"><div className="flex items-center gap-2"><span className="text-[9px] md:text-[10px] font-bold bg-slate-100 text-slate-500 px-1.5 py-0.5 rounded">{record.date}</span><span className="text-[9px] md:text-[10px] font-bold text-indigo-500">{record.subject}</span></div><span className="font-bold text-red-600 text-xs md:text-sm mt-0.5">{record.name}</span></div>
<button onClick={() => markMissingAsDone(record.id)} className="w-full sm:w-auto text-center bg-green-100 text-green-700 hover:bg-green-500 hover:text-white px-2 py-1.5 md:py-1 rounded text-xs font-bold transition-colors border border-green-200">已完成</button>
</div>
))}
{!(missingSummaryByStudent[selectedStudent.id] || []).length && (<div className="text-xs text-slate-400 text-center py-2">目前沒有任何缺交紀錄</div>)}
</div>
<div className="border-t border-red-100 pt-3 mt-2">
<p className="text-[10px] md:text-xs font-bold text-slate-500 mb-2">➕ 手動補登歷史缺交</p>
<div className="flex flex-col gap-2">
<div className="grid grid-cols-2 gap-2">
<input type="date" value={manualMissing.date} onChange={e => setManualMissing({...manualMissing, date: e.target.value})} className="bg-white border border-slate-200 rounded px-2 py-1.5 text-xs focus:ring-1 ring-indigo-500 outline-none w-full" />
<select value={manualMissing.subject} onChange={e => setManualMissing({...manualMissing, subject: e.target.value})} className="bg-white border border-slate-200 rounded px-2 py-1.5 text-xs focus:ring-1 ring-indigo-500 outline-none w-full">
<optgroup label="日常作息">{routineContexts.map(ctx => <option key={ctx} value={ctx}>{ctx}</option>)}</optgroup>
<optgroup label="上課科目">{subjectContexts.map(ctx => <option key={ctx} value={ctx}>{ctx}</option>)}</optgroup>
</select>
</div>
<div className="flex gap-2">
<input type="text" placeholder="輸入作業名稱" value={manualMissing.name} onChange={e => setManualMissing({...manualMissing, name: e.target.value})} className="flex-1 bg-white border border-slate-200 rounded px-2 py-1.5 text-xs focus:ring-1 ring-indigo-500 outline-none" />
<button onClick={() => handleAddManualMissing(selectedStudent.id)} className="bg-red-600 hover:bg-red-700 text-white px-3 py-1.5 rounded text-xs font-bold transition-colors whitespace-nowrap">補登</button>
</div>
</div>
</div>
</section>
<section>
<div className="flex justify-between items-center mb-3 md:mb-4">
<h3 className="text-xs md:text-sm font-bold text-slate-400 uppercase tracking-wider flex items-center gap-2">
<Award size={16} /> 行為表現
</h3>
<select
value={studentPanelContext}
onChange={(e) => setStudentPanelContext(e.target.value)}
className="bg-indigo-50 border border-indigo-200 text-indigo-700 text-[10px] md:text-xs font-bold rounded-lg px-2 py-1 outline-none cursor-pointer max-w-[120px] md:max-w-none"
>
<optgroup label="日常作息">{routineContexts.map(c => <option key={c} value={c}>{c}</option>)}</optgroup>
<optgroup label="上課科目">{subjectContexts.map(c => <option key={c} value={c}>{c}</option>)}</optgroup>
</select>
</div>
<div className="grid grid-cols-2 gap-2 md:gap-3">
{contextTags.map(tag => (
<button
key={tag.id}
onClick={() => addRecord(selectedStudent.id, 'tag', tag, false, studentPanelContext)}
className={`p-2.5 md:p-3 rounded-xl border-2 text-xs md:text-sm font-bold transition-all hover:shadow-md active:scale-95 flex flex-col items-center gap-1 ${tag.type === 'positive' ? 'border-green-100 bg-green-50 text-green-700 hover:border-green-300' : 'border-orange-100 bg-orange-50 text-orange-700 hover:border-orange-300'}`}
>
<span className="text-base md:text-lg">{tag.type === 'positive' ? '👍' : '⚠️'}</span>
{tag.name}
</button>
))}
<button onClick={() => handleCustomTag('positive', false)} className="p-2.5 md:p-3 rounded-xl border-2 border-dashed border-green-200 bg-slate-50 text-xs md:text-sm font-bold transition-all hover:bg-green-100 text-green-600 flex flex-col items-center gap-1">
<span className="text-base md:text-lg">👍</span> 其他(自訂)
</button>
<button onClick={() => handleCustomTag('needs_improvement', false)} className="p-2.5 md:p-3 rounded-xl border-2 border-dashed border-orange-200 bg-slate-50 text-xs md:text-sm font-bold transition-all hover:bg-orange-100 text-orange-600 flex flex-col items-center gap-1">
<span className="text-base md:text-lg">⚠️</span> 其他(自訂)
</button>
</div>
</section>
</div>
</div>
</div>
)}
</div>
);
// ==========================================
// VIEW: Collaborator Portal (Already Mobile-First)
// ==========================================
const renderCollaboratorView = () => (
<div className="min-h-screen bg-slate-100 flex justify-center font-sans">
<div className="w-full max-w-md bg-white min-h-screen shadow-2xl relative flex flex-col pb-24">
<div className="bg-indigo-600 text-white px-5 py-6 rounded-b-3xl shadow-lg relative z-10">
<div className="flex justify-between items-start mb-4">
<h1 className="text-xl font-bold">The Tracker</h1>
<button onClick={() => setView('teacher')} className="text-xs bg-white/20 hover:bg-white/30 px-3 py-1.5 rounded-full backdrop-blur-sm transition-colors font-bold tracking-wider">返回導師</button>
</div>
<div className="bg-white/10 rounded-xl p-3 backdrop-blur-md border border-white/20">
<p className="text-xs text-indigo-100 mb-2">目前正在紀錄:三年二班</p>
<div className="flex items-center bg-white rounded-lg p-1">
<BookOpen size={16} className="text-indigo-600 ml-2" />
<select value={collabContext} onChange={(e) => setCollabContext(e.target.value)} className="w-full bg-transparent text-indigo-900 font-bold text-sm px-2 py-1.5 outline-none cursor-pointer">
<optgroup label="上課科目">{subjectContexts.map(ctx => <option key={ctx} value={ctx}>{ctx}</option>)}</optgroup>
<optgroup label="日常作息">{routineContexts.map(ctx => <option key={ctx} value={ctx}>{ctx}</option>)}</optgroup>
</select>
</div>
</div>
</div>
<div className="p-5 flex-1 overflow-y-auto">
<div className="flex justify-between items-center mb-4">
<p className="text-sm text-slate-500 font-bold flex items-center gap-1"><Check size={16} className="text-indigo-500" /> 點選學生批次給分</p>
{collabSelectedStudents.length > 0 && <span className="text-xs font-bold text-indigo-600 bg-indigo-50 px-2 py-1 rounded-full">已選 {collabSelectedStudents.length} 人</span>}
</div>
<div className="grid grid-cols-3 gap-3">
{students.map(student => {
const isSelected = collabSelectedStudents.includes(student.id);
return (
<div key={student.id} onClick={() => setCollabSelectedStudents(prev => isSelected ? prev.filter(id => id !== student.id) : [...prev, student.id])} className={`relative flex flex-col items-center p-3 rounded-2xl border-2 cursor-pointer transition-all duration-200 ${isSelected ? 'border-indigo-600 bg-indigo-50 shadow-md transform scale-105' : 'border-slate-100 bg-white shadow-sm'}`}>
{isSelected && <div className="absolute -top-2 -right-2 bg-indigo-600 text-white rounded-full p-0.5 shadow-sm"><Check size={14} strokeWidth={3} /></div>}
<div className="text-3xl mb-1 select-none">{student.avatar}</div>
<span className={`text-sm font-bold ${isSelected ? 'text-indigo-800' : 'text-slate-700'}`}>{student.name}</span>
<span className="text-[10px] text-slate-400 font-bold mt-0.5">{student.no}號</span>
</div>
);
})}
</div>
</div>
<div className={`fixed bottom-0 left-0 right-0 md:absolute bg-white border-t border-slate-200 shadow-[0_-10px_40px_-10px_rgba(0,0,0,0.1)] px-5 py-4 pb-8 md:pb-4 transition-transform duration-300 ease-in-out z-20 ${collabSelectedStudents.length > 0 ? 'translate-y-0' : 'translate-y-full'}`}>
<div className="flex justify-between items-center mb-3">
<span className="font-bold text-indigo-800">選擇行為標籤:</span>
<button onClick={() => setCollabSelectedStudents([])} className="text-xs bg-slate-100 text-slate-500 px-2 py-1 rounded-md hover:bg-slate-200 font-bold">取消全選</button>
</div>
<div className="flex gap-2 overflow-x-auto pb-2 -mx-2 px-2 snap-x hide-scrollbar">
{collabTags.map(tag => (
<button key={tag.id} onClick={() => handleCollabSubmit(tag)} className={`snap-start shrink-0 px-4 py-3 rounded-xl font-bold text-sm transition-transform active:scale-95 flex items-center gap-2 ${tag.type === 'positive' ? 'bg-green-100 text-green-700 border border-green-200 hover:bg-green-200' : 'bg-orange-100 text-orange-700 border border-orange-200 hover:bg-orange-200'}`}>
{tag.type === 'positive' ? '👍' : '⚠️'}{tag.name}
</button>
))}
<button onClick={() => handleCustomTag('positive', true)} className="snap-start shrink-0 px-4 py-3 rounded-xl font-bold text-sm transition-transform active:scale-95 flex items-center gap-2 bg-slate-50 text-green-700 border-2 border-dashed border-green-300 hover:bg-green-100">👍 其他(自訂)</button>
<button onClick={() => handleCustomTag('needs_improvement', true)} className="snap-start shrink-0 px-4 py-3 rounded-xl font-bold text-sm transition-transform active:scale-95 flex items-center gap-2 bg-slate-50 text-orange-700 border-2 border-dashed border-orange-300 hover:bg-orange-100">⚠️ 其他(自訂)</button>
</div>
</div>
{showToast && (
<div className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 bg-slate-800 text-white px-6 py-4 rounded-2xl shadow-2xl flex flex-col items-center gap-2 z-50 animate-bounce">
<div className="bg-green-500 rounded-full p-2"><Check size={24} strokeWidth={3} className="text-white" /></div>
<span className="font-bold text-lg">紀錄已送出!</span>
<span className="text-xs text-slate-300">已同步至導師主控台</span>
</div>
)}
</div>
</div>
);
return view === 'teacher' ? renderTeacherView() : renderCollaboratorView();
}💡 給讀者的小撇步: 如果你想讓紀錄「長久保存」,可以在請 Gemini 修改時多加一句指令:「請幫我在代碼中加入 localStorage 功能,讓我重新整理網頁後紀錄也能保留。」這樣功能就更完美囉!



留言