From 239e2eeebd86ac969ffa3505f026c1e0f31d31bd Mon Sep 17 00:00:00 2001 From: iqudoo Date: Fri, 12 Jun 2026 20:58:51 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- index.html | 3 +- src/App.tsx | 327 ++++++++++++++++----------------- src/assets/logo.svg | 1 + src/components/PropSidebar.tsx | 265 ++++++++++++++++---------- src/elementBox.ts | 136 ++++++++++---- src/labelRenderer.ts | 78 ++++---- src/main.tsx | 12 ++ src/types.ts | 16 +- src/vite-env.d.ts | 1 + 9 files changed, 499 insertions(+), 340 deletions(-) create mode 100644 src/assets/logo.svg create mode 100644 src/vite-env.d.ts diff --git a/index.html b/index.html index 5f6c3c5..5df21d5 100644 --- a/index.html +++ b/index.html @@ -2,7 +2,8 @@ - + + 智能标签排版设计与打印生成器 diff --git a/src/App.tsx b/src/App.tsx index 3bff025..8a6b8fb 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -26,7 +26,6 @@ import { useAppDialog } from './components/AppDialog'; import { FileDown, Code, - Wand2, Loader2, Plus, Download, @@ -34,6 +33,7 @@ import { Trash2, X, } from 'lucide-react'; +import logoUrl from './assets/logo.svg'; const TEMPLATE_PREVIEW_MAX_W = 220; const TEMPLATE_PREVIEW_MAX_H = 132; @@ -72,7 +72,7 @@ export default function App() { return parsed.map((t: LabelTemplate) => normalizeTemplate(t)); } } - } catch (e) {} + } catch (e) { } return []; }); @@ -85,7 +85,7 @@ export default function App() { if (Array.isArray(parsed) && parsed.some((t: LabelTemplate) => t.id === savedId)) { return savedId; } - } catch (e) {} + } catch (e) { } return ''; }); @@ -154,7 +154,7 @@ export default function App() { try { const stripped = updatedList.map(stripTemplateForStorage); localStorage.setItem('label_templates_list_v2', JSON.stringify(stripped)); - } catch (e) {} + } catch (e) { } }; // Modify active template elements @@ -169,7 +169,7 @@ export default function App() { setSelectedElementIds([]); try { localStorage.setItem('selected_label_template_id_v2', id); - } catch (e) {} + } catch (e) { } setActiveRowIndex(0); }; @@ -358,7 +358,7 @@ export default function App() { setSelectedTemplateId(''); try { localStorage.removeItem('selected_label_template_id_v2'); - } catch (e) {} + } catch (e) { } setWorkflowStep('template-select'); } } @@ -604,13 +604,12 @@ export default function App() { return (
{/* ========================================= @@ -618,61 +617,56 @@ export default function App() { ========================================= */} {!(isMobile && workflowStep === 'print-view') && ( -
- {/* Title logo and slogan description */} -
-
- +
+ {/* Title logo and slogan description */} +
+ +
+

+ 批量标签排版印刷生成器 +

+ {workflowStep !== 'template-design' && workflowStep !== 'print-view' && ( +

+ 所见即所得,上传Excel批量排版生成标签 +

+ )} +
-
-

- {isMobile ? '批量标签排版印刷生成器' : '批量标签排版印刷生成器'} - PDF -

- {workflowStep !== 'template-design' && workflowStep !== 'print-view' && !isMobile && ( -

- 数据所见即所得:可视化添加变量、绑定 Excel 多表、快速二元素对齐排料、高保真物理 A4 无损裁切 -

- )} -
-
- {workflowStep === 'template-select' && hasTemplates && !isMobile && ( -
- - -
- )} -
+ {workflowStep === 'template-select' && hasTemplates && !isMobile && ( +
+ + +
+ )} + )} {/* Primary Workspace scroll container */}
{/* ======================================================== @@ -691,16 +685,16 @@ export default function App() {
) : (
-
- -
+ {/*
+ +
*/}

还没有标签模板

{isMobile - ? '请导入已有模板,或电脑端进行设计。' + ? '导入已有模板,或在电脑端设计标签模板' : '创建模板开始可视化设计,或导入已有的 JSON 模板文件。'}

