diff --git a/module/picker/schema.py b/module/picker/schema.py index 227e314..0bff17a 100644 --- a/module/picker/schema.py +++ b/module/picker/schema.py @@ -1,3 +1,4 @@ +import random from typing import Optional from openpyxl.reader.excel import load_workbook @@ -8,8 +9,9 @@ from openpyxl.worksheet.worksheet import Worksheet class PickerStudent: total_time: int = 0 - def __init__(self, name: str, position: int, scores: list[int]): + def __init__(self, name: str, so: str, position: int, scores: list[int]): self._name = name + self._so = so self._position = position self._scores = scores self._modify = False @@ -43,6 +45,24 @@ class PickerStudent: self._scores[index] = new_score self.modified() + @staticmethod + def pick(student: list['PickerStudent']) -> Optional['PickerStudent']: + filtered = [item for item in student if item.weight != 100 and item.weight > 0] + if not filtered: + return None + + # 计算倒数权重 + weights = [1 / item.weight for item in filtered] + total = sum(weights) + r = random.uniform(0, total) + + cumulative = 0 + for item, w in zip(filtered, weights): + cumulative += w + if r <= cumulative: + return item + return None + @classmethod def set_total_time(cls, total_time: int): cls.total_time = total_time @@ -63,6 +83,14 @@ class PickerStudent: def weight(self) -> int: return int(sum(1 for x in self._scores if x != 0) / self.total_time * 100) + @property + def name(self) -> str: + return self._name + + @property + def so(self) -> str: + return self._so + def saved(self): self._modify = False @@ -80,7 +108,8 @@ class PickerExcel: wb: Optional[Workbook] ws: Optional[Worksheet] path: str = '' - max_time_position = 'L1' + max_time_position = 'M1' + start_row = 5 def __init__(self, path: str): self.open(path) @@ -88,7 +117,7 @@ class PickerExcel: @classmethod def open(cls, path: str): cls.path = path - cls.wb = load_workbook(path) + cls.wb = load_workbook(path, keep_vba=True) cls.ws = cls.wb.active PickerStudent.set_total_time(cls.ws[cls.max_time_position].value) @@ -99,13 +128,40 @@ class PickerExcel: if cls.wb and cls.ws: ret = [] - for row in cls.ws.iter_rows(min_row=4, max_col=4 + cls.ws[cls.max_time_position].value, values_only=True): + for index, row in enumerate(cls.ws.iter_rows( + min_row=cls.start_row, + max_col=4 + cls.ws[cls.max_time_position].value, + values_only=True) + ): if (name := row[2]) is None: break - ret.append(PickerStudent(name, int(row[0]) + 3, row[4:])) + ret.append(PickerStudent(name, row[1], cls.start_row + index, row[4:])) return ret - else: - raise Exception('No Workbook or Worksheet') + raise Exception('No Workbook or Worksheet') + + @classmethod + def read_total_time(cls, path: Optional[str] = None) -> int: + if path: + cls.open(path) + + if cls.wb and cls.ws: + val = cls.ws[cls.max_time_position].value + if isinstance(val, int): + return val + raise Exception(f'总次数读取错误,期待类型 {type(1)} 但实际为 {type(val)}\n' + f'可能的解决方法:\n' + f'1、确保总次数位于 \'{cls.max_time_position}\' 单元格;\n' + f'2、确保选择了正确的 Excel 文件。') + raise Exception('No Workbook or Worksheet') + + @classmethod + def save_total_time(cls, path: Optional[str] = None, value: Optional[int] = None) -> None: + if path: + cls.open(path) + + if cls.wb and cls.ws and value: + cls.ws[cls.max_time_position].value = value + cls.save() @classmethod def write_back(cls, student: PickerStudent): @@ -116,6 +172,7 @@ class PickerExcel: cls.ws.cell(row=row, column=start_col + i, value=val if val != 0 else '') student.saved() + cls.save() @classmethod def write_all(cls, students: list[PickerStudent]): diff --git a/ui/components/__init__.py b/ui/components/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ui/components/widget.py b/ui/components/widget.py index 745e11e..61e20c0 100644 --- a/ui/components/widget.py +++ b/ui/components/widget.py @@ -1,4 +1,11 @@ -from PySide6.QtWidgets import QFrame +from PySide6.QtWidgets import QApplication, QWidget, QLabel, QPushButton, QVBoxLayout, QFrame +from PySide6.QtCore import QTimer, Qt, Signal +import sys +import random + +from qfluentwidgets import DisplayLabel + +from module.picker.schema import PickerStudent class Widget(QFrame): @@ -7,3 +14,44 @@ class Widget(QFrame): super().__init__(parent=parent) # 必须给子界面设置全局唯一的对象名 self.setObjectName(key.replace(' ', '-')) + + +class RollingTextWidget(QWidget): + finishSignal = Signal() + + def __init__(self, parent=None): + super().__init__(parent) + self.current_index = 0 + self.items = [] + + self.label = DisplayLabel("", self) + self.label.setAlignment(Qt.AlignCenter) + + self.layout = QVBoxLayout() + self.layout.addWidget(self.label) + self.setLayout(self.layout) + + self.rolling_timer = QTimer(self) + self.rolling_timer.setInterval(50) # 滚动速度(毫秒) + self.rolling_timer.timeout.connect(self.update_text) + + self.stop_timer = QTimer(self) + self.stop_timer.setSingleShot(True) + self.stop_timer.timeout.connect(self.stop_rolling) + + def update_text(self): + # 每次显示下一个字符 + self.current_index = (self.current_index + 1) % len(self.items) + self.label.setText(self.items[self.current_index].name) + + def start_rolling(self): + if not self.rolling_timer.isActive(): + self.rolling_timer.start() + self.stop_timer.start(2000) # 2秒后停止滚动 + + def stop_rolling(self): + self.rolling_timer.stop() + self.finishSignal.emit() + + def set_items(self, items: list[PickerStudent]): + self.items = items diff --git a/ui/components/window.py b/ui/components/window.py new file mode 100644 index 0000000..7208ad4 --- /dev/null +++ b/ui/components/window.py @@ -0,0 +1,16 @@ +from PySide6.QtWidgets import QHBoxLayout +from qfluentwidgets import FluentTitleBar +from qfluentwidgets.window.fluent_window import FluentWindowBase + + +class MyWindow(FluentWindowBase): + def __init__(self, parent=None): + super().__init__(parent) + self.setTitleBar(FluentTitleBar(self)) + + self.widgetLayout = QHBoxLayout(self) + self.widgetLayout.setContentsMargins(0, 48, 0, 0) + self.hBoxLayout.addLayout(self.widgetLayout) + + self.titleBar.raise_() + self.showMaximized() diff --git a/ui/main.py b/ui/main.py index 213015c..d60ccb3 100644 --- a/ui/main.py +++ b/ui/main.py @@ -20,21 +20,22 @@ class MainWindow(MSFluentWindow): self.achievementInterface = AchievementWidget('Achievement Interface', self) self.defenseInterface = DefenseWidget('Defense Interface', self) self.aboutInterface = AboutWidget('About Interface', self) + self.pickerInterface = PickerWidget('Picker Interface', self) if not DEVELOPMENT_ENV: - self.pickerInterface = PickerWidget('Picker Interface', self) self.testInterface = TestWidget('Test Interface', self) self.achievementInterface.error.connect(self.showError) self.defenseInterface.errorSignal.connect(self.showError) + self.pickerInterface.errorSignal.connect(self.showError) self.initNavigation() self.initWindow() def initNavigation(self): self.addSubInterface(self.achievementInterface, FluentIcon.SPEED_HIGH, '达成度') - self.addSubInterface(self.defenseInterface, FluentIcon.FEEDBACK, '答辩') + self.addSubInterface(self.defenseInterface, FluentIcon.FEEDBACK, '答辩题目') + self.addSubInterface(self.pickerInterface, FluentIcon.PEOPLE, '提问') if not DEVELOPMENT_ENV: - self.addSubInterface(self.pickerInterface, FluentIcon.PEOPLE, '抽答') self.addSubInterface(self.testInterface, FluentIcon.VIEW, '测试') self.addSubInterface(self.aboutInterface, FluentIcon.INFO, '关于', position=NavigationItemPosition.BOTTOM) @@ -45,7 +46,10 @@ class MainWindow(MSFluentWindow): self.setWindowIcon(QIcon(':/images/logo.png')) def showError(self, title: str, message: str): - MessageBox(title, message, self).exec() + box = MessageBox(title, message, self) + box.yesButton.setText("关闭") + box.cancelButton.hide() + box.exec() def showEvent(self, event: QShowEvent): super().showEvent(event) diff --git a/ui/pyui/picker_ui.py b/ui/pyui/picker_ui.py index da42493..ae5c20d 100644 --- a/ui/pyui/picker_ui.py +++ b/ui/pyui/picker_ui.py @@ -1,27 +1,132 @@ -from PySide6.QtCore import Qt -from PySide6.QtWidgets import QVBoxLayout, QHBoxLayout -from qfluentwidgets import GroupHeaderCardWidget, PushButton, FluentIcon, PrimaryPushButton, IconWidget, BodyLabel +from PySide6.QtCore import Qt, Signal, QTimer +from PySide6.QtWidgets import QVBoxLayout, QHBoxLayout, QWidget, QStackedWidget, QGridLayout, QFileDialog +from qfluentwidgets import GroupHeaderCardWidget, PushButton, FluentIcon, PrimaryPushButton, IconWidget, BodyLabel, \ + SegmentedWidget, SpinBox, LargeTitleLabel, DisplayLabel, SubtitleLabel +from module.picker.schema import PickerExcel, PickerStudent +from ui.components.widget import RollingTextWidget from ui import MAIN_THEME_COLOR from ui.components.widget import Widget +from ui.components.window import MyWindow -class PickerWidget(Widget): - def __init__(self, key: str, parent=None): - super().__init__(key, parent) +class PSMMain(MyWindow): + finishSignal = Signal() + + def __init__(self, student: PickerStudent, parent=None): + super().__init__(parent) + + self.student = student + self.grade_edit = SpinBox(self) + self.init_layout() + + def init_layout(self): + main_layout = QVBoxLayout() + + hLayout = QHBoxLayout() + name_layout = QVBoxLayout() + date_layout = QVBoxLayout() + hLayout.addStretch() + hLayout.addLayout(name_layout) + hLayout.addLayout(date_layout) + hLayout.addStretch() + hLayout.setSpacing(20) + + so = LargeTitleLabel(f"{self.student.so}", self) + so.setAlignment(Qt.AlignCenter) + + sname = DisplayLabel( + f"{self.student.name[0] + ' ' + self.student.name[1] if len(self.student.name) == 2 else self.student.name}", + self) + sname.setAlignment(Qt.AlignCenter) + + name_layout.addWidget(so) + name_layout.addWidget(sname) + + self.grade_edit.setFixedWidth(260) + self.grade_edit.setRange(0, 100) + grade_layout = QHBoxLayout() + grade_layout.setContentsMargins(0, 0, 0, 48) + grade_layout.setSpacing(20) + submit_button = PrimaryPushButton("确定", self) + cancel_button = PushButton("重置", self) + submit_button.clicked.connect(self.close_modal) + cancel_button.clicked.connect(lambda: self.grade_edit.clear()) + submit_button.setFixedWidth(120) + cancel_button.setFixedWidth(120) + grade_layout.addWidget(submit_button) + grade_layout.addWidget(cancel_button) + date_layout.addWidget(self.grade_edit) + date_layout.addLayout(grade_layout) + + main_layout.addStretch() + main_layout.addLayout(hLayout) + main_layout.addStretch() + + # 快速打分 + quick_grade_layout = QVBoxLayout() + quick_grade_layout.setContentsMargins(0, 0, 0, 0) + quick_grade_layout.setSpacing(20) + + quick_grade_title = SubtitleLabel("快速打分", self) + quick_grade_title.setAlignment(Qt.AlignCenter) + + quick_grade_layout.addWidget(quick_grade_title) + + grid_wrapper_layout = QHBoxLayout() + grid_layout = QGridLayout() + buttons = [0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100] + + for i, num in enumerate(buttons): + row = i // 3 + col = i % 3 + btn = PushButton(str(num)) + btn.setFixedSize(80, 80) + # 使用lambda绑定事件 + btn.clicked.connect(lambda _, n=num: self.grade_edit.setValue(n)) + grid_layout.addWidget(btn, row, col) + + grid_wrapper_layout.addStretch() + grid_wrapper_layout.addLayout(grid_layout) + grid_wrapper_layout.addStretch() + + quick_grade_layout.addLayout(grid_layout) + quick_grade_layout.addLayout(grid_wrapper_layout) + + main_layout.addLayout(quick_grade_layout) + self.widgetLayout.addLayout(main_layout) + + def close_modal(self): + self.student.append_score(self.grade_edit.value()) + self.finishSignal.emit() + self.close() + + +class PickStudentMode(QWidget): + errorSignal = Signal(str) + + def __init__(self, parent=None): + super().__init__(parent) self.card = GroupHeaderCardWidget(self) self.vbox = QVBoxLayout(self) + self.vbox.setContentsMargins(0, 0, 0, 0) + self.chooseBtn = PushButton("打开") - self.startButton = PrimaryPushButton(FluentIcon.PLAY_SOLID, "抽签") + self.startButton = PrimaryPushButton(FluentIcon.PLAY_SOLID, "开始") self.bottomLayout = QHBoxLayout() self.hintIcon = IconWidget(FluentIcon.INFO.icon(color=MAIN_THEME_COLOR)) - self.hintLabel = BodyLabel("点击抽签按钮以开始抽签 👉") + self.hintLabel = BodyLabel("点击开始按钮以开始抽签 👉") + self.spinbox = SpinBox() + self.rollingText = RollingTextWidget(self) self.card.setTitle("输入选项") self.chooseBtn.setFixedWidth(120) self.startButton.setFixedWidth(120) self.startButton.setEnabled(False) + self.spinbox.setRange(0, 6) + self.spinbox.setFixedWidth(120) + self.spinbox.setEnabled(False) self.hintIcon.setFixedSize(16, 16) self.hintIcon.autoFillBackground() @@ -33,9 +138,111 @@ class PickerWidget(Widget): self.bottomLayout.addWidget(self.startButton, 0, Qt.AlignRight) self.bottomLayout.setAlignment(Qt.AlignVCenter) - self.group = self.card.addGroup(FluentIcon.DOCUMENT, "抽答名单", "选择抽答名单", self.chooseBtn) - self.group.setSeparatorVisible(True) + self.group = self.card.addGroup(FluentIcon.DOCUMENT, "提问名单", "选择提问名单", self.chooseBtn) + self.spinGroup = self.card.addGroup(FluentIcon.SETTING, "提问次数", "设置提问的最大次数", self.spinbox) + self.spinGroup.setSeparatorVisible(True) self.card.vBoxLayout.addLayout(self.bottomLayout) self.vbox.addWidget(self.card) + self.vbox.addWidget(self.rollingText) self.vbox.addStretch(1) + + # ============================== + self.chooseBtn.clicked.connect(self.choose_file) + self.spinbox.valueChanged.connect(lambda: PickerExcel.save_total_time(value=self.spinbox.value())) + self.startButton.clicked.connect(self.start_rolling) + self.rollingText.finishSignal.connect(self.finish_rolling) + # ============================== + self.filepath = "" + self.students = [] + + def choose_file(self): + file_path, _ = QFileDialog.getOpenFileName(self, "选择文件", "", "Excel 文件 (*.xlsm);") + if file_path: + self.group.setContent("已选择文件:" + file_path) + self.filepath = file_path + self.startButton.setEnabled(True) + self.init_spinbox_value() + + def init_spinbox_value(self): + if not self.filepath: + return + + try: + PickerExcel.open(self.filepath) + self.spinbox.setValue(PickerExcel.read_total_time()) + self.spinbox.setEnabled(True) + except Exception as e: + self.errorSignal.emit(str(e)) + self.spinbox.setEnabled(False) + self.startButton.setEnabled(False) + + def start_rolling(self): + self.students = PickerExcel.read_student() + self.rollingText.set_items(self.students) + self.rollingText.start_rolling() + self.startButton.setEnabled(False) + + def finish_rolling(self): + stu = PickerStudent.pick(self.students) + if not (stu.so and stu.name): + self.errorSignal.emit("学生信息读取失败") + self.rollingText.label.setText(stu.name) + + timer = QTimer(self) + timer.setSingleShot(True) + timer.timeout.connect(lambda: self.show_screen(stu)) + timer.timeout.connect(lambda: self.startButton.setEnabled(True)) + timer.start(1000) + + def show_screen(self, stu: PickerStudent): + screen = PSMMain(stu) + screen.finishSignal.connect(lambda: PickerExcel.write_back(stu)) + screen.finishSignal.connect(lambda: self.rollingText.label.clear()) + + +class PickQuestionMode(QWidget): + def __init__(self, parent=None): + super().__init__(parent) + + lable = DisplayLabel('🚧', self) + lable.setAlignment(Qt.AlignCenter) + self.layout = QHBoxLayout(self) + self.layout.addStretch() + self.layout.addWidget(lable) + self.layout.addStretch() + + +class PickerWidget(Widget): + errorSignal = Signal(str, str) + + def __init__(self, key: str, parent=None): + super().__init__(key, parent) + + self.vbox = QVBoxLayout(self) + self.menu = SegmentedWidget(self) + self.stack = QStackedWidget(self) + + self.psm = PickStudentMode(self) + self.pqm = PickQuestionMode(self) + + self.add_sub_interface(self.psm, "pickStudentMode", "选人模式") + self.add_sub_interface(self.pqm, "pickQuestionMode", "选题模式") + self.menu.setCurrentItem("pickStudentMode") + + self.vbox.addWidget(self.menu) + self.vbox.addWidget(self.stack) + self.vbox.addStretch(1) + + # =========================== + self.stack.currentChanged.connect(self.on_stack_index_changed) + self.psm.errorSignal.connect(lambda n: self.errorSignal.emit("😢 不好出错了", n)) + + def add_sub_interface(self, widget: QWidget, obj_name: str, text: str): + widget.setObjectName(obj_name) + self.stack.addWidget(widget) + self.menu.addItem(routeKey=obj_name, text=text, onClick=lambda: self.stack.setCurrentWidget(widget)) + + def on_stack_index_changed(self, index: int): + widget = self.stack.widget(index) + self.stack.setCurrentWidget(widget)