Files
short-drama-agent-front/src/App.vue
T

362 lines
13 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,
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>