Skip to content

Commit 830c29c

Browse files
authored
Merge pull request #19 from LeafCreeper/copilot/add-export-button-to-course-schedule
前端课次页新增可选导出 PDF(对齐现有导出脚本格式)
2 parents e408837 + ce4ab38 commit 830c29c

4 files changed

Lines changed: 193 additions & 1 deletion

File tree

docs/frontend-readme.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ https://<你的用户名>.github.io/<仓库名>/
5555
- **节次列表** — 单课程视图,显示状态标签(就绪 / 总结中 / 等待中 / 失败)
5656
- **摘要阅读** — 完整 Markdown 渲染,支持 LaTeX 公式(KaTeX)
5757
- **编辑** — 点击编辑按钮修改任意摘要,保存后自动推送到 GitHub
58+
- **课次摘要导出 PDF** — 在课程课次页点击「导出」,可勾选或全选后下载 PDF
5859
- **搜索** — 全文搜索所有摘要内容
5960
- **移动端友好** — 底部标签栏导航,触控优化,响应式布局
6061

frontend/index.html

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,11 @@
8787
d="M4 4v5h4.5M20 20v-5h-4.5M4.5 9A8 8 0 0119.5 15M19.5 15A8 8 0 014.5 9"/>
8888
</svg>
8989
</button>
90+
<!-- Export (lectures view) -->
91+
<button x-show="view === 'lectures'" @click="openExportDialog()"
92+
class="text-sm bg-blue-500 text-white px-3 py-1.5 rounded-lg hover:bg-blue-600">
93+
导出
94+
</button>
9095
<!-- Edit (detail view, desktop) -->
9196
<button x-show="view === 'detail' && currentLecture?.transcript"
9297
@click="showTranscript = !showTranscript"
@@ -474,6 +479,58 @@ <h2 class="text-lg font-bold text-gray-800 mt-8 mb-4">Advanced</h2>
474479

475480
</main>
476481

482+
<!-- ── EXPORT DIALOG ── -->
483+
<div x-show="exportDialogOpen" x-transition.opacity
484+
class="fixed inset-0 z-50 bg-black/40 flex items-end md:items-center justify-center p-3"
485+
@click.self="closeExportDialog()">
486+
<div class="w-full max-w-2xl bg-white rounded-xl shadow-xl max-h-[85vh] flex flex-col">
487+
<div class="flex items-center justify-between px-4 py-3 border-b border-gray-200">
488+
<h3 class="font-semibold text-gray-800">导出课程摘要</h3>
489+
<button @click="closeExportDialog()" class="text-gray-400 hover:text-gray-600 text-sm">关闭</button>
490+
</div>
491+
492+
<div class="px-4 py-3 border-b border-gray-100 flex items-center justify-between">
493+
<label class="inline-flex items-center gap-2 text-sm text-gray-700">
494+
<input type="checkbox" class="rounded border-gray-300 text-blue-600 focus:ring-blue-400"
495+
:checked="isExportAllSelected()"
496+
@change="setExportAll($event.target.checked)">
497+
<span>全选</span>
498+
</label>
499+
<span class="text-xs text-gray-500" x-text="'已选 ' + selectedExportCount() + ' / ' + getExportableLectures().length"></span>
500+
</div>
501+
502+
<div class="overflow-y-auto px-4 py-3 space-y-2">
503+
<template x-for="lec in getExportableLectures()" :key="lec.sub_id">
504+
<label class="flex items-start gap-3 p-3 border border-gray-100 rounded-lg hover:bg-gray-50 cursor-pointer">
505+
<input type="checkbox" class="mt-0.5 rounded border-gray-300 text-blue-600 focus:ring-blue-400"
506+
:checked="isLectureSelected(lec.sub_id)"
507+
@change="toggleLectureSelection(lec.sub_id, $event.target.checked)">
508+
<div class="min-w-0">
509+
<p class="text-sm font-medium text-gray-800 truncate" x-text="lec.sub_title || 'Untitled'"></p>
510+
<p class="text-xs text-gray-500 mt-1 line-clamp-2" x-text="snippet(lec.summary, 100)"></p>
511+
</div>
512+
</label>
513+
</template>
514+
<p x-show="getExportableLectures().length === 0" class="text-sm text-gray-500 text-center py-8">
515+
当前课程暂无可导出的摘要。
516+
</p>
517+
</div>
518+
519+
<div class="px-4 py-3 border-t border-gray-100 flex justify-end gap-2">
520+
<button @click="closeExportDialog()"
521+
:disabled="exportingPdf"
522+
class="px-4 py-2 text-sm rounded-lg border border-gray-200 text-gray-600 hover:bg-gray-50 disabled:opacity-50">
523+
取消
524+
</button>
525+
<button @click="exportSelectedToPdf()"
526+
:disabled="exportingPdf"
527+
class="px-4 py-2 text-sm rounded-lg bg-blue-500 text-white hover:bg-blue-600 disabled:opacity-50"
528+
x-text="exportingPdf ? '导出中…' : '导出 PDF'">
529+
</button>
530+
</div>
531+
</div>
532+
</div>
533+
477534
<!-- ── TOAST ── -->
478535
<div x-show="toast" x-transition.opacity
479536
class="toast"
@@ -489,6 +546,8 @@ <h2 class="text-lg font-bold text-gray-800 mt-8 mb-4">Advanced</h2>
489546
<script src="https://cdn.jsdelivr.net/npm/marked@15/marked.min.js"></script>
490547
<!-- DOMPurify (provides window.DOMPurify) -->
491548
<script src="https://cdn.jsdelivr.net/npm/dompurify@3/dist/purify.min.js"></script>
549+
<!-- html2pdf.js (provides window.html2pdf) -->
550+
<script src="https://cdn.jsdelivr.net/npm/html2pdf.js@0.10.1/dist/html2pdf.bundle.min.js"></script>
492551
<!-- KaTeX + auto-render -->
493552
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.21/dist/katex.min.js"></script>
494553
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.21/dist/contrib/auto-render.min.js"></script>

