From 77a0c7b741c6b5d875bfd7a802a1549fde062f48 Mon Sep 17 00:00:00 2001 From: douboer Date: Sun, 8 Feb 2026 23:38:19 +0800 Subject: [PATCH] update at 2026-02-08 23:38:19 --- miniprogram/assets/icons/download.svg | 3 + miniprogram/assets/icons/export-png-s.svg | 4 +- miniprogram/assets/icons/export-svg-s.svg | 4 +- miniprogram/assets/icons/favorite-red.svg | 3 + miniprogram/pages/index/index.js | 224 ++++++++++++++++------ miniprogram/pages/index/index.wxml | 82 ++++---- miniprogram/pages/index/index.wxss | 110 +++++++++-- 7 files changed, 306 insertions(+), 124 deletions(-) create mode 100644 miniprogram/assets/icons/download.svg create mode 100644 miniprogram/assets/icons/favorite-red.svg diff --git a/miniprogram/assets/icons/download.svg b/miniprogram/assets/icons/download.svg new file mode 100644 index 0000000..0bdb236 --- /dev/null +++ b/miniprogram/assets/icons/download.svg @@ -0,0 +1,3 @@ + + + diff --git a/miniprogram/assets/icons/export-png-s.svg b/miniprogram/assets/icons/export-png-s.svg index 195d038..22300cc 100644 --- a/miniprogram/assets/icons/export-png-s.svg +++ b/miniprogram/assets/icons/export-png-s.svg @@ -1,4 +1,4 @@ - - + + diff --git a/miniprogram/assets/icons/export-svg-s.svg b/miniprogram/assets/icons/export-svg-s.svg index c463242..83fb1bd 100644 --- a/miniprogram/assets/icons/export-svg-s.svg +++ b/miniprogram/assets/icons/export-svg-s.svg @@ -1,4 +1,4 @@ - - + + diff --git a/miniprogram/assets/icons/favorite-red.svg b/miniprogram/assets/icons/favorite-red.svg new file mode 100644 index 0000000..2151bb8 --- /dev/null +++ b/miniprogram/assets/icons/favorite-red.svg @@ -0,0 +1,3 @@ + + + diff --git a/miniprogram/pages/index/index.js b/miniprogram/pages/index/index.js index 1eb407a..30947c2 100644 --- a/miniprogram/pages/index/index.js +++ b/miniprogram/pages/index/index.js @@ -8,6 +8,11 @@ const { renderSvgByApi, renderPngByApi } = require('../../utils/mp/render-api') // const { ICON_PATHS } = require('../../config/cdn') // CDN 方案暂时注释 const COLOR_PALETTE = ['#000000', '#1d4ed8', '#047857', '#b45309', '#dc2626', '#7c3aed'] +const FONT_SIZE_MIN = 20 +const FONT_SIZE_MAX = 120 +const PREVIEW_RENDER_FONT_SIZE = 120 +const MIN_PREVIEW_IMAGE_WIDTH = 24 +const MAX_PREVIEW_IMAGE_WIDTH = 2400 // 临时使用本地图标 - 根据Figma annotation配置 const LOCAL_ICON_PATHS = { @@ -18,6 +23,7 @@ const LOCAL_ICON_PATHS = { // 导出按钮(根据Figma annotation) exportSvg: '/assets/icons/export-svg-s.svg', // 紫色SVG导出按钮 exportPng: '/assets/icons/export-png-s.svg', // 蓝色PNG导出按钮 + download: '/assets/icons/download.svg', // 下载图标 // 输入框图标 content: '/assets/icons/content.svg', // 输入框左侧绿色图标 // 字体树图标(根据Figma annotation) @@ -43,6 +49,12 @@ function normalizeHexColor(input) { return fallback } +function clampFontSize(value, fallback = PREVIEW_RENDER_FONT_SIZE) { + const parsed = Number(value) + const base = Number.isFinite(parsed) ? parsed : Number(fallback) + return Math.min(FONT_SIZE_MAX, Math.max(FONT_SIZE_MIN, Math.round(base))) +} + function extractErrorMessage(error, fallback) { if (!error) { return fallback @@ -69,16 +81,75 @@ function writePngBufferToTempFile(pngBuffer, fontName) { return filePath } +function formatSvgNumber(value) { + const text = String(Number(value).toFixed(2)) + return text.replace(/\.?0+$/, '') +} + +function replaceSvgFillColor(svg, color) { + const normalizedColor = normalizeHexColor(color) + const source = String(svg || '') + if (!source) return '' + + if (/]*\sfill="[^"]*"/.test(source)) { + return source.replace(/(]*\sfill=")[^"]*(")/, `$1${normalizedColor}$2`) + } + + return source.replace(/]*)>/, ``) +} + +function scaleSvgDimensions(svg, scale) { + const source = String(svg || '') + const safeScale = Number(scale) + if (!source || !Number.isFinite(safeScale) || safeScale <= 0) { + return source + } + + return source + .replace(/width="([0-9]+(?:\.[0-9]+)?)"/, (match, width) => { + const scaledWidth = Number(width) * safeScale + return `width="${formatSvgNumber(scaledWidth)}"` + }) + .replace(/height="([0-9]+(?:\.[0-9]+)?)"/, (match, height) => { + const scaledHeight = Number(height) * safeScale + return `height="${formatSvgNumber(scaledHeight)}"` + }) +} + +function applyLocalStyleToFontItem(font, fontSize, color) { + if (!font || !font.baseSvg) { + return font + } + + const targetSize = clampFontSize(fontSize, PREVIEW_RENDER_FONT_SIZE) + const renderSize = Number(font.renderFontSize) > 0 ? Number(font.renderFontSize) : PREVIEW_RENDER_FONT_SIZE + const scale = targetSize / renderSize + const styledSvg = scaleSvgDimensions(replaceSvgFillColor(font.baseSvg, color), scale) + const scaledWidth = Number(font.baseWidth) > 0 ? Number(font.baseWidth) * scale : 0 + const previewImageWidth = Math.max( + MIN_PREVIEW_IMAGE_WIDTH, + Math.min(MAX_PREVIEW_IMAGE_WIDTH, Math.round(scaledWidth || MIN_PREVIEW_IMAGE_WIDTH)), + ) + + return { + ...font, + svg: styledSvg, + previewSrc: toSvgDataUri(styledSvg), + previewImageStyle: `width:${previewImageWidth}px;max-width:100%;`, + previewError: '', + } +} + Page({ data: { inputText: '星程字体转换', - fontSize: 120, + fontSize: FONT_SIZE_MAX, letterSpacingInput: '0', textColor: '#000000', colorPalette: COLOR_PALETTE, selectedFonts: [], // 当前已选中的字体列表 fontCategories: [], // 字体分类树 - favoriteCategories: [], // 已收藏字体分类树 + favoriteFonts: [], // 已收藏字体平铺列表 showColorPicker: false, favorites: [], // 使用本地图标路径 @@ -97,6 +168,7 @@ Page({ this.fontMap = new Map() this.generateTimer = null + this.categoryExpandedMap = {} await this.bootstrap() }, @@ -114,6 +186,13 @@ Page({ } }, + applyLocalPreviewStyles() { + const fontSize = clampFontSize(this.data.fontSize, PREVIEW_RENDER_FONT_SIZE) + const textColor = normalizeHexColor(this.data.textColor) + const selectedFonts = this.data.selectedFonts.map(font => applyLocalStyleToFontItem(font, fontSize, textColor)) + this.setData({ selectedFonts }) + }, + async bootstrap() { wx.showLoading({ title: '加载中', mask: true }) try { @@ -127,12 +206,15 @@ Page({ this.setData({ inputText: state.inputText || this.data.inputText, - fontSize: Number(state.fontSize) > 0 ? Number(state.fontSize) : this.data.fontSize, + fontSize: clampFontSize(state.fontSize, this.data.fontSize), letterSpacingInput: typeof state.letterSpacing === 'number' ? String(state.letterSpacing) : this.data.letterSpacingInput, textColor: normalizeHexColor(state.textColor || this.data.textColor), favorites, }) + this.categoryExpandedMap = state.categoryExpandedMap && typeof state.categoryExpandedMap === 'object' + ? { ...state.categoryExpandedMap } + : {} // 构建字体树 this.updateFontTrees() @@ -164,59 +246,72 @@ Page({ // 构建字体分类树 updateFontTrees() { const categoryMap = new Map() - const favoriteCategoryMap = new Map() const favorites = this.data.favorites - const keyword = (this.data.searchKeyword || '').trim().toLowerCase() + const selectedIdSet = new Set(this.data.selectedFonts.map(f => f.id)) + const normalizedKeyword = (this.data.searchKeyword || '').trim().toLowerCase() + const selectedOnlyMode = + normalizedKeyword.includes('选中') || + normalizedKeyword.includes('选择') || + normalizedKeyword.includes('已选') || + normalizedKeyword.includes('xuan') + const nameKeyword = selectedOnlyMode ? '' : normalizedKeyword + const isSearchMode = normalizedKeyword.length > 0 + const favoriteFonts = [] this.fontMap.forEach(font => { const category = font.category || '其他' const isFavorite = favorites.includes(font.id) + const isSelected = selectedIdSet.has(font.id) + + // “选中/选择/已选/xuan” 搜索模式:仅保留已选字体 + if (selectedOnlyMode && !isSelected) { + return + } // 应用搜索过滤 - if (keyword) { - const matchesSearch = font.name.toLowerCase().includes(keyword) || - category.toLowerCase().includes(keyword) + if (nameKeyword) { + const matchesSearch = font.name.toLowerCase().includes(nameKeyword) || + category.toLowerCase().includes(nameKeyword) if (!matchesSearch) return } // 所有字体树 if (!categoryMap.has(category)) { + const expandedFromState = this.categoryExpandedMap[category] categoryMap.set(category, { category, - expanded: true, + expanded: isSearchMode + ? true + : (typeof expandedFromState === 'boolean' ? expandedFromState : false), fonts: [], }) } - const selectedIds = this.data.selectedFonts.map(f => f.id) categoryMap.get(category).fonts.push({ ...font, - selected: selectedIds.includes(font.id), + selected: isSelected, isFavorite, }) - // 已收藏字体树 + // 已收藏字体平铺列表 if (isFavorite) { - if (!favoriteCategoryMap.has(category)) { - favoriteCategoryMap.set(category, { - category, - expanded: true, - fonts: [], - }) - } - favoriteCategoryMap.get(category).fonts.push({ + favoriteFonts.push({ ...font, - selected: selectedIds.includes(font.id), + selected: isSelected, isFavorite: true, }) } }) - const fontCategories = Array.from(categoryMap.values()) - const favoriteCategories = Array.from(favoriteCategoryMap.values()) + const fontCategories = Array.from(categoryMap.values()).sort((a, b) => a.category.localeCompare(b.category)) + favoriteFonts.sort((a, b) => { + const categoryCompare = String(a.category || '').localeCompare(String(b.category || '')) + if (categoryCompare !== 0) return categoryCompare + return String(a.name || '').localeCompare(String(b.name || '')) + }) this.setData({ fontCategories, - favoriteCategories, + favoriteFonts, }) }, @@ -307,20 +402,15 @@ Page({ if (index >= 0) { fontCategories[index].expanded = !fontCategories[index].expanded this.setData({ fontCategories }) - } - }, - - // 切换收藏分类展开/收起 - onToggleFavoriteCategory(e) { - const category = e.currentTarget.dataset.category - if (!category) return - - const favoriteCategories = this.data.favoriteCategories - const index = favoriteCategories.findIndex(c => c.category === category) - - if (index >= 0) { - favoriteCategories[index].expanded = !favoriteCategories[index].expanded - this.setData({ favoriteCategories }) + this.categoryExpandedMap[category] = fontCategories[index].expanded + saveAppState({ + inputText: this.data.inputText, + selectedFontIds: this.data.selectedFonts.map(f => f.id), + fontSize: Number(this.data.fontSize), + letterSpacing: Number(this.data.letterSpacingInput || 0), + textColor: this.data.textColor, + categoryExpandedMap: this.categoryExpandedMap, + }) } }, @@ -333,29 +423,31 @@ Page({ try { const letterSpacing = Number(this.data.letterSpacingInput || 0) - const fillColor = normalizeHexColor(this.data.textColor) const result = await renderSvgByApi({ fontId, text, - fontSize: Number(this.data.fontSize), - fillColor, + // 预览固定基础字号,避免字号/颜色调整时重复请求服务端 + fontSize: PREVIEW_RENDER_FONT_SIZE, + fillColor: '#000000', letterSpacing, maxCharsPerLine: 45, }) - const previewImageSrc = toSvgDataUri(result.svg) - // 更新对应字体的预览 const selectedFonts = this.data.selectedFonts const index = selectedFonts.findIndex(f => f.id === fontId) if (index >= 0) { - selectedFonts[index].previewSrc = previewImageSrc - selectedFonts[index].svg = result.svg - selectedFonts[index].width = result.width - selectedFonts[index].height = result.height - selectedFonts[index].previewError = '' + selectedFonts[index].baseSvg = result.svg + selectedFonts[index].baseWidth = result.width + selectedFonts[index].baseHeight = result.height + selectedFonts[index].renderFontSize = PREVIEW_RENDER_FONT_SIZE + selectedFonts[index] = applyLocalStyleToFontItem( + selectedFonts[index], + Number(this.data.fontSize), + this.data.textColor, + ) this.setData({ selectedFonts }) } } catch (error) { @@ -365,6 +457,7 @@ Page({ if (index >= 0) { selectedFonts[index].previewSrc = '' selectedFonts[index].svg = '' + selectedFonts[index].baseSvg = '' selectedFonts[index].previewError = (error && error.message) ? error.message : '预览生成失败' this.setData({ selectedFonts }) } @@ -412,23 +505,24 @@ Page({ }, onFontSizeChanging(event) { - this.setData({ fontSize: Number(event.detail.value) || this.data.fontSize }) + this.setData({ fontSize: clampFontSize(event.detail.value, this.data.fontSize) }) }, onFontSizeChange(event) { - this.setData({ fontSize: Number(event.detail.value) || this.data.fontSize }) + const newSize = clampFontSize(event.detail.value, this.data.fontSize) + this.setData({ fontSize: newSize }) saveAppState({ inputText: this.data.inputText, selectedFontIds: this.data.selectedFonts.map(f => f.id), - fontSize: Number(event.detail.value), + fontSize: newSize, letterSpacing: Number(this.data.letterSpacingInput || 0), textColor: this.data.textColor, }) - this.scheduleGenerate() + this.applyLocalPreviewStyles() }, onDecreaseFontSize() { - const newSize = Math.max(24, this.data.fontSize - 10) + const newSize = Math.max(FONT_SIZE_MIN, this.data.fontSize - 10) this.setData({ fontSize: newSize }) saveAppState({ inputText: this.data.inputText, @@ -437,11 +531,11 @@ Page({ letterSpacing: Number(this.data.letterSpacingInput || 0), textColor: this.data.textColor, }) - this.scheduleGenerate() + this.applyLocalPreviewStyles() }, onIncreaseFontSize() { - const newSize = Math.min(320, this.data.fontSize + 10) + const newSize = Math.min(FONT_SIZE_MAX, this.data.fontSize + 10) this.setData({ fontSize: newSize }) saveAppState({ inputText: this.data.inputText, @@ -450,7 +544,7 @@ Page({ letterSpacing: Number(this.data.letterSpacingInput || 0), textColor: this.data.textColor, }) - this.scheduleGenerate() + this.applyLocalPreviewStyles() }, onShowColorPicker() { @@ -467,7 +561,7 @@ Page({ onColorInput(event) { this.setData({ textColor: event.detail.value || '' }) - this.scheduleGenerate() + this.applyLocalPreviewStyles() }, onPickColor(event) { @@ -483,7 +577,7 @@ Page({ letterSpacing: Number(this.data.letterSpacingInput || 0), textColor: color, }) - this.scheduleGenerate() + this.applyLocalPreviewStyles() }, onRegenerate() { @@ -568,7 +662,10 @@ Page({ if (selectedFonts.length === 1) { const font = selectedFonts[0] try { - await shareSvgFromUserTap(font.svg, font.name, this.data.inputText) + const exportSvg = font.baseSvg + ? applyLocalStyleToFontItem(font, Number(this.data.fontSize), this.data.textColor).svg + : font.svg + await shareSvgFromUserTap(exportSvg, font.name, this.data.inputText) wx.showToast({ title: 'SVG 已分享', icon: 'success' }) } catch (error) { showExportError('导出 SVG 失败', error, '请稍后重试') @@ -622,14 +719,17 @@ 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) + const exportSvg = font.baseSvg + ? applyLocalStyleToFontItem(font, Number(this.data.fontSize), this.data.textColor).svg + : font.svg + await shareSvgFromUserTap(exportSvg, font.name, this.data.inputText) wx.showToast({ title: 'SVG 已分享', icon: 'success' }) } catch (error) { showExportError('导出 SVG 失败', error, '请稍后重试') @@ -639,7 +739,7 @@ Page({ 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 diff --git a/miniprogram/pages/index/index.wxml b/miniprogram/pages/index/index.wxml index f22b64b..0cec0ca 100644 --- a/miniprogram/pages/index/index.wxml +++ b/miniprogram/pages/index/index.wxml @@ -11,24 +11,29 @@ - + + {{fontSize}} + + @@ -62,6 +67,7 @@ {{item.name}} + @@ -76,6 +82,7 @@ class="preview-image" mode="widthFix" src="{{item.previewSrc}}" + style="{{item.previewImageStyle}}" /> {{item.previewError}} 生成中... @@ -112,15 +119,17 @@ src="{{icons.expandIcon}}" style="transform: rotate({{item.expanded ? '90deg' : '0deg'}})" /> - {{item.category}} + {{item.category}}({{item.fonts.length}}) - + + + {{font.name}} @@ -146,39 +155,22 @@ 已收藏 - - - - - {{item.category}} - - - - - {{font.name}} - - - - - - - - - + + + + {{item.name}} + + + + + + + - 暂无收藏字体 + 暂无收藏字体 diff --git a/miniprogram/pages/index/index.wxss b/miniprogram/pages/index/index.wxss index 2a824eb..2e58094 100644 --- a/miniprogram/pages/index/index.wxss +++ b/miniprogram/pages/index/index.wxss @@ -34,17 +34,39 @@ flex: 1; gap: 4rpx; /* 改为 2rpx 的 gap,小程序 rpx = 物理像素*2 */ padding: 0 12rpx; + height: 64rpx; } .font-size-icon { width: 24rpx; height: 24rpx; flex-shrink: 0; + display: block; +} + +.font-slider-wrap { + flex: 1; + height: 64rpx; + display: flex; + flex-direction: column; + align-items: center; + justify-content: flex-end; + position: relative; +} + +.font-size-value { + font-size: 20rpx; + color: #4E5969; + line-height: 28rpx; + text-align: center; + margin-bottom: 4rpx; } .font-slider { - flex: 1; - height: 4rpx; + width: 100%; + height: 24rpx; + margin: 0; + padding: 0; } .color-picker-btn { @@ -149,7 +171,14 @@ .export-btns-inline { display: flex; align-items: center; - gap: 12rpx; + gap: 10rpx; +} + +.download-icon { + width: 24rpx; + height: 24rpx; + flex-shrink: 0; + display: block; } .export-btn-sm { @@ -163,11 +192,11 @@ } .export-btn-sm.export-svg-btn { - background: #8552A1; + background: #E3D6EE; } .export-btn-sm.export-png-btn { - background: #2420A8; + background: #FFE4BA; } .export-icon-sm { @@ -207,11 +236,12 @@ min-height: 80rpx; display: flex; align-items: center; - justify-content: center; + justify-content: flex-start; } .preview-image { - width: 100%; + display: block; + max-width: 100%; } .preview-loading { @@ -259,14 +289,17 @@ display: flex; align-items: center; gap: 8rpx; - padding: 4rpx 0; - height: 56rpx; + padding: 0; + height: 40rpx; } .selection-header .section-title { padding: 0; - height: auto; - line-height: 56rpx; + font-size: 28rpx; + line-height: 40rpx; + display: flex; + align-items: center; + flex-shrink: 0; } .search-container { @@ -278,6 +311,7 @@ border-radius: 8rpx; padding: 4rpx 12rpx; height: 40rpx; + min-width: 0; } .search-icon { @@ -311,6 +345,7 @@ } .font-category { + position: relative; margin-bottom: 14rpx; } @@ -322,8 +357,8 @@ } .expand-icon { - width: 20rpx; - height: 20rpx; + width: 40rpx; + height: 40rpx; transition: transform 0.3s; flex-shrink: 0; } @@ -340,6 +375,10 @@ gap: 0; } +.select-font-list { + position: relative; +} + .font-item { display: flex; align-items: center; @@ -349,6 +388,29 @@ margin-bottom: 9rpx; } +.select-font-item { + position: relative; + padding-left: 28rpx; +} + +.tree-vertical-line { + position: absolute; + left: 19rpx; + top: 42rpx; + bottom: 16rpx; + width: 1rpx; + background: #C9CDD4; +} + +.tree-horizontal-line { + position: absolute; + left: 19rpx; + top: 18rpx; + width: 10rpx; + height: 1rpx; + background: #C9CDD4; +} + .font-item-icon { width: 18rpx; height: 18rpx; @@ -357,19 +419,28 @@ .font-item-name { flex: 1; + min-width: 0; font-size: 20rpx; color: #86909C; + line-height: 28rpx; + white-space: normal; + word-break: break-all; } .font-item-actions { display: flex; align-items: center; gap: 9rpx; + flex-shrink: 0; } .font-checkbox { width: 21rpx; height: 21rpx; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; } .checkbox-icon-sm { @@ -380,11 +451,24 @@ .favorite-btn { width: 21rpx; height: 21rpx; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; } .favorite-icon { width: 100%; height: 100%; + display: block; +} + +.favorite-list { + padding-top: 8rpx; +} + +.favorite-font-item { + padding-left: 0; } .empty-favorites {