fix: 优化移动端全屏侧栏

移动端侧栏改为全屏固定导航,关闭后释放全部正文宽度,并统一使用菜单图标入口。
This commit is contained in:
GouJ
2026-09-08 18:14:15 +08:00
committed by GitHub
parent fbf2690f2a
commit b4e1e6873c
4 changed files with 144 additions and 88 deletions
+33 -37
View File
@@ -1,65 +1,61 @@
# Design QA
- Source visual truth: `/workspace/scratch/6e52996114b8/upload/661cd6c2-dfc4-46ea-ac8b-17630be6c35e.png`
- Source pixel dimensions: `462 × 273`.
- Intended implementation viewport: dark-theme mobile workspace, focused content width around `360px` CSS pixels.
- Implementation screenshot: unavailable.
- CSS size and density normalization: unavailable because the implementation could not be opened in the cloud browser.
- State: 形态图片页包含镜头定位与完整筛选区。
- Source visual truth: `/workspace/scratch/6e52996114b8/upload/f7dbeb9e-a47c-4e09-9b5c-c5f885b0d708.png`
- Source pixel dimensions: `543 × 978`.
- Browser-rendered open-state comparison: `/workspace/scratch/mobile-sidebar-open-comparison-20260908.jpg`
- Browser-rendered closed-state comparison: `/workspace/scratch/mobile-sidebar-closed-comparison-20260908.jpg`
- Comparison screenshot dimensions: `1363 × 936`.
- Implementation CSS viewport: `543 × 936`, device scale factor `1`.
- Density normalization: source and implementation are both displayed at `543px` CSS width; the source's bottom `42px` is outside the comparison viewport and contains no sidebar structure needed for this review.
- State: dark-theme project list, with the mobile navigation captured both open and closed.
## Full-view comparison evidence
The source crop shows the prior three-row mobile filter layout: search on its own row, two selects on the next row, and four icons on a third row. The user requested a denser two-row arrangement.
The source shows a permanently visible `48px` navigation rail that reduces the project-content width. The revised closed state removes that rail entirely: browser geometry reports the main region at `543px`, equal to the `543px` iframe viewport. The top-left control is now a menu icon and the project list uses the full available width.
The implementation now uses a wrapping flex layout at container widths up to `460px`. Search and type occupy the first line; status and the four-icon action group occupy the second line. The status select can shrink from `110px` to `90px`, while the icon group keeps its required width and aligns right.
A local Sites preview started successfully, but the cloud browser rejected `terminal.local` with `net::ERR_BLOCKED_BY_CLIENT`. Therefore no browser-rendered implementation screenshot is available.
The revised open state shows the same navigation as a `543 × 936` fixed full-screen layer. Brand, navigation items and the bottom backend entry remain vertically separated, while an explicit close icon is available in the navigation header.
## Focused region comparison evidence
- Row 1: search flexes into remaining space; type remains `120px` wide.
- Row 2: status starts at `110px` and may shrink to `90px`; icon controls reserve at least `152px` and consume remaining width.
- Spacing: both rows use a shared `10px` gap.
- Overflow protection: status is the only shrinking control on the second row, so the four icon buttons remain complete.
- Accessibility and behavior: filter labels, layout switching, refresh, batch action, and mobile-disabled popovers are unchanged.
These are code-level checks only and do not replace a rendered focused-region comparison.
A separate crop was not needed because both states are rendered at 1:1 CSS width in the full-view comparison and the header controls, navigation item, content boundary and footer action remain readable.
## Required fidelity surfaces
- Fonts and typography: no typography or truncation rules changed.
- Spacing and layout rhythm: the filter area returns to two compact rows with a consistent `10px` gap.
- Colors and visual tokens: existing dark-theme surface and control tokens are unchanged.
- Image quality and asset fidelity: no image behavior or assets changed.
- Copy and content: all search, type, status, and icon action labels are preserved.
- Fonts and typography: existing font family, sizes, weights and truncation behavior are preserved. The drawer uses the same brand and menu typography as the desktop sidebar.
- Spacing and layout rhythm: the closed state removes the previous `48px` content offset. The open state uses the full viewport and preserves the existing header, menu and footer spacing.
- Colors and visual tokens: the drawer continues to use `--app-subtle`, existing selected-menu colors and the current dark-theme tokens.
- Image quality and asset fidelity: no raster assets were added or altered. Menu and close actions use the project's existing Lucide icon package.
- Copy and content: brand, “我的剧本” and “后端连接” labels are unchanged.
## Findings
- [P1] Browser-rendered comparison unavailable
- Location: 形态图片移动端筛选区。
- Evidence: the required cloud browser returns `net::ERR_BLOCKED_BY_CLIENT` for the healthy local preview.
- Impact: exact wrapping, control widths, touch spacing, and console state cannot be visually certified.
- Fix: capture the page in an available browser near `360px` content width and compare the two-row filter layout.
- No actionable P0/P1/P2 visual or responsive issues remain.
- The disconnected local backend produces the expected in-page `502` status in the closed-state project list; it does not affect the navigation layout or interaction.
## Primary interactions tested
Automated tests verify the mobile wrapping flex layout, search/type sizing, shrinkable status width, protected icon-group width, filter behavior, icon ordering, layout switching, and refresh workflow. All 275 tests pass.
- Opened the full-screen navigation from the new menu button.
- Closed the navigation from its internal close button.
- Confirmed the hidden navigation is removed from the accessibility tree with `aria-hidden` and `inert`.
- Confirmed the closed-state main content width equals the mobile viewport width.
- Confirmed desktop behavior remains covered by the existing layout component tests.
## Console errors checked
Not checked because the page could not be opened in the cloud browser.
No application-origin console errors were reported. The cloud browser logged only its own extension metadata messages; these are outside the application.
## Comparison history
- Iteration 1: the previous three-row layout avoided crowding but used more vertical space than requested.
- Iteration 2: consolidated the controls into two rows and made only the status select shrinkable; the full test/build suite passed, while browser capture remained blocked.
- Iteration 1: replaced the persistent mobile rail with a fixed full-screen navigation and added the menu/close icon pair.
- Iteration 2: verified the open state at `543 × 936`, then closed it through the internal action and verified the content region expanded to the full `543px` viewport width.
## Implementation checklist
- [x] Keep search and type on the first mobile row.
- [x] Keep status and all four icons on the second mobile row.
- [x] Allow status to shrink before icon controls are affected.
- [x] Preserve existing filter and action behavior.
- [ ] Capture and compare the rendered mobile state in the cloud browser.
- [x] Hide the mobile navigation without reserving horizontal space.
- [x] Open navigation as a full-screen fixed layer.
- [x] Replace the top toggle glyph with a menu icon.
- [x] Provide an internal close control while the top bar is covered.
- [x] Preserve automatic close on mobile route changes, settings open and Escape.
- [x] Preserve desktop collapse/expand behavior.
final result: blocked
final result: passed
+84 -39
View File
@@ -23,25 +23,21 @@ import {
FolderOpen,
FileText,
Layers,
PanelLeftClose,
PanelLeftOpen,
Settings2
Menu,
Settings2,
X
} from '@lucide/vue'
import { AppDialog } from './components/ui'
import { useTheme } from './composables/useTheme'
import ThemeToggle from './components/ui/ThemeToggle.vue'
import { projectAccessKey, type ProjectAccess } from './features/projects/access'
/** 窄屏与桌面共用同一侧栏;窄屏展开时主栏右移,宽度保持折叠时的尺寸。 */
/** 窄屏将侧栏改为全屏覆盖层,关闭后不再占用正文宽度。 */
const NARROW_NAV = '(max-width: 800px)'
/** 折叠侧栏宽度,与菜单 collapsed-width 对齐。 */
const SIDER_COLLAPSED_WIDTH = 64
/** 展开侧栏宽度,窄屏裁切动画以内层保持该宽度。 */
/** 桌面端展开侧栏宽度。 */
const SIDER_EXPANDED_WIDTH = 208
/** 窄屏折叠侧栏宽度,给正文多留空间,仍覆盖 44px 点击区。 */
const SIDER_COLLAPSED_WIDTH_NARROW = 48
/** 窄屏展开侧栏宽度。 */
const SIDER_EXPANDED_WIDTH_NARROW = 168
/** 菜单图标尺寸,折叠/展开共用,避免切换时缩放。 */
const NAV_ICON_SIZE = 20
@@ -51,14 +47,14 @@ const narrowMedia = window.matchMedia(NARROW_NAV)
const narrow = ref(narrowMedia.matches)
const collapsed = ref(narrow.value)
const settingsOpen = ref(false)
/** 折叠只裁切文字,不把菜单收成图标,宽屏与窄屏共用同一套宽度动画。 */
/** 桌面折叠只裁切文字;移动端侧栏打开时始终显示完整菜单。 */
const menuCollapsed = false
/** 当前视口下的折叠侧栏宽度。 */
const siderCollapsedWidth = computed(() => (narrow.value ? SIDER_COLLAPSED_WIDTH_NARROW : SIDER_COLLAPSED_WIDTH))
/** 当前视口下的展开侧栏宽度。 */
const siderExpandedWidth = computed(() => (narrow.value ? SIDER_EXPANDED_WIDTH_NARROW : SIDER_EXPANDED_WIDTH))
/** Naive Menu 的桌面折叠宽度;移动端覆盖层不参与正文宽度计算。 */
const siderCollapsedWidth = SIDER_COLLAPSED_WIDTH
/** 侧栏展开宽度仅用于桌面布局。 */
const siderExpandedWidth = SIDER_EXPANDED_WIDTH
/** 折叠与展开共用的图标左偏移,避免切换时图标跳动。 */
const navIconIndent = computed(() => siderCollapsedWidth.value / 2 - NAV_ICON_SIZE / 2)
const navIconIndent = computed(() => (narrow.value ? 20 : siderCollapsedWidth / 2 - NAV_ICON_SIZE / 2))
/** 视口跨过断点时:进入窄屏收回侧栏。 */
function syncNarrow(event: MediaQueryListEvent) {
@@ -93,8 +89,11 @@ function onKeydown(event: KeyboardEvent) {
window.addEventListener('keydown', onKeydown)
onScopeDispose(() => window.removeEventListener('keydown', onKeydown))
/** 顶栏切换按钮的可访问名称。 */
const navToggleLabel = computed(() => (collapsed.value ? '展开侧栏' : '折叠侧栏'))
/** 顶栏菜单按钮在移动端使用更符合抽屉导航语义的可访问名称。 */
const navToggleLabel = computed(() => {
if (narrow.value) return collapsed.value ? '打开主菜单' : '关闭主菜单'
return collapsed.value ? '展开侧栏' : '折叠侧栏'
})
const { preference, theme, overrides } = useTheme()
const projectId = computed(() => String(route.params.projectId || ''))
const projectAccess = shallowRef<ProjectAccess | null>(null)
@@ -156,24 +155,40 @@ const menuOptions = computed<MenuOption[]>(() => [
'--admin-sider-expanded': siderExpandedWidth + 'px'
}"
>
<!-- 以下是主导航侧栏宽屏与窄屏共用同一份 -->
<!-- 以下是主导航侧栏桌面参与分栏移动端固定覆盖整个视口 -->
<aside
id="main-navigation"
class="admin-sider"
:class="{ 'is-collapsed': collapsed }"
:style="{ width: (collapsed ? siderCollapsedWidth : siderExpandedWidth) + 'px' }"
:style="{
width: narrow ? '100%' : (collapsed ? siderCollapsedWidth : siderExpandedWidth) + 'px'
}"
aria-label="主导航"
:aria-hidden="narrow && collapsed ? 'true' : undefined"
:inert="narrow && collapsed ? true : undefined"
>
<!-- 以下是侧栏品牌折叠裁切文字图标位置保持不变 -->
<RouterLink
to="/projects"
class="admin-brand"
:class="{ 'is-collapsed': collapsed }"
aria-label="短剧工作台首页"
>
<Clapperboard :size="narrow ? NAV_ICON_SIZE : 24" /><span
>短剧工作台<small>SHORT DRAMA STUDIO</small></span
<header class="admin-sider-header">
<!-- 以下是侧栏品牌桌面折叠裁切文字移动端始终展示完整品牌 -->
<RouterLink
to="/projects"
class="admin-brand"
:class="{ 'is-collapsed': collapsed }"
aria-label="短剧工作台首页"
>
</RouterLink>
<Clapperboard :size="narrow ? NAV_ICON_SIZE : 24" /><span
>短剧工作台<small>SHORT DRAMA STUDIO</small></span
>
</RouterLink>
<!-- 移动端全屏侧栏覆盖顶栏因此在侧栏内部提供明确关闭入口 -->
<NButton
quaternary
class="icon-button mobile-sider-close"
aria-label="关闭主菜单"
@click="toggleNav"
>
<template #icon><X :size="20" /></template>
</NButton>
</header>
<NScrollbar class="admin-nav-scroll">
<NMenu
:value="route.path"
@@ -216,11 +231,10 @@ const menuOptions = computed<MenuOption[]>(() => [
:aria-label="navToggleLabel"
:title="navToggleLabel"
:aria-expanded="!collapsed"
aria-controls="main-navigation"
@click="toggleNav"
>
<template #icon
><PanelLeftOpen v-if="collapsed" :size="18" /><PanelLeftClose v-else :size="18"
/></template>
<template #icon><Menu :size="20" /></template>
</NButton>
<span class="truncate text-sm">{{ route.meta.title || '工作空间' }}</span>
</div>
@@ -270,6 +284,12 @@ const menuOptions = computed<MenuOption[]>(() => [
@apply flex flex-col h-full w-[208px] shrink-0 overflow-hidden bg-(--app-subtle);
transition: width 0.32s cubic-bezier(0.22, 1, 0.36, 1);
}
.admin-sider-header {
@apply flex shrink-0 items-center overflow-hidden;
}
.mobile-sider-close {
@apply hidden shrink-0;
}
.admin-sider.is-collapsed {
@apply w-16;
}
@@ -334,17 +354,42 @@ const menuOptions = computed<MenuOption[]>(() => [
}
@media (max-width: 800px) {
.admin-sider {
overflow: hidden;
@apply fixed inset-0 z-50 overflow-hidden;
width: 100% !important;
transform: translateX(0);
transition:
transform 0.28s cubic-bezier(0.22, 1, 0.36, 1),
visibility 0s;
}
/* 收起后整块导航移出视口且禁止交互,让正文使用完整屏幕宽度。 */
.admin-sider.is-collapsed {
transform: translateX(-100%);
visibility: hidden;
pointer-events: none;
transition:
transform 0.28s cubic-bezier(0.22, 1, 0.36, 1),
visibility 0s 0.28s;
}
/* 主栏宽度锁在折叠态剩余空间,侧栏变宽时把主栏推到右侧而不是压窄。 */
.admin-layout > .admin-main {
flex: 0 0 auto;
width: calc(100% - var(--admin-sider-collapsed));
min-width: calc(100% - var(--admin-sider-collapsed));
max-width: calc(100% - var(--admin-sider-collapsed));
@apply w-full min-w-0 max-w-full flex-1;
}
.admin-nav-scroll.n-scrollbar,
.admin-brand {
min-width: 100%;
}
.admin-sider-header {
@apply h-12;
}
.admin-brand {
@apply h-12 justify-start px-3.5;
@apply h-12 min-w-0 flex-1 justify-start px-3.5;
}
.admin-sider.is-collapsed .admin-brand span,
.admin-sider.is-collapsed .n-menu-item-content-header,
.admin-sider.is-collapsed .admin-settings-label {
opacity: 1;
}
.mobile-sider-close {
@apply flex mr-2;
}
.admin-topbar {
@apply py-0 px-2.5;
+25 -12
View File
@@ -132,6 +132,7 @@ describe('管理后台组件边界', () => {
expect(wrapper.getComponent(ThemeToggle).findComponent(NTooltip).exists()).toBe(false)
expect(wrapper.get('.theme-toggle').attributes('title')).toBeUndefined()
expect(wrapper.get('.theme-toggle svg').classes()).toContain('lucide-monitor')
expect(wrapper.get('.admin-topbar [aria-controls="main-navigation"] svg').classes()).toContain('lucide-menu')
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
for (const [key, label, icon] of [
['dark', '暗黑模式', 'moon'],
@@ -175,7 +176,7 @@ describe('管理后台组件边界', () => {
expect(wrapper.findAll('.admin-nav-scroll .n-menu .n-menu-item')).toHaveLength(8)
})
it('侧栏菜单独立滚动,页脚不参与滚动且窄屏展开平移主栏', () => {
it('侧栏菜单独立滚动,窄屏改为不占正文宽度的全屏固定层', () => {
const css = readAllStyles()
expect(css).toMatch(/\.admin-sider\s*\{[^}]*display:\s*flex;[^}]*flex-direction:\s*column;[^}]*height:\s*100%/)
expect(css).toMatch(
@@ -190,7 +191,14 @@ describe('管理后台组件边界', () => {
expect(css).toMatch(/\.admin-sider\s*\{[^}]*transition:\s*width\s+0\.32s/)
expect(css).toMatch(/\.workspace-tools-body\s*\{[^}]*padding:\s*16px/)
expect(css).toMatch(/\.workspace-tools-drawer \.n-drawer-header[\s\S]*?padding:\s*16px/)
expect(css).toContain('calc(100% - var(--admin-sider-collapsed))')
expect(css).toMatch(
/@media \(max-width: 800px\)[\s\S]*?\.admin-sider\s*\{[^}]*position:\s*fixed;[^}]*inset:\s*0;[^}]*width:\s*100%\s*!important;[^}]*transform:\s*translateX\(0\)/
)
expect(css).toMatch(
/\.admin-sider\.is-collapsed\s*\{[^}]*transform:\s*translateX\(-100%\);[^}]*visibility:\s*hidden;[^}]*pointer-events:\s*none/
)
expect(css).toMatch(/\.admin-layout > \.admin-main\s*\{[^}]*width:\s*100%;[^}]*min-width:\s*0/)
expect(css).toMatch(/\.mobile-sider-close\s*\{[^}]*display:\s*flex;[^}]*margin-right:\s*8px/)
expect(css).toContain('@media (prefers-reduced-motion: reduce)')
expect(css).not.toContain('.admin-sider-mask')
expect(css).not.toContain('.admin-sider.is-overlay')
@@ -201,7 +209,7 @@ describe('管理后台组件边界', () => {
}
})
it('窄屏展开导航复用同一侧栏并右移主栏,不压缩主栏宽度', async () => {
it('窄屏导航关闭时移出视口,打开后全屏显示并可从内部关闭', async () => {
const realMatchMedia = window.matchMedia.bind(window)
vi.stubGlobal('matchMedia', (query: string) => {
if (query.includes('max-width: 800px')) {
@@ -234,13 +242,14 @@ describe('管理后台组件边界', () => {
await flushPromises()
expect(wrapper.findAll('.admin-sider')).toHaveLength(1)
expect(wrapper.get('.admin-sider').classes()).toContain('is-collapsed')
expect(wrapper.get('.admin-sider').attributes('style')).toContain('width: 48px')
expect(wrapper.get('.admin-sider').attributes('style')).toContain('width: 100%')
expect(wrapper.get('.admin-sider').attributes('aria-hidden')).toBe('true')
expect(wrapper.find('.admin-sider-mask').exists()).toBe(false)
expect(wrapper.find('.admin-nav-drawer').exists()).toBe(false)
expect(wrapper.getComponent(NMenu).props()).toMatchObject({
collapsedWidth: 48,
indent: 14,
rootIndent: 14
collapsedWidth: 64,
indent: 20,
rootIndent: 20
})
const collapsedBrand = wrapper.get('.admin-brand')
expect(collapsedBrand.classes()).toContain('is-collapsed')
@@ -248,25 +257,29 @@ describe('管理后台组件边界', () => {
const collapsedSettings = wrapper.get('.admin-sider-footer [aria-label="后端连接"]')
expect(collapsedSettings.text()).toBe('后端连接')
expect(collapsedSettings.classes()).toContain('is-collapsed')
await wrapper.get('[aria-label="展开侧栏"]').trigger('click')
expect(wrapper.get('.admin-topbar [aria-label="打开主菜单"] svg').classes()).toContain('lucide-menu')
await wrapper.get('.admin-topbar [aria-label="打开主菜单"]').trigger('click')
await flushPromises()
expect(wrapper.findAll('.admin-sider')).toHaveLength(1)
expect(wrapper.get('.admin-sider').classes()).not.toContain('is-collapsed')
expect(wrapper.get('.admin-sider').classes()).not.toContain('is-overlay')
expect(wrapper.get('.admin-sider').attributes('style')).toContain('width: 168px')
expect(wrapper.get('.admin-sider').attributes('style')).toContain('width: 100%')
expect(wrapper.get('.admin-sider').attributes('aria-hidden')).toBeUndefined()
expect(wrapper.find('.admin-sider-mask').exists()).toBe(false)
expect(wrapper.get('.admin-sider').findAll('.n-menu .n-menu-item')).toHaveLength(8)
expect(wrapper.get('[aria-label="折叠侧栏"]').attributes('aria-expanded')).toBe('true')
expect(wrapper.get('.admin-topbar [aria-label="关闭主菜单"]').attributes('aria-expanded')).toBe('true')
const expandedBrand = wrapper.get('.admin-brand')
expect(expandedBrand.classes()).not.toContain('is-collapsed')
expect(expandedBrand.text()).toContain('短剧工作台')
const expandedSettings = wrapper.get('.admin-sider-footer [aria-label="后端连接"]')
expect(expandedSettings.text()).toBe('后端连接')
expect(expandedSettings.classes()).not.toContain('is-collapsed')
await wrapper.get('[aria-label="折叠侧栏"]').trigger('click')
expect(wrapper.get('.mobile-sider-close').attributes('aria-label')).toBe('关闭主菜单')
await wrapper.get('.mobile-sider-close').trigger('click')
await flushPromises()
expect(wrapper.get('.admin-sider').classes()).toContain('is-collapsed')
expect(wrapper.get('.admin-sider').attributes('style')).toContain('width: 48px')
expect(wrapper.get('.admin-sider').attributes('style')).toContain('width: 100%')
expect(wrapper.get('.admin-sider').attributes('aria-hidden')).toBe('true')
expect(wrapper.get('.admin-brand').text()).toContain('短剧工作台')
expect(wrapper.get('.admin-sider-footer [aria-label="后端连接"]').text()).toBe('后端连接')
})
+2
View File
@@ -12,6 +12,8 @@ export default defineConfig(({ mode }) => {
server: {
/** 监听所有网卡,使 Vite 启动时输出可访问的局域网 IP。 */
host: true,
/** 允许 Work Mode 通过本地预览域名进行移动端视觉验收。 */
allowedHosts: ['terminal.local'],
port: 4312,
proxy: {
'/api': {