frontend/js/app.js

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,23 @@ function _highlightSnippet(text, query, radius) {
113113
return snip.replace(re, "<mark>$1</mark>");
114114
}
115115

116+
function _escapeHtml(s) {
117+
return String(s || "")
118+
.replace(/&/g, "&amp;")
119+
.replace(/</g, "&lt;")
120+
.replace(/>/g, "&gt;")
121+
.replace(/"/g, "&quot;")
122+
.replace(/'/g, "&#39;");
123+
}
124+
125+
function _getLectureDateString(dateText, processedAt) {
126+
if (dateText) return String(dateText);
127+
if (!processedAt) return "";
128+
const d = new Date(processedAt);
129+
if (Number.isNaN(d.getTime())) return String(processedAt);
130+
return d.toISOString().slice(0, 10);
131+
}
132+
116133
/* ── Alpine app ── */
117134
document.addEventListener("alpine:init", () => {
118135
Alpine.data("app", () => ({
@@ -127,6 +144,7 @@ document.addEventListener("alpine:init", () => {
127144
setup: { token: "", stuid: "", uispsw: "", dashscope: "", smtp: "" },
128145
setupError: "", setupTesting: false,
129146
settingsForm: {}, showSecrets: {},
147+
exportDialogOpen: false, exportSelection: {}, exportingPdf: false,
130148
iterations: 10000, repoOwner: "", repoName: "", dataBranch: "data",
131149
_history: [],
132150

@@ -205,6 +223,7 @@ document.addEventListener("alpine:init", () => {
205223
else if (view === "detail" && params.subId) { this.currentLecture = ICS.db.getLecture(params.subId); this.showTranscript = false; }
206224
else if (view === "edit") { this.editText = this.currentLecture?.summary || ""; this.editPreview = false; }
207225
this.view = view;
226+
if (view !== "lectures") this.exportDialogOpen = false;
208227
},
209228
goBack() {
210229
const p = this._history.pop();
@@ -217,6 +236,119 @@ document.addEventListener("alpine:init", () => {
217236
startEdit() { this.navigate("edit"); },
218237
cancelEdit() { this.goBack(); },
219238

239+
getExportableLectures() {
240+
return (this.lectures || []).filter((lec) => lec.summary && lec.summary.trim());
241+
},
242+
openExportDialog() {
243+
const list = this.getExportableLectures();
244+
if (!list.length) { this._toast("No summarized lectures to export", "error"); return; }
245+
this.exportSelection = {};
246+
list.forEach((lec) => { this.exportSelection[lec.sub_id] = true; });
247+
this.exportDialogOpen = true;
248+
},
249+
closeExportDialog() {
250+
if (this.exportingPdf) return;
251+
this.exportDialogOpen = false;
252+
},
253+
isLectureSelected(subId) { return !!this.exportSelection[subId]; },
254+
toggleLectureSelection(subId, checked) { this.exportSelection[subId] = !!checked; },
255+
setExportAll(checked) {
256+
this.getExportableLectures().forEach((lec) => { this.exportSelection[lec.sub_id] = !!checked; });
257+
},
258+
isExportAllSelected() {
259+
const list = this.getExportableLectures();
260+
return list.length > 0 && list.every((lec) => this.exportSelection[lec.sub_id]);
261+
},
262+
selectedExportCount() {
263+
return this.getExportableLectures().filter((lec) => this.exportSelection[lec.sub_id]).length;
264+
},
265+
_buildExportHtml(lectures) {
266+
const courseTitle = this.currentCourse?.title || "课程";
267+
const teacher = this.currentCourse?.teacher || "";
268+
const sections = lectures.map((lec) => {
269+
const dateText = _getLectureDateString(lec.date, lec.processed_at);
270+
const title = _escapeHtml(lec.sub_title || "Untitled");
271+
const subtitle = dateText ? `<small>(${_escapeHtml(dateText)})</small>` : "";
272+
const titleLine = subtitle ? `${title} ${subtitle}` : title;
273+
return `
274+
<section>
275+
<h2>${titleLine}</h2>
276+
${ICS.render.renderMarkdown(lec.summary || "")}
277+
</section>
278+
<hr>
279+
`;
280+
}).join("");
281+
return `
282+
<!DOCTYPE html>
283+
<html lang="zh-CN">
284+
<head>
285+
<meta charset="UTF-8">
286+
<style>
287+
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", Arial, sans-serif; color: #111827; }
288+
h1 { font-size: 24px; margin: 0; }
289+
.meta { color: #6b7280; margin: 8px 0 16px; font-size: 14px; }
290+
section { page-break-inside: avoid; }
291+
h2 { font-size: 18px; margin: 14px 0 10px; }
292+
h2 small { font-size: 12px; font-weight: 400; color: #6b7280; margin-left: 6px; }
293+
hr { border: none; border-top: 1px solid #e5e7eb; margin: 12px 0; }
294+
p { line-height: 1.7; }
295+
pre { white-space: pre-wrap; overflow-wrap: break-word; word-wrap: break-word; }
296+
img { max-width: 100%; }
297+
</style>
298+
</head>
299+
<body>
300+
<h1>${_escapeHtml(courseTitle)}</h1>
301+
<p class="meta">${teacher ? `任课教师:${_escapeHtml(teacher)}` : ""}</p>
302+
<hr>
303+
${sections}
304+
</body>
305+
</html>
306+
`;
307+
},
308+
async exportSelectedToPdf() {
309+
if (this.exportingPdf) return;
310+
if (!window.html2pdf) {
311+
this._toast("PDF library failed to load", "error");
312+
return;
313+
}
314+
const selected = this.getExportableLectures().filter((lec) => this.exportSelection[lec.sub_id]);
315+
if (!selected.length) {
316+
this._toast("Please select at least one lecture", "error");
317+
return;
318+
}
319+
this.exportingPdf = true;
320+
let mount = null;
321+
try {
322+
mount = document.createElement("div");
323+
mount.style.position = "fixed";
324+
mount.style.left = "-10000px";
325+
mount.style.top = "0";
326+
mount.style.width = "794px";
327+
mount.innerHTML = this._buildExportHtml(selected);
328+
document.body.appendChild(mount);
329+
const fileBase = (this.currentCourse?.title?.trim() || "course_summaries")
330+
.replace(/[\\/:*?"<>|]+/g, "_");
331+
await window.html2pdf()
332+
.set({
333+
margin: [12, 10, 12, 10],
334+
filename: fileBase + "_summaries.pdf",
335+
image: { type: "jpeg", quality: 0.98 },
336+
html2canvas: { scale: 2, useCORS: true },
337+
jsPDF: { unit: "mm", format: "a4", orientation: "portrait" },
338+
pagebreak: { mode: ["css", "legacy"] },
339+
})
340+
.from(mount.firstElementChild || mount)
341+
.save();
342+
this.exportDialogOpen = false;
343+
this._toast("PDF exported", "success");
344+
} catch (e) {
345+
this._toast(e?.message || "Export failed", "error");
346+
} finally {
347+
if (mount && mount.parentNode) mount.parentNode.removeChild(mount);
348+
this.exportingPdf = false;
349+
}
350+
},
351+
220352
async saveEdit() {
221353
if (this.saving) return;
222354
this.saving = true;

frontend/js/db.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ function _getCourses() {
6262

6363
function _getLectures(courseId) {
6464
const rows = _queryAll(`
65-
SELECT sub_id, sub_title, summary, processed_at,
65+
SELECT sub_id, sub_title, date, summary, processed_at,
6666
error_stage, error_msg, summary_model, transcript
6767
FROM lectures WHERE course_id = ? ORDER BY sub_id ASC
6868
`, [courseId]);

0 commit comments

Comments
 (0)