Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ HTTP_PORT=80
# ── 預建映像版本(僅「拉取映像」部署方式需要)──────────
# 用 docker compose pull 拉官方映像時,決定要拉哪一版。
# 預設 latest(最新);正式部署建議釘選版本號以利可控升級,例如:
# IMAGE_TAG=v1.3.2
# IMAGE_TAG=v1.3.3
# 若你是從原始碼 docker compose up -d 自行建置,此項可忽略。
IMAGE_TAG=latest

Expand Down
21 changes: 20 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,29 @@

破壞性變更(需人工介入才能升級)以 ⚠️ 標註。

> **新使用者請直接安裝 v1.3.2**(見 [README](README.md) 快速開始)。v1.0.0 ~ v1.3.1 是先前版本;以下條目保留作為變更紀錄。
> **新使用者請直接安裝 v1.3.3**(見 [README](README.md) 快速開始)。v1.0.0 ~ v1.3.2 是先前版本;以下條目保留作為變更紀錄。

## [Unreleased]

## [1.3.3] — 2026-10-21

**儀表板新增「即時概況」:現在這一節,各班實際在上什麼課、誰在上。** 來自使用者 Issue #50:儀表板原本只有資料筆數與今日異動數,看不出現在各班在上什麼。

**本版沒有資料表結構變更**,升級只需換映像;若要退回 v1.3.2,把 `IMAGE_TAG` 改回即可。

官方映像(amd64 + arm64 雙架構)已發布於 GHCR:
`ghcr.io/begin0808/course_scheduling_system-{api,worker,web}:v1.3.3`

### 新增
- **儀表板即時概況**:每一班一格,寫科目、授課老師、教室。
- 和巡堂表同一套計算:已發布課表疊上**當天的調代課**(代課、調課、併班、自習、停課),單雙週依當週判斷;有異動的班級加框標色,「請假待處理」以紅色標出還沒安排人的節次。社團這類 4 組以上的跑班寫「社團 共 N 組」。
- **下課、早自習、午休**時列出**下一節**各班的課,讓組長先確認有沒有缺人;上學前、放學後、沒有課的日子只顯示一行說明。
- 現在是第幾節,依各班**自己的節次表**的上下課時間判斷;國中小、完全中學兩部作息不同時分開列。節次表沒填時間時,提示到「學期與節次表」補上。
- 每分鐘自動更新;可直接開啟今天整天的巡堂表。
- 限教學組長、教務主任、系統管理員(同巡堂表);一般教師的儀表板不顯示。
- 新 API:`GET /api/live-overview?semester_id=`(可加 `at=` 指定時刻,供測試與預覽)。
- 手冊新增 8.9「儀表板即時概況」。

## [1.3.2] — 2026-10-20

**系統管理員忘了密碼、或剛裝好就登不進去,現在有指令可以重設。** 來自使用者來信:照手冊手動安裝,先啟動過一次才改 `.env` 的密碼,結果怎麼登都是「帳號或密碼錯誤」,接著被鎖 15 分鐘。管理員帳號只在系統第一次啟動時依 `.env` 建立,之後改 `.env` 不會生效;以前只能刪資料重裝或還原備份。
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,11 +132,11 @@ docker compose up -d # 首次會建置映像,需數分鐘

## 專案狀態

**v1.3.2 已發行(2026-10-20)。** 六大里程碑 M0–M5 全部完成,功能齊備並經完整驗收(後端 670 項單元/整合測試、51 項 Playwright 端對端測試涵蓋全部 20 個頁面,每次提交皆對真實 Docker 全棧跑過)。官方映像(amd64 + arm64)已發布於 GHCR。
**v1.3.3 已發行(2026-10-21)。** 六大里程碑 M0–M5 全部完成,功能齊備並經完整驗收(後端 685 項單元/整合測試、52 項 Playwright 端對端測試涵蓋全部 20 個頁面,每次提交皆對真實 Docker 全棧跑過)。官方映像(amd64 + arm64)已發布於 GHCR。

