优化,排列

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 {
stripTemplateForStorage,
@@ -28,10 +28,11 @@ import {
Code,
Loader2,
Plus,
Download,
Upload,
Printer,
Pencil,
Trash2,
MoreHorizontal,
X,
} from 'lucide-react';
import logoUrl from './assets/logo.svg';
@@ -145,6 +146,10 @@ export default function App() {
const [newTmplName, setNewTmplName] = useState<string>('');
const [newTmplW, setNewTmplW] = useState<number>(75);
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 cutLine = workingTemplate.cutLine ?? DEFAULT_CUT_LINE_CONFIG;
@@ -247,7 +252,7 @@ export default function App() {
const newId = `custom_tmpl_${Date.now()}`;
const newTmpl: LabelTemplate = {
id: newId,
name: `${newTmplName} (${newTmplW}x${newTmplH}mm)`,
name: `${newTmplName}`,
width: Math.max(10, Number(newTmplW) || 70),
height: Math.max(10, Number(newTmplH) || 40),
elements: [
@@ -259,7 +264,7 @@ export default function App() {
y: 5,
width: Math.max(10, newTmplW - 10),
height: 10,
content: '请插入绑定的文本内容 {货位编号}',
content: '{变量}',
fontSize: 12,
textAlign: 'center',
fontWeight: 'bold',
@@ -287,11 +292,52 @@ export default function App() {
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
const handleExportTemplate = (tmpl: LabelTemplate) => {
const dataStr = JSON.stringify(normalizeTemplate(tmpl), null, 2);
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');
linkElement.setAttribute('href', dataUri);
@@ -780,7 +826,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-visible 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'
: 'border-gray-200 hover:shadow-md hover:border-indigo-200'
@@ -795,12 +841,15 @@ export default function App() {
</div>
)} */}
<div className="space-y-1 pr-12 mb-3">
<div className="space-y-1 mb-3 min-w-0">
<h3 className="text-sm font-bold text-gray-900 mt-1 leading-snug truncate">
{tmpl.name}
</h3>
<p className="text-[10px] text-gray-400 flex items-center gap-1">
<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>
<h3 className="text-sm font-bold text-gray-900 mt-1 leading-snug">{tmpl.name}</h3>
<p className="text-[10px] text-gray-400">{tmpl.elements.length} </p>
</p>
</div>
{/* Preview */}
@@ -857,35 +906,74 @@ export default function App() {
<Printer className={`${isMobile ? 'w-4 h-4' : 'w-3.5 h-3.5'} shrink-0`} />
{isMobile ? '排版生成标签' : '排版导出'}
</button>
<div
ref={templateMenuId === tmpl.id ? templateMenuRef : null}
className="relative shrink-0"
>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setTemplateMenuId((id) => (id === tmpl.id ? null : 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
? 'mobile-icon-btn min-h-[44px] px-3 rounded-xl text-[13px] font-medium'
: 'shrink-0 whitespace-nowrap px-2.5 py-1.5 rounded-lg text-[11px] font-semibold'
}`}
aria-expanded={templateMenuId === tmpl.id}
aria-haspopup="menu"
>
<MoreHorizontal className={`${isMobile ? 'w-5 h-5' : 'w-4 h-4'} shrink-0`} />
</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={`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="导出模板"
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"
>
<Download className={`${isMobile ? 'w-5 h-5' : 'w-3.5 h-3.5'}`} />
<FileDown className="w-4 h-4 shrink-0" />
</button>
<button
type="button"
role="menuitem"
onClick={(e) => {
e.stopPropagation();
handleDeleteTemplate(tmpl.id!);
closeTemplateMenu();
void handleDeleteTemplate(tmpl.id!);
}}
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-w-[44px] min-h-[44px] rounded-xl'
: 'p-1.5 rounded-lg'
}`}
title="删除模板"
aria-label="删除模板"
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={`${isMobile ? 'w-5 h-5' : 'w-3.5 h-3.5'}`} />
<Trash2 className="w-4 h-4 shrink-0" />
</button>
</div>
)}
</div>
</div>
</div>
);
})}
@@ -1096,6 +1184,54 @@ export default function App() {
)}
</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 && (
<div
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,
alignSelectionToCanvas,
tileSelectedEvenly,
tileSelectedFlush,
type AlignMode,
} from '../elementAlign';
import { CollapsiblePanelSection } from './CollapsiblePanelSection';
@@ -44,6 +45,8 @@ import {
AlignVerticalJustifyEnd,
AlignHorizontalSpaceBetween,
AlignVerticalSpaceBetween,
Columns2,
Rows2,
Link2,
Plus,
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({
...template,
elements: tileSelectedEvenly(template.elements, selectedElementIds, axis),
elements: tile(template.elements, selectedElementIds, axis),
});
};
@@ -760,6 +765,18 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
title="在选区高度内垂直等距排列"
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>
@@ -1348,7 +1365,6 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
onClick={() =>
handleElemChange({
...selectedElem,
backgroundColor: 'transparent',
backgroundOpacity: 0,
})
}
@@ -1430,7 +1446,6 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
if (op <= 0) {
handleElemChange({
...selectedElem,
backgroundColor: 'transparent',
backgroundOpacity: 0,
});
} else {

View File

@@ -151,3 +151,30 @@ export function tileSelectedEvenly(
}
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);
}