This commit is contained in:
iqudoo
2026-06-12 21:39:00 +08:00
parent 220841444a
commit e71452ae78
7 changed files with 296 additions and 322 deletions

View File

@@ -1,4 +1,4 @@
import React, { useState, useMemo, useEffect } from 'react'; import React, { useState, useMemo, useEffect, useLayoutEffect } from 'react';
import { LabelTemplate, PaperConfig, ImportData, RecordRow, ExportRangeConfig, CutLineConfig } from './types'; import { LabelTemplate, PaperConfig, ImportData, RecordRow, ExportRangeConfig, CutLineConfig } from './types';
import { import {
stripTemplateForStorage, stripTemplateForStorage,
@@ -442,6 +442,40 @@ export default function App() {
return !exportRangeEquals(draftExportRange, appliedExportData.exportRange); return !exportRangeEquals(draftExportRange, appliedExportData.exportRange);
}, [importData, variableColumnMapping, appliedExportData, draftExportRange]); }, [importData, variableColumnMapping, appliedExportData, draftExportRange]);
// 移动端导出页实时同步数据,无需手动「应用数据」
useLayoutEffect(() => {
if (!isMobile || workflowStep !== 'print-view') return;
if (!importData || activeRows.length === 0) return;
const nextExportRange = { ...draftExportRange };
setAppliedExportData({
fileName: importData.fileName,
currentSheet: importData.currentSheet,
rows: importData.rows,
mapping: { ...variableColumnMapping },
exportRange: nextExportRange,
});
setPreviewSnapshot({
pages: layoutResult.pages,
gridCols: layoutResult.gridCols,
labelsPerPage: layoutResult.labelsPerPage,
totalPages: layoutResult.totalPages,
selectedCount: layoutResult.selectedCount,
paper,
exportRange: nextExportRange,
});
setPreviewLayoutStale(false);
}, [
isMobile,
workflowStep,
importData,
activeRows,
variableColumnMapping,
draftExportRange,
layoutResult,
paper,
]);
const handleApplyData = async () => { const handleApplyData = async () => {
if (!importData || activeRows.length === 0) { if (!importData || activeRows.length === 0) {
await showAlert('请先上传 Excel 或 CSV 数据。', { variant: 'warning' }); await showAlert('请先上传 Excel 或 CSV 数据。', { variant: 'warning' });
@@ -884,7 +918,7 @@ export default function App() {
</span> </span>
<div className="flex-1" /> <div className="flex-1" />
<span className="hidden md:inline text-[10px] text-[#666]"> <span className="hidden md:inline text-[10px] text-[#666]">
Shift/Ctrl+ · V · T · Shift/Ctrl+
</span> </span>
<button <button
onClick={() => openPrintView()} onClick={() => openPrintView()}
@@ -937,14 +971,8 @@ export default function App() {
onChangeDraftExportRange={setDraftExportRange} onChangeDraftExportRange={setDraftExportRange}
cutLine={cutLine} cutLine={cutLine}
onChangeCutLine={handleCutLineChange} onChangeCutLine={handleCutLineChange}
dataStale={dataStale} selectedCount={selectedCount}
dataApplied={appliedExportData !== null} totalPages={totalPages}
onApplyData={handleApplyData}
appliedSelectedCount={appliedLayoutResult.selectedCount}
appliedTotalPages={appliedLayoutResult.totalPages}
appliedGridCols={appliedLayoutResult.gridCols}
appliedGridRows={appliedLayoutResult.gridRows}
draftSelectedCount={selectedCount}
pdfGenerating={pdfGenerating} pdfGenerating={pdfGenerating}
onBack={() => setWorkflowStep('template-select')} onBack={() => setWorkflowStep('template-select')}
onExportPdf={exportPdf} onExportPdf={exportPdf}

View File

@@ -13,6 +13,50 @@ export function downloadDataTemplate(templateName: string, templateVariables: st
XLSX.writeFile(wb, `${safeName}_数据模板.xlsx`); XLSX.writeFile(wb, `${safeName}_数据模板.xlsx`);
} }
function readWorkbook(buffer: ArrayBuffer) {
return XLSX.read(new Uint8Array(buffer), { type: 'array' });
}
function parseWorksheet(
workbook: XLSX.WorkBook,
sheetName: string,
fileName: string
): ImportData | null {
const worksheet = workbook.Sheets[sheetName];
if (!worksheet) return null;
const jsonData = XLSX.utils.sheet_to_json<unknown[]>(worksheet, { header: 1, defval: '' });
if (jsonData.length === 0) return null;
const rawColumns = jsonData[0] as string[];
const columns = rawColumns
.map((c, idx) => (c ? String(c).trim() : `列_${idx + 1}`))
.filter(Boolean);
const rows: RecordRow[] = [];
for (let i = 1; i < jsonData.length; i++) {
const rawRow = jsonData[i] as unknown[];
if (
rawRow.every((cell) => cell === undefined || cell === null || String(cell).trim() === '')
) {
continue;
}
const row: RecordRow = {};
columns.forEach((col, colIdx) => {
row[col] = rawRow[colIdx] !== undefined ? String(rawRow[colIdx]).trim() : '';
});
rows.push(row);
}
return {
fileName,
sheets: workbook.SheetNames,
currentSheet: sheetName,
columns,
rows,
};
}
interface DataImporterProps { interface DataImporterProps {
importData: ImportData | null; importData: ImportData | null;
onDataLoaded: (data: ImportData) => void; onDataLoaded: (data: ImportData) => void;
@@ -34,7 +78,10 @@ export const DataImporter: React.FC<DataImporterProps> = ({
const isPs = variant === 'ps'; const isPs = variant === 'ps';
const isMobile = variant === 'mobile'; const isMobile = variant === 'mobile';
const fileInputRef = useRef<HTMLInputElement | null>(null); const fileInputRef = useRef<HTMLInputElement | null>(null);
/** 缓存已上传文件,切换工作表时无需依赖 input.files拖拽上传时 input 为空) */
const workbookBufferRef = useRef<ArrayBuffer | null>(null);
const [dragActive, setDragActive] = useState<boolean>(false); const [dragActive, setDragActive] = useState<boolean>(false);
const [sheetLoading, setSheetLoading] = useState(false);
const handleDrag = (e: React.DragEvent) => { const handleDrag = (e: React.DragEvent) => {
e.preventDefault(); e.preventDefault();
@@ -51,139 +98,109 @@ export const DataImporter: React.FC<DataImporterProps> = ({
e.stopPropagation(); e.stopPropagation();
setDragActive(false); setDragActive(false);
if (e.dataTransfer.files && e.dataTransfer.files[0]) { if (e.dataTransfer.files && e.dataTransfer.files[0]) {
processFile(e.dataTransfer.files[0]); void processFile(e.dataTransfer.files[0]);
} }
}; };
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files[0]) { if (e.target.files && e.target.files[0]) {
processFile(e.target.files[0]); void processFile(e.target.files[0]);
} }
}; };
const processFile = (file: File) => { const processFile = async (file: File) => {
const reader = new FileReader(); try {
reader.onload = async (e) => { const buffer = await file.arrayBuffer();
try { workbookBufferRef.current = buffer;
const data = new Uint8Array(e.target?.result as ArrayBuffer); const workbook = readWorkbook(buffer);
const workbook = XLSX.read(data, { type: 'array' }); const firstSheetName = workbook.SheetNames[0];
const firstSheetName = workbook.SheetNames[0]; const parsed = parseWorksheet(workbook, firstSheetName, file.name);
const worksheet = workbook.Sheets[firstSheetName];
const jsonData = XLSX.utils.sheet_to_json<any[]>(worksheet, { header: 1, defval: '' });
if (jsonData.length === 0) { if (!parsed || parsed.rows.length === 0) {
await showAlert('表格为空或格式不正确', { variant: 'warning' }); workbookBufferRef.current = null;
return; await showAlert('表格为空或格式不正确', { variant: 'warning' });
} return;
const rawColumns = jsonData[0] as string[];
const columns = rawColumns.map((c, idx) => (c ? String(c).trim() : `列_${idx + 1}`)).filter(Boolean);
const rows: RecordRow[] = [];
for (let i = 1; i < jsonData.length; i++) {
const rawRow = jsonData[i] as any[];
if (rawRow.every((cell) => cell === undefined || cell === null || String(cell).trim() === '')) {
continue;
}
const row: RecordRow = {};
columns.forEach((col, colIdx) => {
row[col] = rawRow[colIdx] !== undefined ? String(rawRow[colIdx]).trim() : '';
});
rows.push(row);
}
onDataLoaded({
fileName: file.name,
sheets: workbook.SheetNames,
currentSheet: firstSheetName,
columns,
rows,
});
} catch (err) {
console.error(err);
await showAlert('解析文件出错,请确保是标准的 Excel 或 CSV 文件', { variant: 'error' });
} }
};
reader.readAsArrayBuffer(file); onDataLoaded(parsed);
} catch (err) {
console.error(err);
workbookBufferRef.current = null;
await showAlert('解析文件出错,请确保是标准的 Excel 或 CSV 文件', { variant: 'error' });
}
}; };
const handleSheetChange = (sheetName: string) => { const handleSheetChange = async (sheetName: string) => {
if (!fileInputRef.current?.files?.[0]) return; if (!importData || sheetName === importData.currentSheet) return;
const file = fileInputRef.current.files[0];
const reader = new FileReader();
reader.onload = async (e) => {
try {
const data = new Uint8Array(e.target?.result as ArrayBuffer);
const workbook = XLSX.read(data, { type: 'array' });
const worksheet = workbook.Sheets[sheetName];
const jsonData = XLSX.utils.sheet_to_json<any[]>(worksheet, { header: 1, defval: '' });
if (jsonData.length === 0) return;
const rawColumns = jsonData[0] as string[]; const buffer = workbookBufferRef.current;
const columns = rawColumns.map((c, idx) => (c ? String(c).trim() : `列_${idx + 1}`)).filter(Boolean); if (!buffer) {
const rows: RecordRow[] = []; await showAlert('无法切换工作表,请重新上传文件', { variant: 'warning' });
for (let i = 1; i < jsonData.length; i++) { return;
const rawRow = jsonData[i] as any[]; }
if (rawRow.every((cell) => cell === undefined || cell === null || String(cell).trim() === '')) {
continue;
}
const row: RecordRow = {};
columns.forEach((col, colIdx) => {
row[col] = rawRow[colIdx] !== undefined ? String(rawRow[colIdx]).trim() : '';
});
rows.push(row);
}
onDataLoaded({ setSheetLoading(true);
fileName: file.name, try {
sheets: workbook.SheetNames, const workbook = readWorkbook(buffer);
currentSheet: sheetName, const parsed = parseWorksheet(workbook, sheetName, importData.fileName);
columns,
rows, if (!parsed) {
}); await showAlert('该工作表为空或无法解析', { variant: 'warning' });
} catch { return;
await showAlert('切换工作表时出错', { variant: 'error' });
} }
};
reader.readAsArrayBuffer(file); onDataLoaded(parsed);
} catch (err) {
console.error(err);
await showAlert('切换工作表时出错', { variant: 'error' });
} finally {
setSheetLoading(false);
}
};
const handleClear = () => {
workbookBufferRef.current = null;
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
onClear();
}; };
const rows = importData?.rows || []; const rows = importData?.rows || [];
const columns = importData?.columns || []; const columns = importData?.columns || [];
const hasMultipleSheets = (importData?.sheets.length ?? 0) > 1;
const wrapperClass = isMobile const wrapperClass = isMobile
? '' ? 'min-w-0 w-full'
: isPs : isPs
? 'space-y-3' ? 'space-y-3 min-w-0 w-full'
: 'bg-white border border-gray-200 rounded-2xl p-6 shadow-sm flex flex-col gap-5'; : 'bg-white border border-gray-200 rounded-2xl p-6 shadow-sm flex flex-col gap-5 min-w-0';
const sheetSelectClass = isMobile
? 'mobile-field text-sm py-2 flex-1 min-w-0 max-w-full'
: isPs
? 'ps-field text-[10px] py-1 flex-1 min-w-0 max-w-full'
: 'px-2.5 py-1 text-xs bg-white border border-gray-100 rounded-lg flex-1 min-w-0 max-w-full';
return ( return (
<div className={wrapperClass}> <div className={wrapperClass}>
{/* {!isPs && (
<div className="flex flex-wrap items-center justify-between gap-4">
<div>
<h3 className="text-sm font-bold text-gray-900 leading-none">批量数据源导入</h3>
<p className="text-[11px] text-gray-400 mt-1">上传 Excel 或 CSV绑定当前模板变量</p>
</div>
</div>
)} */}
{!isMobile && ( {!isMobile && (
<div className={`flex flex-wrap gap-2 ${isPs ? '' : 'justify-end'}`}> <div className={`flex flex-wrap gap-2 ${isPs ? '' : 'justify-end'}`}>
<button <button
type="button" type="button"
onClick={() => downloadDataTemplate(templateName, templateVariables)} onClick={() => downloadDataTemplate(templateName, templateVariables)}
className={ className={
isMobile isMobile
? 'mobile-secondary-btn w-full' ? 'mobile-secondary-btn w-full'
: isPs : isPs
? 'ps-btn text-[10px] w-full justify-center' ? 'ps-btn text-[10px] w-full justify-center'
: 'inline-flex items-center gap-1.5 px-3 py-1.5 border border-gray-200 text-gray-600 hover:bg-gray-50 rounded-lg text-xs font-semibold cursor-pointer' : 'inline-flex items-center gap-1.5 px-3 py-1.5 border border-gray-200 text-gray-600 hover:bg-gray-50 rounded-lg text-xs font-semibold cursor-pointer'
} }
> >
<Download className="w-3.5 h-3.5" /> <Download className="w-3.5 h-3.5" />
</button> </button>
</div> </div>
)} )}
{!importData ? ( {!importData ? (
@@ -197,16 +214,16 @@ export const DataImporter: React.FC<DataImporterProps> = ({
isMobile isMobile
? `mobile-upload-zone ${dragActive ? 'active' : ''}` ? `mobile-upload-zone ${dragActive ? 'active' : ''}`
: isPs : isPs
? `border border-dashed rounded py-8 px-4 text-center cursor-pointer transition flex flex-col items-center gap-2 ${ ? `border border-dashed rounded py-8 px-4 text-center cursor-pointer transition flex flex-col items-center gap-2 ${
dragActive dragActive
? 'border-[#31a8ff] bg-[#1a3a4f]/30' ? 'border-[#31a8ff] bg-[#1a3a4f]/30'
: 'border-[#4a4a4a] hover:border-[#31a8ff]/60 hover:bg-[#1e1e1e]' : 'border-[#4a4a4a] hover:border-[#31a8ff]/60 hover:bg-[#1e1e1e]'
}` }`
: `border-2 border-dashed rounded-2xl py-10 px-6 text-center cursor-pointer transition flex flex-col items-center gap-3 ${ : `border-2 border-dashed rounded-2xl py-10 px-6 text-center cursor-pointer transition flex flex-col items-center gap-3 ${
dragActive dragActive
? 'border-indigo-500 bg-indigo-50/20' ? 'border-indigo-500 bg-indigo-50/20'
: 'border-gray-200 hover:border-indigo-400 hover:bg-gray-50/50' : 'border-gray-200 hover:border-indigo-400 hover:bg-gray-50/50'
}` }`
} }
> >
<input <input
@@ -227,16 +244,17 @@ export const DataImporter: React.FC<DataImporterProps> = ({
isMobile isMobile
? 'text-[15px] font-semibold text-slate-700' ? 'text-[15px] font-semibold text-slate-700'
: isPs : isPs
? 'text-xs text-[#ccc]' ? 'text-xs text-[#ccc]'
: 'text-xs text-gray-700' : 'text-xs text-gray-700'
}`} }`}
> >
{isMobile ? '点击选择 Excel / CSV' : '点击上传或拖拽文件到此处'} {isMobile ? '点击选择 Excel / CSV' : '点击上传或拖拽文件到此处'}
</p> </p>
{isMobile && ( {isMobile && (
<p className="text-xs text-slate-400 leading-relaxed"> <p className="text-xs text-slate-400 leading-relaxed"> .xlsx.xls.csv</p>
.xlsx.xls.csv )}
</p> {!isMobile && isPs && (
<p className="text-[10px] text-[#666] mt-1"> Sheet</p>
)} )}
</div> </div>
</div> </div>
@@ -246,43 +264,74 @@ export const DataImporter: React.FC<DataImporterProps> = ({
isMobile isMobile
? 'mobile-file-card' ? 'mobile-file-card'
: isPs : isPs
? 'flex flex-wrap items-center justify-between p-2.5 bg-[#1e1e1e] border border-[#3a3a3a] rounded gap-3' ? 'p-2.5 bg-[#1e1e1e] border border-[#3a3a3a] rounded space-y-2.5 min-w-0'
: 'flex flex-wrap items-center justify-between p-3.5 bg-gray-50 rounded-xl gap-4 border border-gray-100' : 'p-3.5 bg-gray-50 rounded-xl border border-gray-100 space-y-3 min-w-0'
} }
> >
<div className="flex items-center gap-2 overflow-hidden min-w-0"> <div className="flex items-start gap-2 w-full min-w-0">
<FileSpreadsheet <div className="flex items-start gap-2 min-w-0 flex-1">
className={`w-5 h-5 shrink-0 ${isPs ? 'text-emerald-400' : 'text-emerald-600'}`} <FileSpreadsheet
/> className={`w-5 h-5 shrink-0 mt-0.5 ${isPs ? 'text-emerald-400' : 'text-emerald-600'}`}
<div className="overflow-hidden min-w-0"> />
<p <div className="min-w-0 flex-1">
className={`truncate ${ <p
isMobile ? 'text-sm text-slate-800' : isPs ? 'text-xs text-[#e8e8e8]' : 'text-gray-800' className={`truncate ${
}`} isMobile ? 'text-sm text-slate-800' : isPs ? 'text-xs text-[#e8e8e8]' : 'text-gray-800'
> }`}
{importData.fileName} title={importData.fileName}
</p> >
<p {importData.fileName}
className={`mt-0.5 ${ </p>
isMobile ? 'text-xs text-slate-500' : isPs ? 'text-[10px] text-[#777]' : 'text-[10px] text-gray-400' <p
}`} className={`mt-0.5 truncate ${
> isMobile ? 'text-xs text-slate-500' : isPs ? 'text-[10px] text-[#777]' : 'text-[10px] text-gray-400'
{rows.length} · {columns.length} }`}
</p> title={
hasMultipleSheets
? `${importData.currentSheet} · ${rows.length} 条记录 · ${columns.length}`
: `${rows.length} 条记录 · ${columns.length}`
}
>
{hasMultipleSheets && (
<span className={isPs ? 'text-[#31a8ff]' : 'text-indigo-600'}>
{importData.currentSheet}
{' · '}
</span>
)}
{rows.length} · {columns.length}
</p>
</div>
</div> </div>
<button
type="button"
onClick={handleClear}
className={
isMobile
? 'mobile-text-btn danger shrink-0'
: isPs
? 'ps-btn text-[10px] text-rose-300 hover:text-rose-200 shrink-0'
: 'flex items-center gap-1 text-xs font-semibold text-rose-600 hover:bg-rose-50 px-2.5 py-1.5 rounded-lg cursor-pointer shrink-0'
}
>
<Trash2 className="w-3.5 h-3.5" />
{!isMobile && !isPs && <span></span>}
</button>
</div> </div>
<div className="flex items-center gap-2 shrink-0">
{importData.sheets.length > 1 && ( {hasMultipleSheets && (
<div className="flex items-center gap-2 w-full min-w-0">
<label
className={`shrink-0 ${
isMobile ? 'text-xs text-slate-500' : isPs ? 'text-[10px] text-[#888]' : 'text-[10px] text-gray-500'
}`}
>
</label>
<select <select
value={importData.currentSheet} value={importData.currentSheet}
onChange={(e) => handleSheetChange(e.target.value)} disabled={sheetLoading}
className={ onChange={(e) => void handleSheetChange(e.target.value)}
isMobile className={sheetSelectClass}
? 'mobile-field text-sm py-2'
: isPs
? 'ps-field text-[10px] py-1'
: 'px-2.5 py-1 text-xs bg-white border border-gray-100 rounded-lg'
}
> >
{importData.sheets.map((sheet) => ( {importData.sheets.map((sheet) => (
<option key={sheet} value={sheet}> <option key={sheet} value={sheet}>
@@ -290,21 +339,13 @@ export const DataImporter: React.FC<DataImporterProps> = ({
</option> </option>
))} ))}
</select> </select>
)} {sheetLoading && (
<button <span className={`${isPs ? 'text-[10px] text-[#777]' : 'text-[10px] text-gray-400'}`}>
type="button"
onClick={onClear} </span>
className={ )}
isMobile </div>
? 'mobile-text-btn danger' )}
: isPs
? 'ps-btn text-[10px] text-rose-300 hover:text-rose-200'
: 'flex items-center gap-1 text-xs font-semibold text-rose-600 hover:bg-rose-50 px-2.5 py-1.5 rounded-lg cursor-pointer'
}
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</div> </div>
)} )}
</div> </div>

View File

@@ -33,15 +33,6 @@ export const DataRangeFields: React.FC<DataRangeFieldsProps> = ({
return ( return (
<div className={isMobile ? 'space-y-4' : 'space-y-2'}> <div className={isMobile ? 'space-y-4' : 'space-y-2'}>
{!isMobile && <div className="ps-section-title"></div>} {!isMobile && <div className="ps-section-title"></div>}
{isMobile && (
<div className="flex items-center gap-2">
<Filter className="w-4 h-4 text-indigo-500 shrink-0" />
<span className="text-sm font-semibold text-slate-800"></span>
</div>
)}
<p className={isMobile ? 'text-xs text-slate-500 leading-relaxed' : 'text-[10px] text-[#888] leading-relaxed'}>
1
</p>
<div> <div>
{isMobile && <label className="mobile-field-label"></label>} {isMobile && <label className="mobile-field-label"></label>}
{!isMobile && <label className="ps-field-label"></label>} {!isMobile && <label className="ps-field-label"></label>}
@@ -51,9 +42,9 @@ export const DataRangeFields: React.FC<DataRangeFieldsProps> = ({
onChange={(e) => patchRange({ mode: e.target.value as ExportRangeConfig['mode'] })} onChange={(e) => patchRange({ mode: e.target.value as ExportRangeConfig['mode'] })}
className={isMobile ? 'mobile-field' : 'ps-field text-[11px]'} className={isMobile ? 'mobile-field' : 'ps-field text-[11px]'}
> >
<option value="all"></option> <option value="all"></option>
<option value="odd"></option> <option value="odd"></option>
<option value="even"></option> <option value="even"></option>
<option value="custom"></option> <option value="custom"></option>
</select> </select>
</div> </div>

View File

@@ -32,46 +32,35 @@ export const MobileExportPropsPanel: React.FC<MobileExportPropsPanelProps> = ({
}) => { }) => {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
if (locked) return null;
const paperLabel = const paperLabel =
paper.type === 'custom' ? `${paper.width}×${paper.height} mm` : paper.type; paper.type === 'custom' ? `${paper.width}×${paper.height} mm` : paper.type;
const summary = `${paperLabel} · ${paper.orientation === 'portrait' ? '纵向' : '横向'}${ const summary = `${paperLabel} · ${paper.orientation === 'portrait' ? '纵向' : '横向'}${
cutLine.enabled ? ' · 裁切线' : '' cutLine.enabled ? ' · 裁切线' : ''
}`; }`;
const toggleOpen = () => {
if (locked) return;
setOpen((value) => !value);
};
return ( return (
<section <section className={`mobile-export-card mobile-export-props-card ${open ? 'is-open' : ''}`}>
className={`mobile-export-card mobile-export-props-card ${open ? 'is-open' : ''} ${
locked ? 'mobile-export-card-locked' : ''
}`}
aria-disabled={locked}
>
<button <button
type="button" type="button"
className="mobile-export-card-head mobile-export-props-head" className="mobile-export-card-head mobile-export-props-head"
onClick={toggleOpen} onClick={() => setOpen((value) => !value)}
aria-expanded={open && !locked} aria-expanded={open}
disabled={locked}
> >
<Settings className="w-4 h-4 text-indigo-500 shrink-0 mt-0.5" /> <Settings className="w-4 h-4 text-indigo-500 shrink-0 mt-0.5" />
<div className="flex-1 min-w-0 text-left"> <div className="flex-1 min-w-0 text-left">
<h2></h2> <h2></h2>
<p>{locked ? '上传数据后可配置纸张与裁切线' : summary}</p> <p>{summary}</p>
</div> </div>
{!locked && ( <ChevronDown
<ChevronDown className={`w-5 h-5 text-slate-400 shrink-0 mt-0.5 transition-transform duration-200 ${
className={`w-5 h-5 text-slate-400 shrink-0 mt-0.5 transition-transform duration-200 ${ open ? 'rotate-180' : ''
open ? 'rotate-180' : '' }`}
}`} />
/>
)}
</button> </button>
{open && !locked && ( {open && (
<div className="mobile-export-card-body"> <div className="mobile-export-card-body">
<PaperSettingsPanel <PaperSettingsPanel
theme="mobile" theme="mobile"

View File

@@ -3,8 +3,6 @@ import {
ChevronLeft, ChevronLeft,
FileDown, FileDown,
Loader2, Loader2,
CheckCircle2,
Circle,
Database, Database,
Link2, Link2,
Download, Download,
@@ -40,40 +38,13 @@ interface MobileExportViewProps {
onChangeDraftExportRange: (range: ExportRangeConfig) => void; onChangeDraftExportRange: (range: ExportRangeConfig) => void;
cutLine: CutLineConfig; cutLine: CutLineConfig;
onChangeCutLine: (cutLine: CutLineConfig) => void; onChangeCutLine: (cutLine: CutLineConfig) => void;
dataStale: boolean; selectedCount: number;
dataApplied: boolean; totalPages: number;
onApplyData: () => void;
draftSelectedCount: number;
appliedSelectedCount: number;
appliedTotalPages: number;
pdfGenerating: boolean; pdfGenerating: boolean;
onBack: () => void; onBack: () => void;
onExportPdf: () => void; onExportPdf: () => void;
} }
function StepBadge({
done,
active,
number,
}: {
done: boolean;
active: boolean;
number: number;
}) {
if (done) {
return (
<span className="mobile-step-badge done">
<CheckCircle2 className="w-3.5 h-3.5" />
</span>
);
}
return (
<span className={`mobile-step-badge ${active ? 'active' : ''}`}>
{active ? number : <Circle className="w-3 h-3 opacity-50" />}
</span>
);
}
export const MobileExportView: React.FC<MobileExportViewProps> = ({ export const MobileExportView: React.FC<MobileExportViewProps> = ({
template, template,
templateName, templateName,
@@ -91,12 +62,8 @@ export const MobileExportView: React.FC<MobileExportViewProps> = ({
onChangeDraftExportRange, onChangeDraftExportRange,
cutLine, cutLine,
onChangeCutLine, onChangeCutLine,
dataStale, selectedCount,
dataApplied, totalPages,
onApplyData,
draftSelectedCount,
appliedSelectedCount,
appliedTotalPages,
pdfGenerating, pdfGenerating,
onBack, onBack,
onExportPdf, onExportPdf,
@@ -108,17 +75,13 @@ export const MobileExportView: React.FC<MobileExportViewProps> = ({
const needsMapping = templateVariables.length > 0; const needsMapping = templateVariables.length > 0;
const mappingComplete = !needsMapping || (hasData && mappedCount === templateVariables.length); const mappingComplete = !needsMapping || (hasData && mappedCount === templateVariables.length);
const currentStep = !hasData ? 1 : !mappingComplete ? 2 : 3;
const exportBlockedReason = !hasData const exportBlockedReason = !hasData
? '请先上传 Excel 数据' ? '请先上传 Excel 数据'
: !mappingComplete : !mappingComplete
? `还有 ${templateVariables.length - mappedCount} 个变量未关联` ? `还有 ${templateVariables.length - mappedCount} 个变量未关联`
: dataStale || !dataApplied : selectedCount === 0
? '请先应用数据' ? '当前数据范围内无标签'
: appliedSelectedCount === 0 : null;
? '当前数据范围内无标签'
: null;
return ( return (
<div className="mobile-export-view"> <div className="mobile-export-view">
@@ -130,34 +93,16 @@ export const MobileExportView: React.FC<MobileExportViewProps> = ({
<p className="mobile-export-title truncate">{templateName}</p> <p className="mobile-export-title truncate">{templateName}</p>
<p className="mobile-export-subtitle"> <p className="mobile-export-subtitle">
{template.width}×{template.height} mm {template.width}×{template.height} mm
{hasData && dataApplied && !dataStale && ( {hasData && selectedCount > 0 && (
<span> <span>
{' '} {' '}
· {appliedSelectedCount} · {appliedTotalPages} · {selectedCount} · {totalPages}
</span> </span>
)} )}
</p> </p>
</div> </div>
</header> </header>
<div className="mobile-export-steps" aria-label="导出步骤">
<div className={`mobile-export-step ${currentStep >= 1 ? 'active' : ''} ${hasData ? 'done' : ''}`}>
<StepBadge done={hasData} active={currentStep === 1} number={1} />
<span></span>
</div>
<div className="mobile-export-step-line" />
<div
className={`mobile-export-step ${currentStep >= 2 ? 'active' : ''} ${mappingComplete && hasData ? 'done' : ''}`}
>
<StepBadge done={mappingComplete && hasData} active={currentStep === 2} number={2} />
<span></span>
</div>
<div className={`mobile-export-step ${currentStep >= 3 ? 'active' : ''}`}>
<StepBadge done={dataApplied && !dataStale && mappingComplete} active={currentStep === 3} number={3} />
<span></span>
</div>
</div>
<main className="mobile-export-content"> <main className="mobile-export-content">
<section className="mobile-export-card"> <section className="mobile-export-card">
<div className="mobile-export-card-head"> <div className="mobile-export-card-head">
@@ -165,7 +110,7 @@ export const MobileExportView: React.FC<MobileExportViewProps> = ({
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2"> <div className="flex items-start justify-between gap-2">
<div className="min-w-0"> <div className="min-w-0">
<h2> 1 · </h2> <h2></h2>
<p>{hasData ? '已上传,可设置数据范围' : '上传 Excel 或 CSV 文件'}</p> <p>{hasData ? '已上传,可设置数据范围' : '上传 Excel 或 CSV 文件'}</p>
</div> </div>
<button <button
@@ -189,36 +134,14 @@ export const MobileExportView: React.FC<MobileExportViewProps> = ({
templateVariables={templateVariables} templateVariables={templateVariables}
/> />
{hasData && ( {hasData && (
<> <DataRangeFields
<DataRangeFields variant="mobile"
variant="mobile" exportRange={draftExportRange}
exportRange={draftExportRange} onChangeExportRange={onChangeDraftExportRange}
onChangeExportRange={onChangeDraftExportRange} rows={rows}
rows={rows} paper={paper}
paper={paper} template={template}
template={template} />
/>
<div className="space-y-2 pt-1 border-t border-slate-100">
{dataStale && (
<p className="text-xs text-amber-600"></p>
)}
<button
type="button"
onClick={onApplyData}
disabled={draftSelectedCount === 0}
className={`w-full inline-flex items-center justify-center gap-2 min-h-[44px] px-4 rounded-xl text-sm font-semibold transition cursor-pointer ${
draftSelectedCount > 0
? dataStale || !dataApplied
? 'bg-indigo-600 text-white active:bg-indigo-700'
: 'bg-slate-100 text-slate-600 border border-slate-200'
: 'bg-slate-100 text-slate-400 cursor-not-allowed'
}`}
>
<Database className="w-4 h-4 shrink-0" />
</button>
</div>
</>
)} )}
</div> </div>
</section> </section>
@@ -228,7 +151,7 @@ export const MobileExportView: React.FC<MobileExportViewProps> = ({
<div className="mobile-export-card-head"> <div className="mobile-export-card-head">
<Link2 className="w-4 h-4 text-indigo-500 shrink-0" /> <Link2 className="w-4 h-4 text-indigo-500 shrink-0" />
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<h2> 2 · </h2> <h2></h2>
<p> <p>
{needsMapping {needsMapping
? `已关联 ${mappedCount}/${templateVariables.length}` ? `已关联 ${mappedCount}/${templateVariables.length}`
@@ -281,7 +204,7 @@ export const MobileExportView: React.FC<MobileExportViewProps> = ({
<FileDown className="w-5 h-5 shrink-0" /> <FileDown className="w-5 h-5 shrink-0" />
{exportBlockedReason {exportBlockedReason
? '生成 PDF' ? '生成 PDF'
: `生成 PDF${appliedSelectedCount} 枚 · ${appliedTotalPages} 页)`} : `生成 PDF${selectedCount} 枚 · ${totalPages} 页)`}
</> </>
)} )}
</button> </button>

View File

@@ -91,7 +91,7 @@ export const PaperSettingsPanel: React.FC<PaperSettingsPanelProps> = ({
const isPs = theme === 'ps'; const isPs = theme === 'ps';
const isMobile = theme === 'mobile'; const isMobile = theme === 'mobile';
const fieldClass = isMobile const fieldClass = isMobile
? 'mobile-field font-mono text-center' ? 'mobile-field font-mono'
: isPs : isPs
? 'ps-field font-mono text-center' ? 'ps-field font-mono text-center'
: 'w-full px-2.5 py-1.5 bg-gray-50 border border-gray-100 rounded-lg text-xs text-center font-mono'; : 'w-full px-2.5 py-1.5 bg-gray-50 border border-gray-100 rounded-lg text-xs text-center font-mono';
@@ -326,20 +326,19 @@ export const PaperSettingsPanel: React.FC<PaperSettingsPanelProps> = ({
/> />
</div> </div>
))} ))}
<div> <div className="col-span-2 flex justify-end -mt-0.5">
<label className={labelClass}></label>
<button <button
type="button" type="button"
onClick={autoCenterMargins} onClick={autoCenterMargins}
className={ className={
isPs isPs
? 'ps-btn text-[10px]' ? 'text-[10px] text-[#777] hover:text-[#31a8ff] transition cursor-pointer'
: isMobile : isMobile
? 'mobile-secondary-btn w-full' ? 'text-xs text-slate-500 hover:text-indigo-600 font-normal py-1 cursor-pointer'
: 'w-full inline-flex items-center justify-center gap-1 bg-white hover:bg-gray-100 border border-gray-200 text-gray-600 py-1.5 px-3 rounded-lg text-[10px] font-bold cursor-pointer' : 'text-[10px] text-gray-500 hover:text-indigo-600 font-normal cursor-pointer'
} }
> >
</button> </button>
</div> </div>
</div> </div>

View File

@@ -272,6 +272,7 @@ body,
.ps-panel-body { .ps-panel-body {
color: var(--color-ps-text); color: var(--color-ps-text);
overscroll-behavior: none; overscroll-behavior: none;
min-width: 0;
} }
.ps-panel-body input[type="number"], .ps-panel-body input[type="number"],
@@ -845,6 +846,7 @@ body,
.mobile-export-card-body { .mobile-export-card-body {
padding: 18px 18px 20px; padding: 18px 18px 20px;
min-width: 0;
} }
.mobile-export-card-locked { .mobile-export-card-locked {
@@ -927,10 +929,11 @@ body,
.mobile-file-card { .mobile-file-card {
display: flex; display: flex;
flex-wrap: wrap; flex-direction: column;
align-items: center; align-items: stretch;
justify-content: space-between; gap: 12px;
gap: 14px; min-width: 0;
width: 100%;
padding: 0; padding: 0;
background: transparent; background: transparent;
border: none; border: none;