├── .gitignore
├── README.md
├── assets
├── SJTURM.png
├── help.md
├── help0.png
├── help1.png
└── help3.png
├── qtui.py
└── src
├── api_client.py
├── config_manager.py
├── data_generator.py
├── help_dialog.py
├── main.py
└── utils.py
/.gitignore:
--------------------------------------------------------------------------------
1 | configs/*
2 |
3 | **/__pycache__/
4 |
5 | build/
6 | dist/
7 |
8 | SJTU Sports Uploader.spec
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # SJTU 体育跑步上传工具帮助
2 |
3 | 欢迎使用SJTU体育跑步上传工具!
4 |
5 | ## 简介
6 | 本工具旨在帮助SJTU学生模拟体育跑步数据并上传,以满足学校的体育锻炼要求。请确保您在使用本工具时遵守学校的相关规定。
7 |
8 | ## 配置说明
9 |
10 | ### 用户配置
11 | - **Cookie:** 必填项。请通过浏览器或抓包工具获取您在 `pe.sjtu.edu.cn` 网站上的 `keepalive` 和 `JSESSIONID` cookie。格式通常为 `keepalive='...' ; JSESSIONID='...'`。
12 | - **用户ID:** 必填项。您的Jaccount
13 |
14 | **配置获取步骤**
15 |
16 | **1** 打开链接 [交我跑官网](https://pe.sjtu.edu.cn/phone/#/indexPortrait),如果你的界面类似下面的,则打开成功。
17 |
18 | 
19 |
20 | **2** 登陆后,点击 `F12`,然后打开"应用"界面
21 |
22 | 
23 |
24 | **3** 点击侧边栏的 "Cookie",查看 `JSESSIONID` 和 `keepalive`,并写到应用内
25 |
26 | 
27 |
28 |
29 | ### 跑步路线配置
30 | - **起点纬度/经度 & 终点纬度/经度:** 必填项。请填写您希望模拟跑步路线的起点和终点坐标。建议选择学校内部实际存在的坐标,并确保两点之间有足够的距离(例如1-2公里)。
31 |
32 | ### 跑步参数配置
33 | - **跑步速度 (米/秒):** 必填项。您希望模拟的跑步速度。例如 `2.5` 米/秒大约等于 `9` 公里/小时。
34 | - **轨迹点采样间隔 (秒):** 必填项。生成跑步轨迹点的时间间隔。例如 `3` 秒。
35 |
36 | ### 跑步时间配置
37 | - **使用当前时间:** 默认勾选。如果勾选,脚本将使用您点击“开始上传”时的系统时间作为跑步开始时间。
38 | - **或手动设置开始时间:** 如果取消勾选“使用当前时间”,您可以手动选择一个过去的日期和时间作为跑步开始时间。这对于补签历史跑步记录可能有用。
39 |
40 | ## 配置管理
41 | - **加载默认配置:** 载入 `configs/default.json` 中的预设配置(或程序硬编码的默认值)。
42 | - **保存当前配置:** 将当前UI中的设置保存到当前正在使用的配置文件(默认为 `configs/default.json`)。
43 | - **保存配置为...:** 弹出一个文件对话框,允许您将当前UI中的设置保存为新的 `.json` 配置文件到 `configs/` 目录。
44 |
45 | ## 开始上传
46 | - 点击 **“开始上传”** 按钮启动跑步数据生成和上传流程。
47 | - **进度条** 和 **日志输出** 区域将显示实时状态和任何警告/错误信息。
48 | - 上传完成后,会弹出消息框提示结果。
49 |
50 | ## 免责声明
51 | 本工具仅供学习和研究目的。开发者不对因使用本工具造成的任何后果负责。请谨慎使用,并遵守所有适用的法律法规和学校规章制度。
52 |
53 | ---
54 | 版本: 0.0.1
55 | 日期: 2025年10月1日
56 |
--------------------------------------------------------------------------------
/assets/SJTURM.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Labyrinth0419/SJTURunningMan/d5d67b586d7658784dce8d7e8239854576a7566a/assets/SJTURM.png
--------------------------------------------------------------------------------
/assets/help.md:
--------------------------------------------------------------------------------
1 | # SJTU 体育跑步上传工具帮助
2 |
3 | 欢迎使用SJTU体育跑步上传工具!
4 |
5 | ## 简介
6 | 本工具旨在帮助SJTU学生模拟体育跑步数据并上传,以满足学校的体育锻炼要求。请确保您在使用本工具时遵守学校的相关规定。
7 |
8 | ## 配置说明
9 |
10 | ### 用户配置
11 | - **Cookie:** 必填项。请通过浏览器或抓包工具获取您在 `pe.sjtu.edu.cn` 网站上的 `keepalive` 和 `JSESSIONID` cookie。格式通常为 `keepalive='...' ; JSESSIONID='...'`。
12 | - **用户ID:** 必填项。您的Jaccount
13 |
14 | **配置获取步骤**
15 |
16 | **1** 打开链接 [交我跑官网](https://pe.sjtu.edu.cn/phone/#/indexPortrait),如果你的界面类似下面的,则打开成功。
17 |
18 | 
19 |
20 | **2** 登陆后,点击 `F12`,然后打开"应用"界面
21 |
22 | 
23 |
24 | **3** 点击侧边栏的 "Cookie",查看 `JSESSIONID` 和 `keepalive`,并写到应用内
25 |
26 | 
27 |
28 |
29 | ### 跑步路线配置
30 | - **起点纬度/经度 & 终点纬度/经度:** 必填项。请填写您希望模拟跑步路线的起点和终点坐标。建议选择学校内部实际存在的坐标,并确保两点之间有足够的距离(例如1-2公里)。
31 |
32 | ### 跑步参数配置
33 | - **跑步速度 (米/秒):** 必填项。您希望模拟的跑步速度。例如 `2.5` 米/秒大约等于 `9` 公里/小时。
34 | - **轨迹点采样间隔 (秒):** 必填项。生成跑步轨迹点的时间间隔。例如 `3` 秒。
35 |
36 | ### 跑步时间配置
37 | - **使用当前时间:** 默认勾选。如果勾选,脚本将使用您点击“开始上传”时的系统时间作为跑步开始时间。
38 | - **或手动设置开始时间:** 如果取消勾选“使用当前时间”,您可以手动选择一个过去的日期和时间作为跑步开始时间。这对于补签历史跑步记录可能有用。
39 |
40 | ## 配置管理
41 | - **加载默认配置:** 载入 `configs/default.json` 中的预设配置(或程序硬编码的默认值)。
42 | - **保存当前配置:** 将当前UI中的设置保存到当前正在使用的配置文件(默认为 `configs/default.json`)。
43 | - **保存配置为...:** 弹出一个文件对话框,允许您将当前UI中的设置保存为新的 `.json` 配置文件到 `configs/` 目录。
44 |
45 | ## 开始上传
46 | - 点击 **“开始上传”** 按钮启动跑步数据生成和上传流程。
47 | - **进度条** 和 **日志输出** 区域将显示实时状态和任何警告/错误信息。
48 | - 上传完成后,会弹出消息框提示结果。
49 |
50 | ## 免责声明
51 | 本工具仅供学习和研究目的。开发者不对因使用本工具造成的任何后果负责。请谨慎使用,并遵守所有适用的法律法规和学校规章制度。
52 |
53 | ---
54 | 版本: 1.0.0
55 | 日期: 2024年7月22日
--------------------------------------------------------------------------------
/assets/help0.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Labyrinth0419/SJTURunningMan/d5d67b586d7658784dce8d7e8239854576a7566a/assets/help0.png
--------------------------------------------------------------------------------
/assets/help1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Labyrinth0419/SJTURunningMan/d5d67b586d7658784dce8d7e8239854576a7566a/assets/help1.png
--------------------------------------------------------------------------------
/assets/help3.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Labyrinth0419/SJTURunningMan/d5d67b586d7658784dce8d7e8239854576a7566a/assets/help3.png
--------------------------------------------------------------------------------
/qtui.py:
--------------------------------------------------------------------------------
1 | import sys
2 | import json
3 | import os
4 | import datetime
5 | import time
6 | from PySide6.QtWidgets import (
7 | QApplication, QWidget, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit,
8 | QPushButton, QTextEdit, QProgressBar, QFormLayout, QGroupBox, QDateTimeEdit,
9 | QMessageBox, QScrollArea, QSizePolicy, QFileDialog, QCheckBox,
10 | QSpacerItem
11 | )
12 | from PySide6.QtCore import QThread, Signal, QDateTime, Qt, QUrl
13 | from PySide6.QtGui import QTextCursor, QFont, QColor, QTextCharFormat, QPalette, QBrush, QIcon, QDesktopServices
14 |
15 | from src.main import run_sports_upload
16 | from src.utils import SportsUploaderError, get_base_path
17 | from src.config_manager import ConfigManager, CONFIGS_DIR, DEFAULT_CONFIG_FILE_NAME
18 | from src.help_dialog import HelpDialog
19 |
20 | RESOURCES_SUB_DIR = "assets"
21 | CONFIGS_SUB_DIR = "configs"
22 |
23 | RESOURCES_FULL_PATH = os.path.join(get_base_path(), RESOURCES_SUB_DIR)
24 |
25 | class WorkerThread(QThread):
26 | """
27 | 工作线程,用于在后台执行跑步数据上传任务,避免UI冻结。
28 | """
29 | progress_update = Signal(int, int, str)
30 | log_output = Signal(str, str)
31 | finished = Signal(bool, str)
32 |
33 | def __init__(self, config_data):
34 | super().__init__()
35 | self.config_data = config_data
36 |
37 | def run(self):
38 | success = False
39 | message = "任务已完成。"
40 | try:
41 | success, message = run_sports_upload(
42 | self.config_data,
43 | progress_callback=self.progress_callback,
44 | log_cb=self.log_callback,
45 | stop_check_cb=self.isInterruptionRequested
46 | )
47 | except SportsUploaderError as e:
48 | self.log_output.emit(f"任务中断: {e}", "error")
49 | message = str(e)
50 | success = False
51 | except Exception as e:
52 | self.log_output.emit(f"发生未预期的错误: {e}", "error")
53 | message = f"未预期的错误: {e}"
54 | success = False
55 | finally:
56 | if self.isInterruptionRequested() and not success:
57 | self.finished.emit(False, "任务已手动终止。")
58 | else:
59 | self.finished.emit(success, message)
60 |
61 | def progress_callback(self, current, total, message):
62 | self.progress_update.emit(current, total, message)
63 |
64 | def log_callback(self, message, level):
65 | self.log_output.emit(message, level)
66 |
67 |
68 | class SportsUploaderUI(QWidget):
69 | def __init__(self):
70 | super().__init__()
71 | self.setWindowTitle("SJTU 体育跑步上传工具")
72 | self.setWindowIcon(QIcon(os.path.join(RESOURCES_FULL_PATH, "SJTURM.png")))
73 |
74 | self.thread = None
75 | self.config = {}
76 | self.current_config_filename = DEFAULT_CONFIG_FILE_NAME
77 |
78 | self.setup_ui_style()
79 | self.init_ui()
80 | self.load_settings_to_ui(self.current_config_filename)
81 |
82 | self.setGeometry(100, 100, 800, 950)
83 | self.setMinimumSize(500, 650)
84 |
85 | self.adjust_content_width(self.width())
86 |
87 | def setup_ui_style(self):
88 | """设置UI的整体样式,改为白色背景和Fluent设计。"""
89 | palette = self.palette()
90 | palette.setColor(QPalette.Window, QColor(255, 255, 255))
91 | palette.setColor(QPalette.WindowText, QColor(30, 30, 30))
92 | palette.setColor(QPalette.Base, QColor(255, 255, 255))
93 | palette.setColor(QPalette.AlternateBase, QColor(240, 240, 240))
94 | palette.setColor(QPalette.ToolTipBase, QColor(255, 255, 255))
95 | palette.setColor(QPalette.ToolTipText, QColor(30, 30, 30))
96 | palette.setColor(QPalette.Text, QColor(30, 30, 30))
97 | palette.setColor(QPalette.Button, QColor(225, 225, 225))
98 | palette.setColor(QPalette.ButtonText, QColor(30, 30, 30))
99 | palette.setColor(QPalette.BrightText, QColor("red"))
100 | palette.setColor(QPalette.Link, QColor(0, 120, 212))
101 | palette.setColor(QPalette.Highlight, QColor(0, 120, 212))
102 | palette.setColor(QPalette.HighlightedText, QColor(255, 255, 255))
103 | self.setPalette(palette)
104 |
105 | self.setStyleSheet("""
106 | QGroupBox {
107 | font-size: 11pt;
108 | font-weight: bold;
109 | margin-top: 10px;
110 | border: 1px solid rgb(220, 220, 220);
111 | border-radius: 8px;
112 | padding-top: 20px;
113 | padding-bottom: 5px;
114 | }
115 | QGroupBox::title {
116 | subcontrol-origin: margin;
117 | subcontrol-position: top center;
118 | padding: 0 5px;
119 | color: rgb(0, 120, 212);
120 | }
121 | QLineEdit, QDateTimeEdit {
122 | background-color: rgb(255, 255, 255);
123 | border: 1px solid rgb(220, 220, 220);
124 | border-radius: 5px;
125 | padding: 5px;
126 | selection-background-color: rgb(0, 120, 212);
127 | color: rgb(30, 30, 30);
128 | }
129 | QDateTimeEdit::drop-down {
130 | subcontrol-origin: padding;
131 | subcontrol-position: top right;
132 | width: 20px;
133 | border-left-width: 1px;
134 | border-left-color: rgb(220, 220, 220);
135 | border-left-style: solid;
136 | border-top-right-radius: 5px;
137 | border-bottom-right-radius: 5px;
138 | }
139 | QPushButton {
140 | background-color: rgb(0, 120, 212);
141 | color: white;
142 | border-radius: 5px;
143 | padding: 8px 15px;
144 | font-weight: bold;
145 | min-height: 28px;
146 | }
147 | QPushButton:hover {
148 | background-color: rgb(0, 96, 173);
149 | }
150 | QPushButton:pressed {
151 | background-color: rgb(0, 77, 140);
152 | }
153 | QPushButton:disabled {
154 | background-color: rgb(204, 204, 204);
155 | color: rgb(106, 106, 106);
156 | }
157 | QProgressBar {
158 | border: 1px solid rgb(220, 220, 220);
159 | border-radius: 5px;
160 | text-align: center;
161 | background-color: rgb(240, 240, 240);
162 | color: rgb(30, 30, 30);
163 | }
164 | QProgressBar::chunk {
165 | background-color: rgb(0, 120, 212);
166 | border-radius: 5px;
167 | }
168 | QTextEdit {
169 | background-color: rgb(255, 255, 255);
170 | border: 1px solid rgb(220, 220, 220);
171 | border-radius: 5px;
172 | padding: 5px;
173 | color: rgb(30, 30, 30);
174 | }
175 | QScrollArea {
176 | border: none;
177 | }
178 | QCheckBox {
179 | spacing: 5px;
180 | color: rgb(30, 30, 30);
181 | }
182 | QCheckBox::indicator {
183 | width: 16px;
184 | height: 16px;
185 | border-radius: 3px;
186 | border: 1px solid rgb(142, 142, 142);
187 | background-color: rgb(248, 248, 248);
188 | }
189 | QCheckBox::indicator:checked {
190 | background-color: rgb(0, 120, 212);
191 | border: 1px solid rgb(0, 120, 212);
192 | }
193 | QCheckBox::indicator:disabled {
194 | border: 1px solid rgb(204, 204, 204);
195 | background-color: rgb(225, 225, 225);
196 | }
197 | QFormLayout QLabel {
198 | padding-top: 5px;
199 | padding-bottom: 5px;
200 | }
201 | #startButton {
202 | background-color: rgb(76, 175, 80);
203 | }
204 | #startButton:hover {
205 | background-color: rgb(67, 160, 71);
206 | }
207 | #startButton:pressed {
208 | background-color: rgb(56, 142, 60);
209 | }
210 | #stopButton {
211 | background-color: rgb(220, 53, 69);
212 | }
213 | #stopButton:hover {
214 | background-color: rgb(179, 43, 56);
215 | }
216 | #stopButton:pressed {
217 | background-color: rgb(140, 34, 44);
218 | }
219 | QLabel#getCookieLink {
220 | color: rgb(0, 120, 212);
221 | text-decoration: underline;
222 | padding: 0;
223 | }
224 | QLabel#getCookieLink:hover {
225 | color: rgb(0, 96, 173);
226 | }
227 | """)
228 |
229 | def init_ui(self):
230 | top_h_layout = QHBoxLayout()
231 | top_h_layout.setContentsMargins(0, 0, 0, 0)
232 | top_h_layout.setSpacing(0)
233 |
234 | self.center_widget = QWidget()
235 | main_layout = QVBoxLayout(self.center_widget)
236 | main_layout.setContentsMargins(15, 15, 15, 15)
237 | main_layout.setSpacing(10)
238 |
239 | self.scroll_area = QScrollArea()
240 | self.scroll_content = QWidget()
241 | scroll_layout = QVBoxLayout(self.scroll_content)
242 | scroll_layout.setContentsMargins(10, 10, 10, 10)
243 | scroll_layout.setSpacing(8)
244 | self.scroll_area.setWidgetResizable(True)
245 | self.scroll_area.setWidget(self.scroll_content)
246 |
247 | main_layout.addWidget(self.scroll_area)
248 |
249 | user_group = QGroupBox("用户配置")
250 | user_form_layout = QFormLayout()
251 |
252 | cookie_prompt_layout = QHBoxLayout()
253 | cookie_label = QLabel("Cookie:")
254 | get_cookie_link = QLabel('获取')
255 | get_cookie_link.setOpenExternalLinks(False)
256 | get_cookie_link.linkActivated.connect(self.open_cookie_help_url)
257 |
258 | cookie_prompt_layout.addWidget(cookie_label)
259 | cookie_prompt_layout.addWidget(get_cookie_link)
260 | cookie_prompt_layout.addStretch(1)
261 |
262 | cookie_container_widget = QWidget()
263 | cookie_container_widget.setLayout(cookie_prompt_layout)
264 | user_form_layout.addRow(cookie_container_widget)
265 |
266 | self.keepalive_input = QLineEdit()
267 | self.keepalive_input.setPlaceholderText("keepalive=... (从浏览器复制)")
268 | self.jsessionid_input = QLineEdit()
269 | self.jsessionid_input.setPlaceholderText("JSESSIONID=... (从浏览器复制)")
270 |
271 | user_form_layout.addRow("Keepalive:", self.keepalive_input)
272 | user_form_layout.addRow("JSESSIONID:", self.jsessionid_input)
273 |
274 | self.user_id_input = QLineEdit()
275 | self.user_id_input.setPlaceholderText("你的用户ID")
276 | user_form_layout.addRow("用户ID:", self.user_id_input)
277 | user_group.setLayout(user_form_layout)
278 | scroll_layout.addWidget(user_group)
279 |
280 | route_group = QGroupBox("跑步路线配置")
281 | route_form_layout = QFormLayout()
282 | self.start_lat_input = QLineEdit()
283 | self.start_lon_input = QLineEdit()
284 | self.end_lat_input = QLineEdit()
285 | self.end_lon_input = QLineEdit()
286 | route_form_layout.addRow("起点纬度 (LAT):", self.start_lat_input)
287 | route_form_layout.addRow("起点经度 (LON):", self.start_lon_input)
288 | route_form_layout.addRow("终点纬度 (LAT):", self.end_lat_input)
289 | route_form_layout.addRow("终点经度 (LON):", self.end_lon_input)
290 | route_group.setLayout(route_form_layout)
291 | scroll_layout.addWidget(route_group)
292 |
293 | param_group = QGroupBox("跑步参数配置")
294 | param_form_layout = QFormLayout()
295 | self.speed_input = QLineEdit()
296 | self.speed_input.setPlaceholderText("例如: 2.5 (米/秒, 约9公里/小时)")
297 | self.interval_input = QLineEdit()
298 | self.interval_input.setPlaceholderText("例如: 3 (秒)")
299 | param_form_layout.addRow("跑步速度 (米/秒):", self.speed_input)
300 | param_form_layout.addRow("轨迹点采样间隔 (秒):", self.interval_input)
301 | param_group.setLayout(param_form_layout)
302 | scroll_layout.addWidget(param_group)
303 |
304 | time_group = QGroupBox("跑步时间配置")
305 | time_layout = QVBoxLayout()
306 | self.use_current_time_checkbox = QCheckBox("使用当前时间")
307 | self.use_current_time_checkbox.setChecked(True)
308 | self.use_current_time_checkbox.toggled.connect(self.toggle_time_input)
309 |
310 | self.start_datetime_input = QDateTimeEdit()
311 | self.start_datetime_input.setCalendarPopup(True)
312 | self.start_datetime_input.setDateTime(QDateTime.currentDateTime())
313 | self.start_datetime_input.setDisplayFormat("yyyy-MM-dd HH:mm:ss")
314 | self.start_datetime_input.setEnabled(False)
315 |
316 | time_layout.addWidget(self.use_current_time_checkbox)
317 | time_layout.addWidget(QLabel("或手动设置开始时间:"))
318 | time_layout.addWidget(self.start_datetime_input)
319 | time_group.setLayout(time_layout)
320 | scroll_layout.addWidget(time_group)
321 |
322 | config_button_layout = QHBoxLayout()
323 | self.load_default_button = QPushButton("加载默认配置")
324 | self.load_default_button.clicked.connect(lambda: self.load_settings_to_ui(DEFAULT_CONFIG_FILE_NAME))
325 | self.save_as_button = QPushButton("保存配置为...")
326 | self.save_as_button.clicked.connect(self.save_settings_as_dialog)
327 | self.save_current_button = QPushButton("保存当前配置")
328 | self.save_current_button.clicked.connect(lambda: self.save_current_settings(self.current_config_filename))
329 |
330 | config_button_layout.addWidget(self.load_default_button)
331 | config_button_layout.addWidget(self.save_as_button)
332 | config_button_layout.addWidget(self.save_current_button)
333 | scroll_layout.addLayout(config_button_layout)
334 |
335 | action_button_layout = QHBoxLayout()
336 | self.start_button = QPushButton("开始上传")
337 | self.start_button.setObjectName("startButton")
338 | self.start_button.clicked.connect(self.start_upload)
339 | action_button_layout.addWidget(self.start_button)
340 |
341 | self.stop_button = QPushButton("停止")
342 | self.stop_button.setObjectName("stopButton")
343 | self.stop_button.setEnabled(False)
344 | self.stop_button.clicked.connect(self.stop_upload)
345 | action_button_layout.addWidget(self.stop_button)
346 |
347 | self.help_button = QPushButton("帮助")
348 | self.help_button.clicked.connect(self.show_help_dialog)
349 | action_button_layout.addWidget(self.help_button)
350 |
351 | scroll_layout.addLayout(action_button_layout)
352 |
353 | self.progress_bar = QProgressBar()
354 | self.progress_bar.setValue(0)
355 | scroll_layout.addWidget(self.progress_bar)
356 |
357 | self.status_label = QLabel("状态: 待命")
358 | scroll_layout.addWidget(self.status_label)
359 | self.log_output_area = QTextEdit()
360 | self.log_output_area.setReadOnly(True)
361 | self.log_output_area.setFont(QFont("Monospace", 9))
362 | self.log_output_area.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
363 | scroll_layout.addWidget(self.log_output_area)
364 |
365 | top_h_layout.addSpacerItem(QSpacerItem(0, 0, QSizePolicy.Expanding, QSizePolicy.Minimum))
366 | top_h_layout.addWidget(self.center_widget)
367 | top_h_layout.addSpacerItem(QSpacerItem(0, 0, QSizePolicy.Expanding, QSizePolicy.Minimum))
368 |
369 | self.setLayout(top_h_layout)
370 |
371 | def resizeEvent(self, event):
372 | """
373 | 槽函数,用于处理窗口大小调整事件。
374 | 根据窗口宽度调整内部内容区域的最大宽度。
375 | """
376 | super().resizeEvent(event)
377 | self.adjust_content_width(event.size().width())
378 |
379 | def adjust_content_width(self, window_width):
380 | """
381 | 根据给定的窗口宽度,计算并设置 center_widget 的固定宽度。
382 | """
383 | available_width_for_center_widget = window_width
384 | calculated_width = max(480, available_width_for_center_widget // 2)
385 | self.center_widget.setFixedWidth(calculated_width)
386 |
387 | def toggle_time_input(self, checked):
388 | """根据QCheckBox状态切换时间输入框的启用/禁用状态"""
389 | self.start_datetime_input.setEnabled(not checked)
390 | if checked:
391 | self.start_datetime_input.setDateTime(QDateTime.currentDateTime())
392 |
393 | def open_cookie_help_url(self):
394 | """打开获取 Cookie 的帮助页面链接"""
395 | QDesktopServices.openUrl(QUrl("https://pe.sjtu.edu.cn/phone/#/indexPortrait"))
396 |
397 |
398 | def load_settings_to_ui(self, filename):
399 | """从指定文件加载配置并填充UI"""
400 | self.config = ConfigManager.load_config(filename)
401 | self.current_config_filename = filename
402 | self.setWindowTitle(f"SJTU 体育跑步上传工具 - [{os.path.basename(filename)}]")
403 |
404 | full_cookie = self.config.get("COOKIE", "")
405 | keepalive_val = ""
406 | jsessionid_val = ""
407 | parts = full_cookie.split(';')
408 | for part in parts:
409 | part = part.strip()
410 | if part.startswith("keepalive="):
411 | keepalive_val = part.replace("keepalive=", "")
412 | elif part.startswith("JSESSIONID="):
413 | jsessionid_val = part.replace("JSESSIONID=", "")
414 |
415 | self.keepalive_input.setText(keepalive_val)
416 | self.jsessionid_input.setText(jsessionid_val)
417 | self.user_id_input.setText(self.config.get("USER_ID", ""))
418 | self.start_lat_input.setText(str(self.config.get("START_LATITUDE", "")))
419 | self.start_lon_input.setText(str(self.config.get("START_LONGITUDE", "")))
420 | self.end_lat_input.setText(str(self.config.get("END_LATITUDE", "")))
421 | self.end_lon_input.setText(str(self.config.get("END_LONGITUDE", "")))
422 | self.speed_input.setText(str(self.config.get("RUNNING_SPEED_MPS", "")))
423 | self.interval_input.setText(str(self.config.get("INTERVAL_SECONDS", "")))
424 |
425 | start_time_ms = self.config.get("START_TIME_EPOCH_MS", None)
426 | if start_time_ms is not None:
427 | dt = QDateTime.fromMSecsSinceEpoch(start_time_ms)
428 | self.start_datetime_input.setDateTime(dt)
429 | self.use_current_time_checkbox.setChecked(False)
430 | self.start_datetime_input.setEnabled(True)
431 | else:
432 | self.use_current_time_checkbox.setChecked(True)
433 | self.start_datetime_input.setEnabled(False)
434 | self.start_datetime_input.setDateTime(QDateTime.currentDateTime())
435 |
436 | self.log_output_text(f"已加载配置文件: {os.path.basename(filename)}", "info")
437 |
438 | def get_settings_from_ui(self):
439 | """从UI获取当前配置并返回字典"""
440 | try:
441 | keepalive = self.keepalive_input.text().strip()
442 | jsessionid = self.jsessionid_input.text().strip()
443 | combined_cookie = ""
444 | if keepalive:
445 | combined_cookie += f"keepalive={keepalive}"
446 | if jsessionid:
447 | if combined_cookie:
448 | combined_cookie += "; "
449 | combined_cookie += f"JSESSIONID={jsessionid}"
450 |
451 | current_config = {
452 | "COOKIE": combined_cookie,
453 | "USER_ID": self.user_id_input.text(),
454 | "START_LATITUDE": float(self.start_lat_input.text()),
455 | "START_LONGITUDE": float(self.start_lon_input.text()),
456 | "END_LATITUDE": float(self.end_lat_input.text()),
457 | "END_LONGITUDE": float(self.end_lon_input.text()),
458 | "RUNNING_SPEED_MPS": float(self.speed_input.text()),
459 | "INTERVAL_SECONDS": int(self.interval_input.text()),
460 | "HOST": "pe.sjtu.edu.cn",
461 | "UID_URL": "https://pe.sjtu.edu.cn/sports/my/uid",
462 | "MY_DATA_URL": "https://pe.sjtu.edu.cn/sports/my/data",
463 | "POINT_RULE_URL": "https://pe.sjtu.edu.cn/api/running/point-rule",
464 | "UPLOAD_URL": "https://pe.sjtu.edu.cn/api/running/result/upload"
465 | }
466 |
467 | if self.use_current_time_checkbox.isChecked():
468 | current_config["START_TIME_EPOCH_MS"] = None
469 | else:
470 | current_config["START_TIME_EPOCH_MS"] = self.start_datetime_input.dateTime().toMSecsSinceEpoch()
471 |
472 | if not current_config["COOKIE"] or not current_config["USER_ID"]:
473 | raise ValueError("Cookie (keepalive 和 JSESSIONID) 和 用户ID 不能为空。")
474 |
475 | return current_config
476 |
477 | except ValueError as e:
478 | raise ValueError(f"输入错误: {e}")
479 | except Exception as e:
480 | raise Exception(f"获取配置时发生未知错误: {e}")
481 |
482 | def save_current_settings(self, filename):
483 | """将当前UI中的配置保存到指定文件。"""
484 | try:
485 | new_config = self.get_settings_from_ui()
486 | if ConfigManager.save_config(new_config, filename):
487 | self.config = new_config
488 | self.current_config_filename = filename
489 | self.setWindowTitle(f"SJTU 体育跑步上传工具 - [{os.path.basename(filename)}]")
490 | QMessageBox.information(self, "保存成功", f"配置已成功保存到 '{os.path.basename(filename)}'!")
491 |
492 | except ValueError as e:
493 | QMessageBox.critical(self, "输入错误", str(e))
494 | except Exception as e:
495 | QMessageBox.critical(self, "保存失败", f"保存配置时发生错误: {e}")
496 |
497 | def save_settings_as_dialog(self):
498 | """通过文件对话框让用户选择文件名来保存配置。"""
499 | if not os.path.exists(CONFIGS_DIR):
500 | os.makedirs(CONFIGS_DIR)
501 |
502 | default_filename = os.path.join(CONFIGS_DIR, "custom_config.json")
503 | filename, _ = QFileDialog.getSaveFileName(
504 | self, "保存配置为", default_filename, "JSON Files (*.json);;All Files (*)"
505 | )
506 | if filename:
507 | base_filename = os.path.basename(filename)
508 | self.save_current_settings(base_filename)
509 |
510 | def start_upload(self):
511 | """开始上传跑步数据"""
512 | self.log_output_area.clear()
513 | self.progress_bar.setValue(0)
514 | self.status_label.setText("状态: 准备中...")
515 | self.log_output_text("准备开始上传...", "info")
516 |
517 | try:
518 | current_config_to_send = self.get_settings_from_ui()
519 | except (ValueError, Exception) as e:
520 | self.log_output_text(f"配置错误: {e}", "error")
521 | self.status_label.setText("状态: 错误")
522 | QMessageBox.critical(self, "配置错误", str(e))
523 | return
524 |
525 | self.start_button.setEnabled(False)
526 | self.stop_button.setEnabled(True)
527 | self.save_current_button.setEnabled(False)
528 | self.save_as_button.setEnabled(False)
529 | self.load_default_button.setEnabled(False)
530 | self.use_current_time_checkbox.setEnabled(False)
531 | self.start_datetime_input.setEnabled(False)
532 | self.help_button.setEnabled(False)
533 | self.keepalive_input.setEnabled(False)
534 | self.jsessionid_input.setEnabled(False)
535 | self.user_id_input.setEnabled(False)
536 |
537 |
538 | self.thread = WorkerThread(current_config_to_send)
539 | self.thread.progress_update.connect(self.update_progress)
540 | self.thread.log_output.connect(self.log_output_text)
541 | self.thread.finished.connect(self.upload_finished)
542 | self.thread.start()
543 |
544 | def stop_upload(self):
545 | """请求工作线程停止。"""
546 | if self.thread and self.thread.isRunning():
547 | self.thread.requestInterruption()
548 | self.log_output_text("已发送停止请求,请等待任务清理并退出...", "warning")
549 | self.stop_button.setEnabled(False)
550 | self.status_label.setText("状态: 正在停止...")
551 | else:
552 | self.log_output_text("没有运行中的任务可以停止。", "info")
553 |
554 |
555 | def update_progress(self, current, total, message):
556 | """更新进度条和状态信息"""
557 | self.progress_bar.setMaximum(total)
558 | self.progress_bar.setValue(current)
559 | self.status_label.setText(f"状态: {message}")
560 |
561 | def log_output_text(self, message, level="info"):
562 | """将日志信息添加到文本区域,并根据级别着色"""
563 | cursor = self.log_output_area.textCursor()
564 | cursor.movePosition(QTextCursor.End)
565 |
566 | format = QTextCharFormat()
567 | if level == "error":
568 | format.setForeground(QColor("red"))
569 | elif level == "warning":
570 | format.setForeground(QColor("#FFA500"))
571 | elif level == "success":
572 | format.setForeground(QColor("green"))
573 | else:
574 | format.setForeground(QColor("#1e1e1e"))
575 |
576 | cursor.insertText(f"[{level.upper()}] {message}\n", format)
577 | self.log_output_area.ensureCursorVisible()
578 |
579 | def upload_finished(self, success, message):
580 | """上传任务完成后的处理"""
581 | self.start_button.setEnabled(True)
582 | self.stop_button.setEnabled(False)
583 | self.save_current_button.setEnabled(True)
584 | self.save_as_button.setEnabled(True)
585 | self.load_default_button.setEnabled(True)
586 | self.use_current_time_checkbox.setEnabled(True)
587 | self.start_datetime_input.setEnabled(not self.use_current_time_checkbox.isChecked())
588 | self.help_button.setEnabled(True)
589 | self.keepalive_input.setEnabled(True)
590 | self.jsessionid_input.setEnabled(True)
591 | self.user_id_input.setEnabled(True)
592 |
593 |
594 | self.progress_bar.setValue(100)
595 |
596 | if success:
597 | self.status_label.setText("状态: 上传成功!")
598 | self.log_output_text(f"操作完成: {message}", "success")
599 | QMessageBox.information(self, "上传结果", message)
600 | else:
601 | self.status_label.setText("状态: 上传失败!")
602 | self.log_output_text(f"操作失败: {message}", "error")
603 | QMessageBox.critical(self, "上传结果", f"上传失败: {message}")
604 |
605 | self.thread = None
606 |
607 | def show_help_dialog(self):
608 | """显示帮助对话框。"""
609 | help_dialog = HelpDialog(self, markdown_relative_path=os.path.join(RESOURCES_SUB_DIR, "help.md"))
610 | help_dialog.exec()
611 |
612 | if __name__ == "__main__":
613 | app = QApplication(sys.argv)
614 | ui = SportsUploaderUI()
615 | ui.show()
616 | sys.exit(app.exec())
--------------------------------------------------------------------------------
/src/api_client.py:
--------------------------------------------------------------------------------
1 | import requests
2 | import json
3 | from urllib.parse import quote
4 | from src.utils import log_output, SportsUploaderError
5 |
6 | def make_request(method, url, headers, params=None, data=None, log_cb=None, stop_check_cb=None):
7 | """通用HTTP请求函数"""
8 | try:
9 | if stop_check_cb and stop_check_cb():
10 | log_output("API请求被中断。", "warning", log_cb)
11 | raise SportsUploaderError("任务已停止。")
12 |
13 | timeout_value = 15
14 |
15 | response = None
16 |
17 | if method.upper() == 'GET':
18 | response = requests.get(url, headers=headers, params=params, timeout=timeout_value)
19 | elif method.upper() == 'POST':
20 | response = requests.post(url, headers=headers, data=data, timeout=timeout_value)
21 | else:
22 | raise ValueError(f"Unsupported HTTP method: {method}")
23 |
24 | if stop_check_cb and stop_check_cb():
25 | log_output("API响应已获取,但任务被中断。", "warning", log_cb)
26 | raise SportsUploaderError("任务已停止。")
27 |
28 | response.raise_for_status()
29 | return response.json()
30 | except requests.exceptions.HTTPError as e:
31 | log_output(f"HTTP error occurred: {e}", "error", log_cb)
32 | log_output(f"URL: {url}", "error", log_cb)
33 | if response is not None:
34 | log_output(f"Response status code: {response.status_code}", "error", log_cb)
35 | log_output(f"Response text: {response.text}", "error", log_cb)
36 | try:
37 | log_output(f"Response JSON (if any): {json.dumps(response.json(), indent=2)}", "error", log_cb)
38 | except json.JSONDecodeError:
39 | log_output(f"Response text (non-JSON): {response.text}", "error", log_cb)
40 | raise SportsUploaderError(f"HTTP Error: {e}")
41 | except requests.exceptions.ConnectionError as e:
42 | log_output(f"Connection error occurred: {e}", "error", log_cb)
43 | if isinstance(e.args[0], requests.packages.urllib3.exceptions.MaxRetryError) and e.args[0].reason:
44 | log_output(f"Underlying reason: {e.args[0].reason}", "error", log_cb)
45 | raise SportsUploaderError(f"Connection Error: {e}")
46 | except requests.exceptions.Timeout as e:
47 | log_output(f"Timeout error occurred: {e}", "error", log_cb)
48 | raise SportsUploaderError(f"Timeout Error: {e}")
49 | except requests.exceptions.RequestException as e:
50 | log_output(f"An unexpected error occurred: {e}", "error", log_cb)
51 | raise SportsUploaderError(f"Request Error: {e}")
52 | except json.JSONDecodeError:
53 | log_output(f"Failed to decode JSON from response: {response.text if response else 'No response'}", "error", log_cb)
54 | raise SportsUploaderError(f"JSON Decode Error: {response.text if response else 'No response'}")
55 |
56 |
57 | def get_authorization_token_and_rules(config, log_cb=None, stop_check_cb=None):
58 | """
59 | 通过GET请求获取Authorization Token,并随后获取跑步规则。
60 | """
61 | common_app_headers = {
62 | "Host": config["HOST"],
63 | "Connection": "keep-alive",
64 | "sec-ch-ua-platform": "\"Android\"",
65 | "User-Agent": "Mozilla/5.0 (Linux; Android 12; SM-S9080 Build/V417IR; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/138.0.7204.67 Safari/537.36 TaskCenterApp/3.5.0",
66 | "Accept": "application/json, text/plain, */*",
67 | "sec-ch-ua": "\"Not)A;Brand\";v=\"8\", \"Chromium\";v=\"138\", \"Android WebView\";v=\"138\"",
68 | "Content-Type": "application/json;charset=utf-8",
69 | "sec-ch-ua-mobile": "?0",
70 | "X-Requested-With": "edu.sjtu.infoplus.taskcenter",
71 | "Sec-Fetch-Site": "same-origin",
72 | "Sec-Fetch-Mode": "cors",
73 | "Sec-Fetch-Dest": "empty",
74 | "Referer": "https://pe.sjtu.edu.cn/phone/",
75 | "Accept-Encoding": "gzip, deflate, br, zstd",
76 | "Accept-Language": "zh-CN,zh;q=0.9,en-US;q=0.8,en;q=0.7",
77 | "Cookie": config["COOKIE"]
78 | }
79 |
80 | log_output(f"Attempting to get Authorization Token from: {config['UID_URL']}", callback=log_cb)
81 | uid_response_data = make_request('GET', config['UID_URL'], common_app_headers, log_cb=log_cb, stop_check_cb=stop_check_cb)
82 |
83 | auth_token = None
84 | if uid_response_data.get('code') == 0 and 'uid' in uid_response_data.get('data', {}):
85 | auth_token = uid_response_data['data']['uid']
86 | log_output(f"Successfully retrieved Authorization Token: {auth_token}", callback=log_cb)
87 | else:
88 | raise SportsUploaderError(f"Failed to get Authorization Token: {uid_response_data}")
89 |
90 | if stop_check_cb and stop_check_cb():
91 | log_output("任务被请求停止,正在退出...", "warning", log_cb)
92 | raise SportsUploaderError("任务已停止。")
93 |
94 | log_output(f"\nAttempting to get MyData from: {config['MY_DATA_URL']}", callback=log_cb)
95 | try:
96 | make_request('GET', config['MY_DATA_URL'], common_app_headers, log_cb=log_cb, stop_check_cb=stop_check_cb)
97 | log_output(f"Successfully sent MyData request.", callback=log_cb)
98 | except Exception as e:
99 | log_output(f"Warning: Failed to get MyData (this might be expected or ignorable): {e}", "warning", log_cb)
100 |
101 | if stop_check_cb and stop_check_cb():
102 | log_output("任务被请求停止,正在退出...", "warning", log_cb)
103 | raise SportsUploaderError("任务已停止。")
104 |
105 | formatted_lon_for_param = f"{config['START_LONGITUDE']:.14f}"
106 | formatted_lat_for_param = f"{config['START_LATITUDE']:.14f}"
107 | current_location_param = f"{formatted_lon_for_param},{formatted_lat_for_param}"
108 |
109 | referer_url_for_point_rule = f"{config['POINT_RULE_URL']}?location={quote(current_location_param, safe='')}"
110 |
111 | point_rule_headers = {
112 | "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7",
113 | "accept-language": "zh-CN,zh;q=0.9,en;q=0.8",
114 | "authorization": auth_token,
115 | "cache-control": "no-cache",
116 | "pragma": "no-cache",
117 | "sec-ch-ua": "\"Chromium\";v=\"140\", \"Not=A?Brand\";v=\"24\", \"Google Chrome\";v=\"140\"",
118 | "sec-ch-ua-mobile": "?0",
119 | "sec-ch-ua-platform": "\"Windows\"",
120 | "sec-fetch-dest": "empty",
121 | "sec-fetch-mode": "cors",
122 | "sec-fetch-site": "same-origin",
123 | "upgrade-insecure-requests": "1",
124 | "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36",
125 | "referer": referer_url_for_point_rule,
126 | "Host": config["HOST"],
127 | }
128 |
129 | params_string = f"?location={quote(current_location_param, safe='')}"
130 | url = config["POINT_RULE_URL"]
131 |
132 | log_output(f"\nGetting point rules from: {url} with location: {current_location_param}", callback=log_cb)
133 | point_rule_response_data = make_request('GET', url + params_string, point_rule_headers, log_cb=log_cb, stop_check_cb=stop_check_cb)
134 |
135 | return auth_token, point_rule_response_data.get('data', {})
136 |
137 | def upload_running_data(config, auth_token, running_data, log_cb=None, stop_check_cb=None):
138 | """
139 | 上传跑步数据到服务器。
140 | """
141 | headers = {
142 | "Authorization": auth_token,
143 | "Content-Type": "application/json; charset=utf-8",
144 | "Host": config["HOST"],
145 | "Connection": "Keep-Alive",
146 | "Accept-Encoding": "gzip",
147 | "User-Agent": "okhttp/4.10.0"
148 | }
149 |
150 | if stop_check_cb and stop_check_cb():
151 | log_output("任务被请求停止,正在退出...", "warning", log_cb)
152 | raise SportsUploaderError("任务已停止。")
153 |
154 | response = make_request(
155 | 'POST',
156 | config["UPLOAD_URL"],
157 | headers,
158 | data=json.dumps(running_data),
159 | log_cb=log_cb,
160 | stop_check_cb=stop_check_cb
161 | )
162 | log_output(f"Upload Response: {json.dumps(response, indent=2)}", callback=log_cb)
163 | return response
--------------------------------------------------------------------------------
/src/config_manager.py:
--------------------------------------------------------------------------------
1 | import json
2 | import os
3 | from PySide6.QtWidgets import QMessageBox
4 | from src.utils import get_current_epoch_ms, get_base_path
5 |
6 | CONFIGS_DIR = os.path.join(get_base_path(), "configs")
7 | DEFAULT_CONFIG_FILE_NAME = "default.json"
8 | DEFAULT_CONFIG_FILE = os.path.join(CONFIGS_DIR, DEFAULT_CONFIG_FILE_NAME)
9 |
10 |
11 | class ConfigManager:
12 | """管理配置的加载和保存,支持默认值和自定义文件名。"""
13 |
14 | @staticmethod
15 | def get_default_config():
16 | """提供硬编码的默认配置。"""
17 | return {
18 | "COOKIE": "",
19 | "USER_ID": "",
20 | "START_LATITUDE": 31.031599,
21 | "START_LONGITUDE": 121.442938,
22 | "END_LATITUDE": 31.026400,
23 | "END_LONGITUDE": 121.455100,
24 | "RUNNING_SPEED_MPS": 2.5,
25 | "INTERVAL_SECONDS": 3,
26 | "START_TIME_EPOCH_MS": None,
27 | "HOST": "pe.sjtu.edu.cn",
28 | "UID_URL": "https://pe.sjtu.edu.cn/sports/my/uid",
29 | "MY_DATA_URL": "https://pe.sjtu.edu.cn/sports/my/data",
30 | "POINT_RULE_URL": "https://pe.sjtu.edu.cn/api/running/point-rule",
31 | "UPLOAD_URL": "https://pe.sjtu.edu.cn/api/running/result/upload"
32 | }
33 |
34 | @staticmethod
35 | def load_config(filename=DEFAULT_CONFIG_FILE_NAME):
36 | """
37 | 从指定文件加载配置。如果文件不存在或加载失败,则返回默认配置。
38 | """
39 | if not os.path.exists(CONFIGS_DIR):
40 | os.makedirs(CONFIGS_DIR)
41 |
42 | config_path = os.path.join(CONFIGS_DIR, filename)
43 |
44 | if os.path.exists(config_path):
45 | try:
46 | with open(config_path, 'r', encoding='utf-8') as f:
47 | loaded_config = json.load(f)
48 | default_config = ConfigManager.get_default_config()
49 | final_config = {**default_config, **loaded_config}
50 | return final_config
51 | except json.JSONDecodeError as e:
52 | QMessageBox.warning(None, "配置加载错误",
53 | f"'{os.path.basename(config_path)}' 文件格式不正确: {e}\n将使用默认配置。")
54 | except Exception as e:
55 | QMessageBox.warning(None, "配置加载错误",
56 | f"无法加载 '{os.path.basename(config_path)}' 文件: {e}\n将使用默认配置。")
57 |
58 | return ConfigManager.get_default_config()
59 |
60 | @staticmethod
61 | def save_config(config_data, filename):
62 | """
63 | 将配置保存到指定文件。
64 | """
65 | if not os.path.exists(CONFIGS_DIR):
66 | os.makedirs(CONFIGS_DIR)
67 |
68 | full_path = os.path.join(CONFIGS_DIR, filename)
69 | try:
70 | with open(full_path, 'w', encoding='utf-8') as f:
71 | json.dump(config_data, f, indent=4, ensure_ascii=False)
72 | return True
73 | except Exception as e:
74 | QMessageBox.critical(None, "配置保存错误", f"无法保存文件 '{os.path.basename(filename)}': {e}")
75 | return False
--------------------------------------------------------------------------------
/src/data_generator.py:
--------------------------------------------------------------------------------
1 | import math
2 | import uuid
3 | import time
4 | import random
5 | from src.utils import haversine_distance, log_output, TRACK_POINT_DECIMAL_PLACES, get_current_epoch_ms, SportsUploaderError
6 |
7 | def interpolate_points(start_lat, start_lon, end_lat, end_lon, speed_mps, interval_seconds):
8 | """
9 | 在起点和终点之间以给定速度和采样间隔插值生成轨迹点。
10 | 返回一个包含轨迹点字典的列表,以及这段的距离和时长。
11 | 注意:这里生成的点不包含locatetime,由generate_running_data统一分配。
12 | """
13 | points = []
14 |
15 | start_lat_calc = float(f"{start_lat:.14f}")
16 | start_lon_calc = float(f"{start_lon:.14f}")
17 | end_lat_calc = float(f"{end_lat:.14f}")
18 | end_lon_calc = float(f"{end_lon:.14f}")
19 |
20 | start_lat_rad = math.radians(start_lat_calc)
21 | start_lon_rad = math.radians(start_lon_calc)
22 | end_lat_rad = math.radians(end_lat_calc)
23 | end_lon_rad = math.radians(end_lon_calc)
24 |
25 | segment_distance = haversine_distance(start_lat_calc, start_lon_calc, end_lat_calc, end_lon_calc)
26 | segment_duration_seconds = segment_distance / speed_mps
27 |
28 | num_steps = math.ceil(segment_duration_seconds / interval_seconds)
29 | if num_steps <= 1 and segment_distance > 0:
30 | num_steps = 1
31 | elif segment_distance == 0:
32 | num_steps = 0
33 |
34 | if num_steps == 0:
35 | formatted_lat = f"{start_lat_calc:.{TRACK_POINT_DECIMAL_PLACES}f}"
36 | formatted_lon = f"{start_lon_calc:.{TRACK_POINT_DECIMAL_PLACES}f}"
37 | points.append({
38 | "latLng": {"latitude": float(formatted_lat), "longitude": float(formatted_lon)},
39 | "location": f"{formatted_lon},{formatted_lat}",
40 | "step": 0
41 | })
42 | return points, segment_distance, math.ceil(segment_duration_seconds)
43 |
44 | for i in range(num_steps + 1):
45 | fraction = i / num_steps
46 |
47 | interp_lat_rad = start_lat_rad + fraction * (end_lat_rad - start_lat_rad)
48 | interp_lon_rad = start_lon_rad + fraction * (end_lon_rad - start_lon_rad)
49 |
50 | interp_lat = math.degrees(interp_lat_rad)
51 | interp_lon = math.degrees(interp_lon_rad)
52 |
53 | formatted_lat = f"{interp_lat:.{TRACK_POINT_DECIMAL_PLACES}f}"
54 | formatted_lon = f"{interp_lon:.{TRACK_POINT_DECIMAL_PLACES}f}"
55 |
56 | points.append({
57 | "latLng": {"latitude": float(formatted_lat), "longitude": float(formatted_lon)},
58 | "location": f"{formatted_lon},{formatted_lat}",
59 | "step": 0
60 | })
61 |
62 | final_end_lat_formatted = float(f"{end_lat_calc:.{TRACK_POINT_DECIMAL_PLACES}f}")
63 | final_end_lon_formatted = float(f"{end_lon_calc:.{TRACK_POINT_DECIMAL_PLACES}f}")
64 |
65 | if abs(points[-1]['latLng']['latitude'] - final_end_lat_formatted) > 1e-10 or \
66 | abs(points[-1]['latLng']['longitude'] - final_end_lon_formatted) > 1e-10:
67 | points[-1] = {
68 | "latLng": {"latitude": final_end_lat_formatted, "longitude": final_end_lon_formatted},
69 | "location": f"{final_end_lon_formatted},{final_end_lat_formatted}",
70 | "step": 0
71 | }
72 |
73 | return points, segment_distance, math.ceil(segment_duration_seconds)
74 |
75 |
76 | def split_track_into_segments(all_points_with_time, total_duration_sec, min_segment_points=5, stop_check_cb=None):
77 | """
78 | 将所有带有locatetime的轨迹点拆分为多个轨迹段。
79 | 并分配不同的 status 和 tstate。
80 | """
81 | tracks = []
82 |
83 | status_map = {
84 | "normal": "0",
85 | "stop": "0",
86 | "invalid": "2",
87 | }
88 |
89 | current_start_point_idx = 0
90 |
91 | if not all_points_with_time:
92 | return tracks
93 |
94 | while current_start_point_idx < len(all_points_with_time):
95 | if stop_check_cb and stop_check_cb():
96 | log_output("轨迹生成被中断。", "warning")
97 | raise SportsUploaderError("任务已停止。")
98 |
99 | segment_points = []
100 |
101 | remaining_points = len(all_points_with_time) - current_start_point_idx
102 | if remaining_points <= min_segment_points:
103 | segment_length = remaining_points
104 | else:
105 | segment_length = random.randint(min_segment_points, max(min_segment_points, remaining_points // 3))
106 | if segment_length == 1 and remaining_points > 1:
107 | segment_length = min_segment_points
108 |
109 | segment_points = all_points_with_time[current_start_point_idx: current_start_point_idx + segment_length]
110 | current_start_point_idx += segment_length
111 |
112 | if not segment_points:
113 | continue
114 |
115 | rand_val = random.random()
116 | if rand_val < 0.8:
117 | segment_status = "normal"
118 | elif rand_val < 0.9:
119 | segment_status = "invalid"
120 | else:
121 | segment_status = "stop"
122 |
123 | segment_tstate = status_map.get(segment_status, "0")
124 |
125 | segment_distance = 0
126 | if len(segment_points) > 1:
127 | for i in range(len(segment_points) - 1):
128 | p1 = segment_points[i]['latLng']
129 | p2 = segment_points[i + 1]['latLng']
130 | segment_distance += haversine_distance(p1['latitude'], p1['longitude'], p2['latitude'], p2['longitude'])
131 |
132 | segment_start_time_ms = segment_points[0]['locatetime']
133 | segment_end_time_ms = segment_points[-1]['locatetime']
134 | segment_duration_sec = math.ceil((segment_end_time_ms - segment_start_time_ms) / 1000)
135 |
136 | tracks.append({
137 | "counts": len(segment_points),
138 | "distance": segment_distance,
139 | "duration": segment_duration_sec,
140 | "points": segment_points,
141 | "status": segment_status,
142 | "trid": str(uuid.uuid4()),
143 | "tstate": segment_tstate,
144 | "stime": segment_start_time_ms // 1000,
145 | "etime": segment_end_time_ms // 1000
146 | })
147 |
148 | return tracks
149 |
150 |
151 | def generate_running_data_payload(config, required_signpoints, point_rules_data, log_cb=None, stop_check_cb=None):
152 | """
153 | 生成符合POST请求体格式的跑步数据,并整合打卡点。
154 | """
155 | all_path_segments = []
156 | all_path_segments.append({'latitude': config['START_LATITUDE'], 'longitude': config['START_LONGITUDE']})
157 |
158 | extracted_signpoints_coords = []
159 | for sp in required_signpoints:
160 | lon_str, lat_str = sp['location'].split(',')
161 | formatted_sp_lat = float(f"{float(lat_str):.{TRACK_POINT_DECIMAL_PLACES}f}")
162 | formatted_sp_lon = float(f"{float(lon_str):.{TRACK_POINT_DECIMAL_PLACES}f}")
163 | extracted_signpoints_coords.append({'latitude': formatted_sp_lat, 'longitude': formatted_sp_lon})
164 |
165 | extracted_signpoints_coords.sort(
166 | key=lambda p: haversine_distance(config['START_LATITUDE'], config['START_LONGITUDE'], p['latitude'], p['longitude']))
167 | all_path_segments.extend(extracted_signpoints_coords)
168 | all_path_segments.append({'latitude': config['END_LATITUDE'], 'longitude': config['END_LONGITUDE']})
169 |
170 | full_interpolated_points_with_time = []
171 | total_overall_distance = 0
172 |
173 | current_locatetime_ms = config['START_TIME_EPOCH_MS'] if config['START_TIME_EPOCH_MS'] is not None else get_current_epoch_ms()
174 |
175 | for i in range(len(all_path_segments) - 1):
176 | if stop_check_cb and stop_check_cb():
177 | log_output("轨迹插值被中断。", "warning")
178 | raise SportsUploaderError("任务已停止。")
179 |
180 | p1_coord = all_path_segments[i]
181 | p2_coord = all_path_segments[i + 1]
182 |
183 | segment_interpolated_points, segment_distance, segment_duration_sec = interpolate_points(
184 | p1_coord['latitude'], p1_coord['longitude'], p2_coord['latitude'], p2_coord['longitude'], config['RUNNING_SPEED_MPS'],
185 | config['INTERVAL_SECONDS']
186 | )
187 |
188 | if full_interpolated_points_with_time and segment_interpolated_points:
189 | last_point_in_full = full_interpolated_points_with_time[-1]['latLng']
190 | first_point_in_segment = segment_interpolated_points[0]['latLng']
191 |
192 | if abs(last_point_in_full['latitude'] - first_point_in_segment['latitude']) < 1e-10 and \
193 | abs(last_point_in_full['longitude'] - first_point_in_segment['longitude']) < 1e-10:
194 | segment_interpolated_points = segment_interpolated_points[1:]
195 |
196 | for point in segment_interpolated_points:
197 | point['locatetime'] = current_locatetime_ms
198 | full_interpolated_points_with_time.append(point)
199 | current_locatetime_ms += config['INTERVAL_SECONDS'] * 1000
200 |
201 | total_overall_distance += segment_distance
202 |
203 | actual_total_duration_sec = 0
204 | if full_interpolated_points_with_time:
205 | first_point_time_ms = full_interpolated_points_with_time[0]['locatetime']
206 | last_point_time_ms = full_interpolated_points_with_time[-1]['locatetime']
207 | actual_total_duration_sec = math.ceil((last_point_time_ms - first_point_time_ms + config['INTERVAL_SECONDS'] * 1000) / 1000)
208 |
209 | tracks_list = split_track_into_segments(full_interpolated_points_with_time, actual_total_duration_sec, stop_check_cb=stop_check_cb)
210 | actual_total_distance = sum(t['distance'] for t in tracks_list)
211 |
212 | run_id = point_rules_data.get('rules', {}).get('id', 6)
213 | if run_id == 6:
214 | run_id = 9
215 |
216 | sp_avg = 0
217 | if actual_total_distance > 0 and actual_total_duration_sec > 0:
218 | sp_avg = actual_total_duration_sec / (actual_total_distance / 1000) / 60
219 | sp_avg = round(sp_avg)
220 |
221 | rules_meta = point_rules_data.get('rules', {})
222 | min_sp_s_per_km = rules_meta.get('spmin', 180)
223 | max_sp_s_per_km = rules_meta.get('spmax', 540)
224 |
225 | sp_avg_s_per_km = sp_avg * 60 if sp_avg > 0 else 0
226 |
227 | if actual_total_distance > 0:
228 | if sp_avg_s_per_km < min_sp_s_per_km:
229 | log_output(f"Warning: Calculated pace {sp_avg} min/km ({sp_avg_s_per_km:.0f} s/km) is faster than {min_sp_s_per_km / 60:.0f} min/km ({min_sp_s_per_km:.0f} s/km). Adjusting to minimum allowed pace.", "warning", log_cb)
230 | sp_avg = math.ceil(min_sp_s_per_km / 60)
231 | elif sp_avg_s_per_km > max_sp_s_per_km:
232 | log_output(f"Warning: Calculated pace {sp_avg} min/km ({sp_avg_s_per_km:.0f} s/km) is slower than {max_sp_s_per_km / 60:.0f} min/km ({max_sp_s_per_km:.0f} s/km). Adjusting to maximum allowed pace.", "warning", log_cb)
233 | sp_avg = math.floor(max_sp_s_per_km / 60)
234 |
235 | request_body = [
236 | {
237 | "fravg": 0,
238 | "id": run_id,
239 | "sid": str(uuid.uuid4()),
240 | "signpoints": [],
241 | "spavg": sp_avg,
242 | "state": "0",
243 | "tracks": tracks_list,
244 | "userId": config['USER_ID']
245 | }
246 | ]
247 | return request_body, actual_total_distance, actual_total_duration_sec
--------------------------------------------------------------------------------
/src/help_dialog.py:
--------------------------------------------------------------------------------
1 | import os
2 | import markdown
3 | import re
4 | from PySide6.QtWidgets import QDialog, QVBoxLayout, QTextBrowser, QPushButton, QSizePolicy
5 | from PySide6.QtGui import QIcon, QColor, QPalette
6 | from PySide6.QtCore import Qt, QUrl
7 | from src.utils import get_base_path
8 |
9 | RESOURCES_SUB_DIR = "assets"
10 | RESOURCES_FULL_PATH = os.path.join(get_base_path(), RESOURCES_SUB_DIR)
11 |
12 |
13 | class HelpDialog(QDialog):
14 | def __init__(self, parent=None, markdown_relative_path="assets/help.md"):
15 | super().__init__(parent)
16 | self.setWindowTitle("帮助 - SJTU 体育跑步上传工具")
17 | self.setWindowIcon(QIcon(os.path.join(RESOURCES_FULL_PATH, "SJTURM.png")))
18 | self.resize(600, 700)
19 |
20 | self.init_ui(markdown_relative_path)
21 | self.apply_style()
22 |
23 | def init_ui(self, markdown_relative_path):
24 | main_layout = QVBoxLayout(self)
25 |
26 | self.text_browser = QTextBrowser()
27 | self.text_browser.setOpenExternalLinks(True)
28 | self.text_browser.setReadOnly(True)
29 | self.text_browser.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
30 |
31 | full_markdown_path = os.path.join(get_base_path(), markdown_relative_path)
32 | help_content = self.load_markdown_content(full_markdown_path)
33 |
34 | if help_content:
35 | html_content = markdown.markdown(help_content)
36 |
37 | image_src_pattern = r'(
]*?src=")(\.?/?([^"/\\<>]+\.(?:png|jpg|jpeg|gif|bmp|svg)))("[^>]*?>)'
38 |
39 | def replace_and_resize_image_path(match):
40 | image_filename = match.group(3)
41 | absolute_image_path = os.path.join(RESOURCES_FULL_PATH, image_filename)
42 | file_url = QUrl.fromLocalFile(absolute_image_path).toString()
43 |
44 | image_internal_styles = "max-width: 100%; height: auto; display: block; margin: 0 auto; border: 1px solid rgb(230, 230, 230); border-radius: 5px;"
45 |
46 | original_img_tag = match.group(0)
47 |
48 | modified_img_tag = re.sub(r'src="[^"]+"', f'src="{file_url}"', original_img_tag, flags=re.IGNORECASE)
49 |
50 | if 'style="' in modified_img_tag:
51 | modified_img_tag = re.sub(r'(style="[^"]*)(")', rf'\1; {image_internal_styles}\2', modified_img_tag,
52 | flags=re.IGNORECASE, count=1)
53 | else:
54 | modified_img_tag = re.sub(r'(
{modified_img_tag}'
60 |
61 | html_content = re.sub(image_src_pattern, replace_and_resize_image_path, html_content, flags=re.IGNORECASE)
62 |
63 | self.text_browser.setHtml(html_content)
64 |
65 | else:
66 | self.text_browser.setPlainText(
67 | f"无法加载帮助内容。请确保 '{os.path.basename(markdown_relative_path)}' 文件存在且可读。")
68 |
69 | main_layout.addWidget(self.text_browser)
70 |
71 | close_button = QPushButton("关闭")
72 | close_button.clicked.connect(self.accept)
73 | main_layout.addWidget(close_button, alignment=Qt.AlignCenter)
74 |
75 | def load_markdown_content(self, path):
76 | """从文件加载Markdown内容"""
77 | if os.path.exists(path):
78 | try:
79 | with open(path, 'r', encoding='utf-8') as f:
80 | return f.read()
81 | except Exception as e:
82 | print(f"Error loading {os.path.basename(path)}: {e}")
83 | return None
84 | return None
85 |
86 | def apply_style(self):
87 | """为帮助对话框应用浅色样式,与主窗口保持一致"""
88 | palette = self.palette()
89 | palette.setColor(QPalette.Window, QColor(255, 255, 255))
90 | palette.setColor(QPalette.WindowText, QColor(30, 30, 30))
91 | palette.setColor(QPalette.Base, QColor(255, 255, 255))
92 | palette.setColor(QPalette.Text, QColor(30, 30, 30))
93 | palette.setColor(QPalette.Button, QColor(0, 120, 212))
94 | palette.setColor(QPalette.ButtonText, QColor(255, 255, 255))
95 | self.setPalette(palette)
96 |
97 | self.setStyleSheet("""
98 | QDialog {
99 | background-color: rgb(255, 255, 255);
100 | }
101 | QTextBrowser {
102 | border: 1px solid rgb(220, 220, 220);
103 | border-radius: 5px;
104 | padding: 10px;
105 | font-family: "Segoe UI", "Microsoft YaHei", sans-serif;
106 | font-size: 10pt;
107 | image-rendering: auto;
108 | }
109 | QPushButton {
110 | background-color: rgb(0, 120, 212);
111 | color: white;
112 | border-radius: 5px;
113 | padding: 8px 15px;
114 | font-weight: bold;
115 | min-width: 80px;
116 | }
117 | QPushButton:hover {
118 | background-color: rgb(0, 96, 173);
119 | }
120 | """)
--------------------------------------------------------------------------------
/src/main.py:
--------------------------------------------------------------------------------
1 | import time
2 | import datetime
3 | from src.api_client import get_authorization_token_and_rules, upload_running_data
4 | from src.data_generator import generate_running_data_payload
5 | from src.utils import log_output, SportsUploaderError, get_current_epoch_ms
6 |
7 | def run_sports_upload(config, progress_callback=None, log_cb=None, stop_check_cb=None):
8 | """
9 | 核心的跑步数据生成和上传逻辑,接收配置字典和进度回调函数。
10 | progress_callback: 接收 (current_value, max_value, message)
11 | log_cb: 接收 (message, level)
12 | stop_check_cb: 一个函数,调用时返回True表示请求停止
13 | """
14 | log_output("--- Starting Sports Upload Process ---", callback=log_cb)
15 |
16 | if stop_check_cb and stop_check_cb():
17 | log_output("任务被请求停止,正在退出...", "warning", log_cb)
18 | return False, "任务已停止。"
19 |
20 | auth_token_for_upload = None
21 | required_signpoints = []
22 | point_rules_data = {}
23 |
24 | try:
25 | log_output("Step 1/3: Getting Authorization Token and Point Rules...", callback=log_cb)
26 | if progress_callback: progress_callback(10, 100, "获取认证信息和跑步规则...")
27 |
28 | if stop_check_cb and stop_check_cb():
29 | log_output("任务被请求停止,正在退出...", "warning", log_cb)
30 | return False, "任务已停止。"
31 |
32 | auth_token_for_upload, point_rules_data = get_authorization_token_and_rules(config, log_cb=log_cb, stop_check_cb=stop_check_cb)
33 | required_signpoints = [p for p in point_rules_data.get('points', []) if p.get('isneed') == 'Y']
34 | log_output(f"Required Signpoints for rule: {required_signpoints}", callback=log_cb)
35 |
36 | rules_meta = point_rules_data.get('rules', {})
37 | if rules_meta:
38 | min_sp_s_per_km = rules_meta.get('spmin', 180)
39 | max_sp_s_per_km = rules_meta.get('spmax', 540)
40 |
41 | if min_sp_s_per_km and max_sp_s_per_km:
42 | min_speed_mps = 1000 / max_sp_s_per_km if max_sp_s_per_km > 0 else 0
43 | max_speed_mps = 1000 / min_sp_s_per_km if min_sp_s_per_km > 0 else 0
44 |
45 | if config["RUNNING_SPEED_MPS"] < min_speed_mps:
46 | log_output(
47 | f"Adjusting running speed from {config['RUNNING_SPEED_MPS']:.2f} m/s to minimum allowed {min_speed_mps:.2f} m/s",
48 | "warning", log_cb)
49 | config["RUNNING_SPEED_MPS"] = min_speed_mps
50 | elif config["RUNNING_SPEED_MPS"] > max_speed_mps:
51 | log_output(
52 | f"Adjusting running speed from {config['RUNNING_SPEED_MPS']:.2f} m/s to maximum allowed {max_speed_mps:.2f} m/s",
53 | "warning", log_cb)
54 | config["RUNNING_SPEED_MPS"] = max_speed_mps
55 |
56 | log_output(
57 | f"Pace rule: {min_sp_s_per_km / 60:.0f}'-{max_sp_s_per_km / 60:.0f}' min/km. Using adjusted speed: {config['RUNNING_SPEED_MPS']:.2f} m/s",
58 | callback=log_cb)
59 | else:
60 | log_output("Warning: Could not retrieve pace rules from point-rule response. Using default speed.",
61 | "warning", log_cb)
62 |
63 | except SportsUploaderError as e:
64 | log_output(f"Fatal: Failed during authentication or rule retrieval: {e}", "error", log_cb)
65 | return False, str(e)
66 | except Exception as e:
67 | log_output(f"An unexpected error occurred during auth/rules retrieval: {e}", "error", log_cb)
68 | return False, str(e)
69 |
70 | if stop_check_cb and stop_check_cb():
71 | log_output("任务被请求停止,正在退出...", "warning", log_cb)
72 | return False, "任务已停止。"
73 |
74 | log_output("\nStep 2/3: Generating Running Data...", callback=log_cb)
75 | if progress_callback: progress_callback(40, 100, "生成跑步数据...")
76 | running_data_payload = None
77 | total_dist = 0
78 | total_dur = 0
79 | try:
80 | running_data_payload, total_dist, total_dur = generate_running_data_payload(
81 | config,
82 | required_signpoints,
83 | point_rules_data,
84 | log_cb=log_cb,
85 | stop_check_cb=stop_check_cb
86 | )
87 |
88 | log_output(f"Generated {len(running_data_payload[0]['tracks'])} track segments.", callback=log_cb)
89 | log_output(f"Total simulated distance: {total_dist:.2f} meters", callback=log_cb)
90 | log_output(f"Total simulated duration: {total_dur} seconds", callback=log_cb)
91 | log_output(f"Simulated average pace: {running_data_payload[0]['spavg']} min/km", callback=log_cb)
92 |
93 | if running_data_payload and running_data_payload[0]['tracks']:
94 | first_point_locatetime_ms = running_data_payload[0]['tracks'][0]['points'][0]['locatetime']
95 | first_point_datetime = datetime.datetime.fromtimestamp(first_point_locatetime_ms / 1000).strftime('%Y-%m-%d %H:%M:%S')
96 | log_output(
97 | f"Actual start time of run (from first point): {first_point_datetime} (Epoch MS: {first_point_locatetime_ms})",
98 | callback=log_cb)
99 |
100 | except SportsUploaderError as e:
101 | log_output(f"Failed to generate running data: {e}", "error", log_cb)
102 | return False, str(e)
103 | except Exception as e:
104 | log_output(f"An unexpected error occurred during data generation: {e}", "error", log_cb)
105 | return False, str(e)
106 |
107 | if stop_check_cb and stop_check_cb():
108 | log_output("任务被请求停止,正在退出...", "warning", log_cb)
109 | return False, "任务已停止。"
110 |
111 | if running_data_payload and auth_token_for_upload:
112 | log_output("\nStep 3/3: Sending Running Data...", callback=log_cb)
113 | if progress_callback: progress_callback(70, 100, "准备上传跑步数据...")
114 | try:
115 | if config["START_TIME_EPOCH_MS"] is None:
116 | log_output(f"模拟等待跑步完成 {total_dur} 秒...", callback=log_cb)
117 | for i in range(total_dur):
118 | if stop_check_cb and stop_check_cb():
119 | log_output("任务被请求停止,正在退出...", "warning", log_cb)
120 | return False, "任务已停止。"
121 | if progress_callback:
122 | progress_callback(70 + int(20 * (i + 1) / total_dur), 100,
123 | f"等待跑步完成 ({i + 1}/{total_dur}秒)")
124 | time.sleep(1)
125 | else:
126 | log_output("设置了过去的跑步时间,直接准备上传,短暂停顿 3 秒...", callback=log_cb)
127 | if progress_callback: progress_callback(80, 100, "短暂停顿...")
128 | if stop_check_cb and stop_check_cb():
129 | log_output("任务被请求停止,正在退出...", "warning", log_cb)
130 | return False, "任务已停止。"
131 | time.sleep(3)
132 |
133 | rt = 0
134 | max_rt = 3
135 | while rt < max_rt:
136 | if stop_check_cb and stop_check_cb():
137 | log_output("任务被请求停止,正在退出...", "warning", log_cb)
138 | return False, "任务已停止。"
139 |
140 | log_output(f"Attempting to upload running data (Attempt {rt + 1}/{max_rt})...", callback=log_cb)
141 | if progress_callback: progress_callback(90 + int(5 * rt / max_rt), 100,
142 | f"上传数据 (尝试 {rt + 1}/{max_rt})...")
143 |
144 | response = upload_running_data(
145 | config,
146 | auth_token_for_upload,
147 | running_data_payload,
148 | log_cb=log_cb,
149 | stop_check_cb=stop_check_cb
150 | )
151 |
152 | if response.get('code') == 0 and response.get('data'):
153 | log_output("\nSUCCESS: Running data uploaded successfully with full data response!",
154 | callback=log_cb)
155 | if progress_callback: progress_callback(100, 100, "上传成功!")
156 | return True, "上传成功!"
157 | elif response.get('code') == 0:
158 | log_output(
159 | f"\nWARNING: Uploaded, but response code is 0 with empty data. Server might still process it. Retrying in 3 seconds... (Attempt {rt + 1}/{max_rt})",
160 | "warning", log_cb)
161 | rt += 1
162 | time.sleep(3)
163 | if stop_check_cb and stop_check_cb():
164 | log_output("任务被请求停止,正在退出...", "warning", log_cb)
165 | return False, "任务已停止。"
166 | else:
167 | log_output(
168 | f"\nERROR: Upload returned non-success code ({response.get('code', 'N/A')}). No further retries for this attempt.",
169 | "error", log_cb)
170 | if progress_callback: progress_callback(100, 100, "上传失败!")
171 | return False, f"上传失败,响应代码: {response.get('code', 'N/A')}"
172 | else:
173 | log_output("\nERROR: Upload failed or returned error code after retries.", "error", log_cb)
174 | if progress_callback: progress_callback(100, 100, "上传失败!")
175 | return False, "上传失败或返回错误代码 (达到最大重试次数)"
176 | except SportsUploaderError as e:
177 | log_output(f"Failed to send running data: {e}", "error", log_cb)
178 | if progress_callback: progress_callback(100, 100, "上传失败!")
179 | return False, str(e)
180 | except Exception as e:
181 | log_output(f"An unexpected error occurred during data upload: {e}", "error", log_cb)
182 | if progress_callback: progress_callback(100, 100, "上传失败!")
183 | return False, str(e)
184 | else:
185 | log_output("Skipping data upload due to missing generated data or authorization token.", "error", log_cb)
186 | if progress_callback: progress_callback(100, 100, "上传被跳过!")
187 | return False, "数据生成或认证失败,上传被跳过。"
188 |
189 |
190 | if __name__ == "__main__":
191 | test_config = {
192 | "COOKIE": "your_keepalive_and_jsessionid_cookie_string_here",
193 | "USER_ID": "your_user_id",
194 | "START_LATITUDE": 31.031599,
195 | "START_LONGITUDE": 121.442938,
196 | "END_LATITUDE": 31.026400,
197 | "END_LONGITUDE": 121.455100,
198 | "RUNNING_SPEED_MPS": 2.5,
199 | "INTERVAL_SECONDS": 3,
200 | "START_TIME_EPOCH_MS": None,
201 | "HOST": "pe.sjtu.edu.cn",
202 | "UID_URL": "https://pe.sjtu.edu.cn/sports/my/uid",
203 | "MY_DATA_URL": "https://pe.sjtu.edu.cn/sports/my/data",
204 | "POINT_RULE_URL": "https://pe.sjtu.edu.cn/api/running/point-rule",
205 | "UPLOAD_URL": "https://pe.sjtu.edu.cn/api/running/result/upload"
206 | }
207 |
208 | def simple_log_cb(message, level):
209 | print(f"[{level.upper()}] {message}")
210 |
211 | def simple_progress_cb(current, total, message):
212 | print(f"Progress: {message} ({current}/{total})")
213 |
214 | success, msg = run_sports_upload(test_config, progress_callback=simple_progress_cb, log_cb=simple_log_cb, stop_check_cb=lambda: False)
215 | print(f"\nOperation {'SUCCESS' if success else 'FAILED'}: {msg}")
--------------------------------------------------------------------------------
/src/utils.py:
--------------------------------------------------------------------------------
1 | import math
2 | import time
3 | import datetime
4 | import os
5 | import sys
6 |
7 | EARTH_RADIUS_METERS = 6371000
8 | TRACK_POINT_DECIMAL_PLACES = 7
9 |
10 | def get_base_path():
11 | """获取应用程序的基础路径。
12 | 在打包后是 .exe 所在的目录,开发时是项目根目录。"""
13 | if hasattr(sys, 'frozen'):
14 | return os.path.dirname(sys.executable)
15 | return os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
16 |
17 |
18 | class SportsUploaderError(Exception):
19 | """自定义异常类,用于在UI中捕获和显示错误"""
20 | pass
21 |
22 | def log_output(message, level="info", callback=None):
23 | """统一的日志输出函数,可传递给UI回调"""
24 | if callback:
25 | callback(message, level)
26 | else:
27 | timestamp = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')
28 | if level == "error":
29 | print(f"[{timestamp}][ERROR] {message}")
30 | elif level == "warning":
31 | print(f"[{timestamp}][WARNING] {message}")
32 | else:
33 | print(f"[{timestamp}][INFO] {message}")
34 |
35 |
36 | def haversine_distance(lat1, lon1, lat2, lon2):
37 | """
38 | 计算两个经纬度点之间的Haversine距离(单位:米)。
39 | """
40 | R = EARTH_RADIUS_METERS
41 | phi1 = math.radians(lat1)
42 | phi2 = math.radians(lat2)
43 | delta_phi = math.radians(lat2 - lat1)
44 | delta_lambda = math.radians(lon2 - lon1)
45 |
46 | a = math.sin(delta_phi / 2) ** 2 + math.cos(phi1) * math.cos(phi2) * math.sin(delta_lambda / 2) ** 2
47 | c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))
48 | return R * c
49 |
50 | def get_current_epoch_ms():
51 | """获取当前的Unix Epoch毫秒时间戳"""
52 | return int(time.time() * 1000)
--------------------------------------------------------------------------------