Compare commits
34 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
82ab714405 | ||
|
|
b7dac22327 | ||
|
|
1fb3b51beb | ||
|
|
e4e552cca6 | ||
|
|
35e17b4ba2 | ||
|
|
797da6eb76 | ||
|
|
88648748e7 | ||
|
|
46327636b0 | ||
|
|
3fc17e7f45 | ||
|
|
a8f6168433 | ||
|
|
9d8316332b | ||
|
|
e55d0d00c0 | ||
|
|
63f11b0067 | ||
|
|
cc6c9c8a99 | ||
|
|
b0c7ea4cba | ||
|
|
3d5d517439 | ||
|
|
b3add14421 | ||
|
|
74eebc19db | ||
|
|
2a737f2857 | ||
|
|
eb27b93d1e | ||
|
|
3bbd9e3069 | ||
|
|
22685a412b | ||
|
|
91fa46bd0c | ||
|
|
494c9aec0e | ||
|
|
05ccc985c5 | ||
|
|
2329d36260 | ||
|
|
4c7cbc8ae2 | ||
|
|
831d708838 | ||
|
|
859ec836df | ||
|
|
1076ca1fa0 | ||
|
|
7899b42e5c | ||
|
|
b5f5ade1f3 | ||
|
|
0dbb991522 | ||
|
|
5e4fffbce4 |
@@ -1,15 +0,0 @@
|
|||||||
# 生产环境配置,此文件不会被提交到 Git(已在 .gitignore 中)
|
|
||||||
# Supabase 配置(服务端/脚本使用)
|
|
||||||
SUPABASE_URL=https://fwwsxicqmsoimyrafyek.supabase.co
|
|
||||||
SUPABASE_KEY=sb_publishable_FgA0knvKiNMJEv4k-Q1cAA_IqZW0kjn
|
|
||||||
SUPABASE_SECRET_KEY=sb_secret_8uVHDbhMxE4HOlMBxMWjjw_vsHoZVI8
|
|
||||||
|
|
||||||
ALIBABA_CLOUD_ACCESS_KEY_ID=LTAI5tDM8CzT8ABCdYKrfzH8
|
|
||||||
ALIBABA_CLOUD_ACCESS_KEY_SECRET=hCejmpYoaCGehw2I4jcJo2qd2TwB62
|
|
||||||
|
|
||||||
# 微信小程序 appID和apprSecret
|
|
||||||
mpAppID=wxeda897f274ff33cf
|
|
||||||
mpAppSecret=a2240b3b6bbc3f7757ad689a89d334cc
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -9,8 +9,6 @@ lerna-debug.log*
|
|||||||
|
|
||||||
node_modules
|
node_modules
|
||||||
.npm-cache
|
.npm-cache
|
||||||
dist
|
|
||||||
dist-ssr
|
|
||||||
*.local
|
*.local
|
||||||
|
|
||||||
# Editor directories and files
|
# Editor directories and files
|
||||||
@@ -26,7 +24,9 @@ dist-ssr
|
|||||||
|
|
||||||
*.ttf
|
*.ttf
|
||||||
frontend/vite.config.ts
|
frontend/vite.config.ts
|
||||||
|
frontend/dist/fonts.json
|
||||||
frontend/public/fonts.json
|
frontend/public/fonts.json
|
||||||
|
miniprogram/assets/fonts.json
|
||||||
|
|
||||||
# secrets
|
# secrets
|
||||||
.env
|
.env
|
||||||
@@ -35,3 +35,4 @@ frontend/public/fonts.json
|
|||||||
private.*.key
|
private.*.key
|
||||||
miniprogram/private.*.key
|
miniprogram/private.*.key
|
||||||
project.private.config.json
|
project.private.config.json
|
||||||
|
__pycache__
|
||||||
|
|||||||
5
AI_CONTEXT.md
Normal file
5
AI_CONTEXT.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# 项目 AI 协作说明
|
||||||
|
## 项目目录结构
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Binary file not shown.
Binary file not shown.
@@ -1,5 +1,7 @@
|
|||||||
# Font2SVG - Nginx 配置(mac.biboer.cn)
|
# Font2SVG - Mac Nginx 配置(mac.biboer.cn / mac-tunnel.biboer.cn)
|
||||||
# 用途:为微信小程序提供静态字体资源 + 远端 SVG 渲染 API
|
# 用途:
|
||||||
|
# 1) 为微信小程序提供字体清单、默认配置、路由配置与渲染 API
|
||||||
|
# 2) 提供 Web 应用静态页面(frontend/dist)
|
||||||
|
|
||||||
server {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
@@ -9,15 +11,15 @@ server {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# Cloudflare Tunnel 入口(推荐):
|
# Cloudflare Tunnel 入口(推荐):
|
||||||
# 外部 https://mac-tunnel.biboer.cn(443) -> cloudflared -> 本机 80(直连本机服务)
|
# 外部 https://mac-tunnel.biboer.cn(443) -> cloudflared -> 本机 80(Nginx)
|
||||||
server {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
listen [::]:80;
|
listen [::]:80;
|
||||||
server_name mac-tunnel.biboer.cn;
|
server_name mac-tunnel.biboer.cn;
|
||||||
|
|
||||||
# 静态资源根目录(包含 fonts/、fonts.json、miniprogram/assets/*)
|
# 项目根目录(包含 fonts/、fonts.json、miniprogram/assets/*、frontend/dist/*)
|
||||||
root /Users/gavin/font2svg;
|
root /Users/gavin/font2svg;
|
||||||
index fonts.json;
|
index index.html;
|
||||||
|
|
||||||
access_log /opt/homebrew/var/log/nginx/access.log;
|
access_log /opt/homebrew/var/log/nginx/access.log;
|
||||||
error_log /opt/homebrew/var/log/nginx/error.log;
|
error_log /opt/homebrew/var/log/nginx/error.log;
|
||||||
@@ -32,6 +34,14 @@ server {
|
|||||||
|
|
||||||
# MIME
|
# MIME
|
||||||
types {
|
types {
|
||||||
|
text/html html htm shtml;
|
||||||
|
text/css css;
|
||||||
|
application/javascript js mjs;
|
||||||
|
image/svg+xml svg;
|
||||||
|
image/png png;
|
||||||
|
image/jpeg jpg jpeg;
|
||||||
|
image/gif gif;
|
||||||
|
image/x-icon ico;
|
||||||
application/json json;
|
application/json json;
|
||||||
font/ttf ttf;
|
font/ttf ttf;
|
||||||
font/otf otf;
|
font/otf otf;
|
||||||
@@ -66,11 +76,14 @@ server {
|
|||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
}
|
}
|
||||||
|
|
||||||
# 配置文件:短缓存,便于切换
|
# -------------------- 小程序静态配置 --------------------
|
||||||
|
|
||||||
|
# 字体清单:短缓存,便于更新
|
||||||
location = /fonts.json {
|
location = /fonts.json {
|
||||||
expires 1h;
|
expires 1h;
|
||||||
add_header Cache-Control "public, must-revalidate" always;
|
add_header Cache-Control "public, must-revalidate" always;
|
||||||
try_files $uri =404;
|
# Web 端固定请求 /fonts.json,依次回退到 dist/public/root 三处清单
|
||||||
|
try_files /frontend/dist/fonts.json /frontend/public/fonts.json /fonts.json =404;
|
||||||
}
|
}
|
||||||
|
|
||||||
location = /miniprogram/assets/fonts.json {
|
location = /miniprogram/assets/fonts.json {
|
||||||
@@ -98,9 +111,45 @@ server {
|
|||||||
try_files $uri =404;
|
try_files $uri =404;
|
||||||
}
|
}
|
||||||
|
|
||||||
# 默认仅提供静态文件
|
# -------------------- Web 静态应用(frontend/dist) --------------------
|
||||||
|
|
||||||
|
# Vite 构建产物目录:/assets/*
|
||||||
|
location ^~ /assets/ {
|
||||||
|
expires 30d;
|
||||||
|
add_header Cache-Control "public, immutable" always;
|
||||||
|
try_files /frontend/dist$uri =404;
|
||||||
|
}
|
||||||
|
|
||||||
|
# 常见入口文件(精确匹配)
|
||||||
|
location = /index.html {
|
||||||
|
try_files /frontend/dist/index.html =404;
|
||||||
|
}
|
||||||
|
|
||||||
|
location = /default.json {
|
||||||
|
try_files /frontend/dist/default.json =404;
|
||||||
|
}
|
||||||
|
|
||||||
|
location = /favicon.ico {
|
||||||
|
try_files /frontend/dist/favicon.ico =404;
|
||||||
|
}
|
||||||
|
|
||||||
|
location = /favicon.png {
|
||||||
|
try_files /frontend/dist/favicon.png =404;
|
||||||
|
}
|
||||||
|
|
||||||
|
location = /favicon.svg {
|
||||||
|
try_files /frontend/dist/favicon.svg =404;
|
||||||
|
}
|
||||||
|
|
||||||
|
location = /favicon_new.png {
|
||||||
|
try_files /frontend/dist/favicon_new.png =404;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Web SPA 路由回退(刷新任意前端路径时返回 index.html)
|
||||||
|
# 为 Web 单独指定 root,避免 try_files 内部重定向循环导致 500。
|
||||||
location / {
|
location / {
|
||||||
try_files $uri =404;
|
root /Users/gavin/font2svg/frontend/dist;
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
}
|
}
|
||||||
|
|
||||||
# 禁止访问隐藏文件
|
# 禁止访问隐藏文件
|
||||||
24
frontend/.gitignore
vendored
24
frontend/.gitignore
vendored
@@ -1,24 +0,0 @@
|
|||||||
# Logs
|
|
||||||
logs
|
|
||||||
*.log
|
|
||||||
npm-debug.log*
|
|
||||||
yarn-debug.log*
|
|
||||||
yarn-error.log*
|
|
||||||
pnpm-debug.log*
|
|
||||||
lerna-debug.log*
|
|
||||||
|
|
||||||
node_modules
|
|
||||||
dist
|
|
||||||
dist-ssr
|
|
||||||
*.local
|
|
||||||
|
|
||||||
# Editor directories and files
|
|
||||||
.vscode/*
|
|
||||||
!.vscode/extensions.json
|
|
||||||
.idea
|
|
||||||
.DS_Store
|
|
||||||
*.suo
|
|
||||||
*.ntvs*
|
|
||||||
*.njsproj
|
|
||||||
*.sln
|
|
||||||
*.sw?
|
|
||||||
1
frontend/dist/assets/download-CsugWKTX.js
vendored
Normal file
1
frontend/dist/assets/download-CsugWKTX.js
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
import{_ as v}from"./index-JIsg-Hgz.js";function p(t,e,n="text/plain"){const o=new Blob([t],{type:n});s(o,e)}function s(t,e){const n=URL.createObjectURL(t),o=document.createElement("a");o.href=n,o.download=e,document.body.appendChild(o),o.click(),document.body.removeChild(o),URL.revokeObjectURL(n)}function P(t,e){p(t,e,"image/svg+xml")}async function F(t,e="font2svg-export.zip"){const n=(await v(async()=>{const{default:r}=await import("./jszip.min-D7KnG0-e.js").then(i=>i.j);return{default:r}},[])).default,o=new n;for(const r of t)o.file(r.name,r.content);const a=await o.generateAsync({type:"blob"});s(a,e)}function d(t){if(!t)return null;const e=t.match(/-?\d+(\.\d+)?/);if(!e)return null;const n=Number(e[0]);return Number.isFinite(n)?n:null}function x(t){const n=new DOMParser().parseFromString(t,"image/svg+xml").documentElement,o=d(n.getAttribute("width")),a=d(n.getAttribute("height"));if(o&&a)return{width:o,height:a};const r=n.getAttribute("viewBox");if(r){const i=r.trim().split(/[\s,]+/).map(Number);if(i.length===4&&Number.isFinite(i[2])&&Number.isFinite(i[3]))return{width:Math.max(1,i[2]),height:Math.max(1,i[3])}}return{width:1024,height:1024}}async function _(t,e){const n=x(t),o=e?.scale??1,a=Math.max(1,Math.round((e?.width??n.width)*o)),r=Math.max(1,Math.round((e?.height??n.height)*o)),i=document.createElement("canvas");i.width=a,i.height=r;const l=i.getContext("2d");if(!l)throw new Error("无法创建 PNG 画布");e?.backgroundColor?(l.fillStyle=e.backgroundColor,l.fillRect(0,0,a,r)):l.clearRect(0,0,a,r);const f=new Blob([t],{type:"image/svg+xml;charset=utf-8"}),u=URL.createObjectURL(f);try{const c=new Image;await new Promise((w,b)=>{c.onload=()=>w(),c.onerror=()=>b(new Error("SVG 转 PNG 失败")),c.src=u}),l.drawImage(c,0,0,a,r)}finally{URL.revokeObjectURL(u)}const g=await new Promise(c=>{i.toBlob(c,"image/png")});if(!g)throw new Error("PNG 编码失败");return g}async function R(t,e,n){const o=await _(t,n);s(o,e)}function m(t){return t.replace(/[<>:"/\\|?*\x00-\x1F]/g,"_").replace(/\s+/g,"_").substring(0,200)}function h(t,e){const n=m(Array.from(t).slice(0,8).join(""));return`${m(e.substring(0,20))}_${n}`}function B(t,e){return`${h(t,e)}.svg`}function L(t,e){return`${h(t,e)}.png`}export{_ as convertSvgToPngBlob,s as downloadBlob,F as downloadMultipleFiles,R as downloadPngFromSvg,P as downloadSvg,p as downloadText,L as generatePngFilename,B as generateSvgFilename,m as sanitizeFilename};
|
||||||
1
frontend/dist/assets/index-BvZNih7U.css
vendored
Normal file
1
frontend/dist/assets/index-BvZNih7U.css
vendored
Normal file
File diff suppressed because one or more lines are too long
4
frontend/dist/assets/index-JIsg-Hgz.js
vendored
Normal file
4
frontend/dist/assets/index-JIsg-Hgz.js
vendored
Normal file
File diff suppressed because one or more lines are too long
2
frontend/dist/assets/jszip.min-D7KnG0-e.js
vendored
Normal file
2
frontend/dist/assets/jszip.min-D7KnG0-e.js
vendored
Normal file
File diff suppressed because one or more lines are too long
9
frontend/dist/assets/webicon-K25S575h.svg
vendored
Normal file
9
frontend/dist/assets/webicon-K25S575h.svg
vendored
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 170 KiB |
11
frontend/dist/assets/weixin-nJMOnnsQ.svg
vendored
Normal file
11
frontend/dist/assets/weixin-nJMOnnsQ.svg
vendored
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 88 KiB |
11
frontend/dist/default.json
vendored
Normal file
11
frontend/dist/default.json
vendored
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"inputText": "星程字体转换",
|
||||||
|
"fontSize": 50,
|
||||||
|
"textColor": "#dc2626",
|
||||||
|
"selectedFontIds": [
|
||||||
|
"0001"
|
||||||
|
],
|
||||||
|
"favoriteFontIds": [
|
||||||
|
"0001"
|
||||||
|
]
|
||||||
|
}
|
||||||
BIN
frontend/dist/favicon.png
vendored
Normal file
BIN
frontend/dist/favicon.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 59 KiB |
9
frontend/dist/favicon.svg
vendored
Normal file
9
frontend/dist/favicon.svg
vendored
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 170 KiB |
BIN
frontend/dist/favicon_new.png
vendored
Normal file
BIN
frontend/dist/favicon_new.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 33 KiB |
17
frontend/dist/index.html
vendored
Normal file
17
frontend/dist/index.html
vendored
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<!-- 统一使用 favicon_new.png 作为站点图标,避免 Safari 继续优先采用 SVG 或 mask 图标 -->
|
||||||
|
<link rel="icon" type="image/png" href="/favicon_new.png" />
|
||||||
|
<link rel="shortcut icon" href="/favicon_new.png" />
|
||||||
|
<link rel="apple-touch-icon" href="/favicon_new.png" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Font2SVG - 字体转SVG工具</title>
|
||||||
|
<script type="module" crossorigin src="/assets/index-JIsg-Hgz.js"></script>
|
||||||
|
<link rel="stylesheet" crossorigin href="/assets/index-BvZNih7U.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -20,6 +20,7 @@ type SvgPreviewExpose = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const svgPreviewRef = ref<SvgPreviewExpose | null>(null)
|
const svgPreviewRef = ref<SvgPreviewExpose | null>(null)
|
||||||
|
const showWeixinQrModal = ref(false)
|
||||||
|
|
||||||
const fontSizePercent = computed(() => {
|
const fontSizePercent = computed(() => {
|
||||||
const raw = ((uiStore.fontSize - 10) / (500 - 10)) * 100
|
const raw = ((uiStore.fontSize - 10) / (500 - 10)) * 100
|
||||||
@@ -78,7 +79,7 @@ async function handleExport(format: ExportFormat) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { generateSvg } = await import('./utils/svg-builder')
|
const { renderSvgByApi } = await import('./utils/render-api')
|
||||||
const {
|
const {
|
||||||
convertSvgToPngBlob,
|
convertSvgToPngBlob,
|
||||||
downloadSvg,
|
downloadSvg,
|
||||||
@@ -88,22 +89,27 @@ async function handleExport(format: ExportFormat) {
|
|||||||
generateSvgFilename,
|
generateSvgFilename,
|
||||||
} = await import('./utils/download')
|
} = await import('./utils/download')
|
||||||
|
|
||||||
|
const renderByApi = async (fontId: string) => {
|
||||||
|
return renderSvgByApi({
|
||||||
|
fontId,
|
||||||
|
text: inputText,
|
||||||
|
fontSize: uiStore.fontSize,
|
||||||
|
fillColor: uiStore.textColor,
|
||||||
|
letterSpacing: Number(uiStore.letterSpacing) || 0,
|
||||||
|
maxCharsPerLine: MAX_CHARS_PER_LINE,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
if (selectedItems.length === 1) {
|
if (selectedItems.length === 1) {
|
||||||
// 单个字体,直接下载 SVG
|
// 单个字体,直接下载 SVG
|
||||||
const item = selectedItems[0]
|
const item = selectedItems[0]
|
||||||
if (!item?.fontInfo.font) {
|
const fontId = item?.fontInfo?.id
|
||||||
alert('选中字体未加载完成,请稍后重试')
|
if (!fontId) {
|
||||||
|
alert('选中字体信息无效,请重新选择后重试')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const font = item.fontInfo.font
|
const svgResult = await renderByApi(fontId)
|
||||||
const svgResult = await generateSvg({
|
|
||||||
text: inputText,
|
|
||||||
font,
|
|
||||||
fontSize: uiStore.fontSize,
|
|
||||||
fillColor: uiStore.textColor,
|
|
||||||
letterSpacing: 0
|
|
||||||
})
|
|
||||||
|
|
||||||
if (format === 'svg') {
|
if (format === 'svg') {
|
||||||
const filename = generateSvgFilename(inputText, svgResult.fontName)
|
const filename = generateSvgFilename(inputText, svgResult.fontName)
|
||||||
@@ -122,19 +128,13 @@ async function handleExport(format: ExportFormat) {
|
|||||||
|
|
||||||
for (const item of selectedItems) {
|
for (const item of selectedItems) {
|
||||||
try {
|
try {
|
||||||
const font = item.fontInfo.font
|
const fontId = item?.fontInfo?.id
|
||||||
if (!font) {
|
if (!fontId) {
|
||||||
console.warn(`字体 ${item.fontInfo.name} 尚未加载,已跳过导出`)
|
console.warn('发现无效字体项,已跳过')
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
const svgResult = await generateSvg({
|
const svgResult = await renderByApi(fontId)
|
||||||
text: inputText,
|
|
||||||
font,
|
|
||||||
fontSize: uiStore.fontSize,
|
|
||||||
fillColor: uiStore.textColor,
|
|
||||||
letterSpacing: 0
|
|
||||||
})
|
|
||||||
|
|
||||||
if (format === 'svg') {
|
if (format === 'svg') {
|
||||||
const filename = generateSvgFilename(inputText, svgResult.fontName)
|
const filename = generateSvgFilename(inputText, svgResult.fontName)
|
||||||
@@ -215,21 +215,21 @@ console.log('App.vue: script setup completed')
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="w-screen h-screen box-border p-8px bg-white flex flex-col overflow-hidden">
|
<div class="app-container w-screen h-screen box-border p-8px bg-white flex flex-col overflow-hidden">
|
||||||
<!-- Frame 7: 顶部工具栏 -->
|
<!-- Frame 7: 顶部工具栏 -->
|
||||||
<div class="flex gap-2 items-center shrink-0 h-24 px-2 py-1">
|
<div class="header-row flex gap-2 items-center shrink-0 h-24 px-2 py-1">
|
||||||
<!-- webicon - 48x48 -->
|
<!-- webicon - 48x48 -->
|
||||||
<div class="w-12 h-12 rounded-xl overflow-hidden shrink-0">
|
<div class="logo-box w-12 h-12 rounded-xl overflow-hidden shrink-0">
|
||||||
<img src="./assets/webicon.svg" alt="logo" class="w-full h-full object-cover" />
|
<img src="./assets/webicon.svg" alt="logo" class="w-full h-full object-cover" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 星程字体转换 - 弹性宽度 -->
|
<!-- 星程字体转换 - 弹性宽度 (移动端隐藏) -->
|
||||||
<div class="shrink-0 max-w-[225px] min-w-[120px]" style="height: 72px;">
|
<div class="app-title shrink-0 max-w-[225px] min-w-[120px]" style="height: 72px;">
|
||||||
<img src="./assets/icons/星程字体转换.svg" alt="星程SVG文字生成 TEXT to SVG" class="w-full h-full object-contain" />
|
<img src="./assets/icons/星程字体转换.svg" alt="星程SVG文字生成 TEXT to SVG" class="w-full h-full object-contain" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- slider - 增加宽度 -->
|
<!-- slider - 增加宽度 -->
|
||||||
<div class="flex items-center gap-3 px-2 shrink-0 relative" style="width: 280px; height: 32px;">
|
<div class="slider-box flex items-center gap-3 px-2 shrink-0 relative" style="width: 280px; height: 32px;">
|
||||||
<button
|
<button
|
||||||
@click="handleFontSizeChange(uiStore.fontSize - 10)"
|
@click="handleFontSizeChange(uiStore.fontSize - 10)"
|
||||||
class="w-4 h-4 shrink-0 cursor-pointer hover:opacity-70 transition-opacity flex items-center justify-center p-0 border-0 bg-transparent"
|
class="w-4 h-4 shrink-0 cursor-pointer hover:opacity-70 transition-opacity flex items-center justify-center p-0 border-0 bg-transparent"
|
||||||
@@ -281,8 +281,8 @@ console.log('App.vue: script setup completed')
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Frame 14: 输入框 - 弹性宽度 -->
|
<!-- Frame 14: 输入框 - 弹性宽度 (桌面端显示) -->
|
||||||
<div class="flex-1 min-w-[80px] bg-[#f7f8fa] rounded-lg px-2 py-1 h-12">
|
<div class="input-box-desktop flex-1 min-w-[80px] bg-[#f7f8fa] rounded-lg px-2 py-1 h-12">
|
||||||
<textarea
|
<textarea
|
||||||
:value="uiStore.inputText"
|
:value="uiStore.inputText"
|
||||||
@input="handleTextInput"
|
@input="handleTextInput"
|
||||||
@@ -292,14 +292,14 @@ console.log('App.vue: script setup completed')
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Export Group -->
|
<!-- Export Group -->
|
||||||
<div class="flex items-center gap-1 shrink-0 border border-[#8552A1] rounded-lg px-1 py-1 bg-[#f7f8fa] shadow-sm">
|
<div class="export-group flex items-center gap-1 shrink-0 border border-[#8552A1] rounded-lg px-1 py-1 bg-[#f7f8fa] shadow-sm">
|
||||||
<div class="w-[18px] h-[42px] shrink-0 pointer-events-none">
|
<div class="export-label w-[18px] h-[42px] shrink-0 pointer-events-none">
|
||||||
<img src="./assets/icons/export.svg" alt="导出" class="w-full h-full object-contain" />
|
<img src="./assets/icons/export.svg" alt="导出" class="w-full h-full object-contain" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@click="handleExport('svg')"
|
@click="handleExport('svg')"
|
||||||
class="w-12 h-12 shrink-0 cursor-pointer hover:opacity-85 transition-opacity flex items-center justify-center p-0 border-0 bg-transparent"
|
class="export-btn w-12 h-12 shrink-0 cursor-pointer hover:opacity-85 transition-opacity flex items-center justify-center p-0 border-0 bg-transparent"
|
||||||
title="导出 SVG"
|
title="导出 SVG"
|
||||||
>
|
>
|
||||||
<img src="./assets/icons/export-svg.svg" alt="导出SVG" class="w-12 h-12 object-contain" />
|
<img src="./assets/icons/export-svg.svg" alt="导出SVG" class="w-12 h-12 object-contain" />
|
||||||
@@ -307,27 +307,66 @@ console.log('App.vue: script setup completed')
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
@click="handleExport('png')"
|
@click="handleExport('png')"
|
||||||
class="w-12 h-12 shrink-0 cursor-pointer hover:opacity-85 transition-opacity flex items-center justify-center p-0 border-0 bg-transparent"
|
class="export-btn w-12 h-12 shrink-0 cursor-pointer hover:opacity-85 transition-opacity flex items-center justify-center p-0 border-0 bg-transparent"
|
||||||
title="导出 PNG"
|
title="导出 PNG"
|
||||||
>
|
>
|
||||||
<img src="./assets/icons/export-png.svg" alt="导出PNG" class="w-12 h-12 object-contain" />
|
<img src="./assets/icons/export-png.svg" alt="导出PNG" class="w-12 h-12 object-contain" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
@click="showWeixinQrModal = true"
|
||||||
|
class="weixin-btn w-[54px] h-[42px] shrink-0 cursor-pointer hover:opacity-85 transition-opacity p-0 border-0 bg-transparent"
|
||||||
|
title="微信小程序"
|
||||||
|
>
|
||||||
|
<img src="./assets/icons/weixin.svg" alt="微信小程序二维码" class="w-full h-full object-contain" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 移动端输入栏 (仅移动端显示) -->
|
||||||
|
<div class="input-row-mobile hidden px-2 py-1 shrink-0">
|
||||||
|
<div class="flex-1 bg-[#f7f8fa] rounded-lg px-2 h-[30px]">
|
||||||
|
<textarea
|
||||||
|
:value="uiStore.inputText"
|
||||||
|
@input="handleTextInput"
|
||||||
|
placeholder="此处输入内容"
|
||||||
|
class="w-full h-full bg-transparent border-none outline-none text-sm text-[#4e5969] placeholder-[#4e5969] resize-none leading-[30px] overflow-hidden"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 微信小程序二维码弹窗 -->
|
||||||
|
<div
|
||||||
|
v-if="showWeixinQrModal"
|
||||||
|
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
|
||||||
|
@click.self="showWeixinQrModal = false"
|
||||||
|
>
|
||||||
|
<div class="bg-white rounded-xl p-6 shadow-xl relative max-w-[320px]">
|
||||||
|
<button
|
||||||
|
@click="showWeixinQrModal = false"
|
||||||
|
class="absolute top-2 right-2 w-8 h-8 flex items-center justify-center text-gray-400 hover:text-gray-600 transition-colors border-0 bg-transparent cursor-pointer text-xl"
|
||||||
|
title="关闭"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
<h3 class="text-center text-gray-800 font-medium mb-4">微信扫码使用小程序</h3>
|
||||||
|
<img src="./assets/icons/weixin.svg" alt="微信小程序二维码" class="w-64 h-64 object-contain" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Frame 9: 主内容区 -->
|
<!-- Frame 9: 主内容区 -->
|
||||||
<div class="flex-1 flex gap-2 min-h-0 overflow-hidden px-2">
|
<div class="main-content flex-1 flex gap-2 min-h-0 overflow-hidden px-2">
|
||||||
<!-- Frame 15: 左侧栏 - 弹性宽度 -->
|
<!-- Frame 15: 左侧栏 - 弹性宽度 (桌面端) -->
|
||||||
<div class="flex flex-col gap-2 shrink-0 overflow-hidden" style="flex-basis: 400px; max-width: 480px; min-width: 320px;">
|
<div class="sidebar-left flex flex-col gap-2 shrink-0 overflow-hidden" style="flex-basis: 400px; max-width: 480px; min-width: 320px;">
|
||||||
<!-- Frame 5: 字体选择 - 弹性高度 -->
|
<!-- Frame 5: 字体选择 - 弹性高度 -->
|
||||||
<div class="flex-[2] border border-solid border-[#f7e0e0] rounded-lg p-1.5 flex flex-col gap-2 overflow-hidden min-h-0">
|
<div class="font-selector-box flex-[2] border border-solid border-[#f7e0e0] rounded-lg p-1.5 flex flex-col gap-2 overflow-hidden min-h-0">
|
||||||
<div v-overflow-aware class="scrollbar-hover flex-1 overflow-y-auto overflow-x-hidden pr-2">
|
<div v-overflow-aware class="scrollbar-hover flex-1 overflow-y-auto overflow-x-hidden pr-2">
|
||||||
<FontSelector />
|
<FontSelector />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Frame 5: 已收藏字体 - 弹性高度 -->
|
<!-- Frame 5: 已收藏字体 - 弹性高度 -->
|
||||||
<div class="border border-solid border-[#f7e0e0] rounded-lg p-1.5 flex flex-col gap-2 flex-1 overflow-hidden min-h-[120px]">
|
<div class="favorites-box border border-solid border-[#f7e0e0] rounded-lg p-1.5 flex flex-col gap-2 flex-1 overflow-hidden min-h-[120px]">
|
||||||
<div class="flex items-center pr-[9px]">
|
<div class="flex items-center pr-[9px]">
|
||||||
<h2 class="text-base text-black shrink-0 leading-none flex-1">
|
<h2 class="text-base text-black shrink-0 leading-none flex-1">
|
||||||
已收藏字体({{ favoriteFontCount }}字体)
|
已收藏字体({{ favoriteFontCount }}字体)
|
||||||
@@ -351,7 +390,7 @@ console.log('App.vue: script setup completed')
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Frame 8: 右侧预览区 - 弹性宽度 -->
|
<!-- Frame 8: 右侧预览区 - 弹性宽度 -->
|
||||||
<div class="flex-1 border border-solid border-[#f7e0e0] rounded-lg p-1.5 flex flex-col gap-2 overflow-hidden min-w-0">
|
<div class="preview-box flex-1 border border-solid border-[#f7e0e0] rounded-lg p-1.5 flex flex-col gap-2 overflow-hidden min-w-0">
|
||||||
<div class="flex items-center pr-[9px]">
|
<div class="flex items-center pr-[9px]">
|
||||||
<h2 class="text-base text-black shrink-0 leading-none flex-1">效果预览</h2>
|
<h2 class="text-base text-black shrink-0 leading-none flex-1">效果预览</h2>
|
||||||
<button
|
<button
|
||||||
@@ -370,10 +409,27 @@ console.log('App.vue: script setup completed')
|
|||||||
<SvgPreview ref="svgPreviewRef" />
|
<SvgPreview ref="svgPreviewRef" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 移动端底部区域: 选择 + 已收藏 并排 -->
|
||||||
|
<div class="bottom-section-mobile hidden">
|
||||||
|
<!-- 字体选择 -->
|
||||||
|
<div class="font-selector-mobile border border-solid border-[#3EE4C3] rounded-lg p-1.5 flex flex-col gap-1 overflow-hidden">
|
||||||
|
<div v-overflow-aware class="scrollbar-hover flex-1 overflow-y-auto overflow-x-hidden">
|
||||||
|
<FontSelector />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- 已收藏字体 -->
|
||||||
|
<div class="favorites-mobile border border-solid border-[#3EE4C3] rounded-lg p-1.5 flex flex-col gap-1 overflow-hidden">
|
||||||
|
<h2 class="text-sm text-black shrink-0 leading-none">已收藏</h2>
|
||||||
|
<div v-overflow-aware class="scrollbar-hover flex-1 overflow-y-auto overflow-x-hidden">
|
||||||
|
<FavoritesList />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 底部版权 -->
|
<!-- 底部版权 -->
|
||||||
<div class="text-[#86909c] text-xs text-center shrink-0 h-6 pt-4 flex items-center justify-center px-2">
|
<div class="copyright-footer text-[#86909c] text-xs text-center shrink-0 flex items-center justify-center px-2">
|
||||||
@版权说明:所有字体来源互联网分享,仅供效果预览,不做下载传播,如有侵权,请告知douboer@gmail.com
|
@版权说明:所有字体来源互联网分享,仅供效果预览,不做下载传播,如有侵权,请告知douboer@gmail.com
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -415,4 +471,188 @@ console.log('App.vue: script setup completed')
|
|||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 桌面端版权区域 */
|
||||||
|
.copyright-footer {
|
||||||
|
height: 24px;
|
||||||
|
padding-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 移动端响应式布局 (iOS/Android web) */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.app-container {
|
||||||
|
padding: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 顶部工具栏 - 压缩 */
|
||||||
|
.header-row {
|
||||||
|
height: 48px;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 0 4px;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-box {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 隐藏标题 */
|
||||||
|
.app-title {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* slider 压缩 */
|
||||||
|
.slider-box {
|
||||||
|
width: 132px !important;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 100px;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 0 4px;
|
||||||
|
height: 24px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 隐藏桌面端输入框 */
|
||||||
|
.input-box-desktop {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 显示移动端输入框 */
|
||||||
|
.input-row-mobile {
|
||||||
|
display: flex !important;
|
||||||
|
gap: 6px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
padding-top: 0;
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 导出组缩小 */
|
||||||
|
.export-group {
|
||||||
|
gap: 2px;
|
||||||
|
padding: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-label {
|
||||||
|
width: 12px;
|
||||||
|
height: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-btn {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-btn img {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weixin-btn {
|
||||||
|
width: 36px !important;
|
||||||
|
height: 28px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 主内容区 - 改为垂直布局 */
|
||||||
|
.main-content {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 0 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 隐藏桌面端左侧栏 */
|
||||||
|
.sidebar-left {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 预览区 - 占上半部分 */
|
||||||
|
.preview-box {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
border-color: #3EE4C3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-box h2 {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 显示移动端底部区域 */
|
||||||
|
.bottom-section-mobile {
|
||||||
|
display: flex !important;
|
||||||
|
flex-direction: row;
|
||||||
|
gap: 8px;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.font-selector-mobile,
|
||||||
|
.favorites-mobile {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.font-selector-mobile h2,
|
||||||
|
.favorites-mobile h2 {
|
||||||
|
font-size: 14px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 移动端字体选择器文字大小统一 */
|
||||||
|
.font-selector-mobile :deep(.text-\[16px\]) {
|
||||||
|
font-size: 14px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.font-selector-mobile :deep(.text-base) {
|
||||||
|
font-size: 14px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 版权信息缩小 */
|
||||||
|
.copyright-footer {
|
||||||
|
font-size: 10px;
|
||||||
|
padding: 4px 0;
|
||||||
|
height: auto;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 更小屏幕适配 (iPhone SE 等) */
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.header-row {
|
||||||
|
height: 44px;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-box {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider-box {
|
||||||
|
width: 100px !important;
|
||||||
|
min-width: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-btn {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-btn img {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weixin-btn {
|
||||||
|
width: 32px !important;
|
||||||
|
height: 24px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 手机端底部区域垂直布局 */
|
||||||
|
.bottom-section-mobile {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
11
frontend/src/assets/icons/weixin.svg
Normal file
11
frontend/src/assets/icons/weixin.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 88 KiB |
@@ -56,7 +56,7 @@ const hasMatchedFonts = computed(() => {
|
|||||||
<div class="sticky top-0 z-10 bg-white pt-1 pb-1">
|
<div class="sticky top-0 z-10 bg-white pt-1 pb-1">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<div class="text-[16px] leading-none text-black font-bold shrink-0">
|
<div class="text-[16px] leading-none text-black font-bold shrink-0">
|
||||||
选择预览字体
|
选择字体
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<div class="h-8 rounded-[10px] bg-[#F3EDF7] pl-2 flex items-center">
|
<div class="h-8 rounded-[10px] bg-[#F3EDF7] pl-2 flex items-center">
|
||||||
|
|||||||
@@ -2,20 +2,37 @@
|
|||||||
import { ref, computed, watch, onBeforeUnmount, nextTick } from 'vue'
|
import { ref, computed, watch, onBeforeUnmount, nextTick } from 'vue'
|
||||||
import { useFontStore } from '../stores/fontStore'
|
import { useFontStore } from '../stores/fontStore'
|
||||||
import { useUiStore } from '../stores/uiStore'
|
import { useUiStore } from '../stores/uiStore'
|
||||||
import { generateSvg } from '../utils/svg-builder'
|
import { renderSvgByApi } from '../utils/render-api'
|
||||||
|
import { MAX_CHARS_PER_LINE } from '../utils/text-layout'
|
||||||
import type { PreviewItem as PreviewItemType } from '../types/font'
|
import type { PreviewItem as PreviewItemType } from '../types/font'
|
||||||
import type { SvgGenerateResult, FontInfo } from '../types/font'
|
import type { SvgGenerateResult, FontInfo } from '../types/font'
|
||||||
|
|
||||||
const fontStore = useFontStore()
|
const fontStore = useFontStore()
|
||||||
const uiStore = useUiStore()
|
const uiStore = useUiStore()
|
||||||
|
|
||||||
const previewItems = ref<PreviewItemType[]>([])
|
interface PreviewApiCacheItem {
|
||||||
|
svg: string
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
fontName: string
|
||||||
|
renderFontSize: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PreviewRenderItem extends PreviewItemType {
|
||||||
|
baseSvg: string
|
||||||
|
baseWidth: number
|
||||||
|
baseHeight: number
|
||||||
|
renderFontSize: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const previewItems = ref<PreviewRenderItem[]>([])
|
||||||
const isGenerating = ref(false)
|
const isGenerating = ref(false)
|
||||||
const isBatchGenerating = ref(false)
|
const isBatchGenerating = ref(false)
|
||||||
const activePreviewFonts = ref<FontInfo[]>([])
|
const activePreviewFonts = ref<FontInfo[]>([])
|
||||||
const processedFontCount = ref(0)
|
const processedFontCount = ref(0)
|
||||||
const renderedPreviewCount = ref(0)
|
const renderedPreviewCount = ref(0)
|
||||||
const previewTriggerItemEl = ref<HTMLElement | null>(null)
|
const previewTriggerItemEl = ref<HTMLElement | null>(null)
|
||||||
|
const previewErrorMessage = ref('')
|
||||||
|
|
||||||
const previewFonts = computed(() => fontStore.previewFonts)
|
const previewFonts = computed(() => fontStore.previewFonts)
|
||||||
const inputText = computed(() => uiStore.inputText)
|
const inputText = computed(() => uiStore.inputText)
|
||||||
@@ -24,17 +41,18 @@ const fillColor = computed(() => uiStore.textColor)
|
|||||||
|
|
||||||
const PREVIEW_DEBOUNCE_MS = 240
|
const PREVIEW_DEBOUNCE_MS = 240
|
||||||
const PREVIEW_CONCURRENCY = 4
|
const PREVIEW_CONCURRENCY = 4
|
||||||
const PREVIEW_GEOMETRY_CACHE_LIMIT = 600
|
const PREVIEW_API_CACHE_LIMIT = 600
|
||||||
const PREVIEW_COLOR_TOKEN = '__FONT2SVG_FILL__'
|
|
||||||
const PREVIEW_BATCH_SIZE = 20
|
const PREVIEW_BATCH_SIZE = 20
|
||||||
const PREVIEW_PREFETCH_OFFSET = 10
|
const PREVIEW_PREFETCH_OFFSET = 10
|
||||||
|
const PREVIEW_RENDER_FONT_SIZE = 120
|
||||||
|
|
||||||
let previewGenerateTimer: ReturnType<typeof setTimeout> | null = null
|
let previewGenerateTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
let previewGenerationToken = 0
|
let previewGenerationToken = 0
|
||||||
let hasTriggeredInitialGenerate = false
|
let hasTriggeredInitialGenerate = false
|
||||||
let previewLazyLoadObserver: IntersectionObserver | null = null
|
let previewLazyLoadObserver: IntersectionObserver | null = null
|
||||||
|
let batchOwnerToken: number | null = null
|
||||||
|
|
||||||
const previewGeometryCache = new Map<string, Omit<SvgGenerateResult, 'svg'> & { svgTemplate: string }>()
|
const previewApiCache = new Map<string, PreviewApiCacheItem>()
|
||||||
|
|
||||||
const isAllPreviewSelected = computed(() => {
|
const isAllPreviewSelected = computed(() => {
|
||||||
return previewItems.value.length > 0 && previewItems.value.every(item => item.selected)
|
return previewItems.value.length > 0 && previewItems.value.every(item => item.selected)
|
||||||
@@ -51,6 +69,10 @@ const visiblePreviewItems = computed(() => {
|
|||||||
return previewItems.value.slice(0, renderedPreviewCount.value)
|
return previewItems.value.slice(0, renderedPreviewCount.value)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const hasRenderableInput = computed(() => {
|
||||||
|
return inputText.value.trim() !== '' && activePreviewFonts.value.length > 0
|
||||||
|
})
|
||||||
|
|
||||||
const previewTriggerIndex = computed(() => {
|
const previewTriggerIndex = computed(() => {
|
||||||
if (!hasMorePreviewItems.value || visiblePreviewItems.value.length <= 0) {
|
if (!hasMorePreviewItems.value || visiblePreviewItems.value.length <= 0) {
|
||||||
return -1
|
return -1
|
||||||
@@ -131,88 +153,173 @@ function setPreviewItemRef(el: unknown, index: number) {
|
|||||||
previewTriggerItemEl.value = el instanceof HTMLElement ? el : null
|
previewTriggerItemEl.value = el instanceof HTMLElement ? el : null
|
||||||
}
|
}
|
||||||
|
|
||||||
function getPreviewGeometryCacheKey(fontInfo: FontInfo): string {
|
function normalizeHexColor(input: string, fallback = '#000000'): string {
|
||||||
return [fontInfo.id, fontInfo.path, inputText.value, fontSize.value].join('::')
|
const value = String(input || '').trim()
|
||||||
|
if (/^#[0-9a-fA-F]{6}$/.test(value)) {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
function readPreviewGeometryFromCache(key: string) {
|
function clampFontSize(value: number, fallback = PREVIEW_RENDER_FONT_SIZE): number {
|
||||||
const cached = previewGeometryCache.get(key)
|
if (!Number.isFinite(value)) {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
return Math.max(1, Math.min(2048, Math.round(value)))
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSvgNumber(value: number): string {
|
||||||
|
const text = Number(value).toFixed(2)
|
||||||
|
return text.replace(/\.?0+$/, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceSvgFillColor(svg: string, color: string): string {
|
||||||
|
const normalizedColor = normalizeHexColor(color)
|
||||||
|
if (!svg) return ''
|
||||||
|
|
||||||
|
if (/<g\b[^>]*\sfill="[^"]*"/.test(svg)) {
|
||||||
|
return svg.replace(/(<g\b[^>]*\sfill=")[^"]*(")/, `$1${normalizedColor}$2`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return svg.replace(/<g\b([^>]*)>/, `<g$1 fill="${normalizedColor}">`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function scaleSvgDimensions(svg: string, scale: number): string {
|
||||||
|
if (!svg || !Number.isFinite(scale) || scale <= 0) {
|
||||||
|
return svg
|
||||||
|
}
|
||||||
|
|
||||||
|
return svg
|
||||||
|
.replace(/width="([0-9]+(?:\.[0-9]+)?)"/, (_, width) => {
|
||||||
|
const scaledWidth = Number(width) * scale
|
||||||
|
return `width="${formatSvgNumber(scaledWidth)}"`
|
||||||
|
})
|
||||||
|
.replace(/height="([0-9]+(?:\.[0-9]+)?)"/, (_, height) => {
|
||||||
|
const scaledHeight = Number(height) * scale
|
||||||
|
return `height="${formatSvgNumber(scaledHeight)}"`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPreviewCacheKey(
|
||||||
|
fontId: string,
|
||||||
|
text: string,
|
||||||
|
letterSpacing: number,
|
||||||
|
maxCharsPerLine: number,
|
||||||
|
): string {
|
||||||
|
const safeSpacing = Number.isFinite(letterSpacing) ? letterSpacing.toFixed(4) : '0.0000'
|
||||||
|
return [fontId, safeSpacing, String(maxCharsPerLine), text].join('::')
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPreviewApiCacheKey(fontInfo: FontInfo): string {
|
||||||
|
return buildPreviewCacheKey(
|
||||||
|
fontInfo.id,
|
||||||
|
String(inputText.value || ''),
|
||||||
|
Number(uiStore.letterSpacing) || 0,
|
||||||
|
MAX_CHARS_PER_LINE,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function readPreviewFromCache(key: string): PreviewApiCacheItem | null {
|
||||||
|
const cached = previewApiCache.get(key)
|
||||||
if (!cached) {
|
if (!cached) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
previewGeometryCache.delete(key)
|
previewApiCache.delete(key)
|
||||||
previewGeometryCache.set(key, cached)
|
previewApiCache.set(key, cached)
|
||||||
return cached
|
return cached
|
||||||
}
|
}
|
||||||
|
|
||||||
function writePreviewGeometryToCache(
|
function writePreviewToCache(key: string, value: PreviewApiCacheItem) {
|
||||||
key: string,
|
if (previewApiCache.has(key)) {
|
||||||
geometry: Omit<SvgGenerateResult, 'svg'> & { svgTemplate: string },
|
previewApiCache.delete(key)
|
||||||
) {
|
|
||||||
if (previewGeometryCache.has(key)) {
|
|
||||||
previewGeometryCache.delete(key)
|
|
||||||
}
|
}
|
||||||
previewGeometryCache.set(key, geometry)
|
previewApiCache.set(key, value)
|
||||||
|
|
||||||
while (previewGeometryCache.size > PREVIEW_GEOMETRY_CACHE_LIMIT) {
|
while (previewApiCache.size > PREVIEW_API_CACHE_LIMIT) {
|
||||||
const oldestKey = previewGeometryCache.keys().next().value
|
const oldestKey = previewApiCache.keys().next().value
|
||||||
if (oldestKey === undefined) {
|
if (oldestKey === undefined) {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
previewGeometryCache.delete(oldestKey)
|
previewApiCache.delete(oldestKey)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function generateSvgWithPreviewCache(fontInfo: FontInfo): Promise<SvgGenerateResult | null> {
|
function toStyledSvgResult(item: PreviewRenderItem): SvgGenerateResult {
|
||||||
if (!fontInfo.font) {
|
const targetSize = clampFontSize(Number(fontSize.value), PREVIEW_RENDER_FONT_SIZE)
|
||||||
return null
|
const renderSize = Number(item.renderFontSize) > 0 ? Number(item.renderFontSize) : PREVIEW_RENDER_FONT_SIZE
|
||||||
}
|
const scale = targetSize / renderSize
|
||||||
|
const styledSvg = scaleSvgDimensions(
|
||||||
const cacheKey = getPreviewGeometryCacheKey(fontInfo)
|
replaceSvgFillColor(item.baseSvg, fillColor.value),
|
||||||
let geometry = readPreviewGeometryFromCache(cacheKey)
|
scale,
|
||||||
if (!geometry) {
|
)
|
||||||
const baseResult = await generateSvg({
|
|
||||||
text: inputText.value,
|
|
||||||
font: fontInfo.font,
|
|
||||||
fontSize: fontSize.value,
|
|
||||||
fillColor: PREVIEW_COLOR_TOKEN,
|
|
||||||
})
|
|
||||||
|
|
||||||
geometry = {
|
|
||||||
width: baseResult.width,
|
|
||||||
height: baseResult.height,
|
|
||||||
fontName: baseResult.fontName,
|
|
||||||
svgTemplate: baseResult.svg,
|
|
||||||
}
|
|
||||||
|
|
||||||
writePreviewGeometryToCache(cacheKey, geometry)
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
width: geometry.width,
|
svg: styledSvg,
|
||||||
height: geometry.height,
|
width: Number(item.baseWidth) > 0 ? Number(item.baseWidth) * scale : 0,
|
||||||
fontName: geometry.fontName,
|
height: Number(item.baseHeight) > 0 ? Number(item.baseHeight) * scale : 0,
|
||||||
svg: geometry.svgTemplate.split(PREVIEW_COLOR_TOKEN).join(fillColor.value),
|
fontName: item.svgResult.fontName || item.fontInfo.name,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function applyLocalStyleToPreviewItem(item: PreviewRenderItem): PreviewRenderItem {
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
svgResult: toStyledSvgResult(item),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyLocalPreviewStyles() {
|
||||||
|
if (previewItems.value.length === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
previewItems.value = previewItems.value.map(item => applyLocalStyleToPreviewItem(item))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getOrRenderPreviewBase(fontInfo: FontInfo): Promise<PreviewApiCacheItem | null> {
|
||||||
|
const cacheKey = getPreviewApiCacheKey(fontInfo)
|
||||||
|
const cached = readPreviewFromCache(cacheKey)
|
||||||
|
if (cached) {
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await renderSvgByApi({
|
||||||
|
fontId: fontInfo.id,
|
||||||
|
text: inputText.value,
|
||||||
|
fontSize: PREVIEW_RENDER_FONT_SIZE,
|
||||||
|
fillColor: '#000000',
|
||||||
|
letterSpacing: Number(uiStore.letterSpacing) || 0,
|
||||||
|
maxCharsPerLine: MAX_CHARS_PER_LINE,
|
||||||
|
})
|
||||||
|
|
||||||
|
const base: PreviewApiCacheItem = {
|
||||||
|
svg: result.svg,
|
||||||
|
width: result.width,
|
||||||
|
height: result.height,
|
||||||
|
fontName: result.fontName || fontInfo.name,
|
||||||
|
renderFontSize: PREVIEW_RENDER_FONT_SIZE,
|
||||||
|
}
|
||||||
|
writePreviewToCache(cacheKey, base)
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
|
||||||
async function generatePreviewBatch(
|
async function generatePreviewBatch(
|
||||||
fonts: FontInfo[],
|
fonts: FontInfo[],
|
||||||
startIndex: number,
|
startIndex: number,
|
||||||
batchSize: number,
|
batchSize: number,
|
||||||
generationToken: number,
|
generationToken: number,
|
||||||
): Promise<PreviewItemType[]> {
|
): Promise<{ items: PreviewRenderItem[]; errors: string[] }> {
|
||||||
const endIndex = Math.min(startIndex + batchSize, fonts.length)
|
const endIndex = Math.min(startIndex + batchSize, fonts.length)
|
||||||
const batchFonts = fonts.slice(startIndex, endIndex)
|
const batchFonts = fonts.slice(startIndex, endIndex)
|
||||||
if (batchFonts.length === 0) {
|
if (batchFonts.length === 0) {
|
||||||
return []
|
return { items: [], errors: [] }
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectedFontIdSet = new Set(uiStore.selectedExportItems.map(item => item.fontInfo.id))
|
const selectedFontIdSet = new Set(uiStore.selectedExportItems.map(item => item.fontInfo.id))
|
||||||
const items = new Array<PreviewItemType | null>(batchFonts.length).fill(null)
|
const items = new Array<PreviewRenderItem | null>(batchFonts.length).fill(null)
|
||||||
const workerCount = Math.min(PREVIEW_CONCURRENCY, batchFonts.length)
|
const workerCount = Math.min(PREVIEW_CONCURRENCY, batchFonts.length)
|
||||||
let nextIndex = 0
|
let nextIndex = 0
|
||||||
|
const errors: string[] = []
|
||||||
|
|
||||||
const worker = async () => {
|
const worker = async () => {
|
||||||
while (true) {
|
while (true) {
|
||||||
@@ -231,36 +338,31 @@ async function generatePreviewBatch(
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!fontInfo.loaded) {
|
|
||||||
try {
|
|
||||||
await fontStore.loadFont(fontInfo)
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Failed to load font ${fontInfo.name}:`, error)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isStaleGeneration(generationToken) || !fontInfo.font) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const svgResult = await generateSvgWithPreviewCache(fontInfo)
|
const base = await getOrRenderPreviewBase(fontInfo)
|
||||||
if (!svgResult) {
|
if (!base || isStaleGeneration(generationToken)) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isStaleGeneration(generationToken)) {
|
const item: PreviewRenderItem = {
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
items[localIndex] = {
|
|
||||||
fontInfo,
|
fontInfo,
|
||||||
svgResult,
|
|
||||||
selected: selectedFontIdSet.has(fontInfo.id),
|
selected: selectedFontIdSet.has(fontInfo.id),
|
||||||
|
baseSvg: base.svg,
|
||||||
|
baseWidth: base.width,
|
||||||
|
baseHeight: base.height,
|
||||||
|
renderFontSize: base.renderFontSize,
|
||||||
|
svgResult: {
|
||||||
|
svg: '',
|
||||||
|
width: 0,
|
||||||
|
height: 0,
|
||||||
|
fontName: base.fontName || fontInfo.name,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
items[localIndex] = applyLocalStyleToPreviewItem(item)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Failed to generate SVG for ${fontInfo.name}:`, error)
|
const message = error instanceof Error ? error.message : String(error)
|
||||||
|
console.error(`Failed to render preview for ${fontInfo.name}:`, error)
|
||||||
|
errors.push(`${fontInfo.name}: ${message}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -268,23 +370,36 @@ async function generatePreviewBatch(
|
|||||||
await Promise.all(Array.from({ length: workerCount }, () => worker()))
|
await Promise.all(Array.from({ length: workerCount }, () => worker()))
|
||||||
|
|
||||||
if (isStaleGeneration(generationToken)) {
|
if (isStaleGeneration(generationToken)) {
|
||||||
return []
|
return { items: [], errors: [] }
|
||||||
}
|
}
|
||||||
|
|
||||||
return items.filter((item): item is PreviewItemType => item !== null)
|
return {
|
||||||
|
items: items.filter((item): item is PreviewRenderItem => item !== null),
|
||||||
|
errors,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadNextPreviewBatch(generationToken: number) {
|
async function loadNextPreviewBatch(generationToken: number) {
|
||||||
if (isBatchGenerating.value || isStaleGeneration(generationToken)) {
|
if (isStaleGeneration(generationToken)) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isBatchGenerating.value) {
|
||||||
|
if (batchOwnerToken === generationToken) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 旧批次仍在收尾时,允许新批次接管
|
||||||
|
isBatchGenerating.value = false
|
||||||
|
batchOwnerToken = null
|
||||||
|
}
|
||||||
|
|
||||||
const startIndex = processedFontCount.value
|
const startIndex = processedFontCount.value
|
||||||
if (startIndex >= activePreviewFonts.value.length) {
|
if (startIndex >= activePreviewFonts.value.length) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
isBatchGenerating.value = true
|
isBatchGenerating.value = true
|
||||||
|
batchOwnerToken = generationToken
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const batchItems = await generatePreviewBatch(
|
const batchItems = await generatePreviewBatch(
|
||||||
@@ -303,16 +418,110 @@ async function loadNextPreviewBatch(generationToken: number) {
|
|||||||
activePreviewFonts.value.length,
|
activePreviewFonts.value.length,
|
||||||
)
|
)
|
||||||
|
|
||||||
previewItems.value = [...previewItems.value, ...batchItems]
|
const existingIds = new Set(previewItems.value.map(item => item.fontInfo.id))
|
||||||
|
const uniqueBatchItems = batchItems.items.filter(item => !existingIds.has(item.fontInfo.id))
|
||||||
|
previewItems.value = [...previewItems.value, ...uniqueBatchItems]
|
||||||
renderedPreviewCount.value = Math.min(
|
renderedPreviewCount.value = Math.min(
|
||||||
renderedPreviewCount.value + PREVIEW_BATCH_SIZE,
|
renderedPreviewCount.value + uniqueBatchItems.length,
|
||||||
previewItems.value.length,
|
previewItems.value.length,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (batchItems.errors.length > 0 && previewItems.value.length === 0) {
|
||||||
|
previewErrorMessage.value = `预览生成失败:${batchItems.errors[0]}`
|
||||||
|
} else if (previewItems.value.length > 0) {
|
||||||
|
previewErrorMessage.value = ''
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load preview batch:', error)
|
console.error('Failed to load preview batch:', error)
|
||||||
|
previewErrorMessage.value = `预览生成失败:${error instanceof Error ? error.message : String(error)}`
|
||||||
} finally {
|
} finally {
|
||||||
if (!isStaleGeneration(generationToken)) {
|
if (batchOwnerToken === generationToken) {
|
||||||
isBatchGenerating.value = false
|
isBatchGenerating.value = false
|
||||||
|
batchOwnerToken = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncPreviewFontsIncrementally(previousFonts: FontInfo[]) {
|
||||||
|
const generationToken = ++previewGenerationToken
|
||||||
|
const nextPreviewFonts = [...previewFonts.value]
|
||||||
|
const validPreviewFontIds = new Set(nextPreviewFonts.map(font => font.id))
|
||||||
|
uiStore.retainExportItemsByFontIds(validPreviewFontIds)
|
||||||
|
|
||||||
|
activePreviewFonts.value = nextPreviewFonts
|
||||||
|
previewErrorMessage.value = ''
|
||||||
|
|
||||||
|
if (!inputText.value || inputText.value.trim() === '' || nextPreviewFonts.length === 0) {
|
||||||
|
previewItems.value = []
|
||||||
|
renderedPreviewCount.value = 0
|
||||||
|
processedFontCount.value = 0
|
||||||
|
isGenerating.value = false
|
||||||
|
isBatchGenerating.value = false
|
||||||
|
batchOwnerToken = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextIdSet = new Set(nextPreviewFonts.map(font => font.id))
|
||||||
|
const previousIdSet = new Set(previousFonts.map(font => font.id))
|
||||||
|
const addedFonts = nextPreviewFonts.filter(font => !previousIdSet.has(font.id))
|
||||||
|
|
||||||
|
previewItems.value = previewItems.value.filter(item => nextIdSet.has(item.fontInfo.id))
|
||||||
|
renderedPreviewCount.value = Math.min(renderedPreviewCount.value, previewItems.value.length)
|
||||||
|
processedFontCount.value = Math.min(processedFontCount.value, nextPreviewFonts.length)
|
||||||
|
|
||||||
|
if (addedFonts.length > 0) {
|
||||||
|
if (previewItems.value.length === 0) {
|
||||||
|
isGenerating.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const addedBatch = await generatePreviewBatch(
|
||||||
|
addedFonts,
|
||||||
|
0,
|
||||||
|
addedFonts.length,
|
||||||
|
generationToken,
|
||||||
|
)
|
||||||
|
if (isStaleGeneration(generationToken)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingIds = new Set(previewItems.value.map(item => item.fontInfo.id))
|
||||||
|
const uniqueAddedItems = addedBatch.items.filter(item => !existingIds.has(item.fontInfo.id))
|
||||||
|
if (uniqueAddedItems.length > 0) {
|
||||||
|
// 新勾选字体插到顶部,避免全量刷新带来的闪烁。
|
||||||
|
previewItems.value = [...uniqueAddedItems, ...previewItems.value]
|
||||||
|
renderedPreviewCount.value = Math.min(
|
||||||
|
renderedPreviewCount.value + uniqueAddedItems.length,
|
||||||
|
previewItems.value.length,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (addedBatch.errors.length > 0 && previewItems.value.length === 0) {
|
||||||
|
previewErrorMessage.value = `预览生成失败:${addedBatch.errors[0]}`
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to incrementally load preview fonts:', error)
|
||||||
|
previewErrorMessage.value = `预览生成失败:${error instanceof Error ? error.message : String(error)}`
|
||||||
|
} finally {
|
||||||
|
if (!isStaleGeneration(generationToken)) {
|
||||||
|
isGenerating.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!isStaleGeneration(generationToken) &&
|
||||||
|
previewItems.value.length === 0 &&
|
||||||
|
processedFontCount.value < activePreviewFonts.value.length &&
|
||||||
|
!isBatchGenerating.value
|
||||||
|
) {
|
||||||
|
isGenerating.value = true
|
||||||
|
try {
|
||||||
|
await loadNextPreviewBatch(generationToken)
|
||||||
|
} finally {
|
||||||
|
if (!isStaleGeneration(generationToken)) {
|
||||||
|
isGenerating.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -347,9 +556,12 @@ async function regeneratePreviews() {
|
|||||||
processedFontCount.value = 0
|
processedFontCount.value = 0
|
||||||
previewItems.value = []
|
previewItems.value = []
|
||||||
renderedPreviewCount.value = 0
|
renderedPreviewCount.value = 0
|
||||||
|
previewErrorMessage.value = ''
|
||||||
|
|
||||||
if (!inputText.value || inputText.value.trim() === '' || nextPreviewFonts.length === 0) {
|
if (!inputText.value || inputText.value.trim() === '' || nextPreviewFonts.length === 0) {
|
||||||
isGenerating.value = false
|
isGenerating.value = false
|
||||||
|
isBatchGenerating.value = false
|
||||||
|
batchOwnerToken = null
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -357,8 +569,26 @@ async function regeneratePreviews() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await loadNextPreviewBatch(generationToken)
|
await loadNextPreviewBatch(generationToken)
|
||||||
|
if (
|
||||||
|
!isStaleGeneration(generationToken) &&
|
||||||
|
previewItems.value.length === 0 &&
|
||||||
|
processedFontCount.value < activePreviewFonts.value.length &&
|
||||||
|
!isBatchGenerating.value
|
||||||
|
) {
|
||||||
|
await loadNextPreviewBatch(generationToken)
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!isStaleGeneration(generationToken) &&
|
||||||
|
previewItems.value.length === 0 &&
|
||||||
|
nextPreviewFonts.length > 0 &&
|
||||||
|
inputText.value.trim() !== '' &&
|
||||||
|
!previewErrorMessage.value
|
||||||
|
) {
|
||||||
|
previewErrorMessage.value = '预览生成失败:服务未返回可用结果'
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to regenerate previews:', error)
|
console.error('Failed to regenerate previews:', error)
|
||||||
|
previewErrorMessage.value = `预览生成失败:${error instanceof Error ? error.message : String(error)}`
|
||||||
} finally {
|
} finally {
|
||||||
if (!isStaleGeneration(generationToken)) {
|
if (!isStaleGeneration(generationToken)) {
|
||||||
isGenerating.value = false
|
isGenerating.value = false
|
||||||
@@ -367,12 +597,41 @@ async function regeneratePreviews() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
[previewFonts, inputText, fontSize, fillColor],
|
previewFonts,
|
||||||
|
(nextFonts, previousFonts) => {
|
||||||
|
if (!hasTriggeredInitialGenerate || !previousFonts) {
|
||||||
|
scheduleGeneratePreviews(false)
|
||||||
|
hasTriggeredInitialGenerate = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousIds = new Set(previousFonts.map(font => font.id))
|
||||||
|
if (
|
||||||
|
nextFonts.length === previousFonts.length &&
|
||||||
|
nextFonts.every(font => previousIds.has(font.id))
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
void syncPreviewFontsIncrementally(previousFonts)
|
||||||
|
hasTriggeredInitialGenerate = true
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
[inputText, () => uiStore.letterSpacing],
|
||||||
() => {
|
() => {
|
||||||
scheduleGeneratePreviews(hasTriggeredInitialGenerate)
|
scheduleGeneratePreviews(hasTriggeredInitialGenerate)
|
||||||
hasTriggeredInitialGenerate = true
|
hasTriggeredInitialGenerate = true
|
||||||
},
|
},
|
||||||
{ immediate: true },
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
[fontSize, fillColor],
|
||||||
|
() => {
|
||||||
|
applyLocalPreviewStyles()
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
@@ -392,6 +651,7 @@ onBeforeUnmount(() => {
|
|||||||
}
|
}
|
||||||
disconnectPreviewLazyLoadObserver()
|
disconnectPreviewLazyLoadObserver()
|
||||||
previewGenerationToken += 1
|
previewGenerationToken += 1
|
||||||
|
batchOwnerToken = null
|
||||||
})
|
})
|
||||||
|
|
||||||
function toggleSelectItem(item: PreviewItemType) {
|
function toggleSelectItem(item: PreviewItemType) {
|
||||||
@@ -426,7 +686,19 @@ defineExpose({
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
<div v-if="previewItems.length === 0" class="text-[#86909c] text-center py-20">
|
<div v-if="previewItems.length === 0" class="text-[#86909c] text-center py-20">
|
||||||
{{ isGenerating ? '生成预览中...' : '请选择字体并输入内容' }}
|
{{
|
||||||
|
isGenerating
|
||||||
|
? '生成预览中...'
|
||||||
|
: (
|
||||||
|
previewErrorMessage
|
||||||
|
? previewErrorMessage
|
||||||
|
: (
|
||||||
|
inputText.trim() === '' || activePreviewFonts.length === 0
|
||||||
|
? '请选择字体并输入内容'
|
||||||
|
: (hasRenderableInput ? '暂无可显示预览' : '请选择字体并输入内容')
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="flex flex-col gap-2">
|
<div v-else class="flex flex-col gap-2">
|
||||||
|
|||||||
@@ -13,20 +13,43 @@ interface FontListItem {
|
|||||||
export function useFontLoader() {
|
export function useFontLoader() {
|
||||||
const fontStore = useFontStore()
|
const fontStore = useFontStore()
|
||||||
|
|
||||||
|
async function fetchFontListWithFallback(): Promise<FontListItem[]> {
|
||||||
|
const candidates = ['/frontend/public/fonts.json', '/fonts.json']
|
||||||
|
const errors: string[] = []
|
||||||
|
|
||||||
|
for (const url of candidates) {
|
||||||
|
const requestUrl = `${url}?_ts=${Date.now()}`
|
||||||
|
try {
|
||||||
|
console.log(`Fetching ${requestUrl}...`)
|
||||||
|
const response = await fetch(requestUrl, { cache: 'no-store' })
|
||||||
|
console.log(`${url} response:`, response.status, response.statusText)
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
errors.push(`${url}: HTTP ${response.status}`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
if (!Array.isArray(data)) {
|
||||||
|
errors.push(`${url}: JSON 不是数组`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
return data as FontListItem[]
|
||||||
|
} catch (error) {
|
||||||
|
errors.push(`${url}: ${error instanceof Error ? error.message : String(error)}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(errors.join(' | '))
|
||||||
|
}
|
||||||
|
|
||||||
async function loadFontList() {
|
async function loadFontList() {
|
||||||
console.log('Starting to load font list...')
|
console.log('Starting to load font list...')
|
||||||
fontStore.isLoadingFonts = true
|
fontStore.isLoadingFonts = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log('Fetching /fonts.json...')
|
const fontList = await fetchFontListWithFallback()
|
||||||
const response = await fetch('/fonts.json')
|
|
||||||
console.log('Response status:', response.status, response.statusText)
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Failed to load fonts.json: ${response.statusText}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const fontList: FontListItem[] = await response.json()
|
|
||||||
console.log('Loaded font list:', fontList.length, 'fonts')
|
console.log('Loaded font list:', fontList.length, 'fonts')
|
||||||
|
|
||||||
// 转换为 FontInfo
|
// 转换为 FontInfo
|
||||||
@@ -50,7 +73,7 @@ export function useFontLoader() {
|
|||||||
console.log(`Successfully loaded ${fontList.length} fonts`)
|
console.log(`Successfully loaded ${fontList.length} fonts`)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load font list:', error)
|
console.error('Failed to load font list:', error)
|
||||||
alert('加载字体列表失败,请刷新页面重试')
|
alert(`加载字体列表失败:${error instanceof Error ? error.message : '未知错误'}`)
|
||||||
} finally {
|
} finally {
|
||||||
fontStore.isLoadingFonts = false
|
fontStore.isLoadingFonts = false
|
||||||
console.log('Font loading finished')
|
console.log('Font loading finished')
|
||||||
|
|||||||
140
frontend/src/utils/render-api.ts
Normal file
140
frontend/src/utils/render-api.ts
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
import { MAX_CHARS_PER_LINE } from './text-layout'
|
||||||
|
|
||||||
|
const DEFAULT_RENDER_API_URL = '/api/render-svg'
|
||||||
|
const REQUEST_TIMEOUT_MS = 30000
|
||||||
|
|
||||||
|
interface RenderApiResponseData {
|
||||||
|
svg: string
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
fontName: string
|
||||||
|
fontId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RenderApiResponseBody {
|
||||||
|
ok?: boolean
|
||||||
|
data?: RenderApiResponseData
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type BrowserWithRenderApiConfig = Window & {
|
||||||
|
__FONT2SVG_API_URL__?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RenderSvgPayload {
|
||||||
|
fontId: string
|
||||||
|
text: string
|
||||||
|
fontSize?: number
|
||||||
|
fillColor?: string
|
||||||
|
letterSpacing?: number
|
||||||
|
maxCharsPerLine?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RenderSvgResult {
|
||||||
|
svg: string
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
fontName: string
|
||||||
|
fontId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveRenderApiUrl(): string {
|
||||||
|
const envUrl = (import.meta.env.VITE_RENDER_API_URL as string | undefined)?.trim()
|
||||||
|
if (envUrl) {
|
||||||
|
return envUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
const globalUrl = (window as BrowserWithRenderApiConfig).__FONT2SVG_API_URL__
|
||||||
|
if (typeof globalUrl === 'string' && globalUrl.trim()) {
|
||||||
|
return globalUrl.trim()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return DEFAULT_RENDER_API_URL
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRenderResult(data: RenderApiResponseData | undefined): RenderSvgResult {
|
||||||
|
if (!data || typeof data !== 'object') {
|
||||||
|
throw new Error('渲染服务返回格式无效')
|
||||||
|
}
|
||||||
|
|
||||||
|
const svg = typeof data.svg === 'string' ? data.svg : ''
|
||||||
|
if (!svg.trim()) {
|
||||||
|
throw new Error('渲染服务未返回有效 SVG')
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
svg,
|
||||||
|
width: Number(data.width) || 0,
|
||||||
|
height: Number(data.height) || 0,
|
||||||
|
fontName: data.fontName || 'Unknown',
|
||||||
|
fontId: data.fontId || '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePayload(payload: RenderSvgPayload) {
|
||||||
|
const fontId = String(payload.fontId || '').trim()
|
||||||
|
const text = String(payload.text || '')
|
||||||
|
|
||||||
|
if (!fontId) {
|
||||||
|
throw new Error('缺少字体 ID')
|
||||||
|
}
|
||||||
|
if (!text.trim()) {
|
||||||
|
throw new Error('文本内容不能为空')
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
fontId,
|
||||||
|
text,
|
||||||
|
fontSize: Number(payload.fontSize) || 120,
|
||||||
|
fillColor: payload.fillColor || '#000000',
|
||||||
|
letterSpacing: Number(payload.letterSpacing) || 0,
|
||||||
|
maxCharsPerLine: Number(payload.maxCharsPerLine) || MAX_CHARS_PER_LINE,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function renderSvgByApi(payload: RenderSvgPayload): Promise<RenderSvgResult> {
|
||||||
|
const requestBody = normalizePayload(payload)
|
||||||
|
const controller = new AbortController()
|
||||||
|
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS)
|
||||||
|
|
||||||
|
let response: Response
|
||||||
|
try {
|
||||||
|
response = await fetch(resolveRenderApiUrl(), {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'content-type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(requestBody),
|
||||||
|
signal: controller.signal,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof DOMException && error.name === 'AbortError') {
|
||||||
|
throw new Error('渲染服务请求超时')
|
||||||
|
}
|
||||||
|
throw new Error(`渲染服务请求失败:${error instanceof Error ? error.message : String(error)}`)
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer)
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: RenderApiResponseBody | null = null
|
||||||
|
try {
|
||||||
|
body = (await response.json()) as RenderApiResponseBody
|
||||||
|
} catch {
|
||||||
|
body = null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errMsg = body && typeof body.error === 'string' && body.error.trim()
|
||||||
|
? body.error
|
||||||
|
: `渲染服务请求失败(HTTP ${response.status})`
|
||||||
|
throw new Error(errMsg)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!body || !body.ok) {
|
||||||
|
throw new Error((body && body.error) || '渲染服务返回错误')
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizeRenderResult(body.data)
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,170 +0,0 @@
|
|||||||
[
|
|
||||||
{
|
|
||||||
"id": "0001",
|
|
||||||
"name": "AlimamaDaoLiTi",
|
|
||||||
"filename": "AlimamaDaoLiTi.ttf",
|
|
||||||
"category": "其他字体",
|
|
||||||
"path": "/fonts/其他字体/AlimamaDaoLiTi.ttf"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "0002",
|
|
||||||
"name": "Hangeuljaemin4-Regular",
|
|
||||||
"filename": "Hangeuljaemin4-Regular.ttf",
|
|
||||||
"category": "其他字体",
|
|
||||||
"path": "/fonts/其他字体/Hangeuljaemin4-Regular.ttf"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "0003",
|
|
||||||
"name": "I.顏體",
|
|
||||||
"filename": "I.顏體.ttf",
|
|
||||||
"category": "其他字体",
|
|
||||||
"path": "/fonts/其他字体/I.顏體.ttf"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "0004",
|
|
||||||
"name": "XCDUANZHUANGSONGTI",
|
|
||||||
"filename": "XCDUANZHUANGSONGTI.ttf",
|
|
||||||
"category": "其他字体",
|
|
||||||
"path": "/fonts/其他字体/XCDUANZHUANGSONGTI.ttf"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "0005",
|
|
||||||
"name": "qiji-combo",
|
|
||||||
"filename": "qiji-combo.ttf",
|
|
||||||
"category": "其他字体",
|
|
||||||
"path": "/fonts/其他字体/qiji-combo.ttf"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "0006",
|
|
||||||
"name": "临海隶书",
|
|
||||||
"filename": "临海隶书.ttf",
|
|
||||||
"category": "其他字体",
|
|
||||||
"path": "/fonts/其他字体/临海隶书.ttf"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "0007",
|
|
||||||
"name": "京華老宋体_KingHwa_OldSong",
|
|
||||||
"filename": "京華老宋体_KingHwa_OldSong.ttf",
|
|
||||||
"category": "其他字体",
|
|
||||||
"path": "/fonts/其他字体/京華老宋体_KingHwa_OldSong.ttf"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "0008",
|
|
||||||
"name": "优设标题黑",
|
|
||||||
"filename": "优设标题黑.ttf",
|
|
||||||
"category": "其他字体",
|
|
||||||
"path": "/fonts/其他字体/优设标题黑.ttf"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "0009",
|
|
||||||
"name": "包图小白体",
|
|
||||||
"filename": "包图小白体.ttf",
|
|
||||||
"category": "其他字体",
|
|
||||||
"path": "/fonts/其他字体/包图小白体.ttf"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "0010",
|
|
||||||
"name": "源界明朝",
|
|
||||||
"filename": "源界明朝.ttf",
|
|
||||||
"category": "其他字体",
|
|
||||||
"path": "/fonts/其他字体/源界明朝.ttf"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "0011",
|
|
||||||
"name": "演示佛系体",
|
|
||||||
"filename": "演示佛系体.ttf",
|
|
||||||
"category": "其他字体",
|
|
||||||
"path": "/fonts/其他字体/演示佛系体.ttf"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "0012",
|
|
||||||
"name": "站酷快乐体",
|
|
||||||
"filename": "站酷快乐体.ttf",
|
|
||||||
"category": "其他字体",
|
|
||||||
"path": "/fonts/其他字体/站酷快乐体.ttf"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "0013",
|
|
||||||
"name": "问藏书房",
|
|
||||||
"filename": "问藏书房.ttf",
|
|
||||||
"category": "其他字体",
|
|
||||||
"path": "/fonts/其他字体/问藏书房.ttf"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "0014",
|
|
||||||
"name": "霞鹜臻楷",
|
|
||||||
"filename": "霞鹜臻楷.ttf",
|
|
||||||
"category": "其他字体",
|
|
||||||
"path": "/fonts/其他字体/霞鹜臻楷.ttf"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "0015",
|
|
||||||
"name": "庞门正道标题体",
|
|
||||||
"filename": "庞门正道标题体.ttf",
|
|
||||||
"category": "庞门正道",
|
|
||||||
"path": "/fonts/庞门正道/庞门正道标题体.ttf"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "0016",
|
|
||||||
"name": "庞门正道标题体",
|
|
||||||
"filename": "庞门正道标题体.ttf",
|
|
||||||
"category": "庞门正道-测试",
|
|
||||||
"path": "/fonts/庞门正道-测试/庞门正道标题体.ttf"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "0017",
|
|
||||||
"name": "王漢宗勘亭流繁",
|
|
||||||
"filename": "王漢宗勘亭流繁.ttf",
|
|
||||||
"category": "王漢宗",
|
|
||||||
"path": "/fonts/王漢宗/王漢宗勘亭流繁.ttf"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "0018",
|
|
||||||
"name": "王漢宗新潮體",
|
|
||||||
"filename": "王漢宗新潮體.ttf",
|
|
||||||
"category": "王漢宗",
|
|
||||||
"path": "/fonts/王漢宗/王漢宗新潮體.ttf"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "0019",
|
|
||||||
"name": "王漢宗波卡體空陰",
|
|
||||||
"filename": "王漢宗波卡體空陰.ttf",
|
|
||||||
"category": "王漢宗",
|
|
||||||
"path": "/fonts/王漢宗/王漢宗波卡體空陰.ttf"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "0020",
|
|
||||||
"name": "王漢宗細黑體繁",
|
|
||||||
"filename": "王漢宗細黑體繁.ttf",
|
|
||||||
"category": "王漢宗",
|
|
||||||
"path": "/fonts/王漢宗/王漢宗細黑體繁.ttf"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "0021",
|
|
||||||
"name": "王漢宗綜藝體雙空陰",
|
|
||||||
"filename": "王漢宗綜藝體雙空陰.ttf",
|
|
||||||
"category": "王漢宗",
|
|
||||||
"path": "/fonts/王漢宗/王漢宗綜藝體雙空陰.ttf"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "0022",
|
|
||||||
"name": "王漢宗超明體繁",
|
|
||||||
"filename": "王漢宗超明體繁.ttf",
|
|
||||||
"category": "王漢宗",
|
|
||||||
"path": "/fonts/王漢宗/王漢宗超明體繁.ttf"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "0023",
|
|
||||||
"name": "王漢宗酷儷海報",
|
|
||||||
"filename": "王漢宗酷儷海報.ttf",
|
|
||||||
"category": "王漢宗",
|
|
||||||
"path": "/fonts/王漢宗/王漢宗酷儷海報.ttf"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "0024",
|
|
||||||
"name": "王漢宗顏楷體繁",
|
|
||||||
"filename": "王漢宗顏楷體繁.ttf",
|
|
||||||
"category": "王漢宗",
|
|
||||||
"path": "/fonts/王漢宗/王漢宗顏楷體繁.ttf"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"active": "A",
|
"active": "B",
|
||||||
"cooldownMinutes": 0,
|
"cooldownMinutes": 0,
|
||||||
"servers": {
|
"servers": {
|
||||||
"A": {
|
"A": {
|
||||||
|
|||||||
@@ -104,6 +104,22 @@ function showExportError(title, error, fallback) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toPngSaveError(saveResult) {
|
||||||
|
if (!saveResult || saveResult.success) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (saveResult.reason === 'private_api_banned') {
|
||||||
|
return new Error('当前小程序账号不支持“保存到相册”(private api banned)。请改用“导出 SVG”分享,或更换支持该能力的账号/版本。')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (saveResult.reason === 'auth_denied') {
|
||||||
|
return saveResult.error || new Error('保存失败,请检查相册权限')
|
||||||
|
}
|
||||||
|
|
||||||
|
return saveResult.error || new Error('保存 PNG 失败')
|
||||||
|
}
|
||||||
|
|
||||||
function writePngBufferToTempFile(pngBuffer, fontName) {
|
function writePngBufferToTempFile(pngBuffer, fontName) {
|
||||||
const safeName = String(fontName || 'font').replace(/[<>:"/\\|?*\x00-\x1F]/g, '_').slice(0, 60) || 'font'
|
const safeName = String(fontName || 'font').replace(/[<>:"/\\|?*\x00-\x1F]/g, '_').slice(0, 60) || 'font'
|
||||||
const filePath = `${wx.env.USER_DATA_PATH}/${safeName}_${Date.now()}.png`
|
const filePath = `${wx.env.USER_DATA_PATH}/${safeName}_${Date.now()}.png`
|
||||||
@@ -906,7 +922,7 @@ Page({
|
|||||||
|
|
||||||
const saveResult = await savePngToAlbum(pngPath)
|
const saveResult = await savePngToAlbum(pngPath)
|
||||||
if (!saveResult.success) {
|
if (!saveResult.success) {
|
||||||
throw saveResult.error || new Error('保存 PNG 失败')
|
throw toPngSaveError(saveResult)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
wx.showToast({ title: 'PNG 已保存到相册', icon: 'success' })
|
wx.showToast({ title: 'PNG 已保存到相册', icon: 'success' })
|
||||||
@@ -970,7 +986,7 @@ Page({
|
|||||||
if (saveResult.success) {
|
if (saveResult.success) {
|
||||||
wx.showToast({ title: 'PNG 已保存到相册', icon: 'success' })
|
wx.showToast({ title: 'PNG 已保存到相册', icon: 'success' })
|
||||||
} else {
|
} else {
|
||||||
showExportError('导出 PNG 失败', saveResult.error, '保存失败,请检查相册权限')
|
showExportError('导出 PNG 失败', toPngSaveError(saveResult), '保存 PNG 失败')
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showExportError('导出 PNG 失败', error, '请稍后重试')
|
showExportError('导出 PNG 失败', error, '请稍后重试')
|
||||||
@@ -1029,7 +1045,7 @@ Page({
|
|||||||
if (saveResult.success) {
|
if (saveResult.success) {
|
||||||
wx.showToast({ title: 'PNG 已保存到相册', icon: 'success' })
|
wx.showToast({ title: 'PNG 已保存到相册', icon: 'success' })
|
||||||
} else {
|
} else {
|
||||||
showExportError('导出 PNG 失败', saveResult.error, '保存失败,请检查相册权限')
|
showExportError('导出 PNG 失败', toPngSaveError(saveResult), '保存 PNG 失败')
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showExportError('导出 PNG 失败', error, '请稍后重试')
|
showExportError('导出 PNG 失败', error, '请稍后重试')
|
||||||
|
|||||||
@@ -104,7 +104,9 @@ async function savePngToAlbum(filePath) {
|
|||||||
return { success: true }
|
return { success: true }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errMsg = String(error && error.errMsg ? error.errMsg : error)
|
const errMsg = String(error && error.errMsg ? error.errMsg : error)
|
||||||
const needAuth = errMsg.includes('auth deny') || errMsg.includes('authorize')
|
const lowerErrMsg = errMsg.toLowerCase()
|
||||||
|
const needAuth = lowerErrMsg.includes('auth deny') || lowerErrMsg.includes('authorize')
|
||||||
|
const privateApiBanned = lowerErrMsg.includes('private api banned')
|
||||||
|
|
||||||
if (needAuth) {
|
if (needAuth) {
|
||||||
const modalRes = await showModal({
|
const modalRes = await showModal({
|
||||||
@@ -120,6 +122,10 @@ async function savePngToAlbum(filePath) {
|
|||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
needAuth,
|
needAuth,
|
||||||
|
privateApiBanned,
|
||||||
|
reason: privateApiBanned
|
||||||
|
? 'private_api_banned'
|
||||||
|
: (needAuth ? 'auth_denied' : 'save_failed'),
|
||||||
error,
|
error,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
87
release.md
87
release.md
@@ -1,48 +1,4 @@
|
|||||||
# Release Notes
|
# Release Notes
|
||||||
|
|
||||||
## v1.0.3
|
|
||||||
发布时间:2026年2月10日
|
|
||||||
版本定位:小程序远端路由切换与多节点接入增强
|
|
||||||
|
|
||||||
### 里程碑与亮点
|
|
||||||
|
|
||||||
- 新增小程序“无发版切换后端”能力(基于远端 `route-config.json`)
|
|
||||||
- 引入双确认切换策略,避免单点误配置导致错误跳转
|
|
||||||
- 增加 `cooldown` 防抖,防止 A/B 来回抖动
|
|
||||||
- 新增 Cloudflare Tunnel 入口域名支持(`mac-tunnel.biboer.cn`)
|
|
||||||
|
|
||||||
### 核心变更
|
|
||||||
|
|
||||||
#### 小程序路由管理
|
|
||||||
- 新增 `route-manager`,负责:
|
|
||||||
- 启动时读取路由配置
|
|
||||||
- 回前台按节流规则检查路由
|
|
||||||
- 接口失败时触发兜底路由检查
|
|
||||||
- 路由状态本地持久化:`activeServerKey`、`lastSwitchAt`、`routeConfigCache`、`lastRouteCheckAt`
|
|
||||||
|
|
||||||
#### 切换规则
|
|
||||||
- 双确认:当前服务读取到目标为 `B` 时,必须继续读取 `B` 的配置并确认 `active=B` 才切换
|
|
||||||
- 防抖:`cooldownMinutes` 控制最短驻留时间,`0` 表示允许立即切换
|
|
||||||
- 读取失败保护:目标配置读取失败或非法时保持当前服务不变
|
|
||||||
|
|
||||||
#### 接入与运维
|
|
||||||
- 新增 `miniprogram/assets/route-config.json` 配置模板
|
|
||||||
- `scripts/deploy-fonts.sh` 支持同步 `route-config.json`
|
|
||||||
- `miniprogram/README.md`、`apiserver/README.md` 同步更新
|
|
||||||
- mac 侧新增 Tunnel 接入路径,支持 `mac-tunnel.biboer.cn` 对外服务
|
|
||||||
|
|
||||||
### 配置注意事项
|
|
||||||
|
|
||||||
- 小程序后台必须将目标域名加入 `request` 与 `downloadFile` 合法域名
|
|
||||||
- `route-config.json` 修改后建议清除本地 `route-state` 并冷启动验证
|
|
||||||
- `/healthz` 建议使用 `GET` 校验(`HEAD` 可能返回 `501`)
|
|
||||||
|
|
||||||
### 验收项
|
|
||||||
|
|
||||||
- 可通过修改远端 `route-config.json` 在 A/B 节点间切换,无需重新发布小程序
|
|
||||||
- A/B 配置不一致时不切换(保护行为)
|
|
||||||
- 目标节点不可用时不切换(保护行为)
|
|
||||||
|
|
||||||
## v1.0.1
|
## v1.0.1
|
||||||
发布时间:2026年2月9日
|
发布时间:2026年2月9日
|
||||||
版本定位:小程序 UI 优化与布局完善
|
版本定位:小程序 UI 优化与布局完善
|
||||||
@@ -132,3 +88,46 @@ miniprogram/
|
|||||||
|
|
||||||
- 邮箱:douboer@gmail.com
|
- 邮箱:douboer@gmail.com
|
||||||
- 仓库:Issues
|
- 仓库:Issues
|
||||||
|
|
||||||
|
## v1.0.3
|
||||||
|
发布时间:2026年2月10日
|
||||||
|
版本定位:小程序远端路由切换与多节点接入增强
|
||||||
|
|
||||||
|
### 里程碑与亮点
|
||||||
|
|
||||||
|
- 新增小程序“无发版切换后端”能力(基于远端 `route-config.json`)
|
||||||
|
- 引入双确认切换策略,避免单点误配置导致错误跳转
|
||||||
|
- 增加 `cooldown` 防抖,防止 A/B 来回抖动
|
||||||
|
- 新增 Cloudflare Tunnel 入口域名支持(`mac-tunnel.biboer.cn`)
|
||||||
|
|
||||||
|
### 核心变更
|
||||||
|
|
||||||
|
#### 小程序路由管理
|
||||||
|
- 新增 `route-manager`,负责:
|
||||||
|
- 启动时读取路由配置
|
||||||
|
- 回前台按节流规则检查路由
|
||||||
|
- 接口失败时触发兜底路由检查
|
||||||
|
- 路由状态本地持久化:`activeServerKey`、`lastSwitchAt`、`routeConfigCache`、`lastRouteCheckAt`
|
||||||
|
|
||||||
|
#### 切换规则
|
||||||
|
- 双确认:当前服务读取到目标为 `B` 时,必须继续读取 `B` 的配置并确认 `active=B` 才切换
|
||||||
|
- 防抖:`cooldownMinutes` 控制最短驻留时间,`0` 表示允许立即切换
|
||||||
|
- 读取失败保护:目标配置读取失败或非法时保持当前服务不变
|
||||||
|
|
||||||
|
#### 接入与运维
|
||||||
|
- 新增 `miniprogram/assets/route-config.json` 配置模板
|
||||||
|
- `scripts/deploy-fonts.sh` 支持同步 `route-config.json`
|
||||||
|
- `miniprogram/README.md`、`apiserver/README.md` 同步更新
|
||||||
|
- mac 侧新增 Tunnel 接入路径,支持 `mac-tunnel.biboer.cn` 对外服务
|
||||||
|
|
||||||
|
### 配置注意事项
|
||||||
|
|
||||||
|
- 小程序后台必须将目标域名加入 `request` 与 `downloadFile` 合法域名
|
||||||
|
- `route-config.json` 修改后建议清除本地 `route-state` 并冷启动验证
|
||||||
|
- `/healthz` 建议使用 `GET` 校验(`HEAD` 可能返回 `501`)
|
||||||
|
|
||||||
|
### 验收项
|
||||||
|
|
||||||
|
- 可通过修改远端 `route-config.json` 在 A/B 节点间切换,无需重新发布小程序
|
||||||
|
- A/B 配置不一致时不切换(保护行为)
|
||||||
|
- 目标节点不可用时不切换(保护行为)
|
||||||
|
|||||||
13
run.sh
13
run.sh
@@ -1,10 +1,13 @@
|
|||||||
|
|
||||||
source .venv/bin/activate
|
source .venv/bin/activate
|
||||||
|
|
||||||
lsof -ti:5173,5174,5175,5176 | xargs kill -9 2>/dev/null;
|
lsof -ti:5173,5174,5175,5176 | xargs kill -9 2>/dev/null;
|
||||||
|
|
||||||
python font2svg.py --fontdir font --text "星程紫微" --outdir svg
|
python font2svg.py --fontdir font --text "星程紫微" --outdir svg
|
||||||
|
|
||||||
|
# 新增字体放在fonts目录下,运行以下命令,fonts.json会被更新
|
||||||
# 新增字体放在frontend/public/fonts目录下,运行以下命令,fonts.json会被更新
|
|
||||||
npm run prepare-fonts
|
npm run prepare-fonts
|
||||||
|
|
||||||
|
# 查看请求日志 MacMini
|
||||||
|
tail -f /tmp/font2svg-api.log
|
||||||
|
|
||||||
|
# 查看请求日志 Linux
|
||||||
|
sudo journalctl -u font2svg-api -f
|
||||||
|
|
||||||
|
|||||||
376
scripts/font-id-state.json
Normal file
376
scripts/font-id-state.json
Normal file
@@ -0,0 +1,376 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"nextId": 370,
|
||||||
|
"pathToId": {
|
||||||
|
"上首造字/No.1-上首锐博体.ttf": "0001",
|
||||||
|
"上首造字/No.10-上首叮当体.ttf": "0002",
|
||||||
|
"上首造字/No.100-上首纤宋体.ttf": "0003",
|
||||||
|
"上首造字/No.101-上首武士黑体.ttf": "0004",
|
||||||
|
"上首造字/No.102-上首玄藏体.ttf": "0005",
|
||||||
|
"上首造字/No.103-上首星际体.ttf": "0006",
|
||||||
|
"上首造字/No.104-上首鸿志手写体.ttf": "0007",
|
||||||
|
"上首造字/No.105-上首逍遥体.ttf": "0008",
|
||||||
|
"上首造字/No.106-上首简墨体.ttf": "0009",
|
||||||
|
"上首造字/No.107-上首松羽体.ttf": "0010",
|
||||||
|
"上首造字/No.108-上首燕尾体.ttf": "0011",
|
||||||
|
"上首造字/No.109-上首韶华体.ttf": "0012",
|
||||||
|
"上首造字/No.11-上首车车体.ttf": "0013",
|
||||||
|
"上首造字/No.110-上首烈宋体.ttf": "0014",
|
||||||
|
"上首造字/No.111-上首方舟体.ttf": "0015",
|
||||||
|
"上首造字/No.112-上首壹方体.ttf": "0016",
|
||||||
|
"上首造字/No.113-上首轩正体.ttf": "0017",
|
||||||
|
"上首造字/No.114-上首长城体.ttf": "0018",
|
||||||
|
"上首造字/No.115-上首音符体.ttf": "0019",
|
||||||
|
"上首造字/No.116-上首如意体.ttf": "0020",
|
||||||
|
"上首造字/No.117-上首酷峰体.ttf": "0021",
|
||||||
|
"上首造字/No.118-上首星耀体.ttf": "0022",
|
||||||
|
"上首造字/No.119-上首罗马体.ttf": "0023",
|
||||||
|
"上首造字/No.12-上首秀圆体.ttf": "0024",
|
||||||
|
"上首造字/No.120-上首宽言体.ttf": "0025",
|
||||||
|
"上首造字/No.121-上首刻宋体.ttf": "0026",
|
||||||
|
"上首造字/No.122-上首云润体.ttf": "0027",
|
||||||
|
"上首造字/No.123-上首方义手刷体.ttf": "0028",
|
||||||
|
"上首造字/No.124-上首艺圆体.ttf": "0029",
|
||||||
|
"上首造字/No.125-上首方尊体.ttf": "0030",
|
||||||
|
"上首造字/No.126-上首吉言体.ttf": "0031",
|
||||||
|
"上首造字/No.127-上首简一体.ttf": "0032",
|
||||||
|
"上首造字/No.128-上首玄黑体.ttf": "0033",
|
||||||
|
"上首造字/No.129-上首墨遥体.ttf": "0034",
|
||||||
|
"上首造字/No.13-上首华凤书法体.ttf": "0035",
|
||||||
|
"上首造字/No.130-上首追光手写体.ttf": "0036",
|
||||||
|
"上首造字/No.131-上首斗金体.ttf": "0037",
|
||||||
|
"上首造字/No.132-上首豆干体.ttf": "0038",
|
||||||
|
"上首造字/No.133-上首魔法体.ttf": "0039",
|
||||||
|
"上首造字/No.134-上首文化体.ttf": "0040",
|
||||||
|
"上首造字/No.135-上首龙吟体.ttf": "0041",
|
||||||
|
"上首造字/No.136-上首悠然体.ttf": "0042",
|
||||||
|
"上首造字/No.137-上首本草纲目体.ttf": "0043",
|
||||||
|
"上首造字/No.138-上首星斗体.ttf": "0044",
|
||||||
|
"上首造字/No.139-上首鼎宋体.ttf": "0045",
|
||||||
|
"上首造字/No.14-上首水滴体.ttf": "0046",
|
||||||
|
"上首造字/No.140-上首妖刀体.ttf": "0047",
|
||||||
|
"上首造字/No.141-上首高达体.ttf": "0048",
|
||||||
|
"上首造字/No.142-上首综艺体.ttf": "0049",
|
||||||
|
"上首造字/No.143-上首圆木体.ttf": "0050",
|
||||||
|
"上首造字/No.144-上首逍遥书法体.ttf": "0051",
|
||||||
|
"上首造字/No.145-上首高光体.ttf": "0052",
|
||||||
|
"上首造字/No.146-上首清雅宋体.ttf": "0053",
|
||||||
|
"上首造字/No.147-上首云隶体.ttf": "0054",
|
||||||
|
"上首造字/No.148-上首标榜体.ttf": "0055",
|
||||||
|
"上首造字/No.149-上首奶酪体.ttf": "0056",
|
||||||
|
"上首造字/No.15-上首润黑体.ttf": "0057",
|
||||||
|
"上首造字/No.150-上首秋刀体.ttf": "0058",
|
||||||
|
"上首造字/No.151-上首凤鸣体.ttf": "0059",
|
||||||
|
"上首造字/No.152-上首南枝体.ttf": "0060",
|
||||||
|
"上首造字/No.152上首南枝体.ttf": "0061",
|
||||||
|
"上首造字/No.153-上首可乐体.ttf": "0062",
|
||||||
|
"上首造字/No.154-上首长青体.ttf": "0063",
|
||||||
|
"上首造字/No.155-上首中正宋体.ttf": "0064",
|
||||||
|
"上首造字/No.156-上首曲奇体.ttf": "0065",
|
||||||
|
"上首造字/No.157-上首萌萌手写体.ttf": "0066",
|
||||||
|
"上首造字/No.158-上首朗润体.ttf": "0067",
|
||||||
|
"上首造字/No.159-上首财神体.ttf": "0068",
|
||||||
|
"上首造字/No.16-上首锐圆体.ttf": "0069",
|
||||||
|
"上首造字/No.160-上首混沌体.ttf": "0070",
|
||||||
|
"上首造字/No.161-上首刺云体.ttf": "0071",
|
||||||
|
"上首造字/No.162-上首幻视体.ttf": "0072",
|
||||||
|
"上首造字/No.163-上首万象体.ttf": "0073",
|
||||||
|
"上首造字/No.164-上首简正体.ttf": "0074",
|
||||||
|
"上首造字/No.165-上首刺秦体.ttf": "0075",
|
||||||
|
"上首造字/No.166-上首暗黑体.ttf": "0076",
|
||||||
|
"上首造字/No.167-上首萌虎体.ttf": "0077",
|
||||||
|
"上首造字/No.168-上首雪糕体.ttf": "0078",
|
||||||
|
"上首造字/No.169-上首逐浪书法体.ttf": "0079",
|
||||||
|
"上首造字/No.17-上首钝黑体.ttf": "0080",
|
||||||
|
"上首造字/No.170-上首方玉体.ttf": "0081",
|
||||||
|
"上首造字/No.171-上首龙猫体.ttf": "0082",
|
||||||
|
"上首造字/No.172-上首木兰体.ttf": "0083",
|
||||||
|
"上首造字/No.173-上首山水宋体.ttf": "0084",
|
||||||
|
"上首造字/No.174-上首扑克体.ttf": "0085",
|
||||||
|
"上首造字/No.175-上首潮玩体.ttf": "0086",
|
||||||
|
"上首造字/No.176-上首鲁班宋体.ttf": "0087",
|
||||||
|
"上首造字/No.177-上首奶糖体.ttf": "0088",
|
||||||
|
"上首造字/No.178-上首浓墨体.ttf": "0089",
|
||||||
|
"上首造字/No.179-上首朋克体.ttf": "0090",
|
||||||
|
"上首造字/No.18-上首疾风书法体.ttf": "0091",
|
||||||
|
"上首造字/No.180-上首风云书法体.ttf": "0092",
|
||||||
|
"上首造字/No.181-上首未来体.ttf": "0093",
|
||||||
|
"上首造字/No.182-上首象牙体.ttf": "0094",
|
||||||
|
"上首造字/No.183-上首悦圆体.ttf": "0095",
|
||||||
|
"上首造字/No.184-上首月牙体.ttf": "0096",
|
||||||
|
"上首造字/No.185-上首矩正体.ttf": "0097",
|
||||||
|
"上首造字/No.186-上首鸿运体.ttf": "0098",
|
||||||
|
"上首造字/No.187-上首童年体.ttf": "0099",
|
||||||
|
"上首造字/No.188-上首凌宋体.ttf": "0100",
|
||||||
|
"上首造字/No.189-上首品黑体.ttf": "0101",
|
||||||
|
"上首造字/No.19-上首简黑极细体.ttf": "0102",
|
||||||
|
"上首造字/No.190-上首波波手写体.ttf": "0103",
|
||||||
|
"上首造字/No.191-上首祥瑞体.ttf": "0104",
|
||||||
|
"上首造字/No.192-上首傲骨体.ttf": "0105",
|
||||||
|
"上首造字/No.193-上首轩隶体.ttf": "0106",
|
||||||
|
"上首造字/No.194-上首品宋体.ttf": "0107",
|
||||||
|
"上首造字/No.195-上首金枝体.ttf": "0108",
|
||||||
|
"上首造字/No.196-上首游龙体.ttf": "0109",
|
||||||
|
"上首造字/No.197-上首魔方体.ttf": "0110",
|
||||||
|
"上首造字/No.198-上首呆呆体.ttf": "0111",
|
||||||
|
"上首造字/No.199-上首新潮体.ttf": "0112",
|
||||||
|
"上首造字/No.2-上首维黑体.ttf": "0113",
|
||||||
|
"上首造字/No.20-上首简宋纤细体.ttf": "0114",
|
||||||
|
"上首造字/No.200-上首博雅体.ttf": "0115",
|
||||||
|
"上首造字/No.201-上首鹊桥体.ttf": "0116",
|
||||||
|
"上首造字/No.202-上首雅倩体.ttf": "0117",
|
||||||
|
"上首造字/No.203-上首祥瑞手写体.ttf": "0118",
|
||||||
|
"上首造字/No.204-上首茶颜体.ttf": "0119",
|
||||||
|
"上首造字/No.205-上首羽刃体.ttf": "0120",
|
||||||
|
"上首造字/No.206-上首幻灵体.ttf": "0121",
|
||||||
|
"上首造字/No.207-上首西游体.ttf": "0122",
|
||||||
|
"上首造字/No.208-上首古居体.ttf": "0123",
|
||||||
|
"上首造字/No.209-上首润泽体.ttf": "0124",
|
||||||
|
"上首造字/No.21-上首传奇书法体.ttf": "0125",
|
||||||
|
"上首造字/No.210-上首醒狮体.ttf": "0126",
|
||||||
|
"上首造字/No.211-上首冰刃体.ttf": "0127",
|
||||||
|
"上首造字/No.212-上首玄武体.ttf": "0128",
|
||||||
|
"上首造字/No.213-上首雪兔体.ttf": "0129",
|
||||||
|
"上首造字/No.214-上首朱雀体.ttf": "0130",
|
||||||
|
"上首造字/No.215-上首朗月体.ttf": "0131",
|
||||||
|
"上首造字/No.216-上首招财猫体.ttf": "0132",
|
||||||
|
"上首造字/No.217-上首星舰体.ttf": "0133",
|
||||||
|
"上首造字/No.218-上首正舟体.ttf": "0134",
|
||||||
|
"上首造字/No.219-上首白虎书法体.ttf": "0135",
|
||||||
|
"上首造字/No.22-上首简圆体.ttf": "0136",
|
||||||
|
"上首造字/No.220-上首梧桐体.ttf": "0137",
|
||||||
|
"上首造字/No.221-上首标题体.ttf": "0138",
|
||||||
|
"上首造字/No.222-上首阿呆体.ttf": "0139",
|
||||||
|
"上首造字/No.223-上首月兔体.ttf": "0140",
|
||||||
|
"上首造字/No.224-上首绫黑体.ttf": "0141",
|
||||||
|
"上首造字/No.225-上首福禄体.ttf": "0142",
|
||||||
|
"上首造字/No.226-上首机甲体.ttf": "0143",
|
||||||
|
"上首造字/No.227-上首晴竹体.ttf": "0144",
|
||||||
|
"上首造字/No.228-上首光折体.ttf": "0145",
|
||||||
|
"上首造字/No.229-上首时光体.ttf": "0146",
|
||||||
|
"上首造字/No.23-上首布丁体.ttf": "0147",
|
||||||
|
"上首造字/No.230-上首胖虎体.ttf": "0148",
|
||||||
|
"上首造字/No.231-上首奇妙体.ttf": "0149",
|
||||||
|
"上首造字/No.232-上首龙腾体.ttf": "0150",
|
||||||
|
"上首造字/No.233-上首怪兽体.ttf": "0151",
|
||||||
|
"上首造字/No.234-上首极限体.ttf": "0152",
|
||||||
|
"上首造字/No.235-上首御风书法体.ttf": "0153",
|
||||||
|
"上首造字/No.236-上首东坡体.ttf": "0154",
|
||||||
|
"上首造字/No.237-上首木鱼体.ttf": "0155",
|
||||||
|
"上首造字/No.238-上首妙趣体.ttf": "0156",
|
||||||
|
"上首造字/No.239-上首青云体.ttf": "0157",
|
||||||
|
"上首造字/No.24-上首软糖体.ttf": "0158",
|
||||||
|
"上首造字/No.240-上首雅隶体.ttf": "0159",
|
||||||
|
"上首造字/No.241-上首蔷薇体.ttf": "0160",
|
||||||
|
"上首造字/No.242-上首海浪体.ttf": "0161",
|
||||||
|
"上首造字/No.243-上首青柏体.ttf": "0162",
|
||||||
|
"上首造字/No.244-上首墩墩体.ttf": "0163",
|
||||||
|
"上首造字/No.245-上首无限体.ttf": "0164",
|
||||||
|
"上首造字/No.246-上首本墨体.ttf": "0165",
|
||||||
|
"上首造字/No.247-上首祥云体.ttf": "0166",
|
||||||
|
"上首造字/No.248-上首非凡体.ttf": "0167",
|
||||||
|
"上首造字/No.249-上首市井体.ttf": "0168",
|
||||||
|
"上首造字/No.25-上首锐圆极细体.ttf": "0169",
|
||||||
|
"上首造字/No.250-上首松柏体.ttf": "0170",
|
||||||
|
"上首造字/No.251-上首水波体.ttf": "0171",
|
||||||
|
"上首造字/No.252-上首大头手写体.ttf": "0172",
|
||||||
|
"上首造字/No.253-上首银蛇体.ttf": "0173",
|
||||||
|
"上首造字/No.254-上首琥珀体.ttf": "0174",
|
||||||
|
"上首造字/No.255-上首敦煌隶书体.ttf": "0175",
|
||||||
|
"上首造字/No.256-上首楼兰体.ttf": "0176",
|
||||||
|
"上首造字/No.257-上首福袋体.ttf": "0177",
|
||||||
|
"上首造字/No.258-上首惊鸿体.ttf": "0178",
|
||||||
|
"上首造字/No.259-上首果冻体.ttf": "0179",
|
||||||
|
"上首造字/No.26-上首悦风体.ttf": "0180",
|
||||||
|
"上首造字/No.260-上首山岳体.ttf": "0181",
|
||||||
|
"上首造字/No.261-上首惊云体.ttf": "0182",
|
||||||
|
"上首造字/No.262-上首江南宋体.ttf": "0183",
|
||||||
|
"上首造字/No.263-上首蜡笔体.ttf": "0184",
|
||||||
|
"上首造字/No.264-上首赛博体.ttf": "0185",
|
||||||
|
"上首造字/No.265-上首心动体.ttf": "0186",
|
||||||
|
"上首造字/No.266-上首千禧体.ttf": "0187",
|
||||||
|
"上首造字/No.267-上首柔黑体.ttf": "0188",
|
||||||
|
"上首造字/No.268-上首黑仪体.ttf": "0189",
|
||||||
|
"上首造字/No.269-上首涂鸦体.ttf": "0190",
|
||||||
|
"上首造字/No.27-上首锐棱体.ttf": "0191",
|
||||||
|
"上首造字/No.270-上首天坛体.ttf": "0192",
|
||||||
|
"上首造字/No.271-上首麒麟体.ttf": "0193",
|
||||||
|
"上首造字/No.272-上首青瓦体.ttf": "0194",
|
||||||
|
"上首造字/No.273-上首风云体.ttf": "0195",
|
||||||
|
"上首造字/No.274-上首战魂体.ttf": "0196",
|
||||||
|
"上首造字/No.275-上首招福体.ttf": "0197",
|
||||||
|
"上首造字/No.276-上首佛陀体.ttf": "0198",
|
||||||
|
"上首造字/No.277-上首大漠体.ttf": "0199",
|
||||||
|
"上首造字/No.278-上首绘梦体.ttf": "0200",
|
||||||
|
"上首造字/No.279-上首东方体.ttf": "0201",
|
||||||
|
"上首造字/No.28-上首星岩体.ttf": "0202",
|
||||||
|
"上首造字/No.280-上首英雄体.ttf": "0203",
|
||||||
|
"上首造字/No.281-上首破冰体.ttf": "0204",
|
||||||
|
"上首造字/No.282-上首玄机体.ttf": "0205",
|
||||||
|
"上首造字/No.283-上首舞狮体.ttf": "0206",
|
||||||
|
"上首造字/No.284-上首北斗体.ttf": "0207",
|
||||||
|
"上首造字/No.285-上首万福体.ttf": "0208",
|
||||||
|
"上首造字/No.286-上首云木体.ttf": "0209",
|
||||||
|
"上首造字/No.287-上首方印体.ttf": "0210",
|
||||||
|
"上首造字/No.288-上首几何体.ttf": "0211",
|
||||||
|
"上首造字/No.289-上首江湖书法体.ttf": "0212",
|
||||||
|
"上首造字/No.29-上首先锋体.ttf": "0213",
|
||||||
|
"上首造字/No.290-上首书印体.ttf": "0214",
|
||||||
|
"上首造字/No.291-上首淘气体.ttf": "0215",
|
||||||
|
"上首造字/No.292-上首暗格体.ttf": "0216",
|
||||||
|
"上首造字/No.293-上首逸尘体.ttf": "0217",
|
||||||
|
"上首造字/No.294-上首小新体.ttf": "0218",
|
||||||
|
"上首造字/No.295-上首荒原体.ttf": "0219",
|
||||||
|
"上首造字/No.296-上首松针体.ttf": "0220",
|
||||||
|
"上首造字/No.297-上首墨玄体.ttf": "0221",
|
||||||
|
"上首造字/No.298-上首沧海书法体.ttf": "0222",
|
||||||
|
"上首造字/No.299-上首王朝体.ttf": "0223",
|
||||||
|
"上首造字/No.3-上首方圆体.ttf": "0224",
|
||||||
|
"上首造字/No.30-上首倾城体.ttf": "0225",
|
||||||
|
"上首造字/No.300-上首古皇体.ttf": "0226",
|
||||||
|
"上首造字/No.301-上首言亭体.ttf": "0227",
|
||||||
|
"上首造字/No.302-上首流金体.ttf": "0228",
|
||||||
|
"上首造字/No.303-上首墨宋体.ttf": "0229",
|
||||||
|
"上首造字/No.304-上首龙虎体.ttf": "0230",
|
||||||
|
"上首造字/No.305-上首点墨体.ttf": "0231",
|
||||||
|
"上首造字/No.306-上首万圣体.ttf": "0232",
|
||||||
|
"上首造字/No.307-上首润元体.ttf": "0233",
|
||||||
|
"上首造字/No.308-上首极速体.ttf": "0234",
|
||||||
|
"上首造字/No.309-上首栏栅体.ttf": "0235",
|
||||||
|
"上首造字/No.31-上首简黑纤细体.ttf": "0236",
|
||||||
|
"上首造字/No.310-上首浮华体.ttf": "0237",
|
||||||
|
"上首造字/No.311-上首梦想体.ttf": "0238",
|
||||||
|
"上首造字/No.312-上首诗宋体.ttf": "0239",
|
||||||
|
"上首造字/No.313-上首亦方体.ttf": "0240",
|
||||||
|
"上首造字/No.314-上首心愿体.ttf": "0241",
|
||||||
|
"上首造字/No.315-上首西施体.ttf": "0242",
|
||||||
|
"上首造字/No.316-上首国美体.ttf": "0243",
|
||||||
|
"上首造字/No.317-上首润玉体.ttf": "0244",
|
||||||
|
"上首造字/No.318-上首炫酷体.ttf": "0245",
|
||||||
|
"上首造字/No.319-上首云烟体.ttf": "0246",
|
||||||
|
"上首造字/No.32-上首芊芊体.ttf": "0247",
|
||||||
|
"上首造字/No.320-上首蜀汉体.ttf": "0248",
|
||||||
|
"上首造字/No.321-上首盛唐体.ttf": "0249",
|
||||||
|
"上首造字/No.322-上首折扇体.ttf": "0250",
|
||||||
|
"上首造字/No.323-上首青竹体.ttf": "0251",
|
||||||
|
"上首造字/No.324-上首胭脂体.ttf": "0252",
|
||||||
|
"上首造字/No.33-上首方糖体.ttf": "0253",
|
||||||
|
"上首造字/No.34-上首简黑中细体.ttf": "0254",
|
||||||
|
"上首造字/No.35-上首积木体.ttf": "0255",
|
||||||
|
"上首造字/No.36-上首金牛体.ttf": "0256",
|
||||||
|
"上首造字/No.37-上首朗倩体.ttf": "0257",
|
||||||
|
"上首造字/No.38-上首漠云体.ttf": "0258",
|
||||||
|
"上首造字/No.39-上首至尊书法体.ttf": "0259",
|
||||||
|
"上首造字/No.4-上首秀黑体.ttf": "0260",
|
||||||
|
"上首造字/No.40-上首星芒体.ttf": "0261",
|
||||||
|
"上首造字/No.41-上首山川体.ttf": "0262",
|
||||||
|
"上首造字/No.42-上首芋圆体.ttf": "0263",
|
||||||
|
"上首造字/No.43-上首星辰体.ttf": "0264",
|
||||||
|
"上首造字/No.44-上首哥特体.ttf": "0265",
|
||||||
|
"上首造字/No.45-上首黑岩体 .ttf": "0266",
|
||||||
|
"上首造字/No.46-上首言黑体.ttf": "0267",
|
||||||
|
"上首造字/No.47-上首御宋体.ttf": "0268",
|
||||||
|
"上首造字/No.48-上首三国体.ttf": "0269",
|
||||||
|
"上首造字/No.49-上首熊猫体.ttf": "0270",
|
||||||
|
"上首造字/No.5-上首时尚体.ttf": "0271",
|
||||||
|
"上首造字/No.50-上首国潮体.ttf": "0272",
|
||||||
|
"上首造字/No.51-上首酷方体.ttf": "0273",
|
||||||
|
"上首造字/No.52-上首碑楷体.ttf": "0274",
|
||||||
|
"上首造字/No.53-上首逸飞体.ttf": "0275",
|
||||||
|
"上首造字/No.54-上首少年体.ttf": "0276",
|
||||||
|
"上首造字/No.55-上首锋芒体.ttf": "0277",
|
||||||
|
"上首造字/No.56-上首新艺体.ttf": "0278",
|
||||||
|
"上首造字/No.57-上首嘉木体.ttf": "0279",
|
||||||
|
"上首造字/No.58-上首力方体.ttf": "0280",
|
||||||
|
"上首造字/No.59-上首折言体.ttf": "0281",
|
||||||
|
"上首造字/No.6-上首宽窄体.ttf": "0282",
|
||||||
|
"上首造字/No.60-上首本刻体.ttf": "0283",
|
||||||
|
"上首造字/No.61-上首古宋体.ttf": "0284",
|
||||||
|
"上首造字/No.62-上首黑风体.ttf": "0285",
|
||||||
|
"上首造字/No.63-上首迎风手写体.ttf": "0286",
|
||||||
|
"上首造字/No.64-上首奕星体.ttf": "0287",
|
||||||
|
"上首造字/No.65-上首山河体.ttf": "0288",
|
||||||
|
"上首造字/No.66-上首和风体.ttf": "0289",
|
||||||
|
"上首造字/No.67-上首轩宋体.ttf": "0290",
|
||||||
|
"上首造字/No.68-上首正雅体.ttf": "0291",
|
||||||
|
"上首造字/No.69-上首太空体.ttf": "0292",
|
||||||
|
"上首造字/No.7-上首文正体.ttf": "0293",
|
||||||
|
"上首造字/No.70-上首夏木体.ttf": "0294",
|
||||||
|
"上首造字/No.71-上首锐锋体.TTF": "0295",
|
||||||
|
"上首造字/No.72-上首喵尾体.ttf": "0296",
|
||||||
|
"上首造字/No.73-上首粉笔体.ttf": "0297",
|
||||||
|
"上首造字/No.74-上首战戟体.ttf": "0298",
|
||||||
|
"上首造字/No.75-上首玉立体.ttf": "0299",
|
||||||
|
"上首造字/No.76-上首战刃体.ttf": "0300",
|
||||||
|
"上首造字/No.77-上首苍穹书法体.ttf": "0301",
|
||||||
|
"上首造字/No.78-上首梦黑体.ttf": "0302",
|
||||||
|
"上首造字/No.79-上首博瀚体.ttf": "0303",
|
||||||
|
"上首造字/No.8-上首京东体.ttf": "0304",
|
||||||
|
"上首造字/No.80-上首乐高体.ttf": "0305",
|
||||||
|
"上首造字/No.81-上首胖墩体.ttf": "0306",
|
||||||
|
"上首造字/No.82-上首简黑粗体.ttf": "0307",
|
||||||
|
"上首造字/No.83-上首粗楷体.ttf": "0308",
|
||||||
|
"上首造字/No.84-上首状元体.ttf": "0309",
|
||||||
|
"上首造字/No.85-上首元气体.ttf": "0310",
|
||||||
|
"上首造字/No.86-上首圆盾体.ttf": "0311",
|
||||||
|
"上首造字/No.87-上首乾坤体.ttf": "0312",
|
||||||
|
"上首造字/No.88-上首典宋体.ttf": "0313",
|
||||||
|
"上首造字/No.89-上首金刚体.ttf": "0314",
|
||||||
|
"上首造字/No.9-上首宽云体.ttf": "0315",
|
||||||
|
"上首造字/No.90-上首嘻哈体.ttf": "0316",
|
||||||
|
"上首造字/No.91-上首婷雅体.ttf": "0317",
|
||||||
|
"上首造字/No.92-上首虎啸体.ttf": "0318",
|
||||||
|
"上首造字/No.93-上首萌动体.ttf": "0319",
|
||||||
|
"上首造字/No.94-上首奥丁体.ttf": "0320",
|
||||||
|
"上首造字/No.95-上首昆仑体.ttf": "0321",
|
||||||
|
"上首造字/No.96-上首锦宋体.ttf": "0322",
|
||||||
|
"上首造字/No.97-上首国宋体.ttf": "0323",
|
||||||
|
"上首造字/No.98-上首趣泡手写体.ttf": "0324",
|
||||||
|
"上首造字/No.99-上首元正体.ttf": "0325",
|
||||||
|
"上首造字/Sounso Art.ttf": "0326",
|
||||||
|
"上首造字/Sounso Beauty.ttf": "0327",
|
||||||
|
"上首造字/Sounso Cecily.ttf": "0328",
|
||||||
|
"上首造字/Sounso Diamond.ttf": "0329",
|
||||||
|
"上首造字/Sounso Earnestly.ttf": "0330",
|
||||||
|
"上首造字/上首兰亭体.ttf": "0331",
|
||||||
|
"上首造字/上首凤凰体.ttf": "0332",
|
||||||
|
"上首造字/上首刀锋体.ttf": "0333",
|
||||||
|
"上首造字/上首华光体.ttf": "0334",
|
||||||
|
"上首造字/上首华木体.ttf": "0335",
|
||||||
|
"上首造字/上首南城体.ttf": "0336",
|
||||||
|
"上首造字/上首品尚体.ttf": "0337",
|
||||||
|
"上首造字/上首战马体.ttf": "0338",
|
||||||
|
"上首造字/上首星语体.ttf": "0339",
|
||||||
|
"上首造字/上首熔岩体.ttf": "0340",
|
||||||
|
"上首造字/上首纤云体.ttf": "0341",
|
||||||
|
"上首造字/上首纯元体.ttf": "0342",
|
||||||
|
"上首造字/上首苍兰体.ttf": "0343",
|
||||||
|
"上首造字/上首轩辕体.ttf": "0344",
|
||||||
|
"上首造字/上首锦云体.ttf": "0345",
|
||||||
|
"上首造字/上首雅居体.ttf": "0346",
|
||||||
|
"其他字体/AlimamaDaoLiTi.ttf": "0347",
|
||||||
|
"其他字体/Hangeuljaemin4-Regular.ttf": "0348",
|
||||||
|
"其他字体/I.顏體.ttf": "0349",
|
||||||
|
"其他字体/XCDUANZHUANGSONGTI.ttf": "0350",
|
||||||
|
"其他字体/qiji-combo.ttf": "0351",
|
||||||
|
"其他字体/临海隶书.ttf": "0352",
|
||||||
|
"其他字体/京華老宋体_KingHwa_OldSong.ttf": "0353",
|
||||||
|
"其他字体/优设标题黑.ttf": "0354",
|
||||||
|
"其他字体/包图小白体.ttf": "0355",
|
||||||
|
"其他字体/源界明朝.ttf": "0356",
|
||||||
|
"其他字体/演示佛系体.ttf": "0357",
|
||||||
|
"其他字体/站酷快乐体.ttf": "0358",
|
||||||
|
"其他字体/问藏书房.ttf": "0359",
|
||||||
|
"其他字体/霞鹜臻楷.ttf": "0360",
|
||||||
|
"庞门正道-测试/庞门正道标题体.ttf": "0016",
|
||||||
|
"庞门正道/庞门正道标题体.ttf": "0361",
|
||||||
|
"王漢宗/王漢宗勘亭流繁.ttf": "0362",
|
||||||
|
"王漢宗/王漢宗新潮體.ttf": "0363",
|
||||||
|
"王漢宗/王漢宗波卡體空陰.ttf": "0364",
|
||||||
|
"王漢宗/王漢宗細黑體繁.ttf": "0365",
|
||||||
|
"王漢宗/王漢宗綜藝體雙空陰.ttf": "0366",
|
||||||
|
"王漢宗/王漢宗超明體繁.ttf": "0367",
|
||||||
|
"王漢宗/王漢宗酷儷海報.ttf": "0368",
|
||||||
|
"王漢宗/王漢宗顏楷體繁.ttf": "0369"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,15 +1,176 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""
|
"""
|
||||||
生成字体清单文件
|
生成字体清单文件(稳定 ID 版本)
|
||||||
扫描 fonts/ 目录下的所有字体文件,同时生成:
|
扫描 fonts/ 目录下的所有字体文件,同时生成:
|
||||||
1. frontend/public/fonts.json
|
1. frontend/public/fonts.json
|
||||||
2. miniprogram/assets/fonts.json
|
2. miniprogram/assets/fonts.json
|
||||||
3. miniprogram/assets/fonts.js
|
3. miniprogram/assets/fonts.js
|
||||||
|
|
||||||
|
ID 分配规则:
|
||||||
|
1. 已存在字体(按 relativePath 识别)保持原 ID 不变。
|
||||||
|
2. 新增字体按“上次游标”分配新 ID。
|
||||||
|
3. ID 从 0001 递增到 10000,之后循环分配(跳过当前正在使用的 ID)。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import json
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from urllib.parse import unquote, urlparse
|
||||||
|
|
||||||
|
FONT_DIR = Path('fonts')
|
||||||
|
WEB_FONTS_JSON = Path('frontend/public/fonts.json')
|
||||||
|
MP_FONTS_JSON = Path('miniprogram/assets/fonts.json')
|
||||||
|
MP_FONTS_JS = Path('miniprogram/assets/fonts.js')
|
||||||
|
ID_STATE_FILE = Path('scripts/font-id-state.json')
|
||||||
|
|
||||||
|
ID_MIN = 1
|
||||||
|
ID_MAX = 10000
|
||||||
|
|
||||||
|
|
||||||
|
def format_font_id(num):
|
||||||
|
return str(num).zfill(4)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_font_id(raw):
|
||||||
|
try:
|
||||||
|
value = int(str(raw).strip())
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
if ID_MIN <= value <= ID_MAX:
|
||||||
|
return value
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def increment_id(num):
|
||||||
|
return ID_MIN if num >= ID_MAX else num + 1
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_relative_path(raw_path):
|
||||||
|
if not raw_path:
|
||||||
|
return None
|
||||||
|
|
||||||
|
text = str(raw_path).strip()
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
|
||||||
|
parsed = urlparse(text)
|
||||||
|
if parsed.scheme and parsed.netloc:
|
||||||
|
text = parsed.path
|
||||||
|
|
||||||
|
text = unquote(text)
|
||||||
|
text = text.split('?', 1)[0].split('#', 1)[0]
|
||||||
|
text = text.replace('\\', '/')
|
||||||
|
|
||||||
|
if '/fonts/' in text:
|
||||||
|
text = text.split('/fonts/', 1)[1]
|
||||||
|
elif text.startswith('fonts/'):
|
||||||
|
text = text[len('fonts/'):]
|
||||||
|
elif text.startswith('/fonts'):
|
||||||
|
text = text[len('/fonts'):]
|
||||||
|
|
||||||
|
text = text.lstrip('/')
|
||||||
|
return text or None
|
||||||
|
|
||||||
|
|
||||||
|
def load_manifest_id_map(manifest_file):
|
||||||
|
mapping = {}
|
||||||
|
if not manifest_file.exists():
|
||||||
|
return mapping
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(manifest_file, 'r', encoding='utf-8') as f:
|
||||||
|
data = json.load(f)
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
return mapping
|
||||||
|
|
||||||
|
if not isinstance(data, list):
|
||||||
|
return mapping
|
||||||
|
|
||||||
|
for item in data:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
relative_path = normalize_relative_path(
|
||||||
|
item.get('path') or item.get('relativePath')
|
||||||
|
)
|
||||||
|
id_num = parse_font_id(item.get('id'))
|
||||||
|
if relative_path and id_num:
|
||||||
|
mapping[relative_path] = format_font_id(id_num)
|
||||||
|
|
||||||
|
return mapping
|
||||||
|
|
||||||
|
|
||||||
|
def load_id_state(state_file):
|
||||||
|
default_state = {
|
||||||
|
'version': 1,
|
||||||
|
'nextId': ID_MIN,
|
||||||
|
'pathToId': {},
|
||||||
|
}
|
||||||
|
|
||||||
|
if not state_file.exists():
|
||||||
|
return default_state
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(state_file, 'r', encoding='utf-8') as f:
|
||||||
|
data = json.load(f)
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
return default_state
|
||||||
|
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return default_state
|
||||||
|
|
||||||
|
path_to_id = {}
|
||||||
|
raw_map = data.get('pathToId')
|
||||||
|
if isinstance(raw_map, dict):
|
||||||
|
for path_key, raw_id in raw_map.items():
|
||||||
|
relative_path = normalize_relative_path(path_key)
|
||||||
|
id_num = parse_font_id(raw_id)
|
||||||
|
if relative_path and id_num:
|
||||||
|
path_to_id[relative_path] = format_font_id(id_num)
|
||||||
|
|
||||||
|
next_id = parse_font_id(data.get('nextId')) or ID_MIN
|
||||||
|
|
||||||
|
return {
|
||||||
|
'version': 1,
|
||||||
|
'nextId': next_id,
|
||||||
|
'pathToId': path_to_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def save_id_state(state, state_file):
|
||||||
|
state_file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
normalized_map = dict(sorted(state['pathToId'].items(), key=lambda x: x[0]))
|
||||||
|
data = {
|
||||||
|
'version': 1,
|
||||||
|
'nextId': state['nextId'],
|
||||||
|
'pathToId': normalized_map,
|
||||||
|
}
|
||||||
|
with open(state_file, 'w', encoding='utf-8') as f:
|
||||||
|
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||||
|
f.write('\n')
|
||||||
|
|
||||||
|
|
||||||
|
def bootstrap_state_mappings(state):
|
||||||
|
# 优先级:state 文件 > 小程序现有清单 > web 现有清单
|
||||||
|
for manifest_path in (MP_FONTS_JSON, WEB_FONTS_JSON):
|
||||||
|
mapping = load_manifest_id_map(manifest_path)
|
||||||
|
for relative_path, font_id in mapping.items():
|
||||||
|
state['pathToId'].setdefault(relative_path, font_id)
|
||||||
|
|
||||||
|
if parse_font_id(state.get('nextId')) is None:
|
||||||
|
state['nextId'] = ID_MIN
|
||||||
|
|
||||||
|
if state['nextId'] == ID_MIN and state['pathToId']:
|
||||||
|
max_id = max(parse_font_id(v) for v in state['pathToId'].values())
|
||||||
|
state['nextId'] = increment_id(max_id)
|
||||||
|
|
||||||
|
|
||||||
|
def allocate_new_id(start_id, used_ids):
|
||||||
|
candidate = start_id
|
||||||
|
for _ in range(ID_MAX):
|
||||||
|
if candidate not in used_ids:
|
||||||
|
return candidate, increment_id(candidate)
|
||||||
|
candidate = increment_id(candidate)
|
||||||
|
raise RuntimeError('可用 ID 已耗尽(0001-10000 全部被当前字体占用)')
|
||||||
|
|
||||||
def scan_fonts(font_dir='fonts'):
|
def scan_fonts(font_dir='fonts'):
|
||||||
"""扫描字体目录,返回字体信息列表"""
|
"""扫描字体目录,返回字体信息列表"""
|
||||||
@@ -45,14 +206,48 @@ def scan_fonts(font_dir='fonts'):
|
|||||||
|
|
||||||
fonts.append(font_info)
|
fonts.append(font_info)
|
||||||
|
|
||||||
# 统一排序后分配 4 位数字 id(0001、0002...)
|
# 固定顺序,保证分配冲突时结果稳定
|
||||||
fonts = sorted(fonts, key=lambda x: (x['category'], x['name'], x['filename']))
|
fonts = sorted(fonts, key=lambda x: (x['category'], x['name'], x['filename'], x['relativePath']))
|
||||||
for index, font in enumerate(fonts, start=1):
|
|
||||||
font['id'] = f"{index:04d}"
|
|
||||||
|
|
||||||
return fonts
|
return fonts
|
||||||
|
|
||||||
|
|
||||||
|
def assign_stable_ids(fonts):
|
||||||
|
state = load_id_state(ID_STATE_FILE)
|
||||||
|
bootstrap_state_mappings(state)
|
||||||
|
|
||||||
|
cursor = parse_font_id(state.get('nextId')) or ID_MIN
|
||||||
|
used_ids = set()
|
||||||
|
new_count = 0
|
||||||
|
|
||||||
|
for font in fonts:
|
||||||
|
relative_path = font['relativePath']
|
||||||
|
existing_id = state['pathToId'].get(relative_path)
|
||||||
|
existing_num = parse_font_id(existing_id)
|
||||||
|
|
||||||
|
if existing_num and existing_num not in used_ids:
|
||||||
|
assigned_num = existing_num
|
||||||
|
else:
|
||||||
|
if existing_num and existing_num in used_ids:
|
||||||
|
print(
|
||||||
|
f"警告: 发现 ID 冲突,路径 {relative_path} 原 ID {existing_id} 将重新分配。"
|
||||||
|
)
|
||||||
|
assigned_num, cursor = allocate_new_id(cursor, used_ids)
|
||||||
|
state['pathToId'][relative_path] = format_font_id(assigned_num)
|
||||||
|
new_count += 1
|
||||||
|
|
||||||
|
used_ids.add(assigned_num)
|
||||||
|
font['id'] = format_font_id(assigned_num)
|
||||||
|
|
||||||
|
state['nextId'] = cursor
|
||||||
|
save_id_state(state, ID_STATE_FILE)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'newCount': new_count,
|
||||||
|
'nextId': format_font_id(cursor),
|
||||||
|
'stateFile': str(ID_STATE_FILE),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def build_manifest(fonts, path_prefix):
|
def build_manifest(fonts, path_prefix):
|
||||||
"""根据路径前缀构建对外清单"""
|
"""根据路径前缀构建对外清单"""
|
||||||
prefix = f"/{str(path_prefix or '').strip('/')}"
|
prefix = f"/{str(path_prefix or '').strip('/')}"
|
||||||
@@ -91,18 +286,25 @@ def write_fonts_js(fonts, output_file):
|
|||||||
def main():
|
def main():
|
||||||
"""主函数"""
|
"""主函数"""
|
||||||
# 扫描字体(唯一来源:仓库根目录 fonts/)
|
# 扫描字体(唯一来源:仓库根目录 fonts/)
|
||||||
fonts = scan_fonts('fonts')
|
fonts = scan_fonts(str(FONT_DIR))
|
||||||
|
|
||||||
print(f"找到 {len(fonts)} 个字体文件")
|
print(f"找到 {len(fonts)} 个字体文件")
|
||||||
|
|
||||||
|
allocation_result = assign_stable_ids(fonts)
|
||||||
|
print(
|
||||||
|
f"ID 分配完成:新增 {allocation_result['newCount']} 个,"
|
||||||
|
f"下次起始 ID {allocation_result['nextId']},"
|
||||||
|
f"状态文件 {allocation_result['stateFile']}"
|
||||||
|
)
|
||||||
|
|
||||||
# Web 清单:统一指向根目录 fonts
|
# Web 清单:统一指向根目录 fonts
|
||||||
web_fonts = build_manifest(fonts, '/fonts')
|
web_fonts = build_manifest(fonts, '/fonts')
|
||||||
write_fonts_json(web_fonts, 'frontend/public/fonts.json')
|
write_fonts_json(web_fonts, str(WEB_FONTS_JSON))
|
||||||
|
|
||||||
# 小程序清单:同样指向根目录 fonts(与 web 共用一份字体目录)
|
# 小程序清单:同样指向根目录 fonts(与 web 共用一份字体目录)
|
||||||
miniprogram_fonts = build_manifest(fonts, '/fonts')
|
miniprogram_fonts = build_manifest(fonts, '/fonts')
|
||||||
write_fonts_json(miniprogram_fonts, 'miniprogram/assets/fonts.json')
|
write_fonts_json(miniprogram_fonts, str(MP_FONTS_JSON))
|
||||||
write_fonts_js(miniprogram_fonts, 'miniprogram/assets/fonts.js')
|
write_fonts_js(miniprogram_fonts, str(MP_FONTS_JS))
|
||||||
|
|
||||||
# 统计信息
|
# 统计信息
|
||||||
categories = {}
|
categories = {}
|
||||||
|
|||||||
Reference in New Issue
Block a user