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