移动端适配
This commit is contained in:
203
src/App.tsx
203
src/App.tsx
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
100
src/components/MobileDesignView.tsx
Normal file
100
src/components/MobileDesignView.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
115
src/components/MobileExportLayoutPanel.tsx
Normal file
115
src/components/MobileExportLayoutPanel.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
251
src/index.css
251
src/index.css
@@ -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 行内与输入框同高(44px);ps-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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user