Files
happy-export/frontend/src/components/GlobalHeader.jsx
T
Drsakura 13d67c804f feat: 多用户权限体系、AI 助手、SKU 目录与合同清洗(开源准备)
本地长期开发成果的整体入库快照,包含后端鉴权/权限、AI 接入、合同解析、
SKU 目录等模块,以及前端相应页面与样式改版。

后端
- 新增鉴权与会话:scrypt 密码哈希、HttpOnly cookie(happy_sid)、登录失败锁定
- 新增「角色 → 权限组 → 权限点」三级权限模型与审计日志
- 新增 AI 提供商接入与预设(兼容 OpenAI 协议,内置 DeepSeek / 通义千问等)
- 新增合同解析、合同文件保留策略、客户归属与访问控制
- 新增 SKU 目录、组织品牌、新客开发、系统日志等路由

前端
- 新增登录页与用户 / 权限组 / 系统日志管理页
- 新增客户详情、客户公海、询盘详情、报价编辑器、合同清洗中心
- 新增 AI 配置、组织品牌、包装参数、SKU 选择器等组件
- 新增英文大写金额、未保存改动拦截、抽屉与骨架屏
- 新增 stage1 样式层;产品、供应商、订单等页面改版
- EmbeddedSku 页面并入现有产品体系后删除

开源准备
- 新增 AGPL-3.0 LICENSE
- README:移除未启用的 SQLCipher 加密声明(改为如实的「已知限制」)、
  补充环境变量说明、许可证由「私有项目」改为 AGPL-3.0
- 移除历史遗留的空文件 _index_backup.txt
- 文档中的项目路径更新为 happy-export
2026-09-17 14:53:02 +08:00

161 lines
8.2 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useEffect, useRef, useState } from 'react'
import { useLocation, useNavigate } from 'react-router-dom'
import axios from 'axios'
import { BotIcon, CloudDbIcon, PlusCircleIcon, SearchIcon, TrashIcon } from './Icons'
import { useAuth, roleLabel } from '../auth'
import { useBranding } from '../branding'
import TimeZoneClock from './TimeZoneClock'
import FxCalculator from './FxCalculator'
import { HOME_PATH, PAGE_LABELS } from '../navigation'
const TABS_STORAGE_KEY = 'happy.openTabs'
/* 工作台恒定占第一位,后面最多再挂 4 个页面 */
const MAX_TAB_PAGES = 4
const MAX_TABS = MAX_TAB_PAGES + 1
/* 从本地缓存读取上次打开的页面(只保留仍然存在的路由,工作台恒定在首位) */
function readStoredTabs() {
try {
const raw = JSON.parse(localStorage.getItem(TABS_STORAGE_KEY) || '[]')
const list = Array.isArray(raw) ? raw.filter(path => PAGE_LABELS[path] && path !== HOME_PATH) : []
return [HOME_PATH, ...list.slice(0, MAX_TAB_PAGES)]
} catch { return [HOME_PATH] }
}
function GlobalHeader({ onOpenAssistant, onNavigate }) {
const { user } = useAuth()
const [now, setNow] = useState(new Date())
const [searchOpen, setSearchOpen] = useState(false)
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.find(item => item !== HOME_PATH) || HOME_PATH)
}
}
/* 清空快捷标签:工作台常驻不动,其余一次性摘掉;当前页若被清掉就回工作台 */
const clearTabs = () => {
setOpenTabs([HOME_PATH])
if (location.pathname !== HOME_PATH && PAGE_LABELS[location.pathname]) navigate(HOME_PATH)
}
useEffect(() => {
const keyword = query.trim()
if (!keyword) { setResults([]); setSearching(false); return }
const timer = window.setTimeout(async () => {
setSearching(true)
try {
const response = await axios.get('/api/search', { params: { q: keyword } })
setResults(response.data.results || [])
} catch { setResults([]) } finally { setSearching(false) }
}, 180)
return () => window.clearTimeout(timer)
}, [query])
useEffect(() => {
const onKeyDown = event => {
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'k') {
event.preventDefault()
setSearchOpen(true)
window.setTimeout(() => searchRef.current?.focus(), 0)
}
if (event.key === 'Escape') { setSearchOpen(false); searchRef.current?.blur() }
}
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [])
/* 页内所有跳转都走外壳的 guardedNavigate:个人资料有未保存改动时会先弹确认 */
const go = React.useCallback(path => {
if (onNavigate) onNavigate(path)
else navigate(path)
}, [onNavigate, navigate])
const selectResult = result => { go(result.path); setQuery(''); setSearchOpen(false) }
const displayName = user?.display_name || user?.username || '未登录'
const brand = useBranding()
const positionText = user?.position || roleLabel(user?.role)
const avatarText = (displayName || '?').slice(0, 1).toUpperCase()
return (
<header className="global-header">
<div className="global-header-brand"><span className="brand-mark" title={brand.name}><CloudDbIcon size={36} /><span className="header-status-dot" /></span><div><strong>{brand.name}</strong><span>{brand.subtitle}</span></div></div>
<div className={`global-search ${searchOpen ? 'is-open' : ''}`}>
<SearchIcon size={17} />
<input type="text" ref={searchRef} aria-label="搜索 everything" value={query} onFocus={() => setSearchOpen(true)} onChange={event => setQuery(event.target.value)} placeholder="搜索 everything" autoComplete="off" />
<kbd>⌘ K</kbd>
{searchOpen && query.trim() && <div className="global-search-results" role="listbox" aria-label="全局搜索结果">
{searching ? <div className="global-search-status">正在检索系统业务数据…</div> : results.length ? results.map(result => <button key={`${result.type}-${result.id}`} role="option" onMouseDown={event => event.preventDefault()} onClick={() => selectResult(result)}><span className={`search-result-mark ${result.type}`}>{result.typeLabel}</span><span><strong>{result.title}</strong><small>{result.detail}</small></span></button>) : <div className="global-search-status">未找到匹配的数据</div>}
<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={() => go(path)}>{label}</button>
{!isHome && <button type="button" className="page-tab-close" aria-label={`关闭${label}`} title={`关闭${label}`} onClick={event => closeTab(event, path)}>×</button>}
</div>
)
})}
</nav>
{openTabs.length > 1 && (
<button
type="button"
className="page-tabs-clear"
onClick={clearTabs}
aria-label="清空快捷标签"
title="清空快捷标签(工作台保留)"
>
<TrashIcon size={14} />
</button>
)}
<div className="global-header-actions"><TimeZoneClock now={now} /><FxCalculator /><button className="quick-create-btn" title="快速新建"><PlusCircleIcon size={19} /><span>快速新建</span></button><button className="ai-entry-btn" onClick={onOpenAssistant} title="打开小皮"><BotIcon size={19} /><span>小皮</span><i>β</i></button><button type="button" className="header-user" onClick={() => go('/profile')} title={`${displayName} · ${positionText} · 个人资料`} aria-label="个人资料"><span className="header-avatar">{user?.avatar ? <img src={user.avatar} alt="" /> : avatarText}</span><span className="header-user-text"><b>{displayName}</b><small>{positionText}</small></span></button><button className="mobile-search-btn" onClick={() => { setSearchOpen(v => !v); window.setTimeout(() => searchRef.current?.focus(), 0) }} title="搜索"><SearchIcon size={18} /></button></div>
</header>
)
}
export default GlobalHeader