移动端适配
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 { PropSidebar } from './components/PropSidebar';
|
||||||
import { ExportSidebar } from './components/ExportSidebar';
|
import { ExportSidebar } from './components/ExportSidebar';
|
||||||
import { MobileExportView } from './components/MobileExportView';
|
import { MobileExportView } from './components/MobileExportView';
|
||||||
|
import { MobileDesignView } from './components/MobileDesignView';
|
||||||
import { LayoutPreview } from './components/PreviewGrid';
|
import { LayoutPreview } from './components/PreviewGrid';
|
||||||
import { CanvasLabelImage } from './components/CanvasLabelImage';
|
import { CanvasLabelImage } from './components/CanvasLabelImage';
|
||||||
import { renderLabelToDataUrl } from './labelRenderer';
|
import { renderLabelToDataUrl } from './labelRenderer';
|
||||||
@@ -153,6 +154,9 @@ export default function App() {
|
|||||||
const [editTmplName, setEditTmplName] = useState<string>('');
|
const [editTmplName, setEditTmplName] = useState<string>('');
|
||||||
const [templateMenuId, setTemplateMenuId] = useState<string | null>(null);
|
const [templateMenuId, setTemplateMenuId] = useState<string | null>(null);
|
||||||
const templateMenuRef = useRef<HTMLDivElement | null>(null);
|
const templateMenuRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const [headerTemplateMenuOpen, setHeaderTemplateMenuOpen] = useState(false);
|
||||||
|
const headerTemplateMenuRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const importTemplateInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
const designHistory = useUndoRedo<LabelTemplate>();
|
const designHistory = useUndoRedo<LabelTemplate>();
|
||||||
const skipDesignHistoryRef = useRef(false);
|
const skipDesignHistoryRef = useRef(false);
|
||||||
const designHistorySessionRef = useRef<{ step: string; templateId: string | null }>({
|
const designHistorySessionRef = useRef<{ step: string; templateId: string | null }>({
|
||||||
@@ -396,6 +400,16 @@ export default function App() {
|
|||||||
return () => document.removeEventListener('pointerdown', handlePointerDown);
|
return () => document.removeEventListener('pointerdown', handlePointerDown);
|
||||||
}, [templateMenuId]);
|
}, [templateMenuId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!headerTemplateMenuOpen) return;
|
||||||
|
const handlePointerDown = (e: PointerEvent) => {
|
||||||
|
if (headerTemplateMenuRef.current?.contains(e.target as Node)) return;
|
||||||
|
setHeaderTemplateMenuOpen(false);
|
||||||
|
};
|
||||||
|
document.addEventListener('pointerdown', handlePointerDown);
|
||||||
|
return () => document.removeEventListener('pointerdown', handlePointerDown);
|
||||||
|
}, [headerTemplateMenuOpen]);
|
||||||
|
|
||||||
const handleEditTemplateSubmit = async (e: React.FormEvent) => {
|
const handleEditTemplateSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!editingTemplateId) return;
|
if (!editingTemplateId) return;
|
||||||
@@ -770,7 +784,7 @@ export default function App() {
|
|||||||
className={`flex flex-col font-sans h-dvh max-h-dvh overflow-hidden ${workflowStep === 'template-design' || (workflowStep === 'print-view' && !isMobile)
|
className={`flex flex-col font-sans h-dvh max-h-dvh overflow-hidden ${workflowStep === 'template-design' || (workflowStep === 'print-view' && !isMobile)
|
||||||
? 'bg-[#323232] text-[#e8e8e8]'
|
? 'bg-[#323232] text-[#e8e8e8]'
|
||||||
: workflowStep === 'print-view' && isMobile
|
: workflowStep === 'print-view' && isMobile
|
||||||
? 'bg-slate-100 text-slate-800'
|
? 'bg-[#323232] text-[#e8e8e8]'
|
||||||
: 'bg-slate-50 text-slate-800'
|
: 'bg-slate-50 text-slate-800'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -779,7 +793,7 @@ export default function App() {
|
|||||||
SCREEN HEADER
|
SCREEN HEADER
|
||||||
=========================================
|
=========================================
|
||||||
*/}
|
*/}
|
||||||
{!(isMobile && workflowStep === 'print-view') && (
|
{workflowStep === 'template-select' && (
|
||||||
<header
|
<header
|
||||||
className={`no-print bg-indigo-700 border-b border-indigo-900 shrink-0 shadow flex flex-wrap items-center justify-between gap-4 mobile-template-header app-header-sticky ${isMobile && workflowStep === 'template-select' && hasTemplates
|
className={`no-print bg-indigo-700 border-b border-indigo-900 shrink-0 shadow flex flex-wrap items-center justify-between gap-4 mobile-template-header app-header-sticky ${isMobile && workflowStep === 'template-select' && hasTemplates
|
||||||
? 'mobile-template-header-row'
|
? 'mobile-template-header-row'
|
||||||
@@ -807,31 +821,83 @@ export default function App() {
|
|||||||
className={`flex items-center gap-2 shrink-0 ${isMobile ? 'ml-auto' : 'flex-wrap'
|
className={`flex items-center gap-2 shrink-0 ${isMobile ? 'ml-auto' : 'flex-wrap'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{!isMobile && (
|
{isMobile ? (
|
||||||
<button
|
<>
|
||||||
type="button"
|
<input
|
||||||
onClick={() => setShowAddTmplForm(true)}
|
ref={importTemplateInputRef}
|
||||||
className="inline-flex items-center gap-1.5 px-4 py-2 bg-indigo-600 text-white rounded-xl text-xs font-semibold whitespace-nowrap transition hover:bg-indigo-800 shadow-sm cursor-pointer"
|
type="file"
|
||||||
>
|
accept=".lad"
|
||||||
<Plus className="w-4 h-4 shrink-0" />
|
onChange={handleImportTemplate}
|
||||||
新建模板
|
className="hidden"
|
||||||
</button>
|
/>
|
||||||
|
<div ref={headerTemplateMenuRef} className="relative shrink-0">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setHeaderTemplateMenuOpen((open) => !open)}
|
||||||
|
className="mobile-header-menu-btn"
|
||||||
|
aria-expanded={headerTemplateMenuOpen}
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-label="更多"
|
||||||
|
>
|
||||||
|
<MoreHorizontal className="w-5 h-5 shrink-0" />
|
||||||
|
</button>
|
||||||
|
{headerTemplateMenuOpen && (
|
||||||
|
<div
|
||||||
|
role="menu"
|
||||||
|
className="absolute right-0 top-full mt-1 z-30 min-w-[148px] py-1 bg-white border border-gray-200 rounded-xl shadow-lg"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
onClick={() => {
|
||||||
|
setHeaderTemplateMenuOpen(false);
|
||||||
|
setShowAddTmplForm(true);
|
||||||
|
}}
|
||||||
|
className="inline-flex items-center gap-3 w-full px-3 py-2.5 text-left text-sm text-gray-700 hover:bg-gray-50 active:bg-gray-100 cursor-pointer"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4 shrink-0" />
|
||||||
|
新建模板
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
onClick={() => {
|
||||||
|
setHeaderTemplateMenuOpen(false);
|
||||||
|
importTemplateInputRef.current?.click();
|
||||||
|
}}
|
||||||
|
className="inline-flex items-center gap-3 w-full px-3 py-2.5 text-left text-sm text-gray-700 hover:bg-gray-50 active:bg-gray-100 cursor-pointer"
|
||||||
|
>
|
||||||
|
<Upload className="w-4 h-4 shrink-0" />
|
||||||
|
导入模板
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowAddTmplForm(true)}
|
||||||
|
className="inline-flex items-center gap-1.5 px-4 py-2 bg-indigo-600 text-white rounded-xl text-xs font-semibold whitespace-nowrap transition hover:bg-indigo-800 shadow-sm cursor-pointer"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4 shrink-0" />
|
||||||
|
新建模板
|
||||||
|
</button>
|
||||||
|
<label
|
||||||
|
className="inline-flex items-center gap-1.5 bg-indigo-600 hover:bg-indigo-800 text-white rounded-xl font-semibold whitespace-nowrap transition shadow-sm cursor-pointer px-4 py-2 text-xs"
|
||||||
|
>
|
||||||
|
<Upload className="w-4 h-4 shrink-0" />
|
||||||
|
导入模板
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept=".lad"
|
||||||
|
onChange={handleImportTemplate}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
<label
|
|
||||||
className={`inline-flex items-center gap-1.5 bg-indigo-600 hover:bg-indigo-800 text-white rounded-xl font-semibold whitespace-nowrap transition shadow-sm cursor-pointer ${isMobile
|
|
||||||
? 'min-h-[40px] px-3 py-2 text-[11px]'
|
|
||||||
: 'px-4 py-2 text-xs'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<Upload className={`${isMobile ? 'w-3.5 h-3.5' : 'w-4 h-4'} shrink-0`} />
|
|
||||||
导入模板
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
accept=".lad"
|
|
||||||
onChange={handleImportTemplate}
|
|
||||||
className="hidden"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</header>
|
</header>
|
||||||
@@ -871,16 +937,14 @@ export default function App() {
|
|||||||
: '创建模板开始可视化设计,或导入已有的模板文件。'}
|
: '创建模板开始可视化设计,或导入已有的模板文件。'}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex flex-col sm:flex-row items-center justify-center gap-3 mt-3">
|
<div className="flex flex-col sm:flex-row items-center justify-center gap-3 mt-3">
|
||||||
{!isMobile && (
|
<button
|
||||||
<button
|
type="button"
|
||||||
type="button"
|
onClick={() => setShowAddTmplForm(true)}
|
||||||
onClick={() => setShowAddTmplForm(true)}
|
className="inline-flex items-center justify-center gap-2 px-5 py-2.5 bg-indigo-600 hover:bg-indigo-700 text-white rounded-xl text-sm font-semibold transition shadow-sm cursor-pointer"
|
||||||
className="inline-flex items-center justify-center gap-2 px-5 py-2.5 bg-indigo-600 hover:bg-indigo-700 text-white rounded-xl text-sm font-semibold transition shadow-sm cursor-pointer"
|
>
|
||||||
>
|
<Plus className="w-4 h-4 shrink-0" />
|
||||||
<Plus className="w-4 h-4 shrink-0" />
|
新建模板
|
||||||
新建模板
|
</button>
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<label className="inline-flex items-center justify-center gap-2 px-5 py-2.5 bg-white border border-gray-200 hover:border-indigo-300 text-gray-700 rounded-xl text-sm font-semibold transition shadow-sm cursor-pointer shrink-0">
|
<label className="inline-flex items-center justify-center gap-2 px-5 py-2.5 bg-white border border-gray-200 hover:border-indigo-300 text-gray-700 rounded-xl text-sm font-semibold transition shadow-sm cursor-pointer shrink-0">
|
||||||
<Upload className="w-4 h-4 shrink-0" />
|
<Upload className="w-4 h-4 shrink-0" />
|
||||||
导入模板
|
导入模板
|
||||||
@@ -975,6 +1039,20 @@ export default function App() {
|
|||||||
设计
|
设计
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{isMobile && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleSelectTemplate(tmpl.id!);
|
||||||
|
setWorkflowStep('template-design');
|
||||||
|
}}
|
||||||
|
className="mobile-secondary-btn flex-1 min-w-0 justify-center min-h-[44px] px-3 py-2.5 rounded-xl text-[13px] font-semibold"
|
||||||
|
>
|
||||||
|
<Code className="w-4 h-4 shrink-0" />
|
||||||
|
设计
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -986,7 +1064,7 @@ export default function App() {
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Printer className={`${isMobile ? 'w-4 h-4' : 'w-3.5 h-3.5'} shrink-0`} />
|
<Printer className={`${isMobile ? 'w-4 h-4' : 'w-3.5 h-3.5'} shrink-0`} />
|
||||||
{isMobile ? '排版生成标签' : '排版导出'}
|
{isMobile ? '生成' : '排版导出'}
|
||||||
</button>
|
</button>
|
||||||
<div
|
<div
|
||||||
ref={templateMenuId === tmpl.id ? templateMenuRef : null}
|
ref={templateMenuId === tmpl.id ? templateMenuRef : null}
|
||||||
@@ -1067,27 +1145,20 @@ export default function App() {
|
|||||||
{/* ========================================================
|
{/* ========================================================
|
||||||
STEP 2: The visual drag-and-drop label designer
|
STEP 2: The visual drag-and-drop label designer
|
||||||
======================================================== */}
|
======================================================== */}
|
||||||
{workflowStep === 'template-design' && isMobile && hasTemplates && (
|
{workflowStep === 'template-design' && isMobile && hasTemplates && activeTemplate && (
|
||||||
<div className="mobile-design-block bg-[#292929] text-[#e8e8e8] flex-1">
|
<MobileDesignView
|
||||||
<p className="text-sm text-[#666666] max-w-[280px] leading-relaxed">
|
template={activeTemplate}
|
||||||
请在电脑端进行标签设计
|
paper={paper}
|
||||||
</p>
|
selectedElementIds={selectedElementIds}
|
||||||
<button
|
onSelectElements={setSelectedElementIds}
|
||||||
type="button"
|
onChangeTemplate={handleDesignTemplateChange}
|
||||||
onClick={() => openPrintView()}
|
canUndo={designHistory.canUndo}
|
||||||
className="inline-flex items-center gap-2 px-5 py-2.5 bg-indigo-600 hover:bg-indigo-700 active:bg-indigo-800 text-white text-sm font-semibold rounded-lg cursor-pointer"
|
canRedo={designHistory.canRedo}
|
||||||
>
|
onUndo={handleDesignUndo}
|
||||||
<Printer className="w-4 h-4" />
|
onRedo={handleDesignRedo}
|
||||||
去生成标签
|
onBack={() => setWorkflowStep('template-select')}
|
||||||
</button>
|
onExport={() => openPrintView()}
|
||||||
<button
|
/>
|
||||||
type="button"
|
|
||||||
onClick={() => setWorkflowStep('template-select')}
|
|
||||||
className="text-[12px] text-[#999] underline cursor-pointer"
|
|
||||||
>
|
|
||||||
返回模板列表
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{workflowStep === 'template-design' && !isMobile && hasTemplates && activeTemplate && (
|
{workflowStep === 'template-design' && !isMobile && hasTemplates && activeTemplate && (
|
||||||
@@ -1175,15 +1246,25 @@ export default function App() {
|
|||||||
dataColumns={dataColumns}
|
dataColumns={dataColumns}
|
||||||
variableColumnMapping={variableColumnMapping}
|
variableColumnMapping={variableColumnMapping}
|
||||||
onChangeMapping={handleMappingChange}
|
onChangeMapping={handleMappingChange}
|
||||||
paper={paper}
|
paper={previewSnapshot?.paper ?? paper}
|
||||||
onChangePaper={handlePaperChange}
|
onChangePaper={handlePaperChange}
|
||||||
rows={mappedRows}
|
rows={mappedRows}
|
||||||
draftExportRange={draftExportRange}
|
draftExportRange={draftExportRange}
|
||||||
onChangeDraftExportRange={setDraftExportRange}
|
onChangeDraftExportRange={setDraftExportRange}
|
||||||
cutLine={cutLine}
|
cutLine={cutLine}
|
||||||
onChangeCutLine={handleCutLineChange}
|
onChangeCutLine={handleCutLineChange}
|
||||||
selectedCount={selectedCount}
|
printablePages={previewSnapshot?.pages ?? []}
|
||||||
totalPages={totalPages}
|
gridCols={previewSnapshot?.gridCols ?? gridInfo.cols}
|
||||||
|
labelsPerPage={previewSnapshot?.labelsPerPage ?? labelsPerPage}
|
||||||
|
totalPages={previewSnapshot?.totalPages ?? totalPages}
|
||||||
|
selectedCount={previewSnapshot?.selectedCount ?? selectedCount}
|
||||||
|
draftTotalPages={totalPages}
|
||||||
|
draftSelectedCount={selectedCount}
|
||||||
|
totalRowCount={activeRows.length}
|
||||||
|
previewReady={previewSnapshot !== null}
|
||||||
|
previewLayoutStale={previewLayoutStale}
|
||||||
|
activeRowIndex={activeRowIndex}
|
||||||
|
onSelectRowIndex={setActiveRowIndex}
|
||||||
pdfGenerating={pdfGenerating}
|
pdfGenerating={pdfGenerating}
|
||||||
onBack={() => setWorkflowStep('template-select')}
|
onBack={() => setWorkflowStep('template-select')}
|
||||||
onExportPdf={exportPdf}
|
onExportPdf={exportPdf}
|
||||||
|
|||||||
@@ -156,7 +156,7 @@ export const ExportSidebar: React.FC<ExportSidebarProps> = ({
|
|||||||
collapsed={dataCollapsed}
|
collapsed={dataCollapsed}
|
||||||
onToggle={() => toggleExportPanel('data')}
|
onToggle={() => toggleExportPanel('data')}
|
||||||
icon={<Database className="w-3.5 h-3.5 shrink-0 text-[#31a8ff]" />}
|
icon={<Database className="w-3.5 h-3.5 shrink-0 text-[#31a8ff]" />}
|
||||||
title="数据源与变量绑定"
|
title="数据源"
|
||||||
bodyClassName="ps-panel-body p-3 min-h-0 space-y-3"
|
bodyClassName="ps-panel-body p-3 min-h-0 space-y-3"
|
||||||
>
|
>
|
||||||
<DataImporter
|
<DataImporter
|
||||||
@@ -188,7 +188,7 @@ export const ExportSidebar: React.FC<ExportSidebarProps> = ({
|
|||||||
collapsed={layoutCollapsed}
|
collapsed={layoutCollapsed}
|
||||||
onToggle={() => toggleExportPanel('layout')}
|
onToggle={() => toggleExportPanel('layout')}
|
||||||
icon={<Layout className="w-3.5 h-3.5 shrink-0 text-[#31a8ff]" />}
|
icon={<Layout className="w-3.5 h-3.5 shrink-0 text-[#31a8ff]" />}
|
||||||
title="整页排版 & PDF 设置"
|
title="排版"
|
||||||
bodyClassName="ps-panel-body p-3 min-h-0 space-y-3"
|
bodyClassName="ps-panel-body p-3 min-h-0 space-y-3"
|
||||||
>
|
>
|
||||||
<PaperSettingsPanel
|
<PaperSettingsPanel
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ interface LabelDesignerProps {
|
|||||||
onChange: (updated: LabelTemplate) => void;
|
onChange: (updated: LabelTemplate) => void;
|
||||||
selectedElementIds: string[];
|
selectedElementIds: string[];
|
||||||
onSelectElements: (ids: string[]) => void;
|
onSelectElements: (ids: string[]) => void;
|
||||||
|
variant?: 'desktop' | 'mobile';
|
||||||
}
|
}
|
||||||
|
|
||||||
type ActiveTool = 'move' | 'text' | 'barcode' | 'qrcode';
|
type ActiveTool = 'move' | 'text' | 'barcode' | 'qrcode';
|
||||||
@@ -134,9 +135,11 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
|||||||
onChange,
|
onChange,
|
||||||
selectedElementIds,
|
selectedElementIds,
|
||||||
onSelectElements,
|
onSelectElements,
|
||||||
|
variant = 'desktop',
|
||||||
}) => {
|
}) => {
|
||||||
const selectedElementId = selectedElementIds[0] || null;
|
const selectedElementId = selectedElementIds[0] || null;
|
||||||
const isNarrow = useIsNarrowScreen();
|
const isMobileLayout = variant === 'mobile';
|
||||||
|
const isNarrow = useIsNarrowScreen() || isMobileLayout;
|
||||||
const [zoom, setZoom] = useState<number>(MODERATE_ZOOM_MAX);
|
const [zoom, setZoom] = useState<number>(MODERATE_ZOOM_MAX);
|
||||||
const [activeTool, setActiveTool] = useState<ActiveTool>('move');
|
const [activeTool, setActiveTool] = useState<ActiveTool>('move');
|
||||||
const canvasAreaRef = useRef<HTMLDivElement | null>(null);
|
const canvasAreaRef = useRef<HTMLDivElement | null>(null);
|
||||||
@@ -744,7 +747,7 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
|||||||
const fitToView = () => {
|
const fitToView = () => {
|
||||||
const area = canvasAreaRef.current;
|
const area = canvasAreaRef.current;
|
||||||
if (!area) return;
|
if (!area) return;
|
||||||
const pad = 80;
|
const pad = isMobileLayout ? 32 : 80;
|
||||||
const availW = area.clientWidth - pad;
|
const availW = area.clientWidth - pad;
|
||||||
const availH = area.clientHeight - pad;
|
const availH = area.clientHeight - pad;
|
||||||
const docW = mmToPx(template.width, 1);
|
const docW = mmToPx(template.width, 1);
|
||||||
@@ -753,6 +756,12 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
|||||||
setZoom(Math.max(0.5, Math.round(fitZoom * 100) / 100));
|
setZoom(Math.max(0.5, Math.round(fitZoom * 100) / 100));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isMobileLayout) return;
|
||||||
|
const timer = window.setTimeout(() => fitToView(), 0);
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
|
}, [isMobileLayout, template.width, template.height]);
|
||||||
|
|
||||||
const selectedElemObj = (localElements || template.elements).find(
|
const selectedElemObj = (localElements || template.elements).find(
|
||||||
(el) => el.id === selectedElementId
|
(el) => el.id === selectedElementId
|
||||||
);
|
);
|
||||||
@@ -777,44 +786,85 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
|||||||
{ id: 'qrcode', icon: <QrCode className="w-[18px] h-[18px]" />, label: '二维码' },
|
{ id: 'qrcode', icon: <QrCode className="w-[18px] h-[18px]" />, label: '二维码' },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
const mainToolButtons = (
|
||||||
<div className="flex flex-1 min-w-0 min-h-0">
|
<>
|
||||||
<div className="ps-toolbar flex flex-col">
|
{tools.map((tool) => (
|
||||||
{tools.map((tool) => (
|
|
||||||
<button
|
|
||||||
key={tool.id}
|
|
||||||
className={`ps-tool-btn ${activeTool === tool.id ? 'active' : ''}`}
|
|
||||||
onClick={() => handleToolClick(tool.id)}
|
|
||||||
title={`${tool.label}${tool.shortcut ? ` (${tool.shortcut})` : ''}`}
|
|
||||||
>
|
|
||||||
{tool.icon}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
<button
|
<button
|
||||||
className="ps-tool-btn"
|
key={tool.id}
|
||||||
onClick={handleImageToolClick}
|
className={`ps-tool-btn ${activeTool === tool.id ? 'active' : ''} ${isMobileLayout ? 'mobile-design-tool-btn' : ''}`}
|
||||||
title="图片工具"
|
onClick={() => handleToolClick(tool.id)}
|
||||||
|
title={`${tool.label}${tool.shortcut ? ` (${tool.shortcut})` : ''}`}
|
||||||
>
|
>
|
||||||
<ImageIcon className="w-[18px] h-[18px]" />
|
{tool.icon}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
className={`ps-tool-btn ${isMobileLayout ? 'mobile-design-tool-btn' : ''}`}
|
||||||
|
onClick={handleImageToolClick}
|
||||||
|
title="图片工具"
|
||||||
|
>
|
||||||
|
<ImageIcon className="w-[18px] h-[18px]" />
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
ref={imageInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleImageFile}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
const deleteToolButton =
|
||||||
|
selectedElementIds.length > 0 ? (
|
||||||
|
<button
|
||||||
|
className={`ps-tool-btn hover:!bg-red-900/40 hover:!text-red-400 ${isMobileLayout ? 'mobile-design-tool-btn' : ''}`}
|
||||||
|
onClick={deleteSelectedElements}
|
||||||
|
title="删除选中元素"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-[18px] h-[18px]" />
|
||||||
|
</button>
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
const zoomControls = (
|
||||||
|
<div className="flex items-center gap-1.5 shrink-0">
|
||||||
|
<div className="ps-zoom-control">
|
||||||
|
<button className="ps-zoom-btn" onClick={() => setZoom(Math.max(0.5, zoom - 0.25))} title="缩小">
|
||||||
|
<ZoomOut className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
<span className="px-1.5 text-[10px] font-mono text-[#ccc] min-w-[42px] text-center select-none">
|
||||||
|
{Math.round(zoom * 100)}%
|
||||||
|
</span>
|
||||||
|
<button className="ps-zoom-btn" onClick={() => setZoom(Math.min(5, zoom + 0.25))} title="放大">
|
||||||
|
<ZoomIn className="w-3 h-3" />
|
||||||
</button>
|
</button>
|
||||||
<input
|
|
||||||
ref={imageInputRef}
|
|
||||||
type="file"
|
|
||||||
accept="image/*"
|
|
||||||
className="hidden"
|
|
||||||
onChange={handleImageFile}
|
|
||||||
/>
|
|
||||||
<div className="flex-1" />
|
|
||||||
{selectedElementIds.length > 0 && (
|
|
||||||
<button
|
|
||||||
className="ps-tool-btn hover:!bg-red-900/40 hover:!text-red-400"
|
|
||||||
onClick={deleteSelectedElements}
|
|
||||||
title="删除选中元素 (Del)"
|
|
||||||
>
|
|
||||||
<Trash2 className="w-[18px] h-[18px]" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={fitToView}
|
||||||
|
className="text-[10px] text-[#aaa] hover:text-white px-1.5 py-0.5 border border-[#4a4a4a] hover:border-[#666] transition cursor-pointer"
|
||||||
|
title="适合窗口"
|
||||||
|
>
|
||||||
|
适合
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
isMobileLayout
|
||||||
|
? 'mobile-design-canvas-shell flex flex-col flex-1 min-w-0 min-h-0'
|
||||||
|
: 'flex flex-1 min-w-0 min-h-0'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{!isMobileLayout && (
|
||||||
|
<div className="ps-toolbar flex flex-col">
|
||||||
|
{mainToolButtons}
|
||||||
|
<div className="flex-1" />
|
||||||
|
{deleteToolButton}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex flex-col flex-1 min-w-0 min-h-0">
|
<div className="flex flex-col flex-1 min-w-0 min-h-0">
|
||||||
<div
|
<div
|
||||||
@@ -839,7 +889,7 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
|||||||
height: `${mmToPx(template.height, zoom)}px`,
|
height: `${mmToPx(template.height, zoom)}px`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="absolute -top-5 left-0 text-[10px] font-mono text-[#aaa]">
|
{/* <div className="absolute -top-5 left-0 text-[10px] font-mono text-[#aaa]">
|
||||||
{template.width} mm
|
{template.width} mm
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
@@ -847,7 +897,7 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
|||||||
style={{ writingMode: 'vertical-rl', transform: 'rotate(180deg)' }}
|
style={{ writingMode: 'vertical-rl', transform: 'rotate(180deg)' }}
|
||||||
>
|
>
|
||||||
{template.height} mm
|
{template.height} mm
|
||||||
</div>
|
</div> */}
|
||||||
|
|
||||||
{/* 整页统一渲染:半透明背景按图层层级与下方元素真实合成 */}
|
{/* 整页统一渲染:半透明背景按图层层级与下方元素真实合成 */}
|
||||||
<div className="absolute inset-0 overflow-hidden pointer-events-none z-0">
|
<div className="absolute inset-0 overflow-hidden pointer-events-none z-0">
|
||||||
@@ -962,71 +1012,63 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={`ps-status-bar ${isNarrow ? 'ps-status-bar-compact' : ''}`}>
|
{isMobileLayout ? (
|
||||||
<div className="flex items-center gap-2 min-w-0 truncate">
|
<div className="mobile-design-toolstrip">
|
||||||
{isNarrow ? (
|
<div className="mobile-design-toolstrip-tools">
|
||||||
<>
|
{mainToolButtons}
|
||||||
|
{deleteToolButton}
|
||||||
|
</div>
|
||||||
|
{zoomControls}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className={`ps-status-bar ${isNarrow ? 'ps-status-bar-compact' : ''}`}>
|
||||||
|
<div className="flex items-center gap-2 min-w-0 truncate">
|
||||||
|
{isNarrow ? (
|
||||||
<span className="truncate">
|
<span className="truncate">
|
||||||
{template.width}×{template.height}
|
{template.width}×{template.height}
|
||||||
{selectedElemObj ? ` · ${selectedElemObj.name}` : ''}
|
{selectedElemObj ? ` · ${selectedElemObj.name}` : ''}
|
||||||
{selectedElementIds.length > 1 ? ` · ${selectedElementIds.length}选` : ''}
|
{selectedElementIds.length > 1 ? ` · ${selectedElementIds.length}选` : ''}
|
||||||
</span>
|
</span>
|
||||||
</>
|
) : (
|
||||||
) : (
|
<>
|
||||||
<>
|
<span>
|
||||||
<span>
|
文档: {template.width} × {template.height} mm
|
||||||
文档: {template.width} × {template.height} mm
|
</span>
|
||||||
|
{selectedElemObj && (
|
||||||
|
<span className="text-[#31a8ff]">
|
||||||
|
| {selectedElemObj.name} — X:{selectedElemObj.x} Y:{selectedElemObj.y} W:
|
||||||
|
{selectedElemObj.width} H:{selectedElemObj.height} mm
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{selectedElementIds.length > 1 && (
|
||||||
|
<span className="text-emerald-400">| 已选 {selectedElementIds.length} 个</span>
|
||||||
|
)}
|
||||||
|
{activeTool === 'move' && (
|
||||||
|
<span className="text-[#888] hidden sm:inline">
|
||||||
|
| 框选 · Shift/Ctrl+点选 · Ctrl+A 全选
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
{!isNarrow && (
|
||||||
|
<span className="hidden sm:inline text-[10px]">
|
||||||
|
预览使用变量默认值 · 方向键微调 · Ctrl+滚轮缩放
|
||||||
</span>
|
</span>
|
||||||
{selectedElemObj && (
|
)}
|
||||||
<span className="text-[#31a8ff]">
|
{zoomControls}
|
||||||
| {selectedElemObj.name} — X:{selectedElemObj.x} Y:{selectedElemObj.y} W:
|
<button
|
||||||
{selectedElemObj.width} H:{selectedElemObj.height} mm
|
type="button"
|
||||||
</span>
|
onClick={applyModerateZoom}
|
||||||
)}
|
className="text-[10px] text-[#aaa] hover:text-white px-1.5 py-0.5 border border-[#4a4a4a] hover:border-[#666] transition cursor-pointer"
|
||||||
{selectedElementIds.length > 1 && (
|
title="适中缩放"
|
||||||
<span className="text-emerald-400">| 已选 {selectedElementIds.length} 个</span>
|
>
|
||||||
)}
|
适中
|
||||||
{activeTool === 'move' && (
|
|
||||||
<span className="text-[#888] hidden sm:inline">
|
|
||||||
| 框选 · Shift/Ctrl+点选 · Ctrl+A 全选
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 shrink-0">
|
|
||||||
{!isNarrow && (
|
|
||||||
<span className="hidden sm:inline text-[10px]">
|
|
||||||
预览使用变量默认值 · 方向键微调 · Ctrl+滚轮缩放
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<div className="ps-zoom-control">
|
|
||||||
<button className="ps-zoom-btn" onClick={() => setZoom(Math.max(0.5, zoom - 0.25))} title="缩小">
|
|
||||||
<ZoomOut className="w-3 h-3" />
|
|
||||||
</button>
|
|
||||||
<span className="px-1.5 text-[10px] font-mono text-[#ccc] min-w-[42px] text-center select-none">
|
|
||||||
{Math.round(zoom * 100)}%
|
|
||||||
</span>
|
|
||||||
<button className="ps-zoom-btn" onClick={() => setZoom(Math.min(5, zoom + 0.25))} title="放大">
|
|
||||||
<ZoomIn className="w-3 h-3" />
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button
|
|
||||||
onClick={applyModerateZoom}
|
|
||||||
className="text-[10px] text-[#aaa] hover:text-white px-1.5 py-0.5 border border-[#4a4a4a] hover:border-[#666] transition cursor-pointer"
|
|
||||||
title="适中缩放"
|
|
||||||
>
|
|
||||||
适中
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={fitToView}
|
|
||||||
className="text-[10px] text-[#aaa] hover:text-white px-1.5 py-0.5 border border-[#4a4a4a] hover:border-[#666] transition cursor-pointer"
|
|
||||||
title="适合窗口"
|
|
||||||
>
|
|
||||||
适合
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
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 {
|
import {
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
Printer,
|
FileDown,
|
||||||
Loader2,
|
Loader2,
|
||||||
Database,
|
Database,
|
||||||
Link2,
|
Layout,
|
||||||
Download,
|
Download,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
@@ -15,11 +15,23 @@ import {
|
|||||||
PaperConfig,
|
PaperConfig,
|
||||||
CutLineConfig,
|
CutLineConfig,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
import { isVariableMappingConfigured } from '../utils';
|
import { isVariableMappingConfigured, type PrintableLabel } from '../utils';
|
||||||
import { DataImporter, downloadDataTemplate } from './DataImporter';
|
import { DataImporter, downloadDataTemplate } from './DataImporter';
|
||||||
import { VariableMappingPanel } from './VariableMappingPanel';
|
import { VariableMappingPanel } from './VariableMappingPanel';
|
||||||
import { MobileExportPropsPanel } from './MobileExportPropsPanel';
|
import { MobileExportLayoutPanel } from './MobileExportLayoutPanel';
|
||||||
import { DataRangeFields } from './DataRangeFields';
|
import { DataRangeFields } from './DataRangeFields';
|
||||||
|
import { LayoutPreview } from './PreviewGrid';
|
||||||
|
|
||||||
|
type MobileExportModule = 'data' | 'layout';
|
||||||
|
|
||||||
|
const MOBILE_EXPORT_MODULES: {
|
||||||
|
id: MobileExportModule;
|
||||||
|
label: string;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
}[] = [
|
||||||
|
{ id: 'data', label: '数据源', icon: <Database className="w-4 h-4" /> },
|
||||||
|
{ id: 'layout', label: '排版', icon: <Layout className="w-4 h-4" /> },
|
||||||
|
];
|
||||||
|
|
||||||
interface MobileExportViewProps {
|
interface MobileExportViewProps {
|
||||||
template: LabelTemplate;
|
template: LabelTemplate;
|
||||||
@@ -38,8 +50,18 @@ interface MobileExportViewProps {
|
|||||||
onChangeDraftExportRange: (range: ExportRangeConfig) => void;
|
onChangeDraftExportRange: (range: ExportRangeConfig) => void;
|
||||||
cutLine: CutLineConfig;
|
cutLine: CutLineConfig;
|
||||||
onChangeCutLine: (cutLine: CutLineConfig) => void;
|
onChangeCutLine: (cutLine: CutLineConfig) => void;
|
||||||
selectedCount: number;
|
printablePages: (PrintableLabel | null)[][];
|
||||||
|
gridCols: number;
|
||||||
|
labelsPerPage: number;
|
||||||
totalPages: number;
|
totalPages: number;
|
||||||
|
selectedCount: number;
|
||||||
|
draftTotalPages: number;
|
||||||
|
draftSelectedCount: number;
|
||||||
|
totalRowCount: number;
|
||||||
|
previewReady: boolean;
|
||||||
|
previewLayoutStale: boolean;
|
||||||
|
activeRowIndex: number;
|
||||||
|
onSelectRowIndex: (idx: number) => void;
|
||||||
pdfGenerating: boolean;
|
pdfGenerating: boolean;
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
onExportPdf: () => void;
|
onExportPdf: () => void;
|
||||||
@@ -62,12 +84,24 @@ export const MobileExportView: React.FC<MobileExportViewProps> = ({
|
|||||||
onChangeDraftExportRange,
|
onChangeDraftExportRange,
|
||||||
cutLine,
|
cutLine,
|
||||||
onChangeCutLine,
|
onChangeCutLine,
|
||||||
selectedCount,
|
printablePages,
|
||||||
|
gridCols,
|
||||||
|
labelsPerPage,
|
||||||
totalPages,
|
totalPages,
|
||||||
|
selectedCount,
|
||||||
|
draftTotalPages,
|
||||||
|
draftSelectedCount,
|
||||||
|
totalRowCount,
|
||||||
|
previewReady,
|
||||||
|
previewLayoutStale,
|
||||||
|
activeRowIndex,
|
||||||
|
onSelectRowIndex,
|
||||||
pdfGenerating,
|
pdfGenerating,
|
||||||
onBack,
|
onBack,
|
||||||
onExportPdf,
|
onExportPdf,
|
||||||
}) => {
|
}) => {
|
||||||
|
const [mobileModule, setMobileModule] = useState<MobileExportModule>('data');
|
||||||
|
|
||||||
const hasData = rows.length > 0;
|
const hasData = rows.length > 0;
|
||||||
const mappedCount = templateVariables.filter((v) =>
|
const mappedCount = templateVariables.filter((v) =>
|
||||||
isVariableMappingConfigured(variableColumnMapping, v, dataColumns)
|
isVariableMappingConfigured(variableColumnMapping, v, dataColumns)
|
||||||
@@ -83,133 +117,155 @@ export const MobileExportView: React.FC<MobileExportViewProps> = ({
|
|||||||
? '当前数据范围内无标签'
|
? '当前数据范围内无标签'
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
const exportDisabled = !!exportBlockedReason || pdfGenerating;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mobile-export-view">
|
<div className="mobile-export-view mobile-design-view">
|
||||||
<header className="no-print bg-indigo-700 border-b border-indigo-900 shrink-0 shadow mobile-export-header px-4">
|
<header className="mobile-design-header">
|
||||||
<div className="flex items-center w-full">
|
<button type="button" onClick={onBack} className="mobile-export-back" aria-label="返回">
|
||||||
<label
|
<ChevronLeft className="w-5 h-5" />
|
||||||
onClick={onBack}
|
</button>
|
||||||
className={`inline-flex items-center gap-1.5 bg-indigo-600 hover:bg-indigo-800 text-white rounded-xl font-semibold whitespace-nowrap transition shadow-sm cursor-pointer
|
<div className="min-w-0 flex-1 px-1">
|
||||||
min-h-[40px] px-3 py-2 text-[11px]`}
|
<p className="text-sm font-semibold text-slate-100 truncate" title={templateName}>
|
||||||
>
|
|
||||||
<ChevronLeft className="w-5 h-5" />
|
|
||||||
</label>
|
|
||||||
<p
|
|
||||||
className="flex-1 min-w-0 px-2 text-center text-sm font-bold tracking-wide text-slate-100 truncate"
|
|
||||||
title={templateName}
|
|
||||||
>
|
|
||||||
{templateName}
|
{templateName}
|
||||||
</p>
|
</p>
|
||||||
<div className="w-10 shrink-0" aria-hidden />
|
<p className="text-[11px] text-slate-400 truncate">
|
||||||
|
{previewReady
|
||||||
|
? `${selectedCount} 枚 · ${totalPages} 页`
|
||||||
|
: hasData
|
||||||
|
? `已上传 ${rows.length} 行`
|
||||||
|
: '上传数据后开始排版'}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
|
||||||
|
|
||||||
<main className="mobile-export-content">
|
|
||||||
<section className="mobile-export-card">
|
|
||||||
<div className="mobile-export-card-head">
|
|
||||||
<Database className="w-4 h-4 text-indigo-500 shrink-0" />
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="flex items-start justify-between gap-2">
|
|
||||||
<div className="min-w-0">
|
|
||||||
<h2>数据源</h2>
|
|
||||||
<p>{hasData ? '已上传,可设置数据范围' : '上传 Excel 或 CSV 文件'}</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => downloadDataTemplate(templateName, templateVariables)}
|
|
||||||
className="mobile-export-icon-btn"
|
|
||||||
aria-label="下载数据模板"
|
|
||||||
>
|
|
||||||
<Download className="w-5 h-5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="mobile-export-card-body space-y-5">
|
|
||||||
<DataImporter
|
|
||||||
variant="mobile"
|
|
||||||
importData={importData}
|
|
||||||
onDataLoaded={onDataLoaded}
|
|
||||||
onClear={onClear}
|
|
||||||
templateName={templateName}
|
|
||||||
templateVariables={templateVariables}
|
|
||||||
/>
|
|
||||||
{hasData && (
|
|
||||||
<DataRangeFields
|
|
||||||
variant="mobile"
|
|
||||||
exportRange={draftExportRange}
|
|
||||||
onChangeExportRange={onChangeDraftExportRange}
|
|
||||||
rows={rows}
|
|
||||||
paper={paper}
|
|
||||||
template={template}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{hasData && (
|
|
||||||
<section className="mobile-export-card">
|
|
||||||
<div className="mobile-export-card-head">
|
|
||||||
<Link2 className="w-4 h-4 text-indigo-500 shrink-0" />
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<h2>关联变量</h2>
|
|
||||||
<p>
|
|
||||||
{needsMapping
|
|
||||||
? `已关联 ${mappedCount}/${templateVariables.length}`
|
|
||||||
: '此模板无需变量映射'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="mobile-export-card-body">
|
|
||||||
<VariableMappingPanel
|
|
||||||
variant="mobile"
|
|
||||||
variables={templateVariables}
|
|
||||||
columns={dataColumns}
|
|
||||||
mapping={variableColumnMapping}
|
|
||||||
onChangeMapping={onChangeMapping}
|
|
||||||
variableDefaults={template.variableDefaults}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<MobileExportPropsPanel
|
|
||||||
template={template}
|
|
||||||
paper={paper}
|
|
||||||
onChangePaper={onChangePaper}
|
|
||||||
rows={rows}
|
|
||||||
exportRange={draftExportRange}
|
|
||||||
cutLine={cutLine}
|
|
||||||
onChangeCutLine={onChangeCutLine}
|
|
||||||
locked={!hasData}
|
|
||||||
/>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<footer className="mobile-export-footer">
|
|
||||||
{exportBlockedReason && !pdfGenerating && (
|
|
||||||
<p className="mobile-export-footer-hint">{exportBlockedReason}</p>
|
|
||||||
)}
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onExportPdf}
|
onClick={onExportPdf}
|
||||||
disabled={!!exportBlockedReason || pdfGenerating}
|
disabled={exportDisabled}
|
||||||
className="mobile-export-submit"
|
className={`inline-flex items-center gap-1 ml-1 px-2.5 py-1.5 text-[11px] font-semibold rounded-lg shrink-0 ${
|
||||||
|
exportDisabled
|
||||||
|
? 'bg-[#444] text-[#888] cursor-not-allowed'
|
||||||
|
: 'bg-indigo-600 hover:bg-indigo-700 active:bg-indigo-800 text-white'
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
{pdfGenerating ? (
|
{pdfGenerating ? (
|
||||||
<>
|
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||||
<Loader2 className="w-5 h-5 animate-spin shrink-0" />
|
|
||||||
PDF 生成中
|
|
||||||
</>
|
|
||||||
) : (
|
) : (
|
||||||
<>
|
<FileDown className="w-3.5 h-3.5" />
|
||||||
<Printer className="w-5 h-5 shrink-0" />
|
|
||||||
{exportBlockedReason
|
|
||||||
? '生成标签PDF文件'
|
|
||||||
: `生成标签PDF文件(${selectedCount} 枚 · ${totalPages} 页)`}
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
|
PDF
|
||||||
</button>
|
</button>
|
||||||
</footer>
|
</header>
|
||||||
|
|
||||||
|
<div className="mobile-design-canvas-wrap mobile-export-preview-wrap">
|
||||||
|
<LayoutPreview
|
||||||
|
variant="mobile"
|
||||||
|
paper={paper}
|
||||||
|
template={template}
|
||||||
|
printablePages={printablePages}
|
||||||
|
gridCols={gridCols}
|
||||||
|
labelsPerPage={labelsPerPage}
|
||||||
|
totalPages={totalPages}
|
||||||
|
selectedCount={selectedCount}
|
||||||
|
totalRowCount={totalRowCount}
|
||||||
|
draftTotalPages={draftTotalPages}
|
||||||
|
draftSelectedCount={draftSelectedCount}
|
||||||
|
previewReady={previewReady}
|
||||||
|
previewLayoutStale={previewLayoutStale}
|
||||||
|
cutLine={cutLine}
|
||||||
|
activeRowIndex={activeRowIndex}
|
||||||
|
onSelectRowIndex={onSelectRowIndex}
|
||||||
|
compact
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mobile-design-props-wrap">
|
||||||
|
<div className="mobile-design-props">
|
||||||
|
<nav className="mobile-design-props-nav mobile-export-props-nav" aria-label="导出模块">
|
||||||
|
{MOBILE_EXPORT_MODULES.map((item) => (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setMobileModule(item.id)}
|
||||||
|
className={`mobile-design-props-nav-btn mobile-export-props-nav-btn ${
|
||||||
|
mobileModule === item.id ? 'active' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{item.icon}
|
||||||
|
<span>{item.label}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
<div className="mobile-design-props-panel scrollbar-hide">
|
||||||
|
{mobileModule === 'data' && (
|
||||||
|
<div className="ps-panel-body p-3 space-y-4 min-h-0">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-[11px] font-semibold text-[#e8e8e8]">数据源</p>
|
||||||
|
<p className="text-[10px] text-[#888] mt-0.5">
|
||||||
|
{hasData ? '已上传,可设置数据范围' : '上传 Excel 或 CSV'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => downloadDataTemplate(templateName, templateVariables)}
|
||||||
|
className="mobile-design-header-icon-btn shrink-0"
|
||||||
|
aria-label="下载数据模板"
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<DataImporter
|
||||||
|
variant="ps"
|
||||||
|
importData={importData}
|
||||||
|
onDataLoaded={onDataLoaded}
|
||||||
|
onClear={onClear}
|
||||||
|
templateName={templateName}
|
||||||
|
templateVariables={templateVariables}
|
||||||
|
/>
|
||||||
|
{hasData && (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<p className="text-[11px] font-semibold text-[#e8e8e8] mb-2">关联变量</p>
|
||||||
|
<VariableMappingPanel
|
||||||
|
variant="ps"
|
||||||
|
variables={templateVariables}
|
||||||
|
columns={dataColumns}
|
||||||
|
mapping={variableColumnMapping}
|
||||||
|
onChangeMapping={onChangeMapping}
|
||||||
|
variableDefaults={template.variableDefaults}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<DataRangeFields
|
||||||
|
variant="ps"
|
||||||
|
exportRange={draftExportRange}
|
||||||
|
onChangeExportRange={onChangeDraftExportRange}
|
||||||
|
rows={rows}
|
||||||
|
paper={paper}
|
||||||
|
template={template}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{exportBlockedReason && !pdfGenerating && (
|
||||||
|
<p className="text-[10px] text-amber-500/90 text-center">{exportBlockedReason}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{mobileModule === 'layout' && (
|
||||||
|
<div className="ps-panel-body p-3 min-h-0">
|
||||||
|
<MobileExportLayoutPanel
|
||||||
|
template={template}
|
||||||
|
paper={paper}
|
||||||
|
onChangePaper={onChangePaper}
|
||||||
|
rows={rows}
|
||||||
|
exportRange={draftExportRange}
|
||||||
|
cutLine={cutLine}
|
||||||
|
onChangeCutLine={onChangeCutLine}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -189,7 +189,7 @@ export const PaperSettingsPanel: React.FC<PaperSettingsPanelProps> = ({
|
|||||||
>
|
>
|
||||||
{!isPs && !isMobile && (
|
{!isPs && !isMobile && (
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
<h3 className="text-sm font-bold text-gray-900 leading-none">整页排版 & PDF 设置</h3>
|
<h3 className="text-sm font-bold text-gray-900 leading-none">排版</h3>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{isPs && (
|
{isPs && (
|
||||||
@@ -410,6 +410,7 @@ export interface LayoutPreviewProps {
|
|||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
collapsed?: boolean;
|
collapsed?: boolean;
|
||||||
onToggleCollapsed?: () => void;
|
onToggleCollapsed?: () => void;
|
||||||
|
variant?: 'desktop' | 'mobile';
|
||||||
}
|
}
|
||||||
|
|
||||||
export const LayoutPreview: React.FC<LayoutPreviewProps> = ({
|
export const LayoutPreview: React.FC<LayoutPreviewProps> = ({
|
||||||
@@ -431,8 +432,12 @@ export const LayoutPreview: React.FC<LayoutPreviewProps> = ({
|
|||||||
compact = false,
|
compact = false,
|
||||||
collapsed = false,
|
collapsed = false,
|
||||||
onToggleCollapsed,
|
onToggleCollapsed,
|
||||||
|
variant = 'desktop',
|
||||||
}) => {
|
}) => {
|
||||||
const [previewScale, setPreviewScale] = useState<number>(compact ? 1.4 : 3.3);
|
const isMobileVariant = variant === 'mobile';
|
||||||
|
const [previewScale, setPreviewScale] = useState<number>(
|
||||||
|
isMobileVariant ? 1.15 : compact ? 1.4 : 3.3
|
||||||
|
);
|
||||||
const [showAllPages, setShowAllPages] = useState(false);
|
const [showAllPages, setShowAllPages] = useState(false);
|
||||||
const MAX_PREVIEW_PAGES = 3;
|
const MAX_PREVIEW_PAGES = 3;
|
||||||
|
|
||||||
@@ -478,29 +483,33 @@ export const LayoutPreview: React.FC<LayoutPreviewProps> = ({
|
|||||||
className={`flex-1 flex flex-col min-h-0 min-w-0 layout-preview-panel ${compact && collapsed ? 'layout-preview-collapsed' : ''
|
className={`flex-1 flex flex-col min-h-0 min-w-0 layout-preview-panel ${compact && collapsed ? 'layout-preview-collapsed' : ''
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="ps-preview-toolbar">
|
<div className={`ps-preview-toolbar ${isMobileVariant ? 'mobile-preview-toolbar' : ''}`}>
|
||||||
<div className="flex items-center gap-2 text-[11px] text-[#aaa] min-w-0 flex-1">
|
<div className="flex items-center gap-2 text-[11px] text-[#aaa] min-w-0 flex-1">
|
||||||
<FileDown className="w-3.5 h-3.5 text-[#31a8ff] shrink-0" />
|
<FileDown className="w-3.5 h-3.5 text-[#31a8ff] shrink-0" />
|
||||||
<span className="font-semibold text-[#e8e8e8]">排版预览</span>
|
<span className="font-semibold text-[#e8e8e8]">排版预览</span>
|
||||||
<span className="text-[#666]">|</span>
|
<span className="text-[#666]">|</span>
|
||||||
<span>
|
<span className="truncate">
|
||||||
{previewReady ? totalPages : draftTotalPages} 页 PDF
|
{previewReady ? totalPages : draftTotalPages} 页
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[#666]">|</span>
|
{!isMobileVariant && (
|
||||||
<span>
|
<>
|
||||||
{(previewReady ? selectedCount : draftSelectedCount) < totalRowCount
|
<span className="text-[#666]">|</span>
|
||||||
? `${previewReady ? selectedCount : draftSelectedCount}/${totalRowCount} 行`
|
<span>
|
||||||
: `${totalRowCount} 行`}
|
{(previewReady ? selectedCount : draftSelectedCount) < totalRowCount
|
||||||
</span>
|
? `${previewReady ? selectedCount : draftSelectedCount}/${totalRowCount} 行`
|
||||||
{previewLayoutStale && previewReady && (
|
: `${totalRowCount} 行`}
|
||||||
<span className="text-amber-500/90">预览待更新</span>
|
</span>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
{previewReady && hasMorePages && !showAllPages && (
|
{previewLayoutStale && previewReady && (
|
||||||
|
<span className="text-amber-500/90 shrink-0">待更新</span>
|
||||||
|
)}
|
||||||
|
{previewReady && hasMorePages && !showAllPages && !isMobileVariant && (
|
||||||
<span className="text-amber-500/90">预览前 {MAX_PREVIEW_PAGES} 页</span>
|
<span className="text-amber-500/90">预览前 {MAX_PREVIEW_PAGES} 页</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-3 shrink-0">
|
<div className={`flex items-center gap-3 shrink-0 ${isMobileVariant ? 'hidden' : ''}`}>
|
||||||
{compact && onToggleCollapsed && (
|
{compact && onToggleCollapsed && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -558,16 +567,26 @@ export const LayoutPreview: React.FC<LayoutPreviewProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-auto ps-workspace-bg p-6 min-h-0 ps-preview-workspace">
|
<div
|
||||||
|
className={`flex-1 overflow-auto ps-workspace-bg min-h-0 ps-preview-workspace ${
|
||||||
|
isMobileVariant ? 'p-3 mobile-preview-workspace' : 'p-6'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
{!previewReady ? (
|
{!previewReady ? (
|
||||||
<div className="h-full flex flex-col items-center justify-center text-center px-8 gap-3">
|
<div className="h-full flex flex-col items-center justify-center text-center px-6 gap-2">
|
||||||
<p className="text-sm text-[#aaa]">上传数据源并完成变量映射后</p>
|
<p className="text-sm text-[#aaa]">上传数据源并完成变量映射后</p>
|
||||||
<p className="text-xs text-[#666] max-w-[320px] leading-relaxed">
|
<p className="text-xs text-[#666] max-w-[280px] leading-relaxed">
|
||||||
在右侧「数据源与变量绑定」中点击「应用数据」生成排版预览;纸张排版变更会自动更新
|
{isMobileVariant
|
||||||
|
? '在下方「数据源与变量绑定」中上传 Excel,预览将自动更新'
|
||||||
|
: '在右侧「数据源与变量绑定」中点击「应用数据」生成排版预览;纸张排版变更会自动更新'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-8 pb-8 flex flex-col items-center">
|
<div
|
||||||
|
className={`flex flex-col items-center ${
|
||||||
|
isMobileVariant ? 'space-y-4 pb-4' : 'space-y-8 pb-8'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
{visiblePages.map((pageRows, pageIdx) => (
|
{visiblePages.map((pageRows, pageIdx) => (
|
||||||
<div
|
<div
|
||||||
key={pageIdx}
|
key={pageIdx}
|
||||||
@@ -695,15 +714,17 @@ export const LayoutPreview: React.FC<LayoutPreviewProps> = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="ps-status-bar">
|
<div className={`ps-status-bar ${isMobileVariant ? 'mobile-preview-status' : ''}`}>
|
||||||
<span>
|
<span>
|
||||||
{previewReady
|
{previewReady
|
||||||
? `${gridInfo.cols} 列 × ${gridInfo.rows} 行 · 每页 ${labelsPerPage} 枚`
|
? `${gridInfo.cols}×${gridInfo.rows} · 每页 ${labelsPerPage} 枚`
|
||||||
: `待绘制 · 预计 ${draftTotalPages} 页`}
|
: `预计 ${draftTotalPages} 页`}
|
||||||
</span>
|
|
||||||
<span>
|
|
||||||
{previewReady ? '点击标签可高亮对应数据行' : '数据变更后请在右侧面板手动刷新绘制'}
|
|
||||||
</span>
|
</span>
|
||||||
|
{!isMobileVariant && (
|
||||||
|
<span>
|
||||||
|
{previewReady ? '点击标签可高亮对应数据行' : '数据变更后请在右侧面板手动刷新绘制'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
} from '../elementAlign';
|
} from '../elementAlign';
|
||||||
import { CollapsiblePanelSection } from './CollapsiblePanelSection';
|
import { CollapsiblePanelSection } from './CollapsiblePanelSection';
|
||||||
import { CommitInput } from './CommitInput';
|
import { CommitInput } from './CommitInput';
|
||||||
|
import { useIsNarrowScreen } from '../hooks/useIsMobile';
|
||||||
import {
|
import {
|
||||||
Sliders,
|
Sliders,
|
||||||
LayoutGrid,
|
LayoutGrid,
|
||||||
@@ -146,6 +147,8 @@ const PropTextarea: React.FC<PropTextareaProps> = ({ value, onCommit, ...props }
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type MobilePropModule = 'content' | 'properties' | 'layers';
|
||||||
|
|
||||||
interface PropSidebarProps {
|
interface PropSidebarProps {
|
||||||
template: LabelTemplate;
|
template: LabelTemplate;
|
||||||
onChangeTemplate: (updated: LabelTemplate) => void;
|
onChangeTemplate: (updated: LabelTemplate) => void;
|
||||||
@@ -154,8 +157,15 @@ interface PropSidebarProps {
|
|||||||
onChangeTab: (tab: 'element' | 'paper') => void;
|
onChangeTab: (tab: 'element' | 'paper') => void;
|
||||||
paper: PaperConfig;
|
paper: PaperConfig;
|
||||||
onSelectElements?: (ids: string[]) => void;
|
onSelectElements?: (ids: string[]) => void;
|
||||||
|
variant?: 'desktop' | 'mobile';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const MOBILE_PROP_MODULES: { id: MobilePropModule; label: string; icon: React.ReactNode }[] = [
|
||||||
|
{ id: 'properties', label: '属性', icon: <Sliders className="w-4 h-4" /> },
|
||||||
|
{ id: 'content', label: '内容', icon: <Link2 className="w-4 h-4" /> },
|
||||||
|
{ id: 'layers', label: '图层', icon: <Layers className="w-4 h-4" /> },
|
||||||
|
];
|
||||||
|
|
||||||
export const PropSidebar: React.FC<PropSidebarProps> = ({
|
export const PropSidebar: React.FC<PropSidebarProps> = ({
|
||||||
template,
|
template,
|
||||||
onChangeTemplate,
|
onChangeTemplate,
|
||||||
@@ -164,7 +174,9 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
|
|||||||
onChangeTab,
|
onChangeTab,
|
||||||
paper,
|
paper,
|
||||||
onSelectElements,
|
onSelectElements,
|
||||||
|
variant = 'desktop',
|
||||||
}) => {
|
}) => {
|
||||||
|
const isNarrowScreen = useIsNarrowScreen();
|
||||||
const selectedElementId = selectedElementIds[0] || null;
|
const selectedElementId = selectedElementIds[0] || null;
|
||||||
const selectedElem = template.elements.find((el) => el.id === selectedElementId);
|
const selectedElem = template.elements.find((el) => el.id === selectedElementId);
|
||||||
const [newVarName, setNewVarName] = useState('');
|
const [newVarName, setNewVarName] = useState('');
|
||||||
@@ -172,6 +184,7 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
|
|||||||
{ mode: 'add' } | { mode: 'edit'; index: number } | null
|
{ mode: 'add' } | { mode: 'edit'; index: number } | null
|
||||||
>(null);
|
>(null);
|
||||||
const [panelCollapsed, setPanelCollapsed] = useState<PanelCollapseState>(loadPanelCollapseState);
|
const [panelCollapsed, setPanelCollapsed] = useState<PanelCollapseState>(loadPanelCollapseState);
|
||||||
|
const [mobileModule, setMobileModule] = useState<MobilePropModule>('properties');
|
||||||
|
|
||||||
const togglePanel = (key: keyof PanelCollapseState) => {
|
const togglePanel = (key: keyof PanelCollapseState) => {
|
||||||
setPanelCollapsed((prev) => {
|
setPanelCollapsed((prev) => {
|
||||||
@@ -653,43 +666,8 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
const renderPropertiesPanelBody = () => (
|
||||||
<div className="ps-sidebar-stack">
|
<>
|
||||||
<VisibilityRuleDialog
|
|
||||||
open={visibilityRuleDialog !== null}
|
|
||||||
mode={visibilityRuleDialog?.mode ?? 'add'}
|
|
||||||
initialRule={editingVisibilityRule}
|
|
||||||
variableOptions={visibilityRuleDialogVariableOptions}
|
|
||||||
onClose={() => setVisibilityRuleDialog(null)}
|
|
||||||
onConfirm={handleConfirmVisibilityRule}
|
|
||||||
/>
|
|
||||||
<CollapsiblePanelSection
|
|
||||||
sectionClassName="content-panel"
|
|
||||||
collapsed={panelCollapsed.content}
|
|
||||||
onToggle={() => togglePanel('content')}
|
|
||||||
icon={<Link2 className="w-3.5 h-3.5 text-[#31a8ff] shrink-0" />}
|
|
||||||
title="内容"
|
|
||||||
bodyClassName="ps-panel-body p-3 min-h-0"
|
|
||||||
>
|
|
||||||
{renderContentPanelBody()}
|
|
||||||
</CollapsiblePanelSection>
|
|
||||||
|
|
||||||
<CollapsiblePanelSection
|
|
||||||
sectionClassName="properties-panel flex flex-col"
|
|
||||||
collapsed={panelCollapsed.properties}
|
|
||||||
onToggle={() => togglePanel('properties')}
|
|
||||||
icon={<Sliders className="w-3.5 h-3.5 text-[#31a8ff] shrink-0" />}
|
|
||||||
title="属性"
|
|
||||||
headerExtra={
|
|
||||||
selectedElem ? (
|
|
||||||
<span className="text-[10px] text-[#999] font-normal truncate max-w-[100px]">
|
|
||||||
{selectedElem.name}
|
|
||||||
</span>
|
|
||||||
) : null
|
|
||||||
}
|
|
||||||
bodyClassName="ps-panel-body p-3 space-y-4 min-h-0"
|
|
||||||
>
|
|
||||||
<>
|
|
||||||
{/* Label Canvas Dimensions */}
|
{/* Label Canvas Dimensions */}
|
||||||
{!selectedElem && (
|
{!selectedElem && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
@@ -1674,7 +1652,7 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
|
|||||||
updated[key] = e.target.value;
|
updated[key] = e.target.value;
|
||||||
handleElemChange(updated);
|
handleElemChange(updated);
|
||||||
}}
|
}}
|
||||||
className="color-input shrink-0 scale-90"
|
className="color-input shrink-0"
|
||||||
title={`${label}边边框颜色`}
|
title={`${label}边边框颜色`}
|
||||||
/>
|
/>
|
||||||
<PropInput
|
<PropInput
|
||||||
@@ -1737,20 +1715,11 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
</>
|
</>
|
||||||
</CollapsiblePanelSection>
|
);
|
||||||
|
|
||||||
<CollapsiblePanelSection
|
const renderLayersPanelBody = () => (
|
||||||
sectionClassName="layers-panel-section"
|
<>
|
||||||
collapsed={panelCollapsed.layers}
|
|
||||||
onToggle={() => togglePanel('layers')}
|
|
||||||
icon={<Layers className="w-3.5 h-3.5 shrink-0 text-[#31a8ff]" />}
|
|
||||||
title="图层"
|
|
||||||
headerExtra={
|
|
||||||
<span className="text-[10px] text-[#666] font-mono">{template.elements.length}</span>
|
|
||||||
}
|
|
||||||
bodyClassName="ps-panel-body min-h-0"
|
|
||||||
>
|
|
||||||
{template.elements.length === 0 ? (
|
{template.elements.length === 0 ? (
|
||||||
<div className="text-[11px] text-[#666] text-center py-6">暂无图层</div>
|
<div className="text-[11px] text-[#666] text-center py-6">暂无图层</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -1789,7 +1758,13 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-0.5 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
|
<div
|
||||||
|
className={`flex items-center gap-0.5 shrink-0 ${
|
||||||
|
isNarrowScreen || variant === 'mobile'
|
||||||
|
? 'opacity-100'
|
||||||
|
: 'opacity-0 group-hover:opacity-100 transition-opacity'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={(e) => { e.stopPropagation(); moveLayer(actualIdx, 'up'); }}
|
onClick={(e) => { e.stopPropagation(); moveLayer(actualIdx, 'up'); }}
|
||||||
@@ -1830,6 +1805,96 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
|
|||||||
);
|
);
|
||||||
})
|
})
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
const visibilityRuleDialogNode = (
|
||||||
|
<VisibilityRuleDialog
|
||||||
|
open={visibilityRuleDialog !== null}
|
||||||
|
mode={visibilityRuleDialog?.mode ?? 'add'}
|
||||||
|
initialRule={editingVisibilityRule}
|
||||||
|
variableOptions={visibilityRuleDialogVariableOptions}
|
||||||
|
onClose={() => setVisibilityRuleDialog(null)}
|
||||||
|
onConfirm={handleConfirmVisibilityRule}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (variant === 'mobile') {
|
||||||
|
return (
|
||||||
|
<div className="mobile-design-props">
|
||||||
|
{visibilityRuleDialogNode}
|
||||||
|
<nav className="mobile-design-props-nav" aria-label="属性模块">
|
||||||
|
{MOBILE_PROP_MODULES.map((item) => (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setMobileModule(item.id)}
|
||||||
|
className={`mobile-design-props-nav-btn ${mobileModule === item.id ? 'active' : ''}`}
|
||||||
|
>
|
||||||
|
{item.icon}
|
||||||
|
<span>{item.label}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
<div className="mobile-design-props-panel scrollbar-hide">
|
||||||
|
{mobileModule === 'content' && (
|
||||||
|
<div className="ps-panel-body p-3 min-h-0">{renderContentPanelBody()}</div>
|
||||||
|
)}
|
||||||
|
{mobileModule === 'properties' && (
|
||||||
|
<div className="ps-panel-body p-3 space-y-4 min-h-0">{renderPropertiesPanelBody()}</div>
|
||||||
|
)}
|
||||||
|
{mobileModule === 'layers' && (
|
||||||
|
<div className="ps-panel-body min-h-0 mobile-layers-panel">{renderLayersPanelBody()}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="ps-sidebar-stack">
|
||||||
|
{visibilityRuleDialogNode}
|
||||||
|
<CollapsiblePanelSection
|
||||||
|
sectionClassName="properties-panel flex flex-col"
|
||||||
|
collapsed={panelCollapsed.properties}
|
||||||
|
onToggle={() => togglePanel('properties')}
|
||||||
|
icon={<Sliders className="w-3.5 h-3.5 text-[#31a8ff] shrink-0" />}
|
||||||
|
title="属性"
|
||||||
|
headerExtra={
|
||||||
|
selectedElem ? (
|
||||||
|
<span className="text-[10px] text-[#999] font-normal truncate max-w-[100px]">
|
||||||
|
{selectedElem.name}
|
||||||
|
</span>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
bodyClassName="ps-panel-body p-3 space-y-4 min-h-0"
|
||||||
|
>
|
||||||
|
{renderPropertiesPanelBody()}
|
||||||
|
</CollapsiblePanelSection>
|
||||||
|
|
||||||
|
<CollapsiblePanelSection
|
||||||
|
sectionClassName="content-panel"
|
||||||
|
collapsed={panelCollapsed.content}
|
||||||
|
onToggle={() => togglePanel('content')}
|
||||||
|
icon={<Link2 className="w-3.5 h-3.5 text-[#31a8ff] shrink-0" />}
|
||||||
|
title="内容"
|
||||||
|
bodyClassName="ps-panel-body p-3 min-h-0"
|
||||||
|
>
|
||||||
|
{renderContentPanelBody()}
|
||||||
|
</CollapsiblePanelSection>
|
||||||
|
|
||||||
|
<CollapsiblePanelSection
|
||||||
|
sectionClassName="layers-panel-section"
|
||||||
|
collapsed={panelCollapsed.layers}
|
||||||
|
onToggle={() => togglePanel('layers')}
|
||||||
|
icon={<Layers className="w-3.5 h-3.5 shrink-0 text-[#31a8ff]" />}
|
||||||
|
title="图层"
|
||||||
|
headerExtra={
|
||||||
|
<span className="text-[10px] text-[#666] font-mono">{template.elements.length}</span>
|
||||||
|
}
|
||||||
|
bodyClassName="ps-panel-body min-h-0"
|
||||||
|
>
|
||||||
|
{renderLayersPanelBody()}
|
||||||
</CollapsiblePanelSection>
|
</CollapsiblePanelSection>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
251
src/index.css
251
src/index.css
@@ -368,6 +368,7 @@ body {
|
|||||||
appearance: none;
|
appearance: none;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
width: var(--color-input-size);
|
width: var(--color-input-size);
|
||||||
|
min-width: var(--color-input-size);
|
||||||
height: var(--color-input-size);
|
height: var(--color-input-size);
|
||||||
min-height: var(--color-input-size);
|
min-height: var(--color-input-size);
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@@ -407,6 +408,13 @@ body {
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* mobile-field 行内与输入框同高(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 {
|
.ps-section-title {
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
@@ -730,39 +738,62 @@ body {
|
|||||||
/* ── Mobile layout (< 768px) ── */
|
/* ── Mobile layout (< 768px) ── */
|
||||||
@media (max-width: 767px) {
|
@media (max-width: 767px) {
|
||||||
|
|
||||||
/* Mobile export flow */
|
/* Mobile export flow — shares mobile-design-view shell */
|
||||||
.mobile-export-view {
|
.mobile-export-view {
|
||||||
flex: 1 1 auto;
|
flex: 1;
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
height: 100%;
|
|
||||||
background: #f1f5f9;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mobile-export-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
padding-top: calc(12px + env(safe-area-inset-top, 0px));
|
|
||||||
padding-bottom: 12px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.mobile-export-back {
|
.mobile-export-back {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 40px;
|
width: 36px;
|
||||||
height: 40px;
|
height: 36px;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 10px;
|
border-radius: 8px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: #e2e8f0;
|
color: #ccc;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mobile-export-back:active {
|
.mobile-export-back:active {
|
||||||
background: rgba(255, 255, 255, 0.1);
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-export-preview-wrap {
|
||||||
|
background: #2a2a2a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-export-preview-wrap .mobile-preview-toolbar {
|
||||||
|
padding: 6px 10px;
|
||||||
|
min-height: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-export-preview-wrap .mobile-preview-workspace {
|
||||||
|
background: #2a2a2a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-export-preview-wrap .mobile-preview-status {
|
||||||
|
padding: 4px 10px;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-export-props-nav {
|
||||||
|
width: 76px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-export-props-nav-btn {
|
||||||
|
min-height: 68px;
|
||||||
|
font-size: 9px;
|
||||||
|
line-height: 1.25;
|
||||||
|
white-space: normal;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-export-props-nav-btn span {
|
||||||
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mobile-export-steps {
|
.mobile-export-steps {
|
||||||
@@ -1116,13 +1147,7 @@ body {
|
|||||||
margin: 0 0 8px;
|
margin: 0 0 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.color-input {
|
.flex:has(.mobile-field) > .color-input:focus {
|
||||||
--color-input-radius: 10px;
|
|
||||||
--color-input-border: #e2e8f0;
|
|
||||||
--color-input-size: 44px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.color-input:focus {
|
|
||||||
border-color: #818cf8;
|
border-color: #818cf8;
|
||||||
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.15);
|
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.15);
|
||||||
}
|
}
|
||||||
@@ -1191,13 +1216,32 @@ body {
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mobile-header-menu-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: rgba(255, 255, 255, 0.12);
|
||||||
|
color: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-header-menu-btn:active {
|
||||||
|
background: rgba(255, 255, 255, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
.mobile-template-actions {
|
.mobile-template-actions {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
flex-wrap: nowrap;
|
flex-wrap: nowrap;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mobile-template-actions .mobile-primary-btn {
|
.mobile-template-actions .mobile-primary-btn,
|
||||||
|
.mobile-template-actions .mobile-secondary-btn {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
@@ -1206,15 +1250,162 @@ body {
|
|||||||
transform: scale(0.97);
|
transform: scale(0.97);
|
||||||
}
|
}
|
||||||
|
|
||||||
.mobile-design-block {
|
.mobile-design-view {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
background: #323232;
|
||||||
|
color: #e8e8e8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-design-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
padding-top: calc(10px + env(safe-area-inset-top, 0px));
|
||||||
|
background: #292929;
|
||||||
|
border-bottom: 1px solid #1a1a1a;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-design-header-icon-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: transparent;
|
||||||
|
color: #ccc;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-design-header-icon-btn:active:not(:disabled) {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-design-header-icon-btn:disabled {
|
||||||
|
opacity: 0.35;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-design-canvas-wrap {
|
||||||
|
flex: 1 1 42%;
|
||||||
|
min-height: 180px;
|
||||||
|
max-height: 52vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
border-bottom: 1px solid #1a1a1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-design-canvas-shell {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-design-props-wrap {
|
||||||
|
flex: 1 1 48%;
|
||||||
|
min-height: 220px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #292929;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-design-props {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-design-props-nav {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
width: 64px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 8px 6px;
|
||||||
|
background: #252525;
|
||||||
|
border-right: 1px solid #1a1a1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-design-props-nav-btn {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 24px;
|
gap: 4px;
|
||||||
text-align: center;
|
min-height: 56px;
|
||||||
gap: 16px;
|
padding: 6px 4px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: transparent;
|
||||||
|
color: #888;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-design-props-nav-btn.active {
|
||||||
|
background: #1e3a4f;
|
||||||
|
color: #31a8ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-design-props-panel {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
scrollbar-width: none;
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-design-props-panel::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-layers-panel .ps-layer-item .opacity-0 {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-design-toolstrip {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
background: #292929;
|
||||||
|
border-top: 1px solid #1a1a1a;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-design-toolstrip-tools {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
overflow-x: auto;
|
||||||
|
scrollbar-width: none;
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-design-toolstrip-tools::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-design-tool-btn {
|
||||||
|
width: 40px !important;
|
||||||
|
height: 40px !important;
|
||||||
|
min-height: 40px !important;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user