diff --git a/backend/app/api/users.py b/backend/app/api/users.py index 2aa1323..71bd36e 100644 --- a/backend/app/api/users.py +++ b/backend/app/api/users.py @@ -16,6 +16,7 @@ class UpdateUserRoleRequest(BaseModel): role: str # manager / director / leader department: str = "" require_report: bool = True + color: Optional[str] = None # hex color for manager tags @router.get("/", response_model=list[UserOut]) @@ -73,6 +74,8 @@ async def update_user_role( if data.department: user.department = data.department user.require_report = data.require_report + if data.color is not None: + user.color = data.color await db.commit() await db.refresh(user) @@ -82,6 +85,7 @@ async def update_user_role( "role": user.role, "department": user.department, "require_report": user.require_report, + "color": user.color, } diff --git a/backend/app/main.py b/backend/app/main.py index a758c37..30f34b1 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -45,6 +45,10 @@ async def lifespan(app: FastAPI): await conn.run_sync(lambda c: c.exec_driver_sql( "ALTER TABLE visits ADD COLUMN IF NOT EXISTS companion_names TEXT[] DEFAULT '{}'" )) + # v0.6 — manager color tag + await conn.run_sync(lambda c: c.exec_driver_sql( + "ALTER TABLE users ADD COLUMN IF NOT EXISTS color VARCHAR(7)" + )) # system_config table for v0.5 await conn.run_sync(lambda c: c.exec_driver_sql( "CREATE TABLE IF NOT EXISTS system_config (key VARCHAR(64) PRIMARY KEY, value TEXT DEFAULT '')" diff --git a/backend/app/models/user.py b/backend/app/models/user.py index aaa9613..40e6772 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -16,4 +16,5 @@ class User(Base): department: Mapped[str] = mapped_column(String(100), default="") wecom_userid: Mapped[str | None] = mapped_column(String(100), unique=True, nullable=True) require_report: Mapped[bool] = mapped_column(default=True, server_default="true") + color: Mapped[str | None] = mapped_column(String(7), nullable=True) # e.g. "#C62828" created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) diff --git a/backend/app/schemas/customer.py b/backend/app/schemas/customer.py index be6dcef..d779751 100644 --- a/backend/app/schemas/customer.py +++ b/backend/app/schemas/customer.py @@ -20,6 +20,7 @@ class UserOut(BaseModel): department: str wecom_userid: Optional[str] = None require_report: bool = True + color: Optional[str] = None model_config = {"from_attributes": True} diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 120b6ce..c9d8d5d 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -67,6 +67,114 @@ const isMobile = computed(() => { --radius-sm: 2px; --radius-xs: 2px; --transition: 0.22s cubic-bezier(0.4, 0, 0.2, 1); + + /* ── Sidebar — dark editorial ── */ + --sidebar-bg: var(--ink); + --sidebar-color: #fff; + --sidebar-accent: var(--gold); + --sidebar-nav-text: rgba(255,255,255,0.55); + --sidebar-nav-text-hover: rgba(255,255,255,0.85); + --sidebar-nav-text-active: #fff; + --sidebar-nav-bg-hover: rgba(255,255,255,0.04); + --sidebar-nav-bg-active: rgba(255,255,255,0.06); + --sidebar-nav-border-active: var(--gold); + --sidebar-divider: rgba(255,255,255,0.06); + --sidebar-brand-color: #fff; + --sidebar-brand-sub-color: rgba(255,255,255,0.4); + --sidebar-group-label-color: rgba(255,255,255,0.25); + --sidebar-footer-role-color: var(--gold); + --sidebar-footer-name-color: rgba(255,255,255,0.7); + + /* ── Mobile page accent ── */ + --page-accent-color: rgba(196,147,74,0.06); + + /* ── Manager chip tags ── */ + --mgr-chip-text: #fff; + + /* ── Fonts — editorial Chinese ── */ + --font-body: 'Noto Serif SC', STSong, Songti SC, '宋体', serif; + --font-heading: 'ZCOOL XiaoWei', STSong, Songti SC, serif; + --font-mono: 'JetBrains Mono', 'SF Mono', monospace; +} + +/* ═══════════════════════════════════════════════════════════ + THEME: Light — 极简功能主义 / Brutally Minimal + ═══════════════════════════════════════════════════════════ */ + +:root[data-theme="light"] { + /* ── Core palette ── */ + --ink: #1A1A1A; + --ink-light: #3D3D3D; + --ink-dark: #0A0A0A; + --vermilion: #DC2626; + --vermilion-light: #EF4444; + --vermilion-dark: #B91C1C; + --gold: #D4AF37; + --gold-light: #E5C955; + --gold-dark: #B8941F; + --paper: #F5F5F5; + --paper-dark: #EBEBEB; + --surface: #FFFFFF; + --sage: #16A34A; + --amber: #D97706; + --warm-gray: #666666; + --warm-border: #DDDDDD; + + /* ── Semantic tokens ── */ + --c-primary: var(--ink); + --c-primary-light: var(--ink-light); + --c-primary-bg: #F5F5F5; + --c-accent: var(--vermilion); + --c-accent-light: var(--vermilion-light); + --c-gold: var(--gold); + --c-success: var(--sage); + --c-warning: var(--amber); + --c-danger: var(--vermilion); + --c-info: #2563EB; + --c-text: #1A1A1A; + --c-text-secondary: #666666; + --c-text-muted: #999999; + --c-bg: var(--paper); + --c-bg-card: var(--surface); + --c-border: var(--warm-border); + + /* ── Effects ── */ + --shadow-sm: 0 1px 2px rgba(0,0,0,0.04); + --shadow: 0 1px 3px rgba(0,0,0,0.08); + --shadow-md: 0 2px 6px rgba(0,0,0,0.10); + --shadow-lg: 0 4px 12px rgba(0,0,0,0.12); + --radius: 0px; + --radius-sm: 0px; + --radius-xs: 0px; + --transition: 0.18s cubic-bezier(0.4, 0, 0.2, 1); + + /* ── Sidebar — light minimal ── */ + --sidebar-bg: #FFFFFF; + --sidebar-color: #1A1A1A; + --sidebar-accent: var(--gold); + --sidebar-nav-text: #777777; + --sidebar-nav-text-hover: #1A1A1A; + --sidebar-nav-text-active: #1A1A1A; + --sidebar-nav-bg-hover: #F5F5F5; + --sidebar-nav-bg-active: #F0F0F0; + --sidebar-nav-border-active: var(--gold); + --sidebar-divider: #EEEEEE; + --sidebar-brand-color: #1A1A1A; + --sidebar-brand-sub-color: #999999; + --sidebar-group-label-color: #AAAAAA; + --sidebar-footer-role-color: var(--gold); + --sidebar-footer-name-color: #777777; + + /* ── Mobile page accent ── */ + --page-accent-color: rgba(212,175,55,0.04); + + /* ── Manager chip tags ── */ + --mgr-chip-text: #1A1A1A; + + /* ── Fonts — sans-serif minimal ── */ + --font-body: 'Noto Sans SC', 'PingFang SC', 'Microsoft YaHei', sans-serif; + --font-heading: 'Noto Sans SC', 'PingFang SC', 'Microsoft YaHei', sans-serif; + --font-mono: 'JetBrains Mono', monospace; } /* ── Reset & Base ── */ @@ -76,7 +184,7 @@ html, body, #app { margin: 0; padding: 0; height: 100%; overflow-x: hidden; -webkit-overflow-scrolling: touch; - font-family: 'Noto Serif SC', STSong, Songti SC, '宋体', serif; + font-family: var(--font-body); font-size: 15px; line-height: 1.7; color: var(--c-text); @@ -87,7 +195,7 @@ html, body, #app { /* ── Editorial heading style ── */ h1, h2, h3, h4, h5, h6 { - font-family: 'ZCOOL XiaoWei', STSong, Songti SC, serif; + font-family: var(--font-heading); letter-spacing: 0.02em; } @@ -107,7 +215,7 @@ h1, h2, h3, h4, h5, h6 { .el-card__header { border-bottom: 2px solid var(--gold) !important; padding: 16px 20px !important; - font-family: 'ZCOOL XiaoWei', STSong, serif; + font-family: var(--font-heading); font-weight: 600; font-size: 16px; color: var(--ink); @@ -121,7 +229,7 @@ h1, h2, h3, h4, h5, h6 { font-weight: 500; letter-spacing: 0.02em; transition: all var(--transition); - font-family: 'Noto Serif SC', STSong, Songti SC, serif; + font-family: var(--font-body); } .el-button--primary { background: var(--ink) !important; @@ -149,14 +257,14 @@ h1, h2, h3, h4, h5, h6 { .el-tag { border-radius: 2px !important; font-weight: 500; - font-family: 'Noto Serif SC', STSong, serif; + font-family: var(--font-body); letter-spacing: 0.02em; } /* ── Tabs ── */ .el-tabs__item { font-weight: 500; - font-family: 'ZCOOL XiaoWei', STSong, serif; + font-family: var(--font-heading); } .el-tabs__active-bar { background: var(--gold) !important; } .el-tabs__item.is-active { color: var(--ink) !important; } @@ -167,7 +275,7 @@ h1, h2, h3, h4, h5, h6 { color: var(--ink); font-weight: 600; font-size: 13px; - font-family: 'ZCOOL XiaoWei', STSong, serif; + font-family: var(--font-heading); letter-spacing: 0.03em; } .el-table--striped .el-table__body tr.el-table__row--striped td.el-table__cell { @@ -184,7 +292,7 @@ h1, h2, h3, h4, h5, h6 { .el-dialog__header { padding: 20px 24px 0 !important; font-weight: 600; - font-family: 'ZCOOL XiaoWei', STSong, serif; + font-family: var(--font-heading); } .el-dialog__body { padding: 20px 24px !important; } @@ -225,7 +333,7 @@ h1, h2, h3, h4, h5, h6 { /* ── Textarea ── */ .el-textarea__inner { border-radius: 2px !important; - font-family: 'Noto Serif SC', STSong, serif; + font-family: var(--font-body); } /* ── Radio ── */ @@ -258,7 +366,7 @@ h1, h2, h3, h4, h5, h6 { /* ── Divider ── */ .el-divider__text { - font-family: 'ZCOOL XiaoWei', STSong, serif; + font-family: var(--font-heading); color: var(--warm-gray); } diff --git a/frontend/src/components/DesktopLayout.vue b/frontend/src/components/DesktopLayout.vue index f7ed9b8..a411523 100644 --- a/frontend/src/components/DesktopLayout.vue +++ b/frontend/src/components/DesktopLayout.vue @@ -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) +}