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

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)
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": []}
+5 -2
View File
@@ -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("<br>");
return payments.map((item) => `${escapeHtml(item.date)}${fmtHours(item.hours)}`).join("<br>");
}
async function fetchJson(url) {
+6 -3
View File
@@ -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("<br>");
return payments.map((item) => `${escapeHtml(item.date)}${fmtHours(item.hours)}`).join("<br>");
}
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 `<span class="status closed">删除</span><br><small>${escapeHtml(restored)}</small>`;
}
return renderReviewLine(item.corrected_line);
+7 -4
View File
@@ -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) {
<td colspan="7">
<div class="teacher-group-title">
<strong>${escapeHtml(group.teacher)}</strong>
<span>${group.count} 条记录 · ${fmtHours(group.totalHours)} 小时</span>
<span>${group.count} 条记录 · ${fmtHours(group.totalHours)}</span>
</div>
</td>
</tr>${sortRecordsForDisplay(group.records)
@@ -262,7 +265,7 @@ function statusClass(status) {
function renderPayments(payments) {
if (!payments.length) return "暂无缴费记录";
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("");
}
@@ -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("");