Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 33 additions & 7 deletions dist/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,19 @@
.alt-card { background: white; padding: 20px; border-radius: 8px; max-width: 500px; width: 90%; max-height: 80vh; overflow-y: auto; }
.alt-item { padding: 10px; border-bottom: 1px solid #eee; }
.alt-item:last-child { border-bottom: none; }

/* Mobile Drag Selection */
.drag-selecting { animation: ripple 0.3s ease-out; background-color: rgba(99, 5, 96, 0.1); }
@keyframes ripple {
0% { transform: scale(0.95); opacity: 0.5; }
50% { transform: scale(1.02); opacity: 0.8; }
100% { transform: scale(1); opacity: 1; }
}
.result-item { user-select: none; -webkit-user-select: none; } /* Disable text selection for drag */

/* Sticky Headers */
header { position: sticky; top: 0; }
.nav-tabs { position: sticky; top: 55px; background: #f5f5f5; padding-top: 10px; margin-top: -10px; } /* Adjust top based on header height ~55px */
</style>
</head>
<body>
Expand Down Expand Up @@ -163,14 +176,20 @@ <h3>搜索结果 ({{ filteredSearchResults.length }})</h3>
{{ searchResults.length === 0 ? '未找到符合条件的课程。' : '当前筛选条件下没有课程。' }}
</div>

<div class="results-list" v-else>
<div class="results-list" v-else
@touchmove="touchManager.move"
@touchend="touchManager.end">
<label v-for="(course, idx) in filteredSearchResults"
:key="idx"
class="result-item"
:class="['result-item', (touchState.dragging && touchState.listType==='search' && touchState.startIndex===idx) ? 'drag-selecting' : '']"
:data-index="idx"
@click.prevent="handleSearchItemClick(idx, $event)"
@touchstart="touchManager.start($event, 'search', idx)"
:style="(course.location_text || '').includes('自由时间') ? { opacity: 0.5, cursor: 'not-allowed' } : {}">
<input type="checkbox"
v-model="course.checked"
:disabled="(course.location_text || '').includes('自由时间')">
:checked="course.checked"
:disabled="(course.location_text || '').includes('自由时间')"
style="pointer-events: none;">
<div>
<strong>{{ course.name }}</strong> ({{ course.code }}) <br>
<small>老师: {{ course.teacher }} | 地点: {{ course.location_text }}</small>
Expand All @@ -196,10 +215,17 @@ <h2>我的课程组 ({{ groups.length }})</h2>
</span>
<button class="danger" @click.stop="removeGroup(idx)" style="padding: 4px 8px; font-size: 0.8rem;">删除</button>
</div>
<div :class="['group-body', group.open ? 'open' : '']">
<label v-for="(course, cIdx) in group.candidates" :key="cIdx" class="result-item">
<div :class="['group-body', group.open ? 'open' : '']"
@touchmove="touchManager.move"
@touchend="touchManager.end">
<label v-for="(course, cIdx) in group.candidates" :key="cIdx"
:class="['result-item', (touchState.dragging && touchState.listType==='group' && touchState.groupIdx===idx && touchState.startIndex===cIdx) ? 'drag-selecting' : '']"
:data-index="cIdx"
@click.prevent="handleGroupItemClick(idx, cIdx, $event)"
@touchstart="touchManager.start($event, 'group', cIdx, idx)">
<input type="checkbox"
v-model="course.selected">
:checked="course.selected"
style="pointer-events: none;">
<div>
{{ course.name }} - {{ course.teacher }} <br>
<small>{{ course.location_text }}</small>
Expand Down
238 changes: 236 additions & 2 deletions dist/static/js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ createApp({
const showAllWeeks = ref(false);
const toastRef = ref(null);

// Selection State
const lastSearchIdx = ref(-1);
const lastGroupSelections = reactive({}); // Map groupId -> index

// Import Modal State
const showImportModal = ref(false);
const importText = ref('');
Expand Down Expand Up @@ -97,6 +101,7 @@ createApp({
searchResults.value = res.map(c => ({ ...c, checked: false }));
hasSearched.value = true;
filterText.value = ''; // Reset filter
lastSearchIdx.value = -1; // Reset selection anchor
if (res.length === 0) {
showToast("未找到任何课程", 'info');
} else {
Expand All @@ -115,6 +120,50 @@ createApp({

const allChecked = visible.every(c => c.checked);
visible.forEach(c => c.checked = !allChecked);
lastSearchIdx.value = -1;
};

const handleSearchItemClick = (index, event) => {
const visible = filteredSearchResults.value;
// Handle Shift+Click Range
if (event.shiftKey && lastSearchIdx.value !== -1 && lastSearchIdx.value < visible.length) {
const start = Math.min(lastSearchIdx.value, index);
const end = Math.max(lastSearchIdx.value, index);
const targetState = !visible[index].checked; // Determine target state based on the clicked item's PRE-CLICK state (which is !checked)
// Actually, if we use @click.prevent, the value hasn't changed yet.
// If it's currently checked, we are unchecking it.
// So targetState should be opposite of current state.

for (let i = start; i <= end; i++) {
visible[i].checked = targetState;
}
} else {
// Normal toggle
visible[index].checked = !visible[index].checked;
}
lastSearchIdx.value = index;
};

const handleGroupItemClick = (groupIndex, itemIndex, event) => {
const group = groups.value[groupIndex];
if (!group) return;
const candidates = group.candidates;
const groupId = group.id;

const lastIdx = lastGroupSelections[groupId] ?? -1;

if (event.shiftKey && lastIdx !== -1 && lastIdx < candidates.length) {
const start = Math.min(lastIdx, itemIndex);
const end = Math.max(lastIdx, itemIndex);
// Same logic: toggle based on clicked item
const targetState = !candidates[itemIndex].selected;
for (let i = start; i <= end; i++) {
candidates[i].selected = targetState;
}
} else {
candidates[itemIndex].selected = !candidates[itemIndex].selected;
}
lastGroupSelections[groupId] = itemIndex;
};

const toggleAllDays = (select) => {
Expand All @@ -129,6 +178,188 @@ createApp({
}
};

// --- Touch & Drag Selection Logic ---

const touchState = reactive({
dragging: false,
listType: null, // 'search' or 'group'
groupIdx: -1,
startIndex: -1,
currentDragIndex: -1,
targetState: false, // The checked state we are applying
scrollContainer: null,
scrollSpeed: 0,
autoScrollTimer: null
});

let longPressTimer = null;

const touchManager = {
start: (e, type, idx, groupIdx = -1) => {
// Only left click / single touch
if (e.touches && e.touches.length > 1) return;

touchState.dragging = false;
touchState.scrollSpeed = 0;

// Clear any existing timer
if (longPressTimer) clearTimeout(longPressTimer);

// Set timer for long press (e.g., 500ms)
longPressTimer = setTimeout(() => {
touchManager.activate(e, type, idx, groupIdx);
}, 500);
},

activate: (e, type, idx, groupIdx) => {
touchState.dragging = true;
touchState.listType = type;
touchState.startIndex = idx;
touchState.groupIdx = groupIdx;
touchState.currentDragIndex = idx;

// Determine initial state to apply (toggle the start item)
let item;
if (type === 'search') {
item = filteredSearchResults.value[idx];
} else if (type === 'group') {
item = groups.value[groupIdx].candidates[idx];
}

if (item) {
// We toggle the start item immediately upon activation
// And set that as the target state for the drag
touchState.targetState = !((type === 'search') ? item.checked : item.selected);

// Apply to start item
if (type === 'search') item.checked = touchState.targetState;
else item.selected = touchState.targetState;

// Vibrate if available
if (navigator.vibrate) navigator.vibrate(50);
}

// Find scroll container
// Search: .results-list (closest parent)
// Group: Window/Body (usually) or the group body
// We use e.target to find closest scrollable or just default
const target = e.target;
if (type === 'search') {
touchState.scrollContainer = target.closest('.results-list');
} else {
// For groups, we scroll the window/body
touchState.scrollContainer = window;
}

touchManager.startAutoScroll();
},

move: (e) => {
// If we moved before activation, cancel the timer
if (!touchState.dragging) {
// Simple threshold check could go here if we wanted to be strict
// But usually the browser takes over scrolling, which cancels the long press implicitly?
// Actually, we should clear timeout on significant move
if (longPressTimer) {
// We can't easily detect "significant" without storing start pos.
// But if the user scrolls, we usually want to cancel.
// Let's rely on 'touchcancel' or rely on the fact that if we preventDefault in move, it works.
// If we DON'T preventDefault, scroll happens.
// We'll add logic: if move happens and NOT dragging, clear timer.
clearTimeout(longPressTimer);
longPressTimer = null;
}
return;
}

// If dragging, prevent native scroll
if (e.cancelable) e.preventDefault();

const touch = e.touches[0];
const clientY = touch.clientY;

// 1. Auto Scroll Logic
const winHeight = window.innerHeight;
const topThreshold = winHeight * 0.15;
const bottomThreshold = winHeight * 0.85;

if (clientY < topThreshold) {
touchState.scrollSpeed = -1 * (1 - clientY/topThreshold) * 20; // Up
} else if (clientY > bottomThreshold) {
touchState.scrollSpeed = (1 - (winHeight - clientY)/(winHeight - bottomThreshold)) * 20; // Down
} else {
touchState.scrollSpeed = 0;
}

// 2. Selection Logic
// Find element under finger
const el = document.elementFromPoint(touch.clientX, touch.clientY);
if (!el) return;

const itemEl = el.closest('.result-item');
if (itemEl && itemEl.dataset.index !== undefined) {
const newIdx = parseInt(itemEl.dataset.index);
if (!isNaN(newIdx) && newIdx !== touchState.currentDragIndex) {
touchState.currentDragIndex = newIdx;
touchManager.updateSelection();
}
}
},

end: (e) => {
if (longPressTimer) clearTimeout(longPressTimer);
touchState.dragging = false;
touchState.scrollSpeed = 0;
if (touchState.autoScrollTimer) cancelAnimationFrame(touchState.autoScrollTimer);
},

updateSelection: () => {
const start = Math.min(touchState.startIndex, touchState.currentDragIndex);
const end = Math.max(touchState.startIndex, touchState.currentDragIndex);

if (touchState.listType === 'search') {
const list = filteredSearchResults.value;
for (let i = start; i <= end; i++) {
if (list[i]) list[i].checked = touchState.targetState;
}
} else if (touchState.listType === 'group') {
const group = groups.value[touchState.groupIdx];
if (group) {
for (let i = start; i <= end; i++) {
if (group.candidates[i]) group.candidates[i].selected = touchState.targetState;
}
}
}
},

startAutoScroll: () => {
const step = () => {
if (!touchState.dragging) return;

if (touchState.scrollSpeed !== 0) {
if (touchState.scrollContainer === window) {
window.scrollBy(0, touchState.scrollSpeed);
} else if (touchState.scrollContainer) {
touchState.scrollContainer.scrollTop += touchState.scrollSpeed;
}

// We also need to re-check selection as we scroll
// But elementFromPoint depends on screen coords, which stay roughly same if finger holds still
// So we should re-trigger selection update logic in the loop?
// Currently 'move' updates selection. If finger is static and page scrolls,
// the element under the finger changes! So yes.
// However, we don't have the 'last touch event' here easily unless we store it.
// Let's just rely on the user moving slightly or the next touchmove event.
// Actually, 'touchmove' fires continuously on some devices, but not all.
// Ideally we store lastTouchY/X.
}
touchState.autoScrollTimer = requestAnimationFrame(step);
};
step();
}
};


// --- Import Logic ---

const openImportModal = () => {
Expand Down Expand Up @@ -392,7 +623,8 @@ createApp({
const sess = c.sessions.find(s =>
s.day === day &&
currentPeriod >= s.start &&
currentPeriod <= s.end
currentPeriod <= s.end &&
s.weeks.includes(week)
);
if (sess) {
return {
Expand Down Expand Up @@ -537,7 +769,9 @@ createApp({
showImportModal, importText, isImporting, importStatus, importParams,
openImportModal, closeImportModal, startBatchImport,
showAltModal, currentAltCourse, openAlternatives,
showAllWeeks
showAllWeeks,
handleSearchItemClick, handleGroupItemClick,
touchManager, touchState
};
}
}).mount('#app');
38 changes: 16 additions & 22 deletions server.log
Original file line number Diff line number Diff line change
@@ -1,22 +1,16 @@
127.0.0.1 - - [11/Jan/2026 04:36:31] "GET / HTTP/1.1" 200 -
127.0.0.1 - - [11/Jan/2026 04:36:31] "GET /static/js/app.js HTTP/1.1" 200 -
127.0.0.1 - - [11/Jan/2026 04:36:31] "GET /static/js/solver.js HTTP/1.1" 200 -
127.0.0.1 - - [11/Jan/2026 04:36:31] "GET /static/vue.global.js HTTP/1.1" 200 -
127.0.0.1 - - [11/Jan/2026 04:36:31] "GET /static/html2canvas.min.js HTTP/1.1" 200 -
127.0.0.1 - - [11/Jan/2026 04:36:31] code 404, message File not found
127.0.0.1 - - [11/Jan/2026 04:36:31] "GET /search?name=&code=&campus=1&semester=2025-2026-2&match_mode=OR HTTP/1.1" 404 -
127.0.0.1 - - [11/Jan/2026 04:37:37] "GET / HTTP/1.1" 200 -
127.0.0.1 - - [11/Jan/2026 04:37:37] "GET /static/vue.global.js HTTP/1.1" 200 -
127.0.0.1 - - [11/Jan/2026 04:37:37] "GET /static/js/app.js HTTP/1.1" 200 -
127.0.0.1 - - [11/Jan/2026 04:37:37] "GET /static/js/solver.js HTTP/1.1" 200 -
127.0.0.1 - - [11/Jan/2026 04:37:37] "GET /static/html2canvas.min.js HTTP/1.1" 200 -
127.0.0.1 - - [11/Jan/2026 04:42:04] "GET / HTTP/1.1" 200 -
127.0.0.1 - - [11/Jan/2026 04:42:04] "GET /static/vue.global.js HTTP/1.1" 200 -
127.0.0.1 - - [11/Jan/2026 04:42:04] "GET /static/js/solver.js HTTP/1.1" 200 -
127.0.0.1 - - [11/Jan/2026 04:42:04] "GET /static/js/app.js HTTP/1.1" 200 -
127.0.0.1 - - [11/Jan/2026 04:42:04] "GET /static/html2canvas.min.js HTTP/1.1" 200 -
127.0.0.1 - - [11/Jan/2026 04:43:33] "GET / HTTP/1.1" 200 -
127.0.0.1 - - [11/Jan/2026 04:43:33] "GET /static/vue.global.js HTTP/1.1" 200 -
127.0.0.1 - - [11/Jan/2026 04:43:33] "GET /static/html2canvas.min.js HTTP/1.1" 200 -
127.0.0.1 - - [11/Jan/2026 04:43:33] "GET /static/js/app.js HTTP/1.1" 200 -
127.0.0.1 - - [11/Jan/2026 04:43:33] "GET /static/js/solver.js HTTP/1.1" 200 -
127.0.0.1 - - [11/Jan/2026 09:14:25] "GET / HTTP/1.1" 200 -
127.0.0.1 - - [11/Jan/2026 09:14:25] "GET /static/vue.global.js HTTP/1.1" 200 -
127.0.0.1 - - [11/Jan/2026 09:14:25] "GET /static/html2canvas.min.js HTTP/1.1" 200 -
127.0.0.1 - - [11/Jan/2026 09:14:25] "GET /static/js/solver.js HTTP/1.1" 200 -
127.0.0.1 - - [11/Jan/2026 09:14:25] "GET /static/js/app.js HTTP/1.1" 200 -
127.0.0.1 - - [11/Jan/2026 09:14:26] "GET /search?name=&code=&campus=1&semester=2025-2026-2&match_mode=OR HTTP/1.1" 200 -
127.0.0.1 - - [11/Jan/2026 09:14:26] "GET / HTTP/1.1" 304 -
127.0.0.1 - - [11/Jan/2026 09:14:27] "GET /search?name=&code=&campus=1&semester=2025-2026-2&match_mode=OR HTTP/1.1" 200 -
127.0.0.1 - - [11/Jan/2026 09:14:43] "GET / HTTP/1.1" 200 -
127.0.0.1 - - [11/Jan/2026 09:14:43] "GET /static/vue.global.js HTTP/1.1" 200 -
127.0.0.1 - - [11/Jan/2026 09:14:43] "GET /static/html2canvas.min.js HTTP/1.1" 200 -
127.0.0.1 - - [11/Jan/2026 09:14:43] "GET /static/js/solver.js HTTP/1.1" 200 -
127.0.0.1 - - [11/Jan/2026 09:14:43] "GET /static/js/app.js HTTP/1.1" 200 -
127.0.0.1 - - [11/Jan/2026 09:14:44] "GET /search?name=&code=&campus=1&semester=2025-2026-2&match_mode=OR HTTP/1.1" 200 -
127.0.0.1 - - [11/Jan/2026 09:14:44] "GET / HTTP/1.1" 304 -
127.0.0.1 - - [11/Jan/2026 09:14:44] "GET /search?name=&code=&campus=1&semester=2025-2026-2&match_mode=OR HTTP/1.1" 200 -
Binary file added verification/interaction_test.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading