diff --git a/apps/web_v1/scripts/visual-check.mjs b/apps/web_v1/scripts/visual-check.mjs index b9cb3da..8b92a40 100644 --- a/apps/web_v1/scripts/visual-check.mjs +++ b/apps/web_v1/scripts/visual-check.mjs @@ -35,17 +35,17 @@ const visualExperts = [ let visualDatasetSources = [ { id: '019b0000-0000-7000-8000-000000000030', name: '手动收集', kind: 'manual', url: '', iconUrl: '', - description: '手动收集的文章与线索', enabled: true, lastSyncedAt: null, itemCount: 1, + description: '手动收集的文章与线索', enabled: true, builtIn: false, lastSyncedAt: null, itemCount: 1, createdAt: '2026-07-22T06:00:00Z', updatedAt: '2026-07-22T06:00:00Z', }, { id: '019b0000-0000-7000-8000-000000000031', name: '需求文档', kind: 'link', url: 'https://example.com/requirements', iconUrl: '', - description: '产品需求与业务文档', enabled: true, lastSyncedAt: null, itemCount: 1, + description: '产品需求与业务文档', enabled: true, builtIn: false, lastSyncedAt: null, itemCount: 1, createdAt: '2026-07-22T06:00:00Z', updatedAt: '2026-07-22T06:00:00Z', }, { id: '019b0000-0000-7000-8000-000000000032', name: '架构讨论', kind: 'link', url: 'https://example.com/architecture', iconUrl: '', - description: '架构方案与系统设计讨论', enabled: true, lastSyncedAt: null, itemCount: 0, + description: '架构方案与系统设计讨论', enabled: true, builtIn: false, lastSyncedAt: null, itemCount: 0, createdAt: '2026-07-22T06:00:00Z', updatedAt: '2026-07-22T06:00:00Z', }, ] @@ -225,6 +225,7 @@ await page.route('http://localhost:9150/api/v1/**', async (route) => { const created = { id: `019b0000-0000-7000-8000-${String(50 + visualDatasetSources.length).padStart(12, '0')}`, ...input, + builtIn: false, lastSyncedAt: null, itemCount: 0, createdAt: new Date().toISOString(), @@ -251,7 +252,24 @@ await page.route('http://localhost:9150/api/v1/**', async (route) => { return } if (url.pathname === '/api/v1/dataset-items' && route.request().method() === 'GET') { - await route.fulfill({ json: visualDatasetItems }) + const sourceId = url.searchParams.get('sourceId') + const offset = Number(url.searchParams.get('offset') ?? 0) + const limit = Number(url.searchParams.get('limit') ?? 50) + const matchingItems = sourceId + ? visualDatasetItems.filter((item) => item.sourceId === sourceId) + : visualDatasetItems + await route.fulfill({ + json: { + items: matchingItems.slice(offset, offset + limit), + total: matchingItems.length, + offset, + limit, + }, + }) + return + } + if (url.pathname.endsWith('/deposit') && route.request().method() === 'POST') { + await route.fulfill({ status: 201, json: { noteId: '019b0000-0000-7000-8000-000000000099', projectId } }) return } if (url.pathname.startsWith('/api/v1/dataset-items/') && route.request().method() === 'PATCH') { @@ -281,6 +299,10 @@ await page.route('http://localhost:9150/api/v1/**', async (route) => { }) return } + if (url.pathname === '/api/v1/dataset-crons' && route.request().method() === 'GET') { + await route.fulfill({ json: [] }) + return + } await route.fulfill({ status: 404, json: { error: { code: 'not_found', message: '视觉检查未配置该接口' } } }) }) diff --git a/apps/web_v1/src/api/dataset.ts b/apps/web_v1/src/api/dataset.ts index 3326dc6..fd80e8c 100644 --- a/apps/web_v1/src/api/dataset.ts +++ b/apps/web_v1/src/api/dataset.ts @@ -10,6 +10,7 @@ export type DatasetSourceDTO = { iconUrl: string description: string enabled: boolean + builtIn: boolean lastSyncedAt: string | null itemCount: number createdAt: string @@ -33,16 +34,25 @@ export type DatasetItemDTO = { export type DatasetCronDTO = { id: string sourceId: string - schedule: string status: string - enabled: boolean - nextRunAt: string | null lastRunAt: string | null lastResult: string createdAt: string updatedAt: string } +export type DatasetItemPageDTO = { + items: DatasetItemDTO[] + total: number + limit: number + offset: number +} + +export type DatasetDepositDTO = { + noteId: string + projectId: string +} + export type DatasetSourceInput = { name: string kind: DatasetSourceKind @@ -53,8 +63,8 @@ export type DatasetSourceInput = { } export type DatasetItemUpdate = { - status: DatasetItemDTO['status'] - starred: boolean + status?: DatasetItemDTO['status'] + starred?: boolean } export function listDatasetSources(session: ApiSession, signal?: AbortSignal) { @@ -85,9 +95,16 @@ export function deleteDatasetSource(session: ApiSession, sourceId: string) { }) } -export function listDatasetItems(session: ApiSession, sourceId?: string, signal?: AbortSignal) { - const query = sourceId ? `?sourceId=${encodeURIComponent(sourceId)}` : '' - return apiRequest(`/api/v1/dataset-items${query}`, { token: session.token, signal }) +export function listDatasetItems( + session: ApiSession, + sourceId?: string, + offset = 0, + limit = 50, + signal?: AbortSignal, +) { + const params = new URLSearchParams({ offset: String(offset), limit: String(limit) }) + if (sourceId) params.set('sourceId', sourceId) + return apiRequest(`/api/v1/dataset-items?${params}`, { token: session.token, signal }) } export function updateDatasetItem(session: ApiSession, itemId: string, input: DatasetItemUpdate) { @@ -105,3 +122,15 @@ export function queueDatasetSync(session: ApiSession) { body: {}, }) } + +export function listDatasetCrons(session: ApiSession, signal?: AbortSignal) { + return apiRequest('/api/v1/dataset-crons', { token: session.token, signal }) +} + +export function depositDatasetItem(session: ApiSession, itemId: string, projectId: string) { + return apiRequest(`/api/v1/dataset-items/${itemId}/deposit`, { + method: 'POST', + token: session.token, + body: { projectId }, + }) +} diff --git a/apps/web_v1/src/app/App.tsx b/apps/web_v1/src/app/App.tsx index df8af10..dcdaa91 100644 --- a/apps/web_v1/src/app/App.tsx +++ b/apps/web_v1/src/app/App.tsx @@ -7,6 +7,8 @@ import { createAISession, listAIExperts, listAISessions, type CreateAISessionInp import { createDatasetSource, deleteDatasetSource, + depositDatasetItem, + listDatasetCrons, listDatasetItems, listDatasetSources, queueDatasetSync, @@ -75,9 +77,9 @@ function App() { return listDatasetSources(session, signal) }, [session]) - const handleListDatasetItems = useCallback((signal?: AbortSignal) => { + const handleListDatasetItems = useCallback((sourceId?: string, offset = 0, limit = 50, signal?: AbortSignal) => { if (!session) return Promise.reject(new Error('未登录')) - return listDatasetItems(session, undefined, signal) + return listDatasetItems(session, sourceId, offset, limit, signal) }, [session]) const handleCreateDatasetSource = useCallback((input: DatasetSourceInput) => { @@ -105,6 +107,27 @@ function App() { return queueDatasetSync(session) }, [session]) + const handleListDatasetCrons = useCallback((signal?: AbortSignal) => { + if (!session) return Promise.reject(new Error('未登录')) + return listDatasetCrons(session, signal) + }, [session]) + + const handleDepositDatasetItem = useCallback(async (itemId: string, projectId: string) => { + if (!session) throw new Error('未登录') + const result = await depositDatasetItem(session, itemId, projectId) + const workspaceIndex = workspaces.findIndex((workspace) => workspace.project.id === projectId) + if (workspaceIndex >= 0) { + try { + const payload = await fetchProjectWorkspace(session, projectId) + const refreshed = mapWorkspace(payload, workspaceIndex) + setWorkspaces((current) => current.map((workspace) => workspace.project.id === projectId ? refreshed : workspace)) + } catch { + return { ...result, refreshFailed: true } + } + } + return result + }, [session, workspaces]) + const dark = theme === 'dark' const activeWorkspace = workspaces.find((workspace) => workspace.project.id === activeProjectID) ?? workspaces[0] const activeTagOptions = activeWorkspace?.tags.filter((tag) => tag !== 'all' && tag !== '全部') ?? [] @@ -418,6 +441,8 @@ function App() { onDeleteDatasetSource={handleDeleteDatasetSource} onUpdateDatasetItem={handleUpdateDatasetItem} onQueueDatasetSync={handleQueueDatasetSync} + onListDatasetCrons={handleListDatasetCrons} + onDepositDatasetItem={handleDepositDatasetItem} /> ) : ( diff --git a/apps/web_v1/src/pages/workspace-explore.tsx b/apps/web_v1/src/pages/workspace-explore.tsx index 468a1ca..8138d25 100644 --- a/apps/web_v1/src/pages/workspace-explore.tsx +++ b/apps/web_v1/src/pages/workspace-explore.tsx @@ -1,6 +1,6 @@ import { useEffect, useMemo, useState } from 'react' import type { ReactNode } from 'react' -import { Alert, Button, Card, Empty, Grid, Input, Message, Modal, Select, Space, Spin, Switch, Typography } from '@arco-design/web-react' +import { Alert, Button, Card, Empty, Grid, Input, Message, Modal, Space, Spin, Switch, Typography } from '@arco-design/web-react' import { IconBook, IconCheckCircle, @@ -15,7 +15,9 @@ import { } from '@arco-design/web-react/icon' import type { DatasetCronDTO, + DatasetDepositDTO, DatasetItemDTO, + DatasetItemPageDTO, DatasetItemUpdate, DatasetSourceDTO, DatasetSourceInput, @@ -25,6 +27,7 @@ import type { const { Row, Col } = Grid const { Title, Text, Paragraph } = Typography const { TextArea } = Input +const ITEM_PAGE_SIZE = 50 type DataSourceCard = DatasetSourceDTO & { icon: ReactNode @@ -42,7 +45,7 @@ type SourceDraft = { const EMPTY_SOURCE_DRAFT: SourceDraft = { name: '', - kind: 'link', + kind: 'rss', url: '', iconUrl: '', description: '', @@ -50,7 +53,8 @@ const EMPTY_SOURCE_DRAFT: SourceDraft = { } export function WorkspaceExplorePage({ - onSelectItem, + activeProjectID, + activeProjectName, onListSources, onListItems, onCreateSource, @@ -58,18 +62,24 @@ export function WorkspaceExplorePage({ onDeleteSource, onUpdateItem, onQueueSync, + onListCrons, + onDepositItem, }: { - onSelectItem: (title: string) => void + activeProjectID: string + activeProjectName: string onListSources: (signal?: AbortSignal) => Promise - onListItems: (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 onQueueSync: () => Promise + onListCrons: (signal?: AbortSignal) => 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) @@ -78,17 +88,18 @@ export function WorkspaceExplorePage({ 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('') - Promise.all([onListSources(controller.signal), onListItems(controller.signal)]) - .then(([nextSources, nextItems]) => { + onListSources(controller.signal) + .then((nextSources) => { setSourceRecords(nextSources) - setItems(nextItems) }) .catch((error: unknown) => { if (!controller.signal.aborted) { @@ -99,7 +110,31 @@ export function WorkspaceExplorePage({ if (!controller.signal.aborted) setLoading(false) }) return () => controller.abort() - }, [onListItems, onListSources]) + }, [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 = { @@ -110,8 +145,9 @@ export function WorkspaceExplorePage({ iconUrl: '', description: '全部探索内容', enabled: true, + builtIn: true, lastSyncedAt: null, - itemCount: items.length, + itemCount: sourceRecords.reduce((total, source) => total + source.itemCount, 0), createdAt: '', updatedAt: '', icon: , @@ -127,12 +163,9 @@ export function WorkspaceExplorePage({ color: sourceColor(source.kind), })), ] - }, [items.length, sourceRecords]) + }, [sourceRecords]) - const filteredItems = useMemo( - () => activeSourceID === 'all' ? items : items.filter((item) => item.sourceId === activeSourceID), - [activeSourceID, items], - ) + const filteredItems = items useEffect(() => { if (filteredItems.length === 0) { @@ -147,6 +180,9 @@ export function WorkspaceExplorePage({ 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) @@ -174,7 +210,7 @@ export function WorkspaceExplorePage({ name: sourceDraft.name.trim(), kind: sourceDraft.kind, url: sourceDraft.url.trim(), - iconUrl: sourceDraft.iconUrl, + iconUrl: sourceDraft.iconUrl.trim(), description: sourceDraft.description.trim(), enabled: sourceDraft.enabled, } @@ -182,8 +218,8 @@ export function WorkspaceExplorePage({ setSourceError('请输入数据源名称') return } - if (input.kind !== 'manual' && !input.url) { - setSourceError('链接和 RSS 数据源必须填写地址') + if (input.kind === 'rss' && !input.url) { + setSourceError('RSS 数据源必须填写地址') return } setSaving(true) @@ -229,14 +265,21 @@ export function WorkspaceExplorePage({ async function syncSources() { setSyncing(true) try { - const crons = await onQueueSync() - if (crons.length === 0) { + const queued = await onQueueSync() + if (queued.length === 0) { Message.info('暂无已启用的 RSS 数据源') } else { - const failed = crons.filter((cron) => cron.status === 'failed').length - const [nextSources, nextItems] = await Promise.all([onListSources(), onListItems()]) + Message.info(`已提交 ${queued.length} 个采集任务`) + const crons = await waitForDatasetRuns(queued, onListCrons) + const sourceID = activeSourceID === 'all' ? undefined : activeSourceID + const [nextSources, nextPage] = await Promise.all([ + onListSources(), + onListItems(sourceID, 0, ITEM_PAGE_SIZE), + ]) setSourceRecords(nextSources) - setItems(nextItems) + setItems(nextPage.items) + setItemTotal(nextPage.total) + const failed = crons.filter((cron) => cron.status === 'failed').length if (failed > 0) { Message.warning(`${crons.length - failed} 个数据源采集完成,${failed} 个失败`) } else { @@ -250,6 +293,21 @@ export function WorkspaceExplorePage({ } } + 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 { @@ -260,6 +318,23 @@ export function WorkspaceExplorePage({ } } + 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 (
@@ -275,7 +350,7 @@ export function WorkspaceExplorePage({ {loadError ? : null} - + {sources.map((source) => ( @@ -287,12 +362,14 @@ export function WorkspaceExplorePage({ {source.icon} {source.name} - {source.itemCount} 条 + {sourceSubtitle(source)} {source.id !== 'all' ? ( event.stopPropagation()}> ) })} + {items.length < itemTotal ? ( + + ) : null}
@@ -354,7 +433,13 @@ export function WorkspaceExplorePage({ icon={} onClick={() => void updateSelected({ status: selected.status, starred: !selected.starred })} /> -
@@ -393,18 +478,25 @@ export function WorkspaceExplorePage({ +