feat(nav): 侧栏高对比度 + 顶栏已打开页面标签 + 折叠态只留日夜切换 + 换明月图标
1) 侧边栏对比度
- 新增 --sb-* 专属色板(固定深色底,不随日夜主题翻转),文字 #c3d7e3、
hover 纯白、选中纯白 + 品牌色强调条 + 内描边,参考 GoodJob 的 Command Deck
- 原过渡曲线(--transition-fast/base)保持不变
- luxury.css 里把侧栏刷回浅底的覆盖一并改为同一套色板
2) 顶栏「已打开页面」标签
- 新增 src/navigation.js:NAV_GROUPS 与 PAGE_LABELS 单点维护,App 与顶栏共用
- GlobalHeader 用 useLocation 自动登记访问过的页面,工作台恒定置顶且不可关闭,
其余按最近使用排序,可单独关闭,最多 10 个,落 localStorage(happy.openTabs)
- 关掉当前标签会退到剩下的最近一个,没有则回工作台
3) 折叠态页脚
- .sidebar.collapsed .settings-btn 隐藏,76px 宽度下只留一个日夜切换按钮
4) 明月图标
- MoonIcon → MoonStarsIcon,造型取自 igoutu.cn/icon/101343/moon-and-stars,
重绘为矢量(月牙 = 两段圆弧相减,两枚凹边圆角四角星),走 currentColor 随主题变色
- 源图存 design-refs/(已忽略,不入库)
5) 顶栏排布修正
- 标签条加入后头部变挤,收窄品牌区/搜索框,按钮加 nowrap 防止文字折行
- ≤1280px 隐藏标签条
This commit is contained in:
@@ -1,7 +1,20 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
import axios from 'axios'
|
||||
import { BotIcon, ClockIcon, CloudDbIcon, PlusCircleIcon, SearchIcon } from './Icons'
|
||||
import { HOME_PATH, PAGE_LABELS } from '../navigation'
|
||||
|
||||
const TABS_STORAGE_KEY = 'happy.openTabs'
|
||||
const MAX_TABS = 10
|
||||
|
||||
/* 从本地缓存读取上次打开的页面(只保留仍然存在的路由,工作台恒定在首位) */
|
||||
function readStoredTabs() {
|
||||
try {
|
||||
const raw = JSON.parse(localStorage.getItem(TABS_STORAGE_KEY) || '[]')
|
||||
const list = Array.isArray(raw) ? raw.filter(path => PAGE_LABELS[path]) : []
|
||||
return [HOME_PATH, ...list.filter(path => path !== HOME_PATH)]
|
||||
} catch { return [HOME_PATH] }
|
||||
}
|
||||
|
||||
function GlobalHeader({ onOpenAssistant }) {
|
||||
const [now, setNow] = useState(new Date())
|
||||
@@ -9,14 +22,44 @@ function GlobalHeader({ onOpenAssistant }) {
|
||||
const [query, setQuery] = useState('')
|
||||
const [results, setResults] = useState([])
|
||||
const [searching, setSearching] = useState(false)
|
||||
const [openTabs, setOpenTabs] = useState(readStoredTabs)
|
||||
const searchRef = useRef(null)
|
||||
const tabsRef = useRef(null)
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setInterval(() => setNow(new Date()), 1000)
|
||||
return () => window.clearInterval(timer)
|
||||
}, [])
|
||||
|
||||
/* 访问过的页面自动登记成标签:工作台恒在首位,其余按最近使用排序,新页插在第二位 */
|
||||
useEffect(() => {
|
||||
const path = location.pathname
|
||||
if (!PAGE_LABELS[path]) return
|
||||
setOpenTabs(current => {
|
||||
if (current[0] === path) return current
|
||||
if (current[1] === path) return current
|
||||
const rest = current.filter(item => item !== path && item !== HOME_PATH)
|
||||
return [HOME_PATH, path, ...rest].slice(0, MAX_TABS)
|
||||
})
|
||||
}, [location.pathname])
|
||||
|
||||
useEffect(() => {
|
||||
try { localStorage.setItem(TABS_STORAGE_KEY, JSON.stringify(openTabs)) } catch { /* 存储不可用时仅本次会话有效 */ }
|
||||
const active = tabsRef.current?.querySelector('.page-tab.active')
|
||||
active?.scrollIntoView({ block: 'nearest', inline: 'nearest' })
|
||||
}, [openTabs, location.pathname])
|
||||
|
||||
/* 关闭标签:关掉的若是当前页,就退到剩下的最近一个,没有则回工作台 */
|
||||
const closeTab = (event, path) => {
|
||||
event.stopPropagation()
|
||||
const next = openTabs.filter(item => item !== path)
|
||||
const result = next.length ? next : [HOME_PATH]
|
||||
setOpenTabs(result)
|
||||
if (path === location.pathname) navigate(result[0])
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const keyword = query.trim()
|
||||
if (!keyword) { setResults([]); setSearching(false); return }
|
||||
@@ -59,6 +102,20 @@ function GlobalHeader({ onOpenAssistant }) {
|
||||
<div className="global-search-footnote">已检索客户、供应商、产品、订单、商机、待办和活动记录</div>
|
||||
</div>}
|
||||
</div>
|
||||
{/* 已打开页面:搜索框后的快速跳转标签,工作台常驻且不可关闭 */}
|
||||
<nav className="page-tabs" ref={tabsRef} aria-label="已打开页面">
|
||||
{openTabs.map(path => {
|
||||
const label = PAGE_LABELS[path] || path
|
||||
const isHome = path === HOME_PATH
|
||||
const active = location.pathname === path
|
||||
return (
|
||||
<div key={path} className={`page-tab${active ? ' active' : ''}`}>
|
||||
<button type="button" className="page-tab-label" title={label} aria-current={active ? 'page' : undefined} onClick={() => navigate(path)}>{label}</button>
|
||||
{!isHome && <button type="button" className="page-tab-close" aria-label={`关闭${label}`} title={`关闭${label}`} onClick={event => closeTab(event, path)}>×</button>}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
<div className="global-header-actions"><div className="header-clock"><ClockIcon size={16} /><span>{dateLabel}</span><b>{timeLabel}</b></div><button className="quick-create-btn" title="快速新建"><PlusCircleIcon size={19} /><span>快速新建</span></button><button className="ai-entry-btn" onClick={onOpenAssistant} title="打开 AI 助手"><BotIcon size={19} /><span>AI 助手</span><i>β</i></button><button className="header-avatar" title="当前用户">W</button><button className="mobile-search-btn" onClick={() => { setSearchOpen(v => !v); window.setTimeout(() => searchRef.current?.focus(), 0) }} title="搜索"><SearchIcon size={18} /></button></div>
|
||||
</header>
|
||||
)
|
||||
|
||||
@@ -148,9 +148,17 @@ export const SunIcon = ({ size = 20, className = '' }) => (
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const MoonIcon = ({ size = 20, className = '' }) => (
|
||||
/* 明月(含两颗四角星)——夜间模式切换图标
|
||||
造型取自 https://igoutu.cn/icon/101343/moon-and-stars(Icons8 iOS Glyph 风格「明月」),
|
||||
按原图重绘为矢量:月牙 = 大圆挖掉一枚偏向右上的圆(两段圆弧相减),
|
||||
两枚四角星 = 凹边圆角菱形。全部走 currentColor,可跟随日夜主题换色。 */
|
||||
export const MoonStarsIcon = ({ size = 20, className = '' }) => (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" className={className} aria-hidden="true">
|
||||
<path d="M12 2.5C6.7 2.5 2.5 6.8 2.5 12s4.2 9.5 9.5 9.5c-3.4-2-5.6-5.6-5.6-9.5S8.6 4.5 12 2.5Z" fill="currentColor" />
|
||||
<g fill="currentColor" stroke="currentColor" strokeWidth="1.2" strokeLinejoin="round" strokeLinecap="round">
|
||||
<path d="M9.63 3.09A10 10 0 1 0 20.61 15.77A9.5 9.5 0 0 1 9.63 3.09Z" />
|
||||
<path d="M14.85 1.1C14.85 1.805 16.495 3.45 17.2 3.45C16.495 3.45 14.85 5.095 14.85 5.8C14.85 5.095 13.205 3.45 12.5 3.45C13.205 3.45 14.85 1.805 14.85 1.1Z" />
|
||||
<path d="M19.95 5.9C19.95 6.86 22.19 9.1 23.15 9.1C22.19 9.1 19.95 11.34 19.95 12.3C19.95 11.34 17.71 9.1 16.75 9.1C17.71 9.1 19.95 6.86 19.95 5.9Z" />
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState, useEffect, useRef } from 'react'
|
||||
import axios from 'axios'
|
||||
import { MoonIcon, SunIcon, TuneIcon, CheckIcon, CloseIcon, ExchangeIcon, SparkIcon, RefreshIcon } from './Icons'
|
||||
import { MoonStarsIcon, SunIcon, TuneIcon, CheckIcon, CloseIcon, ExchangeIcon, SparkIcon, RefreshIcon } from './Icons'
|
||||
|
||||
function Settings({ isOpen, onClose, theme, onThemeChange, reducedMotion, onReducedMotionChange }) {
|
||||
const [activeCategory, setActiveCategory] = useState('appearance')
|
||||
@@ -85,7 +85,7 @@ function AppearanceSettings({ theme, onThemeChange, reducedMotion, onReducedMoti
|
||||
|
||||
<div className="theme-selector" role="group" aria-label="界面主题">
|
||||
<button className={`theme-option ${theme === 'dark' ? 'active' : ''}`} aria-pressed={theme === 'dark'} onClick={() => onThemeChange('dark')}>
|
||||
<span className="theme-preview theme-preview-dark" aria-hidden="true"><span className="mini-sidebar"><i /><i /><i /></span><span className="mini-workspace"><MoonIcon size={18} /><span className="mini-stats"><i /><i /><i /></span><span className="mini-table"><i /><i /><i /></span></span></span>
|
||||
<span className="theme-preview theme-preview-dark" aria-hidden="true"><span className="mini-sidebar"><i /><i /><i /></span><span className="mini-workspace"><MoonStarsIcon size={18} /><span className="mini-stats"><i /><i /><i /></span><span className="mini-table"><i /><i /><i /></span></span></span>
|
||||
<span><strong>夜间控制台</strong><small>深色 · 低光环境</small></span>
|
||||
<span className="theme-check" aria-hidden="true">{theme === 'dark' && <CheckIcon size={14} />}</span>
|
||||
</button>
|
||||
|
||||
Reference in New Issue
Block a user