优化记录与后台列表分页展示

This commit is contained in:
Codex
2026-06-25 11:22:31 +08:00
parent 70a5af3020
commit b169cb3d7a
8 changed files with 662 additions and 119 deletions
+49 -8
View File
@@ -108,6 +108,15 @@ def format_teacher_row(teacher: Teacher) -> str:
return f"| {teacher.teacher_id} | {teacher.name} | {teacher.alias} | {subjects} | {teacher.status} | {teacher.note} |"
def paginate_items(items: list, offset: int = 0, limit: int = 100) -> tuple[list, int, bool]:
normalized_offset = max(int(offset or 0), 0)
normalized_limit = max(int(limit or 0), 0)
if normalized_limit <= 0:
return items[normalized_offset:], normalized_offset, False
end = normalized_offset + normalized_limit
return items[normalized_offset:end], normalized_offset, end < len(items)
def parse_payments(text: str) -> list[Payment]:
payments: list[Payment] = []
if not text.strip():
@@ -1073,21 +1082,34 @@ def list_admin_tasks(
status_filter: str = "",
task_type: str = "",
classnotes_path: Path | None = None,
offset: int = 0,
limit: int = 200,
) -> dict:
tasks = read_admin_tasks(tasks_path)
items = tasks["items"]
if status_filter:
items = [item for item in items if item.get("status") == status_filter]
if task_type:
items = [item for item in items if item.get("type") == task_type]
task_types = [item.strip() for item in task_type.split(",") if item.strip()]
if task_types:
items = [item for item in items if str(item.get("type") or "") in task_types]
type_counts: dict[str, int] = defaultdict(int)
for item in items:
type_counts[str(item.get("type") or "")] += 1
sorted_items = sorted(items, key=lambda item: int(item.get("id", 0)), reverse=True)
shown, normalized_offset, has_more = paginate_items(sorted_items, offset, limit)
records = read_classnotes(classnotes_path) if classnotes_path is not None and classnotes_path.exists() else None
return {
"version": tasks["version"],
"next_id": tasks["next_id"],
"count": len(items),
"returned": len(shown),
"offset": normalized_offset,
"limit": limit,
"has_more": has_more,
"type_counts": dict(type_counts),
"items": [
task_to_dict_with_context(item, records)
for item in sorted(items, key=lambda item: int(item.get("id", 0)), reverse=True)
for item in shown
],
}
@@ -2001,6 +2023,7 @@ def rollback_class_record_registration(
def list_operation_logs(
path: Path,
limit: int = 100,
offset: int = 0,
operation: str = "",
status_filter: str = "",
student: str = "",
@@ -2023,9 +2046,16 @@ def list_operation_logs(
if student and student not in str(item.get("student", "")):
continue
rows.append(item)
rows = rows[-limit:]
rows.reverse()
return {"count": len(rows), "items": rows}
shown, normalized_offset, has_more = paginate_items(rows, offset, limit)
return {
"count": len(rows),
"returned": len(shown),
"offset": normalized_offset,
"limit": limit,
"has_more": has_more,
"items": shown,
}
def rollback_operation_log(
@@ -2734,6 +2764,7 @@ def query_course_summaries(
binding_status: str = "",
has_candidate: str = "",
limit: int = 200,
offset: int = 0,
) -> dict:
normalized_from = normalize_filter_date(date_from)
normalized_to = normalize_filter_date(date_to)
@@ -2802,10 +2833,13 @@ def query_course_summaries(
),
reverse=True,
)
limited = matched[:limit]
limited, normalized_offset, has_more = paginate_items(matched, offset, limit)
return {
"count": len(matched),
"returned": len(limited),
"offset": normalized_offset,
"limit": limit,
"has_more": has_more,
"items": limited,
}
@@ -4678,7 +4712,7 @@ def has_filter_condition(spec: QuerySpec) -> bool:
def query_records(records: list[ClassRecord], query: str, limit: int = 200) -> dict:
spec = build_query_spec(query, records)
matched = filter_records(records, spec) if has_filter_condition(spec) else []
shown = matched[:limit] if limit > 0 else matched
shown, normalized_offset, has_more = paginate_items(matched, 0, limit)
return {
"query": {
"raw_query": spec.raw_query,
@@ -4691,6 +4725,9 @@ def query_records(records: list[ClassRecord], query: str, limit: int = 200) -> d
"records": [record_to_dict(record) for record in shown],
"total_records": len(matched),
"shown_records": len(shown),
"offset": normalized_offset,
"limit": limit,
"has_more": has_more,
}
@@ -4699,11 +4736,12 @@ def query_public_records(
teachers: list[Teacher],
query: str,
limit: int = 200,
offset: int = 0,
summaries_root: Path | None = None,
) -> dict:
spec = build_public_query_spec(query, records, teachers)
matched = filter_records(records, spec) if has_filter_condition(spec) else []
shown = matched[:limit] if limit > 0 else matched
shown, normalized_offset, has_more = paginate_items(matched, offset, limit)
display_names = teacher_alias_map(teachers)
summary_index = course_summary_index_for_records(summaries_root, records) if summaries_root is not None else {}
return {
@@ -4718,6 +4756,9 @@ def query_public_records(
"records": [public_record_to_dict(record, teachers, summary_index) for record in shown],
"total_records": len(matched),
"shown_records": len(shown),
"offset": normalized_offset,
"limit": limit,
"has_more": has_more,
}
+10 -2
View File
@@ -69,6 +69,8 @@ def admin_dashboard(period: str = Query("month"), _user: str = Depends(verify_ad
def admin_tasks(
status_filter: str = Query("", alias="status"),
task_type: str = Query("", alias="type"),
limit: int = Query(50, ge=1, le=1000),
offset: int = Query(0, ge=0),
_user: str = Depends(verify_admin_auth),
):
try:
@@ -77,6 +79,8 @@ def admin_tasks(
status_filter=status_filter,
task_type=task_type,
classnotes_path=CLASSNOTES_PATH,
offset=offset,
limit=limit,
)
except ValueError as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
@@ -84,7 +88,8 @@ def admin_tasks(
@router.get("/api/admin/operation-logs")
def admin_operation_logs(
limit: int = Query(100, ge=1, le=500),
limit: int = Query(50, ge=1, le=500),
offset: int = Query(0, ge=0),
operation: str = Query(""),
status_filter: str = Query("", alias="status"),
student: str = Query(""),
@@ -94,6 +99,7 @@ def admin_operation_logs(
return list_operation_logs(
OPERATION_LOGS_PATH,
limit=limit,
offset=offset,
operation=operation,
status_filter=status_filter,
student=student,
@@ -134,7 +140,8 @@ def admin_course_summaries(
missing_time: bool = Query(False),
binding_status: str = Query(""),
has_candidate: str = Query(""),
limit: int = Query(200, ge=1, le=1000),
limit: int = Query(50, ge=1, le=1000),
offset: int = Query(0, ge=0),
_user: str = Depends(verify_admin_auth),
):
try:
@@ -151,6 +158,7 @@ def admin_course_summaries(
binding_status=binding_status,
has_candidate=has_candidate,
limit=limit,
offset=offset,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
+10 -2
View File
@@ -36,10 +36,18 @@ router = APIRouter()
@router.get("/api/records")
def records(
q: str = Query(..., min_length=1, description="自然语言查询,例如:王鑫鹏5月数学课"),
limit: int = Query(200, ge=1, le=1000),
limit: int = Query(30, ge=1, le=1000),
offset: int = Query(0, ge=0),
_user: str = Depends(verify_records_auth),
):
return query_public_records(load_records(), load_teachers(), q, limit=limit, summaries_root=COURSE_SUMMARIES_ROOT)
return query_public_records(
load_records(),
load_teachers(),
q,
limit=limit,
offset=offset,
summaries_root=COURSE_SUMMARIES_ROOT,
)
@router.get("/api/student-account/{student}")
+4
View File
@@ -230,6 +230,7 @@
<tbody id="reviewRows"></tbody>
</table>
</div>
<div id="reviewPager" class="pager" hidden></div>
</section>
<section id="summariesPanel" class="panel admin-panel" hidden></section>
@@ -352,6 +353,7 @@
<tbody id="summarySearchRows"></tbody>
</table>
</div>
<div id="summarySearchPager" class="pager" hidden></div>
<div class="summary-task-section">
<div class="section-head compact-head">
<h3>待处理任务</h3>
@@ -382,6 +384,7 @@
<tbody id="summaryReviewRows"></tbody>
</table>
</div>
<div id="summaryReviewPager" class="pager" hidden></div>
</div>
</section>
@@ -445,6 +448,7 @@
<tbody id="logRows"></tbody>
</table>
</div>
<div id="logPager" class="pager" hidden></div>
</section>
<section id="registerPanel" class="panel admin-panel" hidden>
+198 -71
View File
@@ -50,9 +50,11 @@ const editNote = document.querySelector("#editNote");
const reviewStatus = document.querySelector("#reviewStatus");
const reviewMeta = document.querySelector("#reviewMeta");
const reviewRows = document.querySelector("#reviewRows");
const reviewPager = document.querySelector("#reviewPager");
const summaryReviewStatus = document.querySelector("#summaryReviewStatus");
const summaryReviewMeta = document.querySelector("#summaryReviewMeta");
const summaryReviewRows = document.querySelector("#summaryReviewRows");
const summaryReviewPager = document.querySelector("#summaryReviewPager");
const duplicateSummaryScanBtns = Array.from(document.querySelectorAll(".duplicate-summary-scan"));
const summaryReviewDrawerBackdrop = document.querySelector("#summaryReviewDrawerBackdrop");
const summaryReviewDrawerClose = document.querySelector("#summaryReviewDrawerClose");
@@ -88,6 +90,7 @@ const summarySearchBindingStatus = document.querySelector("#summarySearchBinding
const summarySearchHasCandidate = document.querySelector("#summarySearchHasCandidate");
const summarySearchMeta = document.querySelector("#summarySearchMeta");
const summarySearchRows = document.querySelector("#summarySearchRows");
const summarySearchPager = document.querySelector("#summarySearchPager");
const quickMismatchBtn = document.querySelector("#quickMismatchBtn");
const logOperation = document.querySelector("#logOperation");
const logStatus = document.querySelector("#logStatus");
@@ -95,6 +98,7 @@ const logFilterForm = document.querySelector("#logFilterForm");
const logStudent = document.querySelector("#logStudent");
const logMeta = document.querySelector("#logMeta");
const logRows = document.querySelector("#logRows");
const logPager = document.querySelector("#logPager");
const classRegisterForm = document.querySelector("#classRegisterForm");
const classRegisterLines = document.querySelector("#classRegisterLines");
const classRegisterPreview = document.querySelector("#classRegisterPreview");
@@ -111,20 +115,26 @@ const summaryRegisterPreview = document.querySelector("#summaryRegisterPreview")
const summaryRegisterStatus = document.querySelector("#summaryRegisterStatus");
const summaryRegisterConfirm = document.querySelector("#summaryRegisterConfirm");
const ADMIN_PAGE_SIZE = 50;
let currentAccounts = [];
let currentDashboardPeriod = "month";
let editingAccountId = "";
let currentTeachers = [];
let editingTeacherId = "";
let currentReviewPage = { offset: 0, limit: ADMIN_PAGE_SIZE, total: 0, shown: 0, hasMore: false };
let currentSummaryReviews = [];
let currentSummaryReviewPage = { offset: 0, limit: ADMIN_PAGE_SIZE, total: 0, shown: 0, hasMore: false };
let activeSummaryReview = null;
let currentSummarySearchItems = [];
let currentSummarySearchPage = { offset: 0, limit: ADMIN_PAGE_SIZE, total: 0, shown: 0, hasMore: false };
let expandedSummarySearchId = "";
let editingSummarySearchId = "";
let editingSummaryIdentityId = "";
let quickSummaryMismatchMode = false;
let quickSummaryProcessing = false;
let currentOperationLogs = [];
let currentLogPage = { offset: 0, limit: ADMIN_PAGE_SIZE, total: 0, shown: 0, hasMore: false };
let expandedOperationLogId = "";
const registerPreviewState = {
class_record: null,
@@ -161,6 +171,49 @@ function metric(label, value) {
return `<div class="metric"><span>${escapeHtml(label)}</span><strong>${escapeHtml(value)}</strong></div>`;
}
function pageRangeText(state) {
const total = Number(state.total || 0);
const shown = Number(state.shown || 0);
const offset = Number(state.offset || 0);
if (!total || !shown) return "0 条";
return `${offset + 1}-${offset + shown}`;
}
function currentPageNumber(state) {
return Math.floor((state.offset || 0) / (state.limit || ADMIN_PAGE_SIZE)) + 1;
}
function totalPageNumber(state) {
const limit = state.limit || ADMIN_PAGE_SIZE;
return Math.max(1, Math.ceil((state.total || 0) / limit));
}
function renderPager(container, state, action) {
if (!container) return;
const total = Number(state.total || 0);
const shown = Number(state.shown || 0);
const limit = Number(state.limit || ADMIN_PAGE_SIZE);
const offset = Number(state.offset || 0);
if (total <= limit && offset === 0) {
container.hidden = true;
container.innerHTML = "";
return;
}
container.hidden = false;
container.dataset.pagerAction = action;
container.innerHTML = `<div class="pager-info">第 ${currentPageNumber(state)} / ${totalPageNumber(state)} 页 · ${pageRangeText(state)} / ${total} 条</div>
<div class="pager-actions">
<button class="secondary-button pager-prev" type="button" ${offset <= 0 ? "disabled" : ""}>上一页</button>
<button class="secondary-button pager-next" type="button" ${state.hasMore ? "" : "disabled"}>下一页</button>
</div>`;
}
function setPagerLoading(container) {
if (!container) return;
container.hidden = true;
container.innerHTML = "";
}
function dashboardPeriodLabel(period) {
if (period === "today") return "今日";
if (period === "7d") return "近7日";
@@ -423,21 +476,25 @@ async function fetchJson(url, options = {}) {
function setActiveTab(tabName) {
const activeTabName = tabName === "summaries" ? "summarySearch" : tabName;
let activeButton = null;
document.querySelectorAll("[data-admin-tab]").forEach((button) => {
button.classList.toggle("is-active", button.dataset.adminTab === activeTabName);
const isActive = button.dataset.adminTab === activeTabName;
button.classList.toggle("is-active", isActive);
if (isActive) activeButton = button;
});
if (activeButton) activeButton.scrollIntoView({ behavior: "smooth", block: "nearest", inline: "center" });
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();
if (activeTabName === "reviews") loadReviews(0);
if (activeTabName === "summarySearch") {
loadSummarySearch();
loadSummaryReviews();
loadSummarySearch(0);
loadSummaryReviews(0);
}
if (activeTabName === "logs") loadOperationLogs();
if (activeTabName === "logs") loadOperationLogs(0);
}
async function loadAdminHealth() {
@@ -734,26 +791,45 @@ function renderReviewTarget(item) {
return renderReviewLine(item.corrected_line);
}
async function loadReviews() {
async function loadReviews(offset = currentReviewPage.offset || 0) {
reviewRows.innerHTML = `<tr><td colspan="7" class="empty">正在读取</td></tr>`;
setPagerLoading(reviewPager);
const normalizedOffset = Math.max(0, Number(offset || 0));
const params = new URLSearchParams({
limit: String(ADMIN_PAGE_SIZE),
offset: String(normalizedOffset),
type: "class_record_correction,class_record_deletion",
});
if (reviewStatus.value) params.set("status", reviewStatus.value);
try {
const [correctionData, deletionData] = await Promise.all([
fetchJson(`/api/admin/tasks?${new URLSearchParams({ type: "class_record_correction", ...(reviewStatus.value ? { status: reviewStatus.value } : {}) }).toString()}`),
fetchJson(`/api/admin/tasks?${new URLSearchParams({ type: "class_record_deletion", ...(reviewStatus.value ? { status: reviewStatus.value } : {}) }).toString()}`),
]);
const items = [...(correctionData.items || []), ...(deletionData.items || [])].sort((a, b) => Number(b.id || 0) - Number(a.id || 0));
reviewMeta.innerHTML = [metric("当前结果", `${items.length}`)].join("");
const data = await fetchJson(`/api/admin/tasks?${params.toString()}`);
const items = data.items || [];
const totalCount = Number(data.count || 0);
const typeCounts = data.type_counts || {};
currentReviewPage = {
offset: normalizedOffset,
limit: Number(data.limit || ADMIN_PAGE_SIZE),
total: totalCount,
shown: Number(data.returned || items.length),
hasMore: Boolean(data.has_more),
};
reviewMeta.innerHTML = [
metric("当前结果", `${totalCount}`),
metric("当前显示", pageRangeText(currentReviewPage)),
metric("纠错", `${typeCounts.class_record_correction || 0}`),
metric("删除", `${typeCounts.class_record_deletion || 0}`),
].join("");
reviewRows.innerHTML = items
.map((item) => {
const canReview = item.status === "pending" || item.status === "conflict";
return `<tr>
<td>#${escapeHtml(item.id)}</td>
<td><span class="status ${item.status === "approved" ? "normal" : item.status === "rejected" ? "closed" : item.status === "conflict" ? "debt" : "warning"}">${escapeHtml(item.status)}</span>${item.message ? `<br><small>${escapeHtml(item.message)}</small>` : ""}</td>
<td>${escapeHtml(reviewTaskTypeLabel(item))}</td>
<td>${renderReviewLine(item.original_line)}</td>
<td>${renderReviewTarget(item)}</td>
<td>${escapeHtml(item.created_at || "")}</td>
<td class="record-action-cell">
return `<tr class="admin-data-row">
<td data-label="编号">#${escapeHtml(item.id)}</td>
<td data-label="状态"><span class="status ${item.status === "approved" ? "normal" : item.status === "rejected" ? "closed" : item.status === "conflict" ? "debt" : "warning"}">${escapeHtml(item.status)}</span>${item.message ? `<br><small>${escapeHtml(item.message)}</small>` : ""}</td>
<td data-label="类型">${escapeHtml(reviewTaskTypeLabel(item))}</td>
<td data-label="原记录">${renderReviewLine(item.original_line)}</td>
<td data-label="处理内容">${renderReviewTarget(item)}</td>
<td data-label="提交时间">${escapeHtml(item.created_at || "")}</td>
<td class="record-action-cell" data-label="操作">
<div class="record-actions">
<button class="small-button review-approve" type="button" data-task-id="${escapeHtml(item.id)}" ${canReview ? "" : "disabled"}>批准</button>
<button class="small-button review-reject" type="button" data-task-id="${escapeHtml(item.id)}" ${canReview ? "" : "disabled"}>驳回</button>
@@ -765,6 +841,7 @@ async function loadReviews() {
if (!items.length) {
reviewRows.innerHTML = `<tr><td colspan="7" class="empty">没有符合条件的审核项</td></tr>`;
}
renderPager(reviewPager, currentReviewPage, "reviews");
} catch (error) {
reviewRows.innerHTML = `<tr><td colspan="7" class="empty">读取失败:${escapeHtml(error.message)}</td></tr>`;
}
@@ -1044,26 +1121,33 @@ function closeSummaryReviewDrawer() {
summaryReviewDrawerBackdrop.hidden = true;
}
async function loadSummaryReviews() {
async function loadSummaryReviews(offset = currentSummaryReviewPage.offset || 0) {
summaryReviewRows.innerHTML = `<tr><td colspan="7" class="empty">正在读取</td></tr>`;
const reviewParams = new URLSearchParams({ type: "course_summary_review" });
const duplicateParams = new URLSearchParams({ type: "course_summary_duplicate_review" });
if (summaryReviewStatus.value) {
reviewParams.set("status", summaryReviewStatus.value);
duplicateParams.set("status", summaryReviewStatus.value);
}
setPagerLoading(summaryReviewPager);
const normalizedOffset = Math.max(0, Number(offset || 0));
const params = new URLSearchParams({
type: "course_summary_review,course_summary_duplicate_review",
limit: String(ADMIN_PAGE_SIZE),
offset: String(normalizedOffset),
});
if (summaryReviewStatus.value) params.set("status", summaryReviewStatus.value);
try {
const [reviewData, duplicateData] = await Promise.all([
fetchJson(`/api/admin/tasks?${reviewParams.toString()}`),
fetchJson(`/api/admin/tasks?${duplicateParams.toString()}`),
]);
const reviewItems = reviewData.items || [];
const duplicateItems = duplicateData.items || [];
currentSummaryReviews = [...reviewItems, ...duplicateItems].sort((a, b) => Number(b.id || 0) - Number(a.id || 0));
const data = await fetchJson(`/api/admin/tasks?${params.toString()}`);
currentSummaryReviews = data.items || [];
const totalCount = Number(data.count || 0);
const typeCounts = data.type_counts || {};
currentSummaryReviewPage = {
offset: normalizedOffset,
limit: Number(data.limit || ADMIN_PAGE_SIZE),
total: totalCount,
shown: Number(data.returned || currentSummaryReviews.length),
hasMore: Boolean(data.has_more),
};
summaryReviewMeta.innerHTML = [
metric("待处理任务", `${currentSummaryReviews.length}`),
metric("课程小结审核", `${reviewItems.length}`),
metric("重复小结", `${duplicateItems.length}`),
metric("待处理任务", `${totalCount}`),
metric("当前显示", pageRangeText(currentSummaryReviewPage)),
metric("课程小结审核", `${typeCounts.course_summary_review || 0}`),
metric("重复小结", `${typeCounts.course_summary_duplicate_review || 0}`),
].join("");
summaryReviewRows.innerHTML = currentSummaryReviews
.map((item) => {
@@ -1074,14 +1158,14 @@ async function loadSummaryReviews() {
const approveButton = canApproveDirectly
? `<button class="small-button summary-approve" type="button" data-task-id="${escapeHtml(item.id)}">批准</button>`
: "";
return `<tr>
<td>#${escapeHtml(item.id)}</td>
<td><span class="status ${taskStatusClass(item.status)}">${escapeHtml(item.status)}</span>${item.message ? `<br><small>${escapeHtml(item.message)}</small>` : ""}</td>
<td>${renderSummaryInfo(summary, item)}</td>
<td>${item.proposed_line ? renderReviewLine(item.proposed_line) : "<span class=\"muted\">暂无</span>"}</td>
<td>${renderSummaryPreview(summary)}</td>
<td>${renderSummaryReviewReasons(item)}</td>
<td class="record-action-cell">
return `<tr class="admin-data-row">
<td data-label="编号">#${escapeHtml(item.id)}</td>
<td data-label="状态"><span class="status ${taskStatusClass(item.status)}">${escapeHtml(item.status)}</span>${item.message ? `<br><small>${escapeHtml(item.message)}</small>` : ""}</td>
<td data-label="课程信息">${renderSummaryInfo(summary, item)}</td>
<td data-label="候选记录">${item.proposed_line ? renderReviewLine(item.proposed_line) : "<span class=\"muted\">暂无</span>"}</td>
<td data-label="小结原文">${renderSummaryPreview(summary)}</td>
<td data-label="原因">${renderSummaryReviewReasons(item)}</td>
<td class="record-action-cell" data-label="操作">
<div class="record-actions">
<button class="small-button summary-detail" type="button" data-task-id="${escapeHtml(item.id)}">${escapeHtml(summaryReviewDetailLabel(item))}</button>
${approveButton}
@@ -1094,6 +1178,7 @@ async function loadSummaryReviews() {
if (!currentSummaryReviews.length) {
summaryReviewRows.innerHTML = `<tr><td colspan="7" class="empty">没有符合条件的课程小结任务</td></tr>`;
}
renderPager(summaryReviewPager, currentSummaryReviewPage, "summaryReviews");
} catch (error) {
currentSummaryReviews = [];
summaryReviewRows.innerHTML = `<tr><td colspan="7" class="empty">读取失败:${escapeHtml(error.message)}</td></tr>`;
@@ -1101,7 +1186,7 @@ async function loadSummaryReviews() {
}
function summarySearchParams() {
const params = new URLSearchParams({ limit: "200" });
const params = new URLSearchParams({ limit: String(ADMIN_PAGE_SIZE), offset: String(currentSummarySearchPage.offset || 0) });
if (summarySearchQuery.value.trim()) params.set("q", summarySearchQuery.value.trim());
if (summarySearchStudent.value.trim()) params.set("student", summarySearchStudent.value.trim());
if (summarySearchTeacher.value.trim()) params.set("teacher", summarySearchTeacher.value.trim());
@@ -1314,11 +1399,11 @@ function renderSummarySearchRows(items) {
const binding = summaryBinding(item);
const unmatchedClass = binding.status === "matched" ? "" : " unmatched-summary";
const mainRow = `<tr class="summary-search-result-row${unmatchedClass}" data-summary-id="${escapeHtml(item.id)}" tabindex="0" role="button" aria-expanded="${expanded ? "true" : "false"}">
<td>${escapeHtml(item.date_iso || "未识别")}</td>
<td>${escapeHtml(item.time_range || "缺少时间")}</td>
<td>${escapeHtml(item.student || "")}<br><small>${escapeHtml(item.group || "")}</small></td>
<td>${escapeHtml(item.teacher || "待核对老师")}<br><small>${escapeHtml(item.subject || "待核对科目")}</small></td>
<td>${renderSummaryBindingStatus(item)}</td>
<td data-label="日期">${escapeHtml(item.date_iso || "未识别")}</td>
<td data-label="时间">${escapeHtml(item.time_range || "缺少时间")}</td>
<td data-label="学生">${escapeHtml(item.student || "")}<br><small>${escapeHtml(item.group || "")}</small></td>
<td data-label="老师/科目">${escapeHtml(item.teacher || "待核对老师")}<br><small>${escapeHtml(item.subject || "待核对科目")}</small></td>
<td data-label="绑定状态">${renderSummaryBindingStatus(item)}</td>
</tr>`;
const detailRow = expanded
? `<tr class="summary-search-detail-row" data-summary-detail="${escapeHtml(item.id)}">
@@ -1350,11 +1435,20 @@ function renderCurrentSummarySearch() {
}
}
async function loadSummarySearch() {
async function loadSummarySearch(offset = currentSummarySearchPage.offset || 0) {
currentSummarySearchPage.offset = Math.max(0, Number(offset || 0));
summarySearchRows.innerHTML = `<tr><td colspan="5" class="empty">正在读取</td></tr>`;
setPagerLoading(summarySearchPager);
try {
const data = await fetchJson(`/api/admin/course-summaries?${summarySearchParams().toString()}`);
currentSummarySearchItems = data.items || [];
currentSummarySearchPage = {
offset: data.offset || currentSummarySearchPage.offset || 0,
limit: data.limit || ADMIN_PAGE_SIZE,
total: data.count || 0,
shown: data.returned || currentSummarySearchItems.length,
hasMore: Boolean(data.has_more),
};
if (!currentSummarySearchItems.some((item) => String(item.id) === expandedSummarySearchId)) expandedSummarySearchId = "";
if (!currentSummarySearchItems.some((item) => String(item.id) === editingSummarySearchId)) editingSummarySearchId = "";
if (!currentSummarySearchItems.some((item) => String(item.id) === editingSummaryIdentityId)) editingSummaryIdentityId = "";
@@ -1367,12 +1461,13 @@ async function loadSummarySearch() {
const bindingSummary = summarizeSummaryBindings(currentSummarySearchItems);
summarySearchMeta.innerHTML = [
metric("命中小结", `${data.count}`),
metric("当前显示", `${data.returned}`),
metric("当前显示", pageRangeText(currentSummarySearchPage)),
metric("已绑定", `${bindingSummary.matched}`),
metric("待处理", `${bindingSummary.pending}`),
metric("有候选", `${bindingSummary.withCandidate}`),
].join("");
renderCurrentSummarySearch();
renderPager(summarySearchPager, currentSummarySearchPage, "summarySearch");
updateQuickMismatchButton();
} catch (error) {
currentSummarySearchItems = [];
@@ -1602,12 +1697,12 @@ function renderOperationLogRows(items) {
.map((item) => {
const expanded = String(item.id) === expandedOperationLogId;
const mainRow = `<tr class="log-row" data-log-id="${escapeHtml(item.id)}" tabindex="0" role="button" aria-expanded="${expanded ? "true" : "false"}">
<td>${escapeHtml(item.created_at || "")}</td>
<td>${escapeHtml(item.operation || "")}</td>
<td><span class="status ${taskStatusClass(item.status)}">${escapeHtml(item.status || "")}</span></td>
<td>${escapeHtml(item.student || "")}</td>
<td>${renderLogAssociation(item)}</td>
<td class="record-action-cell">${renderLogActions(item)}</td>
<td data-label="时间">${escapeHtml(item.created_at || "")}</td>
<td data-label="操作">${escapeHtml(item.operation || "")}</td>
<td data-label="结果"><span class="status ${taskStatusClass(item.status)}">${escapeHtml(item.status || "")}</span></td>
<td data-label="学生">${escapeHtml(item.student || "")}</td>
<td data-label="批次/任务">${renderLogAssociation(item)}</td>
<td class="record-action-cell" data-label="操作">${renderLogActions(item)}</td>
</tr>`;
const detailRow = expanded
? `<tr class="log-detail-row" data-log-detail="${escapeHtml(item.id)}">
@@ -1688,18 +1783,31 @@ function renderLogActions(item) {
return `<span class="muted">-</span>`;
}
async function loadOperationLogs() {
async function loadOperationLogs(offset = currentLogPage.offset || 0) {
currentLogPage.offset = Math.max(0, Number(offset || 0));
logRows.innerHTML = `<tr><td colspan="6" class="empty">正在读取</td></tr>`;
const params = new URLSearchParams({ limit: "200" });
setPagerLoading(logPager);
const params = new URLSearchParams({ limit: String(ADMIN_PAGE_SIZE), offset: String(currentLogPage.offset || 0) });
if (logOperation.value) params.set("operation", logOperation.value);
if (logStatus.value) params.set("status", logStatus.value);
if (logStudent.value.trim()) params.set("student", logStudent.value.trim());
try {
const data = await fetchJson(`/api/admin/operation-logs?${params.toString()}`);
currentOperationLogs = data.items || [];
currentLogPage = {
offset: data.offset || currentLogPage.offset || 0,
limit: data.limit || ADMIN_PAGE_SIZE,
total: data.count || 0,
shown: data.returned || currentOperationLogs.length,
hasMore: Boolean(data.has_more),
};
if (!currentOperationLogs.some((item) => String(item.id) === expandedOperationLogId)) expandedOperationLogId = "";
logMeta.innerHTML = [metric("当前结果", `${data.count}`)].join("");
logMeta.innerHTML = [
metric("当前结果", `${data.count}`),
metric("当前显示", pageRangeText(currentLogPage)),
].join("");
renderCurrentOperationLogs();
renderPager(logPager, currentLogPage, "logs");
} catch (error) {
currentOperationLogs = [];
logRows.innerHTML = `<tr><td colspan="6" class="empty">读取失败:${escapeHtml(error.message)}</td></tr>`;
@@ -2113,14 +2221,14 @@ teacherRows.addEventListener("click", (event) => {
if (!button) return;
openEditTeacherEditor(button.dataset.teacherId);
});
reviewStatus.addEventListener("change", loadReviews);
reviewStatus.addEventListener("change", () => loadReviews(0));
reviewRows.addEventListener("click", (event) => {
const approve = event.target.closest(".review-approve");
const reject = event.target.closest(".review-reject");
if (approve) reviewTask(approve.dataset.taskId, "approve");
if (reject) reviewTask(reject.dataset.taskId, "reject");
});
summaryReviewStatus.addEventListener("change", loadSummaryReviews);
summaryReviewStatus.addEventListener("change", () => loadSummaryReviews(0));
duplicateSummaryScanBtns.forEach((button) => {
button.addEventListener("click", scanDuplicateSummaries);
});
@@ -2167,15 +2275,15 @@ document.addEventListener("keydown", (event) => {
summarySearchForm.addEventListener("submit", (event) => {
event.preventDefault();
quickSummaryMismatchMode = false;
loadSummarySearch();
loadSummarySearch(0);
});
summarySearchBindingStatus.addEventListener("change", () => {
quickSummaryMismatchMode = false;
loadSummarySearch();
loadSummarySearch(0);
});
summarySearchHasCandidate.addEventListener("change", () => {
quickSummaryMismatchMode = false;
loadSummarySearch();
loadSummarySearch(0);
});
quickMismatchBtn.addEventListener("click", () => {
processQuickMismatchItem().catch((error) => {
@@ -2278,11 +2386,11 @@ summarySearchRows.addEventListener("keydown", (event) => {
expandedSummarySearchId = expandedSummarySearchId === row.dataset.summaryId ? "" : row.dataset.summaryId;
renderCurrentSummarySearch();
});
logOperation.addEventListener("change", loadOperationLogs);
logStatus.addEventListener("change", loadOperationLogs);
logOperation.addEventListener("change", () => loadOperationLogs(0));
logStatus.addEventListener("change", () => loadOperationLogs(0));
logFilterForm.addEventListener("submit", (event) => {
event.preventDefault();
loadOperationLogs();
loadOperationLogs(0);
});
logRows.addEventListener("click", (event) => {
const rollback = event.target.closest(".log-rollback");
@@ -2305,6 +2413,25 @@ logRows.addEventListener("keydown", (event) => {
expandedOperationLogId = expandedOperationLogId === row.dataset.logId ? "" : row.dataset.logId;
renderCurrentOperationLogs();
});
function bindPager(container, getState, loadPage) {
if (!container) return;
container.addEventListener("click", (event) => {
const previous = event.target.closest(".pager-prev");
const next = event.target.closest(".pager-next");
if (!previous && !next) return;
const state = getState();
const limit = state.limit || ADMIN_PAGE_SIZE;
const nextOffset = Math.max(0, (state.offset || 0) + (previous ? -limit : limit));
loadPage(nextOffset);
});
}
bindPager(summarySearchPager, () => currentSummarySearchPage, loadSummarySearch);
bindPager(summaryReviewPager, () => currentSummaryReviewPage, loadSummaryReviews);
bindPager(reviewPager, () => currentReviewPage, loadReviews);
bindPager(logPager, () => currentLogPage, loadOperationLogs);
classRegisterForm.addEventListener("submit", (event) => {
previewRegister(event, "class_record", linesFromTextarea(classRegisterLines), classRegisterStatus);
});
@@ -2335,7 +2462,7 @@ refreshBtn.addEventListener("click", () => {
if (!panels.accounts.hidden) loadAccounts();
if (!panels.reviews.hidden) loadReviews();
if (!panels.summarySearch.hidden) refreshSummaryWorkspace({ logs: false });
if (!panels.logs.hidden) loadOperationLogs();
if (!panels.logs.hidden) loadOperationLogs(currentLogPage.offset || 0);
});
loadAdminHealth();
+158 -26
View File
@@ -4,6 +4,7 @@ const recordForm = document.querySelector("#recordForm");
const recordQuery = document.querySelector("#recordQuery");
const recordMeta = document.querySelector("#recordMeta");
const recordRows = document.querySelector("#recordRows");
const recordPager = document.querySelector("#recordPager");
const dateSortBtn = document.querySelector("#dateSortBtn");
const timeSortBtn = document.querySelector("#timeSortBtn");
const inlineAccount = document.querySelector("#inlineAccount");
@@ -42,11 +43,16 @@ const submitSummarySupplementBtn = document.querySelector("#submitSummarySupplem
const WEEKDAYS = ["星期日", "星期一", "星期二", "星期三", "星期四", "星期五", "星期六"];
const COPY_LINE_BREAK = "\r\n";
const SORT_DIRECTIONS = { asc: 1, desc: -1 };
const RECORD_PAGE_SIZE = 30;
let currentRecords = [];
let recordSort = { date: "asc", time: "asc" };
let currentRecordOrder = [];
let correctedRecords = new Map();
let correctedRecordOrder = [];
let expandedSummaryRecords = new Set();
let expandedSummaryBodies = new Set();
let collapsedTeacherGroups = new Set();
let currentRecordPage = { query: "", offset: 0, limit: RECORD_PAGE_SIZE, total: 0, shown: 0, hasMore: false };
let activeCorrectionKey = "";
let activeDeleteKey = "";
let activeSummarySupplementKey = "";
@@ -84,6 +90,44 @@ function metricHtml(label, valueHtml) {
return `<div class="metric"><span>${escapeHtml(label)}</span><strong>${valueHtml}</strong></div>`;
}
function currentPageNumber(state) {
return Math.floor((state.offset || 0) / (state.limit || RECORD_PAGE_SIZE)) + 1;
}
function totalPageNumber(state) {
const limit = state.limit || RECORD_PAGE_SIZE;
return Math.max(1, Math.ceil((state.total || 0) / limit));
}
function renderPager(container, state) {
if (!container) return;
const total = Number(state.total || 0);
const shown = Number(state.shown || 0);
const limit = Number(state.limit || RECORD_PAGE_SIZE);
const offset = Number(state.offset || 0);
if (total <= limit && offset === 0) {
container.hidden = true;
container.innerHTML = "";
return;
}
const start = shown ? offset + 1 : 0;
const end = offset + shown;
container.hidden = false;
container.innerHTML = `<div class="pager-info">第 ${currentPageNumber(state)} / ${totalPageNumber(state)} 页 · ${start}-${end} / ${total} 条</div>
<div class="pager-actions">
<button class="secondary-button pager-prev" type="button" ${offset <= 0 ? "disabled" : ""}>上一页</button>
<button class="secondary-button pager-next" type="button" ${state.hasMore ? "" : "disabled"}>下一页</button>
</div>`;
}
function pageRangeText(state) {
const total = Number(state.total || 0);
const shown = Number(state.shown || 0);
const offset = Number(state.offset || 0);
if (!total || !shown) return "0 条";
return `${offset + 1}-${offset + shown}`;
}
function makeRecordKey(row, index) {
return row.record_id || JSON.stringify([index, row.date, row.time, row.student, row.duration, row.teacher, row.subject]);
}
@@ -207,13 +251,13 @@ function renderRecordRow(row) {
const badge = corrected ? '<span class="correction-badge">已修改</span>' : "";
const summaryExpanded = expandedSummaryRecords.has(key);
return `<tr class="record-row${correctedClass}${summaryClass}" data-record-key="${escapeHtml(key)}" tabindex="0" role="button" aria-expanded="${summaryExpanded ? "true" : "false"}">
<td>${escapeHtml(displayRow.date)} ${escapeHtml(displayRow.weekday)}</td>
<td>${escapeHtml(displayRow.time)}</td>
<td>${escapeHtml(displayRow.student)}</td>
<td>${escapeHtml(displayRow.teacher)}</td>
<td>${escapeHtml(displayRow.subject)}</td>
<td class="num">${escapeHtml(displayRow.duration)}</td>
<td class="record-action-cell">
<td data-label="日期">${escapeHtml(displayRow.date)} ${escapeHtml(displayRow.weekday)}</td>
<td data-label="时间">${escapeHtml(displayRow.time)}</td>
<td data-label="学生">${escapeHtml(displayRow.student)}</td>
<td data-label="老师">${escapeHtml(displayRow.teacher)}</td>
<td data-label="科目">${escapeHtml(displayRow.subject)}</td>
<td class="num" data-label="时长">${escapeHtml(displayRow.duration)}</td>
<td class="record-action-cell" data-label="操作">
<div class="record-actions">
<button class="small-button correction-edit" type="button" data-record-key="${escapeHtml(key)}">${actionLabel}</button>
<button class="small-button record-delete" type="button" data-record-key="${escapeHtml(key)}">删除</button>
@@ -223,7 +267,16 @@ function renderRecordRow(row) {
</tr>`;
}
function renderSummaryEntry(summary) {
function summaryBodyKey(recordKey, index) {
return `${recordKey}:${index}`;
}
function summaryPreviewText(value) {
const text = String(value || "暂无正文");
return text.length > 120 ? `${text.slice(0, 120)}...` : text;
}
function renderSummaryEntry(summary, recordKey, index) {
const title = summary.title || "课程小结";
const time = summary.time_range ? ` · ${summary.time_range}` : "";
const meta = [
@@ -231,10 +284,15 @@ function renderSummaryEntry(summary) {
summary.teacher || "",
summary.subject || "",
].filter(Boolean).join(" · ");
const bodyKey = summaryBodyKey(recordKey, index);
const body = summary.body || "暂无正文";
const expanded = expandedSummaryBodies.has(bodyKey);
const canToggle = body.length > 120;
return `<div class="record-summary-item">
<div class="record-summary-title">${escapeHtml(title)}${escapeHtml(time)}</div>
${meta ? `<div class="record-summary-meta">${escapeHtml(meta)}</div>` : ""}
<div class="summary-body">${escapeHtml(summary.body || "暂无正文")}</div>
<div class="summary-body${expanded ? " summary-full" : " summary-preview"}">${escapeHtml(expanded ? body : summaryPreviewText(body))}</div>
${canToggle ? `<button class="summary-toggle" type="button" data-summary-body-key="${escapeHtml(bodyKey)}">${expanded ? "收起全文" : "展开全文"}</button>` : ""}
</div>`;
}
@@ -253,25 +311,31 @@ function renderGroupedRecords(records) {
currentRecordOrder = [];
return groupRecordsByTeacher(records)
.map(
(group) => `<tr class="teacher-group">
<td colspan="7">
<div class="teacher-group-title">
<strong>${escapeHtml(group.teacher)}</strong>
<span>${group.count} 条记录 · ${displayHours(group.totalHours)}</span>
</div>
</td>
</tr>${sortRecordsForDisplay(group.records)
(group) => {
const collapsed = collapsedTeacherGroups.has(group.teacher);
const rows = collapsed
? ""
: sortRecordsForDisplay(group.records)
.map((row) => {
currentRecordOrder.push(row._recordKey);
const summaryCount = Number(row.summary_count || 0);
const summaryRow = summaryCount
? `<tr class="summary-collapse-row" data-summary-row="${escapeHtml(row._recordKey)}" ${expandedSummaryRecords.has(row._recordKey) ? "" : "hidden"}>
<td colspan="7">${(row.summaries || []).map(renderSummaryEntry).join("")}</td>
<td colspan="7">${(row.summaries || []).map((summary, index) => renderSummaryEntry(summary, row._recordKey, index)).join("")}</td>
</tr>`
: renderSummaryMissingRow(row._recordKey, expandedSummaryRecords.has(row._recordKey));
return `${renderRecordRow(row)}${summaryRow}`;
})
.join("")}`,
.join("");
return `<tr class="teacher-group" data-teacher="${escapeHtml(group.teacher)}">
<td colspan="7">
<button class="teacher-group-toggle" type="button" data-teacher="${escapeHtml(group.teacher)}" aria-expanded="${collapsed ? "false" : "true"}">
<strong>${escapeHtml(group.teacher)}</strong>
<span>${group.count} 条记录 · ${displayHours(group.totalHours)} · ${collapsed ? "展开" : "收起"}</span>
</button>
</td>
</tr>${rows}`;
},
)
.join("");
}
@@ -289,6 +353,24 @@ function toggleRecordSummary(key) {
toggleSummaryRow(key);
}
function toggleTeacherGroup(teacher) {
if (collapsedTeacherGroups.has(teacher)) {
collapsedTeacherGroups.delete(teacher);
} else {
collapsedTeacherGroups.add(teacher);
}
renderCurrentRecords();
}
function toggleSummaryBody(key) {
if (expandedSummaryBodies.has(key)) {
expandedSummaryBodies.delete(key);
} else {
expandedSummaryBodies.add(key);
}
renderCurrentRecords();
}
function statusClass(status) {
if (status === "欠费") return "debt";
if (status === "预警") return "warning";
@@ -349,8 +431,11 @@ function updateCorrectionToolbar(message = "", isError = false) {
function resetCorrections() {
correctedRecords = new Map();
correctedRecordOrder = [];
currentRecordOrder = [];
expandedSummaryRecords = new Set();
expandedSummaryBodies = new Set();
collapsedTeacherGroups = new Set();
activeCorrectionKey = "";
updateCorrectionToolbar();
}
@@ -596,8 +681,10 @@ function saveCorrection() {
const corrected = buildCorrectedRecord(original);
if (buildRecordLine(corrected) === buildRecordLine(original)) {
correctedRecords.delete(activeCorrectionKey);
correctedRecordOrder = correctedRecordOrder.filter((key) => key !== activeCorrectionKey);
} else {
correctedRecords.set(activeCorrectionKey, corrected);
if (!correctedRecordOrder.includes(activeCorrectionKey)) correctedRecordOrder.push(activeCorrectionKey);
}
recordRows.innerHTML = renderGroupedRecords(currentRecords);
closeCorrectionDialog();
@@ -630,7 +717,7 @@ async function copyTextToClipboard(text) {
}
async function copyCorrectedRecords() {
const correctedInPageOrder = currentRecordOrder
const correctedInPageOrder = correctedRecordOrder
.map((key) => correctedRecords.get(key))
.filter(Boolean);
if (!correctedInPageOrder.length) return;
@@ -644,7 +731,7 @@ async function copyCorrectedRecords() {
}
async function submitCorrectedRecords() {
const items = currentRecordOrder
const items = correctedRecordOrder
.map((key) => {
const corrected = correctedRecords.get(key);
if (!corrected) return null;
@@ -698,40 +785,64 @@ async function loadHealth() {
}
}
async function queryRecords(query) {
async function queryRecords(query, options = {}) {
const q = query.trim();
if (!q) return;
const offset = Number(options.offset || 0);
recordRows.innerHTML = `<tr><td colspan="7" class="empty">正在查询</td></tr>`;
recordMeta.innerHTML = "";
if (recordPager) {
recordPager.hidden = true;
recordPager.innerHTML = "";
}
currentRecords = [];
if (options.reset !== false) {
resetCorrections();
resetRecordSort();
clearInlineAccount();
} else {
currentRecordOrder = [];
expandedSummaryRecords = new Set();
expandedSummaryBodies = new Set();
collapsedTeacherGroups = new Set();
}
try {
const data = await fetchJson(`/api/records?q=${encodeURIComponent(q)}&limit=500`);
const params = new URLSearchParams({ q, limit: String(RECORD_PAGE_SIZE), offset: String(offset) });
const data = await fetchJson(`/api/records?${params.toString()}`);
const summary = data.summary;
currentRecordPage = {
query: q,
offset: data.offset || offset,
limit: data.limit || RECORD_PAGE_SIZE,
total: data.total_records || 0,
shown: data.shown_records || (data.records || []).length,
hasMore: Boolean(data.has_more),
};
recordMeta.innerHTML = [
metric("识别日期", data.query.date_range),
metric("命中记录", `${summary.count}`),
metric("当前显示", pageRangeText(currentRecordPage)),
metric("总课时", displayHours(summary.total_hours, summary.total_duration)),
metric("授课老师", `${Object.keys(summary.teachers || {}).length} 位`),
].join("");
if (data.query.students.length === 1) {
if (options.reset !== false && data.query.students.length === 1) {
await loadInlineAccount(data.query.students[0]);
}
if (!data.records.length) {
recordRows.innerHTML = `<tr><td colspan="7" class="empty">未找到符合条件的上课记录</td></tr>`;
renderPager(recordPager, currentRecordPage);
return;
}
currentRecords = data.records.map((row, index) => ({
...row,
_recordIndex: index,
_recordKey: makeRecordKey(row, index),
_recordIndex: currentRecordPage.offset + index,
_recordKey: makeRecordKey(row, currentRecordPage.offset + index),
}));
recordRows.innerHTML = renderGroupedRecords(currentRecords);
renderPager(recordPager, currentRecordPage);
} catch (error) {
recordRows.innerHTML = `<tr><td colspan="7" class="empty">查询失败:${escapeHtml(error.message)}</td></tr>`;
}
@@ -761,6 +872,8 @@ recordRows.addEventListener("click", (event) => {
const summarySupplementButton = event.target.closest(".summary-supplement");
const editButton = event.target.closest(".correction-edit");
const deleteButton = event.target.closest(".record-delete");
const teacherToggle = event.target.closest(".teacher-group-toggle");
const summaryToggle = event.target.closest(".summary-toggle");
if (summarySupplementButton) {
openSummarySupplementDialog(summarySupplementButton.dataset.recordKey);
return;
@@ -773,6 +886,14 @@ recordRows.addEventListener("click", (event) => {
openDeleteDialog(deleteButton.dataset.recordKey);
return;
}
if (teacherToggle) {
toggleTeacherGroup(teacherToggle.dataset.teacher);
return;
}
if (summaryToggle) {
toggleSummaryBody(summaryToggle.dataset.summaryBodyKey);
return;
}
const row = event.target.closest(".record-row");
if (row && !event.target.closest(".record-action-cell")) toggleRecordSummary(row.dataset.recordKey);
});
@@ -787,6 +908,17 @@ recordRows.addEventListener("keydown", (event) => {
toggleRecordSummary(row.dataset.recordKey);
});
if (recordPager) {
recordPager.addEventListener("click", (event) => {
const previous = event.target.closest(".pager-prev");
const next = event.target.closest(".pager-next");
if (!previous && !next) return;
const delta = previous ? -currentRecordPage.limit : currentRecordPage.limit;
const nextOffset = Math.max(0, currentRecordPage.offset + delta);
queryRecords(currentRecordPage.query || recordQuery.value, { offset: nextOffset, reset: false });
});
}
[correctionDate, correctionTime, correctionStudent, correctionTeacher, correctionSubject].forEach((input) => {
input.addEventListener("input", updateCorrectionPreview);
});
+1
View File
@@ -73,6 +73,7 @@
</tbody>
</table>
</div>
<div id="recordPager" class="pager" hidden></div>
</section>
</main>
+223 -1
View File
@@ -938,6 +938,22 @@ textarea:focus {
margin-top: 8px;
}
.summary-toggle {
min-height: 30px;
padding: 0 10px;
border: 1px solid var(--line);
border-radius: 6px;
background: #fff;
color: var(--accent-strong);
cursor: pointer;
font-size: 13px;
font-weight: 700;
}
.summary-toggle:hover {
border-color: var(--accent);
}
.summary-conflict-list {
display: grid;
gap: 10px;
@@ -1234,6 +1250,35 @@ textarea:focus {
max-height: calc(100vh - 262px);
}
.pager {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 16px;
border-top: 1px solid var(--line);
background: #fbfcfd;
}
.pager-info {
min-width: 0;
color: #344054;
font-size: 13px;
font-weight: 700;
overflow-wrap: anywhere;
}
.pager-actions {
display: flex;
align-items: center;
gap: 8px;
flex: 0 0 auto;
}
.pager-actions .secondary-button {
min-width: 76px;
}
table {
width: 100%;
min-width: 720px;
@@ -1310,18 +1355,39 @@ td {
color: var(--accent-strong);
}
.teacher-group-toggle {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
width: 100%;
min-height: 34px;
padding: 0;
border: 0;
background: transparent;
color: var(--accent-strong);
cursor: pointer;
text-align: left;
}
.teacher-group-title strong {
font-size: 15px;
line-height: 1.25;
}
.teacher-group-title span {
.teacher-group-title span,
.teacher-group-toggle span {
color: #344054;
font-size: 13px;
font-weight: 700;
white-space: nowrap;
}
.teacher-group-toggle strong {
font-size: 15px;
line-height: 1.25;
}
.record-row td:nth-child(4) {
color: var(--muted);
}
@@ -1833,6 +1899,130 @@ td {
overscroll-behavior-y: auto;
}
.records-panel .table-wrap,
#reviewsPanel .table-wrap,
#summarySearchPanel .table-wrap,
#logsPanel .table-wrap {
overflow: visible;
}
.records-panel table,
#reviewsPanel table,
#summarySearchPanel table,
#logsPanel table {
min-width: 0;
}
.records-panel thead,
#reviewsPanel thead,
#summarySearchPanel thead,
#logsPanel thead {
display: none;
}
.records-panel tbody,
.records-panel tr,
.records-panel td,
#reviewsPanel tbody,
#reviewsPanel tr,
#reviewsPanel td,
#summarySearchPanel tbody,
#summarySearchPanel tr,
#summarySearchPanel td,
#logsPanel tbody,
#logsPanel tr,
#logsPanel td {
display: block;
width: 100%;
}
.record-row,
.admin-data-row,
.summary-search-result-row,
.log-row {
margin: 10px 12px;
overflow: hidden;
border: 1px solid var(--line);
border-radius: 8px;
background: #fff;
}
.record-row td,
.admin-data-row td,
.summary-search-result-row td,
.log-row td {
display: grid;
grid-template-columns: 82px minmax(0, 1fr);
gap: 10px;
padding: 9px 12px;
border-bottom: 1px solid #eef2f6;
white-space: normal;
overflow-wrap: anywhere;
}
.record-row td::before,
.admin-data-row td::before,
.summary-search-result-row td::before,
.log-row td::before {
content: attr(data-label);
color: var(--muted);
font-size: 12px;
font-weight: 700;
}
.record-row td:last-child,
.admin-data-row td:last-child,
.summary-search-result-row td:last-child,
.log-row td:last-child {
border-bottom: 0;
}
.record-row .num {
text-align: left;
}
.record-row .record-action-cell,
.admin-data-row .record-action-cell,
.log-row .record-action-cell {
grid-template-columns: 82px minmax(0, 1fr);
}
.record-actions {
justify-content: flex-start;
}
.teacher-group,
.summary-collapse-row,
.summary-search-detail-row,
.log-detail-row {
display: block;
margin: 10px 12px;
}
.teacher-group td,
.summary-collapse-row td,
.summary-search-detail-row td,
.log-detail-row td {
display: block;
width: 100%;
}
.teacher-group {
margin-top: 14px;
}
.teacher-group td {
border: 1px solid #b7d8d4;
border-radius: 8px;
}
.summary-collapse-row td,
.summary-search-detail-row td,
.log-detail-row td {
border: 1px solid var(--line);
border-radius: 8px;
}
.inline-account-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
@@ -1966,6 +2156,15 @@ td {
.admin-tabs {
overflow-x: auto;
gap: 6px;
margin: 0 -12px;
padding: 0 12px 8px;
scroll-padding: 12px;
}
.admin-tabs::after {
content: "";
flex: 0 0 18px;
align-self: stretch;
}
.admin-tab {
@@ -2070,9 +2269,32 @@ td {
gap: 4px;
}
.teacher-group-toggle {
align-items: flex-start;
flex-direction: column;
gap: 4px;
}
.teacher-group-title span {
white-space: normal;
}
.teacher-group-toggle span {
white-space: normal;
}
.pager {
align-items: stretch;
flex-direction: column;
}
.pager-actions {
width: 100%;
}
.pager-actions .secondary-button {
flex: 1 1 0;
}
}
@media (max-width: 420px) {