import { useEffect, useMemo, useState } from 'react' import type { ReactNode } from 'react' import { Alert, Button, Card, Empty, Grid, Input, Message, Modal, Space, Spin, Switch, Typography } from '@arco-design/web-react' import { IconBook, IconCheckCircle, IconCompass, IconDelete, IconEdit, IconLaunch, IconLink, IconRefresh, IconStar, IconStorage, } from '@arco-design/web-react/icon' import type { DatasetDepositDTO, DatasetItemDTO, DatasetItemPageDTO, DatasetItemUpdate, DatasetSourceDTO, DatasetSourceInput, DatasetSourceKind, DatasetSyncResultDTO, } from '../api/dataset' const { Row, Col } = Grid const { Title, Text, Paragraph } = Typography const { TextArea } = Input const ITEM_PAGE_SIZE = 50 type DataSourceCard = DatasetSourceDTO & { icon: ReactNode color: string } type SourceDraft = { name: string kind: DatasetSourceKind url: string iconUrl: string description: string enabled: boolean } const EMPTY_SOURCE_DRAFT: SourceDraft = { name: '', kind: 'rss', url: '', iconUrl: '', description: '', enabled: true, } export function WorkspaceExplorePage({ activeProjectID, activeProjectName, onListSources, onListItems, onCreateSource, onUpdateSource, onDeleteSource, onUpdateItem, onSyncSources, onDepositItem, }: { activeProjectID: string activeProjectName: string onListSources: (signal?: AbortSignal) => Promise onListItems: (sourceId?: string, offset?: number, limit?: number, signal?: AbortSignal) => Promise onCreateSource: (input: DatasetSourceInput) => Promise onUpdateSource: (sourceId: string, input: DatasetSourceInput) => Promise onDeleteSource: (sourceId: string) => Promise onUpdateItem: (itemId: string, input: DatasetItemUpdate) => Promise onSyncSources: () => Promise onDepositItem: (itemId: string, projectId: string) => Promise }) { const [sourceRecords, setSourceRecords] = useState([]) const [items, setItems] = useState([]) const [itemTotal, setItemTotal] = useState(0) const [activeSourceID, setActiveSourceID] = useState('all') const [activeItemID, setActiveItemID] = useState(null) const [sourceModalMode, setSourceModalMode] = useState<'add' | 'edit' | null>(null) const [editingSourceID, setEditingSourceID] = useState(null) const [sourceDraft, setSourceDraft] = useState(EMPTY_SOURCE_DRAFT) const [sourceError, setSourceError] = useState('') const [loadError, setLoadError] = useState('') const [loading, setLoading] = useState(true) const [itemsLoading, setItemsLoading] = useState(true) const [saving, setSaving] = useState(false) const [syncing, setSyncing] = useState(false) const [depositingItemID, setDepositingItemID] = useState(null) useEffect(() => { const controller = new AbortController() setLoading(true) setLoadError('') onListSources(controller.signal) .then((nextSources) => { setSourceRecords(nextSources) }) .catch((error: unknown) => { if (!controller.signal.aborted) { setLoadError(error instanceof Error ? error.message : '探索数据加载失败') } }) .finally(() => { if (!controller.signal.aborted) setLoading(false) }) return () => controller.abort() }, [onListSources]) useEffect(() => { const controller = new AbortController() setItemsLoading(true) setLoadError('') setItems([]) setItemTotal(0) setActiveItemID(null) const sourceID = activeSourceID === 'all' ? undefined : activeSourceID onListItems(sourceID, 0, ITEM_PAGE_SIZE, controller.signal) .then((page) => { setItems(page.items) setItemTotal(page.total) }) .catch((error: unknown) => { if (!controller.signal.aborted) { setLoadError(error instanceof Error ? error.message : '探索内容加载失败') } }) .finally(() => { if (!controller.signal.aborted) setItemsLoading(false) }) return () => controller.abort() }, [activeSourceID, onListItems]) const sources = useMemo(() => { const allSource: DataSourceCard = { id: 'all', name: '全部', kind: 'manual', url: '', iconUrl: '', description: '全部探索内容', enabled: true, builtIn: true, lastSyncedAt: null, itemCount: sourceRecords.reduce((total, source) => total + source.itemCount, 0), createdAt: '', updatedAt: '', icon: , color: 'blue', } return [ allSource, ...sourceRecords.map((source) => ({ ...source, icon: source.iconUrl ? : sourceIcon(source.kind), color: sourceColor(source.kind), })), ] }, [sourceRecords]) const filteredItems = items useEffect(() => { if (filteredItems.length === 0) { setActiveItemID(null) return } if (!activeItemID || !filteredItems.some((item) => item.id === activeItemID)) { setActiveItemID(filteredItems[0].id) } }, [activeItemID, filteredItems]) const selected = filteredItems.find((item) => item.id === activeItemID) ?? filteredItems[0] const activeSource = sourceByID(sources, activeSourceID) const selectedSource = selected ? sourceByID(sources, selected.sourceId) : activeSource const editingSource = editingSourceID ? sourceRecords.find((source) => source.id === editingSourceID) : undefined function openAddSource() { setEditingSourceID(null) setSourceDraft(EMPTY_SOURCE_DRAFT) setSourceError('') setSourceModalMode('add') } function openEditSource(source: DataSourceCard) { setEditingSourceID(source.id) setSourceDraft({ name: source.name, kind: source.kind, url: source.url, iconUrl: source.iconUrl, description: source.description, enabled: source.enabled, }) setSourceError('') setSourceModalMode('edit') } async function saveSource() { const input: DatasetSourceInput = { name: sourceDraft.name.trim(), kind: sourceDraft.kind, url: sourceDraft.url.trim(), iconUrl: sourceDraft.iconUrl.trim(), description: sourceDraft.description.trim(), enabled: sourceDraft.enabled, } if (!input.name) { setSourceError('请输入数据源名称') return } if (input.kind === 'rss' && !input.url) { setSourceError('RSS 数据源必须填写地址') return } setSaving(true) setSourceError('') try { if (sourceModalMode === 'edit' && editingSourceID) { const updated = await onUpdateSource(editingSourceID, input) setSourceRecords((current) => current.map((source) => source.id === updated.id ? updated : source)) Message.success('数据源已更新') } else { const created = await onCreateSource(input) setSourceRecords((current) => [...current, created]) Message.success('数据源已添加') } setSourceModalMode(null) } catch (error) { setSourceError(error instanceof Error ? error.message : '数据源保存失败') } finally { setSaving(false) } } function deleteSource(source: DataSourceCard) { Modal.confirm({ title: `删除“${source.name}”数据源?`, content: '删除后,该数据源及其采集条目和待执行任务都会移除。', okButtonProps: { status: 'danger' }, onOk: async () => { try { await onDeleteSource(source.id) setSourceRecords((current) => current.filter((item) => item.id !== source.id)) setItems((current) => current.filter((item) => item.sourceId !== source.id)) if (activeSourceID === source.id) setActiveSourceID('all') Message.success('数据源已删除') } catch (error) { Message.error(error instanceof Error ? error.message : '数据源删除失败') throw error } }, }) } async function syncSources() { setSyncing(true) try { const results = await onSyncSources() if (results.length === 0) { Message.info('暂无已启用的 RSS 数据源') } else { const sourceID = activeSourceID === 'all' ? undefined : activeSourceID const [nextSources, nextPage] = await Promise.all([ onListSources(), onListItems(sourceID, 0, ITEM_PAGE_SIZE), ]) setSourceRecords(nextSources) setItems(nextPage.items) setItemTotal(nextPage.total) const failed = results.filter((result) => result.status === 'failed').length if (failed > 0) { Message.warning(`${results.length - failed} 个数据源采集完成,${failed} 个失败`) } else { Message.success(`已完成 ${results.length} 个数据源采集任务`) } } } catch (error) { Message.error(error instanceof Error ? error.message : '数据源采集失败') } finally { setSyncing(false) } } async function loadMoreItems() { if (itemsLoading || items.length >= itemTotal) return setItemsLoading(true) try { const sourceID = activeSourceID === 'all' ? undefined : activeSourceID const page = await onListItems(sourceID, items.length, ITEM_PAGE_SIZE) setItems((current) => [...current, ...page.items]) setItemTotal(page.total) } catch (error) { Message.error(error instanceof Error ? error.message : '更多探索内容加载失败') } finally { setItemsLoading(false) } } async function updateSelected(input: DatasetItemUpdate) { if (!selected) return try { const updated = await onUpdateItem(selected.id, input) setItems((current) => current.map((item) => item.id === updated.id ? updated : item)) } catch (error) { Message.error(error instanceof Error ? error.message : '数据条目更新失败') } } async function depositSelected() { if (!selected || !activeProjectID) return setDepositingItemID(selected.id) try { const result = await onDepositItem(selected.id, activeProjectID) if (result.refreshFailed) { Message.warning(`笔记已沉淀到项目“${activeProjectName}”,但项目数据刷新失败`) } else { Message.success(`已沉淀到项目“${activeProjectName}”的笔记资料`) } } catch (error) { Message.error(error instanceof Error ? error.message : '沉淀到项目失败') } finally { setDepositingItemID(null) } } return (
探索 多个外部数据源的集中阅读页,采集文章并沉淀到项目。
{loadError ? : null} {sources.map((source) => ( setActiveSourceID(source.id)} > {source.icon} {source.name} {sourceSubtitle(source)} {source.id !== 'all' ? ( event.stopPropagation()}>
{filteredItems.map((item) => { const itemSource = sourceByID(sources, item.sourceId) return ( ) })} {items.length < itemTotal ? ( ) : null}
{selected.title}
{sourceInitial(selectedSource.name)} {selectedSource.name} {itemTime(selected)} {selected.imageUrl ? ( ) : null} {selected.content || selected.summary || '暂无正文'}
) : ( )} setSourceModalMode(null)} onOk={() => void saveSource()} > {sourceError ? : null}