重构: layout全局布局抽取(strategy前端架构)
- 新建 components/layout/AppSidebar.vue(289行): Logo/Nav/Footer/AI toggle/响应式768px折叠 - 新建 components/layout/AppLayout.vue(129行): shell容器+sidebar+main+AI Panel拖拽+3 slot(default/detached/ai-panel) - App.vue 546→179: 收敛应用初始化(主题/locale/迁移/dataChangedListener/Cmd+I) + slot填充router-view/AiChat + 根Toast 多角度分析: App.vue原含全局布局, 11 views只渲染内容(无跨view重复), 值得抽取(546行可清晰拆) strategy: 前端架构, layout全局布局抽取; scoped CSS按渲染归属拆三组件(hash一一对应零视觉差异); 填补空layout/目录(此前预留)
This commit is contained in:
399
src/App.vue
399
src/App.vue
@@ -1,102 +1,24 @@
|
|||||||
<template>
|
<template>
|
||||||
<!-- 分离窗口:纯内容,不渲染主布局 -->
|
<AppLayout :is-detached="isDetached">
|
||||||
<div v-if="isDetached" class="df-detached-root">
|
<!-- 主窗口:页面内容(router-view + 页面切换过渡) -->
|
||||||
<router-view v-slot="{ Component }">
|
<router-view v-slot="{ Component }">
|
||||||
<component :is="Component" />
|
<transition name="page" mode="out-in">
|
||||||
|
<component :is="Component" />
|
||||||
|
</transition>
|
||||||
</router-view>
|
</router-view>
|
||||||
</div>
|
|
||||||
<!-- 主窗口:完整布局 -->
|
|
||||||
<div v-else class="df-shell">
|
|
||||||
<!-- ═══ Sidebar ═══ -->
|
|
||||||
<aside class="df-sidebar">
|
|
||||||
<!-- Logo -->
|
|
||||||
<div class="sidebar-brand">
|
|
||||||
<div class="brand-mark">
|
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
|
|
||||||
<path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z" fill="url(#brand-grad)" />
|
|
||||||
<defs><linearGradient id="brand-grad" x1="3" y1="2" x2="21" y2="22">
|
|
||||||
<stop stop-color="#7b6ff0"/><stop offset="1" stop-color="#5eaff0"/>
|
|
||||||
</linearGradient></defs>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<span class="brand-text">DevFlow</span>
|
|
||||||
<span class="brand-version">v0.1</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Navigation -->
|
<!-- 分离窗口:纯内容 -->
|
||||||
<nav class="sidebar-nav">
|
<template #detached>
|
||||||
<div class="nav-group">
|
|
||||||
<span class="nav-group-label">{{ $t('nav.workspace') }}</span>
|
|
||||||
<router-link
|
|
||||||
v-for="item in primaryNav" :key="item.path"
|
|
||||||
:to="item.path"
|
|
||||||
class="nav-link"
|
|
||||||
active-class="nav-link--active"
|
|
||||||
>
|
|
||||||
<span class="nav-link-icon" v-html="item.svg"></span>
|
|
||||||
<span class="nav-link-text">{{ $t(item.label) }}</span>
|
|
||||||
<span v-if="item.badge" class="nav-link-badge">{{ item.badge }}</span>
|
|
||||||
</router-link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="nav-divider"></div>
|
|
||||||
|
|
||||||
<div class="nav-group">
|
|
||||||
<span class="nav-group-label">{{ $t('nav.traceability') }}</span>
|
|
||||||
<router-link
|
|
||||||
v-for="item in secondaryNav" :key="item.path"
|
|
||||||
:to="item.path"
|
|
||||||
class="nav-link"
|
|
||||||
active-class="nav-link--active"
|
|
||||||
>
|
|
||||||
<span class="nav-link-icon" v-html="item.svg"></span>
|
|
||||||
<span class="nav-link-text">{{ $t(item.label) }}</span>
|
|
||||||
<span v-if="item.path === '/knowledge' && candidateCount > 0" class="nav-link-badge">{{ candidateCount }}</span>
|
|
||||||
</router-link>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<!-- Footer -->
|
|
||||||
<div class="sidebar-footer">
|
|
||||||
<div class="sidebar-status">
|
|
||||||
<span class="status-dot"></span>
|
|
||||||
<span class="status-text">{{ $t('nav.systemReady') }}</span>
|
|
||||||
</div>
|
|
||||||
<!-- AI 面板切换按钮 -->
|
|
||||||
<button class="nav-link ai-toggle-btn" :class="{ 'ai-toggle-btn--active': aiStore.state.panelOpen }" @click="aiStore.togglePanel()">
|
|
||||||
<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>
|
|
||||||
</span>
|
|
||||||
<span class="nav-link-text">{{ $t('nav.aiPanel') }}</span>
|
|
||||||
<span class="ai-toggle-shortcut">⌘I</span>
|
|
||||||
</button>
|
|
||||||
<router-link to="/settings" class="nav-link" active-class="nav-link--active">
|
|
||||||
<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>
|
|
||||||
</span>
|
|
||||||
<span class="nav-link-text">{{ $t('nav.settings') }}</span>
|
|
||||||
</router-link>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<!-- ═══ Main (页面内容) ═══ -->
|
|
||||||
<!-- 仅在 AI 面板可见且最大化时隐藏主内容;面板关闭时无论 maximized 状态如何都显示 -->
|
|
||||||
<main class="df-main" v-show="!(aiStore.state.maximized && aiStore.state.panelOpen)">
|
|
||||||
<router-view v-slot="{ Component }">
|
<router-view v-slot="{ Component }">
|
||||||
<transition name="page" mode="out-in">
|
<component :is="Component" />
|
||||||
<component :is="Component" />
|
|
||||||
</transition>
|
|
||||||
</router-view>
|
</router-view>
|
||||||
</main>
|
</template>
|
||||||
|
|
||||||
<!-- ═══ AI Chat Panel ═══ -->
|
<!-- AI Chat Panel 内容 -->
|
||||||
<transition name="ai-slide">
|
<template #ai-panel>
|
||||||
<div v-if="aiStore.state.panelOpen && !aiStore.state.detached" class="df-ai-panel" :style="panelStyle">
|
<AiChat />
|
||||||
<div v-show="!aiStore.state.maximized" class="ai-resize-handle" :class="{ active: resizing }" @mousedown="startResize"></div>
|
</template>
|
||||||
<AiChat />
|
</AppLayout>
|
||||||
</div>
|
|
||||||
</transition>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 全局错误 toast(根级,主窗口与分离窗口共用)-->
|
<!-- 全局错误 toast(根级,主窗口与分离窗口共用)-->
|
||||||
<transition name="toast">
|
<transition name="toast">
|
||||||
@@ -113,6 +35,7 @@ import { useProjectStore } from './stores/project'
|
|||||||
import { useAppSettingsStore } from './stores/appSettings'
|
import { useAppSettingsStore } from './stores/appSettings'
|
||||||
import i18n from './i18n'
|
import i18n from './i18n'
|
||||||
import AiChat from './components/AiChat.vue'
|
import AiChat from './components/AiChat.vue'
|
||||||
|
import AppLayout from './components/layout/AppLayout.vue'
|
||||||
import { aiApi } from '@/api'
|
import { aiApi } from '@/api'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
@@ -139,49 +62,6 @@ watch(() => route.path, (path) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// ── AI 面板拖拽调宽 ──
|
|
||||||
// useSetting 监听 appSettings 缓存:loadAll 异步填充后会自动刷新 ref
|
|
||||||
const panelWidth = appSettings.useSetting<number>('df-ai-width', 500)
|
|
||||||
const resizing = ref(false)
|
|
||||||
|
|
||||||
// maximized=全宽(flex:1填充) | sidebar=固定宽
|
|
||||||
const panelStyle = computed(() => {
|
|
||||||
if (aiStore.state.maximized) return { flex: '1', minWidth: '0' }
|
|
||||||
return {
|
|
||||||
width: panelWidth.value + 'px',
|
|
||||||
minWidth: panelWidth.value + 'px',
|
|
||||||
maxWidth: panelWidth.value + 'px',
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
function startResize(e: MouseEvent) {
|
|
||||||
e.preventDefault()
|
|
||||||
resizing.value = true
|
|
||||||
const startX = e.clientX
|
|
||||||
const startWidth = panelWidth.value
|
|
||||||
|
|
||||||
document.body.style.cursor = 'col-resize'
|
|
||||||
document.body.style.userSelect = 'none'
|
|
||||||
|
|
||||||
function onMouseMove(ev: MouseEvent) {
|
|
||||||
const delta = startX - ev.clientX // 向左拖 = 增宽
|
|
||||||
const newWidth = Math.max(320, Math.min(800, startWidth + delta))
|
|
||||||
panelWidth.value = newWidth
|
|
||||||
}
|
|
||||||
|
|
||||||
function onMouseUp() {
|
|
||||||
resizing.value = false
|
|
||||||
document.body.style.cursor = ''
|
|
||||||
document.body.style.userSelect = ''
|
|
||||||
void appSettings.set('df-ai-width', panelWidth.value)
|
|
||||||
document.removeEventListener('mousemove', onMouseMove)
|
|
||||||
document.removeEventListener('mouseup', onMouseUp)
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener('mousemove', onMouseMove)
|
|
||||||
document.addEventListener('mouseup', onMouseUp)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 主题初始化
|
// 主题初始化
|
||||||
function initTheme() {
|
function initTheme() {
|
||||||
const saved = appSettings.get<string>('df-theme', 'dark')
|
const saved = appSettings.get<string>('df-theme', 'dark')
|
||||||
@@ -270,263 +150,16 @@ async function migrateLegacyLocalStorage(): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 知识库候选数量(侧栏 badge)— 解包 computed 取值保持响应式
|
// 知识库 store(侧栏 badge 计数在 AppSidebar 内消费,此处仅确保 store 实例化供 onMounted loadCandidates)
|
||||||
const knowledgeStore = useKnowledgeStore()
|
const knowledgeStore = useKnowledgeStore()
|
||||||
const candidateCount = computed(() => knowledgeStore.candidateCount)
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
window.removeEventListener('keydown', handleKeydown)
|
window.removeEventListener('keydown', handleKeydown)
|
||||||
projectStore.stopDataChangedListener?.()
|
projectStore.stopDataChangedListener?.()
|
||||||
})
|
})
|
||||||
|
|
||||||
const primaryNav = [
|
|
||||||
{ path: '/dashboard', label: 'nav.overview', badge: null,
|
|
||||||
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"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></svg>' },
|
|
||||||
{ path: '/ideas', label: 'nav.ideas', badge: null,
|
|
||||||
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="M9 18h6M10 22h4M12 2a7 7 0 014 12.74V17a1 1 0 01-1 1h-6a1 1 0 01-1-1v-2.26A7 7 0 0112 2z"/></svg>' },
|
|
||||||
{ path: '/projects', label: 'nav.projects', badge: null,
|
|
||||||
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="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/></svg>' },
|
|
||||||
{ path: '/tasks', label: 'nav.tasks', badge: null,
|
|
||||||
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="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/></svg>' },
|
|
||||||
]
|
|
||||||
|
|
||||||
const secondaryNav = [
|
|
||||||
{ path: '/knowledge', label: 'nav.knowledge',
|
|
||||||
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="M4 19.5A2.5 2.5 0 016.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 014 19.5v-15A2.5 2.5 0 016.5 2z"/></svg>' },
|
|
||||||
{ path: '/audit', label: 'nav.audit',
|
|
||||||
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="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11"/></svg>' },
|
|
||||||
]
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.df-detached-root { width: 100vw; height: 100vh; overflow: hidden; background: var(--df-bg); }
|
|
||||||
.df-shell { display: flex; height: 100vh; width: 100vw; overflow: hidden; }
|
|
||||||
|
|
||||||
/* ═══ Sidebar ═══ */
|
|
||||||
.df-sidebar {
|
|
||||||
width: 192px;
|
|
||||||
min-width: 192px;
|
|
||||||
background: var(--df-sidebar-bg);
|
|
||||||
border-right: 0.5px solid var(--df-border);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
position: relative;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* — Logo — */
|
|
||||||
.sidebar-brand {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 14px 16px 12px;
|
|
||||||
border-bottom: 0.5px solid var(--df-border);
|
|
||||||
}
|
|
||||||
.brand-mark {
|
|
||||||
width: 28px; height: 28px;
|
|
||||||
display: flex; align-items: center; justify-content: center;
|
|
||||||
background: var(--df-accent-bg);
|
|
||||||
border: 0.5px solid var(--df-border);
|
|
||||||
}
|
|
||||||
.brand-text {
|
|
||||||
font-size: 15px;
|
|
||||||
font-weight: 500;
|
|
||||||
letter-spacing: -0.3px;
|
|
||||||
color: var(--df-text);
|
|
||||||
}
|
|
||||||
.brand-version {
|
|
||||||
font-family: var(--df-font-mono);
|
|
||||||
font-size: 10px;
|
|
||||||
color: var(--df-text-dim);
|
|
||||||
background: rgba(255,255,255,0.04);
|
|
||||||
padding: 1px 6px;
|
|
||||||
border-radius: var(--df-radius-xs);
|
|
||||||
margin-left: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* — Nav — */
|
|
||||||
.sidebar-nav { flex: 1; overflow-y: auto; padding: 8px 8px; }
|
|
||||||
|
|
||||||
.nav-group { margin-bottom: 2px; }
|
|
||||||
.nav-group-label {
|
|
||||||
display: block;
|
|
||||||
padding: 8px 10px 4px;
|
|
||||||
font-size: 9px;
|
|
||||||
font-weight: 500;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 1.2px;
|
|
||||||
color: var(--df-text-dim);
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-divider {
|
|
||||||
height: 0.5px;
|
|
||||||
background: var(--df-border);
|
|
||||||
margin: 6px 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-link {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 5px 10px;
|
|
||||||
margin: 1px 0;
|
|
||||||
border-radius: var(--df-radius-sm);
|
|
||||||
color: var(--df-text-secondary);
|
|
||||||
text-decoration: none;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 450;
|
|
||||||
transition: all 0.18s var(--df-ease);
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-link:hover {
|
|
||||||
background: var(--df-sidebar-hover);
|
|
||||||
color: var(--df-text);
|
|
||||||
}
|
|
||||||
.nav-link--active {
|
|
||||||
background: var(--df-sidebar-active);
|
|
||||||
color: var(--df-accent);
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
.nav-link--active::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
left: -10px;
|
|
||||||
top: 50%;
|
|
||||||
transform: translateY(-50%);
|
|
||||||
width: 3px;
|
|
||||||
height: 16px;
|
|
||||||
background: var(--df-accent);
|
|
||||||
border-radius: 0 3px 3px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-link-icon {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 18px;
|
|
||||||
height: 18px;
|
|
||||||
opacity: 0.7;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.nav-link--active .nav-link-icon { opacity: 1; }
|
|
||||||
|
|
||||||
.nav-link-text { flex: 1; }
|
|
||||||
|
|
||||||
.nav-link-badge {
|
|
||||||
font-family: var(--df-font-mono);
|
|
||||||
font-size: 10px;
|
|
||||||
font-weight: 500;
|
|
||||||
padding: 1px 6px;
|
|
||||||
border-radius: var(--df-radius);
|
|
||||||
min-width: 18px;
|
|
||||||
text-align: center;
|
|
||||||
background: var(--df-accent);
|
|
||||||
color: #fff;
|
|
||||||
line-height: 1.4;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* — AI Toggle Button — */
|
|
||||||
.ai-toggle-btn {
|
|
||||||
cursor: pointer;
|
|
||||||
border: none;
|
|
||||||
background: transparent;
|
|
||||||
width: 100%;
|
|
||||||
font: inherit;
|
|
||||||
text-align: left;
|
|
||||||
appearance: none;
|
|
||||||
-webkit-appearance: none;
|
|
||||||
}
|
|
||||||
.ai-toggle-btn--active {
|
|
||||||
color: var(--df-accent);
|
|
||||||
background: var(--df-sidebar-active);
|
|
||||||
}
|
|
||||||
.ai-toggle-shortcut {
|
|
||||||
font-family: var(--df-font-mono);
|
|
||||||
font-size: 9px;
|
|
||||||
color: var(--df-text-dim);
|
|
||||||
background: rgba(255,255,255,0.04);
|
|
||||||
padding: 1px 5px;
|
|
||||||
border-radius: var(--df-radius-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* — Footer — */
|
|
||||||
.sidebar-footer {
|
|
||||||
padding: 6px 8px;
|
|
||||||
border-top: 0.5px solid var(--df-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-status {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
padding: 4px 10px;
|
|
||||||
margin-bottom: 1px;
|
|
||||||
}
|
|
||||||
.status-dot {
|
|
||||||
width: 6px; height: 6px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: var(--df-success);
|
|
||||||
animation: pulseGlow 3s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
.status-text {
|
|
||||||
font-family: var(--df-font-mono);
|
|
||||||
font-size: 10px;
|
|
||||||
color: var(--df-text-dim);
|
|
||||||
letter-spacing: 0.3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* — Main — */
|
|
||||||
.df-main {
|
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
overflow-x: hidden;
|
|
||||||
background: var(--df-bg);
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* — AI Panel (全宽) — */
|
|
||||||
.df-ai-panel {
|
|
||||||
position: relative;
|
|
||||||
height: 100vh;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* — AI Resize Handle — */
|
|
||||||
.ai-resize-handle {
|
|
||||||
position: absolute;
|
|
||||||
left: 0; top: 0; bottom: 0;
|
|
||||||
width: 4px;
|
|
||||||
cursor: col-resize;
|
|
||||||
z-index: 10;
|
|
||||||
transition: background 0.15s;
|
|
||||||
}
|
|
||||||
.ai-resize-handle:hover,
|
|
||||||
.ai-resize-handle.active {
|
|
||||||
background: var(--df-accent);
|
|
||||||
opacity: 0.4;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* — AI Slide Transition — */
|
|
||||||
.ai-slide-enter-active { transition: opacity 0.2s var(--df-ease); }
|
|
||||||
.ai-slide-leave-active { transition: opacity 0.15s var(--df-ease); }
|
|
||||||
.ai-slide-enter-from, .ai-slide-leave-to {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ═══ Responsive ═══ */
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.df-sidebar { width: 56px; min-width: 56px; }
|
|
||||||
.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; }
|
|
||||||
.df-ai-panel { width: 100vw; min-width: 100vw; max-width: 100vw; position: absolute; right: 0; top: 0; z-index: 100; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* — Page Transition — */
|
/* — Page Transition — */
|
||||||
.page-enter-active { transition: opacity 0.2s var(--df-ease), transform 0.2s var(--df-ease); }
|
.page-enter-active { transition: opacity 0.2s var(--df-ease), transform 0.2s var(--df-ease); }
|
||||||
.page-leave-active { transition: opacity 0.12s var(--df-ease), transform 0.12s var(--df-ease); }
|
.page-leave-active { transition: opacity 0.12s var(--df-ease), transform 0.12s var(--df-ease); }
|
||||||
|
|||||||
129
src/components/layout/AppLayout.vue
Normal file
129
src/components/layout/AppLayout.vue
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
<template>
|
||||||
|
<!-- 分离窗口:纯内容,不渲染主布局 -->
|
||||||
|
<div v-if="isDetached" class="df-detached-root">
|
||||||
|
<slot name="detached" />
|
||||||
|
</div>
|
||||||
|
<!-- 主窗口:完整布局 -->
|
||||||
|
<div v-else class="df-shell">
|
||||||
|
<AppSidebar />
|
||||||
|
|
||||||
|
<!-- ═══ Main (页面内容) ═══ -->
|
||||||
|
<!-- 仅在 AI 面板可见且最大化时隐藏主内容;面板关闭时无论 maximized 状态如何都显示 -->
|
||||||
|
<main class="df-main" v-show="!(aiStore.state.maximized && aiStore.state.panelOpen)">
|
||||||
|
<slot />
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- ═══ AI Chat Panel ═══ -->
|
||||||
|
<transition name="ai-slide">
|
||||||
|
<div v-if="aiStore.state.panelOpen && !aiStore.state.detached" class="df-ai-panel" :style="panelStyle">
|
||||||
|
<div v-show="!aiStore.state.maximized" class="ai-resize-handle" :class="{ active: resizing }" @mousedown="startResize"></div>
|
||||||
|
<slot name="ai-panel" />
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { useAiStore } from '@/stores/ai'
|
||||||
|
import { useAppSettingsStore } from '@/stores/appSettings'
|
||||||
|
import AppSidebar from './AppSidebar.vue'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
isDetached: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const aiStore = useAiStore()
|
||||||
|
const appSettings = useAppSettingsStore()
|
||||||
|
|
||||||
|
// ── AI 面板拖拽调宽 ──
|
||||||
|
// useSetting 监听 appSettings 缓存:loadAll 异步填充后会自动刷新 ref
|
||||||
|
const panelWidth = appSettings.useSetting<number>('df-ai-width', 500)
|
||||||
|
const resizing = ref(false)
|
||||||
|
|
||||||
|
// maximized=全宽(flex:1填充) | sidebar=固定宽
|
||||||
|
const panelStyle = computed(() => {
|
||||||
|
if (aiStore.state.maximized) return { flex: '1', minWidth: '0' }
|
||||||
|
return {
|
||||||
|
width: panelWidth.value + 'px',
|
||||||
|
minWidth: panelWidth.value + 'px',
|
||||||
|
maxWidth: panelWidth.value + 'px',
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function startResize(e: MouseEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
resizing.value = true
|
||||||
|
const startX = e.clientX
|
||||||
|
const startWidth = panelWidth.value
|
||||||
|
|
||||||
|
document.body.style.cursor = 'col-resize'
|
||||||
|
document.body.style.userSelect = 'none'
|
||||||
|
|
||||||
|
function onMouseMove(ev: MouseEvent) {
|
||||||
|
const delta = startX - ev.clientX // 向左拖 = 增宽
|
||||||
|
const newWidth = Math.max(320, Math.min(800, startWidth + delta))
|
||||||
|
panelWidth.value = newWidth
|
||||||
|
}
|
||||||
|
|
||||||
|
function onMouseUp() {
|
||||||
|
resizing.value = false
|
||||||
|
document.body.style.cursor = ''
|
||||||
|
document.body.style.userSelect = ''
|
||||||
|
void appSettings.set('df-ai-width', panelWidth.value)
|
||||||
|
document.removeEventListener('mousemove', onMouseMove)
|
||||||
|
document.removeEventListener('mouseup', onMouseUp)
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('mousemove', onMouseMove)
|
||||||
|
document.addEventListener('mouseup', onMouseUp)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.df-detached-root { width: 100vw; height: 100vh; overflow: hidden; background: var(--df-bg); }
|
||||||
|
.df-shell { display: flex; height: 100vh; width: 100vw; overflow: hidden; }
|
||||||
|
|
||||||
|
/* — Main — */
|
||||||
|
.df-main {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
background: var(--df-bg);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* — AI Panel (全宽) — */
|
||||||
|
.df-ai-panel {
|
||||||
|
position: relative;
|
||||||
|
height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* — AI Resize Handle — */
|
||||||
|
.ai-resize-handle {
|
||||||
|
position: absolute;
|
||||||
|
left: 0; top: 0; bottom: 0;
|
||||||
|
width: 4px;
|
||||||
|
cursor: col-resize;
|
||||||
|
z-index: 10;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
.ai-resize-handle:hover,
|
||||||
|
.ai-resize-handle.active {
|
||||||
|
background: var(--df-accent);
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* — AI Slide Transition — */
|
||||||
|
.ai-slide-enter-active { transition: opacity 0.2s var(--df-ease); }
|
||||||
|
.ai-slide-leave-active { transition: opacity 0.15s var(--df-ease); }
|
||||||
|
.ai-slide-enter-from, .ai-slide-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ═══ Responsive ═══ */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.df-ai-panel { width: 100vw; min-width: 100vw; max-width: 100vw; position: absolute; right: 0; top: 0; z-index: 100; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
289
src/components/layout/AppSidebar.vue
Normal file
289
src/components/layout/AppSidebar.vue
Normal file
@@ -0,0 +1,289 @@
|
|||||||
|
<template>
|
||||||
|
<aside class="df-sidebar">
|
||||||
|
<!-- Logo -->
|
||||||
|
<div class="sidebar-brand">
|
||||||
|
<div class="brand-mark">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z" fill="url(#brand-grad)" />
|
||||||
|
<defs><linearGradient id="brand-grad" x1="3" y1="2" x2="21" y2="22">
|
||||||
|
<stop stop-color="#7b6ff0"/><stop offset="1" stop-color="#5eaff0"/>
|
||||||
|
</linearGradient></defs>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<span class="brand-text">DevFlow</span>
|
||||||
|
<span class="brand-version">v0.1</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Navigation -->
|
||||||
|
<nav class="sidebar-nav">
|
||||||
|
<div class="nav-group">
|
||||||
|
<span class="nav-group-label">{{ $t('nav.workspace') }}</span>
|
||||||
|
<router-link
|
||||||
|
v-for="item in primaryNav" :key="item.path"
|
||||||
|
:to="item.path"
|
||||||
|
class="nav-link"
|
||||||
|
active-class="nav-link--active"
|
||||||
|
>
|
||||||
|
<span class="nav-link-icon" v-html="item.svg"></span>
|
||||||
|
<span class="nav-link-text">{{ $t(item.label) }}</span>
|
||||||
|
<span v-if="item.badge" class="nav-link-badge">{{ item.badge }}</span>
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="nav-divider"></div>
|
||||||
|
|
||||||
|
<div class="nav-group">
|
||||||
|
<span class="nav-group-label">{{ $t('nav.traceability') }}</span>
|
||||||
|
<router-link
|
||||||
|
v-for="item in secondaryNav" :key="item.path"
|
||||||
|
:to="item.path"
|
||||||
|
class="nav-link"
|
||||||
|
active-class="nav-link--active"
|
||||||
|
>
|
||||||
|
<span class="nav-link-icon" v-html="item.svg"></span>
|
||||||
|
<span class="nav-link-text">{{ $t(item.label) }}</span>
|
||||||
|
<span v-if="item.path === '/knowledge' && candidateCount > 0" class="nav-link-badge">{{ candidateCount }}</span>
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<div class="sidebar-footer">
|
||||||
|
<div class="sidebar-status">
|
||||||
|
<span class="status-dot"></span>
|
||||||
|
<span class="status-text">{{ $t('nav.systemReady') }}</span>
|
||||||
|
</div>
|
||||||
|
<!-- AI 面板切换按钮 -->
|
||||||
|
<button class="nav-link ai-toggle-btn" :class="{ 'ai-toggle-btn--active': aiStore.state.panelOpen }" @click="aiStore.togglePanel()">
|
||||||
|
<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>
|
||||||
|
</span>
|
||||||
|
<span class="nav-link-text">{{ $t('nav.aiPanel') }}</span>
|
||||||
|
<span class="ai-toggle-shortcut">⌘I</span>
|
||||||
|
</button>
|
||||||
|
<router-link to="/settings" class="nav-link" active-class="nav-link--active">
|
||||||
|
<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>
|
||||||
|
</span>
|
||||||
|
<span class="nav-link-text">{{ $t('nav.settings') }}</span>
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useAiStore } from '@/stores/ai'
|
||||||
|
import { useKnowledgeStore } from '@/stores/knowledge'
|
||||||
|
|
||||||
|
const aiStore = useAiStore()
|
||||||
|
const knowledgeStore = useKnowledgeStore()
|
||||||
|
|
||||||
|
// 知识库候选数量(侧栏 badge)— 解包 computed 取值保持响应式
|
||||||
|
const candidateCount = computed(() => knowledgeStore.candidateCount)
|
||||||
|
|
||||||
|
const primaryNav = [
|
||||||
|
{ path: '/dashboard', label: 'nav.overview', badge: null,
|
||||||
|
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"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></svg>' },
|
||||||
|
{ path: '/ideas', label: 'nav.ideas', badge: null,
|
||||||
|
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="M9 18h6M10 22h4M12 2a7 7 0 014 12.74V17a1 1 0 01-1 1h-6a1 1 0 01-1-1v-2.26A7 7 0 0112 2z"/></svg>' },
|
||||||
|
{ path: '/projects', label: 'nav.projects', badge: null,
|
||||||
|
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="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/></svg>' },
|
||||||
|
{ path: '/tasks', label: 'nav.tasks', badge: null,
|
||||||
|
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="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/></svg>' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const secondaryNav = [
|
||||||
|
{ path: '/knowledge', label: 'nav.knowledge',
|
||||||
|
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="M4 19.5A2.5 2.5 0 016.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 014 19.5v-15A2.5 2.5 0 016.5 2z"/></svg>' },
|
||||||
|
{ path: '/audit', label: 'nav.audit',
|
||||||
|
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="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11"/></svg>' },
|
||||||
|
]
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.df-sidebar {
|
||||||
|
width: 192px;
|
||||||
|
min-width: 192px;
|
||||||
|
background: var(--df-sidebar-bg);
|
||||||
|
border-right: 0.5px solid var(--df-border);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
position: relative;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* — Logo — */
|
||||||
|
.sidebar-brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 14px 16px 12px;
|
||||||
|
border-bottom: 0.5px solid var(--df-border);
|
||||||
|
}
|
||||||
|
.brand-mark {
|
||||||
|
width: 28px; height: 28px;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
background: var(--df-accent-bg);
|
||||||
|
border: 0.5px solid var(--df-border);
|
||||||
|
}
|
||||||
|
.brand-text {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: -0.3px;
|
||||||
|
color: var(--df-text);
|
||||||
|
}
|
||||||
|
.brand-version {
|
||||||
|
font-family: var(--df-font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--df-text-dim);
|
||||||
|
background: rgba(255,255,255,0.04);
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: var(--df-radius-xs);
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* — Nav — */
|
||||||
|
.sidebar-nav { flex: 1; overflow-y: auto; padding: 8px 8px; }
|
||||||
|
|
||||||
|
.nav-group { margin-bottom: 2px; }
|
||||||
|
.nav-group-label {
|
||||||
|
display: block;
|
||||||
|
padding: 8px 10px 4px;
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 500;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1.2px;
|
||||||
|
color: var(--df-text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-divider {
|
||||||
|
height: 0.5px;
|
||||||
|
background: var(--df-border);
|
||||||
|
margin: 6px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 5px 10px;
|
||||||
|
margin: 1px 0;
|
||||||
|
border-radius: var(--df-radius-sm);
|
||||||
|
color: var(--df-text-secondary);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 450;
|
||||||
|
transition: all 0.18s var(--df-ease);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link:hover {
|
||||||
|
background: var(--df-sidebar-hover);
|
||||||
|
color: var(--df-text);
|
||||||
|
}
|
||||||
|
.nav-link--active {
|
||||||
|
background: var(--df-sidebar-active);
|
||||||
|
color: var(--df-accent);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.nav-link--active::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: -10px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
width: 3px;
|
||||||
|
height: 16px;
|
||||||
|
background: var(--df-accent);
|
||||||
|
border-radius: 0 3px 3px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link-icon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
opacity: 0.7;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.nav-link--active .nav-link-icon { opacity: 1; }
|
||||||
|
|
||||||
|
.nav-link-text { flex: 1; }
|
||||||
|
|
||||||
|
.nav-link-badge {
|
||||||
|
font-family: var(--df-font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: var(--df-radius);
|
||||||
|
min-width: 18px;
|
||||||
|
text-align: center;
|
||||||
|
background: var(--df-accent);
|
||||||
|
color: #fff;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* — AI Toggle Button — */
|
||||||
|
.ai-toggle-btn {
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
width: 100%;
|
||||||
|
font: inherit;
|
||||||
|
text-align: left;
|
||||||
|
appearance: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
}
|
||||||
|
.ai-toggle-btn--active {
|
||||||
|
color: var(--df-accent);
|
||||||
|
background: var(--df-sidebar-active);
|
||||||
|
}
|
||||||
|
.ai-toggle-shortcut {
|
||||||
|
font-family: var(--df-font-mono);
|
||||||
|
font-size: 9px;
|
||||||
|
color: var(--df-text-dim);
|
||||||
|
background: rgba(255,255,255,0.04);
|
||||||
|
padding: 1px 5px;
|
||||||
|
border-radius: var(--df-radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* — Footer — */
|
||||||
|
.sidebar-footer {
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-top: 0.5px solid var(--df-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-status {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
margin-bottom: 1px;
|
||||||
|
}
|
||||||
|
.status-dot {
|
||||||
|
width: 6px; height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--df-success);
|
||||||
|
animation: pulseGlow 3s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.status-text {
|
||||||
|
font-family: var(--df-font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--df-text-dim);
|
||||||
|
letter-spacing: 0.3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ═══ Responsive ═══ */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.df-sidebar { width: 56px; min-width: 56px; }
|
||||||
|
.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>
|
||||||
Reference in New Issue
Block a user