313 lines
11 KiB
TypeScript
313 lines
11 KiB
TypeScript
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>
|
||
);
|
||
};
|