update at 2026-02-08 22:31:25

This commit is contained in:
douboer
2026-02-08 22:31:25 +08:00
parent 0f5a7f0d85
commit f078dd3261
39 changed files with 587 additions and 213 deletions

View File

@@ -1,28 +1,33 @@
const { loadFontsManifest } = require('../../utils/mp/font-loader')
const { loadAppState, saveAppState, loadFavorites, saveFavorites } = require('../../utils/mp/storage')
const { saveSvgToUserPath, shareLocalFile, buildFilename } = require('../../utils/mp/file-export')
const { exportSvgToPngByCanvas, savePngToAlbum } = require('../../utils/mp/canvas-export')
const { renderSvgByApi } = require('../../utils/mp/render-api')
const {
shareSvgFromUserTap,
} = require('../../utils/mp/file-export')
const { savePngToAlbum } = require('../../utils/mp/canvas-export')
const { renderSvgByApi, renderPngByApi } = require('../../utils/mp/render-api')
// const { ICON_PATHS } = require('../../config/cdn') // CDN 方案暂时注释
const COLOR_PALETTE = ['#000000', '#1d4ed8', '#047857', '#b45309', '#dc2626', '#7c3aed']
// 临时使用本地图标
// 临时使用本地图标 - 根据Figma annotation配置
const LOCAL_ICON_PATHS = {
logo: '/assets/icons/webicon.png',
fontSizeDecrease: '/assets/icons/font-size-decrease.png',
fontSizeIncrease: '/assets/icons/font-size-increase.png',
chooseColor: '/assets/icons/choose-color.png',
export: '/assets/icons/export.png',
exportSvg: '/assets/icons/export-svg.png',
exportPng: '/assets/icons/export-png.png',
// 字体树图标参考web项目
fontIcon: 'https://fonts.biboer.cn/assets/icons/icons_idx%20_18.svg', // 字体item图标
expandIcon: 'https://fonts.biboer.cn/assets/icons/icons_idx%20_12.svg', // 展开分类图标
collapseIcon: 'https://fonts.biboer.cn/assets/icons/zhedie.svg', // 折叠分类图标
favoriteIcon: 'https://fonts.biboer.cn/assets/icons/icons_idx%20_19.svg', // 收藏图标
checkbox: '/assets/icons/checkbox.png', // 预览checkbox
search: 'https://fonts.biboer.cn/assets/icons/search.svg' // 搜索图标
fontSizeDecrease: '/assets/icons/font-size-decrease.svg',
fontSizeIncrease: '/assets/icons/font-size-increase.svg',
chooseColor: '/assets/icons/choose-color.svg',
// 导出按钮根据Figma annotation
exportSvg: '/assets/icons/export-svg-s.svg', // 紫色SVG导出按钮
exportPng: '/assets/icons/export-png-s.svg', // 蓝色PNG导出按钮
// 输入框图标
content: '/assets/icons/content.svg', // 输入框左侧绿色图标
// 字体树图标根据Figma annotation
fontIcon: '/assets/icons/font-icon.svg', // 字体item图标
expandIcon: '/assets/icons/expand.svg', // 展开分类图标
collapseIcon: '/assets/icons/expand.svg', // 折叠使用同一图标,通过旋转实现
favoriteIcon: '/assets/icons/favorite.svg', // 收藏图标(未收藏白色底,收藏红色底)
checkbox: '/assets/icons/checkbox.svg', // 复选框(未选中)
checkboxChecked: '/assets/icons/checkbox-no.svg', // 复选框(已选中)
search: '/assets/icons/search.svg', // 搜索图标
}
function toSvgDataUri(svg) {
@@ -38,6 +43,32 @@ function normalizeHexColor(input) {
return fallback
}
function extractErrorMessage(error, fallback) {
if (!error) {
return fallback
}
const errMsg = error.errMsg || error.message || String(error)
return errMsg || fallback
}
function showExportError(title, error, fallback) {
const message = extractErrorMessage(error, fallback)
wx.showModal({
title,
content: message,
showCancel: false,
confirmText: '知道了',
})
}
function writePngBufferToTempFile(pngBuffer, fontName) {
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 fs = wx.getFileSystemManager()
fs.writeFileSync(filePath, pngBuffer)
return filePath
}
Page({
data: {
inputText: '星程字体转换',
@@ -324,10 +355,19 @@ Page({
selectedFonts[index].svg = result.svg
selectedFonts[index].width = result.width
selectedFonts[index].height = result.height
selectedFonts[index].previewError = ''
this.setData({ selectedFonts })
}
} catch (error) {
console.error('生成预览失败', error)
const selectedFonts = this.data.selectedFonts
const index = selectedFonts.findIndex(f => f.id === fontId)
if (index >= 0) {
selectedFonts[index].previewSrc = ''
selectedFonts[index].svg = ''
selectedFonts[index].previewError = (error && error.message) ? error.message : '预览生成失败'
this.setData({ selectedFonts })
}
}
},
@@ -471,21 +511,14 @@ Page({
return
}
wx.showLoading({ title: '导出 SVG 中', mask: true })
wx.showModal({
title: '微信限制说明',
content: '微信要求 shareFileMessage 必须由单次点击直接触发,暂不支持批量自动分享 SVG请逐个字体导出。',
showCancel: false,
confirmText: '知道了',
})
return
try {
for (const font of selectedFonts) {
const filePath = await saveSvgToUserPath(font.svg, font.name, this.data.inputText)
const filename = buildFilename(font.name, this.data.inputText, 'svg')
await shareLocalFile(filePath, filename)
}
wx.showToast({ title: 'SVG 导出完成', icon: 'success' })
} catch (error) {
const message = error && error.errMsg ? error.errMsg : error.message
wx.showToast({ title: message || '导出 SVG 失败', icon: 'none', duration: 2400 })
} finally {
wx.hideLoading()
}
},
async exportAllPng() {
@@ -500,21 +533,24 @@ Page({
try {
for (const font of selectedFonts) {
const width = Math.max(64, Math.round(font.width || 1024))
const height = Math.max(64, Math.round(font.height || 1024))
const pngPath = await exportSvgToPngByCanvas(this, {
svgString: font.svg,
width,
height,
const pngBuffer = await renderPngByApi({
fontId: font.id,
text: this.data.inputText,
fontSize: Number(this.data.fontSize),
fillColor: normalizeHexColor(this.data.textColor),
letterSpacing: Number(this.data.letterSpacingInput || 0),
maxCharsPerLine: 45,
})
const pngPath = writePngBufferToTempFile(pngBuffer, font.name)
await savePngToAlbum(pngPath)
const saveResult = await savePngToAlbum(pngPath)
if (!saveResult.success) {
throw saveResult.error || new Error('保存 PNG 失败')
}
}
wx.showToast({ title: 'PNG 已保存到相册', icon: 'success' })
} catch (error) {
const message = error && error.errMsg ? error.errMsg : error.message
wx.showToast({ title: message || '导出 PNG 失败', icon: 'none', duration: 2400 })
showExportError('导出 PNG 失败', error, '请稍后重试')
} finally {
wx.hideLoading()
}
@@ -531,17 +567,11 @@ Page({
// 如果只有一个字体,直接导出
if (selectedFonts.length === 1) {
const font = selectedFonts[0]
wx.showLoading({ title: '导出 SVG 中', mask: true })
try {
const filePath = await saveSvgToUserPath(font.svg, font.name, this.data.inputText)
const filename = buildFilename(font.name, this.data.inputText, 'svg')
await shareLocalFile(filePath, filename)
await shareSvgFromUserTap(font.svg, font.name, this.data.inputText)
wx.showToast({ title: 'SVG 已分享', icon: 'success' })
} catch (error) {
const message = error && error.errMsg ? error.errMsg : error.message
wx.showToast({ title: message || '导出 SVG 失败', icon: 'none', duration: 2400 })
} finally {
wx.hideLoading()
showExportError('导出 SVG 失败', error, '请稍后重试')
}
} else {
this.exportAllSvg()
@@ -562,24 +592,24 @@ Page({
wx.showLoading({ title: '导出 PNG 中', mask: true })
try {
const width = Math.max(64, Math.round(font.width || 1024))
const height = Math.max(64, Math.round(font.height || 1024))
const pngPath = await exportSvgToPngByCanvas(this, {
svgString: font.svg,
width,
height,
const pngBuffer = await renderPngByApi({
fontId: font.id,
text: this.data.inputText,
fontSize: Number(this.data.fontSize),
fillColor: normalizeHexColor(this.data.textColor),
letterSpacing: Number(this.data.letterSpacingInput || 0),
maxCharsPerLine: 45,
})
const pngPath = writePngBufferToTempFile(pngBuffer, font.name)
const saveResult = await savePngToAlbum(pngPath)
if (saveResult.success) {
wx.showToast({ title: 'PNG 已保存到相册', icon: 'success' })
} else {
wx.showToast({ title: '保存失败,请重试', icon: 'none' })
showExportError('导出 PNG 失败', saveResult.error, '保存失败,请检查相册权限')
}
} catch (error) {
const message = error && error.errMsg ? error.errMsg : error.message
wx.showToast({ title: message || '导出 PNG 失败', icon: 'none', duration: 2400 })
showExportError('导出 PNG 失败', error, '请稍后重试')
} finally {
wx.hideLoading()
}
@@ -588,6 +618,59 @@ Page({
}
},
// 单个字体导出
async onExportSingleSvg(e) {
const fontId = e.currentTarget.dataset.fontId
const font = this.data.selectedFonts.find(f => f.id === fontId)
if (!font || !font.svg) {
wx.showToast({ title: '请先生成预览', icon: 'none' })
return
}
try {
await shareSvgFromUserTap(font.svg, font.name, this.data.inputText)
wx.showToast({ title: 'SVG 已分享', icon: 'success' })
} catch (error) {
showExportError('导出 SVG 失败', error, '请稍后重试')
}
},
async onExportSinglePng(e) {
const fontId = e.currentTarget.dataset.fontId
const font = this.data.selectedFonts.find(f => f.id === fontId)
if (!font || !font.svg) {
wx.showToast({ title: '请先生成预览', icon: 'none' })
return
}
wx.showLoading({ title: '导出 PNG 中', mask: true })
try {
const pngBuffer = await renderPngByApi({
fontId: font.id,
text: this.data.inputText,
fontSize: Number(this.data.fontSize),
fillColor: normalizeHexColor(this.data.textColor),
letterSpacing: Number(this.data.letterSpacingInput || 0),
maxCharsPerLine: 45,
})
const pngPath = writePngBufferToTempFile(pngBuffer, font.name)
const saveResult = await savePngToAlbum(pngPath)
if (saveResult.success) {
wx.showToast({ title: 'PNG 已保存到相册', icon: 'success' })
} else {
showExportError('导出 PNG 失败', saveResult.error, '保存失败,请检查相册权限')
}
} catch (error) {
showExportError('导出 PNG 失败', error, '请稍后重试')
} finally {
wx.hideLoading()
}
},
// 搜索功能
onToggleSearch() {
this.setData({ showSearch: !this.data.showSearch })

View File

@@ -38,8 +38,9 @@
</view>
</view>
<!-- 输入栏和导出按钮 -->
<!-- 输入栏 -->
<view class="input-row">
<image class="content-icon" src="{{icons.content}}" />
<view class="text-input-container">
<input
class="text-input"
@@ -50,17 +51,6 @@
bindconfirm="onRegenerate"
/>
</view>
<view class="export-buttons">
<view class="export-btn" bindtap="onShowExportOptions">
<image class="export-icon" src="{{icons.export}}" />
</view>
<view class="export-btn" bindtap="onExportSvg">
<image class="export-icon" src="{{icons.exportSvg}}" />
</view>
<view class="export-btn" bindtap="onExportPng">
<image class="export-icon" src="{{icons.exportPng}}" />
</view>
</view>
</view>
<!-- 效果预览区域 -->
@@ -71,9 +61,12 @@
<view class="preview-header">
<image class="font-icon" src="{{icons.fontIcon}}" />
<view class="font-name-text">{{item.name}}</view>
<view class="preview-checkbox" bindtap="onTogglePreviewFont" data-font-id="{{item.id}}">
<view class="checkbox-wrapper {{item.showInPreview ? 'checked' : ''}}">
<image wx:if="{{item.showInPreview}}" class="checkbox-icon" src="{{icons.checkbox}}" />
<view class="export-btns-inline">
<view class="export-btn-sm export-svg-btn" bindtap="onExportSingleSvg" data-font-id="{{item.id}}">
<image class="export-icon-sm" src="{{icons.exportSvg}}" />
</view>
<view class="export-btn-sm export-png-btn" bindtap="onExportSinglePng" data-font-id="{{item.id}}">
<image class="export-icon-sm" src="{{icons.exportPng}}" />
</view>
</view>
</view>
@@ -84,6 +77,7 @@
mode="widthFix"
src="{{item.previewSrc}}"
/>
<view wx:elif="{{item.previewError}}" class="preview-error">{{item.previewError}}</view>
<view wx:else class="preview-loading">生成中...</view>
</view>
</view>
@@ -106,7 +100,7 @@
bindinput="onSearchInput"
/>
</view>
<view class="search-toggle" bindtap="onToggleSearch">
<view class="search-toggle" wx:if="{{!showSearch}}" bindtap="onToggleSearch">
<image class="search-icon" src="{{icons.search}}" />
</view>
</view>
@@ -115,7 +109,8 @@
<view class="category-header" bindtap="onToggleCategory" data-category="{{item.category}}">
<image
class="expand-icon"
src="{{item.expanded ? icons.collapseIcon : icons.expandIcon}}"
src="{{icons.expandIcon}}"
style="transform: rotate({{item.expanded ? '90deg' : '0deg'}})"
/>
<view class="category-name">{{item.category}}</view>
</view>
@@ -131,7 +126,7 @@
<view class="font-item-actions">
<view class="font-checkbox" bindtap="onToggleFont" data-font-id="{{font.id}}">
<view class="checkbox-wrapper {{font.selected ? 'checked' : ''}}">
<image wx:if="{{font.selected}}" class="checkbox-icon-sm" src="{{icons.checkbox}}" />
<image wx:if="{{font.selected}}" class="checkbox-icon-sm" src="{{icons.checkboxChecked}}" />
</view>
</view>
<view class="favorite-btn" bindtap="onToggleFavorite" data-font-id="{{font.id}}">
@@ -156,7 +151,8 @@
<view class="category-header" bindtap="onToggleFavoriteCategory" data-category="{{item.category}}">
<image
class="expand-icon"
src="{{item.expanded ? icons.collapseIcon : icons.expandIcon}}"
src="{{icons.expandIcon}}"
style="transform: rotate({{item.expanded ? '90deg' : '0deg'}})"
/>
<view class="category-name">{{item.category}}</view>
</view>
@@ -172,7 +168,7 @@
<view class="font-item-actions">
<view class="font-checkbox" bindtap="onToggleFont" data-font-id="{{font.id}}">
<view class="checkbox-wrapper {{font.selected ? 'checked' : ''}}">
<image wx:if="{{font.selected}}" class="checkbox-icon-sm" src="{{icons.checkbox}}" />
<image wx:if="{{font.selected}}" class="checkbox-icon-sm" src="{{icons.checkboxChecked}}" />
</view>
</view>
<view class="favorite-btn" bindtap="onToggleFavorite" data-font-id="{{font.id}}">

View File

@@ -71,12 +71,20 @@
margin-top: 16rpx;
}
.content-icon {
width: 44rpx;
height: 72rpx;
flex-shrink: 0;
}
.text-input-container {
flex: 1;
height: 100%;
background: #F7F8FA;
border-radius: 12rpx;
padding: 0 6rpx;
display: flex;
align-items: center;
}
.text-input {
@@ -86,30 +94,6 @@
color: #4E5969;
}
.export-buttons {
display: flex;
align-items: center;
gap: 12rpx;
height: 100%;
background: #fff;
border: 1rpx solid #E5E6EB;
border-radius: 10rpx;
padding: 5rpx 10rpx;
}
.export-btn {
width: 62rpx;
height: 62rpx;
display: flex;
align-items: center;
justify-content: center;
}
.export-icon {
width: 100%;
height: 100%;
}
/* 效果预览区域 */
.preview-section {
flex: 1;
@@ -162,6 +146,35 @@
color: #86909C;
}
.export-btns-inline {
display: flex;
align-items: center;
gap: 12rpx;
}
.export-btn-sm {
display: flex;
align-items: center;
justify-content: center;
border-radius: 8rpx;
padding: 4rpx 6rpx;
width: 50rpx;
height: 22rpx;
}
.export-btn-sm.export-svg-btn {
background: #8552A1;
}
.export-btn-sm.export-png-btn {
background: #2420A8;
}
.export-icon-sm {
width: 100%;
height: 100%;
}
.preview-checkbox {
width: 14rpx;
height: 14rpx;
@@ -206,6 +219,13 @@
color: #86909C;
}
.preview-error {
font-size: 22rpx;
color: #dc2626;
text-align: center;
padding: 0 12rpx;
}
.preview-empty {
text-align: center;
padding: 80rpx 0;
@@ -240,6 +260,13 @@
align-items: center;
gap: 8rpx;
padding: 4rpx 0;
height: 56rpx;
}
.selection-header .section-title {
padding: 0;
height: auto;
line-height: 56rpx;
}
.search-container {
@@ -250,7 +277,7 @@
background: #F7F8FA;
border-radius: 8rpx;
padding: 4rpx 12rpx;
height: 56rpx;
height: 40rpx;
}
.search-icon {
@@ -264,11 +291,12 @@
flex: 1;
font-size: 22rpx;
color: #4E5969;
height: 100%;
}
.search-toggle {
width: 56rpx;
height: 56rpx;
width: 40rpx;
height: 40rpx;
display: flex;
align-items: center;
justify-content: center;