feat: add course summary search
This commit is contained in:
+1
-1
@@ -83,7 +83,7 @@ git push origin HEAD:<当前分支>
|
|||||||
/root/新时空数据/data/operation_logs.jsonl
|
/root/新时空数据/data/operation_logs.jsonl
|
||||||
```
|
```
|
||||||
|
|
||||||
普通前端和查询类改动不应该改变这些文件。部署前后 `make data-hash` 输出应一致;如果涉及登记 API、课时账户编辑、课程小结自动入账或审核批准,先确认自动备份目录:
|
普通前端和查询类改动不应该改变这些文件;课程小结查询页是只读功能,也不应该改变这些文件。部署前后 `make data-hash` 输出应一致;如果涉及登记 API、课时账户编辑、课程小结自动入账或审核批准,先确认自动备份目录:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
/root/新时空数据/data/backups/
|
/root/新时空数据/data/backups/
|
||||||
|
|||||||
@@ -191,4 +191,5 @@ docker compose up -d
|
|||||||
- `POST /api/admin/tasks/{task_id}/approve`:批准纠错并写入正式上课记录。
|
- `POST /api/admin/tasks/{task_id}/approve`:批准纠错并写入正式上课记录。
|
||||||
- `POST /api/admin/tasks/{task_id}/reject`:驳回纠错。
|
- `POST /api/admin/tasks/{task_id}/reject`:驳回纠错。
|
||||||
- `POST /api/ingest/course-summaries`:本机采集脚本批量推送课程小结,使用 `X-INGEST-TOKEN` 鉴权。
|
- `POST /api/ingest/course-summaries`:本机采集脚本批量推送课程小结,使用 `X-INGEST-TOKEN` 鉴权。
|
||||||
|
- `GET /api/admin/course-summaries`:管理后台只读查询正式课程小结库。
|
||||||
- `GET /api/admin/operation-logs`:管理后台读取小结接收、自动入账、审核批准和驳回记录。
|
- `GET /api/admin/operation-logs`:管理后台读取小结接收、自动入账、审核批准和驳回记录。
|
||||||
|
|||||||
+165
@@ -42,6 +42,8 @@ CLASSNOTE_RE = re.compile(
|
|||||||
)
|
)
|
||||||
PAYMENT_LINE_RE = re.compile(r"^(?P<student>.+?)-(?P<date>\d{4}-\d{2}-\d{2}):(?P<hours>\d+(?:\.\d+)?)$")
|
PAYMENT_LINE_RE = re.compile(r"^(?P<student>.+?)-(?P<date>\d{4}-\d{2}-\d{2}):(?P<hours>\d+(?:\.\d+)?)$")
|
||||||
TIME_RANGE_RE = re.compile(r"^(?P<sh>\d{1,2}):(?P<sm>\d{2})-(?P<eh>\d{1,2}):(?P<em>\d{2})$")
|
TIME_RANGE_RE = re.compile(r"^(?P<sh>\d{1,2}):(?P<sm>\d{2})-(?P<eh>\d{1,2}):(?P<em>\d{2})$")
|
||||||
|
COURSE_SUMMARY_HEADING_RE = re.compile(r"^###\s+(?P<title>.+)$", re.M)
|
||||||
|
COURSE_SUMMARY_DATE_RE = re.compile(r"(?P<date>\d{4}[.-]\d{1,2}[.-]\d{1,2})")
|
||||||
BACKUP_DIR_RE = re.compile(r"^\d{8}-\d{6}-\d{6}-")
|
BACKUP_DIR_RE = re.compile(r"^\d{8}-\d{6}-\d{6}-")
|
||||||
BACKUP_KEEP_COUNT = 50
|
BACKUP_KEEP_COUNT = 50
|
||||||
ACCOUNT_STATUSES = {"正常", "预警", "欠费", "结课", "退费"}
|
ACCOUNT_STATUSES = {"正常", "预警", "欠费", "结课", "退费"}
|
||||||
@@ -1059,6 +1061,169 @@ def list_operation_logs(path: Path, limit: int = 100, operation: str = "", statu
|
|||||||
return {"count": len(rows), "items": rows}
|
return {"count": len(rows), "items": rows}
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_filter_date(value: str) -> str:
|
||||||
|
text = value.strip().replace(".", "-")
|
||||||
|
if not text:
|
||||||
|
return ""
|
||||||
|
parts = text.split("-")
|
||||||
|
if len(parts) == 3:
|
||||||
|
text = f"{int(parts[0]):04d}-{int(parts[1]):02d}-{int(parts[2]):02d}"
|
||||||
|
return date.fromisoformat(text).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def parse_course_summary_file_identity(root: Path, path: Path) -> dict:
|
||||||
|
relative = path.relative_to(root)
|
||||||
|
student = canonical_name(relative.parts[0]) if relative.parts else ""
|
||||||
|
stem = path.stem
|
||||||
|
parts = stem.split("_")
|
||||||
|
teacher = ""
|
||||||
|
subject = ""
|
||||||
|
if len(parts) >= 3:
|
||||||
|
student = canonical_name(parts[0])
|
||||||
|
teacher = canonical_name("_".join(parts[1:-1]))
|
||||||
|
subject = parts[-1]
|
||||||
|
return {
|
||||||
|
"student": student,
|
||||||
|
"teacher": teacher,
|
||||||
|
"subject": normalize_subject(subject),
|
||||||
|
"source_path": str(path),
|
||||||
|
"relative_path": str(relative),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def parse_course_summary_title_date(title: str) -> str:
|
||||||
|
match = COURSE_SUMMARY_DATE_RE.search(title)
|
||||||
|
if not match:
|
||||||
|
return ""
|
||||||
|
raw = match.group("date").replace(".", "-")
|
||||||
|
parts = raw.split("-")
|
||||||
|
if len(parts) != 3:
|
||||||
|
return ""
|
||||||
|
normalized = f"{int(parts[0]):04d}-{int(parts[1]):02d}-{int(parts[2]):02d}"
|
||||||
|
try:
|
||||||
|
return date.fromisoformat(normalized).isoformat()
|
||||||
|
except ValueError:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def iter_course_summary_markdown(root: Path) -> Iterable[dict]:
|
||||||
|
if not root.exists():
|
||||||
|
return
|
||||||
|
for path in sorted(root.rglob("*.md")):
|
||||||
|
if not path.is_file():
|
||||||
|
continue
|
||||||
|
text = path.read_text(encoding="utf-8")
|
||||||
|
matches = list(COURSE_SUMMARY_HEADING_RE.finditer(text))
|
||||||
|
if not matches:
|
||||||
|
continue
|
||||||
|
identity = parse_course_summary_file_identity(root, path)
|
||||||
|
group = ""
|
||||||
|
group_match = re.search(r"^##\s+(.+)$", text[: matches[0].start()], flags=re.M)
|
||||||
|
if group_match:
|
||||||
|
group = group_match.group(1).strip()
|
||||||
|
for index, match in enumerate(matches):
|
||||||
|
title = match.group("title").strip()
|
||||||
|
start = match.end()
|
||||||
|
end = matches[index + 1].start() if index + 1 < len(matches) else len(text)
|
||||||
|
body = text[start:end].strip()
|
||||||
|
body_without_meta = re.sub(r"^(?:>\s+.*\n)+\s*", "", body).strip()
|
||||||
|
body_text = body_without_meta or body
|
||||||
|
item_id = sha1_text(f"{identity['relative_path']}|{title}|{index}|{body_text[:200]}", 20)
|
||||||
|
yield {
|
||||||
|
**identity,
|
||||||
|
"id": item_id,
|
||||||
|
"title": title,
|
||||||
|
"date_iso": parse_course_summary_title_date(title),
|
||||||
|
"group": group,
|
||||||
|
"body": body_text,
|
||||||
|
"body_preview": body_text[:260] + ("..." if len(body_text) > 260 else ""),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def course_summary_matches(item: dict, q: str, student: str, teacher: str, subject: str, date_from: str, date_to: str) -> bool:
|
||||||
|
if student and student not in str(item.get("student", "")):
|
||||||
|
return False
|
||||||
|
if teacher and teacher not in str(item.get("teacher", "")):
|
||||||
|
return False
|
||||||
|
if subject and normalize_subject(subject) not in str(item.get("subject", "")):
|
||||||
|
return False
|
||||||
|
item_date = str(item.get("date_iso") or "")
|
||||||
|
if date_from and (not item_date or item_date < date_from):
|
||||||
|
return False
|
||||||
|
if date_to and (not item_date or item_date > date_to):
|
||||||
|
return False
|
||||||
|
if q:
|
||||||
|
haystack = "\n".join(
|
||||||
|
str(item.get(key, ""))
|
||||||
|
for key in ("student", "teacher", "subject", "title", "group", "body", "relative_path")
|
||||||
|
)
|
||||||
|
return q in haystack
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def course_summary_matched_fields(item: dict, q: str) -> list[str]:
|
||||||
|
if not q:
|
||||||
|
return []
|
||||||
|
labels = {
|
||||||
|
"student": "学生",
|
||||||
|
"teacher": "老师",
|
||||||
|
"subject": "科目",
|
||||||
|
"title": "标题",
|
||||||
|
"group": "群名",
|
||||||
|
"body": "正文",
|
||||||
|
"relative_path": "来源",
|
||||||
|
}
|
||||||
|
return [label for key, label in labels.items() if q in str(item.get(key, ""))]
|
||||||
|
|
||||||
|
|
||||||
|
def query_course_summaries(
|
||||||
|
root: Path,
|
||||||
|
q: str = "",
|
||||||
|
student: str = "",
|
||||||
|
teacher: str = "",
|
||||||
|
subject: str = "",
|
||||||
|
date_from: str = "",
|
||||||
|
date_to: str = "",
|
||||||
|
limit: int = 200,
|
||||||
|
) -> dict:
|
||||||
|
normalized_from = normalize_filter_date(date_from)
|
||||||
|
normalized_to = normalize_filter_date(date_to)
|
||||||
|
if normalized_from and normalized_to and normalized_from > normalized_to:
|
||||||
|
raise ValueError("开始日期不能晚于结束日期")
|
||||||
|
keyword = q.strip()
|
||||||
|
matched = [
|
||||||
|
item
|
||||||
|
for item in iter_course_summary_markdown(root)
|
||||||
|
if course_summary_matches(
|
||||||
|
item,
|
||||||
|
keyword,
|
||||||
|
canonical_name(student.strip()),
|
||||||
|
canonical_name(teacher.strip()),
|
||||||
|
subject.strip(),
|
||||||
|
normalized_from,
|
||||||
|
normalized_to,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
for item in matched:
|
||||||
|
item["matched_fields"] = course_summary_matched_fields(item, keyword)
|
||||||
|
matched.sort(
|
||||||
|
key=lambda item: (
|
||||||
|
str(item.get("date_iso") or "0000-00-00"),
|
||||||
|
str(item.get("student") or ""),
|
||||||
|
str(item.get("teacher") or ""),
|
||||||
|
str(item.get("subject") or ""),
|
||||||
|
str(item.get("title") or ""),
|
||||||
|
),
|
||||||
|
reverse=True,
|
||||||
|
)
|
||||||
|
limited = matched[:limit]
|
||||||
|
return {
|
||||||
|
"count": len(matched),
|
||||||
|
"returned": len(limited),
|
||||||
|
"items": limited,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def course_summary_path(root: Path, summary: dict) -> Path:
|
def course_summary_path(root: Path, summary: dict) -> Path:
|
||||||
student = safe_filename_part(summary["student"])
|
student = safe_filename_part(summary["student"])
|
||||||
teacher = safe_filename_part(summary["teacher"] or "待核对老师")
|
teacher = safe_filename_part(summary["teacher"] or "待核对老师")
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ from .data import (
|
|||||||
ingest_course_summaries,
|
ingest_course_summaries,
|
||||||
list_operation_logs,
|
list_operation_logs,
|
||||||
list_admin_tasks,
|
list_admin_tasks,
|
||||||
|
query_course_summaries,
|
||||||
query_records,
|
query_records,
|
||||||
read_accounts,
|
read_accounts,
|
||||||
read_classnotes,
|
read_classnotes,
|
||||||
@@ -842,6 +843,32 @@ def admin_operation_logs(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/admin/course-summaries")
|
||||||
|
def admin_course_summaries(
|
||||||
|
q: str = Query(""),
|
||||||
|
student: str = Query(""),
|
||||||
|
teacher: str = Query(""),
|
||||||
|
subject: str = Query(""),
|
||||||
|
date_from: str = Query(""),
|
||||||
|
date_to: str = Query(""),
|
||||||
|
limit: int = Query(200, ge=1, le=1000),
|
||||||
|
_user: str = Depends(verify_admin_auth),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
return query_course_summaries(
|
||||||
|
COURSE_SUMMARIES_ROOT,
|
||||||
|
q=q,
|
||||||
|
student=student,
|
||||||
|
teacher=teacher,
|
||||||
|
subject=subject,
|
||||||
|
date_from=date_from,
|
||||||
|
date_to=date_to,
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/admin/tasks/{task_id}/approve")
|
@app.post("/api/admin/tasks/{task_id}/approve")
|
||||||
def admin_approve_task(task_id: int, _user: str = Depends(verify_admin_auth)):
|
def admin_approve_task(task_id: int, _user: str = Depends(verify_admin_auth)):
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -24,6 +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="reviews" type="button">纠错审核</button>
|
<button class="admin-tab" data-admin-tab="reviews" type="button">纠错审核</button>
|
||||||
<button class="admin-tab" data-admin-tab="summaries" type="button">课程小结审核</button>
|
<button class="admin-tab" data-admin-tab="summaries" 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>
|
||||||
@@ -168,6 +169,37 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section id="summarySearchPanel" class="panel admin-panel" hidden>
|
||||||
|
<div class="section-head">
|
||||||
|
<h2>课程小结查询</h2>
|
||||||
|
</div>
|
||||||
|
<form id="summarySearchForm" class="search-row summary-search-row">
|
||||||
|
<input id="summarySearchQuery" autocomplete="off" placeholder="关键词:课堂内容、作业、问题等" />
|
||||||
|
<input id="summarySearchStudent" autocomplete="off" placeholder="学生" />
|
||||||
|
<input id="summarySearchTeacher" autocomplete="off" placeholder="老师" />
|
||||||
|
<input id="summarySearchSubject" autocomplete="off" placeholder="科目" />
|
||||||
|
<input id="summarySearchDateFrom" type="date" aria-label="开始日期" />
|
||||||
|
<input id="summarySearchDateTo" type="date" aria-label="结束日期" />
|
||||||
|
<button type="submit">查询</button>
|
||||||
|
</form>
|
||||||
|
<div id="summarySearchMeta" class="summary-grid"></div>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>日期</th>
|
||||||
|
<th>学生</th>
|
||||||
|
<th>老师/科目</th>
|
||||||
|
<th>标题</th>
|
||||||
|
<th>小结正文</th>
|
||||||
|
<th>来源</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="summarySearchRows"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section id="logsPanel" class="panel admin-panel" hidden>
|
<section id="logsPanel" class="panel admin-panel" hidden>
|
||||||
<div class="section-head">
|
<div class="section-head">
|
||||||
<h2>操作记录</h2>
|
<h2>操作记录</h2>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ const panels = {
|
|||||||
accounts: document.querySelector("#accountsPanel"),
|
accounts: document.querySelector("#accountsPanel"),
|
||||||
reviews: document.querySelector("#reviewsPanel"),
|
reviews: document.querySelector("#reviewsPanel"),
|
||||||
summaries: document.querySelector("#summariesPanel"),
|
summaries: document.querySelector("#summariesPanel"),
|
||||||
|
summarySearch: document.querySelector("#summarySearchPanel"),
|
||||||
logs: document.querySelector("#logsPanel"),
|
logs: document.querySelector("#logsPanel"),
|
||||||
register: document.querySelector("#registerPanel"),
|
register: document.querySelector("#registerPanel"),
|
||||||
};
|
};
|
||||||
@@ -30,6 +31,15 @@ const reviewRows = document.querySelector("#reviewRows");
|
|||||||
const summaryReviewStatus = document.querySelector("#summaryReviewStatus");
|
const summaryReviewStatus = document.querySelector("#summaryReviewStatus");
|
||||||
const summaryReviewMeta = document.querySelector("#summaryReviewMeta");
|
const summaryReviewMeta = document.querySelector("#summaryReviewMeta");
|
||||||
const summaryReviewRows = document.querySelector("#summaryReviewRows");
|
const summaryReviewRows = document.querySelector("#summaryReviewRows");
|
||||||
|
const summarySearchForm = document.querySelector("#summarySearchForm");
|
||||||
|
const summarySearchQuery = document.querySelector("#summarySearchQuery");
|
||||||
|
const summarySearchStudent = document.querySelector("#summarySearchStudent");
|
||||||
|
const summarySearchTeacher = document.querySelector("#summarySearchTeacher");
|
||||||
|
const summarySearchSubject = document.querySelector("#summarySearchSubject");
|
||||||
|
const summarySearchDateFrom = document.querySelector("#summarySearchDateFrom");
|
||||||
|
const summarySearchDateTo = document.querySelector("#summarySearchDateTo");
|
||||||
|
const summarySearchMeta = document.querySelector("#summarySearchMeta");
|
||||||
|
const summarySearchRows = document.querySelector("#summarySearchRows");
|
||||||
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");
|
||||||
@@ -112,6 +122,7 @@ function setActiveTab(tabName) {
|
|||||||
if (tabName === "accounts") loadAccounts();
|
if (tabName === "accounts") loadAccounts();
|
||||||
if (tabName === "reviews") loadReviews();
|
if (tabName === "reviews") loadReviews();
|
||||||
if (tabName === "summaries") loadSummaryReviews();
|
if (tabName === "summaries") loadSummaryReviews();
|
||||||
|
if (tabName === "summarySearch") loadSummarySearch();
|
||||||
if (tabName === "logs") loadOperationLogs();
|
if (tabName === "logs") loadOperationLogs();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -343,6 +354,56 @@ async function loadSummaryReviews() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function summarySearchParams() {
|
||||||
|
const params = new URLSearchParams({ limit: "200" });
|
||||||
|
if (summarySearchQuery.value.trim()) params.set("q", summarySearchQuery.value.trim());
|
||||||
|
if (summarySearchStudent.value.trim()) params.set("student", summarySearchStudent.value.trim());
|
||||||
|
if (summarySearchTeacher.value.trim()) params.set("teacher", summarySearchTeacher.value.trim());
|
||||||
|
if (summarySearchSubject.value.trim()) params.set("subject", summarySearchSubject.value.trim());
|
||||||
|
if (summarySearchDateFrom.value) params.set("date_from", summarySearchDateFrom.value);
|
||||||
|
if (summarySearchDateTo.value) params.set("date_to", summarySearchDateTo.value);
|
||||||
|
return params;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSummarySearchBody(item) {
|
||||||
|
const fullId = `summary-full-${item.id}`;
|
||||||
|
const hasFull = String(item.body || "") !== String(item.body_preview || "");
|
||||||
|
return `<div class="summary-body">${escapeHtml(item.body_preview || "")}</div>
|
||||||
|
<div id="${escapeHtml(fullId)}" class="summary-body summary-full" hidden>${escapeHtml(item.body || "")}</div>
|
||||||
|
<button class="small-button summary-toggle" type="button" data-target="${escapeHtml(fullId)}" ${hasFull ? "" : "hidden"}>展开</button>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMatchedFields(item) {
|
||||||
|
const fields = Array.isArray(item.matched_fields) ? item.matched_fields : [];
|
||||||
|
return fields.length ? `<br><small>命中:${fields.map(escapeHtml).join("、")}</small>` : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSummarySearch() {
|
||||||
|
summarySearchRows.innerHTML = `<tr><td colspan="6" class="empty">正在读取</td></tr>`;
|
||||||
|
try {
|
||||||
|
const data = await fetchJson(`/api/admin/course-summaries?${summarySearchParams().toString()}`);
|
||||||
|
summarySearchMeta.innerHTML = [
|
||||||
|
metric("命中小结", `${data.count} 条`),
|
||||||
|
metric("当前显示", `${data.returned} 条`),
|
||||||
|
].join("");
|
||||||
|
summarySearchRows.innerHTML = data.items
|
||||||
|
.map((item) => `<tr>
|
||||||
|
<td>${escapeHtml(item.date_iso || "未识别")}</td>
|
||||||
|
<td>${escapeHtml(item.student || "")}<br><small>${escapeHtml(item.group || "")}</small></td>
|
||||||
|
<td>${escapeHtml(item.teacher || "待核对老师")}<br><small>${escapeHtml(item.subject || "待核对科目")}</small></td>
|
||||||
|
<td>${escapeHtml(item.title || "")}${renderMatchedFields(item)}</td>
|
||||||
|
<td>${renderSummarySearchBody(item)}</td>
|
||||||
|
<td><code class="line-code">${escapeHtml(item.relative_path || "")}</code></td>
|
||||||
|
</tr>`)
|
||||||
|
.join("");
|
||||||
|
if (!data.items.length) {
|
||||||
|
summarySearchRows.innerHTML = `<tr><td colspan="6" class="empty">没有符合条件的课程小结</td></tr>`;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
summarySearchRows.innerHTML = `<tr><td colspan="6" class="empty">读取失败:${escapeHtml(error.message)}</td></tr>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function renderLogDetail(item) {
|
function renderLogDetail(item) {
|
||||||
const details = [];
|
const details = [];
|
||||||
if (item.source_id) details.push(`来源:${item.source_id}`);
|
if (item.source_id) details.push(`来源:${item.source_id}`);
|
||||||
@@ -457,6 +518,18 @@ summaryReviewRows.addEventListener("click", (event) => {
|
|||||||
if (approve) summaryReviewTask(approve.dataset.taskId, "approve");
|
if (approve) summaryReviewTask(approve.dataset.taskId, "approve");
|
||||||
if (reject) summaryReviewTask(reject.dataset.taskId, "reject");
|
if (reject) summaryReviewTask(reject.dataset.taskId, "reject");
|
||||||
});
|
});
|
||||||
|
summarySearchForm.addEventListener("submit", (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
loadSummarySearch();
|
||||||
|
});
|
||||||
|
summarySearchRows.addEventListener("click", (event) => {
|
||||||
|
const button = event.target.closest(".summary-toggle");
|
||||||
|
if (!button) return;
|
||||||
|
const target = document.getElementById(button.dataset.target);
|
||||||
|
if (!target) return;
|
||||||
|
target.hidden = !target.hidden;
|
||||||
|
button.textContent = target.hidden ? "展开" : "收起";
|
||||||
|
});
|
||||||
logOperation.addEventListener("change", loadOperationLogs);
|
logOperation.addEventListener("change", loadOperationLogs);
|
||||||
logStatus.addEventListener("change", loadOperationLogs);
|
logStatus.addEventListener("change", loadOperationLogs);
|
||||||
logFilterForm.addEventListener("submit", (event) => {
|
logFilterForm.addEventListener("submit", (event) => {
|
||||||
@@ -474,6 +547,7 @@ refreshBtn.addEventListener("click", () => {
|
|||||||
if (!panels.accounts.hidden) loadAccounts();
|
if (!panels.accounts.hidden) loadAccounts();
|
||||||
if (!panels.reviews.hidden) loadReviews();
|
if (!panels.reviews.hidden) loadReviews();
|
||||||
if (!panels.summaries.hidden) loadSummaryReviews();
|
if (!panels.summaries.hidden) loadSummaryReviews();
|
||||||
|
if (!panels.summarySearch.hidden) loadSummarySearch();
|
||||||
if (!panels.logs.hidden) loadOperationLogs();
|
if (!panels.logs.hidden) loadOperationLogs();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -210,6 +210,10 @@ h2 {
|
|||||||
grid-template-columns: minmax(0, 1fr) 76px;
|
grid-template-columns: minmax(0, 1fr) 76px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.summary-search-row {
|
||||||
|
grid-template-columns: minmax(180px, 1.4fr) repeat(5, minmax(110px, 0.8fr)) 88px;
|
||||||
|
}
|
||||||
|
|
||||||
input,
|
input,
|
||||||
select {
|
select {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@@ -389,6 +393,17 @@ textarea:focus {
|
|||||||
line-height: 1.45;
|
line-height: 1.45;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.summary-full {
|
||||||
|
max-width: min(720px, 72vw);
|
||||||
|
margin-top: 8px;
|
||||||
|
padding-top: 8px;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-toggle {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.muted {
|
.muted {
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
@@ -789,7 +804,8 @@ td {
|
|||||||
|
|
||||||
.search-row,
|
.search-row,
|
||||||
.search-row.compact,
|
.search-row.compact,
|
||||||
.account-search-row {
|
.account-search-row,
|
||||||
|
.summary-search-row {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user