diff --git a/apps/web_v1/scripts/visual-check.mjs b/apps/web_v1/scripts/visual-check.mjs index 3044e6e..9d0bdcc 100644 --- a/apps/web_v1/scripts/visual-check.mjs +++ b/apps/web_v1/scripts/visual-check.mjs @@ -22,6 +22,56 @@ page.on('console', (message) => { 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 loginCard = document.querySelector('.login-card') @@ -37,8 +87,14 @@ const getColumnCount = (columns) => columns === 'none' ? 0 : 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 statusbar = document.querySelector('.statusbar') + const statusUser = document.querySelector('.status-user') const statusName = document.querySelector('.status-name') const statusOnlineDot = document.querySelector('.status-online-dot') const search = document.querySelector('.global-search') @@ -86,6 +142,8 @@ const collectMetrics = async () => page.evaluate(() => { return { statusbarHeight: statusbar?.getBoundingClientRect().height, + statusUserTag: statusUser?.tagName, + nestedButtonCount: document.querySelectorAll('button button').length, statusName: rect(statusName), statusOnlineDot: rect(statusOnlineDot), 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 }) 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.waitForTimeout(500) 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 }) 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() const channelSidebarHoverMetrics = await collectMetrics() await page.locator('.stage').hover() @@ -174,7 +271,7 @@ const channelListHoverMetrics = await collectMetrics() const channelPageChecks = [] for (const channel of [ { 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-cron-page', expectedHeading: '计划任务' }, { label: '新建频道', pageClass: 'project-new-channel-page', expectedHeading: '新建频道' }, @@ -183,7 +280,7 @@ for (const channel of [ await page.waitForTimeout(250) channelPageChecks.push(await page.evaluate((expected) => { 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 ?? '' return { ...expected, @@ -194,7 +291,7 @@ for (const channel of [ }, 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 browser.close() @@ -204,6 +301,7 @@ console.log(JSON.stringify({ workspaceMetrics, workspaceExploreMetrics, projectMetrics, + mobileProjectMetrics, channelSidebarHoverMetrics, stageHoverMetrics, channelListHoverMetrics, @@ -214,10 +312,8 @@ console.log(JSON.stringify({ const metrics = workspaceMetrics if (errors.length) failures.push(`console errors: ${errors.join('; ')}`) 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.statusName && metrics.statusOnlineDot && Math.abs(metrics.statusName.top - metrics.statusOnlineDot.top) > 6) { - failures.push(`expected online dot to sit beside username, got name=${JSON.stringify(metrics.statusName)}, dot=${JSON.stringify(metrics.statusOnlineDot)}`) -} +if (metrics.statusUserTag !== 'DIV') failures.push(`status user must be a non-button DIV container, got ${metrics.statusUserTag}`) +if (metrics.nestedButtonCount !== 0) failures.push(`expected no nested buttons, got ${metrics.nestedButtonCount}`) 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') @@ -305,6 +401,7 @@ if (projectMetrics.dashboardButtonActive) failures.push('expected dashboard butt if (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) { failures.push( `missing project layout regions: rail=${JSON.stringify(projectMetrics.projectRail)}, sidebar=${JSON.stringify(projectMetrics.channelSidebar)}, stage=${JSON.stringify(projectMetrics.stage)}`, diff --git a/apps/web_v1/src/App.css b/apps/web_v1/src/App.css index a692a96..440ba69 100644 --- a/apps/web_v1/src/App.css +++ b/apps/web_v1/src/App.css @@ -231,6 +231,10 @@ justify-content: flex-end; } +.mobile-nav-button { + display: none; +} + .dock-icon { position: relative; display: inline-block; @@ -1898,6 +1902,13 @@ .status-user { gap: var(--space-2); +} + +.status-profile { + min-width: 0; + display: inline-flex; + align-items: center; + gap: var(--space-2); border: 0; background: transparent; color: rgba(255, 255, 255, 0.86); @@ -1905,10 +1916,22 @@ cursor: pointer; } -.status-user:hover .status-name { +.status-profile:hover .status-name { 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 { justify-content: center; } @@ -2046,8 +2069,77 @@ } } -@media (max-width: 1180px) { - .workbench-shell { - min-width: 1180px; +@media (max-width: 767px) { + .topbar { + 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; } } diff --git a/apps/web_v1/src/pages/projects/project-rail.tsx b/apps/web_v1/src/pages/projects/project-rail.tsx index aff2fca..6343a08 100644 --- a/apps/web_v1/src/pages/projects/project-rail.tsx +++ b/apps/web_v1/src/pages/projects/project-rail.tsx @@ -9,6 +9,8 @@ export function ProjectRail({ activeProject, projects, activeView, + open, + onClose, onSelectWorkspace, onSelectWorkspaceExplore, onSelectProject, @@ -17,27 +19,34 @@ export function ProjectRail({ activeProject: Project projects: Project[] activeView: WorkbenchView + open: boolean + onClose: () => void onSelectWorkspace: () => void onSelectWorkspaceExplore: () => void onSelectProject: (project: Project) => void onCreateProject: () => void }) { + const selectAndClose = (action: () => void) => { + action() + onClose() + } + return ( - - diff --git a/apps/web_v1/src/pages/projects/project-statusbar.tsx b/apps/web_v1/src/pages/projects/project-statusbar.tsx index 11ef634..e1bf976 100644 --- a/apps/web_v1/src/pages/projects/project-statusbar.tsx +++ b/apps/web_v1/src/pages/projects/project-statusbar.tsx @@ -25,17 +25,13 @@ export function ProjectStatusbar() { return ( <>