diff --git a/app/app/data.py b/app/app/data.py index 7fe82b1..157af22 100644 --- a/app/app/data.py +++ b/app/app/data.py @@ -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, } diff --git a/app/app/routers/admin.py b/app/app/routers/admin.py index a134de8..02afdcc 100644 --- a/app/app/routers/admin.py +++ b/app/app/routers/admin.py @@ -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 diff --git a/app/app/routers/records.py b/app/app/routers/records.py index c3535b8..cf2a7b1 100644 --- a/app/app/routers/records.py +++ b/app/app/routers/records.py @@ -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}") diff --git a/app/app/static/admin.html b/app/app/static/admin.html index d7f83b3..91f084d 100644 --- a/app/app/static/admin.html +++ b/app/app/static/admin.html @@ -230,6 +230,7 @@ + @@ -352,6 +353,7 @@ +

待处理任务

@@ -382,6 +384,7 @@
+
@@ -445,6 +448,7 @@ + diff --git a/app/app/static/styles.css b/app/app/static/styles.css index b56d588..6a19de6 100644 --- a/app/app/static/styles.css +++ b/app/app/static/styles.css @@ -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) {