优化课程小结自动绑定处理
This commit is contained in:
+235
-2
@@ -1439,6 +1439,29 @@ def duration_minutes_from_time_range(time_range: str) -> int | None:
|
|||||||
return end - start if end > start else None
|
return end - start if end > start else None
|
||||||
|
|
||||||
|
|
||||||
|
def time_range_bounds_minutes(time_range: str) -> tuple[int, int] | None:
|
||||||
|
normalized = normalize_time_range_text(time_range)
|
||||||
|
if not normalized:
|
||||||
|
return None
|
||||||
|
match = TIME_RANGE_RE.fullmatch(normalized)
|
||||||
|
if not match:
|
||||||
|
return None
|
||||||
|
start = int(match.group("sh")) * 60 + int(match.group("sm"))
|
||||||
|
end = int(match.group("eh")) * 60 + int(match.group("em"))
|
||||||
|
return (start, end) if end > start else None
|
||||||
|
|
||||||
|
|
||||||
|
def time_range_close_enough(left: str, right: str, tolerance_minutes: int = 20) -> bool:
|
||||||
|
left_bounds = time_range_bounds_minutes(left)
|
||||||
|
right_bounds = time_range_bounds_minutes(right)
|
||||||
|
if left_bounds is None or right_bounds is None:
|
||||||
|
return False
|
||||||
|
return (
|
||||||
|
abs(left_bounds[0] - right_bounds[0]) <= tolerance_minutes
|
||||||
|
and abs(left_bounds[1] - right_bounds[1]) <= tolerance_minutes
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def duration_minutes_from_summary(summary: dict) -> int | None:
|
def duration_minutes_from_summary(summary: dict) -> int | None:
|
||||||
raw_duration = summary.get("duration") or summary.get("duration_text") or ""
|
raw_duration = summary.get("duration") or summary.get("duration_text") or ""
|
||||||
if raw_duration:
|
if raw_duration:
|
||||||
@@ -2229,6 +2252,24 @@ def course_summary_record_key(student: str, teacher: str, subject: str, date_iso
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def course_summary_identity_key(item: dict) -> tuple[str, str, str, str]:
|
||||||
|
return (
|
||||||
|
canonical_name(str(item.get("student") or "").strip()),
|
||||||
|
canonical_teacher_name(str(item.get("teacher") or "").strip()),
|
||||||
|
normalize_subject(str(item.get("subject") or "").strip()),
|
||||||
|
str(item.get("date_iso") or "").strip(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def class_record_identity_key(record: ClassRecord) -> tuple[str, str, str, str]:
|
||||||
|
return (
|
||||||
|
record.student,
|
||||||
|
record.teacher,
|
||||||
|
normalize_subject(record.subject),
|
||||||
|
record.date.replace(".", "-"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def course_summary_duplicate_key(item: dict) -> tuple[str, str, str, str, str]:
|
def course_summary_duplicate_key(item: dict) -> tuple[str, str, str, str, str]:
|
||||||
return course_summary_record_key(
|
return course_summary_record_key(
|
||||||
str(item.get("student") or ""),
|
str(item.get("student") or ""),
|
||||||
@@ -2456,8 +2497,14 @@ def course_summary_to_public(item: dict, teachers: list[Teacher]) -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def course_summary_index_for_records(root: Path) -> dict[tuple[str, str, str, str, str], list[dict]]:
|
def course_summary_index_for_records(
|
||||||
|
root: Path,
|
||||||
|
records: list[ClassRecord] | None = None,
|
||||||
|
) -> dict[tuple[str, str, str, str, str], list[dict]]:
|
||||||
index: dict[tuple[str, str, str, str, str], list[dict]] = defaultdict(list)
|
index: dict[tuple[str, str, str, str, str], list[dict]] = defaultdict(list)
|
||||||
|
records_by_identity: defaultdict[tuple[str, str, str, str], list[ClassRecord]] = defaultdict(list)
|
||||||
|
for record in records or []:
|
||||||
|
records_by_identity[class_record_identity_key(record)].append(record)
|
||||||
for item in iter_course_summary_markdown(root):
|
for item in iter_course_summary_markdown(root):
|
||||||
time_range = str(item.get("time_range") or "")
|
time_range = str(item.get("time_range") or "")
|
||||||
if not time_range:
|
if not time_range:
|
||||||
@@ -2469,6 +2516,12 @@ def course_summary_index_for_records(root: Path) -> dict[tuple[str, str, str, st
|
|||||||
str(item.get("date_iso") or ""),
|
str(item.get("date_iso") or ""),
|
||||||
time_range,
|
time_range,
|
||||||
)
|
)
|
||||||
|
close_record = close_time_binding_record(
|
||||||
|
item,
|
||||||
|
records_by_identity.get(course_summary_identity_key(item), []),
|
||||||
|
)
|
||||||
|
if close_record is not None:
|
||||||
|
key = class_record_binding_key(close_record)
|
||||||
index[key].append(item)
|
index[key].append(item)
|
||||||
for key, values in list(index.items()):
|
for key, values in list(index.items()):
|
||||||
values.sort(key=lambda item: (str(item.get("title") or ""), str(item.get("id") or "")))
|
values.sort(key=lambda item: (str(item.get("title") or ""), str(item.get("id") or "")))
|
||||||
@@ -2487,6 +2540,38 @@ def class_record_binding_key(record: ClassRecord) -> tuple[str, str, str, str, s
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def close_time_binding_record(item: dict, records: list[ClassRecord]) -> ClassRecord | None:
|
||||||
|
item_time = str(item.get("time_range") or "")
|
||||||
|
if not item_time:
|
||||||
|
return None
|
||||||
|
item_bounds = time_range_bounds_minutes(item_time)
|
||||||
|
if item_bounds is None:
|
||||||
|
return None
|
||||||
|
item_identity = course_summary_identity_key(item)
|
||||||
|
if not all(item_identity):
|
||||||
|
return None
|
||||||
|
candidates = [
|
||||||
|
record
|
||||||
|
for record in records
|
||||||
|
if class_record_identity_key(record) == item_identity
|
||||||
|
and record.time != normalize_time_range_text(item_time)
|
||||||
|
and time_range_close_enough(item_time, record.time)
|
||||||
|
]
|
||||||
|
if not candidates:
|
||||||
|
return None
|
||||||
|
def distance(record: ClassRecord) -> tuple[int, str]:
|
||||||
|
record_bounds = time_range_bounds_minutes(record.time)
|
||||||
|
if record_bounds is None:
|
||||||
|
return (9999, record.time)
|
||||||
|
return (
|
||||||
|
abs(item_bounds[0] - record_bounds[0]) + abs(item_bounds[1] - record_bounds[1]),
|
||||||
|
record.time,
|
||||||
|
)
|
||||||
|
|
||||||
|
candidates.sort(key=distance)
|
||||||
|
return candidates[0]
|
||||||
|
|
||||||
|
|
||||||
def course_summary_record_snapshot(record: ClassRecord) -> dict:
|
def course_summary_record_snapshot(record: ClassRecord) -> dict:
|
||||||
return {
|
return {
|
||||||
"record_id": record_identity(record),
|
"record_id": record_identity(record),
|
||||||
@@ -2571,6 +2656,21 @@ def course_summary_binding_status(item: dict, record_keys: dict[tuple[str, str,
|
|||||||
"differences": [],
|
"differences": [],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
close_record = close_time_binding_record(item, records)
|
||||||
|
if close_record is not None:
|
||||||
|
return {
|
||||||
|
"status": "matched",
|
||||||
|
"label": "已绑定",
|
||||||
|
"record": course_summary_record_snapshot(close_record),
|
||||||
|
"candidates": [],
|
||||||
|
"reason": "只有时间段不一致,起止时间差均在20分钟内,已自动绑定",
|
||||||
|
"differences": ["时间不一致"],
|
||||||
|
"auto_bound": True,
|
||||||
|
"auto_bind_reason": "time_range_within_20_minutes",
|
||||||
|
"summary_time_range": str(item.get("time_range") or ""),
|
||||||
|
"record_time_range": close_record.time,
|
||||||
|
}
|
||||||
|
|
||||||
item_student = canonical_name(str(item.get("student") or ""))
|
item_student = canonical_name(str(item.get("student") or ""))
|
||||||
item_date = str(item.get("date_iso") or "")
|
item_date = str(item.get("date_iso") or "")
|
||||||
same_student_date = [
|
same_student_date = [
|
||||||
@@ -3184,6 +3284,83 @@ def course_summary_to_class_record_line(summary: dict) -> str:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def close_time_existing_class_record(summary: dict, classnotes_path: Path) -> ClassRecord | None:
|
||||||
|
if not classnotes_path.exists() or not summary.get("time_range"):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
records = read_classnotes(classnotes_path)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
return close_time_binding_record(summary, records)
|
||||||
|
|
||||||
|
|
||||||
|
def auto_bound_class_record_context(summary: dict, classnotes_path: Path) -> dict | None:
|
||||||
|
record = close_time_existing_class_record(summary, classnotes_path)
|
||||||
|
if record is None:
|
||||||
|
return None
|
||||||
|
summary_time = normalize_time_range_text(summary.get("time_range"))
|
||||||
|
return {
|
||||||
|
"record": record,
|
||||||
|
"record_line": class_record_to_line(record),
|
||||||
|
"summary_time_range": summary_time,
|
||||||
|
"record_time_range": record.time,
|
||||||
|
"status": "自动绑定",
|
||||||
|
"reason": "只有时间段不一致,起止时间差均在20分钟内,已自动绑定",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def auto_bound_course_summary_item(
|
||||||
|
operation_logs_path: Path,
|
||||||
|
operation: str,
|
||||||
|
source_id: str,
|
||||||
|
normalized: dict,
|
||||||
|
saved: dict,
|
||||||
|
bound_context: dict,
|
||||||
|
*,
|
||||||
|
batch_id: str = "",
|
||||||
|
duplicate_tasks: dict | None = None,
|
||||||
|
) -> tuple[str, list[str], dict]:
|
||||||
|
log_id = append_operation_log(
|
||||||
|
operation_logs_path,
|
||||||
|
operation,
|
||||||
|
"自动绑定",
|
||||||
|
batch_id=batch_id,
|
||||||
|
source_id=source_id,
|
||||||
|
student=normalized["student"],
|
||||||
|
teacher=normalized.get("teacher", ""),
|
||||||
|
subject=normalized.get("subject", ""),
|
||||||
|
proposed_line=str(bound_context.get("record_line") or ""),
|
||||||
|
saved_path=str(saved.get("path") or ""),
|
||||||
|
summary_time_range=str(bound_context.get("summary_time_range") or ""),
|
||||||
|
record_time_range=str(bound_context.get("record_time_range") or ""),
|
||||||
|
reasons=[str(bound_context.get("reason") or "")],
|
||||||
|
)
|
||||||
|
operation_log_ids = [log_id]
|
||||||
|
if duplicate_tasks and duplicate_tasks.get("created"):
|
||||||
|
scan_log_id = append_operation_log(
|
||||||
|
operation_logs_path,
|
||||||
|
"重复小结扫描",
|
||||||
|
"待审核",
|
||||||
|
batch_id=batch_id,
|
||||||
|
source_id=source_id,
|
||||||
|
student=normalized["student"],
|
||||||
|
created_tasks=duplicate_tasks["created"],
|
||||||
|
)
|
||||||
|
operation_log_ids.append(scan_log_id)
|
||||||
|
return (
|
||||||
|
"自动绑定",
|
||||||
|
operation_log_ids,
|
||||||
|
{
|
||||||
|
"source_id": source_id,
|
||||||
|
"status": "自动绑定",
|
||||||
|
"task_id": None,
|
||||||
|
"backup_id": "",
|
||||||
|
"reasons": [str(bound_context.get("reason") or "")],
|
||||||
|
"record_line": str(bound_context.get("record_line") or ""),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def auto_register_reasons(summary: dict, classnotes_path: Path, accounts_path: Path) -> tuple[list[str], str]:
|
def auto_register_reasons(summary: dict, classnotes_path: Path, accounts_path: Path) -> tuple[list[str], str]:
|
||||||
reasons: list[str] = []
|
reasons: list[str] = []
|
||||||
confidence = str(summary.get("confidence") or "").lower()
|
confidence = str(summary.get("confidence") or "").lower()
|
||||||
@@ -3723,6 +3900,44 @@ def register_course_summary_texts(
|
|||||||
summaries_root,
|
summaries_root,
|
||||||
target_key=course_summary_duplicate_key(normalized),
|
target_key=course_summary_duplicate_key(normalized),
|
||||||
)
|
)
|
||||||
|
bound_context = auto_bound_class_record_context(normalized, classnotes_path)
|
||||||
|
if bound_context is not None:
|
||||||
|
seen_source_ids.add(source_id)
|
||||||
|
seen_semantic_keys.add(semantic_key)
|
||||||
|
state["seen_source_ids"] = sorted(seen_source_ids)
|
||||||
|
state["seen_semantic_keys"] = sorted(seen_semantic_keys)
|
||||||
|
state.setdefault("batches", []).append(
|
||||||
|
{
|
||||||
|
"batch_id": f"manual-{datetime.now().strftime('%Y%m%d%H%M%S')}-{sha1_text(source_id, 8)}",
|
||||||
|
"received_at": now,
|
||||||
|
"window": {"source": "admin_register", "submitted_at": now},
|
||||||
|
"students": [normalized["student"]],
|
||||||
|
"result": {
|
||||||
|
"received": 1,
|
||||||
|
"saved": 1 if saved.get("added") else 0,
|
||||||
|
"auto_registered": 1,
|
||||||
|
"review_pending": 0,
|
||||||
|
"duplicates": 0,
|
||||||
|
"rejected": 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
state["batches"] = state["batches"][-200:]
|
||||||
|
write_course_summary_state(state_path, state)
|
||||||
|
_status_value, log_ids, item = auto_bound_course_summary_item(
|
||||||
|
operation_logs_path,
|
||||||
|
"课程小结登记",
|
||||||
|
source_id,
|
||||||
|
normalized,
|
||||||
|
saved,
|
||||||
|
bound_context,
|
||||||
|
duplicate_tasks=duplicate_tasks,
|
||||||
|
)
|
||||||
|
result["saved"] += 1 if saved.get("added") else 0
|
||||||
|
result["auto_registered"] += 1
|
||||||
|
result["operation_log_ids"].extend(log_ids)
|
||||||
|
result["items"].append(item)
|
||||||
|
continue
|
||||||
register_result = register_class_record_lines(classnotes_path, accounts_path, line=proposed_line)
|
register_result = register_class_record_lines(classnotes_path, accounts_path, line=proposed_line)
|
||||||
seen_source_ids.add(source_id)
|
seen_source_ids.add(source_id)
|
||||||
seen_semantic_keys.add(semantic_key)
|
seen_semantic_keys.add(semantic_key)
|
||||||
@@ -3916,6 +4131,24 @@ def ingest_course_summaries(
|
|||||||
reason = f"脚本追问:{question}"
|
reason = f"脚本追问:{question}"
|
||||||
if reason not in reasons:
|
if reason not in reasons:
|
||||||
reasons.append(reason)
|
reasons.append(reason)
|
||||||
|
bound_context = auto_bound_class_record_context(normalized, classnotes_path)
|
||||||
|
if bound_context is not None and not reasons:
|
||||||
|
status_value, log_ids, item = auto_bound_course_summary_item(
|
||||||
|
operation_logs_path,
|
||||||
|
"课程小结接收",
|
||||||
|
source_id,
|
||||||
|
normalized,
|
||||||
|
saved,
|
||||||
|
bound_context,
|
||||||
|
batch_id=batch_id,
|
||||||
|
duplicate_tasks=duplicate_tasks,
|
||||||
|
)
|
||||||
|
seen_source_ids.add(source_id)
|
||||||
|
seen_semantic_keys.add(semantic_key)
|
||||||
|
result["auto_registered"] += 1
|
||||||
|
result["operation_log_ids"].extend(log_ids)
|
||||||
|
result["items"].append(item)
|
||||||
|
continue
|
||||||
if reasons:
|
if reasons:
|
||||||
task = create_course_summary_review_task(
|
task = create_course_summary_review_task(
|
||||||
tasks_path,
|
tasks_path,
|
||||||
@@ -4472,7 +4705,7 @@ def query_public_records(
|
|||||||
matched = filter_records(records, spec) if has_filter_condition(spec) else []
|
matched = filter_records(records, spec) if has_filter_condition(spec) else []
|
||||||
shown = matched[:limit] if limit > 0 else matched
|
shown = matched[:limit] if limit > 0 else matched
|
||||||
display_names = teacher_alias_map(teachers)
|
display_names = teacher_alias_map(teachers)
|
||||||
summary_index = course_summary_index_for_records(summaries_root) if summaries_root is not None else {}
|
summary_index = course_summary_index_for_records(summaries_root, records) if summaries_root is not None else {}
|
||||||
return {
|
return {
|
||||||
"query": {
|
"query": {
|
||||||
"raw_query": spec.raw_query,
|
"raw_query": spec.raw_query,
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
<button class="admin-tab is-active" data-admin-tab="accounts" type="button">课时账户</button>
|
<button class="admin-tab is-active" data-admin-tab="accounts" type="button">课时账户</button>
|
||||||
<button class="admin-tab" data-admin-tab="teachers" type="button">老师档案</button>
|
<button class="admin-tab" data-admin-tab="teachers" type="button">老师档案</button>
|
||||||
<button class="admin-tab" data-admin-tab="reviews" type="button">纠错审核</button>
|
<button class="admin-tab" data-admin-tab="reviews" type="button">纠错审核</button>
|
||||||
<button class="admin-tab" data-admin-tab="summarySearch" type="button">课程小结查询</button>
|
<button class="admin-tab" data-admin-tab="summarySearch" type="button">课程小结</button>
|
||||||
<button class="admin-tab" data-admin-tab="logs" type="button">操作记录</button>
|
<button class="admin-tab" data-admin-tab="logs" type="button">操作记录</button>
|
||||||
<button class="admin-tab" data-admin-tab="register" type="button">登记</button>
|
<button class="admin-tab" data-admin-tab="register" type="button">登记</button>
|
||||||
</nav>
|
</nav>
|
||||||
@@ -293,8 +293,9 @@
|
|||||||
|
|
||||||
<section id="summarySearchPanel" class="panel admin-panel" hidden>
|
<section id="summarySearchPanel" class="panel admin-panel" hidden>
|
||||||
<div class="section-head">
|
<div class="section-head">
|
||||||
<h2>课程小结查询</h2>
|
<h2>课程小结</h2>
|
||||||
<div class="quick-actions">
|
<div class="quick-actions">
|
||||||
|
<button id="quickMismatchBtn" class="chip" type="button">快速处理字段不一致</button>
|
||||||
<button class="chip duplicate-summary-scan" type="button">扫描重复小结</button>
|
<button class="chip duplicate-summary-scan" type="button">扫描重复小结</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -396,6 +397,7 @@
|
|||||||
<option value="">全部结果</option>
|
<option value="">全部结果</option>
|
||||||
<option value="完成">完成</option>
|
<option value="完成">完成</option>
|
||||||
<option value="自动入账">自动入账</option>
|
<option value="自动入账">自动入账</option>
|
||||||
|
<option value="自动绑定">自动绑定</option>
|
||||||
<option value="待审核">待审核</option>
|
<option value="待审核">待审核</option>
|
||||||
<option value="重复">重复</option>
|
<option value="重复">重复</option>
|
||||||
<option value="已驳回">失败/驳回</option>
|
<option value="已驳回">失败/驳回</option>
|
||||||
@@ -467,6 +469,6 @@
|
|||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<script src="/static/admin.js?v=20260620-admin-health-summary"></script>
|
<script src="/static/admin.js?v=20260620-course-summary-auto-bind"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+111
-5
@@ -84,6 +84,7 @@ const summarySearchBindingStatus = document.querySelector("#summarySearchBinding
|
|||||||
const summarySearchHasCandidate = document.querySelector("#summarySearchHasCandidate");
|
const summarySearchHasCandidate = document.querySelector("#summarySearchHasCandidate");
|
||||||
const summarySearchMeta = document.querySelector("#summarySearchMeta");
|
const summarySearchMeta = document.querySelector("#summarySearchMeta");
|
||||||
const summarySearchRows = document.querySelector("#summarySearchRows");
|
const summarySearchRows = document.querySelector("#summarySearchRows");
|
||||||
|
const quickMismatchBtn = document.querySelector("#quickMismatchBtn");
|
||||||
const logOperation = document.querySelector("#logOperation");
|
const logOperation = document.querySelector("#logOperation");
|
||||||
const logStatus = document.querySelector("#logStatus");
|
const logStatus = document.querySelector("#logStatus");
|
||||||
const logFilterForm = document.querySelector("#logFilterForm");
|
const logFilterForm = document.querySelector("#logFilterForm");
|
||||||
@@ -116,6 +117,8 @@ let currentSummarySearchItems = [];
|
|||||||
let expandedSummarySearchId = "";
|
let expandedSummarySearchId = "";
|
||||||
let editingSummarySearchId = "";
|
let editingSummarySearchId = "";
|
||||||
let editingSummaryIdentityId = "";
|
let editingSummaryIdentityId = "";
|
||||||
|
let quickSummaryMismatchMode = false;
|
||||||
|
let quickSummaryProcessing = false;
|
||||||
let currentOperationLogs = [];
|
let currentOperationLogs = [];
|
||||||
let expandedOperationLogId = "";
|
let expandedOperationLogId = "";
|
||||||
const registerPreviewState = {
|
const registerPreviewState = {
|
||||||
@@ -161,7 +164,7 @@ function statusClass(status) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function taskStatusClass(status) {
|
function taskStatusClass(status) {
|
||||||
if (["approved", "auto_registered", "已批准", "自动入账", "完成", "已更新", "已删除"].includes(status)) return "normal";
|
if (["approved", "auto_registered", "已批准", "自动入账", "自动绑定", "完成", "已更新", "已删除"].includes(status)) return "normal";
|
||||||
if (["rejected", "duplicate", "已驳回", "重复"].includes(status)) return "closed";
|
if (["rejected", "duplicate", "已驳回", "重复"].includes(status)) return "closed";
|
||||||
if (status === "conflict" || status === "冲突") return "debt";
|
if (status === "conflict" || status === "冲突") return "debt";
|
||||||
return "warning";
|
return "warning";
|
||||||
@@ -999,6 +1002,44 @@ function summarizeSummaryBindings(items) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mismatchItemsWithCandidates() {
|
||||||
|
return currentSummarySearchItems.filter((item) => {
|
||||||
|
const binding = summaryBinding(item);
|
||||||
|
return binding.status === "mismatch" && Array.isArray(binding.candidates) && binding.candidates.length;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstMismatchWithCandidate() {
|
||||||
|
return mismatchItemsWithCandidates()[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentQuickMismatchItem() {
|
||||||
|
const items = mismatchItemsWithCandidates();
|
||||||
|
return items.find((item) => String(item.id) === String(expandedSummarySearchId)) || items[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateQuickMismatchButton() {
|
||||||
|
if (!quickMismatchBtn) return;
|
||||||
|
const remaining = mismatchItemsWithCandidates().length;
|
||||||
|
quickMismatchBtn.disabled = quickSummaryProcessing || (quickSummaryMismatchMode && remaining === 0);
|
||||||
|
if (quickSummaryProcessing) {
|
||||||
|
quickMismatchBtn.textContent = "正在处理";
|
||||||
|
} else if (!quickSummaryMismatchMode) {
|
||||||
|
quickMismatchBtn.textContent = "快速处理字段不一致";
|
||||||
|
} else if (remaining) {
|
||||||
|
quickMismatchBtn.textContent = `按首个候选修正并下一条(剩 ${remaining})`;
|
||||||
|
} else {
|
||||||
|
quickMismatchBtn.textContent = "字段不一致已处理完";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectFirstQuickMismatchItem() {
|
||||||
|
const item = firstMismatchWithCandidate();
|
||||||
|
expandedSummarySearchId = item ? String(item.id) : "";
|
||||||
|
editingSummarySearchId = "";
|
||||||
|
editingSummaryIdentityId = "";
|
||||||
|
}
|
||||||
|
|
||||||
function renderSummaryBindingDetail(item) {
|
function renderSummaryBindingDetail(item) {
|
||||||
const binding = summaryBinding(item);
|
const binding = summaryBinding(item);
|
||||||
if (binding.status === "matched" && binding.record) {
|
if (binding.status === "matched" && binding.record) {
|
||||||
@@ -1085,6 +1126,12 @@ async function loadSummarySearch() {
|
|||||||
if (!currentSummarySearchItems.some((item) => String(item.id) === expandedSummarySearchId)) expandedSummarySearchId = "";
|
if (!currentSummarySearchItems.some((item) => String(item.id) === expandedSummarySearchId)) expandedSummarySearchId = "";
|
||||||
if (!currentSummarySearchItems.some((item) => String(item.id) === editingSummarySearchId)) editingSummarySearchId = "";
|
if (!currentSummarySearchItems.some((item) => String(item.id) === editingSummarySearchId)) editingSummarySearchId = "";
|
||||||
if (!currentSummarySearchItems.some((item) => String(item.id) === editingSummaryIdentityId)) editingSummaryIdentityId = "";
|
if (!currentSummarySearchItems.some((item) => String(item.id) === editingSummaryIdentityId)) editingSummaryIdentityId = "";
|
||||||
|
if (
|
||||||
|
quickSummaryMismatchMode
|
||||||
|
&& !mismatchItemsWithCandidates().some((item) => String(item.id) === String(expandedSummarySearchId))
|
||||||
|
) {
|
||||||
|
selectFirstQuickMismatchItem();
|
||||||
|
}
|
||||||
const bindingSummary = summarizeSummaryBindings(currentSummarySearchItems);
|
const bindingSummary = summarizeSummaryBindings(currentSummarySearchItems);
|
||||||
summarySearchMeta.innerHTML = [
|
summarySearchMeta.innerHTML = [
|
||||||
metric("命中小结", `${data.count} 条`),
|
metric("命中小结", `${data.count} 条`),
|
||||||
@@ -1094,9 +1141,11 @@ async function loadSummarySearch() {
|
|||||||
metric("有候选", `${bindingSummary.withCandidate} 条`),
|
metric("有候选", `${bindingSummary.withCandidate} 条`),
|
||||||
].join("");
|
].join("");
|
||||||
renderCurrentSummarySearch();
|
renderCurrentSummarySearch();
|
||||||
|
updateQuickMismatchButton();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
currentSummarySearchItems = [];
|
currentSummarySearchItems = [];
|
||||||
summarySearchRows.innerHTML = `<tr><td colspan="5" class="empty">读取失败:${escapeHtml(error.message)}</td></tr>`;
|
summarySearchRows.innerHTML = `<tr><td colspan="5" class="empty">读取失败:${escapeHtml(error.message)}</td></tr>`;
|
||||||
|
updateQuickMismatchButton();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1150,7 +1199,7 @@ function findCurrentSummary(summaryId) {
|
|||||||
return currentSummarySearchItems.find((item) => String(item.id) === String(summaryId));
|
return currentSummarySearchItems.find((item) => String(item.id) === String(summaryId));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function applySummaryBindingCandidate(summaryId, record) {
|
async function applySummaryBindingCandidate(summaryId, record, options = {}) {
|
||||||
const item = findCurrentSummary(summaryId);
|
const item = findCurrentSummary(summaryId);
|
||||||
if (!item) throw new Error("未找到当前课程小结");
|
if (!item) throw new Error("未找到当前课程小结");
|
||||||
let activeSummaryId = summaryId;
|
let activeSummaryId = summaryId;
|
||||||
@@ -1180,11 +1229,53 @@ async function applySummaryBindingCandidate(summaryId, record) {
|
|||||||
}
|
}
|
||||||
editingSummaryIdentityId = "";
|
editingSummaryIdentityId = "";
|
||||||
editingSummarySearchId = "";
|
editingSummarySearchId = "";
|
||||||
expandedSummarySearchId = activeSummaryId;
|
expandedSummarySearchId = options.next ? "" : activeSummaryId;
|
||||||
await loadSummarySearch();
|
await loadSummarySearch();
|
||||||
await loadOperationLogs();
|
await loadOperationLogs();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function startQuickMismatchMode() {
|
||||||
|
quickSummaryMismatchMode = true;
|
||||||
|
quickSummaryProcessing = false;
|
||||||
|
summarySearchBindingStatus.value = "mismatch";
|
||||||
|
summarySearchHasCandidate.value = "true";
|
||||||
|
await loadSummarySearch();
|
||||||
|
selectFirstQuickMismatchItem();
|
||||||
|
renderCurrentSummarySearch();
|
||||||
|
updateQuickMismatchButton();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function processQuickMismatchItem() {
|
||||||
|
if (!quickSummaryMismatchMode) {
|
||||||
|
await startQuickMismatchMode();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const item = currentQuickMismatchItem();
|
||||||
|
const binding = item ? summaryBinding(item) : null;
|
||||||
|
const candidate = binding && Array.isArray(binding.candidates) ? binding.candidates[0] : null;
|
||||||
|
if (!item || !candidate) {
|
||||||
|
selectFirstQuickMismatchItem();
|
||||||
|
renderCurrentSummarySearch();
|
||||||
|
updateQuickMismatchButton();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
quickSummaryProcessing = true;
|
||||||
|
updateQuickMismatchButton();
|
||||||
|
try {
|
||||||
|
await applySummaryBindingCandidate(item.id, {
|
||||||
|
student: candidate.student,
|
||||||
|
teacher: candidate.teacher,
|
||||||
|
time: candidate.time,
|
||||||
|
subject: candidate.subject,
|
||||||
|
}, { next: true });
|
||||||
|
selectFirstQuickMismatchItem();
|
||||||
|
renderCurrentSummarySearch();
|
||||||
|
} finally {
|
||||||
|
quickSummaryProcessing = false;
|
||||||
|
updateQuickMismatchButton();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function saveSummaryReviewEdits() {
|
async function saveSummaryReviewEdits() {
|
||||||
if (!activeSummaryReview) return;
|
if (!activeSummaryReview) return;
|
||||||
try {
|
try {
|
||||||
@@ -1770,10 +1861,24 @@ document.addEventListener("keydown", (event) => {
|
|||||||
});
|
});
|
||||||
summarySearchForm.addEventListener("submit", (event) => {
|
summarySearchForm.addEventListener("submit", (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
quickSummaryMismatchMode = false;
|
||||||
loadSummarySearch();
|
loadSummarySearch();
|
||||||
});
|
});
|
||||||
summarySearchBindingStatus.addEventListener("change", loadSummarySearch);
|
summarySearchBindingStatus.addEventListener("change", () => {
|
||||||
summarySearchHasCandidate.addEventListener("change", loadSummarySearch);
|
quickSummaryMismatchMode = false;
|
||||||
|
loadSummarySearch();
|
||||||
|
});
|
||||||
|
summarySearchHasCandidate.addEventListener("change", () => {
|
||||||
|
quickSummaryMismatchMode = false;
|
||||||
|
loadSummarySearch();
|
||||||
|
});
|
||||||
|
quickMismatchBtn.addEventListener("click", () => {
|
||||||
|
processQuickMismatchItem().catch((error) => {
|
||||||
|
quickSummaryProcessing = false;
|
||||||
|
updateQuickMismatchButton();
|
||||||
|
alert(error.message);
|
||||||
|
});
|
||||||
|
});
|
||||||
summarySearchRows.addEventListener("click", (event) => {
|
summarySearchRows.addEventListener("click", (event) => {
|
||||||
const saveTime = event.target.closest(".summary-time-save");
|
const saveTime = event.target.closest(".summary-time-save");
|
||||||
const deleteSummary = event.target.closest(".summary-delete");
|
const deleteSummary = event.target.closest(".summary-delete");
|
||||||
@@ -1928,4 +2033,5 @@ refreshBtn.addEventListener("click", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
loadAdminHealth();
|
loadAdminHealth();
|
||||||
|
updateQuickMismatchButton();
|
||||||
loadAccounts();
|
loadAccounts();
|
||||||
|
|||||||
Reference in New Issue
Block a user