【码道案例】单文件 HTML 搞定游戏公会驻守排班
【码道案例】一个单文件 HTML 搞定游戏公会驻守排班:拖拽分配 + 一键导出 Excel/图片
做游戏公会管理的朋友应该都有过这种痛:每次开战前要在群里 @所有人,再手动把人名一个个填进 Excel 表格里,填错了还要来回改。本文分享一个我用纯 HTML+CSS+JS 写的"驻守排班小工具",单文件、零依赖、双击即用,支持拖拽分配、随机排班、导出带样式 Excel 和截图分享。全文约 2000 行代码,适合想入门前端拖拽交互和前端导出方案的同学参考。
一、为什么写这个工具
我们公会(官渡二团)每周要排一次"各组驻守安排",结构是固定的:
- 兵器坊(一组):1 队长 + 7 队员
- 工匠坊(二组):1 队长 + 7 队员
- 三个小粮仓(三/四/五组):各 1 队长 + 4 队员
- 替补队员:若干
- 右侧还要写"驻守开局地点""行军路线""采集提醒"等备注
之前是手工填 Excel,痛点很明显:
- 人名容易漏填、重复
- 调换人员要剪来剪去
- 排完还要截图发群,截图里 input 框文字会偏移
- 手机上看 Excel 不方便
于是花了半天撸了这个工具,一个 index.html 扔进任意浏览器就能跑,手机也能用。
📷 配图建议:放一张工具整体界面截图,展示左侧人员列表 + 右侧排班表格
二、技术选型与整体架构
1. 技术栈
|
能力 |
方案 |
|
页面结构 |
原生 HTML + CSS(含移动端媒体查询) |
|
交互逻辑 |
原生 JavaScript(无框架) |
|
Excel 导出 |
ExcelJS(CDN) |
|
图片导出 |
html2canvas(CDN) |
|
数据存储 |
内存对象(刷新即重置,可后续扩展 localStorage) |
只引了两个 CDN:
<script src="https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/exceljs@4.4.0/dist/exceljs.min.js"></script>
2. 界面布局
整体是左面板 + 右面板的经典两栏:
┌──────────────────────────────────────────┐
│ Header(可编辑标题 + 统计徽章) │
├──────────────────────────────────────────┤
│ Toolbar(导入/自动分配/清空/导出) │
├────────────┬─────────────────────────────┤
│ 左面板 │ 右面板 │
│ 待拖动 │ 排班表格 │
│ 人员网格 │ (队长/队员/替补/备注) │
└────────────┴─────────────────────────────┘
移动端(≤768px)下左面板变成抽屉式滑出,右下角放一个浮动按钮 📋 唤起,避免在小屏上挤成一团。
📷 配图建议:放一张手机端界面截图,展示抽屉式左面板
三、数据模型设计
整个应用的状态非常简单,用几个全局对象就够:
let nameData = {}; // "groupId-roleType-rowIdx": name
// 例如 "weapon-captain-0": "幽泉镜素弦"
let subData = {}; // "sub-idx": name,替补队员
let subCount = 1; // 替补槽位数
let allMembers = []; // 全部已知人员
let unassignedMembers = []; // 尚未分配到任何位置的人员
五个组用配置数组描述,渲染时循环生成表格:
const groups = [
{ id: 'weapon', name: '兵器坊', sub: '一 组', memCnt: 7, cls: 'weapon-cell' },
{ id: 'craft', name: '工匠坊', sub: '二 组', memCnt: 7, cls: 'craft-cell' },
{ id: 'gt', name: '己方\n(上路)小粮仓', sub: '三 组', memCnt: 4, cls: 'granary-cell' },
{ id: 'gm', name: '已方\n(中路)小粮仓', sub: '四 组', memCnt: 4, cls: 'granary-cell' },
{ id: 'gb', name: '己方\n(下路)小粮仓', sub: '五 组', memCnt: 4, cls: 'granary-cell' },
];
关键设计:用 "groupId-role-rowIdx" 这种扁平 key 而不是嵌套对象,好处是拖拽时一个 key 就能定位到任意格子,交换/清空都只需 nameData[key] = xxx。
四、核心功能实现
1. 拖拽分配(含交换)
这是整个工具的灵魂。要支持三种拖拽场景:
- 左面板 → 表格格子:分配人员
- 表格格子 → 表格格子:两人交换位置
- 表格格子 → 左面板:取消分配(双击也能取消)
拖拽源:左面板的人员卡片
chip.draggable = true;
chip.addEventListener('dragstart', function(e) {
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', name);
dragSourceKey = 'left'; // 标记来源是左面板
this.classList.add('dragging');
});
拖拽目标:表格里的 name-cell
drop 事件里区分两种情况——来自左面板(普通移动)还是来自另一个格子(交换):
cell.addEventListener('drop', function(e) {
e.preventDefault();
const name = e.dataTransfer.getData('text/plain');
const key = inp.dataset.key;
const old = nameData[key] || '';
const sourceKey = dragSourceKey;
dragSourceKey = null;
if (sourceKey && sourceKey !== 'left' && sourceKey !== key) {
// 交换:两个已分配格子互换
if (nameData[sourceKey]) {
nameData[sourceKey] = old || '';
nameData[key] = name;
} else if (subData[sourceKey]) {
subData[sourceKey] = old || '';
nameData[key] = name;
}
if (old) unassignedMembers = unassignedMembers.filter(n => n !== old);
} else {
// 普通移动:从左面板拖过来
removeNameFromAll(name);
if (old && !unassignedMembers.includes(old)) unassignedMembers.push(old);
nameData[key] = name;
unassignedMembers = unassignedMembers.filter(n => n !== name);
}
// ... 重新渲染
});
小技巧:用一个全局变量 dragSourceKey 记录拖拽起点('left' 或格子 key),drop 时判断是移动还是交换。这样比在 dataTransfer 里塞复杂对象更稳。
📷 配图建议:放一张正在拖拽时的截图,展示虚线高亮的放置区
2. 名单导入与去重
支持三种导入方式:
- 快捷导入:左面板顶部文本框,每行一个或逗号分隔
- 文件导入:选 .txt / .csv 文件读取
- 批量替换:弹窗粘贴,替换现有名单(不是追加)
去重逻辑统一走 parseNames:
function parseNames(text) {
const cleaned = text.replace(/[,,]/g, '\n').replace(/[\s]+/g, '\n');
const names = cleaned.split('\n').map(n => n.trim()).filter(n => n.length > 0);
const seen = new Set();
const unique = [];
const dups = [];
for (const name of names) {
if (seen.has(name)) dups.push(name);
else { seen.add(name); unique.push(name); }
}
return { unique, dups };
}
注意它同时处理中文逗号 , 和英文逗号 ,,因为公会成员粘贴名单时格式千奇百怪。
3. 自动随机分配
一键打乱并按结构填满所有位置,适合"懒得手动排"的场景:
function autoAssign() {
const pool = [...allMembers].sort(() => Math.random() - 0.5);
let total = 0;
groups.forEach(g => total += 1 + g.memCnt); // 队长 + 队员
total += subCount;
if (pool.length < total) {
showToast(`人员不足!需 ${total} 人,现有 ${pool.length} 人`, 'error');
return;
}
if (!confirm(`随机分配 ${total} 人到各位置?`)) return;
nameData = {};
subData = {};
let idx = 0;
groups.forEach(g => { nameData[`${g.id}-captain-0`] = pool[idx++]; });
for (let m = 0; m < MAX_MEM; m++) {
groups.forEach(g => {
if (m < g.memCnt) nameData[`${g.id}-member-${m}`] = pool[idx++];
});
}
for (let s = 0; s < subCount; s++) subData[`sub-${s}`] = pool[idx++];
// ... 渲染
}
4. 导出 Excel(精确还原模板样式)
这是最花心思的部分。导出的 Excel 要和公会原来的模板长得一模一样,包括列宽、行高、合并单元格、黄色表头、红色提醒文字等。
列宽与行高
ws.getColumn(1).width = 20.38; // A 列
ws.getColumn(6).width = 20.1; // F 列(兵器坊)
// ...
ws.getRow(2).height = 37;
for (let r = 3; r <= 12; r++) ws.getRow(r).height = 31;
合并单元格
模板里"替补"占 H10:J12 三行三列,"驻守开局地点"占 K5:K12,"红色提醒"占 L8:M12:
ws.mergeCells('H10:J12');
ws.mergeCells('K5:K12');
ws.mergeCells('L8:M12');
ws.mergeCells('K3:K4');
ws.mergeCells('L3:M4');
样式封装
写了个 applyCell 工具函数统一处理字体、对齐、边框、填充:
const applyCell = (row, col, value, opts = {}) => {
const cell = ws.getCell(row, col);
cell.value = value || '';
cell.font = Object.assign({}, fontYaHei, { size: 12 }, opts.font || {});
cell.alignment = Object.assign({}, centerWrap, opts.alignment || {});
cell.border = allBorders;
if (opts.fill) cell.fill = opts.fill;
return cell;
};
红色提醒文字单独指定字体和颜色:
applyCell(8, 12, notes.noteTips, {
font: { name: '黑体', size: 18, bold: true, color: { argb: 'FFFF0000' } },
alignment: { horizontal: 'left', vertical: 'center', wrapText: true }
});
最后用 workbook.xlsx.writeBuffer() 生成二进制,转 Blob 触发下载:
const buffer = await workbook.xlsx.writeBuffer();
const blob = new Blob([buffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `${pageTitle}-各组驻守安排.xlsx`;
link.click();
📷 配图建议:放一张导出的 Excel 在 WPS/Excel 里打开的截图
5. 导出图片(踩了 html2canvas 的坑)
最初直接 html2canvas(target) 截图,结果发现表格里的 <input> 文字全部向下偏移、被裁切。这是 html2canvas 的已知问题:它对表单元素的文字基线渲染不准。
解决方案:在 onclone 里把 input 替换成 div
onclone 回调会在 html2canvas 复制 DOM 后、渲染前触发,可以放心修改克隆 DOM 而不影响页面:
const canvas = await html2canvas(target, {
backgroundColor: '#ffffff',
scale: 2,
onclone: function(clonedDoc) {
const clonedTable = clonedDoc.querySelector('.schedule-table');
// 把 input 替换成 div,文字就不会偏移
clonedTable.querySelectorAll('.name-cell input, .sub-slot-input').forEach(inp => {
const div = clonedDoc.createElement('div');
div.textContent = inp.value || '';
const orig = inp.getBoundingClientRect();
div.style.width = orig.width + 'px';
div.style.height = orig.height + 'px';
div.style.lineHeight = orig.height + 'px';
div.style.textAlign = 'center';
div.style.fontSize = '13px';
// ...按格子类型上色
inp.replaceWith(div);
});
// textarea 同理替换
clonedTable.querySelectorAll('.note-cell textarea').forEach(ta => {
const div = clonedDoc.createElement('div');
div.textContent = ta.value || '';
div.style.whiteSpace = 'pre-wrap';
// ...
ta.replaceWith(div);
});
}
});
截图生成后优先复制到剪贴板,方便直接粘贴到微信群;剪贴板不可用时降级为下载文件:
try {
const blob = await new Promise(resolve => canvas.toBlob(resolve, 'image/png'));
await navigator.clipboard.write([ new ClipboardItem({ 'image/png': blob }) ]);
showToast('截图已复制到剪贴板!可直接粘贴使用', 'success');
} catch (err) {
// 降级:下载图片
const link = document.createElement('a');
link.download = `${pageTitle}-各组驻守安排.png`;
link.href = canvas.toDataURL('image/png');
link.click();
}
📷 配图建议:放一张导出的图片,对比修复前后的差异
五、移动端适配要点
移动端主要解决两个问题:
1. 左面板抽屉化
桌面端两栏并排,手机上左面板用 transform: translateX(-100%) 隐藏到屏幕外,点浮动按钮滑入:
@media (max-width: 768px) {
.left-panel {
position: fixed;
top: 0; left: 0;
width: 85vw;
height: 100vh;
transform: translateX(-100%);
transition: transform 0.3s ease;
z-index: 200;
}
.left-panel.mobile-visible {
transform: translateX(0);
}
}
拖拽完成后自动收起,避免挡住表格:
document.addEventListener('drop', function() {
if (window.innerWidth <= 768) closeMobilePanel();
});
2. 表格横向滚动
手机屏幕窄,表格用 width: max-content 让它撑开,外层容器 overflow: auto 横向滚动:
@media (max-width: 768px) {
.schedule-table {
font-size: 11px;
width: max-content;
min-width: 100%;
}
}
六、一些值得记录的小细节
- 双击取消分配:已分配的格子 dblclick 即把人扔回待分配区,比拖回去快得多。
- 替补槽位动态增减:点 + 添加替补 加槽,点 × 删槽并自动 reindex。
- 标题/电话可编辑:用 contenteditable 实现,失焦时同步到 pageTitle 并重渲染表格。
- Toast 提示:所有操作都有右上角滑入的轻提示,比 alert 友好。
- 统计徽章:Header 实时显示"总人数 / 待分配 / 已分配",一眼看出漏没漏人。
七、可改进的方向
当前版本为了"双击即用"把所有东西塞进单文件,后续可以扩展:
- 持久化:把 nameData / subData / notes 存到 localStorage,刷新不丢。
- 多套方案:支持保存多个排班方案来回切。
- 后端协同:接个轻后端,多个副团同时编辑不冲突。
- 历史记录:记录每次排班,方便复盘。
- PWA 化:加个 manifest.json 装成 App,手机桌面图标直接开。
八、完整代码结构
整个 index.html 大致分四块:
<head> CSS(约 800 行,含移动端媒体查询)
<body>
├ HTML 结构(header / toolbar / 左右面板,约 50 行)
└ <script>
├ 数据模型与默认数据
├ 表格渲染 renderTable()
├ 拖拽逻辑 bindDragTargets() / bindSubEvents()
├ 左面板渲染 renderLeftPanel()
├ 导入 quickImport() / openFileImport() / openBatchImportModal()
├ 自动分配 autoAssign()
├ 导出 exportToExcel() / exportToImage()
└ 初始化
没有构建步骤、没有 npm install,把 index.html 用微信发给副团,他手机浏览器打开就能用——这正是单文件方案最大的价值。
写在最后
这个工具本身不复杂,但把"拖拽交互 + 表格渲染 + Excel 导出 + 截图导出 + 移动端适配"这几件事塞进一个文件里跑通,还是踩了不少坑的(尤其是 html2canvas 的 input 渲染问题)。如果你也在做类似的"固定模板 + 人员填空"类工具(比如值日排班、比赛出场顺序、剧本杀角色分配),这套思路可以直接套用。
如果你觉得有用,欢迎点赞收藏,有问题评论区见~
项目地址:本地单文件,可自行复制 index.html 运行
运行方式:双击 index.html 用浏览器打开即可
更多推荐



所有评论(0)