今天分享一款完全由前端代码构建的实用小工具:田字格加减法字帖生成器。这款工具通过原生的 HTML、CSS 和 JavaScript 实现,支持一键打印,没有后端依赖,您可以直接保存并在本地运行。
工具功能与特色
- 极简卡片化 UI:左侧悬浮的纯净卡片式控制中心,可自定义排版、行列数、字体和网格大小。
- 纯净的数学引擎:自动剔除所有含有数字
0的送分题,强制考察孩子的位值和进退位概念。 - 完美的打印引擎:通过重写
@media print样式,在打印时切换 Flex 布局,绕开浏览器打印 Grid 的断页 Bug,页面空间利用率达到 100%。
完整源代码
将以下代码保存为 index.html 并在浏览器中打开即可直接使用(也可以按 Ctrl+P 直接完美打印):
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>Z2H 风格 - 田字格加减法字帖生成器</title>
<style>
:root {
/* 动态 CSS 变量,通过控制面板调节 */
--tz-color: #64748b; /* 网格颜色 */
--tz-size: 45px; /* 格子大小 */
--font-size: 36px; /* 字体大小 */
--text-color: #333333; /* 字体颜色 */
--columns: 3; /* 排版列数 */
--box-count: 5; /* 每道题整行格子的数量 */
--line-gap: 25px; /* 行间距 */
}
body {
background-color: #f4f6f8;
font-family: "PingFang SC", "Microsoft YaHei", sans-serif;
margin: 0;
padding: 40px 20px 40px 330px; /* 左侧留出控制面板空间 */
display: flex;
justify-content: center;
}
/* ============================
左侧控制面板 (精美卡片化 UI)
============================ */
.sidebar {
position: fixed;
left: 20px;
top: 40px;
width: 280px;
background: transparent;
z-index: 100;
max-height: 90vh;
overflow-y: auto;
/* 隐藏原生滚动条 */
scrollbar-width: none;
}
.sidebar::-webkit-scrollbar {
display: none;
}
.sidebar h2 {
display: none;
}
.card {
background: #ffffff;
border-radius: 8px;
border: 1px solid #e5e7eb;
margin-bottom: 16px;
padding: 0 16px;
box-shadow: 0 1px 2px rgba(0,0,0,0.02);
}
.control-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 0;
border-bottom: 1px solid #f1f5f9;
}
.control-item:last-child {
border-bottom: none;
}
.control-item label {
font-size: 14px;
color: #475569;
white-space: nowrap;
}
.control-right {
display: flex;
align-items: center;
justify-content: flex-end;
flex: 1;
}
/* 纯净风格下拉框 */
select {
appearance: none;
-webkit-appearance: none;
background: transparent;
border: none;
font-size: 14px;
color: #0f172a;
cursor: pointer;
outline: none;
text-align: right;
padding-right: 14px;
background-image: url('data:image/svg+xml;utf8,<svg width="8" height="12" viewBox="0 0 8 12" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M4 11L0 7H8L4 11ZM4 1L8 5H0L4 1Z" fill="%2394a3b8"/></svg>');
background-repeat: no-repeat;
background-position: right center;
font-family: inherit;
}
/* 定制滑块,完美复刻截图 */
input[type="range"] {
-webkit-appearance: none;
width: 90px;
height: 4px;
background: #f1f5f9;
border-radius: 2px;
outline: none;
margin-right: 12px;
cursor: pointer;
/* 黑蓝色的主色调 */
background-image: linear-gradient(#0f172a, #0f172a);
background-repeat: no-repeat;
}
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
height: 16px;
width: 16px;
border-radius: 50%;
background: #ffffff;
border: 2px solid #0f172a;
cursor: pointer;
margin-top: -6px;
}
/* 数值显示 */
.val-text {
font-size: 13px;
color: #0f172a;
width: 35px;
text-align: right;
font-family: monospace;
}
/* 颜色选择器圆角方块 */
input[type="color"] {
-webkit-appearance: none;
width: 24px;
height: 24px;
border: none;
border-radius: 4px;
padding: 0;
cursor: pointer;
background: transparent;
box-shadow: inset 0 0 0 1px rgba(0,0,0,0.1);
}
input[type="color"]::-webkit-color-swatch-wrapper {
padding: 0;
}
input[type="color"]::-webkit-color-swatch {
border: none;
border-radius: 4px;
}
.btn-group {
display: flex;
flex-direction: column;
gap: 12px;
margin-top: 8px;
}
button {
padding: 12px;
font-size: 14px;
cursor: pointer;
border-radius: 8px;
font-weight: normal;
transition: all 0.2s;
text-align: center;
border: none;
}
.btn-gen {
background: #ffffff;
color: #0f172a;
border: 1px solid #e5e7eb;
box-shadow: 0 1px 2px rgba(0,0,0,0.02);
}
.btn-gen:hover { background: #f8fafc; }
.btn-print {
background: #0f172a;
color: #ffffff;
border: 1px solid #0f172a;
}
.btn-print:hover { opacity: 0.9; }
/* ============================
A4 纸张排版
============================ */
.page {
background: white;
width: 210mm;
min-height: 297mm;
padding: 20mm;
box-sizing: border-box;
box-shadow: 0 10px 25px rgba(0,0,0,0.1);
}
.grid-container {
display: grid;
grid-template-columns: repeat(var(--columns), 1fr);
row-gap: var(--line-gap);
column-gap: 15px;
justify-items: center;
}
/* ============================
整行方格叠加文本设计
============================ */
.problem-wrapper {
position: relative;
display: inline-block;
}
.grid-bg {
display: flex;
border: 1.5px solid var(--tz-color);
position: relative;
}
/* 贯穿整行的水平中心虚线 */
.grid-bg::before {
content: '';
position: absolute;
top: 50%;
left: 0;
width: 100%;
height: 0;
border-top: 1px dashed var(--tz-color);
opacity: 0.6;
transform: translateY(-50%);
z-index: 1;
}
.tianzige {
width: var(--tz-size);
height: var(--tz-size);
border: none;
border-right: 1px dashed var(--tz-color);
box-sizing: border-box;
position: relative;
background: transparent;
}
.tianzige:last-child {
border-right: none;
}
/* 田字格中间的垂直虚线 */
.tianzige::after {
content: '';
position: absolute;
left: 50%;
top: 0;
height: 100%;
width: 0;
border-left: 1px dashed var(--tz-color);
opacity: 0.6;
transform: translateX(-50%);
z-index: 1;
}
.text-layer {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
pointer-events: none;
z-index: 10;
}
.text-cell {
width: var(--tz-size);
height: var(--tz-size);
display: flex;
align-items: center;
justify-content: center;
/* 采用类似手写的字体,并应用斜体完美复刻字帖感觉 */
font-family: 'Comic Sans MS', 'Ink Free', 'Segoe Print', cursive, sans-serif;
font-style: italic;
font-size: var(--font-size);
line-height: 1;
color: var(--text-color);
}
.digit-left, .digit-right {
width: 50%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.op-cell {
font-style: normal;
font-family: Arial, sans-serif;
font-size: calc(var(--font-size) * 0.75);
}
/* ============================
打印优化
============================ */
@media print {
@page { size: A4; margin: 0; }
body { background: none; padding: 0; display: block; }
.sidebar { display: none !important; }
.page { box-shadow: none; margin: 0; width: 100%; padding: 10mm; min-height: 0; }
.grid-container {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: var(--line-gap) 15px;
}
.problem-wrapper {
page-break-inside: avoid;
break-inside: avoid;
}
}
</style>
</head>
<body>
<!-- 控制面板 (纯净卡片风格) -->
<div class="sidebar">
<!-- Card 1: 尺寸与排版 -->
<div class="card">
<div class="control-item">
<label>网格尺寸</label>
<div class="control-right">
<input type="range" id="ctrl-tz" min="30" max="60" value="45" oninput="updateStyle('--tz-size', this.value + 'px', 'val-tz', this)">
<span id="val-tz" class="val-text">45px</span>
</div>
</div>
<div class="control-item">
<label>行间距</label>
<div class="control-right">
<input type="range" id="ctrl-gap" min="10" max="60" value="25" oninput="updateStyle('--line-gap', this.value + 'px', 'val-gap', this)">
<span id="val-gap" class="val-text">25px</span>
</div>
</div>
<div class="control-item">
<label>排版列数</label>
<select id="ctrl-cols" onchange="updateStyle('--columns', this.value, null)">
<option value="1">1 列</option>
<option value="2">2 列</option>
<option value="3" selected>3 列</option>
</select>
</div>
</div>
<!-- Card 2: 字体与格子 -->
<div class="card">
<div class="control-item">
<label>字体大小</label>
<div class="control-right">
<input type="range" id="ctrl-font" min="16" max="60" value="36" oninput="updateStyle('--font-size', this.value + 'px', 'val-font', this)">
<span id="val-font" class="val-text">36px</span>
</div>
</div>
<div class="control-item">
<label>单行格子数</label>
<div class="control-right">
<input type="range" id="ctrl-box" min="4" max="25" value="5" oninput="updateBoxCount(this)">
<span id="val-box" class="val-text">5个</span>
</div>
</div>
</div>
<!-- Card 3: 题目与色彩 -->
<div class="card">
<div class="control-item">
<label>算式类型</label>
<select id="ctrl-type" onchange="updateProblemType()">
<option value="2" selected>2个数加减</option>
<option value="3">3个数混合</option>
</select>
</div>
<div class="control-item">
<label>计算范围</label>
<select id="ctrl-max" onchange="generateProblems()">
<option value="20" selected>20以内</option>
<option value="50">50以内</option>
<option value="100">100以内</option>
</select>
</div>
<div class="control-item">
<label>网格颜色</label>
<input type="color" id="ctrl-color" value="#64748b" oninput="updateStyle('--tz-color', this.value, null)">
</div>
<div class="control-item">
<label>字体颜色</label>
<input type="color" id="ctrl-text-color" value="#333333" oninput="updateStyle('--text-color', this.value, null)">
</div>
</div>
<div class="btn-group">
<button class="btn-gen" onclick="generateProblems()">生成新题目</button>
<button class="btn-print" onclick="window.print()">打印字帖</button>
</div>
</div>
<!-- A4 试卷区域 -->
<div class="page">
<div class="grid-container" id="grid">
<!-- JS 挂载区 -->
</div>
</div>
<script>
// 动态更新 CSS 变量
function updateStyle(varName, value, labelId, sliderEl) {
document.documentElement.style.setProperty(varName, value);
if (labelId) {
document.getElementById(labelId).innerText = value.replace('px', '') + (varName === '--box-count' ? '个' : 'px');
}
if (sliderEl) {
const min = sliderEl.min || 0;
const max = sliderEl.max || 100;
const percent = ((sliderEl.value - min) / (max - min)) * 100;
sliderEl.style.backgroundSize = percent + '% 100%';
}
}
// 更新整行格子数量(需要重新渲染)
function updateBoxCount(sliderEl) {
const val = sliderEl.value;
document.documentElement.style.setProperty('--box-count', val);
document.getElementById('val-box').innerText = val + '个';
const min = sliderEl.min || 0;
const max = sliderEl.max || 100;
const percent = ((val - min) / (max - min)) * 100;
sliderEl.style.backgroundSize = percent + '% 100%';
generateProblems();
}
function getRandomInt(min, max) {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
function calculateTotalProblems() {
const rootStyle = getComputedStyle(document.documentElement);
const tzSize = parseInt(rootStyle.getPropertyValue('--tz-size')) || 45;
const lineGap = parseInt(rootStyle.getPropertyValue('--line-gap')) || 25;
const columns = parseInt(rootStyle.getPropertyValue('--columns')) || 3;
// A4总高度297mm(约1122px)
// 打印时 padding 10mm(上下共约75px),因为去掉了标题,有了更多空间
// 设置安全可用高度为 980px
const usableHeight = 980;
const rows = Math.floor((usableHeight + lineGap) / (tzSize + lineGap));
return rows * columns;
}
function updateProblemType() {
const type = document.getElementById('ctrl-type').value;
const ctrlBox = document.getElementById('ctrl-box');
const ctrlCols = document.getElementById('ctrl-cols');
if (type === '2') {
ctrlBox.value = 5;
ctrlCols.value = 3;
} else {
ctrlBox.value = 7;
ctrlCols.value = 2;
}
updateStyle('--columns', ctrlCols.value, null);
updateBoxCount(ctrlBox);
}
function generateProblems() {
const container = document.getElementById('grid');
container.innerHTML = '';
const totalProblems = calculateTotalProblems();
const boxCount = parseInt(document.documentElement.style.getPropertyValue('--box-count')) || 5;
const problemType = document.getElementById('ctrl-type') ? document.getElementById('ctrl-type').value : '2';
const maxVal = parseInt(document.getElementById('ctrl-max') ? document.getElementById('ctrl-max').value : 20);
for (let i = 0; i < totalProblems; i++) {
let a, b, c, symbol1, symbol2, temp;
let isAdd1, isAdd2;
const hasZero = (num) => num.toString().includes('0');
let result;
if (problemType === '2') {
do {
isAdd1 = Math.random() > 0.5;
if (isAdd1) {
a = Math.floor(Math.random() * (maxVal - 1)) + 1;
b = Math.floor(Math.random() * (maxVal - a)) + 1;
result = a + b;
symbol1 = '+';
} else {
a = Math.floor(Math.random() * (maxVal - 1)) + 2; // >= 2
b = Math.floor(Math.random() * (a - 1)) + 1; // < a
result = a - b;
symbol1 = '-';
}
} while (result <= 0 || hasZero(a) || hasZero(b) || hasZero(result));
} else {
do {
isAdd1 = Math.random() > 0.5;
isAdd2 = Math.random() > 0.5;
if (isAdd1) {
let maxA = Math.max(1, maxVal - 2);
a = Math.floor(Math.random() * maxA) + 1;
let maxB = Math.max(1, maxVal - 1 - a);
b = Math.floor(Math.random() * maxB) + 1;
temp = a + b;
symbol1 = '+';
} else {
let minA = 3;
a = Math.floor(Math.random() * (maxVal - minA + 1)) + minA;
let maxB = Math.max(1, a - 2);
b = Math.floor(Math.random() * maxB) + 1;
temp = a - b;
symbol1 = '-';
}
if (isAdd2) {
let maxC = Math.max(1, maxVal - temp);
c = Math.floor(Math.random() * maxC) + 1;
result = temp + c;
symbol2 = '+';
} else {
let maxC = Math.max(1, temp - 1);
c = Math.floor(Math.random() * maxC) + 1;
result = temp - c;
symbol2 = '-';
}
} while (result <= 0 || hasZero(a) || hasZero(b) || hasZero(c) || hasZero(temp) || hasZero(result));
}
// 构建背景方格的 HTML
let gridsHtml = '';
for (let j = 0; j < boxCount; j++) {
gridsHtml += `<div class="tianzige"></div>`;
}
// 构建完全对齐的文本层 HTML
let textHtml = '<div class="text-layer">';
const renderNum = (num) => {
const str = num.toString();
if (str.length === 1) {
return `<div class="text-cell"><div class="digit-left"></div><div class="digit-right">${str}</div></div>`;
} else if (str.length === 2) {
return `<div class="text-cell"><div class="digit-left">${str[0]}</div><div class="digit-right">${str[1]}</div></div>`;
} else {
return `<div class="text-cell" style="font-size: calc(var(--font-size)*0.8);">${str}</div>`;
}
};
const renderOp = (op) => `<div class="text-cell op-cell">${op}</div>`;
if (problemType === '2') {
textHtml += renderNum(a) + renderOp(symbol1) + renderNum(b) + renderOp('=');
} else {
textHtml += renderNum(a) + renderOp(symbol1) + renderNum(b) + renderOp(symbol2) + renderNum(c) + renderOp('=');
}
textHtml += '</div>';
const wrapper = document.createElement('div');
wrapper.className = 'problem-wrapper';
// 使用整行方格为背景,算式文字严格对齐网格
wrapper.innerHTML = `
<div class="grid-bg">
${gridsHtml}
</div>
${textHtml}
`;
container.appendChild(wrapper);
}
}
// 初始化
window.onload = function() {
document.querySelectorAll('input[type="range"]').forEach(el => {
const min = el.min || 0;
const max = el.max || 100;
const percent = ((el.value - min) / (max - min)) * 100;
el.style.backgroundSize = percent + '% 100%';
});
generateProblems();
};
</script>
</body>
</html>
这套代码采用了原生 CSS 变量和轻量级 JS 架构,非常适合作为前端学习者进行排版布局(尤其是 window.print 打印适配)和循环逻辑的参考。
