优化,排列

This commit is contained in:
iqudoo
2026-06-12 23:41:43 +08:00
parent b25c50006d
commit 5b4f9de364
3 changed files with 227 additions and 49 deletions

View File

@@ -1,4 +1,4 @@
import React, { useState, useMemo, useEffect, useLayoutEffect } from 'react'; import React, { useState, useMemo, useEffect, useLayoutEffect, useRef } from 'react';
import { LabelTemplate, PaperConfig, ImportData, RecordRow, ExportRangeConfig, CutLineConfig } from './types'; import { LabelTemplate, PaperConfig, ImportData, RecordRow, ExportRangeConfig, CutLineConfig } from './types';
import { import {
stripTemplateForStorage, stripTemplateForStorage,
@@ -28,10 +28,11 @@ import {
Code, Code,
Loader2, Loader2,
Plus, Plus,
Download,
Upload, Upload,
Printer, Printer,
Pencil,
Trash2, Trash2,
MoreHorizontal,
X, X,
} from 'lucide-react'; } from 'lucide-react';
import logoUrl from './assets/logo.svg'; import logoUrl from './assets/logo.svg';
@@ -145,6 +146,10 @@ export default function App() {
const [newTmplName, setNewTmplName] = useState<string>(''); const [newTmplName, setNewTmplName] = useState<string>('');
const [newTmplW, setNewTmplW] = useState<number>(75); const [newTmplW, setNewTmplW] = useState<number>(75);
const [newTmplH, setNewTmplH] = useState<number>(45); const [newTmplH, setNewTmplH] = useState<number>(45);
const [editingTemplateId, setEditingTemplateId] = useState<string | null>(null);
const [editTmplName, setEditTmplName] = useState<string>('');
const [templateMenuId, setTemplateMenuId] = useState<string | null>(null);
const templateMenuRef = useRef<HTMLDivElement | null>(null);
const paper = workingTemplate.paper ?? DEFAULT_PAPER_CONFIG; const paper = workingTemplate.paper ?? DEFAULT_PAPER_CONFIG;
const cutLine = workingTemplate.cutLine ?? DEFAULT_CUT_LINE_CONFIG; const cutLine = workingTemplate.cutLine ?? DEFAULT_CUT_LINE_CONFIG;
@@ -247,7 +252,7 @@ export default function App() {
const newId = `custom_tmpl_${Date.now()}`; const newId = `custom_tmpl_${Date.now()}`;
const newTmpl: LabelTemplate = { const newTmpl: LabelTemplate = {
id: newId, id: newId,
name: `${newTmplName} (${newTmplW}x${newTmplH}mm)`, name: `${newTmplName}`,
width: Math.max(10, Number(newTmplW) || 70), width: Math.max(10, Number(newTmplW) || 70),
height: Math.max(10, Number(newTmplH) || 40), height: Math.max(10, Number(newTmplH) || 40),
elements: [ elements: [
@@ -259,7 +264,7 @@ export default function App() {
y: 5, y: 5,
width: Math.max(10, newTmplW - 10), width: Math.max(10, newTmplW - 10),
height: 10, height: 10,
content: '请插入绑定的文本内容 {货位编号}', content: '{变量}',
fontSize: 12, fontSize: 12,
textAlign: 'center', textAlign: 'center',
fontWeight: 'bold', fontWeight: 'bold',
@@ -287,11 +292,52 @@ export default function App() {
setNewTmplH(45); setNewTmplH(45);
}; };
const openEditTemplateDialog = (tmpl: LabelTemplate) => {
setEditingTemplateId(tmpl.id!);
setEditTmplName(tmpl.name);
};
const closeEditTemplateDialog = () => {
setEditingTemplateId(null);
setEditTmplName('');
};
const closeTemplateMenu = () => setTemplateMenuId(null);
useEffect(() => {
if (!templateMenuId) return;
const handlePointerDown = (e: PointerEvent) => {
if (templateMenuRef.current?.contains(e.target as Node)) return;
closeTemplateMenu();
};
document.addEventListener('pointerdown', handlePointerDown);
return () => document.removeEventListener('pointerdown', handlePointerDown);
}, [templateMenuId]);
const handleEditTemplateSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!editingTemplateId) return;
const trimmed = editTmplName.trim();
if (!trimmed) {
await showAlert('请先输入合法的模板名', { variant: 'warning' });
return;
}
const target = templates.find((t) => t.id === editingTemplateId);
if (!target) {
closeEditTemplateDialog();
return;
}
saveTemplatesToStorage(
templates.map((t) => (t.id === editingTemplateId ? { ...t, name: trimmed } : t))
);
closeEditTemplateDialog();
};
// Export templates list or single active template as standard json // Export templates list or single active template as standard json
const handleExportTemplate = (tmpl: LabelTemplate) => { const handleExportTemplate = (tmpl: LabelTemplate) => {
const dataStr = JSON.stringify(normalizeTemplate(tmpl), null, 2); const dataStr = JSON.stringify(normalizeTemplate(tmpl), null, 2);
const dataUri = 'data:application/json;charset=utf-8,' + encodeURIComponent(dataStr); const dataUri = 'data:application/json;charset=utf-8,' + encodeURIComponent(dataStr);
const exportFileDefaultName = `模板_${tmpl.name?.replace(/\s+/g, '_') || tmpl.id}.lad`; const exportFileDefaultName = `${tmpl.name?.replace(/\s+/g, '_') || tmpl.id}_${Date.now()}.lad`;
const linkElement = document.createElement('a'); const linkElement = document.createElement('a');
linkElement.setAttribute('href', dataUri); linkElement.setAttribute('href', dataUri);
@@ -654,8 +700,8 @@ export default function App() {
{!(isMobile && workflowStep === 'print-view') && ( {!(isMobile && workflowStep === 'print-view') && (
<header <header
className={`no-print bg-indigo-700 border-b border-indigo-900 shrink-0 shadow flex flex-wrap items-center justify-between gap-4 mobile-template-header app-header-sticky ${isMobile && workflowStep === 'template-select' && hasTemplates className={`no-print bg-indigo-700 border-b border-indigo-900 shrink-0 shadow flex flex-wrap items-center justify-between gap-4 mobile-template-header app-header-sticky ${isMobile && workflowStep === 'template-select' && hasTemplates
? 'mobile-template-header-row' ? 'mobile-template-header-row'
: '' : ''
} px-4 py-3 md:px-6 md:py-4`} } px-4 py-3 md:px-6 md:py-4`}
> >
{/* Title logo and slogan description */} {/* Title logo and slogan description */}
@@ -691,8 +737,8 @@ export default function App() {
)} )}
<label <label
className={`inline-flex items-center gap-1.5 bg-indigo-600 hover:bg-indigo-800 text-white rounded-xl font-semibold whitespace-nowrap transition shadow-sm cursor-pointer ${isMobile className={`inline-flex items-center gap-1.5 bg-indigo-600 hover:bg-indigo-800 text-white rounded-xl font-semibold whitespace-nowrap transition shadow-sm cursor-pointer ${isMobile
? 'min-h-[40px] px-3 py-2 text-[11px]' ? 'min-h-[40px] px-3 py-2 text-[11px]'
: 'px-4 py-2 text-xs' : 'px-4 py-2 text-xs'
}`} }`}
> >
<Upload className={`${isMobile ? 'w-3.5 h-3.5' : 'w-4 h-4'} shrink-0`} /> <Upload className={`${isMobile ? 'w-3.5 h-3.5' : 'w-4 h-4'} shrink-0`} />
@@ -780,7 +826,7 @@ export default function App() {
<div <div
key={tmpl.id} key={tmpl.id}
onClick={() => handleSelectTemplate(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-visible group select-none ${isActive
// ? 'ring-2 ring-indigo-500 border-indigo-500 bg-indigo-50/5' // ? 'ring-2 ring-indigo-500 border-indigo-500 bg-indigo-50/5'
? 'border-gray-200 hover:shadow-md hover:border-indigo-200' ? 'border-gray-200 hover:shadow-md hover:border-indigo-200'
: 'border-gray-200 hover:shadow-md hover:border-indigo-200' : 'border-gray-200 hover:shadow-md hover:border-indigo-200'
@@ -795,12 +841,15 @@ export default function App() {
</div> </div>
)} */} )} */}
<div className="space-y-1 pr-12 mb-3"> <div className="space-y-1 mb-3 min-w-0">
<span className="text-[10px] font-mono font-extrabold text-indigo-700 bg-indigo-50 px-2 py-0.5 rounded-lg border border-indigo-100"> <h3 className="text-sm font-bold text-gray-900 mt-1 leading-snug truncate">
{tmpl.width} × {tmpl.height} mm {tmpl.name}
</span> </h3>
<h3 className="text-sm font-bold text-gray-900 mt-1 leading-snug">{tmpl.name}</h3> <p className="text-[10px] text-gray-400 flex items-center gap-1">
<p className="text-[10px] text-gray-400">{tmpl.elements.length} </p> <span className="text-[10px] font-mono font-extrabold text-indigo-700 bg-indigo-50 px-2 py-0.5 rounded-lg border border-indigo-100">
{tmpl.width} × {tmpl.height} mm
</span>
</p>
</div> </div>
{/* Preview */} {/* Preview */}
@@ -850,41 +899,80 @@ export default function App() {
openPrintView(tmpl.id!); openPrintView(tmpl.id!);
}} }}
className={`inline-flex items-center gap-1.5 bg-indigo-600 hover:bg-indigo-700 active:bg-indigo-800 text-white transition shadow-xs cursor-pointer ${isMobile className={`inline-flex items-center gap-1.5 bg-indigo-600 hover:bg-indigo-700 active:bg-indigo-800 text-white transition shadow-xs cursor-pointer ${isMobile
? 'mobile-primary-btn flex-1 min-w-0 justify-center min-h-[44px] px-4 py-2.5 rounded-xl text-[13px] font-semibold' ? 'mobile-primary-btn flex-1 min-w-0 justify-center min-h-[44px] px-4 py-2.5 rounded-xl text-[13px] font-semibold'
: 'shrink-0 whitespace-nowrap px-3 py-1.5 rounded-lg text-[11px] font-bold' : 'shrink-0 whitespace-nowrap px-3 py-1.5 rounded-lg text-[11px] font-bold'
}`} }`}
> >
<Printer className={`${isMobile ? 'w-4 h-4' : 'w-3.5 h-3.5'} shrink-0`} /> <Printer className={`${isMobile ? 'w-4 h-4' : 'w-3.5 h-3.5'} shrink-0`} />
{isMobile ? '排版生成标签' : '排版导出'} {isMobile ? '排版生成标签' : '排版导出'}
</button> </button>
<button <div
onClick={(e) => { ref={templateMenuId === tmpl.id ? templateMenuRef : null}
e.stopPropagation(); className="relative shrink-0"
handleExportTemplate(tmpl);
}}
className={`shrink-0 bg-gray-50 text-gray-500 hover:bg-gray-100 active:bg-gray-200 border border-gray-100 hover:border-gray-300 inline-flex items-center justify-center transition cursor-pointer ${isMobile
? 'mobile-icon-btn min-w-[44px] min-h-[44px] rounded-xl'
: 'p-1.5 rounded-lg'
}`}
title="导出"
aria-label="导出模板"
> >
<Download className={`${isMobile ? 'w-5 h-5' : 'w-3.5 h-3.5'}`} /> <button
</button> type="button"
<button onClick={(e) => {
onClick={(e) => { e.stopPropagation();
e.stopPropagation(); setTemplateMenuId((id) => (id === tmpl.id ? null : tmpl.id!));
handleDeleteTemplate(tmpl.id!); }}
}} className={`bg-gray-50 text-gray-600 hover:bg-gray-100 active:bg-gray-200 border border-gray-100 hover:border-gray-300 inline-flex items-center justify-center gap-1 transition cursor-pointer ${isMobile
className={`shrink-0 bg-gray-50 text-rose-500 hover:text-white hover:bg-rose-600 active:bg-rose-700 border border-gray-100 inline-flex items-center justify-center transition cursor-pointer ${isMobile ? 'mobile-icon-btn min-h-[44px] px-3 rounded-xl text-[13px] font-medium'
? 'mobile-icon-btn min-w-[44px] min-h-[44px] rounded-xl' : 'shrink-0 whitespace-nowrap px-2.5 py-1.5 rounded-lg text-[11px] font-semibold'
: 'p-1.5 rounded-lg' }`}
}`} aria-expanded={templateMenuId === tmpl.id}
title="删除模板" aria-haspopup="menu"
aria-label="删除模板" >
> <MoreHorizontal className={`${isMobile ? 'w-5 h-5' : 'w-4 h-4'} shrink-0`} />
<Trash2 className={`${isMobile ? 'w-5 h-5' : 'w-3.5 h-3.5'}`} /> </button>
</button> {templateMenuId === tmpl.id && (
<div
role="menu"
className="absolute right-0 top-full mt-1 z-30 min-w-[90px] py-1 bg-white border border-gray-200 rounded-xl shadow-lg"
onClick={(e) => e.stopPropagation()}
>
<button
type="button"
role="menuitem"
onClick={(e) => {
e.stopPropagation();
closeTemplateMenu();
openEditTemplateDialog(tmpl);
}}
className="inline-flex items-center gap-3 w-full px-3 py-2.5 text-left text-sm text-gray-700 hover:bg-gray-50 active:bg-gray-100 cursor-pointer"
>
<Pencil className="w-4 h-4 shrink-0" />
</button>
<button
type="button"
role="menuitem"
onClick={(e) => {
e.stopPropagation();
closeTemplateMenu();
handleExportTemplate(tmpl);
}}
className="inline-flex items-center gap-3 w-full px-3 py-2.5 text-left text-sm text-gray-700 hover:bg-gray-50 active:bg-gray-100 cursor-pointer"
>
<FileDown className="w-4 h-4 shrink-0" />
</button>
<button
type="button"
role="menuitem"
onClick={(e) => {
e.stopPropagation();
closeTemplateMenu();
void handleDeleteTemplate(tmpl.id!);
}}
className="inline-flex items-center gap-3 w-full px-3 py-2.5 text-left text-sm text-rose-600 hover:bg-rose-50 active:bg-rose-100 cursor-pointer"
>
<Trash2 className="w-4 h-4 shrink-0" />
</button>
</div>
)}
</div>
</div> </div>
</div> </div>
); );
@@ -1096,6 +1184,54 @@ export default function App() {
)} )}
</main> </main>
{editingTemplateId && (
<div
className="fixed inset-0 z-[9000] flex items-center justify-center bg-slate-950/60 backdrop-blur-sm p-4"
onClick={closeEditTemplateDialog}
role="presentation"
>
<div
className="bg-white rounded-2xl shadow-2xl w-full max-w-md overflow-hidden"
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-label="重命名模板"
>
<form onSubmit={handleEditTemplateSubmit} className="px-6 py-5 space-y-4">
<div>
<label className="block text-[11px] font-semibold text-gray-500 mb-1.5">
</label>
<input
type="text"
required
autoFocus
placeholder="请输入模板名称"
value={editTmplName}
onChange={(e) => setEditTmplName(e.target.value)}
className="w-full px-3 py-2 bg-gray-50 border border-gray-200 rounded-lg text-sm focus:border-indigo-400 focus:outline-none"
/>
</div>
<div className="flex justify-end gap-2 pt-2">
<button
type="button"
onClick={closeEditTemplateDialog}
className="px-4 py-2 border border-gray-200 text-gray-600 hover:bg-gray-50 rounded-lg text-sm font-medium cursor-pointer"
>
</button>
<button
type="submit"
className="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 text-sm font-semibold cursor-pointer"
>
</button>
</div>
</form>
</div>
</div>
)}
{showAddTmplForm && ( {showAddTmplForm && (
<div <div
className="fixed inset-0 z-[9000] flex items-center justify-center bg-slate-950/60 backdrop-blur-sm p-4" className="fixed inset-0 z-[9000] flex items-center justify-center bg-slate-950/60 backdrop-blur-sm p-4"

View File

@@ -14,6 +14,7 @@ import {
alignToSelectionBounds, alignToSelectionBounds,
alignSelectionToCanvas, alignSelectionToCanvas,
tileSelectedEvenly, tileSelectedEvenly,
tileSelectedFlush,
type AlignMode, type AlignMode,
} from '../elementAlign'; } from '../elementAlign';
import { CollapsiblePanelSection } from './CollapsiblePanelSection'; import { CollapsiblePanelSection } from './CollapsiblePanelSection';
@@ -44,6 +45,8 @@ import {
AlignVerticalJustifyEnd, AlignVerticalJustifyEnd,
AlignHorizontalSpaceBetween, AlignHorizontalSpaceBetween,
AlignVerticalSpaceBetween, AlignVerticalSpaceBetween,
Columns2,
Rows2,
Link2, Link2,
Plus, Plus,
Ruler, Ruler,
@@ -232,10 +235,12 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
}); });
}; };
const applyTile = (axis: 'horizontal' | 'vertical') => { const applyTile = (axis: 'horizontal' | 'vertical', spacing: 'even' | 'none' = 'even') => {
const tile =
spacing === 'even' ? tileSelectedEvenly : tileSelectedFlush;
onChangeTemplate({ onChangeTemplate({
...template, ...template,
elements: tileSelectedEvenly(template.elements, selectedElementIds, axis), elements: tile(template.elements, selectedElementIds, axis),
}); });
}; };
@@ -760,6 +765,18 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
title="在选区高度内垂直等距排列" title="在选区高度内垂直等距排列"
onClick={() => applyTile('vertical')} onClick={() => applyTile('vertical')}
/> />
<AlignToolButton
icon={<Columns2 className="w-3.5 h-3.5" />}
label="水平无间距平铺"
title="从最左元素起水平紧密排列"
onClick={() => applyTile('horizontal', 'none')}
/>
<AlignToolButton
icon={<Rows2 className="w-3.5 h-3.5" />}
label="垂直无间距平铺"
title="从最上元素起垂直紧密排列"
onClick={() => applyTile('vertical', 'none')}
/>
</div> </div>
</div> </div>
</div> </div>
@@ -1348,7 +1365,6 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
onClick={() => onClick={() =>
handleElemChange({ handleElemChange({
...selectedElem, ...selectedElem,
backgroundColor: 'transparent',
backgroundOpacity: 0, backgroundOpacity: 0,
}) })
} }
@@ -1430,7 +1446,6 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
if (op <= 0) { if (op <= 0) {
handleElemChange({ handleElemChange({
...selectedElem, ...selectedElem,
backgroundColor: 'transparent',
backgroundOpacity: 0, backgroundOpacity: 0,
}); });
} else { } else {

View File

@@ -151,3 +151,30 @@ export function tileSelectedEvenly(
} }
return patchElements(elements, patches); return patchElements(elements, patches);
} }
/** 按水平/垂直方向无间距紧密平铺(从当前最左/最上元素位置起依次排列) */
export function tileSelectedFlush(
elements: TemplateElement[],
selectedIds: string[],
axis: 'horizontal' | 'vertical'
): TemplateElement[] {
const selected = elements
.filter((e) => selectedIds.includes(e.id) && !e.locked)
.sort((a, b) => (axis === 'horizontal' ? a.x - b.x : a.y - b.y));
if (selected.length < 2) return elements;
const patches = new Map<string, Partial<TemplateElement>>();
const start = axis === 'horizontal' ? selected[0].x : selected[0].y;
let cursor = start;
for (const el of selected) {
if (axis === 'horizontal') {
patches.set(el.id, { x: round1(cursor) });
cursor += el.width;
} else {
patches.set(el.id, { y: round1(cursor) });
cursor += el.height;
}
}
return patchElements(elements, patches);
}