优化
This commit is contained in:
48
src/App.tsx
48
src/App.tsx
@@ -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}
|
||||
|
||||
@@ -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,122 +98,92 @@ 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) => {
|
||||
const processFile = async (file: File) => {
|
||||
try {
|
||||
const data = new Uint8Array(e.target?.result as ArrayBuffer);
|
||||
const workbook = XLSX.read(data, { type: 'array' });
|
||||
const buffer = await file.arrayBuffer();
|
||||
workbookBufferRef.current = buffer;
|
||||
const workbook = readWorkbook(buffer);
|
||||
const firstSheetName = workbook.SheetNames[0];
|
||||
const worksheet = workbook.Sheets[firstSheetName];
|
||||
const jsonData = XLSX.utils.sheet_to_json<any[]>(worksheet, { header: 1, defval: '' });
|
||||
const parsed = parseWorksheet(workbook, firstSheetName, file.name);
|
||||
|
||||
if (jsonData.length === 0) {
|
||||
if (!parsed || parsed.rows.length === 0) {
|
||||
workbookBufferRef.current = null;
|
||||
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,
|
||||
});
|
||||
onDataLoaded(parsed);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
workbookBufferRef.current = null;
|
||||
await showAlert('解析文件出错,请确保是标准的 Excel 或 CSV 文件', { variant: 'error' });
|
||||
}
|
||||
};
|
||||
reader.readAsArrayBuffer(file);
|
||||
};
|
||||
|
||||
const handleSheetChange = (sheetName: string) => {
|
||||
if (!fileInputRef.current?.files?.[0]) return;
|
||||
const file = fileInputRef.current.files[0];
|
||||
const reader = new FileReader();
|
||||
reader.onload = async (e) => {
|
||||
const handleSheetChange = async (sheetName: string) => {
|
||||
if (!importData || sheetName === importData.currentSheet) return;
|
||||
|
||||
const buffer = workbookBufferRef.current;
|
||||
if (!buffer) {
|
||||
await showAlert('无法切换工作表,请重新上传文件', { variant: 'warning' });
|
||||
return;
|
||||
}
|
||||
|
||||
setSheetLoading(true);
|
||||
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 workbook = readWorkbook(buffer);
|
||||
const parsed = parseWorksheet(workbook, sheetName, importData.fileName);
|
||||
|
||||
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);
|
||||
if (!parsed) {
|
||||
await showAlert('该工作表为空或无法解析', { variant: 'warning' });
|
||||
return;
|
||||
}
|
||||
|
||||
onDataLoaded({
|
||||
fileName: file.name,
|
||||
sheets: workbook.SheetNames,
|
||||
currentSheet: sheetName,
|
||||
columns,
|
||||
rows,
|
||||
});
|
||||
} catch {
|
||||
onDataLoaded(parsed);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
await showAlert('切换工作表时出错', { variant: 'error' });
|
||||
} finally {
|
||||
setSheetLoading(false);
|
||||
}
|
||||
};
|
||||
reader.readAsArrayBuffer(file);
|
||||
|
||||
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
|
||||
@@ -234,9 +251,10 @@ export const DataImporter: React.FC<DataImporterProps> = ({
|
||||
{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">
|
||||
<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 ${isPs ? 'text-emerald-400' : 'text-emerald-600'}`}
|
||||
className={`w-5 h-5 shrink-0 mt-0.5 ${isPs ? 'text-emerald-400' : 'text-emerald-600'}`}
|
||||
/>
|
||||
<div className="overflow-hidden min-w-0">
|
||||
<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 ${
|
||||
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>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{importData.sheets.length > 1 && (
|
||||
<select
|
||||
value={importData.currentSheet}
|
||||
onChange={(e) => handleSheetChange(e.target.value)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClear}
|
||||
className={
|
||||
isMobile
|
||||
? 'mobile-field text-sm py-2'
|
||||
? 'mobile-text-btn danger shrink-0'
|
||||
: isPs
|
||||
? 'ps-field text-[10px] py-1'
|
||||
: 'px-2.5 py-1 text-xs bg-white border border-gray-100 rounded-lg'
|
||||
? '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>
|
||||
|
||||
{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}
|
||||
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>
|
||||
{sheetLoading && (
|
||||
<span className={`${isPs ? 'text-[10px] text-[#777]' : 'text-[10px] text-gray-400'}`}>
|
||||
切换中…
|
||||
</span>
|
||||
)}
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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' : ''
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{open && !locked && (
|
||||
{open && (
|
||||
<div className="mobile-export-card-body">
|
||||
<PaperSettingsPanel
|
||||
theme="mobile"
|
||||
|
||||
@@ -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,15 +75,11 @@ 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
|
||||
: selectedCount === 0
|
||||
? '当前数据范围内无标签'
|
||||
: null;
|
||||
|
||||
@@ -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,7 +134,6 @@ export const MobileExportView: React.FC<MobileExportViewProps> = ({
|
||||
templateVariables={templateVariables}
|
||||
/>
|
||||
{hasData && (
|
||||
<>
|
||||
<DataRangeFields
|
||||
variant="mobile"
|
||||
exportRange={draftExportRange}
|
||||
@@ -198,27 +142,6 @@ export const MobileExportView: React.FC<MobileExportViewProps> = ({
|
||||
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>
|
||||
</>
|
||||
)}
|
||||
</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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user