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 {
stripTemplateForStorage,
@@ -442,6 +442,40 @@ export default function App() {
return !exportRangeEquals(draftExportRange, appliedExportData.exportRange);
}, [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 () => {
if (!importData || activeRows.length === 0) {
await showAlert('请先上传 Excel 或 CSV 数据。', { variant: 'warning' });
@@ -884,7 +918,7 @@ export default function App() {
</span>
<div className="flex-1" />
<span className="hidden md:inline text-[10px] text-[#666]">
Shift/Ctrl+ · V · T ·
Shift/Ctrl+
</span>
<button
onClick={() => openPrintView()}
@@ -937,14 +971,8 @@ export default function App() {
onChangeDraftExportRange={setDraftExportRange}
cutLine={cutLine}
onChangeCutLine={handleCutLineChange}
dataStale={dataStale}
dataApplied={appliedExportData !== null}
onApplyData={handleApplyData}
appliedSelectedCount={appliedLayoutResult.selectedCount}
appliedTotalPages={appliedLayoutResult.totalPages}
appliedGridCols={appliedLayoutResult.gridCols}
appliedGridRows={appliedLayoutResult.gridRows}
draftSelectedCount={selectedCount}
selectedCount={selectedCount}
totalPages={totalPages}
pdfGenerating={pdfGenerating}
onBack={() => setWorkflowStep('template-select')}
onExportPdf={exportPdf}

View File

@@ -13,6 +13,50 @@ export function downloadDataTemplate(templateName: string, templateVariables: st
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 {
importData: ImportData | null;
onDataLoaded: (data: ImportData) => void;
@@ -34,7 +78,10 @@ export const DataImporter: React.FC<DataImporterProps> = ({
const isPs = variant === 'ps';
const isMobile = variant === 'mobile';
const fileInputRef = useRef<HTMLInputElement | null>(null);
/** 缓存已上传文件,切换工作表时无需依赖 input.files拖拽上传时 input 为空) */
const workbookBufferRef = useRef<ArrayBuffer | null>(null);
const [dragActive, setDragActive] = useState<boolean>(false);
const [sheetLoading, setSheetLoading] = useState(false);
const handleDrag = (e: React.DragEvent) => {
e.preventDefault();
@@ -51,139 +98,109 @@ export const DataImporter: React.FC<DataImporterProps> = ({
e.stopPropagation();
setDragActive(false);
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>) => {
if (e.target.files && e.target.files[0]) {
processFile(e.target.files[0]);
void processFile(e.target.files[0]);
}
};
const processFile = (file: File) => {
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 firstSheetName = workbook.SheetNames[0];
const worksheet = workbook.Sheets[firstSheetName];
const jsonData = XLSX.utils.sheet_to_json<any[]>(worksheet, { header: 1, defval: '' });
const processFile = async (file: File) => {
try {
const buffer = await file.arrayBuffer();
workbookBufferRef.current = buffer;
const workbook = readWorkbook(buffer);
const firstSheetName = workbook.SheetNames[0];
const parsed = parseWorksheet(workbook, firstSheetName, file.name);
if (jsonData.length === 0) {
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' });
if (!parsed || parsed.rows.length === 0) {
workbookBufferRef.current = null;
await showAlert('表格为空或格式不正确', { variant: 'warning' });
return;
}
};
reader.readAsArrayBuffer(file);
onDataLoaded(parsed);
} catch (err) {
console.error(err);
workbookBufferRef.current = null;
await showAlert('解析文件出错,请确保是标准的 Excel 或 CSV 文件', { variant: 'error' });
}
};
const handleSheetChange = (sheetName: string) => {
if (!fileInputRef.current?.files?.[0]) 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 handleSheetChange = async (sheetName: string) => {
if (!importData || sheetName === importData.currentSheet) 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);
}
const buffer = workbookBufferRef.current;
if (!buffer) {
await showAlert('无法切换工作表,请重新上传文件', { variant: 'warning' });
return;
}
onDataLoaded({
fileName: file.name,
sheets: workbook.SheetNames,
currentSheet: sheetName,
columns,
rows,
});
} catch {
await showAlert('切换工作表时出错', { variant: 'error' });
setSheetLoading(true);
try {
const workbook = readWorkbook(buffer);
const parsed = parseWorksheet(workbook, sheetName, importData.fileName);
if (!parsed) {
await showAlert('该工作表为空或无法解析', { variant: 'warning' });
return;
}
};
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 columns = importData?.columns || [];
const hasMultipleSheets = (importData?.sheets.length ?? 0) > 1;
const wrapperClass = isMobile
? ''
? 'min-w-0 w-full'
: isPs
? 'space-y-3'
: 'bg-white border border-gray-200 rounded-2xl p-6 shadow-sm flex flex-col gap-5';
? '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 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 (
<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 && (
<div className={`flex flex-wrap gap-2 ${isPs ? '' : 'justify-end'}`}>
<button
type="button"
onClick={() => downloadDataTemplate(templateName, templateVariables)}
className={
isMobile
? 'mobile-secondary-btn w-full'
: isPs
? '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'
}
>
<Download className="w-3.5 h-3.5" />
</button>
</div>
<div className={`flex flex-wrap gap-2 ${isPs ? '' : 'justify-end'}`}>
<button
type="button"
onClick={() => downloadDataTemplate(templateName, templateVariables)}
className={
isMobile
? 'mobile-secondary-btn w-full'
: isPs
? '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'
}
>
<Download className="w-3.5 h-3.5" />
</button>
</div>
)}
{!importData ? (
@@ -197,16 +214,16 @@ export const DataImporter: React.FC<DataImporterProps> = ({
isMobile
? `mobile-upload-zone ${dragActive ? 'active' : ''}`
: isPs
? `border border-dashed rounded py-8 px-4 text-center cursor-pointer transition flex flex-col items-center gap-2 ${
dragActive
? 'border-[#31a8ff] bg-[#1a3a4f]/30'
: '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 ${
dragActive
? 'border-indigo-500 bg-indigo-50/20'
: 'border-gray-200 hover:border-indigo-400 hover:bg-gray-50/50'
}`
? `border border-dashed rounded py-8 px-4 text-center cursor-pointer transition flex flex-col items-center gap-2 ${
dragActive
? 'border-[#31a8ff] bg-[#1a3a4f]/30'
: '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 ${
dragActive
? 'border-indigo-500 bg-indigo-50/20'
: 'border-gray-200 hover:border-indigo-400 hover:bg-gray-50/50'
}`
}
>
<input
@@ -227,16 +244,17 @@ export const DataImporter: React.FC<DataImporterProps> = ({
isMobile
? 'text-[15px] font-semibold text-slate-700'
: isPs
? 'text-xs text-[#ccc]'
: 'text-xs text-gray-700'
? 'text-xs text-[#ccc]'
: 'text-xs text-gray-700'
}`}
>
{isMobile ? '点击选择 Excel / CSV' : '点击上传或拖拽文件到此处'}
</p>
{isMobile && (
<p className="text-xs text-slate-400 leading-relaxed">
.xlsx.xls.csv
</p>
<p className="text-xs text-slate-400 leading-relaxed"> .xlsx.xls.csv</p>
)}
{!isMobile && isPs && (
<p className="text-[10px] text-[#666] mt-1"> Sheet</p>
)}
</div>
</div>
@@ -246,43 +264,74 @@ export const DataImporter: React.FC<DataImporterProps> = ({
isMobile
? 'mobile-file-card'
: isPs
? 'flex flex-wrap items-center justify-between p-2.5 bg-[#1e1e1e] border border-[#3a3a3a] rounded gap-3'
: 'flex flex-wrap items-center justify-between p-3.5 bg-gray-50 rounded-xl gap-4 border border-gray-100'
? 'p-2.5 bg-[#1e1e1e] border border-[#3a3a3a] rounded space-y-2.5 min-w-0'
: '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">
<FileSpreadsheet
className={`w-5 h-5 shrink-0 ${isPs ? 'text-emerald-400' : 'text-emerald-600'}`}
/>
<div className="overflow-hidden min-w-0">
<p
className={`truncate ${
isMobile ? 'text-sm text-slate-800' : isPs ? 'text-xs text-[#e8e8e8]' : 'text-gray-800'
}`}
>
{importData.fileName}
</p>
<p
className={`mt-0.5 ${
isMobile ? 'text-xs text-slate-500' : isPs ? 'text-[10px] text-[#777]' : 'text-[10px] text-gray-400'
}`}
>
{rows.length} · {columns.length}
</p>
<div className="flex items-start gap-2 w-full min-w-0">
<div className="flex items-start gap-2 min-w-0 flex-1">
<FileSpreadsheet
className={`w-5 h-5 shrink-0 mt-0.5 ${isPs ? 'text-emerald-400' : 'text-emerald-600'}`}
/>
<div className="min-w-0 flex-1">
<p
className={`truncate ${
isMobile ? 'text-sm text-slate-800' : isPs ? 'text-xs text-[#e8e8e8]' : 'text-gray-800'
}`}
title={importData.fileName}
>
{importData.fileName}
</p>
<p
className={`mt-0.5 truncate ${
isMobile ? 'text-xs text-slate-500' : isPs ? 'text-[10px] text-[#777]' : 'text-[10px] text-gray-400'
}`}
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>
<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 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
value={importData.currentSheet}
onChange={(e) => handleSheetChange(e.target.value)}
className={
isMobile
? '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'
}
disabled={sheetLoading}
onChange={(e) => void handleSheetChange(e.target.value)}
className={sheetSelectClass}
>
{importData.sheets.map((sheet) => (
<option key={sheet} value={sheet}>
@@ -290,21 +339,13 @@ export const DataImporter: React.FC<DataImporterProps> = ({
</option>
))}
</select>
)}
<button
type="button"
onClick={onClear}
className={
isMobile
? '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>
{sheetLoading && (
<span className={`${isPs ? 'text-[10px] text-[#777]' : 'text-[10px] text-gray-400'}`}>
</span>
)}
</div>
)}
</div>
)}
</div>

View File

@@ -33,15 +33,6 @@ export const DataRangeFields: React.FC<DataRangeFieldsProps> = ({
return (
<div className={isMobile ? 'space-y-4' : 'space-y-2'}>
{!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>
{isMobile && <label className="mobile-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'] })}
className={isMobile ? 'mobile-field' : 'ps-field text-[11px]'}
>
<option value="all"></option>
<option value="odd"></option>
<option value="even"></option>
<option value="all"></option>
<option value="odd"></option>
<option value="even"></option>
<option value="custom"></option>
</select>
</div>

View File

@@ -32,46 +32,35 @@ export const MobileExportPropsPanel: React.FC<MobileExportPropsPanelProps> = ({
}) => {
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 ? ' · 裁切线' : ''
}`;
const toggleOpen = () => {
if (locked) return;
setOpen((value) => !value);
};
return (
<section
className={`mobile-export-card mobile-export-props-card ${open ? 'is-open' : ''} ${
locked ? 'mobile-export-card-locked' : ''
}`}
aria-disabled={locked}
>
<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={toggleOpen}
aria-expanded={open && !locked}
disabled={locked}
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>{locked ? '上传数据后可配置纸张与裁切线' : summary}</p>
<p>{summary}</p>
</div>
{!locked && (
<ChevronDown
className={`w-5 h-5 text-slate-400 shrink-0 mt-0.5 transition-transform duration-200 ${
open ? 'rotate-180' : ''
}`}
/>
)}
<ChevronDown
className={`w-5 h-5 text-slate-400 shrink-0 mt-0.5 transition-transform duration-200 ${
open ? 'rotate-180' : ''
}`}
/>
</button>
{open && !locked && (
{open && (
<div className="mobile-export-card-body">
<PaperSettingsPanel
theme="mobile"

View File

@@ -3,8 +3,6 @@ import {
ChevronLeft,
FileDown,
Loader2,
CheckCircle2,
Circle,
Database,
Link2,
Download,
@@ -40,40 +38,13 @@ interface MobileExportViewProps {
onChangeDraftExportRange: (range: ExportRangeConfig) => void;
cutLine: CutLineConfig;
onChangeCutLine: (cutLine: CutLineConfig) => void;
dataStale: boolean;
dataApplied: boolean;
onApplyData: () => void;
draftSelectedCount: number;
appliedSelectedCount: number;
appliedTotalPages: number;
selectedCount: number;
totalPages: number;
pdfGenerating: boolean;
onBack: () => 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> = ({
template,
templateName,
@@ -91,12 +62,8 @@ export const MobileExportView: React.FC<MobileExportViewProps> = ({
onChangeDraftExportRange,
cutLine,
onChangeCutLine,
dataStale,
dataApplied,
onApplyData,
draftSelectedCount,
appliedSelectedCount,
appliedTotalPages,
selectedCount,
totalPages,
pdfGenerating,
onBack,
onExportPdf,
@@ -108,17 +75,13 @@ export const MobileExportView: React.FC<MobileExportViewProps> = ({
const needsMapping = templateVariables.length > 0;
const mappingComplete = !needsMapping || (hasData && mappedCount === templateVariables.length);
const currentStep = !hasData ? 1 : !mappingComplete ? 2 : 3;
const exportBlockedReason = !hasData
? '请先上传 Excel 数据'
: !mappingComplete
? `还有 ${templateVariables.length - mappedCount} 个变量未关联`
: dataStale || !dataApplied
? '请先应用数据'
: appliedSelectedCount === 0
? '当前数据范围内无标签'
: null;
? `还有 ${templateVariables.length - mappedCount} 个变量未关联`
: selectedCount === 0
? '当前数据范围内无标签'
: null;
return (
<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-subtitle">
{template.width}×{template.height} mm
{hasData && dataApplied && !dataStale && (
{hasData && selectedCount > 0 && (
<span>
{' '}
· {appliedSelectedCount} · {appliedTotalPages}
· {selectedCount} · {totalPages}
</span>
)}
</p>
</div>
</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">
<section className="mobile-export-card">
<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 items-start justify-between gap-2">
<div className="min-w-0">
<h2> 1 · </h2>
<h2></h2>
<p>{hasData ? '已上传,可设置数据范围' : '上传 Excel 或 CSV 文件'}</p>
</div>
<button
@@ -189,36 +134,14 @@ export const MobileExportView: React.FC<MobileExportViewProps> = ({
templateVariables={templateVariables}
/>
{hasData && (
<>
<DataRangeFields
variant="mobile"
exportRange={draftExportRange}
onChangeExportRange={onChangeDraftExportRange}
rows={rows}
paper={paper}
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>
</>
<DataRangeFields
variant="mobile"
exportRange={draftExportRange}
onChangeExportRange={onChangeDraftExportRange}
rows={rows}
paper={paper}
template={template}
/>
)}
</div>
</section>
@@ -228,7 +151,7 @@ export const MobileExportView: React.FC<MobileExportViewProps> = ({
<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> 2 · </h2>
<h2></h2>
<p>
{needsMapping
? `已关联 ${mappedCount}/${templateVariables.length}`
@@ -281,7 +204,7 @@ export const MobileExportView: React.FC<MobileExportViewProps> = ({
<FileDown className="w-5 h-5 shrink-0" />
{exportBlockedReason
? '生成 PDF'
: `生成 PDF${appliedSelectedCount} 枚 · ${appliedTotalPages} 页)`}
: `生成 PDF${selectedCount} 枚 · ${totalPages} 页)`}
</>
)}
</button>

View File

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

View File

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