Files
test1/components/nova-sdk/task-panel/Preview/CsvPreview.tsx
2026-03-20 07:33:46 +00:00

172 lines
4.2 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, { 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>
)
}