优化,排列
This commit is contained in:
184
src/App.tsx
184
src/App.tsx
@@ -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"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user