-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathScratchGrader_teacher.html
More file actions
369 lines (351 loc) · 18.4 KB
/
Copy pathScratchGrader_teacher.html
File metadata and controls
369 lines (351 loc) · 18.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
<!DOCTYPE html>
<html lang="zh-Hant">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Scratch AI 批改 — 教師端</title>
<style>
:root{
--bg:#f4f6f8; --card:#fff; --ink:#2c3e50; --muted:#7f8c8d;
--primary:#4f6df5; --primary-d:#3a55d9; --ok:#2ecc71; --warn:#e67e22; --err:#e74c3c;
--line:#e3e8ee;
}
*{box-sizing:border-box}
body{margin:0;padding:20px;font-family:"Noto Sans TC","Microsoft JhengHei",system-ui,sans-serif;
background:var(--bg);color:var(--ink);line-height:1.6}
h1{font-size:1.4rem;margin:0 0 4px}
h2{font-size:1.05rem;margin:0 0 12px;padding-bottom:6px;border-bottom:2px solid var(--line)}
.wrap{max-width:960px;margin:0 auto}
.sub{color:var(--muted);font-size:.85rem;margin-bottom:16px}
.card{background:var(--card);border-radius:14px;padding:18px 20px;margin-bottom:16px;
box-shadow:0 2px 8px rgba(0,0,0,.05)}
label{display:block;font-size:.85rem;font-weight:600;margin:10px 0 4px}
input[type=text],input[type=password],input[type=number],select,textarea{
width:100%;padding:9px 11px;border:1px solid var(--line);border-radius:9px;
font-size:.9rem;font-family:inherit;background:#fbfcfd}
textarea{resize:vertical}
.row{display:flex;gap:12px;flex-wrap:wrap}
.row>div{flex:1 1 200px}
.chk{display:flex;align-items:center;gap:8px;margin:8px 0;font-size:.88rem;font-weight:600}
.chk input{width:auto}
button{cursor:pointer;border:none;border-radius:9px;padding:10px 16px;font-size:.9rem;
font-weight:700;font-family:inherit;transition:.15s}
.btn-primary{background:var(--primary);color:#fff}
.btn-primary:hover{background:var(--primary-d)}
.btn-ghost{background:#eef1f6;color:var(--ink)}
.btn-ghost:hover{background:#e2e7ef}
.btnbar{display:flex;gap:10px;flex-wrap:wrap;margin-top:12px}
.status{font-size:.82rem;margin-top:8px;min-height:1.2em}
.status.ok{color:var(--ok)} .status.err{color:var(--err)} .status.busy{color:var(--warn)}
.light{display:inline-flex;align-items:center;gap:9px;font-size:.95rem;font-weight:700}
.dot{width:14px;height:14px;border-radius:50%;background:#c3c9d2;transition:.2s;
box-shadow:0 0 0 4px rgba(0,0,0,.04)}
.dot.on{background:var(--ok);box-shadow:0 0 10px var(--ok)}
.dot.off{background:var(--err);box-shadow:0 0 10px var(--err)}
.dot.wait{background:var(--warn);box-shadow:0 0 10px var(--warn)}
pre{background:#0f1729;color:#d6e2ff;padding:12px;border-radius:9px;overflow:auto;
font-size:.78rem;max-height:340px;white-space:pre-wrap}
table{width:100%;border-collapse:collapse;font-size:.82rem;margin-top:10px}
th,td{padding:7px 9px;border-bottom:1px solid var(--line);text-align:left;vertical-align:top}
th{background:#34495e;color:#fff;position:sticky;top:0}
.tag{display:inline-block;padding:2px 8px;border-radius:20px;font-size:.72rem;font-weight:700}
.hint{font-size:.78rem;color:var(--muted);margin-top:4px}
details summary{cursor:pointer;font-weight:700;font-size:.9rem;margin:6px 0}
@media(max-width:640px){body{padding:12px}.card{padding:14px}}
</style>
</head>
<body>
<div class="wrap">
<h1>🧩 Scratch AI 批改系統 — 教師端</h1>
<div class="sub">設定評分規則與金鑰 → 儲存至共用設定檔 → 學生端即可自評。所有評分在伺服器端完成。</div>
<!-- 連線狀態(自動偵測) -->
<div class="card">
<h2>① 伺服器連線</h2>
<div class="light"><span class="dot wait" id="dot"></span><span id="connText">偵測連線中…</span></div>
<div class="btnbar">
<button class="btn-ghost" onclick="autoDetect()">🔄 重新偵測</button>
<button class="btn-ghost" onclick="loadConfig()">📥 載入現有設定</button>
</div>
<div class="status" id="connStatus"></div>
<div class="status" id="storageStatus">ℹ️ 尚未確認資料保存位置</div>
</div>
<!-- 金鑰與模型 -->
<div class="card">
<h2>② 金鑰與模型</h2>
<div class="row">
<div><label>Gemini API Key(主)</label><input type="password" id="apiKey1" placeholder="AIza..."></div>
<div><label>Gemini API Key(備援,可留空)</label><input type="password" id="apiKey2" placeholder="AIza..."></div>
</div>
<div class="row">
<div>
<label>批改模型 <button type="button" class="btn-ghost" style="padding:2px 8px;font-size:.72rem" onclick="loadModels()">🔄 讀取可用模型</button></label>
<select id="modelName">
<option selected>gemini-2.5-flash</option>
<option>gemini-2.5-flash-lite</option>
<option>gemini-2.0-flash</option>
</select>
<div class="status" id="modelStatus"></div>
</div>
<div><label>每份冷卻秒數</label><input type="number" id="delaySeconds" value="13"></div>
<div><label>單檔上限 (MB)</label><input type="number" id="maxSizeMb" value="10"></div>
</div>
</div>
<!-- 作業與規則 -->
<div class="card">
<h2>③ 作業主題與評分規則</h2>
<label>作業主題 / 名稱</label>
<input type="text" id="theme" placeholder="例:迷宮闖關遊戲">
<label>評分規則(老師設定的評分法律)</label>
<textarea id="rules" rows="9" placeholder="逐條列出評分項目與配分..."></textarea>
<div class="chk"><input type="checkbox" id="isStandard" checked><label style="margin:0" for="isStandard">標準答案題型(與參考解答一致即滿分;開放題請取消)</label></div>
<div class="chk"><input type="checkbox" id="showScore" checked><label style="margin:0" for="showScore">學生自評時顯示分數</label></div>
</div>
<!-- 範本與解答 -->
<div class="card">
<h2>④ 初始範本 / 參考解答(可選)</h2>
<div class="hint">上傳後系統會轉成虛擬碼存進設定檔,評分時用於偵測交白卷與參考解答比對。</div>
<div class="row">
<div>
<label>初始空白範本 .sb3</label>
<input type="file" id="templateFile" accept=".sb3">
<div class="status" id="templateStatus"></div>
</div>
<div>
<label>老師參考解答 .sb3</label>
<input type="file" id="exampleFile" accept=".sb3">
<div class="status" id="exampleStatus"></div>
</div>
</div>
<div class="btnbar">
<button class="btn-ghost" onclick="suggestRules()">🤖 依參考解答自動生成主題與規則</button>
</div>
<div class="hint">會用上方的 API Key 與批改模型,依「老師參考解答」產生建議的主題與評分規則,
填入下方③供你檢查調整(會覆蓋③目前的內容)。</div>
<div class="status" id="suggestStatus"></div>
</div>
<!-- 儲存 -->
<div class="card">
<h2>⑤ 儲存設定</h2>
<div class="hint">按下後會寫入 Firebase 共用設定(覆蓋同一筆),學生端立即生效。
下次開啟本頁會自動載入這筆設定,不用重新輸入。</div>
<div class="btnbar">
<button class="btn-primary" onclick="saveConfig()">💾 儲存設定</button>
</div>
<div class="status" id="saveStatus"></div>
</div>
<!-- 試評 -->
<div class="card">
<h2>⑥ 單檔試評</h2>
<div class="hint">上傳一份 .sb3(例如參考解答)測試目前規則是否合理。會使用尚未儲存的即時設定。</div>
<input type="file" id="testFile" accept=".sb3">
<div class="btnbar"><button class="btn-ghost" onclick="testGrade()">▶️ 試評這份作業</button></div>
<div class="status" id="testStatus"></div>
<pre id="testResult" style="display:none"></pre>
</div>
<!-- 學生成績紀錄 -->
<div class="card">
<h2>⑦ 學生成績紀錄</h2>
<div class="hint">學生每次自評都會記錄到 Firebase,這裡顯示學號、分數與評測時間(新到舊)。</div>
<div class="btnbar">
<button class="btn-primary" onclick="loadSubmissions()">🔄 讀取學生成績</button>
</div>
<div class="status" id="subStatus"></div>
<div id="subTable"></div>
</div>
</div>
<script src="app-config.js"></script>
<script>
// API 網址可由 app-config.js、網址 ?api=https://... 或同網域部署時自動提供。
const SERVER_URL = window.SCRATCH_GRADER_API_URL || new URLSearchParams(location.search).get('api') ||
(location.protocol === 'file:' ? '' : location.origin);
const $ = id => document.getElementById(id);
const base = () => SERVER_URL;
let adminToken = sessionStorage.getItem('scratchGraderAdminToken') || '';
function headers(json, requireAdmin=true){
const h = {'ngrok-skip-browser-warning':'true'};
if(requireAdmin && !adminToken){
adminToken = prompt('請輸入教師管理密碼') || '';
if(adminToken) sessionStorage.setItem('scratchGraderAdminToken', adminToken);
}
if(requireAdmin && adminToken) h['X-Admin-Token'] = adminToken;
if(json) h['Content-Type']='application/json';
return h;
}
function setStatus(id,msg,cls){ const e=$(id); e.textContent=msg; e.className='status '+(cls||''); }
function showStorageStatus(storage){
if(!storage){ setStatus('storageStatus','⚠️ 無法確認資料保存位置','busy'); return; }
const isFirestore=storage.is_firestore===true;
const icon=isFirestore?'☁️':'⚠️';
const prefix=isFirestore?'資料已保存到 Firestore:':'資料未保存到 Firestore:';
setStatus('storageStatus',icon+' '+prefix+(storage.message||''),isFirestore?'ok':'busy');
}
function setLight(state,text){
$('dot').className = 'dot '+state; // on / off / wait
$('connText').textContent = text;
}
// 自動偵測伺服器連線,以紅/綠燈顯示;首次連上時自動載入 Firebase 設定
let _autoLoaded = false;
async function autoDetect(){
setLight('wait','偵測連線中…');
try{
const r = await fetch(base()+'/api/health',{headers:headers(false,false)});
const j = await r.json();
if(j.ok){
setLight('on','🟢 伺服器已連線');
if(!_autoLoaded){ _autoLoaded = true; loadConfig(); } // 只自動載入一次,避免蓋掉編輯中的內容
} else setLight('off','🔴 伺服器回應異常');
}catch(e){ setLight('off','🔴 未連線(請確認 Colab 伺服器已啟動)'); }
}
autoDetect();
setInterval(autoDetect, 20000); // 每 20 秒自動重新偵測(不會再重複載入設定)
async function loadConfig(){
setStatus('connStatus','載入設定中...','busy');
try{
const r = await fetch(base()+'/api/teacher/config',{headers:headers()});
const j = await r.json();
if(!j.ok){ setStatus('connStatus','❌ '+(j.error||'載入失敗'),'err'); return; }
const c = j.config;
showStorageStatus(j.storage);
// 只在伺服器確實有值時才回填金鑰,避免用空值蓋掉剛輸入的內容
if(c.api_key_1) $('apiKey1').value=c.api_key_1;
if(c.api_key_2) $('apiKey2').value=c.api_key_2;
// 若儲存的模型不在清單中(例如舊的無效名稱),先補進選單再選取,避免下拉變空白
if(c.model_name){
const sel=$('modelName');
if(![...sel.options].some(o=>o.value===c.model_name)){
const o=document.createElement('option'); o.value=c.model_name;
o.textContent=c.model_name+'(已存設定)'; sel.appendChild(o);
}
sel.value=c.model_name;
}
$('delaySeconds').value=c.delay_seconds ?? 13; $('maxSizeMb').value=c.max_size_mb ?? 10;
$('theme').value=c.theme||''; $('rules').value=c.rules||'';
$('isStandard').checked=!!c.is_standard_answer;
$('showScore').checked=c.student_show_score!==false;
window._templateCode=c.template_code||''; window._exampleCode=c.example_code||'';
if(window._templateCode) setStatus('templateStatus','已載入設定檔中的範本虛擬碼','ok');
if(window._exampleCode) setStatus('exampleStatus','已載入設定檔中的解答虛擬碼','ok');
if(c.updated_at) setStatus('connStatus','✅ 已載入設定(更新於 '+c.updated_at+')','ok');
else setStatus('connStatus','ℹ️ 伺服器上尚無已儲存的設定,請填寫後按⑤「儲存設定」','busy');
}catch(e){ setStatus('connStatus','❌ '+e.message,'err'); }
}
async function convertSb3(fileInput, statusId, target){
const f = $(fileInput).files[0];
if(!f) return;
setStatus(statusId,'轉換中...','busy');
const fd = new FormData(); fd.append('file',f); fd.append('max_size_mb',$('maxSizeMb').value);
try{
const r = await fetch(base()+'/api/teacher/convert_sb3',{method:'POST',headers:headers(),body:fd});
const j = await r.json();
if(j.ok){ window[target]=j.clean_code; setStatus(statusId,'✅ 已轉換('+j.clean_code.length+' 字元)','ok'); }
else setStatus(statusId,'❌ '+(j.error||'轉換失敗'),'err');
}catch(e){ setStatus(statusId,'❌ '+e.message,'err'); }
}
$('templateFile').addEventListener('change',()=>convertSb3('templateFile','templateStatus','_templateCode'));
$('exampleFile').addEventListener('change',()=>convertSb3('exampleFile','exampleStatus','_exampleCode'));
// 向 API 讀取這把金鑰實際可用的模型,填入下拉選單
async function loadModels(){
const k1=$('apiKey1').value.trim(), k2=$('apiKey2').value.trim();
if(!k1 && !k2){ setStatus('modelStatus','請先在②填入 API Key','err'); return; }
setStatus('modelStatus','讀取可用模型中…','busy');
try{
const r=await fetch(base()+'/api/teacher/models',{method:'POST',headers:headers(true),
body:JSON.stringify({api_key_1:k1,api_key_2:k2})});
const j=await r.json();
if(j.ok && j.models && j.models.length){
const cur=$('modelName').value;
const sel=$('modelName'); sel.innerHTML='';
j.models.forEach(m=>{ const o=document.createElement('option'); o.value=m; o.textContent=m; sel.appendChild(o); });
// 保留原選擇;否則優先選 gemini-2.5-flash
if(j.models.includes(cur)) sel.value=cur;
else if(j.models.includes('gemini-2.5-flash')) sel.value='gemini-2.5-flash';
setStatus('modelStatus','✅ 已載入 '+j.models.length+' 個可用模型','ok');
} else setStatus('modelStatus','❌ '+(j.error||'讀取失敗'),'err');
}catch(e){ setStatus('modelStatus','❌ '+e.message,'err'); }
}
// 由參考解答用 AI 自動生成主題與評分規則
async function suggestRules(){
if(!window._exampleCode){ setStatus('suggestStatus','請先上傳「老師參考解答 .sb3」','err'); return; }
const k1=$('apiKey1').value.trim(), k2=$('apiKey2').value.trim();
if(!k1 && !k2){ setStatus('suggestStatus','請先在②填入 API Key','err'); return; }
if(($('theme').value.trim() || $('rules').value.trim()) &&
!confirm('這會覆蓋③目前的主題與評分規則,確定要用 AI 生成的內容取代嗎?')) return;
setStatus('suggestStatus','🤖 AI 生成中,請稍候...','busy');
try{
const r=await fetch(base()+'/api/teacher/suggest_rules',{method:'POST',headers:headers(true),
body:JSON.stringify({api_key_1:k1,api_key_2:k2,model_name:$('modelName').value,clean_code:window._exampleCode})});
const j=await r.json();
if(j.ok){
showStorageStatus(j.storage);
$('theme').value=j.theme||''; $('rules').value=j.rules||'';
setStatus('suggestStatus','✅ 已生成,請檢查/調整③的內容後再按「儲存設定」','ok');
} else setStatus('suggestStatus','❌ '+(j.error||'生成失敗'),'err');
}catch(e){ setStatus('suggestStatus','❌ '+e.message,'err'); }
}
function gatherConfig(){
const cfg = {
api_key_1:$('apiKey1').value.trim(), api_key_2:$('apiKey2').value.trim(),
model_name:$('modelName').value, delay_seconds:parseInt($('delaySeconds').value)||13,
max_size_mb:parseInt($('maxSizeMb').value)||10,
theme:$('theme').value, rules:$('rules').value,
is_standard_answer:$('isStandard').checked,
student_show_score:$('showScore').checked,
use_custom_extension:false, extension_rules:'',
template_code:window._templateCode||'', example_code:window._exampleCode||'',
};
return cfg;
}
async function saveConfig(){
setStatus('saveStatus','儲存中...','busy');
try{
const r = await fetch(base()+'/api/teacher/config',
{method:'POST',headers:headers(true),body:JSON.stringify(gatherConfig())});
const j = await r.json();
if(j.ok){
setStatus('saveStatus','✅ 已儲存('+j.updated_at+')→ '+j.saved_to,'ok');
} else setStatus('saveStatus','❌ '+(j.error||'儲存失敗'),'err');
}catch(e){ setStatus('saveStatus','❌ '+e.message,'err'); }
}
async function testGrade(){
const f=$('testFile').files[0];
if(!f){ setStatus('testStatus','請先選擇檔案','err'); return; }
setStatus('testStatus','AI 試評中,請稍候...','busy');
const fd=new FormData(); fd.append('file',f); fd.append('overrides',JSON.stringify(gatherConfig()));
try{
const r=await fetch(base()+'/api/teacher/test',{method:'POST',headers:headers(),body:fd});
const j=await r.json();
if(!j.ok){ setStatus('testStatus','❌ '+(j.error||'試評失敗'),'err'); return; }
const d=j.result;
$('testResult').style.display='block';
$('testResult').textContent=
`🎯 分數:${d.score}\n\n🧠 邏輯分析:\n${d.logic_analysis||'無'}\n\n`+
`💡 創意亮點:\n${d.creative_highlights||'無'}\n\n📝 講評:\n${d.comments||'無'}\n\n`+
`📉 扣分項目:\n${d.deducted_items||'無'}`;
setStatus('testStatus','✅ 試評完成(提醒:試評不會儲存,學生要能自評請回⑤按「儲存設定」)','ok');
}catch(e){ setStatus('testStatus','❌ '+e.message,'err'); }
}
// 讀取學生成績紀錄(學號 / 分數 / 評測時間)
async function loadSubmissions(){
setStatus('subStatus','讀取中…','busy');
$('subTable').innerHTML='';
try{
const r=await fetch(base()+'/api/teacher/submissions',{headers:headers()});
const j=await r.json();
if(!j.ok){ setStatus('subStatus','❌ '+(j.error||'讀取失敗'),'err'); return; }
const rows=j.submissions||[];
if(!rows.length){ setStatus('subStatus','(目前尚無學生自評紀錄)','ok'); return; }
let h='<table><thead><tr><th>學號</th><th>分數</th><th>評測時間</th><th>作業主題</th></tr></thead><tbody>';
rows.forEach(r=>{
const sc=(r.score===undefined||r.score===null||r.score===-1)?'—':r.score;
const esc=s=>String(s??'').replace(/</g,'<');
h+=`<tr><td class="sid">${esc(r.student_id)}</td><td style="font-weight:700">${sc}</td>`+
`<td>${esc(r.created_at)}</td><td>${esc(r.theme)}</td></tr>`;
});
h+='</tbody></table>';
$('subTable').innerHTML=h;
setStatus('subStatus','✅ 共 '+rows.length+' 筆紀錄','ok');
}catch(e){ setStatus('subStatus','❌ '+e.message,'err'); }
}
</script>
</body>
</html>