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) {