jzyg123 commited on
Commit
1fcca97
·
verified ·
1 Parent(s): 6779fe2

Create app.py

Browse files
Files changed (1) hide show
  1. app.py +1356 -0
app.py ADDED
@@ -0,0 +1,1356 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+
3
+ from flask import Flask, render_template, request, redirect, url_for, make_response, jsonify, session
4
+ import requests
5
+ import json
6
+ import os
7
+ from urllib.parse import quote
8
+ from concurrent.futures import ThreadPoolExecutor
9
+ import hashlib
10
+
11
+ # --- 应用配置 (Application Configuration) ---
12
+ SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
13
+ TEMPLATE_DIR = os.path.join(SCRIPT_DIR, 'templates')
14
+ HISTORY_FILE = os.path.join(SCRIPT_DIR, 'history.json')
15
+ ENV_FILE = os.path.join(SCRIPT_DIR, '.env')
16
+
17
+ app = Flask(__name__, template_folder=TEMPLATE_DIR)
18
+ app.secret_key = os.urandom(24)
19
+
20
+
21
+ # --- 环境变量加载 (.env 支持) ---
22
+ def _load_env_file(path: str = ENV_FILE):
23
+ if not os.path.exists(path):
24
+ return
25
+ try:
26
+ with open(path, 'r', encoding='utf-8') as f:
27
+ for raw in f:
28
+ line = raw.strip()
29
+ if not line or line.startswith('#'):
30
+ continue
31
+ if line.startswith('export '):
32
+ line = line[len('export '):].strip()
33
+ if '=' not in line:
34
+ continue
35
+ key, value = line.split('=', 1)
36
+ key = key.strip()
37
+ value = value.strip().strip('\"').strip("'")
38
+ # 仅在未设置时从文件填充,避免覆盖真实环境变量
39
+ if key and key not in os.environ:
40
+ os.environ[key] = value
41
+ except Exception as e:
42
+ print(f"Warning: failed to load .env file: {e}")
43
+
44
+
45
+ # 尝试在应用导入时加载 .env
46
+ _load_env_file()
47
+
48
+
49
+ # --- 数据处理与持久化 (Data Handling & Persistence) ---
50
+
51
+ def _compute_entry_id(username: str, password: str, seid: str) -> str:
52
+ """基于 (username, password, seid) 计算稳定ID,不写入文件,仅在内存中使用。"""
53
+ raw = f"{username}|||{password}|||{seid}".encode('utf-8')
54
+ return hashlib.sha1(raw).hexdigest()
55
+
56
+
57
+ def load_history():
58
+ """从 JSON 文件加载查询历史(仅最小字段)。为每条记录计算内存态ID。"""
59
+ if not os.path.exists(HISTORY_FILE):
60
+ return []
61
+ try:
62
+ with open(HISTORY_FILE, 'r', encoding='utf-8') as f:
63
+ content = f.read()
64
+ items = json.loads(content) if content else []
65
+ # 为每条记录补充计算得到的 id(不写回磁盘)
66
+ enriched = []
67
+ for e in items:
68
+ username = e.get('username')
69
+ password = e.get('password')
70
+ seid = e.get('seid')
71
+ eid = _compute_entry_id(username or '', password or '', seid or '')
72
+ enriched.append({**e, 'id': eid})
73
+ return enriched
74
+ except (json.JSONDecodeError, IOError) as e:
75
+ print(f"Error loading history file: {e}")
76
+ return []
77
+
78
+ def save_history(history_data):
79
+ """将查询历史保存到 JSON 文件,并进行去重,仅保留所需字段。
80
+ 仅保存以下字段:seid、examName、username、studentName、password、totalScore、unionOrder。
81
+ """
82
+ ALLOWED_FIELDS = {"seid", "examName", "username", "studentName", "password", "totalScore", "unionOrder"}
83
+
84
+ # 去重键:(username, password, seid)
85
+ unique_entries = {}
86
+ for entry in history_data:
87
+ key = (entry.get('username'), entry.get('password'), entry.get('seid'))
88
+ if key in unique_entries:
89
+ continue
90
+ # 仅保留允许的字段
91
+ minimal_entry = {k: entry.get(k) for k in ALLOWED_FIELDS}
92
+ unique_entries[key] = minimal_entry
93
+
94
+ deduplicated_history = list(unique_entries.values())
95
+
96
+ try:
97
+ with open(HISTORY_FILE, 'w', encoding='utf-8') as f:
98
+ json.dump(deduplicated_history, f, indent=4, ensure_ascii=False)
99
+ except IOError as e:
100
+ print(f"Error saving history file: {e}")
101
+
102
+ def process_segment_data(segments):
103
+ """[优化] 处理分数段数据,过滤无效和首尾无人的分数段。
104
+ (Process score segment data, filtering invalid and empty-head/tail segments.)"""
105
+ if not isinstance(segments, list) or not segments:
106
+ return []
107
+ try:
108
+ # Filter out segments with a max score below a reasonable threshold (e.g., 10)
109
+ valid_segments = [s for s in segments if float(s.get('maxscore', 0)) > 10]
110
+ except (ValueError, TypeError):
111
+ valid_segments = segments # Fallback if conversion fails
112
+
113
+ first_idx, last_idx = -1, -1
114
+ # Find the first and last segments that actually have people in them
115
+ for i, segment in enumerate(valid_segments):
116
+ if isinstance(segment.get('num'), (int, float)) and segment['num'] > 0:
117
+ if first_idx == -1: first_idx = i
118
+ last_idx = i
119
+
120
+ # Return the slice of segments from the first person to the last person
121
+ return valid_segments[first_idx : last_idx + 1] if first_idx != -1 else []
122
+
123
+
124
+ # --- 核心 API 逻辑 (Core API Logic) ---
125
+
126
+ def login_and_get_session(user, pw):
127
+ """登录并获取会话ID。 (Login and get session ID.)"""
128
+ login_url = f"https://www.yunchengji.net/app/login?j_username={user}&j_password={pw}"
129
+ student_login_url = "https://www.yunchengji.net/app/student/login"
130
+ headers = {'User-Agent': "ycj/5.7.0(Android;12)<okhttp>(<okhttp/3.10.0>)", 'Accept-Encoding': "gzip"}
131
+ try:
132
+ with requests.Session() as s:
133
+ s.headers.update(headers)
134
+ # First login request
135
+ response1 = s.post(login_url, headers={'content-length': "0"}, allow_redirects=False, timeout=10)
136
+ response1.raise_for_status()
137
+ # Check if SESSIONID cookie is set
138
+ if not s.cookies.get("SESSIONID"):
139
+ return False, "登录失败:无法获取会话ID,请检查用户名和密码。"
140
+ # Second request to finalize student login
141
+ response2 = s.get(student_login_url, timeout=10)
142
+ response2.raise_for_status()
143
+ return True, s.cookies.get("SESSIONID")
144
+ except requests.exceptions.RequestException as e:
145
+ return False, f"登录过程中发生网络错误: {e}"
146
+
147
+ def fetch_data(session_id, url, error_context, seid=None):
148
+ """通用数据获取函数。 (Generic data fetching function.)"""
149
+ headers = {
150
+ 'User-Agent': "Mozilla/5.0 (Linux; Android 12) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.72 Mobile Safari/537.36",
151
+ 'Accept': "application/json, text/plain, */*",
152
+ 'Cookie': f"SESSIONID={session_id}",
153
+ }
154
+ if seid:
155
+ headers['Referer'] = f"https://www.yunchengji.net/app/student/report/html/report.html?seid={seid}"
156
+ try:
157
+ response = requests.get(url, headers=headers, timeout=10)
158
+ response.raise_for_status()
159
+ return True, response.json()
160
+ except requests.exceptions.RequestException as e:
161
+ return False, f"获取{error_context}时发生网络错误: {e}"
162
+ except json.JSONDecodeError:
163
+ return False, f"解析{error_context}失败:服务器返回的不是有效的JSON格式。"
164
+
165
+ def get_processed_subject_details(session_id, seid, subject_id):
166
+ """封装了获取和处理单个科目所有详细数据的逻辑。
167
+ (Encapsulates logic to fetch and process all details for a single subject.)"""
168
+ base_url = "https://www.yunchengji.net/app/student/cj"
169
+ # Fetch data from three different endpoints
170
+ _, q_data = fetch_data(session_id, f"{base_url}/question-list?seid={seid}&subjectid={subject_id}", "题目列表", seid)
171
+ _, a_data = fetch_data(session_id, f"{base_url}/report-subject?seid={seid}&subjectid={subject_id}", "科目分析", seid)
172
+ _, lq_data = fetch_data(session_id, f"{base_url}/lose-question-list?seid={seid}&subjectid={subject_id}", "失分列表", seid)
173
+
174
+ # Process question scores
175
+ q_scores_list = q_data.get("desc", {}).get("questions", [])
176
+ scores_dict = {q['title']: {'score': q.get('score'), 'totalScore': q.get('totalScore')} for q in q_scores_list if q.get('title')}
177
+
178
+ # Process question rates and merge with scores
179
+ quest_rates = a_data.get("desc", {}).get("questRates", [])
180
+ processed_questions = []
181
+ for rate_info in quest_rates:
182
+ if rate_info.get('bqFlag'): # This is a section header
183
+ processed_questions.append({'is_header': True, 'data': rate_info, 'questions': []})
184
+ elif processed_questions: # This is a question under a header
185
+ title = rate_info.get('title')
186
+ score_details = scores_dict.get(title, {'score': 'N/A', 'totalScore': 'N/A'})
187
+ processed_questions[-1]['questions'].append({**rate_info, **score_details})
188
+
189
+ # Calculate section totals
190
+ for section in processed_questions:
191
+ if section['is_header']:
192
+ my_total = sum(float(q.get('score', 0)) for q in section['questions'] if str(q.get('score')).replace('.', '', 1).isdigit())
193
+ section_total = sum(float(q.get('totalScore', 0)) for q in section['questions'] if str(q.get('totalScore')).replace('.', '', 1).isdigit())
194
+ section['data']['my_section_score'] = f"{my_total:.2f}".rstrip('0').rstrip('.')
195
+ section['data']['section_total_score'] = f"{section_total:.2f}".rstrip('0').rstrip('.')
196
+
197
+ return {
198
+ "analysis": a_data.get("desc"),
199
+ "lose_question_analysis": lq_data.get("desc"),
200
+ "processed_questions": processed_questions
201
+ }
202
+
203
+
204
+ # --- 模板上下文 (Template Context) ---
205
+
206
+ @app.context_processor
207
+ def utility_processor():
208
+ """向模板注入实用函数。 (Inject utility functions into templates.)"""
209
+ def get_rate_color_style(my_rate_str, avg_rate_str):
210
+ """Generates a background color style based on the difference between my rate and the average rate."""
211
+ try:
212
+ my_rate = float(str(my_rate_str).strip('%'))
213
+ avg_rate = float(str(avg_rate_str).strip('%'))
214
+ diff = my_rate - avg_rate
215
+ if diff > 0.1: # Better than average
216
+ intensity = min(diff / 30.0, 1.0) # Cap intensity at 30% difference
217
+ return f"background-color: hsl(130, 80%, {92 - (intensity * 20):.0f}%) !important;"
218
+ elif diff < -0.1: # Worse than average
219
+ intensity = min(abs(diff) / 30.0, 1.0) # Cap intensity at 30% difference
220
+ return f"background-color: hsl(0, 90%, {92 - (intensity * 15):.0f}%) !important;"
221
+ return ""
222
+ except (ValueError, TypeError):
223
+ return ""
224
+ return dict(get_rate_color_style=get_rate_color_style)
225
+
226
+
227
+ # --- Flask 路由 (Flask Routes) ---
228
+
229
+ @app.route('/', methods=['GET'])
230
+ def index():
231
+ """渲染主页面。 (Render the main page.)"""
232
+ # 不再加载或显示历史记录
233
+ return render_template('index.html', username='', password='', seid='')
234
+
235
+ @app.route('/query', methods=['POST'])
236
+ def query():
237
+ """处理新查询。 (Handle a new query.)"""
238
+ form = request.form
239
+ username, password, seid = form.get('username'), form.get('password'), form.get('seid')
240
+
241
+ if not all([username, password, seid]):
242
+ return render_template('index.html',
243
+ error="所有字段均为必填项。",
244
+ username=username, seid=seid)
245
+
246
+ login_success, session_or_error = login_and_get_session(username, password)
247
+ if not login_success:
248
+ error_msg = f"查询失败:{session_or_error} <br>请检查用户名 (通常为手机号) 和密码是否正确。"
249
+ return render_template('index.html',
250
+ error=error_msg,
251
+ username=username, seid=seid)
252
+
253
+ session_id = session_or_error
254
+ report_success, report_data = fetch_data(session_id, f"https://www.yunchengji.net/app/student/cj/report-total?seid={seid}", "主报告")
255
+ if not report_success or report_data.get("result") != "1":
256
+ error_msg_raw = report_data.get("desc", "获取报告失败或报告数据无效。") if isinstance(report_data, dict) else report_data
257
+ error_msg = f"查询失败:{error_msg_raw} <br>请检查用户名 (通常为手机号) 和密码和考试编号 (seid) 是否正确。"
258
+ return render_template('index.html',
259
+ error=error_msg,
260
+ username=username, seid=seid)
261
+
262
+ # 提取所需信息
263
+ desc = report_data.get("desc", {})
264
+ stu_order = desc.get("stuOrder", {})
265
+ subjects = stu_order.get("subjects", [])
266
+ total_score_info = next((s for s in subjects if s.get('name') == '总分'), {})
267
+
268
+ # 构造仅含必要字段的历史记录条目(磁盘只保存最小字段,不包含id)
269
+ minimal_entry = {
270
+ "seid": seid,
271
+ "examName": desc.get("examName"),
272
+ "username": username,
273
+ "studentName": desc.get("studentname"),
274
+ "password": password,
275
+ "totalScore": total_score_info.get("score"),
276
+ "unionOrder": total_score_info.get("unionOrder"),
277
+ }
278
+
279
+ history = load_history()
280
+ history.insert(0, minimal_entry)
281
+ save_history(history)
282
+
283
+ # 计算本次查询的内存态ID并跳转到展示页
284
+ entry_id = _compute_entry_id(username, password, seid)
285
+ return redirect(url_for('show_report', item_id=entry_id))
286
+
287
+ @app.route('/report/<item_id>')
288
+ def show_report(item_id):
289
+ """显示单个报告结果。
290
+ (Display a single report result.)"""
291
+ history = load_history()
292
+ item = next((h for h in history if h['id'] == item_id), None)
293
+ if not item:
294
+ return redirect(url_for('index'))
295
+
296
+ # 使用历史中的凭据重新拉取最新报告(不从历史中持久化原始报告数据)
297
+ login_success, session_id = login_and_get_session(item['username'], item['password'])
298
+ if not login_success:
299
+ return render_template('index.html', error=session_id, username=item['username'], seid=item['seid'])
300
+
301
+ report_ok, report_data = fetch_data(session_id, f"https://www.yunchengji.net/app/student/cj/report-total?seid={item['seid']}", "主报告")
302
+ if not report_ok or report_data.get("result") != "1":
303
+ error_msg_raw = report_data.get("desc", "获取报告失败或报告数据无效。") if isinstance(report_data, dict) else report_data
304
+ return render_template('index.html', error=error_msg_raw, username=item['username'], seid=item['seid'])
305
+
306
+ # 拉取总分分布(班级/学校/联考)
307
+ base_url = "https://www.yunchengji.net/app/student/cj/segment-list"
308
+ with ThreadPoolExecutor(max_workers=3) as executor:
309
+ future_class = executor.submit(fetch_data, session_id, f"{base_url}?seid={item['seid']}&subjectid=-100&type=class", "班级分数段", item['seid'])
310
+ future_school = executor.submit(fetch_data, session_id, f"{base_url}?seid={item['seid']}&subjectid=-100&type=school", "学校分数段", item['seid'])
311
+ future_union = executor.submit(fetch_data, session_id, f"{base_url}?seid={item['seid']}&subjectid=-100&type=union", "联考分数段", item['seid'])
312
+
313
+ _, class_data = future_class.result()
314
+ _, school_data = future_school.result()
315
+ _, union_data = future_union.result()
316
+
317
+ total_dist = {
318
+ 'class': process_segment_data(class_data.get("desc")),
319
+ 'school': process_segment_data(school_data.get("desc")),
320
+ 'union': process_segment_data(union_data.get("desc"))
321
+ }
322
+
323
+ return render_template('index.html',
324
+ report=report_data,
325
+ history_item_id=item['id'],
326
+ username=item['username'],
327
+ seid=item['seid'],
328
+ total_score_distributions=total_dist)
329
+
330
+ @app.route('/subject_details')
331
+ def subject_details():
332
+ """显示科目详情页。 (Display subject details page.)"""
333
+ args = request.args
334
+ # 从查询参数中移除 username/password,仅保留必要参数
335
+ seid, subject_id, subject_name, history_item_id = \
336
+ args.get('seid'), args.get('subject_id'), args.get('subject_name'), args.get('history_item_id')
337
+
338
+ if not all([seid, subject_id, subject_name, history_item_id]):
339
+ return render_template('subject_details.html', error="缺少必要参数。")
340
+
341
+ # 从历史记录中查找密码
342
+ history = load_history()
343
+ item = next((h for h in history if h['id'] == history_item_id), None)
344
+ if not item:
345
+ return render_template('subject_details.html', error="无法找到相关的历史记录。")
346
+
347
+ username = item.get('username')
348
+ password = item.get('password')
349
+ if not (username and password):
350
+ return render_template('subject_details.html', error="无法获取凭据。")
351
+
352
+ login_success, session_id = login_and_get_session(username, password)
353
+ if not login_success:
354
+ return render_template('subject_details.html', error=session_id)
355
+
356
+ # Fetch all necessary data for the subject details page
357
+ details = get_processed_subject_details(session_id, seid, subject_id)
358
+
359
+ _, report_data = fetch_data(session_id, f"https://www.yunchengji.net/app/student/cj/report-total?seid={seid}", "总报告")
360
+ all_subjects = report_data.get("desc", {}).get("stuOrder", {}).get("subjects", [])
361
+ subject_info = next((s for s in all_subjects if str(s.get('id')) == subject_id), None)
362
+
363
+ base_url = "https://www.yunchengji.net/app/student/cj/segment-list"
364
+ _, seg_class_data = fetch_data(session_id, f"{base_url}?seid={seid}&subjectid={subject_id}&type=class", "班级分数段", seid)
365
+ _, seg_school_data = fetch_data(session_id, f"{base_url}?seid={seid}&subjectid={subject_id}&type=school", "学校分数段", seid)
366
+ _, seg_union_data = fetch_data(session_id, f"{base_url}?seid={seid}&subjectid={subject_id}&type=union", "联考分数段", seid)
367
+
368
+ score_distributions = {
369
+ 'class': process_segment_data(seg_class_data.get("desc")),
370
+ 'school': process_segment_data(seg_school_data.get("desc")),
371
+ 'union': process_segment_data(seg_union_data.get("desc"))
372
+ }
373
+
374
+ return render_template('subject_details.html',
375
+ **details, score_distributions=score_distributions, subject_info=subject_info,
376
+ subject_name=subject_name, history_item_id=history_item_id,
377
+ exam_name=report_data.get("desc", {}).get("examName"),
378
+ student_name=report_data.get("desc", {}).get("studentname"))
379
+
380
+ @app.route('/print_report/<item_id>')
381
+ def print_report(item_id):
382
+ """[性能优化] 使用并发请求为打印页面准备所有数据。
383
+ (Use concurrent requests to prepare all data for the print page.)"""
384
+ item = next((h for h in load_history() if h['id'] == item_id), None)
385
+ if not item: return "找不到指定的历史记录。", 404
386
+
387
+ username, password, seid = item['username'], item['password'], item['seid']
388
+
389
+ login_success, session_id = login_and_get_session(username, password)
390
+ if not login_success: return f"无法生成报告:重新登录失败 - {session_id}", 500
391
+ report_ok, main_report_data = fetch_data(session_id, f"https://www.yunchengji.net/app/student/cj/report-total?seid={seid}", "主报告")
392
+ if not report_ok or main_report_data.get("result") != "1":
393
+ return f"无法生成报告:获取主报告失败。", 500
394
+
395
+ base_url = "https://www.yunchengji.net/app/student/cj/segment-list"
396
+
397
+ def fetch_all_data_for_subject(subject):
398
+ """Fetches all data for a single subject, to be run in a thread."""
399
+ subject_id = str(subject.get('id'))
400
+ details = get_processed_subject_details(session_id, seid, subject_id)
401
+ details['subject_info'] = subject
402
+
403
+ _, seg_school = fetch_data(session_id, f"{base_url}?seid={seid}&subjectid={subject_id}&type=school", f"{subject.get('name')}学校分数段", seid)
404
+ details['score_distribution'] = process_segment_data(seg_school.get("desc"))
405
+ return details
406
+
407
+ subjects_to_fetch = [s for s in main_report_data.get("desc", {}).get("stuOrder", {}).get("subjects", []) if s.get('name') != '总分']
408
+
409
+ all_subject_details = []
410
+ total_score_distribution = {}
411
+
412
+ with ThreadPoolExecutor(max_workers=10) as executor:
413
+ # Map futures to subjects to fetch details
414
+ future_to_subject = {executor.submit(fetch_all_data_for_subject, s): s for s in subjects_to_fetch}
415
+ # Fetch total score distribution concurrently
416
+ future_total_dist = executor.submit(fetch_data, session_id, f"{base_url}?seid={seid}&subjectid=-100&type=school", "总分学校分数段", seid)
417
+
418
+ for future in future_to_subject:
419
+ try:
420
+ all_subject_details.append(future.result())
421
+ except Exception as exc:
422
+ print(f'Exception fetching subject data: {exc}')
423
+
424
+ try:
425
+ _, school_data = future_total_dist.result()
426
+ total_score_distribution = process_segment_data(school_data.get("desc"))
427
+ except Exception as exc:
428
+ print(f'Exception fetching total score distribution data: {exc}')
429
+
430
+ return render_template('print_report.html',
431
+ report=main_report_data,
432
+ all_subject_details=all_subject_details,
433
+ total_score_distribution=total_score_distribution,
434
+ exam_name=main_report_data.get("desc", {}).get("examName"),
435
+ student_name=main_report_data.get("desc", {}).get("studentname"))
436
+
437
+
438
+ # --- 管理后台 (Admin) ---
439
+
440
+ def _admin_enabled():
441
+ return bool(os.environ.get('ADMIN_PASSWORD'))
442
+
443
+
444
+ @app.route('/admin', methods=['GET', 'POST'])
445
+ def admin():
446
+ # 登录处理
447
+ if request.method == 'POST':
448
+ pw = request.form.get('password', '')
449
+ real = os.environ.get('ADMIN_PASSWORD', '')
450
+ if not real:
451
+ return render_template('admin.html', logged_in=False, admin_enabled=False, error='未配置 ADMIN_PASSWORD,无法登录。')
452
+ if pw == real:
453
+ session['is_admin'] = True
454
+ return redirect(url_for('admin'))
455
+ else:
456
+ return render_template('admin.html', logged_in=False, admin_enabled=True, error='密码错误。')
457
+
458
+ # 非登录请求
459
+ if not session.get('is_admin'):
460
+ return render_template('admin.html', logged_in=False, admin_enabled=_admin_enabled())
461
+
462
+ # 已登录:读取历史并过滤
463
+ q = request.args.get('q', '').strip()
464
+ history = load_history()
465
+ entries = history
466
+ if q:
467
+ qlower = q.lower()
468
+ def _match(e):
469
+ return (
470
+ (e.get('examName') or '').lower().find(qlower) != -1 or
471
+ (e.get('username') or '').lower().find(qlower) != -1 or
472
+ (e.get('studentName') or '').lower().find(qlower) != -1 or
473
+ str(e.get('seid') or '').lower().find(qlower) != -1
474
+ )
475
+ entries = [e for e in history if _match(e)]
476
+
477
+ return render_template('admin.html', logged_in=True, entries=entries, query=q)
478
+
479
+
480
+ @app.route('/admin/logout')
481
+ def admin_logout():
482
+ session.pop('is_admin', None)
483
+ return redirect(url_for('admin'))
484
+
485
+
486
+ # --- 模板定义 (Template Definitions) ---
487
+
488
+ def create_templates():
489
+ """在 'templates' 目录中创建或更新HTML模板文件。
490
+ (Create or update HTML template files in the 'templates' directory.)"""
491
+ if not os.path.exists(TEMPLATE_DIR):
492
+ os.makedirs(TEMPLATE_DIR)
493
+
494
+ # 主页面模板 (index.html)
495
+ index_html = """
496
+ <!DOCTYPE html>
497
+ <html lang="zh-CN">
498
+ <head>
499
+ <meta charset="UTF-8">
500
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
501
+ <title>云成绩查询系统</title>
502
+ <script src="https://cdn.tailwindcss.com"></script>
503
+ <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
504
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Noto+Sans+SC:wght@400;500;700&display=swap" rel="stylesheet">
505
+ <style>
506
+ body { font-family: 'Inter', 'Noto Sans SC', sans-serif; }
507
+ .loader { border: 4px solid #f3f3f3; border-top: 4px solid #4f46e5; border-radius: 50%; width: 40px; height: 40px; animation: spin 1s linear infinite; }
508
+ @keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
509
+ .table-cell { padding: 0.75rem 1rem; white-space: nowrap; text-align: center; }
510
+ .tab-button { padding: 0.5rem 1rem; font-weight: 500; cursor: pointer; border-bottom: 2px solid transparent; transition: all 0.2s; }
511
+ .tab-button.active { color: #4f46e5; border-bottom-color: #4f46e5; }
512
+ .tab-content { display: none; } .tab-content.active { display: block; }
513
+ .disclaimer { font-size: 0.75rem; color: #9ca3af; text-align: center; margin-top: 2rem; }
514
+ </style>
515
+ </head>
516
+ <body class="bg-gray-50 text-gray-800">
517
+ <div class="container mx-auto p-4 md:p-8 max-w-5xl">
518
+ <header class="text-center mb-8">
519
+ <h1 class="text-4xl font-bold text-gray-900">云成绩查询系统</h1>
520
+ <p class="text-gray-600 mt-2">输入您的信息以获取最新的考试报告</p>
521
+ </header>
522
+
523
+ <div class="grid grid-cols-1 gap-8">
524
+
525
+ <div class="bg-white p-6 rounded-xl shadow-lg">
526
+ <h2 class="text-2xl font-bold mb-4 border-b pb-2">查询成绩</h2>
527
+ <form id="query-form" method="POST" action="{{ url_for('query') }}">
528
+ <div class="grid grid-cols-1 md:grid-cols-3 gap-4">
529
+ <div>
530
+ <label for="username" class="block text-sm font-medium text-gray-700">用户名 (手机号)</label>
531
+ <input type="text" name="username" id="username" required value="{{ username or '' }}" class="mt-1 block w-full px-3 py-2 bg-white border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 transition">
532
+ </div>
533
+ <div>
534
+ <label for="password" class="block text-sm font-medium text-gray-700">密码</label>
535
+ <input type="password" name="password" id="password" required value="" class="mt-1 block w-full px-3 py-2 bg-white border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 transition">
536
+ </div>
537
+ <div>
538
+ <label for="seid" class="block text-sm font-medium text-gray-700">考试编号 (seid)</label>
539
+ <input type="text" name="seid" id="seid" required value="{{ seid or '' }}" class="mt-1 block w-full px-3 py-2 bg-white border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 transition">
540
+ </div>
541
+ </div>
542
+ <button type="submit" id="submit-btn" class="mt-6 w-full md:w-auto flex justify-center py-3 px-8 border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 transition-colors">立即查询</button>
543
+ </form>
544
+ <div id="loader-container" class="hidden items-center justify-center mt-4"><div class="loader"></div><p class="ml-4 text-gray-600">正在查询...</p></div>
545
+
546
+ <div class="mt-6 bg-gray-100 p-4 rounded-lg">
547
+ <h3 class="font-semibold text-gray-800 mb-3">如何获取考试的 seID?</h3>
548
+ <div class="space-y-3 text-sm text-gray-600">
549
+ <p>1. 登录云成绩电脑版 <a href="https://www.yunchengji.net/" target="_blank" class="text-indigo-600 hover:underline">https://www.yunchengji.net/</a></p>
550
+ <p>2. 点击进入需要查询的考试。</p>
551
+ <p>3. 此时浏览器地址栏中的链接会包含 <code class="bg-gray-200 px-1 py-0.5 rounded">?seid=</code>,等号后面的数字即为本次考试的 seID。</p>
552
+ </div>
553
+ <h3 class="font-semibold text-gray-800 mt-4 mb-2">目前已知的seID:</h3>
554
+ <ul class="list-disc list-inside text-sm text-gray-600 space-y-1">
555
+ <li>昆明市2025届高二下学期市统测 (理科): <code class="bg-gray-200 px-1 py-0.5 rounded">316099</code></li>
556
+ <li>昆明市2025届高二下学期市统测 (文科): <code class="bg-gray-200 px-1 py-0.5 rounded">316097</code></li>
557
+ </ul>
558
+ </div>
559
+ <p class="disclaimer">免责声明:此页面仅供学习与技术研究使用,不提供任何破解、绕过等功能,请勿用于商业或非法用途。如有侵权请联系移除。</p>
560
+ </div>
561
+
562
+ <div id="results-container" class="bg-white p-6 md:p-8 rounded-xl shadow-lg">
563
+ {% if error %}
564
+ <div class="bg-red-100 border-l-4 border-red-500 text-red-700 p-4 rounded-md" role="alert">
565
+ <p class="font-bold">查询出错</p>
566
+ <p>{{ error | safe }}</p>
567
+ </div>
568
+ {% endif %}
569
+
570
+ {% if report and report.result == "1" %}
571
+ {% set desc = report.desc or {} %}{% set stu_order = desc.stuOrder or {} %}{% set subjects = stu_order.subjects or [] %}{% set score_gap = stu_order.scoreGap or {} %}{% set total_score_info = (subjects | selectattr('name', 'equalto', '总分') | first) or {} %}
572
+ <div id="report-content" class="space-y-10">
573
+ <header class="text-center border-b-2 pb-4 border-indigo-100">
574
+ <h2 class="text-3xl font-bold text-indigo-700">{{ desc.examName or '考试报告' }}</h2>
575
+ <p class="text-xl text-gray-600 mt-2">{{ desc.studentname or '未知学生' }}</p>
576
+ </header>
577
+
578
+ <div class="text-center">
579
+ <button onclick="window.open('{{ url_for('print_report', item_id=history_item_id) }}', '_blank')" class="inline-flex items-center px-4 py-2 border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 transition-colors">
580
+ <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" viewBox="0 0 20 20" fill="currentColor"><path d="M5 4a2 2 0 00-2 2v6a2 2 0 002 2h10a2 2 0 002-2V6a2 2 0 00-2-2H5zm0 2h10v6H5V6zm2 10h6v2H7v-2z" /></svg>
581
+ 打印/保存为PDF
582
+ </button>
583
+ </div>
584
+
585
+ <div>
586
+ <h3 class="text-xl font-semibold mb-4 text-gray-700">总分与排名情况</h3>
587
+ <div class="grid grid-cols-2 sm:grid-cols-4 gap-4 text-center">
588
+ <div class="bg-indigo-50 p-4 rounded-lg"><p class="text-sm text-indigo-800">总分</p><p class="text-2xl font-bold text-indigo-900">{{ total_score_info.score or 'N/A' }} / {{ total_score_info.fullScore or 'N/A' }}</p></div>
589
+ <div class="bg-purple-50 p-4 rounded-lg"><p class="text-sm text-purple-800">班级排名</p><p class="text-2xl font-bold text-purple-900">{{ total_score_info.classOrder or 'N/A' }}</p></div>
590
+ <div class="bg-pink-50 p-4 rounded-lg"><p class="text-sm text-pink-800">学校排名</p><p class="text-2xl font-bold text-pink-900">{{ total_score_info.schoolOrder or 'N/A' }}</p></div>
591
+ <div class="bg-green-50 p-4 rounded-lg"><p class="text-sm text-green-800">统考排名</p><p class="text-2xl font-bold text-green-900">{{ total_score_info.unionOrder or 'N/A' }}</p></div>
592
+ </div>
593
+ </div>
594
+
595
+ {% if total_score_distributions and (total_score_distributions.class or total_score_distributions.school or total_score_distributions.union) %}
596
+ <div>
597
+ <h3 class="text-xl font-semibold mb-4 text-gray-700">总分分数段分布</h3>
598
+ <div class="border-b border-gray-200">
599
+ <nav class="-mb-px flex space-x-4" aria-label="Tabs" id="total-score-tabs">
600
+ {% if total_score_distributions.class %}<button class="tab-button active" data-tab="total-class">班级</button>{% endif %}
601
+ {% if total_score_distributions.school %}<button class="tab-button {% if not total_score_distributions.class %}active{% endif %}" data-tab="total-school">学校</button>{% endif %}
602
+ {% if total_score_distributions.union %}<button class="tab-button {% if not total_score_distributions.class and not total_score_distributions.school %}active{% endif %}" data-tab="total-union">联考</button>{% endif %}
603
+ </nav>
604
+ </div>
605
+ <div class="mt-4" id="total-score-tabs-content" style="height: 300px;">
606
+ {% if total_score_distributions.class %}<div id="tab-content-total-class" class="tab-content active h-full"><canvas id="totalClassSegmentChart"></canvas></div>{% endif %}
607
+ {% if total_score_distributions.school %}<div id="tab-content-total-school" class="tab-content {% if not total_score_distributions.class %}active{% endif %} h-full"><canvas id="totalSchoolSegmentChart"></canvas></div>{% endif %}
608
+ {% if total_score_distributions.union %}<div id="tab-content-total-union" class="tab-content {% if not total_score_distributions.class and not total_score_distributions.school %}active{% endif %} h-full"><canvas id="totalUnionSegmentChart"></canvas></div>{% endif %}
609
+ </div>
610
+ </div>
611
+ {% endif %}
612
+
613
+ <div>
614
+ <h3 class="text-xl font-semibold mb-4 text-gray-700">各科成绩与排名详情</h3>
615
+ <p class="text-sm text-gray-500 mb-2 p-2 bg-gray-100 rounded-md">💡 点击科目名称可查看详细分析</p>
616
+ <div class="overflow-x-auto"><table class="min-w-full bg-white border border-gray-200 rounded-lg"><thead class="bg-gray-50"><tr><th class="table-cell text-left text-xs font-medium text-gray-500 uppercase tracking-wider">科目</th><th class="table-cell text-xs font-medium text-gray-500 uppercase tracking-wider">得分/赋分 (原始分)</th><th class="table-cell text-xs font-medium text-gray-500 uppercase tracking-wider">班级排名</th><th class="table-cell text-xs font-medium text-gray-500 uppercase tracking-wider">学校排名</th><th class="table-cell text-xs font-medium text-gray-500 uppercase tracking-wider">统考排名</th></tr></thead><tbody class="divide-y divide-gray-200">
617
+ {% for subject in subjects %}{% if subject.name != '总分' %}<tr>
618
+ <td class="table-cell text-left font-medium text-gray-900"><a href="{{ url_for('subject_details', seid=seid, subject_id=subject.id, subject_name=subject.name, history_item_id=history_item_id) }}" class="text-indigo-600 hover:underline">{{ subject.name }}</a></td>
619
+ <td class="table-cell font-semibold">{% if subject.score != subject.paperScore %}{{ subject.score }} ({{ subject.paperScore }}){% else %}{{ subject.score }}{% endif %}</td>
620
+ <td class="table-cell">{{ subject.classOrder or 'N/A' }}</td><td class="table-cell">{{ subject.schoolOrder or 'N/A' }}</td><td class="table-cell">{{ subject.unionOrder or 'N/A' }}</td>
621
+ </tr>{% endif %}{% endfor %}
622
+ </tbody></table></div>
623
+ </div>
624
+
625
+ <div>
626
+ <h3 class="text-xl font-semibold mb-4 text-gray-700">各科表现雷达图</h3>
627
+ <div class="max-w-xl mx-auto">
628
+ <div class="bg-gray-50 p-4 rounded-lg"><canvas id="radarChart"></canvas></div>
629
+ </div>
630
+ </div>
631
+
632
+ <div>
633
+ <h3 class="text-xl font-semibold mb-4 text-gray-700">统计数据</h3>
634
+ <div class="overflow-x-auto"><table class="min-w-full bg-white border border-gray-200 rounded-lg"><thead class="bg-gray-50"><tr><th class="table-cell text-left text-xs font-medium text-gray-500 uppercase tracking-wider">类别</th><th class="table-cell text-xs font-medium text-gray-500 uppercase tracking-wider">平均分</th><th class="table-cell text-xs font-medium text-gray-500 uppercase tracking-wider">最高分</th><th class="table-cell text-xs font-medium text-gray-500 uppercase tracking-wider">总人数</th></tr></thead><tbody class="divide-y divide-gray-200">
635
+ <tr><td class="table-cell text-left font-medium">班级</td><td class="table-cell">{{ score_gap.classAvg or 'N/A' }}</td><td class="table-cell">{{ score_gap.classTop or 'N/A' }}</td><td class="table-cell">{{ score_gap.classNum or 'N/A' }}</td></tr>
636
+ <tr><td class="table-cell text-left font-medium">学校</td><td class="table-cell">{{ score_gap.schoolAvg or 'N/A' }}</td><td class="table-cell">{{ score_gap.schoolTop or 'N/A' }}</td><td class="table-cell">{{ score_gap.schoolNum or 'N/A' }}</td></tr>
637
+ <tr><td class="table-cell text-left font-medium">统考</td><td class="table-cell">{{ score_gap.unionAvg or 'N/A' }}</td><td class="table-cell">{{ score_gap.unionTop or 'N/A' }}</td><td class="table-cell">{{ score_gap.unionNum or 'N/A' }}</td></tr>
638
+ </tbody></table></div>
639
+ </div>
640
+ <p class="disclaimer">免责声明:此页面仅供学习与技术研究使用,不提供任何破解、绕过等功能,请勿用于商业或非法用途。如有侵权请联系移除。</p>
641
+ </div>
642
+ {% elif not error %}
643
+ <div class="text-center py-16">
644
+ <svg class="mx-auto h-12 w-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path vector-effect="non-scaling-stroke" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /></svg>
645
+ <h3 class="mt-2 text-sm font-medium text-gray-900">暂无结果</h3>
646
+ <p class="mt-1 text-sm text-gray-500">请在上方输入信息进行查询。</p>
647
+ </div>
648
+ {% endif %}
649
+ </div>
650
+ </div>
651
+ </div>
652
+ <script>
653
+ document.addEventListener('DOMContentLoaded', function () {
654
+ // Show loader on form submission
655
+ document.getElementById('query-form')?.addEventListener('submit', () => {
656
+ document.getElementById('submit-btn').classList.add('hidden');
657
+ document.getElementById('loader-container').style.display = 'flex';
658
+ });
659
+
660
+ function createBarChart(config) {
661
+ const ctx = document.getElementById(config.ctx);
662
+ if (!ctx) return;
663
+ // Display a message if there is no data
664
+ if (!config.data || config.data.length === 0) {
665
+ ctx.parentElement.innerHTML = `<div class="flex items-center justify-center h-full text-gray-500"><svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg><span>此范围无可用数据</span></div>`;
666
+ return;
667
+ }
668
+ const labels = config.data.map(d => `${d.minscore}-${d.maxscore}`);
669
+ const dataPoints = config.data.map(d => d.num);
670
+ const backgroundColors = config.data.map(d => d.stuflag ? 'rgba(139, 92, 246, 0.8)' : 'rgba(56, 189, 248, 0.6)');
671
+ const borderColors = config.data.map(d => d.stuflag ? 'rgba(139, 92, 246, 1)' : 'rgba(56, 189, 248, 1)');
672
+ new Chart(ctx.getContext('2d'), {
673
+ type: 'bar', data: { labels: labels, datasets: [{ label: config.label, data: dataPoints, backgroundColor: backgroundColors, borderColor: borderColors, borderWidth: 1 }] },
674
+ options: { responsive: true, maintainAspectRatio: false, animation: { duration: 1000 }, scales: { y: { beginAtZero: true } }, plugins: { legend: { display: false }, tooltip: { callbacks: { title: ctx => `分数段: ${ctx[0].label}`, label: ctx => `${config.label}: ${ctx.parsed.y}${config.data[ctx.dataIndex].stuflag ? ' (我的位置)' : ''}` } } } }
675
+ });
676
+ }
677
+
678
+ function setupTabs(tabContainerId, contentContainerId) {
679
+ const tabContainer = document.getElementById(tabContainerId);
680
+ if (!tabContainer) return;
681
+ const tabs = tabContainer.querySelectorAll('.tab-button');
682
+ const tabContents = document.getElementById(contentContainerId).querySelectorAll('.tab-content');
683
+ tabs.forEach(tab => {
684
+ tab.addEventListener('click', () => {
685
+ tabs.forEach(item => item.classList.remove('active'));
686
+ tab.classList.add('active');
687
+ const target = tab.getAttribute('data-tab');
688
+ tabContents.forEach(content => {
689
+ content.classList.toggle('active', content.id === `tab-content-${target}`);
690
+ });
691
+ });
692
+ });
693
+ }
694
+
695
+ {% if report and report.result == "1" %}
696
+ try {
697
+ const reportData = {{ report | tojson | safe }};
698
+ const radarCtx = document.getElementById('radarChart');
699
+ if (radarCtx && reportData.desc.stuOrder) {
700
+ const subjects = reportData.desc.stuOrder.subjects.filter(s => s.name !== '总分' && s.schoolOrder > 0);
701
+ const schoolTotal = reportData.desc.stuOrder.scoreGap.schoolNum;
702
+ if (subjects.length > 0 && schoolTotal > 0) {
703
+ const labels = subjects.map(s => s.name);
704
+ const dataPoints = subjects.map(s => ((1 - ((s.schoolOrder - 1) / schoolTotal)) * 100).toFixed(1));
705
+ new Chart(radarCtx.getContext('2d'), {
706
+ type: 'radar', data: { labels: labels, datasets: [{ label: '学校排名百分位 (越高越好)', data: dataPoints, fill: true, backgroundColor: 'rgba(79, 70, 229, 0.2)', borderColor: 'rgb(79, 70, 229)', pointBackgroundColor: 'rgb(79, 70, 229)' }] },
707
+ options: { elements: { line: { borderWidth: 3 } }, scales: { r: { suggestedMin: 0, suggestedMax: 100, ticks: { backdropColor: 'transparent', stepSize: 20 } } } }
708
+ });
709
+ }
710
+ }
711
+ } catch (e) { console.error("Radar chart rendering error:", e); }
712
+ {% endif %}
713
+
714
+ {% if total_score_distributions %}
715
+ try {
716
+ const distributions = {{ total_score_distributions | tojson | safe }};
717
+ if (distributions.class) createBarChart({ ctx: 'totalClassSegmentChart', label: '班级人数', data: distributions.class });
718
+ if (distributions.school) createBarChart({ ctx: 'totalSchoolSegmentChart', label: '学校人数', data: distributions.school });
719
+ if (distributions.union) createBarChart({ ctx: 'totalUnionSegmentChart', label: '联考人数', data: distributions.union });
720
+ setupTabs('total-score-tabs', 'total-score-tabs-content');
721
+ } catch (e) { console.error("Total score distribution chart rendering error:", e); }
722
+ {% endif %}
723
+ });
724
+ </script>
725
+ </body>
726
+ </html>
727
+ """
728
+
729
+ # 科目详情页模板 (subject_details.html)
730
+ subject_details_html = """
731
+ <!DOCTYPE html>
732
+ <html lang="zh-CN">
733
+ <head>
734
+ <meta charset="UTF-8">
735
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
736
+ <title>{{ subject_name }} - 题目详情</title>
737
+ <script src="https://cdn.tailwindcss.com"></script>
738
+ <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
739
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Noto+Sans+SC:wght@400;500;700&display=swap" rel="stylesheet">
740
+ <style>
741
+ body { font-family: 'Inter', 'Noto Sans SC', sans-serif; }
742
+ .table-cell { padding: 0.75rem 1rem; text-align: center; }
743
+ .table-cell-head { padding: 0.75rem 1rem; text-align: center; font-size: 0.75rem; font-weight: 500; color: #6b7280; text-transform: uppercase; }
744
+ .table-cell.text-left { text-align: left; }
745
+ .tab-button { padding: 0.5rem 1rem; font-weight: 500; cursor: pointer; border-bottom: 2px solid transparent; transition: all 0.2s; }
746
+ .tab-button.active { color: #4f46e5; border-bottom-color: #4f46e5; }
747
+ .tab-content { display: none; } .tab-content.active { display: block; }
748
+ .disclaimer { font-size: 0.75rem; color: #9ca3af; text-align: center; margin-top: 2rem; }
749
+ </style>
750
+ </head>
751
+ <body class="bg-gray-50 text-gray-800">
752
+ <div class="max-w-7xl mx-auto px-4 md:px-8 pt-6">
753
+ <a href="{{ url_for('show_report', item_id=history_item_id) }}" class="inline-flex items-center text-indigo-600 hover:text-indigo-800 font-semibold group">
754
+ <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2 transition-transform group-hover:-translate-x-1" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M9.707 16.707a1 1 0 01-1.414 0l-6-6a1 1 0 010-1.414l6-6a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l4.293 4.293a1 1 0 010 1.414z" clip-rule="evenodd" /></svg>
755
+ 返回总报告
756
+ </a>
757
+ </div>
758
+ <div class="container mx-auto p-4 md:p-8 max-w-7xl pt-2 md:pt-4">
759
+ {% if error %}
760
+ <div class="bg-red-100 border-l-4 border-red-500 text-red-700 p-4 rounded-md mb-8"><p class="font-bold">发生错误</p><p>{{ error | safe }}</p></div>
761
+ {% elif analysis %}
762
+ <div class="bg-white p-6 md:p-8 rounded-xl shadow-lg space-y-10">
763
+ <header class="text-center border-b-2 pb-4 border-indigo-100">
764
+ <h1 class="text-3xl font-bold text-indigo-700">{{ subject_name }} - 详细学情分析</h1>
765
+ <p class="text-lg text-gray-600 mt-2">{{ exam_name or '考试报告' }}</p>
766
+ <p class="text-md text-gray-500 mt-1">{{ student_name or '未知学生' }}</p>
767
+ </header>
768
+
769
+ <div class="report-container">
770
+ <h3 class="text-xl font-semibold mb-4 text-gray-700">学情诊断</h3>
771
+ <div class="bg-yellow-50 border-l-4 border-yellow-400 text-yellow-800 p-4 rounded-md"><p class="font-bold">"{{ analysis.analysisDesc }}"</p></div>
772
+ </div>
773
+
774
+ {% if subject_info %}
775
+ <div class="report-container">
776
+ <h3 class="text-xl font-semibold mb-4 text-gray-700">本科目成绩概览</h3>
777
+ <div class="grid grid-cols-2 sm:grid-cols-4 gap-4 text-center">
778
+ <div class="bg-indigo-50 p-4 rounded-lg"><p class="text-sm text-indigo-800">本科目得分</p><p class="text-2xl font-bold text-indigo-900">{{ subject_info.score or 'N/A' }} / {{ subject_info.fullScore or 'N/A' }}</p></div>
779
+ <div class="bg-purple-50 p-4 rounded-lg"><p class="text-sm text-purple-800">班级排名</p><p class="text-2xl font-bold text-purple-900">{{ subject_info.classOrder or 'N/A' }}</p></div>
780
+ <div class="bg-pink-50 p-4 rounded-lg"><p class="text-sm text-pink-800">学校排名</p><p class="text-2xl font-bold text-pink-900">{{ subject_info.schoolOrder or 'N/A' }}</p></div>
781
+ <div class="bg-green-50 p-4 rounded-lg"><p class="text-sm text-green-800">统考排名</p><p class="text-2xl font-bold text-green-900">{{ subject_info.unionOrder or 'N/A' }}</p></div>
782
+ </div>
783
+ </div>
784
+ {% endif %}
785
+
786
+ {% if score_distributions and (score_distributions.class or score_distributions.school or score_distributions.union) %}
787
+ <div class="report-container">
788
+ <h3 class="text-xl font-semibold mb-4 text-gray-700">分数段人数分布</h3>
789
+ <div class="border-b border-gray-200">
790
+ <nav class="-mb-px flex space-x-4" aria-label="Tabs" id="subject-score-tabs">
791
+ {% if score_distributions.class %}<button class="tab-button active" data-tab="class">班级</button>{% endif %}
792
+ {% if score_distributions.school %}<button class="tab-button {% if not score_distributions.class %}active{% endif %}" data-tab="school">学校</button>{% endif %}
793
+ {% if score_distributions.union %}<button class="tab-button {% if not score_distributions.class and not score_distributions.school %}active{% endif %}" data-tab="union">联考</button>{% endif %}
794
+ </nav>
795
+ </div>
796
+ <div class="mt-4" id="subject-score-tabs-content" style="height: 300px;">
797
+ {% if score_distributions.class %}<div id="tab-content-class" class="tab-content active h-full"><canvas id="classSegmentChart"></canvas></div>{% endif %}
798
+ {% if score_distributions.school %}<div id="tab-content-school" class="tab-content {% if not score_distributions.class %}active{% endif %} h-full"><canvas id="schoolSegmentChart"></canvas></div>{% endif %}
799
+ {% if score_distributions.union %}<div id="tab-content-union" class="tab-content {% if not score_distributions.class and not score_distributions.school %}active{% endif %} h-full"><canvas id="unionSegmentChart"></canvas></div>{% endif %}
800
+ </div>
801
+ </div>
802
+ {% endif %}
803
+
804
+ <div class="report-container">
805
+ <h3 class="text-xl font-semibold mb-4 text-gray-700">题目得分明细 (含得分率)</h3>
806
+ <div class="overflow-x-auto border border-gray-200 rounded-lg">
807
+ <table class="min-w-full bg-white">
808
+ <thead class="bg-gray-100"><tr>
809
+ <th class="table-cell-head text-left">题号</th>
810
+ <th class="table-cell-head">我的得分</th>
811
+ <th class="table-cell-head">我的得分率</th>
812
+ <th class="table-cell-head">班级得分率</th>
813
+ <th class="table-cell-head">学校得分率</th>
814
+ <th class="table-cell-head">联考得分率</th>
815
+ </tr></thead>
816
+ <tbody class="divide-y divide-gray-200">
817
+ {% for section in processed_questions %}
818
+ <tr class="bg-gray-100 font-bold text-gray-800">
819
+ <td class="table-cell text-left">{{ section.data.title }}</td>
820
+ <td class="table-cell">{{ section.data.my_section_score }} / {{ section.data.section_total_score }}</td>
821
+ <td class="table-cell" style="{{ get_rate_color_style(section.data.scoreRate, section.data.classScoreRate) }}">{{ section.data.scoreRate }}</td>
822
+ <td class="table-cell">{{ section.data.classScoreRate }}</td>
823
+ <td class="table-cell">{{ section.data.schoolScoreRate }}</td>
824
+ <td class="table-cell">{{ section.data.unionScoreRate }}</td>
825
+ </tr>
826
+ {% for q in section.questions %}
827
+ <tr class="hover:bg-gray-50">
828
+ <td class="table-cell text-left pl-8">{{ q.title }}</td>
829
+ <td class="table-cell font-semibold">{{ q.score }} / {{ q.totalScore }}</td>
830
+ <td class="table-cell font-semibold" style="{{ get_rate_color_style(q.scoreRate, q.classScoreRate) }}">{{ q.scoreRate }}</td>
831
+ <td class="table-cell text-gray-600">{{ q.classScoreRate }}</td>
832
+ <td class="table-cell text-gray-600">{{ q.schoolScoreRate }}</td>
833
+ <td class="table-cell text-gray-600">{{ q.unionScoreRate }}</td>
834
+ </tr>
835
+ {% endfor %}
836
+ {% endfor %}
837
+ </tbody>
838
+ </table>
839
+ </div>
840
+ </div>
841
+
842
+ {% if lose_question_analysis %}
843
+ <div class="report-container">
844
+ <h3 class="text-xl font-semibold mb-4 text-gray-700">失分题目详情</h3>
845
+ <div class="space-y-6">
846
+ {% for category in lose_question_analysis %}
847
+ <div>
848
+ <h4 class="text-lg font-bold mb-2 text-gray-800">{{ category.name }} (得分: {{ category.score }} / {{ category.totalScore }})</h4>
849
+ {% if category.errors %}
850
+ <div class="overflow-x-auto border border-gray-200 rounded-lg">
851
+ <table class="min-w-full bg-white">
852
+ <thead class="bg-gray-100"><tr><th class="table-cell-head">失分题号</th><th class="table-cell-head">我的得分</th><th class="table-cell-head">题目满分</th><th class="table-cell-head">联考平均分</th></tr></thead>
853
+ <tbody class="divide-y divide-gray-200">
854
+ {% for error in category.errors %}
855
+ <tr class="bg-red-50/50">
856
+ <td class="table-cell font-bold">{{ error.title }}</td>
857
+ <td class="table-cell font-semibold text-red-600">{{ error.studentScore }}</td>
858
+ <td class="table-cell">{{ error.score }}</td>
859
+ <td class="table-cell">{{ "%.2f"|format(error.average) }}</td>
860
+ </tr>
861
+ {% endfor %}
862
+ </tbody>
863
+ </table>
864
+ </div>
865
+ {% else %}
866
+ <p class="text-green-600 font-semibold p-3 bg-green-50 rounded-md text-center">恭喜!该难度题目全部正确!</p>
867
+ {% endif %}
868
+ </div>
869
+ {% endfor %}
870
+ </div>
871
+ </div>
872
+ {% endif %}
873
+ <p class="disclaimer">免责声明:此页面仅供学习与技术研究使用,不提供任何破解、绕过等功能,请勿用于商业或非法用途。如有侵权请联系移除。</p>
874
+ </div>
875
+ {% else %}
876
+ <div class="text-center py-16"><svg class="mx-auto h-12 w-12 text-gray-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" /></svg><h3 class="mt-2 text-lg font-medium text-gray-900">未能加载科目详情</h3><p class="mt-1 text-sm text-gray-500">{{ analysis.desc or "返回数据格式不正确或为空。" }}</p></div>
877
+ {% endif %}
878
+ </div>
879
+ <script>
880
+ document.addEventListener('DOMContentLoaded', function () {
881
+ function createBarChart(config) {
882
+ const ctx = document.getElementById(config.ctx);
883
+ if (!ctx) return;
884
+ if (!config.data || config.data.length === 0) {
885
+ ctx.parentElement.innerHTML = `<div class="flex items-center justify-center h-full text-gray-500"><svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg><span>此范围无可用数据</span></div>`;
886
+ return;
887
+ }
888
+ const labels = config.data.map(d => `${d.minscore}-${d.maxscore}`);
889
+ const dataPoints = config.data.map(d => d.num);
890
+ const backgroundColors = config.data.map(d => d.stuflag ? 'rgba(139, 92, 246, 0.8)' : 'rgba(56, 189, 248, 0.6)');
891
+ const borderColors = config.data.map(d => d.stuflag ? 'rgba(139, 92, 246, 1)' : 'rgba(56, 189, 248, 1)');
892
+ new Chart(ctx.getContext('2d'), {
893
+ type: 'bar', data: { labels: labels, datasets: [{ label: config.label, data: dataPoints, backgroundColor: backgroundColors, borderColor: borderColors, borderWidth: 1 }] },
894
+ options: { responsive: true, maintainAspectRatio: false, animation: { duration: 1000 }, scales: { y: { beginAtZero: true } }, plugins: { legend: { display: false }, tooltip: { callbacks: { title: ctx => `分数段: ${ctx[0].label}`, label: ctx => `${config.label}: ${ctx.parsed.y}${config.data[ctx.dataIndex].stuflag ? ' (我的位置)' : ''}` } } } }
895
+ });
896
+ }
897
+ function setupTabs(tabContainerId, contentContainerId) {
898
+ const tabContainer = document.getElementById(tabContainerId);
899
+ if (!tabContainer) return;
900
+ const tabs = tabContainer.querySelectorAll('.tab-button');
901
+ const tabContents = document.getElementById(contentContainerId).querySelectorAll('.tab-content');
902
+ tabs.forEach(tab => {
903
+ tab.addEventListener('click', () => {
904
+ tabs.forEach(item => item.classList.remove('active'));
905
+ tab.classList.add('active');
906
+ const target = tab.getAttribute('data-tab');
907
+ tabContents.forEach(content => {
908
+ content.classList.toggle('active', content.id === `tab-content-${target}`);
909
+ });
910
+ });
911
+ });
912
+ }
913
+ try {
914
+ const distributions = {{ score_distributions | tojson | safe }};
915
+ if(distributions) {
916
+ if(distributions.class) createBarChart({ ctx: 'classSegmentChart', label: '班级人数', data: distributions.class });
917
+ if(distributions.school) createBarChart({ ctx: 'schoolSegmentChart', label: '学校人数', data: distributions.school });
918
+ if(distributions.union) createBarChart({ ctx: 'unionSegmentChart', label: '联考人数', data: distributions.union });
919
+ setupTabs('subject-score-tabs', 'subject-score-tabs-content');
920
+ }
921
+ } catch(e) { console.error("Subject score distribution chart error:", e); }
922
+ });
923
+ </script>
924
+ </body>
925
+ </html>
926
+ """
927
+
928
+ # [重构] 打印专用模板 (print_report.html)
929
+ print_report_html = """
930
+ <!DOCTYPE html>
931
+ <html lang="zh-CN">
932
+ <head>
933
+ <meta charset="UTF-8">
934
+ <title>打印 - {{ exam_name or '完整成绩报告' }} - {{ student_name }}</title>
935
+ <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
936
+ <script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-datalabels@2.2.0"></script>
937
+ <style>
938
+ /* Basic styles for print and screen */
939
+ body {
940
+ -webkit-print-color-adjust: exact !important;
941
+ print-color-adjust: exact !important;
942
+ font-family: 'Helvetica Neue', 'Microsoft YaHei', 'Noto Sans SC', sans-serif;
943
+ line-height: 1.4;
944
+ color: #1f2937;
945
+ }
946
+ @page {
947
+ size: A4;
948
+ margin: 1cm;
949
+ }
950
+ /* Screen-only styles */
951
+ @media screen {
952
+ body { background-color: #f3f4f6; }
953
+ .page-container-wrapper {
954
+ width: 21cm;
955
+ min-height: 29.7cm;
956
+ margin: 2rem auto;
957
+ box-shadow: 0 0 15px rgba(0,0,0,0.15);
958
+ background: #ffffff;
959
+ }
960
+ }
961
+ /* Print-only styles */
962
+ @media print {
963
+ body { margin: 0; }
964
+ .no-print { display: none !important; }
965
+ .page-container-wrapper { box-shadow: none; margin: 0; }
966
+ }
967
+ .page-container {
968
+ padding: 1cm;
969
+ font-size: 9pt;
970
+ }
971
+ h1, h2, h3, h4 { margin: 0 0 0.5em; color: #111827; font-weight: 700; break-after: avoid; }
972
+ h1 { font-size: 20pt; } h2 { font-size: 16pt; } h3 { font-size: 13pt; } h4 { font-size: 11pt; }
973
+ table { border-collapse: collapse; width: 100%; margin-top: 0.5rem; margin-bottom: 1rem; }
974
+ thead { display: table-header-group; } /* Ensure table headers repeat on each page */
975
+ tr { break-inside: avoid-page; }
976
+ th, td { border: 1px solid #e5e7eb; padding: 5px 7px; font-size: 8.5pt; text-align: center; }
977
+ th { font-weight: bold; background-color: #f9fafb !important; }
978
+ td.text-left, th.text-left { text-align: left; }
979
+ .report-section { margin-bottom: 1.2rem; break-inside: avoid; }
980
+ .page-break { page-break-before: always; }
981
+ .grid-container { display: grid; grid-template-columns: 1fr 1fr 1fr 1fr; gap: 0.5rem; text-align: center; }
982
+ .grid-item { padding: 0.4rem; border-radius: 0.375rem; border: 1px solid #e5e7eb; }
983
+ .grid-item-title { font-size: 8pt; margin-bottom: 0.1rem; color: #374151; }
984
+ .grid-item-value { font-size: 12pt; font-weight: 700; color: #1e293b; }
985
+ .bg-indigo-50 { background-color: #eef2ff !important; }
986
+ .bg-purple-50 { background-color: #f5f3ff !important; }
987
+ .bg-pink-50 { background-color: #fdf2f8 !important; }
988
+ .bg-green-50 { background-color: #f0fdf4 !important; }
989
+ .dist-chart-container { width: 100%; height: 220px; }
990
+ .footer-disclaimer {
991
+ position: fixed;
992
+ bottom: 0.5cm;
993
+ left: 1cm;
994
+ right: 1cm;
995
+ text-align: center;
996
+ font-size: 7pt;
997
+ color: #a1a1aa;
998
+ }
999
+ </style>
1000
+ </head>
1001
+ <body>
1002
+ <div class="page-container-wrapper">
1003
+ <div class="page-container">
1004
+ {% set desc = report.desc or {} %}{% set stu_order = desc.stuOrder or {} %}{% set subjects = stu_order.subjects or [] %}{% set score_gap = stu_order.scoreGap or {} %}{% set total_score_info = (subjects | selectattr('name', 'equalto', '总分') | first) or {} %}
1005
+
1006
+ <!-- Main Report Page -->
1007
+ <div id="main-report">
1008
+ <header style="text-align: center; margin-bottom: 1.5rem; border-bottom: 2px solid #e5e7eb; padding-bottom: 0.5rem;">
1009
+ <h1>{{ exam_name or '考试报告' }}</h1>
1010
+ <p style="font-size: 14pt; color: #4b5563; margin-top: 0.5rem;">{{ student_name or '未知学生' }}</p>
1011
+ <div style="text-align: center; margin-top: 1rem;" class="no-print">
1012
+ <button onclick="window.print();" style="background-color: #16a34a; color: white; border: none; padding: 8px 16px; border-radius: 6px; cursor: pointer; font-size: 10pt; display: inline-flex; align-items: center; line-height: 1;">
1013
+ <svg xmlns="http://www.w3.org/2000/svg" style="width: 16px; height: 16px; margin-right: 8px;" viewBox="0 0 20 20" fill="currentColor"><path d="M5 4a2 2 0 00-2 2v6a2 2 0 002 2h10a2 2 0 002-2V6a2 2 0 00-2-2H5zm0 2h10v6H5V6zm2 10h6v2H7v-2z" /></svg>
1014
+ 打印 / 保存为PDF
1015
+ </button>
1016
+ </div>
1017
+ </header>
1018
+
1019
+ <div class="report-section">
1020
+ <h3>总分与排名情况</h3>
1021
+ <div class="grid-container">
1022
+ <div class="grid-item bg-indigo-50"><p class="grid-item-title">总分</p><p class="grid-item-value">{{ total_score_info.score or 'N/A' }} / {{ total_score_info.fullScore or 'N/A' }}</p></div>
1023
+ <div class="grid-item bg-purple-50"><p class="grid-item-title">班级排名</p><p class="grid-item-value">{{ total_score_info.classOrder or 'N/A' }}</p></div>
1024
+ <div class="grid-item bg-pink-50"><p class="grid-item-title">学校排名</p><p class="grid-item-value">{{ total_score_info.schoolOrder or 'N/A' }}</p></div>
1025
+ <div class="grid-item bg-green-50"><p class="grid-item-title">统考排名</p><p class="grid-item-value">{{ total_score_info.unionOrder or 'N/A' }}</p></div>
1026
+ </div>
1027
+ </div>
1028
+
1029
+ <div class="report-section">
1030
+ <h3>各科成绩与排名详情</h3>
1031
+ <table>
1032
+ <thead><tr><th class="text-left">科目</th><th>得分/赋分 (原始分)</th><th>班级排名</th><th>学校排名</th><th>统考排名</th></tr></thead>
1033
+ <tbody>
1034
+ {% for subject in subjects %}{% if subject.name != '总分' %}
1035
+ <tr>
1036
+ <td class="text-left" style="font-weight: bold;">{{ subject.name }}</td>
1037
+ <td>{% if subject.score != subject.paperScore %}{{ subject.score }} ({{ subject.paperScore }}){% else %}{{ subject.score }}{% endif %}</td>
1038
+ <td>{{ subject.classOrder or 'N/A' }}</td><td>{{ subject.schoolOrder or 'N/A' }}</td><td>{{ subject.unionOrder or 'N/A' }}</td>
1039
+ </tr>
1040
+ {% endif %}{% endfor %}
1041
+ </tbody>
1042
+ </table>
1043
+ </div>
1044
+
1045
+ <div class="report-section" style="text-align: center;">
1046
+ <div style="max-width: 400px; margin: 0 auto 1.5rem auto;">
1047
+ <h3>各科表现雷达图</h3>
1048
+ <canvas id="printRadarChart"></canvas>
1049
+ </div>
1050
+ </div>
1051
+
1052
+ <div class="report-section">
1053
+ <h3>总分分数段分布 (学校)</h3>
1054
+ <div class="dist-chart-container">
1055
+ <canvas id="printTotalSchoolSegmentChart"></canvas>
1056
+ </div>
1057
+ </div>
1058
+
1059
+ <div class="report-section" style="margin-top: 1.5rem;">
1060
+ <h3>统计数据</h3>
1061
+ <table>
1062
+ <thead><tr><th class="text-left">类别</th><th>平均分</th><th>最高分</th><th>总人数</th></tr></thead>
1063
+ <tbody>
1064
+ <tr><td class="text-left font-medium">班级</td><td>{{ score_gap.classAvg or 'N/A' }}</td><td>{{ score_gap.classTop or 'N/A' }}</td><td>{{ score_gap.classNum or 'N/A' }}</td></tr>
1065
+ <tr><td class="text-left font-medium">学校</td><td>{{ score_gap.schoolAvg or 'N/A' }}</td><td>{{ score_gap.schoolTop or 'N/A' }}</td><td>{{ score_gap.schoolNum or 'N/A' }}</td></tr>
1066
+ <tr><td class="text-left font-medium">统考</td><td>{{ score_gap.unionAvg or 'N/A' }}</td><td>{{ score_gap.unionTop or 'N/A' }}</td><td>{{ score_gap.unionNum or 'N/A' }}</td></tr>
1067
+ </tbody>
1068
+ </table>
1069
+ </div>
1070
+ </div>
1071
+
1072
+ <!-- Subject Detail Pages -->
1073
+ {% for details in all_subject_details %}
1074
+ {% set subject_info = details.subject_info %}
1075
+ <div class="page-break">
1076
+ <header style="text-align: center; margin-bottom: 1rem; border-bottom: 2px solid #e5e7eb; padding-bottom: 0.5rem;"><h2>{{ subject_info.name }} - 详细学情分析</h2></header>
1077
+
1078
+ <div style="border-left: 4px solid #facc15; background-color: #fffbeb !important; color: #78350f; padding: 0.75rem; margin-bottom: 1rem; border-radius: 0.25rem;" class="report-section">
1079
+ <p style="font-weight: bold;">学情诊断: "{{ details.analysis.analysisDesc }}"</p>
1080
+ </div>
1081
+
1082
+ <div class="report-section">
1083
+ <h3>本科目成绩概览</h3>
1084
+ <div class="grid-container">
1085
+ <div class="grid-item bg-indigo-50"><p class="grid-item-title">本科目得分</p><p class="grid-item-value">{{ subject_info.score or 'N/A' }} / {{ subject_info.fullScore or 'N/A' }}</p></div>
1086
+ <div class="grid-item bg-purple-50"><p class="grid-item-title">班级排名</p><p class="grid-item-value">{{ subject_info.classOrder or 'N/A' }}</p></div>
1087
+ <div class="grid-item bg-pink-50"><p class="grid-item-title">学校排名</p><p class="grid-item-value">{{ subject_info.schoolOrder or 'N/A' }}</p></div>
1088
+ <div class="grid-item bg-green-50"><p class="grid-item-title">统考排名</p><p class="grid-item-value">{{ subject_info.unionOrder or 'N/A' }}</p></div>
1089
+ </div>
1090
+ </div>
1091
+
1092
+ {% if details.score_distribution %}
1093
+ <div class="report-section">
1094
+ <h3>分数段分布 (学校)</h3>
1095
+ <div class="dist-chart-container"><canvas id="print-school-dist-{{subject_info.id}}"></canvas></div>
1096
+ </div>
1097
+ {% endif %}
1098
+
1099
+ <div class="report-section">
1100
+ <h3>题目得分明细 (含得分率)</h3>
1101
+ <table>
1102
+ <thead><tr><th class="text-left">题号</th><th>我的得分</th><th>我的得分率</th><th>班级得分率</th><th>学校得分率</th><th>联考得分率</th></tr></thead>
1103
+ <tbody>
1104
+ {% for section in details.processed_questions %}
1105
+ <tr style="background-color: #f3f4f6 !important; font-weight: bold;">
1106
+ <td class="text-left">{{ section.data.title }}</td><td>{{ section.data.my_section_score }} / {{ section.data.section_total_score }}</td>
1107
+ <td style="{{ get_rate_color_style(section.data.scoreRate, section.data.classScoreRate) }}">{{ section.data.scoreRate }}</td>
1108
+ <td>{{ section.data.classScoreRate }}</td><td>{{ section.data.schoolScoreRate }}</td><td>{{ section.data.unionScoreRate }}</td>
1109
+ </tr>
1110
+ {% for q in section.questions %}
1111
+ <tr>
1112
+ <td class="text-left" style="padding-left: 1.5rem;">{{ q.title }}</td><td style="font-weight: 600;">{{ q.score }} / {{ q.totalScore }}</td>
1113
+ <td style="{{ get_rate_color_style(q.scoreRate, q.classScoreRate) }}">{{ q.scoreRate }}</td>
1114
+ <td>{{ q.classScoreRate }}</td><td>{{ q.schoolScoreRate }}</td><td>{{ q.unionScoreRate }}</td>
1115
+ </tr>
1116
+ {% endfor %}
1117
+ {% endfor %}
1118
+ </tbody>
1119
+ </table>
1120
+ </div>
1121
+ </div>
1122
+ {% endfor %}
1123
+ <div class="footer-disclaimer">
1124
+ 免责声明:此报告仅供学习与技术研究使用,不提供任何破解、绕过等功能,请勿用于商业或非法用途。如有侵权请联系移除。
1125
+ </div>
1126
+ </div>
1127
+ </div>
1128
+
1129
+ <script>
1130
+ document.addEventListener('DOMContentLoaded', function () {
1131
+ const reportData = {{ report | tojson | safe }};
1132
+ const totalDistribution = {{ total_score_distribution | tojson | safe }};
1133
+ const allSubjectDetails = {{ all_subject_details | tojson | safe }};
1134
+
1135
+ Chart.register(ChartDataLabels);
1136
+ // Disable animations for all charts for printing
1137
+ Chart.defaults.animation = false;
1138
+
1139
+ function createBarChart(config) {
1140
+ const ctx = document.getElementById(config.ctx);
1141
+ if (!ctx) return;
1142
+ if (!config.data || config.data.length === 0) {
1143
+ const p = document.createElement('p');
1144
+ p.textContent = `无${config.label}数据`;
1145
+ p.style.cssText = 'text-align: center; padding-top: 2rem; font-size: 9pt;';
1146
+ ctx.parentElement.replaceChild(p, ctx);
1147
+ return;
1148
+ }
1149
+ const labels = config.data.map(d => `${d.minscore}-${d.maxscore}`);
1150
+ const dataPoints = config.data.map(d => d.num);
1151
+ const backgroundColors = config.data.map(d => d.stuflag ? 'rgba(139, 92, 246, 0.8)' : 'rgba(56, 189, 248, 0.6)');
1152
+
1153
+ new Chart(ctx.getContext('2d'), {
1154
+ type: 'bar',
1155
+ data: { labels: labels, datasets: [{ label: config.label, data: dataPoints, backgroundColor: backgroundColors }] },
1156
+ options: {
1157
+ responsive: true, maintainAspectRatio: false,
1158
+ plugins: {
1159
+ legend: { display: true, position: 'top', labels: {font: {size: 9}} },
1160
+ tooltip: { enabled: false },
1161
+ datalabels: {
1162
+ anchor: 'end', align: 'top', font: { size: 8, weight: 'bold' }, color: '#444',
1163
+ formatter: (value) => value > 0 ? value : ''
1164
+ }
1165
+ },
1166
+ scales: { y: { beginAtZero: true, ticks: { font: { size: 8 } } }, x: { ticks: { font: { size: 8 }, autoSkip: true, maxTicksLimit: 15 } } }
1167
+ }
1168
+ });
1169
+ }
1170
+
1171
+ // Render Radar Chart
1172
+ try {
1173
+ const radarCtx = document.getElementById('printRadarChart');
1174
+ if (radarCtx && reportData.desc.stuOrder) {
1175
+ const subjects = reportData.desc.stuOrder.subjects.filter(s => s.name !== '总分' && s.schoolOrder > 0);
1176
+ const schoolTotal = reportData.desc.stuOrder.scoreGap.schoolNum;
1177
+ if (subjects.length > 0 && schoolTotal > 0) {
1178
+ const labels = subjects.map(s => s.name);
1179
+ const dataPoints = subjects.map(s => ((1 - ((s.schoolOrder - 1) / schoolTotal)) * 100).toFixed(1));
1180
+ new Chart(radarCtx.getContext('2d'), {
1181
+ type: 'radar',
1182
+ data: { labels: labels, datasets: [{ label: '学校排名百分位', data: dataPoints, fill: true, backgroundColor: 'rgba(79, 70, 229, 0.2)', borderColor: 'rgb(79, 70, 229)', pointBackgroundColor: 'rgb(79, 70, 229)' }] },
1183
+ options: {
1184
+ responsive: true, maintainAspectRatio: true,
1185
+ scales: { r: { suggestedMin: 0, suggestedMax: 100, pointLabels: { font: { size: 9 } }, ticks: { backdropColor: 'transparent', stepSize: 25, font: {size: 8} } } },
1186
+ plugins: { legend: { labels: { font: { size: 10 } } }, tooltip: { enabled: false }, datalabels: { display: false } }
1187
+ }
1188
+ });
1189
+ }
1190
+ }
1191
+ } catch (e) { console.error("Print radar chart rendering error:", e); }
1192
+
1193
+ // Render Total Score Distribution Chart
1194
+ if (totalDistribution) {
1195
+ createBarChart({ ctx: 'printTotalSchoolSegmentChart', label: '学校人数', data: totalDistribution });
1196
+ }
1197
+
1198
+ // Render Individual Subject Distribution Charts
1199
+ if (allSubjectDetails) {
1200
+ allSubjectDetails.forEach(details => {
1201
+ const subjectId = details.subject_info.id;
1202
+ const distribution = details.score_distribution;
1203
+ if (distribution) {
1204
+ createBarChart({ ctx: `print-school-dist-${subjectId}`, label: '学校人数', data: distribution });
1205
+ }
1206
+ });
1207
+ }
1208
+ });
1209
+ </script>
1210
+ </body>
1211
+ </html>
1212
+ """
1213
+ # 写入文件 (Write files)
1214
+ with open(os.path.join(TEMPLATE_DIR, 'index.html'), 'w', encoding='utf-8') as f:
1215
+ f.write(index_html)
1216
+ with open(os.path.join(TEMPLATE_DIR, 'subject_details.html'), 'w', encoding='utf-8') as f:
1217
+ f.write(subject_details_html)
1218
+ with open(os.path.join(TEMPLATE_DIR, 'print_report.html'), 'w', encoding='utf-8') as f:
1219
+ f.write(print_report_html)
1220
+
1221
+ # 管理页面模板 (admin.html)
1222
+ admin_html = """
1223
+ <!DOCTYPE html>
1224
+ <html lang=\"zh-CN\">
1225
+ <head>
1226
+ <meta charset=\"UTF-8\">
1227
+ <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">
1228
+ <title>管理后台 - 云成绩</title>
1229
+ <script src=\"https://cdn.tailwindcss.com\"></script>
1230
+ <link href=\"https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Noto+Sans+SC:wght@400;500;700&display=swap\" rel=\"stylesheet\">
1231
+ <style>
1232
+ body { font-family: 'Inter', 'Noto Sans+SC', sans-serif; }
1233
+ .disclaimer { font-size: 0.75rem; color: #9ca3af; text-align: center; margin-top: 2rem; }
1234
+ </style>
1235
+ <meta name=\"robots\" content=\"noindex,nofollow\" />
1236
+ <meta http-equiv=\"Cache-Control\" content=\"no-store\" />
1237
+ <meta http-equiv=\"Pragma\" content=\"no-cache\" />
1238
+ <meta http-equiv=\"Expires\" content=\"0\" />
1239
+ <script>if (window.top !== window.self) { window.top.location = window.location; }</script>
1240
+ <style>input[type=password] { letter-spacing: 0.2em; }</style>
1241
+ <style>.badge{display:inline-block;padding:0.2rem 0.5rem;border-radius:0.375rem;background-color:#eef2ff;color:#4338ca;font-size:0.75rem;font-weight:600}</style>
1242
+ <style>table th, table td { white-space: nowrap; }</style>
1243
+ </head>
1244
+ <body class="bg-gray-50 text-gray-800">
1245
+ <div class="container mx-auto p-4 md:p-8 max-w-7xl">
1246
+ <header class="flex items-center justify-between mb-6">
1247
+ <div>
1248
+ <h1 class="text-2xl md:text-3xl font-bold text-gray-900">管理后台</h1>
1249
+ </div>
1250
+ {% if logged_in %}
1251
+ <div>
1252
+ <a href=\"{{ url_for('admin_logout') }}\" class=\"text-sm text-gray-600 hover:text-gray-900\">退出登录</a>
1253
+ </div>
1254
+ {% endif %}
1255
+ </header>
1256
+
1257
+ {% if not logged_in %}
1258
+ <div class="max-w-md mx-auto bg-white p-6 rounded-xl shadow">
1259
+ <h2 class="text-xl font-semibold mb-4">管理员登录</h2>
1260
+ {% if error %}<div class="bg-red-100 border-l-4 border-red-500 text-red-700 p-3 rounded mb-4">{{ error }}</div>{% endif %}
1261
+ {% if not admin_enabled %}
1262
+ <div class="bg-yellow-50 border-l-4 border-yellow-400 text-yellow-800 p-3 rounded mb-4">未配置环境变量 ADMIN_PASSWORD,暂时无法登录。</div>
1263
+ {% endif %}
1264
+ <form method="POST" action="{{ url_for('admin') }}">
1265
+ <label class="block text-sm font-medium text-gray-700" for="password">访问密码</label>
1266
+ <input class="mt-1 block w-full px-3 py-2 bg-white border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 transition" type="password" name="password" id="password" placeholder="请输入管理员密码" {% if not admin_enabled %}disabled{% endif %} required>
1267
+ <button type="submit" class="mt-4 w-full md:w-auto inline-flex items-center justify-center py-2 px-6 border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 transition" {% if not admin_enabled %}disabled{% endif %}>登录</button>
1268
+ </form>
1269
+ </div>
1270
+ {% else %}
1271
+ <div class="bg-white p-6 rounded-xl shadow">
1272
+ <form method="GET" action="{{ url_for('admin') }}" class="flex flex-col md:flex-row md:items-center gap-3 mb-4">
1273
+ <input type="text" name="q" value="{{ query or '' }}" placeholder="搜索(考试名称 / 用户名 / 姓名 / seid)" class="flex-1 px-3 py-2 bg-white border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 transition">
1274
+ <button class="inline-flex items-center py-2 px-5 rounded-md text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700" type="submit">搜索</button>
1275
+ {% if query %}<a class="text-sm text-gray-600 hover:text-gray-900" href="{{ url_for('admin') }}">清除</a>{% endif %}
1276
+ <span class="ml-auto text-sm text-gray-500">共 <span class="badge">{{ entries|length }}</span> 条记录</span>
1277
+ </form>
1278
+ <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
1279
+ {% if entries and entries|length > 0 %}
1280
+ {% for h in entries %}
1281
+ <div class="bg-white rounded-lg shadow-md p-5 border border-gray-100 hover:shadow-lg hover:border-indigo-200 transition-all duration-300">
1282
+ <div class="flex justify-between items-start mb-2">
1283
+ <h3 class="font-bold text-md text-gray-800 leading-tight">{{ h.examName or 'N/A' }}</h3>
1284
+ <span class="badge">{{ h.seid }}</span>
1285
+ </div>
1286
+ <p class="text-sm text-gray-600 mb-4">
1287
+ <span class="font-semibold">{{ h.studentName or 'N/A' }}</span>
1288
+ </p>
1289
+ <div class="space-y-2 text-sm">
1290
+ <div class="flex justify-between">
1291
+ <span class="text-gray-500">用户名:</span>
1292
+ <span class="font-mono text-gray-800">{{ h.username }}</span>
1293
+ </div>
1294
+ <div class="flex justify-between items-center">
1295
+ <span class="text-gray-500">密码:</span>
1296
+ <div class="flex items-center">
1297
+ <span class="font-mono text-gray-800 mr-2" id="password-{{ loop.index }}">{{ h.password }}</span>
1298
+ <button onclick="copyPassword('password-{{ loop.index }}', this)" class="text-gray-400 hover:text-indigo-600 focus:outline-none" title="复制密码">
1299
+ <svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" /></svg>
1300
+ </button>
1301
+ </div>
1302
+ </div>
1303
+ <div class="flex justify-between">
1304
+ <span class="text-gray-500">总分:</span>
1305
+ <span class="font-semibold text-gray-800">{{ h.totalScore or '-' }}</span>
1306
+ </div>
1307
+ <div class="flex justify-between">
1308
+ <span class="text-gray-500">统考排名:</span>
1309
+ <span class="font-semibold text-gray-800">{{ h.unionOrder or '-' }}</span>
1310
+ </div>
1311
+ </div>
1312
+ <div class="mt-4 pt-4 border-t border-gray-200 flex justify-end space-x-4">
1313
+ <a class="text-indigo-600 hover:underline text-sm font-medium" href="{{ url_for('show_report', item_id=h.id) }}" target="_blank">查看报告</a>
1314
+ <a class="text-green-600 hover:underline text-sm font-medium" href="{{ url_for('print_report', item_id=h.id) }}" target="_blank">打印/PDF</a>
1315
+ </div>
1316
+ </div>
1317
+ {% endfor %}
1318
+ {% else %}
1319
+ <div class="col-span-full text-center py-12 text-gray-500">
1320
+ <svg class="mx-auto h-12 w-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path vector-effect="non-scaling-stroke" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /></svg>
1321
+ <h3 class="mt-2 text-sm font-medium text-gray-900">暂无历史记录</h3>
1322
+ <p class="mt-1 text-sm text-gray-500">没有找到匹配的查询记录。</p>
1323
+ </div>
1324
+ {% endif %}
1325
+ </div>
1326
+ </div>
1327
+ {% endif %}
1328
+ </div>
1329
+ <script>
1330
+ function copyPassword(elementId, button) {
1331
+ const passwordSpan = document.getElementById(elementId);
1332
+ const password = passwordSpan.innerText;
1333
+ navigator.clipboard.writeText(password).then(() => {
1334
+ const originalIcon = button.innerHTML;
1335
+ button.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" /></svg>`;
1336
+ setTimeout(() => { button.innerHTML = originalIcon; }, 1500);
1337
+ }, () => {
1338
+ alert('复制失败,您的浏览器可能不支持此功能。');
1339
+ });
1340
+ }
1341
+ </script>
1342
+ </body>
1343
+ </html>
1344
+ """
1345
+ with open(os.path.join(TEMPLATE_DIR, 'admin.html'), 'w', encoding='utf-8') as f:
1346
+ f.write(admin_html)
1347
+ print("模板创建/更新成功")
1348
+
1349
+ if __name__ == '__main__':
1350
+ create_templates()
1351
+ try:
1352
+ save_history(load_history())
1353
+ except Exception as _:
1354
+ pass
1355
+ # Run the Flask application
1356
+ app.run(host='0.0.0.0', port=7860, debug=False)