diff --git a/app/app/data.py b/app/app/data.py index 053f4a3..7fe82b1 100644 --- a/app/app/data.py +++ b/app/app/data.py @@ -4756,6 +4756,152 @@ def account_summary(accounts: list[Account]) -> dict: return result +def dashboard_period_bounds(period: str, today: date | None = None) -> tuple[str, date | None, date | None]: + normalized = period.strip().lower() or "month" + today = today or date.today() + if normalized == "today": + return normalized, today, today + if normalized in {"7d", "week"}: + return "7d", today - timedelta(days=6), today + if normalized == "all": + return normalized, None, None + return "month", today.replace(day=1), today + + +def record_date_value(record: ClassRecord) -> date: + return date.fromisoformat(record.date.replace(".", "-")) + + +def records_in_period(records: list[ClassRecord], start: date | None, end: date | None) -> list[ClassRecord]: + result = [] + for record in records: + current = record_date_value(record) + if start is not None and current < start: + continue + if end is not None and current > end: + continue + result.append(record) + return result + + +def top_duration_items(values: dict[str, float], limit: int = 8) -> list[dict]: + return [ + {"name": name, "hours": round(hours, 2), "duration": duration_text_from_hours(hours)} + for name, hours in sorted(values.items(), key=lambda item: (-item[1], item[0]))[:limit] + ] + + +def dashboard_course_summary_statuses(root: Path, records: list[ClassRecord]) -> dict: + result = { + "total": 0, + "matched": 0, + "auto_bound": 0, + "unmatched": 0, + "missing_time": 0, + "mismatch": 0, + "with_candidate": 0, + } + record_keys = {class_record_binding_key(record): record for record in records} + for item in iter_course_summary_markdown(root): + result["total"] += 1 + binding = course_summary_binding_status(item, record_keys, records) + status = str(binding.get("status") or "unmatched") + if status in result: + result[status] += 1 + else: + result["unmatched"] += 1 + if binding.get("auto_bound"): + result["auto_bound"] += 1 + if binding.get("candidates"): + result["with_candidate"] += 1 + return result + + +def dashboard_task_summary(tasks_path: Path) -> dict: + tasks = read_admin_tasks(tasks_path).get("items", []) + active = [task for task in tasks if task.get("status") in {"pending", "conflict"}] + by_type: defaultdict[str, int] = defaultdict(int) + by_status: defaultdict[str, int] = defaultdict(int) + for task in active: + by_type[str(task.get("type") or "unknown")] += 1 + by_status[str(task.get("status") or "unknown")] += 1 + return { + "active": len(active), + "pending": by_status.get("pending", 0), + "conflict": by_status.get("conflict", 0), + "by_type": dict(sorted(by_type.items())), + } + + +def admin_dashboard_summary( + *, + classnotes_path: Path, + accounts_path: Path, + teachers_path: Path, + tasks_path: Path, + summaries_root: Path, + operation_logs_path: Path, + period: str = "month", +) -> dict: + normalized_period, start, end = dashboard_period_bounds(period) + records = read_classnotes(classnotes_path) if classnotes_path.exists() else [] + period_records = records_in_period(records, start, end) + accounts = read_accounts(accounts_path) if accounts_path.exists() else [] + teachers = read_teachers(teachers_path) + summary = summarize_records(period_records) + daily: defaultdict[str, float] = defaultdict(float) + for record in period_records: + daily[record.date.replace(".", "-")] += record.duration_hours + active_accounts = [account for account in accounts if account.account_status not in {"结课", "退费"}] + low_remaining = sorted(active_accounts, key=lambda account: (account.remaining, account.student))[:8] + logs = list_operation_logs(operation_logs_path, limit=10).get("items", []) if operation_logs_path.exists() else [] + return { + "period": { + "value": normalized_period, + "start": start.isoformat() if start else "", + "end": end.isoformat() if end else "", + }, + "overview": { + "records": summary["count"], + "hours": summary["total_hours"], + "duration": summary["total_duration"], + "students": len(summary["students"]), + "teachers": len(summary["teachers"]), + "subjects": len(summary["subjects"]), + }, + "teaching": { + "daily": [ + {"date": key, "hours": round(value, 2), "duration": duration_text_from_hours(value)} + for key, value in sorted(daily.items()) + ], + "teachers": top_duration_items(summary["teachers"]), + "subjects": top_duration_items(summary["subjects"]), + "students": top_duration_items(summary["students"]), + }, + "accounts": { + "summary": account_summary(accounts), + "low_remaining": [ + { + "student": account.student, + "student_id": account.student_id, + "remaining": account.remaining, + "remaining_duration": duration_text_from_hours(account.remaining), + "status": account.account_status, + } + for account in low_remaining + ], + }, + "teachers": { + "total": len(teachers), + "active": sum(1 for teacher in teachers if teacher.status == "在岗"), + "inactive": sum(1 for teacher in teachers if teacher.status != "在岗"), + }, + "course_summaries": dashboard_course_summary_statuses(summaries_root, records), + "tasks": dashboard_task_summary(tasks_path), + "logs": logs, + } + + def filter_accounts(accounts: list[Account], keyword: str = "", status: str = "") -> list[Account]: keyword = keyword.strip() status = status.strip() diff --git a/app/app/routers/admin.py b/app/app/routers/admin.py index c4ad8a3..a134de8 100644 --- a/app/app/routers/admin.py +++ b/app/app/routers/admin.py @@ -16,6 +16,7 @@ from ..config import ( write_lock, ) from ..data import ( + admin_dashboard_summary, append_operation_log, approve_admin_task, create_course_summary_duplicate_review_tasks, @@ -48,6 +49,22 @@ def rollback_backup_paths() -> list[Path]: ] +@router.get("/api/admin/dashboard") +def admin_dashboard(period: str = Query("month"), _user: str = Depends(verify_admin_auth)): + try: + return admin_dashboard_summary( + classnotes_path=CLASSNOTES_PATH, + accounts_path=ACCOUNTS_PATH, + teachers_path=TEACHERS_PATH, + tasks_path=ADMIN_TASKS_PATH, + summaries_root=COURSE_SUMMARIES_ROOT, + operation_logs_path=OPERATION_LOGS_PATH, + period=period, + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + @router.get("/api/admin/tasks") def admin_tasks( status_filter: str = Query("", alias="status"), diff --git a/app/app/static/admin.html b/app/app/static/admin.html index 00f05da..d7f83b3 100644 --- a/app/app/static/admin.html +++ b/app/app/static/admin.html @@ -4,7 +4,7 @@