Datasets.tsx 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596
  1. import { useState, useEffect, useRef, memo, useCallback } from 'react'
  2. import api, { DatasetInfo, KnowledgeBaseItem, DatasetDownloadTaskResponse } from '../api/client'
  3. import { Database, Upload, Loader2, FolderOpen, CheckCircle, XCircle } from 'lucide-react'
  4. const DatasetRow = memo(function DatasetRow({ d, onPreview, onDelete }: {
  5. d: DatasetInfo
  6. onPreview: (id: string) => void
  7. onDelete: (id: string) => void
  8. }) {
  9. return (
  10. <tr style={{
  11. borderBottom: '1px solid #f1f5f9',
  12. transition: 'background 0.15s ease',
  13. }}
  14. onMouseEnter={e => { e.currentTarget.style.background = '#f0fdfa' }}
  15. onMouseLeave={e => { e.currentTarget.style.background = 'transparent' }}
  16. >
  17. <td style={{ padding: '12px 12px', fontWeight: 500, fontSize: 13 }}>{d.name}</td>
  18. <td style={{ padding: '12px 12px', fontSize: 13, color: '#64748b' }}>
  19. <span style={{
  20. display: 'inline-block', padding: '2px 8px', borderRadius: 4, fontSize: 12,
  21. background: '#f1f5f9', color: '#64748b',
  22. }}>
  23. {d.format}
  24. </span>
  25. </td>
  26. <td style={{ padding: '12px 12px', fontSize: 13 }}>{d.record_count}</td>
  27. <td style={{ padding: '12px 12px', fontSize: 13, color: '#94a3b8' }}>{d.created_at}</td>
  28. <td style={{ padding: '12px 12px' }}>
  29. <button onClick={() => onPreview(d.id)} style={{
  30. marginRight: 8, padding: '4px 12px', color: '#0ea5e9',
  31. border: '1px solid #0ea5e9', borderRadius: 6, background: 'transparent',
  32. cursor: 'pointer', fontSize: 12, fontWeight: 500, transition: 'all 0.15s ease',
  33. }}
  34. onMouseEnter={e => { e.currentTarget.style.background = '#0ea5e9'; e.currentTarget.style.color = '#fff' }}
  35. onMouseLeave={e => { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = '#0ea5e9' }}
  36. >预览</button>
  37. <button onClick={() => onDelete(d.id)} style={{
  38. padding: '4px 12px', color: '#f43f5e', border: '1px solid #f43f5e',
  39. borderRadius: 6, background: 'transparent', cursor: 'pointer',
  40. fontSize: 12, fontWeight: 500, transition: 'all 0.15s ease',
  41. }}
  42. onMouseEnter={e => { e.currentTarget.style.background = '#f43f5e'; e.currentTarget.style.color = '#fff' }}
  43. onMouseLeave={e => { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = '#f43f5e' }}
  44. >删除</button>
  45. </td>
  46. </tr>
  47. )
  48. })
  49. export function Datasets() {
  50. const [datasets, setDatasets] = useState<DatasetInfo[]>([])
  51. const [uploading, setUploading] = useState(false)
  52. const [downloading, setDownloading] = useState(false)
  53. const [loading, setLoading] = useState(false)
  54. const [previewData, setPreviewData] = useState<{ columns: string[]; rows: { row_index: number; data: Record<string, unknown> }[] } | null>(null)
  55. const inputRef = useRef<HTMLInputElement>(null)
  56. // Sample center modal state
  57. const [showSampleCenter, setShowSampleCenter] = useState(false)
  58. const [kbList, setKbList] = useState<KnowledgeBaseItem[]>([])
  59. const [kbLoading, setKbLoading] = useState(false)
  60. const [kbImporting, setKbImporting] = useState<string | null>(null)
  61. const [kbStatus, setKbStatus] = useState('')
  62. const [kbPage, setKbPage] = useState(1)
  63. const [kbTotal, setKbTotal] = useState(0)
  64. // Active downloads tracking
  65. const [activeDownloads, setActiveDownloads] = useState<Map<string, DatasetDownloadTaskResponse>>(new Map())
  66. const downloadPollIntervals = useRef<Map<string, ReturnType<typeof setInterval>>>(new Map())
  67. useEffect(() => {
  68. fetchDatasets()
  69. }, [])
  70. // Download form
  71. const [dlDatasetId, setDlDatasetId] = useState('')
  72. const [dlUseModelscope, setDlUseModelscope] = useState(false)
  73. const [dlStatus, setDlStatus] = useState('')
  74. const fetchDatasets = () => {
  75. setLoading(true)
  76. api.datasets.list()
  77. .then(setDatasets)
  78. .catch(() => setDatasets([]))
  79. .finally(() => setLoading(false))
  80. }
  81. const handleFileUpload = async (file: File) => {
  82. setUploading(true)
  83. try {
  84. await api.datasets.upload(file)
  85. fetchDatasets()
  86. } catch (err) {
  87. console.error('Upload failed:', err)
  88. } finally {
  89. setUploading(false)
  90. }
  91. }
  92. const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
  93. const file = e.target.files?.[0]
  94. if (file) handleFileUpload(file)
  95. }
  96. const handleDownload = async () => {
  97. if (!dlDatasetId.trim()) return
  98. setDownloading(true)
  99. setDlStatus('正在提交下载任务...')
  100. try {
  101. const res = await api.datasets.download(dlDatasetId, dlUseModelscope)
  102. setDlStatus(`下载任务已提交: ${res.dataset_id}`)
  103. setActiveDownloads(prev => new Map(prev).set(res.task_id, res))
  104. startDatasetDownloadPolling(res.task_id)
  105. } catch (err) {
  106. setDlStatus(`下载失败: ${err instanceof Error ? err.message : '未知错误'}`)
  107. } finally {
  108. setDownloading(false)
  109. }
  110. }
  111. const startDatasetDownloadPolling = (taskId: string) => {
  112. const interval = setInterval(() => {
  113. api.datasets.downloadStatus(taskId)
  114. .then(res => {
  115. setActiveDownloads(prev => {
  116. const next = new Map(prev)
  117. next.set(taskId, res)
  118. return next
  119. })
  120. if (res.status === 'completed') {
  121. clearInterval(interval)
  122. downloadPollIntervals.current.delete(taskId)
  123. fetchDatasets()
  124. setDlStatus(`${res.dataset_id} 下载完成 (${res.record_count} 条记录)`)
  125. } else if (res.status === 'failed') {
  126. clearInterval(interval)
  127. downloadPollIntervals.current.delete(taskId)
  128. setDlStatus(`${res.dataset_id} 下载失败: ${res.error}`)
  129. }
  130. })
  131. .catch(() => {})
  132. }, 3000)
  133. downloadPollIntervals.current.set(taskId, interval)
  134. }
  135. const handleCancelDatasetDownload = (taskId: string) => {
  136. api.datasets.cancelDownload(taskId)
  137. setActiveDownloads(prev => {
  138. const next = new Map(prev)
  139. next.delete(taskId)
  140. return next
  141. })
  142. const interval = downloadPollIntervals.current.get(taskId)
  143. if (interval) {
  144. clearInterval(interval)
  145. downloadPollIntervals.current.delete(taskId)
  146. }
  147. }
  148. useEffect(() => {
  149. return () => {
  150. downloadPollIntervals.current.forEach(interval => clearInterval(interval))
  151. }
  152. }, [])
  153. const handlePreview = (id: string) => {
  154. api.datasets.preview(id, 10)
  155. .then(res => setPreviewData({ columns: res.columns, rows: res.preview_rows }))
  156. .catch(() => setPreviewData(null))
  157. }
  158. const handleDelete = async (id: string) => {
  159. if (!confirm('确定删除此数据集?')) return
  160. try {
  161. await api.datasets.delete(id)
  162. fetchDatasets()
  163. setPreviewData(null)
  164. } catch (err) {
  165. console.error('Delete failed:', err)
  166. }
  167. }
  168. const fetchKnowledgeBases = useCallback((page = 1) => {
  169. setKbLoading(true)
  170. api.sampleCenter.listKnowledgeBases(page, 20)
  171. .then(res => {
  172. setKbList(res.items)
  173. setKbTotal(res.total)
  174. setKbPage(res.page)
  175. })
  176. .catch(err => setKbStatus(`获取知识库列表失败: ${err.message}`))
  177. .finally(() => setKbLoading(false))
  178. }, [])
  179. const handleImportFromKB = async (kb: KnowledgeBaseItem) => {
  180. setKbImporting(kb.id)
  181. setKbStatus(`正在导入 "${kb.name}" ...`)
  182. try {
  183. await api.sampleCenter.importFromKnowledgeBase(kb.id, kb.name)
  184. setKbStatus(`"${kb.name}" 导入请求已提交,可在样本中心查看入库进度`)
  185. // 刷新本地数据集列表
  186. fetchDatasets()
  187. } catch (err: unknown) {
  188. const msg = err instanceof Error ? err.message : '导入失败'
  189. setKbStatus(`导入失败: ${msg}`)
  190. } finally {
  191. setKbImporting(null)
  192. }
  193. }
  194. return (
  195. <div>
  196. <h1 style={{ margin: 0, fontSize: 22, fontWeight: 700 }}>数据集管理</h1>
  197. <p style={{ color: '#64748b', fontSize: 13, margin: '4px 0 16px' }}>上传和管理训练数据集</p>
  198. {/* Upload area */}
  199. <div
  200. onClick={() => inputRef.current?.click()}
  201. style={{
  202. marginTop: 16, border: '2px dashed #cbd5e1', borderRadius: 12,
  203. padding: 40, textAlign: 'center', color: '#94a3b8', cursor: 'pointer',
  204. opacity: uploading ? 0.6 : 1, background: '#fff',
  205. transition: 'all 0.2s ease',
  206. }}
  207. onMouseEnter={e => { e.currentTarget.style.borderColor = '#14b8a6'; e.currentTarget.style.background = '#f0fdfa' }}
  208. onMouseLeave={e => { e.currentTarget.style.borderColor = '#cbd5e1'; e.currentTarget.style.background = '#fff' }}
  209. >
  210. <div style={{ marginBottom: 8 }}>
  211. {uploading ? (
  212. <Loader2 size={32} color="#14b8a6" strokeWidth={1.5} style={{ animation: 'lucide-spin 1s linear infinite' }} />
  213. ) : (
  214. <Upload size={32} color="#14b8a6" strokeWidth={1.5} />
  215. )}
  216. </div>
  217. <div style={{ fontSize: 14, fontWeight: 500 }}>
  218. {uploading ? '上传中...' : '拖拽文件到此处或点击上传'}
  219. </div>
  220. <div style={{ fontSize: 12, color: '#cbd5e1', marginTop: 4 }}>
  221. 支持 JSONL / CSV / Parquet / JSON 格式
  222. </div>
  223. <input
  224. ref={inputRef}
  225. type="file"
  226. accept=".jsonl,.csv,.parquet,.json"
  227. style={{ display: 'none' }}
  228. onChange={handleInputChange}
  229. />
  230. </div>
  231. {/* Download section */}
  232. <div style={{
  233. marginTop: 24, background: '#fff', borderRadius: 10, padding: 20,
  234. boxShadow: '0 1px 3px rgba(0,0,0,0.06)', border: '1px solid rgba(0,0,0,0.04)',
  235. }}>
  236. <h2 style={{ margin: '0 0 12px', fontSize: 15, fontWeight: 600 }}>从平台下载</h2>
  237. <div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
  238. <input
  239. type="text"
  240. placeholder="数据集 ID (如 glue, MRPC, stanfordnlp/imdb)"
  241. value={dlDatasetId}
  242. onChange={e => setDlDatasetId(e.target.value)}
  243. style={{
  244. padding: '10px 14px', flex: 1, maxWidth: 400, borderRadius: 8,
  245. border: '1px solid #cbd5e1', fontSize: 14, outline: 'none',
  246. transition: 'border-color 0.2s',
  247. }}
  248. onFocus={e => { e.currentTarget.style.borderColor = '#14b8a6' }}
  249. onBlur={e => { e.currentTarget.style.borderColor = '#cbd5e1' }}
  250. />
  251. <label style={{ fontSize: 13, color: '#64748b', whiteSpace: 'nowrap', display: 'flex', alignItems: 'center', gap: 4, cursor: 'pointer' }}>
  252. <input type="checkbox" checked={dlUseModelscope} onChange={e => setDlUseModelscope(e.target.checked)} />
  253. {' '}ModelScope
  254. </label>
  255. <button
  256. onClick={handleDownload}
  257. disabled={downloading}
  258. style={{
  259. padding: '10px 20px', borderRadius: 8, border: 'none',
  260. background: '#14b8a6', color: '#fff', cursor: 'pointer',
  261. opacity: downloading ? 0.6 : 1, fontSize: 14, fontWeight: 600,
  262. }}
  263. >
  264. {downloading ? '下载中...' : '下载数据集'}
  265. </button>
  266. </div>
  267. {dlStatus && <p style={{
  268. marginTop: 10, padding: '8px 12px', borderRadius: 6, fontSize: 13,
  269. background: dlStatus.includes('失败') ? '#fff1f2' : '#f1f5f9',
  270. color: dlStatus.includes('失败') ? '#e11d48' : '#64748b',
  271. border: `1px solid ${dlStatus.includes('失败') ? '#fecdd3' : 'transparent'}`,
  272. }}>{dlStatus}</p>}
  273. </div>
  274. {/* Active Downloads */}
  275. {activeDownloads.size > 0 && (
  276. <div style={{
  277. marginTop: 24, background: '#fff', borderRadius: 10, padding: 20,
  278. boxShadow: '0 1px 3px rgba(0,0,0,0.06)', border: '1px solid rgba(0,0,0,0.04)',
  279. }}>
  280. <h2 style={{ margin: '0 0 12px', fontSize: 15, fontWeight: 600 }}>下载任务</h2>
  281. <div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
  282. {Array.from(activeDownloads.values()).map(dl => (
  283. <div key={dl.task_id} style={{
  284. display: 'flex', alignItems: 'center', gap: 12, padding: 12,
  285. borderRadius: 8, background: '#f8fafc', border: '1px solid #e2e8f0',
  286. }}>
  287. {dl.status === 'completed' ? (
  288. <CheckCircle size={20} color="#059669" />
  289. ) : dl.status === 'failed' ? (
  290. <XCircle size={20} color="#f43f5e" />
  291. ) : (
  292. <Loader2 size={20} color="#14b8a6" style={{ animation: 'lucide-spin 1s linear infinite' }} />
  293. )}
  294. <div style={{ flex: 1 }}>
  295. <div style={{ fontSize: 13, fontWeight: 500 }}>{dl.dataset_id}</div>
  296. <div style={{ fontSize: 12, color: '#64748b' }}>
  297. {dl.status === 'completed' ? `已完成 (${dl.record_count} 条记录)` : dl.status === 'failed' ? `失败: ${dl.error}` :
  298. dl.status === 'cancelled' ? '已取消' : '下载中...'}
  299. </div>
  300. </div>
  301. {(dl.status === 'pending' || dl.status === 'running' || dl.status === 'downloading') && (
  302. <button onClick={() => handleCancelDatasetDownload(dl.task_id)} style={{
  303. padding: '4px 12px', color: '#f43f5e', border: '1px solid #f43f5e',
  304. borderRadius: 6, background: 'transparent', cursor: 'pointer', fontSize: 12,
  305. }}>取消</button>
  306. )}
  307. </div>
  308. ))}
  309. </div>
  310. </div>
  311. )}
  312. {/* Sample Center section */}
  313. <div style={{
  314. marginTop: 24, background: '#fff', borderRadius: 10, padding: 20,
  315. boxShadow: '0 1px 3px rgba(0,0,0,0.06)', border: '1px solid rgba(0,0,0,0.04)',
  316. }}>
  317. <h2 style={{ margin: '0 0 12px', fontSize: 15, fontWeight: 600 }}>样本中心</h2>
  318. <p style={{ fontSize: 13, color: '#64748b', margin: '0 0 12px' }}>
  319. 从样本中心导入知识库数据作为训练数据集
  320. </p>
  321. <button
  322. onClick={() => { setShowSampleCenter(true); fetchKnowledgeBases(1); }}
  323. style={{
  324. padding: '10px 20px', borderRadius: 8, border: 'none',
  325. background: '#8b5cf6', color: '#fff', cursor: 'pointer', fontSize: 14, fontWeight: 600,
  326. }}
  327. >
  328. <FolderOpen size={16} style={{ display: 'inline', verticalAlign: 'middle', marginRight: 4 }} />
  329. 从样本中心导入
  330. </button>
  331. </div>
  332. {/* Sample Center Modal */}
  333. {showSampleCenter && (
  334. <div
  335. style={{
  336. position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.4)',
  337. display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000,
  338. }}
  339. onClick={() => setShowSampleCenter(false)}
  340. >
  341. <div
  342. onClick={e => e.stopPropagation()}
  343. style={{
  344. background: '#fff', borderRadius: 12, padding: 24, width: '90%', maxWidth: 700,
  345. maxHeight: '80vh', overflow: 'auto', boxShadow: '0 20px 60px rgba(0,0,0,0.15)',
  346. }}
  347. >
  348. <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
  349. <h2 style={{ margin: 0, fontSize: 17, fontWeight: 600 }}>样本中心 - 知识库列表</h2>
  350. <button
  351. onClick={() => setShowSampleCenter(false)}
  352. style={{
  353. border: 'none', background: 'transparent', cursor: 'pointer', fontSize: 20,
  354. color: '#64748b', padding: '4px 8px', borderRadius: 4,
  355. }}
  356. >✕</button>
  357. </div>
  358. <p style={{ fontSize: 13, color: '#64748b', margin: '0 0 16px' }}>
  359. 选择要导入的知识库,数据将转为训练格式
  360. </p>
  361. {/* KB list */}
  362. {kbLoading && (
  363. <div style={{ textAlign: 'center', padding: 20, color: '#94a3b8' }}>
  364. <Loader2 size={24} style={{ animation: 'lucide-spin 1s linear infinite' }} />
  365. <div style={{ marginTop: 8, fontSize: 13 }}>加载中...</div>
  366. </div>
  367. )}
  368. {!kbLoading && kbList.length === 0 && (
  369. <div style={{ padding: 20, textAlign: 'center', color: '#94a3b8', fontSize: 14 }}>
  370. 暂无可用的知识库
  371. </div>
  372. )}
  373. {!kbLoading && kbList.length > 0 && (
  374. <div style={{ border: '1px solid #e2e8f0', borderRadius: 8, overflow: 'hidden' }}>
  375. <table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
  376. <thead>
  377. <tr style={{ background: '#f5f3ff', borderBottom: '2px solid #e2e8f0', textAlign: 'left' }}>
  378. <th style={{ padding: '10px 12px', fontSize: 12, color: '#64748b', fontWeight: 600 }}>名称</th>
  379. <th style={{ padding: '10px 12px', fontSize: 12, color: '#64748b', fontWeight: 600 }}>文档数</th>
  380. <th style={{ padding: '10px 12px', fontSize: 12, color: '#64748b', fontWeight: 600 }}>状态</th>
  381. <th style={{ padding: '10px 12px', fontSize: 12, color: '#64748b', fontWeight: 600 }}>字段</th>
  382. <th style={{ padding: '10px 12px', fontSize: 12, color: '#64748b', fontWeight: 600 }}>操作</th>
  383. </tr>
  384. </thead>
  385. <tbody>
  386. {kbList.map(kb => (
  387. <tr key={kb.id} style={{ borderBottom: '1px solid #f1f5f9' }}>
  388. <td style={{ padding: '10px 12px', fontWeight: 500 }}>{kb.name}</td>
  389. <td style={{ padding: '10px 12px', fontSize: 13 }}>{kb.document_count}</td>
  390. <td style={{ padding: '10px 12px', fontSize: 13 }}>
  391. <span style={{
  392. display: 'inline-block', padding: '2px 8px', borderRadius: 4, fontSize: 12,
  393. background: kb.status === 1 ? '#dcfce7' : '#f1f5f9',
  394. color: kb.status === 1 ? '#16a34a' : '#64748b',
  395. }}>
  396. {kb.status === 1 ? '启用' : '禁用'}
  397. </span>
  398. </td>
  399. <td style={{ padding: '10px 12px', fontSize: 12, color: '#64748b', maxWidth: 200 }}>
  400. {kb.metadata_schema.slice(0, 3).map(f => f.field_name_cn).join('、')}
  401. {kb.metadata_schema.length > 3 ? '...' : ''}
  402. </td>
  403. <td style={{ padding: '10px 12px' }}>
  404. <button
  405. onClick={() => handleImportFromKB(kb)}
  406. disabled={kbImporting === kb.id}
  407. style={{
  408. padding: '4px 12px', color: '#8b5cf6', border: '1px solid #8b5cf6',
  409. borderRadius: 6, background: kbImporting === kb.id ? '#f5f3ff' : 'transparent',
  410. cursor: kbImporting === kb.id ? 'not-allowed' : 'pointer',
  411. fontSize: 12, fontWeight: 500, opacity: kbImporting === kb.id ? 0.7 : 1,
  412. }}
  413. >
  414. {kbImporting === kb.id ? (
  415. <><Loader2 size={12} style={{ animation: 'lucide-spin 1s linear infinite', display: 'inline', verticalAlign: 'middle', marginRight: 4 }} />导入中</>
  416. ) : '导入'}
  417. </button>
  418. </td>
  419. </tr>
  420. ))}
  421. </tbody>
  422. </table>
  423. </div>
  424. )}
  425. {/* Pagination */}
  426. {!kbLoading && kbTotal > 20 && (
  427. <div style={{ display: 'flex', justifyContent: 'center', gap: 8, marginTop: 16, alignItems: 'center' }}>
  428. <button
  429. disabled={kbPage <= 1}
  430. onClick={() => fetchKnowledgeBases(kbPage - 1)}
  431. style={{
  432. padding: '4px 12px', borderRadius: 6, border: '1px solid #cbd5e1',
  433. background: '#fff', cursor: kbPage <= 1 ? 'not-allowed' : 'pointer',
  434. opacity: kbPage <= 1 ? 0.5 : 1, fontSize: 13,
  435. }}
  436. >上一页</button>
  437. <span style={{ fontSize: 13, color: '#64748b' }}>
  438. 第 {kbPage} 页 / 共 {Math.ceil(kbTotal / 20)} 页
  439. </span>
  440. <button
  441. disabled={kbPage * 20 >= kbTotal}
  442. onClick={() => fetchKnowledgeBases(kbPage + 1)}
  443. style={{
  444. padding: '4px 12px', borderRadius: 6, border: '1px solid #cbd5e1',
  445. background: '#fff', cursor: kbPage * 20 >= kbTotal ? 'not-allowed' : 'pointer',
  446. opacity: kbPage * 20 >= kbTotal ? 0.5 : 1, fontSize: 13,
  447. }}
  448. >下一页</button>
  449. </div>
  450. )}
  451. {/* Status */}
  452. {kbStatus && (
  453. <p style={{
  454. marginTop: 12, padding: '8px 12px', borderRadius: 6, fontSize: 13,
  455. background: kbStatus.includes('失败') ? '#fff1f2' : '#f0fdf4',
  456. color: kbStatus.includes('失败') ? '#e11d48' : '#16a34a',
  457. border: `1px solid ${kbStatus.includes('失败') ? '#fecdd3' : '#bbf7d0'}`,
  458. }}>{kbStatus}</p>
  459. )}
  460. </div>
  461. </div>
  462. )}
  463. {/* Dataset list */}
  464. <div style={{ marginTop: 24 }}>
  465. <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
  466. <h2 style={{ margin: 0, fontSize: 15, fontWeight: 600 }}>已上传数据集</h2>
  467. <button onClick={fetchDatasets} style={{
  468. padding: '6px 14px', borderRadius: 6, border: '1px solid #cbd5e1',
  469. background: '#fff', cursor: 'pointer', fontSize: 13, fontWeight: 500,
  470. }}
  471. onMouseEnter={e => { e.currentTarget.style.background = '#f0fdfa' }}
  472. onMouseLeave={e => { e.currentTarget.style.background = '#fff' }}
  473. >
  474. 刷新
  475. </button>
  476. </div>
  477. {loading && <p style={{ color: '#94a3b8', fontSize: 13 }}>加载中...</p>}
  478. {!loading && datasets.length === 0 && (
  479. <div style={{
  480. padding: 40, textAlign: 'center', color: '#94a3b8', fontSize: 14,
  481. background: '#fff', borderRadius: 10, boxShadow: '0 1px 3px rgba(0,0,0,0.06)',
  482. }}>
  483. <div style={{ marginBottom: 8 }}><Database size={32} color="#94a3b8" strokeWidth={1.5} /></div>
  484. 暂无数据集,请上传文件或从平台下载
  485. </div>
  486. )}
  487. {!loading && datasets.length > 0 && (
  488. <div style={{
  489. background: '#fff', borderRadius: 10, overflow: 'hidden',
  490. boxShadow: '0 1px 3px rgba(0,0,0,0.06)', border: '1px solid rgba(0,0,0,0.04)',
  491. }}>
  492. <table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 14 }}>
  493. <thead>
  494. <tr style={{ background: '#f0fdfa', borderBottom: '2px solid #f1f5f9', textAlign: 'left' }}>
  495. <th style={{ padding: '10px 12px', fontSize: 12, color: '#64748b', fontWeight: 600 }}>名称</th>
  496. <th style={{ padding: '10px 12px', fontSize: 12, color: '#64748b', fontWeight: 600 }}>格式</th>
  497. <th style={{ padding: '10px 12px', fontSize: 12, color: '#64748b', fontWeight: 600 }}>记录数</th>
  498. <th style={{ padding: '10px 12px', fontSize: 12, color: '#64748b', fontWeight: 600 }}>上传时间</th>
  499. <th style={{ padding: '10px 12px', fontSize: 12, color: '#64748b', fontWeight: 600 }}>操作</th>
  500. </tr>
  501. </thead>
  502. <tbody>
  503. {datasets.map(d => (
  504. <DatasetRow key={d.id} d={d} onPreview={handlePreview} onDelete={handleDelete} />
  505. ))}
  506. </tbody>
  507. </table>
  508. </div>
  509. )}
  510. </div>
  511. {/* Preview */}
  512. {previewData && previewData.rows.length > 0 && (
  513. <div style={{
  514. marginTop: 24, background: '#fff', borderRadius: 10, padding: 20,
  515. boxShadow: '0 1px 3px rgba(0,0,0,0.06)', border: '1px solid rgba(0,0,0,0.04)',
  516. }}>
  517. <h3 style={{ margin: '0 0 12px', fontSize: 15, fontWeight: 600 }}>数据预览</h3>
  518. <div style={{ overflowX: 'auto' }}>
  519. <table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
  520. <thead>
  521. <tr style={{ background: '#f0fdfa', borderBottom: '2px solid #f1f5f9', textAlign: 'left' }}>
  522. {previewData.columns.map(col => (
  523. <th key={col} style={{ padding: '8px 12px', fontSize: 12, color: '#64748b', fontWeight: 600, whiteSpace: 'nowrap' }}>{col}</th>
  524. ))}
  525. </tr>
  526. </thead>
  527. <tbody>
  528. {previewData.rows.slice(0, 10).map((row, i) => (
  529. <tr key={i} style={{ borderBottom: '1px solid #f1f5f9', transition: 'background 0.15s ease' }}
  530. onMouseEnter={e => { e.currentTarget.style.background = '#f0fdfa' }}
  531. onMouseLeave={e => { e.currentTarget.style.background = 'transparent' }}
  532. >
  533. {previewData.columns.map(col => {
  534. const cellVal = String(row.data[col] ?? '')
  535. const isMultiline = cellVal.includes('\n') || cellVal.length > 100
  536. return (
  537. <td
  538. key={col}
  539. style={{
  540. padding: '8px 12px',
  541. maxWidth: isMultiline ? 500 : 200,
  542. overflow: isMultiline ? 'auto' : 'hidden',
  543. textOverflow: isMultiline ? undefined : 'ellipsis',
  544. whiteSpace: isMultiline ? 'pre-wrap' : 'nowrap',
  545. fontFamily: isMultiline ? 'monospace' : undefined,
  546. fontSize: isMultiline ? 12 : 13,
  547. }}
  548. >
  549. {cellVal}
  550. </td>
  551. )
  552. })}
  553. </tr>
  554. ))}
  555. </tbody>
  556. </table>
  557. </div>
  558. </div>
  559. )}
  560. </div>
  561. )
  562. }