优化
This commit is contained in:
@@ -2,7 +2,8 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<link rel="icon" type="image/svg+xml" href="/src/assets/logo.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, maximum-scale=1.0, user-scalable=no" />
|
||||
<title>智能标签排版设计与打印生成器</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
41
src/App.tsx
41
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;
|
||||
@@ -604,8 +604,7 @@ export default function App() {
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`min-h-screen flex flex-col font-sans ${
|
||||
workflowStep === 'template-design' || (workflowStep === 'print-view' && !isMobile)
|
||||
className={`min-h-screen flex flex-col font-sans ${workflowStep === 'template-design' || (workflowStep === 'print-view' && !isMobile)
|
||||
? 'bg-[#323232] text-[#e8e8e8] h-screen overflow-hidden'
|
||||
: workflowStep === 'print-view' && isMobile
|
||||
? 'bg-slate-100 text-slate-800 h-screen overflow-hidden'
|
||||
@@ -619,23 +618,19 @@ export default function App() {
|
||||
*/}
|
||||
{!(isMobile && workflowStep === 'print-view') && (
|
||||
<header
|
||||
className={`no-print bg-slate-950 border-b border-slate-900 shrink-0 shadow flex flex-wrap items-center justify-between gap-4 mobile-template-header ${
|
||||
workflowStep === 'template-design' || workflowStep === 'print-view' ? 'px-4 py-2' : 'px-4 py-3 md:px-6 md:py-4'
|
||||
className={`no-print bg-slate-950 border-b border-slate-900 shrink-0 shadow flex flex-wrap items-center justify-between gap-4 mobile-template-header ${workflowStep === 'template-design' || workflowStep === 'print-view' ? 'px-4 py-2' : 'px-4 py-3 md:px-6 md:py-4'
|
||||
}`}
|
||||
>
|
||||
{/* Title logo and slogan description */}
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="p-2 md:p-2.5 bg-indigo-500/10 text-indigo-400 border border-indigo-500/20 rounded-xl shadow-inner shrink-0">
|
||||
<Wand2 className="w-5 h-5" />
|
||||
</div>
|
||||
<img src={logoUrl} alt="" className="w-10 h-10" aria-hidden />
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-sm font-bold tracking-wider flex items-center gap-2 text-slate-100 uppercase">
|
||||
<span className="truncate">{isMobile ? '批量标签排版印刷生成器' : '批量标签排版印刷生成器'}</span>
|
||||
<span className="text-[9.5px] bg-indigo-600 text-white font-extrabold px-1.5 py-0.5 rounded shadow-sm tracking-normal shrink-0">PDF</span>
|
||||
<span className="truncate">批量标签排版印刷生成器</span>
|
||||
</h1>
|
||||
{workflowStep !== 'template-design' && workflowStep !== 'print-view' && !isMobile && (
|
||||
{workflowStep !== 'template-design' && workflowStep !== 'print-view' && (
|
||||
<p className="text-[11px] text-slate-400">
|
||||
数据所见即所得:可视化添加变量、绑定 Excel 多表、快速二元素对齐排料、高保真物理 A4 无损裁切
|
||||
所见即所得,上传Excel批量排版生成标签
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -668,8 +663,7 @@ export default function App() {
|
||||
|
||||
{/* Primary Workspace scroll container */}
|
||||
<main
|
||||
className={`no-print flex-1 flex flex-col w-full ${
|
||||
workflowStep === 'template-design' || workflowStep === 'print-view'
|
||||
className={`no-print flex-1 flex flex-col w-full ${workflowStep === 'template-design' || workflowStep === 'print-view'
|
||||
? 'overflow-hidden min-h-0'
|
||||
: 'overflow-auto p-4 md:p-6 max-w-[1720px] mx-auto gap-6'
|
||||
}`}
|
||||
@@ -691,16 +685,16 @@ export default function App() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center text-center px-6 py-16 md:py-24">
|
||||
<div className="w-16 h-16 rounded-2xl bg-indigo-50 text-indigo-500 flex items-center justify-center mb-5 border border-indigo-100">
|
||||
<Wand2 className="w-8 h-8" />
|
||||
</div>
|
||||
{/* <div className="w-16 h-16 rounded-2xl bg-indigo-50 flex items-center justify-center mb-5 border border-indigo-100">
|
||||
<img src={logoUrl} alt="" className="w-12 h-12" aria-hidden />
|
||||
</div> */}
|
||||
<h2 className="text-lg font-bold text-gray-900">还没有标签模板</h2>
|
||||
<p className="text-sm text-gray-500 mt-2 max-w-md leading-relaxed">
|
||||
{isMobile
|
||||
? '请导入已有模板,或电脑端进行设计。'
|
||||
? '导入已有模板,或在电脑端设计标签模板'
|
||||
: '创建模板开始可视化设计,或导入已有的 JSON 模板文件。'}
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row items-center justify-center gap-3 mt-8">
|
||||
<div className="flex flex-col sm:flex-row items-center justify-center gap-3 mt-3">
|
||||
{!isMobile && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -738,8 +732,7 @@ export default function App() {
|
||||
<div
|
||||
key={tmpl.id}
|
||||
onClick={() => handleSelectTemplate(tmpl.id!)}
|
||||
className={`bg-white border rounded-2xl p-5 shadow-xs transition-all flex flex-col cursor-pointer relative overflow-hidden group select-none ${
|
||||
isActive
|
||||
className={`bg-white border rounded-2xl p-5 shadow-xs transition-all flex flex-col cursor-pointer relative overflow-hidden group select-none ${isActive
|
||||
? 'ring-2 ring-indigo-500 border-indigo-500 bg-indigo-50/5'
|
||||
: 'border-gray-200 hover:shadow-md hover:border-indigo-200'
|
||||
}`}
|
||||
@@ -807,8 +800,7 @@ export default function App() {
|
||||
e.stopPropagation();
|
||||
openPrintView(tmpl.id!);
|
||||
}}
|
||||
className={`shrink-0 whitespace-nowrap inline-flex items-center gap-1 px-3 py-1.5 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg text-[11px] font-bold transition shadow-xs cursor-pointer ${
|
||||
isMobile ? 'mobile-primary-btn flex-1' : ''
|
||||
className={`shrink-0 whitespace-nowrap inline-flex items-center gap-1 px-3 py-1.5 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg text-[11px] font-bold transition shadow-xs cursor-pointer ${isMobile ? 'mobile-primary-btn flex-1' : ''
|
||||
}`}
|
||||
>
|
||||
<FileDown className="w-3.5 h-3.5 shrink-0" />
|
||||
@@ -993,8 +985,7 @@ 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'
|
||||
}`}
|
||||
|
||||
1
src/assets/logo.svg
Normal file
1
src/assets/logo.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg t="1781266539277" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="9515" width="200" height="200"><path d="M690.2528 140.8h-356.608C297.984 140.8 268.8 170.9568 268.8 207.7952V294.4h486.4V207.7952c-0.1024-36.864-29.2864-66.9952-64.9472-66.9952z" fill="#B3DBFF" p-id="9516"></path><path d="M786.6624 268.8H237.2608c-39.168 0-70.8608 31.1296-70.8608 69.6064v296.0128c0 38.4512 31.6928 69.5808 70.8608 69.5808h549.504c39.1424 0 70.8352-31.1296 70.8352-69.5808V338.304c-0.1024-38.3744-31.7952-69.504-70.9376-69.504z" fill="#0084FF" p-id="9517"></path><path d="M268.8 810.2656c0 40.1152 35.4816 72.9344 78.7968 72.9344h328.8064c43.3152 0 78.7968-32.8192 78.7968-72.9344V678.4h-486.4v131.8656z" fill="#B3DBFF" p-id="9518"></path><path d="M204.8 358.4m25.6 0l128 0q25.6 0 25.6 25.6l0 0q0 25.6-25.6 25.6l-128 0q-25.6 0-25.6-25.6l0 0q0-25.6 25.6-25.6Z" fill="#FFFFFF" p-id="9519"></path><path d="M384 716.8m25.6 0l204.8 0q25.6 0 25.6 25.6l0 0q0 25.6-25.6 25.6l-204.8 0q-25.6 0-25.6-25.6l0 0q0-25.6 25.6-25.6Z" fill="#0084FF" p-id="9520"></path><path d="M384 793.6m25.6 0l204.8 0q25.6 0 25.6 25.6l0 0q0 25.6-25.6 25.6l-204.8 0q-25.6 0-25.6-25.6l0 0q0-25.6 25.6-25.6Z" fill="#0084FF" p-id="9521"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -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<PropSidebarProps> = ({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Text Formatting Controls */}
|
||||
{/* Alignment Tools */}
|
||||
<div className="space-y-1.5">
|
||||
<h5 className="ps-section-title">
|
||||
对齐与分布
|
||||
</h5>
|
||||
<div className="grid grid-cols-3 gap-1.5">
|
||||
<AlignToolButton
|
||||
icon={<AlignHorizontalJustifyStart className="w-3.5 h-3.5" />}
|
||||
label="左对齐"
|
||||
title="靠左对齐 (X=0)"
|
||||
onClick={() => handleElemChange({ ...selectedElem, x: 0 })}
|
||||
/>
|
||||
<AlignToolButton
|
||||
icon={<AlignHorizontalJustifyCenter className="w-3.5 h-3.5" />}
|
||||
label="水平居中"
|
||||
title="水平居中对齐"
|
||||
onClick={() => {
|
||||
const newX = Math.round(((template.width - selectedElem.width) / 2) * 10) / 10;
|
||||
handleElemChange({ ...selectedElem, x: Math.max(0, newX) });
|
||||
}}
|
||||
/>
|
||||
<AlignToolButton
|
||||
icon={<AlignHorizontalJustifyEnd className="w-3.5 h-3.5" />}
|
||||
label="右对齐"
|
||||
title="靠右对齐"
|
||||
onClick={() => {
|
||||
const newX = Math.round((template.width - selectedElem.width) * 10) / 10;
|
||||
handleElemChange({ ...selectedElem, x: Math.max(0, newX) });
|
||||
}}
|
||||
/>
|
||||
<AlignToolButton
|
||||
icon={<AlignVerticalJustifyStart className="w-3.5 h-3.5" />}
|
||||
label="顶对齐"
|
||||
title="靠顶对齐 (Y=0)"
|
||||
onClick={() => handleElemChange({ ...selectedElem, y: 0 })}
|
||||
/>
|
||||
<AlignToolButton
|
||||
icon={<AlignVerticalJustifyCenter className="w-3.5 h-3.5" />}
|
||||
label="垂直居中"
|
||||
title="垂直居中对齐"
|
||||
onClick={() => {
|
||||
const newY = Math.round(((template.height - selectedElem.height) / 2) * 10) / 10;
|
||||
handleElemChange({ ...selectedElem, y: Math.max(0, newY) });
|
||||
}}
|
||||
/>
|
||||
<AlignToolButton
|
||||
icon={<AlignVerticalJustifyEnd className="w-3.5 h-3.5" />}
|
||||
label="底对齐"
|
||||
title="靠底对齐"
|
||||
onClick={() => {
|
||||
const newY = Math.round((template.height - selectedElem.height) * 10) / 10;
|
||||
handleElemChange({ ...selectedElem, y: Math.max(0, newY) });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Text Settings */}
|
||||
{selectedElem.type === 'text' && (
|
||||
<div className="space-y-3 ps-section-divider">
|
||||
<h5 className="ps-section-title">
|
||||
@@ -1096,7 +1151,7 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Barcode Formats Options */}
|
||||
{/* Barcode Settings */}
|
||||
{selectedElem.type === 'barcode' && (
|
||||
<div className="space-y-3 ps-section-divider">
|
||||
<h5 className="ps-section-title">
|
||||
@@ -1134,6 +1189,7 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Image Settings */}
|
||||
{selectedElem.type === 'image' && (
|
||||
<div className="space-y-3 ps-section-divider">
|
||||
<h5 className="ps-section-title">
|
||||
@@ -1367,7 +1423,7 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
|
||||
className="ps-range"
|
||||
/>
|
||||
</div>
|
||||
<div className="border-t border-[#3a3a3a] pt-2.5">
|
||||
<div className="border-t border-[#3a3a3a] pt-2.5 space-y-2">
|
||||
<label className="ps-field-label">内边距 (mm)</label>
|
||||
<PropInput
|
||||
type="number"
|
||||
@@ -1383,6 +1439,41 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
|
||||
}
|
||||
className="ps-field font-mono"
|
||||
/>
|
||||
<div>
|
||||
<label className="ps-field-label">各边独立内边距 (mm,留空用统一值)</label>
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
{(
|
||||
[
|
||||
['paddingTop', '上'],
|
||||
['paddingRight', '右'],
|
||||
['paddingBottom', '下'],
|
||||
['paddingLeft', '左'],
|
||||
] as const
|
||||
).map(([key, label]) => (
|
||||
<div key={key} className="flex items-center gap-1">
|
||||
<span className="text-[9px] text-[#777] w-6 shrink-0">{label}</span>
|
||||
<PropInput
|
||||
type="number"
|
||||
min="0"
|
||||
max="10"
|
||||
step="0.5"
|
||||
value={selectedElem[key] ?? ''}
|
||||
onCommit={(val) => {
|
||||
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="—"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1459,39 +1550,85 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="ps-field-label mb-1">边框边(点击切换)</label>
|
||||
<div className="grid grid-cols-4 gap-1">
|
||||
<label className="ps-field-label">各边独立边框宽度 (mm,留空用统一值,0 为不显示)</label>
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
{(
|
||||
[
|
||||
['top', '上'],
|
||||
['right', '右'],
|
||||
['bottom', '下'],
|
||||
['left', '左'],
|
||||
] as [ElementSide, string][]
|
||||
).map(([side, label]) => (
|
||||
<button
|
||||
key={side}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
handleElemChange({
|
||||
...selectedElem,
|
||||
borderSides: toggleSide(selectedElem.borderSides, side),
|
||||
borderWidth:
|
||||
(selectedElem.borderWidth ?? 0) > 0
|
||||
? selectedElem.borderWidth
|
||||
: 0.3,
|
||||
borderColor: selectedElem.borderColor || '#111827',
|
||||
})
|
||||
['borderWidthTop', '上'],
|
||||
['borderWidthRight', '右'],
|
||||
['borderWidthBottom', '下'],
|
||||
['borderWidthLeft', '左'],
|
||||
] as const
|
||||
).map(([key, label]) => (
|
||||
<div key={key} className="flex items-center gap-1">
|
||||
<span className="text-[9px] text-[#777] w-6 shrink-0">{label}</span>
|
||||
<PropInput
|
||||
type="number"
|
||||
min="0"
|
||||
max="5"
|
||||
step="0.1"
|
||||
value={selectedElem[key] ?? ''}
|
||||
onCommit={(val) => {
|
||||
const updated = { ...selectedElem };
|
||||
if (val === '' || val === undefined) {
|
||||
delete updated[key];
|
||||
} else {
|
||||
updated[key] = Math.max(0, Math.min(5, Number(val) || 0));
|
||||
}
|
||||
className={`ps-btn text-[10px] justify-center ${
|
||||
isSideEnabled(selectedElem.borderSides, side) &&
|
||||
(selectedElem.borderWidth ?? 0) > 0
|
||||
? 'active'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
handleElemChange(updated);
|
||||
}}
|
||||
className="ps-field font-mono text-[10px] flex-1 min-w-0"
|
||||
placeholder="—"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="ps-field-label">各边独立边框颜色 (留空用统一值)</label>
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
{(
|
||||
[
|
||||
['borderColorTop', '上'],
|
||||
['borderColorRight', '右'],
|
||||
['borderColorBottom', '下'],
|
||||
['borderColorLeft', '左'],
|
||||
] as const
|
||||
).map(([key, label]) => (
|
||||
<div key={key} className="flex items-center gap-1 min-w-0">
|
||||
<span className="text-[9px] text-[#777] w-6 shrink-0">{label}</span>
|
||||
<input
|
||||
type="color"
|
||||
value={
|
||||
selectedElem[key]?.trim() ||
|
||||
selectedElem.borderColor ||
|
||||
'#111827'
|
||||
}
|
||||
onChange={(e) => {
|
||||
const updated = { ...selectedElem };
|
||||
updated[key] = e.target.value;
|
||||
handleElemChange(updated);
|
||||
}}
|
||||
className="color-input shrink-0 scale-90"
|
||||
title={`${label}边边框颜色`}
|
||||
/>
|
||||
<PropInput
|
||||
type="text"
|
||||
value={selectedElem[key] ?? ''}
|
||||
onCommit={(val) => {
|
||||
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="—"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1533,62 +1670,6 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Alignment Tools */}
|
||||
<div className="space-y-1.5">
|
||||
<h5 className="ps-section-title">
|
||||
对齐与分布
|
||||
</h5>
|
||||
<div className="grid grid-cols-3 gap-1.5">
|
||||
<AlignToolButton
|
||||
icon={<AlignHorizontalJustifyStart className="w-3.5 h-3.5" />}
|
||||
label="左对齐"
|
||||
title="靠左对齐 (X=0)"
|
||||
onClick={() => handleElemChange({ ...selectedElem, x: 0 })}
|
||||
/>
|
||||
<AlignToolButton
|
||||
icon={<AlignHorizontalJustifyCenter className="w-3.5 h-3.5" />}
|
||||
label="水平居中"
|
||||
title="水平居中对齐"
|
||||
onClick={() => {
|
||||
const newX = Math.round(((template.width - selectedElem.width) / 2) * 10) / 10;
|
||||
handleElemChange({ ...selectedElem, x: Math.max(0, newX) });
|
||||
}}
|
||||
/>
|
||||
<AlignToolButton
|
||||
icon={<AlignHorizontalJustifyEnd className="w-3.5 h-3.5" />}
|
||||
label="右对齐"
|
||||
title="靠右对齐"
|
||||
onClick={() => {
|
||||
const newX = Math.round((template.width - selectedElem.width) * 10) / 10;
|
||||
handleElemChange({ ...selectedElem, x: Math.max(0, newX) });
|
||||
}}
|
||||
/>
|
||||
<AlignToolButton
|
||||
icon={<AlignVerticalJustifyStart className="w-3.5 h-3.5" />}
|
||||
label="顶对齐"
|
||||
title="靠顶对齐 (Y=0)"
|
||||
onClick={() => handleElemChange({ ...selectedElem, y: 0 })}
|
||||
/>
|
||||
<AlignToolButton
|
||||
icon={<AlignVerticalJustifyCenter className="w-3.5 h-3.5" />}
|
||||
label="垂直居中"
|
||||
title="垂直居中对齐"
|
||||
onClick={() => {
|
||||
const newY = Math.round(((template.height - selectedElem.height) / 2) * 10) / 10;
|
||||
handleElemChange({ ...selectedElem, y: Math.max(0, newY) });
|
||||
}}
|
||||
/>
|
||||
<AlignToolButton
|
||||
icon={<AlignVerticalJustifyEnd className="w-3.5 h-3.5" />}
|
||||
label="底对齐"
|
||||
title="靠底对齐"
|
||||
onClick={() => {
|
||||
const newY = Math.round((template.height - selectedElem.height) * 10) / 10;
|
||||
handleElemChange({ ...selectedElem, y: Math.max(0, newY) });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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<ElementSide, BorderSideStyle>;
|
||||
|
||||
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<Record<ElementSide, boolean>> | undefined,
|
||||
side: ElementSide
|
||||
): Partial<Record<ElementSide, boolean>> {
|
||||
const next = { ...current };
|
||||
const enabled = current?.[side] !== false;
|
||||
next[side] = !enabled;
|
||||
return next;
|
||||
}
|
||||
|
||||
export function isSideEnabled(
|
||||
sides: Partial<Record<ElementSide, boolean>> | undefined,
|
||||
side: ElementSide
|
||||
): boolean {
|
||||
return sides?.[side] !== false;
|
||||
}
|
||||
|
||||
@@ -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') {
|
||||
|
||||
12
src/main.tsx
12
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<HTMLLinkElement>("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(
|
||||
<StrictMode>
|
||||
<AppDialogProvider>
|
||||
|
||||
16
src/types.ts
16
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<Record<ElementSide, boolean>>;
|
||||
/** 圆角半径 (mm),可被各角覆盖 */
|
||||
borderRadius?: number;
|
||||
|
||||
1
src/vite-env.d.ts
vendored
Normal file
1
src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user