-
+
{!isMobile && ( + )} - )} - - {!isMobile && ( - <> - - - - )} + {!isMobile && ( + <> + + + + )} +
-
- ); - })} - + ); + })} + )} )} @@ -917,7 +909,7 @@ export default function App() { selectedElementIds={selectedElementIds} onSelectElements={setSelectedElementIds} activeTab="element" - onChangeTab={() => {}} + onChangeTab={() => { }} paper={paper} /> @@ -993,11 +985,10 @@ export default function App() { disabled={ !appliedExportData || appliedLayoutResult.selectedCount === 0 || pdfGenerating } - className={`inline-flex items-center gap-1.5 px-3 py-1 text-[11px] font-semibold transition cursor-pointer ${ - appliedExportData && appliedLayoutResult.selectedCount > 0 && !pdfGenerating + className={`inline-flex items-center gap-1.5 px-3 py-1 text-[11px] font-semibold transition cursor-pointer ${appliedExportData && appliedLayoutResult.selectedCount > 0 && !pdfGenerating ? 'bg-[#31a8ff] hover:bg-[#2890d8] text-white' : 'bg-[#444] text-[#666] cursor-not-allowed' - }`} + }`} > {pdfGenerating ? ( diff --git a/src/assets/logo.svg b/src/assets/logo.svg new file mode 100644 index 0000000..c1865d0 --- /dev/null +++ b/src/assets/logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/components/PropSidebar.tsx b/src/components/PropSidebar.tsx index 5766697..fae3582 100644 --- a/src/components/PropSidebar.tsx +++ b/src/components/PropSidebar.tsx @@ -10,14 +10,12 @@ import { } from '../utils'; import { VisibilityRuleDialog } from './VisibilityRuleDialog'; import { VisibilityRuleSummary } from './VisibilityRuleSummary'; -import { toggleSide, isSideEnabled } from '../elementBox'; import { alignToSelectionBounds, alignSelectionToCanvas, tileSelectedEvenly, type AlignMode, } from '../elementAlign'; -import type { ElementSide } from '../types'; import { CollapsiblePanelSection } from './CollapsiblePanelSection'; import { CommitInput } from './CommitInput'; import { @@ -779,7 +777,64 @@ export const PropSidebar: React.FC = ({ /> - {/* Text Formatting Controls */} + {/* Alignment Tools */} +
+
+ 对齐与分布 +
+
+ } + label="左对齐" + title="靠左对齐 (X=0)" + onClick={() => handleElemChange({ ...selectedElem, x: 0 })} + /> + } + label="水平居中" + title="水平居中对齐" + onClick={() => { + const newX = Math.round(((template.width - selectedElem.width) / 2) * 10) / 10; + handleElemChange({ ...selectedElem, x: Math.max(0, newX) }); + }} + /> + } + label="右对齐" + title="靠右对齐" + onClick={() => { + const newX = Math.round((template.width - selectedElem.width) * 10) / 10; + handleElemChange({ ...selectedElem, x: Math.max(0, newX) }); + }} + /> + } + label="顶对齐" + title="靠顶对齐 (Y=0)" + onClick={() => handleElemChange({ ...selectedElem, y: 0 })} + /> + } + label="垂直居中" + title="垂直居中对齐" + onClick={() => { + const newY = Math.round(((template.height - selectedElem.height) / 2) * 10) / 10; + handleElemChange({ ...selectedElem, y: Math.max(0, newY) }); + }} + /> + } + label="底对齐" + title="靠底对齐" + onClick={() => { + const newY = Math.round((template.height - selectedElem.height) * 10) / 10; + handleElemChange({ ...selectedElem, y: Math.max(0, newY) }); + }} + /> +
+
+ + {/* Text Settings */} {selectedElem.type === 'text' && (
@@ -1096,7 +1151,7 @@ export const PropSidebar: React.FC = ({
)} - {/* Barcode Formats Options */} + {/* Barcode Settings */} {selectedElem.type === 'barcode' && (
@@ -1134,6 +1189,7 @@ export const PropSidebar: React.FC = ({
)} + {/* Image Settings */} {selectedElem.type === 'image' && (
@@ -1367,7 +1423,7 @@ export const PropSidebar: React.FC = ({ className="ps-range" />
-
+
= ({ } className="ps-field font-mono" /> +
+ +
+ {( + [ + ['paddingTop', '上'], + ['paddingRight', '右'], + ['paddingBottom', '下'], + ['paddingLeft', '左'], + ] as const + ).map(([key, label]) => ( +
+ {label} + { + const updated = { ...selectedElem }; + if (val === '' || val === undefined) { + delete updated[key]; + } else { + updated[key] = Math.max(0, Math.min(10, Number(val) || 0)); + } + handleElemChange(updated); + }} + className="ps-field font-mono text-[10px] flex-1 min-w-0" + placeholder="—" + /> +
+ ))} +
+
@@ -1459,39 +1550,85 @@ export const PropSidebar: React.FC = ({
- -
+ +
{( [ - ['top', '上'], - ['right', '右'], - ['bottom', '下'], - ['left', '左'], - ] as [ElementSide, string][] - ).map(([side, label]) => ( - + ['borderWidthTop', '上'], + ['borderWidthRight', '右'], + ['borderWidthBottom', '下'], + ['borderWidthLeft', '左'], + ] as const + ).map(([key, label]) => ( +
+ {label} + { + const updated = { ...selectedElem }; + if (val === '' || val === undefined) { + delete updated[key]; + } else { + updated[key] = Math.max(0, Math.min(5, Number(val) || 0)); + } + handleElemChange(updated); + }} + className="ps-field font-mono text-[10px] flex-1 min-w-0" + placeholder="—" + /> +
+ ))} +
+
+ +
+ +
+ {( + [ + ['borderColorTop', '上'], + ['borderColorRight', '右'], + ['borderColorBottom', '下'], + ['borderColorLeft', '左'], + ] as const + ).map(([key, label]) => ( +
+ {label} + { + const updated = { ...selectedElem }; + updated[key] = e.target.value; + handleElemChange(updated); + }} + className="color-input shrink-0 scale-90" + title={`${label}边边框颜色`} + /> + { + const updated = { ...selectedElem }; + if (val === '' || val === undefined) { + delete updated[key]; + } else { + updated[key] = val; + } + handleElemChange(updated); + }} + className="ps-field font-mono text-[10px] flex-1 min-w-0" + placeholder="—" + /> +
))}
@@ -1533,62 +1670,6 @@ export const PropSidebar: React.FC = ({
- {/* Alignment Tools */} -
-
- 对齐与分布 -
-
- } - label="左对齐" - title="靠左对齐 (X=0)" - onClick={() => handleElemChange({ ...selectedElem, x: 0 })} - /> - } - label="水平居中" - title="水平居中对齐" - onClick={() => { - const newX = Math.round(((template.width - selectedElem.width) / 2) * 10) / 10; - handleElemChange({ ...selectedElem, x: Math.max(0, newX) }); - }} - /> - } - label="右对齐" - title="靠右对齐" - onClick={() => { - const newX = Math.round((template.width - selectedElem.width) * 10) / 10; - handleElemChange({ ...selectedElem, x: Math.max(0, newX) }); - }} - /> - } - label="顶对齐" - title="靠顶对齐 (Y=0)" - onClick={() => handleElemChange({ ...selectedElem, y: 0 })} - /> - } - label="垂直居中" - title="垂直居中对齐" - onClick={() => { - const newY = Math.round(((template.height - selectedElem.height) / 2) * 10) / 10; - handleElemChange({ ...selectedElem, y: Math.max(0, newY) }); - }} - /> - } - label="底对齐" - title="靠底对齐" - onClick={() => { - const newY = Math.round((template.height - selectedElem.height) * 10) / 10; - handleElemChange({ ...selectedElem, y: Math.max(0, newY) }); - }} - /> -
-
)} diff --git a/src/elementBox.ts b/src/elementBox.ts index ed1e82f..9c29a0e 100644 --- a/src/elementBox.ts +++ b/src/elementBox.ts @@ -15,6 +15,86 @@ export interface BorderSides { left: boolean; } +export interface PaddingMm { + top: number; + right: number; + bottom: number; + left: number; +} + +export interface BorderSideStyle { + width: number; + color: string; +} + +export type BorderConfig = Record; + +export function resolvePaddingMm(elem: TemplateElement): PaddingMm { + const base = elem.padding ?? 0; + const val = (specific?: number) => + specific !== undefined && specific !== null ? specific : base; + return { + top: val(elem.paddingTop), + right: val(elem.paddingRight), + bottom: val(elem.paddingBottom), + left: val(elem.paddingLeft), + }; +} + +export function getPaddedRectPx( + x: number, + y: number, + w: number, + h: number, + elem: TemplateElement, + scale: number +) { + const pad = resolvePaddingMm(elem); + const top = Math.round(pad.top * scale); + const right = Math.round(pad.right * scale); + const bottom = Math.round(pad.bottom * scale); + const left = Math.round(pad.left * scale); + return { + x: x + left, + y: y + top, + w: Math.max(1, w - left - right), + h: Math.max(1, h - top - bottom), + paddingPx: { top, right, bottom, left }, + }; +} + +export function resolveBorderConfig(elem: TemplateElement): BorderConfig { + const baseWidth = elem.borderWidth ?? 0; + const baseColor = elem.borderColor || '#111827'; + const legacy = elem.borderSides ?? {}; + + const resolveSide = ( + side: ElementSide, + widthOverride: number | undefined, + colorOverride: string | undefined + ): BorderSideStyle => { + let width: number; + if (widthOverride !== undefined && widthOverride !== null) { + width = widthOverride; + } else if (baseWidth <= 0 || legacy[side] === false) { + width = 0; + } else { + width = baseWidth; + } + return { + width, + color: colorOverride?.trim() ? colorOverride : baseColor, + }; + }; + + return { + top: resolveSide('top', elem.borderWidthTop, elem.borderColorTop), + right: resolveSide('right', elem.borderWidthRight, elem.borderColorRight), + bottom: resolveSide('bottom', elem.borderWidthBottom, elem.borderColorBottom), + left: resolveSide('left', elem.borderWidthLeft, elem.borderColorLeft), + }; +} + export function resolveCornerRadiiPx( elem: TemplateElement, scale: number, @@ -39,16 +119,12 @@ export function resolveCornerRadiiPx( } export function resolveBorderSides(elem: TemplateElement): BorderSides { - const width = elem.borderWidth ?? 0; - if (width <= 0) { - return { top: false, right: false, bottom: false, left: false }; - } - const sides = elem.borderSides ?? {}; + const cfg = resolveBorderConfig(elem); return { - top: sides.top !== false, - right: sides.right !== false, - bottom: sides.bottom !== false, - left: sides.left !== false, + top: cfg.top.width > 0, + right: cfg.right.width > 0, + bottom: cfg.bottom.width > 0, + left: cfg.left.width > 0, }; } @@ -106,20 +182,17 @@ export function drawElementBorders( elem: TemplateElement, scale: number ) { - const widthMm = elem.borderWidth ?? 0; - if (widthMm <= 0) return; - - const color = elem.borderColor || '#111827'; - const lw = Math.max(1, widthMm * scale); + const border = resolveBorderConfig(elem); const { tl, tr, br, bl } = radii; - const inset = lw / 2; - ctx.strokeStyle = color; - ctx.lineWidth = lw; ctx.lineCap = 'butt'; ctx.lineJoin = 'round'; if (sides.top) { + const lw = Math.max(1, border.top.width * scale); + const inset = lw / 2; + ctx.strokeStyle = border.top.color; + ctx.lineWidth = lw; ctx.beginPath(); if (sides.left && tl > 0) { ctx.arc(x + tl, y + tl, Math.max(0, tl - inset), Math.PI, 1.5 * Math.PI); @@ -136,6 +209,10 @@ export function drawElementBorders( } if (sides.right) { + const lw = Math.max(1, border.right.width * scale); + const inset = lw / 2; + ctx.strokeStyle = border.right.color; + ctx.lineWidth = lw; ctx.beginPath(); const topStart = sides.top && tr > 0; if (topStart) { @@ -153,6 +230,10 @@ export function drawElementBorders( } if (sides.bottom) { + const lw = Math.max(1, border.bottom.width * scale); + const inset = lw / 2; + ctx.strokeStyle = border.bottom.color; + ctx.lineWidth = lw; ctx.beginPath(); if (sides.right && br > 0) { ctx.moveTo(x + w - inset, y + h - br); @@ -170,6 +251,10 @@ export function drawElementBorders( } if (sides.left) { + const lw = Math.max(1, border.left.width * scale); + const inset = lw / 2; + ctx.strokeStyle = border.left.color; + ctx.lineWidth = lw; ctx.beginPath(); const bottomStart = sides.bottom && bl > 0; if (bottomStart) { @@ -202,20 +287,3 @@ export function drawElementChrome( drawElementBorders(ctx, x, y, w, h, radii, sides, elem, scale); return { radii, sides }; } - -export function toggleSide( - current: Partial> | undefined, - side: ElementSide -): Partial> { - const next = { ...current }; - const enabled = current?.[side] !== false; - next[side] = !enabled; - return next; -} - -export function isSideEnabled( - sides: Partial> | undefined, - side: ElementSide -): boolean { - return sides?.[side] !== false; -} diff --git a/src/labelRenderer.ts b/src/labelRenderer.ts index c98e2d0..39daa60 100644 --- a/src/labelRenderer.ts +++ b/src/labelRenderer.ts @@ -10,6 +10,8 @@ import { import { resolveCornerRadiiPx, resolveBorderSides, + resolvePaddingMm, + getPaddedRectPx, traceRoundedRect, drawElementFill, drawElementBorders, @@ -213,23 +215,6 @@ function layoutVerticalTextColumns( return columns; } -function getPaddedRect( - x: number, - y: number, - w: number, - h: number, - paddingMm: number, - scale: number -) { - const paddingPx = Math.round(paddingMm * scale); - return { - x: x + paddingPx, - y: y + paddingPx, - w: Math.max(1, w - 2 * paddingPx), - h: Math.max(1, h - 2 * paddingPx), - }; -} - function drawImagePlaceholder( ctx: CanvasRenderingContext2D, x: number, @@ -343,10 +328,17 @@ export async function renderLabelToDataUrl(options: RenderLabelOptions): Promise drawElementFill(ctx, ex, ey, ew, eh, elem, radii); - const paddingMm = elem.padding || 0; - const paddingPx = Math.round(paddingMm * scale); - const writableW = Math.max(1, ew - 2 * paddingPx); - const writableH = Math.max(1, eh - 2 * paddingPx); + const padPx = (() => { + const pad = resolvePaddingMm(elem); + return { + top: Math.round(pad.top * scale), + right: Math.round(pad.right * scale), + bottom: Math.round(pad.bottom * scale), + left: Math.round(pad.left * scale), + }; + })(); + const writableW = Math.max(1, ew - padPx.left - padPx.right); + const writableH = Math.max(1, eh - padPx.top - padPx.bottom); const fontStyle = elem.fontStyle === 'italic' ? 'italic ' : ''; const fontWeight = elem.fontWeight === 'bold' ? 'bold ' : ''; @@ -375,8 +367,8 @@ export async function renderLabelToDataUrl(options: RenderLabelOptions): Promise const textScaleX = Math.max(0.1, Math.min(5, elem.textScaleX ?? 1)); const textScaleY = Math.max(0.1, Math.min(5, elem.textScaleY ?? 1)); - const transformOriginX = ex + paddingPx + writableW / 2; - const transformOriginY = ey + paddingPx + writableH / 2; + const transformOriginX = ex + padPx.left + writableW / 2; + const transformOriginY = ey + padPx.top + writableH / 2; ctx.save(); if (textScaleX !== 1 || textScaleY !== 1) { @@ -393,11 +385,11 @@ export async function renderLabelToDataUrl(options: RenderLabelOptions): Promise let startX: number; if (elem.textAlign === 'center') { - startX = ex + paddingPx + Math.max(0, (writableW - totalColumnsWidth) / 2) + columnWidth / 2; + startX = ex + padPx.left + Math.max(0, (writableW - totalColumnsWidth) / 2) + columnWidth / 2; } else if (elem.textAlign === 'right') { - startX = ex + paddingPx + Math.max(0, writableW - totalColumnsWidth) + columnWidth / 2; + startX = ex + padPx.left + Math.max(0, writableW - totalColumnsWidth) + columnWidth / 2; } else { - startX = ex + paddingPx + columnWidth / 2; + startX = ex + padPx.left + columnWidth / 2; } ctx.textAlign = 'center'; @@ -406,18 +398,18 @@ export async function renderLabelToDataUrl(options: RenderLabelOptions): Promise const colTextHeight = column.length * lineHeight; let colStartY: number; if (vAlign === 'top') { - colStartY = ey + paddingPx + lineHeight / 2; + colStartY = ey + padPx.top + lineHeight / 2; } else if (vAlign === 'bottom') { - colStartY = ey + eh - paddingPx - colTextHeight + lineHeight / 2; + colStartY = ey + eh - padPx.bottom - colTextHeight + lineHeight / 2; } else { colStartY = - ey + paddingPx + Math.max(0, (writableH - colTextHeight) / 2) + lineHeight / 2; + ey + padPx.top + Math.max(0, (writableH - colTextHeight) / 2) + lineHeight / 2; } const colX = startX + col * columnWidth; for (let i = 0; i < column.length; i++) { const charY = colStartY + i * lineHeight; - if (charY - lineHeight / 2 <= ey + eh - paddingPx) { + if (charY - lineHeight / 2 <= ey + eh - padPx.bottom) { ctx.fillText(column[i], colX, charY); } } @@ -426,32 +418,32 @@ export async function renderLabelToDataUrl(options: RenderLabelOptions): Promise const lines = layoutHorizontalTextLines(ctx, String(value), writableW, wordWrap); const totalTextHeight = lines.length * lineHeight; - let textX = ex + paddingPx; + let textX = ex + padPx.left; let textAlign: CanvasTextAlign = 'left'; if (elem.textAlign === 'center') { - textX = ex + paddingPx + writableW / 2; + textX = ex + padPx.left + writableW / 2; textAlign = 'center'; } else if (elem.textAlign === 'right') { - textX = ex + paddingPx + (writableW - 2); + textX = ex + padPx.left + (writableW - 2); textAlign = 'right'; } else { - textX = ex + paddingPx + 2; + textX = ex + padPx.left + 2; } ctx.textAlign = textAlign; let startY: number; if (vAlign === 'top') { - startY = ey + paddingPx + lineHeight / 2; + startY = ey + padPx.top + lineHeight / 2; } else if (vAlign === 'bottom') { - startY = ey + eh - paddingPx - totalTextHeight + lineHeight / 2; + startY = ey + eh - padPx.bottom - totalTextHeight + lineHeight / 2; } else { startY = - ey + paddingPx + Math.max(0, (writableH - totalTextHeight) / 2) + lineHeight / 2; + ey + padPx.top + Math.max(0, (writableH - totalTextHeight) / 2) + lineHeight / 2; } for (let i = 0; i < lines.length; i++) { const lineY = startY + i * lineHeight; - if (lineY - lineHeight / 2 <= ey + eh - paddingPx) { + if (lineY - lineHeight / 2 <= ey + eh - padPx.bottom) { const line = lines[i]; ctx.fillText(line, textX, lineY); drawTextLineDecorations(ctx, elem, textX, lineY, line, fontSizePx, textAlign); @@ -468,7 +460,7 @@ export async function renderLabelToDataUrl(options: RenderLabelOptions): Promise } else if (elem.type === 'barcode') { try { drawElementChrome(ctx, ex, ey, ew, eh, elem, scale); - const inner = getPaddedRect(ex, ey, ew, eh, elem.padding || 0, scale); + const inner = getPaddedRectPx(ex, ey, ew, eh, elem, scale); const tempCanvas = document.createElement('canvas'); const barcodeValue = value ? String(value).trim() : mode === 'design' ? 'SAMPLE' : ''; @@ -488,7 +480,7 @@ export async function renderLabelToDataUrl(options: RenderLabelOptions): Promise } catch (error) { console.warn('Barcode drawing failed', error); if (mode === 'design') { - const inner = getPaddedRect(ex, ey, ew, eh, elem.padding || 0, scale); + const inner = getPaddedRectPx(ex, ey, ew, eh, elem, scale); ctx.fillStyle = '#f3f4f6'; ctx.fillRect(inner.x, inner.y, inner.w, inner.h); ctx.strokeStyle = '#ef4444'; @@ -503,7 +495,7 @@ export async function renderLabelToDataUrl(options: RenderLabelOptions): Promise } else if (elem.type === 'qrcode') { try { drawElementChrome(ctx, ex, ey, ew, eh, elem, scale); - const inner = getPaddedRect(ex, ey, ew, eh, elem.padding || 0, scale); + const inner = getPaddedRectPx(ex, ey, ew, eh, elem, scale); const qrValue = value ? String(value).trim() : mode === 'design' ? 'SAMPLE QR' : ''; if (qrValue) { @@ -522,7 +514,7 @@ export async function renderLabelToDataUrl(options: RenderLabelOptions): Promise } catch (error) { console.warn('QR code drawing failed', error); if (mode === 'design') { - const inner = getPaddedRect(ex, ey, ew, eh, elem.padding || 0, scale); + const inner = getPaddedRectPx(ex, ey, ew, eh, elem, scale); ctx.fillStyle = '#f3f4f6'; ctx.fillRect(inner.x, inner.y, inner.w, inner.h); ctx.strokeStyle = '#ef4444'; @@ -532,7 +524,7 @@ export async function renderLabelToDataUrl(options: RenderLabelOptions): Promise } } else if (elem.type === 'image') { drawElementChrome(ctx, ex, ey, ew, eh, elem, scale); - const inner = getPaddedRect(ex, ey, ew, eh, elem.padding || 0, scale); + const inner = getPaddedRectPx(ex, ey, ew, eh, elem, scale); const src = value ? String(value).trim() : ''; if (!src) { if (mode === 'design') { diff --git a/src/main.tsx b/src/main.tsx index 7cd4d97..65bf8e8 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -2,8 +2,20 @@ import {StrictMode} from 'react'; import {createRoot} from 'react-dom/client'; import App from './App.tsx'; import { AppDialogProvider } from './components/AppDialog.tsx'; +import logoUrl from './assets/logo.svg'; import './index.css'; +const faviconLink = + document.querySelector("link[rel='icon']") ?? + (() => { + const link = document.createElement('link'); + link.rel = 'icon'; + link.type = 'image/svg+xml'; + document.head.appendChild(link); + return link; + })(); +faviconLink.href = logoUrl; + createRoot(document.getElementById('root')!).render( diff --git a/src/types.ts b/src/types.ts index 23cc796..a82c6fe 100644 --- a/src/types.ts +++ b/src/types.ts @@ -71,6 +71,10 @@ export interface TemplateElement { textColor?: string; /** 内边距 (mm),所有元素类型均有效 */ padding?: number; + paddingTop?: number; + paddingRight?: number; + paddingBottom?: number; + paddingLeft?: number; locked?: boolean; /** 图片缩放方式,仅 type=image 时有效 */ imageFit?: 'contain' | 'cover' | 'fill'; @@ -78,10 +82,18 @@ export interface TemplateElement { textFormat?: TextFormat; /** 数值/百分比/货币的小数位数,默认 2 */ decimalPlaces?: number; - /** 边框宽度 (mm),0 为无边框 */ + /** 边框宽度 (mm),0 为无边框;各边留空时沿用此值 */ borderWidth?: number; borderColor?: string; - /** 指定哪些边显示边框,默认四边 */ + borderWidthTop?: number; + borderWidthRight?: number; + borderWidthBottom?: number; + borderWidthLeft?: number; + borderColorTop?: string; + borderColorRight?: string; + borderColorBottom?: string; + borderColorLeft?: string; + /** @deprecated 使用各边 borderWidth*(0 为不显示) */ borderSides?: Partial>; /** 圆角半径 (mm),可被各角覆盖 */ borderRadius?: number; diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/src/vite-env.d.ts @@ -0,0 +1 @@ +///