feat: Add Learn-Number tool for preschool math practice and refactor HTML components
Build and Push Docker Image / build (push) Successful in 2m55s
Build and Push Docker Image / build (push) Successful in 2m55s
This commit is contained in:
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
# Binaries
|
# Binaries
|
||||||
toolbox
|
/toolbox
|
||||||
*.exe
|
*.exe
|
||||||
*.exe~
|
*.exe~
|
||||||
*.dll
|
*.dll
|
||||||
|
|||||||
+3
-2
@@ -11,6 +11,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
"toolbox/pkg/base"
|
"toolbox/pkg/base"
|
||||||
|
_ "toolbox/pkg/learnnumber"
|
||||||
_ "toolbox/pkg/zitie" // 匿名导入以触发 init()
|
_ "toolbox/pkg/zitie" // 匿名导入以触发 init()
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -55,8 +56,8 @@ func main() {
|
|||||||
r.StaticFS("/static", http.FS(subFS))
|
r.StaticFS("/static", http.FS(subFS))
|
||||||
|
|
||||||
// 3. 模板引擎初始化
|
// 3. 模板引擎初始化
|
||||||
// 关键修复:先 Sub 再 Parse,确保路径匹配
|
// 递归加载 web 目录下所有的 .html 文件
|
||||||
tmpl, err := template.ParseFS(subFS, "layout.html", "index.html")
|
tmpl, err := template.ParseFS(subFS, "layout.html", "index.html", "tools/*.html")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Failed to parse templates: %v", err)
|
log.Fatalf("Failed to parse templates: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
+43
-70
@@ -1,51 +1,10 @@
|
|||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
<!-- 汉字字帖面板 -->
|
|
||||||
<div id="panel-zitie" class="tool-panel">
|
|
||||||
<header class="page-header">
|
|
||||||
<h1>汉字字帖生成器</h1>
|
|
||||||
<p>生成高颜值的硬笔/毛笔书法练习帖,支持 A4/Letter 高清矢量打印。</p>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div class="tabs-container">
|
<!-- 注入各个工具面板 -->
|
||||||
<div id="tab-teaching" class="tab-btn active" onclick="switchZitieTab('teaching')">2x3 教学方格</div>
|
{{template "zitie" .}}
|
||||||
<div id="tab-step" class="tab-btn" onclick="switchZitieTab('step')">步进式分解</div>
|
{{template "learn_number" .}}
|
||||||
<div id="tab-manuscript" class="tab-btn" onclick="switchZitieTab('manuscript')">古风竖排</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card">
|
<!-- 欢迎面板 -->
|
||||||
<div style="display: flex; flex-direction: column; gap: 24px;">
|
|
||||||
<div class="input-group">
|
|
||||||
<label id="input-label">输入汉字内容 (2x3 教学方格模式下标点将被自动过滤)</label>
|
|
||||||
<textarea id="chars" placeholder="支持多行输入...">永和九年,岁在癸丑。</textarea>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style="display: flex; gap: 20px; align-items: flex-end; flex-wrap: wrap;">
|
|
||||||
<div style="flex: 1; min-width: 200px;">
|
|
||||||
<label style="font-size: 13px; font-weight: 600; color: #86868b; display: block; margin-bottom: 8px;">纸张大小</label>
|
|
||||||
<select id="paper_size">
|
|
||||||
<option value="A4">A4 (210x297mm)</option>
|
|
||||||
<option value="Letter">Letter (8.5x11in)</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div id="font-select-group" style="flex: 1; min-width: 200px; display: none;">
|
|
||||||
<label style="font-size: 13px; font-weight: 600; color: #86868b; display: block; margin-bottom: 8px;">书法字体</label>
|
|
||||||
<select id="font_type">
|
|
||||||
<option value="kaiti">华光楷体</option>
|
|
||||||
<option value="xingshu">华光行草</option>
|
|
||||||
<option value="lishu">华光隶变</option>
|
|
||||||
<option value="songti">华光书宋</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<button onclick="generatePDF()">生成高清预览</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div id="pdf-preview-container" style="display:none; width: 100%; height: 850px; border-radius: 20px; overflow: hidden; box-shadow: 0 10px 40px rgba(0,0,0,0.1); background: #fff; border: 1px solid #d2d2d7;">
|
|
||||||
<iframe id="pdf-frame" style="width: 100%; height: 100%; border: none;"></iframe>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 默认欢迎面板 -->
|
|
||||||
<div id="panel-welcome" class="tool-panel">
|
<div id="panel-welcome" class="tool-panel">
|
||||||
<header class="page-header">
|
<header class="page-header">
|
||||||
<h1>欢迎使用个人工具箱</h1>
|
<h1>欢迎使用个人工具箱</h1>
|
||||||
@@ -55,53 +14,67 @@
|
|||||||
<div style="margin-top: 40px; display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 24px;">
|
<div style="margin-top: 40px; display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 24px;">
|
||||||
<div class="card" style="cursor:pointer; transition: transform 0.2s;" onmouseover="this.style.transform='translateY(-5px)'" onmouseout="this.style.transform='translateY(0)'" onclick="navigateTo('/zitie')">
|
<div class="card" style="cursor:pointer; transition: transform 0.2s;" onmouseover="this.style.transform='translateY(-5px)'" onmouseout="this.style.transform='translateY(0)'" onclick="navigateTo('/zitie')">
|
||||||
<h3 style="margin-top:0;">🖋️ 汉字字帖生成</h3>
|
<h3 style="margin-top:0;">🖋️ 汉字字帖生成</h3>
|
||||||
<p style="color: #86868b; line-height: 1.5;">支持多种书法字体的 2x3 教学方格、步进分解和古风竖排信纸。专为硬笔和毛笔练习设计。</p>
|
<p style="color: #86868b; line-height: 1.5;">支持多种书法字体的 2x3 教学方格、步进分解和古风竖排信纸。</p>
|
||||||
|
<div style="color: var(--apple-blue); font-weight: 600; margin-top: 16px;">立即开始 →</div>
|
||||||
|
</div>
|
||||||
|
<div class="card" style="cursor:pointer; transition: transform 0.2s;" onmouseover="this.style.transform='translateY(-5px)'" onmouseout="this.style.transform='translateY(0)'" onclick="navigateTo('/learn-number')">
|
||||||
|
<h3 style="margin-top:0;">🔢 幼儿数学助手</h3>
|
||||||
|
<p style="color: #86868b; line-height: 1.5;">数图形、基础算术等趣味练习。培养孩子的数感与逻辑。</p>
|
||||||
<div style="color: var(--apple-blue); font-weight: 600; margin-top: 16px;">立即开始 →</div>
|
<div style="color: var(--apple-blue); font-weight: 600; margin-top: 16px;">立即开始 →</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
let currentMode = 'teaching';
|
// --- Zitie Tool Logic ---
|
||||||
|
let currentZitieMode = 'teaching';
|
||||||
function switchZitieTab(mode) {
|
function switchZitieTab(mode) {
|
||||||
currentMode = mode;
|
currentZitieMode = mode;
|
||||||
document.querySelectorAll('.tab-btn').forEach(btn => btn.classList.remove('active'));
|
document.querySelectorAll('#panel-zitie .tab-btn').forEach(btn => btn.classList.remove('active'));
|
||||||
document.getElementById(`tab-${mode}`).classList.add('active');
|
document.getElementById(`tab-${mode}`).classList.add('active');
|
||||||
document.getElementById('font-select-group').style.display = (mode === 'manuscript') ? 'block' : 'none';
|
document.getElementById('font-select-group').style.display = (mode === 'manuscript') ? 'block' : 'none';
|
||||||
|
|
||||||
const labels = {
|
|
||||||
'teaching': '输入汉字内容 (2x3 教学方格模式下标点将被自动过滤)',
|
|
||||||
'step': '输入汉字内容 (步进式分解模式下标点将被自动过滤)',
|
|
||||||
'manuscript': '输入汉字内容 (古风竖排支持换行,标点将被过滤)'
|
|
||||||
};
|
|
||||||
document.getElementById('input-label').innerText = labels[mode];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function generatePDF() {
|
async function generateZitiePDF() {
|
||||||
const chars = document.getElementById('chars').value;
|
const chars = document.getElementById('chars').value;
|
||||||
const paper_size = document.getElementById('paper_size').value;
|
const paper_size = document.getElementById('paper_size').value;
|
||||||
const font_type = document.getElementById('font_type').value;
|
const font_type = document.getElementById('font_type').value;
|
||||||
const btn = document.querySelector('button');
|
const btn = document.querySelector('#panel-zitie button');
|
||||||
const originalText = btn.innerText;
|
btn.innerText = '绘图中...'; btn.disabled = true;
|
||||||
btn.innerText = '正在绘图中...'; btn.disabled = true;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/zitie/${currentMode}`, {
|
const response = await fetch(`/api/zitie/${currentZitieMode}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ chars, paper_size, font_type })
|
body: JSON.stringify({ chars, paper_size, font_type })
|
||||||
});
|
});
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const blob = await response.blob();
|
const blob = await response.blob();
|
||||||
document.getElementById('pdf-preview-container').style.display = 'block';
|
document.getElementById('zitie-preview').style.display = 'block';
|
||||||
document.getElementById('pdf-frame').src = URL.createObjectURL(blob);
|
document.getElementById('zitie-frame').src = URL.createObjectURL(blob);
|
||||||
if(window.innerWidth < 768) document.getElementById('pdf-preview-container').scrollIntoView({behavior: 'smooth'});
|
|
||||||
} else { alert('生成失败'); }
|
|
||||||
} catch (e) { alert('网络错误'); } finally {
|
|
||||||
btn.innerText = originalText;
|
|
||||||
btn.disabled = false;
|
|
||||||
}
|
}
|
||||||
|
} catch (e) { alert('错误'); } finally { btn.innerText = '生成字帖预览'; btn.disabled = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Math Tool Logic ---
|
||||||
|
async function generateMathPDF() {
|
||||||
|
const total_count = parseInt(document.getElementById('total_count').value);
|
||||||
|
const icon_types = parseInt(document.getElementById('icon_types').value);
|
||||||
|
const paper_size = document.getElementById('math_paper_size').value;
|
||||||
|
const btn = document.querySelector('#panel-learn-number button');
|
||||||
|
btn.innerText = '生成中...'; btn.disabled = true;
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/learn-number/counting', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ total_count, icon_types, paper_size })
|
||||||
|
});
|
||||||
|
if (response.ok) {
|
||||||
|
const blob = await response.blob();
|
||||||
|
document.getElementById('math-preview').style.display = 'block';
|
||||||
|
document.getElementById('math-frame').src = URL.createObjectURL(blob);
|
||||||
|
if(window.innerWidth < 768) document.getElementById('math-preview').scrollIntoView({behavior: 'smooth'});
|
||||||
|
}
|
||||||
|
} catch (e) { alert('错误'); } finally { btn.innerText = '生成数学练习帖'; btn.disabled = false; }
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
{{define "learn_number"}}
|
||||||
|
<div id="panel-learn-number" class="tool-panel">
|
||||||
|
<header class="page-header">
|
||||||
|
<h1>幼儿数学助手</h1>
|
||||||
|
<p>通过趣味图形和基础练习,培养孩子的数感与逻辑。</p>
|
||||||
|
</header>
|
||||||
|
<div class="tabs-container">
|
||||||
|
<div id="tab-counting" class="tab-btn active">数图形练习</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div style="display: flex; flex-direction: column; gap: 24px;">
|
||||||
|
<div style="display: flex; gap: 30px; flex-wrap: wrap;">
|
||||||
|
<div style="flex: 1; min-width: 250px;">
|
||||||
|
<label style="font-size: 13px; font-weight: 600; color: #86868b; display: block; margin-bottom: 12px;">图标种类数 (1 - 5)</label>
|
||||||
|
<input type="range" id="icon_types" min="1" max="5" value="3" style="width: 100%;" oninput="this.nextElementSibling.value = this.value">
|
||||||
|
<output style="font-size: 18px; font-weight: 700; color: var(--apple-blue); margin-top: 10px; display: block;">3</output>
|
||||||
|
</div>
|
||||||
|
<div style="flex: 1; min-width: 250px;">
|
||||||
|
<label style="font-size: 13px; font-weight: 600; color: #86868b; display: block; margin-bottom: 12px;">总图案数量 (种类数 - 30)</label>
|
||||||
|
<input type="range" id="total_count" min="5" max="30" value="15" style="width: 100%;" oninput="this.nextElementSibling.value = this.value">
|
||||||
|
<output style="font-size: 18px; font-weight: 700; color: var(--apple-blue); margin-top: 10px; display: block;">15</output>
|
||||||
|
</div>
|
||||||
|
<div style="width: 240px;">
|
||||||
|
<label>纸张大小</label>
|
||||||
|
<select id="math_paper_size">
|
||||||
|
<option value="A4">A4</option>
|
||||||
|
<option value="Letter">Letter</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button onclick="generateMathPDF()">生成数学练习帖</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="math-preview" style="display:none; width: 100%; height: 800px; border-radius: 20px; overflow: hidden; box-shadow: 0 10px 40px rgba(0,0,0,0.1); background: #fff; border: 1px solid #d2d2d7;">
|
||||||
|
<iframe id="math-frame" style="width:100%; height:100%; border:none;"></iframe>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
{{define "zitie"}}
|
||||||
|
<div id="panel-zitie" class="tool-panel">
|
||||||
|
<header class="page-header">
|
||||||
|
<h1>汉字字帖生成器</h1>
|
||||||
|
<p>生成高颜值的硬笔/毛笔书法练习帖。</p>
|
||||||
|
</header>
|
||||||
|
<div class="tabs-container">
|
||||||
|
<div id="tab-teaching" class="tab-btn active" onclick="switchZitieTab('teaching')">2x3 教学方格</div>
|
||||||
|
<div id="tab-step" class="tab-btn" onclick="switchZitieTab('step')">步进式分解</div>
|
||||||
|
<div id="tab-manuscript" class="tab-btn" onclick="switchZitieTab('manuscript')">古风竖排</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div style="display: flex; flex-direction: column; gap: 24px;">
|
||||||
|
<div class="input-group">
|
||||||
|
<label id="input-label">输入汉字内容</label>
|
||||||
|
<textarea id="chars" placeholder="支持多行输入...">永和九年,岁在癸丑。</textarea>
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; gap: 20px; align-items: flex-end; flex-wrap: wrap;">
|
||||||
|
<div style="flex: 1; min-width: 200px;">
|
||||||
|
<label>纸张大小</label>
|
||||||
|
<select id="paper_size">
|
||||||
|
<option value="A4">A4 (210x297mm)</option>
|
||||||
|
<option value="Letter">Letter (8.5x11in)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div id="font-select-group" style="flex: 1; min-width: 200px; display: none;">
|
||||||
|
<label>书法字体</label>
|
||||||
|
<select id="font_type">
|
||||||
|
<option value="kaiti">华光楷体</option>
|
||||||
|
<option value="xingshu">华光行草</option>
|
||||||
|
<option value="lishu">华光隶变</option>
|
||||||
|
<option value="songti">华光书宋</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button onclick="generateZitiePDF()">生成字帖预览</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="zitie-preview" style="display:none; width: 100%; height: 800px; border-radius: 20px; overflow: hidden; box-shadow: 0 10px 40px rgba(0,0,0,0.1); background: #fff; border: 1px solid #d2d2d7;">
|
||||||
|
<iframe id="zitie-frame" style="width:100%; height:100%; border:none;"></iframe>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
+10
-8
@@ -1,8 +1,13 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Namespace
|
||||||
|
metadata:
|
||||||
|
name: toolbox
|
||||||
|
---
|
||||||
apiVersion: apps/v1
|
apiVersion: apps/v1
|
||||||
kind: Deployment
|
kind: Deployment
|
||||||
metadata:
|
metadata:
|
||||||
name: own-tools
|
name: own-tools
|
||||||
namespace: default
|
namespace: toolbox
|
||||||
spec:
|
spec:
|
||||||
replicas: 1
|
replicas: 1
|
||||||
selector:
|
selector:
|
||||||
@@ -13,9 +18,6 @@ spec:
|
|||||||
labels:
|
labels:
|
||||||
app: own-tools
|
app: own-tools
|
||||||
spec:
|
spec:
|
||||||
# 如果你的 Gitea 仓库是私有的,请取消下面注释并创建对应的 Secret
|
|
||||||
# imagePullSecrets:
|
|
||||||
# - name: gitea-registry-secret
|
|
||||||
containers:
|
containers:
|
||||||
- name: toolbox
|
- name: toolbox
|
||||||
image: git.pengzhan.dev/haopengzhan/own-tools:latest
|
image: git.pengzhan.dev/haopengzhan/own-tools:latest
|
||||||
@@ -52,8 +54,8 @@ spec:
|
|||||||
apiVersion: v1
|
apiVersion: v1
|
||||||
kind: Service
|
kind: Service
|
||||||
metadata:
|
metadata:
|
||||||
name: own-tools
|
name: own-tools-service
|
||||||
namespace: default
|
namespace: toolbox
|
||||||
spec:
|
spec:
|
||||||
selector:
|
selector:
|
||||||
app: own-tools
|
app: own-tools
|
||||||
@@ -67,7 +69,7 @@ apiVersion: networking.k8s.io/v1
|
|||||||
kind: Ingress
|
kind: Ingress
|
||||||
metadata:
|
metadata:
|
||||||
name: own-tools-ingress
|
name: own-tools-ingress
|
||||||
namespace: default
|
namespace: toolbox
|
||||||
annotations:
|
annotations:
|
||||||
cert-manager.io/cluster-issuer: letsencrypt-prod
|
cert-manager.io/cluster-issuer: letsencrypt-prod
|
||||||
spec:
|
spec:
|
||||||
@@ -80,7 +82,7 @@ spec:
|
|||||||
pathType: Prefix
|
pathType: Prefix
|
||||||
backend:
|
backend:
|
||||||
service:
|
service:
|
||||||
name: own-tools
|
name: own-tools-service
|
||||||
port:
|
port:
|
||||||
number: 80
|
number: 80
|
||||||
tls:
|
tls:
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package data
|
||||||
|
|
||||||
|
type Icon struct {
|
||||||
|
Name string
|
||||||
|
Paths []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// 升级版:更卡通、更饱满的简笔画
|
||||||
|
var CountingIcons = []Icon{
|
||||||
|
{Name: "Bear", Paths: []string{
|
||||||
|
"M 512 800 C 300 800 200 700 200 500 C 200 300 350 200 512 200 C 674 200 824 300 824 500 C 824 700 724 800 512 800 Z", // Body
|
||||||
|
"M 300 300 m -50 0 a 50 50 0 1 0 100 0 a 50 50 0 1 0 -100 0", // Ear L
|
||||||
|
"M 724 300 m -50 0 a 50 50 0 1 0 100 0 a 50 50 0 1 0 -100 0", // Ear R
|
||||||
|
"M 400 450 m -20 0 a 20 20 0 1 0 40 0 a 20 20 0 1 0 -40 0", // Eye L
|
||||||
|
"M 624 450 m -20 0 a 20 20 0 1 0 40 0 a 20 20 0 1 0 -40 0", // Eye R
|
||||||
|
"M 512 550 Q 512 650 400 650 M 512 550 Q 512 650 624 650", // Mouth
|
||||||
|
}},
|
||||||
|
{Name: "Cat", Paths: []string{
|
||||||
|
"M 200 800 L 300 400 L 400 200 L 512 350 L 624 200 L 724 400 L 824 800 Z", // Head
|
||||||
|
"M 400 550 m -15 0 a 15 15 0 1 0 30 0 a 15 15 0 1 0 -30 0", // Eye L
|
||||||
|
"M 624 550 m -15 0 a 15 15 0 1 0 30 0 a 15 15 0 1 0 -30 0", // Eye R
|
||||||
|
"M 512 650 L 450 700 M 512 650 L 574 700", // Nose
|
||||||
|
}},
|
||||||
|
{Name: "Car", Paths: []string{
|
||||||
|
"M 100 700 L 100 500 Q 100 400 300 400 L 700 400 Q 900 400 900 500 L 900 700 Z", // Body
|
||||||
|
"M 250 700 m -60 0 a 60 60 0 1 0 120 0 a 60 60 0 1 0 -120 0", // Wheel L
|
||||||
|
"M 750 700 m -60 0 a 60 60 0 1 0 120 0 a 60 60 0 1 0 -120 0", // Wheel R
|
||||||
|
"M 300 400 L 400 250 L 624 250 L 724 400", // Roof
|
||||||
|
}},
|
||||||
|
{Name: "Bird", Paths: []string{
|
||||||
|
"M 512 512 m -300 0 a 300 300 0 1 0 600 0 a 300 300 0 1 0 -600 0", // Body
|
||||||
|
"M 812 512 L 950 450 L 812 400 Z", // Beak
|
||||||
|
"M 400 400 m -20 0 a 20 20 0 1 0 40 0 a 20 20 0 1 0 -40 0", // Eye
|
||||||
|
"M 212 512 Q 100 400 212 300", // Wing
|
||||||
|
}},
|
||||||
|
{Name: "Rocket", Paths: []string{
|
||||||
|
"M 512 100 Q 700 400 700 800 L 324 800 Q 324 400 512 100 Z", // Body
|
||||||
|
"M 512 400 m -50 0 a 50 50 0 1 0 100 0 a 50 50 0 1 0 -100 0", // Window
|
||||||
|
"M 324 800 L 200 950 L 324 900 M 700 800 L 824 950 L 700 900", // Fins
|
||||||
|
}},
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
package logic
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"math"
|
||||||
|
"math/rand"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
"toolbox/pkg/learnnumber/data"
|
||||||
|
|
||||||
|
"github.com/signintech/gopdf"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CountingRequest struct {
|
||||||
|
TotalCount int `json:"total_count"`
|
||||||
|
IconTypes int `json:"icon_types"`
|
||||||
|
PaperSize string `json:"paper_size"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var reSVG = regexp.MustCompile(`([MLQCZmlqcz])|(-?\d+\.?\d*)`)
|
||||||
|
|
||||||
|
func GenerateCountingPDF(req CountingRequest) ([]byte, error) {
|
||||||
|
rand.Seed(time.Now().UnixNano())
|
||||||
|
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})
|
||||||
|
pdf.AddPage()
|
||||||
|
|
||||||
|
drawProblem(pdf, req, 0, rect.W, rect.H/2)
|
||||||
|
drawProblem(pdf, req, rect.H/2, rect.W, rect.H/2)
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
_, err := pdf.WriteTo(&buf)
|
||||||
|
return buf.Bytes(), err
|
||||||
|
}
|
||||||
|
|
||||||
|
func drawProblem(pdf *gopdf.GoPdf, req CountingRequest, startY, pW, pH float64) {
|
||||||
|
margin := 40.0
|
||||||
|
boxW := (pW - 2*margin) * 0.7
|
||||||
|
boxH := pH - 60.0
|
||||||
|
x, y := margin, startY + 30.0
|
||||||
|
|
||||||
|
// 1. 绘制方框
|
||||||
|
pdf.SetStrokeColor(0, 0, 0); pdf.SetLineWidth(2.0)
|
||||||
|
pdf.RectFromUpperLeft(x, y, boxW, boxH)
|
||||||
|
|
||||||
|
// 2. 分配图标数量
|
||||||
|
numTypes := req.IconTypes; if numTypes < 1 { numTypes = 1 }
|
||||||
|
if numTypes > len(data.CountingIcons) { numTypes = len(data.CountingIcons) }
|
||||||
|
|
||||||
|
total := req.TotalCount; if total < numTypes { total = numTypes }
|
||||||
|
if total > 30 { total = 30 }
|
||||||
|
|
||||||
|
// 随机选出图标种类
|
||||||
|
allIcons := rand.Perm(len(data.CountingIcons))
|
||||||
|
selectedIcons := []data.Icon{}
|
||||||
|
counts := []int{}
|
||||||
|
|
||||||
|
// 先每种分配1个
|
||||||
|
rem := total - numTypes
|
||||||
|
for i := 0; i < numTypes; i++ {
|
||||||
|
selectedIcons = append(selectedIcons, data.CountingIcons[allIcons[i]])
|
||||||
|
counts = append(counts, 1)
|
||||||
|
}
|
||||||
|
// 随机分配剩下的
|
||||||
|
for i := 0; i < rem; i++ {
|
||||||
|
counts[rand.Intn(numTypes)]++
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 布局计划 (使用网格,增加 Padding 避开边框)
|
||||||
|
rows, cols := 5, 6
|
||||||
|
cellW, cellH := boxW/float64(cols), boxH/float64(rows)
|
||||||
|
indices := rand.Perm(rows * cols)
|
||||||
|
|
||||||
|
// 严格图标大小:单元格的 70%,确保不溢出单元格边界
|
||||||
|
iconSize := math.Min(cellW, cellH) * 0.7
|
||||||
|
paddingX, paddingY := (cellW - iconSize)/2, (cellH - iconSize)/2
|
||||||
|
|
||||||
|
iconTypeIdx := 0
|
||||||
|
countInCurrentType := 0
|
||||||
|
|
||||||
|
for i := 0; i < total; i++ {
|
||||||
|
cellIdx := indices[i]
|
||||||
|
rIdx, cIdx := cellIdx/cols, cellIdx%cols
|
||||||
|
|
||||||
|
// 基础位置
|
||||||
|
baseX := x + float64(cIdx)*cellW + paddingX
|
||||||
|
baseY := y + float64(rIdx)*cellH + paddingY
|
||||||
|
|
||||||
|
// 渲染图标
|
||||||
|
drawIcon(pdf, selectedIcons[iconTypeIdx], baseX + iconSize/2, baseY + iconSize/2, iconSize)
|
||||||
|
|
||||||
|
countInCurrentType++
|
||||||
|
if countInCurrentType >= counts[iconTypeIdx] {
|
||||||
|
iconTypeIdx++
|
||||||
|
countInCurrentType = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 绘制右侧对照表
|
||||||
|
legendX := x + boxW + 20.0
|
||||||
|
legendStepY := boxH / float64(numTypes + 1)
|
||||||
|
for i := 0; i < numTypes; i++ {
|
||||||
|
lY := y + float64(i+1)*legendStepY
|
||||||
|
drawIcon(pdf, selectedIcons[i], legendX + 20, lY, 30)
|
||||||
|
pdf.SetStrokeColor(150, 150, 150); pdf.SetLineWidth(0.8)
|
||||||
|
pdf.RectFromUpperLeft(legendX + 50, lY - 15, 30, 30)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func drawIcon(pdf *gopdf.GoPdf, icon data.Icon, cX, cY, size float64) {
|
||||||
|
scale := size / 1024.0
|
||||||
|
pdf.SetStrokeColor(0, 0, 0); pdf.SetLineWidth(1.5) // 加粗线条
|
||||||
|
|
||||||
|
ox, oy := cX - size/2, cY - size/2
|
||||||
|
for _, pStr := range icon.Paths {
|
||||||
|
pts := parseSmoothPath(pStr, scale, ox, oy)
|
||||||
|
if len(pts) > 1 {
|
||||||
|
// 如果是闭合路径
|
||||||
|
if strings.Contains(strings.ToUpper(pStr), "Z") {
|
||||||
|
pdf.Polygon(pts, "D")
|
||||||
|
} else {
|
||||||
|
for i := 0; i < len(pts)-1; i++ {
|
||||||
|
pdf.Line(pts[i].X, pts[i].Y, pts[i+1].X, pts[i+1].Y)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 支持贝塞尔曲线采样,确保图标圆润
|
||||||
|
func parseSmoothPath(path string, scale, ox, oy float64) []gopdf.Point {
|
||||||
|
var pts []gopdf.Point
|
||||||
|
matches := reSVG.FindAllStringSubmatch(path, -1)
|
||||||
|
var lx, ly float64
|
||||||
|
for i := 0; i < len(matches); {
|
||||||
|
cmd := matches[i][0]
|
||||||
|
if cmd == "M" || cmd == "L" {
|
||||||
|
x, _ := strconv.ParseFloat(matches[i+1][0], 64); y, _ := strconv.ParseFloat(matches[i+2][0], 64)
|
||||||
|
lx, ly = x, y
|
||||||
|
pts = append(pts, gopdf.Point{X: ox + x*scale, Y: oy + y*scale})
|
||||||
|
i += 3
|
||||||
|
} else if cmd == "C" {
|
||||||
|
x1, _ := strconv.ParseFloat(matches[i+1][0], 64); y1, _ := strconv.ParseFloat(matches[i+2][0], 64)
|
||||||
|
x2, _ := strconv.ParseFloat(matches[i+3][0], 64); y2, _ := strconv.ParseFloat(matches[i+4][0], 64)
|
||||||
|
x, _ := strconv.ParseFloat(matches[i+5][0], 64); y, _ := strconv.ParseFloat(matches[i+6][0], 64)
|
||||||
|
for t := 0.25; t <= 1.0; t += 0.25 {
|
||||||
|
tx := math.Pow(1-t, 3)*lx + 3*math.Pow(1-t, 2)*t*x1 + 3*(1-t)*math.Pow(t, 2)*x2 + math.Pow(t, 3)*x
|
||||||
|
ty := math.Pow(1-t, 3)*ly + 3*math.Pow(1-t, 2)*t*y1 + 3*(1-t)*math.Pow(t, 2)*y2 + math.Pow(t, 3)*y
|
||||||
|
pts = append(pts, gopdf.Point{X: ox + tx*scale, Y: oy + ty*scale})
|
||||||
|
}
|
||||||
|
lx, ly = x, y; i += 7
|
||||||
|
} else if cmd == "Q" {
|
||||||
|
x1, _ := strconv.ParseFloat(matches[i+1][0], 64); y1, _ := strconv.ParseFloat(matches[i+2][0], 64)
|
||||||
|
x, _ := strconv.ParseFloat(matches[i+3][0], 64); y, _ := strconv.ParseFloat(matches[i+4][0], 64)
|
||||||
|
for t := 0.25; t <= 1.0; t += 0.25 {
|
||||||
|
tx := math.Pow(1-t, 2)*lx + 2*(1-t)*t*x1 + math.Pow(t, 2)*x
|
||||||
|
ty := math.Pow(1-t, 2)*ly + 2*(1-t)*t*y1 + math.Pow(t, 2)*y
|
||||||
|
pts = append(pts, gopdf.Point{X: ox + tx*scale, Y: oy + ty*scale})
|
||||||
|
}
|
||||||
|
lx, ly = x, y; i += 5
|
||||||
|
} else { i++ }
|
||||||
|
}
|
||||||
|
return pts
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package learnnumber
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"toolbox/pkg/base"
|
||||||
|
"toolbox/pkg/learnnumber/logic"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
type learnNumberTool struct{}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
base.Register(&learnNumberTool{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *learnNumberTool) ID() string { return "learn-number" }
|
||||||
|
func (t *learnNumberTool) Name() string { return "幼儿数学助手" }
|
||||||
|
func (t *learnNumberTool) Description() string { return "包含数图形、基础加减法等趣味数学练习" }
|
||||||
|
|
||||||
|
func (t *learnNumberTool) Init() error {
|
||||||
|
fmt.Println("Initializing Learn-Number tool...")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *learnNumberTool) RegisterRoutes(r *gin.RouterGroup) {
|
||||||
|
r.POST("/counting", t.handleCounting)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *learnNumberTool) handleCounting(c *gin.Context) {
|
||||||
|
var req logic.CountingRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.TotalCount <= 0 { req.TotalCount = 20 }
|
||||||
|
if req.TotalCount > 30 { req.TotalCount = 30 }
|
||||||
|
|
||||||
|
pdfBytes, err := logic.GenerateCountingPDF(req)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Data(http.StatusOK, "application/pdf", pdfBytes)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user