feat(web): add responsive workbench navigation
This commit is contained in:
@@ -22,6 +22,56 @@ page.on('console', (message) => {
|
|||||||
if (message.type() === 'error') errors.push(message.text())
|
if (message.type() === 'error') errors.push(message.text())
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const visualCheckWorkspace = {
|
||||||
|
project: {
|
||||||
|
id: 1,
|
||||||
|
name: '森林项目',
|
||||||
|
identifier: 'forest',
|
||||||
|
icon: '森',
|
||||||
|
background: '#165DFF',
|
||||||
|
description: '移动导航视觉检查项目',
|
||||||
|
initials: '森林',
|
||||||
|
unreadCount: 2,
|
||||||
|
},
|
||||||
|
channels: [
|
||||||
|
{ id: 'overview', projectID: 1, type: 'overview', title: '概况', icon: 'home', sortOrder: 0 },
|
||||||
|
{ id: 'tasks', projectID: 1, type: 'tasks', title: '工作计划', icon: 'list', count: 1, sortOrder: 1 },
|
||||||
|
{ id: 'ai', projectID: 1, type: 'ai_sessions', title: 'AI 助手', icon: 'robot', sortOrder: 2 },
|
||||||
|
{ id: 'notes', projectID: 1, type: 'notes_sources', title: '笔记资料', icon: 'file', sortOrder: 3 },
|
||||||
|
{ id: 'cron', projectID: 1, type: 'cron', title: '计划任务', icon: 'clock', sortOrder: 4 },
|
||||||
|
],
|
||||||
|
tags: ['all', '产品'],
|
||||||
|
recentSessions: [
|
||||||
|
{ id: 'session-1', title: '移动导航会话', summary: '检查抽屉关闭行为', updatedAt: '今天 10:00', references: [] },
|
||||||
|
],
|
||||||
|
inbox: [],
|
||||||
|
tasks: [],
|
||||||
|
aiSessions: [],
|
||||||
|
notesSources: [],
|
||||||
|
cronPlans: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.route('http://localhost:9150/api/**', async (route) => {
|
||||||
|
const url = new URL(route.request().url())
|
||||||
|
if (url.pathname === '/api/status') {
|
||||||
|
await route.fulfill({ json: { timestamp_ms: Date.now() } })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (url.pathname === '/api/auth/login') {
|
||||||
|
await route.fulfill({ json: { token: 'visual-check-token' } })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (url.pathname === '/api/projects') {
|
||||||
|
await route.fulfill({ json: [{ ID: 1, Name: visualCheckWorkspace.project.name }] })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (url.pathname === '/api/projects/1/workspace') {
|
||||||
|
await route.fulfill({ json: visualCheckWorkspace })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await route.fulfill({ status: 404, json: { error: `unmocked visual check route: ${url.pathname}` } })
|
||||||
|
})
|
||||||
|
|
||||||
const collectLoginMetrics = async () => page.evaluate(() => {
|
const collectLoginMetrics = async () => page.evaluate(() => {
|
||||||
const loginCard = document.querySelector('.login-card')
|
const loginCard = document.querySelector('.login-card')
|
||||||
|
|
||||||
@@ -37,8 +87,14 @@ const getColumnCount = (columns) => columns === 'none'
|
|||||||
? 0
|
? 0
|
||||||
: columns.split(' ').filter(Boolean).length
|
: columns.split(' ').filter(Boolean).length
|
||||||
|
|
||||||
|
const waitForDrawerOpen = async (selector) => page.waitForFunction((drawerSelector) => {
|
||||||
|
const drawer = document.querySelector(drawerSelector)
|
||||||
|
return drawer?.classList.contains('open') && Math.abs(drawer.getBoundingClientRect().left) < 1
|
||||||
|
}, selector)
|
||||||
|
|
||||||
const collectMetrics = async () => page.evaluate(() => {
|
const collectMetrics = async () => page.evaluate(() => {
|
||||||
const statusbar = document.querySelector('.statusbar')
|
const statusbar = document.querySelector('.statusbar')
|
||||||
|
const statusUser = document.querySelector('.status-user')
|
||||||
const statusName = document.querySelector('.status-name')
|
const statusName = document.querySelector('.status-name')
|
||||||
const statusOnlineDot = document.querySelector('.status-online-dot')
|
const statusOnlineDot = document.querySelector('.status-online-dot')
|
||||||
const search = document.querySelector('.global-search')
|
const search = document.querySelector('.global-search')
|
||||||
@@ -86,6 +142,8 @@ const collectMetrics = async () => page.evaluate(() => {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
statusbarHeight: statusbar?.getBoundingClientRect().height,
|
statusbarHeight: statusbar?.getBoundingClientRect().height,
|
||||||
|
statusUserTag: statusUser?.tagName,
|
||||||
|
nestedButtonCount: document.querySelectorAll('button button').length,
|
||||||
statusName: rect(statusName),
|
statusName: rect(statusName),
|
||||||
statusOnlineDot: rect(statusOnlineDot),
|
statusOnlineDot: rect(statusOnlineDot),
|
||||||
searchHeight: search?.getBoundingClientRect().height,
|
searchHeight: search?.getBoundingClientRect().height,
|
||||||
@@ -154,6 +212,12 @@ await page.waitForTimeout(700)
|
|||||||
await page.screenshot({ path: 'test-results/workbench-react-acro-light.png', fullPage: true })
|
await page.screenshot({ path: 'test-results/workbench-react-acro-light.png', fullPage: true })
|
||||||
const workspaceMetrics = await collectMetrics()
|
const workspaceMetrics = await collectMetrics()
|
||||||
|
|
||||||
|
await page.setViewportSize({ width: 390, height: 844 })
|
||||||
|
if (await page.getByRole('button', { name: '打开频道导航', exact: true }).count() !== 0) {
|
||||||
|
failures.push('workspace mobile view must not offer channel navigation without a channel aside')
|
||||||
|
}
|
||||||
|
await page.setViewportSize({ width: 1440, height: 1024 })
|
||||||
|
|
||||||
await page.locator('.dashboard-button[title="探索"]').click()
|
await page.locator('.dashboard-button[title="探索"]').click()
|
||||||
await page.waitForTimeout(500)
|
await page.waitForTimeout(500)
|
||||||
await page.screenshot({ path: 'test-results/workspace-explore-react-acro-light.png', fullPage: true })
|
await page.screenshot({ path: 'test-results/workspace-explore-react-acro-light.png', fullPage: true })
|
||||||
@@ -164,6 +228,39 @@ await page.waitForTimeout(500)
|
|||||||
await page.screenshot({ path: 'test-results/project-react-acro-light.png', fullPage: true })
|
await page.screenshot({ path: 'test-results/project-react-acro-light.png', fullPage: true })
|
||||||
const projectMetrics = await collectMetrics()
|
const projectMetrics = await collectMetrics()
|
||||||
|
|
||||||
|
await page.setViewportSize({ width: 390, height: 844 })
|
||||||
|
const mobileProjectMetrics = await collectMetrics()
|
||||||
|
const projectNavButton = page.getByRole('button', { name: '打开项目导航', exact: true })
|
||||||
|
if (await projectNavButton.count() === 0) {
|
||||||
|
failures.push('missing mobile button 打开项目导航')
|
||||||
|
} else {
|
||||||
|
await projectNavButton.click()
|
||||||
|
await waitForDrawerOpen('aside.project-rail')
|
||||||
|
await page.screenshot({ path: 'test-results/project-navigation-mobile.png', fullPage: true })
|
||||||
|
if (!await page.locator('aside.project-rail.open').isVisible()) {
|
||||||
|
failures.push('打开项目导航 must reveal the project rail aside')
|
||||||
|
}
|
||||||
|
await page.getByRole('button', { name: '关闭导航', exact: true }).click({ position: { x: 380, y: 20 } })
|
||||||
|
}
|
||||||
|
|
||||||
|
const channelNavButton = page.getByRole('button', { name: '打开频道导航', exact: true })
|
||||||
|
if (await channelNavButton.count() === 0) {
|
||||||
|
failures.push('missing mobile button 打开频道导航')
|
||||||
|
} else {
|
||||||
|
await channelNavButton.click()
|
||||||
|
await waitForDrawerOpen('aside.channel-sidebar')
|
||||||
|
await page.screenshot({ path: 'test-results/channel-navigation-mobile.png', fullPage: true })
|
||||||
|
if (!await page.locator('aside.channel-sidebar.open').isVisible()) {
|
||||||
|
failures.push('打开频道导航 must reveal the channel sidebar aside')
|
||||||
|
}
|
||||||
|
await page.locator('.recent-sessions button').first().click()
|
||||||
|
if (await page.locator('aside.channel-sidebar.open').count() !== 0) {
|
||||||
|
failures.push('selecting a recent session must close the channel drawer')
|
||||||
|
await page.getByRole('button', { name: '关闭导航', exact: true }).click({ position: { x: 380, y: 20 } })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await page.setViewportSize({ width: 1440, height: 1024 })
|
||||||
|
|
||||||
await page.locator('.channel-sidebar').hover()
|
await page.locator('.channel-sidebar').hover()
|
||||||
const channelSidebarHoverMetrics = await collectMetrics()
|
const channelSidebarHoverMetrics = await collectMetrics()
|
||||||
await page.locator('.stage').hover()
|
await page.locator('.stage').hover()
|
||||||
@@ -174,7 +271,7 @@ const channelListHoverMetrics = await collectMetrics()
|
|||||||
const channelPageChecks = []
|
const channelPageChecks = []
|
||||||
for (const channel of [
|
for (const channel of [
|
||||||
{ label: '工作计划', pageClass: 'project-tasks-page', expectedHeading: '工作计划' },
|
{ label: '工作计划', pageClass: 'project-tasks-page', expectedHeading: '工作计划' },
|
||||||
{ label: 'AI智能体', pageClass: 'project-ai-page', expectedHeading: 'AI智能体' },
|
{ label: 'AI 助手', pageClass: 'project-ai-page', expectedHeading: '选择专家,开始对话' },
|
||||||
{ label: '笔记资料', pageClass: 'project-notes-page', expectedHeading: '笔记资料' },
|
{ label: '笔记资料', pageClass: 'project-notes-page', expectedHeading: '笔记资料' },
|
||||||
{ label: '计划任务', pageClass: 'project-cron-page', expectedHeading: '计划任务' },
|
{ label: '计划任务', pageClass: 'project-cron-page', expectedHeading: '计划任务' },
|
||||||
{ label: '新建频道', pageClass: 'project-new-channel-page', expectedHeading: '新建频道' },
|
{ label: '新建频道', pageClass: 'project-new-channel-page', expectedHeading: '新建频道' },
|
||||||
@@ -183,7 +280,7 @@ for (const channel of [
|
|||||||
await page.waitForTimeout(250)
|
await page.waitForTimeout(250)
|
||||||
channelPageChecks.push(await page.evaluate((expected) => {
|
channelPageChecks.push(await page.evaluate((expected) => {
|
||||||
const pageNode = document.querySelector(`.${expected.pageClass}`)
|
const pageNode = document.querySelector(`.${expected.pageClass}`)
|
||||||
const heading = pageNode?.querySelector('h4, h5')?.textContent ?? ''
|
const heading = pageNode?.querySelector('h2, h3, h4, h5')?.textContent ?? ''
|
||||||
const activeChannel = document.querySelector('.channel-button.active')?.textContent ?? ''
|
const activeChannel = document.querySelector('.channel-button.active')?.textContent ?? ''
|
||||||
return {
|
return {
|
||||||
...expected,
|
...expected,
|
||||||
@@ -194,7 +291,7 @@ for (const channel of [
|
|||||||
}, channel))
|
}, channel))
|
||||||
}
|
}
|
||||||
|
|
||||||
await page.locator('.topbar-actions .arco-btn').first().click()
|
await page.getByRole('button', { name: '切换深色模式', exact: true }).click()
|
||||||
await page.screenshot({ path: 'test-results/project-react-acro-dark.png', fullPage: true })
|
await page.screenshot({ path: 'test-results/project-react-acro-dark.png', fullPage: true })
|
||||||
|
|
||||||
await browser.close()
|
await browser.close()
|
||||||
@@ -204,6 +301,7 @@ console.log(JSON.stringify({
|
|||||||
workspaceMetrics,
|
workspaceMetrics,
|
||||||
workspaceExploreMetrics,
|
workspaceExploreMetrics,
|
||||||
projectMetrics,
|
projectMetrics,
|
||||||
|
mobileProjectMetrics,
|
||||||
channelSidebarHoverMetrics,
|
channelSidebarHoverMetrics,
|
||||||
stageHoverMetrics,
|
stageHoverMetrics,
|
||||||
channelListHoverMetrics,
|
channelListHoverMetrics,
|
||||||
@@ -214,10 +312,8 @@ console.log(JSON.stringify({
|
|||||||
const metrics = workspaceMetrics
|
const metrics = workspaceMetrics
|
||||||
if (errors.length) failures.push(`console errors: ${errors.join('; ')}`)
|
if (errors.length) failures.push(`console errors: ${errors.join('; ')}`)
|
||||||
if (metrics.statusbarHeight !== 32) failures.push(`expected statusbar height 32, got ${metrics.statusbarHeight}`)
|
if (metrics.statusbarHeight !== 32) failures.push(`expected statusbar height 32, got ${metrics.statusbarHeight}`)
|
||||||
if (!metrics.statusOnlineDot) failures.push('expected online status to render as an icon dot')
|
if (metrics.statusUserTag !== 'DIV') failures.push(`status user must be a non-button DIV container, got ${metrics.statusUserTag}`)
|
||||||
if (metrics.statusName && metrics.statusOnlineDot && Math.abs(metrics.statusName.top - metrics.statusOnlineDot.top) > 6) {
|
if (metrics.nestedButtonCount !== 0) failures.push(`expected no nested buttons, got ${metrics.nestedButtonCount}`)
|
||||||
failures.push(`expected online dot to sit beside username, got name=${JSON.stringify(metrics.statusName)}, dot=${JSON.stringify(metrics.statusOnlineDot)}`)
|
|
||||||
}
|
|
||||||
if (metrics.searchHeight !== 42) failures.push(`expected search height 42, got ${metrics.searchHeight}`)
|
if (metrics.searchHeight !== 42) failures.push(`expected search height 42, got ${metrics.searchHeight}`)
|
||||||
if (metrics.overflowX) failures.push('expected no horizontal overflow')
|
if (metrics.overflowX) failures.push('expected no horizontal overflow')
|
||||||
if (!metrics.workbenchMain) failures.push('missing workbench main')
|
if (!metrics.workbenchMain) failures.push('missing workbench main')
|
||||||
@@ -305,6 +401,7 @@ if (projectMetrics.dashboardButtonActive) failures.push('expected dashboard butt
|
|||||||
if (projectMetrics.inspector) {
|
if (projectMetrics.inspector) {
|
||||||
failures.push(`expected project page to remove right inspector until it becomes an on-demand panel, got ${JSON.stringify(projectMetrics.inspector)}`)
|
failures.push(`expected project page to remove right inspector until it becomes an on-demand panel, got ${JSON.stringify(projectMetrics.inspector)}`)
|
||||||
}
|
}
|
||||||
|
if (mobileProjectMetrics.overflowX) failures.push('mobile project viewport must not overflow horizontally')
|
||||||
if (!projectMetrics.projectRail || !projectMetrics.channelSidebar || !projectMetrics.stage) {
|
if (!projectMetrics.projectRail || !projectMetrics.channelSidebar || !projectMetrics.stage) {
|
||||||
failures.push(
|
failures.push(
|
||||||
`missing project layout regions: rail=${JSON.stringify(projectMetrics.projectRail)}, sidebar=${JSON.stringify(projectMetrics.channelSidebar)}, stage=${JSON.stringify(projectMetrics.stage)}`,
|
`missing project layout regions: rail=${JSON.stringify(projectMetrics.projectRail)}, sidebar=${JSON.stringify(projectMetrics.channelSidebar)}, stage=${JSON.stringify(projectMetrics.stage)}`,
|
||||||
|
|||||||
@@ -231,6 +231,10 @@
|
|||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mobile-nav-button {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.dock-icon {
|
.dock-icon {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
@@ -1898,6 +1902,13 @@
|
|||||||
|
|
||||||
.status-user {
|
.status-user {
|
||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-profile {
|
||||||
|
min-width: 0;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
border: 0;
|
border: 0;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: rgba(255, 255, 255, 0.86);
|
color: rgba(255, 255, 255, 0.86);
|
||||||
@@ -1905,10 +1916,22 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-user:hover .status-name {
|
.status-profile:hover .status-name {
|
||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.status-avatar {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
display: inline-grid;
|
||||||
|
flex: 0 0 24px;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.status-tasks {
|
.status-tasks {
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
@@ -2046,8 +2069,77 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 1180px) {
|
@media (max-width: 767px) {
|
||||||
.workbench-shell {
|
.topbar {
|
||||||
min-width: 1180px;
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding: 0 var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar .global-search {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-actions {
|
||||||
|
gap: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-actions .arco-space-item:last-child {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-nav-button {
|
||||||
|
display: inline-flex;
|
||||||
|
min-width: 40px;
|
||||||
|
min-height: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workbench-main {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-rail,
|
||||||
|
.channel-sidebar {
|
||||||
|
position: fixed !important;
|
||||||
|
top: 58px;
|
||||||
|
bottom: 32px;
|
||||||
|
left: 0;
|
||||||
|
z-index: 20;
|
||||||
|
height: auto !important;
|
||||||
|
max-width: calc(100vw - 48px);
|
||||||
|
visibility: hidden;
|
||||||
|
pointer-events: none;
|
||||||
|
transform: translateX(-100%);
|
||||||
|
transition: transform 160ms ease, visibility 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-rail.open,
|
||||||
|
.channel-sidebar.open {
|
||||||
|
visibility: visible;
|
||||||
|
pointer-events: auto;
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 58px 0 32px;
|
||||||
|
z-index: 19;
|
||||||
|
border: 0;
|
||||||
|
background: rgba(29, 33, 41, 0.38);
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage {
|
||||||
|
width: 100%;
|
||||||
|
padding: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusbar {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
padding: 0 var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-system {
|
||||||
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ export function ProjectRail({
|
|||||||
activeProject,
|
activeProject,
|
||||||
projects,
|
projects,
|
||||||
activeView,
|
activeView,
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
onSelectWorkspace,
|
onSelectWorkspace,
|
||||||
onSelectWorkspaceExplore,
|
onSelectWorkspaceExplore,
|
||||||
onSelectProject,
|
onSelectProject,
|
||||||
@@ -17,27 +19,34 @@ export function ProjectRail({
|
|||||||
activeProject: Project
|
activeProject: Project
|
||||||
projects: Project[]
|
projects: Project[]
|
||||||
activeView: WorkbenchView
|
activeView: WorkbenchView
|
||||||
|
open: boolean
|
||||||
|
onClose: () => void
|
||||||
onSelectWorkspace: () => void
|
onSelectWorkspace: () => void
|
||||||
onSelectWorkspaceExplore: () => void
|
onSelectWorkspaceExplore: () => void
|
||||||
onSelectProject: (project: Project) => void
|
onSelectProject: (project: Project) => void
|
||||||
onCreateProject: () => void
|
onCreateProject: () => void
|
||||||
}) {
|
}) {
|
||||||
|
const selectAndClose = (action: () => void) => {
|
||||||
|
action()
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sider className="project-rail" width={96}>
|
<Sider className={open ? 'project-rail open' : 'project-rail'} width={96}>
|
||||||
<button className="rail-brand" aria-label="森林AI 工作台" title="森林AI 工作台" onClick={onSelectWorkspace}>
|
<button className="rail-brand" aria-label="森林AI 工作台" title="森林AI 工作台" onClick={() => selectAndClose(onSelectWorkspace)}>
|
||||||
<img className="brand-icon" src="/senlinai-icon.svg" alt="" />
|
<img className="brand-icon" src="/senlinai-icon.svg" alt="" />
|
||||||
</button>
|
</button>
|
||||||
<Button
|
<Button
|
||||||
className={activeView === 'workspace' ? 'dashboard-button active' : 'dashboard-button'}
|
className={activeView === 'workspace' ? 'dashboard-button active' : 'dashboard-button'}
|
||||||
icon={<IconDashboard />}
|
icon={<IconDashboard />}
|
||||||
onClick={onSelectWorkspace}
|
onClick={() => selectAndClose(onSelectWorkspace)}
|
||||||
aria-label="工作台"
|
aria-label="工作台"
|
||||||
title="工作台"
|
title="工作台"
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
className={activeView === 'workspace-explore' ? 'dashboard-button active' : 'dashboard-button'}
|
className={activeView === 'workspace-explore' ? 'dashboard-button active' : 'dashboard-button'}
|
||||||
icon={<IconCompass />}
|
icon={<IconCompass />}
|
||||||
onClick={onSelectWorkspaceExplore}
|
onClick={() => selectAndClose(onSelectWorkspaceExplore)}
|
||||||
aria-label="探索"
|
aria-label="探索"
|
||||||
title="探索"
|
title="探索"
|
||||||
/>
|
/>
|
||||||
@@ -46,7 +55,7 @@ export function ProjectRail({
|
|||||||
<Badge key={project.id} count={project.badge} dot={false} className={project.urgent ? 'project-badge urgent' : 'project-badge'}>
|
<Badge key={project.id} count={project.badge} dot={false} className={project.urgent ? 'project-badge urgent' : 'project-badge'}>
|
||||||
<button
|
<button
|
||||||
className={activeView === 'project' && activeProject.id === project.id ? 'project-button active' : 'project-button'}
|
className={activeView === 'project' && activeProject.id === project.id ? 'project-button active' : 'project-button'}
|
||||||
onClick={() => onSelectProject(project)}
|
onClick={() => selectAndClose(() => onSelectProject(project))}
|
||||||
style={{ '--project-color': project.color } as CSSProperties}
|
style={{ '--project-color': project.color } as CSSProperties}
|
||||||
title={project.name}
|
title={project.name}
|
||||||
>
|
>
|
||||||
@@ -55,7 +64,7 @@ export function ProjectRail({
|
|||||||
</Badge>
|
</Badge>
|
||||||
))}
|
))}
|
||||||
</Space>
|
</Space>
|
||||||
<Button className="create-project" aria-label="新建项目" icon={<IconPlus />} title="新建项目" onClick={onCreateProject} />
|
<Button className="create-project" aria-label="新建项目" icon={<IconPlus />} title="新建项目" onClick={() => selectAndClose(onCreateProject)} />
|
||||||
</Sider>
|
</Sider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ export function ProjectSidebar({
|
|||||||
activeView,
|
activeView,
|
||||||
activeWorkspace,
|
activeWorkspace,
|
||||||
activeChannel,
|
activeChannel,
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
onSelectChannel,
|
onSelectChannel,
|
||||||
onSelectItem,
|
onSelectItem,
|
||||||
onUpdateProject,
|
onUpdateProject,
|
||||||
@@ -27,12 +29,18 @@ export function ProjectSidebar({
|
|||||||
activeView: WorkbenchView
|
activeView: WorkbenchView
|
||||||
activeWorkspace: ProjectWorkspace
|
activeWorkspace: ProjectWorkspace
|
||||||
activeChannel: ChannelKey
|
activeChannel: ChannelKey
|
||||||
|
open: boolean
|
||||||
|
onClose: () => void
|
||||||
onSelectChannel: (key: ChannelKey) => void
|
onSelectChannel: (key: ChannelKey) => void
|
||||||
onSelectItem: (title: string) => void
|
onSelectItem: (title: string) => void
|
||||||
onUpdateProject: (update: ProjectSettingsUpdate) => void
|
onUpdateProject: (update: ProjectSettingsUpdate) => void
|
||||||
}) {
|
}) {
|
||||||
const workspaceMode = activeView === 'workspace'
|
const workspaceMode = activeView === 'workspace'
|
||||||
const channels = activeWorkspace.channels
|
const channels = activeWorkspace.channels
|
||||||
|
const selectChannelAndClose = (key: ChannelKey) => {
|
||||||
|
onSelectChannel(key)
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
const [settingsOpen, setSettingsOpen] = useState(false)
|
const [settingsOpen, setSettingsOpen] = useState(false)
|
||||||
const [draft, setDraft] = useState({
|
const [draft, setDraft] = useState({
|
||||||
name: activeWorkspace.project.name,
|
name: activeWorkspace.project.name,
|
||||||
@@ -55,7 +63,7 @@ export function ProjectSidebar({
|
|||||||
|
|
||||||
const projectIcon = activeWorkspace.project.short || activeWorkspace.project.identifier || activeWorkspace.project.name.slice(0, 2)
|
const projectIcon = activeWorkspace.project.short || activeWorkspace.project.identifier || activeWorkspace.project.name.slice(0, 2)
|
||||||
return (
|
return (
|
||||||
<Sider className="channel-sidebar" width={248}>
|
<Sider className={open ? 'channel-sidebar open' : 'channel-sidebar'} width={248}>
|
||||||
<div className="project-title">
|
<div className="project-title">
|
||||||
<Space>
|
<Space>
|
||||||
{workspaceMode ? <IconApps /> : <span className="project-title-icon">{projectIcon}</span>}
|
{workspaceMode ? <IconApps /> : <span className="project-title-icon">{projectIcon}</span>}
|
||||||
@@ -79,13 +87,13 @@ export function ProjectSidebar({
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{channels.flatMap((channel) => {
|
{channels.flatMap((channel) => {
|
||||||
const items = [renderChannelButton(channel, activeChannel, onSelectChannel)]
|
const items = [renderChannelButton(channel, activeChannel, selectChannelAndClose)]
|
||||||
if (channel.key === 'cron') {
|
if (channel.key === 'cron') {
|
||||||
items.push(
|
items.push(
|
||||||
<button
|
<button
|
||||||
key="new-channel"
|
key="new-channel"
|
||||||
className={activeChannel === 'new-channel' ? 'channel-button active' : 'channel-button'}
|
className={activeChannel === 'new-channel' ? 'channel-button active' : 'channel-button'}
|
||||||
onClick={() => onSelectChannel('new-channel')}
|
onClick={() => selectChannelAndClose('new-channel')}
|
||||||
>
|
>
|
||||||
<span className="channel-left"><IconPlus /><Text>新建频道</Text></span>
|
<span className="channel-left"><IconPlus /><Text>新建频道</Text></span>
|
||||||
<IconMore />
|
<IconMore />
|
||||||
@@ -102,7 +110,10 @@ export function ProjectSidebar({
|
|||||||
<section className="recent-sessions">
|
<section className="recent-sessions">
|
||||||
<Text className="section-title">最近会话</Text>
|
<Text className="section-title">最近会话</Text>
|
||||||
{activeWorkspace.recentSessions.slice(0, 4).map((session) => (
|
{activeWorkspace.recentSessions.slice(0, 4).map((session) => (
|
||||||
<button key={session.id} onClick={() => onSelectItem(session.title)}>
|
<button key={session.id} onClick={() => {
|
||||||
|
onSelectItem(session.title)
|
||||||
|
onClose()
|
||||||
|
}}>
|
||||||
<Text>{session.title}</Text>
|
<Text>{session.title}</Text>
|
||||||
<Text type="secondary">{session.time}</Text>
|
<Text type="secondary">{session.time}</Text>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -25,17 +25,13 @@ export function ProjectStatusbar() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Footer className="statusbar">
|
<Footer className="statusbar">
|
||||||
<button className="status-user" type="button" onClick={() => setProfileOpen(true)}>
|
<div className="status-user">
|
||||||
<Avatar size={24} style={{ backgroundColor: 'var(--color-primary)' }}>张</Avatar>
|
<button className="status-profile" type="button" onClick={() => setProfileOpen(true)}>
|
||||||
<span className="status-identity">
|
<span className="status-avatar" aria-hidden="true">张</span>
|
||||||
<Text className="status-name">张明</Text>
|
<span className="status-name">张明</span>
|
||||||
<span className="status-online-dot" aria-label="在线" title="在线" />
|
</button>
|
||||||
</span>
|
<Button className="status-upgrade" size="mini" type="primary" onClick={() => setUpgradeOpen(true)}>升级</Button>
|
||||||
<Button className="status-upgrade" size="mini" type="primary" onClick={(event) => {
|
</div>
|
||||||
event.stopPropagation()
|
|
||||||
setUpgradeOpen(true)
|
|
||||||
}}>升级</Button>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<Space className="status-system" size={18}>
|
<Space className="status-system" size={18}>
|
||||||
<span><IconInteractionFallback /> 3 个计划进行中</span>
|
<span><IconInteractionFallback /> 3 个计划进行中</span>
|
||||||
|
|||||||
@@ -1,11 +1,23 @@
|
|||||||
import { Button, Input, Layout, Space, Typography } from '@arco-design/web-react'
|
import { Button, Input, Layout, Space, Typography } from '@arco-design/web-react'
|
||||||
import { IconApps, IconMoon, IconSearch, IconSun } from '@arco-design/web-react/icon'
|
import { IconApps, IconMenuUnfold, IconMoon, IconSearch, IconSun } from '@arco-design/web-react/icon'
|
||||||
import type { Theme } from './project-types'
|
import type { Theme } from './project-types'
|
||||||
|
|
||||||
const { Header } = Layout
|
const { Header } = Layout
|
||||||
const { Text } = Typography
|
const { Text } = Typography
|
||||||
|
|
||||||
export function ProjectTopbar({ theme, onToggleTheme }: { theme: Theme; onToggleTheme: () => void }) {
|
export function ProjectTopbar({
|
||||||
|
theme,
|
||||||
|
onToggleTheme,
|
||||||
|
showChannels,
|
||||||
|
onOpenProjects,
|
||||||
|
onOpenChannels,
|
||||||
|
}: {
|
||||||
|
theme: Theme
|
||||||
|
onToggleTheme: () => void
|
||||||
|
showChannels: boolean
|
||||||
|
onOpenProjects: () => void
|
||||||
|
onOpenChannels: () => void
|
||||||
|
}) {
|
||||||
const desktop = isDesktopRuntime()
|
const desktop = isDesktopRuntime()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -19,6 +31,10 @@ export function ProjectTopbar({ theme, onToggleTheme }: { theme: Theme; onToggle
|
|||||||
<Button size="large" type="primary">搜索</Button>
|
<Button size="large" type="primary">搜索</Button>
|
||||||
</div>
|
</div>
|
||||||
<Space className="topbar-actions">
|
<Space className="topbar-actions">
|
||||||
|
<Button className="mobile-nav-button" aria-label="打开项目导航" icon={<IconApps />} onClick={onOpenProjects} />
|
||||||
|
{showChannels && (
|
||||||
|
<Button className="mobile-nav-button" aria-label="打开频道导航" icon={<IconMenuUnfold />} onClick={onOpenChannels} />
|
||||||
|
)}
|
||||||
<Button aria-label={theme === 'dark' ? '切换浅色模式' : '切换深色模式'} icon={theme === 'dark' ? <IconSun /> : <IconMoon />} onClick={onToggleTheme} />
|
<Button aria-label={theme === 'dark' ? '切换浅色模式' : '切换深色模式'} icon={theme === 'dark' ? <IconSun /> : <IconMoon />} onClick={onToggleTheme} />
|
||||||
{desktop && (
|
{desktop && (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
import { Layout } from '@arco-design/web-react'
|
import { Layout } from '@arco-design/web-react'
|
||||||
import { WorkspacePage, type WorkspaceTaskUpdate } from './workspace-body'
|
import { WorkspacePage, type WorkspaceTaskUpdate } from './workspace-body'
|
||||||
import { WorkspaceExplorePage } from './workspace-explore'
|
import { WorkspaceExplorePage } from './workspace-explore'
|
||||||
@@ -57,6 +58,7 @@ export function ProjectPage({
|
|||||||
onCreateProjectTag: (name: string) => void
|
onCreateProjectTag: (name: string) => void
|
||||||
}) {
|
}) {
|
||||||
const isProject = activeView === 'project'
|
const isProject = activeView === 'project'
|
||||||
|
const [navOpen, setNavOpen] = useState<'projects' | 'channels' | null>(null)
|
||||||
const projects = workspaces.map((workspace) => workspace.project)
|
const projects = workspaces.map((workspace) => workspace.project)
|
||||||
const updateActiveProjectTask = (update: ProjectTaskUpdate) => {
|
const updateActiveProjectTask = (update: ProjectTaskUpdate) => {
|
||||||
onUpdateWorkspaceTask({
|
onUpdateWorkspaceTask({
|
||||||
@@ -72,13 +74,21 @@ export function ProjectPage({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout className="workbench-shell">
|
<Layout className="workbench-shell">
|
||||||
<ProjectTopbar theme={theme} onToggleTheme={onToggleTheme} />
|
<ProjectTopbar
|
||||||
|
theme={theme}
|
||||||
|
onToggleTheme={onToggleTheme}
|
||||||
|
showChannels={isProject}
|
||||||
|
onOpenProjects={() => setNavOpen('projects')}
|
||||||
|
onOpenChannels={() => setNavOpen('channels')}
|
||||||
|
/>
|
||||||
|
|
||||||
<Layout className="workbench-main" hasSider>
|
<Layout className="workbench-main" hasSider>
|
||||||
<ProjectRail
|
<ProjectRail
|
||||||
activeProject={activeWorkspace.project}
|
activeProject={activeWorkspace.project}
|
||||||
projects={projects}
|
projects={projects}
|
||||||
activeView={activeView}
|
activeView={activeView}
|
||||||
|
open={navOpen === 'projects'}
|
||||||
|
onClose={() => setNavOpen(null)}
|
||||||
onSelectWorkspace={onSelectWorkspace}
|
onSelectWorkspace={onSelectWorkspace}
|
||||||
onSelectWorkspaceExplore={onSelectWorkspaceExplore}
|
onSelectWorkspaceExplore={onSelectWorkspaceExplore}
|
||||||
onSelectProject={onSelectProject}
|
onSelectProject={onSelectProject}
|
||||||
@@ -90,12 +100,18 @@ export function ProjectPage({
|
|||||||
activeView={activeView}
|
activeView={activeView}
|
||||||
activeWorkspace={activeWorkspace}
|
activeWorkspace={activeWorkspace}
|
||||||
activeChannel={activeChannel}
|
activeChannel={activeChannel}
|
||||||
|
open={navOpen === 'channels'}
|
||||||
|
onClose={() => setNavOpen(null)}
|
||||||
onSelectChannel={onSelectChannel}
|
onSelectChannel={onSelectChannel}
|
||||||
onSelectItem={onSelectItem}
|
onSelectItem={onSelectItem}
|
||||||
onUpdateProject={onUpdateProject}
|
onUpdateProject={onUpdateProject}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{navOpen && (
|
||||||
|
<button className="nav-backdrop" type="button" aria-label="关闭导航" onClick={() => setNavOpen(null)} />
|
||||||
|
)}
|
||||||
|
|
||||||
<Content className="stage">
|
<Content className="stage">
|
||||||
{activeView === 'workspace' ? (
|
{activeView === 'workspace' ? (
|
||||||
<WorkspacePage workspaces={workspaces} onOpenTask={onOpenTask} onUpdateTask={onUpdateWorkspaceTask} />
|
<WorkspacePage workspaces={workspaces} onOpenTask={onOpenTask} onUpdateTask={onUpdateWorkspaceTask} />
|
||||||
|
|||||||
Reference in New Issue
Block a user