增加管理后台仪表盘
This commit is contained in:
+146
@@ -4756,6 +4756,152 @@ def account_summary(accounts: list[Account]) -> dict:
|
|||||||
return result
|
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]:
|
def filter_accounts(accounts: list[Account], keyword: str = "", status: str = "") -> list[Account]:
|
||||||
keyword = keyword.strip()
|
keyword = keyword.strip()
|
||||||
status = status.strip()
|
status = status.strip()
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ from ..config import (
|
|||||||
write_lock,
|
write_lock,
|
||||||
)
|
)
|
||||||
from ..data import (
|
from ..data import (
|
||||||
|
admin_dashboard_summary,
|
||||||
append_operation_log,
|
append_operation_log,
|
||||||
approve_admin_task,
|
approve_admin_task,
|
||||||
create_course_summary_duplicate_review_tasks,
|
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")
|
@router.get("/api/admin/tasks")
|
||||||
def admin_tasks(
|
def admin_tasks(
|
||||||
status_filter: str = Query("", alias="status"),
|
status_filter: str = Query("", alias="status"),
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title>管理后台</title>
|
<title>管理后台</title>
|
||||||
<link rel="stylesheet" href="/static/styles.css?v=20260620-summary-workspace" />
|
<link rel="stylesheet" href="/static/styles.css?v=20260620-dashboard" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
@@ -21,7 +21,8 @@
|
|||||||
|
|
||||||
<main class="layout account-layout admin-layout">
|
<main class="layout account-layout admin-layout">
|
||||||
<nav class="admin-tabs" aria-label="管理后台功能">
|
<nav class="admin-tabs" aria-label="管理后台功能">
|
||||||
<button class="admin-tab is-active" data-admin-tab="accounts" type="button">课时账户</button>
|
<button class="admin-tab is-active" data-admin-tab="dashboard" type="button">仪表盘</button>
|
||||||
|
<button class="admin-tab" data-admin-tab="accounts" type="button">课时账户</button>
|
||||||
<button class="admin-tab" data-admin-tab="teachers" type="button">老师档案</button>
|
<button class="admin-tab" data-admin-tab="teachers" type="button">老师档案</button>
|
||||||
<button class="admin-tab" data-admin-tab="reviews" type="button">纠错审核</button>
|
<button class="admin-tab" data-admin-tab="reviews" type="button">纠错审核</button>
|
||||||
<button class="admin-tab" data-admin-tab="summarySearch" type="button">课程小结</button>
|
<button class="admin-tab" data-admin-tab="summarySearch" type="button">课程小结</button>
|
||||||
@@ -29,7 +30,23 @@
|
|||||||
<button class="admin-tab" data-admin-tab="register" type="button">登记</button>
|
<button class="admin-tab" data-admin-tab="register" type="button">登记</button>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<section id="accountsPanel" class="panel admin-panel">
|
<section id="dashboardPanel" class="panel admin-panel">
|
||||||
|
<div class="section-head">
|
||||||
|
<h2>仪表盘</h2>
|
||||||
|
<div class="quick-actions dashboard-periods" role="group" aria-label="统计周期">
|
||||||
|
<button class="chip dashboard-period" data-dashboard-period="today" type="button">今日</button>
|
||||||
|
<button class="chip dashboard-period" data-dashboard-period="7d" type="button">近7日</button>
|
||||||
|
<button class="chip dashboard-period is-active" data-dashboard-period="month" type="button">本月</button>
|
||||||
|
<button class="chip dashboard-period" data-dashboard-period="all" type="button">全部</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="dashboardMeta" class="summary-grid"></div>
|
||||||
|
<div id="dashboardContent" class="dashboard-content">
|
||||||
|
<div class="dashboard-loading">正在读取仪表盘数据</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="accountsPanel" class="panel admin-panel" hidden>
|
||||||
<div class="section-head">
|
<div class="section-head">
|
||||||
<h2>课时账户</h2>
|
<h2>课时账户</h2>
|
||||||
<div class="quick-actions">
|
<div class="quick-actions">
|
||||||
@@ -469,6 +486,6 @@
|
|||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<script src="/static/admin.js?v=20260620-course-summary-auto-bind"></script>
|
<script src="/static/admin.js?v=20260620-dashboard"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+307
-1
@@ -1,6 +1,7 @@
|
|||||||
const adminHealthText = document.querySelector("#adminHealthText");
|
const adminHealthText = document.querySelector("#adminHealthText");
|
||||||
const refreshBtn = document.querySelector("#refreshBtn");
|
const refreshBtn = document.querySelector("#refreshBtn");
|
||||||
const panels = {
|
const panels = {
|
||||||
|
dashboard: document.querySelector("#dashboardPanel"),
|
||||||
accounts: document.querySelector("#accountsPanel"),
|
accounts: document.querySelector("#accountsPanel"),
|
||||||
teachers: document.querySelector("#teachersPanel"),
|
teachers: document.querySelector("#teachersPanel"),
|
||||||
reviews: document.querySelector("#reviewsPanel"),
|
reviews: document.querySelector("#reviewsPanel"),
|
||||||
@@ -9,6 +10,9 @@ const panels = {
|
|||||||
logs: document.querySelector("#logsPanel"),
|
logs: document.querySelector("#logsPanel"),
|
||||||
register: document.querySelector("#registerPanel"),
|
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 accountForm = document.querySelector("#accountForm");
|
||||||
const accountQuery = document.querySelector("#accountQuery");
|
const accountQuery = document.querySelector("#accountQuery");
|
||||||
const accountStatus = document.querySelector("#accountStatus");
|
const accountStatus = document.querySelector("#accountStatus");
|
||||||
@@ -108,6 +112,7 @@ const summaryRegisterStatus = document.querySelector("#summaryRegisterStatus");
|
|||||||
const summaryRegisterConfirm = document.querySelector("#summaryRegisterConfirm");
|
const summaryRegisterConfirm = document.querySelector("#summaryRegisterConfirm");
|
||||||
|
|
||||||
let currentAccounts = [];
|
let currentAccounts = [];
|
||||||
|
let currentDashboardPeriod = "month";
|
||||||
let editingAccountId = "";
|
let editingAccountId = "";
|
||||||
let currentTeachers = [];
|
let currentTeachers = [];
|
||||||
let editingTeacherId = "";
|
let editingTeacherId = "";
|
||||||
@@ -156,6 +161,232 @@ function metric(label, value) {
|
|||||||
return `<div class="metric"><span>${escapeHtml(label)}</span><strong>${escapeHtml(value)}</strong></div>`;
|
return `<div class="metric"><span>${escapeHtml(label)}</span><strong>${escapeHtml(value)}</strong></div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 `<div class="dashboard-empty">${escapeHtml(text)}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDashboardList(items, renderItem, emptyText) {
|
||||||
|
const rows = Array.isArray(items) ? items : [];
|
||||||
|
if (!rows.length) return renderDashboardEmpty(emptyText);
|
||||||
|
return `<div class="dashboard-list">${rows.map(renderItem).join("")}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 `<div class="dashboard-bar-list">${rows
|
||||||
|
.map((item) => {
|
||||||
|
const hours = Number(item.hours || 0);
|
||||||
|
const width = max > 0 ? Math.max(6, Math.round((hours / max) * 100)) : 0;
|
||||||
|
return `<div class="dashboard-bar-row">
|
||||||
|
<div class="dashboard-bar-label">${escapeHtml(item.name || "")}</div>
|
||||||
|
<div class="dashboard-bar-track"><span style="width: ${width}%"></span></div>
|
||||||
|
<div class="dashboard-bar-value">${escapeHtml(item.duration || fmtHours(hours))}</div>
|
||||||
|
</div>`;
|
||||||
|
})
|
||||||
|
.join("")}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 `<div class="dashboard-actions">${actions.filter(Boolean).join("")}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dashboardAction(label, attrs = {}) {
|
||||||
|
const attrText = Object.entries(attrs)
|
||||||
|
.map(([key, value]) => ` ${key}="${escapeHtml(value)}"`)
|
||||||
|
.join("");
|
||||||
|
return `<button class="small-button dashboard-jump" type="button"${attrText}>${escapeHtml(label)}</button>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = `
|
||||||
|
<section class="dashboard-section">
|
||||||
|
<div class="dashboard-section-head">
|
||||||
|
<h3>教学数据</h3>
|
||||||
|
${renderDashboardActions([
|
||||||
|
dashboardAction("查看课程记录", { "data-dashboard-action": "public-records" }),
|
||||||
|
dashboardAction("登记课程", { "data-dashboard-tab": "register" }),
|
||||||
|
])}
|
||||||
|
</div>
|
||||||
|
<div class="dashboard-grid four">
|
||||||
|
<article class="dashboard-card">
|
||||||
|
<h4>老师课时排行</h4>
|
||||||
|
${renderDashboardBarList(teaching.teachers, "当前周期没有老师课时")}
|
||||||
|
</article>
|
||||||
|
<article class="dashboard-card">
|
||||||
|
<h4>科目课时排行</h4>
|
||||||
|
${renderDashboardBarList(teaching.subjects, "当前周期没有科目课时")}
|
||||||
|
</article>
|
||||||
|
<article class="dashboard-card">
|
||||||
|
<h4>学生上课排行</h4>
|
||||||
|
${renderDashboardBarList(teaching.students, "当前周期没有学生课时")}
|
||||||
|
</article>
|
||||||
|
<article class="dashboard-card">
|
||||||
|
<h4>每日课时</h4>
|
||||||
|
${renderDashboardDaily(teaching.daily)}
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section class="dashboard-section">
|
||||||
|
<div class="dashboard-section-head">
|
||||||
|
<h3>账户与老师</h3>
|
||||||
|
${renderDashboardActions([
|
||||||
|
dashboardAction("欠费账户", { "data-dashboard-tab": "accounts", "data-account-status": "欠费" }),
|
||||||
|
dashboardAction("预警账户", { "data-dashboard-tab": "accounts", "data-account-status": "预警" }),
|
||||||
|
dashboardAction("老师档案", { "data-dashboard-tab": "teachers" }),
|
||||||
|
])}
|
||||||
|
</div>
|
||||||
|
<div class="dashboard-grid two">
|
||||||
|
<article class="dashboard-card dashboard-metrics-card">
|
||||||
|
<h4>账户概况</h4>
|
||||||
|
<div class="dashboard-mini-metrics">
|
||||||
|
${metric("总账户", `${accountSummary.total || 0} 人`)}
|
||||||
|
${metric("正常", accountSummary.normal || 0)}
|
||||||
|
${metric("预警", accountSummary.warning || 0)}
|
||||||
|
${metric("欠费", accountSummary.debt || 0)}
|
||||||
|
${metric("结课", accountSummary.completed || 0)}
|
||||||
|
${metric("退费", accountSummary.refunded || 0)}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
<article class="dashboard-card">
|
||||||
|
<h4>低剩余课时</h4>
|
||||||
|
${renderDashboardList(accounts.low_remaining, (item) => `
|
||||||
|
<button class="dashboard-list-row" type="button" data-dashboard-tab="accounts" data-account-query="${escapeHtml(item.student || "")}">
|
||||||
|
<span><strong>${escapeHtml(item.student || "")}</strong><small>${escapeHtml(item.student_id || "")}</small></span>
|
||||||
|
<span class="dashboard-list-value">${escapeHtml(item.remaining_duration || fmtHours(item.remaining || 0))}</span>
|
||||||
|
<span class="status ${statusClass(item.status)}">${escapeHtml(item.status || "")}</span>
|
||||||
|
</button>
|
||||||
|
`, "没有需要重点关注的账户")}
|
||||||
|
</article>
|
||||||
|
<article class="dashboard-card dashboard-metrics-card">
|
||||||
|
<h4>老师概况</h4>
|
||||||
|
<div class="dashboard-mini-metrics">
|
||||||
|
${metric("老师总数", `${teachers.total || 0} 位`)}
|
||||||
|
${metric("在岗", `${teachers.active || 0} 位`)}
|
||||||
|
${metric("离职", `${teachers.inactive || 0} 位`)}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section class="dashboard-section">
|
||||||
|
<div class="dashboard-section-head">
|
||||||
|
<h3>课程小结</h3>
|
||||||
|
${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" }),
|
||||||
|
])}
|
||||||
|
</div>
|
||||||
|
<div class="dashboard-grid three">
|
||||||
|
<article class="dashboard-card dashboard-metrics-card">
|
||||||
|
<h4>绑定状态</h4>
|
||||||
|
<div class="dashboard-mini-metrics">
|
||||||
|
${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)}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
<article class="dashboard-card dashboard-metrics-card">
|
||||||
|
<h4>待处理任务</h4>
|
||||||
|
<div class="dashboard-mini-metrics">
|
||||||
|
${metric("待处理", `${tasks.active || 0} 条`)}
|
||||||
|
${metric("待审核", tasks.pending || 0)}
|
||||||
|
${metric("冲突", tasks.conflict || 0)}
|
||||||
|
</div>
|
||||||
|
${renderDashboardList(taskTypeRows, (item) => `
|
||||||
|
<button class="dashboard-list-row" type="button" data-dashboard-tab="${String(item.type).startsWith("course_summary") ? "summarySearch" : "reviews"}" data-task-type="${escapeHtml(item.type)}">
|
||||||
|
<span><strong>${escapeHtml(dashboardTaskTypeLabel(item.type))}</strong></span>
|
||||||
|
<span class="dashboard-list-value">${escapeHtml(item.count)} 条</span>
|
||||||
|
</button>
|
||||||
|
`, "暂无待处理任务")}
|
||||||
|
</article>
|
||||||
|
<article class="dashboard-card">
|
||||||
|
<h4>最近操作</h4>
|
||||||
|
${renderDashboardList(data.logs, (item) => `
|
||||||
|
<button class="dashboard-list-row log-summary-row" type="button" data-dashboard-tab="logs" data-log-status="${escapeHtml(item.status || "")}">
|
||||||
|
<span>
|
||||||
|
<strong>${escapeHtml(item.operation || "")}</strong>
|
||||||
|
<small>${escapeHtml(item.created_at || "")}${item.student ? ` · ${escapeHtml(item.student)}` : ""}</small>
|
||||||
|
</span>
|
||||||
|
<span class="status ${taskStatusClass(item.status)}">${escapeHtml(item.status || "")}</span>
|
||||||
|
</button>
|
||||||
|
`, "暂无操作记录")}
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadDashboard() {
|
||||||
|
dashboardContent.innerHTML = `<div class="dashboard-loading">正在读取仪表盘数据</div>`;
|
||||||
|
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 = `<div class="dashboard-loading is-error">读取失败:${escapeHtml(error.message)}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function statusClass(status) {
|
function statusClass(status) {
|
||||||
if (status === "欠费") return "debt";
|
if (status === "欠费") return "debt";
|
||||||
if (status === "预警") return "warning";
|
if (status === "预警") return "warning";
|
||||||
@@ -198,6 +429,7 @@ function setActiveTab(tabName) {
|
|||||||
Object.entries(panels).forEach(([name, panel]) => {
|
Object.entries(panels).forEach(([name, panel]) => {
|
||||||
panel.hidden = name !== activeTabName;
|
panel.hidden = name !== activeTabName;
|
||||||
});
|
});
|
||||||
|
if (activeTabName === "dashboard") loadDashboard();
|
||||||
if (activeTabName === "accounts") loadAccounts();
|
if (activeTabName === "accounts") loadAccounts();
|
||||||
if (activeTabName === "teachers") loadTeachers();
|
if (activeTabName === "teachers") loadTeachers();
|
||||||
if (activeTabName === "reviews") loadReviews();
|
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) => {
|
document.querySelectorAll("[data-admin-tab]").forEach((button) => {
|
||||||
button.addEventListener("click", () => setActiveTab(button.dataset.adminTab));
|
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) => {
|
accountForm.addEventListener("submit", (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
loadAccounts();
|
loadAccounts();
|
||||||
@@ -2026,6 +2331,7 @@ summaryRegisterConfirm.addEventListener("click", () => {
|
|||||||
});
|
});
|
||||||
refreshBtn.addEventListener("click", () => {
|
refreshBtn.addEventListener("click", () => {
|
||||||
loadAdminHealth();
|
loadAdminHealth();
|
||||||
|
if (!panels.dashboard.hidden) loadDashboard();
|
||||||
if (!panels.accounts.hidden) loadAccounts();
|
if (!panels.accounts.hidden) loadAccounts();
|
||||||
if (!panels.reviews.hidden) loadReviews();
|
if (!panels.reviews.hidden) loadReviews();
|
||||||
if (!panels.summarySearch.hidden) refreshSummaryWorkspace({ logs: false });
|
if (!panels.summarySearch.hidden) refreshSummaryWorkspace({ logs: false });
|
||||||
@@ -2034,4 +2340,4 @@ refreshBtn.addEventListener("click", () => {
|
|||||||
|
|
||||||
loadAdminHealth();
|
loadAdminHealth();
|
||||||
updateQuickMismatchButton();
|
updateQuickMismatchButton();
|
||||||
loadAccounts();
|
loadDashboard();
|
||||||
|
|||||||
@@ -270,6 +270,216 @@ select:focus {
|
|||||||
border-bottom: 1px solid var(--line);
|
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 {
|
.metric {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
@@ -1631,6 +1841,11 @@ td {
|
|||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dashboard-grid.four,
|
||||||
|
.dashboard-grid.three {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
.admin-tabs {
|
.admin-tabs {
|
||||||
padding-bottom: 6px;
|
padding-bottom: 6px;
|
||||||
}
|
}
|
||||||
@@ -1690,6 +1905,7 @@ td {
|
|||||||
.search-row,
|
.search-row,
|
||||||
.section-head,
|
.section-head,
|
||||||
.summary-grid,
|
.summary-grid,
|
||||||
|
.dashboard-content,
|
||||||
.inline-account,
|
.inline-account,
|
||||||
.admin-form,
|
.admin-form,
|
||||||
.correction-toolbar,
|
.correction-toolbar,
|
||||||
@@ -1705,6 +1921,26 @@ td {
|
|||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
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 {
|
.correction-toolbar {
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -1856,6 +2092,29 @@ td {
|
|||||||
grid-template-columns: 1fr;
|
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 {
|
.metric strong {
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user