优化记录与后台列表分页展示
This commit is contained in:
+49
-8
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
});
|
||||
|
||||
@@ -73,6 +73,7 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div id="recordPager" class="pager" hidden></div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
|
||||
+223
-1
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user