chore: happy 出口通初始提交(前端 React + 后端 Express,采购管理已原生化)

This commit is contained in:
Drsakura
2026-09-10 16:16:59 +08:00
commit d8df2c3960
34 changed files with 9343 additions and 0 deletions
+85
View File
@@ -0,0 +1,85 @@
import React, { useEffect, useMemo, useRef, useState } from 'react'
import axios from 'axios'
import { BotIcon, CloseIcon, SendIcon, SparkIcon } from './Icons'
const starterMessages = [
{ role: 'assistant', text: '你好,我是你的供应链 AI 助手。可以帮你查看客户、产品、供应商、订单、商机和待办,也可以协助发起系统内的业务操作。' },
]
function AIAssistant({ isOpen, onClose }) {
const [messages, setMessages] = useState(starterMessages)
const [input, setInput] = useState('')
const [context, setContext] = useState(null)
const [loading, setLoading] = useState(false)
const inputRef = useRef(null)
const panelRef = useRef(null)
useEffect(() => {
if (!isOpen) return
inputRef.current?.focus()
const onKeyDown = (event) => {
if (event.key === 'Escape') onClose()
}
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [isOpen, onClose])
useEffect(() => {
if (!isOpen) return
axios.get('/api/assistant/context').then(response => setContext(response.data)).catch(() => setContext(null))
}, [isOpen])
const capabilities = useMemo(() => [
'读取全局业务概览与实时统计',
'检索客户、供应商、产品和订单',
'查看商机、待办与近期活动',
'发起已授权的新增、更新和状态操作',
], [])
if (!isOpen) return null
const sendMessage = async (text = input) => {
const prompt = text.trim()
if (!prompt || loading) return
setInput('')
setMessages(prev => [...prev, { role: 'user', text: prompt }])
setLoading(true)
try {
const response = await axios.post('/api/assistant/command', { prompt })
setMessages(prev => [...prev, { role: 'assistant', text: response.data.reply || '我已收到指令,但当前还没有可执行的结果。', meta: response.data.meta }])
if (response.data.context) setContext(response.data.context)
} catch {
setMessages(prev => [...prev, { role: 'assistant', text: '暂时无法连接系统能力服务,请确认后端服务正在运行。' }])
} finally {
setLoading(false)
}
}
return (
<div className="assistant-layer" onClick={onClose}>
<aside ref={panelRef} className="ai-assistant-panel" role="dialog" aria-modal="true" aria-labelledby="ai-assistant-title" onClick={event => event.stopPropagation()}>
<div className="ai-panel-header">
<div className="ai-panel-title"><span className="ai-orb"><BotIcon size={20} /></span><div><span className="section-label">[SYSTEM COPILOT]</span><h2 id="ai-assistant-title">AI 助手</h2></div></div>
<button className="ai-close-btn" onClick={onClose} aria-label="关闭 AI 助手"><CloseIcon size={18} /></button>
</div>
<div className="ai-access-banner"><span className="ai-live-dot" /><div><strong>已连接系统业务层</strong><small>可读取模块数据,敏感写操作会先请求确认</small></div></div>
<div className="ai-panel-body">
<div className="ai-capability-card"><div className="ai-capability-heading"><SparkIcon size={15} />我能帮你</div>{capabilities.map(capability => <div className="ai-capability-row" key={capability}><span>✓</span>{capability}</div>)}</div>
{context && <div className="ai-context-strip"><span>当前数据快照</span><b>{context.stats?.customers || 0} 客户</b><b>{context.stats?.products || 0} 产品</b><b>{context.stats?.orders || 0} 订单</b></div>}
<div className="ai-message-list">
{messages.map((message, index) => <div className={`ai-message ${message.role}`} key={`${message.role}-${index}`}><div className="ai-message-avatar">{message.role === 'assistant' ? <BotIcon size={15} /> : 'W'}</div><div className="ai-message-content"><p>{message.text}</p>{message.meta && <small>{message.meta}</small>}</div></div>)}
{loading && <div className="ai-message assistant"><div className="ai-message-avatar"><BotIcon size={15} /></div><div className="ai-message-content ai-thinking"><span /><span /><span /></div></div>}
</div>
</div>
<div className="ai-suggestion-row"><button onClick={() => sendMessage('汇总今天最重要的事项')}>汇总今天事项</button><button onClick={() => sendMessage('查看系统数据概览')}>查看系统概览</button></div>
<form className="ai-input-row" onSubmit={event => { event.preventDefault(); sendMessage() }}><input ref={inputRef} value={input} onChange={event => setInput(event.target.value)} placeholder="告诉 AI 你要查什么或做什么…" aria-label="输入给 AI 助手的内容" /><button type="submit" disabled={!input.trim() || loading} aria-label="发送"><SendIcon size={18} /></button></form>
<div className="ai-panel-footer">AI 会先说明将要执行的操作,再进行需要写入数据的动作。</div>
</aside>
</div>
)
}
export default AIAssistant
+67
View File
@@ -0,0 +1,67 @@
import React, { useEffect, useMemo, useRef, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import axios from 'axios'
import { BotIcon, ClockIcon, PlusCircleIcon, SearchIcon } from './Icons'
function GlobalHeader({ onOpenAssistant }) {
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 searchRef = useRef(null)
const navigate = useNavigate()
useEffect(() => {
const timer = window.setInterval(() => setNow(new Date()), 1000)
return () => window.clearInterval(timer)
}, [])
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)
}, [])
const timeLabel = useMemo(() => now.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', second: '2-digit' }), [now])
const dateLabel = useMemo(() => now.toLocaleDateString('zh-CN', { month: 'long', day: 'numeric', weekday: 'short' }), [now])
const selectResult = result => { navigate(result.path); setQuery(''); setSearchOpen(false) }
return (
<header className="global-header">
<div className="global-header-brand"><span className="header-status-dot" /><div><strong>happy出口通</strong><span>EXPORT OPERATIONS CONSOLE</span></div></div>
<div className={`global-search ${searchOpen ? 'is-open' : ''}`}>
<SearchIcon size={17} />
<input 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>
<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>
)
}
export default GlobalHeader
+271
View File
@@ -0,0 +1,271 @@
import React from 'react'
// SVG图标组件库
export const DashboardIcon = ({ size = 20, className = '' }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
<rect x="3" y="3" width="7" height="7" />
<rect x="14" y="3" width="7" height="7" />
<rect x="14" y="14" width="7" height="7" />
<rect x="3" y="14" width="7" height="7" />
</svg>
)
/* 供应商:厂房 + 烟囱(照 icons8-厂-parakeet-line 重绘为矢量,跟随主题变色) */
export const FactoryIcon = ({ size = 20, className = '' }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true">
{/* 厂房主体:锯齿双峰屋顶 + 左右墙 + 底边 */}
<path d="M2.4 12l4-3.4 3 3.4 3-3.4 3.6 3.3V21.6H2.4z" />
{/* 烟囱:左直边 + 顶口 + 略微收窄的右斜边 */}
<path d="M18 21.6V3.4h3.4l.6 18.2" />
{/* 窗户:2 行 × 4 列 */}
<path d="M4.7 14.1h1.6v1.6H4.7zM7.8 14.1h1.6v1.6H7.8zM10.9 14.1h1.6v1.6h-1.6zM14 14.1h1.6v1.6H14z" />
<path d="M4.7 17.6h1.6v1.6H4.7zM7.8 17.6h1.6v1.6H7.8zM10.9 17.6h1.6v1.6h-1.6zM14 17.6h1.6v1.6H14z" />
{/* 补齐烟囱下方的地面线 */}
<path d="M16 21.6h6.2" />
</svg>
)
export const DownloadIcon = ({ size = 20, className = '' }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
)
export const PackageIcon = ({ size = 20, className = '' }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" />
<polyline points="3.27 6.96 12 12.01 20.73 6.96" />
<line x1="12" y1="22.08" x2="12" y2="12" />
</svg>
)
export const UsersIcon = ({ size = 20, className = '' }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
</svg>
)
export const TargetIcon = ({ size = 20, className = '' }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
<circle cx="12" cy="12" r="10" />
<circle cx="12" cy="12" r="6" />
<circle cx="12" cy="12" r="2" />
</svg>
)
export const ClipboardIcon = ({ size = 20, className = '' }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2" />
<rect x="8" y="2" width="8" height="4" rx="1" ry="1" />
</svg>
)
export const SettingsIcon = ({ size = 20, className = '' }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
<circle cx="12" cy="12" r="3" />
<path d="M12 1v6m0 6v6M5.64 5.64l4.24 4.24m4.24 4.24l4.24 4.24M1 12h6m6 0h6M5.64 18.36l4.24-4.24m4.24-4.24l4.24-4.24" />
</svg>
)
export const DollarIcon = ({ size = 20, className = '' }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
<line x1="12" y1="1" x2="12" y2="23" />
<path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6" />
</svg>
)
export const CheckIcon = ({ size = 20, className = '' }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
<polyline points="20 6 9 17 4 12" />
</svg>
)
export const PinIcon = ({ size = 20, className = '' }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
<path d="M12 17v5" />
<path d="M9 10.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V7a1 1 0 0 1 1-1 2 2 0 0 0 0-4H8a2 2 0 0 0 0 4 1 1 0 0 1 1 1z" />
</svg>
)
export const CalendarIcon = ({ size = 20, className = '' }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
<line x1="16" y1="2" x2="16" y2="6" />
<line x1="8" y1="2" x2="8" y2="6" />
<line x1="3" y1="10" x2="21" y2="10" />
</svg>
)
export const BellIcon = ({ size = 20, className = '' }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9" />
<path d="M13.73 21a2 2 0 0 1-3.46 0" />
</svg>
)
export const FlagIcon = ({ size = 20, className = '' }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
<path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z" />
<line x1="4" y1="22" x2="4" y2="15" />
</svg>
)
export const TuneIcon = ({ size = 20, className = '' }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true">
<path d="M4 6h16M4 12h16M4 18h16" />
<circle cx="9" cy="6" r="2" fill="currentColor" stroke="none" />
<circle cx="15" cy="12" r="2" fill="currentColor" stroke="none" />
<circle cx="10" cy="18" r="2" fill="currentColor" stroke="none" />
</svg>
)
export const SunIcon = ({ size = 20, className = '' }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true">
<circle cx="12" cy="12" r="4" />
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41" />
</svg>
)
export const MoonIcon = ({ 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" />
</svg>
)
export const CloseIcon = ({ size = 20 }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" aria-hidden="true"><path d="m6 6 12 12M6 18 18 6" /></svg>
)
export const ExchangeIcon = ({ size = 20 }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M4 7h16m-4-4 4 4-4 4M20 17H4m4-4-4 4 4 4" /></svg>
)
export const SparkIcon = ({ size = 20 }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="m12 3 2.6 6.4L21 12l-6.4 2.6L12 21l-2.6-6.4L3 12l6.4-2.6ZM20 2v4m-2-2h4" /></svg>
)
export const SearchIcon = ({ size = 20, className = '' }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true"><circle cx="11" cy="11" r="7" /><path d="m20 20-4-4" /></svg>
)
export const ClockIcon = ({ size = 20, className = '' }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true"><circle cx="12" cy="12" r="9" /><path d="M12 7v5l3 2" /></svg>
)
export const PlusCircleIcon = ({ size = 20, className = '' }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" className={className} aria-hidden="true"><circle cx="12" cy="12" r="9" /><path d="M12 8v8M8 12h8" /></svg>
)
export const BotIcon = ({ size = 20, className = '' }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true"><rect x="4" y="7" width="16" height="12" rx="3" /><path d="M12 3v4M8 12h.01M16 12h.01M8 16h8" /><path d="M2 12h2M20 12h2" /></svg>
)
export const SendIcon = ({ size = 20, className = '' }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true"><path d="m22 2-7 20-4-9-9-4Z" /><path d="M22 2 11 13" /></svg>
)
export const ActivityIcon = ({ size = 20, className = '' }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true"><path d="M4 17V9M9 17V5M14 17v-3M19 17V7" /><path d="M3 20h18" /></svg>
)
export const SidebarLayoutIcon = ({ size = 20, className = '' }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.65" strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true">
<rect x="3" y="3" width="18" height="18" rx="4" />
<path d="M9 4v16" />
<path d="M6.4 8h.1M6.4 12h.1M6.4 16h.1" strokeWidth="2.4" />
</svg>
)
/* 分组折叠箭头:展开时朝下,收起时由 CSS 旋转 -90° */
export const ChevronDownIcon = ({ size = 16, className = '' }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true">
<path d="m6 9 6 6 6-6" />
</svg>
)
/* 客户报价单 */
export const QuoteIcon = ({ size = 20, className = '' }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<path d="M12 11v8" />
<path d="M14 12.4h-2.9a1.5 1.5 0 0 0 0 3h1.8a1.5 1.5 0 0 1 0 3H10" />
</svg>
)
/* PI 合同(形式发票 / 合同文件) */
export const ContractIcon = ({ size = 20, className = '' }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<path d="M8 13h4M8 17h6" />
<path d="M15.5 16.2c1-.9 2.2.9 3.2 0" />
</svg>
)
/* 采购单 */
export const PurchaseIcon = ({ size = 20, className = '' }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true">
<path d="M2 3h2.4l2.2 11.1a2 2 0 0 0 2 1.6h8.4a2 2 0 0 0 2-1.6L21 6.6H5.2" />
<circle cx="9.5" cy="20" r="1.6" />
<circle cx="17.5" cy="20" r="1.6" />
</svg>
)
/* 卡片网格排列 */
export const GridIcon = ({ size = 18, className = '' }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true">
<rect x="3" y="3" width="7.5" height="7.5" rx="2" />
<rect x="13.5" y="3" width="7.5" height="7.5" rx="2" />
<rect x="3" y="13.5" width="7.5" height="7.5" rx="2" />
<rect x="13.5" y="13.5" width="7.5" height="7.5" rx="2" />
</svg>
)
/* 横列表排列 */
export const ListIcon = ({ size = 18, className = '' }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true">
<path d="M8 6h13M8 12h13M8 18h13" />
<circle cx="3.6" cy="6" r="1.2" />
<circle cx="3.6" cy="12" r="1.2" />
<circle cx="3.6" cy="18" r="1.2" />
</svg>
)
/* 小推车 / 报价篮 */
export const CartIcon = ({ size = 18, className = '' }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true">
<path d="M2 3h2l2.6 12.4a1.6 1.6 0 0 0 1.6 1.3h9.2a1.6 1.6 0 0 0 1.6-1.3L21 7H5.3" />
<circle cx="9.5" cy="20.2" r="1.5" />
<circle cx="17.5" cy="20.2" r="1.5" />
</svg>
)
/* 图片占位 */
export const ImageIcon = ({ size = 18, className = '' }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true">
<rect x="3" y="3" width="18" height="18" rx="3" />
<circle cx="8.6" cy="8.6" r="1.7" />
<path d="m4 17 4.6-4.6a1.6 1.6 0 0 1 2.3 0L15 16.5" />
<path d="m14.2 15.2 1.7-1.7a1.6 1.6 0 0 1 2.3 0L21 16.5" />
</svg>
)
/* 删除 / 回收 */
export const TrashIcon = ({ size = 16, className = '' }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true">
<path d="M3.5 6h17" />
<path d="M8.5 6V4.2A1.2 1.2 0 0 1 9.7 3h4.6a1.2 1.2 0 0 1 1.2 1.2V6" />
<path d="M6 6l1 14a2 2 0 0 0 2 1.8h6A2 2 0 0 0 17 20l1-14" />
<path d="M10.2 10.5v6M13.8 10.5v6" />
</svg>
)
/* 铅笔 / 编辑 */
export const PencilIcon = ({ size = 16, className = '' }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true">
<path d="M16.5 3.7a2.1 2.1 0 0 1 3 3L7.4 18.8l-4 1 1-4z" />
<path d="m14.7 5.5 3.8 3.8" />
</svg>
)
+130
View File
@@ -0,0 +1,130 @@
import React, { useState, useEffect, useRef } from 'react'
import { MoonIcon, SunIcon, TuneIcon, CheckIcon, CloseIcon, ExchangeIcon, SparkIcon } from './Icons'
function Settings({ isOpen, onClose, theme, onThemeChange, reducedMotion, onReducedMotionChange }) {
const [activeCategory, setActiveCategory] = useState('appearance')
const modalRef = useRef(null)
useEffect(() => {
if (!isOpen) return
const previousFocus = document.activeElement
const previousOverflow = document.body.style.overflow
document.body.style.overflow = 'hidden'
const modal = modalRef.current
const focusable = () => [...modal.querySelectorAll('button, input, select, textarea, a[href], [tabindex="0"]')].filter(el => !el.disabled && el.getClientRects().length)
modal.querySelector('.close-btn')?.focus()
const onKeyDown = event => {
if (event.key === 'Escape') { event.preventDefault(); onClose() }
if (event.key === 'Tab') {
const items = focusable()
const first = items[0], last = items[items.length - 1]
if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last?.focus() }
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first?.focus() }
}
}
modal.addEventListener('keydown', onKeyDown)
return () => {
document.body.style.overflow = previousOverflow
modal.removeEventListener('keydown', onKeyDown)
previousFocus?.focus()
}
}, [isOpen, onClose])
if (!isOpen) return null
return (
<div className="settings-overlay" onClick={onClose}>
<div ref={modalRef} role="dialog" aria-modal="true" aria-labelledby="settings-title" className="settings-modal luxury-settings" onClick={(e) => e.stopPropagation()}>
<aside className="settings-sidebar">
<div className="settings-header">
<div>
<span className="section-label">[CONTROL CENTER]</span>
<h2 id="settings-title">系统设置</h2>
</div>
<button className="close-btn" onClick={onClose} aria-label="关闭设置"><CloseIcon size={18} /></button>
</div>
<nav className="settings-nav" aria-label="设置分类">
<button className={`settings-nav-item ${activeCategory === 'appearance' ? 'active' : ''}`} onClick={() => setActiveCategory('appearance')}>
<span className="settings-nav-icon"><TuneIcon size={16} /></span>
<span>外观与主题</span>
</button>
<button className={`settings-nav-item ${activeCategory === 'import-export' ? 'active' : ''}`} onClick={() => setActiveCategory('import-export')}>
<span className="settings-nav-icon"><ExchangeIcon size={18} /></span>
<span>数据交换</span>
</button>
<button className={`settings-nav-item ${activeCategory === 'ai-config' ? 'active' : ''}`} onClick={() => setActiveCategory('ai-config')}>
<span className="settings-nav-icon"><SparkIcon size={18} /></span>
<span>智能配置</span>
</button>
</nav>
<div className="settings-sidebar-status"><TuneIcon size={15} /><span>专注业务,自在掌控<small>TRADE MANAGEMENT</small></span></div>
</aside>
<section className="settings-content">
{activeCategory === 'appearance' && <AppearanceSettings theme={theme} onThemeChange={onThemeChange} reducedMotion={reducedMotion} onReducedMotionChange={onReducedMotionChange} />}
{activeCategory === 'import-export' && <ImportExportSettings />}
{activeCategory === 'ai-config' && <AIConfigSettings />}
</section>
</div>
</div>
)
}
function AppearanceSettings({ theme, onThemeChange, reducedMotion, onReducedMotionChange }) {
return (
<div className="settings-section">
<span className="section-label">[APPEARANCE]</span>
<h3>外观与主题</h3>
<p className="settings-description">选择适合工作环境的显示模式,设置会自动保存在本机。</p>
<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><strong>夜间控制台</strong><small>深色 · 低光环境</small></span>
<span className="theme-check" aria-hidden="true">{theme === 'dark' && <CheckIcon size={14} />}</span>
</button>
<button className={`theme-option ${theme === 'light' ? 'active' : ''}`} aria-pressed={theme === 'light'} onClick={() => onThemeChange('light')}>
<span className="theme-preview theme-preview-light" aria-hidden="true"><span className="mini-sidebar"><i /><i /><i /></span><span className="mini-workspace"><SunIcon 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 === 'light' && <CheckIcon size={14} />}</span>
</button>
</div>
<div className="settings-group">
<h4>显示偏好</h4>
<div className="setting-info"><span><strong>清晰优先</strong><small>两种主题均保留高对比度文字与明确的状态标识。</small></span><span className="preference-badge">默认启用</span></div>
<label className="setting-toggle"><span><strong>减少动效</strong><small>适用于需要稳定画面的工作环境</small></span><input type="checkbox" aria-label="减少动效" checked={reducedMotion} onChange={e => onReducedMotionChange(e.target.checked)} /><i aria-hidden="true" /></label>
</div>
<p className="appearance-note"><CheckIcon size={14} /> 即时生效 · 自动记住您的主题与动效偏好</p>
</div>
)
}
function ImportExportSettings() {
return (
<div className="settings-section">
<span className="section-label">[DATA EXCHANGE]</span>
<h3>导入 / 导出</h3>
<p className="settings-description">管理业务数据的导入、导出与本地备份。</p>
<div className="settings-group"><h4>导出数据</h4><div className="settings-actions"><button className="btn btn-secondary">导出供应商数据</button><button className="btn btn-secondary">导出产品数据</button><button className="btn btn-secondary">导出客户数据</button><button className="btn btn-secondary">导出订单数据</button></div></div>
<div className="settings-group"><h4>导入数据</h4><div className="settings-actions"><label className="btn btn-secondary file-label">导入供应商数据<input type="file" accept=".csv,.xlsx" /></label><label className="btn btn-secondary file-label">导入产品数据<input type="file" accept=".csv,.xlsx" /></label><label className="btn btn-secondary file-label">导入客户数据<input type="file" accept=".csv,.xlsx" /></label></div><p className="help-text">支持 CSV 和 Excel 格式文件</p></div>
<div className="settings-group"><h4>备份与恢复</h4><div className="settings-actions"><button className="btn btn-primary">创建完整备份</button><label className="btn btn-secondary file-label">恢复备份<input type="file" accept=".db,.backup" /></label></div><p className="help-text">建议定期备份数据库</p></div>
</div>
)
}
function AIConfigSettings() {
return (
<div className="settings-section">
<span className="section-label">[INTELLIGENCE]</span>
<h3>智能配置</h3>
<p className="settings-description">配置 AI 功能相关参数与模型。</p>
<div className="settings-group"><h4>API 密钥配置</h4><div className="form-group"><label>OpenAI API Key</label><input type="password" placeholder="sk-..." className="form-input" /></div><div className="form-group"><label>Claude API Key</label><input type="password" placeholder="sk-ant-..." className="form-input" /></div><p className="help-text">当前为配置界面预览,密钥保存和连接测试尚未接入。</p></div>
<div className="settings-group"><h4>AI 功能开关</h4><label className="setting-toggle"><span><strong>启用合同智能解析</strong><small>自动提取合同关键字段</small></span><input type="checkbox" defaultChecked /><i /></label><label className="setting-toggle"><span><strong>启用客户画像分析</strong><small>辅助判断客户跟进优先级</small></span><input type="checkbox" defaultChecked /><i /></label><label className="setting-toggle"><span><strong>启用自动报价建议</strong><small>基于历史价格提供参考</small></span><input type="checkbox" /><i /></label></div>
<div className="settings-group"><h4>模型选择</h4><div className="form-group"><label>OCR 模型</label><select className="form-select"><option>GPT-4 Vision</option><option>Claude 3 Opus</option><option>Claude 3 Sonnet</option></select></div><div className="form-group"><label>文本分析模型</label><select className="form-select"><option>GPT-4</option><option>Claude 3 Opus</option><option>GPT-3.5 Turbo</option></select></div></div>
<div className="settings-actions"><button className="btn btn-primary">保存配置</button><button className="btn btn-secondary">测试连接</button></div>
</div>
)
}
export default Settings