1839 lines
95 KiB
HTML
1839 lines
95 KiB
HTML
<!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>
|