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>
This commit is contained in:
2026-07-06 16:03:13 +08:00
parent 336b60b3af
commit deeafe239f
37 changed files with 2275 additions and 218 deletions
+38 -24
View File
@@ -2,10 +2,12 @@
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 }[] }
@@ -57,6 +59,11 @@ const roleLabel = computed(() => {
if (auth.isLeader) return '分管领导'
return '客户经理'
})
function cycleTheme() {
const next = themeStore.currentTheme === 'editorial' ? 'light' : 'editorial'
themeStore.setTheme(next)
}
</script>
<template>
@@ -100,6 +107,13 @@ const roleLabel = computed(() => {
</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>
@@ -135,8 +149,8 @@ const roleLabel = computed(() => {
/* ═══ Sidebar ═══ */
.sidebar {
width: 240px;
background: var(--ink);
color: #fff;
background: var(--sidebar-bg);
color: var(--sidebar-color);
display: flex;
flex-direction: column;
flex-shrink: 0;
@@ -158,31 +172,31 @@ const roleLabel = computed(() => {
.sidebar-brand {
padding: 26px 22px 18px;
border-bottom: 1px solid rgba(255,255,255,0.06);
border-bottom: 1px solid var(--sidebar-divider);
}
.brand-title {
margin: 0;
font-family: 'ZCOOL XiaoWei', STSong, Songti SC, serif;
font-family: var(--font-heading);
font-size: 24px;
font-weight: 400;
letter-spacing: 0.12em;
color: #fff;
color: var(--sidebar-brand-color);
line-height: 1.2;
}
.brand-sub {
margin: 2px 0 0;
font-family: 'Noto Serif SC', STSong, serif;
font-family: var(--font-body);
font-size: 10px;
color: rgba(255,255,255,0.4);
color: var(--sidebar-brand-sub-color);
letter-spacing: 0.1em;
}
.brand-rule {
width: 28px;
height: 3px;
background: var(--gold);
background: var(--sidebar-accent);
margin-top: 14px;
}
@@ -197,10 +211,10 @@ const roleLabel = computed(() => {
}
.nav-group-label {
font-family: 'JetBrains Mono', 'SF Mono', monospace;
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.15em;
color: rgba(255,255,255,0.25);
color: var(--sidebar-group-label-color);
padding: 12px 14px 4px;
text-transform: uppercase;
}
@@ -212,9 +226,9 @@ const roleLabel = computed(() => {
align-items: center;
gap: 10px;
padding: 11px 14px;
color: rgba(255,255,255,0.55);
color: var(--sidebar-nav-text);
text-decoration: none;
font-family: 'Noto Serif SC', STSong, serif;
font-family: var(--font-body);
font-size: 14px;
letter-spacing: 0.04em;
transition: all 0.22s;
@@ -222,14 +236,14 @@ const roleLabel = computed(() => {
}
.nav-item:hover {
color: rgba(255,255,255,0.85);
background: rgba(255,255,255,0.04);
color: var(--sidebar-nav-text-hover);
background: var(--sidebar-nav-bg-hover);
}
.nav-item--active {
color: #fff;
background: rgba(255,255,255,0.06);
border-left-color: var(--gold);
color: var(--sidebar-nav-text-active);
background: var(--sidebar-nav-bg-active);
border-left-color: var(--sidebar-nav-border-active);
}
.nav-icon {
@@ -244,23 +258,23 @@ const roleLabel = computed(() => {
/* ═══ Sidebar Footer ═══ */
.sidebar-footer {
padding: 14px 22px;
border-top: 1px solid rgba(255,255,255,0.06);
border-top: 1px solid var(--sidebar-divider);
display: flex;
flex-direction: column;
gap: 2px;
}
.footer-role {
font-family: 'Noto Serif SC', STSong, serif;
font-family: var(--font-body);
font-size: 10px;
color: var(--gold);
color: var(--sidebar-footer-role-color);
letter-spacing: 0.1em;
}
.footer-name {
font-family: 'ZCOOL XiaoWei', STSong, serif;
font-family: var(--font-heading);
font-size: 13px;
color: rgba(255,255,255,0.7);
color: var(--sidebar-footer-name-color);
letter-spacing: 0.04em;
}
@@ -285,7 +299,7 @@ const roleLabel = computed(() => {
}
.topbar-greeting {
font-family: 'ZCOOL XiaoWei', STSong, serif;
font-family: var(--font-heading);
font-size: 14px;
color: var(--ink);
letter-spacing: 0.04em;
@@ -315,7 +329,7 @@ const roleLabel = computed(() => {
background: none;
border: 1px solid var(--warm-border);
color: var(--warm-gray);
font-family: 'Noto Serif SC', STSong, serif;
font-family: var(--font-body);
font-size: 12px;
letter-spacing: 0.03em;
cursor: pointer;
+2 -2
View File
@@ -125,14 +125,14 @@ function labelFor(field: string) {
}
.log-editor {
font-family: 'ZCOOL XiaoWei', STSong, serif;
font-family: var(--font-heading);
font-size: 13px;
color: var(--ink);
font-weight: 600;
}
.log-time {
font-family: 'JetBrains Mono', 'SF Mono', monospace;
font-family: var(--font-mono);
font-size: 11px;
color: var(--c-text-muted);
}
+2 -2
View File
@@ -266,7 +266,7 @@ const imgStyle = computed(() => {
background: transparent;
color: rgba(255, 255, 255, 0.75);
cursor: pointer;
font-family: 'Noto Serif SC', STSong, serif;
font-family: var(--font-body);
font-size: 12px;
transition: all 0.15s;
white-space: nowrap;
@@ -287,7 +287,7 @@ const imgStyle = computed(() => {
}
.fit-label {
font-family: 'JetBrains Mono', 'SF Mono', monospace;
font-family: var(--font-mono);
font-size: 11px;
letter-spacing: 0.03em;
}
+5 -5
View File
@@ -129,7 +129,7 @@ const activeTab = computed(() => {
.header-title {
margin: 0;
font-family: 'ZCOOL XiaoWei', STSong, Songti SC, serif;
font-family: var(--font-heading);
font-size: 26px;
font-weight: 400;
color: var(--ink);
@@ -138,7 +138,7 @@ const activeTab = computed(() => {
}
.header-subtitle {
font-family: 'Noto Serif SC', STSong, Songti SC, serif;
font-family: var(--font-body);
font-size: 11px;
color: var(--warm-gray);
letter-spacing: 0.12em;
@@ -178,7 +178,7 @@ const activeTab = computed(() => {
}
.header-user {
font-family: 'Noto Serif SC', STSong, serif;
font-family: var(--font-body);
font-size: 13px;
color: var(--warm-gray);
letter-spacing: 0.03em;
@@ -201,7 +201,7 @@ const activeTab = computed(() => {
right: 0;
width: 60px;
height: 60px;
background: linear-gradient(135deg, transparent 60%, rgba(196,147,74,0.06) 60%);
background: linear-gradient(135deg, transparent 60%, var(--page-accent-color) 60%);
pointer-events: none;
z-index: 0;
}
@@ -238,7 +238,7 @@ const activeTab = computed(() => {
cursor: pointer;
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
white-space: nowrap;
font-family: 'Noto Serif SC', STSong, serif;
font-family: var(--font-body);
letter-spacing: 0.03em;
}