/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */
#content_views .toc,
/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */
#content_views.markdown_views > p:empty:has(+ .toc),
#content_views.markdown_views > .toc + p:empty,
/* 富文本旧版目录标记 */
#content_views.htmledit_views #main-toc,
#content_views.htmledit_views #hr-toc,
#content_views.htmledit_views p[id*=\”-toc\”] {
display: none !important;
}
/* 目录去掉后,紧跟的首个标题不再多出一块上边距 */
#content_views.markdown_views > .toc + h1,
#content_views.markdown_views > .toc + h2,
#content_views.markdown_views > .toc + h3,
#content_views.markdown_views > .toc + h4,
#content_views.markdown_views > .toc + p:empty + h1,
#content_views.markdown_views > .toc + p:empty + h2,
#content_views.markdown_views > .toc + p:empty + h3,
#content_views.markdown_views > .toc + p:empty + h4 {
margin-top: 0 !important;
}
学生/题目选择弹窗:两个选择器,一套"列表-选择-回传"契约
一句话摘要:StudentSelectDialog(搜索 + 班级分组树 + 双击确认)与 QuestionSelectDialog(学科筛选 + 可排序表格 + 右侧 LaTeX 实时预览)都是"模态弹窗 + 属性回传选中 id"的通用选择器,一个按"班"组织、一个按"学科/难度"筛选,覆盖跨页选择实体的高频需求。
一、引言
从第一性原理出发,"选择实体"是桌面应用里最高频的跨页操作:看板要选学生、报告要选学生、题目流水线要选题目、变式要关联原题……如果每个页面各自实现一套选择 UI,会出现 N 个互不兼容的变体:有的没搜索、有的没分组、有的交互不一致——用户每次都要重新学习。
公共选择弹窗的价值正在于此:把"列表展示 + 搜索过滤 + 确认回传"这一整套交互收敛成一次实现,所有页面复用。本文的两个主角就是这套模式的两个实例,它们共享同一个骨架:
模态 QDialog
├── 搜索/筛选区
├── 列表区(StudentSelectDialog 用分组树 / QuestionSelectDialog 用平铺表格)
├── 提示 + 取消/确认按钮
└── 选中结果经属性回传:selected_student_id / selected_question_id
差异只发生在"列表区"的形态上——而形态差异的根源是实体的组织维度不同:学生的天然维度是"班级"(分组树),题目的天然维度是"学科/难度/来源"(可排序表格)。本文对比拆解两个弹窗,重点看选择器模式的公共契约与各自特色。
二、核心原理
2.1 公共契约:模态 + 属性回传
两个弹窗都遵循同一套调用协议:
选择弹窗调用方页面选择弹窗调用方页面#mermaid-svg-3yyonQwwq1hjaCAm{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-3yyonQwwq1hjaCAm .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-3yyonQwwq1hjaCAm .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-3yyonQwwq1hjaCAm .error-icon{fill:#552222;}#mermaid-svg-3yyonQwwq1hjaCAm .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-3yyonQwwq1hjaCAm .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-3yyonQwwq1hjaCAm .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-3yyonQwwq1hjaCAm .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-3yyonQwwq1hjaCAm .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-3yyonQwwq1hjaCAm .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-3yyonQwwq1hjaCAm .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-3yyonQwwq1hjaCAm .marker{fill:#333333;stroke:#333333;}#mermaid-svg-3yyonQwwq1hjaCAm .marker.cross{stroke:#333333;}#mermaid-svg-3yyonQwwq1hjaCAm svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-3yyonQwwq1hjaCAm p{margin:0;}#mermaid-svg-3yyonQwwq1hjaCAm .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-3yyonQwwq1hjaCAm text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-3yyonQwwq1hjaCAm .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-3yyonQwwq1hjaCAm .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-3yyonQwwq1hjaCAm .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-3yyonQwwq1hjaCAm .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-3yyonQwwq1hjaCAm #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-3yyonQwwq1hjaCAm .sequenceNumber{fill:white;}#mermaid-svg-3yyonQwwq1hjaCAm #sequencenumber{fill:#333;}#mermaid-svg-3yyonQwwq1hjaCAm #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-3yyonQwwq1hjaCAm .messageText{fill:#333;stroke:none;}#mermaid-svg-3yyonQwwq1hjaCAm .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-3yyonQwwq1hjaCAm .labelText,#mermaid-svg-3yyonQwwq1hjaCAm .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-3yyonQwwq1hjaCAm .loopText,#mermaid-svg-3yyonQwwq1hjaCAm .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-3yyonQwwq1hjaCAm .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-3yyonQwwq1hjaCAm .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-3yyonQwwq1hjaCAm .noteText,#mermaid-svg-3yyonQwwq1hjaCAm .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-3yyonQwwq1hjaCAm .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-3yyonQwwq1hjaCAm .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-3yyonQwwq1hjaCAm .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-3yyonQwwq1hjaCAm .actorPopupMenu{position:absolute;}#mermaid-svg-3yyonQwwq1hjaCAm .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-3yyonQwwq1hjaCAm .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-3yyonQwwq1hjaCAm .actor-man circle,#mermaid-svg-3yyonQwwq1hjaCAm line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-3yyonQwwq1hjaCAm :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}用户搜索/浏览/双击或点确认dialog = XxxSelectDialog(实体列表, parent)if dialog.exec() == QDialog.AcceptedAccepteddialog.selected_student_id / selected_question_id用 id 继续业务(加载数据/生成报告…)
选中的 id 通过 @property 属性回传(selected_student_id、selected_question_id),而不是 exec() 的返回值——因为返回值的语义是"Accepted/Rejected"(结果码),具体选了谁属于"业务数据",用属性读取更清晰,且与"先接受、后取数"的时序天然契合。
2.2 分组树 vs 平铺表格:形态由"组织维度"决定
| 自然组织维度 | 班级(少量分组,每组多人) | 学科/难度/来源(大量条目) |
| 列表形态 | QTreeWidget 分组树 | QTableWidget 平铺表格 |
| 辅助筛选 | 关键词(姓名/班级/年级/学校) | 学科下拉 + 关键词 + 表头排序 |
| 额外能力 | — | 右侧 LaTeX 实时预览 |
学生的组织单位小(几个班、每班若干人),树形分组 + 双击确认就够了;题目的集合大(几十上百道)且每道题都是"独立个体",需要可排序表格 + 预览来精确定位。
2.3 过滤策略对比
- 学生弹窗:textChanged → 关键词匹配四字段 → 整树重建(_populate_tree)。因为树要同步维护分组结构,重建是最直接正确的做法;
- 题目弹窗:学科 + 关键词叠加过滤(AND 关系)→ _populate_table 重建表格行。空关键词、空学科时退化为不过滤。
2.4 双击即确认:最少点击路径
两个弹窗都支持"双击列表项直接 accept"——把"选中 + 点确认"两步合成一步,且不破坏"点确认"路径(兼容鼠标党)。学生弹窗双击学生行、题目弹窗双击表格行,都直接 accept()。
三、代码实现
3.1 学生弹窗:班级分组构建
def _build_group_items(self, students: List[Student]) –> List[QTreeWidgetItem]:
"""按班级分组构建顶层项和学生子项,返回排序后的顶层项列表"""
groups = defaultdict(list)
for s in students:
key = s.class_name or ""
groups[key].append(s)
# 排序:有班级名称的按名称排序,未分配班级的放最后
class_names = sorted(k for k in groups if k)
if "" in groups:
class_names.append("")
result = []
for cn in class_names:
members = groups[cn]
label = f"📚 {cn}({len(members)}人)" if cn else "(未分配班级)"
group_item = QTreeWidgetItem()
group_item.setText(self.COL_NAME, label)
group_item.setFlags(group_item.flags() & ~Qt.ItemIsSelectable)
...
# 学生子项按年级→姓名排序
members.sort(key=lambda x: (x.grade or "", x.name or ""))
for idx, s in enumerate(members, 1):
child = QTreeWidgetItem()
child.setText(self.COL_IDX, str(idx))
child.setText(self.COL_NAME, s.name or "")
child.setText(self.COL_GRADE, s.grade or "")
child.setText(self.COL_CLASS, s.class_name or "")
child.setText(self.COL_SCHOOL, s.school or "")
child.setData(self.COL_NAME, Qt.UserRole, s.id)
group_item.addChild(child)
result.append(group_item)
return result
源码位置:ui/components/common/student_select_dialog.py L128-L172
细节:
- defaultdict(list):按 class_name 聚合学生,“未分配班级”(空串)也是合法 key;
- 班级名排序 + 空班级垫底:sorted(k for k in groups if k) 后把 "" append 到最后,与 51 篇的"未分配放最后"策略一致;
- 分组项不可选:去掉 ItemIsSelectable,双击分组项不会误触选择;
- 子项序号从 1 开始:每组内学生按"年级→姓名"排序后编号,方便口头交流(“第三个”)。
3.2 学生弹窗:搜索与确认
def _on_search(self):
"""搜索框文本变化 → 过滤"""
keyword = self.search_edit.text().strip().lower()
if not keyword:
self._populate_tree(self._students)
return
filtered = [
s for s in self._students
if keyword in (s.name or "").lower()
or keyword in (s.grade or "").lower()
or keyword in (s.class_name or "").lower()
or keyword in (s.school or "").lower()
]
self._populate_tree(filtered)
def _on_item_double_clicked(self, item: QTreeWidgetItem, column: int):
"""双击学生行 → 确认选择"""
if not item.parent():
return # 分组项不可选
sid = item.data(self.COL_NAME, Qt.UserRole)
if sid:
self._selected_student_id = sid
self.accept()
@property
def selected_student_id(self) –> Optional[str]:
return self._selected_student_id
源码位置:ui/components/common/student_select_dialog.py L185-L229
三个关键防御:
- item.parent() 判断:只有学生子项有 parent(分组项是顶层),双击分组项直接 return——分组永远不是合法选择目标;
- 全小写匹配:keyword.lower() 与四个字段的 lower 比较,搜索大小写不敏感;
- 空关键词恢复全量:搜索框清空时 _populate_tree(self._students) 回到完整列表,而不是停留在过滤态。
3.3 题目弹窗:叠加过滤与表头排序
def _apply_filters(self):
"""学科 + 关键词 叠加过滤表格行"""
keyword = self.search_edit.text().strip().lower()
subject = self.subject_combo.currentData() or ""
filtered = []
for q in self._questions:
q_subject = q.subject or ""
if subject and q_subject != subject:
continue
if keyword and not (
keyword in (q.question_text or "").lower()
or keyword in q_subject.lower()
):
continue
filtered.append(q)
self._populate_table(filtered)
_DIFF_RANK = {"易": 0, "中": 1, "难": 2}
def _sort_rows(self, rows: List[Question]) –> List[Question]:
"""按当前排序列稳定排序;未排序时保持原顺序"""
col = self._sort_col
if col not in (1, 2, 3, 4, 5):
return list(rows)
def key(q: Question):
if col == 1:
return (q.subject or "").lower()
if col == 2:
return self._DIFF_RANK.get(q.difficulty or "", 99)
if col == 3:
return (q.source_type or "").lower()
if col == 4:
return (q.question_text or "").lower()
return q.recorded_at or 0
return sorted(rows, key=key, reverse=not self._sort_asc)
源码位置:ui/components/common/question_select_dialog.py L252-L293
要点:
- AND 叠加:学科不符直接 continue、关键词不中也 continue——两个条件是"且"关系;任一侧为空自动跳过该约束;
- 难度排序用枚举映射:{"易":0, "中":1, "难":2}——“难” > “中” > “易” 是业务定义的序,字典序(“中” > “难”?)是错的。未知名难度映射到 99 排最后;
- 稳定排序:sorted 默认稳定,同 key 的题目保持原始录入顺序,排序不产生"随机抖动";
- 表头点击切换排序方向:_on_header_clicked 里同列再次点击反转 _sort_asc,并用 _apply_header_indicator 在表头文字追加 “↑/↓” 箭头——排序状态可视化。
3.4 题目弹窗:实时预览与居中
def _on_current_row_changed(self, row: int, prev_row: int):
"""表格选中行变化 → 刷新右侧预览"""
if 0 <= row < len(self._row_questions):
self._update_preview(self._row_questions[row])
else:
self._set_preview_placeholder()
def _update_preview(self, q: Question):
"""渲染选中习题:元信息 + 题干(LaTeX)"""
ts = q.recorded_at or 0
date_str = time.strftime("%Y-%m-%d", time.localtime(ts)) if ts else ""
mistake = "是" if q.is_mistake else "否"
meta = (
f"学科:{q.subject or '-'} | 难度:{q.difficulty or '-'} | 来源:{q.source_type or '-'}\\n"
f"录入时间:{date_str or '-'} | 错题:{mistake}"
)
self._meta_label.setText(meta)
self._viewer.set_text(q.question_text or "(空题)")
源码位置:ui/components/common/question_select_dialog.py L326-L343
表格选中行变化(currentCellChanged)→ 右侧预览面板刷新:元信息(学科/难度/来源/时间/是否错题)进 _meta_label,题干文本交给 LaTeXViewer(第 82 篇详述的公式渲染组件)渲染。**“列表定位 + 预览确认”**让老师在不打开题目的情况下就能判断"是不是这道题"。
另一个细节是 showEvent 里的手动居中:
def showEvent(self, event):
"""首次显示时对父窗口居中(QWebEngineView 会跳过默认居中);叠加暗色标题栏"""
super().showEvent(event)
if not self._centered:
self._centered = True
if self.parent():
target = self.parent().window().frameGeometry().center()
else:
target = QGuiApplication.primaryScreen().availableGeometry().center()
geo = self.frameGeometry()
geo.moveCenter(target)
self.move(geo.topLeft())
源码位置:ui/components/common/question_select_dialog.py L33-L44
弹窗内含 LaTeXViewer(基于 QWebEngineView),而 Qt 对含 WebEngine 的窗口默认居中机制会被跳过,弹窗可能出现在屏幕角落。这里在首次 showEvent 手动把窗口移到父窗口中心,用 _centered 标志保证只执行一次。
四、设计权衡 / 踩坑记录
4.1 为什么用属性回传而不是 exec() 返回值?
exec() 的返回语义是结果码(Accepted/Rejected),塞 id 进去会污染语义;属性读取则把"选择结果"作为对话框的公开状态暴露。调用方流程是"先判 Accepted,再读属性",与 Qt 习惯一致。若未来需要"多选",改成 selected_ids: List[str] 属性即可,调用协议不变——属性的扩展性优于返回值。
4.2 为什么两个弹窗形态不同却共享"选择器"心智?
虽然一个是树、一个是表格,但两者对外契约完全一致(构造传列表 → exec → 属性取 id),页面层可以无差别消费。这是"内部实现自由、对外契约稳定"的封装收益——未来学生弹窗想升级成"带头像卡片",题目弹窗想加"按错题/正确题筛选",都只改内部,调用方零改动。
4.3 坑:QWebEngineView 弹窗不居中
含 WebEngine 的 QDialog 首次显示位置异常是 Qt 的已知现象。手动 showEvent 居中时注意:必须用 frameGeometry().center() 而不是 geometry()(frame 含边框,居中才准确),且用 _centered 标志防止每次显示都"跳回中心"。
4.4 坑:难度字符串的字典序陷阱
题目难度存的是 “易/中/难” 字符串。若直接按字符串排序,“中” 和 “难” 的顺序在 Unicode 里是"中 < 难"(恰好对),但"易"排在"中"之前——字典序与业务序毫无关系。_DIFF_RANK 映射是唯一正确做法,未知名难度归 99 兜底,保证脏数据不破坏排序稳定性。
4.5 双击确认与单选确认并存
两条路径都收敛到同一个结果:双击路径直接 accept();确认按钮路径先校验 selectedRows() 非空再 accept。两条路径共用 _selected_student_id/_selected_question_id 属性,不会出现"一个路径改了状态另一个不知道"的分叉。
五、小结
两个选择弹窗合起来演示了"选择器"这一跨页通用能力的完整设计:
网硕互联帮助中心



评论前必须登录!
注册