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 @@ 管理后台 - +
@@ -21,7 +21,8 @@
-
+
+
+

仪表盘

+
+ + + + +
+
+
+
+
正在读取仪表盘数据
+
+
+ +
- + diff --git a/app/app/static/admin.js b/app/app/static/admin.js index eacf178..c32f77d 100644 --- a/app/app/static/admin.js +++ b/app/app/static/admin.js @@ -1,6 +1,7 @@ const adminHealthText = document.querySelector("#adminHealthText"); const refreshBtn = document.querySelector("#refreshBtn"); const panels = { + dashboard: document.querySelector("#dashboardPanel"), accounts: document.querySelector("#accountsPanel"), teachers: document.querySelector("#teachersPanel"), reviews: document.querySelector("#reviewsPanel"), @@ -9,6 +10,9 @@ const panels = { logs: document.querySelector("#logsPanel"), register: document.querySelector("#registerPanel"), }; +const dashboardMeta = document.querySelector("#dashboardMeta"); +const dashboardContent = document.querySelector("#dashboardContent"); +const dashboardPeriodBtns = Array.from(document.querySelectorAll(".dashboard-period")); const accountForm = document.querySelector("#accountForm"); const accountQuery = document.querySelector("#accountQuery"); const accountStatus = document.querySelector("#accountStatus"); @@ -108,6 +112,7 @@ const summaryRegisterStatus = document.querySelector("#summaryRegisterStatus"); const summaryRegisterConfirm = document.querySelector("#summaryRegisterConfirm"); let currentAccounts = []; +let currentDashboardPeriod = "month"; let editingAccountId = ""; let currentTeachers = []; let editingTeacherId = ""; @@ -156,6 +161,232 @@ function metric(label, value) { return `
${escapeHtml(label)}${escapeHtml(value)}
`; } +function dashboardPeriodLabel(period) { + if (period === "today") return "今日"; + if (period === "7d") return "近7日"; + if (period === "all") return "全部"; + return "本月"; +} + +function dashboardRangeText(period) { + if (!period) return ""; + if (period.value === "all") return "全部数据"; + if (period.start && period.end && period.start !== period.end) return `${period.start} 至 ${period.end}`; + return period.start || period.end || dashboardPeriodLabel(period.value); +} + +function dashboardTaskTypeLabel(type) { + if (type === "course_summary_review") return "课程小结审核"; + if (type === "course_summary_duplicate_review") return "重复小结"; + if (type === "class_record_correction") return "纠错审核"; + if (type === "class_record_deletion") return "删除审核"; + return type || "其他任务"; +} + +function renderDashboardEmpty(text) { + return `
${escapeHtml(text)}
`; +} + +function renderDashboardList(items, renderItem, emptyText) { + const rows = Array.isArray(items) ? items : []; + if (!rows.length) return renderDashboardEmpty(emptyText); + return `
${rows.map(renderItem).join("")}
`; +} + +function renderDashboardBarList(items, emptyText) { + const rows = Array.isArray(items) ? items : []; + if (!rows.length) return renderDashboardEmpty(emptyText); + const max = Math.max(...rows.map((item) => Number(item.hours || 0)), 0); + return `
${rows + .map((item) => { + const hours = Number(item.hours || 0); + const width = max > 0 ? Math.max(6, Math.round((hours / max) * 100)) : 0; + return `
+
${escapeHtml(item.name || "")}
+
+
${escapeHtml(item.duration || fmtHours(hours))}
+
`; + }) + .join("")}
`; +} + +function renderDashboardDaily(items) { + const rows = Array.isArray(items) ? items : []; + if (!rows.length) return renderDashboardEmpty("当前周期没有课程记录"); + const shown = rows.slice(-14); + return renderDashboardBarList(shown.map((item) => ({ + name: item.date, + hours: item.hours, + duration: item.duration, + })), "当前周期没有课程记录"); +} + +function renderDashboardActions(actions) { + return `
${actions.filter(Boolean).join("")}
`; +} + +function dashboardAction(label, attrs = {}) { + const attrText = Object.entries(attrs) + .map(([key, value]) => ` ${key}="${escapeHtml(value)}"`) + .join(""); + return ``; +} + +function renderDashboard(data) { + const overview = data.overview || {}; + const accounts = data.accounts || {}; + const accountSummary = accounts.summary || {}; + const teachers = data.teachers || {}; + const summaries = data.course_summaries || {}; + const tasks = data.tasks || {}; + const teaching = data.teaching || {}; + const period = data.period || {}; + dashboardMeta.innerHTML = [ + metric("统计周期", dashboardRangeText(period)), + metric("课次", `${overview.records || 0} 条`), + metric("课时", overview.duration || fmtHours(overview.hours || 0)), + metric("参与学生", `${overview.students || 0} 人`), + metric("授课老师", `${overview.teachers || 0} 位`), + metric("科目", `${overview.subjects || 0} 个`), + ].join(""); + const taskTypeRows = Object.entries(tasks.by_type || {}).map(([type, count]) => ({ type, count })); + dashboardContent.innerHTML = ` +
+
+

教学数据

+ ${renderDashboardActions([ + dashboardAction("查看课程记录", { "data-dashboard-action": "public-records" }), + dashboardAction("登记课程", { "data-dashboard-tab": "register" }), + ])} +
+
+
+

老师课时排行

+ ${renderDashboardBarList(teaching.teachers, "当前周期没有老师课时")} +
+
+

科目课时排行

+ ${renderDashboardBarList(teaching.subjects, "当前周期没有科目课时")} +
+
+

学生上课排行

+ ${renderDashboardBarList(teaching.students, "当前周期没有学生课时")} +
+
+

每日课时

+ ${renderDashboardDaily(teaching.daily)} +
+
+
+
+
+

账户与老师

+ ${renderDashboardActions([ + dashboardAction("欠费账户", { "data-dashboard-tab": "accounts", "data-account-status": "欠费" }), + dashboardAction("预警账户", { "data-dashboard-tab": "accounts", "data-account-status": "预警" }), + dashboardAction("老师档案", { "data-dashboard-tab": "teachers" }), + ])} +
+
+
+

账户概况

+
+ ${metric("总账户", `${accountSummary.total || 0} 人`)} + ${metric("正常", accountSummary.normal || 0)} + ${metric("预警", accountSummary.warning || 0)} + ${metric("欠费", accountSummary.debt || 0)} + ${metric("结课", accountSummary.completed || 0)} + ${metric("退费", accountSummary.refunded || 0)} +
+
+
+

低剩余课时

+ ${renderDashboardList(accounts.low_remaining, (item) => ` + + `, "没有需要重点关注的账户")} +
+
+

老师概况

+
+ ${metric("老师总数", `${teachers.total || 0} 位`)} + ${metric("在岗", `${teachers.active || 0} 位`)} + ${metric("离职", `${teachers.inactive || 0} 位`)} +
+
+
+
+
+
+

课程小结

+ ${renderDashboardActions([ + dashboardAction("未绑定", { "data-dashboard-tab": "summarySearch", "data-summary-binding": "unmatched" }), + dashboardAction("缺时间", { "data-dashboard-tab": "summarySearch", "data-summary-binding": "missing_time" }), + dashboardAction("字段不一致", { "data-dashboard-tab": "summarySearch", "data-summary-binding": "mismatch", "data-summary-candidate": "true" }), + dashboardAction("扫描重复", { "data-dashboard-action": "duplicate-scan" }), + ])} +
+
+
+

绑定状态

+
+ ${metric("小结总数", `${summaries.total || 0} 条`)} + ${metric("已绑定", summaries.matched || 0)} + ${metric("自动绑定", summaries.auto_bound || 0)} + ${metric("未绑定", summaries.unmatched || 0)} + ${metric("缺时间", summaries.missing_time || 0)} + ${metric("字段不一致", summaries.mismatch || 0)} + ${metric("有候选", summaries.with_candidate || 0)} +
+
+
+

待处理任务

+
+ ${metric("待处理", `${tasks.active || 0} 条`)} + ${metric("待审核", tasks.pending || 0)} + ${metric("冲突", tasks.conflict || 0)} +
+ ${renderDashboardList(taskTypeRows, (item) => ` + + `, "暂无待处理任务")} +
+
+

最近操作

+ ${renderDashboardList(data.logs, (item) => ` + + `, "暂无操作记录")} +
+
+
+ `; +} + +async function loadDashboard() { + dashboardContent.innerHTML = `
正在读取仪表盘数据
`; + dashboardPeriodBtns.forEach((button) => { + button.classList.toggle("is-active", button.dataset.dashboardPeriod === currentDashboardPeriod); + }); + try { + const data = await fetchJson(`/api/admin/dashboard?${new URLSearchParams({ period: currentDashboardPeriod }).toString()}`); + renderDashboard(data); + } catch (error) { + dashboardMeta.innerHTML = ""; + dashboardContent.innerHTML = `
读取失败:${escapeHtml(error.message)}
`; + } +} + function statusClass(status) { if (status === "欠费") return "debt"; if (status === "预警") return "warning"; @@ -198,6 +429,7 @@ function setActiveTab(tabName) { Object.entries(panels).forEach(([name, panel]) => { panel.hidden = name !== activeTabName; }); + if (activeTabName === "dashboard") loadDashboard(); if (activeTabName === "accounts") loadAccounts(); if (activeTabName === "teachers") loadTeachers(); if (activeTabName === "reviews") loadReviews(); @@ -1774,10 +2006,83 @@ async function confirmRegister(type, statusNode, url, onSuccess) { } } +function resetAccountFilters() { + accountQuery.value = ""; + accountStatus.value = ""; +} + +function resetSummarySearchFilters() { + summarySearchQuery.value = ""; + summarySearchStudent.value = ""; + summarySearchTeacher.value = ""; + summarySearchSubject.value = ""; + summarySearchDateFrom.value = ""; + summarySearchDateTo.value = ""; + summarySearchBindingStatus.value = ""; + summarySearchHasCandidate.value = ""; + quickSummaryMismatchMode = false; +} + +function openDashboardTarget(button) { + const targetTab = button.dataset.dashboardTab; + if (targetTab === "accounts") { + resetAccountFilters(); + if (button.dataset.accountStatus !== undefined) accountStatus.value = button.dataset.accountStatus; + if (button.dataset.accountQuery !== undefined) accountQuery.value = button.dataset.accountQuery; + setActiveTab("accounts"); + return; + } + if (targetTab === "summarySearch") { + resetSummarySearchFilters(); + if (button.dataset.summaryBinding !== undefined) summarySearchBindingStatus.value = button.dataset.summaryBinding; + if (button.dataset.summaryCandidate !== undefined) summarySearchHasCandidate.value = button.dataset.summaryCandidate; + summaryReviewStatus.value = "pending"; + setActiveTab("summarySearch"); + return; + } + if (targetTab === "reviews") { + reviewStatus.value = "pending"; + setActiveTab("reviews"); + return; + } + if (targetTab === "logs") { + logStatus.value = button.dataset.logStatus || ""; + logOperation.value = ""; + logStudent.value = ""; + setActiveTab("logs"); + return; + } + if (targetTab) { + setActiveTab(targetTab); + } +} + document.querySelectorAll("[data-admin-tab]").forEach((button) => { button.addEventListener("click", () => setActiveTab(button.dataset.adminTab)); }); +dashboardPeriodBtns.forEach((button) => { + button.addEventListener("click", () => { + currentDashboardPeriod = button.dataset.dashboardPeriod || "month"; + loadDashboard(); + }); +}); + +dashboardContent.addEventListener("click", (event) => { + const actionButton = event.target.closest("[data-dashboard-action]"); + if (actionButton && actionButton.dataset.dashboardAction === "public-records") { + window.location.href = "/"; + return; + } + if (actionButton && actionButton.dataset.dashboardAction === "duplicate-scan") { + scanDuplicateSummaries(); + return; + } + const jumpButton = event.target.closest(".dashboard-jump, .dashboard-list-row"); + if (!jumpButton) return; + openDashboardTarget(jumpButton); +}); + accountForm.addEventListener("submit", (event) => { event.preventDefault(); loadAccounts(); @@ -2026,6 +2331,7 @@ summaryRegisterConfirm.addEventListener("click", () => { }); refreshBtn.addEventListener("click", () => { loadAdminHealth(); + if (!panels.dashboard.hidden) loadDashboard(); if (!panels.accounts.hidden) loadAccounts(); if (!panels.reviews.hidden) loadReviews(); if (!panels.summarySearch.hidden) refreshSummaryWorkspace({ logs: false }); @@ -2034,4 +2340,4 @@ refreshBtn.addEventListener("click", () => { loadAdminHealth(); updateQuickMismatchButton(); -loadAccounts(); +loadDashboard(); diff --git a/app/app/static/styles.css b/app/app/static/styles.css index 23d7e55..b56d588 100644 --- a/app/app/static/styles.css +++ b/app/app/static/styles.css @@ -270,6 +270,216 @@ select:focus { border-bottom: 1px solid var(--line); } +.dashboard-periods .chip { + border: 1px solid transparent; +} + +.dashboard-periods .chip.is-active { + border-color: var(--accent); + background: #e9f5f3; + color: var(--accent-strong); +} + +.dashboard-content { + display: grid; + gap: 18px; + padding: 16px; +} + +.dashboard-loading { + padding: 28px 16px; + color: var(--muted); + text-align: center; +} + +.dashboard-loading.is-error { + color: var(--danger); + font-weight: 700; +} + +.dashboard-section { + display: grid; + gap: 12px; + padding-bottom: 18px; + border-bottom: 1px solid var(--line); +} + +.dashboard-section:last-child { + padding-bottom: 0; + border-bottom: 0; +} + +.dashboard-section-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.dashboard-section-head h3, +.dashboard-card h4 { + margin: 0; + line-height: 1.3; + letter-spacing: 0; +} + +.dashboard-section-head h3 { + font-size: 17px; +} + +.dashboard-card h4 { + font-size: 14px; +} + +.dashboard-actions { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 8px; +} + +.dashboard-grid { + display: grid; + gap: 12px; +} + +.dashboard-grid.two { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.dashboard-grid.three { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.dashboard-grid.four { + grid-template-columns: repeat(4, minmax(0, 1fr)); +} + +.dashboard-card { + min-width: 0; + padding: 14px; + border: 1px solid var(--line); + border-radius: 6px; + background: #fbfcfd; +} + +.dashboard-card h4 { + margin-bottom: 12px; + color: #344054; +} + +.dashboard-mini-metrics { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px; +} + +.dashboard-mini-metrics .metric { + padding: 8px; + background: #fff; +} + +.dashboard-mini-metrics .metric strong { + font-size: 16px; +} + +.dashboard-list, +.dashboard-bar-list { + display: grid; + gap: 8px; +} + +.dashboard-empty { + min-height: 40px; + display: grid; + place-items: center; + padding: 10px; + border: 1px dashed var(--line); + border-radius: 6px; + color: var(--muted); + font-size: 13px; + text-align: center; +} + +.dashboard-list-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto auto; + align-items: center; + gap: 10px; + width: 100%; + min-height: 46px; + padding: 8px 10px; + border: 1px solid var(--line); + border-radius: 6px; + background: #fff; + color: var(--text); + cursor: pointer; + text-align: left; +} + +.dashboard-list-row:hover { + border-color: var(--accent); +} + +.dashboard-list-row strong, +.dashboard-list-row small { + display: block; + min-width: 0; + overflow-wrap: anywhere; +} + +.dashboard-list-row small { + margin-top: 2px; + color: var(--muted); + font-size: 12px; +} + +.dashboard-list-value { + color: #344054; + font-size: 13px; + font-weight: 700; + white-space: nowrap; +} + +.log-summary-row { + grid-template-columns: minmax(0, 1fr) auto; +} + +.dashboard-bar-row { + display: grid; + grid-template-columns: minmax(72px, 0.75fr) minmax(80px, 1fr) minmax(72px, auto); + align-items: center; + gap: 8px; + min-height: 28px; +} + +.dashboard-bar-label, +.dashboard-bar-value { + overflow-wrap: anywhere; + color: #344054; + font-size: 13px; + line-height: 1.35; +} + +.dashboard-bar-value { + text-align: right; + white-space: nowrap; +} + +.dashboard-bar-track { + height: 8px; + overflow: hidden; + border-radius: 999px; + background: #eef2f6; +} + +.dashboard-bar-track span { + display: block; + height: 100%; + border-radius: inherit; + background: #0f766e; +} + .metric { min-width: 0; padding: 10px; @@ -1631,6 +1841,11 @@ td { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .dashboard-grid.four, + .dashboard-grid.three { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .admin-tabs { padding-bottom: 6px; } @@ -1690,6 +1905,7 @@ td { .search-row, .section-head, .summary-grid, + .dashboard-content, .inline-account, .admin-form, .correction-toolbar, @@ -1705,6 +1921,26 @@ td { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .dashboard-section-head { + align-items: flex-start; + flex-direction: column; + } + + .dashboard-actions { + width: 100%; + justify-content: flex-start; + } + + .dashboard-grid.two, + .dashboard-grid.three, + .dashboard-grid.four { + grid-template-columns: 1fr; + } + + .dashboard-mini-metrics { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .correction-toolbar { align-items: flex-start; flex-direction: column; @@ -1856,6 +2092,29 @@ td { grid-template-columns: 1fr; } + .dashboard-mini-metrics { + grid-template-columns: 1fr; + } + + .dashboard-list-row { + grid-template-columns: 1fr; + align-items: flex-start; + } + + .dashboard-list-value { + white-space: normal; + } + + .dashboard-bar-row { + grid-template-columns: 1fr; + gap: 5px; + } + + .dashboard-bar-value { + text-align: left; + white-space: normal; + } + .metric strong { font-size: 16px; }