【码道案例】一个单文件 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 用浏览器打开即可

Logo

码道开发者社区,聚焦华为云码道 CodeArts 代码智能体,沉淀 Agent、Skill、鸿蒙开发实战内容,供开发者查阅资料、交流技术、分享工程实践

更多推荐