feat: 为桌面折叠侧栏添加菜单提示
PC 折叠侧栏菜单图标增加 NPopover 提示,并保留图标导航能力;展开态和移动端不显示重复提示。
This commit is contained in:
+29
-32
@@ -1,61 +1,58 @@
|
|||||||
# Design QA
|
# Design QA
|
||||||
|
|
||||||
- Source visual truth: `/workspace/scratch/6e52996114b8/upload/f7dbeb9e-a47c-4e09-9b5c-c5f885b0d708.png`
|
- Source visual truth: `/workspace/scratch/6e52996114b8/upload/6c29987c-43a6-4ded-ba86-b66d1c638f51.png`
|
||||||
- Source pixel dimensions: `543 × 978`.
|
- Source pixel dimensions: `151 × 506`.
|
||||||
- Browser-rendered open-state comparison: `/workspace/scratch/mobile-sidebar-open-comparison-20260908.jpg`
|
- Browser-rendered implementation: `/workspace/scratch/6e52996114b8/sidebar-popover-pc-final-20260909.jpg`
|
||||||
- Browser-rendered closed-state comparison: `/workspace/scratch/mobile-sidebar-closed-comparison-20260908.jpg`
|
- Browser-rendered implementation dimensions: `1363 × 936`.
|
||||||
- Comparison screenshot dimensions: `1363 × 936`.
|
- Focused side-by-side comparison: `/workspace/scratch/6e52996114b8/sidebar-popover-comparison-final-20260909.jpg`
|
||||||
- Implementation CSS viewport: `543 × 936`, device scale factor `1`.
|
- Implementation CSS viewport: `1363 × 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: PC dark theme, collapsed sidebar, pointer hovering the disabled “视觉风格” icon.
|
||||||
- State: dark-theme project list, with the mobile navigation captured both open and closed.
|
|
||||||
|
|
||||||
## Full-view comparison evidence
|
## 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 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.
|
||||||
|
|
||||||
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
|
## 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
|
## 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.
|
- Typography: the application typography is unchanged; popover copy uses the current Naive UI theme and compact UI text sizing.
|
||||||
- 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.
|
- 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: the drawer continues to use `--app-subtle`, existing selected-menu colors and the current dark-theme tokens.
|
- Colors and visual tokens: dark sidebar surfaces and the selected green state remain unchanged. Popovers inherit the existing dark Naive UI theme.
|
||||||
- Image quality and asset fidelity: no raster assets were added or altered. Menu and close actions use the project's existing Lucide icon package.
|
- Image and asset fidelity: no raster assets were introduced. Existing Lucide menu icons are preserved.
|
||||||
- Copy and content: brand, “我的剧本” and “后端连接” labels are unchanged.
|
- Copy and content: PC hints use each menu's existing label. Locked entries append “(剧本完成后可用)”; “后端连接” keeps its original copy.
|
||||||
|
|
||||||
## Findings
|
## Findings
|
||||||
|
|
||||||
- No actionable P0/P1/P2 visual or responsive issues remain.
|
- 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
|
## Primary interactions tested
|
||||||
|
|
||||||
- Opened the full-screen navigation from the new menu button.
|
- Collapsed the PC sidebar and hovered an enabled menu icon; “我的剧本” appeared.
|
||||||
- Closed the navigation from its internal close button.
|
- Hovered the disabled “视觉风格” icon; its availability explanation appeared.
|
||||||
- Confirmed the hidden navigation is removed from the accessibility tree with `aria-hidden` and `inert`.
|
- Hovered the bottom settings icon; “后端连接” appeared.
|
||||||
- Confirmed the closed-state main content width equals the mobile viewport width.
|
- Clicked an enabled collapsed icon and confirmed navigation to the target workflow route.
|
||||||
- Confirmed desktop behavior remains covered by the existing layout component tests.
|
- 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
|
## 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
|
## Comparison history
|
||||||
|
|
||||||
- Iteration 1: replaced the persistent mobile rail with a fixed full-screen navigation and added the menu/close icon pair.
|
- Iteration 1: added native NPopover wrappers and found that the full-width transparent RouterLink intercepted hover on enabled menu icons.
|
||||||
- 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 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
|
## Implementation checklist
|
||||||
|
|
||||||
- [x] Hide the mobile navigation without reserving horizontal space.
|
- [x] Add NPopover hints to all PC collapsed sidebar menu icons.
|
||||||
- [x] Open navigation as a full-screen fixed layer.
|
- [x] Preserve click navigation for enabled collapsed icons.
|
||||||
- [x] Replace the top toggle glyph with a menu icon.
|
- [x] Explain locked workflow items in their hint copy.
|
||||||
- [x] Provide an internal close control while the top bar is covered.
|
- [x] Keep PC expanded and mobile navigation free of duplicate popovers.
|
||||||
- [x] Preserve automatic close on mobile route changes, settings open and Escape.
|
- [x] Preserve sidebar size, spacing, theme and selected state.
|
||||||
- [x] Preserve desktop collapse/expand behavior.
|
|
||||||
|
|
||||||
final result: passed
|
final result: passed
|
||||||
|
|||||||
+52
-9
@@ -8,8 +8,8 @@ import {
|
|||||||
NLayout,
|
NLayout,
|
||||||
NLayoutHeader,
|
NLayoutHeader,
|
||||||
NMenu,
|
NMenu,
|
||||||
|
NPopover,
|
||||||
NScrollbar,
|
NScrollbar,
|
||||||
NTooltip,
|
|
||||||
zhCN,
|
zhCN,
|
||||||
dateZhCN,
|
dateZhCN,
|
||||||
type MenuOption
|
type MenuOption
|
||||||
@@ -49,6 +49,10 @@ const collapsed = ref(narrow.value)
|
|||||||
const settingsOpen = ref(false)
|
const settingsOpen = ref(false)
|
||||||
/** 桌面折叠只裁切文字;移动端侧栏打开时始终显示完整菜单。 */
|
/** 桌面折叠只裁切文字;移动端侧栏打开时始终显示完整菜单。 */
|
||||||
const menuCollapsed = false
|
const menuCollapsed = false
|
||||||
|
/** 菜单内部会缓存图标 VNode,切换显示模式时重建一次以同步 Popover 状态。 */
|
||||||
|
const menuRenderKey = computed(
|
||||||
|
() => `${narrow.value ? 'mobile' : 'desktop'}-${collapsed.value ? 'collapsed' : 'expanded'}`
|
||||||
|
)
|
||||||
/** Naive Menu 的桌面折叠宽度;移动端覆盖层不参与正文宽度计算。 */
|
/** Naive Menu 的桌面折叠宽度;移动端覆盖层不参与正文宽度计算。 */
|
||||||
const siderCollapsedWidth = SIDER_COLLAPSED_WIDTH
|
const siderCollapsedWidth = SIDER_COLLAPSED_WIDTH
|
||||||
/** 侧栏展开宽度仅用于桌面布局。 */
|
/** 侧栏展开宽度仅用于桌面布局。 */
|
||||||
@@ -111,31 +115,62 @@ const workflowItems = [
|
|||||||
['storyboard', '分镜设计', Camera],
|
['storyboard', '分镜设计', Camera],
|
||||||
['production', '镜头生产', Clapperboard]
|
['production', '镜头生产', Clapperboard]
|
||||||
] as const
|
] 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,保留新标签打开和浏览器导航行为。 */
|
/** 菜单链接使用 RouterLink,保留新标签打开和浏览器导航行为。 */
|
||||||
const menuOptions = computed<MenuOption[]>(() => [
|
const menuOptions = computed<MenuOption[]>(() => {
|
||||||
|
// 仅在 PC 折叠态创建 Popover,避免展开态和移动端出现重复提示。
|
||||||
|
const popoverEnabled = !narrow.value && collapsed.value
|
||||||
|
return [
|
||||||
{
|
{
|
||||||
key: '/projects',
|
key: '/projects',
|
||||||
label: () => h(RouterLink, { to: '/projects' }, () => '我的剧本'),
|
label: () => h(RouterLink, { to: '/projects' }, () => '我的剧本'),
|
||||||
icon: () => h(FolderOpen, { size: 18 })
|
icon: createMenuIcon(FolderOpen, '我的剧本', popoverEnabled, '/projects')
|
||||||
},
|
},
|
||||||
...(projectId.value
|
...(projectId.value
|
||||||
? [
|
? [
|
||||||
{ type: 'divider' as const, key: 'divider' },
|
{ type: 'divider' as const, key: 'divider' },
|
||||||
...workflowItems.map(([path, label, icon]) => {
|
...workflowItems.map(([path, label, icon]) => {
|
||||||
const disabled = path !== 'create-drama' && !workflowsUnlocked.value
|
const disabled = path !== 'create-drama' && !workflowsUnlocked.value
|
||||||
|
const target = '/projects/' + projectId.value + '/' + path
|
||||||
return {
|
return {
|
||||||
key: '/projects/' + projectId.value + '/' + path,
|
key: target,
|
||||||
disabled,
|
disabled,
|
||||||
label: () =>
|
label: () =>
|
||||||
disabled
|
disabled
|
||||||
? h('span', { title: '剧本完成后可用', 'aria-disabled': 'true' }, label)
|
? h('span', { title: '剧本完成后可用', 'aria-disabled': 'true' }, label)
|
||||||
: h(RouterLink, { to: '/projects/' + projectId.value + '/' + path }, () => label),
|
: h(RouterLink, { to: target }, () => label),
|
||||||
icon: () => h(icon, { size: 18 })
|
icon: createMenuIcon(
|
||||||
|
icon,
|
||||||
|
disabled ? `${label}(剧本完成后可用)` : label,
|
||||||
|
popoverEnabled,
|
||||||
|
disabled ? undefined : target
|
||||||
|
)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
]
|
]
|
||||||
: [])
|
: [])
|
||||||
])
|
]
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -191,6 +226,7 @@ const menuOptions = computed<MenuOption[]>(() => [
|
|||||||
</header>
|
</header>
|
||||||
<NScrollbar class="admin-nav-scroll">
|
<NScrollbar class="admin-nav-scroll">
|
||||||
<NMenu
|
<NMenu
|
||||||
|
:key="menuRenderKey"
|
||||||
:value="route.path"
|
:value="route.path"
|
||||||
:options="menuOptions"
|
:options="menuOptions"
|
||||||
:collapsed="menuCollapsed"
|
:collapsed="menuCollapsed"
|
||||||
@@ -203,7 +239,7 @@ const menuOptions = computed<MenuOption[]>(() => [
|
|||||||
</NScrollbar>
|
</NScrollbar>
|
||||||
<!-- 以下是侧栏底部后端连接:折叠裁切文字,图标位置保持不变 -->
|
<!-- 以下是侧栏底部后端连接:折叠裁切文字,图标位置保持不变 -->
|
||||||
<footer class="admin-sider-footer">
|
<footer class="admin-sider-footer">
|
||||||
<NTooltip placement="right" :disabled="!collapsed">
|
<NPopover trigger="hover" placement="right" :disabled="narrow || !collapsed">
|
||||||
<template #trigger>
|
<template #trigger>
|
||||||
<NButton
|
<NButton
|
||||||
quaternary
|
quaternary
|
||||||
@@ -218,7 +254,7 @@ const menuOptions = computed<MenuOption[]>(() => [
|
|||||||
</NButton>
|
</NButton>
|
||||||
</template>
|
</template>
|
||||||
后端连接
|
后端连接
|
||||||
</NTooltip>
|
</NPopover>
|
||||||
</footer>
|
</footer>
|
||||||
</aside>
|
</aside>
|
||||||
<!-- 以下是顶栏与主内容 -->
|
<!-- 以下是顶栏与主内容 -->
|
||||||
@@ -304,6 +340,10 @@ const menuOptions = computed<MenuOption[]>(() => [
|
|||||||
.admin-sider.is-collapsed .n-menu-item-content-header {
|
.admin-sider.is-collapsed .n-menu-item-content-header {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
|
/* 折叠态将图标抬到透明路由链接上方,使点击与悬停都命中图标触发区。 */
|
||||||
|
.admin-sider.is-collapsed .n-menu-item-content__icon {
|
||||||
|
@apply relative z-[2];
|
||||||
|
}
|
||||||
.admin-sider-footer {
|
.admin-sider-footer {
|
||||||
@apply w-full shrink-0 overflow-hidden;
|
@apply w-full shrink-0 overflow-hidden;
|
||||||
padding-block: 8px max(8px, env(safe-area-inset-bottom));
|
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 {
|
.admin-sider .n-menu .n-menu-item-content::before {
|
||||||
@apply left-0 right-0 rounded-none;
|
@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 {
|
.admin-brand {
|
||||||
@apply flex shrink-0 items-center gap-3 h-[74px] overflow-hidden py-0 px-5 text-ink whitespace-nowrap;
|
@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);
|
min-width: var(--admin-sider-expanded);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { h } from 'vue'
|
|||||||
import { createMemoryHistory, createRouter } from 'vue-router'
|
import { createMemoryHistory, createRouter } from 'vue-router'
|
||||||
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
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 App from '@/App.vue'
|
||||||
import WorkspacePage from '@/components/ui/WorkspacePage.vue'
|
import WorkspacePage from '@/components/ui/WorkspacePage.vue'
|
||||||
import AppDialog from '@/components/ui/AppDialog.vue'
|
import AppDialog from '@/components/ui/AppDialog.vue'
|
||||||
@@ -165,9 +165,20 @@ describe('管理后台组件边界', () => {
|
|||||||
indent: 22,
|
indent: 22,
|
||||||
rootIndent: 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 wrapper.get('[aria-label="折叠侧栏"]').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
expect(wrapper.find('[aria-label="展开侧栏"]').exists()).toBe(true)
|
expect(wrapper.find('[aria-label="展开侧栏"]').exists()).toBe(true)
|
||||||
expect(wrapper.getComponent(NMenu).props('collapsed')).toBe(false)
|
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="后端连接"]')
|
const settings = wrapper.get('.admin-sider-footer [aria-label="后端连接"]')
|
||||||
expect(settings.text()).toBe('后端连接')
|
expect(settings.text()).toBe('后端连接')
|
||||||
await settings.trigger('click')
|
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\)/
|
/\.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-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-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-settings-label\s*\{[^}]*opacity:\s*0/)
|
||||||
expect(css).toMatch(/\.admin-sider\.is-collapsed \.admin-brand span\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,
|
indent: 20,
|
||||||
rootIndent: 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')
|
const collapsedBrand = wrapper.get('.admin-brand')
|
||||||
expect(collapsedBrand.classes()).toContain('is-collapsed')
|
expect(collapsedBrand.classes()).toContain('is-collapsed')
|
||||||
expect(collapsedBrand.text()).toContain('短剧工作台')
|
expect(collapsedBrand.text()).toContain('短剧工作台')
|
||||||
|
|||||||
Reference in New Issue
Block a user