Mochi0622 commited on
Commit
fc76b7f
·
1 Parent(s): c467c9f
desktop.ini ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ [.ShellClassInfo]
2
+ IconResource=D:\other_thing\2�N���лP��Ƨ�\�N�׭�����Ƨ�\���A�Y�N�ת��B��\��.ico,0
3
+ [ViewState]
4
+ Mode=
5
+ Vid=
6
+ FolderType=Generic
firebase_progress.py CHANGED
@@ -81,6 +81,73 @@ def log_solution_viewed(student_id: str, unit: str, solution_id: str) -> None:
81
  .set({"solutions_viewed_count": _increment(1), "last_seen": _now()}, merge=True)
82
 
83
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
84
  def log_hint_used(student_id: str, unit: str, qid: str) -> None:
85
  """記錄學生使用了 AI 提示。"""
86
  log_event(student_id, "hint_used", unit=unit, qid=qid)
 
81
  .set({"solutions_viewed_count": _increment(1), "last_seen": _now()}, merge=True)
82
 
83
 
84
+ def log_choice_option(unit: str, qid: str, option_key: str) -> None:
85
+ """記錄某題某選項被選擇的次數(全班統計,存在 question_stats 集合)。"""
86
+ db = get_db()
87
+ ref = (
88
+ db.collection("question_stats")
89
+ .document(f"{unit}__{qid}")
90
+ )
91
+ ref.set({
92
+ "unit": unit,
93
+ "qid": qid,
94
+ f"option_{option_key}": _increment(1),
95
+ }, merge=True)
96
+
97
+
98
+ def log_quiz_question_stat(unit: str, qid: str, correct: bool) -> None:
99
+ """記錄某題全班答對/答錯次數(存在 question_stats 集合)。"""
100
+ db = get_db()
101
+ ref = db.collection("question_stats").document(f"{unit}__{qid}")
102
+ field = "correct_count" if correct else "wrong_count"
103
+ ref.set({"unit": unit, "qid": qid, field: _increment(1)}, merge=True)
104
+
105
+
106
+ def get_all_question_stats() -> list[dict]:
107
+ """讀取所有題目的答題統計(老師後台用)。"""
108
+ db = get_db()
109
+ docs = db.collection("question_stats").stream()
110
+ return [d.to_dict() for d in docs]
111
+
112
+
113
+ def register_question(
114
+ unit: str,
115
+ qid: str,
116
+ qtype: str,
117
+ prompt_runs: list | None = None,
118
+ prompt_md: str = "",
119
+ options: list | None = None,
120
+ correct_answer: str | list | None = None,
121
+ ) -> None:
122
+ """
123
+ 在學生作答時,記錄題目原始內容到 question_stats。
124
+ 每次都更新(允許覆蓋),確保每道題目各自正確記錄。
125
+ prompt_runs: 題目的 run 清單(含文字與 LaTeX)
126
+ prompt_md: 題目的 markdown 純文字(備援)
127
+ options: 選項清單 [{"key": "a", "text": "..."}, ...]
128
+ correct_answer: 正確答案 key(單選為 str,多選為 list)
129
+ """
130
+ db = get_db()
131
+ ref = db.collection("question_stats").document(f"{unit}__{qid}")
132
+ update: dict = {"unit": unit, "qid": qid, "qtype": qtype}
133
+ if prompt_runs is not None:
134
+ # 只存 text/latex 欄位,避免多餘 key 佔空間
135
+ update["prompt_runs"] = [
136
+ {k: v for k, v in r.items() if k in ("text", "latex", "kind", "style", "newline")}
137
+ for r in (prompt_runs or [])
138
+ ]
139
+ if prompt_md:
140
+ update["prompt_md"] = prompt_md[:300]
141
+ if options:
142
+ update["options"] = [{"key": o.get("key", ""), "text": o.get("text", "")} for o in options]
143
+ if correct_answer is not None:
144
+ if isinstance(correct_answer, list):
145
+ update["correct_answers"] = [str(k).lower() for k in correct_answer]
146
+ else:
147
+ update["correct_answers"] = [str(correct_answer).lower()]
148
+ ref.set(update, merge=True)
149
+
150
+
151
  def log_hint_used(student_id: str, unit: str, qid: str) -> None:
152
  """記錄學生使用了 AI 提示。"""
153
  log_event(student_id, "hint_used", unit=unit, qid=qid)
