Initial commit: Modular personal toolbox with high-fidelity Chinese stroke order tool and CI/CD
Build and Push Docker Image / build (push) Successful in 2m45s

This commit is contained in:
2026-02-23 02:04:11 -08:00
commit fc74b7c9f7
22 changed files with 1131 additions and 0 deletions
+108
View File
@@ -0,0 +1,108 @@
package main
import (
"bytes"
"embed"
"flag"
"html/template"
"io/fs"
"log"
"net/http"
"os"
"strconv"
"toolbox/pkg/base"
_ "toolbox/pkg/zitie" // 匿名导入以触发 init()
"github.com/gin-gonic/gin"
)
//go:embed web/*
var webFS embed.FS
func main() {
portFlag := flag.Int("port", 0, "端口号 (优先于环境变量 PORT)")
gaFlag := flag.String("ga", "", "Google Analytics ID")
flag.Parse()
gaID := *gaFlag
if gaID == "" {
gaID = os.Getenv("GA_ID")
}
if os.Getenv("GIN_MODE") == "release" {
gin.SetMode(gin.ReleaseMode)
}
r := gin.New()
r.Use(gin.Logger(), gin.Recovery())
r.RedirectTrailingSlash = false
r.RedirectFixedPath = false
_ = r.SetTrustedProxies(nil)
// 1. 自动发现并注册工具路由
api := r.Group("/api")
{
for id, tool := range base.Registry {
if err := tool.Init(); err != nil {
log.Fatalf("Failed to initialize tool %s: %v", id, err)
}
tool.RegisterRoutes(api.Group("/" + id))
}
}
// 2. 静态资源
subFS, _ := fs.Sub(webFS, "web")
r.StaticFS("/static", http.FS(subFS))
// 3. 模板引擎初始化
tmpl, err := template.ParseFS(webFS, "web/layout.html", "web/index.html")
if err != nil {
log.Fatalf("Failed to parse templates: %v", err)
}
// 通用页面渲染函数
serveIndex := func(c *gin.Context) {
data := gin.H{
"Title": "Own-Tools",
"GA_ID": gaID,
}
var buf bytes.Buffer
if err := tmpl.ExecuteTemplate(&buf, "layout.html", data); err != nil {
c.String(http.StatusInternalServerError, "Template error: %v", err)
return
}
c.Data(http.StatusOK, "text/html; charset=utf-8", buf.Bytes())
}
r.GET("/", serveIndex)
r.GET("/api/tools", func(c *gin.Context) {
var list []map[string]string
for _, t := range base.Registry {
list = append(list, map[string]string{
"id": t.ID(),
"name": t.Name(),
"desc": t.Description(),
})
}
c.JSON(http.StatusOK, list)
})
// 4. 全路径回退 (SPA Routing)
r.NoRoute(serveIndex)
// 5. 启动
var finalPort string
if *portFlag > 0 {
finalPort = strconv.Itoa(*portFlag)
} else if envPort := os.Getenv("PORT"); envPort != "" {
finalPort = envPort
} else {
finalPort = "8080"
}
log.Printf("Toolbox server starting on :%s", finalPort)
if err := r.Run(":" + finalPort); err != nil {
log.Fatalf("Server failed: %v", err)
}
}
+107
View File
@@ -0,0 +1,107 @@
{{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>
<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">输入汉字内容 (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">
<header class="page-header">
<h1>欢迎使用个人工具箱</h1>
<p>这是您的私人效率基地。请从侧边栏或下方列表选择一个功能开始。</p>
</header>
<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')">
<h3 style="margin-top:0;">🖋️ 汉字字帖生成</h3>
<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>
</div>
<script>
let currentMode = 'teaching';
function switchZitieTab(mode) {
currentMode = mode;
document.querySelectorAll('.tab-btn').forEach(btn => btn.classList.remove('active'));
document.getElementById(`tab-${mode}`).classList.add('active');
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() {
const chars = document.getElementById('chars').value;
const paper_size = document.getElementById('paper_size').value;
const font_type = document.getElementById('font_type').value;
const btn = document.querySelector('button');
const originalText = btn.innerText;
btn.innerText = '正在绘图中...'; btn.disabled = true;
try {
const response = await fetch(`/api/zitie/${currentMode}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ chars, paper_size, font_type })
});
if (response.ok) {
const blob = await response.blob();
document.getElementById('pdf-preview-container').style.display = 'block';
document.getElementById('pdf-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;
}
}
</script>
{{end}}