steveyehtw commited on
Commit
ed832a7
·
1 Parent(s): 6737361

Add content admin and interactive Python console

Browse files
README.md CHANGED
@@ -13,6 +13,7 @@ Check out the configuration reference at https://huggingface.co/docs/hub/spaces-
13
 
14
  - 學生:`/classroom`
15
  - 教師:`/classroom/teacher`
 
16
  - 展示:`/`
17
 
18
  內建 2026 Fall 18 週課程,Week 1、2 已有套版。支援多個 Python 檔、ZIP 匯入匯出、即時監看、每週只保留最後正式版與整班發布。
@@ -23,6 +24,8 @@ Check out the configuration reference at https://huggingface.co/docs/hub/spaces-
23
  - 教師可選現有課程,也可用教師密碼建立新課程代碼。新課程會寫回此 Space,因此建立時會觸發一次重新建置。
24
  - 教師可從課程刪除學生及其教室內進度;已經發布到首頁 Demo 的作品不會連帶刪除。
25
  - 學生名單與尚未發布的草稿保存在 Space 執行期間;每週正式提交發布後才會成為此 repository 內的長期作品。
 
 
26
 
27
  Space Secrets:
28
 
 
13
 
14
  - 學生:`/classroom`
15
  - 教師:`/classroom/teacher`
16
+ - 內容管理:`/admin`
17
  - 展示:`/`
18
 
19
  內建 2026 Fall 18 週課程,Week 1、2 已有套版。支援多個 Python 檔、ZIP 匯入匯出、即時監看、每週只保留最後正式版與整班發布。
 
24
  - 教師可選現有課程,也可用教師密碼建立新課程代碼。新課程會寫回此 Space,因此建立時會觸發一次重新建置。
25
  - 教師可從課程刪除學生及其教室內進度;已經發布到首頁 Demo 的作品不會連帶刪除。
26
  - 學生名單與尚未發布的草稿保存在 Space 執行期間;每週正式提交發布後才會成為此 repository 內的長期作品。
27
+ - 學生端 Python 會逐段顯示輸出;程式執行到 `input()` 時,Console 才出現輸入列並等待送出。
28
+ - 內容管理後台可修改 18 週課程資料與每週套版,也可新增、改名、編輯或刪除公開 Demo 專題。所有儲存都會寫回此 Space。
29
 
30
  Space Secrets:
31
 
