移动端适配

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 { ExportSidebar } from './components/ExportSidebar';
import { MobileExportView } from './components/MobileExportView';
import { MobileDesignView } from './components/MobileDesignView';
import { LayoutPreview } from './components/PreviewGrid';
import { CanvasLabelImage } from './components/CanvasLabelImage';
import { renderLabelToDataUrl } from './labelRenderer';
@@ -153,6 +154,9 @@ export default function App() {
const [editTmplName, setEditTmplName] = useState<string>('');
const [templateMenuId, setTemplateMenuId] = useState<string | 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 skipDesignHistoryRef = useRef(false);
const designHistorySessionRef = useRef<{ step: string; templateId: string | null }>({
@@ -396,6 +400,16 @@ export default function App() {
return () => document.removeEventListener('pointerdown', handlePointerDown);
}, [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) => {
e.preventDefault();
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)
? 'bg-[#323232] text-[#e8e8e8]'
: workflowStep === 'print-view' && isMobile
? 'bg-slate-100 text-slate-800'
? 'bg-[#323232] text-[#e8e8e8]'
: 'bg-slate-50 text-slate-800'
}`}
>
@@ -779,7 +793,7 @@ export default function App() {
SCREEN HEADER
=========================================
*/}
{!(isMobile && workflowStep === 'print-view') && (
{workflowStep === 'template-select' && (
<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
? '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'
}`}
>
{!isMobile && (
<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>
{isMobile ? (
<>
<input
ref={importTemplateInputRef}
type="file"
accept=".lad"
onChange={handleImportTemplate}
className="hidden"
/>
<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>
)}
</header>
@@ -871,16 +937,14 @@ export default function App() {
: '创建模板开始可视化设计,或导入已有的模板文件。'}
</p>
<div className="flex flex-col sm:flex-row items-center justify-center gap-3 mt-3">
{!isMobile && (
<button
type="button"
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"
>
<Plus className="w-4 h-4 shrink-0" />
</button>
)}
<button
type="button"
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"
>
<Plus className="w-4 h-4 shrink-0" />
</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">
<Upload className="w-4 h-4 shrink-0" />
@@ -975,6 +1039,20 @@ export default function App() {
</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
onClick={(e) => {
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`} />
{isMobile ? '排版生成标签' : '排版导出'}
{isMobile ? '生成' : '排版导出'}
</button>
<div
ref={templateMenuId === tmpl.id ? templateMenuRef : null}
@@ -1067,27 +1145,20 @@ export default function App() {
{/* ========================================================
STEP 2: The visual drag-and-drop label designer
======================================================== */}
{workflowStep === 'template-design' && isMobile && hasTemplates && (
<div className="mobile-design-block bg-[#292929] text-[#e8e8e8] flex-1">
<p className="text-sm text-[#666666] max-w-[280px] leading-relaxed">
</p>
<button
type="button"
onClick={() => openPrintView()}
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"
>
<Printer className="w-4 h-4" />
</button>
<button
type="button"
onClick={() => setWorkflowStep('template-select')}
className="text-[12px] text-[#999] underline cursor-pointer"
>
</button>
</div>
{workflowStep === 'template-design' && isMobile && hasTemplates && activeTemplate && (
<MobileDesignView
template={activeTemplate}
paper={paper}
selectedElementIds={selectedElementIds}
onSelectElements={setSelectedElementIds}
onChangeTemplate={handleDesignTemplateChange}
canUndo={designHistory.canUndo}
canRedo={designHistory.canRedo}
onUndo={handleDesignUndo}
onRedo={handleDesignRedo}
onBack={() => setWorkflowStep('template-select')}
onExport={() => openPrintView()}
/>
)}
{workflowStep === 'template-design' && !isMobile && hasTemplates && activeTemplate && (
@@ -1175,15 +1246,25 @@ export default function App() {
dataColumns={dataColumns}
variableColumnMapping={variableColumnMapping}
onChangeMapping={handleMappingChange}
paper={paper}
paper={previewSnapshot?.paper ?? paper}
onChangePaper={handlePaperChange}
rows={mappedRows}
draftExportRange={draftExportRange}
onChangeDraftExportRange={setDraftExportRange}
cutLine={cutLine}
onChangeCutLine={handleCutLineChange}
selectedCount={selectedCount}
totalPages={totalPages}
printablePages={previewSnapshot?.pages ?? []}
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}
onBack={() => setWorkflowStep('template-select')}
onExportPdf={exportPdf}

View File

@@ -156,7 +156,7 @@ export const ExportSidebar: React.FC<ExportSidebarProps> = ({
collapsed={dataCollapsed}
onToggle={() => toggleExportPanel('data')}
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"
>
<DataImporter
@@ -188,7 +188,7 @@ export const ExportSidebar: React.FC<ExportSidebarProps> = ({
collapsed={layoutCollapsed}
onToggle={() => toggleExportPanel('layout')}
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"
>
<PaperSettingsPanel

View File

@@ -19,6 +19,7 @@ interface LabelDesignerProps {
onChange: (updated: LabelTemplate) => void;
selectedElementIds: string[];
onSelectElements: (ids: string[]) => void;
variant?: 'desktop' | 'mobile';
}
type ActiveTool = 'move' | 'text' | 'barcode' | 'qrcode';
@@ -134,9 +135,11 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
onChange,
selectedElementIds,
onSelectElements,
variant = 'desktop',
}) => {
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 [activeTool, setActiveTool] = useState<ActiveTool>('move');
const canvasAreaRef = useRef<HTMLDivElement | null>(null);
@@ -744,7 +747,7 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
const fitToView = () => {
const area = canvasAreaRef.current;
if (!area) return;
const pad = 80;
const pad = isMobileLayout ? 32 : 80;
const availW = area.clientWidth - pad;
const availH = area.clientHeight - pad;
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));
};
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(
(el) => el.id === selectedElementId
);
@@ -777,44 +786,85 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
{ id: 'qrcode', icon: <QrCode className="w-[18px] h-[18px]" />, label: '二维码' },
];
return (
<div className="flex flex-1 min-w-0 min-h-0">
<div className="ps-toolbar flex flex-col">
{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>
))}
const mainToolButtons = (
<>
{tools.map((tool) => (
<button
className="ps-tool-btn"
onClick={handleImageToolClick}
title="图片工具"
key={tool.id}
className={`ps-tool-btn ${activeTool === tool.id ? 'active' : ''} ${isMobileLayout ? 'mobile-design-tool-btn' : ''}`}
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>
<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>
<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
@@ -839,7 +889,7 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
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
</div>
<div
@@ -847,7 +897,7 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
style={{ writingMode: 'vertical-rl', transform: 'rotate(180deg)' }}
>
{template.height} mm
</div>
</div> */}
{/* 整页统一渲染:半透明背景按图层层级与下方元素真实合成 */}
<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 className={`ps-status-bar ${isNarrow ? 'ps-status-bar-compact' : ''}`}>
<div className="flex items-center gap-2 min-w-0 truncate">
{isNarrow ? (
<>
{isMobileLayout ? (
<div className="mobile-design-toolstrip">
<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">
{template.width}×{template.height}
{selectedElemObj ? ` · ${selectedElemObj.name}` : ''}
{selectedElementIds.length > 1 ? ` · ${selectedElementIds.length}` : ''}
</span>
</>
) : (
<>
<span>
: {template.width} × {template.height} mm
) : (
<>
<span>
: {template.width} × {template.height} mm
</span>
{selectedElemObj && (
<span className="text-[#31a8ff]">
| {selectedElemObj.name} X:{selectedElemObj.x} Y:{selectedElemObj.y} W:
{selectedElemObj.width} H:{selectedElemObj.height} mm
</span>
)}
{selectedElementIds.length > 1 && (
<span className="text-emerald-400">| {selectedElementIds.length} </span>
)}
{activeTool === 'move' && (
<span className="text-[#888] hidden sm:inline">
| · Shift/Ctrl+ · Ctrl+A
</span>
)}
</>
)}
</div>
<div className="flex items-center gap-2 shrink-0">
{!isNarrow && (
<span className="hidden sm:inline text-[10px]">
使 · · Ctrl+
</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>
)}
<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" />
)}
{zoomControls}
<button
type="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>
</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>
);

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 {
ChevronLeft,
Printer,
FileDown,
Loader2,
Database,
Link2,
Layout,
Download,
} from 'lucide-react';
import {
@@ -15,11 +15,23 @@ import {
PaperConfig,
CutLineConfig,
} from '../types';
import { isVariableMappingConfigured } from '../utils';
import { isVariableMappingConfigured, type PrintableLabel } from '../utils';
import { DataImporter, downloadDataTemplate } from './DataImporter';
import { VariableMappingPanel } from './VariableMappingPanel';
import { MobileExportPropsPanel } from './MobileExportPropsPanel';
import { MobileExportLayoutPanel } from './MobileExportLayoutPanel';
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 {
template: LabelTemplate;
@@ -38,8 +50,18 @@ interface MobileExportViewProps {
onChangeDraftExportRange: (range: ExportRangeConfig) => void;
cutLine: CutLineConfig;
onChangeCutLine: (cutLine: CutLineConfig) => void;
selectedCount: number;
printablePages: (PrintableLabel | null)[][];
gridCols: number;
labelsPerPage: number;
totalPages: number;
selectedCount: number;
draftTotalPages: number;
draftSelectedCount: number;
totalRowCount: number;
previewReady: boolean;
previewLayoutStale: boolean;
activeRowIndex: number;
onSelectRowIndex: (idx: number) => void;
pdfGenerating: boolean;
onBack: () => void;
onExportPdf: () => void;
@@ -62,12 +84,24 @@ export const MobileExportView: React.FC<MobileExportViewProps> = ({
onChangeDraftExportRange,
cutLine,
onChangeCutLine,
selectedCount,
printablePages,
gridCols,
labelsPerPage,
totalPages,
selectedCount,
draftTotalPages,
draftSelectedCount,
totalRowCount,
previewReady,
previewLayoutStale,
activeRowIndex,
onSelectRowIndex,
pdfGenerating,
onBack,
onExportPdf,
}) => {
const [mobileModule, setMobileModule] = useState<MobileExportModule>('data');
const hasData = rows.length > 0;
const mappedCount = templateVariables.filter((v) =>
isVariableMappingConfigured(variableColumnMapping, v, dataColumns)
@@ -83,133 +117,155 @@ export const MobileExportView: React.FC<MobileExportViewProps> = ({
? '当前数据范围内无标签'
: null;
const exportDisabled = !!exportBlockedReason || pdfGenerating;
return (
<div className="mobile-export-view">
<header className="no-print bg-indigo-700 border-b border-indigo-900 shrink-0 shadow mobile-export-header px-4">
<div className="flex items-center w-full">
<label
onClick={onBack}
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
min-h-[40px] px-3 py-2 text-[11px]`}
>
<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}
>
<div className="mobile-export-view 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" title={templateName}>
{templateName}
</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>
</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
type="button"
onClick={onExportPdf}
disabled={!!exportBlockedReason || pdfGenerating}
className="mobile-export-submit"
disabled={exportDisabled}
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 ? (
<>
<Loader2 className="w-5 h-5 animate-spin shrink-0" />
PDF
</>
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<>
<Printer className="w-5 h-5 shrink-0" />
{exportBlockedReason
? '生成标签PDF文件'
: `生成标签PDF文件${selectedCount} 枚 · ${totalPages} 页)`}
</>
<FileDown className="w-3.5 h-3.5" />
)}
PDF
</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>
);
};

View File

@@ -189,7 +189,7 @@ export const PaperSettingsPanel: React.FC<PaperSettingsPanelProps> = ({
>
{!isPs && !isMobile && (
<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>
)}
{isPs && (
@@ -410,6 +410,7 @@ export interface LayoutPreviewProps {
compact?: boolean;
collapsed?: boolean;
onToggleCollapsed?: () => void;
variant?: 'desktop' | 'mobile';
}
export const LayoutPreview: React.FC<LayoutPreviewProps> = ({
@@ -431,8 +432,12 @@ export const LayoutPreview: React.FC<LayoutPreviewProps> = ({
compact = false,
collapsed = false,
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 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' : ''
}`}
>
<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">
<FileDown className="w-3.5 h-3.5 text-[#31a8ff] shrink-0" />
<span className="font-semibold text-[#e8e8e8]"></span>
<span className="text-[#666]">|</span>
<span>
{previewReady ? totalPages : draftTotalPages} PDF
<span className="truncate">
{previewReady ? totalPages : draftTotalPages}
</span>
<span className="text-[#666]">|</span>
<span>
{(previewReady ? selectedCount : draftSelectedCount) < totalRowCount
? `${previewReady ? selectedCount : draftSelectedCount}/${totalRowCount}`
: `${totalRowCount}`}
</span>
{previewLayoutStale && previewReady && (
<span className="text-amber-500/90"></span>
{!isMobileVariant && (
<>
<span className="text-[#666]">|</span>
<span>
{(previewReady ? selectedCount : draftSelectedCount) < totalRowCount
? `${previewReady ? selectedCount : draftSelectedCount}/${totalRowCount}`
: `${totalRowCount}`}
</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>
)}
</div>
<div className="flex items-center gap-3 shrink-0">
<div className={`flex items-center gap-3 shrink-0 ${isMobileVariant ? 'hidden' : ''}`}>
{compact && onToggleCollapsed && (
<button
type="button"
@@ -558,16 +567,26 @@ export const LayoutPreview: React.FC<LayoutPreviewProps> = ({
</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 ? (
<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-xs text-[#666] max-w-[320px] leading-relaxed">
<p className="text-xs text-[#666] max-w-[280px] leading-relaxed">
{isMobileVariant
? '在下方「数据源与变量绑定」中上传 Excel预览将自动更新'
: '在右侧「数据源与变量绑定」中点击「应用数据」生成排版预览;纸张排版变更会自动更新'}
</p>
</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) => (
<div
key={pageIdx}
@@ -695,15 +714,17 @@ export const LayoutPreview: React.FC<LayoutPreviewProps> = ({
)}
</div>
<div className="ps-status-bar">
<div className={`ps-status-bar ${isMobileVariant ? 'mobile-preview-status' : ''}`}>
<span>
{previewReady
? `${gridInfo.cols}× ${gridInfo.rows} · 每页 ${labelsPerPage}`
: `待绘制 · 预计 ${draftTotalPages}`}
</span>
<span>
{previewReady ? '点击标签可高亮对应数据行' : '数据变更后请在右侧面板手动刷新绘制'}
? `${gridInfo.cols}×${gridInfo.rows} · 每页 ${labelsPerPage}`
: `预计 ${draftTotalPages}`}
</span>
{!isMobileVariant && (
<span>
{previewReady ? '点击标签可高亮对应数据行' : '数据变更后请在右侧面板手动刷新绘制'}
</span>
)}
</div>
</div>
);

View File

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

View File

@@ -368,6 +368,7 @@ body {
appearance: none;
box-sizing: border-box;
width: var(--color-input-size);
min-width: var(--color-input-size);
height: var(--color-input-size);
min-height: var(--color-input-size);
padding: 0;
@@ -407,6 +408,13 @@ body {
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 {
font-size: 10px;
font-weight: 700;
@@ -730,39 +738,62 @@ body {
/* ── Mobile layout (< 768px) ── */
@media (max-width: 767px) {
/* Mobile export flow */
/* Mobile export flow — shares mobile-design-view shell */
.mobile-export-view {
flex: 1 1 auto;
display: flex;
flex-direction: column;
flex: 1;
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 {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
width: 36px;
height: 36px;
border: none;
border-radius: 10px;
border-radius: 8px;
background: transparent;
color: #e2e8f0;
color: #ccc;
cursor: pointer;
flex-shrink: 0;
}
.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 {
@@ -1116,13 +1147,7 @@ body {
margin: 0 0 8px;
}
.color-input {
--color-input-radius: 10px;
--color-input-border: #e2e8f0;
--color-input-size: 44px;
}
.color-input:focus {
.flex:has(.mobile-field) > .color-input:focus {
border-color: #818cf8;
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.15);
}
@@ -1191,13 +1216,32 @@ body {
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 {
width: 100%;
flex-wrap: nowrap;
gap: 10px;
}
.mobile-template-actions .mobile-primary-btn {
.mobile-template-actions .mobile-primary-btn,
.mobile-template-actions .mobile-secondary-btn {
flex: 1;
min-width: 0;
}
@@ -1206,15 +1250,162 @@ body {
transform: scale(0.97);
}
.mobile-design-block {
.mobile-design-view {
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;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 24px;
text-align: center;
gap: 16px;
gap: 4px;
min-height: 56px;
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;
}
}