362 lines
13 KiB
Vue
362 lines
13 KiB
Vue
<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,
|
||
NScrollbar,
|
||
NTooltip,
|
||
zhCN,
|
||
dateZhCN,
|
||
type MenuOption
|
||
} from 'naive-ui'
|
||
import {
|
||
Clapperboard,
|
||
Camera,
|
||
Images,
|
||
Palette,
|
||
Fingerprint,
|
||
FolderOpen,
|
||
FileText,
|
||
Layers,
|
||
PanelLeftClose,
|
||
PanelLeftOpen,
|
||
Settings2
|
||
} 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
|
||
|
||
/** 应用外壳固定在视口内,业务数据仍由各工作区自行加载。 */
|
||
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
|
||
/** 当前视口下的折叠侧栏宽度。 */
|
||
const siderCollapsedWidth = computed(() => (narrow.value ? SIDER_COLLAPSED_WIDTH_NARROW : SIDER_COLLAPSED_WIDTH))
|
||
/** 当前视口下的展开侧栏宽度。 */
|
||
const siderExpandedWidth = computed(() => (narrow.value ? SIDER_EXPANDED_WIDTH_NARROW : SIDER_EXPANDED_WIDTH))
|
||
/** 折叠与展开共用的图标左偏移,避免切换时图标跳动。 */
|
||
const navIconIndent = computed(() => siderCollapsedWidth.value / 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(() => (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
|
||
/** 菜单链接使用 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 })
|
||
}
|
||
})
|
||
]
|
||
: [])
|
||
])
|
||
</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
|
||
class="admin-sider"
|
||
:class="{ 'is-collapsed': collapsed }"
|
||
:style="{ width: (collapsed ? siderCollapsedWidth : siderExpandedWidth) + 'px' }"
|
||
aria-label="主导航"
|
||
>
|
||
<!-- 以下是侧栏品牌:折叠裁切文字,图标位置保持不变 -->
|
||
<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>
|
||
<NScrollbar class="admin-nav-scroll">
|
||
<NMenu
|
||
: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">
|
||
<NTooltip placement="right" :disabled="!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>
|
||
后端连接
|
||
</NTooltip>
|
||
</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"
|
||
@click="toggleNav"
|
||
>
|
||
<template #icon
|
||
><PanelLeftOpen v-if="collapsed" :size="18" /><PanelLeftClose v-else :size="18"
|
||
/></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.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-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-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 {
|
||
overflow: hidden;
|
||
}
|
||
/* 主栏宽度锁在折叠态剩余空间,侧栏变宽时把主栏推到右侧而不是压窄。 */
|
||
.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));
|
||
}
|
||
.admin-brand {
|
||
@apply h-12 justify-start px-3.5;
|
||
}
|
||
.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>
|