admin_service.py ADDED
@@ -0,0 +1,282 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Teacher-only content administration for classroom plans and Demo projects."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import re
6
+ import shutil
7
+ from pathlib import Path, PurePosixPath
8
+
9
+ from fastapi import APIRouter, HTTPException
10
+ from fastapi.responses import FileResponse
11
+ from pydantic import BaseModel
12
+
13
+ import classroom_service as classroom
14
+
15
+ ROOT = Path(__file__).resolve().parent
16
+ PROJECTS = ROOT / "projects"
17
+ STATIC = ROOT / "classroom_static"
18
+ TEXT_EXTENSIONS = {
19
+ ".py", ".html", ".css", ".js", ".json", ".txt", ".csv", ".md",
20
+ ".toml", ".yaml", ".yml",
21
+ }
22
+ router = APIRouter()
23
+
24
+
25
+ class AdminAuth(BaseModel):
26
+ password: str
27
+
28
+
29
+ class WeekRequest(AdminAuth):
30
+ week_id: str
31
+
32
+
33
+ class WeekSave(WeekRequest):
34
+ title: str
35
+ project: str
36
+ output: str
37
+ files: dict[str, str]
38
+
39
+
40
+ class ProjectRef(BaseModel):
41
+ class_name: str
42
+ student_name: str
43
+ project_name: str
44
+
45
+
46
+ class DemoRequest(AdminAuth):
47
+ project: ProjectRef
48
+
49
+
50
+ class DemoSave(AdminAuth):
51
+ original: ProjectRef | None = None
52
+ class_name: str
53
+ student_name: str
54
+ project_name: str
55
+ files: dict[str, str]
56
+
57
+
58
+ def segment(value: str, label: str) -> str:
59
+ text = re.sub(r"[\x00-\x1f\x7f]", "", str(value).strip())[:100]
60
+ if not text or text in {".", ".."} or "/" in text or "\\" in text:
61
+ raise HTTPException(400, f"{label}不合法")
62
+ return text
63
+
64
+
65
+ def text_path(value: str) -> str:
66
+ raw = str(value).replace("\\", "/").strip("/")
67
+ path = PurePosixPath(raw)
68
+ if (
69
+ not raw or path.is_absolute() or ".." in path.parts or len(raw) > 180
70
+ or path.suffix.lower() not in TEXT_EXTENSIONS
71
+ ):
72
+ raise HTTPException(400, f"檔案路徑不合法:{value}")
73
+ return path.as_posix()
74
+
75
+
76
+ def project_root(ref: ProjectRef) -> Path:
77
+ return PROJECTS / segment(ref.class_name, "班級名稱") / segment(ref.student_name, "學生名稱") / segment(ref.project_name, "專題名稱")
78
+
79
+
80
+ def relative_repo_path(path: Path) -> str:
81
+ return path.relative_to(ROOT).as_posix()
82
+
83
+
84
+ def commit_changes(additions: dict[str, bytes | Path], deletions: list[str], message: str):
85
+ if not classroom.TOKEN:
86
+ raise HTTPException(503, "請先在 Space Secrets 設定 HF_TOKEN")
87
+ from huggingface_hub import CommitOperationAdd, CommitOperationDelete, HfApi
88
+
89
+ add_paths = set(additions)
90
+ operations = [
91
+ CommitOperationDelete(path_in_repo=path)
92
+ for path in sorted(set(deletions) - add_paths)
93
+ ]
94
+ operations.extend(
95
+ CommitOperationAdd(path_in_repo=path, path_or_fileobj=value)
96
+ for path, value in sorted(additions.items())
97
+ )
98
+ if not operations:
99
+ return None
100
+ return HfApi(token=classroom.TOKEN).create_commit(
101
+ repo_id=classroom.REPO,
102
+ repo_type="space",
103
+ operations=operations,
104
+ commit_message=message,
105
+ )
106
+
107
+
108
+ def load_course() -> dict:
109
+ return json.loads((classroom.DATA / "course.json").read_text(encoding="utf-8"))
110
+
111
+
112
+ def scan_demo() -> list[dict]:
113
+ projects = []
114
+ if not PROJECTS.is_dir():
115
+ return projects
116
+ for class_dir in sorted(path for path in PROJECTS.iterdir() if path.is_dir() and not path.name.startswith(".")):
117
+ for student_dir in sorted(path for path in class_dir.iterdir() if path.is_dir() and not path.name.startswith(".")):
118
+ for item in sorted(path for path in student_dir.iterdir() if path.is_dir() and not path.name.startswith(".")):
119
+ kind = "python" if (item / "main.py").is_file() else "html" if (item / "index.html").is_file() else "external" if (item / "link.txt").is_file() else "asset"
120
+ projects.append({
121
+ "class_name": class_dir.name,
122
+ "student_name": student_dir.name,
123
+ "project_name": item.name,
124
+ "type": kind,
125
+ })
126
+ return projects
127
+
128
+
129
+ def read_editable_text(path: Path) -> str | None:
130
+ if path.suffix.lower() not in TEXT_EXTENSIONS or path.stat().st_size > 800_000:
131
+ return None
132
+ try:
133
+ return path.read_text(encoding="utf-8")
134
+ except UnicodeDecodeError:
135
+ return None
136
+
137
+
138
+ def editable_project(root: Path) -> dict:
139
+ if not root.is_dir():
140
+ raise HTTPException(404, "找不到這個 Demo 專題")
141
+ files: dict[str, str] = {}
142
+ assets: list[str] = []
143
+ for path in sorted(item for item in root.rglob("*") if item.is_file()):
144
+ rel = path.relative_to(root).as_posix()
145
+ content = read_editable_text(path)
146
+ if content is None:
147
+ assets.append(rel)
148
+ else:
149
+ files[rel] = content
150
+ return {"files": files, "assets": assets}
151
+
152
+
153
+ @router.get("/admin")
154
+ def admin_page():
155
+ return FileResponse(STATIC / "admin.html")
156
+
157
+
158
+ @router.post("/api/admin/state")
159
+ def admin_state(req: AdminAuth):
160
+ classroom.check_password(req.password)
161
+ return {"course": load_course(), "demo_projects": scan_demo()}
162
+
163
+
164
+ @router.post("/api/admin/week")
165
+ def admin_week(req: WeekRequest):
166
+ classroom.check_password(req.password)
167
+ week_id = classroom.clean(req.week_id, "")
168
+ course = load_course()
169
+ week = next((item for item in course.get("weeks", []) if item.get("id") == week_id), None)
170
+ if not week:
171
+ raise HTTPException(404, "找不到這一週")
172
+ root = classroom.TEMPLATES / week_id
173
+ files = {}
174
+ if root.is_dir():
175
+ for path in sorted(item for item in root.rglob("*") if item.is_file()):
176
+ rel = path.relative_to(root).as_posix()
177
+ if path.suffix.lower() in TEXT_EXTENSIONS:
178
+ files[rel] = path.read_text(encoding="utf-8")
179
+ return {"week": week, "files": files}
180
+
181
+
182
+ @router.post("/api/admin/week/save")
183
+ def save_week(req: WeekSave):
184
+ classroom.check_password(req.password)
185
+ week_id = classroom.clean(req.week_id, "")
186
+ files = {text_path(path): str(content) for path, content in req.files.items()}
187
+ course = load_course()
188
+ week = next((item for item in course.get("weeks", []) if item.get("id") == week_id), None)
189
+ if not week:
190
+ raise HTTPException(404, "找不到這一週")
191
+ week.update({
192
+ "title": classroom.clean_label(req.title, week.get("title", "")),
193
+ "project": classroom.clean_label(req.project, week.get("project", "")),
194
+ "output": classroom.clean_label(req.output, week.get("output", ""), 160),
195
+ "template": bool(files),
196
+ })
197
+ course_bytes = (json.dumps(course, ensure_ascii=False, indent=2) + "\n").encode()
198
+ root = classroom.TEMPLATES / week_id
199
+ old = [path for path in root.rglob("*") if path.is_file()] if root.is_dir() else []
200
+ additions = {"classroom_data/course.json": course_bytes}
201
+ additions.update({f"classroom_data/templates/{week_id}/{path}": content.encode() for path, content in files.items()})
202
+ deletions = [relative_repo_path(path) for path in old if path.relative_to(root).as_posix() not in files]
203
+ result = commit_changes(additions, deletions, f"Update classroom {week_id}")
204
+
205
+ (classroom.DATA / "course.json").write_bytes(course_bytes)
206
+ root.mkdir(parents=True, exist_ok=True)
207
+ for path in old:
208
+ if path.relative_to(root).as_posix() not in files:
209
+ path.unlink(missing_ok=True)
210
+ for rel, content in files.items():
211
+ target = root / rel
212
+ target.parent.mkdir(parents=True, exist_ok=True)
213
+ target.write_text(content, encoding="utf-8")
214
+ return {"ok": True, "commit_url": str(result.commit_url) if result else ""}
215
+
216
+
217
+ @router.post("/api/admin/demo/project")
218
+ def admin_demo_project(req: DemoRequest):
219
+ classroom.check_password(req.password)
220
+ return editable_project(project_root(req.project))
221
+
222
+
223
+ @router.post("/api/admin/demo/save")
224
+ def save_demo(req: DemoSave):
225
+ classroom.check_password(req.password)
226
+ target_ref = ProjectRef(
227
+ class_name=segment(req.class_name, "班級名稱"),
228
+ student_name=segment(req.student_name, "學生名稱"),
229
+ project_name=segment(req.project_name, "專題名稱"),
230
+ )
231
+ target = project_root(target_ref)
232
+ original = project_root(req.original) if req.original else None
233
+ if original and not original.is_dir():
234
+ raise HTTPException(404, "原始 Demo 專題不存在")
235
+ if target != original and target.exists():
236
+ raise HTTPException(409, "目標位置已有同名專題")
237
+ files = {text_path(path): str(content) for path, content in req.files.items()}
238
+ if not any(name in files for name in ("main.py", "index.html", "link.txt")):
239
+ raise HTTPException(400, "專題至少需要 main.py、index.html 或 link.txt 其中一個入口")
240
+
241
+ existing = [path for path in original.rglob("*") if path.is_file()] if original else []
242
+ additions: dict[str, bytes | Path] = {}
243
+ target_prefix = relative_repo_path(target)
244
+ if target != original:
245
+ for path in existing:
246
+ rel = path.relative_to(original).as_posix()
247
+ if read_editable_text(path) is None:
248
+ additions[f"{target_prefix}/{rel}"] = path
249
+ additions.update({f"{target_prefix}/{rel}": content.encode() for rel, content in files.items()})
250
+ if target == original:
251
+ deletions = [relative_repo_path(path) for path in existing if read_editable_text(path) is not None and path.relative_to(original).as_posix() not in files]
252
+ else:
253
+ deletions = [relative_repo_path(path) for path in existing]
254
+ result = commit_changes(additions, deletions, f"Update Demo project {target_ref.project_name}")
255
+
256
+ if original and target != original:
257
+ target.parent.mkdir(parents=True, exist_ok=True)
258
+ shutil.copytree(original, target)
259
+ shutil.rmtree(original)
260
+ else:
261
+ target.mkdir(parents=True, exist_ok=True)
262
+ for path in list(target.rglob("*")):
263
+ if path.is_file() and read_editable_text(path) is not None and path.relative_to(target).as_posix() not in files:
264
+ path.unlink()
265
+ for rel, content in files.items():
266
+ path = target / rel
267
+ path.parent.mkdir(parents=True, exist_ok=True)
268
+ path.write_text(content, encoding="utf-8")
269
+ project_data = target_ref.model_dump() if hasattr(target_ref, "model_dump") else target_ref.dict()
270
+ return {"ok": True, "project": project_data, "commit_url": str(result.commit_url) if result else ""}
271
+
272
+
273
+ @router.post("/api/admin/demo/delete")
274
+ def delete_demo(req: DemoRequest):
275
+ classroom.check_password(req.password)
276
+ root = project_root(req.project)
277
+ if not root.is_dir():
278
+ raise HTTPException(404, "找不到這個 Demo 專題")
279
+ paths = [path for path in root.rglob("*") if path.is_file()]
280
+ result = commit_changes({}, [relative_repo_path(path) for path in paths], f"Delete Demo project {root.name}")
281
+ shutil.rmtree(root)
282
+ return {"ok": True, "commit_url": str(result.commit_url) if result else ""}
classroom_static/admin.css ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .admin-body { min-width:0; }
2
+ .app-header a { text-decoration:none; }
3
+ .admin-tabs { flex:none;min-height:52px;display:flex;align-items:center;gap:.25rem;padding:0 1.5rem;border-bottom:1px solid var(--line);background:var(--surface); }
4
+ .admin-tabs>span { margin-left:auto;color:var(--muted);font-size:.75rem; }
5
+ .admin-tab { align-self:stretch;padding:0 1.1rem;border:0;border-bottom:3px solid transparent;background:transparent;color:var(--muted);font-weight:800; }
6
+ .admin-tab:hover { color:var(--navy);background:var(--surface-subtle); }
7
+ .admin-tab.active { border-bottom-color:var(--action);color:var(--navy); }
8
+ .admin-main { min-height:0;flex:1;overflow:hidden; }
9
+ .admin-view { display:none;height:100%;grid-template-columns:320px minmax(0,1fr); }
10
+ .admin-view.active { display:grid; }
11
+ .admin-sidebar { min-height:0;display:grid;grid-template-rows:auto auto minmax(0,1fr);overflow:hidden;border-right:1px solid var(--line);background:var(--surface); }
12
+ #courseView .admin-sidebar { grid-template-rows:auto minmax(0,1fr); }
13
+ .admin-sidebar-head { min-height:68px;display:flex;align-items:center;justify-content:space-between;gap:.75rem;padding:.75rem 1rem;border-bottom:1px solid var(--line); }
14
+ .admin-sidebar-head strong,.admin-sidebar-head small { display:block; }
15
+ .admin-sidebar-head small { margin-top:.2rem;color:var(--muted);font-size:.75rem; }
16
+ .admin-search { padding:.75rem;border-bottom:1px solid var(--line); }
17
+ .admin-list { min-height:0;overflow:auto;padding:.5rem; }
18
+ .admin-list-button { width:100%;display:grid;grid-template-columns:minmax(0,1fr) auto;gap:.75rem;padding:.7rem .75rem;border:0;border-radius:var(--radius-sm);background:transparent;color:var(--ink);text-align:left; }
19
+ .admin-list-button:hover { background:var(--surface-subtle); }
20
+ .admin-list-button.active { background:var(--action-soft);color:var(--navy); }
21
+ .admin-list-button strong,.admin-list-button span { overflow:hidden;text-overflow:ellipsis;white-space:nowrap; }
22
+ .admin-list-button span,.admin-list-button small { color:var(--muted);font-size:.75rem; }
23
+ .admin-list-button>div small { display:block;margin-top:.2rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap; }
24
+ .admin-editor { min-width:0;min-height:0;display:grid;grid-template-rows:auto auto minmax(0,1fr);overflow:hidden;background:var(--shell); }
25
+ .admin-editor-head { min-height:76px;display:flex;align-items:center;justify-content:space-between;gap:1rem;padding:1rem 1.5rem;border-bottom:1px solid var(--line);background:var(--surface); }
26
+ .admin-editor-head h1 { margin:0;font-size:1.15rem; }
27
+ .admin-editor-head p { margin:.25rem 0 0;color:var(--muted);font-size:.8rem; }
28
+ .admin-actions { display:flex;gap:.5rem; }
29
+ .admin-fields { display:grid;gap:1rem;padding:1rem 1.5rem;border-bottom:1px solid var(--line);background:var(--surface); }
30
+ .admin-fields.three { grid-template-columns:repeat(3,minmax(0,1fr)); }
31
+ .admin-fields label { color:var(--muted);font-size:.78rem;font-weight:750; }
32
+ .admin-fields input { margin-top:.4rem;color:var(--ink);font-weight:600; }
33
+ .file-workspace { min-height:0;display:grid;grid-template-columns:240px minmax(0,1fr); }
34
+ .file-workspace>aside { min-height:0;display:grid;grid-template-rows:auto minmax(0,1fr) auto;overflow:hidden;border-right:1px solid var(--line);background:var(--surface); }
35
+ .file-workspace-head { min-height:48px;display:flex;align-items:center;justify-content:space-between;gap:.5rem;padding:.5rem .75rem;border-bottom:1px solid var(--line); }
36
+ .admin-file-list { min-height:0;overflow:auto;padding:.4rem; }
37
+ .admin-file-row { display:grid;grid-template-columns:minmax(0,1fr) 34px;align-items:center;border-radius:var(--radius-sm); }
38
+ .admin-file-row.active { background:var(--action-soft); }
39
+ .admin-file-row button { min-width:0;min-height:38px;border:0;background:transparent;color:var(--ink);text-align:left;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font:600 .78rem/1.2 "SFMono-Regular",Consolas,monospace; }
40
+ .admin-file-row button:first-child { padding:0 .65rem; }
41
+ .admin-file-row .remove-file { text-align:center;color:var(--muted);font-size:1.1rem; }
42
+ .admin-file-row .remove-file:hover { color:var(--danger); }
43
+ .code-field { min-width:0;min-height:0;display:grid;grid-template-rows:42px minmax(0,1fr);background:var(--editor); }
44
+ .code-field>span { display:flex;align-items:center;padding:0 1rem;border-bottom:1px solid var(--editor-line);background:var(--editor-raised);color:var(--editor-ink);font:650 .8rem/1 "SFMono-Regular",Consolas,monospace; }
45
+ .code-field textarea { min-width:0;min-height:0;resize:none;padding:1rem;border:0;outline:0;background:var(--editor);color:var(--editor-ink);font:500 .9rem/1.65 "SFMono-Regular",Consolas,monospace;tab-size:4; }
46
+ .code-field textarea:focus { box-shadow:inset 0 0 0 2px var(--action); }
47
+ .asset-note { max-height:110px;overflow:auto;padding:.65rem .75rem;border-top:1px solid var(--line);color:var(--muted);font-size:.72rem;line-height:1.45; }
48
+ .admin-empty { padding:1.25rem;color:var(--muted);font-size:.82rem;line-height:1.5; }
49
+ @media (max-width:980px) { .admin-tabs>span{display:none}.admin-view{grid-template-columns:270px minmax(0,1fr)}.admin-fields.three{grid-template-columns:1fr}.admin-fields{max-height:230px;overflow:auto}.file-workspace{grid-template-columns:200px minmax(0,1fr)} }
50
+ @media (max-width:720px) { .admin-view{grid-template-columns:220px minmax(0,1fr)}.admin-tabs{padding-inline:.5rem}.admin-tab{padding-inline:.7rem}.admin-editor-head{padding-inline:.75rem}.admin-editor-head p{display:none}.admin-fields{padding:.75rem}.file-workspace{grid-template-columns:170px minmax(0,1fr)} }
51
+ @media (prefers-reduced-motion:reduce) { *,*::before,*::after{transition-duration:.01ms!important;animation-duration:.01ms!important} }
classroom_static/admin.html ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="zh-Hant">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width,initial-scale=1">
6
+ <title>Telearn 內容管理後台</title>
7
+ <link rel="stylesheet" href="/classroom-assets/classroom.css?v=2026fall-4">
8
+ <link rel="stylesheet" href="/classroom-assets/admin.css?v=2026fall-4">
9
+ </head>
10
+ <body class="admin-body">
11
+ <header class="app-header">
12
+ <div class="brand"><div class="brand-mark">Py</div><div class="brand-copy"><strong>內容管理後台</strong><span id="adminStatus">尚未登入</span></div></div>
13
+ <div class="header-actions"><a class="btn secondary compact" href="/classroom/teacher">即時教室</a><a class="btn secondary compact" href="/">查看 Demo</a><button class="btn compact" id="loginButton">重新登入</button></div>
14
+ </header>
15
+ <nav class="admin-tabs" aria-label="管理內容"><button class="admin-tab active" data-view="courseView">教室課程</button><button class="admin-tab" data-view="demoView">Demo 專題</button><span>儲存後會重新建置 Space,通常需等待約 1–2 分鐘。</span></nav>
16
+
17
+ <main class="admin-main">
18
+ <section class="admin-view active" id="courseView">
19
+ <aside class="admin-sidebar"><div class="admin-sidebar-head"><div><strong>18 週課程</strong><small>選一週修改主題與套版</small></div></div><div class="admin-list" id="weekAdminList"></div></aside>
20
+ <form class="admin-editor" id="weekForm">
21
+ <header class="admin-editor-head"><div><h1 id="weekHeading">選擇週次</h1><p>課堂顯示內容與學生可匯入的本週套版。</p></div><button class="btn" id="saveWeekButton" type="submit">儲存這一週</button></header>
22
+ <div class="admin-fields three"><label>課程標題<input class="text-field" id="weekTitle" required></label><label>專題名稱<input class="text-field" id="weekProject" required></label><label>本週產出<input class="text-field" id="weekOutput" required></label></div>
23
+ <div class="file-workspace"><aside><div class="file-workspace-head"><strong>套版檔案</strong><button class="copy-button" id="newWeekFile" type="button">+新增</button></div><div class="admin-file-list" id="weekFiles"></div></aside><label class="code-field"><span id="weekFileLabel">尚未選擇檔案</span><textarea id="weekFileContent" spellcheck="false" disabled></textarea></label></div>
24
+ </form>
25
+ </section>
26
+
27
+ <section class="admin-view" id="demoView">
28
+ <aside class="admin-sidebar"><div class="admin-sidebar-head"><div><strong>公開 Demo</strong><small>可搜尋、編輯或新增專題</small></div><button class="copy-button" id="newDemoButton">+新增</button></div><div class="admin-search"><input class="text-field" id="demoSearch" type="search" placeholder="搜尋班級、學生或專題"></div><div class="admin-list" id="demoAdminList"></div></aside>
29
+ <form class="admin-editor" id="demoForm">
30
+ <header class="admin-editor-head"><div><h1 id="demoHeading">選擇一個 Demo 專題</h1><p>文字程式可直接編輯;圖片、音效等資產會原樣保留。</p></div><div class="admin-actions"><button class="copy-button danger-action" id="deleteDemoButton" type="button" disabled>刪除</button><button class="btn" id="saveDemoButton" type="submit" disabled>儲存專題</button></div></header>
31
+ <div class="admin-fields three"><label>班級<input class="text-field" id="demoClass" required disabled></label><label>學生<input class="text-field" id="demoStudent" required disabled></label><label>專題名稱<input class="text-field" id="demoProject" required disabled></label></div>
32
+ <div class="file-workspace"><aside><div class="file-workspace-head"><strong>程式檔案</strong><button class="copy-button" id="newDemoFile" type="button" disabled>+新增</button></div><div class="admin-file-list" id="demoFiles"></div><div class="asset-note" id="demoAssets"></div></aside><label class="code-field"><span id="demoFileLabel">尚未選擇檔案</span><textarea id="demoFileContent" spellcheck="false" disabled></textarea></label></div>
33
+ </form>
34
+ </section>
35
+ </main>
36
+
37
+ <dialog id="adminLoginDialog"><form class="dialog-content form-stack" id="adminLoginForm"><div><h1>登入內容管理後台</h1><p>使用與教師控制台相同的密碼。</p></div><label><span class="field-label">教師密碼</span><input class="text-field" id="adminPassword" type="password" required autofocus></label><p class="form-error" id="adminLoginError"></p><button class="btn" type="submit">登入</button></form></dialog>
38
+ <div class="toast" id="toast"></div>
39
+ <script src="/classroom-assets/admin.js?v=2026fall-4"></script>
40
+ </body>
41
+ </html>
classroom_static/admin.js ADDED
@@ -0,0 +1,288 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const $ = id => document.getElementById(id);
2
+ const E = {};
3
+ [
4
+ "adminStatus", "loginButton", "weekAdminList", "weekForm", "weekHeading",
5
+ "weekTitle", "weekProject", "weekOutput", "saveWeekButton", "newWeekFile",
6
+ "weekFiles", "weekFileLabel", "weekFileContent", "demoSearch", "newDemoButton",
7
+ "demoAdminList", "demoForm", "demoHeading", "demoClass", "demoStudent",
8
+ "demoProject", "newDemoFile", "demoFiles", "demoFileLabel", "demoFileContent",
9
+ "demoAssets", "saveDemoButton", "deleteDemoButton", "adminLoginDialog",
10
+ "adminLoginForm", "adminPassword", "adminLoginError", "toast"
11
+ ].forEach(id => E[id] = $(id));
12
+
13
+ let password = "";
14
+ let state = { course: { weeks: [] }, demo_projects: [] };
15
+ let selectedWeek = "";
16
+ let weekFiles = {};
17
+ let weekFile = "";
18
+ let originalDemo = null;
19
+ let demoFiles = {};
20
+ let demoFile = "";
21
+ let demoAssets = [];
22
+
23
+ const esc = value => String(value).replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;");
24
+
25
+ function toast(message) {
26
+ E.toast.textContent = message;
27
+ E.toast.classList.add("visible");
28
+ setTimeout(() => E.toast.classList.remove("visible"), 2600);
29
+ }
30
+
31
+ async function request(path, data = {}) {
32
+ const response = await fetch(path, {
33
+ method: "POST",
34
+ headers: { "Content-Type": "application/json" },
35
+ body: JSON.stringify({ password, ...data })
36
+ });
37
+ if (!response.ok) {
38
+ let message = "操作失敗";
39
+ try { message = (await response.json()).detail || message; } catch (_) {}
40
+ throw Error(message);
41
+ }
42
+ return response.json();
43
+ }
44
+
45
+ function setBusy(button, busy, label) {
46
+ if (!button.dataset.label) button.dataset.label = button.textContent;
47
+ button.disabled = busy;
48
+ button.textContent = busy ? label : button.dataset.label;
49
+ }
50
+
51
+ document.querySelectorAll(".admin-tab").forEach(button => button.onclick = () => {
52
+ document.querySelectorAll(".admin-tab").forEach(item => item.classList.toggle("active", item === button));
53
+ document.querySelectorAll(".admin-view").forEach(view => view.classList.toggle("active", view.id === button.dataset.view));
54
+ });
55
+
56
+ async function login() {
57
+ state = await request("/api/admin/state");
58
+ E.adminStatus.textContent = `已登入 · ${state.course.title || "課程管理"}`;
59
+ renderWeeks();
60
+ renderDemoList();
61
+ if (state.course.weeks.length) await selectWeek(state.course.weeks[0].id);
62
+ }
63
+
64
+ E.adminLoginForm.onsubmit = async event => {
65
+ event.preventDefault();
66
+ password = E.adminPassword.value;
67
+ const button = E.adminLoginForm.querySelector("button[type=submit]");
68
+ setBusy(button, true, "登入中…");
69
+ E.adminLoginError.textContent = "";
70
+ try {
71
+ await login();
72
+ E.adminLoginDialog.close();
73
+ } catch (error) {
74
+ password = "";
75
+ E.adminLoginError.textContent = error.message;
76
+ } finally { setBusy(button, false); }
77
+ };
78
+ E.loginButton.onclick = () => E.adminLoginDialog.showModal();
79
+
80
+ function renderWeeks() {
81
+ E.weekAdminList.innerHTML = state.course.weeks.map(week => `
82
+ <button class="admin-list-button ${week.id === selectedWeek ? "active" : ""}" data-week="${esc(week.id)}">
83
+ <div><strong>第 ${week.week} 週|${esc(week.title)}</strong><small>${esc(week.project)}</small></div>
84
+ <span>${week.template ? "有套版" : "無套版"}</span>
85
+ </button>`).join("");
86
+ E.weekAdminList.querySelectorAll("[data-week]").forEach(button => button.onclick = () => selectWeek(button.dataset.week));
87
+ }
88
+
89
+ async function selectWeek(weekId) {
90
+ saveWeekFileBuffer();
91
+ selectedWeek = weekId;
92
+ renderWeeks();
93
+ const data = await request("/api/admin/week", { week_id: weekId });
94
+ E.weekHeading.textContent = `第 ${data.week.week} 週|${data.week.title}`;
95
+ E.weekTitle.value = data.week.title || "";
96
+ E.weekProject.value = data.week.project || "";
97
+ E.weekOutput.value = data.week.output || "";
98
+ weekFiles = data.files || {};
99
+ weekFile = Object.keys(weekFiles)[0] || "";
100
+ renderWeekFiles();
101
+ }
102
+
103
+ function saveWeekFileBuffer() {
104
+ if (weekFile) weekFiles[weekFile] = E.weekFileContent.value;
105
+ }
106
+
107
+ function renderWeekFiles() {
108
+ E.weekFiles.innerHTML = Object.keys(weekFiles).sort().map(path => `
109
+ <div class="admin-file-row ${path === weekFile ? "active" : ""}"><button type="button" data-open="${esc(path)}">${esc(path)}</button><button class="remove-file" type="button" data-remove="${esc(path)}" aria-label="移除 ${esc(path)}">×</button></div>`).join("") || '<div class="admin-empty">這週目前沒有套版。學生會看到「尚未發布套版」。</div>';
110
+ E.weekFiles.querySelectorAll("[data-open]").forEach(button => button.onclick = () => {
111
+ saveWeekFileBuffer(); weekFile = button.dataset.open; renderWeekFiles();
112
+ });
113
+ E.weekFiles.querySelectorAll("[data-remove]").forEach(button => button.onclick = () => {
114
+ const path = button.dataset.remove;
115
+ delete weekFiles[path];
116
+ if (weekFile === path) weekFile = Object.keys(weekFiles)[0] || "";
117
+ renderWeekFiles();
118
+ });
119
+ E.weekFileLabel.textContent = weekFile || "尚未選擇檔案";
120
+ E.weekFileContent.disabled = !weekFile;
121
+ E.weekFileContent.value = weekFile ? weekFiles[weekFile] : "";
122
+ }
123
+
124
+ function askFileName(existing) {
125
+ const path = (prompt("檔案名稱(例如 main.py 或 data/case.json):") || "").trim().replaceAll("\\", "/");
126
+ if (!/^[\w\-\u4e00-\u9fff/]+\.(py|html|css|js|json|txt|csv|md|toml|yaml|yml)$/i.test(path) || path.includes("..")) {
127
+ if (path) toast("檔名或副檔名不符合規則");
128
+ return "";
129
+ }
130
+ if (path in existing) { toast("檔案已存在"); return ""; }
131
+ return path;
132
+ }
133
+
134
+ E.newWeekFile.onclick = () => {
135
+ saveWeekFileBuffer();
136
+ const path = askFileName(weekFiles);
137
+ if (!path) return;
138
+ weekFiles[path] = path.endsWith(".py") ? "# 本週 Python 套版\n" : "";
139
+ weekFile = path;
140
+ renderWeekFiles();
141
+ };
142
+
143
+ E.weekForm.onsubmit = async event => {
144
+ event.preventDefault();
145
+ saveWeekFileBuffer();
146
+ setBusy(E.saveWeekButton, true, "儲存中…");
147
+ try {
148
+ await request("/api/admin/week/save", {
149
+ week_id: selectedWeek,
150
+ title: E.weekTitle.value,
151
+ project: E.weekProject.value,
152
+ output: E.weekOutput.value,
153
+ files: weekFiles
154
+ });
155
+ const week = state.course.weeks.find(item => item.id === selectedWeek);
156
+ Object.assign(week, { title: E.weekTitle.value.trim(), project: E.weekProject.value.trim(), output: E.weekOutput.value.trim(), template: Object.keys(weekFiles).length > 0 });
157
+ E.weekHeading.textContent = `第 ${week.week} 週|${week.title}`;
158
+ renderWeeks();
159
+ toast("課程內容已儲存,Space 正在重新建置");
160
+ } catch (error) { toast(error.message); }
161
+ finally { setBusy(E.saveWeekButton, false); }
162
+ };
163
+
164
+ function demoKey(item) { return `${item.class_name}\n${item.student_name}\n${item.project_name}`; }
165
+ function renderDemoList() {
166
+ const query = E.demoSearch.value.trim().toLowerCase();
167
+ const visible = state.demo_projects.filter(item => `${item.class_name} ${item.student_name} ${item.project_name}`.toLowerCase().includes(query));
168
+ E.demoAdminList.innerHTML = visible.map(item => `
169
+ <button class="admin-list-button ${originalDemo && demoKey(item) === demoKey(originalDemo) ? "active" : ""}" data-key="${esc(demoKey(item))}">
170
+ <div><strong>${esc(item.project_name)}</strong><small>${esc(item.class_name)} · ${esc(item.student_name)}</small></div><span>${esc(item.type)}</span>
171
+ </button>`).join("") || '<div class="admin-empty">找不到符合的 Demo 專題。</div>';
172
+ E.demoAdminList.querySelectorAll("[data-key]").forEach(button => button.onclick = () => {
173
+ const item = state.demo_projects.find(project => demoKey(project) === button.dataset.key);
174
+ if (item) selectDemo(item);
175
+ });
176
+ }
177
+ E.demoSearch.oninput = renderDemoList;
178
+
179
+ function enableDemoForm(enabled) {
180
+ [E.demoClass, E.demoStudent, E.demoProject, E.newDemoFile, E.saveDemoButton].forEach(element => element.disabled = !enabled);
181
+ E.deleteDemoButton.disabled = !enabled || !originalDemo;
182
+ }
183
+
184
+ async function selectDemo(item) {
185
+ saveDemoFileBuffer();
186
+ originalDemo = { class_name: item.class_name, student_name: item.student_name, project_name: item.project_name };
187
+ renderDemoList();
188
+ const data = await request("/api/admin/demo/project", { project: originalDemo });
189
+ E.demoClass.value = item.class_name;
190
+ E.demoStudent.value = item.student_name;
191
+ E.demoProject.value = item.project_name;
192
+ E.demoHeading.textContent = item.project_name;
193
+ demoFiles = data.files || {};
194
+ demoAssets = data.assets || [];
195
+ demoFile = Object.keys(demoFiles)[0] || "";
196
+ enableDemoForm(true);
197
+ renderDemoFiles();
198
+ }
199
+
200
+ function saveDemoFileBuffer() {
201
+ if (demoFile) demoFiles[demoFile] = E.demoFileContent.value;
202
+ }
203
+
204
+ function renderDemoFiles() {
205
+ E.demoFiles.innerHTML = Object.keys(demoFiles).sort().map(path => `
206
+ <div class="admin-file-row ${path === demoFile ? "active" : ""}"><button type="button" data-open="${esc(path)}">${esc(path)}</button><button class="remove-file" type="button" data-remove="${esc(path)}" aria-label="移除 ${esc(path)}">×</button></div>`).join("") || '<div class="admin-empty">尚無文字程式檔案。</div>';
207
+ E.demoFiles.querySelectorAll("[data-open]").forEach(button => button.onclick = () => {
208
+ saveDemoFileBuffer(); demoFile = button.dataset.open; renderDemoFiles();
209
+ });
210
+ E.demoFiles.querySelectorAll("[data-remove]").forEach(button => button.onclick = () => {
211
+ const path = button.dataset.remove;
212
+ delete demoFiles[path];
213
+ if (demoFile === path) demoFile = Object.keys(demoFiles)[0] || "";
214
+ renderDemoFiles();
215
+ });
216
+ E.demoFileLabel.textContent = demoFile || "尚未選擇檔案";
217
+ E.demoFileContent.disabled = !demoFile;
218
+ E.demoFileContent.value = demoFile ? demoFiles[demoFile] : "";
219
+ E.demoAssets.textContent = demoAssets.length ? `保留 ${demoAssets.length} 個非文字資產:${demoAssets.join("、")}` : "沒有圖片或音效資產。";
220
+ }
221
+
222
+ E.newDemoButton.onclick = () => {
223
+ originalDemo = null;
224
+ demoFiles = { "main.py": '# 新 Demo 專題\nprint("Hello, Telearn!")\n' };
225
+ demoAssets = [];
226
+ demoFile = "main.py";
227
+ E.demoClass.value = "2026Fall進階專題";
228
+ E.demoStudent.value = "";
229
+ E.demoProject.value = "";
230
+ E.demoHeading.textContent = "建立新 Demo 專題";
231
+ enableDemoForm(true);
232
+ renderDemoList();
233
+ renderDemoFiles();
234
+ E.demoStudent.focus();
235
+ };
236
+
237
+ E.newDemoFile.onclick = () => {
238
+ saveDemoFileBuffer();
239
+ const path = askFileName(demoFiles);
240
+ if (!path) return;
241
+ demoFiles[path] = "";
242
+ demoFile = path;
243
+ renderDemoFiles();
244
+ };
245
+
246
+ E.demoForm.onsubmit = async event => {
247
+ event.preventDefault();
248
+ saveDemoFileBuffer();
249
+ setBusy(E.saveDemoButton, true, "儲存中…");
250
+ try {
251
+ const data = await request("/api/admin/demo/save", {
252
+ original: originalDemo,
253
+ class_name: E.demoClass.value,
254
+ student_name: E.demoStudent.value,
255
+ project_name: E.demoProject.value,
256
+ files: demoFiles
257
+ });
258
+ if (originalDemo) state.demo_projects = state.demo_projects.filter(item => demoKey(item) !== demoKey(originalDemo));
259
+ originalDemo = data.project;
260
+ state.demo_projects.push({ ...data.project, type: "main.py" in demoFiles ? "python" : "index.html" in demoFiles ? "html" : "external" });
261
+ E.demoHeading.textContent = data.project.project_name;
262
+ renderDemoList();
263
+ enableDemoForm(true);
264
+ toast("Demo 專題已儲存,Space 正在重新建置");
265
+ } catch (error) { toast(error.message); }
266
+ finally { setBusy(E.saveDemoButton, false); }
267
+ };
268
+
269
+ E.deleteDemoButton.onclick = async () => {
270
+ if (!originalDemo || !confirm(`永久刪除 Demo 專題「${originalDemo.project_name}」?\n\n這會移除程式、圖片與音效,無法從管理頁復原。`)) return;
271
+ setBusy(E.deleteDemoButton, true, "刪除中…");
272
+ try {
273
+ await request("/api/admin/demo/delete", { project: originalDemo });
274
+ state.demo_projects = state.demo_projects.filter(item => demoKey(item) !== demoKey(originalDemo));
275
+ originalDemo = null;
276
+ demoFiles = {};
277
+ demoAssets = [];
278
+ demoFile = "";
279
+ E.demoHeading.textContent = "選擇一個 Demo 專題";
280
+ [E.demoClass, E.demoStudent, E.demoProject].forEach(input => input.value = "");
281
+ enableDemoForm(false);
282
+ renderDemoList();
283
+ renderDemoFiles();
284
+ toast("Demo 專題已刪除,Space 正在重新建置");
285
+ } catch (error) { toast(error.message); enableDemoForm(true); }
286
+ };
287
+
288
+ E.adminLoginDialog.showModal();
classroom_static/classroom.css CHANGED
@@ -39,6 +39,7 @@
39
 
