Skip to content

Commit de32de2

Browse files
committed
fix: multi-select term/dept filters + per-term lazy course loading
1 parent c8647b7 commit de32de2

2 files changed

Lines changed: 96 additions & 36 deletions

File tree

frontend/index.html

Lines changed: 35 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -461,25 +461,47 @@ <h4 class="font-medium text-gray-800 text-sm mt-0.5" x-text="r.sub_title || 'Unt
461461
<div>
462462
<!-- ── Filter bar ── -->
463463
<div class="flex flex-wrap gap-2 mb-4 items-end">
464-
<div>
464+
<!-- Term multi-select -->
465+
<div class="relative" @click.away="subsTermOpen = false">
465466
<label class="block text-xs text-gray-500 mb-1">学期</label>
466-
<select x-model="subsTerm" @change="rebuildSubsFiltered()"
467-
class="border rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-300 focus:outline-none">
468-
<option value="">全部学期</option>
467+
<button @click="subsTermOpen = !subsTermOpen"
468+
class="border rounded-lg px-3 py-2 text-sm bg-white text-left min-w-[100px] focus:ring-2 focus:ring-blue-300 focus:outline-none">
469+
<span x-text="subsTermLabel"></span>
470+
<span class="float-right ml-2"></span>
471+
</button>
472+
<div x-show="subsTermOpen" class="absolute z-10 mt-1 bg-white border rounded-lg shadow-lg max-h-48 overflow-y-auto min-w-[160px]">
469473
<template x-for="t in allCoursesTerms" :key="t">
470-
<option :value="t" x-text="t"></option>
474+
<label class="flex items-center gap-2 px-3 py-1.5 hover:bg-gray-50 text-sm cursor-pointer whitespace-nowrap">
475+
<input type="checkbox" :checked="subsTerms.includes(t)"
476+
@change="toggleSubsTerm(t, $event.target.checked)"
477+
class="rounded border-gray-300 text-blue-600">
478+
<span x-text="t"></span>
479+
</label>
471480
</template>
472-
</select>
481+
</div>
473482
</div>
474-
<div>
483+
<!-- Department multi-select with search -->
484+
<div class="relative" @click.away="subsDeptOpen = false">
475485
<label class="block text-xs text-gray-500 mb-1">院系</label>
476-
<select x-model="subsDept" @change="rebuildSubsFiltered()"
477-
class="border rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-300 focus:outline-none">
478-
<option value="">全部院系</option>
479-
<template x-for="d in allDepts" :key="d">
480-
<option :value="d" x-text="d"></option>
486+
<button @click="subsDeptOpen = !subsDeptOpen"
487+
class="border rounded-lg px-3 py-2 text-sm bg-white text-left min-w-[100px] focus:ring-2 focus:ring-blue-300 focus:outline-none">
488+
<span x-text="subsDeptLabel"></span>
489+
<span class="float-right ml-2"></span>
490+
</button>
491+
<div x-show="subsDeptOpen" class="absolute z-10 mt-1 bg-white border rounded-lg shadow-lg max-h-64 overflow-y-auto min-w-[240px]">
492+
<input x-model="deptSearchQuery" @input="rebuildSubsFiltered()" type="text"
493+
placeholder="搜索院系…"
494+
class="w-full border-b px-3 py-2 text-sm sticky top-0 bg-white focus:outline-none">
495+
<template x-for="d in subsDeptFiltered" :key="d">
496+
<label class="flex items-center gap-2 px-3 py-1.5 hover:bg-gray-50 text-sm cursor-pointer whitespace-nowrap">
497+
<input type="checkbox" :checked="subsDepts.includes(d)"
498+
@change="toggleSubsDept(d, $event.target.checked)"
499+
class="rounded border-gray-300 text-blue-600">
500+
<span x-text="d"></span>
501+
</label>
481502
</template>
482-
</select>
503+
<p x-show="subsDeptFiltered.length === 0" class="text-xs text-gray-400 px-3 py-2">无匹配</p>
504+
</div>
483505
</div>
484506
<div class="flex-1 min-w-[120px]">
485507
<label class="block text-xs text-gray-500 mb-1">课程名</label>

frontend/js/app.js

