新增: 左侧导航支持收缩展开(手动+小窗口自动)
- 手动收缩:brand 区右侧收缩按钮,收缩后底部展开按钮 - 小窗口自动收缩:窗口宽度≤768px 自动切换为图标模式 - 状态持久化:用户偏好存 appSettings(df-sidebar-collapsed) - 收缩态:仅显示图标,hover 显示 tooltip 文字 - 过渡动画:width/min-width 0.2s ease 平滑收缩
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<aside class="df-sidebar">
|
<aside class="df-sidebar" :class="{ 'df-sidebar--collapsed': collapsed }">
|
||||||
<!-- Logo -->
|
<!-- Logo -->
|
||||||
<div class="sidebar-brand">
|
<div class="sidebar-brand">
|
||||||
<div class="brand-mark">
|
<div class="brand-mark">
|
||||||
@@ -10,74 +10,118 @@
|
|||||||
</linearGradient></defs>
|
</linearGradient></defs>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<span class="brand-text">DevFlow</span>
|
<span v-if="!collapsed" class="brand-text">DevFlow</span>
|
||||||
<span class="brand-version">v0.1</span>
|
<span v-if="!collapsed" class="brand-version">v0.1</span>
|
||||||
|
<button v-if="!collapsed" class="collapse-btn" @click="toggleCollapsed" :title="$t('nav.collapse')">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"/></svg>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 收缩态展开按钮(底部固定位置,仅在 collapsed 时显示) -->
|
||||||
|
<button v-if="collapsed" class="expand-btn" @click="toggleCollapsed" :title="$t('nav.expand')">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
<!-- Navigation -->
|
<!-- Navigation -->
|
||||||
<nav class="sidebar-nav">
|
<nav class="sidebar-nav">
|
||||||
<div class="nav-group">
|
<div class="nav-group">
|
||||||
<span class="nav-group-label">{{ $t('nav.workspace') }}</span>
|
<span v-if="!collapsed" class="nav-group-label">{{ $t('nav.workspace') }}</span>
|
||||||
<router-link
|
<router-link
|
||||||
v-for="item in primaryNav" :key="item.path"
|
v-for="item in primaryNav" :key="item.path"
|
||||||
:to="item.path"
|
:to="item.path"
|
||||||
class="nav-link"
|
class="nav-link"
|
||||||
active-class="nav-link--active"
|
active-class="nav-link--active"
|
||||||
|
:title="collapsed ? $t(item.label) : undefined"
|
||||||
>
|
>
|
||||||
<span class="nav-link-icon" v-html="item.svg"></span>
|
<span class="nav-link-icon" v-html="item.svg"></span>
|
||||||
<span class="nav-link-text">{{ $t(item.label) }}</span>
|
<span v-if="!collapsed" class="nav-link-text">{{ $t(item.label) }}</span>
|
||||||
<span v-if="item.badge" class="nav-link-badge">{{ item.badge }}</span>
|
<span v-if="!collapsed && item.badge" class="nav-link-badge">{{ item.badge }}</span>
|
||||||
</router-link>
|
</router-link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="nav-divider"></div>
|
<div class="nav-divider"></div>
|
||||||
|
|
||||||
<div class="nav-group">
|
<div class="nav-group">
|
||||||
<span class="nav-group-label">{{ $t('nav.traceability') }}</span>
|
<span v-if="!collapsed" class="nav-group-label">{{ $t('nav.traceability') }}</span>
|
||||||
<router-link
|
<router-link
|
||||||
v-for="item in secondaryNav" :key="item.path"
|
v-for="item in secondaryNav" :key="item.path"
|
||||||
:to="item.path"
|
:to="item.path"
|
||||||
class="nav-link"
|
class="nav-link"
|
||||||
active-class="nav-link--active"
|
active-class="nav-link--active"
|
||||||
|
:title="collapsed ? $t(item.label) : undefined"
|
||||||
>
|
>
|
||||||
<span class="nav-link-icon" v-html="item.svg"></span>
|
<span class="nav-link-icon" v-html="item.svg"></span>
|
||||||
<span class="nav-link-text">{{ $t(item.label) }}</span>
|
<span v-if="!collapsed" class="nav-link-text">{{ $t(item.label) }}</span>
|
||||||
<span v-if="item.path === '/knowledge' && candidateCount > 0" class="nav-link-badge">{{ candidateCount }}</span>
|
<span v-if="!collapsed && item.path === '/knowledge' && candidateCount > 0" class="nav-link-badge">{{ candidateCount }}</span>
|
||||||
</router-link>
|
</router-link>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<!-- Footer -->
|
<!-- Footer -->
|
||||||
<div class="sidebar-footer">
|
<div class="sidebar-footer">
|
||||||
<div class="sidebar-status">
|
<div v-if="!collapsed" class="sidebar-status">
|
||||||
<span class="status-dot"></span>
|
<span class="status-dot"></span>
|
||||||
<span class="status-text">{{ $t('nav.systemReady') }}</span>
|
<span class="status-text">{{ $t('nav.systemReady') }}</span>
|
||||||
</div>
|
</div>
|
||||||
<!-- AI 面板切换按钮 -->
|
<!-- AI 面板切换按钮 -->
|
||||||
<button class="nav-link ai-toggle-btn" :class="{ 'ai-toggle-btn--active': aiStore.state.panelOpen }" @click="aiStore.togglePanel()">
|
<button class="nav-link ai-toggle-btn" :class="{ 'ai-toggle-btn--active': aiStore.state.panelOpen }" @click="aiStore.togglePanel()" :title="collapsed ? $t('nav.aiPanel') : undefined">
|
||||||
<span class="nav-link-icon">
|
<span class="nav-link-icon">
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2a7 7 0 014 12.74V17a1 1 0 01-1 1H9a1 1 0 01-1-1v-2.26A7 7 0 0112 2z"/><line x1="9" y1="21" x2="15" y2="21"/></svg>
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2a7 7 0 014 12.74V17a1 1 0 01-1 1H9a1 1 0 01-1-1v-2.26A7 7 0 0112 2z"/><line x1="9" y1="21" x2="15" y2="21"/></svg>
|
||||||
</span>
|
</span>
|
||||||
<span class="nav-link-text">{{ $t('nav.aiPanel') }}</span>
|
<span v-if="!collapsed" class="nav-link-text">{{ $t('nav.aiPanel') }}</span>
|
||||||
<span class="ai-toggle-shortcut">⌘I</span>
|
<span v-if="!collapsed" class="ai-toggle-shortcut">⌘I</span>
|
||||||
</button>
|
</button>
|
||||||
<router-link to="/settings" class="nav-link" active-class="nav-link--active">
|
<router-link to="/settings" class="nav-link" active-class="nav-link--active" :title="collapsed ? $t('nav.settings') : undefined">
|
||||||
<span class="nav-link-icon">
|
<span class="nav-link-icon">
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 012.83-2.83l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z"/></svg>
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 012.83-2.83l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z"/></svg>
|
||||||
</span>
|
</span>
|
||||||
<span class="nav-link-text">{{ $t('nav.settings') }}</span>
|
<span v-if="!collapsed" class="nav-link-text">{{ $t('nav.settings') }}</span>
|
||||||
</router-link>
|
</router-link>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed, ref, onMounted, onBeforeUnmount } from 'vue'
|
||||||
import { useAiStore } from '@/stores/ai'
|
import { useAiStore } from '@/stores/ai'
|
||||||
import { useKnowledgeStore } from '@/stores/knowledge'
|
import { useKnowledgeStore } from '@/stores/knowledge'
|
||||||
|
import { useAppSettingsStore } from '@/stores/appSettings'
|
||||||
|
|
||||||
const aiStore = useAiStore()
|
const aiStore = useAiStore()
|
||||||
const knowledgeStore = useKnowledgeStore()
|
const knowledgeStore = useKnowledgeStore()
|
||||||
|
const appSettings = useAppSettingsStore()
|
||||||
|
|
||||||
|
// ── 侧边栏收缩状态(持久化到 appSettings,小窗口自动收缩) ──
|
||||||
|
// 展开 192px(文字+图标) / 收缩 56px(仅图标,hover tooltip 显示文字)
|
||||||
|
// 优先级:用户手动设置 > 小窗口自动收缩(≤768px)
|
||||||
|
const COLLAPSE_THRESHOLD = 768
|
||||||
|
const userCollapsed = ref(false) // 用户手动设置的收缩状态
|
||||||
|
const isNarrowWindow = ref(false) // 小窗口自动收缩标记
|
||||||
|
|
||||||
|
const collapsed = computed(() => userCollapsed.value || isNarrowWindow.value)
|
||||||
|
|
||||||
|
// 持久化用户手动收缩状态
|
||||||
|
function toggleCollapsed() {
|
||||||
|
userCollapsed.value = !userCollapsed.value
|
||||||
|
void appSettings.set('df-sidebar-collapsed', userCollapsed.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 监听窗口宽度:小窗口自动收缩(但不改变用户偏好)
|
||||||
|
function checkWindowWidth() {
|
||||||
|
isNarrowWindow.value = window.innerWidth < COLLAPSE_THRESHOLD
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
// 恢复用户偏好(默认展开)
|
||||||
|
userCollapsed.value = appSettings.get('df-sidebar-collapsed', false)
|
||||||
|
// 检查当前窗口宽度
|
||||||
|
checkWindowWidth()
|
||||||
|
window.addEventListener('resize', checkWindowWidth)
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
window.removeEventListener('resize', checkWindowWidth)
|
||||||
|
})
|
||||||
|
|
||||||
// 知识库候选数量(侧栏 badge)— 解包 computed 取值保持响应式
|
// 知识库候选数量(侧栏 badge)— 解包 computed 取值保持响应式
|
||||||
const candidateCount = computed(() => knowledgeStore.candidateCount)
|
const candidateCount = computed(() => knowledgeStore.candidateCount)
|
||||||
@@ -111,6 +155,13 @@ const secondaryNav = [
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
position: relative;
|
position: relative;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
|
transition: width 0.2s var(--df-ease), min-width 0.2s var(--df-ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 收缩态 */
|
||||||
|
.df-sidebar--collapsed {
|
||||||
|
width: 56px;
|
||||||
|
min-width: 56px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* — Logo — */
|
/* — Logo — */
|
||||||
@@ -121,12 +172,18 @@ const secondaryNav = [
|
|||||||
padding: 14px 16px 12px;
|
padding: 14px 16px 12px;
|
||||||
border-bottom: 0.5px solid var(--df-border);
|
border-bottom: 0.5px solid var(--df-border);
|
||||||
}
|
}
|
||||||
|
.df-sidebar--collapsed .sidebar-brand {
|
||||||
|
padding: 12px 10px 10px;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
.brand-mark {
|
.brand-mark {
|
||||||
width: 28px; height: 28px;
|
width: 28px; height: 28px;
|
||||||
display: flex; align-items: center; justify-content: center;
|
display: flex; align-items: center; justify-content: center;
|
||||||
background: var(--df-accent-bg);
|
background: var(--df-accent-bg);
|
||||||
border: 0.5px solid var(--df-border);
|
border: 0.5px solid var(--df-border);
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
.df-sidebar--collapsed .brand-mark { width: 32px; height: 32px; }
|
||||||
.brand-text {
|
.brand-text {
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
@@ -140,11 +197,48 @@ const secondaryNav = [
|
|||||||
background: rgba(255,255,255,0.04);
|
background: rgba(255,255,255,0.04);
|
||||||
padding: 1px 6px;
|
padding: 1px 6px;
|
||||||
border-radius: var(--df-radius-xs);
|
border-radius: var(--df-radius-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 收缩按钮(展开态 brand 区右侧) */
|
||||||
|
.collapse-btn {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 22px; height: 22px;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--df-radius-sm);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--df-text-dim);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s var(--df-ease);
|
||||||
|
}
|
||||||
|
.collapse-btn:hover {
|
||||||
|
background: var(--df-sidebar-hover);
|
||||||
|
color: var(--df-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 展开按钮(收缩态 brand 区下方) */
|
||||||
|
.expand-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
padding: 6px 0;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--df-text-dim);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s var(--df-ease);
|
||||||
|
}
|
||||||
|
.expand-btn:hover {
|
||||||
|
background: var(--df-sidebar-hover);
|
||||||
|
color: var(--df-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* — Nav — */
|
/* — Nav — */
|
||||||
.sidebar-nav { flex: 1; overflow-y: auto; padding: 8px 8px; }
|
.sidebar-nav { flex: 1; overflow-y: auto; padding: 8px 8px; }
|
||||||
|
.df-sidebar--collapsed .sidebar-nav { padding: 8px 6px; }
|
||||||
|
|
||||||
.nav-group { margin-bottom: 2px; }
|
.nav-group { margin-bottom: 2px; }
|
||||||
.nav-group-label {
|
.nav-group-label {
|
||||||
@@ -162,6 +256,7 @@ const secondaryNav = [
|
|||||||
background: var(--df-border);
|
background: var(--df-border);
|
||||||
margin: 6px 12px;
|
margin: 6px 12px;
|
||||||
}
|
}
|
||||||
|
.df-sidebar--collapsed .nav-divider { margin: 4px 8px; }
|
||||||
|
|
||||||
.nav-link {
|
.nav-link {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -177,6 +272,10 @@ const secondaryNav = [
|
|||||||
transition: all 0.18s var(--df-ease);
|
transition: all 0.18s var(--df-ease);
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
.df-sidebar--collapsed .nav-link {
|
||||||
|
justify-content: center;
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
.nav-link:hover {
|
.nav-link:hover {
|
||||||
background: var(--df-sidebar-hover);
|
background: var(--df-sidebar-hover);
|
||||||
@@ -198,6 +297,8 @@ const secondaryNav = [
|
|||||||
background: var(--df-accent);
|
background: var(--df-accent);
|
||||||
border-radius: 0 3px 3px 0;
|
border-radius: 0 3px 3px 0;
|
||||||
}
|
}
|
||||||
|
/* 收缩态隐藏左边高亮线(居中布局无空间) */
|
||||||
|
.df-sidebar--collapsed .nav-link--active::before { display: none; }
|
||||||
|
|
||||||
.nav-link-icon {
|
.nav-link-icon {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -276,14 +377,6 @@ const secondaryNav = [
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ═══ Responsive ═══ */
|
/* ═══ Responsive ═══ */
|
||||||
@media (max-width: 768px) {
|
/* 小窗口自动收缩由 JS resize listener 驱动(isNarrowWindow),无需 media query。
|
||||||
.df-sidebar { width: 56px; min-width: 56px; }
|
原 768px media query 已删除,收缩逻辑统一走 collapsed class 控制。 */
|
||||||
.brand-text, .brand-version, .nav-group-label, .nav-link-text,
|
|
||||||
.nav-link-badge, .sidebar-status, .status-text, .ai-toggle-shortcut { display: none; }
|
|
||||||
.sidebar-brand { padding: 12px 10px 10px; justify-content: center; }
|
|
||||||
.brand-mark { width: 32px; height: 32px; }
|
|
||||||
.nav-link { justify-content: center; padding: 8px 0; }
|
|
||||||
.nav-divider { margin: 4px 8px; }
|
|
||||||
.nav-link--active::before { display: none; }
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -11,5 +11,7 @@ export default {
|
|||||||
traceability: 'Traceability',
|
traceability: 'Traceability',
|
||||||
systemReady: 'System Ready',
|
systemReady: 'System Ready',
|
||||||
aiPanel: 'AI Panel',
|
aiPanel: 'AI Panel',
|
||||||
|
collapse: 'Collapse sidebar',
|
||||||
|
expand: 'Expand sidebar',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,5 +11,7 @@ export default {
|
|||||||
traceability: '追溯',
|
traceability: '追溯',
|
||||||
systemReady: '系统就绪',
|
systemReady: '系统就绪',
|
||||||
aiPanel: 'AI 面板',
|
aiPanel: 'AI 面板',
|
||||||
|
collapse: '收起侧边栏',
|
||||||
|
expand: '展开侧边栏',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user