移动端适配

This commit is contained in:
iqudoo
2026-06-13 02:23:11 +08:00
parent f2689520bd
commit 6c314f9808
10 changed files with 1058 additions and 533 deletions

View File

@@ -17,6 +17,7 @@ import { LabelDesigner } from './components/LabelDesigner';
import { PropSidebar } from './components/PropSidebar'; import { PropSidebar } from './components/PropSidebar';
import { ExportSidebar } from './components/ExportSidebar'; import { ExportSidebar } from './components/ExportSidebar';
import { MobileExportView } from './components/MobileExportView'; import { MobileExportView } from './components/MobileExportView';
import { MobileDesignView } from './components/MobileDesignView';
import { LayoutPreview } from './components/PreviewGrid'; import { LayoutPreview } from './components/PreviewGrid';
import { CanvasLabelImage } from './components/CanvasLabelImage'; import { CanvasLabelImage } from './components/CanvasLabelImage';
import { renderLabelToDataUrl } from './labelRenderer'; import { renderLabelToDataUrl } from './labelRenderer';
@@ -153,6 +154,9 @@ export default function App() {
const [editTmplName, setEditTmplName] = useState<string>(''); const [editTmplName, setEditTmplName] = useState<string>('');
const [templateMenuId, setTemplateMenuId] = useState<string | null>(null); const [templateMenuId, setTemplateMenuId] = useState<string | null>(null);
const templateMenuRef = useRef<HTMLDivElement | null>(null); const templateMenuRef = useRef<HTMLDivElement | null>(null);
const [headerTemplateMenuOpen, setHeaderTemplateMenuOpen] = useState(false);
const headerTemplateMenuRef = useRef<HTMLDivElement | null>(null);
const importTemplateInputRef = useRef<HTMLInputElement | null>(null);
const designHistory = useUndoRedo<LabelTemplate>(); const designHistory = useUndoRedo<LabelTemplate>();
const skipDesignHistoryRef = useRef(false); const skipDesignHistoryRef = useRef(false);
const designHistorySessionRef = useRef<{ step: string; templateId: string | null }>({ const designHistorySessionRef = useRef<{ step: string; templateId: string | null }>({
@@ -396,6 +400,16 @@ export default function App() {
return () => document.removeEventListener('pointerdown', handlePointerDown); return () => document.removeEventListener('pointerdown', handlePointerDown);
}, [templateMenuId]); }, [templateMenuId]);
useEffect(() => {
if (!headerTemplateMenuOpen) return;
const handlePointerDown = (e: PointerEvent) => {
if (headerTemplateMenuRef.current?.contains(e.target as Node)) return;
setHeaderTemplateMenuOpen(false);
};
document.addEventListener('pointerdown', handlePointerDown);
return () => document.removeEventListener('pointerdown', handlePointerDown);
}, [headerTemplateMenuOpen]);
const handleEditTemplateSubmit = async (e: React.FormEvent) => { const handleEditTemplateSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!editingTemplateId) return; if (!editingTemplateId) return;
@@ -770,7 +784,7 @@ export default function App() {
className={`flex flex-col font-sans h-dvh max-h-dvh overflow-hidden ${workflowStep === 'template-design' || (workflowStep === 'print-view' && !isMobile) className={`flex flex-col font-sans h-dvh max-h-dvh overflow-hidden ${workflowStep === 'template-design' || (workflowStep === 'print-view' && !isMobile)
? 'bg-[#323232] text-[#e8e8e8]' ? 'bg-[#323232] text-[#e8e8e8]'
: workflowStep === 'print-view' && isMobile : workflowStep === 'print-view' && isMobile
? 'bg-slate-100 text-slate-800' ? 'bg-[#323232] text-[#e8e8e8]'
: 'bg-slate-50 text-slate-800' : 'bg-slate-50 text-slate-800'
}`} }`}
> >
@@ -779,7 +793,7 @@ export default function App() {
SCREEN HEADER SCREEN HEADER
========================================= =========================================
*/} */}
{!(isMobile && workflowStep === 'print-view') && ( {workflowStep === 'template-select' && (
<header <header
className={`no-print bg-indigo-700 border-b border-indigo-900 shrink-0 shadow flex flex-wrap items-center justify-between gap-4 mobile-template-header app-header-sticky ${isMobile && workflowStep === 'template-select' && hasTemplates className={`no-print bg-indigo-700 border-b border-indigo-900 shrink-0 shadow flex flex-wrap items-center justify-between gap-4 mobile-template-header app-header-sticky ${isMobile && workflowStep === 'template-select' && hasTemplates
? 'mobile-template-header-row' ? 'mobile-template-header-row'
@@ -807,31 +821,83 @@ export default function App() {
className={`flex items-center gap-2 shrink-0 ${isMobile ? 'ml-auto' : 'flex-wrap' className={`flex items-center gap-2 shrink-0 ${isMobile ? 'ml-auto' : 'flex-wrap'
}`} }`}
> >
{!isMobile && ( {isMobile ? (
<button <>
type="button" <input
onClick={() => setShowAddTmplForm(true)} ref={importTemplateInputRef}
className="inline-flex items-center gap-1.5 px-4 py-2 bg-indigo-600 text-white rounded-xl text-xs font-semibold whitespace-nowrap transition hover:bg-indigo-800 shadow-sm cursor-pointer" type="file"
> accept=".lad"
<Plus className="w-4 h-4 shrink-0" /> onChange={handleImportTemplate}
className="hidden"
</button> />
<div ref={headerTemplateMenuRef} className="relative shrink-0">
<button
type="button"
onClick={() => setHeaderTemplateMenuOpen((open) => !open)}
className="mobile-header-menu-btn"
aria-expanded={headerTemplateMenuOpen}
aria-haspopup="menu"
aria-label="更多"
>
<MoreHorizontal className="w-5 h-5 shrink-0" />
</button>
{headerTemplateMenuOpen && (
<div
role="menu"
className="absolute right-0 top-full mt-1 z-30 min-w-[148px] py-1 bg-white border border-gray-200 rounded-xl shadow-lg"
>
<button
type="button"
role="menuitem"
onClick={() => {
setHeaderTemplateMenuOpen(false);
setShowAddTmplForm(true);
}}
className="inline-flex items-center gap-3 w-full px-3 py-2.5 text-left text-sm text-gray-700 hover:bg-gray-50 active:bg-gray-100 cursor-pointer"
>
<Plus className="w-4 h-4 shrink-0" />
</button>
<button
type="button"
role="menuitem"
onClick={() => {
setHeaderTemplateMenuOpen(false);
importTemplateInputRef.current?.click();
}}
className="inline-flex items-center gap-3 w-full px-3 py-2.5 text-left text-sm text-gray-700 hover:bg-gray-50 active:bg-gray-100 cursor-pointer"
>
<Upload className="w-4 h-4 shrink-0" />
</button>
</div>
)}
</div>
</>
) : (
<>
<button
type="button"
onClick={() => setShowAddTmplForm(true)}
className="inline-flex items-center gap-1.5 px-4 py-2 bg-indigo-600 text-white rounded-xl text-xs font-semibold whitespace-nowrap transition hover:bg-indigo-800 shadow-sm cursor-pointer"
>
<Plus className="w-4 h-4 shrink-0" />
</button>
<label
className="inline-flex items-center gap-1.5 bg-indigo-600 hover:bg-indigo-800 text-white rounded-xl font-semibold whitespace-nowrap transition shadow-sm cursor-pointer px-4 py-2 text-xs"
>
<Upload className="w-4 h-4 shrink-0" />
<input
type="file"
accept=".lad"
onChange={handleImportTemplate}
className="hidden"
/>
</label>
</>
)} )}
<label
className={`inline-flex items-center gap-1.5 bg-indigo-600 hover:bg-indigo-800 text-white rounded-xl font-semibold whitespace-nowrap transition shadow-sm cursor-pointer ${isMobile
? 'min-h-[40px] px-3 py-2 text-[11px]'
: 'px-4 py-2 text-xs'
}`}
>
<Upload className={`${isMobile ? 'w-3.5 h-3.5' : 'w-4 h-4'} shrink-0`} />
<input
type="file"
accept=".lad"
onChange={handleImportTemplate}
className="hidden"
/>
</label>
</div> </div>
)} )}
</header> </header>
@@ -871,16 +937,14 @@ export default function App() {
: '创建模板开始可视化设计,或导入已有的模板文件。'} : '创建模板开始可视化设计,或导入已有的模板文件。'}
</p> </p>
<div className="flex flex-col sm:flex-row items-center justify-center gap-3 mt-3"> <div className="flex flex-col sm:flex-row items-center justify-center gap-3 mt-3">
{!isMobile && ( <button
<button type="button"
type="button" onClick={() => setShowAddTmplForm(true)}
onClick={() => setShowAddTmplForm(true)} className="inline-flex items-center justify-center gap-2 px-5 py-2.5 bg-indigo-600 hover:bg-indigo-700 text-white rounded-xl text-sm font-semibold transition shadow-sm cursor-pointer"
className="inline-flex items-center justify-center gap-2 px-5 py-2.5 bg-indigo-600 hover:bg-indigo-700 text-white rounded-xl text-sm font-semibold transition shadow-sm cursor-pointer" >
> <Plus className="w-4 h-4 shrink-0" />
<Plus className="w-4 h-4 shrink-0" />
</button>
</button>
)}
<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"> <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" /> <Upload className="w-4 h-4 shrink-0" />
@@ -975,6 +1039,20 @@ export default function App() {
</button> </button>
)} )}
{isMobile && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handleSelectTemplate(tmpl.id!);
setWorkflowStep('template-design');
}}
className="mobile-secondary-btn flex-1 min-w-0 justify-center min-h-[44px] px-3 py-2.5 rounded-xl text-[13px] font-semibold"
>
<Code className="w-4 h-4 shrink-0" />
</button>
)}
<button <button
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
@@ -986,7 +1064,7 @@ export default function App() {
}`} }`}
> >
<Printer className={`${isMobile ? 'w-4 h-4' : 'w-3.5 h-3.5'} shrink-0`} /> <Printer className={`${isMobile ? 'w-4 h-4' : 'w-3.5 h-3.5'} shrink-0`} />
{isMobile ? '排版生成标签' : '排版导出'} {isMobile ? '生成' : '排版导出'}
</button> </button>
<div <div
ref={templateMenuId === tmpl.id ? templateMenuRef : null} ref={templateMenuId === tmpl.id ? templateMenuRef : null}
@@ -1067,27 +1145,20 @@ export default function App() {
{/* ======================================================== {/* ========================================================
STEP 2: The visual drag-and-drop label designer STEP 2: The visual drag-and-drop label designer
======================================================== */} ======================================================== */}
{workflowStep === 'template-design' && isMobile && hasTemplates && ( {workflowStep === 'template-design' && isMobile && hasTemplates && activeTemplate && (
<div className="mobile-design-block bg-[#292929] text-[#e8e8e8] flex-1"> <MobileDesignView
<p className="text-sm text-[#666666] max-w-[280px] leading-relaxed"> template={activeTemplate}
paper={paper}
</p> selectedElementIds={selectedElementIds}
<button onSelectElements={setSelectedElementIds}
type="button" onChangeTemplate={handleDesignTemplateChange}
onClick={() => openPrintView()} canUndo={designHistory.canUndo}
className="inline-flex items-center gap-2 px-5 py-2.5 bg-indigo-600 hover:bg-indigo-700 active:bg-indigo-800 text-white text-sm font-semibold rounded-lg cursor-pointer" canRedo={designHistory.canRedo}
> onUndo={handleDesignUndo}
<Printer className="w-4 h-4" /> onRedo={handleDesignRedo}
onBack={() => setWorkflowStep('template-select')}
</button> onExport={() => openPrintView()}
<button />
type="button"
onClick={() => setWorkflowStep('template-select')}
className="text-[12px] text-[#999] underline cursor-pointer"
>
</button>
</div>
)} )}
{workflowStep === 'template-design' && !isMobile && hasTemplates && activeTemplate && ( {workflowStep === 'template-design' && !isMobile && hasTemplates && activeTemplate && (
@@ -1175,15 +1246,25 @@ export default function App() {
dataColumns={dataColumns} dataColumns={dataColumns}
variableColumnMapping={variableColumnMapping} variableColumnMapping={variableColumnMapping}
onChangeMapping={handleMappingChange} onChangeMapping={handleMappingChange}
paper={paper} paper={previewSnapshot?.paper ?? paper}
onChangePaper={handlePaperChange} onChangePaper={handlePaperChange}
rows={mappedRows} rows={mappedRows}
draftExportRange={draftExportRange} draftExportRange={draftExportRange}
onChangeDraftExportRange={setDraftExportRange} onChangeDraftExportRange={setDraftExportRange}
cutLine={cutLine} cutLine={cutLine}
onChangeCutLine={handleCutLineChange} onChangeCutLine={handleCutLineChange}
selectedCount={selectedCount} printablePages={previewSnapshot?.pages ?? []}
totalPages={totalPages} gridCols={previewSnapshot?.gridCols ?? gridInfo.cols}
labelsPerPage={previewSnapshot?.labelsPerPage ?? labelsPerPage}
totalPages={previewSnapshot?.totalPages ?? totalPages}
selectedCount={previewSnapshot?.selectedCount ?? selectedCount}
draftTotalPages={totalPages}
draftSelectedCount={selectedCount}
totalRowCount={activeRows.length}
previewReady={previewSnapshot !== null}
previewLayoutStale={previewLayoutStale}
activeRowIndex={activeRowIndex}
onSelectRowIndex={setActiveRowIndex}
pdfGenerating={pdfGenerating} pdfGenerating={pdfGenerating}
onBack={() => setWorkflowStep('template-select')} onBack={() => setWorkflowStep('template-select')}
onExportPdf={exportPdf} onExportPdf={exportPdf}

View File

@@ -156,7 +156,7 @@ export const ExportSidebar: React.FC<ExportSidebarProps> = ({
collapsed={dataCollapsed} collapsed={dataCollapsed}
onToggle={() => toggleExportPanel('data')} onToggle={() => toggleExportPanel('data')}
icon={<Database className="w-3.5 h-3.5 shrink-0 text-[#31a8ff]" />} icon={<Database className="w-3.5 h-3.5 shrink-0 text-[#31a8ff]" />}
title="数据源与变量绑定" title="数据源"
bodyClassName="ps-panel-body p-3 min-h-0 space-y-3" bodyClassName="ps-panel-body p-3 min-h-0 space-y-3"
> >
<DataImporter <DataImporter
@@ -188,7 +188,7 @@ export const ExportSidebar: React.FC<ExportSidebarProps> = ({
collapsed={layoutCollapsed} collapsed={layoutCollapsed}
onToggle={() => toggleExportPanel('layout')} onToggle={() => toggleExportPanel('layout')}
icon={<Layout className="w-3.5 h-3.5 shrink-0 text-[#31a8ff]" />} icon={<Layout className="w-3.5 h-3.5 shrink-0 text-[#31a8ff]" />}
title="整页排版 & PDF 设置" title="排版"
bodyClassName="ps-panel-body p-3 min-h-0 space-y-3" bodyClassName="ps-panel-body p-3 min-h-0 space-y-3"
> >
<PaperSettingsPanel <PaperSettingsPanel

View File

@@ -19,6 +19,7 @@ interface LabelDesignerProps {
onChange: (updated: LabelTemplate) => void; onChange: (updated: LabelTemplate) => void;
selectedElementIds: string[]; selectedElementIds: string[];
onSelectElements: (ids: string[]) => void; onSelectElements: (ids: string[]) => void;
variant?: 'desktop' | 'mobile';
} }
type ActiveTool = 'move' | 'text' | 'barcode' | 'qrcode'; type ActiveTool = 'move' | 'text' | 'barcode' | 'qrcode';
@@ -134,9 +135,11 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
onChange, onChange,
selectedElementIds, selectedElementIds,
onSelectElements, onSelectElements,
variant = 'desktop',
}) => { }) => {
const selectedElementId = selectedElementIds[0] || null; const selectedElementId = selectedElementIds[0] || null;
const isNarrow = useIsNarrowScreen(); const isMobileLayout = variant === 'mobile';
const isNarrow = useIsNarrowScreen() || isMobileLayout;
const [zoom, setZoom] = useState<number>(MODERATE_ZOOM_MAX); const [zoom, setZoom] = useState<number>(MODERATE_ZOOM_MAX);
const [activeTool, setActiveTool] = useState<ActiveTool>('move'); const [activeTool, setActiveTool] = useState<ActiveTool>('move');
const canvasAreaRef = useRef<HTMLDivElement | null>(null); const canvasAreaRef = useRef<HTMLDivElement | null>(null);
@@ -744,7 +747,7 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
const fitToView = () => { const fitToView = () => {
const area = canvasAreaRef.current; const area = canvasAreaRef.current;
if (!area) return; if (!area) return;
const pad = 80; const pad = isMobileLayout ? 32 : 80;
const availW = area.clientWidth - pad; const availW = area.clientWidth - pad;
const availH = area.clientHeight - pad; const availH = area.clientHeight - pad;
const docW = mmToPx(template.width, 1); const docW = mmToPx(template.width, 1);
@@ -753,6 +756,12 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
setZoom(Math.max(0.5, Math.round(fitZoom * 100) / 100)); setZoom(Math.max(0.5, Math.round(fitZoom * 100) / 100));
}; };
useEffect(() => {
if (!isMobileLayout) return;
const timer = window.setTimeout(() => fitToView(), 0);
return () => window.clearTimeout(timer);
}, [isMobileLayout, template.width, template.height]);
const selectedElemObj = (localElements || template.elements).find( const selectedElemObj = (localElements || template.elements).find(
(el) => el.id === selectedElementId (el) => el.id === selectedElementId
); );
@@ -777,44 +786,85 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
{ id: 'qrcode', icon: <QrCode className="w-[18px] h-[18px]" />, label: '二维码' }, { id: 'qrcode', icon: <QrCode className="w-[18px] h-[18px]" />, label: '二维码' },
]; ];
return ( const mainToolButtons = (
<div className="flex flex-1 min-w-0 min-h-0"> <>
<div className="ps-toolbar flex flex-col"> {tools.map((tool) => (
{tools.map((tool) => (
<button
key={tool.id}
className={`ps-tool-btn ${activeTool === tool.id ? 'active' : ''}`}
onClick={() => handleToolClick(tool.id)}
title={`${tool.label}${tool.shortcut ? ` (${tool.shortcut})` : ''}`}
>
{tool.icon}
</button>
))}
<button <button
className="ps-tool-btn" key={tool.id}
onClick={handleImageToolClick} className={`ps-tool-btn ${activeTool === tool.id ? 'active' : ''} ${isMobileLayout ? 'mobile-design-tool-btn' : ''}`}
title="图片工具" onClick={() => handleToolClick(tool.id)}
title={`${tool.label}${tool.shortcut ? ` (${tool.shortcut})` : ''}`}
> >
<ImageIcon className="w-[18px] h-[18px]" /> {tool.icon}
</button>
))}
<button
className={`ps-tool-btn ${isMobileLayout ? 'mobile-design-tool-btn' : ''}`}
onClick={handleImageToolClick}
title="图片工具"
>
<ImageIcon className="w-[18px] h-[18px]" />
</button>
<input
ref={imageInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={handleImageFile}
/>
</>
);
const deleteToolButton =
selectedElementIds.length > 0 ? (
<button
className={`ps-tool-btn hover:!bg-red-900/40 hover:!text-red-400 ${isMobileLayout ? 'mobile-design-tool-btn' : ''}`}
onClick={deleteSelectedElements}
title="删除选中元素"
>
<Trash2 className="w-[18px] h-[18px]" />
</button>
) : null;
const zoomControls = (
<div className="flex items-center gap-1.5 shrink-0">
<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" />
</button>
<span className="px-1.5 text-[10px] font-mono text-[#ccc] min-w-[42px] text-center select-none">
{Math.round(zoom * 100)}%
</span>
<button className="ps-zoom-btn" onClick={() => setZoom(Math.min(5, zoom + 0.25))} title="放大">
<ZoomIn className="w-3 h-3" />
</button> </button>
<input
ref={imageInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={handleImageFile}
/>
<div className="flex-1" />
{selectedElementIds.length > 0 && (
<button
className="ps-tool-btn hover:!bg-red-900/40 hover:!text-red-400"
onClick={deleteSelectedElements}
title="删除选中元素 (Del)"
>
<Trash2 className="w-[18px] h-[18px]" />
</button>
)}
</div> </div>
<button
type="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"
title="适合窗口"
>
</button>
</div>
);
return (
<div
className={
isMobileLayout
? 'mobile-design-canvas-shell flex flex-col flex-1 min-w-0 min-h-0'
: 'flex flex-1 min-w-0 min-h-0'
}
>
{!isMobileLayout && (
<div className="ps-toolbar flex flex-col">
{mainToolButtons}
<div className="flex-1" />
{deleteToolButton}
</div>
)}
<div className="flex flex-col flex-1 min-w-0 min-h-0"> <div className="flex flex-col flex-1 min-w-0 min-h-0">
<div <div
@@ -839,7 +889,7 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
height: `${mmToPx(template.height, zoom)}px`, height: `${mmToPx(template.height, zoom)}px`,
}} }}
> >
<div className="absolute -top-5 left-0 text-[10px] font-mono text-[#aaa]"> {/* <div className="absolute -top-5 left-0 text-[10px] font-mono text-[#aaa]">
{template.width} mm {template.width} mm
</div> </div>
<div <div
@@ -847,7 +897,7 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
style={{ writingMode: 'vertical-rl', transform: 'rotate(180deg)' }} style={{ writingMode: 'vertical-rl', transform: 'rotate(180deg)' }}
> >
{template.height} mm {template.height} mm
</div> </div> */}
{/* 整页统一渲染:半透明背景按图层层级与下方元素真实合成 */} {/* 整页统一渲染:半透明背景按图层层级与下方元素真实合成 */}
<div className="absolute inset-0 overflow-hidden pointer-events-none z-0"> <div className="absolute inset-0 overflow-hidden pointer-events-none z-0">
@@ -962,71 +1012,63 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
</div> </div>
</div> </div>
<div className={`ps-status-bar ${isNarrow ? 'ps-status-bar-compact' : ''}`}> {isMobileLayout ? (
<div className="flex items-center gap-2 min-w-0 truncate"> <div className="mobile-design-toolstrip">
{isNarrow ? ( <div className="mobile-design-toolstrip-tools">
<> {mainToolButtons}
{deleteToolButton}
</div>
{zoomControls}
</div>
) : (
<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"> <span className="truncate">
{template.width}×{template.height} {template.width}×{template.height}
{selectedElemObj ? ` · ${selectedElemObj.name}` : ''} {selectedElemObj ? ` · ${selectedElemObj.name}` : ''}
{selectedElementIds.length > 1 ? ` · ${selectedElementIds.length}` : ''} {selectedElementIds.length > 1 ? ` · ${selectedElementIds.length}` : ''}
</span> </span>
</> ) : (
) : ( <>
<> <span>
<span> : {template.width} × {template.height} mm
: {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-2 shrink-0">
{!isNarrow && (
<span className="hidden sm:inline text-[10px]">
使 · · Ctrl+
</span> </span>
{selectedElemObj && ( )}
<span className="text-[#31a8ff]"> {zoomControls}
| {selectedElemObj.name} X:{selectedElemObj.x} Y:{selectedElemObj.y} W: <button
{selectedElemObj.width} H:{selectedElemObj.height} mm type="button"
</span> 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"
{selectedElementIds.length > 1 && ( title="适中缩放"
<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-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" />
</button>
<span className="px-1.5 text-[10px] font-mono text-[#ccc] min-w-[42px] text-center select-none">
{Math.round(zoom * 100)}%
</span>
<button className="ps-zoom-btn" onClick={() => setZoom(Math.min(5, zoom + 0.25))} title="放大">
<ZoomIn className="w-3 h-3" />
</button> </button>
</div> </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"
title="适合窗口"
>
</button>
</div> </div>
</div> )}
</div> </div>
</div> </div>
); );

View File

@@ -0,0 +1,100 @@
import React from 'react';
import { ChevronLeft, Printer, Undo2, Redo2 } from 'lucide-react';
import { LabelTemplate, PaperConfig } from '../types';
import { LabelDesigner } from './LabelDesigner';
import { PropSidebar } from './PropSidebar';
interface MobileDesignViewProps {
template: LabelTemplate;
paper: PaperConfig;
selectedElementIds: string[];
onSelectElements: (ids: string[]) => void;
onChangeTemplate: (updated: LabelTemplate) => void;
canUndo: boolean;
canRedo: boolean;
onUndo: () => void;
onRedo: () => void;
onBack: () => void;
onExport: () => void;
}
export const MobileDesignView: React.FC<MobileDesignViewProps> = ({
template,
paper,
selectedElementIds,
onSelectElements,
onChangeTemplate,
canUndo,
canRedo,
onUndo,
onRedo,
onBack,
onExport,
}) => {
return (
<div className="mobile-design-view">
<header className="mobile-design-header">
<button type="button" onClick={onBack} className="mobile-export-back" aria-label="返回">
<ChevronLeft className="w-5 h-5" />
</button>
<div className="min-w-0 flex-1 px-1">
<p className="text-sm font-semibold text-slate-100 truncate">{template.name}</p>
<p className="text-[11px] text-slate-400">
{template.width}×{template.height} mm
</p>
</div>
<div className="flex items-center gap-0.5 shrink-0">
<button
type="button"
onClick={onUndo}
disabled={!canUndo}
className="mobile-design-header-icon-btn"
aria-label="撤销"
>
<Undo2 className="w-4 h-4" />
</button>
<button
type="button"
onClick={onRedo}
disabled={!canRedo}
className="mobile-design-header-icon-btn"
aria-label="恢复"
>
<Redo2 className="w-4 h-4" />
</button>
<button
type="button"
onClick={onExport}
className="inline-flex items-center gap-1 ml-1 px-2.5 py-1.5 bg-indigo-600 hover:bg-indigo-700 active:bg-indigo-800 text-white text-[11px] font-semibold rounded-lg"
>
<Printer className="w-3.5 h-3.5" />
</button>
</div>
</header>
<div className="mobile-design-canvas-wrap">
<LabelDesigner
variant="mobile"
template={template}
onChange={onChangeTemplate}
selectedElementIds={selectedElementIds}
onSelectElements={onSelectElements}
/>
</div>
<div className="mobile-design-props-wrap">
<PropSidebar
variant="mobile"
template={template}
onChangeTemplate={onChangeTemplate}
selectedElementIds={selectedElementIds}
onSelectElements={onSelectElements}
activeTab="element"
onChangeTab={() => {}}
paper={paper}
/>
</div>
</div>
);
};

View File

@@ -0,0 +1,115 @@
import React from 'react';
import { Settings } from 'lucide-react';
import {
CutLineConfig,
ExportRangeConfig,
LabelTemplate,
PaperConfig,
RecordRow,
} from '../types';
import { PaperSettingsPanel, PageInput } from './PreviewGrid';
interface MobileExportLayoutPanelProps {
template: LabelTemplate;
paper: PaperConfig;
onChangePaper: (paper: PaperConfig) => void;
rows: RecordRow[];
exportRange: ExportRangeConfig;
cutLine: CutLineConfig;
onChangeCutLine: (cutLine: CutLineConfig) => void;
}
export const MobileExportLayoutPanel: React.FC<MobileExportLayoutPanelProps> = ({
template,
paper,
onChangePaper,
rows,
exportRange,
cutLine,
onChangeCutLine,
}) => {
return (
<div className="space-y-4">
<PaperSettingsPanel
theme="ps"
paper={paper}
onChangePaper={onChangePaper}
template={template}
rows={rows}
exportRange={exportRange}
totalRowCount={rows.length}
/>
<div className="ps-section-divider space-y-2">
<div className="ps-section-title">
<Settings className="w-3 h-3" />
线
</div>
<label className="flex items-center gap-2 cursor-pointer text-[11px] text-[#ccc] select-none min-h-[40px]">
<input
type="checkbox"
checked={cutLine.enabled}
onChange={(e) => onChangeCutLine({ ...cutLine, enabled: e.target.checked })}
className="ps-checkbox"
/>
<span> PDF 线</span>
</label>
<fieldset
disabled={!cutLine.enabled}
className={`space-y-2 border-0 p-0 m-0 min-w-0 ${!cutLine.enabled ? 'opacity-55' : ''}`}
>
<div>
<label className="ps-field-label">线</label>
<select
value={cutLine.style}
onChange={(e) =>
onChangeCutLine({
...cutLine,
style: e.target.value as CutLineConfig['style'],
})
}
className="ps-field text-[11px]"
>
<option value="dashed">线</option>
<option value="solid">线</option>
</select>
</div>
<div>
<label className="ps-field-label">线 (mm)</label>
<PageInput
type="number"
min="0.02"
max="2"
step="0.02"
value={cutLine.width}
onCommit={(val) =>
onChangeCutLine({
...cutLine,
width: Math.max(0.02, Math.min(2, Number(val) || 0.1)),
})
}
inputClassName="ps-field font-mono text-[11px]"
/>
</div>
<div>
<label className="ps-field-label"></label>
<div className="flex items-center gap-2">
<input
type="color"
value={cutLine.color}
onChange={(e) => onChangeCutLine({ ...cutLine, color: e.target.value })}
className="color-input shrink-0"
aria-label="裁切线颜色"
/>
<input
type="text"
value={cutLine.color}
onChange={(e) => onChangeCutLine({ ...cutLine, color: e.target.value })}
className="ps-field font-mono text-[11px] flex-1 min-w-0"
/>
</div>
</div>
</fieldset>
</div>
</div>
);
};

View File

@@ -1,146 +0,0 @@
import React, { useState } from 'react';
import { ChevronDown, Settings } from 'lucide-react';
import {
CutLineConfig,
ExportRangeConfig,
LabelTemplate,
PaperConfig,
RecordRow,
} from '../types';
import { PaperSettingsPanel, PageInput } from './PreviewGrid';
interface MobileExportPropsPanelProps {
template: LabelTemplate;
paper: PaperConfig;
onChangePaper: (paper: PaperConfig) => void;
rows: RecordRow[];
exportRange: ExportRangeConfig;
cutLine: CutLineConfig;
onChangeCutLine: (cutLine: CutLineConfig) => void;
locked?: boolean;
}
export const MobileExportPropsPanel: React.FC<MobileExportPropsPanelProps> = ({
template,
paper,
onChangePaper,
rows,
exportRange,
cutLine,
onChangeCutLine,
locked = false,
}) => {
const [open, setOpen] = useState(false);
if (locked) return null;
const paperLabel =
paper.type === 'custom' ? `${paper.width}×${paper.height} mm` : paper.type;
const summary = `${paperLabel} · ${paper.orientation === 'portrait' ? '纵向' : '横向'}${
cutLine.enabled ? ' · 裁切线' : ''
}`;
return (
<section className={`mobile-export-card mobile-export-props-card ${open ? 'is-open' : ''}`}>
<button
type="button"
className="mobile-export-card-head mobile-export-props-head"
onClick={() => setOpen((value) => !value)}
aria-expanded={open}
>
<Settings className="w-4 h-4 text-indigo-500 shrink-0 mt-0.5" />
<div className="flex-1 min-w-0 text-left">
<h2></h2>
<p>{summary}</p>
</div>
<ChevronDown
className={`w-5 h-5 text-slate-400 shrink-0 mt-0.5 transition-transform duration-200 ${
open ? 'rotate-180' : ''
}`}
/>
</button>
{open && (
<div className="mobile-export-card-body">
<PaperSettingsPanel
theme="mobile"
paper={paper}
onChangePaper={onChangePaper}
template={template}
rows={rows}
exportRange={exportRange}
totalRowCount={rows.length}
/>
<div className="space-y-3 pt-1 mt-3">
<h4 className="mobile-paper-section-title">线</h4>
<label className="flex items-center gap-3 min-h-[44px] cursor-pointer select-none">
<input
type="checkbox"
checked={cutLine.enabled}
onChange={(e) => onChangeCutLine({ ...cutLine, enabled: e.target.checked })}
className="w-5 h-5 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500"
/>
<span className="text-sm text-slate-700"> PDF 线</span>
</label>
<fieldset
disabled={!cutLine.enabled}
className={`space-y-3 border-0 p-0 m-0 min-w-0 ${!cutLine.enabled ? 'opacity-50' : ''}`}
>
<div>
<label className="mobile-field-label">线</label>
<select
value={cutLine.style}
onChange={(e) =>
onChangeCutLine({
...cutLine,
style: e.target.value as CutLineConfig['style'],
})
}
className="mobile-field"
>
<option value="dashed">线</option>
<option value="solid">线</option>
</select>
</div>
<div>
<label className="mobile-field-label">线 (mm)</label>
<PageInput
type="number"
min="0.02"
max="2"
step="0.02"
value={cutLine.width}
onCommit={(val) =>
onChangeCutLine({
...cutLine,
width: Math.max(0.02, Math.min(2, Number(val) || 0.1)),
})
}
inputClassName="mobile-field font-mono"
/>
</div>
<div>
<label className="mobile-field-label"></label>
<div className="flex items-center gap-2">
<input
type="color"
value={cutLine.color}
onChange={(e) => onChangeCutLine({ ...cutLine, color: e.target.value })}
className="color-input shrink-0"
aria-label="裁切线颜色"
/>
<input
type="text"
value={cutLine.color}
onChange={(e) => onChangeCutLine({ ...cutLine, color: e.target.value })}
className="mobile-field font-mono flex-1 min-w-0"
/>
</div>
</div>
</fieldset>
</div>
</div>
)}
</section>
);
};

View File

@@ -1,10 +1,10 @@
import React from 'react'; import React, { useState } from 'react';
import { import {
ChevronLeft, ChevronLeft,
Printer, FileDown,
Loader2, Loader2,
Database, Database,
Link2, Layout,
Download, Download,
} from 'lucide-react'; } from 'lucide-react';
import { import {
@@ -15,11 +15,23 @@ import {
PaperConfig, PaperConfig,
CutLineConfig, CutLineConfig,
} from '../types'; } from '../types';
import { isVariableMappingConfigured } from '../utils'; import { isVariableMappingConfigured, type PrintableLabel } from '../utils';
import { DataImporter, downloadDataTemplate } from './DataImporter'; import { DataImporter, downloadDataTemplate } from './DataImporter';
import { VariableMappingPanel } from './VariableMappingPanel'; import { VariableMappingPanel } from './VariableMappingPanel';
import { MobileExportPropsPanel } from './MobileExportPropsPanel'; import { MobileExportLayoutPanel } from './MobileExportLayoutPanel';
import { DataRangeFields } from './DataRangeFields'; import { DataRangeFields } from './DataRangeFields';
import { LayoutPreview } from './PreviewGrid';
type MobileExportModule = 'data' | 'layout';
const MOBILE_EXPORT_MODULES: {
id: MobileExportModule;
label: string;
icon: React.ReactNode;
}[] = [
{ id: 'data', label: '数据源', icon: <Database className="w-4 h-4" /> },
{ id: 'layout', label: '排版', icon: <Layout className="w-4 h-4" /> },
];
interface MobileExportViewProps { interface MobileExportViewProps {
template: LabelTemplate; template: LabelTemplate;
@@ -38,8 +50,18 @@ interface MobileExportViewProps {
onChangeDraftExportRange: (range: ExportRangeConfig) => void; onChangeDraftExportRange: (range: ExportRangeConfig) => void;
cutLine: CutLineConfig; cutLine: CutLineConfig;
onChangeCutLine: (cutLine: CutLineConfig) => void; onChangeCutLine: (cutLine: CutLineConfig) => void;
selectedCount: number; printablePages: (PrintableLabel | null)[][];
gridCols: number;
labelsPerPage: number;
totalPages: number; totalPages: number;
selectedCount: number;
draftTotalPages: number;
draftSelectedCount: number;
totalRowCount: number;
previewReady: boolean;
previewLayoutStale: boolean;
activeRowIndex: number;
onSelectRowIndex: (idx: number) => void;
pdfGenerating: boolean; pdfGenerating: boolean;
onBack: () => void; onBack: () => void;
onExportPdf: () => void; onExportPdf: () => void;
@@ -62,12 +84,24 @@ export const MobileExportView: React.FC<MobileExportViewProps> = ({
onChangeDraftExportRange, onChangeDraftExportRange,
cutLine, cutLine,
onChangeCutLine, onChangeCutLine,
selectedCount, printablePages,
gridCols,
labelsPerPage,
totalPages, totalPages,
selectedCount,
draftTotalPages,
draftSelectedCount,
totalRowCount,
previewReady,
previewLayoutStale,
activeRowIndex,
onSelectRowIndex,
pdfGenerating, pdfGenerating,
onBack, onBack,
onExportPdf, onExportPdf,
}) => { }) => {
const [mobileModule, setMobileModule] = useState<MobileExportModule>('data');
const hasData = rows.length > 0; const hasData = rows.length > 0;
const mappedCount = templateVariables.filter((v) => const mappedCount = templateVariables.filter((v) =>
isVariableMappingConfigured(variableColumnMapping, v, dataColumns) isVariableMappingConfigured(variableColumnMapping, v, dataColumns)
@@ -83,133 +117,155 @@ export const MobileExportView: React.FC<MobileExportViewProps> = ({
? '当前数据范围内无标签' ? '当前数据范围内无标签'
: null; : null;
const exportDisabled = !!exportBlockedReason || pdfGenerating;
return ( return (
<div className="mobile-export-view"> <div className="mobile-export-view mobile-design-view">
<header className="no-print bg-indigo-700 border-b border-indigo-900 shrink-0 shadow mobile-export-header px-4"> <header className="mobile-design-header">
<div className="flex items-center w-full"> <button type="button" onClick={onBack} className="mobile-export-back" aria-label="返回">
<label <ChevronLeft className="w-5 h-5" />
onClick={onBack} </button>
className={`inline-flex items-center gap-1.5 bg-indigo-600 hover:bg-indigo-800 text-white rounded-xl font-semibold whitespace-nowrap transition shadow-sm cursor-pointer <div className="min-w-0 flex-1 px-1">
min-h-[40px] px-3 py-2 text-[11px]`} <p className="text-sm font-semibold text-slate-100 truncate" title={templateName}>
>
<ChevronLeft className="w-5 h-5" />
</label>
<p
className="flex-1 min-w-0 px-2 text-center text-sm font-bold tracking-wide text-slate-100 truncate"
title={templateName}
>
{templateName} {templateName}
</p> </p>
<div className="w-10 shrink-0" aria-hidden /> <p className="text-[11px] text-slate-400 truncate">
{previewReady
? `${selectedCount} 枚 · ${totalPages}`
: hasData
? `已上传 ${rows.length}`
: '上传数据后开始排版'}
</p>
</div> </div>
</header>
<main className="mobile-export-content">
<section className="mobile-export-card">
<div className="mobile-export-card-head">
<Database className="w-4 h-4 text-indigo-500 shrink-0" />
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<h2></h2>
<p>{hasData ? '已上传,可设置数据范围' : '上传 Excel 或 CSV 文件'}</p>
</div>
<button
type="button"
onClick={() => downloadDataTemplate(templateName, templateVariables)}
className="mobile-export-icon-btn"
aria-label="下载数据模板"
>
<Download className="w-5 h-5" />
</button>
</div>
</div>
</div>
<div className="mobile-export-card-body space-y-5">
<DataImporter
variant="mobile"
importData={importData}
onDataLoaded={onDataLoaded}
onClear={onClear}
templateName={templateName}
templateVariables={templateVariables}
/>
{hasData && (
<DataRangeFields
variant="mobile"
exportRange={draftExportRange}
onChangeExportRange={onChangeDraftExportRange}
rows={rows}
paper={paper}
template={template}
/>
)}
</div>
</section>
{hasData && (
<section className="mobile-export-card">
<div className="mobile-export-card-head">
<Link2 className="w-4 h-4 text-indigo-500 shrink-0" />
<div className="flex-1 min-w-0">
<h2></h2>
<p>
{needsMapping
? `已关联 ${mappedCount}/${templateVariables.length}`
: '此模板无需变量映射'}
</p>
</div>
</div>
<div className="mobile-export-card-body">
<VariableMappingPanel
variant="mobile"
variables={templateVariables}
columns={dataColumns}
mapping={variableColumnMapping}
onChangeMapping={onChangeMapping}
variableDefaults={template.variableDefaults}
/>
</div>
</section>
)}
<MobileExportPropsPanel
template={template}
paper={paper}
onChangePaper={onChangePaper}
rows={rows}
exportRange={draftExportRange}
cutLine={cutLine}
onChangeCutLine={onChangeCutLine}
locked={!hasData}
/>
</main>
<footer className="mobile-export-footer">
{exportBlockedReason && !pdfGenerating && (
<p className="mobile-export-footer-hint">{exportBlockedReason}</p>
)}
<button <button
type="button" type="button"
onClick={onExportPdf} onClick={onExportPdf}
disabled={!!exportBlockedReason || pdfGenerating} disabled={exportDisabled}
className="mobile-export-submit" className={`inline-flex items-center gap-1 ml-1 px-2.5 py-1.5 text-[11px] font-semibold rounded-lg shrink-0 ${
exportDisabled
? 'bg-[#444] text-[#888] cursor-not-allowed'
: 'bg-indigo-600 hover:bg-indigo-700 active:bg-indigo-800 text-white'
}`}
> >
{pdfGenerating ? ( {pdfGenerating ? (
<> <Loader2 className="w-3.5 h-3.5 animate-spin" />
<Loader2 className="w-5 h-5 animate-spin shrink-0" />
PDF
</>
) : ( ) : (
<> <FileDown className="w-3.5 h-3.5" />
<Printer className="w-5 h-5 shrink-0" />
{exportBlockedReason
? '生成标签PDF文件'
: `生成标签PDF文件${selectedCount} 枚 · ${totalPages} 页)`}
</>
)} )}
PDF
</button> </button>
</footer> </header>
<div className="mobile-design-canvas-wrap mobile-export-preview-wrap">
<LayoutPreview
variant="mobile"
paper={paper}
template={template}
printablePages={printablePages}
gridCols={gridCols}
labelsPerPage={labelsPerPage}
totalPages={totalPages}
selectedCount={selectedCount}
totalRowCount={totalRowCount}
draftTotalPages={draftTotalPages}
draftSelectedCount={draftSelectedCount}
previewReady={previewReady}
previewLayoutStale={previewLayoutStale}
cutLine={cutLine}
activeRowIndex={activeRowIndex}
onSelectRowIndex={onSelectRowIndex}
compact
/>
</div>
<div className="mobile-design-props-wrap">
<div className="mobile-design-props">
<nav className="mobile-design-props-nav mobile-export-props-nav" aria-label="导出模块">
{MOBILE_EXPORT_MODULES.map((item) => (
<button
key={item.id}
type="button"
onClick={() => setMobileModule(item.id)}
className={`mobile-design-props-nav-btn mobile-export-props-nav-btn ${
mobileModule === item.id ? 'active' : ''
}`}
>
{item.icon}
<span>{item.label}</span>
</button>
))}
</nav>
<div className="mobile-design-props-panel scrollbar-hide">
{mobileModule === 'data' && (
<div className="ps-panel-body p-3 space-y-4 min-h-0">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="text-[11px] font-semibold text-[#e8e8e8]"></p>
<p className="text-[10px] text-[#888] mt-0.5">
{hasData ? '已上传,可设置数据范围' : '上传 Excel 或 CSV'}
</p>
</div>
<button
type="button"
onClick={() => downloadDataTemplate(templateName, templateVariables)}
className="mobile-design-header-icon-btn shrink-0"
aria-label="下载数据模板"
>
<Download className="w-4 h-4" />
</button>
</div>
<DataImporter
variant="ps"
importData={importData}
onDataLoaded={onDataLoaded}
onClear={onClear}
templateName={templateName}
templateVariables={templateVariables}
/>
{hasData && (
<>
<div>
<p className="text-[11px] font-semibold text-[#e8e8e8] mb-2"></p>
<VariableMappingPanel
variant="ps"
variables={templateVariables}
columns={dataColumns}
mapping={variableColumnMapping}
onChangeMapping={onChangeMapping}
variableDefaults={template.variableDefaults}
/>
</div>
<DataRangeFields
variant="ps"
exportRange={draftExportRange}
onChangeExportRange={onChangeDraftExportRange}
rows={rows}
paper={paper}
template={template}
/>
</>
)}
{exportBlockedReason && !pdfGenerating && (
<p className="text-[10px] text-amber-500/90 text-center">{exportBlockedReason}</p>
)}
</div>
)}
{mobileModule === 'layout' && (
<div className="ps-panel-body p-3 min-h-0">
<MobileExportLayoutPanel
template={template}
paper={paper}
onChangePaper={onChangePaper}
rows={rows}
exportRange={draftExportRange}
cutLine={cutLine}
onChangeCutLine={onChangeCutLine}
/>
</div>
)}
</div>
</div>
</div>
</div> </div>
); );
}; };

View File

@@ -189,7 +189,7 @@ export const PaperSettingsPanel: React.FC<PaperSettingsPanelProps> = ({
> >
{!isPs && !isMobile && ( {!isPs && !isMobile && (
<div className="flex items-center gap-2 mb-1"> <div className="flex items-center gap-2 mb-1">
<h3 className="text-sm font-bold text-gray-900 leading-none"> & PDF </h3> <h3 className="text-sm font-bold text-gray-900 leading-none"></h3>
</div> </div>
)} )}
{isPs && ( {isPs && (
@@ -410,6 +410,7 @@ export interface LayoutPreviewProps {
compact?: boolean; compact?: boolean;
collapsed?: boolean; collapsed?: boolean;
onToggleCollapsed?: () => void; onToggleCollapsed?: () => void;
variant?: 'desktop' | 'mobile';
} }
export const LayoutPreview: React.FC<LayoutPreviewProps> = ({ export const LayoutPreview: React.FC<LayoutPreviewProps> = ({
@@ -431,8 +432,12 @@ export const LayoutPreview: React.FC<LayoutPreviewProps> = ({
compact = false, compact = false,
collapsed = false, collapsed = false,
onToggleCollapsed, onToggleCollapsed,
variant = 'desktop',
}) => { }) => {
const [previewScale, setPreviewScale] = useState<number>(compact ? 1.4 : 3.3); const isMobileVariant = variant === 'mobile';
const [previewScale, setPreviewScale] = useState<number>(
isMobileVariant ? 1.15 : compact ? 1.4 : 3.3
);
const [showAllPages, setShowAllPages] = useState(false); const [showAllPages, setShowAllPages] = useState(false);
const MAX_PREVIEW_PAGES = 3; const MAX_PREVIEW_PAGES = 3;
@@ -478,29 +483,33 @@ export const LayoutPreview: React.FC<LayoutPreviewProps> = ({
className={`flex-1 flex flex-col min-h-0 min-w-0 layout-preview-panel ${compact && collapsed ? 'layout-preview-collapsed' : '' className={`flex-1 flex flex-col min-h-0 min-w-0 layout-preview-panel ${compact && collapsed ? 'layout-preview-collapsed' : ''
}`} }`}
> >
<div className="ps-preview-toolbar"> <div className={`ps-preview-toolbar ${isMobileVariant ? 'mobile-preview-toolbar' : ''}`}>
<div className="flex items-center gap-2 text-[11px] text-[#aaa] min-w-0 flex-1"> <div className="flex items-center gap-2 text-[11px] text-[#aaa] min-w-0 flex-1">
<FileDown className="w-3.5 h-3.5 text-[#31a8ff] shrink-0" /> <FileDown className="w-3.5 h-3.5 text-[#31a8ff] shrink-0" />
<span className="font-semibold text-[#e8e8e8]"></span> <span className="font-semibold text-[#e8e8e8]"></span>
<span className="text-[#666]">|</span> <span className="text-[#666]">|</span>
<span> <span className="truncate">
{previewReady ? totalPages : draftTotalPages} PDF {previewReady ? totalPages : draftTotalPages}
</span> </span>
<span className="text-[#666]">|</span> {!isMobileVariant && (
<span> <>
{(previewReady ? selectedCount : draftSelectedCount) < totalRowCount <span className="text-[#666]">|</span>
? `${previewReady ? selectedCount : draftSelectedCount}/${totalRowCount}` <span>
: `${totalRowCount}`} {(previewReady ? selectedCount : draftSelectedCount) < totalRowCount
</span> ? `${previewReady ? selectedCount : draftSelectedCount}/${totalRowCount}`
{previewLayoutStale && previewReady && ( : `${totalRowCount}`}
<span className="text-amber-500/90"></span> </span>
</>
)} )}
{previewReady && hasMorePages && !showAllPages && ( {previewLayoutStale && previewReady && (
<span className="text-amber-500/90 shrink-0"></span>
)}
{previewReady && hasMorePages && !showAllPages && !isMobileVariant && (
<span className="text-amber-500/90"> {MAX_PREVIEW_PAGES} </span> <span className="text-amber-500/90"> {MAX_PREVIEW_PAGES} </span>
)} )}
</div> </div>
<div className="flex items-center gap-3 shrink-0"> <div className={`flex items-center gap-3 shrink-0 ${isMobileVariant ? 'hidden' : ''}`}>
{compact && onToggleCollapsed && ( {compact && onToggleCollapsed && (
<button <button
type="button" type="button"
@@ -558,16 +567,26 @@ export const LayoutPreview: React.FC<LayoutPreviewProps> = ({
</div> </div>
</div> </div>
<div className="flex-1 overflow-auto ps-workspace-bg p-6 min-h-0 ps-preview-workspace"> <div
className={`flex-1 overflow-auto ps-workspace-bg min-h-0 ps-preview-workspace ${
isMobileVariant ? 'p-3 mobile-preview-workspace' : 'p-6'
}`}
>
{!previewReady ? ( {!previewReady ? (
<div className="h-full flex flex-col items-center justify-center text-center px-8 gap-3"> <div className="h-full flex flex-col items-center justify-center text-center px-6 gap-2">
<p className="text-sm text-[#aaa]"></p> <p className="text-sm text-[#aaa]"></p>
<p className="text-xs text-[#666] max-w-[320px] leading-relaxed"> <p className="text-xs text-[#666] max-w-[280px] leading-relaxed">
{isMobileVariant
? '在下方「数据源与变量绑定」中上传 Excel预览将自动更新'
: '在右侧「数据源与变量绑定」中点击「应用数据」生成排版预览;纸张排版变更会自动更新'}
</p> </p>
</div> </div>
) : ( ) : (
<div className="space-y-8 pb-8 flex flex-col items-center"> <div
className={`flex flex-col items-center ${
isMobileVariant ? 'space-y-4 pb-4' : 'space-y-8 pb-8'
}`}
>
{visiblePages.map((pageRows, pageIdx) => ( {visiblePages.map((pageRows, pageIdx) => (
<div <div
key={pageIdx} key={pageIdx}
@@ -695,15 +714,17 @@ export const LayoutPreview: React.FC<LayoutPreviewProps> = ({
)} )}
</div> </div>
<div className="ps-status-bar"> <div className={`ps-status-bar ${isMobileVariant ? 'mobile-preview-status' : ''}`}>
<span> <span>
{previewReady {previewReady
? `${gridInfo.cols}× ${gridInfo.rows} · 每页 ${labelsPerPage}` ? `${gridInfo.cols}×${gridInfo.rows} · 每页 ${labelsPerPage}`
: `待绘制 · 预计 ${draftTotalPages}`} : `预计 ${draftTotalPages}`}
</span>
<span>
{previewReady ? '点击标签可高亮对应数据行' : '数据变更后请在右侧面板手动刷新绘制'}
</span> </span>
{!isMobileVariant && (
<span>
{previewReady ? '点击标签可高亮对应数据行' : '数据变更后请在右侧面板手动刷新绘制'}
</span>
)}
</div> </div>
</div> </div>
); );

View File

@@ -19,6 +19,7 @@ import {
} from '../elementAlign'; } from '../elementAlign';
import { CollapsiblePanelSection } from './CollapsiblePanelSection'; import { CollapsiblePanelSection } from './CollapsiblePanelSection';
import { CommitInput } from './CommitInput'; import { CommitInput } from './CommitInput';
import { useIsNarrowScreen } from '../hooks/useIsMobile';
import { import {
Sliders, Sliders,
LayoutGrid, LayoutGrid,
@@ -146,6 +147,8 @@ const PropTextarea: React.FC<PropTextareaProps> = ({ value, onCommit, ...props }
); );
}; };
type MobilePropModule = 'content' | 'properties' | 'layers';
interface PropSidebarProps { interface PropSidebarProps {
template: LabelTemplate; template: LabelTemplate;
onChangeTemplate: (updated: LabelTemplate) => void; onChangeTemplate: (updated: LabelTemplate) => void;
@@ -154,8 +157,15 @@ interface PropSidebarProps {
onChangeTab: (tab: 'element' | 'paper') => void; onChangeTab: (tab: 'element' | 'paper') => void;
paper: PaperConfig; paper: PaperConfig;
onSelectElements?: (ids: string[]) => void; onSelectElements?: (ids: string[]) => void;
variant?: 'desktop' | 'mobile';
} }
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" /> },
];
export const PropSidebar: React.FC<PropSidebarProps> = ({ export const PropSidebar: React.FC<PropSidebarProps> = ({
template, template,
onChangeTemplate, onChangeTemplate,
@@ -164,7 +174,9 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
onChangeTab, onChangeTab,
paper, paper,
onSelectElements, onSelectElements,
variant = 'desktop',
}) => { }) => {
const isNarrowScreen = useIsNarrowScreen();
const selectedElementId = selectedElementIds[0] || null; const selectedElementId = selectedElementIds[0] || null;
const selectedElem = template.elements.find((el) => el.id === selectedElementId); const selectedElem = template.elements.find((el) => el.id === selectedElementId);
const [newVarName, setNewVarName] = useState(''); const [newVarName, setNewVarName] = useState('');
@@ -172,6 +184,7 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
{ mode: 'add' } | { mode: 'edit'; index: number } | null { mode: 'add' } | { mode: 'edit'; index: number } | null
>(null); >(null);
const [panelCollapsed, setPanelCollapsed] = useState<PanelCollapseState>(loadPanelCollapseState); const [panelCollapsed, setPanelCollapsed] = useState<PanelCollapseState>(loadPanelCollapseState);
const [mobileModule, setMobileModule] = useState<MobilePropModule>('properties');
const togglePanel = (key: keyof PanelCollapseState) => { const togglePanel = (key: keyof PanelCollapseState) => {
setPanelCollapsed((prev) => { setPanelCollapsed((prev) => {
@@ -653,43 +666,8 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
); );
}; };
return ( const renderPropertiesPanelBody = () => (
<div className="ps-sidebar-stack"> <>
<VisibilityRuleDialog
open={visibilityRuleDialog !== null}
mode={visibilityRuleDialog?.mode ?? 'add'}
initialRule={editingVisibilityRule}
variableOptions={visibilityRuleDialogVariableOptions}
onClose={() => setVisibilityRuleDialog(null)}
onConfirm={handleConfirmVisibilityRule}
/>
<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="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"
>
<>
{/* Label Canvas Dimensions */} {/* Label Canvas Dimensions */}
{!selectedElem && ( {!selectedElem && (
<div className="space-y-4"> <div className="space-y-4">
@@ -1674,7 +1652,7 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
updated[key] = e.target.value; updated[key] = e.target.value;
handleElemChange(updated); handleElemChange(updated);
}} }}
className="color-input shrink-0 scale-90" className="color-input shrink-0"
title={`${label}边边框颜色`} title={`${label}边边框颜色`}
/> />
<PropInput <PropInput
@@ -1737,20 +1715,11 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
</div> </div>
)} )}
</> </>
</CollapsiblePanelSection> );
<CollapsiblePanelSection const renderLayersPanelBody = () => (
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"
>
{template.elements.length === 0 ? ( {template.elements.length === 0 ? (
<div className="text-[11px] text-[#666] text-center py-6"></div> <div className="text-[11px] text-[#666] text-center py-6"></div>
) : ( ) : (
@@ -1789,7 +1758,13 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
</span> </span>
</div> </div>
<div className="flex items-center gap-0.5 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity"> <div
className={`flex items-center gap-0.5 shrink-0 ${
isNarrowScreen || variant === 'mobile'
? 'opacity-100'
: 'opacity-0 group-hover:opacity-100 transition-opacity'
}`}
>
<button <button
type="button" type="button"
onClick={(e) => { e.stopPropagation(); moveLayer(actualIdx, 'up'); }} onClick={(e) => { e.stopPropagation(); moveLayer(actualIdx, 'up'); }}
@@ -1830,6 +1805,96 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
); );
}) })
)} )}
</>
);
const visibilityRuleDialogNode = (
<VisibilityRuleDialog
open={visibilityRuleDialog !== null}
mode={visibilityRuleDialog?.mode ?? 'add'}
initialRule={editingVisibilityRule}
variableOptions={visibilityRuleDialogVariableOptions}
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>
))}
</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 === '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> </CollapsiblePanelSection>
</div> </div>
); );

View File

@@ -368,6 +368,7 @@ body {
appearance: none; appearance: none;
box-sizing: border-box; box-sizing: border-box;
width: var(--color-input-size); width: var(--color-input-size);
min-width: var(--color-input-size);
height: var(--color-input-size); height: var(--color-input-size);
min-height: var(--color-input-size); min-height: var(--color-input-size);
padding: 0; padding: 0;
@@ -407,6 +408,13 @@ body {
height: 100%; height: 100%;
} }
/* mobile-field 行内与输入框同高44pxps-field 使用默认 calc 与 ps-field 一致 */
.flex:has(.mobile-field) > .color-input {
--color-input-radius: 10px;
--color-input-border: #e2e8f0;
--color-input-size: 44px;
}
.ps-section-title { .ps-section-title {
font-size: 10px; font-size: 10px;
font-weight: 700; font-weight: 700;
@@ -730,39 +738,62 @@ body {
/* ── Mobile layout (< 768px) ── */ /* ── Mobile layout (< 768px) ── */
@media (max-width: 767px) { @media (max-width: 767px) {
/* Mobile export flow */ /* Mobile export flow — shares mobile-design-view shell */
.mobile-export-view { .mobile-export-view {
flex: 1 1 auto; flex: 1;
display: flex;
flex-direction: column;
min-height: 0; min-height: 0;
height: 100%;
background: #f1f5f9;
}
.mobile-export-header {
display: flex;
align-items: center;
padding-top: calc(12px + env(safe-area-inset-top, 0px));
padding-bottom: 12px;
} }
.mobile-export-back { .mobile-export-back {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 40px; width: 36px;
height: 40px; height: 36px;
border: none; border: none;
border-radius: 10px; border-radius: 8px;
background: transparent; background: transparent;
color: #e2e8f0; color: #ccc;
cursor: pointer; cursor: pointer;
flex-shrink: 0; flex-shrink: 0;
} }
.mobile-export-back:active { .mobile-export-back:active {
background: rgba(255, 255, 255, 0.1); background: rgba(255, 255, 255, 0.08);
}
.mobile-export-preview-wrap {
background: #2a2a2a;
}
.mobile-export-preview-wrap .mobile-preview-toolbar {
padding: 6px 10px;
min-height: 36px;
}
.mobile-export-preview-wrap .mobile-preview-workspace {
background: #2a2a2a;
}
.mobile-export-preview-wrap .mobile-preview-status {
padding: 4px 10px;
font-size: 10px;
}
.mobile-export-props-nav {
width: 76px;
}
.mobile-export-props-nav-btn {
min-height: 68px;
font-size: 9px;
line-height: 1.25;
white-space: normal;
text-align: center;
}
.mobile-export-props-nav-btn span {
max-width: 100%;
} }
.mobile-export-steps { .mobile-export-steps {
@@ -1116,13 +1147,7 @@ body {
margin: 0 0 8px; margin: 0 0 8px;
} }
.color-input { .flex:has(.mobile-field) > .color-input:focus {
--color-input-radius: 10px;
--color-input-border: #e2e8f0;
--color-input-size: 44px;
}
.color-input:focus {
border-color: #818cf8; border-color: #818cf8;
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.15); box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.15);
} }
@@ -1191,13 +1216,32 @@ body {
font-size: 12px; font-size: 12px;
} }
.mobile-header-menu-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border: none;
border-radius: 12px;
background: rgba(255, 255, 255, 0.12);
color: #fff;
cursor: pointer;
transition: background 0.15s;
}
.mobile-header-menu-btn:active {
background: rgba(255, 255, 255, 0.22);
}
.mobile-template-actions { .mobile-template-actions {
width: 100%; width: 100%;
flex-wrap: nowrap; flex-wrap: nowrap;
gap: 10px; gap: 10px;
} }
.mobile-template-actions .mobile-primary-btn { .mobile-template-actions .mobile-primary-btn,
.mobile-template-actions .mobile-secondary-btn {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
} }
@@ -1206,15 +1250,162 @@ body {
transform: scale(0.97); transform: scale(0.97);
} }
.mobile-design-block { .mobile-design-view {
flex: 1; flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
background: #323232;
color: #e8e8e8;
}
.mobile-design-header {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
padding-top: calc(10px + env(safe-area-inset-top, 0px));
background: #292929;
border-bottom: 1px solid #1a1a1a;
flex-shrink: 0;
}
.mobile-design-header-icon-btn {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border: none;
border-radius: 8px;
background: transparent;
color: #ccc;
cursor: pointer;
}
.mobile-design-header-icon-btn:active:not(:disabled) {
background: rgba(255, 255, 255, 0.08);
}
.mobile-design-header-icon-btn:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.mobile-design-canvas-wrap {
flex: 1 1 42%;
min-height: 180px;
max-height: 52vh;
display: flex;
flex-direction: column;
min-width: 0;
overflow: hidden;
border-bottom: 1px solid #1a1a1a;
}
.mobile-design-canvas-shell {
flex: 1;
min-height: 0;
}
.mobile-design-props-wrap {
flex: 1 1 48%;
min-height: 220px;
display: flex;
flex-direction: column;
min-width: 0;
overflow: hidden;
background: #292929;
}
.mobile-design-props {
display: flex;
flex: 1;
min-height: 0;
min-width: 0;
}
.mobile-design-props-nav {
display: flex;
flex-direction: column;
gap: 4px;
width: 64px;
flex-shrink: 0;
padding: 8px 6px;
background: #252525;
border-right: 1px solid #1a1a1a;
}
.mobile-design-props-nav-btn {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
padding: 24px; gap: 4px;
text-align: center; min-height: 56px;
gap: 16px; padding: 6px 4px;
border: none;
border-radius: 10px;
background: transparent;
color: #888;
font-size: 10px;
font-weight: 600;
cursor: pointer;
}
.mobile-design-props-nav-btn.active {
background: #1e3a4f;
color: #31a8ff;
}
.mobile-design-props-panel {
flex: 1;
min-width: 0;
overflow-y: auto;
scrollbar-width: none;
-ms-overflow-style: none;
-webkit-overflow-scrolling: touch;
}
.mobile-design-props-panel::-webkit-scrollbar {
display: none;
}
.mobile-layers-panel .ps-layer-item .opacity-0 {
opacity: 1;
}
.mobile-design-toolstrip {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 6px 10px;
background: #292929;
border-top: 1px solid #1a1a1a;
flex-shrink: 0;
}
.mobile-design-toolstrip-tools {
display: flex;
align-items: center;
gap: 4px;
overflow-x: auto;
scrollbar-width: none;
-ms-overflow-style: none;
min-width: 0;
flex: 1;
}
.mobile-design-toolstrip-tools::-webkit-scrollbar {
display: none;
}
.mobile-design-tool-btn {
width: 40px !important;
height: 40px !important;
min-height: 40px !important;
flex-shrink: 0;
} }
} }