40
  * { box-sizing: border-box; }
41
  [hidden] { display: none !important; }
 
42
  html, body { width: 100%; height: 100%; overflow: hidden; }
43
  body { min-height: 0; margin: 0; display: flex; flex-direction: column; background: var(--shell); color: var(--ink); }
44
  button, input, textarea, select { font: inherit; }
@@ -132,6 +133,7 @@ button:disabled { cursor: not-allowed; opacity: .48; }
132
  .btn.help { background: var(--help); color: oklch(0.25 0.08 70); }
133
  .btn.help.active { background: oklch(0.65 0.17 68); }
134
  .btn.compact { min-height: 36px; padding: 0 var(--space-md); font-size: .875rem; }
 
135
 
136
  .project-bar { min-height:58px;flex:none;display:flex;align-items:center;justify-content:space-between;gap:var(--space-lg);padding:var(--space-sm) var(--space-xl);border-bottom:1px solid var(--line);background:var(--surface); }
137
  .project-context,.project-actions { display:flex;align-items:center;gap:var(--space-sm);flex-wrap:wrap; }
@@ -239,9 +241,8 @@ button:disabled { cursor: not-allowed; opacity: .48; }
239
  .CodeMirror { height: 100%; font: 500 1rem/1.7 "SFMono-Regular", Consolas, monospace; background: var(--editor); }
