Files
qiji/frontend/src/components/DesktopLayout.vue
T
v6ole deeafe239f feat: 双主题系统 — 编辑风 + 极简亮色
前端:
- 新增 Pinia theme store,localStorage 持久化,data-theme 切换
- App.vue: :root 新增 --font-*/--sidebar-* CSS 变量;新增 :root[data-theme=light] 完整配色(极简黑白金 + Noto Sans SC)
- DesktopLayout: 侧边栏 15 个变量化 + 顶栏主题切换按钮
- MobileLayout: 装饰色变量化
- Settings: 新增「界面主题」卡片
- 21 个组件 font-family 统一替换为 var(--font-*)
- 新增 utils/managerColor.ts — 12 色调色板 + 自适应文字色 + 后端自定义色优先
- WorkPlans/MiniBusiness/KeyVisits/CustomerManage: 客户经理标签色统一走共享工具
- UserManage: 编辑弹窗新增颜色选择器(仅 manager)
- LightBoard: 去标题星标图标
- ManagerWorkspace/WorkPlans/MiniBusiness/KeyVisits: 删除按钮改为实心红底白字

后端:
- User 模型新增 color 列 + lifespan 自动迁移
- users API 支持 color 字段读写
- UserOut schema 新增 color

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-06 16:03:13 +08:00

356 lines
12 KiB
Vue

