前端开发

极客工具分享:纯前端田字格加减法字帖生成器

2026年7月3日 davisfan 10 min read

今天分享一款完全由前端代码构建的实用小工具:田字格加减法字帖生成器。这款工具通过原生的 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 打印适配)和循环逻辑的参考。

Leave a comment