Files
agent/apps/web_v1/src/pages/workspace-explore.tsx

549 lines
20 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 { 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<DatasetSourceDTO[]>
onListItems: (sourceId?: string, offset?: number, limit?: number, signal?: AbortSignal) => Promise<DatasetItemPageDTO>
onCreateSource: (input: DatasetSourceInput) => Promise<DatasetSourceDTO>
onUpdateSource: (sourceId: string, input: DatasetSourceInput) => Promise<DatasetSourceDTO>
onDeleteSource: (sourceId: string) => Promise<void>
onUpdateItem: (itemId: string, input: DatasetItemUpdate) => Promise<DatasetItemDTO>
onSyncSources: () => Promise<DatasetSyncResultDTO[]>
onDepositItem: (itemId: string, projectId: string) => Promise<DatasetDepositDTO & { refreshFailed?: boolean }>
}) {
const [sourceRecords, setSourceRecords] = useState<DatasetSourceDTO[]>([])
const [items, setItems] = useState<DatasetItemDTO[]>([])
const [itemTotal, setItemTotal] = useState(0)
const [activeSourceID, setActiveSourceID] = useState('all')
const [activeItemID, setActiveItemID] = useState<string | null>(null)
const [sourceModalMode, setSourceModalMode] = useState<'add' | 'edit' | null>(null)
const [editingSourceID, setEditingSourceID] = useState<string | null>(null)
const [sourceDraft, setSourceDraft] = useState<SourceDraft>(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<string | null>(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<DataSourceCard[]>(() => {
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: <IconStorage />,
color: 'blue',
}
return [
allSource,
...sourceRecords.map((source) => ({
...source,
icon: source.iconUrl
? <img className="explore-source-image" src={source.iconUrl} alt="" />
: 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 (
<div className="workspace-explore-page overview-page">
<div className="overview-head">
<div>
<Title heading={4}></Title>
<Text type="secondary"></Text>
</div>
<Space>
<Button icon={<IconRefresh />} loading={syncing} onClick={() => void syncSources()}></Button>
<Button type="primary" icon={<IconLink />} onClick={openAddSource}></Button>
</Space>
</div>
{loadError ? <Alert type="error" content={loadError} /> : null}
<Spin loading={loading || (itemsLoading && items.length === 0)} style={{ width: '100%' }}>
<Row gutter={10} className="explore-source-row">
{sources.map((source) => (
<Col span={6} key={source.id}>
<Card
className={activeSourceID === source.id ? 'compact-card explore-source-card active' : 'compact-card explore-source-card'}
bordered
onClick={() => setActiveSourceID(source.id)}
>
<span className={`explore-source-icon ${source.color}`}>{source.icon}</span>
<span className="explore-source-copy">
<Text className="explore-source-name">{source.name}</Text>
<Text type="secondary">{sourceSubtitle(source)}</Text>
</span>
{source.id !== 'all' ? (
<span className="explore-source-actions" onClick={(event) => event.stopPropagation()}>
<Button aria-label={`编辑${source.name}`} type="text" size="mini" icon={<IconEdit />} onClick={() => openEditSource(source)} />
{!source.builtIn ? (
<Button aria-label={`删除${source.name}`} type="text" size="mini" status="danger" icon={<IconDelete />} onClick={() => deleteSource(source)} />
) : null}
</span>
) : null}
</Card>
</Col>
))}
</Row>
{selected ? (
<section className="explore-reader-layout">
<Card className="explore-article-list queue-section" bordered>
<div className="explore-list-header">
<Title heading={5}>
<Space size={6}>
<span className={`explore-source-icon mini ${activeSource.color}`}>{activeSource.icon}</span>
<span>{activeSource.name}</span>
</Space>
</Title>
<Button type="text" icon={<IconRefresh />} loading={syncing} onClick={() => void syncSources()} />
</div>
<div className="explore-list-body">
{filteredItems.map((item) => {
const itemSource = sourceByID(sources, item.sourceId)
return (
<button
key={item.id}
className={item.id === selected.id ? 'explore-article-item active' : 'explore-article-item'}
onClick={() => setActiveItemID(item.id)}
>
<span className={`explore-source-logo ${itemSource.color}`}>{sourceInitial(itemSource.name)}</span>
<span className="explore-article-copy">
<Text type="secondary">{itemSource.name} · {itemTime(item)}</Text>
<Text className="explore-article-title">{item.title}</Text>
<Text className="explore-article-summary" type="secondary" ellipsis={{ showTooltip: true }}>
{item.summary || '暂无摘要'}
</Text>
</span>
</button>
)
})}
{items.length < itemTotal ? (
<Button long type="text" loading={itemsLoading} onClick={() => void loadMoreItems()}>
{items.length}/{itemTotal}
</Button>
) : null}
</div>
</Card>
<Card className="explore-article-detail queue-section" bordered>
<div className="explore-detail-toolbar">
<Title heading={5}>{selected.title}</Title>
<Space>
<Button
aria-label={selected.status === 'read' ? '标记未读' : '标记已读'}
type={selected.status === 'read' ? 'primary' : 'text'}
icon={<IconCheckCircle />}
onClick={() => void updateSelected({ status: selected.status === 'read' ? 'unread' : 'read', starred: selected.starred })}
/>
<Button
aria-label={selected.starred ? '取消收藏' : '收藏'}
type={selected.starred ? 'primary' : 'text'}
icon={<IconStar />}
onClick={() => void updateSelected({ status: selected.status, starred: !selected.starred })}
/>
<Button
aria-label={`沉淀到项目${activeProjectName}`}
type="text"
icon={<IconBook />}
loading={depositingItemID === selected.id}
onClick={() => void depositSelected()}
/>
{selected.url ? <Button type="text" icon={<IconLaunch />} href={selected.url} target="_blank" /> : null}
</Space>
</div>
<article className="explore-article-body">
<Space className="explore-article-meta" wrap>
<span className={`explore-source-logo small ${selectedSource.color}`}>{sourceInitial(selectedSource.name)}</span>
<Text type="secondary">{selectedSource.name}</Text>
<Text type="secondary">{itemTime(selected)}</Text>
</Space>
{selected.imageUrl ? (
<img className="explore-article-image" src={selected.imageUrl} alt="" />
) : null}
<Paragraph className="explore-article-content">{selected.content || selected.summary || '暂无正文'}</Paragraph>
</article>
</Card>
</section>
) : (
<Card className="queue-section" bordered>
<Empty description={sourceRecords.length === 0 ? '暂无数据源,请先添加数据源' : '暂无数据源文章'} />
</Card>
)}
</Spin>
<Modal
className="explore-source-modal"
title={sourceModalMode === 'edit' ? '编辑数据源' : '添加数据源'}
visible={sourceModalMode !== null}
okText="保存"
cancelText="取消"
confirmLoading={saving}
onCancel={() => setSourceModalMode(null)}
onOk={() => void saveSource()}
>
<Space direction="vertical" size={12} className="action-form">
{sourceError ? <Alert type="error" content={sourceError} /> : null}
<label>
<Text></Text>
<Input value={sourceDraft.name} placeholder="例如:行业资讯" onChange={(name) => setSourceDraft((current) => ({ ...current, name }))} />
</label>
<label>
<Text></Text>
<Input value={sourceDraft.kind === 'rss' ? 'RSS 订阅' : '历史数据源(仅维护)'} disabled />
</label>
<label>
<Text></Text>
<Input
value={sourceDraft.url}
disabled={Boolean(editingSource?.builtIn || (editingSource && editingSource.kind !== 'rss'))}
placeholder="https://example.com/feed"
onChange={(url) => setSourceDraft((current) => ({ ...current, url }))}
/>
</label>
<label>
<Text></Text>
<Input
value={sourceDraft.iconUrl}
disabled={Boolean(editingSource?.builtIn || (editingSource && editingSource.kind !== 'rss'))}
placeholder="https://example.com/icon.png"
onChange={(iconUrl) => setSourceDraft((current) => ({ ...current, iconUrl }))}
/>
</label>
<label>
<Text></Text>
<TextArea rows={4} value={sourceDraft.description} placeholder="数据源用途或内容范围" onChange={(description) => setSourceDraft((current) => ({ ...current, description }))} />
</label>
<label>
<Space>
<Switch checked={sourceDraft.enabled} onChange={(enabled) => setSourceDraft((current) => ({ ...current, enabled }))} />
<Text></Text>
</Space>
</label>
</Space>
</Modal>
</div>
)
}
function sourceByID(sources: DataSourceCard[], id: string): DataSourceCard {
return sources.find((source) => source.id === id) ?? sources[0]
}
function sourceIcon(kind: DatasetSourceKind) {
if (kind === 'rss') return <IconStorage />
if (kind === 'manual') return <IconCompass />
return <IconLink />
}
function sourceColor(kind: DatasetSourceKind) {
if (kind === 'rss') return 'orange'
if (kind === 'manual') return 'green'
return 'blue'
}
function sourceInitial(name: string) {
return Array.from(name.trim())[0] || '源'
}
function itemTime(item: DatasetItemDTO) {
const value = item.publishedAt ?? item.createdAt
const parsed = new Date(value)
if (Number.isNaN(parsed.getTime())) return ''
return new Intl.DateTimeFormat('zh-CN', { dateStyle: 'medium' }).format(parsed)
}
function sourceSubtitle(source: DatasetSourceDTO) {
if (!source.lastSyncedAt) return `${source.itemCount}`
const parsed = new Date(source.lastSyncedAt)
if (Number.isNaN(parsed.getTime())) return `${source.itemCount}`
return `${source.itemCount} 条 · ${new Intl.DateTimeFormat('zh-CN', { dateStyle: 'short', timeStyle: 'short' }).format(parsed)}`
}