From 638d7bb42f394270b9b85c99277225f0e9f44f35 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Sat, 10 Jan 2026 07:28:22 +0000 Subject: [PATCH 1/3] feat: show 20 schedules, detailed stats, and privacy notice - Increased schedule display limit to 20 with horizontal scrolling. - Added comprehensive stats (credits, hours, weeks) and score breakdown. - Implemented system save dialog for screenshots. - Added privacy disclaimer to header. - Updated backend to extract credit and hours from API. --- backend/ranker.py | 93 ++++++++++++------------- jwFetcher.py | 13 ++++ main.py | 81 ++++++++++++++++++++- server.log | 16 +++++ static/app.js | 34 +++++++-- static/index.html | 33 +++++++-- tests/saved_sessions/test_session.json | 2 +- verification/verification.png | Bin 0 -> 67931 bytes verification/verify_frontend.py | 72 +++++++++++++++++++ verification/verify_frontend_robust.py | 71 +++++++++++++++++++ 10 files changed, 352 insertions(+), 63 deletions(-) create mode 100644 server.log create mode 100644 verification/verification.png create mode 100644 verification/verify_frontend.py create mode 100644 verification/verify_frontend_robust.py diff --git a/backend/ranker.py b/backend/ranker.py index 8647202..e2a926c 100644 --- a/backend/ranker.py +++ b/backend/ranker.py @@ -2,18 +2,14 @@ class ScheduleRanker: @staticmethod - def score_schedule(schedule, preferences): + def evaluate_schedule(schedule, preferences): """ - Scores a schedule based on preferences. - Lower score is better? Or Higher? Let's say Higher is better. - - Preferences: - - avoid_early_morning (bool): 1-2节 - - avoid_weekend (bool): Sat/Sun - - compactness (str): "high" (bunch together), "low" (spread out), "none" - - max_daily_load (int): Penalty if > X + Evaluates a schedule based on preferences and returns score + breakdown. """ - score = 100.0 + base_score = 100.0 + details = {} + total_penalty = 0.0 + total_bonus = 0.0 # Merge bitmaps full_bitmap = [0] * 30 # Assume max weeks 25 @@ -29,14 +25,12 @@ def score_schedule(schedule, preferences): val = 0 full_bitmap[w] |= val - # 1. Avoid Early Morning (Nodes 1-2 -> Bits 0-1, 13-14, etc.) + # 1. Avoid Early Morning if preferences.get('avoid_early_morning'): penalty = 0 - for w in range(1, 26): # Check typical weeks + for w in range(1, 26): mask = full_bitmap[w] if mask == 0: continue - # Early morning mask: Day 0..6, Nodes 0..1 - # Bit pos = day*13 + node early_mask = 0 for d in range(7): early_mask |= (1 << (d * 13 + 0)) @@ -44,14 +38,15 @@ def score_schedule(schedule, preferences): if (mask & early_mask): penalty += 1 - score -= penalty * 2 # 严格惩罚早八 - # 2. Avoid Weekend (Days 5, 6) + p_val = penalty * 2 + total_penalty += p_val + details['早八回避'] = -p_val + + # 2. Avoid Weekend if preferences.get('avoid_weekend'): penalty = 0 weekend_mask = 0 - # Day 5 (Sat), Day 6 (Sun). All 13 nodes. - # Bits 65-77 (Sat), 78-90 (Sun) ... wait 5*13=65. for node in range(13): weekend_mask |= (1 << (5 * 13 + node)) weekend_mask |= (1 << (6 * 13 + node)) @@ -59,14 +54,13 @@ def score_schedule(schedule, preferences): for w in range(1, 26): if (full_bitmap[w] & weekend_mask): penalty += 1 - score -= penalty * 2.0 # Higher penalty for weekends - # 3. Compactness (Variance of start times? Or density?) - # Let's use daily variance. - # Compact -> High density, few gaps. - # Spread -> Evenly distributed. + p_val = penalty * 2.0 + total_penalty += p_val + details['周末回避'] = -p_val + + # 3. Compactness if preferences.get('compactness') in ['high', 'low']: - # Calculate gaps total_gaps = 0 for w in range(1, 26): mask = full_bitmap[w] @@ -75,10 +69,6 @@ def score_schedule(schedule, preferences): day_bits = (mask >> (d * 13)) & 0x1FFF if day_bits == 0: continue - # Convert to string binary to find 101 patterns (gap) - bin_str = bin(day_bits)[2:].zfill(13) # LSB is 1st class? - # Actually LSB is node 0 (1st class). `bin` output is MSB left. - # Let's just iterate has_started = False gap_count = 0 current_gap = 0 @@ -96,9 +86,13 @@ def score_schedule(schedule, preferences): total_gaps += gap_count if preferences['compactness'] == 'high': - score -= total_gaps * 0.2 # Penalty for gaps + p_val = total_gaps * 0.2 + total_penalty += p_val + details['课程紧凑'] = -p_val else: - score += total_gaps * 0.2 # Bonus for gaps (spread) + b_val = total_gaps * 0.2 + total_bonus += b_val + details['课程分散'] = +b_val # 4. Max Daily Load limit = preferences.get('max_daily_load') @@ -109,40 +103,43 @@ def score_schedule(schedule, preferences): if mask == 0: continue for d in range(7): day_bits = (mask >> (d * 13)) & 0x1FFF - # Count set bits count = bin(day_bits).count('1') if count > limit: overload += (count - limit) - score -= overload * 5.0 # Heavy penalty + p_val = overload * 5.0 + total_penalty += p_val + details['每日负载'] = -p_val - # 5. Day Max Limit (Specific Days) + # 5. Day Max Limit if preferences.get('day_max_limit_enabled'): limit = preferences.get('day_max_limit_value', 4) - target_days = preferences.get('day_max_limit_days', []) # List of bools, idx 0=Mon - - penalty = 0 - # Ensure target_days has 7 elements + target_days = preferences.get('day_max_limit_days', []) if len(target_days) < 7: target_days = target_days + [False] * (7 - len(target_days)) + penalty = 0 for w in range(1, 26): mask = full_bitmap[w] if mask == 0: continue - for d in range(7): - # Check if this day is selected for limiting - if not target_days[d]: - continue - + if not target_days[d]: continue day_bits = (mask >> (d * 13)) & 0x1FFF count = bin(day_bits).count('1') - if count > limit: - # Penalty calculation - # If limit is 0 (day off), any class is bad. diff = count - limit - penalty += diff * 50.0 # Very heavy penalty + penalty += diff * 50.0 - score -= penalty + p_val = penalty + total_penalty += p_val + details['特定日限制'] = -p_val - return score + final_score = base_score + total_bonus - total_penalty + return { + 'score': final_score, + 'details': details + } + + @staticmethod + def score_schedule(schedule, preferences): + result = ScheduleRanker.evaluate_schedule(schedule, preferences) + return result['score'] diff --git a/jwFetcher.py b/jwFetcher.py index 35e7795..bbde736 100644 --- a/jwFetcher.py +++ b/jwFetcher.py @@ -338,10 +338,23 @@ def search(self, course_name=None, course_code=None, campus="1", semester="2025- # 调用正则解析器 bitmap, sessions = ScheduleBitmapper.generate_bitmap(raw_loc) + # Try to extract credit (XF) and hours (XS) + try: + credit = float(row.get("XF", 0)) + except: + credit = 0.0 + + try: + hours = float(row.get("XS", 0)) + except: + hours = 0.0 + item = { "name": row.get("KCM"), "code": row.get("KCH"), "teacher": teacher, + "credit": credit, + "hours": hours, "location_text": raw_loc, "school": row.get("PKDWDM_DISPLAY") or row.get("KKDWDM_DISPLAY"), # 输出二进制列表 (核心) diff --git a/main.py b/main.py index 743e858..10702b3 100644 --- a/main.py +++ b/main.py @@ -59,18 +59,93 @@ def generate_schedules(self, groups, preferences): raw_schedules = ScheduleSolver.generate_schedules(groups, preferences=preferences) print(f"[Api] Found {len(raw_schedules)} valid schedules") - # 3. Rank + # 3. Rank and Enrich ranker = ScheduleRanker() ranked = [] for s in raw_schedules: - score = ranker.score_schedule(s, preferences) - ranked.append({'courses': s, 'score': score}) + eval_result = ranker.evaluate_schedule(s, preferences) + score = eval_result['score'] + details = eval_result['details'] + + # Calculate stats + total_credits = 0.0 + total_hours = 0 + + # Find week span + min_week = 999 + max_week = -1 + has_classes = False + + for course in s: + total_credits += course.get('credit', 0) + + # Use official hours if available, else calculate + if course.get('hours', 0) > 0: + total_hours += course.get('hours') + else: + # Calculate hours from sessions fallback + for sess in course.get('sessions', []): + p_len = sess['end'] - sess['start'] + 1 + w_len = len(sess['weeks']) + total_hours += (p_len * w_len) + + # Update week span (always needed) + for sess in course.get('sessions', []): + if sess['weeks']: + has_classes = True + min_week = min(min_week, min(sess['weeks'])) + max_week = max(max_week, max(sess['weeks'])) + + avg_weekly = 0.0 + if has_classes and max_week >= min_week: + span = max_week - min_week + 1 + if span > 0: + avg_weekly = total_hours / span + + ranked.append({ + 'courses': s, + 'score': score, + 'score_details': details, + 'stats': { + 'total_credits': round(total_credits, 1), + 'total_hours': total_hours, + 'avg_weekly_hours': round(avg_weekly, 1), + 'week_span': f"{min_week}-{max_week}" if has_classes else "N/A" + } + }) # Sort desc ranked.sort(key=lambda x: x['score'], reverse=True) return {'schedules': ranked} + def save_image_dialog(self, base64_data): + import base64 + try: + active_window = webview.windows[0] + file_path = active_window.create_file_dialog( + webview.SAVE_DIALOG, + directory='', + save_filename='schedule.png', + file_types=('PNG Image (*.png)', 'All files (*.*)') + ) + + if file_path: + if isinstance(file_path, (list, tuple)): + file_path = file_path[0] + + # Remove header if present + if ',' in base64_data: + base64_data = base64_data.split(',')[1] + + with open(file_path, "wb") as f: + f.write(base64.b64decode(base64_data)) + return True + return False + except Exception as e: + print(f"[Api] Save Image Error: {e}") + return False + def save_session(self, groups_json, prefs_json): try: groups = json.loads(groups_json) diff --git a/server.log b/server.log new file mode 100644 index 0000000..01b331c --- /dev/null +++ b/server.log @@ -0,0 +1,16 @@ +127.0.0.1 - - [10/Jan/2026 07:22:19] "GET /static/index.html HTTP/1.1" 200 - +127.0.0.1 - - [10/Jan/2026 07:22:19] "GET /static/vue.global.js HTTP/1.1" 200 - +127.0.0.1 - - [10/Jan/2026 07:22:19] "GET /static/app.js HTTP/1.1" 200 - +127.0.0.1 - - [10/Jan/2026 07:22:19] "GET /static/html2canvas.min.js HTTP/1.1" 200 - +127.0.0.1 - - [10/Jan/2026 07:23:49] "GET /static/index.html HTTP/1.1" 200 - +127.0.0.1 - - [10/Jan/2026 07:23:49] "GET /static/app.js HTTP/1.1" 200 - +127.0.0.1 - - [10/Jan/2026 07:23:49] "GET /static/html2canvas.min.js HTTP/1.1" 200 - +127.0.0.1 - - [10/Jan/2026 07:23:49] "GET /static/vue.global.js HTTP/1.1" 200 - +127.0.0.1 - - [10/Jan/2026 07:24:21] "GET /static/index.html HTTP/1.1" 200 - +127.0.0.1 - - [10/Jan/2026 07:24:21] "GET /static/app.js HTTP/1.1" 200 - +127.0.0.1 - - [10/Jan/2026 07:24:21] "GET /static/vue.global.js HTTP/1.1" 200 - +127.0.0.1 - - [10/Jan/2026 07:24:21] "GET /static/html2canvas.min.js HTTP/1.1" 200 - +127.0.0.1 - - [10/Jan/2026 07:24:50] "GET /static/index.html HTTP/1.1" 200 - +127.0.0.1 - - [10/Jan/2026 07:24:50] "GET /static/vue.global.js HTTP/1.1" 200 - +127.0.0.1 - - [10/Jan/2026 07:24:50] "GET /static/app.js HTTP/1.1" 200 - +127.0.0.1 - - [10/Jan/2026 07:24:50] "GET /static/html2canvas.min.js HTTP/1.1" 200 - diff --git a/static/app.js b/static/app.js index a70bb23..0245165 100644 --- a/static/app.js +++ b/static/app.js @@ -252,7 +252,17 @@ createApp({ } else { // Mock schedules.value = [ - { score: 95, courses: [groups.value[0].candidates[0]] } + { + score: 95, + score_details: {'早八回避': -2, '课程紧凑': 5}, + stats: { + 'total_credits': 22.5, + 'total_hours': 400, + 'avg_weekly_hours': 25.0, + 'week_span': '1-16' + }, + courses: [groups.value[0].candidates[0]] + } ]; currentView.value = 'results'; } @@ -330,11 +340,23 @@ createApp({ const downloadImage = () => { const el = document.getElementById('capture-area'); if(window.html2canvas) { - window.html2canvas(el).then(canvas => { - const link = document.createElement('a'); - link.download = 'schedule.png'; - link.href = canvas.toDataURL(); - link.click(); + window.html2canvas(el).then(async (canvas) => { + const dataUrl = canvas.toDataURL(); + if (window.pywebview) { + // Use backend dialog + const success = await window.pywebview.api.save_image_dialog(dataUrl); + if (success) { + showToast("图片已保存"); + } else { + showToast("保存取消或失败"); + } + } else { + // Fallback for browser testing + const link = document.createElement('a'); + link.download = 'schedule.png'; + link.href = dataUrl; + link.click(); + } }); } }; diff --git a/static/index.html b/static/index.html index fc4a236..bfcaf15 100644 --- a/static/index.html +++ b/static/index.html @@ -10,7 +10,7 @@ body { font-family: "Microsoft YaHei", sans-serif; margin: 0; padding: 0; background: #f5f5f5; color: #333; } .container { max-width: 1200px; margin: 0 auto; padding: 20px; } header { background: #6200ea; color: white; padding: 15px 20px; display: flex; justify-content: space-between; align-items: center; box-shadow: 0 2px 4px rgba(0,0,0,0.1); } - h1 { margin: 0; font-size: 1.2rem; } + h1 { margin: 0; font-size: 1.2rem; display: flex; align-items: baseline; } button { background: #03dac6; border: none; padding: 8px 16px; border-radius: 4px; cursor: pointer; font-weight: bold; transition: 0.2s; } button:hover { opacity: 0.9; } button.secondary { background: #ccc; } @@ -55,7 +55,11 @@