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>
)
}