Spaces:
Running
Running
Commit ·
ed832a7
1
Parent(s): 6737361
Add content admin and interactive Python console
Browse files- README.md +3 -0
- admin_service.py +282 -0
- classroom_static/admin.css +51 -0
- classroom_static/admin.html +41 -0
- classroom_static/admin.js +288 -0
- classroom_static/classroom.css +10 -3
- classroom_static/py-worker.js +51 -25
- classroom_static/student.html +3 -3
- classroom_static/student.js +5 -5
- classroom_static/teacher.html +3 -3
- server.py +11 -0
- tests/test_admin.py +84 -0
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("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """);
|
| 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:
|
| 243 |
-
.
|
| 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
|
|
|
|
| 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 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 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
|
| 43 |
-
import
|
| 44 |
root = "/home/pyodide/classroom_project"
|
| 45 |
os.chdir(root)
|
| 46 |
-
if root not in sys.path:
|
|
|
|
| 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 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
|
|
|
|
|
|
| 59 |
`);
|
| 60 |
-
|
| 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-
|
| 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>
|
| 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-
|
|
|
|
| 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","
|
| 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-
|
| 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",
|
| 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-
|
| 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>選取現有課程,或建立新
|
|
|
|
| 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()
|