240
  .CodeMirror-gutters { background: var(--editor); border-right: 1px solid var(--editor-line); }
241
 
242
- .console-body { min-height:0;display:grid;grid-template-rows:auto minmax(120px,1fr) auto; }
243
- .stdin-section { padding: var(--space-lg); border-bottom: 1px solid var(--line); }
244
- .stdin-field { min-height: 96px; resize: vertical; padding: var(--space-md); font: 500 .875rem/1.5 "SFMono-Regular", Consolas, monospace; }
245
  .field-help { margin: var(--space-sm) 0 0; color: var(--muted); font-size: .75rem; line-height: 1.45; }
246
  .console-output {
247
  margin: 0;
@@ -253,6 +254,12 @@ button:disabled { cursor: not-allowed; opacity: .48; }
253
  white-space: pre-wrap;
254
  word-break: break-word;
255
  }
 
 
 
 
 
 
256
  .console-status { min-height: 48px; display: flex; align-items: center; gap: var(--space-sm); padding: 0 var(--space-lg); border-top: 1px solid var(--line); color: var(--muted); font-size: .8rem; }
257
 
258
  .mobile-tabs { display: none; }
 
39
 
40
  * { box-sizing: border-box; }
41
  [hidden] { display: none !important; }
42
+ .sr-only { position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0; }
43
  html, body { width: 100%; height: 100%; overflow: hidden; }
44
  body { min-height: 0; margin: 0; display: flex; flex-direction: column; background: var(--shell); color: var(--ink); }
45
  button, input, textarea, select { font: inherit; }
 
133
  .btn.help { background: var(--help); color: oklch(0.25 0.08 70); }
134
  .btn.help.active { background: oklch(0.65 0.17 68); }
135
  .btn.compact { min-height: 36px; padding: 0 var(--space-md); font-size: .875rem; }
136
+ .header-link { text-decoration:none; }
137
 
138
  .project-bar { min-height:58px;flex:none;display:flex;align-items:center;justify-content:space-between;gap:var(--space-lg);padding:var(--space-sm) var(--space-xl);border-bottom:1px solid var(--line);background:var(--surface); }
139
  .project-context,.project-actions { display:flex;align-items:center;gap:var(--space-sm);flex-wrap:wrap; }
 
241
  .CodeMirror { height: 100%; font: 500 1rem/1.7 "SFMono-Regular", Consolas, monospace; background: var(--editor); }
242
  .CodeMirror-gutters { background: var(--editor); border-right: 1px solid var(--editor-line); }
243
 
244
+ .console-body { min-height:0;display:grid;grid-template-rows:minmax(120px,1fr) auto auto; }
245
+ .console-stream { min-height:0;display:grid;grid-template-rows:minmax(0,1fr) auto;background:var(--editor); }
 
246
  .field-help { margin: var(--space-sm) 0 0; color: var(--muted); font-size: .75rem; line-height: 1.45; }
247
  .console-output {
248
  margin: 0;
 
254
  white-space: pre-wrap;
255
  word-break: break-word;
256
  }
257
+ .console-input-row { display:grid;grid-template-columns:auto minmax(0,1fr) auto;align-items:center;gap:var(--space-sm);padding:var(--space-sm) var(--space-md);border-top:1px solid var(--editor-line);background:var(--editor-raised);color:var(--editor-ink); }
258
+ .console-input-row>span { color:oklch(.76 .15 150);font:800 1.1rem/1 monospace; }
259
+ .console-input-row input { min-width:0;height:38px;padding:0 var(--space-md);border:1px solid var(--editor-line);border-radius:var(--radius-sm);background:var(--editor);color:var(--editor-ink);font:500 .875rem/1.4 "SFMono-Regular",Consolas,monospace; }
260
+ .console-input-row input::placeholder { color:oklch(.7 .02 255); }
261
+ .console-input-row input:focus { border-color:var(--action);outline:2px solid var(--action);outline-offset:1px; }
262
+ .console-hint { margin:0;padding:var(--space-sm) var(--space-lg);border-top:1px solid var(--line);background:var(--surface);color:var(--muted);font-size:.75rem;line-height:1.45; }
263
  .console-status { min-height: 48px; display: flex; align-items: center; gap: var(--space-sm); padding: 0 var(--space-lg); border-top: 1px solid var(--line); color: var(--muted); font-size: .8rem; }
264
 
265
  .mobile-tabs { display: none; }
classroom_static/py-worker.js CHANGED
@@ -1,25 +1,52 @@
1
  let pyodide = null;
2
- let inputQueue = [];
 
3
 
4
  async function initialize() {
5
  try {
6
  importScripts("https://cdn.jsdelivr.net/pyodide/v0.27.7/full/pyodide.js");
7
- pyodide = await loadPyodide();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8
  postMessage({ type: "ready" });
9
  } catch (error) {
10
  postMessage({ type: "load_error", text: String(error) });
11
  }
12
  }
13
 
 
 
 
 
 
 
 
 
 
 
 
14
  self.onmessage = async event => {
15
  if (event.data.type !== "run" || !pyodide) return;
16
- inputQueue = Array.isArray(event.data.inputs) ? [...event.data.inputs] : [];
17
- pyodide.setStdin({
18
- stdin: () => {
19
- const value = inputQueue.length ? inputQueue.shift() : "";
20
- return value;
21
- }
22
- });
 
23
 
24
  try {
25
  const files = event.data.files || { "main.py": event.data.code || "" };
@@ -39,28 +66,27 @@ for name, module in list(sys.modules.items()):
39
  pyodide.FS.writeFile(fullPath, content, { encoding: "utf8" });
40
  }
41
  pyodide.globals.set("classroom_entry", entry);
42
- const captured = await pyodide.runPythonAsync(`
43
- import contextlib, io, json, os, sys, traceback
44
  root = "/home/pyodide/classroom_project"
45
  os.chdir(root)
46
- if root not in sys.path: sys.path.insert(0, root)
 
47
  entry = os.path.join(root, classroom_entry)
48
  namespace = {"__name__": "__main__", "__file__": entry}
49
- stdout_buffer, stderr_buffer = io.StringIO(), io.StringIO()
50
  ok = True
51
- with contextlib.redirect_stdout(stdout_buffer), contextlib.redirect_stderr(stderr_buffer):
52
- try:
53
- with open(entry, "r", encoding="utf-8") as source:
54
- exec(compile(source.read(), entry, "exec"), namespace)
55
- except BaseException:
56
- ok = False
57
- traceback.print_exc()
58
- json.dumps({"ok": ok, "stdout": stdout_buffer.getvalue(), "stderr": stderr_buffer.getvalue()})
 
 
59
  `);
60
- const result = JSON.parse(captured);
61
- if (result.stdout) postMessage({ type: "stdout", text: result.stdout.replace(/\n$/, "") });
62
- if (result.stderr) postMessage({ type: "stderr", text: result.stderr.replace(/\n$/, "") });
63
- postMessage({ type: result.ok ? "done" : "python_error", text: result.ok ? "" : "程式執行失敗" });
64
  } catch (error) {
65
  postMessage({ type: "python_error", text: String(error) });
66
  }
 
1
  let pyodide = null;
2
+ let inputControl = null;
3
+ let inputBytes = null;
4
 
5
  async function initialize() {
6
  try {
7
  importScripts("https://cdn.jsdelivr.net/pyodide/v0.27.7/full/pyodide.js");
8
+ pyodide = await loadPyodide({ indexURL: "https://cdn.jsdelivr.net/pyodide/v0.27.7/full/" });
9
+ pyodide.setStdout({
10
+ write(buffer) {
11
+ postMessage({ type: "stream", channel: "stdout", bytes: buffer });
12
+ return buffer.length;
13
+ },
14
+ isatty: true
15
+ });
16
+ pyodide.setStderr({
17
+ write(buffer) {
18
+ postMessage({ type: "stream", channel: "stderr", bytes: buffer });
19
+ return buffer.length;
20
+ },
21
+ isatty: true
22
+ });
23
  postMessage({ type: "ready" });
24
  } catch (error) {
25
  postMessage({ type: "load_error", text: String(error) });
26
  }
27
  }
28
 
29
+ function readInteractiveInput() {
30
+ if (!inputControl || !inputBytes) return "";
31
+ Atomics.store(inputControl, 0, 0);
32
+ postMessage({ type: "input_request" });
33
+ Atomics.wait(inputControl, 0, 0);
34
+ const length = Math.max(0, Math.min(Atomics.load(inputControl, 1), inputBytes.length));
35
+ const value = new TextDecoder().decode(inputBytes.slice(0, length));
36
+ Atomics.store(inputControl, 0, 0);
37
+ return value;
38
+ }
39
+
40
  self.onmessage = async event => {
41
  if (event.data.type !== "run" || !pyodide) return;
42
+ const sharedInput = event.data.inputBuffer;
43
+ if (!(sharedInput instanceof SharedArrayBuffer)) {
44
+ postMessage({ type: "python_error", text: "瀏覽器未提供互動式 Python 所需的安全執行環境。" });
45
+ return;
46
+ }
47
+ inputControl = new Int32Array(sharedInput, 0, 2);
48
+ inputBytes = new Uint8Array(sharedInput, 8);
49
+ pyodide.setStdin({ stdin: readInteractiveInput, isatty: true });
50
 
51
  try {
52
  const files = event.data.files || { "main.py": event.data.code || "" };
 
66
  pyodide.FS.writeFile(fullPath, content, { encoding: "utf8" });
67
  }
68
  pyodide.globals.set("classroom_entry", entry);
69
+ const succeeded = await pyodide.runPythonAsync(`
70
+ import os, sys, traceback
71
  root = "/home/pyodide/classroom_project"
72
  os.chdir(root)
73
+ if root not in sys.path:
74
+ sys.path.insert(0, root)
75
  entry = os.path.join(root, classroom_entry)
76
  namespace = {"__name__": "__main__", "__file__": entry}
 
77
  ok = True
78
+ try:
79
+ with open(entry, "r", encoding="utf-8") as source:
80
+ exec(compile(source.read(), entry, "exec"), namespace)
81
+ except BaseException:
82
+ ok = False
83
+ traceback.print_exc()
84
+ finally:
85
+ sys.stdout.flush()
86
+ sys.stderr.flush()
87
+ ok
88
  `);
89
+ postMessage({ type: succeeded ? "done" : "python_error", text: succeeded ? "" : "程式執行失敗" });
 
 
 
90
  } catch (error) {
91
  postMessage({ type: "python_error", text: String(error) });
92
  }
