rope_helper/index.html

1839 lines
95 KiB
HTML
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="zh-TW">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>國小學生跳繩運動紀錄與 BMI 智能建議系統</title>
<!-- Tailwind CSS CDN -->
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
theme: {
extend: {
colors: {
brand: {
50: '#f0fdf4',
100: '#dcfce7',
500: '#22c55e',
600: '#16a34a',
700: '#15803d',
},
primary: {
50: '#f0f9ff',
100: '#e0f2fe',
500: '#0ea5e9',
600: '#0284c7',
700: '#0369a1',
}
}
}
}
}
</script>
<!-- Vue 3 CDN -->
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
<!-- SheetJS (xlsx) CDN -->
<script src="https://cdn.jsdelivr.net/npm/xlsx@0.18.5/dist/xlsx.full.min.js"></script>
<!-- Chart.js CDN -->
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
<style>
@media screen {
#print-area {
display: none !important;
}
}
@media print {
@page {
size: A4 portrait;
margin: 1.2cm;
}
html, body {
background: #ffffff !important;
color: #000000 !important;
margin: 0 !important;
padding: 0 !important;
width: 100% !important;
height: auto !important;
}
/* 隱藏主螢幕所有非列印區域與彈窗 */
#app > header,
#app > main,
#app > footer,
#app > div.fixed,
.no-print,
.fixed {
display: none !important;
}
#print-area {
display: block !important;
visibility: visible !important;
position: static !important;
width: 100% !important;
margin: 0 !important;
padding: 0 !important;
background: #ffffff !important;
}
#print-area * {
visibility: visible !important;
-webkit-print-color-adjust: exact !important;
print-color-adjust: exact !important;
}
.page-break {
page-break-after: always !important;
break-after: page !important;
page-break-inside: avoid !important;
break-inside: avoid !important;
}
}
[v-cloak] {
display: none;
}
/* 自訂捲軸樣式 */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: #f1f5f9;
}
::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 9999px;
}
::-webkit-scrollbar-thumb:hover {
background: #94a3b8;
}
</style>
</head>
<body class="bg-slate-50 text-slate-800 min-h-screen antialiased flex flex-col font-sans">
<div id="app" v-cloak class="min-h-screen flex flex-col justify-between">
<!-- 頂部 Toast 通知提示 -->
<div class="fixed top-4 right-4 z-50 flex flex-col gap-2 pointer-events-none">
<transition-group
enter-active-class="transform ease-out duration-300 transition"
enter-from-class="translate-y-2 opacity-0 sm:translate-y-0 sm:translate-x-2"
enter-to-class="translate-y-0 opacity-100 sm:translate-x-0"
leave-active-class="transition ease-in duration-100"
leave-from-class="opacity-100"
leave-to-class="opacity-0">
<div
v-for="toast in toasts"
:key="toast.id"
:class="getToastClass(toast.type)"
class="pointer-events-auto max-w-sm w-full shadow-lg rounded-2xl p-4 flex items-center gap-3 border backdrop-blur-md">
<span class="text-lg">{{ getToastIcon(toast.type) }}</span>
<p class="text-xs font-semibold flex-1 leading-snack">{{ toast.message }}</p>
<button @click="removeToast(toast.id)" class="opacity-60 hover:opacity-100 text-xs"></button>
</div>
</transition-group>
</div>
<div>
<!-- 頂部導航與標題 -->
<header class="bg-gradient-to-r from-teal-700 via-emerald-600 to-green-600 text-white shadow-md sticky top-0 z-30">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-3.5 flex flex-wrap items-center justify-between gap-4">
<div class="flex items-center space-x-3">
<div class="p-2 bg-white/20 rounded-2xl backdrop-blur-sm shadow-inner flex items-center justify-center">
<svg class="w-7 h-7 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
</div>
<div>
<h1 class="text-xl sm:text-2xl font-black tracking-tight flex items-center gap-2">
國小跳繩健康管理與 AI 運動建議系統
<span class="text-xs bg-amber-400 text-amber-950 font-bold px-2.5 py-0.5 rounded-full shadow-sm">體育署 SH150 • 國教署指引</span>
</h1>
<p class="text-xs text-emerald-100 hidden sm:block mt-0.5">整合衛福部兒童生長 BMI 常模與 Google Gemini AI守護學童心肺與體能成長</p>
</div>
</div>
<!-- 頂部快捷按鈕 -->
<div class="flex items-center gap-2.5">
<button @click="showApiModal = true" class="flex items-center gap-2 px-3.5 py-1.5 bg-white/15 hover:bg-white/25 rounded-xl text-xs sm:text-sm font-medium transition backdrop-blur-sm border border-white/20">
<span class="w-2.5 h-2.5 rounded-full" :class="apiKey ? 'bg-emerald-300 animate-pulse' : 'bg-rose-300'"></span>
<span>{{ apiKey ? `AI: ${selectedModel}` : '設定 AI Studio Key' }}</span>
</button>
<button @click="loadSampleData" class="flex items-center gap-1.5 px-3.5 py-1.5 bg-amber-400 hover:bg-amber-300 text-amber-950 rounded-xl text-xs sm:text-sm font-bold shadow-sm transition">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" /></svg>
載入示範班級
</button>
</div>
</div>
</header>
<!-- 主要內容區 -->
<main class="max-w-7xl w-full mx-auto px-4 sm:px-6 lg:px-8 py-6 space-y-6">
<!-- 狀態欄 / 快速導航標籤 -->
<div class="flex flex-wrap items-center justify-between gap-4 bg-white p-4 rounded-2xl shadow-sm border border-slate-100">
<div class="flex items-center gap-2 overflow-x-auto pb-1 sm:pb-0">
<button
@click="activeTab = 'overview'"
:class="activeTab === 'overview' ? 'bg-emerald-600 text-white shadow-sm' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'"
class="px-4 py-2 rounded-xl text-sm font-semibold transition flex items-center gap-2 whitespace-nowrap">
<span>📊 班級數據總覽</span>
</button>
<button
@click="activeTab = 'students'"
:class="activeTab === 'students' ? 'bg-emerald-600 text-white shadow-sm' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'"
class="px-4 py-2 rounded-xl text-sm font-semibold transition flex items-center gap-2 whitespace-nowrap">
<span>🏃 學生清單與跳繩紀錄 ({{ students.length }}人)</span>
</button>
<button
@click="activeTab = 'import'"
:class="activeTab === 'import' ? 'bg-emerald-600 text-white shadow-sm' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'"
class="px-4 py-2 rounded-xl text-sm font-semibold transition flex items-center gap-2 whitespace-nowrap">
<span>📥 Excel 匯入與範本</span>
</button>
</div>
<!-- 匯出與列印動作 -->
<div class="flex items-center gap-2">
<button
@click="exportAnalysisExcel"
:disabled="students.length === 0"
class="px-3.5 py-2 bg-slate-800 hover:bg-slate-900 disabled:opacity-50 text-white rounded-xl text-xs sm:text-sm font-medium transition flex items-center gap-1.5 shadow-sm">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /></svg>
匯出分析 Excel
</button>
<button
@click="openPrintAllModal"
:disabled="students.length === 0"
class="px-3.5 py-2 bg-teal-600 hover:bg-teal-700 disabled:opacity-50 text-white rounded-xl text-xs sm:text-sm font-medium transition flex items-center gap-1.5 shadow-sm">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 17h2a2 2 0 002-2v-4a2 2 0 00-2-2H5a2 2 0 00-2 2v4a2 2 0 002 2h2m2 4h6a2 2 0 002-2v-4a2 2 0 00-2-2H9a2 2 0 00-2 2v4a2 2 0 002 2zm8-12V5a2 2 0 00-2-2H9a2 2 0 00-2 2v4h10z" /></svg>
列印全班成長卡 (A4)
</button>
</div>
</div>
<!-- TAB 1: 班級數據總覽 -->
<div v-show="activeTab === 'overview'" class="space-y-6">
<!-- 頂部指標卡片 -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
<div class="bg-white p-5 rounded-2xl border border-slate-100 shadow-sm flex flex-col justify-between">
<span class="text-xs font-semibold text-slate-500 uppercase tracking-wider">班級學生總數</span>
<div class="mt-2 flex items-baseline gap-2">
<span class="text-3xl font-extrabold text-slate-900">{{ students.length }}</span>
<span class="text-xs text-slate-500">位學童</span>
</div>
<span class="text-xs text-emerald-600 mt-2 font-medium">男 {{ boyCount }} 人 / 女 {{ girlCount }} 人</span>
</div>
<div class="bg-white p-5 rounded-2xl border border-slate-100 shadow-sm flex flex-col justify-between">
<span class="text-xs font-semibold text-slate-500 uppercase tracking-wider">累積總跳繩次數</span>
<div class="mt-2 flex items-baseline gap-2">
<span class="text-3xl font-extrabold text-emerald-600">{{ totalClassJumps.toLocaleString() }}</span>
<span class="text-xs text-slate-500"></span>
</div>
<span class="text-xs text-slate-500 mt-2">人均 {{ students.length ? Math.round(totalClassJumps / students.length).toLocaleString() : 0 }} 次</span>
</div>
<div class="bg-white p-5 rounded-2xl border border-slate-100 shadow-sm flex flex-col justify-between">
<span class="text-xs font-semibold text-slate-500 uppercase tracking-wider">累積運動總時間</span>
<div class="mt-2 flex items-baseline gap-2">
<span class="text-3xl font-extrabold text-sky-600">{{ (totalClassMinutes / 60).toFixed(1) }}</span>
<span class="text-xs text-slate-500">小時 ({{ totalClassMinutes.toLocaleString() }} 分)</span>
</div>
<span class="text-xs text-slate-500 mt-2">人均 {{ students.length ? Math.round(totalClassMinutes / students.length) : 0 }} 分鐘</span>
</div>
<div class="bg-white p-5 rounded-2xl border border-slate-100 shadow-sm flex flex-col justify-between">
<span class="text-xs font-semibold text-slate-500 uppercase tracking-wider">AI 建議生成進度</span>
<div class="mt-2 flex items-baseline gap-2">
<span class="text-3xl font-extrabold text-purple-600">{{ aiGeneratedCount }}</span>
<span class="text-xs text-slate-500">/ {{ students.length }} 人</span>
</div>
<div class="w-full bg-slate-100 h-1.5 rounded-full mt-3 overflow-hidden">
<div class="bg-purple-600 h-full transition-all duration-500" :style="{ width: students.length ? (aiGeneratedCount / students.length * 100) + '%' : '0%' }"></div>
</div>
</div>
</div>
<!-- 統計圖表區 -->
<div class="grid grid-cols-1 lg:grid-cols-12 gap-6">
<!-- 左側BMI 分布圖表 -->
<div class="lg:col-span-5 bg-white p-6 rounded-2xl border border-slate-100 shadow-sm flex flex-col">
<div class="flex items-center justify-between mb-4">
<div>
<h3 class="font-bold text-slate-800 flex items-center gap-2">
<span>衛福部兒童 BMI 常模分布</span>
</h3>
<p class="text-xs text-slate-400">依性別年齡標準界定體位常態</p>
</div>
<span class="text-xs font-semibold bg-emerald-50 text-emerald-700 px-2.5 py-1 rounded-lg">國教署標準</span>
</div>
<div class="relative flex-1 min-h-[260px] flex items-center justify-center">
<canvas id="bmiChart"></canvas>
</div>
<div class="grid grid-cols-4 gap-2 text-center mt-4 pt-3 border-t border-slate-100 text-xs">
<div class="bg-sky-50 p-2 rounded-xl">
<div class="text-sky-700 font-bold text-sm">{{ bmiSummary.underweight }}</div>
<div class="text-sky-600 font-medium">過輕</div>
</div>
<div class="bg-emerald-50 p-2 rounded-xl">
<div class="text-emerald-700 font-bold text-sm">{{ bmiSummary.normal }}</div>
<div class="text-emerald-600 font-medium">正常</div>
</div>
<div class="bg-amber-50 p-2 rounded-xl">
<div class="text-amber-700 font-bold text-sm">{{ bmiSummary.overweight }}</div>
<div class="text-amber-600 font-medium">過重</div>
</div>
<div class="bg-rose-50 p-2 rounded-xl">
<div class="text-rose-700 font-bold text-sm">{{ bmiSummary.obese }}</div>
<div class="text-rose-600 font-medium">肥胖</div>
</div>
</div>
</div>
<!-- 右側:每週跳繩趨勢圖表 -->
<div class="lg:col-span-7 bg-white p-6 rounded-2xl border border-slate-100 shadow-sm flex flex-col">
<div class="flex items-center justify-between mb-4">
<div>
<h3 class="font-bold text-slate-800">全班跳繩運動週趨勢 (近12週)</h3>
<p class="text-xs text-slate-400">分析全班每週累計次數與運動時間變化</p>
</div>
<div class="flex items-center gap-3 text-xs">
<span class="flex items-center gap-1.5 text-emerald-600 font-medium"><span class="w-3 h-3 rounded-full bg-emerald-500"></span> 跳繩總次數</span>
<span class="flex items-center gap-1.5 text-sky-600 font-medium"><span class="w-3 h-3 rounded-full bg-sky-500"></span> 運動分鐘數</span>
</div>
</div>
<div class="relative flex-1 min-h-[260px]">
<canvas id="trendChart"></canvas>
</div>
</div>
</div>
<!-- 國教署 SH150 體適能達標率提示區 -->
<div class="bg-gradient-to-r from-amber-500/10 via-emerald-500/10 to-teal-500/10 p-5 rounded-2xl border border-emerald-200 flex flex-col md:flex-row items-start md:items-center justify-between gap-4">
<div class="flex items-start gap-3.5">
<div class="p-2.5 bg-emerald-600 text-white rounded-xl shadow-sm mt-0.5">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
</div>
<div>
<h4 class="font-bold text-slate-800">教育部體育署 SH150 與跳繩體適能方案指引</h4>
<p class="text-xs text-slate-600 mt-1 leading-relaxed">
國教署推動國小學童在校與課後每週運動 150 分鐘。跳繩是鍛鍊心肺耐力、骨骼密度與身體協調的最佳全身性運動。
本系統已依據學童 BMI 與年級生理發展,為您配置最適切的 AI 運動評估模型。
</p>
</div>
</div>
<button
@click="activeTab = 'students'"
class="px-4 py-2 bg-emerald-600 hover:bg-emerald-700 text-white text-xs sm:text-sm font-semibold rounded-xl whitespace-nowrap shadow-sm transition">
查看學生運動名單 →
</button>
</div>
</div>
<!-- TAB 2: 學生清單與跳繩紀錄 -->
<div v-show="activeTab === 'students'" class="space-y-4">
<!-- 操作控制列 -->
<div class="bg-white p-4 rounded-2xl border border-slate-100 shadow-sm flex flex-wrap items-center justify-between gap-3">
<!-- 篩選與搜尋 -->
<div class="flex flex-wrap items-center gap-2.5">
<div class="relative">
<input
type="text"
v-model="searchQuery"
placeholder="搜尋座號、姓名..."
class="pl-9 pr-3 py-1.5 text-sm bg-slate-50 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-emerald-500 w-44 sm:w-56"
/>
<svg class="w-4 h-4 text-slate-400 absolute left-3 top-2.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /></svg>
</div>
<select v-model="filterBmi" class="px-3 py-1.5 text-sm bg-slate-50 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-emerald-500">
<option value="ALL">全部體位 ({{ students.length }})</option>
<option value="體重過輕">體重過輕 ({{ bmiSummary.underweight }})</option>
<option value="正常範圍">正常範圍 ({{ bmiSummary.normal }})</option>
<option value="體重過重">體重過重 ({{ bmiSummary.overweight }})</option>
<option value="肥胖">肥胖 ({{ bmiSummary.obese }})</option>
</select>
<select v-model="filterAiStatus" class="px-3 py-1.5 text-sm bg-slate-50 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-emerald-500">
<option value="ALL">全部 AI 狀態</option>
<option value="DONE">已生成建議</option>
<option value="NONE">尚未生成</option>
</select>
</div>
<!-- 批量 AI 生成按鈕 -->
<div class="flex items-center gap-2">
<button
@click="startBatchAiGeneration"
:disabled="isGeneratingBatch || students.length === 0"
class="px-4 py-2 bg-gradient-to-r from-purple-600 to-indigo-600 hover:from-purple-700 hover:to-indigo-700 disabled:opacity-50 text-white rounded-xl text-sm font-semibold shadow-sm transition flex items-center gap-2">
<svg class="w-4 h-4" :class="isGeneratingBatch ? 'animate-spin' : ''" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" /></svg>
<span>{{ isGeneratingBatch ? `AI 批量生成中 (${batchProgress.current}/${batchProgress.total})` : '一鍵全班 AI 運動建議生成' }}</span>
</button>
<button
v-if="isGeneratingBatch"
@click="stopBatchGeneration"
class="px-3 py-2 bg-rose-500 hover:bg-rose-600 text-white rounded-xl text-xs font-semibold transition">
停止
</button>
</div>
</div>
<!-- 批量進度條 (當生成中時顯示) -->
<div v-if="isGeneratingBatch" class="bg-purple-50 border border-purple-200 p-4 rounded-2xl space-y-2">
<div class="flex justify-between text-xs font-semibold text-purple-900">
<span>正在為【{{ batchProgress.currentStudentName }}】生成國教署跳繩處方建議... (速率防護間隔中)</span>
<span>{{ Math.round((batchProgress.current / batchProgress.total) * 100) }}%</span>
</div>
<div class="w-full bg-purple-200 h-2 rounded-full overflow-hidden">
<div class="bg-purple-600 h-full transition-all duration-300" :style="{ width: (batchProgress.current / batchProgress.total * 100) + '%' }"></div>
</div>
</div>
<!-- 學生表格卡片 -->
<div class="bg-white rounded-2xl border border-slate-100 shadow-sm overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full text-left text-sm text-slate-600">
<thead class="bg-slate-50 text-xs font-bold text-slate-500 uppercase border-b border-slate-100">
<tr>
<th class="px-4 py-3.5">座號</th>
<th class="px-4 py-3.5">姓名 / 性別</th>
<th class="px-4 py-3.5">年級 / 年齡</th>
<th class="px-4 py-3.5">身高 / 體重</th>
<th class="px-4 py-3.5">BMI 與體位判定</th>
<th class="px-4 py-3.5 text-right">累計跳繩次數</th>
<th class="px-4 py-3.5 text-right">累計時間 (分)</th>
<th class="px-4 py-3.5 text-center">打卡天數</th>
<th class="px-4 py-3.5">AI 運動處方建議</th>
<th class="px-4 py-3.5 text-center">操作</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100">
<tr v-for="student in filteredStudents" :key="student.id" class="hover:bg-slate-50/80 transition">
<td class="px-4 py-3.5 font-bold text-slate-800">
{{ student.seatNo || '-' }}
</td>
<td class="px-4 py-3.5 font-semibold text-slate-900">
<div class="flex items-center gap-1.5">
<span>{{ student.name }}</span>
<span :class="student.gender === '男' ? 'bg-blue-100 text-blue-700' : 'bg-pink-100 text-pink-700'" class="text-[10px] px-1.5 py-0.5 rounded font-bold">
{{ student.gender }}
</span>
</div>
</td>
<td class="px-4 py-3.5 text-slate-600">
{{ student.grade }}年級 <span class="text-xs text-slate-400">({{ student.age }}歲)</span>
</td>
<td class="px-4 py-3.5 text-slate-700 whitespace-nowrap">
{{ student.height }} cm / {{ student.weight }} kg
</td>
<td class="px-4 py-3.5 whitespace-nowrap">
<div class="flex items-center gap-2">
<span class="font-mono font-bold text-slate-800">{{ student.bmi.toFixed(1) }}</span>
<span :class="getBmiBadgeClass(student.bmiStatus)" class="text-xs px-2 py-0.5 rounded-full font-bold">
{{ student.bmiStatus }}
</span>
</div>
</td>
<td class="px-4 py-3.5 text-right font-mono font-bold text-emerald-600 whitespace-nowrap">
{{ (student.totalJumps || 0).toLocaleString() }} 次
</td>
<td class="px-4 py-3.5 text-right font-mono font-semibold text-sky-600 whitespace-nowrap">
{{ student.totalMinutes || 0 }} 分
</td>
<td class="px-4 py-3.5 text-center text-slate-700">
<span class="px-2 py-0.5 bg-slate-100 rounded-lg text-xs font-semibold">{{ student.logDays || 0 }} 天</span>
</td>
<td class="px-4 py-3.5 max-w-xs truncate">
<div v-if="student.aiAdvice" class="text-xs text-slate-700 line-clamp-2" :title="student.aiAdvice">
{{ student.aiAdvice }}
</div>
<span v-else class="text-xs text-slate-400 italic">尚未生成建議</span>
</td>
<td class="px-4 py-3.5 text-center whitespace-nowrap">
<div class="flex items-center justify-center gap-1.5">
<button
@click="openStudentDetail(student)"
class="p-1.5 text-slate-600 hover:text-emerald-600 hover:bg-emerald-50 rounded-lg transition"
title="查看詳情與圖表">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" /></svg>
</button>
<button
@click="generateSingleAiAdvice(student)"
:disabled="student.isGeneratingAi"
class="p-1.5 text-purple-600 hover:text-purple-700 hover:bg-purple-50 rounded-lg transition disabled:opacity-50"
title="生成/重新生成 AI 建議">
<svg class="w-4 h-4" :class="student.isGeneratingAi ? 'animate-spin' : ''" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" /></svg>
</button>
<button
@click="printSingleStudent(student)"
class="p-1.5 text-teal-600 hover:text-teal-700 hover:bg-teal-50 rounded-lg transition"
title="列印運動卡">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 17h2a2 2 0 002-2v-4a2 2 0 00-2-2H5a2 2 0 00-2 2v4a2 2 0 002 2h2m2 4h6a2 2 0 002-2v-4a2 2 0 00-2-2H9a2 2 0 00-2 2v4a2 2 0 002 2zm8-12V5a2 2 0 00-2-2H9a2 2 0 00-2 2v4h10z" /></svg>
</button>
</div>
</td>
</tr>
<tr v-if="filteredStudents.length === 0">
<td colspan="10" class="text-center py-10 text-slate-400">
無符合條件之學生資料,請嘗試清除搜尋條件或匯入 Excel 檔案。
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- TAB 3: Excel 匯入與範本下載 -->
<div v-show="activeTab === 'import'" class="space-y-6">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- 上傳 Excel 區域 -->
<div class="bg-white p-6 rounded-2xl border border-slate-100 shadow-sm flex flex-col justify-between space-y-4">
<div>
<div class="flex items-center gap-3">
<div class="p-2.5 bg-emerald-100 text-emerald-700 rounded-xl">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" /></svg>
</div>
<div>
<h3 class="font-bold text-slate-800 text-lg">匯入班級 Excel 活頁簿 / CSV</h3>
<p class="text-xs text-slate-400">支援單一活頁簿雙工作表,或分別上傳兩個檔案</p>
</div>
</div>
<!-- 拖放與選擇檔案 -->
<div
@dragover.prevent="isDragging = true"
@dragleave.prevent="isDragging = false"
@drop.prevent="handleFileDrop"
:class="isDragging ? 'border-emerald-500 bg-emerald-50/50' : 'border-slate-200 bg-slate-50'"
class="mt-5 border-2 border-dashed rounded-2xl p-8 text-center transition flex flex-col items-center justify-center cursor-pointer hover:border-emerald-400">
<input type="file" ref="fileInput" @change="handleFileUpload" accept=".xlsx, .xls, .csv" class="hidden" multiple />
<svg class="w-12 h-12 text-slate-400 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /></svg>
<p class="text-sm font-semibold text-slate-700">點擊選擇檔案 或 拖曳 Excel 檔案至此</p>
<p class="text-xs text-slate-400 mt-1">支援 .xlsx, .xls, .csv 格式 (多檔自動合併辨識)</p>
<button @click="$refs.fileInput.click()" class="mt-4 px-4 py-2 bg-emerald-600 hover:bg-emerald-700 text-white text-xs font-semibold rounded-xl shadow-sm transition">
瀏覽本機檔案
</button>
</div>
</div>
<!-- 智慧辨識提示 -->
<div class="bg-slate-50 p-4 rounded-xl text-xs text-slate-600 space-y-1.5 border border-slate-100">
<div class="font-bold text-slate-800 flex items-center gap-1.5">
<svg class="w-4 h-4 text-emerald-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
強大且彈性的智慧欄位對應支援:
</div>
<p><b>基本資料表</b>:自動對應「座號/學號/編號」、「姓名/學生姓名」、「性別 (男/女/1/2)」、「年級」、「身高(cm)」、「體重(kg)」等。</p>
<p><b>跳繩紀錄表</b>:自動對應「座號/姓名」、「日期 (支援西元/民國年/Excel數值日期)」、「跳繩次數/次數/下數」、「運動時間/時間(分鐘)」等。</p>
</div>
</div>
<!-- 下載範本與示範資料區域 -->
<div class="bg-white p-6 rounded-2xl border border-slate-100 shadow-sm flex flex-col justify-between space-y-4">
<div>
<div class="flex items-center gap-3">
<div class="p-2.5 bg-sky-100 text-sky-700 rounded-xl">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" /></svg>
</div>
<div>
<h3 class="font-bold text-slate-800 text-lg">標準 Excel 範本下載</h3>
<p class="text-xs text-slate-400">已預先規劃好工作表結構與格式,填入即可匯入</p>
</div>
</div>
<div class="mt-5 space-y-3">
<div class="p-4 bg-sky-50/50 border border-sky-100 rounded-xl flex items-center justify-between">
<div>
<div class="font-bold text-sm text-slate-800">📄 標準班級跳繩整合活頁簿 (.xlsx)</div>
<div class="text-xs text-slate-500 mt-0.5">含「學生基本資料」與「跳繩打卡紀錄」雙工作表與範例資料</div>
</div>
<button @click="downloadTemplate" class="px-3.5 py-1.5 bg-sky-600 hover:bg-sky-700 text-white rounded-xl text-xs font-semibold transition shadow-sm">
下載範本
</button>
</div>
<div class="p-4 bg-amber-50/50 border border-amber-100 rounded-xl flex items-center justify-between">
<div>
<div class="font-bold text-sm text-slate-800">🌟 載入 3 個月示範資料 (25 位學生)</div>
<div class="text-xs text-slate-500 mt-0.5">模擬三年二班 25 位學童連續 90 天各類體能跳繩數據</div>
</div>
<button @click="loadSampleData" class="px-3.5 py-1.5 bg-amber-500 hover:bg-amber-600 text-white rounded-xl text-xs font-semibold transition shadow-sm">
立即載入
</button>
</div>
</div>
</div>
<div class="border-t border-slate-100 pt-4 flex items-center justify-between text-xs text-slate-400">
<span>支援雙檔案同時上傳</span>
<button @click="clearAllData" class="text-rose-500 hover:text-rose-600 font-semibold">清空目前所有資料</button>
</div>
</div>
</div>
</div>
</main>
</div>
<!-- 學生個人詳細資訊 & AI 運動處方彈窗 (Modal) -->
<div v-if="selectedStudent" class="fixed inset-0 z-50 bg-slate-900/60 backdrop-blur-sm flex items-center justify-center p-4 overflow-y-auto">
<div class="bg-white w-full max-w-4xl rounded-3xl shadow-2xl border border-slate-100 overflow-hidden flex flex-col max-h-[90vh]">
<!-- 彈窗頂部 Header -->
<div class="bg-gradient-to-r from-emerald-600 to-teal-600 text-white px-6 py-4 flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-2xl bg-white/20 flex items-center justify-center text-lg font-bold">
{{ selectedStudent.seatNo }}
</div>
<div>
<h3 class="text-lg font-bold flex items-center gap-2">
{{ selectedStudent.name }}
<span class="text-xs font-normal bg-white/20 px-2 py-0.5 rounded-full">{{ selectedStudent.grade }}年級 / {{ selectedStudent.gender }} ({{ selectedStudent.age }}歲)</span>
</h3>
<p class="text-xs text-emerald-100">身高 {{ selectedStudent.height }} cm / 體重 {{ selectedStudent.weight }} kg (BMI: {{ selectedStudent.bmi.toFixed(1) }})</p>
</div>
</div>
<button @click="selectedStudent = null" class="text-white/80 hover:text-white p-1 rounded-lg hover:bg-white/10 transition">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /></svg>
</button>
</div>
<!-- 彈窗內容卷軸區 -->
<div class="p-6 space-y-6 overflow-y-auto flex-1">
<!-- 四大指標卡片 -->
<div class="grid grid-cols-2 sm:grid-cols-4 gap-3">
<div class="bg-slate-50 p-3.5 rounded-2xl border border-slate-100 text-center">
<div class="text-xs text-slate-400 font-semibold">BMI 體位判定</div>
<div class="text-base font-bold mt-1" :class="getBmiTextColor(selectedStudent.bmiStatus)">{{ selectedStudent.bmiStatus }}</div>
<div class="text-[11px] text-slate-500">標準常模對照</div>
</div>
<div class="bg-slate-50 p-3.5 rounded-2xl border border-slate-100 text-center">
<div class="text-xs text-slate-400 font-semibold">累積跳繩次數</div>
<div class="text-base font-bold text-emerald-600 mt-1">{{ (selectedStudent.totalJumps || 0).toLocaleString() }} 次</div>
<div class="text-[11px] text-slate-500">日均 {{ selectedStudent.logDays ? Math.round(selectedStudent.totalJumps / selectedStudent.logDays) : 0 }} 次</div>
</div>
<div class="bg-slate-50 p-3.5 rounded-2xl border border-slate-100 text-center">
<div class="text-xs text-slate-400 font-semibold">累積運動時長</div>
<div class="text-base font-bold text-sky-600 mt-1">{{ selectedStudent.totalMinutes || 0 }} 分鐘</div>
<div class="text-[11px] text-slate-500">共打卡 {{ selectedStudent.logDays || 0 }} 天</div>
</div>
<div class="bg-slate-50 p-3.5 rounded-2xl border border-slate-100 text-center">
<div class="text-xs text-slate-400 font-semibold">平均跳繩速率</div>
<div class="text-base font-bold text-purple-600 mt-1">
{{ selectedStudent.totalMinutes ? Math.round(selectedStudent.totalJumps / selectedStudent.totalMinutes) : 0 }} 下/分
</div>
<div class="text-[11px] text-slate-500">體適能強度指標</div>
</div>
</div>
<!-- 個人跳繩趨勢圖 -->
<div class="bg-white p-4 rounded-2xl border border-slate-100 shadow-sm">
<h4 class="font-bold text-slate-800 text-sm mb-2 flex items-center justify-between">
<span>📈 個人跳繩歷程趨勢圖</span>
<span class="text-xs text-slate-400">近幾週運動紀錄</span>
</h4>
<div class="relative h-48 w-full">
<canvas id="studentTrendChart"></canvas>
</div>
</div>
<!-- AI 運動建議生成區 -->
<div class="bg-gradient-to-br from-purple-50 via-indigo-50 to-pink-50 p-5 rounded-2xl border border-purple-100 space-y-3">
<div class="flex items-center justify-between flex-wrap gap-2">
<div class="flex items-center gap-2">
<span class="text-xl">🤖</span>
<div>
<h4 class="font-bold text-slate-900 text-sm">Google Gemini AI 國小跳繩運動處方建議</h4>
<p class="text-[11px] text-purple-700">依據國教署跳繩教學綱要、{{ selectedStudent.grade }}年級發展重點及【{{ selectedStudent.bmiStatus }}】量身規劃</p>
</div>
</div>
<div class="flex items-center gap-2">
<button
v-if="selectedStudent.aiAdvice"
@click="copyText(selectedStudent.aiAdvice)"
class="px-2.5 py-1.5 bg-white hover:bg-purple-100 text-purple-700 border border-purple-200 rounded-xl text-xs font-semibold shadow-xs transition flex items-center gap-1">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3" /></svg>
複製建議
</button>
<button
@click="generateSingleAiAdvice(selectedStudent)"
:disabled="selectedStudent.isGeneratingAi"
class="px-3.5 py-1.5 bg-purple-600 hover:bg-purple-700 disabled:opacity-50 text-white rounded-xl text-xs font-semibold shadow-sm transition flex items-center gap-1.5">
<svg class="w-3.5 h-3.5" :class="selectedStudent.isGeneratingAi ? 'animate-spin' : ''" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" /></svg>
<span>{{ selectedStudent.aiAdvice ? '重新生成建議' : '產生 AI 建議' }}</span>
</button>
</div>
</div>
<!-- AI 建議文字框 -->
<div v-if="selectedStudent.aiAdvice" class="relative">
<textarea
v-model="selectedStudent.aiAdvice"
rows="8"
class="w-full text-xs sm:text-sm bg-white/95 border border-purple-200 rounded-xl p-3.5 text-slate-700 focus:outline-none focus:ring-2 focus:ring-purple-500 leading-relaxed shadow-inner"
placeholder="AI 產生的運動建議可在此直接編輯微調...">
</textarea>
<div class="text-[10px] text-slate-400 text-right mt-1">老師可在此直接編輯文字微調內容</div>
</div>
<div v-else class="bg-white/60 p-4 rounded-xl text-center text-xs text-slate-500 border border-purple-100">
尚無運動建議。請確保已設定 Google AI Studio API Key並點擊右上角「產生 AI 建議」按鈕。
</div>
</div>
<!-- 近期詳細打卡紀錄清單 -->
<div>
<h4 class="font-bold text-slate-800 text-sm mb-2">📅 跳繩詳細打卡紀錄 ({{ selectedStudent.logs ? selectedStudent.logs.length : 0 }} 筆)</h4>
<div class="max-h-48 overflow-y-auto rounded-xl border border-slate-100">
<table class="w-full text-left text-xs text-slate-600">
<thead class="bg-slate-50 sticky top-0 font-semibold text-slate-500 border-b border-slate-100">
<tr>
<th class="px-3 py-2">日期</th>
<th class="px-3 py-2 text-right">跳繩次數</th>
<th class="px-3 py-2 text-right">運動時間 (分)</th>
<th class="px-3 py-2 text-right">速率 (下/分)</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100">
<tr v-for="log in (selectedStudent.logs || [])" :key="log.date" class="hover:bg-slate-50">
<td class="px-3 py-2 font-mono">{{ log.date }}</td>
<td class="px-3 py-2 text-right font-mono font-bold text-emerald-600">{{ log.count }} 次</td>
<td class="px-3 py-2 text-right font-mono text-sky-600">{{ log.minutes }} 分</td>
<td class="px-3 py-2 text-right font-mono text-slate-500">{{ log.minutes ? Math.round(log.count / log.minutes) : '-' }}</td>
</tr>
<tr v-if="!selectedStudent.logs || selectedStudent.logs.length === 0">
<td colspan="4" class="text-center py-4 text-slate-400">尚無打卡紀錄</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- 彈窗底部 Actions -->
<div class="bg-slate-50 px-6 py-3.5 border-t border-slate-100 flex items-center justify-between">
<button @click="printSingleStudent(selectedStudent)" class="px-4 py-2 bg-teal-600 hover:bg-teal-700 text-white rounded-xl text-xs sm:text-sm font-semibold transition flex items-center gap-1.5 shadow-sm">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 17h2a2 2 0 002-2v-4a2 2 0 00-2-2H5a2 2 0 00-2 2v4a2 2 0 002 2h2m2 4h6a2 2 0 002-2v-4a2 2 0 00-2-2H9a2 2 0 00-2 2v4a2 2 0 002 2zm8-12V5a2 2 0 00-2-2H9a2 2 0 00-2 2v4h10z" /></svg>
列印此學生運動建議卡 (A4)
</button>
<button @click="selectedStudent = null" class="px-4 py-2 bg-slate-200 hover:bg-slate-300 text-slate-700 rounded-xl text-xs sm:text-sm font-semibold transition">
關閉
</button>
</div>
</div>
</div>
<!-- API KEY 設定彈窗 (Modal) -->
<div v-if="showApiModal" class="fixed inset-0 z-50 bg-slate-900/60 backdrop-blur-sm flex items-center justify-center p-4">
<div class="bg-white w-full max-w-lg rounded-3xl shadow-2xl border border-slate-100 overflow-hidden">
<div class="bg-gradient-to-r from-purple-600 to-indigo-600 text-white px-6 py-4 flex items-center justify-between">
<div class="flex items-center gap-2">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" /></svg>
<h3 class="font-bold text-base">Google AI Studio API 設定</h3>
</div>
<button @click="showApiModal = false" class="text-white/80 hover:text-white">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /></svg>
</button>
</div>
<div class="p-6 space-y-4 text-sm text-slate-600">
<div>
<div class="flex items-center justify-between mb-1">
<label class="font-bold text-slate-700">Google AI Studio API Key</label>
<button @click="showApiKeyMask = !showApiKeyMask" class="text-xs text-purple-600 hover:text-purple-700">
{{ showApiKeyMask ? '顯示金鑰' : '隱藏金鑰' }}
</button>
</div>
<div class="relative">
<input
:type="showApiKeyMask ? 'password' : 'text'"
v-model="apiKeyInput"
placeholder="貼上您的 AI Studio API Key (AIzaSy...)"
class="w-full px-3.5 py-2.5 text-sm bg-slate-50 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-purple-500 font-mono"
/>
</div>
<p class="text-[11px] text-slate-400 mt-1">
🔑 金鑰將安全保存在您本機瀏覽器 localStorage 中,絕不上傳第三方伺服器。
</p>
</div>
<div>
<label class="block font-bold text-slate-700 mb-1">選擇 Gemini 模型</label>
<select v-model="selectedModel" class="w-full px-3.5 py-2 text-sm bg-slate-50 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-purple-500">
<option value="gemini-2.5-flash">gemini-2.5-flash (推薦,速度極快且聰慧)</option>
<option value="gemini-1.5-flash">gemini-1.5-flash (經典穩定版)</option>
<option value="gemini-2.0-flash">gemini-2.0-flash (次世代高速版)</option>
<option value="gemini-2.5-pro">gemini-2.5-pro (深度體適能推論版)</option>
</select>
</div>
<div>
<label class="block font-bold text-slate-700 mb-1">老師自訂特別指導方向 (選填 Prompt 補充)</label>
<textarea
v-model="customPromptExtra"
rows="2"
placeholder="例:請強調跳繩前後的足踝小腿伸展,多給予正向鼓勵..."
class="w-full text-xs bg-slate-50 border border-slate-200 rounded-xl p-2.5 text-slate-700 focus:outline-none focus:ring-2 focus:ring-purple-500">
</textarea>
</div>
<!-- 測試 API 連線按鈕 -->
<div class="flex items-center justify-between bg-slate-50 p-3 rounded-xl border border-slate-200">
<span class="text-xs text-slate-600 font-medium">連線狀態測試:</span>
<button
@click="testApiConnection"
:disabled="isTestingApi || !apiKeyInput.trim()"
class="px-3 py-1 bg-white hover:bg-slate-100 border border-slate-300 rounded-lg text-xs font-semibold text-slate-700 shadow-xs transition disabled:opacity-50 flex items-center gap-1.5">
<span v-if="isTestingApi" class="w-3 h-3 border-2 border-purple-600 border-t-transparent rounded-full animate-spin"></span>
<span>{{ isTestingApi ? '測試中...' : '測試 API 連線' }}</span>
</button>
</div>
<!-- 如何取得免費金鑰說明 -->
<div class="bg-purple-50/70 p-3.5 rounded-xl border border-purple-100 text-xs space-y-1">
<div class="font-bold text-purple-900 flex items-center gap-1">
<span>💡 如何免費取得 Google API Key</span>
</div>
<p>1. 前往 <a href="https://aistudio.google.com/app/apikey" target="_blank" class="text-purple-700 underline font-semibold">Google AI Studio</a> 登入 Google 帳號。</p>
<p>2. 點擊「Create API key」建立專屬金鑰後複製貼上至上方。</p>
<p>3. 免費方案即享每分鐘 15 次請求額度,足供班級使用!</p>
</div>
</div>
<div class="bg-slate-50 px-6 py-3.5 border-t border-slate-100 flex items-center justify-end gap-2">
<button @click="showApiModal = false" class="px-4 py-2 text-slate-600 hover:bg-slate-200 rounded-xl text-xs font-semibold transition">
取消
</button>
<button @click="saveApiKey" class="px-5 py-2 bg-purple-600 hover:bg-purple-700 text-white rounded-xl text-xs font-semibold transition shadow-sm">
儲存設定
</button>
</div>
</div>
</div>
<!-- 列印專用區域 (Print Area) -->
<div id="print-area">
<div v-for="student in studentsToPrint" :key="student.id" class="p-8 max-w-4xl mx-auto bg-white page-break border border-slate-300 rounded-2xl mb-8">
<!-- 學生運動成長卡 標題 -->
<div class="border-b-2 border-emerald-600 pb-4 mb-6 flex justify-between items-end">
<div>
<div class="text-xs font-bold text-emerald-700 tracking-wider">國民小學學生健康體適能成長檔案</div>
<h2 class="text-2xl font-black text-slate-900 mt-1">學生跳繩運動歷程與個別化體能處方建議</h2>
</div>
<div class="text-right text-xs text-slate-500">
<div>印製日期:{{ printDate }}</div>
<div class="font-bold text-slate-700">教育部體育署 SH150 普及化運動指引</div>
</div>
</div>
<!-- 基本資料與體位資訊欄 -->
<div class="grid grid-cols-4 gap-4 p-4 bg-slate-50 rounded-xl border border-slate-200 mb-6 text-sm">
<div><span class="text-slate-500">班級座號:</span><b class="text-slate-800">{{ student.grade }}年級 {{ student.seatNo }} 號</b></div>
<div><span class="text-slate-500">學生姓名:</span><b class="text-slate-800">{{ student.name }} ({{ student.gender }})</b></div>
<div><span class="text-slate-500">身高 / 體重:</span><b class="text-slate-800">{{ student.height }} cm / {{ student.weight }} kg</b></div>
<div><span class="text-slate-500">BMI 常模體位:</span><b class="text-slate-800">{{ student.bmi.toFixed(1) }} ({{ student.bmiStatus }})</b></div>
</div>
<!-- 跳繩歷程統計大盤 -->
<div class="grid grid-cols-4 gap-4 mb-6 text-center">
<div class="p-3 border border-emerald-200 bg-emerald-50/40 rounded-xl">
<div class="text-xs text-emerald-800 font-semibold">累計跳繩總次數</div>
<div class="text-xl font-black text-emerald-700 mt-1">{{ (student.totalJumps || 0).toLocaleString() }} <span class="text-xs font-normal"></span></div>
</div>
<div class="p-3 border border-sky-200 bg-sky-50/40 rounded-xl">
<div class="text-xs text-sky-800 font-semibold">累計運動時間</div>
<div class="text-xl font-black text-sky-700 mt-1">{{ student.totalMinutes || 0 }} <span class="text-xs font-normal">分鐘</span></div>
</div>
<div class="p-3 border border-slate-200 bg-slate-50 rounded-xl">
<div class="text-xs text-slate-600 font-semibold">打卡天數</div>
<div class="text-xl font-black text-slate-800 mt-1">{{ student.logDays || 0 }} <span class="text-xs font-normal"></span></div>
</div>
<div class="p-3 border border-purple-200 bg-purple-50/40 rounded-xl">
<div class="text-xs text-purple-800 font-semibold">平均跳繩速率</div>
<div class="text-xl font-black text-purple-700 mt-1">{{ student.totalMinutes ? Math.round(student.totalJumps / student.totalMinutes) : 0 }} <span class="text-xs font-normal">下/分</span></div>
</div>
</div>
<!-- 運動處方建議內容 -->
<div class="border-2 border-emerald-500/40 rounded-2xl p-6 bg-gradient-to-b from-white to-emerald-50/20 mb-8">
<h3 class="font-bold text-emerald-800 text-base mb-3 flex items-center gap-2">
<span>🏅 體能成長與跳繩運動個別化指引建議</span>
</h3>
<div class="text-sm text-slate-800 leading-relaxed whitespace-pre-line font-medium">
{{ student.aiAdvice || '此學生目前尚未生成 AI 運動建議。' }}
</div>
</div>
<!-- 簽章與回饋聯 -->
<div class="grid grid-cols-3 gap-6 pt-6 border-t border-slate-200 text-xs text-slate-600 text-center">
<div class="border-b border-dotted border-slate-400 pb-8">體育教師 / 導師簽章</div>
<div class="border-b border-dotted border-slate-400 pb-8">家長簽名確認</div>
<div class="border-b border-dotted border-slate-400 pb-8">家庭親職運動回饋</div>
</div>
</div>
</div>
<!-- 頁尾 -->
<footer class="bg-white border-t border-slate-100 py-4 mt-auto">
<div class="max-w-7xl mx-auto px-4 text-center text-xs text-slate-400 flex flex-col sm:flex-row items-center justify-between gap-2">
<span>© 2026 國小跳繩健康管理系統 • 體育署 SH150 體適能指引 • 衛福部國健署兒童 BMI 常模</span>
<span>純前端離線安全架構 • 本地運算保護學童隱私</span>
</div>
</footer>
</div>
<!-- 主應用程式 JS -->
<script>
// 衛福部國民健康署 6~12 歲兒童及青少年生長身體質量指數 (BMI) 建議值對照常模表
const TAIWAN_CHILDREN_BMI_NORMS = {
male: {
6: { underweight: 13.9, normalMax: 17.5, overweightMax: 19.0 },
7: { underweight: 14.1, normalMax: 17.9, overweightMax: 19.6 },
8: { underweight: 14.4, normalMax: 18.5, overweightMax: 20.3 },
9: { underweight: 14.7, normalMax: 19.3, overweightMax: 21.2 },
10: { underweight: 15.0, normalMax: 20.1, overweightMax: 22.1 },
11: { underweight: 15.4, normalMax: 20.9, overweightMax: 22.9 },
12: { underweight: 15.9, normalMax: 21.6, overweightMax: 23.7 },
},
female: {
6: { underweight: 13.6, normalMax: 17.1, overweightMax: 18.5 },
7: { underweight: 13.8, normalMax: 17.5, overweightMax: 19.0 },
8: { underweight: 14.1, normalMax: 18.1, overweightMax: 19.7 },
9: { underweight: 14.4, normalMax: 18.8, overweightMax: 20.6 },
10: { underweight: 14.8, normalMax: 19.6, overweightMax: 21.5 },
11: { underweight: 15.2, normalMax: 20.4, overweightMax: 22.3 },
12: { underweight: 15.7, normalMax: 21.1, overweightMax: 23.1 },
}
};
// Excel 日期與各種格式智慧解析器
const parseFlexibleDate = (val) => {
if (!val) return new Date().toISOString().slice(0, 10);
if (val instanceof Date) return val.toISOString().slice(0, 10);
if (typeof val === 'number') {
// Excel serial date to JS Date
const date = new Date(Math.round((val - 25569) * 86400 * 1000));
return !isNaN(date) ? date.toISOString().slice(0, 10) : new Date().toISOString().slice(0, 10);
}
const str = String(val).trim();
// 民國年格式如 113/06/01, 113-6-1
const rocMatch = str.match(/^(\d{2,3})[\/\-\.年](\d{1,2})[\/\-\.月](\d{1,2})日?$/);
if (rocMatch) {
const year = Number(rocMatch[1]) + 1911;
const month = String(rocMatch[2]).padStart(2, '0');
const day = String(rocMatch[3]).padStart(2, '0');
return `${year}-${month}-${day}`;
}
// 西元年格式
const standardDate = new Date(str.replace(/\//g, '-'));
if (!isNaN(standardDate.getTime())) {
return standardDate.toISOString().slice(0, 10);
}
return str;
};
const { createApp, ref, computed, onMounted, watch, nextTick } = Vue;
createApp({
setup() {
const activeTab = ref('overview');
const students = ref([]);
const searchQuery = ref('');
const filterBmi = ref('ALL');
const filterAiStatus = ref('ALL');
const selectedStudent = ref(null);
// Google AI Studio API
const apiKey = ref(localStorage.getItem('gemini_api_key') || '');
const apiKeyInput = ref(apiKey.value);
const showApiKeyMask = ref(true);
const selectedModel = ref(localStorage.getItem('gemini_model') || 'gemini-2.5-flash');
const customPromptExtra = ref(localStorage.getItem('gemini_custom_prompt') || '');
const showApiModal = ref(false);
const isTestingApi = ref(false);
// Toast 訊息佇列
const toasts = ref([]);
const addToast = (message, type = 'info') => {
const id = Date.now() + Math.random();
toasts.value.push({ id, message, type });
setTimeout(() => removeToast(id), 4000);
};
const removeToast = (id) => {
toasts.value = toasts.value.filter(t => t.id !== id);
};
const getToastClass = (type) => {
switch(type) {
case 'success': return 'bg-emerald-800/90 text-white border-emerald-600';
case 'error': return 'bg-rose-800/90 text-white border-rose-600';
case 'warning': return 'bg-amber-800/90 text-white border-amber-600';
default: return 'bg-slate-800/90 text-white border-slate-600';
}
};
const getToastIcon = (type) => {
switch(type) {
case 'success': return '✅';
case 'error': return '❌';
case 'warning': return '⚠️';
default: return '';
}
};
// 批次 AI 生成狀態
const isGeneratingBatch = ref(false);
const batchProgress = ref({ current: 0, total: 0, currentStudentName: '' });
let abortBatchFlag = false;
// 列印
const studentsToPrint = ref([]);
const printDate = ref(new Date().toLocaleDateString('zh-TW'));
// 拖拉檔案
const isDragging = ref(false);
// 圖表實例
let bmiChartInstance = null;
let trendChartInstance = null;
let studentTrendChartInstance = null;
// 計算學童 BMI
const calculateBmi = (heightCm, weightKg) => {
if (!heightCm || !weightKg) return 0;
const hM = heightCm / 100;
return Number((weightKg / (hM * hM)).toFixed(1));
};
// 判定衛福部國小學童體位狀態
const determineBmiStatus = (gender, age, bmi) => {
const gKey = (gender === '女' || gender === 'female' || gender === 'F' || gender === '2') ? 'female' : 'male';
const validAge = Math.min(Math.max(Number(age) || 9, 6), 12);
const norm = TAIWAN_CHILDREN_BMI_NORMS[gKey][validAge] || TAIWAN_CHILDREN_BMI_NORMS[gKey][9];
if (bmi < norm.underweight) return '體重過輕';
if (bmi <= norm.normalMax) return '正常範圍';
if (bmi <= norm.overweightMax) return '體重過重';
return '肥胖';
};
// 規範性別欄位文字
const normalizeGender = (val) => {
const str = String(val || '').trim();
if (str === '女' || str === 'female' || str === 'F' || str === '2' || str === 'girl') return '女';
return '男';
};
// BMI 徽章樣式
const getBmiBadgeClass = (status) => {
switch (status) {
case '體重過輕': return 'bg-sky-100 text-sky-700';
case '正常範圍': return 'bg-emerald-100 text-emerald-700';
case '體重過重': return 'bg-amber-100 text-amber-800';
case '肥胖': return 'bg-rose-100 text-rose-700';
default: return 'bg-slate-100 text-slate-700';
}
};
const getBmiTextColor = (status) => {
switch (status) {
case '體重過輕': return 'text-sky-600';
case '正常範圍': return 'text-emerald-600';
case '體重過重': return 'text-amber-600';
case '肥胖': return 'text-rose-600';
default: return 'text-slate-600';
}
};
// 複製文字至剪貼簿
const copyText = (text) => {
if (!text) return;
navigator.clipboard.writeText(text).then(() => {
addToast('已成功複製運動建議至剪貼簿!', 'success');
}).catch(() => {
addToast('複製失敗,請手動選取文字複製', 'warning');
});
};
// 統計摘要計算
const boyCount = computed(() => students.value.filter(s => s.gender === '男').length);
const girlCount = computed(() => students.value.filter(s => s.gender === '女').length);
const totalClassJumps = computed(() => {
return students.value.reduce((sum, s) => sum + (s.totalJumps || 0), 0);
});
const totalClassMinutes = computed(() => {
return students.value.reduce((sum, s) => sum + (s.totalMinutes || 0), 0);
});
const aiGeneratedCount = computed(() => {
return students.value.filter(s => s.aiAdvice && s.aiAdvice.trim().length > 0).length;
});
const bmiSummary = computed(() => {
const summary = { underweight: 0, normal: 0, overweight: 0, obese: 0 };
students.value.forEach(s => {
if (s.bmiStatus === '體重過輕') summary.underweight++;
else if (s.bmiStatus === '正常範圍') summary.normal++;
else if (s.bmiStatus === '體重過重') summary.overweight++;
else if (s.bmiStatus === '肥胖') summary.obese++;
});
return summary;
});
// 篩選後學生
const filteredStudents = computed(() => {
return students.value.filter(s => {
const matchesQuery = !searchQuery.value ||
s.name.includes(searchQuery.value) ||
String(s.seatNo).includes(searchQuery.value);
const matchesBmi = filterBmi.value === 'ALL' || s.bmiStatus === filterBmi.value;
const hasAi = !!(s.aiAdvice && s.aiAdvice.trim());
const matchesAi = filterAiStatus.value === 'ALL' ||
(filterAiStatus.value === 'DONE' && hasAi) ||
(filterAiStatus.value === 'NONE' && !hasAi);
return matchesQuery && matchesBmi && matchesAi;
});
});
// 儲存 API Key
const saveApiKey = () => {
apiKey.value = apiKeyInput.value.trim();
localStorage.setItem('gemini_api_key', apiKey.value);
localStorage.setItem('gemini_model', selectedModel.value);
localStorage.setItem('gemini_custom_prompt', customPromptExtra.value);
showApiModal.value = false;
addToast('API Key 與模型設定已成功儲存!', 'success');
};
// 測試 API 連線
const testApiConnection = async () => {
const testKey = apiKeyInput.value.trim();
if (!testKey) {
addToast('請先輸入 API Key 再進行測試!', 'warning');
return;
}
isTestingApi.value = true;
const url = `https://generativelanguage.googleapis.com/v1beta/models/${selectedModel.value}:generateContent?key=${testKey}`;
try {
const resp = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
contents: [{ parts: [{ text: '請回答 OK' }] }],
generationConfig: { maxOutputTokens: 10 }
})
});
if (resp.ok) {
addToast(`連線成功Google Gemini API (${selectedModel.value}) 運作正常!`, 'success');
} else {
const err = await resp.json().catch(() => ({}));
addToast(`連線失敗: ${err.error?.message || resp.statusText}`, 'error');
}
} catch (e) {
addToast(`連線異常: ${e.message}`, 'error');
} finally {
isTestingApi.value = false;
}
};
// 更新班級圖表
const updateOverviewCharts = () => {
nextTick(() => {
// BMI 甜甜圈圖
const ctxBmi = document.getElementById('bmiChart');
if (ctxBmi) {
if (bmiChartInstance) bmiChartInstance.destroy();
bmiChartInstance = new Chart(ctxBmi, {
type: 'doughnut',
data: {
labels: ['體重過輕', '正常範圍', '體重過重', '肥胖'],
datasets: [{
data: [
bmiSummary.value.underweight,
bmiSummary.value.normal,
bmiSummary.value.overweight,
bmiSummary.value.obese
],
backgroundColor: ['#38bdf8', '#22c55e', '#fbbf24', '#f87171'],
borderWidth: 2,
borderColor: '#ffffff'
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { position: 'bottom' }
},
cutout: '65%'
}
});
}
// 週趨勢圖
const ctxTrend = document.getElementById('trendChart');
if (ctxTrend) {
if (trendChartInstance) trendChartInstance.destroy();
const weekLabels = ['第1週', '第2週', '第3週', '第4週', '第5週', '第6週', '第7週', '第8週', '第9週', '第10週', '第11週', '第12週'];
const weeklyJumps = Array(12).fill(0);
const weeklyMins = Array(12).fill(0);
const now = new Date();
students.value.forEach(s => {
if (s.logs && s.logs.length) {
s.logs.forEach(log => {
const logDate = new Date(log.date);
const dayDiff = Math.max(0, Math.floor((now - logDate) / (1000 * 60 * 60 * 24)));
const weekIdx = Math.min(11, Math.max(0, 11 - Math.floor(dayDiff / 7)));
weeklyJumps[weekIdx] += (Number(log.count) || 0);
weeklyMins[weekIdx] += (Number(log.minutes) || 0);
});
}
});
trendChartInstance = new Chart(ctxTrend, {
type: 'line',
data: {
labels: weekLabels,
datasets: [
{
label: '全班跳繩總次數',
data: weeklyJumps,
borderColor: '#10b981',
backgroundColor: 'rgba(16, 185, 129, 0.1)',
yAxisID: 'y',
tension: 0.3,
fill: true
},
{
label: '全班運動分鐘數',
data: weeklyMins,
borderColor: '#0284c7',
backgroundColor: 'transparent',
borderDash: [5, 5],
yAxisID: 'y1',
tension: 0.3
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
y: {
type: 'linear',
display: true,
position: 'left',
title: { display: true, text: '次數' }
},
y1: {
type: 'linear',
display: true,
position: 'right',
grid: { drawOnChartArea: false },
title: { display: true, text: '分鐘' }
}
}
}
});
}
});
};
// 開啟學生個人詳情
const openStudentDetail = (student) => {
selectedStudent.value = student;
nextTick(() => {
const ctx = document.getElementById('studentTrendChart');
if (ctx) {
if (studentTrendChartInstance) studentTrendChartInstance.destroy();
const logs = student.logs || [];
const recentLogs = [...logs].sort((a, b) => new Date(a.date) - new Date(b.date)).slice(-15);
const labels = recentLogs.map(l => l.date.slice(5));
const dataCounts = recentLogs.map(l => l.count);
const dataMinutes = recentLogs.map(l => l.minutes);
studentTrendChartInstance = new Chart(ctx, {
type: 'bar',
data: {
labels: labels.length ? labels : ['無資料'],
datasets: [
{
label: '跳繩次數',
data: dataCounts.length ? dataCounts : [0],
backgroundColor: '#10b981',
borderRadius: 6
},
{
label: '運動時間 (分)',
data: dataMinutes.length ? dataMinutes : [0],
backgroundColor: '#38bdf8',
borderRadius: 6
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { position: 'top' } }
}
});
}
});
};
// Gemini AI 調用 (含 429 自動重試防護)
const callGeminiApi = async (student, retryCount = 0) => {
if (!apiKey.value) {
showApiModal.value = true;
throw new Error('請先輸入並儲存 Google AI Studio API Key');
}
const model = selectedModel.value || 'gemini-2.5-flash';
const url = `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${apiKey.value}`;
const avgRate = student.totalMinutes ? Math.round(student.totalJumps / student.totalMinutes) : 0;
const avgJumpsPerDay = student.logDays ? Math.round(student.totalJumps / student.logDays) : 0;
const avgMinsPerDay = student.logDays ? Math.round(student.totalMinutes / student.logDays) : 0;
const prompt = `你是一位資深的台灣國小體適能指導專家與學校體能處方顧問。請為學生「${student.name}」制定一份高度專業、數據導向、具體量化且符合生理發育的【國小學童跳繩體適能成長處方與運動指導報告】。
【學童生理與體位數據】
- 學生姓名:${student.name} (${student.gender}生,${student.grade}年級,實足年齡約 ${student.age} 歲)
- 身高 / 體重:${student.height} cm / ${student.weight} kg
- BMI 指數:${student.bmi.toFixed(1)}(依衛福部兒童生長常模判定為:【${student.bmiStatus}】)
【近 3 個月跳繩運動量化數據】
- 累計打卡天數:${student.logDays || 0} 天 / 累計總跳繩次數:${(student.totalJumps || 0).toLocaleString()}
- 累計運動總時間:${student.totalMinutes || 0} 分鐘
- 平均跳繩速率:${avgRate} 下/分鐘
- 平均單日運動量:約 ${avgJumpsPerDay} 次 / ${avgMinsPerDay} 分鐘
【處方撰寫最高原則(嚴格遵守)】
1. 嚴禁空洞問候與口語客套:絕對禁止使用「哈囉我是你的體育老師」、「看到你很認真」等開場白,直接以體適能報告與量化處方格式呈現。
2. 落實 FITT 原則量化處方:
- 運動頻率 (Frequency):每週執行天數,如何融入體育署 SH150每週在校+課後達 150 分鐘)。
- 運動強度與組數 (Intensity & Sets):明確設定「每組跳幾下或幾秒、進行幾組、組間休息幾秒」。
- 年級技巧進階 (Type & Progression):針對【${student.grade}年級】發展量身推薦動作(低年級:雙腳併跳、開合跳、側打;中年級:跑步跳、交叉跳、一開一合;高年級:雙迴旋/雙巴、計時競速、節奏變速)。
3. 依據 BMI【${student.bmiStatus}】進行生理安全調整:
- 若為「過重」或「肥胖」:嚴格採取「間歇低衝擊跳法」、限制連續跳躍時間以保護膝踝生長板與足底筋膜,強制要求彈性地面與緩衝鞋款。
- 若為「過輕」:注重下肢骨骼適度應力刺激以促進生長板發育,提醒運動後蛋白質與碳水營養補充。
- 若為「正常」:著重於心肺耐力提升與神經肌肉協調挑戰。
${customPromptExtra.value ? `4. 教師特別叮嚀:${customPromptExtra.value}` : ''}
【請嚴格依以下三大結構輸出,字數約 280~380 字,繁體中文】:
一、體適能診斷與數據分析
(深度剖析速率、出勤率與 BMI 狀態之生理意義,指明優勢與待加強面向)
二、個別化跳繩運動處方FITT 計畫)
(包含每週天數、組數/次數/休息時間、年級專屬動作技巧與 SH150 達標指引)
三、生長發育防護與伸展叮嚀
(指名具體伸展肌群如小腿腓腸肌/阿基里斯腱/大腿股四頭肌、場地材質與安全守則)`;
const requestBody = {
systemInstruction: {
parts: [{
text: '你是一位嚴謹的國小體適能指導專家與學校運動處方顧問。請直接輸出客觀、專業、數據化的體適能運動處方報告。嚴禁任何書信格式(如「親愛的...」、「你好」)、口語寒暄或感性讚美,必須直接以「一、體適能診斷與數據分析」為開頭輸出。'
}]
},
contents: [{
parts: [{ text: prompt }]
}],
generationConfig: {
temperature: 0.2,
maxOutputTokens: 2048
}
};
// 若為 Gemini 2.5 系列,關閉 Thinking 模式以避免 Token 耗盡與截斷延遲
if (model && model.includes('2.5')) {
requestBody.generationConfig.thinkingConfig = { thinkingBudget: 0 };
}
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(requestBody)
});
if (!response.ok) {
// 429 速率限制重試
if (response.status === 429 && retryCount < 2) {
await new Promise(r => setTimeout(r, 6000));
return await callGeminiApi(student, retryCount + 1);
}
const errData = await response.json().catch(() => ({}));
throw new Error(errData.error?.message || `API 請求失敗 (狀態碼: ${response.status})`);
}
const data = await response.json();
const candidate = data.candidates?.[0];
if (!candidate) throw new Error('未能取得 AI 回應內容');
const parts = candidate.content?.parts || [];
const textParts = parts.filter(p => !p.thought && p.text).map(p => p.text);
const text = textParts.length > 0 ? textParts.join('') : parts.map(p => p.text || '').join('');
if (!text || !text.trim()) throw new Error('未能取得 AI 回應內容');
return text.trim();
};
// 單獨生成個別學生的 AI 建議
const generateSingleAiAdvice = async (student) => {
student.isGeneratingAi = true;
try {
const advice = await callGeminiApi(student);
student.aiAdvice = advice;
addToast(`已成功為 ${student.name} 生成 AI 運動建議!`, 'success');
} catch (err) {
addToast(`生成失敗: ${err.message}`, 'error');
} finally {
student.isGeneratingAi = false;
}
};
// 全班批量生成 AI 建議 (含 Rate Limit 延遲防護)
const startBatchAiGeneration = async () => {
if (!apiKey.value) {
showApiModal.value = true;
return;
}
const targetStudents = students.value.filter(s => !s.aiAdvice || !s.aiAdvice.trim());
if (targetStudents.length === 0) {
if (!confirm('所有學生的 AI 建議皆已生成完畢,是否要全部重新生成?')) return;
}
const queue = targetStudents.length > 0 ? targetStudents : students.value;
isGeneratingBatch.value = true;
abortBatchFlag = false;
batchProgress.value = { current: 0, total: queue.length, currentStudentName: '' };
for (let i = 0; i < queue.length; i++) {
if (abortBatchFlag) break;
const student = queue[i];
batchProgress.value.current = i + 1;
batchProgress.value.currentStudentName = student.name;
student.isGeneratingAi = true;
try {
const advice = await callGeminiApi(student);
student.aiAdvice = advice;
} catch (err) {
console.error(`學生 ${student.name} 生成失敗:`, err);
student.aiAdvice = `生成失敗 (${err.message}),請檢查 API Key 或重試。`;
} finally {
student.isGeneratingAi = false;
}
// 免費版 API (15 RPM) 速率防護:每次間隔 4.1 秒
if (i < queue.length - 1 && !abortBatchFlag) {
await new Promise(resolve => setTimeout(resolve, 4100));
}
}
isGeneratingBatch.value = false;
addToast('全班 AI 運動建議批量生成已完成!', 'success');
};
const stopBatchGeneration = () => {
abortBatchFlag = true;
isGeneratingBatch.value = false;
addToast('已暫停批量生成作業', 'info');
};
// 列印單一學生
const printSingleStudent = (student) => {
studentsToPrint.value = [student];
nextTick(() => {
setTimeout(() => {
window.print();
}, 120);
});
};
// 列印全班
const openPrintAllModal = () => {
studentsToPrint.value = [...students.value];
nextTick(() => {
setTimeout(() => {
window.print();
}, 120);
});
};
// 匯出完整 Excel 報告 (含自訂欄寬)
const exportAnalysisExcel = () => {
if (students.value.length === 0) return;
const summaryData = students.value.map(s => ({
'座號': s.seatNo,
'姓名': s.name,
'性別': s.gender,
'年級': s.grade,
'實足年齡': s.age,
'身高(cm)': s.height,
'體重(kg)': s.weight,
'BMI指數': s.bmi,
'體位判定(衛福部)': s.bmiStatus,
'累計跳繩總次數': s.totalJumps,
'累計運動時長(分鐘)': s.totalMinutes,
'打卡天數': s.logDays,
'平均速率(下/分)': s.totalMinutes ? Math.round(s.totalJumps / s.totalMinutes) : 0,
'AI 運動處方建議': s.aiAdvice || ''
}));
const logData = [];
students.value.forEach(s => {
(s.logs || []).forEach(l => {
logData.push({
'座號': s.seatNo,
'姓名': s.name,
'日期': l.date,
'跳繩次數': l.count,
'運動時間(分鐘)': l.minutes
});
});
});
const wb = XLSX.utils.book_new();
const wsSummary = XLSX.utils.json_to_sheet(summaryData);
const wsLogs = XLSX.utils.json_to_sheet(logData);
// 設定欄寬
wsSummary['!cols'] = [
{ wch: 8 }, { wch: 10 }, { wch: 6 }, { wch: 8 }, { wch: 10 },
{ wch: 10 }, { wch: 10 }, { wch: 10 }, { wch: 16 }, { wch: 14 },
{ wch: 16 }, { wch: 10 }, { wch: 14 }, { wch: 60 }
];
wsLogs['!cols'] = [
{ wch: 8 }, { wch: 10 }, { wch: 14 }, { wch: 12 }, { wch: 14 }
];
XLSX.utils.book_append_sheet(wb, wsSummary, "班級體適能與AI建議總表");
XLSX.utils.book_append_sheet(wb, wsLogs, "跳繩歷史打卡紀錄");
const fileName = `國小班級跳繩與BMI分析報告_${new Date().toISOString().slice(0, 10)}.xlsx`;
XLSX.writeFile(wb, fileName);
addToast('已成功匯出分析報告 Excel 檔案!', 'success');
};
// 下載標準 Excel 範本
const downloadTemplate = () => {
const sampleStudents = [
{ '座號': 1, '姓名': '王小明', '性別': '男', '年級': 3, '年齡': 9, '身高': 132, '體重': 28 },
{ '座號': 2, '姓名': '李美華', '性別': '女', '年級': 3, '年齡': 9, '身高': 130, '體重': 33 },
{ '座號': 3, '姓名': '陳大同', '性別': '男', '年級': 3, '年齡': 9, '身高': 135, '體重': 24 }
];
const sampleLogs = [
{ '座號': 1, '姓名': '王小明', '日期': '2026-06-01', '跳繩次數': 300, '運動時間(分鐘)': 15 },
{ '座號': 1, '姓名': '王小明', '日期': '2026-06-02', '跳繩次數': 350, '運動時間(分鐘)': 15 },
{ '座號': 2, '姓名': '李美華', '日期': '2026-06-01', '跳繩次數': 200, '運動時間(分鐘)': 10 },
{ '座號': 3, '姓名': '陳大同', '日期': '2026-06-01', '跳繩次數': 400, '運動時間(分鐘)': 20 }
];
const wb = XLSX.utils.book_new();
const ws1 = XLSX.utils.json_to_sheet(sampleStudents);
const ws2 = XLSX.utils.json_to_sheet(sampleLogs);
ws1['!cols'] = [{ wch: 8 }, { wch: 10 }, { wch: 8 }, { wch: 8 }, { wch: 8 }, { wch: 10 }, { wch: 10 }];
ws2['!cols'] = [{ wch: 8 }, { wch: 10 }, { wch: 14 }, { wch: 12 }, { wch: 14 }];
XLSX.utils.book_append_sheet(wb, ws1, "學生基本資料");
XLSX.utils.book_append_sheet(wb, ws2, "跳繩紀錄");
XLSX.writeFile(wb, "班級跳繩與基本資料標準範本.xlsx");
addToast('已下載標準 Excel 範本!', 'success');
};
// 處理上傳檔案
const handleFileUpload = (e) => {
const files = e.target.files;
if (files && files.length) parseFiles(files);
};
const handleFileDrop = (e) => {
isDragging.value = false;
const files = e.dataTransfer.files;
if (files && files.length) parseFiles(files);
};
const parseFiles = (files) => {
Array.from(files).forEach(file => {
const reader = new FileReader();
reader.onload = (evt) => {
try {
const bstr = evt.target.result;
const wb = XLSX.read(bstr, { type: 'binary' });
let parsedStudents = [];
let parsedLogs = [];
wb.SheetNames.forEach(sheetName => {
const sheet = wb.Sheets[sheetName];
const json = XLSX.utils.sheet_to_json(sheet);
if (json.length === 0) return;
const firstRow = json[0];
const keys = Object.keys(firstRow).map(k => k.toLowerCase());
const isProfileSheet = keys.some(k => k.includes('身高') || k.includes('體重') || k.includes('height') || k.includes('weight'));
const isLogSheet = keys.some(k => k.includes('次數') || k.includes('跳繩') || k.includes('count') || k.includes('jump') || k.includes('日期') || k.includes('date'));
if (isProfileSheet) {
json.forEach((row, idx) => {
const seatNo = row['座號'] || row['學號'] || row['編號'] || row['SeatNo'] || (idx + 1);
const name = row['姓名'] || row['學生姓名'] || row['Name'] || `學生${seatNo}`;
const gender = normalizeGender(row['性別'] || row['Gender'] || '男');
const grade = Number(row['年級'] || row['Grade'] || 3);
const age = Number(row['年齡'] || row['實足年齡'] || row['Age'] || (grade + 6));
const height = Number(row['身高'] || row['身高(cm)'] || row['身高(公分)'] || row['Height'] || 130);
const weight = Number(row['體重'] || row['體重(kg)'] || row['體重(公斤)'] || row['Weight'] || 30);
const bmi = calculateBmi(height, weight);
const bmiStatus = determineBmiStatus(gender, age, bmi);
parsedStudents.push({
id: `s_${seatNo}_${Date.now()}_${idx}`,
seatNo,
name,
gender,
grade,
age,
height,
weight,
bmi,
bmiStatus,
totalJumps: 0,
totalMinutes: 0,
logDays: 0,
logs: [],
aiAdvice: '',
isGeneratingAi: false
});
});
}
if (isLogSheet) {
json.forEach(row => {
const seatNo = row['座號'] || row['學號'] || row['編號'] || row['SeatNo'];
const name = row['姓名'] || row['學生姓名'] || row['Name'];
const date = parseFlexibleDate(row['日期'] || row['Date']);
const count = Number(row['跳繩次數'] || row['次數'] || row['跳繩(下)'] || row['Count'] || row['Jumps'] || 0);
const minutes = Number(row['運動時間'] || row['運動時間(分鐘)'] || row['時間'] || row['時間(分鐘)'] || row['Minutes'] || 10);
parsedLogs.push({ seatNo, name, date, count, minutes });
});
}
});
if (parsedStudents.length > 0) {
parsedStudents.forEach(st => {
const studentLogs = parsedLogs.filter(l => (l.seatNo && String(l.seatNo) === String(st.seatNo)) || (l.name && l.name === st.name));
st.logs = studentLogs;
st.totalJumps = studentLogs.reduce((sum, l) => sum + l.count, 0);
st.totalMinutes = studentLogs.reduce((sum, l) => sum + l.minutes, 0);
st.logDays = new Set(studentLogs.map(l => l.date)).size;
});
students.value = parsedStudents;
} else if (parsedLogs.length > 0 && students.value.length > 0) {
students.value.forEach(st => {
const studentLogs = parsedLogs.filter(l => (l.seatNo && String(l.seatNo) === String(st.seatNo)) || (l.name && l.name === st.name));
if (studentLogs.length) {
st.logs = [...st.logs, ...studentLogs];
st.totalJumps = st.logs.reduce((sum, l) => sum + l.count, 0);
st.totalMinutes = st.logs.reduce((sum, l) => sum + l.minutes, 0);
st.logDays = new Set(st.logs.map(l => l.date)).size;
}
});
}
updateOverviewCharts();
activeTab.value = 'students';
addToast(`成功匯入 ${students.value.length} 位學生與 ${parsedLogs.length} 筆跳繩紀錄!`, 'success');
} catch (err) {
addToast(`檔案解析失敗: ${err.message}`, 'error');
}
};
reader.readAsBinaryString(file);
});
};
// 載入 3 個月 25 位學童示範資料
const loadSampleData = () => {
const names = [
{ name: '王小明', gender: '男', h: 134, w: 29 },
{ name: '李佩珊', gender: '女', h: 131, w: 36 },
{ name: '張家豪', gender: '男', h: 138, w: 42 },
{ name: '陳美琪', gender: '女', h: 129, w: 22 },
{ name: '林子軒', gender: '男', h: 133, w: 30 },
{ name: '黃冠宇', gender: '男', h: 135, w: 31 },
{ name: '吳欣怡', gender: '女', h: 132, w: 28 },
{ name: '劉宇翔', gender: '男', h: 136, w: 32 },
{ name: '蔡佳穎', gender: '女', h: 130, w: 27 },
{ name: '鄭宏恩', gender: '男', h: 140, w: 44 },
{ name: '楊雅婷', gender: '女', h: 133, w: 38 },
{ name: '許文傑', gender: '男', h: 128, w: 21 },
{ name: '謝沛玲', gender: '女', h: 134, w: 29 },
{ name: '郭宗翰', gender: '男', h: 137, w: 33 },
{ name: '洪巧玲', gender: '女', h: 131, w: 26 },
{ name: '曾俊傑', gender: '男', h: 139, w: 34 },
{ name: '邱依婷', gender: '女', h: 130, w: 27 },
{ name: '廖柏翔', gender: '男', h: 132, w: 30 },
{ name: '賴宛君', gender: '女', h: 135, w: 31 },
{ name: '周聖傑', gender: '男', h: 136, w: 32 },
{ name: '徐雅萍', gender: '女', h: 128, w: 22 },
{ name: '蘇建豪', gender: '男', h: 134, w: 30 },
{ name: '莊凱婷', gender: '女', h: 132, w: 28 },
{ name: '葉庭宇', gender: '男', h: 135, w: 31 },
{ name: '潘宥廷', gender: '男', h: 137, w: 33 }
];
const demoStudents = [];
const startDate = new Date();
startDate.setDate(startDate.getDate() - 90);
names.forEach((item, index) => {
const seatNo = index + 1;
const grade = 3;
const age = 9;
const bmi = calculateBmi(item.h, item.w);
const bmiStatus = determineBmiStatus(item.gender, age, bmi);
const logs = [];
let totalJumps = 0;
let totalMinutes = 0;
for (let d = 0; d < 90; d += Math.floor(Math.random() * 2) + 1) {
const curDate = new Date(startDate);
curDate.setDate(curDate.getDate() + d);
const dateStr = curDate.toISOString().slice(0, 10);
const count = Math.floor(Math.random() * 400) + 300;
const minutes = Math.floor(count / 35) + 5;
logs.push({ date: dateStr, count, minutes });
totalJumps += count;
totalMinutes += minutes;
}
let demoAdvice = '';
if (index === 0) {
demoAdvice = `一、體適能診斷與數據分析\n小明過去 3 個月累積跳繩達 ${totalJumps.toLocaleString()} 次、打卡 ${logs.length} 天,平均速率約 ${Math.round(totalJumps / totalMinutes)} 下/分出勤率與心肺協調俱佳。BMI 處於正常範圍(${bmi.toFixed(1)}),具備良好的體適能與神經肌肉協調基礎。\n\n二、個別化跳繩運動處方FITT 計畫)\n• 運動頻率:每週 4~5 天,課後累積至少 150 分鐘(達成 SH150 標竿)。\n• 訓練菜單:採進階間歇訓練,每次進行 5 組,每組連續跳 120~150 下,組間微歇 45 秒。\n• 技巧進階中年級重點強化「單腳交換跳跑步跳」與「交叉跳Crossover挑戰 1 分鐘競速 100 下以上。\n\n三、生長發育防護與伸展叮嚀\n跳繩前確實活動足踝與膝關節;運動後著重「小腿腓腸肌」與「阿基里斯腱」靜態伸展各 30 秒。務必著吸震運動鞋於 PU 地面進行,保護生長板發育。`;
} else if (index === 1) {
demoAdvice = `一、體適能診斷與數據分析\n佩珊近 3 個月累積運動 ${totalMinutes} 分鐘,展現良好運動耐力。目前 BMI 屬「體重過重」,跳繩時下肢膝踝承受較大衝擊力,需以「低衝擊燃脂、關節減壓、漸進增量」為核心策略。\n\n二、個別化跳繩運動處方FITT 計畫)\n• 運動頻率:每週 3~4 天,每次總時間 20~25 分鐘。\n• 訓練菜單:採「高間歇跳繩法」,跳 45 秒(約 60~80 下)配合慢走調節呼吸 45 秒,循環進行 8~10 組,避免單次連續過久造成足底負擔。\n• 技巧引導:以低空雙腳併跳為基礎,維持穩定呼吸節奏與中低強度有氧區間。\n\n三、生長發育防護與伸展叮嚀\n嚴禁水泥硬地,須在 PU 跑道或吸震跳繩墊上進行;加強「股四頭肌」與「足底筋膜」放鬆。運動期間每 15 分鐘補充溫開水 100~150ml。`;
}
demoStudents.push({
id: `demo_${seatNo}`,
seatNo,
name: item.name,
gender: item.gender,
grade,
age,
height: item.h,
weight: item.w,
bmi,
bmiStatus,
totalJumps,
totalMinutes,
logDays: logs.length,
logs,
aiAdvice: demoAdvice,
isGeneratingAi: false
});
});
students.value = demoStudents;
updateOverviewCharts();
activeTab.value = 'overview';
addToast('已成功載入三年二班 25 位學生連續 3 個月的跳繩體能示範數據!', 'success');
};
const clearAllData = () => {
if (confirm('確定要清空目前所有學生與跳繩數據嗎?')) {
students.value = [];
updateOverviewCharts();
addToast('已清空所有學生資料', 'info');
}
};
onMounted(() => {
loadSampleData();
});
watch(activeTab, (newTab) => {
if (newTab === 'overview') {
updateOverviewCharts();
}
});
return {
activeTab,
students,
searchQuery,
filterBmi,
filterAiStatus,
selectedStudent,
apiKey,
apiKeyInput,
showApiKeyMask,
selectedModel,
customPromptExtra,
showApiModal,
isTestingApi,
isGeneratingBatch,
batchProgress,
studentsToPrint,
printDate,
isDragging,
toasts,
boyCount,
girlCount,
totalClassJumps,
totalClassMinutes,
aiGeneratedCount,
bmiSummary,
filteredStudents,
saveApiKey,
testApiConnection,
copyText,
addToast,
removeToast,
getToastClass,
getToastIcon,
openStudentDetail,
generateSingleAiAdvice,
startBatchAiGeneration,
stopBatchGeneration,
printSingleStudent,
openPrintAllModal,
exportAnalysisExcel,
downloadTemplate,
handleFileUpload,
handleFileDrop,
loadSampleData,
clearAllData,
getBmiBadgeClass,
getBmiTextColor
};
}
}).mount('#app');
</script>
</body>
</html>