This commit is contained in:
iqudoo
2026-06-12 21:17:24 +08:00
parent 239e2eeebd
commit 220841444a
7 changed files with 240 additions and 81 deletions

View File

@@ -290,7 +290,7 @@ export default function App() {
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}.json`;
const exportFileDefaultName = `模板_${tmpl.name?.replace(/\s+/g, '_') || tmpl.id}.lad`;
const linkElement = document.createElement('a');
linkElement.setAttribute('href', dataUri);
@@ -325,12 +325,12 @@ export default function App() {
handleSelectTemplate(newId);
await showAlert('模板读取解析成功,已追加至库中。', { variant: 'success' });
} else {
await showAlert('JSON格式错误:外部模板必须至少包含 width, height, 以及 elements 分组', {
await showAlert('模板格式错误:外部模板必须至少包含 width, height, 以及 elements 分组', {
variant: 'error',
});
}
} catch (err) {
await showAlert('文件解码失败,请确保您上传的是合法的 json 模板结构文件。', {
await showAlert('文件解码失败,请确保您上传的是合法的模板文件。', {
variant: 'error',
});
}
@@ -648,10 +648,10 @@ export default function App() {
</button>
<label className="inline-flex items-center gap-1.5 px-4 py-2 bg-white/10 hover:bg-white/15 border border-slate-700 text-slate-100 rounded-xl text-xs font-semibold whitespace-nowrap transition shadow-sm cursor-pointer">
<Upload className="w-4 h-4 shrink-0" />
JSON
<input
type="file"
accept=".json"
accept=".lad"
onChange={handleImportTemplate}
className="hidden"
/>
@@ -692,7 +692,7 @@ export default function App() {
<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-3">
{!isMobile && (
@@ -707,10 +707,10 @@ export default function App() {
)}
<label className="inline-flex items-center justify-center gap-2 px-5 py-2.5 bg-white border border-gray-200 hover:border-indigo-300 text-gray-700 rounded-xl text-sm font-semibold transition shadow-sm cursor-pointer shrink-0">
<Upload className="w-4 h-4 shrink-0" />
JSON
<input
type="file"
accept=".json"
accept=".lad"
onChange={handleImportTemplate}
className="hidden"
/>
@@ -814,7 +814,7 @@ export default function App() {
handleExportTemplate(tmpl);
}}
className="shrink-0 p-1.5 bg-gray-50 text-gray-500 hover:bg-gray-100 border border-gray-100 hover:border-gray-300 rounded-lg inline-flex items-center justify-center transition cursor-pointer"
title="导出 JSON"
title="导出"
>
<Download className="w-3.5 h-3.5" />
</button>

View File

@@ -1,6 +1,7 @@
import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react';
import { LabelTemplate, TemplateElement } from '../types';
import { mmToPx, shouldRenderElement } from '../utils';
import { useIsNarrowScreen } from '../hooks/useIsMobile';
import { CanvasLabelImage } from './CanvasLabelImage';
import {
ZoomIn,
@@ -70,6 +71,7 @@ function applyCornerResize(
const MARQUEE_MIN_PX = 4;
const DRAG_START_THRESHOLD_PX = 4;
const MODERATE_ZOOM_MAX = 2.0;
function rectFullyContains(
outer: { left: number; top: number; right: number; bottom: number },
@@ -123,7 +125,8 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
onSelectElements,
}) => {
const selectedElementId = selectedElementIds[0] || null;
const [zoom, setZoom] = useState<number>(2.5);
const isNarrow = useIsNarrowScreen();
const [zoom, setZoom] = useState<number>(MODERATE_ZOOM_MAX);
const [activeTool, setActiveTool] = useState<ActiveTool>('move');
const canvasAreaRef = useRef<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLDivElement | null>(null);
@@ -378,6 +381,23 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
deleteSelectedElements,
]);
const applyModerateZoom = useCallback(() => {
const area = canvasAreaRef.current;
if (!area) return;
const pad = isNarrow ? 32 : 80;
const availW = area.clientWidth - pad;
const availH = area.clientHeight - pad;
if (availW <= 0 || availH <= 0) return;
const docW = mmToPx(template.width, 1);
const docH = mmToPx(template.height, 1);
const fitZoom = Math.min(availW / docW, availH / docH, MODERATE_ZOOM_MAX);
setZoom(Math.max(0.5, Math.round(fitZoom * 100) / 100));
}, [template.width, template.height, isNarrow]);
useEffect(() => {
applyModerateZoom();
}, [template.id, applyModerateZoom]);
useEffect(() => {
const area = canvasAreaRef.current;
if (!area) return;
@@ -399,7 +419,7 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
useEffect(() => {
if (!marqueeActive) return;
const handleMouseMove = (e: MouseEvent) => {
const handlePointerMove = (e: PointerEvent) => {
const canvas = canvasRef.current;
if (!canvas) return;
const rect = canvas.getBoundingClientRect();
@@ -408,7 +428,7 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
setMarqueeState((prev) => (prev ? { ...prev, currentX, currentY } : null));
};
const handleMouseUp = () => {
const handlePointerUp = () => {
const state = marqueeRef.current;
setMarqueeState(null);
@@ -453,11 +473,13 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
}
};
window.addEventListener('mousemove', handleMouseMove);
window.addEventListener('mouseup', handleMouseUp);
window.addEventListener('pointermove', handlePointerMove);
window.addEventListener('pointerup', handlePointerUp);
window.addEventListener('pointercancel', handlePointerUp);
return () => {
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('mouseup', handleMouseUp);
window.removeEventListener('pointermove', handlePointerMove);
window.removeEventListener('pointerup', handlePointerUp);
window.removeEventListener('pointercancel', handlePointerUp);
};
}, [marqueeActive, zoom, onSelectElements]);
@@ -465,7 +487,7 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
if (!dragState) return;
dragMovedRef.current = false;
const handleMouseMove = (e: MouseEvent) => {
const handlePointerMove = (e: PointerEvent) => {
const deltaX = e.clientX - dragState.startX;
const deltaY = e.clientY - dragState.startY;
if (
@@ -547,7 +569,7 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
}
};
const handleMouseUp = () => {
const handlePointerUp = () => {
if (dragRafRef.current !== null) {
cancelAnimationFrame(dragRafRef.current);
dragRafRef.current = null;
@@ -565,11 +587,13 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
setDragState(null);
};
window.addEventListener('mousemove', handleMouseMove);
window.addEventListener('mouseup', handleMouseUp);
window.addEventListener('pointermove', handlePointerMove);
window.addEventListener('pointerup', handlePointerUp);
window.addEventListener('pointercancel', handlePointerUp);
return () => {
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('mouseup', handleMouseUp);
window.removeEventListener('pointermove', handlePointerMove);
window.removeEventListener('pointerup', handlePointerUp);
window.removeEventListener('pointercancel', handlePointerUp);
};
}, [dragState, template, zoom, onChange]);
@@ -588,7 +612,7 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
return groupStartPositions;
};
const startMarquee = (e: React.MouseEvent) => {
const startMarquee = (e: React.PointerEvent) => {
if (activeTool !== 'move' || e.button !== 0) return;
const canvas = canvasRef.current;
@@ -600,6 +624,7 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
e.preventDefault();
e.stopPropagation();
e.currentTarget.setPointerCapture(e.pointerId);
setMarqueeState({
startX,
@@ -610,7 +635,7 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
});
};
const handleWorkspaceMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
const handleWorkspacePointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
if (activeTool !== 'move' || e.button !== 0) return;
const target = e.target as HTMLElement;
if (target.closest('[data-label-element]') || target.closest('.ps-handle')) return;
@@ -618,8 +643,8 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
startMarquee(e);
};
const handleElementMouseDown = (
e: React.MouseEvent,
const handleElementPointerDown = (
e: React.PointerEvent,
element: TemplateElement,
actionType: 'drag' | 'resize'
) => {
@@ -652,13 +677,18 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
}
};
const handleDragStart = (e: React.MouseEvent, element: TemplateElement, actionType: 'drag' | 'resize') => {
const handleDragStart = (
e: React.PointerEvent,
element: TemplateElement,
actionType: 'drag' | 'resize'
) => {
e.preventDefault();
if (!selectedElementIds.includes(element.id)) {
onSelectElements([element.id]);
}
if (element.locked) return;
e.currentTarget.setPointerCapture(e.pointerId);
setFrozenCanvasElements(template.elements);
setDragState({
elementId: element.id,
@@ -674,7 +704,7 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
};
const handleResizeStart = (
e: React.MouseEvent,
e: React.PointerEvent,
element: TemplateElement,
corner: ResizeCorner
) => {
@@ -683,6 +713,7 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
onSelectElements([element.id]);
if (element.locked) return;
e.currentTarget.setPointerCapture(e.pointerId);
setFrozenCanvasElements(template.elements);
setDragState({
elementId: element.id,
@@ -775,8 +806,8 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
<div className="flex flex-col flex-1 min-w-0 min-h-0">
<div
ref={canvasAreaRef}
className="flex-1 overflow-auto ps-workspace-bg flex items-center justify-center"
onMouseDown={handleWorkspaceMouseDown}
className="flex-1 overflow-auto ps-workspace-bg ps-design-canvas-touch flex items-center justify-center"
onPointerDown={handleWorkspacePointerDown}
onClick={(e) => {
if (ignoreCanvasClickRef.current) {
ignoreCanvasClickRef.current = false;
@@ -820,9 +851,9 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
{activeTool === 'move' && (
<div
className="absolute inset-0 z-[1] cursor-crosshair"
className="absolute inset-0 z-[1] cursor-crosshair touch-none"
aria-hidden
onMouseDown={startMarquee}
onPointerDown={startMarquee}
/>
)}
@@ -851,7 +882,7 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
<div
key={element.id}
data-label-element
className={`absolute group ${
className={`absolute group touch-none ${
isLocked
? 'pointer-events-none'
: isSelected
@@ -867,8 +898,8 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
transform: element.rotation ? `rotate(${element.rotation}deg)` : undefined,
transformOrigin: 'center center',
}}
onMouseDown={
isLocked ? undefined : (e) => handleElementMouseDown(e, element, 'drag')
onPointerDown={
isLocked ? undefined : (e) => handleElementPointerDown(e, element, 'drag')
}
onClick={isLocked ? undefined : (e) => handleElementClick(e, element)}
>
@@ -886,22 +917,22 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
<>
<div
className="ps-handle top-0 left-0 -translate-x-1/2 -translate-y-1/2 cursor-nw-resize"
onMouseDown={(e) => handleResizeStart(e, element, 'nw')}
onPointerDown={(e) => handleResizeStart(e, element, 'nw')}
title="拖拽调整尺寸"
/>
<div
className="ps-handle top-0 right-0 translate-x-1/2 -translate-y-1/2 cursor-ne-resize"
onMouseDown={(e) => handleResizeStart(e, element, 'ne')}
onPointerDown={(e) => handleResizeStart(e, element, 'ne')}
title="拖拽调整尺寸"
/>
<div
className="ps-handle bottom-0 left-0 -translate-x-1/2 translate-y-1/2 cursor-sw-resize"
onMouseDown={(e) => handleResizeStart(e, element, 'sw')}
onPointerDown={(e) => handleResizeStart(e, element, 'sw')}
title="拖拽调整尺寸"
/>
<div
className="ps-handle bottom-0 right-0 translate-x-1/2 translate-y-1/2 cursor-se-resize"
onMouseDown={(e) => handleResizeStart(e, element, 'se')}
onPointerDown={(e) => handleResizeStart(e, element, 'se')}
title="拖拽调整尺寸"
/>
</>
@@ -918,8 +949,18 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
</div>
</div>
<div className="ps-status-bar">
<div className="flex items-center gap-3">
<div className={`ps-status-bar ${isNarrow ? 'ps-status-bar-compact' : ''}`}>
<div className="flex items-center gap-2 min-w-0 truncate">
{isNarrow ? (
<>
<span className="truncate">
{template.width}×{template.height}
{selectedElemObj ? ` · ${selectedElemObj.name}` : ''}
{selectedElementIds.length > 1 ? ` · ${selectedElementIds.length}` : ''}
</span>
</>
) : (
<>
<span>
: {template.width} × {template.height} mm
</span>
@@ -930,20 +971,22 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
</span>
)}
{selectedElementIds.length > 1 && (
<span className="text-emerald-400">
| {selectedElementIds.length}
</span>
<span className="text-emerald-400">| {selectedElementIds.length} </span>
)}
{activeTool === 'move' && (
<span className="text-[#888] hidden sm:inline">
| · Shift/Ctrl+ · Ctrl+A
</span>
)}
</>
)}
</div>
<div className="flex items-center gap-3">
<div className="flex items-center gap-2 shrink-0">
{!isNarrow && (
<span className="hidden sm:inline text-[10px]">
使 · · Ctrl+
</span>
)}
<div className="ps-zoom-control">
<button className="ps-zoom-btn" onClick={() => setZoom(Math.max(0.5, zoom - 0.25))} title="缩小">
<ZoomOut className="w-3 h-3" />
@@ -955,6 +998,13 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
<ZoomIn className="w-3 h-3" />
</button>
</div>
<button
onClick={applyModerateZoom}
className="text-[10px] text-[#aaa] hover:text-white px-1.5 py-0.5 border border-[#4a4a4a] hover:border-[#666] transition cursor-pointer"
title="适中缩放"
>
</button>
<button
onClick={fitToView}
className="text-[10px] text-[#aaa] hover:text-white px-1.5 py-0.5 border border-[#4a4a4a] hover:border-[#666] transition cursor-pointer"

View File

@@ -867,6 +867,7 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
{(selectedElem.textFormat === 'number' ||
selectedElem.textFormat === 'percent' ||
selectedElem.textFormat === 'currency') && (
<>
<div>
<label className="ps-field-label"></label>
<PropInput
@@ -883,6 +884,30 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
className="ps-field font-mono"
/>
</div>
<div className="col-span-2">
<label className="ps-field-label"></label>
<select
value={selectedElem.numberPartDisplay ?? 'full'}
onChange={(e) =>
handleElemChange({
...selectedElem,
numberPartDisplay: e.target.value as
| 'full'
| 'integer'
| 'fraction',
})
}
className="ps-field"
>
<option value="full"></option>
<option value="integer"> 19.9 19</option>
<option value="fraction"> 19.9 .9</option>
</select>
<p className="text-[9px] text-[#666] mt-1 leading-relaxed">
19.9
</p>
</div>
</>
)}
</div>

View File

@@ -21,3 +21,8 @@ export function useMediaQuery(query: string): boolean {
export function useIsMobile(): boolean {
return useMediaQuery('(max-width: 767px)');
}
/** 平板等窄屏:&lt; 1024px */
export function useIsNarrowScreen(): boolean {
return useMediaQuery('(max-width: 1023px)');
}

View File

@@ -634,6 +634,34 @@ body,
background: #fff;
border: 1px solid var(--color-ps-accent);
z-index: 50;
touch-action: none;
}
.ps-design-canvas-touch {
touch-action: pan-x pan-y;
}
.ps-design-canvas-touch [data-label-element],
.ps-design-canvas-touch .ps-handle,
.ps-design-canvas-touch .touch-none {
touch-action: none;
}
.ps-status-bar-compact {
font-size: 10px;
gap: 8px;
padding: 0 8px;
}
@media (max-width: 1023px) {
.ps-handle {
width: 18px;
height: 18px;
}
.ps-status-bar {
flex-wrap: nowrap;
}
}
.ps-zoom-control {

View File

@@ -1,4 +1,6 @@
export type TextFormat = 'text' | 'number' | 'percent' | 'currency';
/** 数值格式下的显示部分:完整 / 仅整数 / 仅小数(含小数点) */
export type NumberPartDisplay = 'full' | 'integer' | 'fraction';
export type ElementVisibilityMode = 'always' | 'conditional';
export type VisibilityOperator = 'empty' | 'not_empty' | 'gt' | 'lt' | 'eq' | 'neq';
@@ -82,6 +84,8 @@ export interface TemplateElement {
textFormat?: TextFormat;
/** 数值/百分比/货币的小数位数,默认 2 */
decimalPlaces?: number;
/** 数值格式下显示整数或小数部分,仅 type=text 且 textFormat≠text */
numberPartDisplay?: NumberPartDisplay;
/** 边框宽度 (mm)0 为无边框;各边留空时沿用此值 */
borderWidth?: number;
borderColor?: string;

View File

@@ -6,6 +6,7 @@ import {
DataSourceMeta,
TemplateElement,
TextFormat,
NumberPartDisplay,
ExportRangeConfig,
CutLineConfig,
VisibilityRule,
@@ -643,6 +644,51 @@ export function formatDisplayValue(
}
}
/** 按整数/小数部分拆分数值显示(如价格 19.9 →「19」与「.9」分元素展示) */
export function applyNumberPartDisplay(
value: string,
format: Exclude<TextFormat, 'text'>,
decimalPlaces: number | undefined,
part: NumberPartDisplay
): string {
if (part === 'full') {
return formatDisplayValue(value, format, decimalPlaces);
}
const trimmed = value.trim().replace(/,/g, '');
if (!trimmed) return '';
const num = Number(trimmed);
if (Number.isNaN(num)) {
return formatDisplayValue(value, format, decimalPlaces);
}
const places = decimalPlaces ?? 2;
const sign = num < 0 ? '-' : '';
const abs = Math.abs(num);
const intVal = Math.trunc(abs);
const intStr = intVal.toLocaleString('zh-CN');
if (part === 'integer') {
switch (format) {
case 'number':
return `${sign}${intStr}`;
case 'percent':
return `${sign}${intStr}%`;
case 'currency':
return `${sign}¥${intStr}`;
default:
return `${sign}${intStr}`;
}
}
const fracNum = abs - intVal;
let fracStr = fracNum.toFixed(places).slice(1);
if (!fracStr || fracStr === '.' || fracStr === '.' + '0'.repeat(places)) {
fracStr = places > 0 ? '.' + '0'.repeat(places) : '';
}
return fracStr;
}
/** 读取变量原始值(仅行数据;设计模式可选用默认值) */
export function getVariableRawValue(
varName: string,
@@ -693,10 +739,11 @@ export function resolveElementValue(
const formatSubst =
elem.type === 'text' && elem.textFormat && elem.textFormat !== 'text'
? (raw: string) =>
formatDisplayValue(
applyNumberPartDisplay(
raw,
elem.textFormat as Exclude<TextFormat, 'text'>,
elem.decimalPlaces
elem.decimalPlaces,
elem.numberPartDisplay ?? 'full'
)
: undefined;