适配
This commit is contained in:
18
src/App.tsx
18
src/App.tsx
@@ -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>
|
||||
|
||||
@@ -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,32 +949,44 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ps-status-bar">
|
||||
<div className="flex items-center gap-3">
|
||||
<span>
|
||||
文档: {template.width} × {template.height} mm
|
||||
</span>
|
||||
{selectedElemObj && (
|
||||
<span className="text-[#31a8ff]">
|
||||
| {selectedElemObj.name} — X:{selectedElemObj.x} Y:{selectedElemObj.y} W:
|
||||
{selectedElemObj.width} H:{selectedElemObj.height} mm
|
||||
</span>
|
||||
)}
|
||||
{selectedElementIds.length > 1 && (
|
||||
<span className="text-emerald-400">
|
||||
| 已选 {selectedElementIds.length} 个
|
||||
</span>
|
||||
)}
|
||||
{activeTool === 'move' && (
|
||||
<span className="text-[#888] hidden sm:inline">
|
||||
| 框选 · Shift/Ctrl+点选 · Ctrl+A 全选
|
||||
</span>
|
||||
<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>
|
||||
{selectedElemObj && (
|
||||
<span className="text-[#31a8ff]">
|
||||
| {selectedElemObj.name} — X:{selectedElemObj.x} Y:{selectedElemObj.y} W:
|
||||
{selectedElemObj.width} H:{selectedElemObj.height} mm
|
||||
</span>
|
||||
)}
|
||||
{selectedElementIds.length > 1 && (
|
||||
<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">
|
||||
<span className="hidden sm:inline text-[10px]">
|
||||
预览使用变量默认值 · 方向键微调 · Ctrl+滚轮缩放
|
||||
</span>
|
||||
<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"
|
||||
|
||||
@@ -867,22 +867,47 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
|
||||
{(selectedElem.textFormat === 'number' ||
|
||||
selectedElem.textFormat === 'percent' ||
|
||||
selectedElem.textFormat === 'currency') && (
|
||||
<div>
|
||||
<label className="ps-field-label">小数位数</label>
|
||||
<PropInput
|
||||
type="number"
|
||||
min="0"
|
||||
max="6"
|
||||
value={selectedElem.decimalPlaces ?? 2}
|
||||
onCommit={(val) =>
|
||||
handleElemChange({
|
||||
...selectedElem,
|
||||
decimalPlaces: Math.max(0, Math.min(6, Number(val) || 0)),
|
||||
})
|
||||
}
|
||||
className="ps-field font-mono"
|
||||
/>
|
||||
</div>
|
||||
<>
|
||||
<div>
|
||||
<label className="ps-field-label">小数位数</label>
|
||||
<PropInput
|
||||
type="number"
|
||||
min="0"
|
||||
max="6"
|
||||
value={selectedElem.decimalPlaces ?? 2}
|
||||
onCommit={(val) =>
|
||||
handleElemChange({
|
||||
...selectedElem,
|
||||
decimalPlaces: Math.max(0, Math.min(6, Number(val) || 0)),
|
||||
})
|
||||
}
|
||||
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>
|
||||
|
||||
|
||||
@@ -21,3 +21,8 @@ export function useMediaQuery(query: string): boolean {
|
||||
export function useIsMobile(): boolean {
|
||||
return useMediaQuery('(max-width: 767px)');
|
||||
}
|
||||
|
||||
/** 平板等窄屏:< 1024px */
|
||||
export function useIsNarrowScreen(): boolean {
|
||||
return useMediaQuery('(max-width: 1023px)');
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
51
src/utils.ts
51
src/utils.ts
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user