<script setup lang="ts">
import { useRoute, useRouter } from 'vue-router'
import { computed, ref } from 'vue'
import { useAuthStore } from '@/stores/auth'
import { useThemeStore, themeLabels } from '@/stores/theme'
const route = useRoute()
const router = useRouter()
const auth = useAuthStore()
const themeStore = useThemeStore()
const collapsed = ref(false)
interface MenuGroup { label?: string; items: { path: string; label: string; icon: string }[] }
const menuGroups = computed<MenuGroup[]>(() => {
const groups: MenuGroup[] = [
{
label: '汇总',
items: [
{ path: '/', label: '仪表盘', icon: '<polyline points="4 7 12 3 20 7"></polyline><polyline points="20 7 20 21 4 21 4 7"></polyline><line x1="8" y1="21" x2="8" y2="12"></line><line x1="16" y1="21" x2="16" y2="12"></line>' },
{ path: '/weekly-report', label: '周报', icon: '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14 2 14 8 20 8"></polyline><line x1="16" y1="13" x2="8" y2="13"></line><line x1="16" y1="17" x2="8" y2="17"></line>' },
{ path: '/light-board', label: '亮灯表', icon: '<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"></polygon>' },
],
},
{
label: '工作',
items: [
{ path: '/work-plans', label: '工作计划', icon: '<rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect><line x1="16" y1="2" x2="16" y2="6"></line><line x1="8" y1="2" x2="8" y2="6"></line><line x1="3" y1="10" x2="21" y2="10"></line><polyline points="8 14 12 18 16 14"></polyline>' },
{ path: '/mini-business', label: '商机跟单', icon: '<line x1="12" y1="1" x2="12" y2="23"></line><path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"></path>' },
{ path: '/key-visits', label: '要客拜访', icon: '<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"></polygon>' },
],
},
]
if (auth.isManager || auth.isDirector) {
groups.push({
label: undefined,
items: [
{ path: '/workspace', label: '我的数据', icon: '<line x1="8" y1="6" x2="21" y2="6"></line><line x1="8" y1="12" x2="21" y2="12"></line><line x1="8" y1="18" x2="21" y2="18"></line><line x1="3" y1="6" x2="3.01" y2="6"></line><line x1="3" y1="12" x2="3.01" y2="12"></line><line x1="3" y1="18" x2="3.01" y2="18"></line>' },
],
})
}
groups.push({
label: '管理',
items: [
{ path: '/customers', label: '客户管理', icon: '<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="4"></circle>' },
],
})
if (auth.isDirector) {
groups[groups.length - 1].items.push(
{ path: '/users', label: '用户管理', icon: '<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="4"></circle><path d="M23 21v-2a4 4 0 0 0-3-3.87"></path><path d="M16 3.13a4 4 0 0 1 0 7.75"></path>' },
{ path: '/settings', label: '系统设置', icon: '<circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path>' },
)
}
return groups
})
const roleLabel = computed(() => {
if (auth.isDirector) return '支局长'
if (auth.isLeader) return '分管领导'
return '客户经理'
})
function cycleTheme() {
const next = themeStore.currentTheme === 'editorial' ? 'light' : 'editorial'
themeStore.setTheme(next)
}
</script>
<template>
<div class="desktop-layout">
<!-- ═══ Sidebar Ink & Gold Editorial ═══ -->
<aside class="sidebar" :class="{ 'sidebar--collapsed': collapsed }">
<div class="sidebar-brand">
<h1 class="brand-title">企迹</h1>
<p class="brand-sub">政企周报管理</p>
<div class="brand-rule"></div>
</div>
<nav class="sidebar-nav">
<template v-for="group in menuGroups" :key="group.label || 'nogroup'">
<div v-if="group.label" class="nav-group-label">{{ group.label }}</div>
<router-link
v-for="item in group.items"
:key="item.path"
:to="item.path"
class="nav-item"
:class="{ 'nav-item--active': route.path === item.path }"
>
<svg class="nav-icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" v-html="item.icon"></svg>
<span class="nav-label">{{ item.label }}</span>
</router-link>
</template>
</nav>
<div class="sidebar-footer">
<span class="footer-role">{{ roleLabel }}</span>
<span class="footer-name">{{ auth.name }}</span>
</div>
</aside>
<!-- ═══ Main Area ═══ -->
<div class="main-area">
<header class="topbar">
<button class="collapse-btn" @click="collapsed = !collapsed" :title="collapsed ? '展开菜单' : '折叠菜单'">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline :points="collapsed ? '13 17 18 12 13 7' : '11 7 6 12 11 17'"></polyline>
</svg>
</button>
<div class="topbar-actions">
<button class="topbar-btn" @click="cycleTheme" :title="'切换至' + themeLabels[themeStore.currentTheme === 'editorial' ? 'light' : 'editorial']">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle v-if="themeStore.currentTheme === 'editorial'" cx="12" cy="12" r="5"></circle>
<path v-if="themeStore.currentTheme === 'editorial'" d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"></path>
<path v-if="themeStore.currentTheme === 'light'" d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path>
</svg>
</button>
<button class="topbar-btn" @click="router.push('/m')" title="移动端">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="5" y="2" width="14" height="20" rx="2" ry="2"></rect>
<line x1="12" y1="18" x2="12.01" y2="18"></line>
</svg>
移动端
</button>
<button class="topbar-btn topbar-btn--logout" @click="auth.logout(); router.push('/login')">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path>
<polyline points="16 17 21 12 16 7"></polyline>
<line x1="21" y1="12" x2="9" y2="12"></line>
</svg>
退出
</button>
</div>
</header>
<main class="content">
<router-view />
</main>
</div>
</div>
</template>
<style scoped>
/* ═══ Layout ═══ */
.desktop-layout {
display: flex;
height: 100vh;
overflow: hidden;
}
/* ═══ Sidebar ═══ */
.sidebar {
width: 240px;
background: var(--sidebar-bg);
color: var(--sidebar-color);
display: flex;
flex-direction: column;
flex-shrink: 0;
overflow: hidden;
transition: width 0.25s ease;
}
.sidebar--collapsed {
width: 64px;
}
.sidebar--collapsed .brand-sub,
.sidebar--collapsed .brand-rule,
.sidebar--collapsed .nav-label,
.sidebar--collapsed .footer-role,
.sidebar--collapsed .footer-name { display: none; }
.sidebar--collapsed .brand-title { font-size: 20px; text-align: center; }
.sidebar--collapsed .nav-item { justify-content: center; padding: 11px 0; }
.sidebar--collapsed .nav-icon { font-size: 20px; margin: 0; }
.sidebar--collapsed .sidebar-footer { text-align: center; padding: 10px; }
.sidebar-brand {
padding: 26px 22px 18px;
border-bottom: 1px solid var(--sidebar-divider);
}
.brand-title {
margin: 0;
font-family: var(--font-heading);
font-size: 24px;
font-weight: 400;
letter-spacing: 0.12em;
color: var(--sidebar-brand-color);
line-height: 1.2;
}
.brand-sub {
margin: 2px 0 0;
font-family: var(--font-body);
font-size: 10px;
color: var(--sidebar-brand-sub-color);
letter-spacing: 0.1em;
}
.brand-rule {
width: 28px;
height: 3px;
background: var(--sidebar-accent);
margin-top: 14px;
}
/* ═══ Navigation ═══ */
.sidebar-nav {
flex: 1;
padding: 12px 10px;
display: flex;
flex-direction: column;
gap: 2px;
overflow-y: auto;
}
.nav-group-label {
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.15em;
color: var(--sidebar-group-label-color);
padding: 12px 14px 4px;
text-transform: uppercase;
}
.sidebar--collapsed .nav-group-label { display: none; }
.nav-item {
display: flex;
align-items: center;
gap: 10px;
padding: 11px 14px;
color: var(--sidebar-nav-text);
text-decoration: none;
font-family: var(--font-body);
font-size: 14px;
letter-spacing: 0.04em;
transition: all 0.22s;
border-left: 2px solid transparent;
}
.nav-item:hover {
color: var(--sidebar-nav-text-hover);
background: var(--sidebar-nav-bg-hover);
}
.nav-item--active {
color: var(--sidebar-nav-text-active);
background: var(--sidebar-nav-bg-active);
border-left-color: var(--sidebar-nav-border-active);
}
.nav-icon {
font-size: 18px;
flex-shrink: 0;
}
.nav-label {
font-weight: 500;
}
/* ═══ Sidebar Footer ═══ */
.sidebar-footer {
padding: 14px 22px;
border-top: 1px solid var(--sidebar-divider);
display: flex;
flex-direction: column;
gap: 2px;
}
.footer-role {
font-family: var(--font-body);
font-size: 10px;
color: var(--sidebar-footer-role-color);
letter-spacing: 0.1em;
}
.footer-name {
font-family: var(--font-heading);
font-size: 13px;
color: var(--sidebar-footer-name-color);
letter-spacing: 0.04em;
}
/* ═══ Main Area ═══ */
.main-area {
flex: 1;
display: flex;
flex-direction: column;
background: var(--paper);
min-width: 0;
}
/* ═══ Topbar ═══ */
.topbar {
background: var(--surface);
padding: 10px 24px;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid var(--warm-border);
flex-shrink: 0;
}
.topbar-greeting {
font-family: var(--font-heading);
font-size: 14px;
color: var(--ink);
letter-spacing: 0.04em;
}
.collapse-btn {
display: flex; align-items: center; justify-content: center;
width: 28px; height: 28px;
background: none; border: none;
color: var(--warm-gray); cursor: pointer;
transition: all 0.2s; flex-shrink: 0; margin-right: 8px;
opacity: 0.5;
}
.collapse-btn:hover { color: var(--ink); opacity: 1; }
.topbar-actions {
display: flex;
align-items: center;
gap: 8px;
}
.topbar-btn {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 6px 12px;
background: none;
border: 1px solid var(--warm-border);
color: var(--warm-gray);
font-family: var(--font-body);
font-size: 12px;
letter-spacing: 0.03em;
cursor: pointer;
transition: all 0.2s;
}
.topbar-btn:hover {
color: var(--ink);
border-color: var(--ink);
}
.topbar-btn--logout:hover {
color: var(--vermilion);
border-color: var(--vermilion);
}
/* ═══ Content ═══ */
.content {
flex: 1;
overflow-y: auto;
padding: 24px;
}
</style>