Spaces:
Running
Running
| <html lang="zh-TW"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>學生成績雷達圖產生器 (Web版)</title> | |
| <!-- 引入字型 --> | |
| <link rel="preconnect" href="https://fonts.googleapis.com"> | |
| <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> | |
| <link href="https://fonts.googleapis.com/css2?family=Noto+Sans+TC:wght@400;700&display=swap" rel="stylesheet"> | |
| <!-- 引入 Tailwind CSS (樣式) --> | |
| <script src="https://cdn.tailwindcss.com"></script> | |
| <!-- 引入必要函式庫 --> | |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/PapaParse/5.4.1/papaparse.min.js"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/chart.js"></script> | |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"></script> | |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/FileSaver.js/2.0.5/FileSaver.min.js"></script> | |
| <style> | |
| body { | |
| font-family: 'Noto Sans TC', sans-serif; | |
| background-color: #f3f4f6; | |
| } | |
| .step-card { | |
| background: white; | |
| padding: 1.5rem; | |
| border-radius: 0.5rem; | |
| box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1); | |
| margin-bottom: 1.5rem; | |
| } | |
| /* 隱藏用於批次生成的 Canvas,避免畫面閃爍 */ | |
| #batchCanvas { | |
| position: absolute; | |
| left: -9999px; | |
| top: -9999px; | |
| } | |
| .loading-overlay { | |
| position: fixed; | |
| top: 0; | |
| left: 0; | |
| width: 100%; | |
| height: 100%; | |
| background: rgba(255, 255, 255, 0.8); | |
| display: flex; | |
| justify-content: center; | |
| align-items: center; | |
| z-index: 50; | |
| } | |
| </style> | |
| </head> | |
| <body class="p-6"> | |
| <div id="loadingOverlay" class="loading-overlay hidden"> | |
| <div class="text-xl font-bold text-green-700">處理中,請稍候...</div> | |
| </div> | |
| <div class="max-w-6xl mx-auto"> | |
| <header class="text-center mb-8"> | |
| <h1 class="text-3xl font-bold text-gray-800 mb-2">📊 學生成績雷達圖產生器</h1> | |
| <p class="text-gray-600">純網頁版 - 資料安全不需上傳伺服器</p> | |
| </header> | |
| <!-- 步驟 1: 上傳檔案 --> | |
| <div class="step-card"> | |
| <h2 class="text-xl font-bold text-green-700 mb-4">Step 1: 上傳 CSV 成績檔</h2> | |
| <div class="flex flex-col gap-2"> | |
| <div class="flex items-center gap-4"> | |
| <input type="file" id="csvInput" accept=".csv" | |
| class="block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-green-50 file:text-green-700 hover:file:bg-green-100 cursor-pointer"> | |
| </div> | |
| <p class="text-xs text-gray-500 mt-2"> | |
| * 檔案必須包含「姓名」欄位及數值成績欄位。<br> | |
| * 如果沒反應,請嘗試將 Excel 檔案「另存新檔」為 <b>CSV (UTF-8)</b> 格式。 | |
| </p> | |
| </div> | |
| </div> | |
| <!-- 主要控制區 (讀取檔案後顯示) --> | |
| <div id="mainControl" class="hidden grid grid-cols-1 lg:grid-cols-3 gap-6"> | |
| <!-- 左側:設定區 --> | |
| <div class="lg:col-span-1 space-y-6"> | |
| <!-- 科目選擇 --> | |
| <div class="step-card"> | |
| <h3 class="font-bold text-gray-700 mb-3 border-b pb-2">選擇分析科目</h3> | |
| <div id="subjectCheckboxes" class="grid grid-cols-2 gap-2 text-sm"> | |
| <!-- 動態生成 --> | |
| </div> | |
| </div> | |
| <!-- 比較基準 --> | |
| <div class="step-card"> | |
| <h3 class="font-bold text-gray-700 mb-3 border-b pb-2">比較基準 (背景圖形)</h3> | |
| <div class="space-y-3"> | |
| <label class="flex items-center space-x-2"> | |
| <input type="checkbox" id="showAverage" checked class="form-checkbox text-green-600"> | |
| <span>顯示全班平均</span> | |
| </label> | |
| <div> | |
| <label class="block text-sm text-gray-600 mb-1">額外比較對象 (選填)</label> | |
| <select id="compareStudentSelect" class="w-full border rounded p-2 text-sm bg-gray-50"> | |
| <option value="">無</option> | |
| <!-- 動態生成 --> | |
| </select> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- 批次下載 --> | |
| <div class="step-card bg-green-50 border-green-200 border"> | |
| <h3 class="font-bold text-green-800 mb-2">📥 批次輸出</h3> | |
| <p class="text-sm text-green-700 mb-4">將為全班每位學生生成一張圖片並打包下載。</p> | |
| <button id="btnDownloadZip" class="w-full bg-green-600 hover:bg-green-700 text-white font-bold py-2 px-4 rounded shadow transition flex items-center justify-center gap-2"> | |
| <span>下載全班圖表 (.ZIP)</span> | |
| </button> | |
| <!-- 進度條 --> | |
| <div id="progressContainer" class="hidden mt-4"> | |
| <div class="w-full bg-gray-200 rounded-full h-2.5"> | |
| <div id="progressBar" class="bg-green-600 h-2.5 rounded-full" style="width: 0%"></div> | |
| </div> | |
| <p id="progressText" class="text-xs text-center mt-1 text-gray-600">準備中...</p> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- 右側:預覽區 --> | |
| <div class="lg:col-span-2"> | |
| <div class="step-card h-full min-h-[500px]"> | |
| <div class="flex justify-between items-center mb-4 border-b pb-2"> | |
| <h3 class="font-bold text-gray-700">即時預覽</h3> | |
| <div class="flex items-center gap-2"> | |
| <label class="text-sm">預覽學生:</label> | |
| <select id="previewStudentSelect" class="border rounded p-1 text-sm"> | |
| <!-- 動態生成 --> | |
| </select> | |
| </div> | |
| </div> | |
| <div class="relative w-full aspect-[4/3] flex justify-center items-center bg-white"> | |
| <canvas id="radarChart"></canvas> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- 隱藏的 Canvas 用於批次生成 --> | |
| <canvas id="batchCanvas" width="1200" height="900"></canvas> | |
| <script> | |
| // 全域變數 | |
| let globalData = []; | |
| let numericColumns = []; | |
| let studentNames = []; | |
| let chartInstance = null; | |
| let batchChartInstance = null; | |
| // 顏色設定 | |
| const COLORS = { | |
| main: { border: 'rgba(31, 119, 180, 1)', bg: 'rgba(31, 119, 180, 0.2)' }, // 藍色 (主要學生) | |
| avg: { border: 'rgba(255, 127, 14, 1)', bg: 'rgba(255, 127, 14, 0.1)' }, // 橘色 (平均) | |
| compare: { border: 'rgba(44, 160, 44, 1)', bg: 'rgba(44, 160, 44, 0.0)' } // 綠色 (比較對象) | |
| }; | |
| // DOM 元素 | |
| const els = { | |
| csvInput: document.getElementById('csvInput'), | |
| mainControl: document.getElementById('mainControl'), | |
| subjectCheckboxes: document.getElementById('subjectCheckboxes'), | |
| previewSelect: document.getElementById('previewStudentSelect'), | |
| compareSelect: document.getElementById('compareStudentSelect'), | |
| showAvg: document.getElementById('showAverage'), | |
| canvas: document.getElementById('radarChart'), | |
| batchCanvas: document.getElementById('batchCanvas'), | |
| btnZip: document.getElementById('btnDownloadZip'), | |
| progressContainer: document.getElementById('progressContainer'), | |
| progressBar: document.getElementById('progressBar'), | |
| progressText: document.getElementById('progressText'), | |
| loading: document.getElementById('loadingOverlay') | |
| }; | |
| // 檢查函式庫是否載入 | |
| window.addEventListener('load', () => { | |
| if (typeof Papa === 'undefined' || typeof Chart === 'undefined') { | |
| alert("錯誤:程式庫未正確載入。\n\n請檢查您的網路連線,或確認此環境未封鎖 cdnjs.cloudflare.com 與 cdn.jsdelivr.net。"); | |
| } | |
| }); | |
| // 初始化 Chart.js 預設字型 | |
| if (typeof Chart !== 'undefined') { | |
| Chart.defaults.font.family = "'Noto Sans TC', sans-serif"; | |
| Chart.defaults.font.size = 14; | |
| } | |
| // 監聽檔案上傳 | |
| if (els.csvInput) { | |
| els.csvInput.addEventListener('change', handleFileUpload); | |
| } | |
| // 監聽控制項變更以更新圖表 | |
| els.previewSelect.addEventListener('change', updateChart); | |
| els.compareSelect.addEventListener('change', updateChart); | |
| els.showAvg.addEventListener('change', updateChart); | |
| // 監聽下載按鈕 | |
| els.btnZip.addEventListener('click', generateBatchZip); | |
| function handleFileUpload(event) { | |
| const file = event.target.files[0]; | |
| if (!file) return; | |
| // 顯示 Loading | |
| els.loading.classList.remove('hidden'); | |
| // 稍微延遲以確保 loading 顯示 | |
| setTimeout(() => { | |
| Papa.parse(file, { | |
| header: true, | |
| skipEmptyLines: true, | |
| // 不強制指定 encoding,讓 PapaParse 自動處理,但後續會檢查內容 | |
| complete: function(results) { | |
| try { | |
| if (results.errors && results.errors.length > 0) { | |
| console.warn("CSV 解析警告:", results.errors); | |
| } | |
| processData(results.data); | |
| } catch (e) { | |
| console.error(e); | |
| alert("❌ 資料處理錯誤:\n" + e.message); | |
| } finally { | |
| els.loading.classList.add('hidden'); | |
| } | |
| }, | |
| error: function(err) { | |
| els.loading.classList.add('hidden'); | |
| alert("❌ CSV 檔案讀取失敗: " + err.message); | |
| } | |
| }); | |
| }, 100); | |
| } | |
| function processData(data) { | |
| if (!data || data.length === 0) { | |
| throw new Error("檔案內容為空,或無法識別任何資料行。"); | |
| } | |
| // --- 關鍵修正:處理 BOM 與 欄位名稱 --- | |
| // 取得第一列的所有 key | |
| let rawHeaders = Object.keys(data[0]); | |
| // 建立 key 對應表 (處理 BOM 或前後空白) | |
| const keyMap = {}; | |
| rawHeaders.forEach(key => { | |
| // 移除 BOM (\ufeff) 和前後空白 | |
| let cleanKey = key.replace(/^\ufeff/, '').trim(); | |
| keyMap[key] = cleanKey; | |
| }); | |
| // 重新整理 data,將所有 key 換成乾淨的 key | |
| const cleanData = data.map(row => { | |
| const newRow = {}; | |
| Object.keys(row).forEach(k => { | |
| const cleanK = keyMap[k] || k.trim(); | |
| newRow[cleanK] = row[k]; | |
| }); | |
| return newRow; | |
| }); | |
| // 檢查「姓名」欄位是否存在 | |
| const headers = Object.keys(cleanData[0]); | |
| if (!headers.includes('姓名')) { | |
| // 偵測是否是亂碼 (簡易判斷:檢查是否包含大量非 ASCII 且非中文的奇怪符號,或長度過長) | |
| const headerStr = headers.join(","); | |
| if (headerStr.includes("")) { | |
| throw new Error("找不到「姓名」欄位且偵測到亂碼。\n\n原因:這可能是 Excel 存檔的編碼問題。\n解法:請用 Excel 開啟檔案,選擇「另存新檔」,格式選擇「CSV UTF-8 (逗號分隔)」。"); | |
| } | |
| throw new Error(`找不到「姓名」欄位。\n\n偵測到的欄位有:${headers.join(", ")}\n\n請確認 CSV 檔案中第一列包含「姓名」二字。`); | |
| } | |
| // 2. 篩選數值欄位 | |
| numericColumns = headers.filter(h => { | |
| if (h === '姓名') return false; | |
| // 檢查第一筆資料是否可轉為數字 (過濾掉空值或非數字) | |
| const val = cleanData[0][h]; | |
| return val !== "" && !isNaN(parseFloat(val)); | |
| }); | |
| if (numericColumns.length === 0) { | |
| throw new Error("找不到數值欄位(成績)。請確認檔案中除「姓名」外,還有數字成績欄位。"); | |
| } | |
| // 3. 轉型與儲存 | |
| globalData = cleanData.map(row => { | |
| let newRow = { '姓名': row['姓名'] }; | |
| numericColumns.forEach(col => { | |
| newRow[col] = parseFloat(row[col]) || 0; | |
| }); | |
| return newRow; | |
| }); | |
| // 過濾掉沒有名字的空行 | |
| globalData = globalData.filter(d => d['姓名'] && d['姓名'].trim() !== ""); | |
| studentNames = globalData.map(d => d['姓名']); | |
| if (studentNames.length === 0) { | |
| throw new Error("解析後沒有找到任何有效的學生資料。"); | |
| } | |
| // 4. 初始化 UI | |
| initUI(); | |
| // 5. 顯示控制區 | |
| els.mainControl.classList.remove('hidden'); | |
| // 6. 繪製初始圖表 | |
| updateChart(); | |
| } | |
| function initUI() { | |
| // 生成科目 Checkbox | |
| els.subjectCheckboxes.innerHTML = ''; | |
| // 預設選取的科目 (常用的) | |
| const defaultSubjects = ['國文', '英文', '數學', '物理', '化學', '生物', '歷史', '地理', '公民', '社會', '自科', '總分', '平均']; | |
| numericColumns.forEach(col => { | |
| const wrapper = document.createElement('div'); | |
| wrapper.className = 'flex items-center space-x-2'; | |
| const cb = document.createElement('input'); | |
| cb.type = 'checkbox'; | |
| cb.value = col; | |
| cb.id = `sub_${col}`; | |
| cb.className = 'subject-cb form-checkbox text-green-600 rounded'; | |
| // 簡單邏輯:如果是預設列表中的,或者數值欄位少於 6 個,則預設勾選 | |
| if (defaultSubjects.includes(col) || numericColumns.length <= 6) { | |
| cb.checked = true; | |
| } | |
| // 國文、英文、數學 必勾 (如果有的話) | |
| if (['國文', '英文', '數學'].includes(col)) cb.checked = true; | |
| cb.addEventListener('change', updateChart); | |
| const label = document.createElement('label'); | |
| label.htmlFor = `sub_${col}`; | |
| label.textContent = col; | |
| wrapper.appendChild(cb); | |
| wrapper.appendChild(label); | |
| els.subjectCheckboxes.appendChild(wrapper); | |
| }); | |
| // 填充下拉選單 | |
| const fillSelect = (selectEl, includeEmpty) => { | |
| selectEl.innerHTML = includeEmpty ? '<option value="">無</option>' : ''; | |
| studentNames.forEach(name => { | |
| const opt = document.createElement('option'); | |
| opt.value = name; | |
| opt.textContent = name; | |
| selectEl.appendChild(opt); | |
| }); | |
| }; | |
| fillSelect(els.previewSelect, false); | |
| fillSelect(els.compareSelect, true); | |
| } | |
| function getSelectedSubjects() { | |
| const checkboxes = document.querySelectorAll('.subject-cb:checked'); | |
| return Array.from(checkboxes).map(cb => cb.value); | |
| } | |
| function getStudentData(name) { | |
| return globalData.find(d => d['姓名'] === name); | |
| } | |
| function calculateAverage(subjects) { | |
| let sums = {}; | |
| subjects.forEach(s => sums[s] = 0); | |
| globalData.forEach(student => { | |
| subjects.forEach(s => { | |
| sums[s] += student[s]; | |
| }); | |
| }); | |
| const count = globalData.length; | |
| return subjects.map(s => (sums[s] / count).toFixed(1)); // 保留一位小數 | |
| } | |
| function createChartConfig(targetStudentName, compareStudentName, showAvg, subjects) { | |
| const targetData = getStudentData(targetStudentName); | |
| const datasets = []; | |
| // 1. 全班平均 (放在最底層) | |
| if (showAvg) { | |
| const avgData = calculateAverage(subjects); | |
| datasets.push({ | |
| label: '全班平均', | |
| data: avgData, | |
| backgroundColor: COLORS.avg.bg, | |
| borderColor: COLORS.avg.border, | |
| borderWidth: 2, | |
| borderDash: [5, 5], | |
| pointRadius: 0 | |
| }); | |
| } | |
| // 2. 比較對象 | |
| if (compareStudentName && compareStudentName !== targetStudentName) { | |
| const compData = getStudentData(compareStudentName); | |
| if (compData) { | |
| const dataValues = subjects.map(s => compData[s]); | |
| datasets.push({ | |
| label: compareStudentName, | |
| data: dataValues, | |
| backgroundColor: COLORS.compare.bg, | |
| borderColor: COLORS.compare.border, | |
| borderWidth: 2, | |
| pointStyle: 'rectRot', | |
| pointRadius: 5 | |
| }); | |
| } | |
| } | |
| // 3. 主要學生 (最上層) | |
| const mainValues = subjects.map(s => targetData[s]); | |
| datasets.push({ | |
| label: targetStudentName, | |
| data: mainValues, | |
| fill: true, | |
| backgroundColor: COLORS.main.bg, | |
| borderColor: COLORS.main.border, | |
| pointBackgroundColor: COLORS.main.border, | |
| pointBorderColor: '#fff', | |
| pointHoverBackgroundColor: '#fff', | |
| pointHoverBorderColor: COLORS.main.border, | |
| borderWidth: 3, | |
| pointRadius: 4 | |
| }); | |
| // 計算最大值以設定刻度 | |
| let allValues = [...mainValues]; | |
| if (showAvg) allValues = allValues.concat(calculateAverage(subjects).map(Number)); | |
| const maxVal = Math.max(...allValues) || 100; | |
| const suggestedMax = Math.ceil(maxVal * 1.1); // 留 10% 空間 | |
| return { | |
| type: 'radar', | |
| data: { | |
| labels: subjects, | |
| datasets: datasets | |
| }, | |
| options: { | |
| responsive: true, | |
| maintainAspectRatio: false, | |
| scales: { | |
| r: { | |
| angleLines: { display: true }, | |
| suggestedMin: 0, | |
| suggestedMax: suggestedMax, | |
| ticks: { | |
| backdropColor: 'transparent', | |
| font: { size: 10 } | |
| }, | |
| pointLabels: { | |
| font: { size: 14, weight: 'bold', family: "'Noto Sans TC', sans-serif" }, | |
| color: '#374151' | |
| } | |
| } | |
| }, | |
| plugins: { | |
| title: { | |
| display: true, | |
| text: `${targetStudentName} 成績分析圖`, | |
| font: { size: 20, family: "'Noto Sans TC', sans-serif" }, | |
| padding: 20 | |
| }, | |
| legend: { | |
| position: 'bottom', | |
| labels: { | |
| font: { size: 12, family: "'Noto Sans TC', sans-serif" }, | |
| padding: 20 | |
| } | |
| } | |
| } | |
| } | |
| }; | |
| } | |
| function updateChart() { | |
| const targetName = els.previewSelect.value; | |
| const compareName = els.compareSelect.value; | |
| const subjects = getSelectedSubjects(); | |
| const showAvg = els.showAvg.checked; | |
| if (!targetName || subjects.length === 0) return; | |
| const config = createChartConfig(targetName, compareName, showAvg, subjects); | |
| if (chartInstance) { | |
| chartInstance.destroy(); | |
| } | |
| chartInstance = new Chart(els.canvas, config); | |
| } | |
| // ============================ | |
| // 批次生成邏輯 | |
| // ============================ | |
| async function generateBatchZip() { | |
| const subjects = getSelectedSubjects(); | |
| const compareName = els.compareSelect.value; | |
| const showAvg = els.showAvg.checked; | |
| if (subjects.length === 0) { | |
| alert("請至少選擇一個科目!"); | |
| return; | |
| } | |
| // UI 狀態更新 | |
| els.btnZip.disabled = true; | |
| els.btnZip.textContent = "生成中... 請勿關閉"; | |
| els.progressContainer.classList.remove('hidden'); | |
| const zip = new JSZip(); | |
| const batchCtx = els.batchCanvas.getContext('2d'); | |
| // 確保 Canvas 尺寸足夠大以獲得高品質圖片 | |
| // Chart.js 依賴父容器或 Canvas 屬性,這裡我們直接畫在隱藏的 1200x900 Canvas 上 | |
| let processedCount = 0; | |
| const total = studentNames.length; | |
| // 為了不卡死瀏覽器 UI,我們使用非同步遞迴或 for loop with await | |
| for (let i = 0; i < total; i++) { | |
| const studentName = studentNames[i]; | |
| // 更新進度條 | |
| const percent = Math.round(((i + 1) / total) * 100); | |
| els.progressBar.style.width = `${percent}%`; | |
| els.progressText.textContent = `正在處理: ${studentName} (${i + 1}/${total})`; | |
| // 產生 Config | |
| const config = createChartConfig(studentName, compareName, showAvg, subjects); | |
| // 強制關閉動畫,這樣畫完馬上就能截圖 | |
| config.options.animation = false; | |
| config.options.devicePixelRatio = 2; // 提高清晰度 | |
| // 銷毀舊的批次圖表 | |
| if (batchChartInstance) { | |
| batchChartInstance.destroy(); | |
| } | |
| // 繪製新圖表 | |
| // 使用 Promise 等待渲染完成 (雖然 animation: false 通常是同步的,但保險起見) | |
| await new Promise(resolve => { | |
| batchChartInstance = new Chart(batchCtx, { | |
| ...config, | |
| plugins: [{ | |
| id: 'background-colour', | |
| beforeDraw: (chart) => { | |
| const ctx = chart.ctx; | |
| ctx.save(); | |
| ctx.fillStyle = 'white'; | |
| ctx.fillRect(0, 0, chart.width, chart.height); | |
| ctx.restore(); | |
| } | |
| }] | |
| }); | |
| // 給一點點時間讓 Canvas 渲染 buffer | |
| setTimeout(resolve, 50); | |
| }); | |
| // 轉成 Blob/Base64 | |
| const imgData = els.batchCanvas.toDataURL("image/png").split(',')[1]; // 去除 header | |
| // 加入 ZIP | |
| zip.file(`${studentName}_成績雷達圖.png`, imgData, {base64: true}); | |
| } | |
| els.progressText.textContent = "正在壓縮檔案..."; | |
| // 生成 ZIP 並下載 | |
| zip.generateAsync({type: "blob"}).then(function(content) { | |
| saveAs(content, "學生成績雷達圖_全班.zip"); | |
| // 復原 UI | |
| els.btnZip.disabled = false; | |
| els.btnZip.innerHTML = "<span>下載全班圖表 (.ZIP)</span>"; | |
| els.progressText.textContent = "完成!"; | |
| setTimeout(() => { | |
| els.progressContainer.classList.add('hidden'); | |
| }, 3000); | |
| }); | |
| } | |
| </script> | |
| </body> | |
| </html> |