Eidos

Installation Notice

The installation may currently fail. We recommend copying the code below and creating the extension manually in Eidos.

Excel Importer

By: Mayne

Install Latest (v0.0.1)

Import XLSX into the specified table

// @ts-nocheck
import React, { useState, useEffect } from 'react';
import ExcelJS from 'exceljs';
import { Input } from "@/components/ui/input";
import { Card, CardHeader, CardTitle, CardContent, CardDescription } from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Button } from "@/components/ui/button";
import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuCheckboxItem, DropdownMenuLabel, DropdownMenuSeparator } from "@/components/ui/dropdown-menu";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Progress } from "@/components/ui/progress";
import { Loader2, FileWarning, FileUp, Columns, Upload, Database } from 'lucide-react';

// Helper function to convert buffer to base64 for display
function bufferToBase64(buffer) {
  let binary = '';
  const bytes = new Uint8Array(buffer);
  for (let i = 0; i < bytes.byteLength; i++) {
    binary += String.fromCharCode(bytes[i]);
  }
  return window.btoa(binary);
}

// Component to render cell content (image, link, or text)
const CellContent = ({ data }) => {
  if (data?.isImage) {
    return <img src={data.src} alt="Embedded" className="max-w-full h-auto object-contain rounded" style={{ maxWidth: '150px', maxHeight: '100px' }} />;
  }
  if (data?.hyperlink) {
    return <a href={data.hyperlink} target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline">{data.text}</a>;
  }
  if (data?.richText) {
    return data.richText.map((rt, i) => <span key={i}>{rt.text}</span>);
  }
  return data?.toString() || '';
};

