diff --git a/app/app/data.py b/app/app/data.py index 63f7a6f..cb278d9 100644 --- a/app/app/data.py +++ b/app/app/data.py @@ -180,15 +180,15 @@ def read_classnotes(path: Path) -> list[ClassRecord]: match = CLASSNOTE_RE.fullmatch(line) if not match: raise ValueError(f"{path}:{line_number} 无法解析课程记录行: {line}") - duration = match.group("duration") + true_minutes = parse_time_range_minutes(match.group("time")) records.append( ClassRecord( date=match.group("date"), weekday=match.group("weekday"), time=match.group("time"), student=canonical_name(match.group("student")), - duration=duration, - duration_hours=parse_hours_text(duration), + duration=duration_text_from_minutes(true_minutes), + duration_hours=round(true_minutes / 60.0, 2), teacher=match.group("teacher").strip(), subject=match.group("subject").strip(), ) @@ -304,7 +304,7 @@ def parse_class_record_line(line: str) -> ClassRecord: ) -def parse_time_range_hours(text: str) -> float: +def parse_time_range_minutes(text: str) -> int: match = TIME_RANGE_RE.fullmatch(text.strip()) if not match: raise ValueError(f"无法解析时间段: {text}") @@ -320,7 +320,11 @@ def parse_time_range_hours(text: str) -> float: end_total = end_hour * 60 + end_minute if end_total <= start_total: raise ValueError(f"结束时间必须晚于开始时间: {text}") - return (end_total - start_total) / 60.0 + return end_total - start_total + + +def parse_time_range_hours(text: str) -> float: + return parse_time_range_minutes(text) / 60.0 def class_record_to_line(record: ClassRecord) -> str: @@ -729,6 +733,115 @@ def register_payment_lines( return {"registered": len(registered), "lines": registered, "backup_id": backup_dir.name, "operation": "登记缴费记录"} +def normalize_classnote_durations_and_accounts( + classnotes_path: Path, + accounts_path: Path, + operation_logs_path: Path | None = None, +) -> dict: + original_classnotes = classnotes_path.read_text(encoding="utf-8") + original_accounts = accounts_path.read_text(encoding="utf-8") + accounts = read_accounts(accounts_path) + updated_accounts = list(accounts) + account_deltas: defaultdict[str, float] = defaultdict(float) + changed_lines: list[str] = [] + output_lines: list[str] = [] + skipped_lines: list[str] = [] + + for line_number, raw_line in enumerate(original_classnotes.splitlines(), start=1): + stripped = raw_line.strip() + if not stripped or stripped.startswith("#"): + output_lines.append(raw_line) + continue + match = CLASSNOTE_RE.fullmatch(stripped) + if not match: + output_lines.append(raw_line) + skipped_lines.append(f"{line_number}: {stripped}") + continue + + old_minutes = int(round(parse_hours_text(match.group("duration")) * 60)) + true_minutes = parse_time_range_minutes(match.group("time")) + true_duration = duration_text_from_minutes(true_minutes) + if true_minutes == old_minutes and match.group("duration") == true_duration: + output_lines.append(raw_line) + continue + + student = canonical_name(match.group("student")) + account_index = find_account_index(updated_accounts, student) + delta_hours = round((old_minutes - true_minutes) / 60.0, 2) + if delta_hours: + updated_accounts[account_index] = update_account_remaining(updated_accounts[account_index], delta_hours) + account_deltas[updated_accounts[account_index].student_id] += delta_hours + + new_line = ( + f"{match.group('date')}-{match.group('weekday')}-{match.group('time')}-" + f"{student}-{true_duration}-{match.group('teacher').strip()}-{match.group('subject').strip()}" + ) + output_lines.append(new_line) + changed_lines.append(f"{line_number}: {stripped} => {new_line}") + + if not changed_lines: + return { + "updated_records": 0, + "updated_accounts": 0, + "account_deltas": {}, + "skipped_lines": skipped_lines, + "backup_id": "", + } + + trailing_newline = "\n" if original_classnotes.endswith("\n") else "" + new_classnotes = "\n".join(output_lines) + trailing_newline + changed_account_ids = {account_id for account_id, delta in account_deltas.items() if round(delta, 2)} + updated_accounts_by_id = { + account.student_id: account + for account in updated_accounts + if account.student_id in changed_account_ids + } + new_accounts = replace_account_lines(original_accounts, updated_accounts_by_id) + backup_dir = create_data_backup( + "normalize-classnote-durations", + { + accounts_path: original_accounts, + classnotes_path: original_classnotes, + }, + changed_lines, + ) + try: + atomic_write_text(classnotes_path, new_classnotes) + atomic_write_text(accounts_path, new_accounts) + except Exception: + atomic_write_text(classnotes_path, original_classnotes) + atomic_write_text(accounts_path, original_accounts) + raise + try: + prune_data_backups(backup_dir.parent) + except OSError: + pass + + result = { + "updated_records": len(changed_lines), + "updated_accounts": len(updated_accounts_by_id), + "account_deltas": { + account_id: round(delta, 2) + for account_id, delta in sorted(account_deltas.items()) + if round(delta, 2) + }, + "skipped_lines": skipped_lines, + "backup_id": backup_dir.name, + } + if operation_logs_path is not None: + append_operation_log( + operation_logs_path, + "历史上课记录真实时长迁移", + "完成", + backup_id=backup_dir.name, + updated_records=result["updated_records"], + updated_accounts=result["updated_accounts"], + account_deltas=result["account_deltas"], + skipped_lines_count=len(skipped_lines), + ) + return result + + def default_admin_tasks() -> dict: return {"version": 1, "next_id": 1, "items": []} diff --git a/app/app/static/accounts.js b/app/app/static/accounts.js index 6719460..450041d 100644 --- a/app/app/static/accounts.js +++ b/app/app/static/accounts.js @@ -7,7 +7,10 @@ const accountMeta = document.querySelector("#accountMeta"); const accountRows = document.querySelector("#accountRows"); function fmtHours(value) { - return Number(value || 0).toLocaleString("zh-CN", { maximumFractionDigits: 2 }); + const totalMinutes = Math.round(Number(value || 0) * 60); + const hours = Math.floor(totalMinutes / 60); + const minutes = totalMinutes % 60; + return `${hours}小时${minutes}分`; } function fmtTime(seconds) { @@ -37,7 +40,7 @@ function statusClass(status) { function renderPayments(payments) { if (!payments.length) return "暂无"; - return payments.map((item) => `${escapeHtml(item.date)}:${fmtHours(item.hours)} 小时`).join("
"); + return payments.map((item) => `${escapeHtml(item.date)}:${fmtHours(item.hours)}`).join("
"); } async function fetchJson(url) { diff --git a/app/app/static/admin.js b/app/app/static/admin.js index 318a0a7..4960f33 100644 --- a/app/app/static/admin.js +++ b/app/app/static/admin.js @@ -105,7 +105,10 @@ const SUMMARY_REQUIRED_FIELDS = [ ]; function fmtHours(value) { - return Number(value || 0).toLocaleString("zh-CN", { maximumFractionDigits: 2 }); + const totalMinutes = Math.round(Number(value || 0) * 60); + const hours = Math.floor(totalMinutes / 60); + const minutes = totalMinutes % 60; + return `${hours}小时${minutes}分`; } function fmtTime(seconds) { @@ -142,7 +145,7 @@ function taskStatusClass(status) { function renderPayments(payments) { if (!payments.length) return "暂无"; - return payments.map((item) => `${escapeHtml(item.date)}:${fmtHours(item.hours)} 小时`).join("
"); + return payments.map((item) => `${escapeHtml(item.date)}:${fmtHours(item.hours)}`).join("
"); } async function fetchJson(url, options = {}) { @@ -449,7 +452,7 @@ function reviewTaskTypeLabel(item) { function renderReviewTarget(item) { if (item.type === "class_record_deletion") { - const restored = item.original && item.original.duration_hours ? `通过后恢复 ${fmtHours(item.original.duration_hours)} 小时` : "通过后恢复课时"; + const restored = item.original && item.original.duration_hours ? `通过后恢复 ${fmtHours(item.original.duration_hours)}` : "通过后恢复课时"; return `删除
${escapeHtml(restored)}`; } return renderReviewLine(item.corrected_line); diff --git a/app/app/static/app.js b/app/app/static/app.js index 4f3c5ee..a06e7bb 100644 --- a/app/app/static/app.js +++ b/app/app/static/app.js @@ -42,7 +42,10 @@ let activeCorrectionKey = ""; let activeDeleteKey = ""; function fmtHours(value) { - return Number(value || 0).toLocaleString("zh-CN", { maximumFractionDigits: 2 }); + const totalMinutes = Math.round(Number(value || 0) * 60); + const hours = Math.floor(totalMinutes / 60); + const minutes = totalMinutes % 60; + return `${hours}小时${minutes}分`; } function fmtTime(seconds) { @@ -224,7 +227,7 @@ function renderGroupedRecords(records) {
${escapeHtml(group.teacher)} - ${group.count} 条记录 · ${fmtHours(group.totalHours)} 小时 + ${group.count} 条记录 · ${fmtHours(group.totalHours)}
${sortRecordsForDisplay(group.records) @@ -262,7 +265,7 @@ function statusClass(status) { function renderPayments(payments) { if (!payments.length) return "暂无缴费记录"; return payments - .map((item) => `${escapeHtml(item.date)}:${fmtHours(item.hours)} 小时`) + .map((item) => `${escapeHtml(item.date)}:${fmtHours(item.hours)}`) .join(""); } @@ -590,7 +593,7 @@ async function queryRecords(query) { recordMeta.innerHTML = [ metric("识别日期", data.query.date_range), metric("命中记录", `${summary.count} 条`), - metric("总课时", `${fmtHours(summary.total_hours)} 小时`), + metric("总课时", fmtHours(summary.total_hours)), metric("授课老师", `${Object.keys(summary.teachers || {}).length} 位`), ].join("");