v1.3.2 新增**重設管理員密碼**的指令(忘了密碼或第一次就登不進去時,不必刪資料或還原備份);v1.3.1 新增**帳號管理**(系統管理員可設定教學組長、教務主任的帳號);v1.3.0 支援**單雙週課程**(同一節單週、雙週輪流上不同的課,排課、請假代課、調課、巡堂表、鐘點都分單雙週;⚠️ 首次改動資料表結構,升級前請先備份);v1.2.19 支援**日薪代課**(國小常見:月結統計把鐘點與日薪分開列)並可**整批指派代課**(整張假單或整天一次派給同一位老師);v1.2.18 可**檢查課表**(發布前列出衝堂、教師不可排時段等衝突;事後修改不可排時段時當場提示已排的課);v1.2.17 修正登入按一次送出兩筆請求(輸錯密碼約第 3 次就被鎖定);v1.2.15、v1.2.16 修正跑班群組的兩個問題(同一位老師在群組裡重複配課時自動排課會排出撞課的課表;同一格有多筆同科目配課時,手動排課把每日節數重複計算);v1.2.12~v1.2.14 可列印**巡堂表**並匯出 Excel(依當天的調代課結果產生,社團另出分組巡堂單);v1.2.10、v1.2.11 讓**國中小、完全中學**跨部上課的老師,課表與通知單都標出實際上課時間,兩部節數不同(國中七節、高中八節)也不會漏課;v1.2.9 可一次匯出**全校教師課表**(總表與批次打包);v1.2.8 可用 Excel **批次更新既有教師**(新學年職務異動),並能**停用/啟用教師登入帳號**(離職教師保留歷史紀錄又進不來);v1.2.7 補上**學期起訖日期**的填寫與修改(先前畫面上無處可填,導致請假被擋);v1.2.6 修掉第一批外部使用者回報的三個問題(教師帳號首次登入卡死、無法事後補開教師帳號、未排課務清單過長時拖不動),並可在畫面上自行修改與重設密碼;v1.2.5 可列印**教師代課通知單與班級代課通知單**(照學校紙本格式,代課時可選計費方式);v1.2.4 的調代課處理會寫出處置方式(代課或調課、哪天補課);v1.2.3 起系統會顯示目前版本,並在有新版時提醒系統管理員(只提醒、不自動升級);v1.2.2 可列印**教師調課通知單與班級調課通知單**(照學校紙本格式);v1.2.1 補上**調課**的操作畫面——請假(含研習)的節次可直接列出雙方都有空的對調節次,支援跨週(最多四週)與連堂,補課日也會出現在今日看板。v1.2.0 起另有**一鍵示範資料**,裝好後兩分鐘內就能看到一張排好的全校課表。
v1.3.3 的儀表板新增**即時概況**(現在這一節各班在上什麼課、誰在上,已套用當天的調代課);v1.3.2 新增**重設管理員密碼**的指令(忘了密碼或第一次就登不進去時,不必刪資料或還原備份);v1.3.1 新增**帳號管理**(系統管理員可設定教學組長、教務主任的帳號);v1.3.0 支援**單雙週課程**(同一節單週、雙週輪流上不同的課,排課、請假代課、調課、巡堂表、鐘點都分單雙週;⚠️ 首次改動資料表結構,升級前請先備份);v1.2.19 支援**日薪代課**(國小常見:月結統計把鐘點與日薪分開列)並可**整批指派代課**(整張假單或整天一次派給同一位老師);v1.2.18 可**檢查課表**(發布前列出衝堂、教師不可排時段等衝突;事後修改不可排時段時當場提示已排的課);v1.2.17 修正登入按一次送出兩筆請求(輸錯密碼約第 3 次就被鎖定);v1.2.15、v1.2.16 修正跑班群組的兩個問題(同一位老師在群組裡重複配課時自動排課會排出撞課的課表;同一格有多筆同科目配課時,手動排課把每日節數重複計算);v1.2.12~v1.2.14 可列印**巡堂表**並匯出 Excel(依當天的調代課結果產生,社團另出分組巡堂單);v1.2.10、v1.2.11 讓**國中小、完全中學**跨部上課的老師,課表與通知單都標出實際上課時間,兩部節數不同(國中七節、高中八節)也不會漏課;v1.2.9 可一次匯出**全校教師課表**(總表與批次打包);v1.2.8 可用 Excel **批次更新既有教師**(新學年職務異動),並能**停用/啟用教師登入帳號**(離職教師保留歷史紀錄又進不來);v1.2.7 補上**學期起訖日期**的填寫與修改(先前畫面上無處可填,導致請假被擋);v1.2.6 修掉第一批外部使用者回報的三個問題(教師帳號首次登入卡死、無法事後補開教師帳號、未排課務清單過長時拖不動),並可在畫面上自行修改與重設密碼;v1.2.5 可列印**教師代課通知單與班級代課通知單**(照學校紙本格式,代課時可選計費方式);v1.2.4 的調代課處理會寫出處置方式(代課或調課、哪天補課);v1.2.3 起系統會顯示目前版本,並在有新版時提醒系統管理員(只提醒、不自動升級);v1.2.2 可列印**教師調課通知單與班級調課通知單**(照學校紙本格式);v1.2.1 補上**調課**的操作畫面——請假(含研習)的節次可直接列出雙方都有空的對調節次,支援跨週(最多四週)與連堂,補課日也會出現在今日看板。v1.2.0 起另有**一鍵示範資料**,裝好後兩分鐘內就能看到一張排好的全校課表。

