Add workspace landing page

This commit is contained in:
2026-07-20 11:40:45 +08:00
parent 9c4ad986c0
commit da76abf2d0
10 changed files with 260 additions and 28 deletions

View File

@@ -3,6 +3,7 @@ import { existsSync, readFileSync } from 'node:fs'
const requiredFiles = [
'src/app/App.tsx',
'src/pages/login.tsx',
'src/pages/workspace.tsx',
'src/pages/project.tsx',
'src/pages/projects/project-overview.tsx',
'src/pages/projects/project-data.tsx',

View File

@@ -34,10 +34,13 @@ const metrics = await page.evaluate(() => {
const statusbar = document.querySelector('.statusbar')
const search = document.querySelector('.global-search')
const main = document.querySelector('.workbench-main')
const workspacePage = document.querySelector('.workspace-page')
const rail = document.querySelector('.project-rail')
const railChildren = rail?.querySelector('.arco-layout-sider-children')
const sidebar = document.querySelector('.channel-sidebar')
const sidebarTitle = document.querySelector('.project-title h5')
const stage = document.querySelector('.stage')
const inspectorTitle = document.querySelector('.inspector-title h5')
const inspector = document.querySelector('.inspector')
const dashboard = document.querySelector('.dashboard-button')
const project = document.querySelector('.project-button')
@@ -72,13 +75,18 @@ const metrics = await page.evaluate(() => {
statusbarHeight: statusbar?.getBoundingClientRect().height,
searchHeight: search?.getBoundingClientRect().height,
workbenchMain: rect(main),
hasWorkspacePage: Boolean(workspacePage),
projectRailWidth: rail?.getBoundingClientRect().width,
projectRail: rect(rail),
channelSidebar: rect(sidebar),
sidebarTitleText: sidebarTitle?.textContent ?? '',
stage: rect(stage),
inspector: rect(inspector),
inspectorTitleText: inspectorTitle?.textContent ?? '',
dashboardButton: rect(dashboard),
dashboardButtonActive: dashboard?.classList.contains('active') ?? false,
projectButton: rect(project),
projectButtonActive: project?.classList.contains('active') ?? false,
createProjectButton: rect(create),
firstProjectBadge: rect(firstBadge),
horizontalInsets,
@@ -101,6 +109,15 @@ if (metrics.statusbarHeight !== 32) failures.push(`expected statusbar height 32,
if (metrics.searchHeight !== 42) failures.push(`expected search height 42, got ${metrics.searchHeight}`)
if (metrics.overflowX) failures.push('expected no horizontal overflow')
if (!metrics.workbenchMain) failures.push('missing workbench main')
if (!metrics.hasWorkspacePage) failures.push('expected login to land on workspace page')
if (!metrics.dashboardButtonActive) failures.push('expected dashboard button to be active after login')
if (metrics.projectButtonActive) failures.push('expected first project button not to be active on workspace landing page')
if (!metrics.sidebarTitleText.includes('工作台')) {
failures.push(`expected sidebar title to describe workspace landing page, got ${JSON.stringify(metrics.sidebarTitleText)}`)
}
if (!metrics.inspectorTitleText.includes('工作台')) {
failures.push(`expected inspector title to describe workspace landing page, got ${JSON.stringify(metrics.inspectorTitleText)}`)
}
if (!metrics.projectRail || !metrics.channelSidebar || !metrics.stage || !metrics.inspector) {
failures.push(
`missing main layout regions: rail=${JSON.stringify(metrics.projectRail)}, sidebar=${JSON.stringify(metrics.channelSidebar)}, stage=${JSON.stringify(metrics.stage)}, inspector=${JSON.stringify(metrics.inspector)}`,

View File

@@ -312,6 +312,12 @@
border-radius: 8px;
}
.dashboard-button.active {
border-color: var(--senlin-primary);
background: var(--senlin-primary);
color: white;
}
.create-project {
font-size: 11px;
white-space: normal;

View File

@@ -5,11 +5,12 @@ import '../App.css'
import { LoginPage } from '../pages/login'
import { ProjectPage } from '../pages/project'
import { projects } from '../pages/projects/project-data'
import type { ChannelKey, Screen, Theme } from '../pages/projects/project-types'
import type { ChannelKey, Screen, Theme, WorkbenchView } from '../pages/projects/project-types'
function App() {
const [screen, setScreen] = useState<Screen>('login')
const [theme, setTheme] = useState<Theme>('light')
const [activeView, setActiveView] = useState<WorkbenchView>('workspace')
const [activeProject, setActiveProject] = useState(projects[0])
const [activeChannel, setActiveChannel] = useState<ChannelKey>('overview')
const [selectedItem, setSelectedItem] = useState('Inbox 待处理(36)')
@@ -23,11 +24,16 @@ function App() {
<LoginPage onLogin={() => setScreen('workbench')} />
) : (
<ProjectPage
activeView={activeView}
activeProject={activeProject}
activeChannel={activeChannel}
selectedItem={selectedItem}
theme={theme}
onSelectProject={setActiveProject}
onSelectWorkspace={() => setActiveView('workspace')}
onSelectProject={(project) => {
setActiveProject(project)
setActiveView('project')
}}
onSelectChannel={setActiveChannel}
onSelectItem={setSelectedItem}
onToggleTheme={() => setTheme(dark ? 'light' : 'dark')}

View File

@@ -1,29 +1,34 @@
import { Layout } from '@arco-design/web-react'
import { WorkspacePage } from './workspace'
import { ProjectOverview } from './projects/project-overview'
import { ProjectInspector } from './projects/project-inspector'
import { ProjectRail } from './projects/project-rail'
import { ProjectSidebar } from './projects/project-sidebar'
import { ProjectStatusbar } from './projects/project-statusbar'
import { ProjectTopbar } from './projects/project-topbar'
import type { ChannelKey, Project, Theme } from './projects/project-types'
import type { ChannelKey, Project, Theme, WorkbenchView } from './projects/project-types'
const { Content } = Layout
export function ProjectPage({
activeView,
activeProject,
activeChannel,
selectedItem,
theme,
onSelectProject,
onSelectWorkspace,
onSelectChannel,
onSelectItem,
onToggleTheme,
}: {
activeView: WorkbenchView
activeProject: Project
activeChannel: ChannelKey
selectedItem: string
theme: Theme
onSelectProject: (project: Project) => void
onSelectWorkspace: () => void
onSelectChannel: (key: ChannelKey) => void
onSelectItem: (title: string) => void
onToggleTheme: () => void
@@ -33,9 +38,15 @@ export function ProjectPage({
<ProjectTopbar theme={theme} onToggleTheme={onToggleTheme} />
<Layout className="workbench-main" hasSider>
<ProjectRail activeProject={activeProject} onSelectProject={onSelectProject} />
<ProjectRail
activeProject={activeProject}
activeView={activeView}
onSelectWorkspace={onSelectWorkspace}
onSelectProject={onSelectProject}
/>
<ProjectSidebar
activeView={activeView}
activeProject={activeProject}
activeChannel={activeChannel}
onSelectChannel={onSelectChannel}
@@ -43,10 +54,19 @@ export function ProjectPage({
/>
<Content className="stage">
<ProjectOverview onSelectItem={onSelectItem} />
{activeView === 'workspace' ? (
<WorkspacePage onSelectProject={onSelectProject} />
) : (
<ProjectOverview onSelectItem={onSelectItem} />
)}
</Content>
<ProjectInspector activeProject={activeProject} activeChannel={activeChannel} selectedItem={selectedItem} />
<ProjectInspector
activeView={activeView}
activeProject={activeProject}
activeChannel={activeChannel}
selectedItem={activeView === 'workspace' ? '工作台动态' : selectedItem}
/>
</Layout>
<ProjectStatusbar />

View File

@@ -1,18 +1,45 @@
import { Avatar, Button, Divider, Input, Layout, Message, Space, Tabs, Typography } from '@arco-design/web-react'
import { IconLink, IconMessage, IconMore } from '@arco-design/web-react/icon'
import { channels, discussions } from './project-data'
import type { ChannelKey, Project } from './project-types'
import type { ChannelKey, Project, WorkbenchView } from './project-types'
const { Sider } = Layout
const { Title, Text, Paragraph } = Typography
export function ProjectInspector({ activeProject, activeChannel, selectedItem }: { activeProject: Project; activeChannel: ChannelKey; selectedItem: string }) {
export function ProjectInspector({
activeView,
activeProject,
activeChannel,
selectedItem,
}: {
activeView: WorkbenchView
activeProject: Project
activeChannel: ChannelKey
selectedItem: string
}) {
const workspaceMode = activeView === 'workspace'
return (
<Sider className="inspector" width={360}>
<Tabs defaultActiveTab="discussion">
<Tabs.TabPane key="discussion" title="讨论"><InspectorDiscussion selectedItem={selectedItem} /></Tabs.TabPane>
<Tabs.TabPane key="props" title="属性"><div className="property-list"><Property label="所属项目" value={activeProject.name} /><Property label="频道" value={channels.find((c) => c.key === activeChannel)?.label ?? '概况'} /><Property label="创建人" value="张伟" /><Property label="更新时间" value="2026-07-20 10:30" /></div></Tabs.TabPane>
<Tabs.TabPane key="more" title="更多"><Space direction="vertical" className="more-actions"><Button long>复制链接</Button><Button long>标记已处理</Button><Button long status="danger">归档对象</Button></Space></Tabs.TabPane>
<Tabs.TabPane key="discussion" title="讨论">
<InspectorDiscussion selectedItem={selectedItem} />
</Tabs.TabPane>
<Tabs.TabPane key="props" title="属性">
<div className="property-list">
<Property label="所属项目" value={workspaceMode ? '全部项目' : activeProject.name} />
<Property label="频道" value={workspaceMode ? '工作台总览' : channels.find((channel) => channel.key === activeChannel)?.label ?? '概况'} />
<Property label="创建人" value="张伟" />
<Property label="更新时间" value="2026-07-20 10:30" />
</div>
</Tabs.TabPane>
<Tabs.TabPane key="more" title="更多">
<Space direction="vertical" className="more-actions">
<Button long>复制链接</Button>
<Button long>标记已处理</Button>
<Button long status="danger">归档对象</Button>
</Space>
</Tabs.TabPane>
</Tabs>
</Sider>
)

View File

@@ -2,18 +2,34 @@ import type { CSSProperties } from 'react'
import { Badge, Button, Layout, Space } from '@arco-design/web-react'
import { IconDashboard, IconPlus } from '@arco-design/web-react/icon'
import { projects } from './project-data'
import type { Project } from './project-types'
import type { Project, WorkbenchView } from './project-types'
const { Sider } = Layout
export function ProjectRail({ activeProject, onSelectProject }: { activeProject: Project; onSelectProject: (project: Project) => void }) {
export function ProjectRail({
activeProject,
activeView,
onSelectWorkspace,
onSelectProject,
}: {
activeProject: Project
activeView: WorkbenchView
onSelectWorkspace: () => void
onSelectProject: (project: Project) => void
}) {
return (
<Sider className="project-rail" width={96}>
<Button className="dashboard-button" icon={<IconDashboard />} />
<Button
className={activeView === 'workspace' ? 'dashboard-button active' : 'dashboard-button'}
icon={<IconDashboard />}
onClick={onSelectWorkspace}
aria-label="工作台"
title="工作台"
/>
<Space className="project-stack" direction="vertical" size={12}>
{projects.map((project) => (
<Badge key={project.id} count={project.badge} dot={false} className={project.urgent ? 'project-badge urgent' : 'project-badge'}>
<button className={activeProject.id === project.id ? 'project-button active' : 'project-button'} onClick={() => onSelectProject(project)} style={{ '--project-color': project.color } as CSSProperties} title={project.name}>
<button className={activeView === 'project' && activeProject.id === project.id ? 'project-button active' : 'project-button'} onClick={() => onSelectProject(project)} style={{ '--project-color': project.color } as CSSProperties} title={project.name}>
{project.short}
</button>
</Badge>

View File

@@ -1,34 +1,81 @@
import { Badge, Button, Divider, Layout, Space, Tag, Typography } from '@arco-design/web-react'
import { IconDashboard, IconMore, IconSettings } from '@arco-design/web-react/icon'
import { IconApps, IconDashboard, IconMore, IconSettings } from '@arco-design/web-react/icon'
import { channels } from './project-data'
import type { ChannelKey, Project } from './project-types'
import type { ChannelKey, Project, WorkbenchView } from './project-types'
const { Sider } = Layout
const { Title, Text } = Typography
export function ProjectSidebar({ activeProject, activeChannel, onSelectChannel, onSelectItem }: { activeProject: Project; activeChannel: ChannelKey; onSelectChannel: (key: ChannelKey) => void; onSelectItem: (title: string) => void }) {
export function ProjectSidebar({
activeView,
activeProject,
activeChannel,
onSelectChannel,
onSelectItem,
}: {
activeView: WorkbenchView
activeProject: Project
activeChannel: ChannelKey
onSelectChannel: (key: ChannelKey) => void
onSelectItem: (title: string) => void
}) {
const workspaceMode = activeView === 'workspace'
return (
<Sider className="channel-sidebar" width={248}>
<div className="project-title"><Space><IconDashboard /><Title heading={5}>{activeProject.name}</Title></Space><Button icon={<IconSettings />} size="mini" /></div>
<div className="channel-list">
{channels.map((channel) => (
<button key={channel.key} className={activeChannel === channel.key ? 'channel-button active' : 'channel-button'} onClick={() => onSelectChannel(channel.key)}>
<span className="channel-left">{channel.icon}<Text>{channel.label}</Text></span>
{channel.count !== undefined ? <Badge count={channel.count} maxCount={999} /> : <IconMore />}
</button>
))}
<div className="project-title">
<Space>
{workspaceMode ? <IconApps /> : <IconDashboard />}
<Title heading={5}>{workspaceMode ? '工作台' : activeProject.name}</Title>
</Space>
<Button icon={<IconSettings />} size="mini" />
</div>
<div className="channel-list">
{workspaceMode ? (
<>
<button className="channel-button active">
<span className="channel-left"><IconDashboard /><Text>工作台总览</Text></span>
<Badge count={4} />
</button>
<button className="channel-button">
<span className="channel-left"><IconApps /><Text>全部项目</Text></span>
<IconMore />
</button>
</>
) : (
channels.map((channel) => (
<button
key={channel.key}
className={activeChannel === channel.key ? 'channel-button active' : 'channel-button'}
onClick={() => onSelectChannel(channel.key)}
>
<span className="channel-left">{channel.icon}<Text>{channel.label}</Text></span>
{channel.count !== undefined ? <Badge count={channel.count} maxCount={999} /> : <IconMore />}
</button>
))
)}
</div>
<Divider />
<section className="recent-sessions">
<Text className="section-title">最近会话</Text>
{['需求评审要点整理', '生成 Q3 营销策略初稿', 'npx 安装 skill 至 codex', '产品定价模型讨论'].map((title, index) => (
<button key={title} onClick={() => onSelectItem(title)}><Text>{title}</Text><Text type="secondary">{index === 0 ? '09:15' : index === 1 ? '08:47' : '昨天'}</Text></button>
<button key={title} onClick={() => onSelectItem(title)}>
<Text>{title}</Text>
<Text type="secondary">{index === 0 ? '09:15' : index === 1 ? '08:47' : '昨天'}</Text>
</button>
))}
</section>
<Divider />
<section className="tag-cloud">
<Text className="section-title">标签</Text>
<Space wrap>{['全部', '需求', '设计', '研发', '运营', 'AI'].map((tag) => <Tag key={tag} color={tag === '全部' ? 'arcoblue' : 'gray'}>{tag}</Tag>)}</Space>
<Space wrap>
{['全部', '需求', '设计', '研发', '运营', 'AI'].map((tag) => (
<Tag key={tag} color={tag === '全部' ? 'arcoblue' : 'gray'}>{tag}</Tag>
))}
</Space>
</section>
</Sider>
)

View File

@@ -2,6 +2,7 @@ import type { ReactElement } from 'react'
export type Screen = 'login' | 'workbench'
export type Theme = 'light' | 'dark'
export type WorkbenchView = 'workspace' | 'project'
export type ChannelKey = 'overview' | 'inbox' | 'tasks' | 'ai' | 'notes' | 'cron' | 'research' | 'design'
export type Project = {

View File

@@ -0,0 +1,91 @@
import { Button, Card, Grid, Space, Tag, Typography } from '@arco-design/web-react'
import {
IconCheckCircleFill,
IconDashboard,
IconEmail,
IconFile,
IconRobot,
IconRight,
IconUserGroup,
} from '@arco-design/web-react/icon'
import { aiSessions, inbox, notes, projects, tasks } from './projects/project-data'
import type { Project } from './projects/project-types'
const { Row, Col } = Grid
const { Title, Text } = Typography
export function WorkspacePage({ onSelectProject }: { onSelectProject: (project: Project) => void }) {
const totalProjectBadges = projects.reduce((sum, project) => sum + project.badge, 0)
const urgentProjects = projects.filter((project) => project.urgent).length
const workspaceMetrics = [
{ title: '项目总数', value: projects.length, delta: `${urgentProjects} 个高优先级`, icon: <IconDashboard />, color: 'arcoblue' },
{ title: '全局待处理', value: totalProjectBadges, delta: '跨项目聚合', icon: <IconEmail />, color: 'red' },
{ title: '进行中任务', value: tasks.length, delta: '本周持续推进', icon: <IconCheckCircleFill />, color: 'green' },
{ title: 'AI 会话', value: aiSessions.length, delta: '队列空闲', icon: <IconRobot />, color: 'purple' },
{ title: '知识资料', value: notes.length, delta: '最近 7 天更新', icon: <IconFile />, color: 'cyan' },
]
return (
<div className="workspace-page overview-page">
<div className="overview-head">
<div>
<Text type="secondary">所有项目的统一工作台,汇总项目动态、任务压力和知识资产。</Text>
<Title heading={4}>工作台</Title>
</div>
<Space>
<Button icon={<IconUserGroup />}>团队视图</Button>
<Button type="primary" icon={<IconRight />}>进入重点项目</Button>
</Space>
</div>
<Row gutter={12} className="metric-row">
{workspaceMetrics.map((metric) => (
<Col span={24 / workspaceMetrics.length} key={metric.title}>
<Card className="metric-card" bordered>
<Space align="start">
<Tag color={metric.color}>{metric.icon}</Tag>
<div>
<Text type="secondary">{metric.title}</Text>
<Title heading={3}>{metric.value}</Title>
<Text className="metric-delta">{metric.delta}</Text>
</div>
</Space>
</Card>
</Col>
))}
</Row>
<Card className="queue-section" bordered>
<div className="section-header">
<Title heading={6}>项目健康度</Title>
<Button type="text" size="mini">查看全部项目</Button>
</div>
{projects.map((project) => (
<button key={project.id} className="data-row" onClick={() => onSelectProject(project)}>
<span className="row-title"><IconDashboard /> {project.name}</span>
<Tag color={project.urgent ? 'red' : 'green'}>{project.urgent ? '高优先级' : '正常'}</Tag>
<span>待处理 {project.badge}</span>
<span>项目代码 {project.short}</span>
<time>今日更新</time>
</button>
))}
</Card>
<Card className="queue-section" bordered>
<div className="section-header">
<Title heading={6}>跨项目待处理</Title>
<Button type="text" size="mini">打开全局 Inbox</Button>
</div>
{inbox.slice(0, 4).map((item) => (
<div key={item.title} className="data-row">
<span className="row-title"><IconEmail /> {item.title}</span>
<span>{item.meta}</span>
<Tag>{item.tag}</Tag>
<span>{item.owner}</span>
<time>{item.time}</time>
</div>
))}
</Card>
</div>
)
}