diff --git a/apps/senlinai-acro-react/src/App.css b/apps/senlinai-acro-react/src/App.css index f02eb9f..7db13cc 100644 --- a/apps/senlinai-acro-react/src/App.css +++ b/apps/senlinai-acro-react/src/App.css @@ -636,6 +636,296 @@ padding: 12px; } +.workspace-explore-page { + min-height: 0; +} + +.explore-source-row { + min-width: 0; +} + +.explore-source-card .arco-card-body { + display: flex; + align-items: center; + gap: 12px; + min-height: 54px; + padding: 12px 14px; +} + +.explore-source-icon { + width: 34px; + height: 34px; + flex: 0 0 auto; + display: inline-grid; + place-items: center; + border-radius: 8px; + font-size: 18px; +} + +.explore-source-icon.blue { + background: rgb(var(--arcoblue-1)); + color: rgb(var(--arcoblue-6)); +} + +.explore-source-icon.green { + background: rgb(var(--green-1)); + color: rgb(var(--green-6)); +} + +.explore-source-icon.orange { + background: rgb(var(--orange-1)); + color: rgb(var(--orange-6)); +} + +.explore-source-copy { + min-width: 0; + display: grid; + gap: 2px; +} + +.explore-source-name { + overflow: hidden; + font-weight: 700; + text-overflow: ellipsis; + white-space: nowrap; +} + +.explore-reader-layout { + min-height: 0; + display: grid; + grid-template-columns: minmax(320px, 34%) minmax(520px, 1fr); + gap: 12px; +} + +.explore-article-list.queue-section .arco-card-body, +.explore-article-detail.queue-section .arco-card-body { + min-height: 620px; + padding: 0; +} + +.explore-list-header { + height: 54px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 0 12px; + border-bottom: 1px solid var(--senlin-border); +} + +.explore-list-header h5 { + margin: 0; +} + +.explore-list-body { + max-height: calc(100vh - 252px); + overflow: auto; + scrollbar-width: none; +} + +.explore-list-body:hover { + scrollbar-width: thin; +} + +.explore-list-body::-webkit-scrollbar { + width: 0; + height: 0; +} + +.explore-list-body:hover::-webkit-scrollbar { + width: 8px; +} + +.explore-article-item { + width: 100%; + display: grid; + grid-template-columns: 34px minmax(0, 1fr); + gap: 12px; + border: 0; + border-bottom: 1px solid var(--senlin-border); + background: transparent; + color: var(--senlin-text); + padding: 14px 12px; + text-align: left; + cursor: pointer; +} + +.explore-article-item:hover, +.explore-article-item.active { + background: color-mix(in srgb, var(--senlin-primary) 8%, var(--senlin-panel)); +} + +.explore-source-logo { + width: 28px; + height: 28px; + display: inline-grid; + place-items: center; + border-radius: 6px; + background: #1f2a63; + color: #fff; + font-size: 14px; + font-weight: 800; +} + +.explore-source-logo.small { + width: 20px; + height: 20px; + border-radius: 5px; + font-size: 12px; +} + +.explore-article-copy { + min-width: 0; + display: grid; + gap: 4px; +} + +.explore-article-title { + display: -webkit-box; + overflow: hidden; + color: var(--senlin-text); + font-size: 15px; + font-weight: 700; + line-height: 1.45; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; +} + +.explore-detail-toolbar { + height: 54px; + display: flex; + align-items: center; + justify-content: flex-end; + padding: 0 18px; + border-bottom: 1px solid var(--senlin-border); +} + +.explore-article-body { + max-width: 780px; + margin: 0 auto; + padding: 54px 28px 72px; +} + +.explore-article-body h2 { + margin: 0; + color: var(--senlin-text); + font-size: 28px; + line-height: 1.35; +} + +.explore-article-meta { + margin-top: 20px; +} + +.explore-avatar-strip { + display: flex; + align-items: center; + margin-top: 20px; + padding-left: 8px; +} + +.explore-avatar-strip span { + width: 26px; + height: 26px; + display: inline-grid; + place-items: center; + margin-left: -8px; + border: 2px solid var(--senlin-panel); + border-radius: 999px; + background: var(--senlin-soft-blue); + color: #fff; + font-size: 11px; + font-weight: 700; +} + +.explore-avatar-strip span:last-child { + color: var(--senlin-muted); +} + +.explore-ai-summary.arco-card { + margin-top: 34px; + border-color: color-mix(in srgb, var(--senlin-primary) 14%, var(--senlin-border)); + border-radius: 8px; + box-shadow: 0 8px 24px rgba(29, 33, 41, 0.06); +} + +.explore-ai-summary .arco-card-body { + display: grid; + gap: 22px; + padding: 24px 28px; +} + +.explore-ai-summary .arco-space { + color: #a855f7; + font-size: 16px; + font-weight: 700; +} + +.explore-ai-summary .arco-typography { + margin: 0; + color: var(--senlin-text); + font-size: 15px; + line-height: 1.8; +} + +.explore-article-body blockquote { + margin: 38px 0 0; + border-left: 4px solid var(--senlin-border); + color: var(--senlin-text); + padding: 4px 0 4px 18px; + font-size: 17px; + font-style: italic; + line-height: 1.9; +} + +.theme-dark .explore-source-icon.blue, +.theme-dark .explore-source-icon.green, +.theme-dark .explore-source-icon.orange { + background: color-mix(in srgb, currentcolor 18%, transparent); +} + +.theme-dark .explore-ai-summary.arco-card { + box-shadow: 0 8px 24px rgb(0 0 0 / 18%); +} + +@media (max-width: 960px) { + .workspace-explore-page .overview-head { + align-items: flex-start; + flex-direction: column; + gap: 10px; + } + + .workspace-explore-page .arco-col-8 { + flex: 0 0 100%; + max-width: 100%; + } + + .explore-source-row { + row-gap: 10px; + } + + .explore-reader-layout { + grid-template-columns: minmax(0, 1fr); + } + + .explore-article-list.queue-section .arco-card-body, + .explore-article-detail.queue-section .arco-card-body { + min-height: 0; + } + + .explore-list-body { + max-height: 360px; + } + + .explore-article-body { + padding: 28px 18px 40px; + } + + .explore-article-body h2 { + font-size: 22px; + } +} + .section-header { display: flex; align-items: center; diff --git a/apps/senlinai-acro-react/src/pages/workspace-explore.tsx b/apps/senlinai-acro-react/src/pages/workspace-explore.tsx index e79ce88..30b3391 100644 --- a/apps/senlinai-acro-react/src/pages/workspace-explore.tsx +++ b/apps/senlinai-acro-react/src/pages/workspace-explore.tsx @@ -1,116 +1,201 @@ -import { Button, Card, Empty, Grid, Space, Tag, Typography } from '@arco-design/web-react' -import { IconBook, IconCompass, IconLink, IconLoop, IconRefresh, IconStar } from '@arco-design/web-react/icon' -import type { ProjectWorkspace } from './projects/project-types' +import { useEffect, useMemo, useState } from 'react' +import type { ReactNode } from 'react' +import { Button, Card, Empty, Grid, Space, Typography } from '@arco-design/web-react' +import { + IconBook, + IconCheckCircle, + IconCompass, + IconFire, + IconLink, + IconMessage, + IconRefresh, + IconRobot, + IconStar, +} from '@arco-design/web-react/icon' +import type { InboxItem, Project, ProjectWorkspace } from './projects/project-types' const { Row, Col } = Grid const { Title, Text, Paragraph } = Typography -const rssSources = [ - { name: '产品更新', url: 'https://example.com/product.xml', status: '运行中', count: 18, color: 'arcoblue' }, - { name: '行业情报', url: 'https://example.com/market.xml', status: '运行中', count: 24, color: 'green' }, - { name: '技术博客', url: 'https://example.com/engineering.xml', status: '待同步', count: 9, color: 'orange' }, -] +type ExploreArticle = InboxItem & { + project: Project +} -export function WorkspaceExplorePage({ workspaces, onSelectItem }: { workspaces: ProjectWorkspace[]; onSelectItem: (title: string) => void }) { - const articles = workspaces.flatMap((workspace) => workspace.inbox.map((item) => ({ ...item, project: workspace.project }))) - const selected = articles[0] - const pendingCount = articles.filter((item) => item.status === 'open').length - const sourceCount = rssSources.length +type DataSource = { + name: string + count: number + icon: ReactNode + color: string +} + +export function WorkspaceExplorePage({ + workspaces, + onSelectItem, +}: { + workspaces: ProjectWorkspace[] + onSelectItem: (title: string) => void +}) { + const articles = useMemo( + () => + workspaces.flatMap((workspace) => + workspace.inbox.map((item) => ({ + ...item, + project: workspace.project, + })), + ), + [workspaces], + ) + const sources = useMemo(() => dataSources(articles), [articles]) + const [activeArticleID, setActiveArticleID] = useState(articles[0]?.id ?? null) + + useEffect(() => { + if (articles.length === 0) { + setActiveArticleID(null) + return + } + if (!activeArticleID || !articles.some((article) => article.id === activeArticleID)) { + setActiveArticleID(articles[0].id) + } + }, [activeArticleID, articles]) + + const selected = articles.find((article) => article.id === activeArticleID) ?? articles[0] return (
探索 - 集中管理多个 RSS 源,采集外部内容、沉淀线索,并分发到对应项目。 + 多个外部数据源的集中阅读页,采集文章、AI 总结并沉淀到项目。
- - + +
- - - - {sourceCount}RSS 源 - - - - - {pendingCount}待处理采集 - - - - - {articles.length}采集条目 - - + + {sources.map((source) => ( + + + {source.icon} + + {source.name} + {source.count} 条 + + + + ))} - -
- RSS 源 - -
- {rssSources.map((source) => ( - - ))} -
- - {articles.length ? ( -
- -
- 采集队列 - + {selected ? ( +
+ +
+ {sourceName(selected)} - 最新文章 + +
+
+ {articles.map((article) => ( + + ))}
- {articles.map((item, index) => ( - - ))}
- -
- {selected.title} + +
+
- {selected.meta} · {selected.project.name} - {selected.summary || '暂无采集正文'} -
- 归档到项目 - - - - +
+ {selected.title} + + {sourceInitial(sourceName(selected))} + {sourceName(selected)} - 最新文章 + {selected.time} -
+
+ {workspaces.slice(0, 8).map((workspace) => ( + + {workspace.project.short} + + ))} + +{Math.max(workspaces.length - 8, 0)} +
+ + + + AI 总结 + + + {selected.summary || + '暂无正文摘要。同步数据源后,系统会在这里沉淀文章核心观点、风险点和可转化为项目计划的线索。'} + + +
+ 推荐:将外部文章中的项目机会、风险提示和资料线索归档到对应项目,形成可追踪的计划和知识资产。 +
+
-
+
) : ( - + )}
) } + +function dataSources(articles: ExploreArticle[]): DataSource[] { + const counts = new Map() + articles.forEach((article) => counts.set(sourceName(article), (counts.get(sourceName(article)) ?? 0) + 1)) + const iconSet = [ + { icon: , color: 'blue' }, + { icon: , color: 'green' }, + { icon: , color: 'orange' }, + ] + + return Array.from(counts.entries()) + .slice(0, 3) + .map(([name, count], index) => ({ + name, + count, + icon: iconSet[index]?.icon ?? , + color: iconSet[index]?.color ?? 'blue', + })) +} + +function sourceName(article: ExploreArticle) { + return article.meta?.replace(/^来源:/, '') || article.project.name +} + +function sourceInitial(name: string) { + return name.trim().slice(0, 1) || '源' +}