layout/renderer/dt_callbacks.py CHANGED
@@ -94,8 +94,14 @@ def _cb_check_fill(n_check, n_hint, user_input, meta, prev_pass, student_id, pat
94
  qid = trig.get("qid", "")
95
  unit = (pathname or "").strip("/").replace("/", "_") or "unknown"
96
  try:
97
- from firebase_progress import log_quiz_result
98
- log_quiz_result(student_id, unit, qid, True)
 
 
 
 
 
 
99
  except Exception as e:
100
  print(f"[Firebase] fill correct 失敗:{e}")
101
  return _feedback_child(right_msg), "green", "light", {}, True
@@ -103,8 +109,14 @@ def _cb_check_fill(n_check, n_hint, user_input, meta, prev_pass, student_id, pat
103
  qid = trig.get("qid", "")
104
  unit = (pathname or "").strip("/").replace("/", "_") or "unknown"
105
  try:
106
- from firebase_progress import log_quiz_result
107
- log_quiz_result(student_id, unit, qid, False)
 
 
 
 
 
 
108
  except Exception as e:
109
  print(f"[Firebase] fill wrong 失敗:{e}")
110
  return _feedback_child(wrong_msg), "red", "light", {}, False
@@ -246,8 +258,26 @@ def _cb_check_choice(n_submit, n_hint, val, meta, student_id, pathname):
246
  qid = trig.get("qid", "") if isinstance(trig, dict) else ""
247
  unit = (pathname or "").strip("/").replace("/", "_") or "unknown"
248
  try:
249
- from firebase_progress import log_quiz_result
250
- log_quiz_result(student_id, unit, qid, ok)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
251
  except Exception as e:
252
  print(f"[Firebase] choice 失敗:{e}")
253
 
 
94
  qid = trig.get("qid", "")
95
  unit = (pathname or "").strip("/").replace("/", "_") or "unknown"
96
  try:
97
+ from firebase_progress import log_quiz_result, log_quiz_question_stat, register_question
98
+ _meta = meta or {}
99
+ if qid:
100
+ register_question(unit, qid, "fill",
101
+ prompt_runs=_meta.get("prompt_runs"),
102
+ prompt_md=_meta.get("prompt_md", ""))
103
+ log_quiz_result(student_id, unit, qid, True, answer=user_input or "")
104
+ log_quiz_question_stat(unit, qid, True)
105
  except Exception as e:
106
  print(f"[Firebase] fill correct 失敗:{e}")
107
  return _feedback_child(right_msg), "green", "light", {}, True
 
109
  qid = trig.get("qid", "")
110
  unit = (pathname or "").strip("/").replace("/", "_") or "unknown"
111
  try:
112
+ from firebase_progress import log_quiz_result, log_quiz_question_stat, register_question
113
+ _meta = meta or {}
114
+ if qid:
115
+ register_question(unit, qid, "fill",
116
+ prompt_runs=_meta.get("prompt_runs"),
117
+ prompt_md=_meta.get("prompt_md", ""))
118
+ log_quiz_result(student_id, unit, qid, False, answer=user_input or "")
119
+ log_quiz_question_stat(unit, qid, False)
120
  except Exception as e:
121
  print(f"[Firebase] fill wrong 失敗:{e}")
122
  return _feedback_child(wrong_msg), "red", "light", {}, False
 
258
  qid = trig.get("qid", "") if isinstance(trig, dict) else ""
259
  unit = (pathname or "").strip("/").replace("/", "_") or "unknown"
260
  try:
261
+ from firebase_progress import log_quiz_result, log_quiz_question_stat, log_choice_option, register_question
262
+ _meta = meta or {}
263
+ options = _meta.get("options", [])
264
+ if qid:
265
+ _correct = _meta.get("answers") if qtype == "multi" else _meta.get("answer")
266
+ register_question(unit, qid, qtype,
267
+ prompt_runs=_meta.get("prompt_runs"),
268
+ prompt_md=_meta.get("prompt_md", ""),
269
+ options=options or None,
270
+ correct_answer=_correct or None)
271
+ # 記錄答題結果(含答案)
272
+ selected_answer = ",".join(sorted(selected)) if qtype == "multi" else (val or "")
273
+ log_quiz_result(student_id, unit, qid, ok, answer=selected_answer)
274
+ log_quiz_question_stat(unit, qid, ok)
275
+ # 記錄各選項被選次數(選擇題)
276
+ if qtype == "choice" and val:
277
+ log_choice_option(unit, qid, str(val))
278
+ elif qtype == "multi":
279
+ for sel_key in selected:
280
+ log_choice_option(unit, qid, str(sel_key))
281
  except Exception as e:
282
  print(f"[Firebase] choice 失敗:{e}")
283
 
layout/renderer/dt_quiz.py CHANGED
@@ -160,6 +160,8 @@ def render_quiz(node: dict):
160
 
161
  meta = {
162
  "qtype": qtype, "prompt_text": full_prompt_text,
 
 
163
  "options": opts, "answer": q.get("answer", ""),
164
  "answers": q.get("answers", []), "explain": q.get("explain", ""),
165
  "msg_right": q.get("msg_right", "✔ 正確!"),
@@ -222,6 +224,8 @@ def render_quiz(node: dict):
222
  "normalize": q.get("normalize", []),
223
  "msg_right": node.get("right_msg", ""), "msg_wrong": node.get("wrong_msg", ""),
224
  "prompt_text": full_prompt_text,
 
 
225
  "ai": {
226
  "enabled": str(node.get("ai", "1")).lower() not in ("0", "false", "no", "off"),
227
  "provider": node.get("ai_provider") or "gemini",
 
160
 
161
  meta = {
162
  "qtype": qtype, "prompt_text": full_prompt_text,
163
+ "prompt_runs": prompt_runs,
164
+ "prompt_md": prompt_md,
165
  "options": opts, "answer": q.get("answer", ""),
166
  "answers": q.get("answers", []), "explain": q.get("explain", ""),
167
  "msg_right": q.get("msg_right", "✔ 正確!"),
 
224
  "normalize": q.get("normalize", []),
225
  "msg_right": node.get("right_msg", ""), "msg_wrong": node.get("wrong_msg", ""),
226
  "prompt_text": full_prompt_text,
227
+ "prompt_runs": prompt_runs,
228
+ "prompt_md": prompt_md,
229
  "ai": {
230
  "enabled": str(node.get("ai", "1")).lower() not in ("0", "false", "no", "off"),
231
  "provider": node.get("ai_provider") or "gemini",
layout/shell/header.py CHANGED
@@ -1,3 +1,4 @@
 
1
  # header.py (或你專案中產生 header 的檔案)
2
  from dash import html
3
  import dash_mantine_components as dmc
@@ -39,6 +40,19 @@ def generate_header():
39
  align="center",
40
  children=[
41
  create_student_badge(),
 
 
 
 
 
 
 
 
 
 
 
 
 
42
  dmc.Switch(
43
  id="color-scheme-switch", size="lg", color="gray",
44
  onLabel="🌙", offLabel="☀️", persistence=True
 
1
+ from dash_iconify import DashIconify
2
  # header.py (或你專案中產生 header 的檔案)
3
  from dash import html
4
  import dash_mantine_components as dmc
 
40
  align="center",
41
  children=[
42
  create_student_badge(),
43
+ dmc.Anchor(
44
+ href="/teacher",
45
+ children=dmc.Tooltip(
46
+ label="老師後台",
47
+ children=dmc.ActionIcon(
48
+ DashIconify(icon="tabler:layout-dashboard", height=18),
49
+ size="md",
50
+ variant="subtle",
51
+ color="gray",
52
+ ),
53
+ ),
54
+ style={"lineHeight": 0},
55
+ ),
56
  dmc.Switch(
57
  id="color-scheme-switch", size="lg", color="gray",
58
  onLabel="🌙", offLabel="☀️", persistence=True
pages/firebase_progress.py ADDED
@@ -0,0 +1,175 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ firebase_progress.py
3
+ --------------------
4
+ 學生學習進度的讀寫邏輯。
5
+ 放在專案根目錄,由 dt_callbacks.py 和頁面呼叫。
6
+
7
+ Firestore 資料結構:
8
+ students/{student_id}/
9
+ profile: { name, student_id, last_seen }
10
+ progress: { quiz_correct, solutions_viewed, ai_hints_used, ... }
11
+ events: [ { type, unit, qid, ts, ... }, ... ] ← 子集合
12
+ """
13
+ from __future__ import annotations
14
+ import datetime
15
+ from firebase_init import get_db
16
+
17
+ # ─── 時間戳 ────────────────────────────────────────────────────────────────
18
+ def _now() -> str:
19
+ return datetime.datetime.now(datetime.timezone.utc).isoformat()
20
+
21
+
22
+ # ─── 學生個人資料 ──────────────────────────────────────────────────────────
23
+
24
+ def upsert_student(student_id: str, name: str = "") -> None:
25
+ """建立或更新學生個人資料。"""
26
+ db = get_db()
27
+ ref = db.collection("students").document(student_id)
28
+ ref.set({
29
+ "student_id": student_id,
30
+ "name": name or student_id,
31
+ "last_seen": _now(),
32
+ }, merge=True)
33
+
34
+
35
+ # ─── 記錄事件 ─────────────────────────────────────────────────────────────
36
+
37
+ def log_event(student_id: str, event_type: str, **kwargs) -> None:
38
+ """
39
+ 記錄一筆學習事件到 students/{student_id}/events 子集合。
40
+
41
+ event_type 範例:
42
+ "quiz_correct" - 填空/選擇題答對
43
+ "quiz_wrong" - 答錯
44
+ "solution_viewed" - 展開詳細解法
45
+ "hint_used" - 按下 AI 提示
46
+ "quiz_ai_done" - AI 批改完成
47
+ """
48
+ db = get_db()
49
+ payload = {
50
+ "type": event_type,
51
+ "ts": _now(),
52
+ **kwargs, # unit, qid, answer, ...
53
+ }
54
+ db.collection("students").document(student_id)\
55
+ .collection("events").add(payload)
56
+
57
+
58
+ def log_quiz_result(
59
+ student_id: str,
60
+ unit: str,
61
+ qid: str,
62
+ correct: bool,
63
+ answer: str = "",
64
+ ) -> None:
65
+ """記錄一題答題結果,同時更新 progress 計數。"""
66
+ event_type = "quiz_correct" if correct else "quiz_wrong"
67
+ log_event(student_id, event_type, unit=unit, qid=qid, answer=answer)
68
+
69
+ # 更新統計計數
70
+ db = get_db()
71
+ ref = db.collection("students").document(student_id)
72
+ field = "quiz_correct_count" if correct else "quiz_wrong_count"
73
+ ref.set({field: _increment(1), "last_seen": _now()}, merge=True)
74
+
75
+
76
+ def log_solution_viewed(student_id: str, unit: str, solution_id: str) -> None:
77
+ """記錄學生展開了某個解法區塊。"""
78
+ log_event(student_id, "solution_viewed", unit=unit, solution_id=solution_id)
79
+ db = get_db()
80
+ db.collection("students").document(student_id)\
81
+ .set({"solutions_viewed_count": _increment(1), "last_seen": _now()}, merge=True)
82
+
83
+
84
+ def log_choice_option(unit: str, qid: str, option_key: str) -> None:
85
+ """記錄某題某選項被選擇的次數(全班統計,存在 question_stats 集合)。"""
86
+ db = get_db()
87
+ ref = (
88
+ db.collection("question_stats")
89
+ .document(f"{unit}__{qid}")
90
+ )
91
+ ref.set({
92
+ "unit": unit,
93
+ "qid": qid,
94
+ f"option_{option_key}": _increment(1),
95
+ }, merge=True)
96
+
97
+
98
+ def log_quiz_question_stat(unit: str, qid: str, correct: bool) -> None:
99
+ """記錄某題全班答對/答錯次數(存在 question_stats 集合)。"""
100
+ db = get_db()
101
+ ref = db.collection("question_stats").document(f"{unit}__{qid}")
102
+ field = "correct_count" if correct else "wrong_count"
103
+ ref.set({"unit": unit, "qid": qid, field: _increment(1)}, merge=True)
104
+
105
+
106
+ def get_all_question_stats() -> list[dict]:
107
+ """讀取所有題目的答題統計(老師後台用)。"""
108
+ db = get_db()
109
+ docs = db.collection("question_stats").stream()
110
+ return [d.to_dict() for d in docs]
111
+
112
+
113
+ def register_question(unit: str, qid: str, prompt_text: str, qtype: str, options: list | None = None) -> None:
114
+ """
115
+ 在第一次有學生作答時,記錄題目內容到 question_stats(若尚未存在)。
116
+ prompt_text: 題目文字(純文字,不含 LaTeX 標記也可)
117
+ options: [{"key": "a", "text": "..."}, ...] 或 None
118
+ """
119
+ db = get_db()
120
+ ref = db.collection("question_stats").document(f"{unit}__{qid}")
121
+ doc = ref.get()
122
+ if doc.exists and doc.to_dict().get("prompt_text"):
123
+ return # 已有紀錄,不重複寫
124
+ update = {"unit": unit, "qid": qid, "qtype": qtype}
125
+ if prompt_text:
126
+ update["prompt_text"] = prompt_text[:300] # 截斷避免過長
127
+ if options:
128
+ update["options"] = [{"key": o.get("key", ""), "text": o.get("text", "")} for o in options]
129
+ ref.set(update, merge=True)
130
+
131
+
132
+ def log_hint_used(student_id: str, unit: str, qid: str) -> None:
133
+ """記錄學生使用了 AI 提示。"""
134
+ log_event(student_id, "hint_used", unit=unit, qid=qid)
135
+ db = get_db()
136
+ db.collection("students").document(student_id)\
137
+ .set({"ai_hints_used_count": _increment(1), "last_seen": _now()}, merge=True)
138
+
139
+
140
+ # ─── 讀取 ────────────────────────────────────────────────────────────────
141
+
142
+ def get_student_progress(student_id: str) -> dict:
143
+ """讀取單一學生的 progress 計數(老師後台用)。"""
144
+ db = get_db()
145
+ doc = db.collection("students").document(student_id).get()
146
+ if doc.exists:
147
+ return doc.to_dict()
148
+ return {}
149
+
150
+
151
+ def get_all_students() -> list[dict]:
152
+ """讀取所有學生的 profile(老師後台列表用)。"""
153
+ db = get_db()
154
+ docs = db.collection("students").stream()
155
+ return [d.to_dict() for d in docs]
156
+
157
+
158
+ def get_student_events(student_id: str, limit: int = 100) -> list[dict]:
159
+ """讀取某學生最近的事件記錄。"""
160
+ db = get_db()
161
+ docs = (
162
+ db.collection("students").document(student_id)
163
+ .collection("events")
164
+ .order_by("ts", direction="DESCENDING")
165
+ .limit(limit)
166
+ .stream()
167
+ )
168
+ return [d.to_dict() for d in docs]
169
+
170
+
171
+ # ─── Firestore 遞增輔助 ───────────────────────────────────────────────────
172
+
173
+ def _increment(n: int):
174
+ from google.cloud.firestore import Increment
175
+ return Increment(n)
pages/teacher.py CHANGED
@@ -63,9 +63,32 @@ def layout():
63
  dmc.Text("📚 單元答題統計", fw=700, size="lg", mb="sm"),
64
  html.Div(id="teacher-unit-stats"),
65
 
 
 
 
 
 
 
66
  # 個別學生事件
67
  dmc.Divider(my="lg"),
68
  html.Div(id="teacher-events-section"),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
69
  ]),
70
  ],
71
  )
@@ -134,7 +157,11 @@ def clear_all_data(n):
134
  "ai_hints_used_count": 0,
135
  "solutions_viewed_count": 0,
136
  }, merge=True)
137
- print("[Teacher] 清除所有答記錄")
 
 
 
 
138
  except Exception as e:
139
  print(f"[Teacher] 清除失敗:{e}")
140
  return 1 # 觸發重新整理
@@ -146,24 +173,25 @@ def clear_all_data(n):
146
  Output("teacher-stats-cards", "children"),
147
  Output("teacher-student-table", "children"),
148
  Output("teacher-unit-stats", "children"),
 
149
  Input("teacher-auth-store", "data"),
150
  Input("teacher-refresh-btn", "n_clicks"),
151
  prevent_initial_call=False,
152
  )
153
  def load_dashboard(auth, _):
154
  if not auth:
155
- return no_update, no_update, no_update
156
 
157
  try:
158
  from firebase_progress import get_all_students, get_student_events
159
  students = get_all_students()
160
  except Exception as e:
161
  err = dmc.Alert(f"讀取失敗:{e}", color="red")
162
- return err, err, err
163
 
164
  if not students:
165
  empty = dmc.Text("目前沒有學生資料。", c="dimmed")
166
- return empty, empty, empty
167
 
168
  # ── 統計卡片 ──
169
  total = len(students)
@@ -247,7 +275,73 @@ def load_dashboard(auth, _):
247
  dmc.TableTbody(dmc.TableTr(dmc.TableTd("沒有記錄"))),
248
  ])
249
 
250
- return stats_cards, table, unit_table
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
251
 
252
 
253
  # ─── 個別學生事件 ─────────────────────────────────────────────────────────
@@ -276,12 +370,15 @@ def show_events(n_clicks_list):
276
  for ev in events:
277
  etype = ev.get("type", "")
278
  _, color = _event_style(etype)
 
 
279
  rows.append(dmc.TableTr([
280
  dmc.TableTd(dmc.Badge(etype, color=color, variant="light", size="sm")),
281
  dmc.TableTd(ev.get("unit", "—").replace("_", "/"),
282
  style={"fontSize": "12px"}),
283
  dmc.TableTd(ev.get("qid") or ev.get("solution_id", "—"),
284
  style={"fontSize": "11px", "color": "#888"}),
 
285
  dmc.TableTd((ev.get("ts", "")[:16] or "—").replace("T", " "),
286
  style={"fontSize": "12px", "color": "#888"}),
287
  ]))
@@ -291,13 +388,120 @@ def show_events(n_clicks_list):
291
  dmc.Table(striped=True, withTableBorder=True,
292
  children=[
293
  dmc.TableThead(dmc.TableTr([dmc.TableTh(h) for h in
294
- ["事件", "單元", "題目", "時間"]])),
295
  dmc.TableTbody(rows) if rows else
296
  dmc.TableTbody(dmc.TableTr(dmc.TableTd("沒有記錄"))),
297
  ]),
298
  ])
299
 
300
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
301
  # ─── 輔助 ────────────────────────────────────────────────────────────────
302
 
303
  def _stat_card(label, value, icon, color):
 
63
  dmc.Text("📚 單元答題統計", fw=700, size="lg", mb="sm"),
64
  html.Div(id="teacher-unit-stats"),
65
 
66
+ # 每題答題統計
67
+ dmc.Divider(my="lg"),
68
+ dmc.Text("📊 每題答題統計", fw=700, size="lg", mb="sm"),
69
+ dmc.Text("(包含各選項被選次數)", size="sm", c="dimmed", mb="sm"),
70
+ html.Div(id="teacher-question-stats"),
71
+
72
  # 個別學生事件
73
  dmc.Divider(my="lg"),
74
  html.Div(id="teacher-events-section"),
75
+
76
+ # 題目 doc_ids Store(由 load_dashboard 寫入)
77
+ dcc.Store(id="teacher-question-doc-ids", data=[]),
78
+
79
+ # 題目預覽 Modal
80
+ dmc.Modal(
81
+ id="teacher-question-modal",
82
+ opened=False,
83
+ centered=True,
84
+ size="lg",
85
+ title=dmc.Group([
86
+ DashIconify(icon="tabler:file-text", height=20),
87
+ dmc.Text("題目內容", fw=700),
88
+ ], gap="xs"),
89
+ children=html.Div(id="teacher-question-modal-body"),
90
+ ),
91
+ dcc.Store(id="teacher-question-modal-store", data=None),
92
  ]),
93
  ],
94
  )
 
157
  "ai_hints_used_count": 0,
158
  "solutions_viewed_count": 0,
159
  }, merge=True)
160
+ # 清除統計
161
+ q_stats = db.collection("question_stats").stream()
162
+ for qs in q_stats:
163
+ qs.reference.delete()
164
+ print("[Teacher] 已清除所有答題記錄(含每題統計)")
165
  except Exception as e:
166
  print(f"[Teacher] 清除失敗:{e}")
167
  return 1 # 觸發重新整理
 
173
  Output("teacher-stats-cards", "children"),
174
  Output("teacher-student-table", "children"),
175
  Output("teacher-unit-stats", "children"),
176
+ Output("teacher-question-stats", "children"),
177
  Input("teacher-auth-store", "data"),
178
  Input("teacher-refresh-btn", "n_clicks"),
179
  prevent_initial_call=False,
180
  )
181
  def load_dashboard(auth, _):
182
  if not auth:
183
+ return no_update, no_update, no_update, no_update
184
 
185
  try:
186
  from firebase_progress import get_all_students, get_student_events
187
  students = get_all_students()
188
  except Exception as e:
189
  err = dmc.Alert(f"讀取失敗:{e}", color="red")
190
+ return err, err, err, err
191
 
192
  if not students:
193
  empty = dmc.Text("目前沒有學生資料。", c="dimmed")
194
+ return empty, empty, empty, empty
195
 
196
  # ── 統計卡片 ──
197
  total = len(students)
 
275
  dmc.TableTbody(dmc.TableTr(dmc.TableTd("沒有記錄"))),
276
  ])
277
 
278
+ # ── 每題答題統計 ──
279
+ try:
280
+ from firebase_progress import get_all_question_stats
281
+ q_stats_raw = get_all_question_stats()
282
+ except Exception:
283
+ q_stats_raw = []
284
+
285
+ question_rows = []
286
+ question_doc_ids = [] # 與 question_rows 同索引,對應 Firestore doc id
287
+ for qs in sorted(q_stats_raw, key=lambda x: (x.get("unit", ""), x.get("qid", ""))):
288
+ unit_raw = qs.get("unit", "") # 原始格式,如 "Ch14_6"
289
+ qid_raw = qs.get("qid", "") # 原始格式,如 "flow-15::step-1::q1"
290
+ unit_label = unit_raw.replace("_", "/") # 顯示用
291
+ qid_label = qid_raw
292
+ c = qs.get("correct_count", 0)
293
+ w = qs.get("wrong_count", 0)
294
+ t = c + w
295
+ rate = f"{c/t*100:.0f}%" if t > 0 else "—"
296
+
297
+ # 選項統計
298
+ option_keys = sorted([k for k in qs if k.startswith("option_")])
299
+ correct_answers = qs.get("correct_answers", []) # list of correct key strings
300
+ if option_keys:
301
+ badges = []
302
+ for k in option_keys:
303
+ opt_key = k.replace("option_", "").lower()
304
+ is_correct = opt_key in [str(a).lower() for a in correct_answers]
305
+ badges.append(dmc.Badge(
306
+ f"{opt_key.upper()}:{qs[k]}",
307
+ color="green" if is_correct else "red",
308
+ variant="light",
309
+ size="sm",
310
+ ))
311
+ option_badges = dmc.Group(gap=4, children=badges)
312
+ else:
313
+ option_badges = dmc.Text("(填空題)", size="xs", c="dimmed")
314
+
315
+ question_rows.append(dmc.TableTr([
316
+ dmc.TableTd(unit_label, style={"fontSize": "12px"}),
317
+ dmc.TableTd(qid_label, style={"fontSize": "11px", "color": "#888"}),
318
+ dmc.TableTd(str(c), style={"color": "green", "fontWeight": 600}),
319
+ dmc.TableTd(str(w), style={"color": "red", "fontWeight": 600}),
320
+ dmc.TableTd(rate),
321
+ dmc.TableTd(option_badges),
322
+ dmc.TableTd(dmc.Button(
323
+ "查看題目",
324
+ id={"type": "teacher-view-question", "idx": len(question_rows)},
325
+ size="xs", variant="subtle",
326
+ leftSection=DashIconify(icon="tabler:eye", height=12),
327
+ )),
328
+ ]))
329
+ question_doc_ids.append(f"{unit_raw}__{qid_raw}")
330
+
331
+ question_table = html.Div([
332
+ dcc.Store(id="teacher-question-doc-ids", data=question_doc_ids),
333
+ dmc.Table(
334
+ striped=True, withTableBorder=True,
335
+ children=[
336
+ dmc.TableThead(dmc.TableTr([dmc.TableTh(h) for h in
337
+ ["單元", "題目 ID", "答對次數", "答錯次數", "正確率", "各選項次數", ""]])),
338
+ dmc.TableTbody(question_rows) if question_rows else
339
+ dmc.TableTbody(dmc.TableTr(dmc.TableTd("沒有記錄"))),
340
+ ]
341
+ ),
342
+ ])
343
+
344
+ return stats_cards, table, unit_table, question_table
345
 
346
 
347
  # ─── 個別學生事件 ─────────────────────────────────────────────────────────
 
370
  for ev in events:
371
  etype = ev.get("type", "")
372
  _, color = _event_style(etype)
373
+ answer_val = ev.get("answer", "")
374
+ answer_cell = dmc.Text(str(answer_val)[:40], size="xs", c="dimmed") if answer_val else dmc.Text("—", size="xs", c="dimmed")
375
  rows.append(dmc.TableTr([
376
  dmc.TableTd(dmc.Badge(etype, color=color, variant="light", size="sm")),
377
  dmc.TableTd(ev.get("unit", "—").replace("_", "/"),
378
  style={"fontSize": "12px"}),
379
  dmc.TableTd(ev.get("qid") or ev.get("solution_id", "—"),
380
  style={"fontSize": "11px", "color": "#888"}),
381
+ dmc.TableTd(answer_cell),
382
  dmc.TableTd((ev.get("ts", "")[:16] or "—").replace("T", " "),
383
  style={"fontSize": "12px", "color": "#888"}),
384
  ]))
 
388
  dmc.Table(striped=True, withTableBorder=True,
389
  children=[
390
  dmc.TableThead(dmc.TableTr([dmc.TableTh(h) for h in
391
+ ["事件", "單元", "題目", "學生答案", "時間"]])),
392
  dmc.TableTbody(rows) if rows else
393
  dmc.TableTbody(dmc.TableTr(dmc.TableTd("沒有記錄"))),
394
  ]),
395
  ])
396
 
397
 
398
+ # ─── 查看題目 Modal ──────────────────────────────────────────────────────
399
+
400
+ @callback(
401
+ Output("teacher-question-modal", "opened"),
402
+ Output("teacher-question-modal-body", "children"),
403
+ Input({"type": "teacher-view-question", "idx": ALL}, "n_clicks"),
404
+ State("teacher-question-doc-ids", "data"),
405
+ prevent_initial_call=True,
406
+ )
407
+ def open_question_modal(n_clicks_list, doc_ids):
408
+ trigger = dash.callback_context.triggered_id
409
+ if not trigger or not isinstance(trigger, dict):
410
+ return no_update, no_update
411
+
412
+ # 確認是真正的點擊(n_clicks > 0),避免重整時觸發
413
+ idx = trigger.get("idx")
414
+ if idx is None:
415
+ return no_update, no_update
416
+ triggered_clicks = n_clicks_list[idx] if idx < len(n_clicks_list) else 0
417
+ if not triggered_clicks or triggered_clicks < 1:
418
+ return no_update, no_update
419
+
420
+ doc_ids = doc_ids or []
421
+ if idx >= len(doc_ids):
422
+ return no_update, no_update
423
+ doc_id = doc_ids[idx]
424
+
425
+ if not doc_id:
426
+ return no_update, no_update
427
+
428
+ try:
429
+ from firebase_init import get_db
430
+ db = get_db()
431
+ doc = db.collection("question_stats").document(doc_id).get()
432
+ if not doc.exists:
433
+ return True, dmc.Alert("找不到題目資料(學生尚未作答此題)。", color="yellow")
434
+ data = doc.to_dict()
435
+ except Exception as e:
436
+ return True, dmc.Alert(f"讀取失敗:{e}", color="red")
437
+
438
+ unit = data.get("unit", "—").replace("_", "/")
439
+ qid = data.get("qid", "—")
440
+ qtype = data.get("qtype", "—")
441
+ options = data.get("options", [])
442
+
443
+ qtype_label = {"fill": "填空題", "choice": "單選題", "multi": "多選題", "order": "排序題"}.get(qtype, qtype)
444
+
445
+ # 把 prompt_runs 轉成 Markdown 字串(含 $...$ LaTeX)
446
+ prompt_runs = data.get("prompt_runs") or []
447
+ prompt_md = data.get("prompt_md", "")
448
+
449
+ def runs_to_md(runs):
450
+ parts = []
451
+ for r in runs:
452
+ if "latex" in r:
453
+ latex = r["latex"].strip()
454
+ if r.get("kind") in ("math_block", "block_math", "display_math"):
455
+ parts.append("\n\n$$\n" + latex + "\n$$\n\n")
456
+ else:
457
+ parts.append(f"${latex}$")
458
+ elif "text" in r:
459
+ parts.append(r["text"])
460
+ if str(r.get("newline", "false")).lower() == "true":
461
+ parts.append("\n\n")
462
+ return "".join(parts).strip()
463
+
464
+ prompt_display = runs_to_md(prompt_runs) if prompt_runs else prompt_md
465
+
466
+ if not prompt_display:
467
+ prompt_component = dmc.Text(
468
+ "(此題題目文字嵌在上下文卡片中,請參考下方選項識別題目。)",
469
+ size="sm", c="dimmed", style={"fontStyle": "italic"},
470
+ )
471
+ else:
472
+ prompt_component = dcc.Markdown(
473
+ prompt_display,
474
+ mathjax=True,
475
+ style={"fontSize": "0.95rem", "lineHeight": "1.7"},
476
+ )
477
+
478
+ body_children = [
479
+ dmc.Group([
480
+ dmc.Badge(qtype_label, color="violet", variant="light"),
481
+ dmc.Text(f"單元:{unit}", size="sm", c="dimmed"),
482
+ dmc.Text(f"題目 ID:{qid}", size="sm", c="dimmed"),
483
+ ], gap="xs", mb="md"),
484
+ dmc.Divider(mb="md"),
485
+ dmc.Text("📝 題目:", fw=700, size="sm", mb=4),
486
+ dmc.Paper(prompt_component, p="sm", radius="sm", withBorder=True, mb="md"),
487
+ ]
488
+
489
+ if options:
490
+ body_children.append(dmc.Text("🔘 選項:", fw=700, size="sm", mb=4))
491
+ for op in options:
492
+ key = op.get("key", "?")
493
+ text = op.get("text", "")
494
+ body_children.append(
495
+ dmc.Group([
496
+ dmc.Badge(key.upper(), variant="outline", size="sm", color="blue"),
497
+ dcc.Markdown(text, mathjax=True,
498
+ style={"display": "inline", "fontSize": "0.9rem", "margin": 0}),
499
+ ], gap="xs", mb=4, align="center")
500
+ )
501
+
502
+ return True, html.Div(body_children)
503
+
504
+
505
  # ─── 輔助 ────────────────────────────────────────────────────────────────
506
 
507
  def _stat_card(label, value, icon, color):