feat: 为桌面折叠侧栏添加菜单提示

PC 折叠侧栏菜单图标增加 NPopover 提示,并保留图标导航能力;展开态和移动端不显示重复提示。
This commit is contained in:
GouJ
2026-09-09 10:18:02 +08:00
committed by GitHub
parent 6dc3a93348
commit c2639ca7af
3 changed files with 117 additions and 60 deletions
+29 -32
View File
@@ -1,61 +1,58 @@
# Design QA
- 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.
- Source visual truth: `/workspace/scratch/6e52996114b8/upload/6c29987c-43a6-4ded-ba86-b66d1c638f51.png`
- Source pixel dimensions: `151 × 506`.
- Browser-rendered implementation: `/workspace/scratch/6e52996114b8/sidebar-popover-pc-final-20260909.jpg`
- Browser-rendered implementation dimensions: `1363 × 936`.
- Focused side-by-side comparison: `/workspace/scratch/6e52996114b8/sidebar-popover-comparison-final-20260909.jpg`
- Implementation CSS viewport: `1363 × 936`, device scale factor `1`.
- State: PC dark theme, collapsed sidebar, pointer hovering the disabled “视觉风格” icon.
## Full-view comparison evidence
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 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.
The implementation preserves the existing collapsed sidebar width, icon order, selected state and main-content boundary. Hovering an icon opens a compact Naive UI popover to the right without shifting the sidebar or content.
## Focused region comparison evidence
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.
The focused comparison places the supplied sidebar crop beside a `320 × 506` crop of the browser render. The source icon spacing, dark surfaces and green selected state remain unchanged. The implementation adds the requested hover label beside the icon; disabled workflow items use the explanatory suffix “(剧本完成后可用)”.
## Required fidelity surfaces
- 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.
- Typography: the application typography is unchanged; popover copy uses the current Naive UI theme and compact UI text sizing.
- Spacing and layout rhythm: the `64px` collapsed sidebar and existing vertical menu rhythm are preserved. Popovers use `placement="right"` and do not alter document flow.
- Colors and visual tokens: dark sidebar surfaces and the selected green state remain unchanged. Popovers inherit the existing dark Naive UI theme.
- Image and asset fidelity: no raster assets were introduced. Existing Lucide menu icons are preserved.
- Copy and content: PC hints use each menu's existing label. Locked entries append “(剧本完成后可用)”; “后端连接” keeps its original copy.
## Findings
- 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.
- The disconnected local backend produces the expected in-page `502` response; it does not affect sidebar layout or popover behavior.
## Primary interactions tested
- 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.
- Collapsed the PC sidebar and hovered an enabled menu icon; “我的剧本” appeared.
- Hovered the disabled “视觉风格” icon; its availability explanation appeared.
- Hovered the bottom settings icon; “后端连接” appeared.
- Clicked an enabled collapsed icon and confirmed navigation to the target workflow route.
- Expanded the PC sidebar and confirmed menu popovers were absent.
- Confirmed through component tests that mobile navigation does not render menu popovers.
## Console errors checked
No application-origin console errors were reported. The cloud browser logged only its own extension metadata messages; these are outside the application.
No application-origin console errors were reported. The cloud browser logged only extension metadata messages outside the application.
## Comparison history
- 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.
- Iteration 1: added native NPopover wrappers and found that the full-width transparent RouterLink intercepted hover on enabled menu icons.
- Iteration 2: made enabled collapsed icons direct RouterLinks, raised their trigger layer above the transparent label and rechecked enabled, disabled and settings hints in the browser.
## Implementation checklist
- [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.
- [x] Add NPopover hints to all PC collapsed sidebar menu icons.
- [x] Preserve click navigation for enabled collapsed icons.
- [x] Explain locked workflow items in their hint copy.
- [x] Keep PC expanded and mobile navigation free of duplicate popovers.
- [x] Preserve sidebar size, spacing, theme and selected state.
final result: passed
+70 -27
View File
@@ -8,8 +8,8 @@ import {
NLayout,
NLayoutHeader,
NMenu,
NPopover,
NScrollbar,
NTooltip,
zhCN,
dateZhCN,
type MenuOption
@@ -49,6 +49,10 @@ const collapsed = ref(narrow.value)
const settingsOpen = ref(false)
/** 桌面折叠只裁切文字;移动端侧栏打开时始终显示完整菜单。 */
const menuCollapsed = false
/** 菜单内部会缓存图标 VNode,切换显示模式时重建一次以同步 Popover 状态。 */
const menuRenderKey = computed(
() => `${narrow.value ? 'mobile' : 'desktop'}-${collapsed.value ? 'collapsed' : 'expanded'}`
)
/** Naive Menu 的桌面折叠宽度;移动端覆盖层不参与正文宽度计算。 */
const siderCollapsedWidth = SIDER_COLLAPSED_WIDTH
/** 侧栏展开宽度仅用于桌面布局。 */
@@ -111,31 +115,62 @@ const workflowItems = [
['storyboard', '分镜设计', Camera],
['production', '镜头生产', Clapperboard]
] as const
/** PC 折叠侧栏只显示图标时渲染 Popover;可用图标同时保留路由跳转能力。 */
function createMenuIcon(icon: typeof FolderOpen, label: string, popoverEnabled: boolean, target?: string) {
return () => {
const trigger = () =>
popoverEnabled && target
? h(RouterLink, { to: target, class: 'admin-menu-popover-trigger' }, () => h(icon, { size: 18 }))
: h('span', { class: 'admin-menu-popover-trigger' }, [h(icon, { size: 18 })])
if (!popoverEnabled) return trigger()
return h(
NPopover,
{ trigger: 'hover', placement: 'right' },
{
trigger,
default: () => label
}
)
}
}
/** 菜单链接使用 RouterLink,保留新标签打开和浏览器导航行为。 */
const menuOptions = computed<MenuOption[]>(() => [
{
key: '/projects',
label: () => h(RouterLink, { to: '/projects' }, () => '我的剧本'),
icon: () => h(FolderOpen, { size: 18 })
},
...(projectId.value
? [
{ type: 'divider' as const, key: 'divider' },
...workflowItems.map(([path, label, icon]) => {
const disabled = path !== 'create-drama' && !workflowsUnlocked.value
return {
key: '/projects/' + projectId.value + '/' + path,
disabled,
label: () =>
disabled
? h('span', { title: '剧本完成后可用', 'aria-disabled': 'true' }, label)
: h(RouterLink, { to: '/projects/' + projectId.value + '/' + path }, () => label),
icon: () => h(icon, { size: 18 })
}
})
]
: [])
])
const menuOptions = computed<MenuOption[]>(() => {
// 仅在 PC 折叠态创建 Popover,避免展开态和移动端出现重复提示。
const popoverEnabled = !narrow.value && collapsed.value
return [
{
key: '/projects',
label: () => h(RouterLink, { to: '/projects' }, () => '我的剧本'),
icon: createMenuIcon(FolderOpen, '我的剧本', popoverEnabled, '/projects')
},
...(projectId.value
? [
{ type: 'divider' as const, key: 'divider' },
...workflowItems.map(([path, label, icon]) => {
const disabled = path !== 'create-drama' && !workflowsUnlocked.value
const target = '/projects/' + projectId.value + '/' + path
return {
key: target,
disabled,
label: () =>
disabled
? h('span', { title: '剧本完成后可用', 'aria-disabled': 'true' }, label)
: h(RouterLink, { to: target }, () => label),
icon: createMenuIcon(
icon,
disabled ? `${label}(剧本完成后可用)` : label,
popoverEnabled,
disabled ? undefined : target
)
}
})
]
: [])
]
})
</script>
<template>
@@ -191,6 +226,7 @@ const menuOptions = computed<MenuOption[]>(() => [
</header>
<NScrollbar class="admin-nav-scroll">
<NMenu
:key="menuRenderKey"
:value="route.path"
:options="menuOptions"
:collapsed="menuCollapsed"
@@ -203,7 +239,7 @@ const menuOptions = computed<MenuOption[]>(() => [
</NScrollbar>
<!-- 以下是侧栏底部后端连接折叠裁切文字图标位置保持不变 -->
<footer class="admin-sider-footer">
<NTooltip placement="right" :disabled="!collapsed">
<NPopover trigger="hover" placement="right" :disabled="narrow || !collapsed">
<template #trigger>
<NButton
quaternary
@@ -218,7 +254,7 @@ const menuOptions = computed<MenuOption[]>(() => [
</NButton>
</template>
后端连接
</NTooltip>
</NPopover>
</footer>
</aside>
<!-- 以下是顶栏与主内容 -->
@@ -304,6 +340,10 @@ const menuOptions = computed<MenuOption[]>(() => [
.admin-sider.is-collapsed .n-menu-item-content-header {
opacity: 0;
}
/* 折叠态将图标抬到透明路由链接上方,使点击与悬停都命中图标触发区。 */
.admin-sider.is-collapsed .n-menu-item-content__icon {
@apply relative z-[2];
}
.admin-sider-footer {
@apply w-full shrink-0 overflow-hidden;
padding-block: 8px max(8px, env(safe-area-inset-bottom));
@@ -325,6 +365,9 @@ const menuOptions = computed<MenuOption[]>(() => [
.admin-sider .n-menu .n-menu-item-content::before {
@apply left-0 right-0 rounded-none;
}
.admin-menu-popover-trigger {
@apply relative z-[1] inline-flex items-center justify-center text-inherit;
}
.admin-brand {
@apply flex shrink-0 items-center gap-3 h-[74px] overflow-hidden py-0 px-5 text-ink whitespace-nowrap;
min-width: var(--admin-sider-expanded);
+18 -1
View File
@@ -2,7 +2,7 @@ import { h } from 'vue'
import { createMemoryHistory, createRouter } from 'vue-router'
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { NConfigProvider, NDrawer, NDrawerContent, NMenu, NModal, NScrollbar, NTooltip } from 'naive-ui'
import { NConfigProvider, NDrawer, NDrawerContent, NMenu, NModal, NPopover, NScrollbar, NTooltip } from 'naive-ui'
import App from '@/App.vue'
import WorkspacePage from '@/components/ui/WorkspacePage.vue'
import AppDialog from '@/components/ui/AppDialog.vue'
@@ -165,9 +165,20 @@ describe('管理后台组件边界', () => {
indent: 22,
rootIndent: 22
})
const firstMenuIcon = () => {
const [option] = wrapper!.getComponent(NMenu).props('options') as Array<{
icon: () => ReturnType<typeof h>
}>
return option!.icon()
}
expect(firstMenuIcon().type).not.toBe(NPopover)
expect(wrapper.get('.admin-sider-footer').getComponent(NPopover).props('disabled')).toBe(true)
await wrapper.get('[aria-label="折叠侧栏"]').trigger('click')
await flushPromises()
expect(wrapper.find('[aria-label="展开侧栏"]').exists()).toBe(true)
expect(wrapper.getComponent(NMenu).props('collapsed')).toBe(false)
expect(firstMenuIcon().type).toBe(NPopover)
expect(wrapper.get('.admin-sider-footer').getComponent(NPopover).props('disabled')).toBe(false)
const settings = wrapper.get('.admin-sider-footer [aria-label="后端连接"]')
expect(settings.text()).toBe('后端连接')
await settings.trigger('click')
@@ -183,6 +194,7 @@ describe('管理后台组件边界', () => {
/\.admin-nav-scroll\.n-scrollbar\s*\{[^}]*flex:\s*1;[^}]*min-height:\s*0;[^}]*min-width:\s*var\(--admin-sider-expanded\)/
)
expect(css).toMatch(/\.admin-sider\.is-collapsed \.n-menu-item-content-header\s*\{[^}]*opacity:\s*0/)
expect(css).toMatch(/\.admin-sider\.is-collapsed \.n-menu-item-content__icon\s*\{[^}]*z-index:\s*2/)
expect(css).toMatch(/\.admin-sider-footer\s*\{[^}]*flex-shrink:\s*0/)
expect(css).toMatch(/\.admin-sider\.is-collapsed \.admin-settings-label\s*\{[^}]*opacity:\s*0/)
expect(css).toMatch(/\.admin-sider\.is-collapsed \.admin-brand span\s*\{[^}]*opacity:\s*0/)
@@ -251,6 +263,11 @@ describe('管理后台组件边界', () => {
indent: 20,
rootIndent: 20
})
const [mobileOption] = wrapper.getComponent(NMenu).props('options') as Array<{
icon: () => ReturnType<typeof h>
}>
expect(mobileOption!.icon().type).not.toBe(NPopover)
expect(wrapper.get('.admin-sider-footer').getComponent(NPopover).props('disabled')).toBe(true)
const collapsedBrand = wrapper.get('.admin-brand')
expect(collapsedBrand.classes()).toContain('is-collapsed')
expect(collapsedBrand.text()).toContain('短剧工作台')