Initial commit: Modular personal toolbox with high-fidelity Chinese stroke order tool and CI/CD
Build and Push Docker Image / build (push) Failing after 2m11s
Build and Push Docker Image / build (push) Failing after 2m11s
This commit is contained in:
@@ -0,0 +1,86 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"flag"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"toolbox/pkg/base"
|
||||
_ "toolbox/pkg/jitie" // 匿名导入以触发 init()
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
//go:embed web/*
|
||||
var webFS embed.FS
|
||||
|
||||
func main() {
|
||||
// 定义端口 Flag
|
||||
portFlag := flag.Int("port", 0, "端口号 (优先于环境变量 PORT)")
|
||||
flag.Parse()
|
||||
|
||||
// 禁用 Gin 的默认重定向逻辑,防止与静态资源冲突
|
||||
r := gin.New()
|
||||
r.Use(gin.Logger(), gin.Recovery())
|
||||
r.RedirectTrailingSlash = false
|
||||
r.RedirectFixedPath = false
|
||||
|
||||
// 1. 自动发现并注册工具路由
|
||||
api := r.Group("/api")
|
||||
{
|
||||
for id, tool := range base.Registry {
|
||||
log.Printf("Loading tool: %s (%s)", tool.Name(), id)
|
||||
if err := tool.Init(); err != nil {
|
||||
log.Fatalf("Failed to initialize tool %s: %v", id, err)
|
||||
}
|
||||
group := api.Group("/" + id)
|
||||
tool.RegisterRoutes(group)
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 静态资源路由 (内嵌前端)
|
||||
// 根路径直接返回 index.html 内容,不使用文件服务器转发
|
||||
r.GET("/", func(c *gin.Context) {
|
||||
content, err := webFS.ReadFile("web/index.html")
|
||||
if err != nil {
|
||||
c.String(http.StatusNotFound, "index.html not found")
|
||||
return
|
||||
}
|
||||
c.Data(http.StatusOK, "text/html; charset=utf-8", content)
|
||||
})
|
||||
|
||||
// 如果将来有 css/js,可以挂载到 /web 路径
|
||||
subFS, _ := fs.Sub(webFS, "web")
|
||||
r.StaticFS("/web", http.FS(subFS))
|
||||
|
||||
// 3. 工具列表发现接口
|
||||
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. 确定最终使用的端口
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>个人工具箱 - 汉字字帖生成器</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background-color: #f5f5f7;
|
||||
color: #1d1d1f;
|
||||
}
|
||||
.container {
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
padding: 40px 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
header {
|
||||
text-align: center;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
header h1 { font-size: 32px; margin-bottom: 8px; }
|
||||
header p { color: #86868b; margin: 0; }
|
||||
|
||||
.card {
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
padding: 28px;
|
||||
box-shadow: 0 8px 30px rgba(0,0,0,0.04);
|
||||
}
|
||||
|
||||
.form-layout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.input-row-top {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.input-row-bottom {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.input-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.input-group.flex-main { flex: 2; }
|
||||
.input-group.flex-side { flex: 1; }
|
||||
|
||||
label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #1d1d1f;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
input[type="text"] {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 14px 16px;
|
||||
border: 1px solid #d2d2d7;
|
||||
border-radius: 10px;
|
||||
font-size: 18px;
|
||||
background-color: #fbfbfd;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
input[type="text"]:focus {
|
||||
border-color: #0071e3;
|
||||
background-color: #fff;
|
||||
box-shadow: 0 0 0 4px rgba(0,113,227,0.1);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
select {
|
||||
appearance: none;
|
||||
padding: 12px 16px;
|
||||
border: 1px solid #d2d2d7;
|
||||
border-radius: 10px;
|
||||
font-size: 15px;
|
||||
background-color: #fbfbfd;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button {
|
||||
background-color: #0071e3;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 14px 32px;
|
||||
border-radius: 10px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
height: 48px;
|
||||
}
|
||||
button:hover { background-color: #0077ed; transform: translateY(-1px); }
|
||||
button:active { transform: translateY(0); }
|
||||
|
||||
#preview-container {
|
||||
width: 100%;
|
||||
height: 850px;
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.08);
|
||||
display: none;
|
||||
}
|
||||
iframe { width: 100%; height: 100%; border: none; }
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 80px 0;
|
||||
border: 2px dashed #d2d2d7;
|
||||
border-radius: 16px;
|
||||
color: #86868b;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<header>
|
||||
<h1>汉字字帖工具箱</h1>
|
||||
<p>生成标准笔顺教学字帖,支持 A4/Letter 打印</p>
|
||||
</header>
|
||||
|
||||
<div class="card">
|
||||
<div class="form-layout">
|
||||
<div class="input-row-top">
|
||||
<div class="input-group">
|
||||
<label for="chars">输入想要生成的汉字</label>
|
||||
<input type="text" id="chars" placeholder="例如:永和九年,岁在癸丑..." value="永">
|
||||
</div>
|
||||
</div>
|
||||
<div class="input-row-bottom">
|
||||
<div class="input-group flex-main">
|
||||
<label for="mode">布局模式</label>
|
||||
<select id="mode">
|
||||
<option value="teaching">2x3 教学方格 (带笔顺、箭头、序号)</option>
|
||||
<option value="step">步进式分解 (逐笔展示过程)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="input-group flex-side">
|
||||
<label for="paper_size">纸张大小</label>
|
||||
<select id="paper_size">
|
||||
<option value="A4">A4 (210x297mm)</option>
|
||||
<option value="Letter">Letter (8.5x11in)</option>
|
||||
</select>
|
||||
</div>
|
||||
<button onclick="generatePDF()">生成预览</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="preview-container">
|
||||
<iframe id="pdf-frame"></iframe>
|
||||
</div>
|
||||
|
||||
<div id="empty-state" class="empty-state">
|
||||
<p>在上方输入汉字并点击按钮,即可生成高清矢量字帖预览</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
async function generatePDF() {
|
||||
const chars = document.getElementById('chars').value;
|
||||
const mode = document.getElementById('mode').value;
|
||||
const paper_size = document.getElementById('paper_size').value;
|
||||
|
||||
if (!chars.trim()) {
|
||||
alert('请输入汉字');
|
||||
return;
|
||||
}
|
||||
|
||||
const btn = document.querySelector('button');
|
||||
const originalText = btn.innerText;
|
||||
btn.innerText = '正在绘图中...';
|
||||
btn.disabled = true;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/jitie/generate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
chars: chars,
|
||||
mode: mode,
|
||||
flip_y: true,
|
||||
paper_size: paper_size
|
||||
})
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const blob = await response.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
document.getElementById('empty-state').style.display = 'none';
|
||||
const container = document.getElementById('preview-container');
|
||||
container.style.display = 'block';
|
||||
document.getElementById('pdf-frame').src = url;
|
||||
} else {
|
||||
const err = await response.json();
|
||||
alert('生成失败: ' + (err.error || '未知错误'));
|
||||
}
|
||||
} catch (e) {
|
||||
alert('网络请求失败,请检查后端服务是否运行');
|
||||
} finally {
|
||||
btn.innerText = originalText;
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user