Lines changed: 61 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -234,8 +234,10 @@ document.addEventListener("alpine:init", () => {
234234
left (subscribed) | middle (catalog search) | right (single-run).
235235
The left column persists to GitHub Secret on demand; the right
236236
column is session-only and cleared after triggering a workflow. */
237-
allCourses: [], allCoursesTerms: [], allDepts: [],
238-
subsTerm: "", subsDept: "", subsSearchTitle: "", subsSearchTeacher: "",
237+
allCourses: [], allCoursesTerms: [],
238+
subsTerms: [], subsDepts: [], deptSearchQuery: "",
239+
subsSearchTitle: "", subsSearchTeacher: "",
240+
subsTermOpen: false, subsDeptOpen: false,
239241
subscribedIds: [], singleRunIds: [],
240242
subsSelLeft: [], subsSelMiddle: [], subsSelRight: [],
241243
subsFiltered: [],
@@ -588,19 +590,18 @@ document.addEventListener("alpine:init", () => {
588590

589591
// ── Subscriptions editor (three-column) ──────────────────────────
590592
openSubscriptions() {
591-
this.allCourses = ICS.db.getAllCourses();
592593
this.allCoursesTerms = ICS.db.getAllCoursesTerms();
593-
// Derive department list from catalog
594-
var deptSet = new Set();
595-
for (var i = 0; i < this.allCourses.length; i++) {
596-
var d = this.allCourses[i].dept;
597-
if (d) deptSet.add(d);
598-
}
599-
this.allDepts = Array.from(deptSet).sort();
600-
this.subsTerm = "";
601-
this.subsDept = "";
594+
// Default: select the first term and load its courses only
595+
// (loading all 20k+ courses at once would freeze the UI).
596+
this.subsTerms = this.allCoursesTerms.length
597+
? [this.allCoursesTerms[0]] : [];
598+
this.subsDepts = [];
599+
this.deptSearchQuery = "";
602600
this.subsSearchTitle = "";
603601
this.subsSearchTeacher = "";
602+
this.subsTermOpen = false;
603+
this.subsDeptOpen = false;
604+
this._loadCoursesForTerms();
604605
this.singleRunIds = [];
605606
this.subsSelLeft = [];
606607
this.subsSelMiddle = [];
@@ -622,11 +623,21 @@ document.addEventListener("alpine:init", () => {
622623
this.rebuildSubsFiltered();
623624
this.navigate("subscriptions");
624625
},
625-
// ── Column data ─────────────────────────────────────────────────
626-
get allCoursesForTerm() {
627-
if (!this.subsTerm) return this.allCourses;
628-
return this.allCourses.filter(function (c) { return c.term === this.subsTerm; }, this);
626+
// ── Load courses for selected terms (not all 20k) ──────────────
627+
_loadCoursesForTerms() {
628+
var ids = new Set(this.subsTerms);
629+
var all = [];
630+
for (var i = 0; i < this.allCoursesTerms.length; i++) {
631+
var t = this.allCoursesTerms[i];
632+
if (ids.size === 0 || ids.has(t)) {
633+
var rows = ICS.db.getAllCourses(t);
634+
for (var j = 0; j < rows.length; j++) all.push(rows[j]);
635+
}
636+
}
637+
this.allCourses = all;
638+
this.rebuildSubsFiltered();
629639
},
640+
// ── Column data ─────────────────────────────────────────────────
630641
get subscribedCourses() {
631642
var ids = new Set(this.subscribedIds.map(String));
632643
return this.allCourses.filter(function (c) { return ids.has(String(c.course_id)); });
@@ -635,17 +646,44 @@ document.addEventListener("alpine:init", () => {
635646
var ids = new Set(this.singleRunIds.map(String));
636647
return this.allCourses.filter(function (c) { return ids.has(String(c.course_id)); });
637648
},
649+
// ── Dropdown labels ─────────────────────────────────────────────
650+
get subsTermLabel() {
651+
if (!this.subsTerms.length) return '全部学期';
652+
return this.subsTerms.length + '个学期';
653+
},
654+
get subsDeptLabel() {
655+
if (!this.subsDepts.length) return '全部院系';
656+
return this.subsDepts.length + '个院系';
657+
},
658+
get subsDeptFiltered() {
659+
var q = (this.deptSearchQuery || '').toLowerCase();
660+
var deptSet = new Set();
661+
for (var i = 0; i < this.allCourses.length; i++) {
662+
var d = this.allCourses[i].dept;
663+
if (d && (!q || d.toLowerCase().indexOf(q) !== -1)) deptSet.add(d);
664+
}
665+
return Array.from(deptSet).sort();
666+
},
667+
// ── Toggle multi-select ─────────────────────────────────────────
668+
toggleSubsTerm(term, checked) {
669+
var s = new Set(this.subsTerms);
670+
if (checked) s.add(term); else s.delete(term);
671+
this.subsTerms = Array.from(s);
672+
this._loadCoursesForTerms();
673+
},
674+
toggleSubsDept(dept, checked) {
675+
var s = new Set(this.subsDepts);
676+
if (checked) s.add(dept); else s.delete(dept);
677+
this.subsDepts = Array.from(s);
678+
this.rebuildSubsFiltered();
679+
},
638680
// ── Filter middle column ─────────────────────────────────────────
639681
rebuildSubsFiltered() {
640-
var termQ = this.subsTerm;
641-
var deptQ = (this.subsDept || "").trim().toLowerCase();
682+
var deptSet = new Set(this.subsDepts.map(function (d) { return d.toLowerCase(); }));
642683
var titleQ = (this.subsSearchTitle || "").trim().toLowerCase();
643684
var teacherQ = (this.subsSearchTeacher || "").trim().toLowerCase();
644-
var list = termQ
645-
? this.allCourses.filter(function (c) { return c.term === termQ; })
646-
: this.allCourses;
647-
this.subsFiltered = list.filter(function (c) {
648-
if (deptQ && (!c.dept || c.dept.toLowerCase().indexOf(deptQ) === -1)) return false;
685+
this.subsFiltered = this.allCourses.filter(function (c) {
686+
if (deptSet.size && (!c.dept || !deptSet.has(c.dept.toLowerCase()))) return false;
649687
if (titleQ && (!c.title || c.title.toLowerCase().indexOf(titleQ) === -1)) return false;
650688
if (teacherQ && (!c.teacher || c.teacher.toLowerCase().indexOf(teacherQ) === -1)) return false;
651689
return true;

0 commit comments

Comments
 (0)