classroom_static/student.html CHANGED
@@ -1,10 +1,10 @@
1
- <!doctype html><html lang="zh-Hant"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="color-scheme" content="light"><title>Python 專案教室|學生工作區</title><link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/codemirror.min.css"><link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/theme/material-darker.min.css"><link rel="stylesheet" href="/classroom-assets/classroom.css?v=2026fall-3"></head><body>
2
  <header class="app-header"><div class="brand"><div class="brand-mark">Py</div><div class="brand-copy"><strong>Python 專案教室</strong><span id="studentIdentity">尚未進入教室</span></div></div><div class="header-actions"><div class="connection-state"><i class="status-dot" id="connectionDot"></i><span id="connectionText">未連線</span></div><button class="btn help compact" id="helpButton">請老師協助</button><button class="btn secondary compact" id="identityButton">更換身分</button></div></header>
3
  <section class="project-bar"><div class="project-context"><label for="weekSelect">週次</label><select id="weekSelect"></select><label for="projectName">專案</label><input id="projectName" maxlength="36" value="Python專案"></div><div class="project-actions"><button class="copy-button" id="templateButton">匯入本週套版</button><button class="copy-button" id="progressButton">選擇舊進度</button><button class="copy-button" id="importButton">匯入 .py / ZIP</button><button class="copy-button" id="exportButton">匯出專案</button><button class="btn compact" id="submitButton">提交本週版本</button><input id="fileImport" type="file" accept=".py,.zip" hidden></div></section>
4
  <nav class="mobile-tabs" role="tablist"><button class="mobile-tab" data-panel="referencePanel">專案/語法</button><button class="mobile-tab" data-panel="editorPanel" aria-selected="true">程式</button><button class="mobile-tab" data-panel="consolePanel">執行結果</button></nav>
5
  <main class="student-layout"><aside class="panel reference-panel" id="referencePanel"><div class="side-tabs"><button class="side-tab active" data-side="filesView">專案檔案</button><button class="side-tab" data-side="syntaxView">語法查詢</button></div><section class="side-view" id="filesView"><div class="panel-heading"><h1>檔案夾</h1><button class="copy-button" id="newFileButton">+新增</button></div><div class="file-list" id="fileList"></div><p class="side-note">入口固定為 <code>main.py</code>,可用 <code>import</code> 引入其他 Python 檔。</p></section><section class="side-view" id="syntaxView" hidden><div class="panel-heading"><h1>Python 語法查詢</h1><span id="snippetCount">0 項</span></div><div class="search-wrap"><label class="field-label" for="syntaxSearch">輸入語法或用途</label><input class="text-field" id="syntaxSearch" type="search" placeholder="迴圈、字典、JSON"><div class="chip-row" id="categoryFilters"></div></div><div class="snippet-list" id="snippetList"></div></section></aside>
6
  <section class="panel editor-panel mobile-active" id="editorPanel"><div class="panel-heading dark"><span class="file-name" id="activeFileLabel">main.py</span><div class="editor-toolbar"><button class="btn stop compact" id="stopButton" disabled>停止</button><button class="btn compact" id="runButton" disabled>載入 Python…</button></div></div><div class="editor-host"><textarea id="codeEditor" aria-label="程式碼"></textarea></div></section>
7
- <aside class="panel console-panel" id="consolePanel"><div class="panel-heading"><h2>執行與輸出</h2><button class="copy-button" id="clearOutputButton">清除</button></div><div class="console-body"><section class="stdin-section"><label class="field-label" for="stdinInput">程式輸入</label><textarea class="stdin-field" id="stdinInput" placeholder="input() 每行一筆"></textarea><p class="field-help">執行 <code>main.py</code>,同專案的文字檔會一起載入。</p></section><pre class="console-output" id="consoleOutput">Python 執行環境載入中…</pre><div class="console-status"><i class="status-dot" id="runStatusDot"></i><span id="runStatusText">準備環境</span><span>·</span><span id="saveStatus">尚未同步</span><span>·</span><span id="submitStatus">本週尚未提交</span></div></div></aside></main>
8
  <dialog id="identityDialog"><form class="dialog-content form-stack" id="identityForm"><div><h1>進入程式教室</h1><p>先選課程,再選自己的名字。名單上沒有時可新增。</p></div><div><label class="field-label" for="classCode">課程</label><select class="text-field" id="classCode" required></select></div><div><label class="field-label" for="studentChoice">學生</label><select class="text-field" id="studentChoice" required></select></div><div id="newStudentFields" hidden><label class="field-label" for="displayName">新學生名稱</label><input class="text-field" id="displayName" placeholder="12_Alex"><p class="field-help">建議使用座號與英文名,方便老師辨識。</p></div><p class="form-error" id="identityError"></p><button class="btn" type="submit">進入教室</button></form></dialog>
9
  <dialog id="progressDialog"><div class="dialog-content"><h2>選擇正式進度</h2><p>匯入後會成為目前週次的工作副本。</p><div class="progress-list" id="progressList"></div><button class="btn secondary dialog-close">關閉</button></div></dialog><div class="toast" id="toast"></div>
10
- <script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/codemirror.min.js"></script><script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/mode/python/python.min.js"></script><script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"></script><script src="/classroom-assets/student.js?v=2026fall-3"></script></body></html>
 
1
+ <!doctype html><html lang="zh-Hant"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="color-scheme" content="light"><title>Python 專案教室|學生工作區</title><link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/codemirror.min.css"><link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/theme/material-darker.min.css"><link rel="stylesheet" href="/classroom-assets/classroom.css?v=2026fall-4"></head><body>
2
  <header class="app-header"><div class="brand"><div class="brand-mark">Py</div><div class="brand-copy"><strong>Python 專案教室</strong><span id="studentIdentity">尚未進入教室</span></div></div><div class="header-actions"><div class="connection-state"><i class="status-dot" id="connectionDot"></i><span id="connectionText">未連線</span></div><button class="btn help compact" id="helpButton">請老師協助</button><button class="btn secondary compact" id="identityButton">更換身分</button></div></header>
3
  <section class="project-bar"><div class="project-context"><label for="weekSelect">週次</label><select id="weekSelect"></select><label for="projectName">專案</label><input id="projectName" maxlength="36" value="Python專案"></div><div class="project-actions"><button class="copy-button" id="templateButton">匯入本週套版</button><button class="copy-button" id="progressButton">選擇舊進度</button><button class="copy-button" id="importButton">匯入 .py / ZIP</button><button class="copy-button" id="exportButton">匯出專案</button><button class="btn compact" id="submitButton">提交本週版本</button><input id="fileImport" type="file" accept=".py,.zip" hidden></div></section>
4
  <nav class="mobile-tabs" role="tablist"><button class="mobile-tab" data-panel="referencePanel">專案/語法</button><button class="mobile-tab" data-panel="editorPanel" aria-selected="true">程式</button><button class="mobile-tab" data-panel="consolePanel">執行結果</button></nav>
5
  <main class="student-layout"><aside class="panel reference-panel" id="referencePanel"><div class="side-tabs"><button class="side-tab active" data-side="filesView">專案檔案</button><button class="side-tab" data-side="syntaxView">語法查詢</button></div><section class="side-view" id="filesView"><div class="panel-heading"><h1>檔案夾</h1><button class="copy-button" id="newFileButton">+新增</button></div><div class="file-list" id="fileList"></div><p class="side-note">入口固定為 <code>main.py</code>,可用 <code>import</code> 引入其他 Python 檔。</p></section><section class="side-view" id="syntaxView" hidden><div class="panel-heading"><h1>Python 語法查詢</h1><span id="snippetCount">0 項</span></div><div class="search-wrap"><label class="field-label" for="syntaxSearch">輸入語法或用途</label><input class="text-field" id="syntaxSearch" type="search" placeholder="迴圈、字典、JSON"><div class="chip-row" id="categoryFilters"></div></div><div class="snippet-list" id="snippetList"></div></section></aside>
6
  <section class="panel editor-panel mobile-active" id="editorPanel"><div class="panel-heading dark"><span class="file-name" id="activeFileLabel">main.py</span><div class="editor-toolbar"><button class="btn stop compact" id="stopButton" disabled>停止</button><button class="btn compact" id="runButton" disabled>載入 Python…</button></div></div><div class="editor-host"><textarea id="codeEditor" aria-label="程式碼"></textarea></div></section>
7
+ <aside class="panel console-panel" id="consolePanel"><div class="panel-heading"><h2>互動式 Console</h2><button class="copy-button" id="clearOutputButton">清除</button></div><div class="console-body"><div class="console-stream"><pre class="console-output" id="consoleOutput">Python 執行環境載入中…</pre><form class="console-input-row" id="consoleInputForm" hidden><span aria-hidden="true">›</span><label class="sr-only" for="consoleInput">Python 程式要求輸入</label><input id="consoleInput" autocomplete="off" placeholder="輸入後按 Enter"><button class="btn compact" type="submit">送出</button></form></div><p class="console-hint">執行 <code>main.py</code>;遇到 <code>input()</code> 時會在這裡詢問。</p><div class="console-status"><i class="status-dot" id="runStatusDot"></i><span id="runStatusText">準備環境</span><span>·</span><span id="saveStatus">尚未同步</span><span>·</span><span id="submitStatus">本週尚未提交</span></div></div></aside></main>
8
  <dialog id="identityDialog"><form class="dialog-content form-stack" id="identityForm"><div><h1>進入程式教室</h1><p>先選課程,再選自己的名字。名單上沒有時可新增。</p></div><div><label class="field-label" for="classCode">課程</label><select class="text-field" id="classCode" required></select></div><div><label class="field-label" for="studentChoice">學生</label><select class="text-field" id="studentChoice" required></select></div><div id="newStudentFields" hidden><label class="field-label" for="displayName">新學生名稱</label><input class="text-field" id="displayName" placeholder="12_Alex"><p class="field-help">建議使用座號與英文名,方便老師辨識。</p></div><p class="form-error" id="identityError"></p><button class="btn" type="submit">進入教室</button></form></dialog>
9
  <dialog id="progressDialog"><div class="dialog-content"><h2>選擇正式進度</h2><p>匯入後會成為目前週次的工作副本。</p><div class="progress-list" id="progressList"></div><button class="btn secondary dialog-close">關閉</button></div></dialog><div class="toast" id="toast"></div>
10
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/codemirror.min.js"></script><script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/mode/python/python.min.js"></script><script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"></script><script src="/classroom-assets/student.js?v=2026fall-4"></script></body></html>
classroom_static/student.js CHANGED
@@ -1,8 +1,8 @@
1
  const SNIPPETS=[["基礎","輸入與輸出",'name = input("名字:")\nprint("你好", name)'],["判斷","if / elif / else",'if score >= 90:\n print("A")\nelse:\n print("繼續加油")'],["迴圈","for 與 range",'for number in range(1, 6):\n print(number)'],["迴圈","while",'while True:\n choice = input("選擇:")\n if choice == "q":\n break'],["資料","list",'items = []\nitems.append("線索")\nfor item in items:\n print(item)'],["資料","dict",'student = {"name": "Alex"}\nprint(student.get("team", "未分組"))'],["函式","def / return",'def add(a, b):\n return a + b'],["檔案","引入自己的模組",'from clues import find_clue'],["檔案","讀取 JSON",'import json\nwith open("data.json", encoding="utf-8") as file:\n data = json.load(file)'],["錯誤","try / except",'try:\n age = int(input("年齡:"))\nexcept ValueError:\n print("請輸入整數")']].map(([category,title,code])=>({category,title,code}));
2
- const $=id=>document.getElementById(id),E={};["identityDialog","identityForm","identityError","classCode","studentChoice","newStudentFields","displayName","studentIdentity","identityButton","connectionDot","connectionText","helpButton","weekSelect","projectName","templateButton","progressButton","importButton","exportButton","submitButton","fileImport","fileList","newFileButton","activeFileLabel","syntaxSearch","categoryFilters","snippetList","snippetCount","runButton","stopButton","stdinInput","consoleOutput","clearOutputButton","runStatusDot","runStatusText","saveStatus","submitStatus","toast","progressDialog","progressList"].forEach(id=>E[id]=$(id));
3
  const editor=CodeMirror.fromTextArea($("codeEditor"),{mode:"python",theme:"material-darker",lineNumbers:true,indentUnit:4,tabSize:4,extraKeys:{Tab:cm=>cm.replaceSelection(" ")}});
4
  let studentId=localStorage.getItem("classroom_student_id")||crypto.randomUUID(),catalog={courses:[]};localStorage.setItem("classroom_student_id",studentId);
5
- let course,files={"main.py":"# 在這裡寫 Python\n"},activeFile="main.py",socket,worker,workerReady=false,running=false,needsHelp=false,submittedAt=null,output="",suppress=false,timer,reconnect,category="全部";
6
  const clean=v=>String(v||"").trim().replace(/[^0-9A-Za-z_\-一-鿿]/g,"").slice(0,48),week=()=>E.weekSelect.value||"week02",key=()=>`classroom_project_${localStorage.getItem("classroom_code")}_${week()}`;