**請直接從最新版開始安裝**(見上方快速開始);`v1.3.2` 是目前建議使用的版本。各版變更見 [CHANGELOG](CHANGELOG.md),開發歷程見 [docs/tasks.md](docs/tasks.md)。
**請直接從最新版開始安裝**(見上方快速開始);`v1.3.3` 是目前建議使用的版本。各版變更見 [CHANGELOG](CHANGELOG.md),開發歷程見 [docs/tasks.md](docs/tasks.md)。

系統仍在實際校園環境試用中,若你是第一批使用者,歡迎加入 [LINE 社群](https://line.me/ti/g2/hP0Lg7mqvE5rvWg0sE9MHI-QsLkd94Cfp3wKcg) 或透過 [Issues](https://github.com/begin0808/Course_Scheduling_System/issues) 回報任何問題。

Expand Down
31 changes: 28 additions & 3 deletions backend/app/api/substitution_log.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,24 @@
"""

from dataclasses import asdict
from datetime import date
from datetime import date, datetime
from urllib.parse import quote

from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from sqlalchemy.orm import Session

from app.core.auth import require_roles
from app.core.clock import school_now
from app.core.db import get_db
from app.models.semester import Semester
from app.models.user import Role, User
from app.schemas.substitution_log import DailyBoardOut, LogEntryOut, PatrolSheetsOut
from app.services import patrol_sheets, patrol_xlsx
from app.schemas.substitution_log import (
DailyBoardOut,
LiveOverviewOut,
LogEntryOut,
PatrolSheetsOut,
)
from app.services import live_overview, patrol_sheets, patrol_xlsx
from app.services import settings as app_settings
from app.services import substitution_log as log_service

Expand Down Expand Up @@ -55,6 +61,25 @@ def daily_board(
)


@router.get("/live-overview", response_model=LiveOverviewOut)
def get_live_overview(
semester_id: int = Query(...),
at: datetime | None = Query(
default=None, description="指定時刻(學校時區,供測試與預覽);預設為現在"),
db: Session = Depends(get_db),
_: User = Depends(viewer),
):
"""儀表板即時概況:現在這一節各班實際在上什麼課、誰在上(已套用當天的調代課)。

下課、早自習、午休時列出下一節;上學前、放學後、沒有課的日子只回一行說明。
"""
_get_semester(db, semester_id)
now = school_now() if at is None else at
if now.tzinfo is not None:
now = now.astimezone(school_now().tzinfo).replace(tzinfo=None)
return LiveOverviewOut.model_validate(asdict(live_overview.build(db, semester_id, now)))


@router.get("/patrol-sheets", response_model=PatrolSheetsOut)
def get_patrol_sheets(
semester_id: int = Query(...),
Expand Down
29 changes: 29 additions & 0 deletions backend/app/schemas/substitution_log.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,3 +94,32 @@ class DailyBoardOut(BaseModel):
school_name: str
semester_label: str
entries: list[LogEntryOut] = []


class LiveCellOut(BaseModel):
class_name: str
subject: str
teacher: str
room: str
note: str


class LiveSectionOut(BaseModel):
table_name: str
state: str
message: str
now_label: str
period_name: str
start: str
end: str
cells: list[LiveCellOut]


class LiveOverviewOut(BaseModel):
"""儀表板即時概況(v1.3.3):現在這一節(或下課時的下一節)各班實際的課。"""

date: _Date
weekday: int
time: str
message: str
sections: list[LiveSectionOut]
160 changes: 160 additions & 0 deletions backend/app/services/live_overview.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
"""儀表板「即時概況」(v1.3.3):現在這一節,各班實際在上什麼課、誰在上。

起因(使用者 Issue #50):儀表板只有資料筆數與今日異動數,看不出「現在這一節各班在上什麼」。

巡堂表已經把「已發布課表 + 當天的調代課」疊成那一天每班每節的實際樣子(含單雙週、
跨部作息,見 `patrol_sheets.Day`),這裡只取「現在」那一節。現在是第幾節,依各班**自己的
節次表**的上下課時間判斷,所以國中小兩套作息的學校,同一時刻兩部可能在不同節,分開列。

使用者定案:
- 下課、早自習、午休時,列出**下一節**各班的課,讓組長先確認有沒有缺人。
- 上學前、放學後、沒有課的日子只顯示一行說明。
- 節次表沒填上下課時間就無從判斷現在是第幾節,請組長去填。

本模組只讀,不寫資料庫。
"""

import enum
from dataclasses import dataclass, field
from datetime import date, datetime, time

from sqlalchemy.orm import Session

from app.models.basedata import ClassUnit
from app.models.period import Period, PeriodType
from app.models.semester import Semester
from app.services import patrol_sheets
from app.services.availability import published_timetable


class LiveState(enum.StrEnum):
in_class = "in_class" # 正在上課:列出這一節
upcoming = "upcoming" # 下課、早自習、午休:列出下一節
before_school = "before_school"
after_school = "after_school"
no_class = "no_class" # 今天沒有課(假日、這天沒有節次或沒有排課)
no_times = "no_times" # 節次表沒填上下課時間


@dataclass(frozen=True, slots=True)
class LiveCell:
class_name: str
subject: str = ""
teacher: str = ""
room: str = ""
note: str = "" # 代課/調課/併班/自習/停課/請假待處理


@dataclass
class LiveSection:
"""一套節次表(全校只有一套時就只有這一段)。"""

table_name: str # 全校只有一套節次表時為空
state: str
message: str = "" # 不列課表時的一行說明
now_label: str = "" # 現在是什麼時段:「下課」「午休」「早自習」;上課中為空
period_name: str = ""
start: str = ""
end: str = ""
cells: list[LiveCell] = field(default_factory=list)


@dataclass
class LiveOverview:
date: date
weekday: int
time: str
message: str = "" # 整體無法顯示的原因(沒有已發布課表、不在學期期間)
sections: list[LiveSection] = field(default_factory=list)


def _hhmm(t: time | None) -> str:
return t.strftime("%H:%M") if t else ""


def _cell(klass: ClassUnit, lessons: list) -> LiveCell:
if not lessons:
return LiveCell(class_name=klass.name)
if lessons[0].is_group and len(lessons) >= patrol_sheets.GROUP_LIST_MIN:
# 社團這類大型跑班一格塞不下,只寫群組名稱與組數(同巡堂表)
return LiveCell(class_name=klass.name, subject=lessons[0].unit_name,
teacher=f"共 {len(lessons)} 組")
j = patrol_sheets.join
return LiveCell(
class_name=klass.name,
subject=j([x.subject for x in lessons]),
teacher=j([x.teacher for x in lessons]),
room=j([x.room for x in lessons]),
note=j([x.note for x in lessons], "、"),
)


def _section(periods: list[Period], now: time) -> tuple[LiveSection, Period | None]:
"""判斷這套節次表「現在」的狀態,回傳 (狀態, 要列出的那一節)。"""
regular = [p for p in periods if p.type == PeriodType.regular.value]
if not regular:
return LiveSection("", LiveState.no_class, "今天沒有課"), None
if any(p.start_time is None or p.end_time is None for p in regular):
return LiveSection(
"", LiveState.no_times,
"節次表沒有填上下課時間,無法判斷現在是第幾節;"
"請到「基礎資料 → 學期與節次表」補上。",
), None
regular.sort(key=lambda p: p.start_time or time.min)
timed = [p for p in periods if p.start_time is not None]
first = min(p.start_time for p in timed if p.start_time)
if now < first:
return LiveSection(
"", LiveState.before_school,
f"還沒到上課時間({regular[0].name} {_hhmm(regular[0].start_time)} 開始)",
), None
if now >= max(p.end_time for p in regular if p.end_time):
return LiveSection("", LiveState.after_school, "今天的課已結束"), None

for p in regular:
if p.start_time and p.end_time and p.start_time <= now < p.end_time:
return LiveSection("", LiveState.in_class), p
nxt = next(p for p in regular if p.start_time and p.start_time > now)
during = next(
(p.name for p in timed
if p.start_time and p.end_time and p.start_time <= now < p.end_time),
"下課",
)
return LiveSection("", LiveState.upcoming, now_label=during), nxt


def build(db: Session, semester_id: int, now: datetime) -> LiveOverview:
"""`now` 為學校時區的現在(呼叫端傳入,測試可指定時刻)。"""
today = now.date()
result = LiveOverview(date=today, weekday=today.isoweekday(), time=now.strftime("%H:%M"))
semester = db.get(Semester, semester_id)
if semester is not None and (
(semester.start_date and today < semester.start_date)
or (semester.end_date and today > semester.end_date)
):
result.message = "今天不在這個學期的起訖日期內"
return result
timetable = published_timetable(db, semester_id)
if timetable is None:
result.message = "這個學期還沒有發布課表"
return result

by_table = patrol_sheets.classes_by_period_table(db, semester_id)
if not by_table:
result.message = "這個學期還沒有班級"
return result
day = patrol_sheets.Day(db, semester_id, timetable.id, today)
multi = len(by_table) > 1
for table, members in by_table.values():
periods = [p for p in table.periods if p.weekday == today.isoweekday()]
if not day.entries:
section, period = LiveSection("", LiveState.no_class, "今天沒有課"), None
else:
section, period = _section(periods, now.time())
section.table_name = table.name if multi else ""
if period is not None:
section.period_name = period.name
section.start, section.end = _hhmm(period.start_time), _hhmm(period.end_time)
section.cells = [_cell(c, day.lessons(c.id, period.period_no)) for c in members]
result.sections.append(section)
return result
Loading
Loading