172 lines
4.2 KiB
TypeScript
172 lines
4.2 KiB
TypeScript
import React, { useEffect, useState } from 'react'
|
||
import {
|
||
Table,
|
||
TableBody,
|
||
TableCell,
|
||
TableHead,
|
||
TableHeader,
|
||
TableRow,
|
||
} from '@/components/ui/table'
|
||
import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area'
|
||
|
||
export interface CsvPreviewProps {
|
||
/** CSV 文件的远程 URL */
|
||
url?: string
|
||
/** 直接传入的 CSV 内容 */
|
||
content?: string
|
||
}
|
||
|
||
function parseCsv(text: string): string[][] {
|
||
const lines = text.split(/\r?\n/)
|
||
return lines
|
||
.filter(line => line.trim() !== '')
|
||
.map(line => {
|
||
const row: string[] = []
|
||
let inQuotes = false
|
||
let cell = ''
|
||
for (let i = 0; i < line.length; i++) {
|
||
const ch = line[i]
|
||
if (ch === '"') {
|
||
if (inQuotes && line[i + 1] === '"') {
|
||
cell += '"'
|
||
i++
|
||
} else {
|
||
inQuotes = !inQuotes
|
||
}
|
||
} else if (ch === ',' && !inQuotes) {
|
||
row.push(cell)
|
||
cell = ''
|
||
} else {
|
||
cell += ch
|
||
}
|
||
}
|
||
row.push(cell)
|
||
return row
|
||
})
|
||
}
|
||
|
||
export function CsvPreview({ url, content }: CsvPreviewProps) {
|
||
const [rows, setRows] = useState<string[][]>([])
|
||
const [loading, setLoading] = useState(true)
|
||
const [error, setError] = useState<string | null>(null)
|
||
|
||
useEffect(() => {
|
||
let cancelled = false
|
||
|
||
// 在异步回调里更新本地 state,避免在 effect 体内同步 setState
|
||
queueMicrotask(() => {
|
||
if (cancelled) return
|
||
setLoading(true)
|
||
setError(null)
|
||
})
|
||
|
||
if (content != null) {
|
||
queueMicrotask(() => {
|
||
if (cancelled) return
|
||
setRows(parseCsv(content))
|
||
setLoading(false)
|
||
})
|
||
|
||
return () => {
|
||
cancelled = true
|
||
}
|
||
}
|
||
|
||
if (!url) {
|
||
queueMicrotask(() => {
|
||
if (cancelled) return
|
||
setRows([])
|
||
setLoading(false)
|
||
})
|
||
|
||
return () => {
|
||
cancelled = true
|
||
}
|
||
}
|
||
|
||
fetch(url)
|
||
.then(res => {
|
||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||
return res.text()
|
||
})
|
||
.then(text => {
|
||
if (cancelled) return
|
||
setRows(parseCsv(text))
|
||
})
|
||
.catch(err => {
|
||
if (cancelled) return
|
||
setError(err.message || '加载失败')
|
||
})
|
||
.finally(() => {
|
||
if (cancelled) return
|
||
setLoading(false)
|
||
})
|
||
|
||
return () => {
|
||
cancelled = true
|
||
}
|
||
}, [content, url])
|
||
|
||
if (loading) {
|
||
return (
|
||
<div className="flex items-center justify-center h-full text-muted-foreground">
|
||
<div className="w-6 h-6 border-2 border-muted border-t-primary rounded-full animate-spin mr-2" />
|
||
<span className="text-sm">加载中...</span>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
if (error) {
|
||
return (
|
||
<div className="flex items-center justify-center h-full text-destructive text-sm">
|
||
加载失败:{error}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
if (rows.length === 0) {
|
||
return (
|
||
<div className="flex items-center justify-center h-full text-muted-foreground text-sm">
|
||
文件为空
|
||
</div>
|
||
)
|
||
}
|
||
|
||
const [header, ...body] = rows
|
||
const colCount = Math.max(...rows.map(r => r.length))
|
||
const paddedHeader = header.concat(Array(colCount - header.length).fill(''))
|
||
|
||
return (
|
||
<ScrollArea className="w-full h-full">
|
||
<div className="p-4">
|
||
<Table>
|
||
<TableHeader>
|
||
<TableRow>
|
||
{paddedHeader.map((col, i) => (
|
||
<TableHead key={i} className="whitespace-nowrap font-medium text-foreground">
|
||
{col || `列 ${i + 1}`}
|
||
</TableHead>
|
||
))}
|
||
</TableRow>
|
||
</TableHeader>
|
||
<TableBody>
|
||
{body.map((row, ri) => {
|
||
const paddedRow = row.concat(Array(colCount - row.length).fill(''))
|
||
return (
|
||
<TableRow key={ri}>
|
||
{paddedRow.map((cell, ci) => (
|
||
<TableCell key={ci} className="whitespace-nowrap text-sm">
|
||
{cell}
|
||
</TableCell>
|
||
))}
|
||
</TableRow>
|
||
)
|
||
})}
|
||
</TableBody>
|
||
</Table>
|
||
</div>
|
||
<ScrollBar orientation="horizontal" />
|
||
</ScrollArea>
|
||
)
|
||
}
|