├── .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 | ![img.png](assets/help0.png) 19 | 20 | **2** 登陆后,点击 `F12`,然后打开"应用"界面 21 | 22 | ![img.png](assets/help1.png) 23 | 24 | **3** 点击侧边栏的 "Cookie",查看 `JSESSIONID` 和 `keepalive`,并写到应用内 25 | 26 | ![img.png](assets/help3.png) 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 | ![img.png](help0.png) 19 | 20 | **2** 登陆后,点击 `F12`,然后打开"应用"界面 21 | 22 | ![img.png](help1.png) 23 | 24 | **3** 点击侧边栏的 "Cookie",查看 `JSESSIONID` 和 `keepalive`,并写到应用内 25 | 26 | ![img.png](help3.png) 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) --------------------------------------------------------------------------------