镜像自地址
https://github.com/a13zhy/a13rollcall.git
已同步 2026-10-20 11:27:47 +08:00
279 行
12 KiB
Python
279 行
12 KiB
Python
# -*- coding: utf-8 -*-
|
|
import os, sys, zipfile, shutil, traceback
|
|
from PyQt5.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
|
|
QLabel, QPushButton, QLineEdit, QFileDialog, QStackedWidget,
|
|
QProgressBar, QFrame, QGraphicsOpacityEffect)
|
|
from PyQt5.QtCore import Qt, QTimer, QPropertyAnimation, QEasingCurve, QSize
|
|
from PyQt5.QtGui import QIcon, QFont, QPixmap, QPainter, QLinearGradient, QColor
|
|
|
|
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
APP_NAME = "A13课堂点名系统"
|
|
VERSION = "V6.8"
|
|
EXE_NAME = "A13课堂点名程序.exe"
|
|
|
|
|
|
def _resource_path(name):
|
|
if getattr(sys, "frozen", False):
|
|
return os.path.join(getattr(sys, "_MEIPASS", BASE_DIR), name)
|
|
return os.path.join(BASE_DIR, name)
|
|
|
|
|
|
PAYLOAD = _resource_path("payload.zip")
|
|
|
|
SLIDES = [
|
|
{"title": "第六代 A13 抽签引擎", "desc": "会话防重复、动态权重、概率均衡\n把命运交给算法,告别点名偏心"},
|
|
{"title": "大屏课堂适配", "desc": "超大姓名展示、粒子动画、圆角窗口\n支持 3000+ 分辨率班级大屏"},
|
|
{"title": "悬浮快捷抽取", "desc": "上课缩成右上角小浮窗,不挡课件\n一键抽人,快速扫荡全班"},
|
|
{"title": "完整插件生态", "desc": "自研 .arcx 插件格式,支持 UI 注入\n事件总线、HTTP、数据库、自定义快捷键"},
|
|
{"title": "统计与导出", "desc": "按轮次统计背诵进度、权重分布\n一键导出到桌面,纯本地离线运行"},
|
|
]
|
|
|
|
|
|
class WelcomeSlide(QWidget):
|
|
def __init__(self, parent=None):
|
|
super().__init__(parent)
|
|
self._idx = 0
|
|
lay = QVBoxLayout(self)
|
|
lay.setContentsMargins(60, 50, 60, 50)
|
|
lay.setSpacing(18)
|
|
self.logo = QLabel("A13")
|
|
self.logo.setAlignment(Qt.AlignCenter)
|
|
self.logo.setStyleSheet("font-size: 64px; font-weight: 900; color: #0067c0;")
|
|
lay.addWidget(self.logo)
|
|
self.sub = QLabel("课堂点名抽背神器 " + VERSION)
|
|
self.sub.setAlignment(Qt.AlignCenter)
|
|
self.sub.setStyleSheet("font-size: 15px; color: #666;")
|
|
lay.addWidget(self.sub)
|
|
lay.addSpacing(20)
|
|
self.title = QLabel()
|
|
self.title.setAlignment(Qt.AlignCenter)
|
|
self.title.setStyleSheet("font-size: 26px; font-weight: 800; color: #1a1a1a;")
|
|
self.desc = QLabel()
|
|
self.desc.setAlignment(Qt.AlignCenter)
|
|
self.desc.setWordWrap(True)
|
|
self.desc.setStyleSheet("font-size: 15px; color: #555; line-height: 160%;")
|
|
lay.addWidget(self.title)
|
|
lay.addWidget(self.desc)
|
|
lay.addStretch()
|
|
self.dots = QHBoxLayout()
|
|
self.dots.setAlignment(Qt.AlignCenter)
|
|
self.dot_labels = []
|
|
for i in range(len(SLIDES)):
|
|
d = QLabel("●")
|
|
d.setStyleSheet("font-size: 14px; color: #cccccc;")
|
|
self.dot_labels.append(d)
|
|
self.dots.addWidget(d)
|
|
lay.addLayout(self.dots)
|
|
self._update_slide()
|
|
self.timer = QTimer(self)
|
|
self.timer.timeout.connect(self._next)
|
|
self.timer.start(3500)
|
|
|
|
def _update_slide(self):
|
|
s = SLIDES[self._idx]
|
|
self.title.setText(s["title"])
|
|
self.desc.setText(s["desc"])
|
|
for i, d in enumerate(self.dot_labels):
|
|
d.setStyleSheet("font-size: 14px; color: #0067c0;" if i == self._idx else "font-size: 14px; color: #cccccc;")
|
|
|
|
def _next(self):
|
|
self._idx = (self._idx + 1) % len(SLIDES)
|
|
self._update_slide()
|
|
|
|
|
|
class Installer(QMainWindow):
|
|
def __init__(self):
|
|
super().__init__()
|
|
self.setWindowTitle(f"{APP_NAME} 安装程序")
|
|
self.setWindowIcon(QIcon(_resource_path("app.ico")))
|
|
self.resize(760, 560)
|
|
self.setStyleSheet("QMainWindow{background:#f0f4f9;} QWidget{font-family:'Microsoft YaHei';}")
|
|
self.target = os.path.join(os.path.expanduser("~"), "A13Rollcall")
|
|
self.stack = QStackedWidget()
|
|
self.setCentralWidget(self.stack)
|
|
self._build_welcome()
|
|
self._build_path()
|
|
self._build_progress()
|
|
self._build_done()
|
|
self.stack.setCurrentIndex(0)
|
|
|
|
def _nav(self, back_text, next_text, on_back=None, on_next=None, hide_back=False):
|
|
bar = QHBoxLayout()
|
|
bar.addStretch()
|
|
back = QPushButton(back_text)
|
|
back.setStyleSheet("QPushbutton{background:white;color:#0067c0;border:1px solid #0067c0;border-radius:6px;padding:8px 22px;}")
|
|
back.setMinimumHeight(38)
|
|
if hide_back:
|
|
back.hide()
|
|
else:
|
|
back.clicked.connect(on_back or (lambda: self.stack.setCurrentIndex(self.stack.currentIndex()-1)))
|
|
nxt = QPushButton(next_text)
|
|
nxt.setStyleSheet("QPushButton{background:#0067c0;color:white;border:none;border-radius:6px;padding:8px 26px;font-weight:600;} QPushButton:hover{background:#005a9e;}")
|
|
nxt.setMinimumHeight(38)
|
|
nxt.clicked.connect(on_next)
|
|
bar.addWidget(back)
|
|
bar.addSpacing(10)
|
|
bar.addWidget(nxt)
|
|
return bar, back, nxt
|
|
|
|
def _build_welcome(self):
|
|
w = QWidget(); lay = QVBoxLayout(w); lay.setContentsMargins(0,0,0,0)
|
|
lay.addWidget(WelcomeSlide(), 1)
|
|
bar = QHBoxLayout(); bar.addStretch()
|
|
nxt = QPushButton("开始安装"); nxt.setStyleSheet("QPushButton{background:#0067c0;color:white;border:none;border-radius:6px;padding:10px 30px;font-weight:700;font-size:14px;}")
|
|
nxt.clicked.connect(lambda: self.stack.setCurrentIndex(1)); bar.addWidget(nxt)
|
|
row = QWidget(); row.setLayout(bar); row.setStyleSheet("background:#e8eef7;")
|
|
lay.addWidget(row)
|
|
self.stack.addWidget(w)
|
|
|
|
def _build_path(self):
|
|
w = QWidget(); lay = QVBoxLayout(w); lay.setContentsMargins(40,30,40,20); lay.setSpacing(16)
|
|
t = QLabel("选择安装位置"); t.setStyleSheet("font-size:22px;font-weight:800;color:#1a1a1a;"); lay.addWidget(t)
|
|
d = QLabel("建议使用默认路径。安装完成后可从开始菜单和桌面快捷方式启动。"); d.setStyleSheet("color:#666;"); lay.addWidget(d)
|
|
row = QHBoxLayout()
|
|
self.path_edit = QLineEdit(self.target)
|
|
self.path_edit.setStyleSheet("padding:8px;border:1px solid #ccc;border-radius:6px;")
|
|
browse = QPushButton("浏览..."); browse.setStyleSheet("background:#0067c0;color:white;border:none;border-radius:6px;padding:8px 16px;")
|
|
browse.clicked.connect(self._pick_dir)
|
|
row.addWidget(self.path_edit,1); row.addWidget(browse)
|
|
lay.addLayout(row)
|
|
lay.addStretch()
|
|
bar, back, nxt = self._nav("上一步", "开始安装", on_next=self._start)
|
|
nxt.setText("开始安装")
|
|
lay.addLayout(bar)
|
|
self.stack.addWidget(w)
|
|
|
|
def _pick_dir(self):
|
|
d = QFileDialog.getExistingDirectory(self, "选择安装目录", self.path_edit.text())
|
|
if d:
|
|
self.path_edit.setText(d)
|
|
|
|
def _build_progress(self):
|
|
w = QWidget(); lay = QVBoxLayout(w); lay.setContentsMargins(40,40,40,20); lay.setSpacing(16)
|
|
t = QLabel("正在安装"); t.setStyleSheet("font-size:22px;font-weight:800;"); lay.addWidget(t)
|
|
self.status = QLabel("准备中..."); self.status.setStyleSheet("color:#666;"); lay.addWidget(self.status)
|
|
self.bar = QProgressBar(); self.bar.setValue(0); self.bar.setTextVisible(True)
|
|
self.bar.setStyleSheet("QProgressBar{border:1px solid #ccc;border-radius:6px;height:22px;background:white;} QProgressBar::chunk{background:#0067c0;border-radius:5px;}")
|
|
lay.addWidget(self.bar)
|
|
lay.addStretch()
|
|
self.stack.addWidget(w)
|
|
|
|
def _build_done(self):
|
|
w = QWidget(); lay = QVBoxLayout(w); lay.setContentsMargins(40,40,40,20); lay.setSpacing(14)
|
|
t = QLabel("安装完成"); t.setStyleSheet("font-size:26px;font-weight:800;color:#107c10;"); t.setAlignment(Qt.AlignCenter)
|
|
lay.addWidget(t)
|
|
d = QLabel(f"{APP_NAME} {VERSION} 已成功安装到你选择的目录。\n首次启动会引导你配置班级名单和课文。")
|
|
d.setAlignment(Qt.AlignCenter); d.setStyleSheet("color:#555;font-size:14px;"); lay.addWidget(d)
|
|
lay.addStretch()
|
|
bar = QHBoxLayout(); bar.addStretch()
|
|
run = QPushButton("立即启动"); run.setStyleSheet("QPushButton{background:#0067c0;color:white;border:none;border-radius:6px;padding:10px 28px;font-weight:700;}")
|
|
run.clicked.connect(self._launch)
|
|
close = QPushButton("退出"); close.setStyleSheet("background:white;color:#333;border:1px solid #ccc;border-radius:6px;padding:10px 24px;")
|
|
close.clicked.connect(self.close)
|
|
bar.addWidget(close); bar.addSpacing(10); bar.addWidget(run)
|
|
lay.addLayout(bar)
|
|
self.stack.addWidget(w)
|
|
|
|
def _start(self):
|
|
self.target = self.path_edit.text().strip()
|
|
if not self.target:
|
|
self.target = os.path.join(os.path.expanduser("~"), "A13Rollcall")
|
|
self.target = os.path.abspath(os.path.normpath(self.target))
|
|
self.stack.setCurrentIndex(2)
|
|
QTimer.singleShot(200, self._extract)
|
|
|
|
def _safe_extract(self, zf, member, dest):
|
|
name = member.filename.replace("\\", "/")
|
|
while name.startswith("/"):
|
|
name = name[1:]
|
|
parts = [p for p in name.split("/") if p and p not in (".", "..")]
|
|
safe = os.path.join(dest, *parts)
|
|
if not os.path.abspath(safe).startswith(os.path.abspath(dest)):
|
|
raise RuntimeError(f"非法路径:{member.filename}")
|
|
if member.is_dir():
|
|
os.makedirs(safe, exist_ok=True)
|
|
else:
|
|
os.makedirs(os.path.dirname(safe), exist_ok=True)
|
|
with zf.open(member) as src, open(safe, "wb") as out:
|
|
shutil.copyfileobj(src, out)
|
|
|
|
def _extract(self):
|
|
try:
|
|
if not os.path.exists(PAYLOAD):
|
|
self.status.setText("错误:找不到 payload.zip")
|
|
return
|
|
os.makedirs(self.target, exist_ok=True)
|
|
with zipfile.ZipFile(PAYLOAD) as zf:
|
|
names = zf.namelist()
|
|
total = len(names)
|
|
for i, name in enumerate(names):
|
|
self._safe_extract(zf, zf.getinfo(name), self.target)
|
|
self.bar.setValue(int((i+1)/total*95))
|
|
self.status.setText(f"正在释放文件:{os.path.basename(name)}")
|
|
QApplication.processEvents()
|
|
self._create_shortcut()
|
|
self._mark_wizard_pending()
|
|
self.bar.setValue(100)
|
|
self.status.setText("完成!")
|
|
self.stack.setCurrentIndex(3)
|
|
except PermissionError:
|
|
self.status.setText("安装失败:没有该目录的写入权限,请更换安装目录(如 D 盘或桌面)后重试。")
|
|
except Exception as e:
|
|
self.status.setText(f"安装失败:{e}")
|
|
traceback.print_exc()
|
|
|
|
def _mark_wizard_pending(self):
|
|
try:
|
|
import configparser
|
|
ini = os.path.join(self.target, "wizard_record.ini")
|
|
cfg = configparser.ConfigParser()
|
|
cfg.read(ini, encoding="utf-8-sig")
|
|
if not cfg.has_section("status"):
|
|
cfg["status"] = {}
|
|
cfg["status"]["finished"] = "False"
|
|
if not cfg.has_section("app"):
|
|
cfg["app"] = {}
|
|
if not cfg.has_option("app", "class_name"):
|
|
cfg["app"]["class_name"] = "A13"
|
|
with open(ini, "w", encoding="utf-8") as f:
|
|
cfg.write(f)
|
|
except Exception:
|
|
pass
|
|
|
|
def _create_shortcut(self):
|
|
try:
|
|
exe = os.path.join(self.target, EXE_NAME)
|
|
desktop = os.path.join(os.path.expanduser("~"), "Desktop")
|
|
lnk = os.path.join(desktop, f"{APP_NAME}.lnk")
|
|
try:
|
|
import win32com.client
|
|
shell = win32com.client.Dispatch("WScript.Shell")
|
|
s = shell.CreateShortCut(lnk)
|
|
s.Targetpath = exe
|
|
s.WorkingDirectory = self.target
|
|
s.IconLocation = exe
|
|
s.save()
|
|
except Exception:
|
|
shutil.copy2(exe, os.path.join(desktop, f"{APP_NAME}.exe"))
|
|
except Exception:
|
|
pass
|
|
|
|
def _launch(self):
|
|
try:
|
|
os.startfile(os.path.join(self.target, EXE_NAME))
|
|
except Exception as e:
|
|
self.status.setText(str(e))
|
|
self.close()
|
|
|
|
|
|
def main():
|
|
app = QApplication(sys.argv)
|
|
app.setFont(QFont("Microsoft YaHei", 10))
|
|
w = Installer()
|
|
w.show()
|
|
sys.exit(app.exec_())
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|