2409 lines
91 KiB
TypeScript
2409 lines
91 KiB
TypeScript
import React, { useState, useEffect, useMemo, useRef } from 'react';
|
||
import { LabelTemplate, TemplateElement, PaperConfig, VisibilityRule } from '../types';
|
||
import {
|
||
extractTemplateVariables,
|
||
extractVariablesFromContent,
|
||
isConditionalVisibility,
|
||
isTemplateVariableInUse,
|
||
removeUnusedTemplateVariable,
|
||
resolveVisibilityRules,
|
||
toggleVisibilityRuleEnabled,
|
||
withContentAndSyncedName,
|
||
} from '../utils';
|
||
import { VisibilityRuleDialog } from './VisibilityRuleDialog';
|
||
import { VisibilityRuleSummary } from './VisibilityRuleSummary';
|
||
import { ValueExtractFields } from './ValueExtractFields';
|
||
import { TextDisplayAffixFields } from './TextDisplayAffixFields';
|
||
import {
|
||
alignToSelectionBounds,
|
||
alignSelectionToCanvas,
|
||
tileSelectedEvenly,
|
||
tileSelectedFlush,
|
||
type AlignMode,
|
||
} from '../elementAlign';
|
||
import { CollapsiblePanelSection } from './CollapsiblePanelSection';
|
||
import { FieldSelect } from './PsSelect';
|
||
import { CommitInput } from './CommitInput';
|
||
import { useAppDialog } from './AppDialog';
|
||
import { useIsNarrowScreen } from '../hooks/useIsMobile';
|
||
import type { ElementClipboardActions } from '../hooks/useElementClipboard';
|
||
import {
|
||
TablePropertyFields,
|
||
TableCellContentFields,
|
||
scaleTableElementSize,
|
||
} from './TablePropertyFields';
|
||
import type { TableCellCoord } from '../tableUtils';
|
||
import { expandTableForPadding } from '../tableUtils';
|
||
import {
|
||
Sliders,
|
||
LayoutGrid,
|
||
Type,
|
||
AlignLeft,
|
||
AlignCenter,
|
||
AlignRight,
|
||
Bold,
|
||
Italic,
|
||
Underline,
|
||
Strikethrough,
|
||
HelpCircle,
|
||
Lock,
|
||
Unlock,
|
||
ArrowUp,
|
||
ArrowDown,
|
||
ArrowLeft,
|
||
ArrowRight,
|
||
Move,
|
||
Layers,
|
||
Trash2,
|
||
Copy,
|
||
AlignHorizontalJustifyStart,
|
||
AlignHorizontalJustifyCenter,
|
||
AlignHorizontalJustifyEnd,
|
||
AlignVerticalJustifyStart,
|
||
AlignVerticalJustifyCenter,
|
||
AlignVerticalJustifyEnd,
|
||
AlignHorizontalSpaceBetween,
|
||
AlignVerticalSpaceBetween,
|
||
Columns2,
|
||
Rows2,
|
||
Link2,
|
||
Plus,
|
||
Ruler,
|
||
RotateCw,
|
||
Barcode,
|
||
QrCode,
|
||
Image as ImageIcon,
|
||
Upload,
|
||
Table2,
|
||
} from 'lucide-react';
|
||
|
||
const PANEL_COLLAPSE_STORAGE_KEY = 'label_designer_panel_collapse_v2';
|
||
|
||
type PanelCollapseState = {
|
||
content: boolean;
|
||
properties: boolean;
|
||
layers: boolean;
|
||
};
|
||
|
||
function loadPanelCollapseState(): PanelCollapseState {
|
||
try {
|
||
const raw = localStorage.getItem(PANEL_COLLAPSE_STORAGE_KEY);
|
||
if (raw) return JSON.parse(raw) as PanelCollapseState;
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
return { content: true, properties: false, layers: true };
|
||
}
|
||
|
||
const alignBtnClass =
|
||
'px-2 py-1.5 bg-[#383838] hover:bg-[#444] border border-[#4a4a4a] hover:border-[#31a8ff]/50 text-[10px] text-[#ccc] flex flex-col items-center justify-center gap-0.5 transition cursor-pointer rounded-sm min-h-[52px]';
|
||
|
||
interface AlignToolButtonProps {
|
||
icon: React.ReactNode;
|
||
label: string;
|
||
title: string;
|
||
onClick: () => void;
|
||
}
|
||
|
||
const AlignToolButton: React.FC<AlignToolButtonProps> = ({ icon, label, title, onClick }) => (
|
||
<button type="button" className={alignBtnClass} onClick={onClick} title={title}>
|
||
{icon}
|
||
<span>{label}</span>
|
||
</button>
|
||
);
|
||
|
||
const PropInput = CommitInput;
|
||
|
||
interface PropTextareaProps extends Omit<React.TextareaHTMLAttributes<HTMLTextAreaElement>, 'onChange'> {
|
||
value: string;
|
||
onCommit: (val: string) => void;
|
||
}
|
||
|
||
const PropTextarea: React.FC<PropTextareaProps> = ({ value, onCommit, ...props }) => {
|
||
const [localVal, setLocalVal] = useState<string>(value);
|
||
const focusedRef = useRef(false);
|
||
const localValRef = useRef(localVal);
|
||
const valueRef = useRef(value);
|
||
const onCommitRef = useRef(onCommit);
|
||
localValRef.current = localVal;
|
||
valueRef.current = value;
|
||
onCommitRef.current = onCommit;
|
||
|
||
useEffect(() => {
|
||
if (!focusedRef.current) {
|
||
setLocalVal(value);
|
||
}
|
||
}, [value]);
|
||
|
||
useEffect(() => {
|
||
return () => {
|
||
if (
|
||
focusedRef.current &&
|
||
localValRef.current !== valueRef.current
|
||
) {
|
||
onCommitRef.current(localValRef.current);
|
||
}
|
||
};
|
||
}, []);
|
||
|
||
const handleBlur = () => {
|
||
focusedRef.current = false;
|
||
if (localValRef.current !== valueRef.current) {
|
||
onCommitRef.current(localValRef.current);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<textarea
|
||
{...props}
|
||
value={localVal}
|
||
onChange={(e) => setLocalVal(e.target.value)}
|
||
onFocus={() => {
|
||
focusedRef.current = true;
|
||
}}
|
||
onBlur={handleBlur}
|
||
/>
|
||
);
|
||
};
|
||
|
||
export type MobilePropModule = 'content' | 'properties' | 'layers' | 'nudge';
|
||
|
||
const MOBILE_NUDGE_STEP_MM = 0.1;
|
||
|
||
interface PropSidebarProps {
|
||
template: LabelTemplate;
|
||
onChangeTemplate: (updated: LabelTemplate) => void;
|
||
selectedElementIds: string[];
|
||
activeTab: 'element' | 'paper';
|
||
onChangeTab: (tab: 'element' | 'paper') => void;
|
||
paper: PaperConfig;
|
||
onSelectElements?: (ids: string[]) => void;
|
||
selectedTableCells?: TableCellCoord[];
|
||
onSelectTableCells?: (cells: TableCellCoord[]) => void;
|
||
variant?: 'desktop' | 'mobile';
|
||
mobileModule?: MobilePropModule;
|
||
onMobileModuleChange?: (module: MobilePropModule) => void;
|
||
elementClipboard?: ElementClipboardActions;
|
||
}
|
||
|
||
const MOBILE_PROP_MODULES: { id: MobilePropModule; label: string; icon: React.ReactNode }[] = [
|
||
{ id: 'properties', label: '属性', icon: <Sliders className="w-4 h-4" /> },
|
||
{ id: 'content', label: '内容', icon: <Link2 className="w-4 h-4" /> },
|
||
{ id: 'layers', label: '图层', icon: <Layers className="w-4 h-4" /> },
|
||
{ id: 'nudge', label: '微调', icon: <Move className="w-4 h-4" /> },
|
||
];
|
||
|
||
export const PropSidebar: React.FC<PropSidebarProps> = ({
|
||
template,
|
||
onChangeTemplate,
|
||
selectedElementIds,
|
||
activeTab,
|
||
onChangeTab,
|
||
paper,
|
||
onSelectElements,
|
||
selectedTableCells = [],
|
||
onSelectTableCells,
|
||
variant = 'desktop',
|
||
mobileModule: mobileModuleProp,
|
||
onMobileModuleChange,
|
||
elementClipboard,
|
||
}) => {
|
||
const { showConfirm } = useAppDialog();
|
||
const templateRef = useRef(template);
|
||
templateRef.current = template;
|
||
const isNarrowScreen = useIsNarrowScreen();
|
||
const selectedElementId = selectedElementIds[0] || null;
|
||
const selectedElem = template.elements.find((el) => el.id === selectedElementId);
|
||
const [newVarName, setNewVarName] = useState('');
|
||
const [visibilityRuleDialog, setVisibilityRuleDialog] = useState<
|
||
{ mode: 'add' } | { mode: 'edit'; index: number } | null
|
||
>(null);
|
||
const [panelCollapsed, setPanelCollapsed] = useState<PanelCollapseState>(loadPanelCollapseState);
|
||
const [internalMobileModule, setInternalMobileModule] =
|
||
useState<MobilePropModule>('properties');
|
||
const mobileModule = mobileModuleProp ?? internalMobileModule;
|
||
const setMobileModule = (module: MobilePropModule) => {
|
||
onMobileModuleChange?.(module);
|
||
if (mobileModuleProp === undefined) {
|
||
setInternalMobileModule(module);
|
||
}
|
||
};
|
||
|
||
const togglePanel = (key: keyof PanelCollapseState) => {
|
||
setPanelCollapsed((prev) => {
|
||
const isCollapsed = prev[key];
|
||
const next: PanelCollapseState = isCollapsed
|
||
? { content: true, properties: true, layers: true, [key]: false }
|
||
: { ...prev, [key]: true };
|
||
try {
|
||
localStorage.setItem(PANEL_COLLAPSE_STORAGE_KEY, JSON.stringify(next));
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
return next;
|
||
});
|
||
};
|
||
|
||
const templateVariables = useMemo(() => extractTemplateVariables(template), [template]);
|
||
|
||
const editingVisibilityRule = useMemo(() => {
|
||
if (!selectedElem || visibilityRuleDialog?.mode !== 'edit') return undefined;
|
||
return resolveVisibilityRules(selectedElem)[visibilityRuleDialog.index];
|
||
}, [selectedElem, visibilityRuleDialog]);
|
||
|
||
// Compute maximum allowed label dimensions based on paper configurations
|
||
const paperW = paper.orientation === 'portrait' ? paper.width : paper.height;
|
||
const paperH = paper.orientation === 'portrait' ? paper.height : paper.width;
|
||
|
||
const maxLabelW = Math.max(10, paperW - paper.marginLeft - paper.marginRight);
|
||
const maxLabelH = Math.max(10, paperH - paper.marginTop - paper.marginBottom);
|
||
|
||
const handleElemChange = (updatedElem: TemplateElement) => {
|
||
const current = templateRef.current;
|
||
onChangeTemplate({
|
||
...current,
|
||
elements: current.elements.map((el) => (el.id === updatedElem.id ? updatedElem : el)),
|
||
});
|
||
};
|
||
|
||
const commitTableAwareChange = (updatedElem: TemplateElement) => {
|
||
if (updatedElem.type === 'table') {
|
||
handleElemChange(expandTableForPadding(updatedElem));
|
||
} else {
|
||
handleElemChange(updatedElem);
|
||
}
|
||
};
|
||
|
||
const handleContentChange = (content: string) => {
|
||
if (!selectedElem || selectedElem.type === 'table') return;
|
||
handleElemChange(withContentAndSyncedName(selectedElem, content));
|
||
};
|
||
|
||
const nudgeableElementIds = useMemo(() => {
|
||
const idSet = new Set(selectedElementIds);
|
||
return template.elements
|
||
.filter((el) => idSet.has(el.id) && !el.locked)
|
||
.map((el) => el.id);
|
||
}, [selectedElementIds, template.elements]);
|
||
|
||
const nudgeSelectedElements = (deltaX: number, deltaY: number) => {
|
||
if (nudgeableElementIds.length === 0) return;
|
||
const idSet = new Set(nudgeableElementIds);
|
||
onChangeTemplate({
|
||
...template,
|
||
elements: template.elements.map((el) => {
|
||
if (!idSet.has(el.id)) return el;
|
||
return {
|
||
...el,
|
||
x: Math.round((el.x + deltaX) * 10) / 10,
|
||
y: Math.round((el.y + deltaY) * 10) / 10,
|
||
};
|
||
}),
|
||
});
|
||
};
|
||
|
||
const applyVisibilityRules = (next: VisibilityRule[]) => {
|
||
if (!selectedElem) return;
|
||
handleElemChange({
|
||
...selectedElem,
|
||
visibilityMode: 'conditional',
|
||
visibilityRules: next,
|
||
hideWhenNoData: undefined,
|
||
visibilityVariable: undefined,
|
||
visibilityVariables: undefined,
|
||
});
|
||
};
|
||
|
||
const handleConfirmVisibilityRule = (rule: VisibilityRule) => {
|
||
if (!selectedElem || !visibilityRuleDialog) return;
|
||
const rules = resolveVisibilityRules(selectedElem);
|
||
if (visibilityRuleDialog.mode === 'edit') {
|
||
const { index } = visibilityRuleDialog;
|
||
if (rules.some((r, i) => i !== index && r.variable === rule.variable)) return;
|
||
applyVisibilityRules(rules.map((r, i) => (i === index ? rule : r)));
|
||
} else {
|
||
if (rules.some((r) => r.variable === rule.variable)) return;
|
||
applyVisibilityRules([...rules, rule]);
|
||
}
|
||
};
|
||
|
||
const applyMultiAlign = (mode: AlignMode) => {
|
||
onChangeTemplate({
|
||
...template,
|
||
elements: alignToSelectionBounds(template.elements, selectedElementIds, mode),
|
||
});
|
||
};
|
||
|
||
const applyTile = (axis: 'horizontal' | 'vertical', spacing: 'even' | 'none' = 'even') => {
|
||
const tile =
|
||
spacing === 'even' ? tileSelectedEvenly : tileSelectedFlush;
|
||
onChangeTemplate({
|
||
...template,
|
||
elements: tile(template.elements, selectedElementIds, axis),
|
||
});
|
||
};
|
||
|
||
const applyCanvasAlign = (mode: AlignMode) => {
|
||
onChangeTemplate({
|
||
...template,
|
||
elements: alignSelectionToCanvas(
|
||
template.elements,
|
||
selectedElementIds,
|
||
template.width,
|
||
template.height,
|
||
mode
|
||
),
|
||
});
|
||
};
|
||
|
||
const renderAlignButtonGrid = (
|
||
onAlign: (mode: AlignMode) => void,
|
||
scope: 'selection' | 'canvas' = 'selection'
|
||
) => {
|
||
const toCanvas = scope === 'canvas';
|
||
return (
|
||
<div className="grid grid-cols-3 gap-1.5">
|
||
<AlignToolButton
|
||
icon={<AlignHorizontalJustifyStart className="w-3.5 h-3.5" />}
|
||
label="左对齐"
|
||
title={toCanvas ? '整体贴齐画布左边缘' : '选区内左边缘对齐'}
|
||
onClick={() => onAlign('left')}
|
||
/>
|
||
<AlignToolButton
|
||
icon={<AlignHorizontalJustifyCenter className="w-3.5 h-3.5" />}
|
||
label="水平居中"
|
||
title={toCanvas ? '整体在画布水平居中' : '选区内水平居中对齐'}
|
||
onClick={() => onAlign('centerH')}
|
||
/>
|
||
<AlignToolButton
|
||
icon={<AlignHorizontalJustifyEnd className="w-3.5 h-3.5" />}
|
||
label="右对齐"
|
||
title={toCanvas ? '整体贴齐画布右边缘' : '选区内右边缘对齐'}
|
||
onClick={() => onAlign('right')}
|
||
/>
|
||
<AlignToolButton
|
||
icon={<AlignVerticalJustifyStart className="w-3.5 h-3.5" />}
|
||
label="顶对齐"
|
||
title={toCanvas ? '整体贴齐画布顶边' : '选区内顶边对齐'}
|
||
onClick={() => onAlign('top')}
|
||
/>
|
||
<AlignToolButton
|
||
icon={<AlignVerticalJustifyCenter className="w-3.5 h-3.5" />}
|
||
label="垂直居中"
|
||
title={toCanvas ? '整体在画布垂直居中' : '选区内垂直居中对齐'}
|
||
onClick={() => onAlign('centerV')}
|
||
/>
|
||
<AlignToolButton
|
||
icon={<AlignVerticalJustifyEnd className="w-3.5 h-3.5" />}
|
||
label="底对齐"
|
||
title={toCanvas ? '整体贴齐画布底边' : '选区内底边对齐'}
|
||
onClick={() => onAlign('bottom')}
|
||
/>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const insertVariable = (varName: string) => {
|
||
if (!selectedElem) return;
|
||
const token = `{${varName}}`;
|
||
const content = selectedElem.content.includes(token)
|
||
? selectedElem.content
|
||
: selectedElem.content.trim()
|
||
? `${selectedElem.content}${selectedElem.content.endsWith(' ') ? '' : ' '}${token}`
|
||
: token;
|
||
handleContentChange(content);
|
||
};
|
||
|
||
const handleAddTemplateVariable = () => {
|
||
const name = newVarName.trim();
|
||
if (!name || name === '#INDEX') return;
|
||
const list = new Set<string>(template.variableList ?? extractTemplateVariables(template));
|
||
list.add(name);
|
||
onChangeTemplate({
|
||
...template,
|
||
variableList: Array.from(list).sort((a, b) => a.localeCompare(b, 'zh-CN')),
|
||
});
|
||
setNewVarName('');
|
||
};
|
||
|
||
const handleRemoveTemplateVariable = (varName: string) => {
|
||
if (isTemplateVariableInUse(template, varName)) return;
|
||
onChangeTemplate(removeUnusedTemplateVariable(template, varName));
|
||
};
|
||
|
||
const setVariableDefault = (varName: string, value: string) => {
|
||
const defaults = { ...(template.variableDefaults || {}) };
|
||
if (value.trim()) {
|
||
defaults[varName] = value;
|
||
} else {
|
||
delete defaults[varName];
|
||
}
|
||
onChangeTemplate({
|
||
...template,
|
||
variableDefaults: Object.keys(defaults).length > 0 ? defaults : undefined,
|
||
});
|
||
};
|
||
|
||
const renderVariableManagementBlock = () => (
|
||
<div className="space-y-2 pt-2 border-t border-[#3a3a3a]">
|
||
<div className="text-[10px] font-bold text-[#31a8ff]">变量管理</div>
|
||
<p className="text-[10px] text-[#666] leading-relaxed">
|
||
在此添加模板变量并设置设计预览默认值;不会自动写入元素内容,请使用「插入变量到内容」。未被内容或显示条件引用的变量可删除。
|
||
</p>
|
||
<div className="flex gap-1">
|
||
<input
|
||
type="text"
|
||
value={newVarName}
|
||
onChange={(e) => setNewVarName(e.target.value)}
|
||
onKeyDown={(e) => e.key === 'Enter' && handleAddTemplateVariable()}
|
||
placeholder="新变量名"
|
||
className="ps-field text-[10px] flex-1 min-w-0"
|
||
/>
|
||
<button
|
||
type="button"
|
||
onClick={handleAddTemplateVariable}
|
||
className="ps-btn shrink-0 text-[10px]"
|
||
>
|
||
<Plus className="w-3 h-3" />
|
||
添加
|
||
</button>
|
||
</div>
|
||
{templateVariables.length === 0 ? (
|
||
<p className="text-[10px] text-[#888]">暂无变量</p>
|
||
) : (
|
||
|
||
<div className="space-y-3">
|
||
<div className="text-[10px] font-bold text-[#31a8ff]">变量列表</div>
|
||
<p className="text-[10px] text-[#666] leading-relaxed mb-3">
|
||
可编辑变量预览值,未被内容或显示条件引用的变量可删除。
|
||
</p>
|
||
{templateVariables.map((v) => {
|
||
const inUse = isTemplateVariableInUse(template, v);
|
||
return (
|
||
<div
|
||
key={v}
|
||
className="grid grid-cols-[72px_minmax(0,1fr)_44px] gap-1 items-center min-w-0"
|
||
>
|
||
<span
|
||
className="text-[9px] font-mono text-[#31a8ff] truncate"
|
||
title={v}
|
||
>
|
||
{`{${v}}`}
|
||
</span>
|
||
<input
|
||
type="text"
|
||
value={template.variableDefaults?.[v] ?? ''}
|
||
onChange={(e) => setVariableDefault(v, e.target.value)}
|
||
placeholder="预览值"
|
||
className="ps-field font-mono text-[10px] w-full min-w-0"
|
||
/>
|
||
<div className="flex items-center justify-center w-[44px] shrink-0">
|
||
{inUse ? (
|
||
<span
|
||
className="p-1 text-[#666] cursor-default"
|
||
title="已被内容或显示条件引用,不可删除"
|
||
>
|
||
<Lock className="w-3.5 h-3.5" />
|
||
</span>
|
||
) : (
|
||
<button
|
||
type="button"
|
||
onClick={() => handleRemoveTemplateVariable(v)}
|
||
title="删除未使用的变量"
|
||
className="p-1 rounded text-[#666] hover:text-red-400 hover:bg-[#3a2020] transition-colors"
|
||
>
|
||
<Trash2 className="w-3.5 h-3.5" />
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
|
||
// Layer ordering movement
|
||
const moveLayer = (idx: number, direction: 'up' | 'down' | 'top' | 'bottom') => {
|
||
const list = [...template.elements];
|
||
const item = list[idx];
|
||
if (!item) return;
|
||
|
||
if (direction === 'up') {
|
||
if (idx >= list.length - 1) return;
|
||
list.splice(idx, 1);
|
||
list.splice(idx + 1, 0, item);
|
||
} else if (direction === 'down') {
|
||
if (idx <= 0) return;
|
||
list.splice(idx, 1);
|
||
list.splice(idx - 1, 0, item);
|
||
} else if (direction === 'top') {
|
||
if (idx >= list.length - 1) return;
|
||
list.splice(idx, 1);
|
||
list.push(item);
|
||
} else if (direction === 'bottom') {
|
||
if (idx <= 0) return;
|
||
list.splice(idx, 1);
|
||
list.unshift(item);
|
||
}
|
||
|
||
onChangeTemplate({ ...template, elements: list });
|
||
};
|
||
|
||
const toggleLockLayer = (idx: number) => {
|
||
const target = template.elements[idx];
|
||
if (!target) return;
|
||
const willLock = !target.locked;
|
||
const list = template.elements.map((el, i) => {
|
||
if (i === idx) {
|
||
return { ...el, locked: !el.locked };
|
||
}
|
||
return el;
|
||
});
|
||
onChangeTemplate({ ...template, elements: list });
|
||
if (willLock && onSelectElements) {
|
||
onSelectElements(selectedElementIds.filter((id) => id !== target.id));
|
||
if (target.type === 'table') {
|
||
onSelectTableCells?.([]);
|
||
}
|
||
}
|
||
};
|
||
|
||
const deleteLayer = async (id: string) => {
|
||
const el = template.elements.find((x) => x.id === id);
|
||
if (el?.locked) return;
|
||
|
||
const confirmed = await showConfirm(
|
||
`确定删除图层「${el?.name ?? '未命名'}」吗?此操作无法撤销。`,
|
||
{
|
||
title: '删除图层',
|
||
confirmLabel: '删除',
|
||
variant: 'error',
|
||
}
|
||
);
|
||
if (!confirmed) return;
|
||
|
||
onChangeTemplate({
|
||
...templateRef.current,
|
||
elements: templateRef.current.elements.filter((item) => item.id !== id),
|
||
});
|
||
if (onSelectElements && selectedElementIds.includes(id)) {
|
||
onSelectElements(selectedElementIds.filter((selectedId) => selectedId !== id));
|
||
}
|
||
};
|
||
|
||
const deletableSelectedIds = useMemo(
|
||
() =>
|
||
selectedElementIds.filter((id) => {
|
||
const el = template.elements.find((item) => item.id === id);
|
||
return el && !el.locked;
|
||
}),
|
||
[selectedElementIds, template.elements]
|
||
);
|
||
|
||
const deleteSelectedElements = async () => {
|
||
if (deletableSelectedIds.length === 0) return;
|
||
|
||
const message =
|
||
deletableSelectedIds.length === 1
|
||
? `确定删除「${template.elements.find((item) => item.id === deletableSelectedIds[0])?.name ?? '未命名'}」吗?此操作无法撤销。`
|
||
: `确定删除选中的 ${deletableSelectedIds.length} 个元素吗?此操作无法撤销。`;
|
||
|
||
const confirmed = await showConfirm(message, {
|
||
title: '删除元素',
|
||
confirmLabel: '删除',
|
||
variant: 'error',
|
||
});
|
||
if (!confirmed) return;
|
||
|
||
const current = templateRef.current;
|
||
const idSet = new Set(deletableSelectedIds);
|
||
const remaining = current.elements.filter((el) => !idSet.has(el.id));
|
||
onChangeTemplate({ ...current, elements: remaining });
|
||
onSelectElements?.(selectedElementIds.filter((id) => remaining.some((el) => el.id === id)));
|
||
};
|
||
|
||
const renderContentPanelBody = () => {
|
||
if (!selectedElem) {
|
||
return (
|
||
<div className="space-y-3">
|
||
<p className="text-[10px] text-[#777] leading-normal flex items-start gap-1">
|
||
<HelpCircle className="w-3.5 h-3.5 text-[#31a8ff] shrink-0 mt-0.5" />
|
||
<span>选中元素后可编辑内容与显示控制</span>
|
||
</p>
|
||
{renderVariableManagementBlock()}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-2 min-w-0 max-w-full overflow-hidden">
|
||
{selectedElem.type === 'image' && (
|
||
<div>
|
||
<label className="ps-field-label">本地图片</label>
|
||
<label className="ps-btn text-[10px] cursor-pointer">
|
||
<Upload className="w-3.5 h-3.5" />
|
||
选择文件
|
||
<input
|
||
type="file"
|
||
accept="image/*"
|
||
className="hidden"
|
||
onChange={(e) => {
|
||
const file = e.target.files?.[0];
|
||
if (!file) return;
|
||
const reader = new FileReader();
|
||
reader.onload = () => handleContentChange(reader.result as string);
|
||
reader.readAsDataURL(file);
|
||
e.target.value = '';
|
||
}}
|
||
/>
|
||
</label>
|
||
</div>
|
||
)}
|
||
{selectedElem.type !== 'table' && (
|
||
<>
|
||
<div className="flex items-center justify-between gap-2">
|
||
<label className="ps-field-label">内容</label>
|
||
<button
|
||
type="button"
|
||
disabled={!selectedElem.content}
|
||
onClick={() => handleContentChange('')}
|
||
className="text-[9px] text-[#31a8ff] hover:underline cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed disabled:no-underline shrink-0"
|
||
>
|
||
清空内容
|
||
</button>
|
||
</div>
|
||
<PropTextarea
|
||
value={selectedElem.content}
|
||
onCommit={(val) => handleContentChange(val)}
|
||
rows={selectedElem.type === 'image' ? 3 : 2}
|
||
className="ps-field resize-none"
|
||
placeholder={
|
||
selectedElem.type === 'image'
|
||
? '图片 URL 或 data URI,也可使用 {变量名}'
|
||
: '输入文本,使用 {变量名} 引用变量'
|
||
}
|
||
/>
|
||
{templateVariables.length > 0 && (
|
||
<div className="space-y-1">
|
||
<div className="text-[9px] text-[#777] font-medium">插入变量到内容</div>
|
||
<div className="flex flex-wrap gap-1">
|
||
{templateVariables.map((v) => (
|
||
<button
|
||
key={v}
|
||
type="button"
|
||
onClick={() => insertVariable(v)}
|
||
className="ps-btn text-[9px] font-mono"
|
||
>
|
||
{`{${v}}`}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
{(selectedElem.type === 'barcode' ||
|
||
selectedElem.type === 'qrcode' ||
|
||
selectedElem.type === 'image') && (
|
||
<label className="flex items-start gap-2 pt-1">
|
||
<input
|
||
type="checkbox"
|
||
checked={selectedElem.showWhenContentEmpty ?? false}
|
||
onChange={(e) =>
|
||
handleElemChange({
|
||
...selectedElem,
|
||
showWhenContentEmpty: e.target.checked ? true : undefined,
|
||
})
|
||
}
|
||
className="ps-checkbox mt-0.5"
|
||
/>
|
||
<span className="text-xs text-[#ccc] leading-normal">
|
||
输出模式下内容为空仍显示
|
||
{selectedElem.type === 'image' && (
|
||
<span className="block text-[9px] text-[#777] mt-0.5">
|
||
开启后绑定数据为空时可显示默认图片
|
||
</span>
|
||
)}
|
||
</span>
|
||
</label>
|
||
)}
|
||
{selectedElem.type === 'table' && (
|
||
<p className="text-[10px] text-[#777] leading-normal">
|
||
表格各单元格内容独立设置,默认为空。点击画布上的单元格后在属性面板编辑。
|
||
</p>
|
||
)}
|
||
{(selectedElem.type === 'text' ||
|
||
selectedElem.type === 'barcode' ||
|
||
selectedElem.type === 'qrcode' ||
|
||
selectedElem.type === 'image' ||
|
||
selectedElem.type === 'table') && (
|
||
<div className="pt-2 border-t border-[#3a3a3a]">
|
||
{selectedElem.type === 'table' ? (
|
||
<TableCellContentFields
|
||
elem={selectedElem}
|
||
selectedCells={selectedTableCells}
|
||
onChange={handleElemChange}
|
||
templateVariables={templateVariables}
|
||
/>
|
||
) : (
|
||
<>
|
||
<ValueExtractFields elem={selectedElem} onChange={handleElemChange} />
|
||
{selectedElem.type === 'text' && (
|
||
<TextDisplayAffixFields elem={selectedElem} onChange={handleElemChange} />
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
)}
|
||
{renderVariableManagementBlock()}
|
||
<div className="space-y-2 pt-2 border-t border-[#3a3a3a] min-w-0 max-w-full overflow-hidden">
|
||
<div className="text-[10px] font-bold text-[#31a8ff]">显示控制</div>
|
||
<div>
|
||
<label className="ps-field-label">显示模式</label>
|
||
<FieldSelect
|
||
value={isConditionalVisibility(selectedElem) ? 'conditional' : 'always'}
|
||
onChange={(e) => {
|
||
const conditional = e.target.value === 'conditional';
|
||
if (!conditional) {
|
||
handleElemChange({
|
||
...selectedElem,
|
||
visibilityMode: 'always',
|
||
hideWhenNoData: undefined,
|
||
visibilityVariable: undefined,
|
||
visibilityVariables: undefined,
|
||
});
|
||
return;
|
||
}
|
||
const migrated = resolveVisibilityRules(selectedElem);
|
||
handleElemChange({
|
||
...selectedElem,
|
||
visibilityMode: 'conditional',
|
||
visibilityRules: migrated.length > 0 ? migrated : [],
|
||
hideWhenNoData: undefined,
|
||
visibilityVariable: undefined,
|
||
visibilityVariables: undefined,
|
||
});
|
||
}}
|
||
className="ps-field text-[11px]"
|
||
>
|
||
<option value="always">始终显示</option>
|
||
<option value="conditional">按条件显示</option>
|
||
</FieldSelect>
|
||
</div>
|
||
{isConditionalVisibility(selectedElem) && (() => {
|
||
const rules = resolveVisibilityRules(selectedElem);
|
||
|
||
const setRules = (next: VisibilityRule[]) => {
|
||
handleElemChange({
|
||
...selectedElem,
|
||
visibilityMode: 'conditional',
|
||
visibilityRules: next,
|
||
hideWhenNoData: undefined,
|
||
visibilityVariable: undefined,
|
||
visibilityVariables: undefined,
|
||
});
|
||
};
|
||
|
||
const removeRule = (index: number) => {
|
||
setRules(rules.filter((_, i) => i !== index));
|
||
};
|
||
|
||
return (
|
||
<div className="space-y-2 min-w-0 max-w-full">
|
||
<p className="text-[10px] text-[#666] leading-relaxed">
|
||
全部条件满足时才显示。设计预览按变量默认值判断,导出按数据行(或映射默认值)判断。
|
||
</p>
|
||
{rules.length > 0 ? (
|
||
<div className="space-y-1.5">
|
||
{rules.map((rule, index) => (
|
||
<VisibilityRuleSummary
|
||
key={`${rule.target ?? 'variable'}-${rule.variable || 'content'}-${index}`}
|
||
rule={rule}
|
||
onEdit={() => setVisibilityRuleDialog({ mode: 'edit', index })}
|
||
onDelete={() => removeRule(index)}
|
||
onToggleEnabled={() =>
|
||
setRules(
|
||
rules.map((item, i) =>
|
||
i === index ? toggleVisibilityRuleEnabled(item) : item
|
||
)
|
||
)
|
||
}
|
||
/>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<p className="text-[10px] text-[#888]">尚未添加显示条件</p>
|
||
)}
|
||
<button
|
||
type="button"
|
||
onClick={() => setVisibilityRuleDialog({ mode: 'add' })}
|
||
className="ps-btn w-full text-[10px] justify-center"
|
||
>
|
||
<Plus className="w-3 h-3" />
|
||
添加条件
|
||
</button>
|
||
</div>
|
||
);
|
||
})()}
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const renderPreviewReferenceBackgroundFields = () => (
|
||
<div className="space-y-3">
|
||
<h5 className="ps-section-title">
|
||
<ImageIcon className="w-3.5 h-3.5" />
|
||
预览参考背景
|
||
</h5>
|
||
<p className="text-[9px] text-[#777] leading-relaxed">
|
||
上传底图作为设计参考;可分别控制设计预览与生成/打印输出是否显示。
|
||
</p>
|
||
<label className="ps-btn text-[10px] cursor-pointer inline-flex">
|
||
<Upload className="w-3.5 h-3.5" />
|
||
上传参考图
|
||
<input
|
||
type="file"
|
||
accept="image/*"
|
||
className="hidden"
|
||
onChange={(e) => {
|
||
const file = e.target.files?.[0];
|
||
if (!file) return;
|
||
const reader = new FileReader();
|
||
reader.onload = () =>
|
||
onChangeTemplate({
|
||
...template,
|
||
previewReferenceBackground: reader.result as string,
|
||
previewReferenceBackgroundOpacity:
|
||
template.previewReferenceBackgroundOpacity ?? 100,
|
||
previewReferenceBackgroundFit:
|
||
template.previewReferenceBackgroundFit ?? 'cover',
|
||
enablePreviewReferenceBackground: true,
|
||
});
|
||
reader.readAsDataURL(file);
|
||
e.target.value = '';
|
||
}}
|
||
/>
|
||
</label>
|
||
{template.previewReferenceBackground && (
|
||
<>
|
||
<div className="border border-[#3a3a3a] bg-[#1e1e1e] p-2">
|
||
<img
|
||
src={template.previewReferenceBackground}
|
||
alt="预览参考背景"
|
||
className="block max-h-24 mx-auto object-contain"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="ps-field-label">适应方式</label>
|
||
<FieldSelect
|
||
value={template.previewReferenceBackgroundFit ?? 'cover'}
|
||
onChange={(e) =>
|
||
onChangeTemplate({
|
||
...template,
|
||
previewReferenceBackgroundFit: e.target.value as 'contain' | 'cover' | 'fill',
|
||
})
|
||
}
|
||
className="ps-field text-[11px]"
|
||
>
|
||
<option value="cover">覆盖 (cover)</option>
|
||
<option value="contain">包含 (contain)</option>
|
||
<option value="fill">拉伸 (fill)</option>
|
||
</FieldSelect>
|
||
</div>
|
||
<div>
|
||
<label className="ps-field-label">
|
||
不透明度 ({template.previewReferenceBackgroundOpacity ?? 100}%)
|
||
</label>
|
||
<input
|
||
type="range"
|
||
min="0"
|
||
max="100"
|
||
step="1"
|
||
value={template.previewReferenceBackgroundOpacity ?? 100}
|
||
onChange={(e) =>
|
||
onChangeTemplate({
|
||
...template,
|
||
previewReferenceBackgroundOpacity: Number(e.target.value),
|
||
})
|
||
}
|
||
className="w-full accent-[#31a8ff]"
|
||
/>
|
||
</div>
|
||
<label className="flex items-start gap-2">
|
||
<input
|
||
type="checkbox"
|
||
checked={template.enablePreviewReferenceBackground !== false}
|
||
onChange={(e) =>
|
||
onChangeTemplate({
|
||
...template,
|
||
enablePreviewReferenceBackground: e.target.checked ? true : false,
|
||
})
|
||
}
|
||
className="ps-checkbox mt-0.5"
|
||
/>
|
||
<span className="text-xs text-[#ccc] leading-normal">
|
||
设计预览中显示参考背景
|
||
<span className="block text-[9px] text-[#777] mt-0.5">
|
||
控制设计画布与模板列表缩略图;关闭后仍保留参考图,可随时重新开启
|
||
</span>
|
||
</span>
|
||
</label>
|
||
<label className="flex items-start gap-2">
|
||
<input
|
||
type="checkbox"
|
||
checked={template.includeReferenceBackgroundInOutput ?? false}
|
||
onChange={(e) =>
|
||
onChangeTemplate({
|
||
...template,
|
||
includeReferenceBackgroundInOutput: e.target.checked ? true : undefined,
|
||
})
|
||
}
|
||
className="ps-checkbox mt-0.5"
|
||
/>
|
||
<span className="text-xs text-[#ccc] leading-normal">
|
||
生成与打印时包含参考背景
|
||
<span className="block text-[9px] text-[#777] mt-0.5">
|
||
开启后 PDF 导出、打印与排版预览输出均会绘制参考图
|
||
</span>
|
||
</span>
|
||
</label>
|
||
<button
|
||
type="button"
|
||
onClick={() =>
|
||
onChangeTemplate({
|
||
...template,
|
||
previewReferenceBackground: undefined,
|
||
previewReferenceBackgroundOpacity: undefined,
|
||
previewReferenceBackgroundFit: undefined,
|
||
enablePreviewReferenceBackground: undefined,
|
||
includeReferenceBackgroundInOutput: undefined,
|
||
})
|
||
}
|
||
className="text-[9px] text-[#31a8ff] hover:underline cursor-pointer"
|
||
>
|
||
清除参考背景
|
||
</button>
|
||
</>
|
||
)}
|
||
</div>
|
||
);
|
||
|
||
const renderPropertiesPanelBody = () => (
|
||
<>
|
||
{!selectedElem && (
|
||
<div className="pb-3 border-b border-[#3a3a3a]">{renderPreviewReferenceBackgroundFields()}</div>
|
||
)}
|
||
{/* Label Canvas Dimensions */}
|
||
{!selectedElem && (
|
||
<div className="space-y-4">
|
||
<div className="space-y-3">
|
||
<h4 className="text-[10px] font-bold uppercase tracking-widest mb-1">
|
||
画布尺寸
|
||
</h4>
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<div>
|
||
<label className="ps-field-label">宽度 (mm)</label>
|
||
<PropInput
|
||
type="number"
|
||
min="10"
|
||
max={maxLabelW}
|
||
value={template.width}
|
||
onCommit={(val) => {
|
||
const wVal = Math.min(maxLabelW, Math.max(10, Number(val) || 10));
|
||
// Also sanitize elements that might now be out of bounds
|
||
const sanitizedElements = template.elements.map(el => {
|
||
const newX = Math.min(wVal - el.width, el.x);
|
||
const newW = Math.min(wVal - Math.max(0, newX), el.width);
|
||
return { ...el, x: Math.max(0, newX), width: Math.max(2, newW) };
|
||
});
|
||
onChangeTemplate({ ...template, width: wVal, elements: sanitizedElements });
|
||
}}
|
||
className="ps-field font-mono"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="ps-field-label">高度 (mm)</label>
|
||
<PropInput
|
||
type="number"
|
||
min="10"
|
||
max={maxLabelH}
|
||
value={template.height}
|
||
onCommit={(val) => {
|
||
const hVal = Math.min(maxLabelH, Math.max(10, Number(val) || 10));
|
||
// Also sanitize elements that might now be out of bounds
|
||
const sanitizedElements = template.elements.map(el => {
|
||
const newY = Math.min(hVal - el.height, el.y);
|
||
const newH = Math.min(hVal - Math.max(0, newY), el.height);
|
||
return {
|
||
...el,
|
||
y: Math.max(0, newY),
|
||
height: el.type === 'qrcode' ? Math.max(2, newH) : Math.max(2, newH),
|
||
width: el.type === 'qrcode' ? Math.max(2, newH) : el.width
|
||
};
|
||
});
|
||
onChangeTemplate({ ...template, height: hVal, elements: sanitizedElements });
|
||
}}
|
||
className="ps-field font-mono"
|
||
/>
|
||
</div>
|
||
</div>
|
||
<p className="text-[10px] text-[#777] leading-normal flex items-center gap-1 bg-[#1e1e1e] p-2 border border-[#3a3a3a]">
|
||
<HelpCircle className="w-3.5 h-3.5 text-[#31a8ff] shrink-0" />
|
||
<span>选中元素以编辑属性</span>
|
||
</p>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Selected Element Adjust Properties */}
|
||
{selectedElem && (
|
||
<div className="space-y-4">
|
||
{selectedElementIds.length >= 2 && (
|
||
<div className="bg-[#1e1e1e] border border-[#3a3a3a] p-3 space-y-3">
|
||
<span className="text-[10px] bg-[#31a8ff] text-white font-bold px-1.5 py-0.5 inline-block">
|
||
多选 ({selectedElementIds.length})
|
||
</span>
|
||
<div className="space-y-1.5">
|
||
<span className="text-[10px] font-semibold text-[#aaa]">对齐选区</span>
|
||
<p className="text-[10px] text-[#666] leading-snug">
|
||
调整各元素在选区内的相对位置
|
||
</p>
|
||
{renderAlignButtonGrid((mode) => applyMultiAlign(mode), 'selection')}
|
||
</div>
|
||
<div className="space-y-1.5">
|
||
<span className="text-[10px] font-semibold text-[#aaa]">画布对齐</span>
|
||
<p className="text-[10px] text-[#666] leading-snug">
|
||
将选中元素作为整体,相对画布 {template.width}×{template.height} mm 对齐
|
||
</p>
|
||
{renderAlignButtonGrid((mode) => applyCanvasAlign(mode), 'canvas')}
|
||
</div>
|
||
<div className="space-y-1.5">
|
||
<span className="text-[10px] font-semibold text-[#aaa]">排列选中</span>
|
||
<div className="grid grid-cols-2 gap-1.5">
|
||
<AlignToolButton
|
||
icon={<AlignHorizontalSpaceBetween className="w-3.5 h-3.5" />}
|
||
label="水平等距平铺"
|
||
title="在选区宽度内水平等距排列"
|
||
onClick={() => applyTile('horizontal')}
|
||
/>
|
||
<AlignToolButton
|
||
icon={<AlignVerticalSpaceBetween className="w-3.5 h-3.5" />}
|
||
label="垂直等距平铺"
|
||
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>
|
||
)}
|
||
|
||
<div className="space-y-1">
|
||
<div className="text-[10px] font-bold text-[#31a8ff] uppercase tracking-widest leading-none">
|
||
图层名称
|
||
</div>
|
||
<PropInput
|
||
type="text"
|
||
value={selectedElem.name}
|
||
onCommit={(val) =>
|
||
handleElemChange({
|
||
...selectedElem,
|
||
name: val || '未命名元素',
|
||
nameCustomized: true,
|
||
})
|
||
}
|
||
className="ps-field mt-2"
|
||
/>
|
||
</div>
|
||
|
||
{/* Alignment Tools */}
|
||
<div className="space-y-1.5">
|
||
<h5 className="ps-section-title">
|
||
对齐与分布
|
||
</h5>
|
||
<div className="grid grid-cols-3 gap-1.5">
|
||
<AlignToolButton
|
||
icon={<AlignHorizontalJustifyStart className="w-3.5 h-3.5" />}
|
||
label="左对齐"
|
||
title="靠左对齐 (X=0)"
|
||
onClick={() => handleElemChange({ ...selectedElem, x: 0 })}
|
||
/>
|
||
<AlignToolButton
|
||
icon={<AlignHorizontalJustifyCenter className="w-3.5 h-3.5" />}
|
||
label="水平居中"
|
||
title="水平居中对齐"
|
||
onClick={() => {
|
||
const newX = Math.round(((template.width - selectedElem.width) / 2) * 10) / 10;
|
||
handleElemChange({ ...selectedElem, x: Math.max(0, newX) });
|
||
}}
|
||
/>
|
||
<AlignToolButton
|
||
icon={<AlignHorizontalJustifyEnd className="w-3.5 h-3.5" />}
|
||
label="右对齐"
|
||
title="靠右对齐"
|
||
onClick={() => {
|
||
const newX = Math.round((template.width - selectedElem.width) * 10) / 10;
|
||
handleElemChange({ ...selectedElem, x: Math.max(0, newX) });
|
||
}}
|
||
/>
|
||
<AlignToolButton
|
||
icon={<AlignVerticalJustifyStart className="w-3.5 h-3.5" />}
|
||
label="顶对齐"
|
||
title="靠顶对齐 (Y=0)"
|
||
onClick={() => handleElemChange({ ...selectedElem, y: 0 })}
|
||
/>
|
||
<AlignToolButton
|
||
icon={<AlignVerticalJustifyCenter className="w-3.5 h-3.5" />}
|
||
label="垂直居中"
|
||
title="垂直居中对齐"
|
||
onClick={() => {
|
||
const newY = Math.round(((template.height - selectedElem.height) / 2) * 10) / 10;
|
||
handleElemChange({ ...selectedElem, y: Math.max(0, newY) });
|
||
}}
|
||
/>
|
||
<AlignToolButton
|
||
icon={<AlignVerticalJustifyEnd className="w-3.5 h-3.5" />}
|
||
label="底对齐"
|
||
title="靠底对齐"
|
||
onClick={() => {
|
||
const newY = Math.round((template.height - selectedElem.height) * 10) / 10;
|
||
handleElemChange({ ...selectedElem, y: Math.max(0, newY) });
|
||
}}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Text Settings */}
|
||
{selectedElem.type === 'table' && selectedElementIds.length === 1 && (
|
||
<TablePropertyFields
|
||
elem={selectedElem}
|
||
selectedCells={selectedTableCells}
|
||
onChange={handleElemChange}
|
||
onSelectTableCells={onSelectTableCells}
|
||
templateVariables={templateVariables}
|
||
/>
|
||
)}
|
||
{selectedElem.type === 'text' && (
|
||
<div className="space-y-3 ps-section-divider">
|
||
<h5 className="ps-section-title">
|
||
<Type className="w-3.5 h-3.5" />
|
||
字体排版设置
|
||
</h5>
|
||
<div className="grid grid-cols-2 gap-2">
|
||
<div>
|
||
<label className="ps-field-label">显示格式</label>
|
||
<FieldSelect
|
||
value={selectedElem.textFormat || 'text'}
|
||
onChange={(e) =>
|
||
handleElemChange({
|
||
...selectedElem,
|
||
textFormat: e.target.value as 'text' | 'number' | 'percent' | 'currency',
|
||
decimalPlaces:
|
||
e.target.value === 'text'
|
||
? selectedElem.decimalPlaces
|
||
: selectedElem.decimalPlaces ?? 2,
|
||
})
|
||
}
|
||
className="ps-field"
|
||
>
|
||
<option value="text">文本</option>
|
||
<option value="number">数值</option>
|
||
<option value="percent">百分比</option>
|
||
<option value="currency">货币 (¥)</option>
|
||
</FieldSelect>
|
||
</div>
|
||
{(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 className="col-span-2">
|
||
<label className="ps-field-label">数值显示部分</label>
|
||
<FieldSelect
|
||
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>
|
||
</FieldSelect>
|
||
<p className="text-[9px] text-[#666] mt-1 leading-relaxed">
|
||
可将价格拆成两个文本元素分别设置字号,如「19」与「.9」
|
||
</p>
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
|
||
<div className="grid grid-cols-2 gap-2">
|
||
<div>
|
||
<label className="ps-field-label">字号 (pt)</label>
|
||
<PropInput
|
||
type="number"
|
||
min="6"
|
||
max="80"
|
||
value={selectedElem.fontSize}
|
||
onCommit={(val) =>
|
||
handleElemChange({ ...selectedElem, fontSize: Number(val) || 12 })
|
||
}
|
||
className="ps-field font-mono"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="ps-field-label">字体库</label>
|
||
<FieldSelect
|
||
value={selectedElem.fontFamily}
|
||
onChange={(e) =>
|
||
handleElemChange({
|
||
...selectedElem,
|
||
fontFamily: e.target.value as 'sans' | 'serif' | 'mono',
|
||
})
|
||
}
|
||
className="ps-field"
|
||
>
|
||
<option value="sans">黑体 (Sans)</option>
|
||
<option value="serif">宋体 (Serif)</option>
|
||
<option value="mono">等宽体 (Mono)</option>
|
||
</FieldSelect>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Text color */}
|
||
<div className="border-t border-[#3a3a3a] pt-2.5">
|
||
<label className="block text-[10px] font-semibold text-[#777] mb-1">文本颜色</label>
|
||
<div className="flex gap-1 items-center">
|
||
<input
|
||
type="color"
|
||
value={selectedElem.textColor || '#111827'}
|
||
onChange={(e) => handleElemChange({ ...selectedElem, textColor: e.target.value })}
|
||
className="color-input shrink-0"
|
||
/>
|
||
<input
|
||
type="text"
|
||
placeholder="#111827"
|
||
value={selectedElem.textColor || ''}
|
||
onChange={(e) => handleElemChange({ ...selectedElem, textColor: e.target.value })}
|
||
className="ps-field font-mono text-[11px] flex-1 min-w-0"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
<label className="ps-field-label mb-0">文本样式</label>
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<button
|
||
type="button"
|
||
onClick={() =>
|
||
handleElemChange({
|
||
...selectedElem,
|
||
fontWeight: selectedElem.fontWeight === 'bold' ? 'normal' : 'bold',
|
||
})
|
||
}
|
||
className={`ps-btn ${selectedElem.fontWeight === 'bold' ? 'active' : ''}`}
|
||
title="加粗"
|
||
>
|
||
<Bold className="w-3.5 h-3.5" />
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() =>
|
||
handleElemChange({
|
||
...selectedElem,
|
||
fontStyle:
|
||
selectedElem.fontStyle === 'italic' ? 'normal' : 'italic',
|
||
})
|
||
}
|
||
className={`ps-btn ${selectedElem.fontStyle === 'italic' ? 'active' : ''}`}
|
||
title="斜体"
|
||
>
|
||
<Italic className="w-3.5 h-3.5" />
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() =>
|
||
handleElemChange({
|
||
...selectedElem,
|
||
textUnderline: !selectedElem.textUnderline,
|
||
})
|
||
}
|
||
className={`ps-btn ${selectedElem.textUnderline ? 'active' : ''}`}
|
||
title="下划线"
|
||
>
|
||
<Underline className="w-3.5 h-3.5" />
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() =>
|
||
handleElemChange({
|
||
...selectedElem,
|
||
textLineThrough: !selectedElem.textLineThrough,
|
||
})
|
||
}
|
||
className={`ps-btn ${selectedElem.textLineThrough ? 'active' : ''}`}
|
||
title="删除线"
|
||
>
|
||
<Strikethrough className="w-3.5 h-3.5" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-3 gap-2">
|
||
<div>
|
||
<label className="ps-field-label">水平拉伸</label>
|
||
<PropInput
|
||
type="number"
|
||
min="10"
|
||
max="500"
|
||
step="5"
|
||
value={Math.round((selectedElem.textScaleX ?? 1) * 100)}
|
||
onCommit={(val) =>
|
||
handleElemChange({
|
||
...selectedElem,
|
||
textScaleX: Math.max(0.1, Math.min(5, (Number(val) || 100) / 100)),
|
||
})
|
||
}
|
||
className="ps-field font-mono"
|
||
/>
|
||
<p className="text-[9px] text-[#666] mt-0.5">%</p>
|
||
</div>
|
||
<div>
|
||
<label className="ps-field-label">垂直拉伸</label>
|
||
<PropInput
|
||
type="number"
|
||
min="10"
|
||
max="500"
|
||
step="5"
|
||
value={Math.round((selectedElem.textScaleY ?? 1) * 100)}
|
||
onCommit={(val) =>
|
||
handleElemChange({
|
||
...selectedElem,
|
||
textScaleY: Math.max(0.1, Math.min(5, (Number(val) || 100) / 100)),
|
||
})
|
||
}
|
||
className="ps-field font-mono"
|
||
/>
|
||
<p className="text-[9px] text-[#666] mt-0.5">%</p>
|
||
</div>
|
||
<div>
|
||
<label className="ps-field-label">字间距</label>
|
||
<PropInput
|
||
type="number"
|
||
min="0"
|
||
max="10"
|
||
step="0.1"
|
||
value={selectedElem.letterSpacing ?? 0}
|
||
onCommit={(val) =>
|
||
handleElemChange({
|
||
...selectedElem,
|
||
letterSpacing: Math.max(0, Math.min(10, Number(val) || 0)),
|
||
})
|
||
}
|
||
className="ps-field font-mono"
|
||
/>
|
||
<p className="text-[9px] text-[#666] mt-0.5">mm</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
<label className="ps-field-label mb-0">排列方向</label>
|
||
<div className="ps-toggle-group">
|
||
<button
|
||
type="button"
|
||
onClick={() =>
|
||
handleElemChange({ ...selectedElem, textWritingMode: 'horizontal' })
|
||
}
|
||
className={`ps-toggle-btn ${(selectedElem.textWritingMode ?? 'horizontal') === 'horizontal' ? 'active' : ''}`}
|
||
title="横排"
|
||
>
|
||
横排
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() =>
|
||
handleElemChange({ ...selectedElem, textWritingMode: 'vertical' })
|
||
}
|
||
className={`ps-toggle-btn ${selectedElem.textWritingMode === 'vertical' ? 'active' : ''}`}
|
||
title="竖排"
|
||
>
|
||
竖排
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
<label className="ps-field-label mb-0">对齐方式</label>
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<div className="ps-toggle-group">
|
||
<button
|
||
type="button"
|
||
onClick={() => handleElemChange({ ...selectedElem, textAlign: 'left' })}
|
||
className={`ps-toggle-btn ${selectedElem.textAlign === 'left' ? 'active' : ''}`}
|
||
title="左对齐"
|
||
>
|
||
<AlignLeft className="w-3.5 h-3.5" />
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => handleElemChange({ ...selectedElem, textAlign: 'center' })}
|
||
className={`ps-toggle-btn ${selectedElem.textAlign === 'center' ? 'active' : ''}`}
|
||
title="水平居中"
|
||
>
|
||
<AlignCenter className="w-3.5 h-3.5" />
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => handleElemChange({ ...selectedElem, textAlign: 'right' })}
|
||
className={`ps-toggle-btn ${selectedElem.textAlign === 'right' ? 'active' : ''}`}
|
||
title="右对齐"
|
||
>
|
||
<AlignRight className="w-3.5 h-3.5" />
|
||
</button>
|
||
</div>
|
||
<div className="ps-toggle-group">
|
||
<button
|
||
type="button"
|
||
onClick={() => handleElemChange({ ...selectedElem, verticalAlign: 'top' })}
|
||
className={`ps-toggle-btn ${(selectedElem.verticalAlign ?? 'middle') === 'top' ? 'active' : ''}`}
|
||
title="顶对齐"
|
||
>
|
||
<AlignVerticalJustifyStart className="w-3.5 h-3.5" />
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => handleElemChange({ ...selectedElem, verticalAlign: 'middle' })}
|
||
className={`ps-toggle-btn ${(selectedElem.verticalAlign ?? 'middle') === 'middle' ? 'active' : ''}`}
|
||
title="垂直居中"
|
||
>
|
||
<AlignVerticalJustifyCenter className="w-3.5 h-3.5" />
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => handleElemChange({ ...selectedElem, verticalAlign: 'bottom' })}
|
||
className={`ps-toggle-btn ${(selectedElem.verticalAlign ?? 'middle') === 'bottom' ? 'active' : ''}`}
|
||
title="底对齐"
|
||
>
|
||
<AlignVerticalJustifyEnd className="w-3.5 h-3.5" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<label className="flex items-center gap-2 cursor-pointer">
|
||
<input
|
||
type="checkbox"
|
||
checked={selectedElem.wordWrap !== false}
|
||
onChange={(e) => handleElemChange({ ...selectedElem, wordWrap: e.target.checked })}
|
||
className="ps-checkbox"
|
||
/>
|
||
<span className="text-[12px] text-[#ccc]">自动换行</span>
|
||
</label>
|
||
</div>
|
||
)}
|
||
|
||
{/* Barcode Settings */}
|
||
{selectedElem.type === 'barcode' && (
|
||
<div className="space-y-3 ps-section-divider">
|
||
<h5 className="ps-section-title">
|
||
<Barcode className="w-3.5 h-3.5" />
|
||
条形码特定格式
|
||
</h5>
|
||
<div>
|
||
<label className="ps-field-label">条码码制</label>
|
||
<FieldSelect
|
||
value={selectedElem.barcodeFormat}
|
||
onChange={(e) =>
|
||
handleElemChange({
|
||
...selectedElem,
|
||
barcodeFormat: e.target.value as 'CODE128' | 'CODE39' | 'EAN13' | 'ITF',
|
||
})
|
||
}
|
||
className="ps-field"
|
||
>
|
||
<option value="CODE128">CODE 128 (推荐 · 常用)</option>
|
||
<option value="CODE39">CODE 39 (数字和字符)</option>
|
||
<option value="EAN13">EAN 13 (纯商品数字 13位)</option>
|
||
<option value="ITF">ITF (交插双五码)</option>
|
||
</FieldSelect>
|
||
</div>
|
||
|
||
<label className="flex items-center gap-2 cursor-pointer mt-1">
|
||
<input
|
||
type="checkbox"
|
||
checked={selectedElem.showText}
|
||
onChange={(e) => handleElemChange({ ...selectedElem, showText: e.target.checked })}
|
||
className="ps-checkbox"
|
||
/>
|
||
<span className="text-xs font-semibold text-[#ccc]">显示下方文字 (Human Readable)</span>
|
||
</label>
|
||
</div>
|
||
)}
|
||
|
||
{/* Image Settings */}
|
||
{selectedElem.type === 'image' && (
|
||
<div className="space-y-3 ps-section-divider">
|
||
<h5 className="ps-section-title">
|
||
<ImageIcon className="w-3.5 h-3.5" />
|
||
图片设置
|
||
</h5>
|
||
<div>
|
||
<label className="ps-field-label">适应方式</label>
|
||
<FieldSelect
|
||
value={selectedElem.imageFit ?? 'contain'}
|
||
onChange={(e) =>
|
||
handleElemChange({
|
||
...selectedElem,
|
||
imageFit: e.target.value as 'contain' | 'cover' | 'fill',
|
||
})
|
||
}
|
||
className="ps-field"
|
||
>
|
||
<option value="contain">包含 (contain)</option>
|
||
<option value="cover">覆盖 (cover)</option>
|
||
<option value="fill">拉伸 (fill)</option>
|
||
</FieldSelect>
|
||
</div>
|
||
<div>
|
||
<label className="ps-field-label">默认图片</label>
|
||
<p className="text-[9px] text-[#777] leading-normal mb-1.5">
|
||
主图地址加载失败时使用;需同时开启「内容为空仍显示」才能在绑定数据为空时展示
|
||
</p>
|
||
<label className="ps-btn text-[10px] cursor-pointer mb-1.5 inline-flex">
|
||
<Upload className="w-3.5 h-3.5" />
|
||
选择默认图片
|
||
<input
|
||
type="file"
|
||
accept="image/*"
|
||
className="hidden"
|
||
onChange={(e) => {
|
||
const file = e.target.files?.[0];
|
||
if (!file) return;
|
||
const reader = new FileReader();
|
||
reader.onload = () =>
|
||
handleElemChange({
|
||
...selectedElem,
|
||
defaultImage: reader.result as string,
|
||
});
|
||
reader.readAsDataURL(file);
|
||
e.target.value = '';
|
||
}}
|
||
/>
|
||
</label>
|
||
<PropTextarea
|
||
value={selectedElem.defaultImage ?? ''}
|
||
onCommit={(val) =>
|
||
handleElemChange({
|
||
...selectedElem,
|
||
defaultImage: val.trim() || undefined,
|
||
})
|
||
}
|
||
rows={2}
|
||
className="ps-field resize-none"
|
||
placeholder="默认图片 URL 或 data URI"
|
||
/>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Positions (X, Y) and Sizes (W, H) */}
|
||
<div className="space-y-3">
|
||
<h5 className="ps-section-title">
|
||
<Ruler className="w-3.5 h-3.5" />
|
||
位置与尺寸 (mm)
|
||
</h5>
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<div>
|
||
<label className="ps-field-label">X 坐标 (左边距)</label>
|
||
<PropInput
|
||
type="number"
|
||
step="0.5"
|
||
value={selectedElem.x}
|
||
onCommit={(val) => {
|
||
handleElemChange({
|
||
...selectedElem,
|
||
x: Math.round((Number(val) || 0) * 10) / 10,
|
||
});
|
||
}}
|
||
className="ps-field font-mono"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="ps-field-label">Y 坐标 (顶边距)</label>
|
||
<PropInput
|
||
type="number"
|
||
step="0.5"
|
||
value={selectedElem.y}
|
||
onCommit={(val) => {
|
||
handleElemChange({
|
||
...selectedElem,
|
||
y: Math.round((Number(val) || 0) * 10) / 10,
|
||
});
|
||
}}
|
||
className="ps-field font-mono"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="ps-field-label">元素宽度</label>
|
||
<PropInput
|
||
type="number"
|
||
step="0.5"
|
||
min="2"
|
||
value={selectedElem.width}
|
||
onCommit={(val) => {
|
||
const wVal = Math.max(2, Number(val) || 2);
|
||
if (selectedElem.type === 'qrcode') {
|
||
handleElemChange({ ...selectedElem, width: wVal, height: wVal });
|
||
} else if (selectedElem.type === 'table') {
|
||
handleElemChange(
|
||
scaleTableElementSize(selectedElem, wVal, selectedElem.height)
|
||
);
|
||
} else {
|
||
handleElemChange({ ...selectedElem, width: wVal });
|
||
}
|
||
}}
|
||
className="ps-field font-mono"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="ps-field-label">元素高度</label>
|
||
<PropInput
|
||
type="number"
|
||
step="0.5"
|
||
min="2"
|
||
value={selectedElem.height}
|
||
disabled={selectedElem.type === 'qrcode'}
|
||
onCommit={(val) => {
|
||
const hVal = Math.max(2, Number(val) || 2);
|
||
if (selectedElem.type === 'table') {
|
||
handleElemChange(scaleTableElementSize(selectedElem, selectedElem.width, hVal));
|
||
} else {
|
||
handleElemChange({ ...selectedElem, height: hVal });
|
||
}
|
||
}}
|
||
className={`ps-field font-mono ${selectedElem.type === 'qrcode' ? 'opacity-50 select-none' : ''
|
||
}`}
|
||
/>
|
||
</div>
|
||
<div className="col-span-2">
|
||
<label className="ps-field-label flex items-center gap-1">
|
||
旋转角度(度)
|
||
</label>
|
||
<div className="flex items-center gap-2">
|
||
<PropInput
|
||
type="number"
|
||
step="1"
|
||
min="-360"
|
||
max="360"
|
||
value={selectedElem.rotation ?? 0}
|
||
onCommit={(val) => {
|
||
let deg = Number(val) || 0;
|
||
deg = ((deg % 360) + 360) % 360;
|
||
handleElemChange({ ...selectedElem, rotation: deg });
|
||
}}
|
||
className="ps-field font-mono flex-1"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Background — all element types */}
|
||
<div className="space-y-2 ps-section-divider">
|
||
<h5 className="ps-section-title">
|
||
背景
|
||
</h5>
|
||
<div className="flex justify-between items-center mb-1">
|
||
<span className="text-[10px] text-[#777]">颜色</span>
|
||
<button
|
||
type="button"
|
||
onClick={() =>
|
||
handleElemChange({
|
||
...selectedElem,
|
||
backgroundOpacity: 0,
|
||
})
|
||
}
|
||
className="text-[9px] text-[#31a8ff] hover:underline cursor-pointer"
|
||
>
|
||
透明
|
||
</button>
|
||
</div>
|
||
<div className="flex gap-1 items-center">
|
||
<input
|
||
type="color"
|
||
value={
|
||
selectedElem.backgroundColor &&
|
||
selectedElem.backgroundColor !== 'transparent'
|
||
? selectedElem.backgroundColor
|
||
: '#ffffff'
|
||
}
|
||
onChange={(e) =>
|
||
handleElemChange({
|
||
...selectedElem,
|
||
backgroundColor: e.target.value,
|
||
backgroundOpacity:
|
||
selectedElem.backgroundOpacity && selectedElem.backgroundOpacity > 0
|
||
? selectedElem.backgroundOpacity
|
||
: 100,
|
||
})
|
||
}
|
||
className="color-input shrink-0"
|
||
/>
|
||
<input
|
||
type="text"
|
||
placeholder="transparent"
|
||
value={
|
||
selectedElem.backgroundColor === 'transparent'
|
||
? 'transparent'
|
||
: selectedElem.backgroundColor || ''
|
||
}
|
||
onChange={(e) => {
|
||
const v = e.target.value.trim();
|
||
if (v === 'transparent' || v === '') {
|
||
handleElemChange({
|
||
...selectedElem,
|
||
backgroundColor: '#ffffff',
|
||
backgroundOpacity: 0,
|
||
});
|
||
} else {
|
||
handleElemChange({
|
||
...selectedElem,
|
||
backgroundColor: v,
|
||
backgroundOpacity: selectedElem.backgroundOpacity ?? 100,
|
||
});
|
||
}
|
||
}}
|
||
className="ps-field font-mono text-[11px] flex-1 min-w-0"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<div className="flex justify-between items-center mb-1">
|
||
<span className="text-[10px] text-[#777]">不透明度</span>
|
||
<span className="text-[10px] text-[#999] font-mono">
|
||
{selectedElem.backgroundColor === 'transparent'
|
||
? 0
|
||
: selectedElem.backgroundOpacity ?? 100}
|
||
%
|
||
</span>
|
||
</div>
|
||
<input
|
||
type="range"
|
||
min="0"
|
||
max="100"
|
||
step="1"
|
||
value={
|
||
selectedElem.backgroundColor === 'transparent'
|
||
? 0
|
||
: selectedElem.backgroundOpacity ?? 100
|
||
}
|
||
onChange={(e) => {
|
||
const op = Number(e.target.value);
|
||
if (op <= 0) {
|
||
handleElemChange({
|
||
...selectedElem,
|
||
backgroundOpacity: 0,
|
||
});
|
||
} else {
|
||
handleElemChange({
|
||
...selectedElem,
|
||
backgroundColor:
|
||
selectedElem.backgroundColor &&
|
||
selectedElem.backgroundColor !== 'transparent'
|
||
? selectedElem.backgroundColor
|
||
: '#ffffff',
|
||
backgroundOpacity: op,
|
||
});
|
||
}
|
||
}}
|
||
className="ps-range"
|
||
/>
|
||
</div>
|
||
<div className="border-t border-[#3a3a3a] pt-2.5 space-y-2">
|
||
<label className="ps-field-label">内边距 (mm)</label>
|
||
<PropInput
|
||
type="number"
|
||
min="0"
|
||
max="10"
|
||
step="0.5"
|
||
value={selectedElem.padding ?? 0}
|
||
onCommit={(val) =>
|
||
commitTableAwareChange({
|
||
...selectedElem,
|
||
padding: Math.max(0, Math.min(10, Number(val) || 0)),
|
||
})
|
||
}
|
||
className="ps-field font-mono"
|
||
/>
|
||
<div>
|
||
<label className="ps-field-label">各边独立内边距 (mm,留空用统一值)</label>
|
||
<div className="grid grid-cols-2 gap-1.5">
|
||
{(
|
||
[
|
||
['paddingTop', '上'],
|
||
['paddingRight', '右'],
|
||
['paddingBottom', '下'],
|
||
['paddingLeft', '左'],
|
||
] as const
|
||
).map(([key, label]) => (
|
||
<div key={key} className="flex items-center gap-1">
|
||
<span className="text-[9px] text-[#777] w-6 shrink-0">{label}</span>
|
||
<PropInput
|
||
type="number"
|
||
min="0"
|
||
max="10"
|
||
step="0.5"
|
||
value={selectedElem[key] ?? ''}
|
||
onCommit={(val) => {
|
||
const updated = { ...selectedElem };
|
||
if (val === '' || val === undefined) {
|
||
delete updated[key];
|
||
} else {
|
||
updated[key] = Math.max(0, Math.min(10, Number(val) || 0));
|
||
}
|
||
commitTableAwareChange(updated);
|
||
}}
|
||
className="ps-field font-mono text-[10px] flex-1 min-w-0"
|
||
placeholder="—"
|
||
/>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Border & radius */}
|
||
<div className="space-y-2 ps-section-divider">
|
||
<h5 className="ps-section-title">边框与圆角</h5>
|
||
|
||
<div className="grid grid-cols-2 gap-2">
|
||
<div>
|
||
<label className="ps-field-label">边框宽度 (mm)</label>
|
||
<PropInput
|
||
type="number"
|
||
min="0"
|
||
max="5"
|
||
step="0.1"
|
||
value={selectedElem.borderWidth ?? 0}
|
||
onCommit={(val) =>
|
||
handleElemChange({
|
||
...selectedElem,
|
||
borderWidth: Math.max(0, Math.min(5, Number(val) || 0)),
|
||
borderColor: selectedElem.borderColor || '#111827',
|
||
})
|
||
}
|
||
className="ps-field font-mono"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="ps-field-label">圆角半径 (mm)</label>
|
||
<PropInput
|
||
type="number"
|
||
min="0"
|
||
max="20"
|
||
step="0.5"
|
||
value={selectedElem.borderRadius ?? 0}
|
||
onCommit={(val) =>
|
||
handleElemChange({
|
||
...selectedElem,
|
||
borderRadius: Math.max(0, Math.min(20, Number(val) || 0)),
|
||
})
|
||
}
|
||
className="ps-field font-mono"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="ps-field-label">边框颜色</label>
|
||
<div className="flex gap-1 items-center">
|
||
<input
|
||
type="color"
|
||
value={selectedElem.borderColor || '#111827'}
|
||
onChange={(e) =>
|
||
handleElemChange({
|
||
...selectedElem,
|
||
borderColor: e.target.value,
|
||
borderWidth: selectedElem.borderWidth || 0.3,
|
||
})
|
||
}
|
||
className="color-input shrink-0"
|
||
/>
|
||
<input
|
||
type="text"
|
||
value={selectedElem.borderColor || ''}
|
||
placeholder="#111827"
|
||
onChange={(e) =>
|
||
handleElemChange({
|
||
...selectedElem,
|
||
borderColor: e.target.value,
|
||
})
|
||
}
|
||
className="ps-field font-mono text-[11px] flex-1 min-w-0"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="ps-field-label">各边独立边框宽度 (mm,留空用统一值,0 为不显示)</label>
|
||
<div className="grid grid-cols-2 gap-1.5">
|
||
{(
|
||
[
|
||
['borderWidthTop', '上'],
|
||
['borderWidthRight', '右'],
|
||
['borderWidthBottom', '下'],
|
||
['borderWidthLeft', '左'],
|
||
] as const
|
||
).map(([key, label]) => (
|
||
<div key={key} className="flex items-center gap-1">
|
||
<span className="text-[9px] text-[#777] w-6 shrink-0">{label}</span>
|
||
<PropInput
|
||
type="number"
|
||
min="0"
|
||
max="5"
|
||
step="0.1"
|
||
value={selectedElem[key] ?? ''}
|
||
onCommit={(val) => {
|
||
const updated = { ...selectedElem };
|
||
if (val === '' || val === undefined) {
|
||
delete updated[key];
|
||
} else {
|
||
updated[key] = Math.max(0, Math.min(5, Number(val) || 0));
|
||
}
|
||
handleElemChange(updated);
|
||
}}
|
||
className="ps-field font-mono text-[10px] flex-1 min-w-0"
|
||
placeholder="—"
|
||
/>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="ps-field-label">各边独立边框颜色 (留空用统一值)</label>
|
||
<div className="grid grid-cols-2 gap-1.5">
|
||
{(
|
||
[
|
||
['borderColorTop', '上'],
|
||
['borderColorRight', '右'],
|
||
['borderColorBottom', '下'],
|
||
['borderColorLeft', '左'],
|
||
] as const
|
||
).map(([key, label]) => (
|
||
<div key={key} className="flex items-center gap-1 min-w-0">
|
||
<span className="text-[9px] text-[#777] w-6 shrink-0">{label}</span>
|
||
<input
|
||
type="color"
|
||
value={
|
||
selectedElem[key]?.trim() ||
|
||
selectedElem.borderColor ||
|
||
'#111827'
|
||
}
|
||
onChange={(e) => {
|
||
const updated = { ...selectedElem };
|
||
updated[key] = e.target.value;
|
||
handleElemChange(updated);
|
||
}}
|
||
className="color-input shrink-0"
|
||
title={`${label}边边框颜色`}
|
||
/>
|
||
<PropInput
|
||
type="text"
|
||
value={selectedElem[key] ?? ''}
|
||
onCommit={(val) => {
|
||
const updated = { ...selectedElem };
|
||
if (val === '' || val === undefined) {
|
||
delete updated[key];
|
||
} else {
|
||
updated[key] = val;
|
||
}
|
||
handleElemChange(updated);
|
||
}}
|
||
className="ps-field font-mono text-[10px] flex-1 min-w-0"
|
||
placeholder="—"
|
||
/>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="ps-field-label">各角独立圆角 (mm,留空用统一值)</label>
|
||
<div className="grid grid-cols-2 gap-1.5">
|
||
{(
|
||
[
|
||
['borderRadiusTL', '左上'],
|
||
['borderRadiusTR', '右上'],
|
||
['borderRadiusBL', '左下'],
|
||
['borderRadiusBR', '右下'],
|
||
] as const
|
||
).map(([key, label]) => (
|
||
<div key={key} className="flex items-center gap-1">
|
||
<span className="text-[9px] text-[#777] w-6 shrink-0">{label}</span>
|
||
<PropInput
|
||
type="number"
|
||
min="0"
|
||
max="20"
|
||
step="0.5"
|
||
value={selectedElem[key] ?? ''}
|
||
onCommit={(val) => {
|
||
const updated = { ...selectedElem };
|
||
if (val === '' || val === undefined) {
|
||
delete updated[key];
|
||
} else {
|
||
updated[key] = Math.max(0, Math.min(20, Number(val) || 0));
|
||
}
|
||
handleElemChange(updated);
|
||
}}
|
||
className="ps-field font-mono text-[10px] flex-1 min-w-0"
|
||
placeholder="—"
|
||
/>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
)}
|
||
|
||
</>
|
||
);
|
||
|
||
const renderLayersPanelBody = () => (
|
||
<>
|
||
{template.elements.length === 0 ? (
|
||
<div className="text-[11px] text-[#666] text-center py-6">暂无图层</div>
|
||
) : (
|
||
[...template.elements].reverse().map((elem, revIdx) => {
|
||
const actualIdx = template.elements.length - 1 - revIdx;
|
||
const isSelected = selectedElementIds.includes(elem.id);
|
||
const isLocked = !!elem.locked;
|
||
|
||
return (
|
||
<div
|
||
key={elem.id}
|
||
onClick={
|
||
isLocked
|
||
? undefined
|
||
: (e) => {
|
||
if (!onSelectElements) return;
|
||
|
||
if (isSelected) {
|
||
onSelectElements(selectedElementIds.filter((id) => id !== elem.id));
|
||
return;
|
||
}
|
||
|
||
if (variant === 'mobile') {
|
||
onSelectElements([...selectedElementIds, elem.id]);
|
||
return;
|
||
}
|
||
|
||
const isMulti = e.shiftKey || e.ctrlKey || e.metaKey;
|
||
if (isMulti) {
|
||
onSelectElements([...selectedElementIds, elem.id]);
|
||
} else {
|
||
onSelectElements([elem.id]);
|
||
}
|
||
}
|
||
}
|
||
className={`ps-layer-item group ${isSelected && !isLocked ? 'selected' : ''} ${isLocked ? 'ps-layer-item-locked' : ''
|
||
}`}
|
||
>
|
||
<div className="flex items-center gap-2 min-w-0 flex-1 pointer-events-none">
|
||
<span className="ps-layer-item-icon">
|
||
{elem.type === 'text' && <Type />}
|
||
{elem.type === 'barcode' && <Barcode />}
|
||
{elem.type === 'qrcode' && <QrCode />}
|
||
{elem.type === 'image' && <ImageIcon />}
|
||
{elem.type === 'table' && <Table2 />}
|
||
</span>
|
||
<span className="truncate" title={elem.name}>
|
||
{elem.name}
|
||
</span>
|
||
</div>
|
||
|
||
<div
|
||
className={`ps-layer-item-actions pointer-events-auto ${isNarrowScreen || variant === 'mobile' || isLocked
|
||
? 'opacity-100'
|
||
: 'opacity-0 group-hover:opacity-100 transition-opacity'
|
||
}`}
|
||
>
|
||
{!isLocked && (
|
||
<>
|
||
<button
|
||
type="button"
|
||
onClick={(e) => { e.stopPropagation(); moveLayer(actualIdx, 'up'); }}
|
||
disabled={actualIdx === template.elements.length - 1}
|
||
title="上移"
|
||
>
|
||
<ArrowUp />
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={(e) => { e.stopPropagation(); moveLayer(actualIdx, 'down'); }}
|
||
disabled={actualIdx === 0}
|
||
title="下移"
|
||
>
|
||
<ArrowDown />
|
||
</button>
|
||
</>
|
||
)}
|
||
<button
|
||
type="button"
|
||
onClick={(e) => { e.stopPropagation(); toggleLockLayer(actualIdx); }}
|
||
title={isLocked ? '解锁' : '锁定'}
|
||
className={isLocked ? 'text-amber-400' : 'text-[#666] hover:text-[#ccc]'}
|
||
>
|
||
{isLocked ? <Lock /> : <Unlock />}
|
||
</button>
|
||
{!isLocked && (
|
||
<button
|
||
type="button"
|
||
onClick={(e) => { e.stopPropagation(); void deleteLayer(elem.id); }}
|
||
title="删除"
|
||
className="hover:text-red-400 text-[#666]"
|
||
>
|
||
<Trash2 />
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
})
|
||
)}
|
||
</>
|
||
);
|
||
|
||
const renderNudgePanelBody = () => {
|
||
const canNudge = nudgeableElementIds.length > 0;
|
||
const primaryElem =
|
||
selectedElem && nudgeableElementIds.includes(selectedElem.id) ? selectedElem : null;
|
||
|
||
return (
|
||
<div className="mobile-nudge-panel">
|
||
<div className="mobile-nudge-header space-y-1 mb-4">
|
||
<h4 className="text-[10px] font-bold uppercase tracking-widest text-[#ccc]">微调位置</h4>
|
||
<p className="text-[10px] text-[#777]">每次移动 {MOBILE_NUDGE_STEP_MM} mm,与桌面方向键一致</p>
|
||
</div>
|
||
|
||
{!canNudge ? (
|
||
<div className="mobile-nudge-empty">
|
||
<Move className="w-8 h-8 text-[#555] mb-2" />
|
||
<p className="text-[12px] text-[#888]">请先选中未锁定的元素</p>
|
||
</div>
|
||
) : (
|
||
<>
|
||
<div className="mobile-nudge-pad" role="group" aria-label="位置微调">
|
||
<button
|
||
type="button"
|
||
className="mobile-nudge-btn"
|
||
onClick={() => nudgeSelectedElements(0, -MOBILE_NUDGE_STEP_MM)}
|
||
aria-label="向上微调"
|
||
>
|
||
<ArrowUp className="w-5 h-5" />
|
||
</button>
|
||
<div className="mobile-nudge-pad-row">
|
||
<button
|
||
type="button"
|
||
className="mobile-nudge-btn"
|
||
onClick={() => nudgeSelectedElements(-MOBILE_NUDGE_STEP_MM, 0)}
|
||
aria-label="向左微调"
|
||
>
|
||
<ArrowLeft className="w-5 h-5" />
|
||
</button>
|
||
<div className="mobile-nudge-pad-center" aria-hidden="true" />
|
||
<button
|
||
type="button"
|
||
className="mobile-nudge-btn"
|
||
onClick={() => nudgeSelectedElements(MOBILE_NUDGE_STEP_MM, 0)}
|
||
aria-label="向右微调"
|
||
>
|
||
<ArrowRight className="w-5 h-5" />
|
||
</button>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
className="mobile-nudge-btn"
|
||
onClick={() => nudgeSelectedElements(0, MOBILE_NUDGE_STEP_MM)}
|
||
aria-label="向下微调"
|
||
>
|
||
<ArrowDown className="w-5 h-5" />
|
||
</button>
|
||
</div>
|
||
|
||
{primaryElem && (
|
||
<div className="mobile-nudge-info">
|
||
<p className="text-[12px] font-medium text-[#ddd] truncate">{primaryElem.name}</p>
|
||
<p className="text-[11px] font-mono text-[#31a8ff] mt-1">
|
||
X {primaryElem.x} · Y {primaryElem.y} mm
|
||
</p>
|
||
{nudgeableElementIds.length > 1 && (
|
||
<p className="text-[10px] text-[#888] mt-1">
|
||
已选 {nudgeableElementIds.length} 个元素,将一起移动
|
||
</p>
|
||
)}
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const visibilityRuleDialogNode = (
|
||
<VisibilityRuleDialog
|
||
open={visibilityRuleDialog !== null}
|
||
mode={visibilityRuleDialog?.mode ?? 'add'}
|
||
initialRule={editingVisibilityRule}
|
||
variableOptions={templateVariables}
|
||
onClose={() => setVisibilityRuleDialog(null)}
|
||
onConfirm={handleConfirmVisibilityRule}
|
||
/>
|
||
);
|
||
|
||
if (variant === 'mobile') {
|
||
return (
|
||
<div className="mobile-design-props">
|
||
{visibilityRuleDialogNode}
|
||
<nav className="mobile-design-props-nav" aria-label="属性模块">
|
||
{MOBILE_PROP_MODULES.map((item) => (
|
||
<button
|
||
key={item.id}
|
||
type="button"
|
||
onClick={() => setMobileModule(item.id)}
|
||
className={`mobile-design-props-nav-btn ${mobileModule === item.id ? 'active' : ''}`}
|
||
>
|
||
{item.icon}
|
||
<span>{item.label}</span>
|
||
</button>
|
||
))}
|
||
<div className="mobile-design-props-nav-spacer" aria-hidden />
|
||
{elementClipboard?.canCopy && (
|
||
<button
|
||
type="button"
|
||
onClick={() => elementClipboard.copyAndPasteSelectedElements()}
|
||
className="mobile-design-props-nav-btn"
|
||
aria-label="复制选中元素"
|
||
title="复制选中元素并创建副本"
|
||
>
|
||
<Copy className="w-4 h-4" />
|
||
<span>复制</span>
|
||
</button>
|
||
)}
|
||
<button
|
||
type="button"
|
||
onClick={() => void deleteSelectedElements()}
|
||
disabled={deletableSelectedIds.length === 0}
|
||
className="mobile-design-props-nav-btn mobile-design-props-nav-delete"
|
||
aria-label="删除选中元素"
|
||
title="删除选中元素"
|
||
>
|
||
<Trash2 className="w-4 h-4" />
|
||
<span>删除</span>
|
||
</button>
|
||
</nav>
|
||
<div className="mobile-design-props-panel scrollbar-hide">
|
||
{mobileModule === 'content' && (
|
||
<div className="ps-panel-body p-3 min-h-0">{renderContentPanelBody()}</div>
|
||
)}
|
||
{mobileModule === 'properties' && (
|
||
<div className="ps-panel-body p-3 space-y-4 min-h-0">{renderPropertiesPanelBody()}</div>
|
||
)}
|
||
{mobileModule === 'nudge' && (
|
||
<div className="ps-panel-body p-3 min-h-0">{renderNudgePanelBody()}</div>
|
||
)}
|
||
{mobileModule === 'layers' && (
|
||
<div className="ps-panel-body min-h-0 mobile-layers-panel">{renderLayersPanelBody()}</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="ps-sidebar-stack">
|
||
{visibilityRuleDialogNode}
|
||
<CollapsiblePanelSection
|
||
sectionClassName="properties-panel flex flex-col"
|
||
collapsed={panelCollapsed.properties}
|
||
onToggle={() => togglePanel('properties')}
|
||
icon={<Sliders className="w-3.5 h-3.5 text-[#31a8ff] shrink-0" />}
|
||
title="属性"
|
||
headerExtra={
|
||
selectedElem ? (
|
||
<span className="text-[10px] text-[#999] font-normal truncate max-w-[100px]">
|
||
{selectedElem.name}
|
||
</span>
|
||
) : null
|
||
}
|
||
bodyClassName="ps-panel-body p-3 space-y-4 min-h-0"
|
||
>
|
||
{renderPropertiesPanelBody()}
|
||
</CollapsiblePanelSection>
|
||
|
||
<CollapsiblePanelSection
|
||
sectionClassName="content-panel"
|
||
collapsed={panelCollapsed.content}
|
||
onToggle={() => togglePanel('content')}
|
||
icon={<Link2 className="w-3.5 h-3.5 text-[#31a8ff] shrink-0" />}
|
||
title="内容"
|
||
bodyClassName="ps-panel-body p-3 min-h-0"
|
||
>
|
||
{renderContentPanelBody()}
|
||
</CollapsiblePanelSection>
|
||
|
||
<CollapsiblePanelSection
|
||
sectionClassName="layers-panel-section"
|
||
collapsed={panelCollapsed.layers}
|
||
onToggle={() => togglePanel('layers')}
|
||
icon={<Layers className="w-3.5 h-3.5 shrink-0 text-[#31a8ff]" />}
|
||
title="图层"
|
||
headerExtra={
|
||
<span className="text-[10px] text-[#666] font-mono">{template.elements.length}</span>
|
||
}
|
||
bodyClassName="ps-panel-body min-h-0"
|
||
>
|
||
{renderLayersPanelBody()}
|
||
</CollapsiblePanelSection>
|
||
</div>
|
||
);
|
||
};
|