增加管理后台仪表盘

This commit is contained in:
Codex
2026-06-20 20:32:27 +08:00
parent 253c414e8f
commit da368100db
5 changed files with 750 additions and 5 deletions
+146
View File
@@ -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()
+17
View File
@@ -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"),
+21 -4
View File
@@ -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
View File
@@ -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();
+259
View File
@@ -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;
} }