修正课时时长显示和归一化

This commit is contained in:
Codex
2026-06-16 05:09:55 +08:00
parent c1d3d19dfc
commit 1a9235408c
4 changed files with 136 additions and 14 deletions
+118 -5
View File
@@ -180,15 +180,15 @@ def read_classnotes(path: Path) -> list[ClassRecord]:
match = CLASSNOTE_RE.fullmatch(line) match = CLASSNOTE_RE.fullmatch(line)
if not match: if not match:
raise ValueError(f"{path}:{line_number} 无法解析课程记录行: {line}") raise ValueError(f"{path}:{line_number} 无法解析课程记录行: {line}")
duration = match.group("duration") true_minutes = parse_time_range_minutes(match.group("time"))
records.append( records.append(
ClassRecord( ClassRecord(
date=match.group("date"), date=match.group("date"),
weekday=match.group("weekday"), weekday=match.group("weekday"),
time=match.group("time"), time=match.group("time"),
student=canonical_name(match.group("student")), student=canonical_name(match.group("student")),
duration=duration, duration=duration_text_from_minutes(true_minutes),
duration_hours=parse_hours_text(duration), duration_hours=round(true_minutes / 60.0, 2),
teacher=match.group("teacher").strip(), teacher=match.group("teacher").strip(),
subject=match.group("subject").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()) match = TIME_RANGE_RE.fullmatch(text.strip())
if not match: if not match:
raise ValueError(f"无法解析时间段: {text}") raise ValueError(f"无法解析时间段: {text}")
@@ -320,7 +320,11 @@ def parse_time_range_hours(text: str) -> float:
end_total = end_hour * 60 + end_minute end_total = end_hour * 60 + end_minute
if end_total <= start_total: if end_total <= start_total:
raise ValueError(f"结束时间必须晚于开始时间: {text}") 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: 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": "登记缴费记录"} 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: def default_admin_tasks() -> dict:
return {"version": 1, "next_id": 1, "items": []} return {"version": 1, "next_id": 1, "items": []}
+5 -2
View File
@@ -7,7 +7,10 @@ const accountMeta = document.querySelector("#accountMeta");
const accountRows = document.querySelector("#accountRows"); const accountRows = document.querySelector("#accountRows");
function fmtHours(value) { 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) { function fmtTime(seconds) {
@@ -37,7 +40,7 @@ function statusClass(status) {
function renderPayments(payments) { function renderPayments(payments) {
if (!payments.length) return "暂无"; if (!payments.length) return "暂无";
return payments.map((item) => `${escapeHtml(item.date)}${fmtHours(item.hours)} 小时`).join("<br>"); return payments.map((item) => `${escapeHtml(item.date)}${fmtHours(item.hours)}`).join("<br>");
} }
async function fetchJson(url) { async function fetchJson(url) {
+6 -3
View File
@@ -105,7 +105,10 @@ const SUMMARY_REQUIRED_FIELDS = [
]; ];
function fmtHours(value) { 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) { function fmtTime(seconds) {
@@ -142,7 +145,7 @@ function taskStatusClass(status) {
function renderPayments(payments) { function renderPayments(payments) {
if (!payments.length) return "暂无"; if (!payments.length) return "暂无";
return payments.map((item) => `${escapeHtml(item.date)}${fmtHours(item.hours)} 小时`).join("<br>"); return payments.map((item) => `${escapeHtml(item.date)}${fmtHours(item.hours)}`).join("<br>");
} }
async function fetchJson(url, options = {}) { async function fetchJson(url, options = {}) {
@@ -449,7 +452,7 @@ function reviewTaskTypeLabel(item) {
function renderReviewTarget(item) { function renderReviewTarget(item) {
if (item.type === "class_record_deletion") { 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 `<span class="status closed">删除</span><br><small>${escapeHtml(restored)}</small>`; return `<span class="status closed">删除</span><br><small>${escapeHtml(restored)}</small>`;
} }
return renderReviewLine(item.corrected_line); return renderReviewLine(item.corrected_line);
+7 -4
View File
@@ -42,7 +42,10 @@ let activeCorrectionKey = "";
let activeDeleteKey = ""; let activeDeleteKey = "";
function fmtHours(value) { 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) { function fmtTime(seconds) {
@@ -224,7 +227,7 @@ function renderGroupedRecords(records) {
<td colspan="7"> <td colspan="7">
<div class="teacher-group-title"> <div class="teacher-group-title">
<strong>${escapeHtml(group.teacher)}</strong> <strong>${escapeHtml(group.teacher)}</strong>
<span>${group.count} 条记录 · ${fmtHours(group.totalHours)} 小时</span> <span>${group.count} 条记录 · ${fmtHours(group.totalHours)}</span>
</div> </div>
</td> </td>
</tr>${sortRecordsForDisplay(group.records) </tr>${sortRecordsForDisplay(group.records)
@@ -262,7 +265,7 @@ function statusClass(status) {
function renderPayments(payments) { function renderPayments(payments) {
if (!payments.length) return "暂无缴费记录"; if (!payments.length) return "暂无缴费记录";
return payments return payments
.map((item) => `<span class="payment-line">${escapeHtml(item.date)}${fmtHours(item.hours)} 小时</span>`) .map((item) => `<span class="payment-line">${escapeHtml(item.date)}${fmtHours(item.hours)}</span>`)
.join(""); .join("");
} }
@@ -590,7 +593,7 @@ async function queryRecords(query) {
recordMeta.innerHTML = [ recordMeta.innerHTML = [
metric("识别日期", data.query.date_range), metric("识别日期", data.query.date_range),
metric("命中记录", `${summary.count}`), metric("命中记录", `${summary.count}`),
metric("总课时", `${fmtHours(summary.total_hours)} 小时`), metric("总课时", fmtHours(summary.total_hours)),
metric("授课老师", `${Object.keys(summary.teachers || {}).length} 位`), metric("授课老师", `${Object.keys(summary.teachers || {}).length} 位`),
].join(""); ].join("");