Files
short-drama-agent-front/src/App.vue
T
GouJ 826e3bfa0f fix: 将侧栏提示层移到导航外侧
将桌面折叠侧栏的 NPopover 显式挂载到 body,并把提示框完整偏移到 64px 导航边界之外。
2026-09-09 10:49:10 +08:00

467 lines
17 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, h, onScopeDispose, provide, ref, shallowRef, watch } from 'vue'
import { RouterLink, useRoute } from 'vue-router'
import {
NButton,
NConfigProvider,
NGlobalStyle,
NLayout,
NLayoutHeader,
NMenu,
NPopover,
NScrollbar,
zhCN,
dateZhCN,
type MenuOption
} from 'naive-ui'
import {
Clapperboard,
Camera,
Images,
Palette,
Fingerprint,
FolderOpen,
FileText,
Layers,
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
/** 菜单图标尺寸,折叠/展开共用,避免切换时缩放。 */
const NAV_ICON_SIZE = 20
/** 应用外壳固定在视口内,业务数据仍由各工作区自行加载。 */
const route = useRoute()
const narrowMedia = window.matchMedia(NARROW_NAV)
const narrow = ref(narrowMedia.matches)
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
/** 侧栏展开宽度仅用于桌面布局。 */
const siderExpandedWidth = SIDER_EXPANDED_WIDTH
/** 折叠与展开共用的图标左偏移,避免切换时图标跳动。 */
const navIconIndent = computed(() => (narrow.value ? 20 : siderCollapsedWidth / 2 - NAV_ICON_SIZE / 2))
/** 视口跨过断点时:进入窄屏收回侧栏。 */
function syncNarrow(event: MediaQueryListEvent) {
narrow.value = event.matches
if (event.matches) collapsed.value = true
}
narrowMedia.addEventListener('change', syncNarrow)
onScopeDispose(() => narrowMedia.removeEventListener('change', syncNarrow))
/** 宽屏与窄屏都只切换这一份侧栏的展开态。 */
function toggleNav() {
collapsed.value = !collapsed.value
}
/** 打开后端连接说明;窄屏先收回侧栏给弹窗腾出宽度。 */
function openSettings() {
if (narrow.value) collapsed.value = true
settingsOpen.value = true
}
watch(
() => route.fullPath,
() => {
if (narrow.value) collapsed.value = true
}
)
/** Esc 收回窄屏展开的侧栏。 */
function onKeydown(event: KeyboardEvent) {
if (event.key === 'Escape' && narrow.value && !collapsed.value) collapsed.value = true
}
window.addEventListener('keydown', onKeydown)
onScopeDispose(() => window.removeEventListener('keydown', onKeydown))
/** 顶栏菜单按钮在移动端使用更符合抽屉导航语义的可访问名称。 */
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)
provide(projectAccessKey, projectAccess)
const workflowsUnlocked = computed(
() => projectAccess.value?.projectId === projectId.value && projectAccess.value.completed
)
const apiBase = import.meta.env.VITE_API_BASE_URL || '/api'
const workflowItems = [
['create-drama', '剧本创作', FileText],
['breakdown', '剧本拆解', Layers],
['visual-style', '视觉风格', Palette],
['subject-identity', '主体身份', Fingerprint],
['subject-images', '形态图片', Images],
['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',
to: 'body',
class: 'admin-nav-popover',
zIndex: 3000
},
{
trigger,
default: () => label
}
)
}
}
/** 菜单链接使用 RouterLink,保留新标签打开和浏览器导航行为。 */
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>
<NConfigProvider
:theme="theme"
:theme-overrides="overrides"
:locale="zhCN"
:date-locale="dateZhCN"
class="app-provider"
>
<NGlobalStyle />
<a href="#main-content" class="skip-link">跳到主要内容</a>
<div
class="admin-layout"
:style="{
'--admin-sider-collapsed': siderCollapsedWidth + 'px',
'--admin-sider-expanded': siderExpandedWidth + 'px'
}"
>
<!-- 以下是主导航侧栏桌面参与分栏移动端固定覆盖整个视口 -->
<aside
id="main-navigation"
class="admin-sider"
:class="{ 'is-collapsed': collapsed }"
:style="{
width: narrow ? '100%' : (collapsed ? siderCollapsedWidth : siderExpandedWidth) + 'px'
}"
aria-label="主导航"
:aria-hidden="narrow && collapsed ? 'true' : undefined"
:inert="narrow && collapsed ? true : undefined"
>
<header class="admin-sider-header">
<!-- 以下是侧栏品牌桌面折叠裁切文字移动端始终展示完整品牌 -->
<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
>
</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
:key="menuRenderKey"
:value="route.path"
:options="menuOptions"
:collapsed="menuCollapsed"
:collapsed-width="siderCollapsedWidth"
:icon-size="NAV_ICON_SIZE"
:collapsed-icon-size="NAV_ICON_SIZE"
:indent="navIconIndent"
:root-indent="navIconIndent"
/>
</NScrollbar>
<!-- 以下是侧栏底部后端连接折叠裁切文字图标位置保持不变 -->
<footer class="admin-sider-footer">
<NPopover
trigger="hover"
placement="right"
to="body"
class="admin-nav-popover"
:z-index="3000"
:disabled="narrow || !collapsed"
>
<template #trigger>
<NButton
quaternary
block
class="admin-settings-button"
:class="{ 'is-collapsed': collapsed }"
aria-label="后端连接"
@click="openSettings"
>
<template #icon><Settings2 :size="18" /></template>
<span class="admin-settings-label">后端连接</span>
</NButton>
</template>
后端连接
</NPopover>
</footer>
</aside>
<!-- 以下是顶栏与主内容 -->
<NLayout class="admin-main">
<NLayoutHeader class="admin-topbar">
<div class="flex min-w-0 items-center gap-3">
<NButton
quaternary
class="icon-button"
:aria-label="navToggleLabel"
:title="navToggleLabel"
:aria-expanded="!collapsed"
aria-controls="main-navigation"
@click="toggleNav"
>
<template #icon><Menu :size="20" /></template>
</NButton>
<span class="truncate text-sm">{{ route.meta.title || '工作空间' }}</span>
</div>
<ThemeToggle v-model="preference" />
</NLayoutHeader>
<main id="main-content" tabindex="-1" class="admin-content"><RouterView /></main>
</NLayout>
</div>
<AppDialog v-model:open="settingsOpen" title="后端连接" description="查看当前 API 地址与代理配置。">
<dl class="detail-grid mt-6">
<dt>API 地址</dt>
<dd>
<code>{{ apiBase }}</code>
</dd>
<dt>开发环境</dt>
<dd> .env 中配置 API_PROXY_TARGET修改后重启 Vite</dd>
<dt>生产环境</dt>
<dd> /api/storage 配置反向代理并为长工作流设置足够的超时</dd>
<dt>状态更新</dt>
<dd>已有刷新按钮的页面使用手动更新剧本创作和拆解仍每 6 秒同步项目状态</dd>
</dl>
</AppDialog>
</NConfigProvider>
</template>
<style>
/* 本组件专属布局与 Naive 内部结构覆盖。 */
@reference "styles/styles.css";
.skip-link {
@apply fixed z-[100] -top-[80px] left-[15px] py-2.5 px-[15px] bg-(--app-surface) border-[1px_solid_var(--color-accent)];
}
.skip-link:focus {
@apply top-2.5 z-[3000];
}
.admin-layout {
--admin-sider-collapsed: 64px;
--admin-sider-expanded: 208px;
@apply flex h-dvh overflow-hidden;
}
.admin-main {
@apply min-w-0 h-full flex-1;
}
.admin-main > .n-layout-scroll-container {
@apply h-full flex flex-col overflow-hidden;
}
.admin-sider {
@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;
}
.admin-nav-scroll.n-scrollbar {
@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.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));
}
.n-button.admin-settings-button {
@apply justify-start h-11 w-full overflow-hidden px-5;
}
.n-button.admin-settings-button .n-button__icon {
@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 {
@apply left-0 right-0 rounded-none;
}
.admin-menu-popover-trigger {
@apply relative z-[1] inline-flex items-center justify-center text-inherit;
}
/* 折叠侧栏提示完整落在 64px 边界之外,并覆盖业务内容层。 */
.n-popover.admin-nav-popover {
margin-inline-start: 24px !important;
}
.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);
}
.admin-brand > svg {
@apply shrink-0;
}
.admin-brand span {
@apply text-[15px] font-semibold;
opacity: 1;
transition: opacity 0.2s ease;
}
.admin-sider.is-collapsed .admin-brand span {
opacity: 0;
}
.admin-brand small {
@apply block text-[8px] tracking-[1px] font-normal text-muted mt-[3px];
}
.admin-topbar {
@apply relative z-10 flex items-center justify-between shrink-0 h-11 py-0 px-5 gap-3 bg-(--app-surface);
}
.admin-sider .n-menu-divider {
@apply h-3 bg-transparent;
}
.admin-content {
@apply flex-1 min-h-0 min-w-0 overflow-hidden outline-none;
}
@media (max-width: 800px) {
.admin-sider {
@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 {
@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 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;
}
}
@media (prefers-reduced-motion: reduce) {
.admin-sider,
.admin-sider .n-menu-item-content-header,
.admin-brand span,
.admin-settings-label {
transition: none;
}
}
</style>