From 26dd3004a12fdba5cc63a98d683881f9cf90f435 Mon Sep 17 00:00:00 2001 From: yanweidong Date: Thu, 23 Jul 2026 15:07:35 +0800 Subject: [PATCH] feat: connect explore datasets to backend --- apps/web_v1/scripts/visual-check.mjs | 97 ++++ apps/web_v1/src/api/dataset.ts | 105 ++++ apps/web_v1/src/app/App.tsx | 53 ++ apps/web_v1/src/pages/workspace-explore.tsx | 480 +++++++++++------- apps/web_v1/src/pages/workspace-home.tsx | 32 +- backend/cmd/api/main.go | 3 + backend/internal/logic/dataset/handlers.go | 353 +++++++++++++ .../internal/logic/dataset/handlers_test.go | 139 +++++ backend/internal/logic/dataset/service.go | 278 ++++++++++ backend/internal/models/dataset_cron.go | 24 + backend/internal/models/dataset_item.go | 25 + backend/internal/models/dataset_source.go | 22 + backend/internal/models/identity.go | 27 + backend/internal/models/new.go | 3 + 14 files changed, 1445 insertions(+), 196 deletions(-) create mode 100644 apps/web_v1/src/api/dataset.ts create mode 100644 backend/internal/logic/dataset/handlers.go create mode 100644 backend/internal/logic/dataset/handlers_test.go create mode 100644 backend/internal/logic/dataset/service.go create mode 100644 backend/internal/models/dataset_cron.go create mode 100644 backend/internal/models/dataset_item.go create mode 100644 backend/internal/models/dataset_source.go diff --git a/apps/web_v1/scripts/visual-check.mjs b/apps/web_v1/scripts/visual-check.mjs index 9fa0160..b1c760c 100644 --- a/apps/web_v1/scripts/visual-check.mjs +++ b/apps/web_v1/scripts/visual-check.mjs @@ -32,6 +32,37 @@ const visualExperts = [ name: '前端开发者', description: '负责现代 Web 应用实现与性能优化。', emoji: '💻', color: '#0FC6C2', }, ] +let visualDatasetSources = [ + { + id: '019b0000-0000-7000-8000-000000000030', name: '手动收集', kind: 'manual', url: '', + description: '手动收集的文章与线索', enabled: true, 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', + description: '产品需求与业务文档', enabled: true, 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', + description: '架构方案与系统设计讨论', enabled: true, lastSyncedAt: null, itemCount: 0, + createdAt: '2026-07-22T06:00:00Z', updatedAt: '2026-07-22T06:00:00Z', + }, +] +let visualDatasetItems = [ + { + id: '019b0000-0000-7000-8000-000000000040', sourceId: visualDatasetSources[0].id, + title: '森林项目体验优化需求', summary: '整理工作台信息层级,并沉淀为可跟进计划。', content: '探索数据条目的正文内容。', + url: 'https://example.com/article-1', status: 'unread', starred: false, publishedAt: '2026-07-22T08:00:00Z', + createdAt: '2026-07-22T08:00:00Z', updatedAt: '2026-07-22T08:00:00Z', + }, + { + id: '019b0000-0000-7000-8000-000000000041', sourceId: visualDatasetSources[1].id, + title: '项目资料组织方案', summary: '围绕项目上下文统一组织任务、笔记、资料和 AI 会话。', content: '资料组织方案正文。', + url: 'https://example.com/article-2', status: 'read', starred: true, publishedAt: '2026-07-22T07:00:00Z', + createdAt: '2026-07-22T07:00:00Z', updatedAt: '2026-07-22T07:00:00Z', + }, +] page.on('console', (message) => { if (message.type() === 'error') errors.push(message.text()) }) @@ -185,6 +216,71 @@ await page.route('http://localhost:9150/api/v1/**', async (route) => { await route.fulfill({ json: visualExperts }) return } + if (url.pathname === '/api/v1/dataset-sources' && route.request().method() === 'GET') { + await route.fulfill({ json: visualDatasetSources }) + return + } + if (url.pathname === '/api/v1/dataset-sources' && route.request().method() === 'POST') { + const input = route.request().postDataJSON() + const created = { + id: `019b0000-0000-7000-8000-${String(50 + visualDatasetSources.length).padStart(12, '0')}`, + ...input, + lastSyncedAt: null, + itemCount: 0, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + } + visualDatasetSources = [...visualDatasetSources, created] + await route.fulfill({ status: 201, json: created }) + return + } + if (url.pathname.startsWith('/api/v1/dataset-sources/') && route.request().method() === 'PATCH') { + const id = url.pathname.split('/').at(-1) + const input = route.request().postDataJSON() + const current = visualDatasetSources.find((source) => source.id === id) + const updated = { ...current, ...input, updatedAt: new Date().toISOString() } + visualDatasetSources = visualDatasetSources.map((source) => source.id === id ? updated : source) + await route.fulfill({ json: updated }) + return + } + if (url.pathname.startsWith('/api/v1/dataset-sources/') && route.request().method() === 'DELETE') { + const id = url.pathname.split('/').at(-1) + visualDatasetSources = visualDatasetSources.filter((source) => source.id !== id) + visualDatasetItems = visualDatasetItems.filter((item) => item.sourceId !== id) + await route.fulfill({ status: 204, body: '' }) + return + } + if (url.pathname === '/api/v1/dataset-items' && route.request().method() === 'GET') { + await route.fulfill({ json: visualDatasetItems }) + return + } + if (url.pathname.startsWith('/api/v1/dataset-items/') && route.request().method() === 'PATCH') { + const id = url.pathname.split('/').at(-1) + const input = route.request().postDataJSON() + const current = visualDatasetItems.find((item) => item.id === id) + const updated = { ...current, ...input, updatedAt: new Date().toISOString() } + visualDatasetItems = visualDatasetItems.map((item) => item.id === id ? updated : item) + await route.fulfill({ json: updated }) + return + } + if (url.pathname === '/api/v1/dataset-crons/sync') { + await route.fulfill({ + status: 202, + json: visualDatasetSources.map((source, index) => ({ + id: `019b0000-0000-7000-8000-${String(70 + index).padStart(12, '0')}`, + sourceId: source.id, + schedule: '@once', + status: 'pending', + enabled: true, + nextRunAt: new Date().toISOString(), + lastRunAt: null, + lastResult: '', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + })), + }) + return + } await route.fulfill({ status: 404, json: { error: { code: 'not_found', message: '视觉检查未配置该接口' } } }) }) @@ -304,6 +400,7 @@ const addSourceModalCheck = await page.evaluate(() => ({ inputCount: document.querySelectorAll('.explore-source-modal input, .explore-source-modal textarea').length, })) await page.locator('.explore-source-modal input').first().fill('行业资讯') +await page.locator('.explore-source-modal input[placeholder="https://example.com/feed"]').fill('https://example.com/feed') await page.locator('.explore-source-modal .arco-modal-footer .arco-btn-primary').click() const addedSourceVisible = await page.locator('.explore-source-card', { hasText: '行业资讯' }).count() === 1 diff --git a/apps/web_v1/src/api/dataset.ts b/apps/web_v1/src/api/dataset.ts new file mode 100644 index 0000000..084f5e5 --- /dev/null +++ b/apps/web_v1/src/api/dataset.ts @@ -0,0 +1,105 @@ +import { apiRequest, type ApiSession } from './client' + +export type DatasetSourceKind = 'manual' | 'link' | 'rss' + +export type DatasetSourceDTO = { + id: string + name: string + kind: DatasetSourceKind + url: string + description: string + enabled: boolean + lastSyncedAt: string | null + itemCount: number + createdAt: string + updatedAt: string +} + +export type DatasetItemDTO = { + id: string + sourceId: string + title: string + summary: string + content: string + url: string + status: 'unread' | 'read' | 'archived' + starred: boolean + publishedAt: string | null + createdAt: string + updatedAt: string +} + +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 DatasetSourceInput = { + name: string + kind: DatasetSourceKind + url: string + description: string + enabled: boolean +} + +export type DatasetItemUpdate = { + status: DatasetItemDTO['status'] + starred: boolean +} + +export function listDatasetSources(session: ApiSession, signal?: AbortSignal) { + return apiRequest('/api/v1/dataset-sources', { token: session.token, signal }) +} + +export function createDatasetSource(session: ApiSession, input: DatasetSourceInput) { + return apiRequest('/api/v1/dataset-sources', { + method: 'POST', + token: session.token, + body: input, + }) +} + +export function updateDatasetSource(session: ApiSession, sourceId: string, input: DatasetSourceInput) { + return apiRequest(`/api/v1/dataset-sources/${sourceId}`, { + method: 'PATCH', + token: session.token, + body: input, + }) +} + +export function deleteDatasetSource(session: ApiSession, sourceId: string) { + return apiRequest(`/api/v1/dataset-sources/${sourceId}`, { + method: 'DELETE', + token: session.token, + responseType: 'void', + }) +} + +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 updateDatasetItem(session: ApiSession, itemId: string, input: DatasetItemUpdate) { + return apiRequest(`/api/v1/dataset-items/${itemId}`, { + method: 'PATCH', + token: session.token, + body: input, + }) +} + +export function queueDatasetSync(session: ApiSession) { + return apiRequest('/api/v1/dataset-crons/sync', { + method: 'POST', + token: session.token, + body: {}, + }) +} diff --git a/apps/web_v1/src/app/App.tsx b/apps/web_v1/src/app/App.tsx index 3ebd885..df8af10 100644 --- a/apps/web_v1/src/app/App.tsx +++ b/apps/web_v1/src/app/App.tsx @@ -4,6 +4,17 @@ import '@arco-design/web-react/dist/css/arco.css' import '../App.css' import { ApiError, login, setApiBaseUrl, type ApiSession } from '../api/client' import { createAISession, listAIExperts, listAISessions, type CreateAISessionInput } from '../api/ai' +import { + createDatasetSource, + deleteDatasetSource, + listDatasetItems, + listDatasetSources, + queueDatasetSync, + updateDatasetItem, + updateDatasetSource, + type DatasetItemUpdate, + type DatasetSourceInput, +} from '../api/dataset' import { analyzeInboxItem, confirmInboxItem } from '../api/inbox' import { mapWorkspace } from '../api/mappers' import { @@ -59,6 +70,41 @@ function App() { return createAISession(session, projectId, input, signal) }, [session]) + const handleListDatasetSources = useCallback((signal?: AbortSignal) => { + if (!session) return Promise.reject(new Error('未登录')) + return listDatasetSources(session, signal) + }, [session]) + + const handleListDatasetItems = useCallback((signal?: AbortSignal) => { + if (!session) return Promise.reject(new Error('未登录')) + return listDatasetItems(session, undefined, signal) + }, [session]) + + const handleCreateDatasetSource = useCallback((input: DatasetSourceInput) => { + if (!session) return Promise.reject(new Error('未登录')) + return createDatasetSource(session, input) + }, [session]) + + const handleUpdateDatasetSource = useCallback((sourceId: string, input: DatasetSourceInput) => { + if (!session) return Promise.reject(new Error('未登录')) + return updateDatasetSource(session, sourceId, input) + }, [session]) + + const handleDeleteDatasetSource = useCallback((sourceId: string) => { + if (!session) return Promise.reject(new Error('未登录')) + return deleteDatasetSource(session, sourceId) + }, [session]) + + const handleUpdateDatasetItem = useCallback((itemId: string, input: DatasetItemUpdate) => { + if (!session) return Promise.reject(new Error('未登录')) + return updateDatasetItem(session, itemId, input) + }, [session]) + + const handleQueueDatasetSync = useCallback(() => { + if (!session) return Promise.reject(new Error('未登录')) + return queueDatasetSync(session) + }, [session]) + const dark = theme === 'dark' const activeWorkspace = workspaces.find((workspace) => workspace.project.id === activeProjectID) ?? workspaces[0] const activeTagOptions = activeWorkspace?.tags.filter((tag) => tag !== 'all' && tag !== '全部') ?? [] @@ -365,6 +411,13 @@ function App() { onListAISessions={handleListAISessions} onListAIExperts={handleListAIExperts} onCreateAISession={handleCreateAISession} + onListDatasetSources={handleListDatasetSources} + onListDatasetItems={handleListDatasetItems} + onCreateDatasetSource={handleCreateDatasetSource} + onUpdateDatasetSource={handleUpdateDatasetSource} + onDeleteDatasetSource={handleDeleteDatasetSource} + onUpdateDatasetItem={handleUpdateDatasetItem} + onQueueDatasetSync={handleQueueDatasetSync} /> ) : ( diff --git a/apps/web_v1/src/pages/workspace-explore.tsx b/apps/web_v1/src/pages/workspace-explore.tsx index eceaaf1..b4aded4 100644 --- a/apps/web_v1/src/pages/workspace-explore.tsx +++ b/apps/web_v1/src/pages/workspace-explore.tsx @@ -1,112 +1,147 @@ import { useEffect, useMemo, useState } from 'react' import type { ReactNode } from 'react' -import { Alert, Button, Card, Empty, Grid, Input, Modal, Select, Space, Typography } from '@arco-design/web-react' +import { Alert, Button, Card, Empty, Grid, Input, Message, Modal, Select, Space, Spin, Switch, Typography } from '@arco-design/web-react' import { IconBook, IconCheckCircle, IconCompass, IconDelete, IconEdit, - IconFile, + IconLaunch, IconLink, IconRefresh, IconStar, IconStorage, } from '@arco-design/web-react/icon' -import type { InboxItem, Project, ProjectWorkspace } from './projects/project-types' +import type { + DatasetCronDTO, + DatasetItemDTO, + DatasetItemUpdate, + DatasetSourceDTO, + DatasetSourceInput, + DatasetSourceKind, +} from '../api/dataset' const { Row, Col } = Grid const { Title, Text, Paragraph } = Typography const { TextArea } = Input -type BuiltinDataSourceID = 'all' | 'manual' | 'requirements' | 'architecture' -type DataSourceID = BuiltinDataSourceID | `custom:${number}` -type DataSourceKind = 'manual' | 'link' | 'rss' - -type ExploreArticle = InboxItem & { - project: Project - sourceID: BuiltinDataSourceID -} - -type DataSourceConfig = { - id: DataSourceID - name: string +type DataSourceCard = DatasetSourceDTO & { icon: ReactNode color: string - kind: DataSourceKind - url: string - description: string -} - -type DataSourceCard = DataSourceConfig & { - count: number } type SourceDraft = { name: string - kind: DataSourceKind + kind: DatasetSourceKind url: string description: string + enabled: boolean } -const INITIAL_SOURCES: DataSourceConfig[] = [ - { id: 'all', name: '全部', icon: , color: 'blue', kind: 'manual', url: '', description: '全部探索内容' }, - { id: 'manual', name: '手动收集', icon: , color: 'green', kind: 'manual', url: '', description: '手动收集的文章与线索' }, - { id: 'requirements', name: '需求文档', icon: , color: 'orange', kind: 'link', url: '', description: '产品需求与业务文档' }, - { id: 'architecture', name: '架构讨论', icon: , color: 'purple', kind: 'link', url: '', description: '架构方案与系统设计讨论' }, -] - const EMPTY_SOURCE_DRAFT: SourceDraft = { name: '', kind: 'link', url: '', description: '', + enabled: true, } export function WorkspaceExplorePage({ - workspaces, onSelectItem, + onListSources, + onListItems, + onCreateSource, + onUpdateSource, + onDeleteSource, + onUpdateItem, + onQueueSync, }: { - workspaces: ProjectWorkspace[] onSelectItem: (title: string) => void + onListSources: (signal?: AbortSignal) => Promise + onListItems: (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 }) { - const articles = useMemo( - () => - workspaces.flatMap((workspace) => - workspace.inbox.map((item) => ({ - ...item, - project: workspace.project, - sourceID: detectSource(item), - })), - ), - [workspaces], - ) - const [sourceConfigs, setSourceConfigs] = useState(INITIAL_SOURCES) - const [activeSourceID, setActiveSourceID] = useState('all') + const [sourceRecords, setSourceRecords] = useState([]) + const [items, setItems] = useState([]) + const [activeSourceID, setActiveSourceID] = useState('all') + const [activeItemID, setActiveItemID] = useState(null) const [sourceModalMode, setSourceModalMode] = useState<'add' | 'edit' | null>(null) - const [editingSourceID, setEditingSourceID] = useState(null) + const [editingSourceID, setEditingSourceID] = useState(null) const [sourceDraft, setSourceDraft] = useState(EMPTY_SOURCE_DRAFT) const [sourceError, setSourceError] = useState('') - const filteredArticles = useMemo( - () => (activeSourceID === 'all' ? articles : articles.filter((article) => article.sourceID === activeSourceID)), - [activeSourceID, articles], - ) - const sources = useMemo(() => dataSources(articles, sourceConfigs), [articles, sourceConfigs]) - const [activeArticleID, setActiveArticleID] = useState(filteredArticles[0]?.id ?? null) + const [loadError, setLoadError] = useState('') + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + const [syncing, setSyncing] = useState(false) useEffect(() => { - if (filteredArticles.length === 0) { - setActiveArticleID(null) + const controller = new AbortController() + setLoading(true) + setLoadError('') + Promise.all([onListSources(controller.signal), onListItems(controller.signal)]) + .then(([nextSources, nextItems]) => { + setSourceRecords(nextSources) + setItems(nextItems) + }) + .catch((error: unknown) => { + if (!controller.signal.aborted) { + setLoadError(error instanceof Error ? error.message : '探索数据加载失败') + } + }) + .finally(() => { + if (!controller.signal.aborted) setLoading(false) + }) + return () => controller.abort() + }, [onListItems, onListSources]) + + const sources = useMemo(() => { + const allSource: DataSourceCard = { + id: 'all', + name: '全部', + kind: 'manual', + url: '', + description: '全部探索内容', + enabled: true, + lastSyncedAt: null, + itemCount: items.length, + createdAt: '', + updatedAt: '', + icon: , + color: 'blue', + } + return [ + allSource, + ...sourceRecords.map((source) => ({ + ...source, + icon: sourceIcon(source.kind), + color: sourceColor(source.kind), + })), + ] + }, [items.length, sourceRecords]) + + const filteredItems = useMemo( + () => activeSourceID === 'all' ? items : items.filter((item) => item.sourceId === activeSourceID), + [activeSourceID, items], + ) + + useEffect(() => { + if (filteredItems.length === 0) { + setActiveItemID(null) return } - if (!activeArticleID || !filteredArticles.some((article) => article.id === activeArticleID)) { - setActiveArticleID(filteredArticles[0].id) + if (!activeItemID || !filteredItems.some((item) => item.id === activeItemID)) { + setActiveItemID(filteredItems[0].id) } - }, [activeArticleID, filteredArticles]) + }, [activeItemID, filteredItems]) - const selected = filteredArticles.find((article) => article.id === activeArticleID) ?? filteredArticles[0] + const selected = filteredItems.find((item) => item.id === activeItemID) ?? filteredItems[0] const activeSource = sourceByID(sources, activeSourceID) - const selectedSource = selected ? sourceByID(sources, selected.sourceID) : activeSource + const selectedSource = selected ? sourceByID(sources, selected.sourceId) : activeSource function openAddSource() { setEditingSourceID(null) @@ -122,46 +157,94 @@ export function WorkspaceExplorePage({ kind: source.kind, url: source.url, description: source.description, + enabled: source.enabled, }) setSourceError('') setSourceModalMode('edit') } - function saveSource() { - const name = sourceDraft.name.trim() - if (!name) { + async function saveSource() { + const input: DatasetSourceInput = { + name: sourceDraft.name.trim(), + kind: sourceDraft.kind, + url: sourceDraft.url.trim(), + description: sourceDraft.description.trim(), + enabled: sourceDraft.enabled, + } + if (!input.name) { setSourceError('请输入数据源名称') return } - const nextDraft = { ...sourceDraft, name, url: sourceDraft.url.trim(), description: sourceDraft.description.trim() } - if (sourceModalMode === 'edit' && editingSourceID) { - setSourceConfigs((current) => current.map((source) => source.id === editingSourceID ? { ...source, ...nextDraft } : source)) - } else { - setSourceConfigs((current) => [ - ...current, - { - id: `custom:${Date.now()}`, - ...nextDraft, - icon: sourceIcon(nextDraft.kind), - color: sourceColor(nextDraft.kind), - }, - ]) + if (input.kind !== 'manual' && !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) } - setSourceModalMode(null) } function deleteSource(source: DataSourceCard) { Modal.confirm({ title: `删除“${source.name}”数据源?`, - content: '删除后,该数据源将从探索页移除。', + content: '删除后,该数据源及其采集条目和待执行任务都会移除。', okButtonProps: { status: 'danger' }, - onOk: () => { - setSourceConfigs((current) => current.filter((item) => item.id !== source.id)) - if (activeSourceID === source.id) setActiveSourceID('all') + 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 crons = await onQueueSync() + if (crons.length === 0) { + Message.info('暂无已启用的数据源') + } else { + Message.success(`已提交 ${crons.length} 个采集任务`) + } + } catch (error) { + Message.error(error instanceof Error ? error.message : '同步任务提交失败') + } finally { + setSyncing(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 : '数据条目更新失败') + } + } + return (
@@ -170,109 +253,112 @@ export function WorkspaceExplorePage({ 多个外部数据源的集中阅读页,采集文章并沉淀到项目。
- - + +
- - {sources.map((source) => ( - - setActiveSourceID(source.id)} - > - {source.icon} - - {source.name} - {source.count} 条 - - {source.id !== 'all' ? ( - event.stopPropagation()}> - + ) + })} + - - ))} - - {selected ? ( -
- -
- - <Space size={6}> - <span className={`explore-source-icon mini ${activeSource.color}`}>{activeSource.icon}</span> - <span>{activeSource.name}</span> + <Card className="explore-article-detail queue-section" bordered> + <div className="explore-detail-toolbar"> + <Title heading={5}>{selected.title} + +
-
- {filteredArticles.map((article) => { - const articleSource = sourceByID(sources, article.sourceID) - return ( - - ) - })} -
+ +
+ + {sourceInitial(selectedSource.name)} + {selectedSource.name} + {itemTime(selected)} + + {selected.content || selected.summary || '暂无正文'} +
+
+
+ ) : ( + + - - -
- {selected.title} - -
-
- - - {sourceInitial(selectedSource.name)} - - {selectedSource.name} - {selected.project.name} - {selected.time} - - {selected.summary || '暂无正文'} -
- 推荐:将外部文章中的项目机会、风险提示和资料线索归档到对应项目,形成可追踪的计划和知识资产。 -
-
-
- - ) : ( - - - - )} + )} +
setSourceModalMode(null)} - onOk={saveSource} + onOk={() => void saveSource()} > - {sourceError && } + {sourceError ? : null}