feat: add Number Connect game with ABC spatial optimization and auto-pagination
Build and Push Docker Image / build (push) Successful in 3m45s
Build and Push Docker Image / build (push) Successful in 3m45s
This commit is contained in:
@@ -2,14 +2,15 @@
|
||||
<div id="panel-learn-number" class="tool-panel">
|
||||
<header class="page-header">
|
||||
<h1>幼儿数学助手</h1>
|
||||
<p>通过高品质卡通图形,培养孩子的数感与逻辑思维。</p>
|
||||
<p>通过趣味游戏,培养孩子的数感、逻辑思维与书写能力。</p>
|
||||
</header>
|
||||
<div class="tabs-container">
|
||||
<div id="tab-counting" class="tab-btn active">数图形练习</div>
|
||||
<div id="tab-counting" class="tab-btn active" onclick="switchMathTab('counting')">数图形练习</div>
|
||||
<div id="tab-writing" class="tab-btn" onclick="switchMathTab('writing')">数字连连看</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div style="display: flex; flex-direction: column; gap: 32px;">
|
||||
<!-- 设置区 -->
|
||||
|
||||
<!-- 数图形面板 -->
|
||||
<div id="math-counting-controls" class="card">
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 20px; align-items: flex-end;">
|
||||
<div class="input-group">
|
||||
<label style="font-size: 13px; font-weight: 600; color: #86868b; margin-bottom: 8px; display: block;">练习主题</label>
|
||||
@@ -31,30 +32,45 @@
|
||||
<input type="number" id="page_count" min="1" max="10" value="1" class="apple-input">
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<label style="font-size: 13px; font-weight: 600; color: #86868b; margin-bottom: 8px; display: block;">纸张大小</label>
|
||||
<label style="font-size: 13px; font-weight: 600; color: #86868b; margin-bottom: 8px; display: block;">纸张</label>
|
||||
<select id="math_paper_size" class="apple-select">
|
||||
<option value="Letter" selected>Letter</option>
|
||||
<option value="A4">A4</option>
|
||||
</select>
|
||||
</div>
|
||||
<button onclick="generateMathPDF()" id="btn-math-counting">生成练习帖</button>
|
||||
</div>
|
||||
<div id="icon-preview-container" style="margin-top: 24px; padding: 20px; background: #fbfbfd; border-radius: 12px; border: 1px solid #e5e5e7;">
|
||||
<div id="icon-list" style="display: flex; gap: 12px; flex-wrap: wrap;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 数字连连看面板 (已简化页数选择) -->
|
||||
<div id="math-writing-controls" class="card" style="display:none;">
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 24px; align-items: flex-end;">
|
||||
<div class="input-group">
|
||||
<label style="font-size: 13px; font-weight: 600; color: #86868b; margin-bottom: 8px; display: block;">起始数字</label>
|
||||
<input type="number" id="start_num" min="0" value="1" class="apple-input">
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<label style="font-size: 13px; font-weight: 600; color: #86868b; margin-bottom: 8px; display: block;">结束数字</label>
|
||||
<input type="number" id="end_num" min="1" value="15" class="apple-input">
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<label style="font-size: 13px; font-weight: 600; color: #86868b; margin-bottom: 8px; display: block;">纸张大小</label>
|
||||
<select id="write_paper_size" class="apple-select">
|
||||
<option value="Letter" selected>Letter (8.5x11in)</option>
|
||||
<option value="A4">A4 (210x297mm)</option>
|
||||
</select>
|
||||
</div>
|
||||
<button id="btn-generate-math" onclick="generateMathPDF()" style="height: 46px; padding: 0 20px;">生成练习帖</button>
|
||||
<button onclick="generateWritingPDF()" id="btn-math-writing">生成闯关地图</button>
|
||||
</div>
|
||||
<p style="margin-top: 16px; font-size: 13px; color: #86868b;">💡 规则:系统将根据数字范围自动分页(每页约 15 个)。描红数字,并寻找下一个数字进行连线。</p>
|
||||
</div>
|
||||
|
||||
<!-- 图标预览 -->
|
||||
<div id="icon-preview-container" style="padding: 24px; background: #fbfbfd; border-radius: 16px; border: 1px solid #e5e5e7;">
|
||||
<label style="font-size: 11px; font-weight: 700; color: #86868b; display: block; margin-bottom: 16px; text-transform: uppercase; letter-spacing: 1px;">当前主题包含的图案:</label>
|
||||
<div id="icon-list" style="display: flex; gap: 16px; flex-wrap: wrap;">
|
||||
<!-- JS 动态注入 -->
|
||||
</div>
|
||||
</div>
|
||||
<div id="math-error-msg" style="color: #ff3b30; font-size: 14px; display: none; font-weight: 500; text-align: center; padding: 10px; background: #fff2f2; border-radius: 8px; margin-bottom: 20px;"></div>
|
||||
|
||||
<div id="math-error-msg" style="color: #ff3b30; font-size: 14px; display: none; font-weight: 500; text-align: center; padding: 10px; background: #fff2f2; border-radius: 8px;">
|
||||
⚠️ 请确保:总数 >= 种类数,页数 1-10。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="math-preview" style="display:none; width: 100%; height: 850px; border-radius: 24px; overflow: hidden; box-shadow: 0 20px 60px rgba(0,0,0,0.12); background: #fff; border: 1px solid #d2d2d7; margin-top: 40px;">
|
||||
<div id="math-preview" style="display:none; width: 100%; height: 850px; border-radius: 24px; overflow: hidden; box-shadow: 0 20px 60px rgba(0,0,0,0.12); background: #fff; border: 1px solid #d2d2d7;">
|
||||
<iframe id="math-frame" style="width:100%; height:100%; border:none;"></iframe>
|
||||
</div>
|
||||
</div>
|
||||
@@ -67,14 +83,22 @@
|
||||
}
|
||||
.apple-input:focus, .apple-select:focus { border-color: #0071e3; box-shadow: 0 0 0 4px rgba(0,113,227,0.15); }
|
||||
.icon-thumbnail {
|
||||
width: 64px; height: 64px; background: white; border-radius: 12px; border: 1px solid #e5e5e7;
|
||||
display: flex; align-items: center; justify-content: center; padding: 8px;
|
||||
width: 52px; height: 52px; background: white; border-radius: 10px; border: 1px solid #e5e5e7;
|
||||
display: flex; align-items: center; justify-content: center; padding: 6px;
|
||||
box-shadow: 0 2px 6px rgba(0,0,0,0.04); transition: all 0.2s ease;
|
||||
}
|
||||
.icon-thumbnail:hover { transform: translateY(-3px); border-color: #0071e3; box-shadow: 0 8px 20px rgba(0,0,0,0.08); }
|
||||
</style>
|
||||
|
||||
<script>
|
||||
let mathTab = 'counting';
|
||||
function switchMathTab(tab) {
|
||||
mathTab = tab;
|
||||
document.querySelectorAll('#panel-learn-number .tab-btn').forEach(b => b.classList.remove('active'));
|
||||
document.getElementById(`tab-${tab}`).classList.add('active');
|
||||
document.getElementById('math-counting-controls').style.display = (tab === 'counting') ? 'block' : 'none';
|
||||
document.getElementById('math-writing-controls').style.display = (tab === 'writing') ? 'block' : 'none';
|
||||
}
|
||||
|
||||
let allCategories = {};
|
||||
async function initMathTool() {
|
||||
try {
|
||||
@@ -86,14 +110,12 @@
|
||||
|
||||
function calculateBBox(paths) {
|
||||
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||||
// 增强正则:匹配所有数字,包括带逗号的
|
||||
const numRegex = /-?\d+\.?\d*/g;
|
||||
paths.forEach(path => {
|
||||
const matches = path.match(numRegex);
|
||||
if (matches) {
|
||||
for (let i=0; i<matches.length; i+=2) {
|
||||
const x = parseFloat(matches[i]);
|
||||
const y = parseFloat(matches[i+1]);
|
||||
const x = parseFloat(matches[i]); const y = parseFloat(matches[i+1]);
|
||||
if (!isNaN(x) && !isNaN(y)) {
|
||||
if (x < minX) minX = x; if (x > maxX) maxX = x;
|
||||
if (y < minY) minY = y; if (y > maxY) maxY = y;
|
||||
@@ -118,7 +140,6 @@
|
||||
const viewBox = calculateBBox(icon.Paths);
|
||||
const pathsHtml = icon.Paths.map(p => `<path d="${p}" fill="none" stroke="black" stroke-width="2%" stroke-linecap="round" stroke-linejoin="round" />`).join('');
|
||||
div.innerHTML = `<svg viewBox="${viewBox}" style="width: 100%; height: 100%; overflow: visible;">${pathsHtml}</svg>`;
|
||||
div.title = icon.Name;
|
||||
container.appendChild(div);
|
||||
});
|
||||
}
|
||||
@@ -129,16 +150,9 @@
|
||||
const page_count = parseInt(document.getElementById('page_count').value || 1);
|
||||
const category = document.getElementById('math_category').value;
|
||||
const paper_size = document.getElementById('math_paper_size').value;
|
||||
const errorMsg = document.getElementById('math-error-msg');
|
||||
if (total_count < icon_types) { alert('总量必须大于种类'); return; }
|
||||
|
||||
if (total_count < icon_types || icon_types > 6 || total_count > 30) {
|
||||
errorMsg.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
errorMsg.style.display = 'none';
|
||||
|
||||
const btn = document.getElementById('btn-generate-math');
|
||||
const originalText = btn.innerText;
|
||||
const btn = document.getElementById('btn-math-counting');
|
||||
btn.innerText = '生成中...'; btn.disabled = true;
|
||||
try {
|
||||
const response = await fetch('/api/learn-number/counting', {
|
||||
@@ -147,14 +161,29 @@
|
||||
body: JSON.stringify({ total_count, icon_types, page_count, category, paper_size })
|
||||
});
|
||||
const blob = await response.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
document.getElementById('math-preview').style.display = 'block';
|
||||
document.getElementById('math-frame').src = url;
|
||||
} catch (e) {
|
||||
alert('生成失败: ' + e);
|
||||
} finally {
|
||||
btn.innerText = originalText; btn.disabled = false;
|
||||
document.getElementById('math-frame').src = URL.createObjectURL(blob);
|
||||
} finally { btn.innerText = '生成练习帖'; btn.disabled = false; }
|
||||
}
|
||||
|
||||
async function generateWritingPDF() {
|
||||
const start_num = parseInt(document.getElementById('start_num').value);
|
||||
const end_num = parseInt(document.getElementById('end_num').value);
|
||||
const paper_size = document.getElementById('write_paper_size').value;
|
||||
if (end_num <= start_num) { alert('结束数字必须大于起始数字'); return; }
|
||||
|
||||
const btn = document.getElementById('btn-math-writing');
|
||||
btn.innerText = '生成中...'; btn.disabled = true;
|
||||
try {
|
||||
const response = await fetch('/api/learn-number/writing', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ start_num, end_num, paper_size })
|
||||
});
|
||||
const blob = await response.blob();
|
||||
document.getElementById('math-preview').style.display = 'block';
|
||||
document.getElementById('math-frame').src = URL.createObjectURL(blob);
|
||||
} finally { btn.innerText = '生成闯关地图'; btn.disabled = false; }
|
||||
}
|
||||
|
||||
setTimeout(initMathTool, 200);
|
||||
|
||||
Binary file not shown.
@@ -10,6 +10,9 @@ import (
|
||||
//go:embed fruit.svg
|
||||
var fruitSVGContent []byte
|
||||
|
||||
//go:embed font.ttf
|
||||
var FontContent []byte
|
||||
|
||||
type Icon struct {
|
||||
Name string
|
||||
Paths []string
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
package logic
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"math"
|
||||
"math/rand"
|
||||
"strconv"
|
||||
"toolbox/pkg/learnnumber/data"
|
||||
|
||||
"github.com/signintech/gopdf"
|
||||
)
|
||||
|
||||
type WritingRequest struct {
|
||||
StartNum int `json:"start_num"`
|
||||
EndNum int `json:"end_num"`
|
||||
PaperSize string `json:"paper_size"`
|
||||
}
|
||||
|
||||
type WritingNode struct {
|
||||
X, Y, R float64
|
||||
AngleA, AngleB float64
|
||||
BulgeR, Dist float64
|
||||
Number int
|
||||
}
|
||||
|
||||
func GenerateWritingPDF(req WritingRequest) ([]byte, error) {
|
||||
pdf := &gopdf.GoPdf{}
|
||||
rect := gopdf.Rect{W: 595.28, H: 841.89}
|
||||
if req.PaperSize == "Letter" { rect = gopdf.Rect{W: 612, H: 792} }
|
||||
pdf.Start(gopdf.Config{PageSize: rect})
|
||||
|
||||
err := pdf.AddTTFFontData("basic", data.FontContent)
|
||||
if err != nil { return nil, err }
|
||||
|
||||
currentNum := req.StartNum
|
||||
// 只要还没达到 EndNum,就继续生成“整页”内容
|
||||
for currentNum <= req.EndNum {
|
||||
pdf.AddPage()
|
||||
lastPlaced := drawOneFullPage(pdf, currentNum, rect.W, rect.H)
|
||||
currentNum = lastPlaced + 1
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
_, err = pdf.WriteTo(&buf)
|
||||
return buf.Bytes(), err
|
||||
}
|
||||
|
||||
// 核心改变:不再受限于 end 参数,而是尝试填满整页
|
||||
func drawOneFullPage(pdf *gopdf.GoPdf, start int, pW, pH float64) int {
|
||||
margin := 80.0
|
||||
boxW, boxH := pW-2*margin, pH-160.0
|
||||
xBase, yBase := margin, 100.0
|
||||
|
||||
nodeR := 28.0
|
||||
bulgeR := nodeR * 0.32
|
||||
dist := nodeR + bulgeR + 18.0
|
||||
checkR := nodeR * 1.3
|
||||
|
||||
var nodes []WritingNode
|
||||
num := start
|
||||
|
||||
// 无限循环,直到页面塞不下为止
|
||||
for {
|
||||
placed := false
|
||||
for retry := 0; retry < 1000; retry++ {
|
||||
rx := xBase + dist + 20 + rand.Float64()*(boxW - 2*dist - 40)
|
||||
ry := yBase + dist + 20 + rand.Float64()*(boxH - 2*dist - 40)
|
||||
|
||||
if isColliding(rx, ry, checkR, nodes) { continue }
|
||||
|
||||
angleA := rand.Float64() * 2 * math.Pi
|
||||
angleB := angleA + math.Pi + (rand.Float64()-0.5)*1.5
|
||||
nodes = append(nodes, WritingNode{X: rx, Y: ry, R: nodeR, AngleA: angleA, AngleB: angleB, BulgeR: bulgeR, Dist: dist, Number: num})
|
||||
placed = true
|
||||
break
|
||||
}
|
||||
|
||||
if !placed {
|
||||
// 页面已满
|
||||
break
|
||||
}
|
||||
num++
|
||||
}
|
||||
|
||||
// 空间优化
|
||||
optimizeSpace(nodes, xBase, yBase, boxW, boxH, dist, checkR)
|
||||
|
||||
// 绘制
|
||||
for _, n := range nodes {
|
||||
drawFullNode(pdf, n)
|
||||
}
|
||||
|
||||
// 返回本页最后一个数字
|
||||
if len(nodes) > 0 {
|
||||
return nodes[len(nodes)-1].Number
|
||||
}
|
||||
return start
|
||||
}
|
||||
|
||||
func optimizeSpace(nodes []WritingNode, xBase, yBase, boxW, boxH, dist, checkR float64) {
|
||||
if len(nodes) < 2 { return }
|
||||
for iter := 0; iter < 120; iter++ {
|
||||
for i := range nodes {
|
||||
bestX, bestY := nodes[i].X, nodes[i].Y
|
||||
maxMinDist := calcMinDist(bestX, bestY, i, nodes)
|
||||
for k := 0; k < 8; k++ {
|
||||
moveAngle := rand.Float64() * 2 * math.Pi
|
||||
nx := nodes[i].X + math.Cos(moveAngle)*5.0
|
||||
ny := nodes[i].Y + math.Sin(moveAngle)*5.0
|
||||
if nx-dist < xBase || nx+dist > xBase+boxW || ny-dist < yBase || ny+dist > yBase+boxH { continue }
|
||||
if isColliding(nx, ny, checkR, nodes[:i]) || isColliding(nx, ny, checkR, nodes[i+1:]) { continue }
|
||||
newMinDist := calcMinDist(nx, ny, i, nodes)
|
||||
if newMinDist > maxMinDist {
|
||||
maxMinDist = newMinDist; bestX, bestY = nx, ny
|
||||
}
|
||||
}
|
||||
nodes[i].X, nodes[i].Y = bestX, bestY
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func isColliding(x, y, r float64, existing []WritingNode) bool {
|
||||
for _, e := range existing {
|
||||
d := math.Sqrt(math.Pow(x-e.X, 2) + math.Pow(y-e.Y, 2))
|
||||
if d < (r + e.R*1.3 + 30.0) { return true }
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func calcMinDist(x, y float64, idx int, nodes []WritingNode) float64 {
|
||||
minD := 10000.0
|
||||
for i, n := range nodes {
|
||||
if i == idx { continue }
|
||||
d := math.Sqrt(math.Pow(x-n.X, 2) + math.Pow(y-n.Y, 2))
|
||||
if d < minD { minD = d }
|
||||
}
|
||||
return minD
|
||||
}
|
||||
|
||||
func getEdgePoint(x, y, r, angle float64, isCircle bool) (float64, float64) {
|
||||
gap := 2.0
|
||||
er := r + gap
|
||||
if isCircle { return x + math.Cos(angle)*er, y + math.Sin(angle)*er }
|
||||
absCos, absSin := math.Abs(math.Cos(angle)), math.Abs(math.Sin(angle))
|
||||
var d float64
|
||||
if absCos > absSin { d = er / absCos } else { d = er / absSin }
|
||||
return x + math.Cos(angle)*d, y + math.Sin(angle)*d
|
||||
}
|
||||
|
||||
func drawCenteredText(pdf *gopdf.GoPdf, cx, cy float64, text string, fontSize float64) {
|
||||
pdf.SetFont("basic", "", fontSize)
|
||||
tw, _ := pdf.MeasureTextWidth(text)
|
||||
pdf.SetXY(cx - tw/2, cy + fontSize*0.38)
|
||||
pdf.Text(text)
|
||||
}
|
||||
|
||||
func drawFullNode(pdf *gopdf.GoPdf, n WritingNode) {
|
||||
isOdd := n.Number%2 != 0
|
||||
pdf.SetStrokeColor(0, 0, 0); pdf.SetLineWidth(1.8)
|
||||
if isOdd { pdf.Oval(n.X-n.R, n.Y-n.R, n.X+n.R, n.Y+n.R) } else { pdf.RectFromUpperLeft(n.X-n.R, n.Y-n.R, n.R*2, n.R*2) }
|
||||
pdf.SetTextColor(220, 220, 220)
|
||||
drawCenteredText(pdf, n.X, n.Y, strconv.Itoa(n.Number), n.R*1.35)
|
||||
|
||||
pdf.SetStrokeColor(180, 180, 180); pdf.SetLineWidth(1.0)
|
||||
ax, ay := n.X + math.Cos(n.AngleA)*n.Dist, n.Y + math.Sin(n.AngleA)*n.Dist
|
||||
lx1, ly1 := getEdgePoint(n.X, n.Y, n.R, n.AngleA, isOdd)
|
||||
lx2, ly2 := getEdgePoint(ax, ay, n.BulgeR, n.AngleA+math.Pi, !isOdd)
|
||||
pdf.Line(lx1, ly1, lx2, ly2)
|
||||
if isOdd { pdf.RectFromUpperLeft(ax-n.BulgeR, ay-n.BulgeR, n.BulgeR*2, n.BulgeR*2) } else { pdf.Oval(ax-n.BulgeR, ay-n.BulgeR, ax+n.BulgeR, ay+n.BulgeR) }
|
||||
|
||||
bx, by := n.X + math.Cos(n.AngleB)*n.Dist, n.Y + math.Sin(n.AngleB)*n.Dist
|
||||
lx3, ly3 := getEdgePoint(n.X, n.Y, n.R, n.AngleB, isOdd)
|
||||
lx4, ly4 := getEdgePoint(bx, by, n.BulgeR, n.AngleB+math.Pi, !isOdd)
|
||||
pdf.Line(lx3, ly3, lx4, ly4)
|
||||
if isOdd { pdf.RectFromUpperLeft(bx-n.BulgeR, by-n.BulgeR, n.BulgeR*2, n.BulgeR*2) } else { pdf.Oval(bx-n.BulgeR, by-n.BulgeR, bx+n.BulgeR, by+n.BulgeR) }
|
||||
|
||||
pdf.SetTextColor(120, 120, 120)
|
||||
drawCenteredText(pdf, bx, by, strconv.Itoa(n.Number+1), math.Max(7, n.BulgeR*1.2))
|
||||
}
|
||||
@@ -29,9 +29,24 @@ func (t *learnNumberTool) Init() error {
|
||||
|
||||
func (t *learnNumberTool) RegisterRoutes(r *gin.RouterGroup) {
|
||||
r.POST("/counting", t.handleCounting)
|
||||
r.POST("/writing", t.handleWriting)
|
||||
r.GET("/categories", t.handleCategories)
|
||||
}
|
||||
|
||||
func (t *learnNumberTool) handleWriting(c *gin.Context) {
|
||||
var req logic.WritingRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
pdfBytes, err := logic.GenerateWritingPDF(req)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.Data(http.StatusOK, "application/pdf", pdfBytes)
|
||||
}
|
||||
|
||||
func (t *learnNumberTool) handleCategories(c *gin.Context) {
|
||||
// 返回分类信息供前端预览
|
||||
c.JSON(http.StatusOK, data.IconCategories)
|
||||
|
||||
Reference in New Issue
Block a user