7
  function toast(t){E.toast.textContent=t;E.toast.classList.add("visible");setTimeout(()=>E.toast.classList.remove("visible"),1900)}
8
  function send(m){if(socket?.readyState===WebSocket.OPEN)socket.send(JSON.stringify(m))}
@@ -14,9 +14,9 @@ function setProject(next,name,preferred="main.py"){files=Object.fromEntries(Obje
14
  function renderFiles(){E.fileList.innerHTML=Object.keys(files).sort((a,b)=>a==="main.py"?-1:b==="main.py"?1:a.localeCompare(b)).map(p=>`<div class="file-row ${p===activeFile?"selected":""}"><button data-open="${esc(p)}">◇ ${esc(p)}</button>${p!=="main.py"?`<button class="file-remove" data-remove="${esc(p)}">×</button>`:""}</div>`).join("");E.fileList.querySelectorAll("[data-open]").forEach(b=>b.onclick=()=>openFile(b.dataset.open));E.fileList.querySelectorAll("[data-remove]").forEach(b=>b.onclick=()=>{if(confirm(`刪除 ${b.dataset.remove}?`)){delete files[b.dataset.remove];if(activeFile===b.dataset.remove)activeFile="main.py";openFile(activeFile);sync()}})}
15
  function connect(){clearTimeout(reconnect);const previous=socket;if(previous){previous.onclose=null;previous.close()}const c=clean(localStorage.getItem("classroom_code")),n=clean(localStorage.getItem("classroom_name"));if(!c||!n){E.identityDialog.showModal();return}E.studentIdentity.textContent=`${c} · ${n}`;E.connectionText.textContent="連線中";const scheme=location.protocol==="https:"?"wss":"ws",q=new URLSearchParams({class_code:c,student_id:studentId,display_name:n,week_id:week()}),current=new WebSocket(`${scheme}://${location.host}/ws/classroom/student?${q}`);socket=current;current.onopen=()=>{E.connectionDot.className="status-dot online";E.connectionText.textContent="已連線"};current.onmessage=e=>{const m=JSON.parse(e.data);if(m.type==="restore"){const local=JSON.parse(localStorage.getItem(key())||"null"),s=m.state,src=local||{files:s.files,activeFile:s.active_file,projectName:s.project_name,output:s.output};output=src.output||"";E.consoleOutput.textContent=output||"尚無執行結果。";submittedAt=s.submitted_at;needsHelp=!!s.needs_help;renderState();setProject(src.files,src.projectName,src.activeFile);send({type:"output",output})}if(m.type==="help_ack"){needsHelp=false;renderState();toast("老師已看到你的協助請求")}if(["ack","pong"].includes(m.type)){if(typeof m.needs_help==="boolean"){const seen=needsHelp&&!m.needs_help;needsHelp=m.needs_help;if(seen)toast("老師已看到你的協助請求")}if(m.submitted_at)submittedAt=m.submitted_at;renderState();if(m.type==="ack")E.saveStatus.textContent="已同步"}};current.onclose=async e=>{if(socket!==current)return;E.connectionDot.className="status-dot error";E.connectionText.textContent="已離線";E.saveStatus.textContent="保存在此裝置";if(e.code===4004){localStorage.removeItem("classroom_student_id");localStorage.removeItem("classroom_name");studentId=crypto.randomUUID();localStorage.setItem("classroom_student_id",studentId);await loadCatalog();toast("老師已將這個學生從名單移除");E.identityDialog.showModal();return}reconnect=setTimeout(connect,2500)}}
16
  function renderState(){E.helpButton.classList.toggle("active",needsHelp);E.helpButton.textContent=needsHelp?"取消請求協助":"請老師協助";E.submitStatus.textContent=submittedAt?`已提交 ${new Date(submittedAt).toLocaleTimeString("zh-TW",{hour:"2-digit",minute:"2-digit"})}`:"本週尚未提交"}
17
- function initWorker(){worker?.terminate();workerReady=false;E.runButton.disabled=true;worker=new Worker("/classroom-assets/py-worker.js?v=2026fall-1");worker.onmessage=e=>{const m=e.data;if(m.type==="ready"){workerReady=true;E.runButton.disabled=false;E.runButton.textContent="執行";runState("online","準備完成")}else if(m.type==="stdout"||m.type==="stderr")append(`${m.text}\n`);else if(m.type==="done")runState("success","執行成功");else if(m.type==="python_error"){append(`${m.text}\n`);runState("error","程式錯誤")}else if(m.type==="load_error")runState("error","載入失敗")}}
18
  function runState(s,t){running=s==="running";E.runStatusDot.className=`status-dot ${s}`;E.runStatusText.textContent=t;E.runButton.disabled=!workerReady||running;E.stopButton.disabled=!running;send({type:"status",status:s==="online"?"idle":s})}
19
- function append(t){output=(output+t).slice(-40000);E.consoleOutput.textContent=output;saveLocal();send({type:"output",output})}
20
  function renderSyntax(){const cats=["全部",...new Set(SNIPPETS.map(s=>s.category))];E.categoryFilters.innerHTML=cats.map(c=>`<button class="chip" data-cat="${c}" aria-pressed="${c===category}">${c}</button>`).join("");E.categoryFilters.querySelectorAll("button").forEach(b=>b.onclick=()=>{category=b.dataset.cat;renderSyntax()});const q=E.syntaxSearch.value.toLowerCase(),list=SNIPPETS.filter(s=>(category==="全部"||s.category===category)&&(`${s.title} ${s.code}`.toLowerCase().includes(q)));E.snippetCount.textContent=`${list.length} 項`;E.snippetList.innerHTML=list.map(s=>`<article class="snippet-item"><div class="snippet-title"><strong>${s.title}</strong><span>${s.category}</span></div><pre>${esc(s.code)}</pre><button class="copy-button" data-copy="${SNIPPETS.indexOf(s)}">複製範例</button></article>`).join("")||'<div class="snippet-empty">找不到符合的語法。</div>';E.snippetList.querySelectorAll("[data-copy]").forEach(b=>b.onclick=async()=>{const s=SNIPPETS[+b.dataset.copy];await navigator.clipboard.writeText(s.code);toast(`已複製:${s.title}`)})}
21
  async function loadCatalog(){catalog=await(await fetch("/api/classroom/classes")).json();const saved=localStorage.getItem("classroom_code");E.classCode.innerHTML=catalog.courses.map(c=>`<option value="${esc(c.class_code)}">${esc(c.course_name)}|${esc(c.class_code)}</option>`).join("");E.classCode.value=catalog.courses.some(c=>c.class_code===saved)?saved:(catalog.courses[0]?.class_code||"");renderStudentChoices()}
22
  function renderStudentChoices(){const selectedCourse=catalog.courses.find(c=>c.class_code===E.classCode.value),members=selectedCourse?.students||[],savedId=localStorage.getItem("classroom_student_id");E.studentChoice.innerHTML=members.map(s=>`<option value="${esc(s.student_id)}" data-name="${esc(s.display_name)}">${esc(s.display_name)}</option>`).join("")+`<option value="__new__">+ 輸入新名字</option>`;E.studentChoice.value=members.some(s=>s.student_id===savedId)?savedId:"__new__";toggleNewStudent()}
@@ -28,6 +28,6 @@ E.progressButton.onclick=async()=>{const q=new URLSearchParams({class_code:local
28
  E.importButton.onclick=()=>E.fileImport.click();E.fileImport.onchange=async()=>{const f=E.fileImport.files[0];if(!f)return;if(f.name.endsWith(".py"))setProject({"main.py":await f.text()},f.name.replace(/\.py$/,"").trim());else{const z=await JSZip.loadAsync(f),next={};for(const [p,item] of Object.entries(z.files))if(!item.dir&&/\.(py|json|txt|csv|md)$/i.test(p)&&!p.includes(".."))next[p]=await item.async("string");setProject(next,f.name.replace(/\.zip$/i,""))}E.fileImport.value="";toast("已匯入專案")};
29
  E.exportButton.onclick=async()=>{files[activeFile]=editor.getValue();const z=new JSZip();Object.entries(files).forEach(([p,t])=>z.file(p,t));const blob=await z.generateAsync({type:"blob"}),a=document.createElement("a");a.href=URL.createObjectURL(blob);a.download=`${clean(E.projectName.value)||"project"}.zip`;a.click();URL.revokeObjectURL(a.href)};
30
  E.submitButton.onclick=()=>{files[activeFile]=editor.getValue();if(!files["main.py"].trim())return toast("main.py 不能為空");if(!confirm("設為本週最後正式版本?重新提交會覆蓋前一版。"))return;send({type:"submit",files});submittedAt=Date.now();renderState();toast("本週正式版本已提交")};E.helpButton.onclick=()=>{needsHelp=!needsHelp;renderState();send({type:"help",needs_help:needsHelp})};
31
- E.runButton.onclick=()=>{if(!workerReady||running)return;files[activeFile]=editor.getValue();output="";E.consoleOutput.textContent="";runState("running","執行中");worker.postMessage({type:"run",files,entry:"main.py",inputs:E.stdinInput.value.replace(/\r/g,"").split("\n")})};E.stopButton.onclick=()=>{append("\n[已停止]\n");runState("idle","已停止");initWorker()};E.clearOutputButton.onclick=()=>{output="";E.consoleOutput.textContent="尚無執行結果。";send({type:"output",output:""})};E.syntaxSearch.oninput=renderSyntax;editor.on("change",()=>{if(!suppress){files[activeFile]=editor.getValue();sync()}});
32
  document.querySelectorAll(".side-tab").forEach(b=>b.onclick=()=>{document.querySelectorAll(".side-tab").forEach(x=>x.classList.toggle("active",x===b));document.querySelectorAll(".side-view").forEach(v=>v.hidden=v.id!==b.dataset.side)});document.querySelectorAll(".mobile-tab").forEach(b=>b.onclick=()=>{document.querySelectorAll(".mobile-tab").forEach(x=>x.setAttribute("aria-selected",String(x===b)));document.querySelectorAll(".student-layout>.panel").forEach(p=>p.classList.toggle("mobile-active",p.id===b.dataset.panel));if(b.dataset.panel==="editorPanel")setTimeout(()=>editor.refresh(),0)});setInterval(()=>send({type:"ping"}),4000);
33
  (async()=>{[course]=await Promise.all([(await fetch("/api/classroom/course")).json(),loadCatalog()]);E.weekSelect.innerHTML=course.weeks.map(w=>`<option value="${w.id}">第 ${w.week} 週|${esc(w.title)}${w.template?"":"(規劃)"}</option>`).join("");E.weekSelect.value=localStorage.getItem("classroom_week")||"week02";renderFiles();renderSyntax();initWorker();connect()})();
 
1
  const SNIPPETS=[["基礎","輸入與輸出",'name = input("名字:")\nprint("你好", name)'],["判斷","if / elif / else",'if score >= 90:\n print("A")\nelse:\n print("繼續加油")'],["迴圈","for 與 range",'for number in range(1, 6):\n print(number)'],["迴圈","while",'while True:\n choice = input("選擇:")\n if choice == "q":\n break'],["資料","list",'items = []\nitems.append("線索")\nfor item in items:\n print(item)'],["資料","dict",'student = {"name": "Alex"}\nprint(student.get("team", "未分組"))'],["函式","def / return",'def add(a, b):\n return a + b'],["檔案","引入自己的模組",'from clues import find_clue'],["檔案","讀取 JSON",'import json\nwith open("data.json", encoding="utf-8") as file:\n data = json.load(file)'],["錯誤","try / except",'try:\n age = int(input("年齡:"))\nexcept ValueError:\n print("請輸入整數")']].map(([category,title,code])=>({category,title,code}));
2
+ const $=id=>document.getElementById(id),E={};["identityDialog","identityForm","identityError","classCode","studentChoice","newStudentFields","displayName","studentIdentity","identityButton","connectionDot","connectionText","helpButton","weekSelect","projectName","templateButton","progressButton","importButton","exportButton","submitButton","fileImport","fileList","newFileButton","activeFileLabel","syntaxSearch","categoryFilters","snippetList","snippetCount","runButton","stopButton","consoleInputForm","consoleInput","consoleOutput","clearOutputButton","runStatusDot","runStatusText","saveStatus","submitStatus","toast","progressDialog","progressList"].forEach(id=>E[id]=$(id));
3
  const editor=CodeMirror.fromTextArea($("codeEditor"),{mode:"python",theme:"material-darker",lineNumbers:true,indentUnit:4,tabSize:4,extraKeys:{Tab:cm=>cm.replaceSelection(" ")}});
4
  let studentId=localStorage.getItem("classroom_student_id")||crypto.randomUUID(),catalog={courses:[]};localStorage.setItem("classroom_student_id",studentId);
5
+ let course,files={"main.py":"# 在這裡寫 Python\n"},activeFile="main.py",socket,worker,workerReady=false,running=false,needsHelp=false,submittedAt=null,output="",suppress=false,timer,reconnect,category="全部",inputBuffer,inputControl,inputBytes,stdoutDecoder=new TextDecoder(),stderrDecoder=new TextDecoder();
6
  const clean=v=>String(v||"").trim().replace(/[^0-9A-Za-z_\-一-鿿]/g,"").slice(0,48),week=()=>E.weekSelect.value||"week02",key=()=>`classroom_project_${localStorage.getItem("classroom_code")}_${week()}`;
7
  function toast(t){E.toast.textContent=t;E.toast.classList.add("visible");setTimeout(()=>E.toast.classList.remove("visible"),1900)}
8
  function send(m){if(socket?.readyState===WebSocket.OPEN)socket.send(JSON.stringify(m))}
 
14
  function renderFiles(){E.fileList.innerHTML=Object.keys(files).sort((a,b)=>a==="main.py"?-1:b==="main.py"?1:a.localeCompare(b)).map(p=>`<div class="file-row ${p===activeFile?"selected":""}"><button data-open="${esc(p)}">◇ ${esc(p)}</button>${p!=="main.py"?`<button class="file-remove" data-remove="${esc(p)}">×</button>`:""}</div>`).join("");E.fileList.querySelectorAll("[data-open]").forEach(b=>b.onclick=()=>openFile(b.dataset.open));E.fileList.querySelectorAll("[data-remove]").forEach(b=>b.onclick=()=>{if(confirm(`刪除 ${b.dataset.remove}?`)){delete files[b.dataset.remove];if(activeFile===b.dataset.remove)activeFile="main.py";openFile(activeFile);sync()}})}
15
  function connect(){clearTimeout(reconnect);const previous=socket;if(previous){previous.onclose=null;previous.close()}const c=clean(localStorage.getItem("classroom_code")),n=clean(localStorage.getItem("classroom_name"));if(!c||!n){E.identityDialog.showModal();return}E.studentIdentity.textContent=`${c} · ${n}`;E.connectionText.textContent="連線中";const scheme=location.protocol==="https:"?"wss":"ws",q=new URLSearchParams({class_code:c,student_id:studentId,display_name:n,week_id:week()}),current=new WebSocket(`${scheme}://${location.host}/ws/classroom/student?${q}`);socket=current;current.onopen=()=>{E.connectionDot.className="status-dot online";E.connectionText.textContent="已連線"};current.onmessage=e=>{const m=JSON.parse(e.data);if(m.type==="restore"){const local=JSON.parse(localStorage.getItem(key())||"null"),s=m.state,src=local||{files:s.files,activeFile:s.active_file,projectName:s.project_name,output:s.output};output=src.output||"";E.consoleOutput.textContent=output||"尚無執行結果。";submittedAt=s.submitted_at;needsHelp=!!s.needs_help;renderState();setProject(src.files,src.projectName,src.activeFile);send({type:"output",output})}if(m.type==="help_ack"){needsHelp=false;renderState();toast("老師已看到你的協助請求")}if(["ack","pong"].includes(m.type)){if(typeof m.needs_help==="boolean"){const seen=needsHelp&&!m.needs_help;needsHelp=m.needs_help;if(seen)toast("老師已看到你的協助請求")}if(m.submitted_at)submittedAt=m.submitted_at;renderState();if(m.type==="ack")E.saveStatus.textContent="已同步"}};current.onclose=async e=>{if(socket!==current)return;E.connectionDot.className="status-dot error";E.connectionText.textContent="已離線";E.saveStatus.textContent="保存在此裝置";if(e.code===4004){localStorage.removeItem("classroom_student_id");localStorage.removeItem("classroom_name");studentId=crypto.randomUUID();localStorage.setItem("classroom_student_id",studentId);await loadCatalog();toast("老師已將這個學生從名單移除");E.identityDialog.showModal();return}reconnect=setTimeout(connect,2500)}}
16
  function renderState(){E.helpButton.classList.toggle("active",needsHelp);E.helpButton.textContent=needsHelp?"取消請求協助":"請老師協助";E.submitStatus.textContent=submittedAt?`已提交 ${new Date(submittedAt).toLocaleTimeString("zh-TW",{hour:"2-digit",minute:"2-digit"})}`:"本週尚未提交"}
17
+ function initWorker(){worker?.terminate();workerReady=false;running=false;E.consoleInputForm.hidden=true;E.runButton.disabled=true;E.runButton.textContent="載入 Python…";if(!crossOriginIsolated||typeof SharedArrayBuffer==="undefined"){append("瀏覽器未啟用互動式 Python。請重新整理,並使用最新版 Chrome、Edge 或 Safari。\n");runState("error","環境不支援");return}inputBuffer=new SharedArrayBuffer(16392);inputControl=new Int32Array(inputBuffer,0,2);inputBytes=new Uint8Array(inputBuffer,8);worker=new Worker("/classroom-assets/py-worker.js?v=2026fall-4");worker.onmessage=e=>{const m=e.data;if(m.type==="ready"){workerReady=true;E.runButton.disabled=false;E.runButton.textContent="執行";runState("online","準備完成")}else if(m.type==="stream"){const decoder=m.channel==="stderr"?stderrDecoder:stdoutDecoder;append(decoder.decode(m.bytes,{stream:true}))}else if(m.type==="input_request"){E.consoleInputForm.hidden=false;E.consoleInput.value="";E.runStatusText.textContent="等待輸入";E.consoleInput.focus()}else if(m.type==="done"){E.consoleInputForm.hidden=true;runState("success","執行成功")}else if(m.type==="python_error"){E.consoleInputForm.hidden=true;if(m.text)append(`${m.text}\n`);runState("error","程式錯誤")}else if(m.type==="load_error"){append(`Python 載入失敗:${m.text}\n`);runState("error","載入失敗")}};worker.onerror=e=>{append(`Python 執行器錯誤:${e.message}\n`);runState("error","執行器錯誤")}}
18
  function runState(s,t){running=s==="running";E.runStatusDot.className=`status-dot ${s}`;E.runStatusText.textContent=t;E.runButton.disabled=!workerReady||running;E.stopButton.disabled=!running;send({type:"status",status:s==="online"?"idle":s})}
19
+ function append(t){output=(output+t).slice(-40000);E.consoleOutput.textContent=output;E.consoleOutput.scrollTop=E.consoleOutput.scrollHeight;saveLocal();send({type:"output",output})}
20
  function renderSyntax(){const cats=["全部",...new Set(SNIPPETS.map(s=>s.category))];E.categoryFilters.innerHTML=cats.map(c=>`<button class="chip" data-cat="${c}" aria-pressed="${c===category}">${c}</button>`).join("");E.categoryFilters.querySelectorAll("button").forEach(b=>b.onclick=()=>{category=b.dataset.cat;renderSyntax()});const q=E.syntaxSearch.value.toLowerCase(),list=SNIPPETS.filter(s=>(category==="全部"||s.category===category)&&(`${s.title} ${s.code}`.toLowerCase().includes(q)));E.snippetCount.textContent=`${list.length} 項`;E.snippetList.innerHTML=list.map(s=>`<article class="snippet-item"><div class="snippet-title"><strong>${s.title}</strong><span>${s.category}</span></div><pre>${esc(s.code)}</pre><button class="copy-button" data-copy="${SNIPPETS.indexOf(s)}">複製範例</button></article>`).join("")||'<div class="snippet-empty">找不到符合的語法。</div>';E.snippetList.querySelectorAll("[data-copy]").forEach(b=>b.onclick=async()=>{const s=SNIPPETS[+b.dataset.copy];await navigator.clipboard.writeText(s.code);toast(`已複製:${s.title}`)})}
21
  async function loadCatalog(){catalog=await(await fetch("/api/classroom/classes")).json();const saved=localStorage.getItem("classroom_code");E.classCode.innerHTML=catalog.courses.map(c=>`<option value="${esc(c.class_code)}">${esc(c.course_name)}|${esc(c.class_code)}</option>`).join("");E.classCode.value=catalog.courses.some(c=>c.class_code===saved)?saved:(catalog.courses[0]?.class_code||"");renderStudentChoices()}
22
  function renderStudentChoices(){const selectedCourse=catalog.courses.find(c=>c.class_code===E.classCode.value),members=selectedCourse?.students||[],savedId=localStorage.getItem("classroom_student_id");E.studentChoice.innerHTML=members.map(s=>`<option value="${esc(s.student_id)}" data-name="${esc(s.display_name)}">${esc(s.display_name)}</option>`).join("")+`<option value="__new__">+ 輸入新名字</option>`;E.studentChoice.value=members.some(s=>s.student_id===savedId)?savedId:"__new__";toggleNewStudent()}
 
28
  E.importButton.onclick=()=>E.fileImport.click();E.fileImport.onchange=async()=>{const f=E.fileImport.files[0];if(!f)return;if(f.name.endsWith(".py"))setProject({"main.py":await f.text()},f.name.replace(/\.py$/,"").trim());else{const z=await JSZip.loadAsync(f),next={};for(const [p,item] of Object.entries(z.files))if(!item.dir&&/\.(py|json|txt|csv|md)$/i.test(p)&&!p.includes(".."))next[p]=await item.async("string");setProject(next,f.name.replace(/\.zip$/i,""))}E.fileImport.value="";toast("已匯入專案")};
29
  E.exportButton.onclick=async()=>{files[activeFile]=editor.getValue();const z=new JSZip();Object.entries(files).forEach(([p,t])=>z.file(p,t));const blob=await z.generateAsync({type:"blob"}),a=document.createElement("a");a.href=URL.createObjectURL(blob);a.download=`${clean(E.projectName.value)||"project"}.zip`;a.click();URL.revokeObjectURL(a.href)};
30
  E.submitButton.onclick=()=>{files[activeFile]=editor.getValue();if(!files["main.py"].trim())return toast("main.py 不能為空");if(!confirm("設為本週最後正式版本?重新提交會覆蓋前一版。"))return;send({type:"submit",files});submittedAt=Date.now();renderState();toast("本週正式版本已提交")};E.helpButton.onclick=()=>{needsHelp=!needsHelp;renderState();send({type:"help",needs_help:needsHelp})};
31
+ E.runButton.onclick=()=>{if(!workerReady||running)return;files[activeFile]=editor.getValue();output="";E.consoleOutput.textContent="";E.consoleInputForm.hidden=true;stdoutDecoder=new TextDecoder();stderrDecoder=new TextDecoder();Atomics.store(inputControl,0,0);runState("running","執行中");worker.postMessage({type:"run",files,entry:"main.py",inputBuffer})};E.consoleInputForm.onsubmit=e=>{e.preventDefault();if(!running||E.consoleInputForm.hidden)return;const encoded=new TextEncoder().encode(E.consoleInput.value);if(encoded.length>inputBytes.length)return toast("單次輸入內容太長");inputBytes.fill(0);inputBytes.set(encoded);Atomics.store(inputControl,1,encoded.length);append(`${E.consoleInput.value}\n`);E.consoleInputForm.hidden=true;E.runStatusText.textContent="執行中";Atomics.store(inputControl,0,1);Atomics.notify(inputControl,0,1)};E.stopButton.onclick=()=>{append("\n[已停止]\n");runState("idle","已停止");initWorker()};E.clearOutputButton.onclick=()=>{output="";E.consoleOutput.textContent="尚無執行結果。";send({type:"output",output:""})};E.syntaxSearch.oninput=renderSyntax;editor.on("change",()=>{if(!suppress){files[activeFile]=editor.getValue();sync()}});
32
  document.querySelectorAll(".side-tab").forEach(b=>b.onclick=()=>{document.querySelectorAll(".side-tab").forEach(x=>x.classList.toggle("active",x===b));document.querySelectorAll(".side-view").forEach(v=>v.hidden=v.id!==b.dataset.side)});document.querySelectorAll(".mobile-tab").forEach(b=>b.onclick=()=>{document.querySelectorAll(".mobile-tab").forEach(x=>x.setAttribute("aria-selected",String(x===b)));document.querySelectorAll(".student-layout>.panel").forEach(p=>p.classList.toggle("mobile-active",p.id===b.dataset.panel));if(b.dataset.panel==="editorPanel")setTimeout(()=>editor.refresh(),0)});setInterval(()=>send({type:"ping"}),4000);
33
  (async()=>{[course]=await Promise.all([(await fetch("/api/classroom/course")).json(),loadCatalog()]);E.weekSelect.innerHTML=course.weeks.map(w=>`<option value="${w.id}">第 ${w.week} 週|${esc(w.title)}${w.template?"":"(規劃)"}</option>`).join("");E.weekSelect.value=localStorage.getItem("classroom_week")||"week02";renderFiles();renderSyntax();initWorker();connect()})();
classroom_static/teacher.html CHANGED
@@ -1,6 +1,6 @@
1
- <!doctype html><html lang="zh-Hant"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Python 專案教室|教師控制台</title><link rel="stylesheet" href="/classroom-assets/classroom.css?v=2026fall-3"></head><body>
2
- <header class="app-header"><div class="brand"><div class="brand-mark">Py</div><div class="brand-copy"><strong>全班專案控制台</strong><span id="classLabel">尚未進入班級</span></div></div><div class="header-actions"><div class="connection-state"><i id="connectionDot" class="status-dot"></i><span id="connectionText">未連線</span></div><button class="btn secondary compact" id="loginButton">切換班級</button></div></header>
3
  <section class="project-bar"><div class="project-context"><label for="teacherWeek">週次</label><select id="teacherWeek"></select><span id="submissionSummary" class="submission-summary">等待學生</span></div><div class="project-actions"><button class="copy-button" id="downloadButton">下載全班 ZIP</button><button class="btn compact" id="publishButton">結算並發布 Demo</button></div></section>
4
  <main class="teacher-layout"><section class="roster-panel"><div class="panel-heading"><h1>學生狀態</h1><span id="studentTotal">0 人</span></div><div class="filter-bar" id="teacherFilters"><button class="chip" data-filter="all" aria-pressed="true">全部</button><button class="chip" data-filter="submitted">已提交</button><button class="chip" data-filter="help">需要協助</button><button class="chip" data-filter="error">錯誤</button></div><div class="roster-header"><span>學生</span><span>狀態</span><span>檔案</span><span>提交</span></div><div class="roster-list" id="rosterList"></div></section>
5
  <section class="teacher-detail" id="teacherDetail"><div class="detail-empty" id="detailEmpty">選擇一位學生查看多檔案專案。</div><div class="detail-view" id="detailView" hidden><header class="detail-header"><div><h1 id="selectedStudentName"></h1><p class="detail-meta" id="selectedStudentMeta"></p></div><div class="detail-actions"><button class="copy-button" id="ackHelpButton" hidden>標記已查看</button><button class="copy-button" id="copyCodeButton">複製檔案</button><button class="copy-button danger-action" id="deleteStudentButton">刪除學生</button></div></header><div class="teacher-project"><nav class="teacher-file-tabs" id="teacherFiles"></nav><div class="detail-split"><section class="read-only-pane"><div class="pane-label"><span id="teacherActiveFile">main.py</span><span id="lineCount">0 行</span></div><pre class="code-readonly" id="studentCode"></pre></section><section class="read-only-pane"><div class="pane-label"><span>Console</span><span id="outputState"></span></div><pre class="output-readonly" id="studentOutput"></pre></section></div></div></div></section></main>
6
- <dialog id="loginDialog"><form class="dialog-content form-stack" id="loginForm"><div><h1>進入教師控制台</h1><p>選取現有課程,或建立新的課程代碼。</p></div><div><label class="field-label" for="classCode">課程</label><select class="text-field" id="classCode" required></select></div><div id="newClassFields" hidden><label class="field-label" for="newClassCode">新課程代碼</label><input class="text-field" id="newClassCode" placeholder="L6B"><label class="field-label secondary-label" for="newClassName">課程名稱</label><input class="text-field" id="newClassName" placeholder="進階專題 B 班"></div><div><label class="field-label" for="teacherPassword">教師密碼</label><input class="text-field" id="teacherPassword" type="password" required></div><p class="form-error" id="loginError"></p><button class="btn" type="submit">開始監看</button></form></dialog><dialog id="publishDialog"><div class="dialog-content"><h2>結算並發布</h2><p id="publishMessage"></p><div class="publish-actions"><button class="btn secondary" id="cancelPublish">取消</button><button class="btn" id="confirmPublish">確認發布</button></div></div></dialog><div class="toast" id="toast"></div><script src="/classroom-assets/teacher.js?v=2026fall-3"></script></body></html>
 
1
+ <!doctype html><html lang="zh-Hant"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Python 專案教室|教師控制台</title><link rel="stylesheet" href="/classroom-assets/classroom.css?v=2026fall-4"></head><body>
2
+ <header class="app-header"><div class="brand"><div class="brand-mark">Py</div><div class="brand-copy"><strong>全班專案控制台</strong><span id="classLabel">尚未進入班級</span></div></div><div class="header-actions"><div class="connection-state"><i id="connectionDot" class="status-dot"></i><span id="connectionText">未連線</span></div><a class="btn secondary compact header-link" href="/admin">內容管理</a><button class="btn secondary compact" id="loginButton">切換班級</button></div></header>
3
  <section class="project-bar"><div class="project-context"><label for="teacherWeek">週次</label><select id="teacherWeek"></select><span id="submissionSummary" class="submission-summary">等待學生</span></div><div class="project-actions"><button class="copy-button" id="downloadButton">下載全班 ZIP</button><button class="btn compact" id="publishButton">結算並發布 Demo</button></div></section>
4
  <main class="teacher-layout"><section class="roster-panel"><div class="panel-heading"><h1>學生狀態</h1><span id="studentTotal">0 人</span></div><div class="filter-bar" id="teacherFilters"><button class="chip" data-filter="all" aria-pressed="true">全部</button><button class="chip" data-filter="submitted">已提交</button><button class="chip" data-filter="help">需要協助</button><button class="chip" data-filter="error">錯誤</button></div><div class="roster-header"><span>學生</span><span>狀態</span><span>檔案</span><span>提交</span></div><div class="roster-list" id="rosterList"></div></section>
5
  <section class="teacher-detail" id="teacherDetail"><div class="detail-empty" id="detailEmpty">選擇一位學生查看多檔案專案。</div><div class="detail-view" id="detailView" hidden><header class="detail-header"><div><h1 id="selectedStudentName"></h1><p class="detail-meta" id="selectedStudentMeta"></p></div><div class="detail-actions"><button class="copy-button" id="ackHelpButton" hidden>標記已查看</button><button class="copy-button" id="copyCodeButton">複製檔案</button><button class="copy-button danger-action" id="deleteStudentButton">刪除學生</button></div></header><div class="teacher-project"><nav class="teacher-file-tabs" id="teacherFiles"></nav><div class="detail-split"><section class="read-only-pane"><div class="pane-label"><span id="teacherActiveFile">main.py</span><span id="lineCount">0 行</span></div><pre class="code-readonly" id="studentCode"></pre></section><section class="read-only-pane"><div class="pane-label"><span>Console</span><span id="outputState"></span></div><pre class="output-readonly" id="studentOutput"></pre></section></div></div></div></section></main>
6
+ <dialog id="loginDialog"><form class="dialog-content form-stack" id="loginForm"><div><h1>進入教師控制台</h1><p>選取現有課程,或建立新��課程代碼。</p></div><div><label class="field-label" for="classCode">課程</label><select class="text-field" id="classCode" required></select></div><div id="newClassFields" hidden><label class="field-label" for="newClassCode">新課程代碼</label><input class="text-field" id="newClassCode" placeholder="L6B"><label class="field-label secondary-label" for="newClassName">課程名稱</label><input class="text-field" id="newClassName" placeholder="進階專題 B 班"></div><div><label class="field-label" for="teacherPassword">教師密碼</label><input class="text-field" id="teacherPassword" type="password" required></div><p class="form-error" id="loginError"></p><button class="btn" type="submit">開始監看</button></form></dialog><dialog id="publishDialog"><div class="dialog-content"><h2>結算並發布</h2><p id="publishMessage"></p><div class="publish-actions"><button class="btn secondary" id="cancelPublish">取消</button><button class="btn" id="confirmPublish">確認發布</button></div></div></dialog><div class="toast" id="toast"></div><script src="/classroom-assets/teacher.js?v=2026fall-4"></script></body></html>
server.py CHANGED
@@ -23,6 +23,7 @@ from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, Redirect
23
  from fastapi.staticfiles import StaticFiles
24
  from fastapi.templating import Jinja2Templates
25
  from classroom_service import router as classroom_router
 
26
 
27
  # ---------------------------------------------------------------------------
28
  # Configuration
@@ -61,6 +62,16 @@ app = FastAPI(title="Student Pygame/OpenCV Arcade Hub")
61
  templates = Jinja2Templates(directory=TEMPLATES_DIR)
62
  app.mount("/classroom-assets", StaticFiles(directory=os.path.join(BASE_DIR, "classroom_static")), name="classroom-assets")
63
  app.include_router(classroom_router)
 
 
 
 
 
 
 
 
 
 
64
 
65
  # Concurrency + live session metrics
66
  runner_semaphore = asyncio.Semaphore(MAX_CONCURRENT_RUNNERS)
 
23
  from fastapi.staticfiles import StaticFiles
24
  from fastapi.templating import Jinja2Templates
25
  from classroom_service import router as classroom_router
26
+ from admin_service import router as admin_router
27
 
28
  # ---------------------------------------------------------------------------
29
  # Configuration
 
62
  templates = Jinja2Templates(directory=TEMPLATES_DIR)
63
  app.mount("/classroom-assets", StaticFiles(directory=os.path.join(BASE_DIR, "classroom_static")), name="classroom-assets")
64
  app.include_router(classroom_router)
65
+ app.include_router(admin_router)
66
+
67
+
68
+ @app.middleware("http")
69
+ async def browser_runtime_headers(request: Request, call_next):
70
+ """Enable SharedArrayBuffer for interactive input inside the Python worker."""
71
+ response = await call_next(request)
72
+ response.headers["Cross-Origin-Opener-Policy"] = "same-origin"
73
+ response.headers["Cross-Origin-Embedder-Policy"] = "credentialless"
74
+ return response
75
 
76
  # Concurrency + live session metrics
77
  runner_semaphore = asyncio.Semaphore(MAX_CONCURRENT_RUNNERS)
tests/test_admin.py ADDED
@@ -0,0 +1,84 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import tempfile
3
+ import unittest
4
+ from pathlib import Path
5
+ from types import SimpleNamespace
6
+
7
+ import admin_service as admin
8
+ import classroom_service as classroom
9
+
10
+
11
+ class AdminServiceTests(unittest.TestCase):
12
+ def setUp(self):
13
+ self.temp_dir = tempfile.TemporaryDirectory()
14
+ self.root = Path(self.temp_dir.name)
15
+ self.data = self.root / "classroom_data"
16
+ self.templates = self.data / "templates"
17
+ self.projects = self.root / "projects"
18
+ self.templates.mkdir(parents=True)
19
+ self.projects.mkdir()
20
+ (self.data / "course.json").write_text(json.dumps({
21
+ "title": "測試課程",
22
+ "weeks": [{
23
+ "id": "week02", "week": 2, "title": "舊標題",
24
+ "project": "舊專題", "output": "舊產出", "template": False,
25
+ }],
26
+ }, ensure_ascii=False), encoding="utf-8")
27
+ self.old = (classroom.DATA, classroom.TEMPLATES, classroom.PASSWORD, admin.ROOT, admin.PROJECTS, admin.commit_changes)
28
+ classroom.DATA = self.data
29
+ classroom.TEMPLATES = self.templates
30
+ classroom.PASSWORD = "teacher-test"
31
+ admin.ROOT = self.root
32
+ admin.PROJECTS = self.projects
33
+ self.commits = []
34
+
35
+ def fake_commit(additions, deletions, message):
36
+ self.commits.append((additions, deletions, message))
37
+ return SimpleNamespace(commit_url="https://example.test/commit")
38
+
39
+ admin.commit_changes = fake_commit
40
+
41
+ def tearDown(self):
42
+ classroom.DATA, classroom.TEMPLATES, classroom.PASSWORD, admin.ROOT, admin.PROJECTS, admin.commit_changes = self.old
43
+ self.temp_dir.cleanup()
44
+
45
+ def test_teacher_updates_week_and_template_files(self):
46
+ request = admin.WeekSave(
47
+ password="teacher-test", week_id="week02", title="新標題",
48
+ project="新專題", output="新產出",
49
+ files={"main.py": 'print("ok")\n', "data/case.json": "{}\n"},
50
+ )
51
+ result = admin.save_week(request)
52
+ course = json.loads((self.data / "course.json").read_text(encoding="utf-8"))
53
+ self.assertTrue(result["ok"])
54
+ self.assertEqual(course["weeks"][0]["title"], "新標題")
55
+ self.assertTrue(course["weeks"][0]["template"])
56
+ self.assertEqual((self.templates / "week02" / "main.py").read_text(), 'print("ok")\n')
57
+ self.assertEqual(len(self.commits), 1)
58
+
59
+ def test_teacher_creates_renames_and_deletes_demo_project(self):
60
+ created = admin.save_demo(admin.DemoSave(
61
+ password="teacher-test", class_name="進階班", student_name="Alex",
62
+ project_name="互動故事", files={"main.py": 'print("start")\n'},
63
+ ))
64
+ original = admin.ProjectRef(**created["project"])
65
+ project_root = self.projects / "進階班" / "Alex" / "互動故事"
66
+ (project_root / "picture.png").write_bytes(b"png")
67
+ renamed = admin.save_demo(admin.DemoSave(
68
+ password="teacher-test", original=original, class_name="進階班",
69
+ student_name="Alex", project_name="互動故事完整版",
70
+ files={"main.py": 'print("done")\n'},
71
+ ))
72
+ renamed_root = self.projects / "進階班" / "Alex" / "互動故事完整版"
73
+ self.assertFalse(project_root.exists())
74
+ self.assertEqual((renamed_root / "main.py").read_text(), 'print("done")\n')
75
+ self.assertEqual((renamed_root / "picture.png").read_bytes(), b"png")
76
+ admin.delete_demo(admin.DemoRequest(
77
+ password="teacher-test", project=admin.ProjectRef(**renamed["project"])
78
+ ))
79
+ self.assertFalse(renamed_root.exists())
80
+ self.assertEqual(len(self.commits), 3)
81
+
82
+
83
+ if __name__ == "__main__":
84
+ unittest.main()