Files
label-designer/src/components/DataImporter.tsx
2026-06-12 19:34:27 +08:00

313 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useRef, useState } from 'react';
import * as XLSX from 'xlsx';
import { ImportData, RecordRow } from '../types';
import { FileSpreadsheet, Upload, Download, Trash2 } from 'lucide-react';
import { useAppDialog } from './AppDialog';
export function downloadDataTemplate(templateName: string, templateVariables: string[]) {
const columns = templateVariables.length > 0 ? templateVariables : ['数据'];
const ws = XLSX.utils.aoa_to_sheet([columns, columns.map(() => '')]);
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, '数据');
const safeName = templateName.replace(/[^\w\u4e00-\u9fa5-]+/g, '_').slice(0, 30) || '标签';
XLSX.writeFile(wb, `${safeName}_数据模板.xlsx`);
}
interface DataImporterProps {
importData: ImportData | null;
onDataLoaded: (data: ImportData) => void;
onClear: () => void;
templateName: string;
templateVariables: string[];
variant?: 'default' | 'ps' | 'mobile';
}
export const DataImporter: React.FC<DataImporterProps> = ({
importData,
onDataLoaded,
onClear,
templateName,
templateVariables,
variant = 'default',
}) => {
const { showAlert } = useAppDialog();
const isPs = variant === 'ps';
const isMobile = variant === 'mobile';
const fileInputRef = useRef<HTMLInputElement | null>(null);
const [dragActive, setDragActive] = useState<boolean>(false);
const handleDrag = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (e.type === 'dragenter' || e.type === 'dragover') {
setDragActive(true);
} else if (e.type === 'dragleave') {
setDragActive(false);
}
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setDragActive(false);
if (e.dataTransfer.files && e.dataTransfer.files[0]) {
processFile(e.dataTransfer.files[0]);
}
};
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files[0]) {
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: '' });
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' });
}
};
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) => {
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 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: sheetName,
columns,
rows,
});
} catch {
await showAlert('切换工作表时出错', { variant: 'error' });
}
};
reader.readAsArrayBuffer(file);
};
const rows = importData?.rows || [];
const columns = importData?.columns || [];
const wrapperClass = isMobile
? ''
: isPs
? 'space-y-3'
: 'bg-white border border-gray-200 rounded-2xl p-6 shadow-sm flex flex-col gap-5';
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>
)}
{!importData ? (
<div
onDragEnter={handleDrag}
onDragOver={handleDrag}
onDragLeave={handleDrag}
onDrop={handleDrop}
onClick={() => fileInputRef.current?.click()}
className={
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'
}`
}
>
<input
ref={fileInputRef}
type="file"
accept=".xlsx,.xls,.csv"
onChange={handleFileChange}
className="hidden"
/>
<Upload
className={`${isMobile ? 'w-8 h-8' : 'w-6 h-6'} ${
isPs ? 'text-[#777]' : isMobile ? 'text-indigo-400' : 'text-gray-400'
}`}
/>
<div>
<p
className={`${
isMobile
? 'text-[15px] font-semibold text-slate-700'
: isPs
? '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>
)}
</div>
</div>
) : (
<div
className={
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'
}
>
<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>
</div>
<div className="flex items-center gap-2 shrink-0">
{importData.sheets.length > 1 && (
<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'
}
>
{importData.sheets.map((sheet) => (
<option key={sheet} value={sheet}>
{sheet}
</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>
</div>
)}
</div>
);
};