init commit

This commit is contained in:
iqudoo
2026-06-12 19:34:27 +08:00
commit 89b59ecc1b
38 changed files with 17719 additions and 0 deletions

View File

@@ -0,0 +1,312 @@
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>
);
};