feat: 统一侧栏折叠动画并收口拆解页

This commit is contained in:
GJ
2026-09-04 20:06:40 +08:00
parent 278874e3ee
commit a37b0b613f
7 changed files with 70 additions and 74 deletions
+37 -29
View File
@@ -51,8 +51,8 @@ const narrowMedia = window.matchMedia(NARROW_NAV)
const narrow = ref(narrowMedia.matches) const narrow = ref(narrowMedia.matches)
const collapsed = ref(narrow.value) const collapsed = ref(narrow.value)
const settingsOpen = ref(false) const settingsOpen = ref(false)
/** 窄屏折叠只裁切文字,不把菜单收成图标,避免推挤动画时错位。 */ /** 折叠只裁切文字,不把菜单收成图标,宽屏与窄屏共用同一套宽度动画。 */
const menuCollapsed = computed(() => !narrow.value && collapsed.value) const menuCollapsed = false
/** 当前视口下的折叠侧栏宽度。 */ /** 当前视口下的折叠侧栏宽度。 */
const siderCollapsedWidth = computed(() => (narrow.value ? SIDER_COLLAPSED_WIDTH_NARROW : SIDER_COLLAPSED_WIDTH)) const siderCollapsedWidth = computed(() => (narrow.value ? SIDER_COLLAPSED_WIDTH_NARROW : SIDER_COLLAPSED_WIDTH))
/** 当前视口下的展开侧栏宽度。 */ /** 当前视口下的展开侧栏宽度。 */
@@ -163,14 +163,14 @@ const menuOptions = computed<MenuOption[]>(() => [
:style="{ width: (collapsed ? siderCollapsedWidth : siderExpandedWidth) + 'px' }" :style="{ width: (collapsed ? siderCollapsedWidth : siderExpandedWidth) + 'px' }"
aria-label="主导航" aria-label="主导航"
> >
<!-- 以下是侧栏品牌折叠仅图标展开图标加文字 --> <!-- 以下是侧栏品牌折叠裁切文字图标位置保持不变 -->
<RouterLink <RouterLink
to="/projects" to="/projects"
class="admin-brand" class="admin-brand"
:class="{ 'is-collapsed': collapsed }" :class="{ 'is-collapsed': collapsed }"
aria-label="短剧工作台首页" aria-label="短剧工作台首页"
> >
<Clapperboard :size="narrow ? NAV_ICON_SIZE : 24" /><span v-if="!collapsed" <Clapperboard :size="narrow ? NAV_ICON_SIZE : 24" /><span
>短剧工作台<small>SHORT DRAMA STUDIO</small></span >短剧工作台<small>SHORT DRAMA STUDIO</small></span
> >
</RouterLink> </RouterLink>
@@ -186,7 +186,7 @@ const menuOptions = computed<MenuOption[]>(() => [
:root-indent="navIconIndent" :root-indent="navIconIndent"
/> />
</NScrollbar> </NScrollbar>
<!-- 以下是侧栏底部后端连接折叠仅图标展开图标加文字 --> <!-- 以下是侧栏底部后端连接折叠裁切文字图标位置保持不变 -->
<footer class="admin-sider-footer"> <footer class="admin-sider-footer">
<NTooltip placement="right" :disabled="!collapsed"> <NTooltip placement="right" :disabled="!collapsed">
<template #trigger> <template #trigger>
@@ -199,7 +199,7 @@ const menuOptions = computed<MenuOption[]>(() => [
@click="openSettings" @click="openSettings"
> >
<template #icon><Settings2 :size="18" /></template> <template #icon><Settings2 :size="18" /></template>
<span v-if="!collapsed">后端连接</span> <span class="admin-settings-label">后端连接</span>
</NButton> </NButton>
</template> </template>
后端连接 后端连接
@@ -272,37 +272,57 @@ const menuOptions = computed<MenuOption[]>(() => [
} }
.admin-sider { .admin-sider {
@apply flex flex-col h-full w-[208px] shrink-0 overflow-hidden bg-(--app-subtle); @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.is-collapsed { .admin-sider.is-collapsed {
@apply w-16; @apply w-16;
} }
.admin-nav-scroll.n-scrollbar { .admin-nav-scroll.n-scrollbar {
@apply flex-1 min-h-0; @apply flex-1 min-h-0;
min-width: var(--admin-sider-expanded);
}
.admin-sider .n-menu-item-content-header {
opacity: 1;
transition: opacity 0.2s ease;
}
.admin-sider.is-collapsed .n-menu-item-content-header {
opacity: 0;
} }
.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));
} }
.n-button.admin-settings-button { .n-button.admin-settings-button {
@apply justify-start h-11 w-full px-5; @apply justify-start h-11 w-full overflow-hidden px-5;
} }
.n-button.admin-settings-button.is-collapsed { .n-button.admin-settings-button .n-button__icon {
@apply justify-center px-0; @apply shrink-0;
}
.admin-settings-label {
@apply whitespace-nowrap;
opacity: 1;
transition: opacity 0.2s ease;
}
.admin-sider.is-collapsed .admin-settings-label {
opacity: 0;
} }
.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-brand { .admin-brand {
@apply flex shrink-0 items-center gap-3 h-[74px] 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);
.admin-brand.is-collapsed {
@apply justify-center px-0;
} }
.admin-brand > svg { .admin-brand > svg {
@apply shrink-0; @apply shrink-0;
} }
.admin-brand span { .admin-brand span {
@apply text-[15px] font-semibold; @apply text-[15px] font-semibold;
opacity: 1;
transition: opacity 0.2s ease;
}
.admin-sider.is-collapsed .admin-brand span {
opacity: 0;
} }
.admin-brand small { .admin-brand small {
@apply block text-[8px] tracking-[1px] font-normal text-muted mt-[3px]; @apply block text-[8px] tracking-[1px] font-normal text-muted mt-[3px];
@@ -319,7 +339,6 @@ const menuOptions = computed<MenuOption[]>(() => [
@media (max-width: 800px) { @media (max-width: 800px) {
.admin-sider { .admin-sider {
overflow: hidden; overflow: hidden;
transition: width 0.32s cubic-bezier(0.22, 1, 0.36, 1);
} }
/* 主栏宽度锁在折叠态剩余空间,侧栏变宽时把主栏推到右侧而不是压窄。 */ /* 主栏宽度锁在折叠态剩余空间,侧栏变宽时把主栏推到右侧而不是压窄。 */
.admin-layout > .admin-main { .admin-layout > .admin-main {
@@ -328,21 +347,8 @@ const menuOptions = computed<MenuOption[]>(() => [
min-width: calc(100% - var(--admin-sider-collapsed)); min-width: calc(100% - var(--admin-sider-collapsed));
max-width: calc(100% - var(--admin-sider-collapsed)); max-width: calc(100% - var(--admin-sider-collapsed));
} }
.admin-nav-scroll.n-scrollbar {
min-width: var(--admin-sider-expanded);
}
.admin-brand { .admin-brand {
@apply h-12 w-full overflow-hidden; @apply h-12 justify-start px-3.5;
}
.admin-sider:not(.is-collapsed) .admin-brand {
@apply justify-start px-3.5;
}
.admin-sider .n-menu-item-content-header {
opacity: 1;
transition: opacity 0.2s ease;
}
.admin-sider.is-collapsed .n-menu-item-content-header {
opacity: 0;
} }
.admin-topbar { .admin-topbar {
@apply py-0 px-2.5; @apply py-0 px-2.5;
@@ -350,7 +356,9 @@ const menuOptions = computed<MenuOption[]>(() => [
} }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.admin-sider, .admin-sider,
.admin-sider .n-menu-item-content-header { .admin-sider .n-menu-item-content-header,
.admin-brand span,
.admin-settings-label {
transition: none; transition: none;
} }
} }
+6 -8
View File
@@ -54,19 +54,22 @@ const open = defineModel<boolean>('open', { default: false })
@reference "../../styles/styles.css"; @reference "../../styles/styles.css";
.workspace-tools-drawer .n-drawer-header, .workspace-tools-drawer .n-drawer-header,
.workspace-tools-drawer .n-drawer-footer { .workspace-tools-drawer .n-drawer-footer {
@apply bg-(--app-subtle); @apply bg-(--app-subtle) p-4;
} }
.workspace-tools-drawer .n-drawer-body { .workspace-tools-drawer .n-drawer-body {
@apply bg-(--app-body); @apply bg-(--app-body) p-0;
} }
.workspace-tools-drawer { .workspace-tools-drawer {
--n-header-padding: 16px;
--n-body-padding: 16px;
--n-footer-padding: 16px;
@apply max-w-full; @apply max-w-full;
} }
.workspace-tools-heading { .workspace-tools-heading {
@apply flex items-center justify-between gap-4 w-full text-[15px]; @apply flex items-center justify-between gap-4 w-full text-[15px];
} }
.workspace-tools-body { .workspace-tools-body {
@apply p-5; @apply p-4;
} }
.workspace-tools-body .storyboard-controls, .workspace-tools-body .storyboard-controls,
.workspace-tools-body .production-controls { .workspace-tools-body .production-controls {
@@ -84,9 +87,4 @@ const open = defineModel<boolean>('open', { default: false })
.workspace-tools-body .production-pipeline { .workspace-tools-body .production-pipeline {
@apply grid-cols-[repeat(auto-fit,_minmax(min(100%,_230px),_1fr))]; @apply grid-cols-[repeat(auto-fit,_minmax(min(100%,_230px),_1fr))];
} }
@media (max-width: 760px) {
.workspace-tools-body {
@apply p-3.5;
}
}
</style> </style>
+9 -15
View File
@@ -145,10 +145,7 @@ function exportResult() {
>抽取 {{ summary.completed }} / {{ summary.total }} · 失败 {{ summary.failed }}</span >抽取 {{ summary.completed }} / {{ summary.total }} · 失败 {{ summary.failed }}</span
> >
<span>主体 {{ subjects.length }} · 分镜剧集 {{ shots.length }} / {{ plans.length }}</span> <span>主体 {{ subjects.length }} · 分镜剧集 {{ shots.length }} / {{ plans.length }}</span>
</div>
<div v-if="snapshot || project?.episodes.length" class="toolbar-actions">
<StatusBadge <StatusBadge
v-if="snapshot"
:status="execution?.status || 'unknown'" :status="execution?.status || 'unknown'"
:label=" :label="
execution?.status === 'completed' execution?.status === 'completed'
@@ -158,12 +155,9 @@ function exportResult() {
: '执行状态待确认' : '执行状态待确认'
" "
/> />
<WorkspaceTools </div>
v-if="project?.episodes.length" <div v-if="project?.episodes.length" class="toolbar-actions">
v-model:open="toolsOpen" <WorkspaceTools v-model:open="toolsOpen" title="拆解设置与恢复" label="拆解设置与恢复">
title="拆解设置与恢复"
label="拆解设置与恢复"
>
<section class="panel breakdown-settings-panel p-5 lg:p-6"> <section class="panel breakdown-settings-panel p-5 lg:p-6">
<div class="mb-5 flex flex-wrap items-start justify-between gap-3"> <div class="mb-5 flex flex-wrap items-start justify-between gap-3">
<div> <div>
@@ -423,14 +417,14 @@ function exportResult() {
<!-- 所有结果标签共用有界的内容滚动切换标签回到顶部不继承上一列表的位置 --> <!-- 所有结果标签共用有界的内容滚动切换标签回到顶部不继承上一列表的位置 -->
<NScrollbar :key="tab" class="panel-scroll breakdown-results-scroll"> <NScrollbar :key="tab" class="panel-scroll breakdown-results-scroll">
<template v-for="option in moduleOptions" :key="option.value" <template v-for="option in moduleOptions" :key="option.value"
><div v-if="tab === option.value"> ><div v-if="tab === option.value" class="breakdown-result-pad">
<SubjectList <SubjectList
:project-id="project?.id" :project-id="project?.id"
:subjects="subjects.filter(item => item.module === option.value)" :subjects="subjects.filter(item => item.module === option.value)"
:forms="forms" :forms="forms"
/></div /></div
></template> ></template>
<div v-if="tab === 'storyboard'"> <div v-if="tab === 'storyboard'" class="breakdown-result-pad">
<StoryboardList :plans="plans" :episodes="shots"> <StoryboardList :plans="plans" :episodes="shots">
<template #actions> <template #actions>
<RouterLink :to="`/projects/${project?.id}/storyboard`" class="text-button" <RouterLink :to="`/projects/${project?.id}/storyboard`" class="text-button"
@@ -439,7 +433,7 @@ function exportResult() {
</template> </template>
</StoryboardList> </StoryboardList>
</div> </div>
<div v-if="tab === 'tasks'" class="p-5"> <div v-if="tab === 'tasks'" class="breakdown-result-pad">
<NScrollbar x-scrollable v-if="snapshot?.tasks?.length" class="table-scroll"> <NScrollbar x-scrollable v-if="snapshot?.tasks?.length" class="table-scroll">
<NTable :bordered="false" :single-line="true" striped class="project-table" <NTable :bordered="false" :single-line="true" striped class="project-table"
><thead> ><thead>
@@ -588,6 +582,9 @@ function exportResult() {
.breakdown-results-scroll .n-scrollbar-content { .breakdown-results-scroll .n-scrollbar-content {
@apply wrap-anywhere; @apply wrap-anywhere;
} }
.breakdown-result-pad {
@apply p-4;
}
.breakdown-results-section > .content-with-history.history-hidden { .breakdown-results-section > .content-with-history.history-hidden {
@apply grid-cols-[minmax(0,_1fr)] grid-rows-[minmax(0,_1fr)]; @apply grid-cols-[minmax(0,_1fr)] grid-rows-[minmax(0,_1fr)];
} }
@@ -618,9 +615,6 @@ function exportResult() {
.breakdown-workspace-page .toolbar-actions > * { .breakdown-workspace-page .toolbar-actions > * {
@apply shrink-0; @apply shrink-0;
} }
.breakdown-workspace-page .toolbar-actions .n-tag {
@apply hidden;
}
.breakdown-workspace-page .workspace-tools-label { .breakdown-workspace-page .workspace-tools-label {
@apply hidden; @apply hidden;
} }
@@ -131,7 +131,7 @@ const purposeLabels: Record<string, string> = {
} }
} }
.breakdown-storyboard { .breakdown-storyboard {
@apply pt-4 px-5 pb-5; @apply min-w-0;
} }
.breakdown-storyboard-toolbar { .breakdown-storyboard-toolbar {
@apply flex items-center justify-between flex-wrap gap-y-3 gap-x-6 mb-4; @apply flex items-center justify-between flex-wrap gap-y-3 gap-x-6 mb-4;
@@ -140,14 +140,6 @@ const filtered = computed(() =>
.subject-details[open] summary svg { .subject-details[open] summary svg {
@apply rotate-180; @apply rotate-180;
} }
.subject-list {
@apply p-5;
}
@media (min-width: 1024px) {
.subject-list {
@apply p-7;
}
}
.subject-list-toolbar { .subject-list-toolbar {
@apply flex flex-wrap items-center justify-start gap-y-2.5 gap-x-3 mb-5; @apply flex flex-wrap items-center justify-start gap-y-2.5 gap-x-3 mb-5;
} }
@@ -158,9 +150,6 @@ const filtered = computed(() =>
@apply shrink-0 text-muted text-xs whitespace-nowrap; @apply shrink-0 text-muted text-xs whitespace-nowrap;
} }
@media (max-width: 800px) { @media (max-width: 800px) {
.subject-list {
@apply p-3;
}
.subject-list-toolbar { .subject-list-toolbar {
@apply flex-nowrap gap-2 mb-3; @apply flex-nowrap gap-2 mb-3;
} }
+16 -9
View File
@@ -157,6 +157,7 @@ describe('管理后台组件边界', () => {
expect(wrapper.find('.admin-nav-scroll [aria-label="后端连接"]').exists()).toBe(false) expect(wrapper.find('.admin-nav-scroll [aria-label="后端连接"]').exists()).toBe(false)
expect(wrapper.get('.admin-sider-footer [aria-label="后端连接"]').text()).toBe('后端连接') expect(wrapper.get('.admin-sider-footer [aria-label="后端连接"]').text()).toBe('后端连接')
expect(wrapper.getComponent(NMenu).props()).toMatchObject({ expect(wrapper.getComponent(NMenu).props()).toMatchObject({
collapsed: false,
collapsedWidth: 64, collapsedWidth: 64,
iconSize: 20, iconSize: 20,
collapsedIconSize: 20, collapsedIconSize: 20,
@@ -165,8 +166,9 @@ describe('管理后台组件边界', () => {
}) })
await wrapper.get('[aria-label="折叠侧栏"]').trigger('click') await wrapper.get('[aria-label="折叠侧栏"]').trigger('click')
expect(wrapper.find('[aria-label="展开侧栏"]').exists()).toBe(true) expect(wrapper.find('[aria-label="展开侧栏"]').exists()).toBe(true)
expect(wrapper.getComponent(NMenu).props('collapsed')).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')
await flushPromises() await flushPromises()
expect(document.querySelector('[role="dialog"][aria-label="后端连接"]')).not.toBeNull() expect(document.querySelector('[role="dialog"][aria-label="后端连接"]')).not.toBeNull()
@@ -176,13 +178,18 @@ describe('管理后台组件边界', () => {
it('侧栏菜单独立滚动,页脚不参与滚动且窄屏展开平移主栏', () => { it('侧栏菜单独立滚动,页脚不参与滚动且窄屏展开平移主栏', () => {
const css = readAllStyles() const css = readAllStyles()
expect(css).toMatch(/\.admin-sider\s*\{[^}]*display:\s*flex;[^}]*flex-direction:\s*column;[^}]*height:\s*100%/) expect(css).toMatch(/\.admin-sider\s*\{[^}]*display:\s*flex;[^}]*flex-direction:\s*column;[^}]*height:\s*100%/)
expect(css).toMatch(/\.admin-nav-scroll\.n-scrollbar\s*\{[^}]*flex:\s*1;[^}]*min-height:\s*0/) expect(css).toMatch(
/\.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-footer\s*\{[^}]*flex-shrink:\s*0/) expect(css).toMatch(/\.admin-sider-footer\s*\{[^}]*flex-shrink:\s*0/)
expect(css).toMatch(/\.n-button\.admin-settings-button\.is-collapsed\s*\{[^}]*justify-content:\s*center/) expect(css).toMatch(/\.admin-sider\.is-collapsed \.admin-settings-label\s*\{[^}]*opacity:\s*0/)
expect(css).toMatch(/\.admin-brand\.is-collapsed\s*\{[^}]*justify-content:\s*center/) expect(css).toMatch(/\.admin-sider\.is-collapsed \.admin-brand span\s*\{[^}]*opacity:\s*0/)
expect(css).toMatch(/@media \(max-width: 800px\)[\s\S]*?\.admin-brand\s*\{[^}]*height:\s*48px/) expect(css).toMatch(/@media \(max-width: 800px\)[\s\S]*?\.admin-brand\s*\{[^}]*height:\s*48px/)
expect(css).toContain('@media (max-width: 800px)') expect(css).toContain('@media (max-width: 800px)')
expect(css).toMatch(/transition:\s*width\s+0\.32s/) 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).toContain('calc(100% - var(--admin-sider-collapsed))')
expect(css).toContain('@media (prefers-reduced-motion: reduce)') expect(css).toContain('@media (prefers-reduced-motion: reduce)')
expect(css).not.toContain('.admin-sider-mask') expect(css).not.toContain('.admin-sider-mask')
@@ -237,9 +244,9 @@ describe('管理后台组件边界', () => {
}) })
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()).toBe('') expect(collapsedBrand.text()).toContain('短剧工作台')
const collapsedSettings = wrapper.get('.admin-sider-footer [aria-label="后端连接"]') const collapsedSettings = wrapper.get('.admin-sider-footer [aria-label="后端连接"]')
expect(collapsedSettings.text()).toBe('') expect(collapsedSettings.text()).toBe('后端连接')
expect(collapsedSettings.classes()).toContain('is-collapsed') expect(collapsedSettings.classes()).toContain('is-collapsed')
await wrapper.get('[aria-label="展开侧栏"]').trigger('click') await wrapper.get('[aria-label="展开侧栏"]').trigger('click')
await flushPromises() await flushPromises()
@@ -260,8 +267,8 @@ describe('管理后台组件边界', () => {
await flushPromises() await flushPromises()
expect(wrapper.get('.admin-sider').classes()).toContain('is-collapsed') 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: 48px')
expect(wrapper.get('.admin-brand').text()).toBe('') expect(wrapper.get('.admin-brand').text()).toContain('短剧工作台')
expect(wrapper.get('.admin-sider-footer [aria-label="后端连接"]').text()).toBe('') expect(wrapper.get('.admin-sider-footer [aria-label="后端连接"]').text()).toBe('后端连接')
}) })
it('长弹窗使用内部滚动,提交期间禁止遮罩和 Esc 关闭', async () => { it('长弹窗使用内部滚动,提交期间禁止遮罩和 Esc 关闭', async () => {
@@ -365,7 +365,7 @@ describe('拆解页内容滚动', () => {
it('分镜顶部无叠加操作行,选择标签保持单行且统计操作垂直居中', () => { it('分镜顶部无叠加操作行,选择标签保持单行且统计操作垂直居中', () => {
// 仅保护样式契约,DOM 环境不提供真实布局坐标。 // 仅保护样式契约,DOM 环境不提供真实布局坐标。
const css = readAllStyles() const css = readAllStyles()
expect(css).toMatch(/\.breakdown-storyboard\s*\{[^}]*padding:\s*16px 20px 20px;/) expect(css).toMatch(/\.breakdown-result-pad\s*\{[^}]*padding:\s*16px/)
expect(css).toMatch(/\.breakdown-episode-picker > span\s*\{[^}]*flex-shrink:\s*0;[^}]*white-space:\s*nowrap;/) expect(css).toMatch(/\.breakdown-episode-picker > span\s*\{[^}]*flex-shrink:\s*0;[^}]*white-space:\s*nowrap;/)
expect(css).toMatch(/\.breakdown-storyboard-summary\s*\{[^}]*align-items:\s*center;/) expect(css).toMatch(/\.breakdown-storyboard-summary\s*\{[^}]*align-items:\s*center;/)
}) })