export default function ExcelImporterBlock() {
  const [sheets, setSheets] = useState([]);
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState(null);
  const [fileName, setFileName] = useState('');

  const [availableTables, setAvailableTables] = useState([]);
  const [isFetchingTables, setIsFetchingTables] = useState(false);
  const [tableId, setTableId] = useState('');
  const [tableFields, setTableFields] = useState([]);
  const [isFetchingFields, setIsFetchingFields] = useState(false);
  const [fieldError, setFieldError] = useState(null);

  const [columnMappings, setColumnMappings] = useState({});
  const [importingSheet, setImportingSheet] = useState(null);
  const [importProgress, setImportProgress] = useState(0);
  const [importResults, setImportResults] = useState({});

  const resetState = () => {
    setSheets([]);
    setError(null);
    setFileName('');
    setTableId('');
    setTableFields([]);
    setAvailableTables([]);
    setFieldError(null);
    setColumnMappings({});
    setImportingSheet(null);
    setImportResults({});
  };

  const fetchAvailableTables = async () => {
    setIsFetchingTables(true);
    try {
      const tables = await eidos.currentSpace.tree.list({ type: 'table', is_deleted: false });
      setAvailableTables(tables);
    } catch (err) {
      console.error("Error fetching tables:", err);
      setFieldError("无法加载可用表格列表。");
    } finally {
      setIsFetchingTables(false);
    }
  };

  useEffect(() => {
    const fetchTableFields = async () => {
      if (!tableId) {
        setTableFields([]);
        setFieldError(null);
        return;
      }
      setIsFetchingFields(true);
      setFieldError(null);
      setTableFields([]);
      try {
        // Assuming the table name in eidos__columns is prefixed with 'tb_'
        const query = `select * from eidos__columns where table_name = 'tb_${tableId}'`;
        const fields = await eidos.currentSpace.sqlQuery2(query);
        setTableFields(fields);
      } catch (err) {
        console.error("Error fetching table fields:", err);
        setFieldError(`加载字段失败。请检查表格 "${tableId}" 是否正确以及您是否有查询权限。`);
      } finally {
        setIsFetchingFields(false);
      }
    };

    fetchTableFields();
  }, [tableId]);

  const handleFileChange = async (event) => {
    const file = event.target.files[0];
    if (!file) return;

    resetState();
    setFileName(file.name);
    setIsLoading(true);

    try {
      const arrayBuffer = await file.arrayBuffer();
      const workbook = new ExcelJS.Workbook();
      await workbook.xlsx.load(arrayBuffer);

      const parsedSheets = workbook.worksheets.map(worksheet => {
        const rows = [];
        worksheet.eachRow({ includeEmpty: true }, (row) => {
          const rowValues = [...row.values];
          rowValues.shift(); // Remove the empty first element
          rows.push(rowValues);
        });

        worksheet.getImages().forEach(image => {
          const img = workbook.getImage(image.imageId);
          if (img) {
            const dataUrl = `data:image/${img.extension};base64,${bufferToBase64(img.buffer)}`;
            const rowIdx = image.range.tl.row;
            const colIdx = image.range.tl.col;
            if (rows[rowIdx]) {
              rows[rowIdx][colIdx] = { isImage: true, src: dataUrl };
            }
          }
        });

        const headerRow = rows[0] || [];
        const columns = headerRow.map((name, index) => ({
          name: name?.toString() || `列 ${index + 1}`,
          index: index
        }));

        return {
          name: worksheet.name,
          rows: rows,
          columns: columns,
          selectedColumns: new Set(columns.map(c => c.name))
        };
      });

      setSheets(parsedSheets);
      fetchAvailableTables(); // Fetch tables after successful parsing
    } catch (err) {
      console.error("Error parsing Excel file:", err);
      setError("解析失败。请确保这是一个有效的 .xlsx 文件。");
    } finally {
      setIsLoading(false);
    }
  };

  const handleColumnToggle = (sheetIndex, columnName) => {
    setSheets(prev => {
      const newSheets = [...prev];
      const sheet = newSheets[sheetIndex];
      const newSelectedColumns = new Set(sheet.selectedColumns);
      newSelectedColumns.has(columnName) ? newSelectedColumns.delete(columnName) : newSelectedColumns.add(columnName);
      sheet.selectedColumns = newSelectedColumns;
      return newSheets;
    });
  };

  const handleMappingChange = (sheetIndex, columnIndex, eidosFieldName) => {
    setColumnMappings(prev => ({
      ...prev,
      [sheetIndex]: { ...prev[sheetIndex], [columnIndex]: eidosFieldName }
    }));
  };

  const handleImport = async (sheetIndex) => {
    const sheet = sheets[sheetIndex];
    const mapping = columnMappings[sheetIndex] || {};
    const rowsToImport = sheet.rows.slice(1);
    const totalRows = rowsToImport.length;

    if (totalRows === 0 || Object.keys(mapping).length === 0) {
      setFieldError("没有数据或未设置字段映射。");
      return;
    }

    setImportingSheet(sheetIndex);
    setImportProgress(0);
    setImportResults(prev => ({ ...prev, [sheetIndex]: null }));
    let successCount = 0, failedCount = 0;

    for (let i = 0; i < totalRows; i++) {
      const row = rowsToImport[i];
      const newRecord = {};
      let hasData = false;

      for (const colIdxStr in mapping) {
        const colIdx = parseInt(colIdxStr, 10);
        const eidosFieldName = mapping[colIdx];
        if (eidosFieldName) {
          let cellData = row[colIdx];
          if (cellData !== undefined && cellData !== null && cellData !== '') {
            hasData = true;
            if (cellData.isImage) {
              newRecord[eidosFieldName] = cellData.src;
            } else {
              newRecord[eidosFieldName] = cellData.hyperlink ? cellData.hyperlink : (cellData.richText ? cellData.richText.map(rt => rt.text).join('') : cellData);
            }
          }
        }
      }

      if (hasData) {
        try {
          await eidos.currentSpace.table(tableId).rows.create(newRecord);
          successCount++;
        } catch (createError) {
          console.error("Error creating record:", createError, "Data:", newRecord);
          failedCount++;
        }
      }
      setImportProgress(((i + 1) / totalRows) * 100);
    }

    setImportResults(prev => ({ ...prev, [sheetIndex]: { success: successCount, failed: failedCount, total: totalRows } }));
    setImportingSheet(null);
  };

  const selectedTableName = availableTables.find(t => t.id === tableId)?.name || tableId;

  return (
    <div className="p-4 space-y-6 bg-slate-50 min-h-screen">
      <Card className="overflow-hidden">
        <CardHeader>
          <CardTitle className="flex items-center gap-2"><FileUp className="w-6 h-6" />Excel 数据导入工具</CardTitle>
          <CardDescription>上传 .xlsx 文件,将其数据导入到指定的 Eidos 表格中。</CardDescription>
        </CardHeader>
        <CardContent className="space-y-4">
          <div className="grid w-full max-w-lg items-center gap-2">
            <Label htmlFor="excel-file">1. 上传文件</Label>
            <Input id="excel-file" type="file" onChange={handleFileChange} accept=".xlsx" disabled={isLoading} className="file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-primary/10 file:text-primary hover:file:bg-primary/20" />
          </div>
          {sheets.length > 0 && (
            <div className="grid w-full max-w-lg items-center gap-2">
              <Label htmlFor="table-select">2. 选择目标表格</Label>
              <Select
                id="table-select"
                value={tableId}
                onValueChange={(value) => setTableId(value)}
                disabled={isLoading || isFetchingTables}
              >
                <SelectTrigger className="w-full">
                  <SelectValue placeholder={isFetchingTables ? "正在加载表格..." : "选择一个表格..."} />
                </SelectTrigger>
                <SelectContent>
                  {availableTables.map(table => (
                    <SelectItem key={table.id} value={table.id}>
                      {table.name}
                    </SelectItem>
                  ))}
                </SelectContent>
              </Select>
              {isFetchingFields && <div className="flex items-center text-sm text-muted-foreground"><Loader2 className="mr-2 h-4 w-4 animate-spin" />正在加载字段...</div>}
              {fieldError && <p className="text-sm text-red-500 mt-1">{fieldError}</p>}
            </div>
          )}
        </CardContent>
      </Card>

      {isLoading && <div className="flex items-center justify-center p-10 rounded-lg bg-white border"><Loader2 className="h-8 w-8 animate-spin text-primary" /><span className="ml-4 text-lg font-medium text-gray-600">正在解析 "{fileName}"...</span></div>}
      {error && <Alert variant="destructive"><FileWarning className="h-4 w-4" /><AlertTitle>出错了</AlertTitle><AlertDescription>{error}</AlertDescription></Alert>}

      {!isLoading && !error && sheets.map((sheet, sheetIndex) => (
        <Card key={sheetIndex}>
          <CardHeader className="flex flex-row items-center justify-between">
            <CardTitle>表格预览: {sheet.name}</CardTitle>
            <DropdownMenu>
              <DropdownMenuTrigger asChild><Button variant="outline" size="sm"><Columns className="mr-2 h-4 w-4" />选择列</Button></DropdownMenuTrigger>
              <DropdownMenuContent align="end">
                <DropdownMenuLabel>显示的列</DropdownMenuLabel>
                <DropdownMenuSeparator />
                {sheet.columns.map(c => <DropdownMenuCheckboxItem key={c.name} checked={sheet.selectedColumns.has(c.name)} onCheckedChange={() => handleColumnToggle(sheetIndex, c.name)}>{c.name}</DropdownMenuCheckboxItem>)}
              </DropdownMenuContent>
            </DropdownMenu>
          </CardHeader>
          <CardContent>
            <div className="overflow-x-auto border rounded-lg">
              <Table>
                <TableHeader>{sheet.rows[0] && <TableRow>{sheet.columns.filter(c => sheet.selectedColumns.has(c.name)).map(c => <TableHead key={c.index}><CellContent data={sheet.rows[0][c.index]} /></TableHead>)}</TableRow>}</TableHeader>
                <TableBody>{sheet.rows.slice(1, 6).map((row, rowIndex) => <TableRow key={rowIndex}>{sheet.columns.filter(c => sheet.selectedColumns.has(c.name)).map(c => <TableCell key={c.index} className="align-top"><CellContent data={row[c.index]} /></TableCell>)}</TableRow>)}</TableBody>
              </Table>
            </div>
            {sheet.rows.length > 6 && <p className="text-sm text-center text-muted-foreground mt-2">仅显示前 5 行数据作为预览。</p>}

            {tableFields.length > 0 && (
              <div className="mt-6 pt-6 border-t">
                <h3 className="text-lg font-semibold mb-2">3. 字段映射与导入</h3>
                <p className="text-sm text-muted-foreground mb-4">将 Excel 列映射到表格字段,然后开始导入。</p>
                <div className="overflow-x-auto border rounded-lg mb-4">
                  <Table>
                    <TableHeader><TableRow><TableHead>Excel 列</TableHead><TableHead>表格字段</TableHead></TableRow></TableHeader>
                    <TableBody>
                      {sheet.columns.map(col => (
                        <TableRow key={col.index}>
                          <TableCell className="font-medium">{col.name}</TableCell>
                          <TableCell>
                            <Select onValueChange={(value) => handleMappingChange(sheetIndex, col.index, value === 'null' ? null : value)}>
                              <SelectTrigger><SelectValue placeholder="选择一个字段..." /></SelectTrigger>
                              <SelectContent>
                                <SelectItem value="null">-- 不导入 --</SelectItem>
                                {tableFields.map(field => <SelectItem key={field.name} value={field.name}>{field.name} ({field.type})</SelectItem>)}
                              </SelectContent>
                            </Select>
                          </TableCell>
                        </TableRow>
                      ))}
                    </TableBody>
                  </Table>
                </div>
                <div className="flex items-center gap-4">
                  <Button onClick={() => handleImport(sheetIndex)} disabled={importingSheet !== null}>
                    {importingSheet === sheetIndex ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Upload className="mr-2 h-4 w-4" />}
                    导入到 {selectedTableName}
                  </Button>
                  {importingSheet === sheetIndex && <Progress value={importProgress} className="w-[200px]" />}
                </div>
                {importResults[sheetIndex] && (
                  <Alert className="mt-4">
                    <AlertTitle>导入完成</AlertTitle>
                    <AlertDescription>成功: {importResults[sheetIndex].success}, 失败: {importResults[sheetIndex].failed}, 总计: {importResults[sheetIndex].total}.</AlertDescription>
                  </Alert>
                )}
              </div>
            )}
          </CardContent>
        </Card>
      ))}
    </div>
  );
}

Information

Author
Mayne
Type
block
Latest Version
0.0.1
Last Updated
06/16/2025
Published
06/16/2025

Version History

  • v0.0.1 06/16/2025