不想排座位

为什么我开发了「不想排座位」?

fuckfuckfuck

老三 · 2026-03-21

缘起:排座这件事,真不是“把学生塞进二维数组”那么简单 很多没做过教育场景开发的人,第一眼看到“排座位”这三个字,下意识都会觉得这能叫个事儿? 不就是搞个名单,画个网格,拿成绩排个序,再不然随机 shuffle 一下不就交差了吗? 但只要你真把这东西扔进班主任和教务老师的日常工作流里,你就会发现,这事儿很快会从一个“随手写的脚本”升级成一出名叫“今天又是谁坐错了”的连续剧。最折磨人的,往往根本不是某一个算法节点,而是整条工作链都在拽你的裤脚: 1. 表格永远是薛定谔的格式:名单通常来自 Excel,但表头千奇百怪。有的列叫“总分”,有的叫“学生总分”,里面还经常混着空列、神秘注释和老师手工捏出来的合并单元格。 2. 教室从来不是标准矩形:真实的课堂里有讲台、有走廊、有空位、有坏掉的桌椅、有临时不用的区域,不同学校的座位布局根本不能一概而论。 3. 规则能引发组合爆炸:排座不可能纯随机。有人非要坐前排,有人死活不能坐某列,有人必须绑定在一起,有人坐在一起绝对会把教室掀了。 4. 排完只是噩梦的开始:排完座,老师还要拖动微调、批量平移、撤销重做、搜人、查到底谁没入座、改小组、存个快照,最后还得导出能直接发群里的打印版。 5. 真正的底线是“能用”:也就是说,导入得稳如老狗,界面不能卡,导出得体面,桌面版也必须有完整正经软件的交付感,不能是个半成品网页。 我做「不想排座位」,本质上就是想把这条折磨人的工作链彻底捋顺、补齐。 所以我做的是一套面向班级管理场景的桌面级工作区,绝不只停在搞个“自动排座”按钮就跑路: - 前端能接得住乱七八糟的数据导入。 - 中间扛得住老师持续的调整、规则修补、分组轮换和快照管理。 - 后端能把结果优雅地导出成 Excel、SVG、PPTX、小组登记表,甚至一整个 .seats 方案文件。 - 天花板再往上抬一抬,还能挂个 AI 工作台和插件系统,让“排座”从一个单调的功能变成可扩展的平台。 也正因如此,仓库里的核心实现远不止一个脚本,而是一整套被逼出来的完整应用。 --- 一、先交个底:它本质上是一套桌面工作区 如果光看页面,你很容易误以为它是个普通 Web 网页;如果只看 runapp.py,你又会觉得它像个桌面程序。它其实有点像两栖动物,平时开发在浏览器里划水,交付的时候就上岸套个桌面壳。 项目主工作区的技术栈极其务实: - 后端:Django - 数据库:SQLite - 本地服务:Waitress - 静态资源:WhiteNoise - 桌面壳:pywebview - 脏活累活处理:pandas、openpyxl、xlrd - 导出:python-pptx - 中文检索辅助:pypinyin - 宣传官网:React 19 + Vite 7 这里面真正有意思的取舍是,我没有为了显得高大上,把“桌面端”和“网页端”拆成两套完全不同的产品,而是走了一条很直接的路线: 1. Django 负责算逻辑、管页面和数据。 2. Waitress 在本机悄悄起一个 127.0.0.1:23948 的服务。 3. 开发模式下,我自己用浏览器直接打开,调试爽得飞起。 4. 生产交付模式下,用 pywebview 把这个本地服务包进一个原生窗口里给老师用。 说白了,就是让浏览器负责貌美如花,让 Python 在本地负责干活。对我来说,这条路线最大的优点不在“新潮”,而在极其合适: - 复杂的业务逻辑可以继续留在 Python 的舒适区里。 - 本地 SQLite 用起来直接、稳定,没有任何服务端环境依赖。 - 桌面壳可以接管系统的保存对话框、剪贴板、右键菜单这些浏览器不擅长的东西。 - 开发时又完美保留了 Django 模板和浏览器调试的高效。 这也是为什么我最后没上 Electron,也没把所有东西重写成 React 单页应用。对这种“本地数据密集、规则密集、导出密集”的桌面工作流来说,现在这套架构反而最顺手。 看看启动入口的代码就知道了,真的没藏什么花活: python def startwaitress(application): serve(application, host=HOST, port=PORT) serverthread = threading.Thread( target=startwaitress, args=(application,), daemon=True, name='fuckseats-waitress', ) serverthread.start() waitforserverready(appurl) if devmode: openbrowser(appurl) else: startdesktopwindow(appurl) 翻译成人话就是:先把本地服务跑起来,再看今天是“浏览器调试模式”,还是“老师直接双击可用模式”。 ----- 二、为什么主工作区死磕 Django 很多人看到现代桌面应用,第一反应都是必须前后端完全分离。但我在这个项目里刻意保留了 Django 模板体系,因为教育场景没必要为了“看起来现代”先给自己叠一层复杂度 buff。 「不想排座位」的日常工作流里,大量交互都死死绑定在一个具体班级上: - 班级详情页 - 布局编辑页 - 导入选项页 - 导出选项页 - AI 工作台 - 插件渲染页 这些页面之间共享的数据模型、上下文、按钮样式和工作区语义非常强。如果为了“前端潮流”把它们全部拆成纯接口 + 纯前端路由,我会凭空引入一堆比如路由同步、鉴权状态、导出文件流处理、桌面桥接通信等破事。 所以我切了一刀,做法更直接: - 主工作区页面老老实实由 Django 直接渲染。 - 需要高频交互的地方,用原生 JavaScript 糊一糊补足体验。 - 需要酷炫动效和独立营销表达的官网,再单独放到 website/ 里用 React + Vite 构建。 这种拆法让我两头占便宜: 1. 主程序保持稳定、轻量、好维护。 2. 宣传站可以自由做动效、分屏展示、挂下载入口,怎么折腾都不影响主工作区。 仓库里这两部分边界切得很清楚。website/ 下的 React 宣传站甚至是通过 public/api.json 动态去拿下载地址的;而真正的业务工作区,依然稳稳地以 Django 模板和本地接口为中心。 ----- 三、桌面壳补上了浏览器做不到的那一截 如果你只把 pywebview 理解成“给网页套一层壳”,那你就忽略了这个项目真正落地时最有价值的部分。难点从来不在跑个 demo 截图,而在那些“老师一上手就会骂娘”的细节上。 真正干活的是 desktopshell.py 里的 DesktopBridge。它做了几件非常实际、极其影响体验的事: 1\. 驯服系统的“另存为”对话框 项目里的 Excel、SVG、PPTX、.seats 导出,走的是完整的桌面保存流程。 桌面模式下,前端会调用桥接脚本,后端从本地 Waitress 服务下载导出内容,再通过原生文件保存对话框交给用户选路径,最后落盘。 这意味着: - 文件名可以更准确地继承后端给的响应头。 - 扩展名绝不会变成奇怪的乱码。 - 保存目录会自动记忆你上一次选的位置。 - 最重要的是,少了“浏览器弹个破下载框”的割裂感。 听着像个小细节,但班主任导出一张座位图后往往还要反复改五六个版本;如果每次都走浏览器默认下载目录,那体验绝对是灾难级的。 2\. 拯救 Windows 的右键菜单 这个点我很喜欢,因为它特别能说明“桌面 WebView 真落地时,坑都在教程之外”。 项目在 runapp.py 里对 Windows 做了额外配置:尝试启用 WebView2 的右键菜单桥接。对应逻辑在 desktopshell.py 里做了两层处理: - 一层是注入脚本,截获页面的 contextmenu 事件。 - 另一层是挂接原生 WebView2 的 ContextMenuRequested,在需要时自己重新画菜单。 解决的痛点很具体: - 文本输入框里,你得能正常复制、粘贴、全选。 - 页面自定义右键菜单绝对不能和系统原生菜单互相打架。 - 某些座位编辑区域的右键,必须优先走页面自身的逻辑。 用户才不管你技术栈多优雅,连个名字都复制不了,这软件就是垃圾。 3\. 开发模式和桌面模式共存 runapp.py 有一个极其朴素但救命的设计:-dev。 - 平时开发,敲 python runapp.py -dev,直接开浏览器。 - 日常交付,敲 python runapp.py,弹桌面外壳。 一套代码,两个外壳,不用维护两份逻辑。 ----- 四、真正的核心:别拿座位不当实体(表关系设计) 一个排座系统想不崩,首先要把数据模型想透。 「不想排座位」的核心模型不复杂,但每个都卡在痛点上: | 模型 | 作用 | | --- | --- | | Classroom | 班级本体,包含行列数,创建时自动生成座位 | | Student | 学生信息,含姓名、学号、性别、成绩 | | Seat | 物理座位,记录行列、单元类型、当前学生、小组归属 | | SeatGroup | 小组信息,包含名称、顺序、组长 | | SeatConstraint | 约束系统,支持指定/禁用座位、行列、相邻关系 | | LayoutSnapshot | 布局快照 | | FutureModeConfig | AI 连接配置 | | AIConversation / AIConversationMessage | AI 多轮对话持久化 | | FrontendKVStore | 前端配置落库 | 这里面我做了一个最关键的决定:我把座位做成了一张真实的数据库表,而不是只把它当成前端 JS 数组里的一个格子。 这步听起来特别土,但它帮我省掉了无数次“页面上看着没问题,数据库里已经乱成一锅粥”的事故。 这带来两个巨大的好处: 1. 学生和座位彻底解耦。你把张三挪到最后一排,本质上只是改了 Seat.student 的关系,张三的档案毫发无损。 2. 座位可以承载更多属性。它既可以是“座位”,也可以是“走廊”“讲台”“空位”,还能挂小组、挂坐标。 这就是为什么后续的布局编辑、小组分配、约束校验全都能围着这一套结构转。 看一眼源码就知道,它的性格很直接: python class Classroom(models.Model): name = models.CharField(maxlength=100, verbosename="班级/教室名称") rows = models.IntegerField(default=6, verbosename="行数") cols = models.IntegerField(default=8, verbosename="列数") def save(self, args, kwargs): isnew = self.pk is None super().save(args, kwargs) if isnew: self.generateseats() 这段代码的意思就是:新班级一建出来,后端就自己把座位网格在库里铺好,别等前端来提醒。稳得很。 ----- 五、比起“一键排座”,老师更需要的是“后悔药” 如果一个产品只提供“一键排座”,那它最多是个半成品。教务场景的真实常态是:不断试、不断改、不断回退。排座更像是在搬教室里的家具,挪了沙发发现挡了电视,挪了电视发现插座不够长。 所以主工作区的重点,全砸在“可持续编辑”的配套能力上: 1\. 班级详情页就是主工作台 templates/seats/classroomdetail.html 用了一个带选项卡的 Ribbon 风格头部,把操作按类别(开始、视图、文件、AI、插件、帮助、班级)收纳得清清楚楚。它像个总控台,而不是一个乱七八糟的表单页。 2\. 空间定义权必须交还给老师 布局编辑页允许你把每个格子改成:座位、走廊、讲台、空位。支持右键和框选批量操作。 这太关键了,真实教室本来就不规则,你得允许老师先定义“物理空间”,再往里塞人。 3\. 给足撤销/重做的安全感 不仅有单人拖拽,还有批量移动、交换、清空。更重要的是,所有这些动作都被收进了历史栈,支持撤销和重做。 没有后悔药,老师就会极其保守,根本不敢去尝试更好的布局方案。 4\. 搜索是高频刚需 searchstudents 不仅搜名字,还支持拼音和首字母。搜“zs”就能定位“张三”。 再配上插件在右下角注的一个悬浮搜索面板,秒高亮座位,班级人一多,这功能就是神。 ----- 六、自动排座:老实巴交的“约束优先 + 贪心 + 自动修补” 很多人容易把排座算法神化,觉得得是什么“掐指一算,全局最优”。 我得写实一点:「不想排座位」确实有自动排座,但它更像是一套层次分明的调度流程。 目前支持的模式:random、scoredesc、scoreasc、goodfront、goodback、scorespread、groupbalanced、groupmentor。 1\. 标准排座:不废话,先卡规则 arrangestandard() 分三步走: 1. 读 SeatConstraint 建立约束映射。 2. 先把固定座位、必须相邻这类“大爷”安排明白。 3. 剩下的按策略贪心塞进空位。 在 seatisvalid() 里,每个待选座位都要被过滤一遍。看代码就知道这逻辑多老实: python def seatisvalid(student, seat, assignments, maps, requiredgroupmap=None): , mustrows, mustcols, forbidrows, forbidcols, forbidseats, , = maps sid = student.pk if sid in mustrows and seat.row not in mustrows[sid]: return False if sid in mustcols and seat.col not in mustcols[sid]: return False if sid in forbidrows and seat.row in forbidrows[sid]: return False if sid in forbidcols and seat.col in forbidcols[sid]: return False if sid in forbidseats and (seat.row, seat.col) in forbidseats[sid]: return False return True 它绝不跟你玩“先排出来看看”,而是先把不合规的位置统统拦在门外。 2\. scorespread:主打一个雨露均沾 很多老师不想高分扎堆,也不想后排变成摆烂区。scorespread 的做法很朴素:按成绩排好,然后用“高、低、高、低”交错着发牌。未必理论最优,但极其符合老师要的那种平衡感。 3\. 小组模式:能干活才是硬道理 groupbalanced 追求组间实力平均。 groupmentor 则更接近真实教学里的“优带差”:高低分配对 -\> 按总分排序 -\> 贪心塞进当前总分最低的组。 数学上漂不漂亮不重要,每组能不能形成互助结构才重要。 4\. 自动修补:愿意帮你擦屁股 排座最让人高血压的就是:你好不容易挪好张三,结果触发了李四不能挨着王五的规则。 所以所有写操作后,都会过一遍 stabilizelayoutwithrules()。要是撞了硬冲突,就走 attemptautoconstraintfix() 疯狂重试(随机模式试 16 次,其他 5 次)。 与其追求一次性天才求解,不如做一个愿意反复帮你把结果修回可用状态的系统。 ----- 七、导入之痛:对抗人类表格多样性 接用户的 Excel,就跟开盲盒一样刺激。为了不被拖垮,我分了两条路: 1\. 学生成绩导入 - 自动识别:极其克制。表头必须精确命中“总分”或“学生总分”才敢直接导。一旦拿不准,把文件暂存到 tempimports/,回传前 20 行让用户自己选列映射。绝不瞎猜。 - 模式可选:分 match(匹配已有更新成绩,没有则新增)和 replace(全量覆盖)。比无脑覆盖安全得多。 2\. 座位表导入:真正的硬核识别 面对整块空间结构,导入逻辑做了这几件事: - 扫非空边界、处理合并单元格扩散。 - 自动找讲台行、支持上下左右翻转。 - 手工词典:把特定的词强行标为某类型。 - 前 2 排/后 2 排预览:杜绝盲导。 最满意的是 classifylayoutcell():它结合关键词、词典、以及“2-5位无数字文本”的启发式规则,去猜这个格子里到底是名字还是过道。这让它能接住现实中极度混乱的表格。 ----- 八、导出:这图是要上墙张贴的 方案再牛,导出来像车祸现场,老师依然会骂娘。导出从一开始就是按独立配置页做的。 支持的格式:Excel、SVG、PPTX、小组登记表 Excel、.seats。 1\. 打印级 Excel 支持原始方向和 180° 翻转,讲台自动合并,页面设置直接瞄准 A4 横向打印。这就是冲着张贴去的。 2\. SVG 和 PPTX 的视觉体系 这块太值了。支持 classic、minimal、contrast 三套主题。 还能自由开关标题、坐标、姓名、成绩、小组标签等。 PPTX 是元素级生成的,老师拿过去在 Office 里还能接着自己画框加字。 3\. 懂行的小组登记表 导出逻辑考虑了:组头、组员、组长优先、双栏分布、页眉,还要在一页 A4 里尽量排下。 这说明系统还在关心老师拿表之后怎么管理计分。 ----- 九、AI 工作台:别让模型乱动数据库 AI 必须有,但绝不能是个只会瞎逼逼、还能随便乱改数据的危险分子。 aiworkspace 划出了极其明确的工具边界和“人机共驾”授权机制。 1\. 独立工作台,不搞侧边栏玩具 它有自己的会话列表、输入框、快捷提示词、流式输出和卡片渲染。地位和主工作区平级。 2\. 灵活的连接配置 配置(Key、URL、模型)同时写进 DB 和浏览器的 localStorage。哪怕本地没填,也能回退服务端变量。 通过判断 Base URL,它能自动在 OpenAI 官方的 Responses API 和第三方的 chat.completions + tools 之间切,不绑死单一厂商。 3\. 工具必须授权才能执行 AI 会算数,会列清单,还能调用 executeclassroomaction(换座、分组、删人等)。 但绝不直接执行。 它会先把意图缓存起来,给前端发个待授权列表。老师点头点“允许”,后端才干活。 代码里真是这么干的: python def storefuturemodepending(request, classroomid, conversationid, responseid, functioncalls, mode='responses', chatmessages=None): cleanupfuturemodependingcache() ownersessionkey = ensuresessionkey(request) token = uuid.uuid4().hex payload = { 'classroomid': classroomid, 'conversationid': conversationid, 'responseid': responseid, 'functioncalls': functioncalls, 'sessionkey': ownersessionkey, } store = getfuturemodependingstore(request) store[token] = payload FUTUREMODEPENDINGCACHE[token] = payload request.session.modified = True return token 这把 AI 从“黑箱自动机”变成了“带人工制动的副驾驶”。你要跟它说“换张三李四”,它甚至会直接抽出意图进入授权流,连废话都省了。 ----- 十、插件系统:不想给自己挖坑的折中之举 如果功能全写死,以后我会被源源不断的需求累死。 所以系统里藏了一套四层能力的插件机制: - Hook:监听 appready、studentmoved 等系统事件。 - Action:插件自己暴露 HTTP 接口。 - UI Script:零前端渲染。插件只要返回一个 JSON Schema,系统自带的组件库(metric, list, table 等)就能直接把插件页面画出来。 - Workspace Script:比如搜人插件,直接在主页右下角注入悬浮搜索面板。 这已经不是一个单机工具了,而是一个能长出新东西的平台雏形。 ----- 十一、彩蛋:开放版与完整版裁切 为了方便以后发不同版本,仓库里搞了个 gov.py 脚本。 它会把主项目复制到 openver/,然后根据代码里 GOV: START / END 的注释,把核心代码替换成“此部分未开源”。顺便还会踢掉官网、打包产物等目录。 emmm…… 其实主要还是为了藏自己的私货 这种纯工程化的小手段,说明项目已经在认真考虑分发和边界控制了。 ----- 十二、打包安装:给小白双击就能用的体验 很多开源项目扔个 requirements.txt 就完事了,但我把它往下推了一步。 1. PyInstaller 打包:package.py 负责收集各种隐蔽的依赖,并且构建后一定移除测试数据库文件,坚决不把脏数据带给用户。 2. Inno Setup 安装:自动装 HarmonyOS 字体,查 WebView2 运行库缺不缺,默认路径装好,扔个桌面快捷方式。 3. GitHub Actions 持续出包:满足指定 commit 格式,自己跑 Windows 构建、打包、上传。 一切为了让配环境的痛苦留给我,把双击就能用的爽快留给老师。 ----- 十三、宣传站为什么单独做成 React 把主程序用 Django 写,又搞个 React 的官网,不是我精神分裂,而是任务不同。 主工作区要稳、低心智负担。宣传站要的是好看、动效、能转化下载。 website/ 里的 React 19 独立页面甚至动态去拿下载地址,解耦部署,互不干涉。 ----- 十四、结语:把鸡毛蒜皮盘成产品 把这套代码完整看下来,它其实已经脱离了“随手写的排座脚本”的范畴,长出了一种很明确的产品气质。 不管是半透明的顶栏、胶囊按钮、完善的导入导出、还是带授权的 AI 和成型的插件系统。 我给它起名叫「不想排座位」。 名字挺直白,甚至有点摆烂,但这是每一个面对几十号人、几百条关系、各种奇葩课桌布局的老师最真实的想法(也有可能是黑奴电教的呻吟)。 比起搞个理论上无敌的排座算法,我更想做的,是把一个非常具体、非常容易被鄙视、却非常折磨人的现实问题,一点一点捋平。 老三