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
+198
View File
@@ -0,0 +1,198 @@
import React from 'react'
import { BrowserRouter as Router, Routes, Route, NavLink, Navigate } from 'react-router-dom'
import Dashboard from './pages/Dashboard'
import EmbeddedSku from './pages/EmbeddedSku'
import ProductManagement from './pages/ProductManagement'
import SupplierManagement from './pages/SupplierManagement'
import CustomerManagement from './pages/CustomerManagement'
import OrderManagement from './pages/OrderManagement'
import ProspectManagement from './pages/ProspectManagement'
import QuoteManagement from './pages/QuoteManagement'
import PiContractManagement from './pages/PiContractManagement'
import PurchaseOrderManagement from './pages/PurchaseOrderManagement'
import Settings from './components/Settings'
import GlobalHeader from './components/GlobalHeader'
import AIAssistant from './components/AIAssistant'
import { readPreference } from './preferences'
import {
DashboardIcon,
FactoryIcon,
PackageIcon,
UsersIcon,
TargetIcon,
ClipboardIcon,
DownloadIcon,
QuoteIcon,
ContractIcon,
PurchaseIcon,
ChevronDownIcon,
TuneIcon,
SunIcon,
MoonIcon,
SidebarLayoutIcon
} from './components/Icons'
/* 侧边栏分组配置:工作台独立置顶,不归属任何分组 */
const NAV_GROUPS = [
{
id: 'business',
title: '业务管理',
items: [
{ to: '/quotes', label: '客户报价单管理', icon: QuoteIcon },
{ to: '/pi-contracts', label: 'PI合同管理', icon: ContractIcon },
{ to: '/purchase-orders', label: '采购单管理', icon: PurchaseIcon }
]
},
{
id: 'customer',
title: '客户管理',
items: [
{ to: '/customers', label: '客户管理', icon: UsersIcon },
{ to: '/prospects', label: '新客开发', icon: TargetIcon },
{ to: '/orders', label: '订单管理', icon: ClipboardIcon }
]
},
{
id: 'procurement',
title: '采购管理',
items: [
{ to: '/products', label: '产品管理', icon: PackageIcon },
{ to: '/suppliers', label: '供应商管理', icon: FactoryIcon },
{ to: '/procurement/import', label: '合同导入', icon: DownloadIcon }
]
}
]
function App() {
const [collapsed, setCollapsed] = React.useState(false)
const [collapsedGroups, setCollapsedGroups] = React.useState([])
const [settingsOpen, setSettingsOpen] = React.useState(false)
const [assistantOpen, setAssistantOpen] = React.useState(false)
const [theme, setTheme] = React.useState(() => readPreference('tms-theme', ['dark', 'light'], 'dark'))
const [reducedMotion, setReducedMotion] = React.useState(() => readPreference('tms-motion', ['full', 'reduced'], 'full') === 'reduced')
const closeSettings = React.useCallback(() => setSettingsOpen(false), [])
const closeAssistant = React.useCallback(() => setAssistantOpen(false), [])
React.useEffect(() => {
document.documentElement.dataset.theme = theme
document.documentElement.style.colorScheme = theme
try { localStorage.setItem('tms-theme', theme) } catch { /* Session-only when storage is unavailable. */ }
}, [theme])
React.useEffect(() => {
document.documentElement.dataset.motion = reducedMotion ? 'reduced' : 'full'
try { localStorage.setItem('tms-motion', reducedMotion ? 'reduced' : 'full') } catch { /* Session-only. */ }
}, [reducedMotion])
const navClass = ({ isActive }) => `nav-item${isActive ? ' active' : ''}`
const toggleGroup = React.useCallback((id) => {
setCollapsedGroups(current => current.includes(id) ? current.filter(x => x !== id) : [...current, id])
}, [])
return (
<Router>
<div className="app-container">
<aside inert={settingsOpen || assistantOpen ? "" : undefined} className={`sidebar ${collapsed ? 'collapsed' : ''}`}>
<div className="sidebar-header">
<h1>{collapsed ? 'HET' : 'happy出口通'}</h1>
<button
onClick={() => setCollapsed(!collapsed)}
className="collapse-btn"
aria-label={collapsed ? '展开导航' : '收起导航'}
title={collapsed ? '展开导航' : '收起导航'}
>
<SidebarLayoutIcon size={20} />
</button>
</div>
<nav className="sidebar-nav">
{/* 工作台:独立置顶,不属于任何分组 */}
<NavLink aria-label="工作台" title="工作台" to="/" end className={navClass}>
<span className="icon"><DashboardIcon size={18} /></span>
{!collapsed && <span>工作台</span>}
</NavLink>
{NAV_GROUPS.map(group => {
/* 侧栏整体收起时忽略分组的折叠状态,保证图标仍可点 */
const open = collapsed || !collapsedGroups.includes(group.id)
return (
<div className="nav-group" key={group.id}>
<div className="nav-group-header">
<span className="nav-group-title">{group.title}</span>
{!collapsed && (
<button
type="button"
className={`nav-group-toggle${open ? ' open' : ''}`}
onClick={() => toggleGroup(group.id)}
aria-expanded={open}
aria-label={`${open ? '收起' : '展开'}${group.title}`}
title={`${open ? '收起' : '展开'}${group.title}`}
>
<ChevronDownIcon size={14} />
</button>
)}
</div>
{open && group.items.map(item => {
const ItemIcon = item.icon
return (
<NavLink key={item.to} aria-label={item.label} title={item.label} to={item.to} className={navClass}>
<span className="icon"><ItemIcon size={18} /></span>
{!collapsed && <span>{item.label}</span>}
</NavLink>
)
})}
</div>
)
})}
</nav>
<div className="sidebar-footer">
<div className="user-info">管理员</div>
<button
className="theme-toggle-btn"
onClick={() => setTheme(current => current === 'light' ? 'dark' : 'light')}
title={theme === 'light' ? '切换至夜间模式' : '切换至日间模式'}
aria-label={theme === 'light' ? '切换至夜间模式' : '切换至日间模式'}
>
{theme === 'light' ? <MoonIcon size={18} /> : <SunIcon size={18} />}
</button>
<button className="settings-btn" onClick={() => setSettingsOpen(true)} title="设置" aria-label="设置">
<TuneIcon size={18} />
</button>
</div>
</aside>
<main inert={settingsOpen || assistantOpen ? "" : undefined} className="main-content">
<GlobalHeader onOpenAssistant={() => setAssistantOpen(true)} />
<div className="route-content">
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/quotes" element={<QuoteManagement />} />
<Route path="/pi-contracts" element={<PiContractManagement />} />
<Route path="/purchase-orders" element={<PurchaseOrderManagement />} />
<Route path="/products" element={<ProductManagement />} />
<Route path="/suppliers" element={<SupplierManagement />} />
<Route path="/procurement/import" element={<EmbeddedSku view="import" />} />
<Route path="/customers" element={<CustomerManagement />} />
<Route path="/orders" element={<OrderManagement />} />
<Route path="/prospects" element={<ProspectManagement />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</div>
</main>
<AIAssistant isOpen={assistantOpen} onClose={closeAssistant} />
<Settings isOpen={settingsOpen} onClose={closeSettings} theme={theme} onThemeChange={setTheme} reducedMotion={reducedMotion} onReducedMotionChange={setReducedMotion} />
</div>
</Router>
)
}
export default App
+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
+12
View File
@@ -0,0 +1,12 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import './styles/industrial.css'
import './styles/index.css'
import './styles/luxury.css'
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)
+207
View File
@@ -0,0 +1,207 @@
import React, { useState, useEffect } from 'react'
import axios from 'axios'
function CustomerManagement() {
const [customers, setCustomers] = useState([])
const [loading, setLoading] = useState(true)
const [showAddModal, setShowAddModal] = useState(false)
const [formData, setFormData] = useState({
company: '',
country: '',
industry: '',
website: '',
email: '',
phone: '',
notes: ''
})
useEffect(() => {
fetchCustomers()
}, [])
const fetchCustomers = async () => {
try {
const response = await axios.get('/api/customers')
setCustomers(response.data)
} catch (error) {
console.error('获取客户列表失败:', error)
} finally {
setLoading(false)
}
}
const handleSubmit = async (e) => {
e.preventDefault()
try {
await axios.post('/api/customers', formData)
setShowAddModal(false)
setFormData({
company: '',
country: '',
industry: '',
website: '',
email: '',
phone: '',
notes: ''
})
fetchCustomers()
} catch (error) {
console.error('创建客户失败:', error)
alert('创建失败: ' + error.message)
}
}
if (loading) {
return <div className="loading">加载中...</div>
}
return (
<div className="page-container">
<div className="page-header">
<h1 className="page-title">客户管理</h1>
<button className="btn-primary" onClick={() => setShowAddModal(true)}>
+ 新增客户
</button>
</div>
<div className="table-container">
<table className="data-table">
<thead>
<tr>
<th>公司名称</th>
<th>国家</th>
<th>行业</th>
<th>网站</th>
<th>联系方式</th>
<th>负责人</th>
<th>状态</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{customers.length === 0 ? (
<tr>
<td colSpan="8" className="empty-state">暂无客户数据</td>
</tr>
) : (
customers.map(customer => (
<tr key={customer.id}>
<td><strong>{customer.company}</strong></td>
<td>{customer.country || '-'}</td>
<td>{customer.industry || '-'}</td>
<td>
{customer.website ? (
<a href={customer.website} target="_blank" rel="noopener noreferrer">
{customer.website}
</a>
) : '-'}
</td>
<td>
{customer.email && <div>{customer.email}</div>}
{customer.phone && <div>{customer.phone}</div>}
</td>
<td>{customer.owner_name || '-'}</td>
<td>
<span className={`status-badge status-${customer.status}`}>
{customer.status === 'active' ? '活跃' : '非活跃'}
</span>
</td>
<td>
<button className="btn-link">查看</button>
<button className="btn-link">编辑</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{/* 新增客户模态框 */}
{showAddModal && (
<div className="modal-overlay" onClick={() => setShowAddModal(false)}>
<div className="modal-content" onClick={e => e.stopPropagation()}>
<div className="modal-header">
<h2>新增客户</h2>
<button className="modal-close" onClick={() => setShowAddModal(false)}>×</button>
</div>
<form onSubmit={handleSubmit}>
<div className="form-group">
<label>公司名称 *</label>
<input
type="text"
required
value={formData.company}
onChange={e => setFormData({...formData, company: e.target.value})}
/>
</div>
<div className="form-row">
<div className="form-group">
<label>国家</label>
<input
type="text"
value={formData.country}
onChange={e => setFormData({...formData, country: e.target.value})}
/>
</div>
<div className="form-group">
<label>行业</label>
<input
type="text"
value={formData.industry}
onChange={e => setFormData({...formData, industry: e.target.value})}
/>
</div>
</div>
<div className="form-group">
<label>网站</label>
<input
type="url"
placeholder="https://"
value={formData.website}
onChange={e => setFormData({...formData, website: e.target.value})}
/>
</div>
<div className="form-row">
<div className="form-group">
<label>邮箱</label>
<input
type="email"
value={formData.email}
onChange={e => setFormData({...formData, email: e.target.value})}
/>
</div>
<div className="form-group">
<label>电话</label>
<input
type="text"
value={formData.phone}
onChange={e => setFormData({...formData, phone: e.target.value})}
/>
</div>
</div>
<div className="form-group">
<label>备注</label>
<textarea
rows="3"
value={formData.notes}
onChange={e => setFormData({...formData, notes: e.target.value})}
/>
</div>
<div className="modal-footer">
<button type="button" className="btn-secondary" onClick={() => setShowAddModal(false)}>
取消
</button>
<button type="submit" className="btn-primary">
保存
</button>
</div>
</form>
</div>
</div>
)}
</div>
)
}
export default CustomerManagement
+125
View File
@@ -0,0 +1,125 @@
import React, { useEffect, useMemo, useState } from 'react'
import axios from 'axios'
import {
ActivityIcon,
BellIcon,
CalendarIcon,
CheckIcon,
ClipboardIcon,
DollarIcon,
FactoryIcon,
FlagIcon,
PackageIcon,
PinIcon,
SparkIcon,
UsersIcon
} from '../components/Icons'
const WEEKDAYS = ['周一', '周二', '周三', '周四', '周五', '周六', '周日']
function toDateKey(date) {
const year = date.getFullYear()
const month = `${date.getMonth() + 1}`.padStart(2, '0')
const day = `${date.getDate()}`.padStart(2, '0')
return `${year}-${month}-${day}`
}
function CalendarBoard({ todos }) {
const today = new Date()
const [month, setMonth] = useState(new Date(today.getFullYear(), today.getMonth(), 1))
const monthLabel = month.toLocaleDateString('zh-CN', { year: 'numeric', month: 'long' })
const monthStartDay = (month.getDay() + 6) % 7
const daysInMonth = new Date(month.getFullYear(), month.getMonth() + 1, 0).getDate()
const previousDays = new Date(month.getFullYear(), month.getMonth(), 0).getDate()
const calendarDays = useMemo(() => {
return Array.from({ length: 42 }, (_, index) => {
const offset = index - monthStartDay + 1
const date = new Date(month.getFullYear(), month.getMonth(), offset)
return { date, currentMonth: date.getMonth() === month.getMonth(), key: toDateKey(date) }
})
}, [month, monthStartDay])
const events = useMemo(() => {
const result = {}
todos.forEach(todo => {
if (!todo.due_date) return
const key = todo.due_date.slice(0, 10)
result[key] = [...(result[key] || []), { label: todo.title, tone: todo.priority === 'high' ? 'danger' : 'blue' }]
})
const year = month.getFullYear()
const holidays = [
{ date: `${year}-01-01`, label: '元旦 · 节日', tone: 'holiday' },
{ date: `${year}-05-01`, label: '劳动节 · 节日', tone: 'holiday' },
{ date: `${year}-10-01`, label: '国庆节 · 节日', tone: 'holiday' },
{ date: `${year}-12-25`, label: '圣诞节 · 节日', tone: 'holiday' },
]
holidays.forEach(event => { result[event.date] = [...(result[event.date] || []), event] })
return result
}, [month, todos])
const shiftMonth = (delta) => setMonth(current => new Date(current.getFullYear(), current.getMonth() + delta, 1))
const resetMonth = () => setMonth(new Date(today.getFullYear(), today.getMonth(), 1))
return (
<section className="calendar-board dashboard-panel">
<div className="dashboard-panel-header calendar-board-header">
<div><span className="section-label">[SCHEDULE]</span><h2><CalendarIcon size={18} /> 工作日历</h2></div>
<div className="calendar-toolbar"><button className="calendar-today-btn" onClick={resetMonth}>今天</button><button onClick={() => shiftMonth(-1)} aria-label="上个月">‹</button><strong>{monthLabel}</strong><button onClick={() => shiftMonth(1)} aria-label="下个月">›</button><button className="calendar-view-btn">月</button></div>
</div>
<div className="calendar-week-row">{WEEKDAYS.map(day => <div key={day}>{day}</div>)}</div>
<div className="calendar-grid">
{calendarDays.map(({ date, currentMonth, key }) => {
const dayEvents = events[key] || []
const isToday = key === toDateKey(today)
return <div className={`calendar-day ${currentMonth ? '' : 'is-outside'} ${isToday ? 'is-today' : ''}`} key={`${key}-${date.getTime()}`}>
<span className="calendar-day-number">{date.getDate()}</span>
<div className="calendar-events">{dayEvents.slice(0, 3).map((event, index) => <span className={`calendar-event ${event.tone}`} key={`${event.label}-${index}`} title={event.label}>{event.label}</span>)}</div>
</div>
})}
</div>
</section>
)
}
function Dashboard() {
const [data, setData] = useState({ stats: {}, recentActivities: [], todos: [] })
const [loading, setLoading] = useState(true)
useEffect(() => {
axios.get('/api/dashboard').then(response => setData(response.data)).catch(error => console.error('获取工作台数据失败:', error)).finally(() => setLoading(false))
}, [])
if (loading) return <div className="loading">加载工作台数据…</div>
const { stats, recentActivities, todos } = data
const completion = todos.length ? Math.max(12, Math.round((todos.filter(todo => todo.status === 'completed').length / todos.length) * 100)) : 0
return (
<div className="dashboard-container">
<div className="dashboard-welcome"><div><span className="section-label">[WORKSPACE / OVERVIEW]</span><h1 className="page-title">工作台</h1><p>把今天的节奏、客户和订单放在同一个视野里。</p></div><div className="dashboard-date-chip"><span>运行日期</span><strong>{new Date().toLocaleDateString('zh-CN', { year: 'numeric', month: 'long', day: 'numeric' })}</strong></div></div>
<CalendarBoard todos={todos} />
<div className="dashboard-content-grid">
<div className="dashboard-primary-column">
<section className="dashboard-panel ai-digest-panel"><div className="dashboard-panel-header"><div><span className="section-label">[AI BRIEFING]</span><h2><SparkIcon size={16} /> 今日工作摘要</h2></div><span className="panel-live-status"><i />实时</span></div><div className="ai-digest-body"><strong>系统已准备好协助你处理今天的业务。</strong><p>当前共有 <b>{stats.pendingTodos || 0}</b> 项待办、<b>{stats.activeDeals || 0}</b> 个进行中商机和 <b>{stats.orders || 0}</b> 笔进行中订单。打开右上角 AI 助手,可以直接用自然语言查阅和操作系统。</p><div className="digest-tags"><span>供应链</span><span>客户跟进</span><span>订单节奏</span></div></div></section>
<section className="dashboard-panel"><div className="dashboard-panel-header"><div><span className="section-label">[TASK QUEUE]</span><h2><PinIcon size={17} /> 待办事项</h2></div><button className="panel-link">查看全部</button></div>{todos.length === 0 ? <div className="dashboard-empty">暂无待办事项,今天可以保持从容。</div> : <ul className="todo-list">{todos.map(todo => <li key={todo.id} className="todo-item"><input type="checkbox" aria-label={`完成 ${todo.title}`} /><div className="todo-content"><div className="todo-title">{todo.title}</div><div className="todo-meta">{todo.customer_name ? `客户:${todo.customer_name}` : '内部任务'}{todo.due_date ? ` · 截止 ${new Date(todo.due_date).toLocaleDateString('zh-CN')}` : ''}</div></div><span className={`priority-badge priority-${todo.priority}`}>{todo.priority === 'high' ? '高' : todo.priority === 'medium' ? '中' : '低'}</span></li>)}</ul>}</section>
<section className="dashboard-panel"><div className="dashboard-panel-header"><div><span className="section-label">[ACTIVITY LOG]</span><h2><ActivityIcon size={17} /> 最近活动</h2></div><button className="panel-link">查看日志</button></div>{recentActivities.length === 0 ? <div className="dashboard-empty">暂无最近活动</div> : <ul className="activity-list">{recentActivities.map(activity => <li key={activity.id} className="activity-item"><span className="activity-time">{new Date(activity.created_at).toLocaleString('zh-CN')}</span><div className="activity-content"><strong>{activity.subject}</strong><span>{activity.content}</span></div></li>)}</ul>}</section>
</div>
<aside className="dashboard-secondary-column">
<section className="dashboard-panel stat-rail"><div className="dashboard-panel-header"><div><span className="section-label">[SYSTEM PULSE]</span><h2>业务概览</h2></div><span className="panel-period">本月</span></div><StatLine icon={<FactoryIcon size={17} />} label="供应商" value={stats.suppliers || 0} /><StatLine icon={<PackageIcon size={17} />} label="产品 SKU" value={stats.products || 0} /><StatLine icon={<UsersIcon size={17} />} label="客户" value={stats.customers || 0} /><StatLine icon={<ClipboardIcon size={17} />} label="进行中订单" value={stats.orders || 0} /><StatLine icon={<DollarIcon size={17} />} label="进行中商机" value={stats.activeDeals || 0} /></section>
<section className="dashboard-panel completion-panel"><div className="dashboard-panel-header"><div><span className="section-label">[TASK HEALTH]</span><h2>任务完成情况</h2></div><span className="panel-period">当前</span></div><div className="completion-ring" style={{ '--progress': `${completion}%` }}><div><strong>{completion}%</strong><span>完成率</span></div></div><div className="completion-legend"><span><i className="dot done" />已完成</span><span><i className="dot pending" />待处理</span></div></section>
<section className="dashboard-panel quick-panel"><div className="dashboard-panel-header"><div><span className="section-label">[QUICK ACCESS]</span><h2>快捷入口</h2></div></div><button><UsersIcon size={16} /> 新增客户</button><button><PackageIcon size={16} /> 添加产品</button><button><ClipboardIcon size={16} /> 新建订单</button></section>
</aside>
</div>
</div>
)
}
function StatLine({ icon, label, value }) { return <div className="stat-line"><span className="stat-line-icon">{icon}</span><span>{label}</span><strong>{value}</strong></div> }
export default Dashboard
+91
View File
@@ -0,0 +1,91 @@
import React, { useState, useRef } from 'react'
/**
* SKU 功能嵌入页(整合过渡期方案)。
*
* 合并策略是「先嵌后重写」:SKU 的能力先以嵌入方式跑通,
* 之后再逐步重写为原生 React 页面。
*
* 现在嵌入的是独立的 sku-manager 服务(3300);等后端合并(P1/P2)完成后,
* 把 SKU_ORIGIN 换成同源子路径(如 /sku)即可,页面组件本身不用动。
*/
const SKU_ORIGIN = import.meta.env.VITE_SKU_ORIGIN || 'http://127.0.0.1:3300'
const VIEW_META = {
search: {
title: '产品管理',
sub: '按货号、品名、规格、材质或参数检索产品与采购价'
},
suppliers: {
title: '供应商管理',
sub: '供应商档案、联系方式、工厂所在地与主营类目'
},
import: {
title: '合同导入',
sub: '批量导入采购合同,自动解析价格、产品与参数'
}
}
function EmbeddedSku({ view = 'search' }) {
const [loaded, setLoaded] = useState(false)
const frameRef = useRef(null)
const meta = VIEW_META[view] || VIEW_META.search
const src = `${SKU_ORIGIN}/?embed=1&view=${encodeURIComponent(view)}`
return (
<div className="page-container">
<div className="page-header">
<div>
<h1 className="page-title">{meta.title}</h1>
<p style={{ color: 'var(--text-secondary)', fontSize: '14px', marginTop: '6px' }}>
{meta.sub}
</p>
</div>
<div style={{ display: 'flex', gap: '8px' }}>
<button className="btn btn-sm" onClick={() => frameRef.current?.contentWindow?.location.reload()}>
刷新
</button>
<a className="btn btn-sm" href={src} target="_blank" rel="noreferrer" style={{ textDecoration: 'none' }}>
新窗口打开
</a>
</div>
</div>
<div
style={{
position: 'relative',
height: 'calc(100vh - 210px)',
minHeight: '560px',
border: '1px solid var(--border)',
borderRadius: 'var(--radius-lg)',
overflow: 'hidden',
background: 'var(--surface)'
}}
>
{!loaded && (
<div className="loading" style={{ position: 'absolute', inset: 0, zIndex: 1 }}>
<div className="spinner"></div>
<span style={{ marginLeft: '12px' }}>正在加载 SKU 模块…</span>
</div>
)}
<iframe
ref={frameRef}
title={meta.title}
src={src}
onLoad={() => setLoaded(true)}
allow="clipboard-write"
style={{
width: '100%',
height: '100%',
border: 0,
display: 'block',
opacity: loaded ? 1 : 0,
transition: 'opacity .18s ease'
}}
/>
</div>
</div>
)
}
export default EmbeddedSku
+99
View File
@@ -0,0 +1,99 @@
import React, { useState, useEffect } from 'react'
import axios from 'axios'
function OrderManagement() {
const [orders, setOrders] = useState([])
const [loading, setLoading] = useState(true)
useEffect(() => {
fetchOrders()
}, [])
const fetchOrders = async () => {
try {
const response = await axios.get('/api/orders')
setOrders(response.data)
} catch (error) {
console.error('获取订单列表失败:', error)
} finally {
setLoading(false)
}
}
const getStatusText = (status) => {
const statusMap = {
draft: '草稿',
confirmed: '已确认',
production: '生产中',
shipped: '已发货',
completed: '已完成',
cancelled: '已取消'
}
return statusMap[status] || status
}
if (loading) {
return <div className="loading">加载中...</div>
}
return (
<div className="page-container">
<div className="page-header">
<h1 className="page-title">订单管理</h1>
<button className="btn-primary">+ 新建订单</button>
</div>
<div className="table-container">
<table className="data-table">
<thead>
<tr>
<th>订单号</th>
<th>客户</th>
<th>订单日期</th>
<th>交货日期</th>
<th>金额</th>
<th>状态</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{orders.length === 0 ? (
<tr>
<td colSpan="7" className="empty-state">暂无订单数据</td>
</tr>
) : (
orders.map(order => (
<tr key={order.id}>
<td><strong>{order.order_no}</strong></td>
<td>{order.customer_name}</td>
<td>{new Date(order.order_date).toLocaleDateString('zh-CN')}</td>
<td>
{order.delivery_date
? new Date(order.delivery_date).toLocaleDateString('zh-CN')
: '-'}
</td>
<td>
{order.total_amount
? `${order.currency} ${order.total_amount.toFixed(2)}`
: '-'}
</td>
<td>
<span className={`status-badge status-${order.status}`}>
{getStatusText(order.status)}
</span>
</td>
<td>
<button className="btn-link">查看</button>
<button className="btn-link">编辑</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
)
}
export default OrderManagement
@@ -0,0 +1,96 @@
import React, { useState, useEffect, useMemo } from 'react'
import axios from 'axios'
/**
* PI 合同管理(Proforma Invoice)
* 数据来源:happy 后端 /api/pi-contracts(P1 后端合并阶段开放)
*/
function PiContractManagement() {
const [contracts, setContracts] = useState([])
const [loading, setLoading] = useState(true)
const [keyword, setKeyword] = useState('')
useEffect(() => {
let alive = true
axios.get('/api/pi-contracts')
.then(res => { if (alive) setContracts(Array.isArray(res.data) ? res.data : res.data?.items || []) })
.catch(() => { if (alive) setContracts([]) })
.finally(() => { if (alive) setLoading(false) })
return () => { alive = false }
}, [])
const rows = useMemo(() => {
const kw = keyword.trim().toLowerCase()
if (!kw) return contracts
return contracts.filter(c => [c.pi_no, c.customer_name, c.customer_company].filter(Boolean).join(' ').toLowerCase().includes(kw))
}, [contracts, keyword])
const statusText = (s) => ({ draft: '草稿', signed: '已签署', deposited: '已收定金', producing: '生产中', shipped: '已出运', closed: '已结案' }[s] || s || '—')
return (
<div className="page-container">
<div className="page-header">
<h1 className="page-title">PI 合同管理</h1>
<button className="btn btn-primary">+ 新建 PI</button>
</div>
<div className="toolbar">
<input
type="text"
className="toolbar-search"
placeholder="搜索 PI 编号 / 客户"
value={keyword}
onChange={e => setKeyword(e.target.value)}
/>
<div className="toolbar-actions">
<span className="secondary">{rows.length} 条</span>
</div>
</div>
<div className="table-container">
<table className="data-table">
<thead>
<tr>
<th>PI 编号</th>
<th>客户</th>
<th>签订日期</th>
<th>金额</th>
<th>收款条件</th>
<th>状态</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr><td colSpan="7" className="empty-state">加载中...</td></tr>
) : rows.length === 0 ? (
<tr>
<td colSpan="7" className="empty-state">
<div className="empty-state-title">暂无 PI 合同</div>
<div className="empty-state-hint">PI 合同接口待接入(P1 后端合并阶段开放)</div>
</td>
</tr>
) : (
rows.map(c => (
<tr key={c.id}>
<td><strong>{c.pi_no}</strong></td>
<td>{c.customer_name || c.customer_company || '—'}</td>
<td>{c.signed_date ? new Date(c.signed_date).toLocaleDateString('zh-CN') : '—'}</td>
<td>{c.total_amount ? `${c.currency || 'USD'} ${Number(c.total_amount).toFixed(2)}` : '—'}</td>
<td>{c.payment_terms || '—'}</td>
<td><span className={`status-badge status-${c.status}`}>{statusText(c.status)}</span></td>
<td>
<button className="btn-link">查看</button>
<button className="btn-link">生成单据</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
)
}
export default PiContractManagement
File diff suppressed because it is too large Load Diff
+108
View File
@@ -0,0 +1,108 @@
import React, { useState } from 'react'
function ProspectManagement() {
const [formData, setFormData] = useState({
productKeywords: '',
countries: '',
customerTypes: '',
excludeKeywords: '',
resultLimit: 100
})
const handleSubmit = async (e) => {
e.preventDefault()
alert('自动获客功能开发中...')
}
return (
<div className="page-container">
<div className="page-header">
<h1 className="page-title">新客开发(自动获客)</h1>
</div>
<div className="prospect-container">
{/* 搜索条件 */}
<div className="card">
<h2>搜索条件</h2>
<form onSubmit={handleSubmit}>
<div className="form-group">
<label>产品关键词</label>
<input
type="text"
placeholder="如:LED灯具, 五金工具"
value={formData.productKeywords}
onChange={e => setFormData({...formData, productKeywords: e.target.value})}
/>
</div>
<div className="form-group">
<label>目标国家</label>
<input
type="text"
placeholder="如:德国, 英国, 美国"
value={formData.countries}
onChange={e => setFormData({...formData, countries: e.target.value})}
/>
</div>
<div className="form-group">
<label>客户类型</label>
<input
type="text"
placeholder="如:经销商, 系统集成商, OEM"
value={formData.customerTypes}
onChange={e => setFormData({...formData, customerTypes: e.target.value})}
/>
</div>
<div className="form-group">
<label>排除关键词</label>
<input
type="text"
placeholder="如:招聘, 二手, 学校"
value={formData.excludeKeywords}
onChange={e => setFormData({...formData, excludeKeywords: e.target.value})}
/>
</div>
<div className="form-group">
<label>结果数量</label>
<input
type="number"
value={formData.resultLimit}
onChange={e => setFormData({...formData, resultLimit: e.target.value})}
/>
</div>
<button type="submit" className="btn-primary">
开始搜索
</button>
</form>
</div>
{/* 搜索结果 */}
<div className="card">
<h2>搜索结果</h2>
<div className="empty-state">
请配置搜索条件并点击"开始搜索"
</div>
</div>
</div>
<div className="info-box">
<h3>功能说明</h3>
<p>自动获客功能将帮助你从多个数据源自动搜索潜在客户:</p>
<ul>
<li>公开公司注册数据(GLEIF、Companies House)</li>
<li>网页搜索引擎</li>
<li>AI 智能解析和评分</li>
<li>自动去重和质量过滤</li>
</ul>
<p><strong>此功能将在后续版本中完善。</strong></p>
</div>
</div>
)
}
export default ProspectManagement
@@ -0,0 +1,96 @@
import React, { useState, useEffect, useMemo } from 'react'
import axios from 'axios'
/**
* 采购单管理
* 数据来源:happy 后端 /api/purchase-orders(P1 后端合并阶段开放)
*/
function PurchaseOrderManagement() {
const [orders, setOrders] = useState([])
const [loading, setLoading] = useState(true)
const [keyword, setKeyword] = useState('')
useEffect(() => {
let alive = true
axios.get('/api/purchase-orders')
.then(res => { if (alive) setOrders(Array.isArray(res.data) ? res.data : res.data?.items || []) })
.catch(() => { if (alive) setOrders([]) })
.finally(() => { if (alive) setLoading(false) })
return () => { alive = false }
}, [])
const rows = useMemo(() => {
const kw = keyword.trim().toLowerCase()
if (!kw) return orders
return orders.filter(o => [o.po_no, o.supplier_name].filter(Boolean).join(' ').toLowerCase().includes(kw))
}, [orders, keyword])
const statusText = (s) => ({ draft: '草稿', issued: '已下达', received: '已收货', partial: '部分到货', closed: '已关闭', cancelled: '已取消' }[s] || s || '—')
return (
<div className="page-container">
<div className="page-header">
<h1 className="page-title">采购单管理</h1>
<button className="btn btn-primary">+ 新建采购单</button>
</div>
<div className="toolbar">
<input
type="text"
className="toolbar-search"
placeholder="搜索采购单号 / 供应商"
value={keyword}
onChange={e => setKeyword(e.target.value)}
/>
<div className="toolbar-actions">
<span className="secondary">{rows.length} 条</span>
</div>
</div>
<div className="table-container">
<table className="data-table">
<thead>
<tr>
<th>采购单号</th>
<th>供应商</th>
<th>下单日期</th>
<th>预计交期</th>
<th>金额</th>
<th>状态</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr><td colSpan="7" className="empty-state">加载中...</td></tr>
) : rows.length === 0 ? (
<tr>
<td colSpan="7" className="empty-state">
<div className="empty-state-title">暂无采购单</div>
<div className="empty-state-hint">采购单接口待接入(P1 后端合并阶段开放)</div>
</td>
</tr>
) : (
rows.map(o => (
<tr key={o.id}>
<td><strong>{o.po_no}</strong></td>
<td>{o.supplier_name || '—'}</td>
<td>{o.order_date ? new Date(o.order_date).toLocaleDateString('zh-CN') : '—'}</td>
<td>{o.expected_date ? new Date(o.expected_date).toLocaleDateString('zh-CN') : '—'}</td>
<td>{o.total_amount ? `${o.currency || 'CNY'} ${Number(o.total_amount).toFixed(2)}` : '—'}</td>
<td><span className={`status-badge status-${o.status}`}>{statusText(o.status)}</span></td>
<td>
<button className="btn-link">查看</button>
<button className="btn-link">编辑</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
)
}
export default PurchaseOrderManagement
+97
View File
@@ -0,0 +1,97 @@
import React, { useState, useEffect, useMemo } from 'react'
import axios from 'axios'
/**
* 客户报价单管理
* 数据来源:happy 后端 /api/quotes(P1 后端合并阶段开放)
* 未接入前保持可用的空态,不做假数据。
*/
function QuoteManagement() {
const [quotes, setQuotes] = useState([])
const [loading, setLoading] = useState(true)
const [keyword, setKeyword] = useState('')
useEffect(() => {
let alive = true
axios.get('/api/quotes')
.then(res => { if (alive) setQuotes(Array.isArray(res.data) ? res.data : res.data?.items || []) })
.catch(() => { if (alive) setQuotes([]) })
.finally(() => { if (alive) setLoading(false) })
return () => { alive = false }
}, [])
const rows = useMemo(() => {
const kw = keyword.trim().toLowerCase()
if (!kw) return quotes
return quotes.filter(q => [q.quote_no, q.customer_name, q.customer_company].filter(Boolean).join(' ').toLowerCase().includes(kw))
}, [quotes, keyword])
const statusText = (s) => ({ draft: '草稿', sent: '已发送', accepted: '已接受', rejected: '已拒绝', expired: '已过期' }[s] || s || '—')
return (
<div className="page-container">
<div className="page-header">
<h1 className="page-title">客户报价单管理</h1>
<button className="btn btn-primary">+ 新建报价单</button>
</div>
<div className="toolbar">
<input
type="text"
className="toolbar-search"
placeholder="搜索报价单号 / 客户"
value={keyword}
onChange={e => setKeyword(e.target.value)}
/>
<div className="toolbar-actions">
<span className="secondary">{rows.length} 条</span>
</div>
</div>
<div className="table-container">
<table className="data-table">
<thead>
<tr>
<th>报价单号</th>
<th>客户</th>
<th>报价日期</th>
<th>有效期至</th>
<th>金额</th>
<th>状态</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr><td colSpan="7" className="empty-state">加载中...</td></tr>
) : rows.length === 0 ? (
<tr>
<td colSpan="7" className="empty-state">
<div className="empty-state-title">暂无报价单</div>
<div className="empty-state-hint">报价单接口待接入(P1 后端合并阶段开放)</div>
</td>
</tr>
) : (
rows.map(q => (
<tr key={q.id}>
<td><strong>{q.quote_no}</strong></td>
<td>{q.customer_name || q.customer_company || '—'}</td>
<td>{q.quote_date ? new Date(q.quote_date).toLocaleDateString('zh-CN') : '—'}</td>
<td>{q.valid_until ? new Date(q.valid_until).toLocaleDateString('zh-CN') : '—'}</td>
<td>{q.total_amount ? `${q.currency || 'USD'} ${Number(q.total_amount).toFixed(2)}` : '—'}</td>
<td><span className={`status-badge status-${q.status}`}>{statusText(q.status)}</span></td>
<td>
<button className="btn-link">查看</button>
<button className="btn-link">转 PI</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
)
}
export default QuoteManagement
+596
View File
@@ -0,0 +1,596 @@
import React, { useState, useEffect, useMemo, useCallback } from 'react'
import { useNavigate } from 'react-router-dom'
import axios from 'axios'
import { GridIcon, ListIcon } from '../components/Icons'
/**
* 供应商管理(原生 React 版)
*
* 由 SKU Manager 的「供应商管理」视图迁移而来,功能对齐:
* 卡片列表 · 未分类入口 · 新建/编辑 · 删除 · 详情(联系方式 / 供货清单 / 合同记录)。
* 数据经 /api/sku/* 同源代理访问,禁止硬编码 SKU 的独立端口。
*/
const API = '/api/sku'
/* ------------------------------ 格式化工具 ------------------------------ */
const money = (n) => (n == null ? '—' : Number(n).toFixed(2))
function fmtDate(iso, withTime = true) {
if (!iso) return '—'
const d = new Date(iso)
const pad = (n) => String(n).padStart(2, '0')
const base = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
return withTime ? `${base} ${pad(d.getHours())}:${pad(d.getMinutes())}` : base
}
function splitCats(text) {
return String(text || '')
.split(/[、,,;;/|\s]+/)
.map((t) => t.trim())
.filter(Boolean)
}
/** 从完整地址里抽出省市,卡片上只显示大致方位 */
function shortLocation(address) {
const a = String(address || '').trim()
if (!a) return ''
const m = a.match(/^(.{2,4}?(?:省|市|自治区|特别行政区))?\s*(.{2,6}?(?:市|自治州|地区|县|区))?/)
const parts = [m?.[1], m?.[2]].filter(Boolean)
if (parts.length) return parts.join('')
return a.length > 12 ? `${a.slice(0, 12)}…` : a
}
const FIELDS = [
['name', '供应商名称', true],
['short_name', '简称'],
['contact_person', '联系人'],
['phone', '电话'],
['email', '邮箱'],
['website', '网址'],
['payment_terms', '付款条件'],
['address', '地址']
]
const EMPTY_FORM = {
name: '',
short_name: '',
contact_person: '',
phone: '',
email: '',
website: '',
payment_terms: '',
address: '',
main_categories: '',
notes: ''
}
const FILE_TYPE_LABEL = {
excel: 'Excel',
'pdf-text': 'PDF 文字版',
'pdf-scan': 'PDF 扫描件',
unknown: '无法识别'
}
const STATUS_LABEL = { success: '成功', empty: '未识别到数据', failed: '失败' }
const STATUS_PILL = { success: 'pill pill-ok', empty: 'pill pill-mute', failed: 'pill pill-warn' }
/* -------------------------------- 小组件 -------------------------------- */
function CatChips({ text }) {
const cats = splitCats(text)
if (!cats.length) return <div className="chip-row"><span className="muted-note">未填主营类目</span></div>
return (
<div className="chip-row">
{cats.map((c) => <span className="chip" key={c}>{c}</span>)}
</div>
)
}
function Kv({ label, value, mono }) {
return (
<div className="kv">
<span className="kv-label">{label}</span>
<span className={`kv-value${value ? '' : ' is-empty'}`} style={mono && value ? { fontFamily: 'var(--font-mono)' } : undefined}>
{value || '未填写'}
</span>
</div>
)
}
/* -------------------------------- 主页面 -------------------------------- */
function SupplierManagement() {
const navigate = useNavigate()
const [mode, setMode] = useState('list') // list | detail | form
const [suppliers, setSuppliers] = useState([])
const [unassigned, setUnassigned] = useState(0)
const [loading, setLoading] = useState(true)
const [notice, setNotice] = useState('')
const [keyword, setKeyword] = useState('')
// 排列方式:卡片网格 / 横列表。选择记在本地,下次进来自动沿用。
const [viewMode, setViewMode] = useState(() => {
try { return localStorage.getItem('happy.supplierView') || 'card' } catch { return 'card' }
})
const changeView = useCallback((next) => {
setViewMode(next)
try { localStorage.setItem('happy.supplierView', next) } catch { /* 隐私模式下忽略 */ }
}, [])
const [detail, setDetail] = useState(null)
const [detailLoading, setDetailLoading] = useState(false)
const [form, setForm] = useState(EMPTY_FORM)
const [editingId, setEditingId] = useState(null)
const [editingName, setEditingName] = useState('')
const [saving, setSaving] = useState(false)
const loadList = useCallback(async () => {
try {
const [listRes, unassignedRes] = await Promise.all([
axios.get(`${API}/suppliers`),
axios.get(`${API}/suppliers/unassigned/count`).catch(() => ({ data: { count: 0 } }))
])
setSuppliers(Array.isArray(listRes.data) ? listRes.data : [])
setUnassigned(unassignedRes.data?.count || 0)
} catch (err) {
setNotice(err?.response?.data?.error || `加载供应商失败:${err.message}`)
} finally {
setLoading(false)
}
}, [])
useEffect(() => { loadList() }, [loadList])
const filtered = useMemo(() => {
const kw = keyword.trim().toLowerCase()
if (!kw) return suppliers
return suppliers.filter((s) =>
[s.name, s.short_name, s.contact_person, s.phone, s.address, s.main_categories]
.filter(Boolean)
.join(' ')
.toLowerCase()
.includes(kw)
)
}, [suppliers, keyword])
const openDetail = useCallback(async (id) => {
setMode('detail')
setDetailLoading(true)
setDetail(null)
setNotice('')
try {
const { data } = await axios.get(`${API}/suppliers/${id}`)
setDetail(data)
} catch (err) {
setNotice(err?.response?.data?.error || `加载详情失败:${err.message}`)
} finally {
setDetailLoading(false)
}
}, [])
const startCreate = () => {
setForm(EMPTY_FORM)
setEditingId(null)
setEditingName('')
setNotice('')
setMode('form')
}
const startEdit = (supplier) => {
setForm({ ...EMPTY_FORM, ...Object.fromEntries(Object.entries(supplier).map(([k, v]) => [k, v ?? ''])) })
setEditingId(supplier.id)
setEditingName(supplier.name)
setNotice('')
setMode('form')
}
const save = async () => {
const payload = {}
for (const [key] of FIELDS) payload[key] = form[key]?.trim() || null
payload.main_categories = form.main_categories?.trim() || null
payload.notes = form.notes?.trim() || null
if (!payload.name) {
setNotice('供应商名称不能为空')
return
}
setSaving(true)
setNotice('')
try {
if (editingId) {
await axios.put(`${API}/suppliers/${editingId}`, payload)
await loadList()
await openDetail(editingId)
} else {
const { data } = await axios.post(`${API}/suppliers`, payload)
await loadList()
await openDetail(data.id)
}
} catch (err) {
setNotice(err?.response?.data?.error || `保存失败:${err.message}`)
} finally {
setSaving(false)
}
}
const removeSupplier = async (id, name) => {
const ok = window.confirm(
`删除供应商「${name}」?\n\n该供应商名下的产品和价格会保留,只是变成「未指定供应商」。`
)
if (!ok) return
try {
await axios.delete(`${API}/suppliers/${id}`)
await loadList()
setDetail(null)
setMode('list')
} catch (err) {
setNotice(err?.response?.data?.error || `删除失败:${err.message}`)
}
}
/* ------------------------------- 列表视图 ------------------------------- */
if (mode === 'list') {
return (
<div className="page-container">
<div className="page-header">
<div>
<h1 className="page-title">供应商管理</h1>
<p className="page-sub">档案、联系方式、工厂所在地与主营类目</p>
</div>
<button className="btn btn-primary" onClick={startCreate}>+ 新建供应商</button>
</div>
{notice && <div className="notice-inline">{notice}</div>}
<div className="toolbar">
<input
type="text"
className="toolbar-search"
placeholder="按名称 / 联系人 / 电话 / 地址搜索"
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
/>
<div className="toolbar-actions">
<span className="secondary">共 {filtered.length} 家</span>
<div className="view-toggle" role="group" aria-label="切换排列方式">
<button
type="button"
className={viewMode === 'card' ? 'active' : ''}
onClick={() => changeView('card')}
title="卡片排列"
aria-pressed={viewMode === 'card'}
>
<GridIcon size={16} />
</button>
<button
type="button"
className={viewMode === 'table' ? 'active' : ''}
onClick={() => changeView('table')}
title="列表排列"
aria-pressed={viewMode === 'table'}
>
<ListIcon size={16} />
</button>
</div>
</div>
</div>
{loading ? (
<div className="loading">加载中…</div>
) : suppliers.length === 0 ? (
<div className="empty-state">
<div className="empty-state-icon">🏭</div>
<div className="empty-state-title">还没有供应商档案</div>
<div className="empty-state-hint">
建好供应商后,导入合同时就能把价格归到对应供应商名下,查价时也能按供应商筛选。
</div>
</div>
) : viewMode === 'table' ? (
<div className="table-container">
<table className="data-table supplier-table">
<thead>
<tr>
<th>供应商</th>
<th>联系人</th>
<th>电话</th>
<th>工厂所在地</th>
<th>主营类目</th>
<th style={{ textAlign: 'right', width: 72 }}>产品</th>
<th style={{ textAlign: 'right', width: 72 }}>货号</th>
<th>付款条件</th>
</tr>
</thead>
<tbody>
{unassigned > 0 && (
<tr className="row-unassigned" onClick={() => navigate('/products')}>
<td className="bold">未分类</td>
<td className="secondary" colSpan={4}>还没归属供应商的产品</td>
<td className="number" style={{ textAlign: 'right' }}>{unassigned}</td>
<td className="number" style={{ textAlign: 'right' }}>—</td>
<td className="secondary">—</td>
</tr>
)}
{filtered.map((s) => (
<tr key={s.id} onClick={() => openDetail(s.id)}>
<td className="bold">{s.name}</td>
<td className="secondary">{s.contact_person || '—'}</td>
<td className="secondary mono">{s.phone || '—'}</td>
<td className="secondary" title={s.address || ''}>
{shortLocation(s.address) || '—'}
</td>
<td>
{splitCats(s.main_categories).length
? <div className="chip-row">{splitCats(s.main_categories).map((c) => <span className="chip" key={c}>{c}</span>)}</div>
: <span className="muted-note">未填</span>}
</td>
<td className="number" style={{ textAlign: 'right' }}>{s.group_count ?? s.sku_count ?? 0}</td>
<td className="number" style={{ textAlign: 'right' }}>{s.sku_count ?? 0}</td>
<td className="secondary">{s.payment_terms || '—'}</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="supplier-grid">
{unassigned > 0 && (
<div className="supplier-card is-unassigned" onClick={() => navigate('/products')}>
<div className="sc-name">未分类</div>
<div className="sc-contact"><span className="muted-note">还没归属供应商的产品</span></div>
<div className="sc-foot">
<span className="sc-count"><b>{unassigned}</b> 个产品</span>
</div>
</div>
)}
{filtered.map((s) => {
const contact = [s.contact_person, s.phone].filter(Boolean).join(' · ')
const loc = shortLocation(s.address)
return (
<div className="supplier-card" key={s.id} onClick={() => openDetail(s.id)}>
<div className="sc-name">{s.name}</div>
<div className="sc-contact">
{contact || <span className="muted-note">未填联系人</span>}
</div>
<div className="sc-loc" title={s.address || ''}>
{loc || <span className="muted-note">未填工厂所在地</span>}
</div>
<CatChips text={s.main_categories} />
<div className="sc-foot">
<span className="sc-count">
<b>{s.group_count ?? s.sku_count}</b> 个产品 · {s.sku_count} 个货号
</span>
{s.payment_terms && <span className="chip">{s.payment_terms}</span>}
</div>
</div>
)
})}
</div>
)}
</div>
)
}
/* ------------------------------- 详情视图 ------------------------------- */
if (mode === 'detail') {
const s = detail?.supplier
const products = detail?.products || []
const contracts = detail?.contracts || []
return (
<div className="page-container">
<div className="page-header">
<div>
<h1 className="page-title">{s?.name || '供应商详情'}</h1>
{s && (
<p className="page-sub">
{s.short_name ? `${s.short_name} · ` : ''}
供货 {products.length} 个货号 · {contracts.length} 份合同记录
</p>
)}
{s && <CatChips text={s.main_categories} />}
</div>
<div className="header-actions">
<button className="btn" onClick={() => { setDetail(null); setMode('list') }}>← 供应商列表</button>
{s && <button className="btn btn-primary" onClick={() => startEdit(s)}>编辑档案</button>}
</div>
</div>
{notice && <div className="notice-inline">{notice}</div>}
{detailLoading || !s ? (
<div className="loading">加载中…</div>
) : (
<>
<div className="card">
<div className="card-header"><h2>联系方式</h2></div>
<div className="kv-grid">
<Kv label="联系人" value={s.contact_person} />
<Kv label="电话" value={s.phone} mono />
<Kv label="邮箱" value={s.email} mono />
<Kv label="付款条件" value={s.payment_terms} />
<Kv label="网址" value={s.website} mono />
<Kv label="地址" value={s.address} />
</div>
{s.notes && (
<div className="kv-grid" style={{ marginTop: 14 }}>
<Kv label="备注" value={s.notes} />
</div>
)}
</div>
<div className="card">
<div className="card-header">
<h2>供货清单</h2>
<span className="secondary">{products.length} 条</span>
</div>
{products.length === 0 ? (
<div className="empty-state" style={{ padding: '28px 12px' }}>
<div className="empty-state-hint">
还没有归到这家供应商的货号。导入合同时在「合同导入」页选上这家供应商即可。
</div>
</div>
) : (
<div className="table-container" style={{ maxHeight: 460 }}>
<table className="data-table">
<thead>
<tr>
<th style={{ width: 56 }}></th>
<th>货号</th>
<th>所属产品</th>
<th style={{ textAlign: 'right' }}>采购价</th>
<th style={{ textAlign: 'right' }}>起订量</th>
<th>更新时间</th>
</tr>
</thead>
<tbody>
{products.map((p) => (
<tr key={`${p.sku}-${p.group_id ?? ''}`}>
<td>
{p.thumb
? <img className="thumb" src={`/product-images/${p.thumb}`} alt="" loading="lazy" />
: <span className="thumb thumb-empty" />}
</td>
<td className="mono">{p.display_sku || p.sku}</td>
<td>{p.group_name || p.name || '—'}</td>
<td className="number" style={{ textAlign: 'right' }}>
<span className="secondary">¥</span>{money(p.price)}
{p.confidence === 'low' && <span className="pill pill-warn" style={{ marginLeft: 6 }}>待核对</span>}
</td>
<td className="number" style={{ textAlign: 'right' }}>{p.moq ?? '—'}</td>
<td className="secondary">{fmtDate(p.last_updated, false)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
<div className="card">
<div className="card-header">
<h2>合同导入记录</h2>
<span className="secondary">{contracts.length} 条</span>
</div>
{contracts.length === 0 ? (
<div className="empty-state" style={{ padding: '28px 12px' }}>
<div className="empty-state-hint">还没有该供应商的合同导入记录。</div>
</div>
) : (
<div className="table-container" style={{ maxHeight: 340 }}>
<table className="data-table">
<thead>
<tr>
<th>文件名</th>
<th>类型</th>
<th style={{ textAlign: 'right' }}>新增</th>
<th style={{ textAlign: 'right' }}>更新</th>
<th>状态</th>
<th>时间</th>
</tr>
</thead>
<tbody>
{contracts.map((c) => (
<tr key={c.id}>
<td className="mono">
{c.has_file
? <a href={`${API}/contracts/${c.id}/file`} title="下载原件">{c.filename}</a>
: c.filename}
</td>
<td className="secondary">{FILE_TYPE_LABEL[c.file_type] || c.file_type}</td>
<td className="number" style={{ textAlign: 'right' }}>{c.rows_new}</td>
<td className="number" style={{ textAlign: 'right' }}>{c.rows_matched}</td>
<td><span className={STATUS_PILL[c.status] || 'pill pill-mute'}>{STATUS_LABEL[c.status] || c.status}</span></td>
<td className="secondary">{fmtDate(c.processed_at)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</>
)}
</div>
)
}
/* ------------------------------- 表单视图 ------------------------------- */
return (
<div className="page-container">
<div className="page-header">
<div>
<h1 className="page-title">{editingId ? '编辑供应商' : '新建供应商'}</h1>
<p className="page-sub">
{editingId ? editingName : '填写基础档案,之后可以随时修改'}
</p>
</div>
<button className="btn" onClick={() => (editingId ? openDetail(editingId) : setMode('list'))}>
返回
</button>
</div>
{notice && <div className="notice-inline">{notice}</div>}
<div className="card" style={{ maxWidth: 860 }}>
<div className="card-header"><h2>基础信息</h2></div>
<div className="form-row">
{FIELDS.map(([key, label, required]) => (
<div className="form-group" key={key}>
<label>{label}{required ? ' *' : ''}</label>
<input
type="text"
value={form[key] ?? ''}
onChange={(e) => setForm((f) => ({ ...f, [key]: e.target.value }))}
/>
</div>
))}
</div>
<div className="form-group">
<label>主营产品类目</label>
<input
type="text"
placeholder="用顿号或逗号分隔,例如:长嘴钳、老虎钳、斜口钳、卡簧钳"
value={form.main_categories}
onChange={(e) => setForm((f) => ({ ...f, main_categories: e.target.value }))}
/>
</div>
<div className="form-group">
<label>备注</label>
<textarea
rows={3}
value={form.notes}
onChange={(e) => setForm((f) => ({ ...f, notes: e.target.value }))}
/>
</div>
<div className="form-actions">
<button className="btn btn-primary" onClick={save} disabled={saving}>
{saving ? '保存中…' : '保存'}
</button>
{editingId && (
<button
className="btn btn-danger"
onClick={() => removeSupplier(editingId, editingName)}
disabled={saving}
>
删除该供应商
</button>
)}
</div>
</div>
</div>
)
}
export default SupplierManagement
+7
View File
@@ -0,0 +1,7 @@
// Whitelist stored preferences and tolerate blocked browser storage.
export function readPreference(key, allowed, fallback) {
try {
const value = localStorage.getItem(key)
return allowed.includes(value) ? value : fallback
} catch { return fallback }
}
+289
View File
@@ -0,0 +1,289 @@
/* ============================================================
应用布局与页面组件
============================================================ */
.app-container { display: flex; min-height: 100vh; background: var(--bg-primary); }
/* 侧边栏:仪表板式工业控制台 */
.sidebar {
position: sticky;
top: 0;
width: var(--sidebar-width);
height: 100vh;
flex: 0 0 var(--sidebar-width);
display: flex;
flex-direction: column;
background: linear-gradient(180deg, #0d2539 0%, #091a2a 100%);
border-right: 1px solid var(--border);
box-shadow: 10px 0 28px rgba(0, 0, 0, 0.2);
transition: width var(--transition-base), flex-basis var(--transition-base);
z-index: 10;
}
.sidebar::before { content: ""; height: 3px; flex: 0 0 3px; background: linear-gradient(90deg, var(--primary-dark), var(--primary-light)); }
.sidebar.collapsed { width: var(--sidebar-collapsed-width); flex-basis: var(--sidebar-collapsed-width); }
.sidebar-header { display: flex; align-items: center; justify-content: space-between; gap: 12px; min-height: 76px; padding: 18px 18px 16px; border-bottom: 1px solid var(--border-light); }
.sidebar-header h1 { margin: 0; overflow: hidden; color: var(--text-primary); font-family: var(--font-display); font-size: 16px; font-weight: 700; letter-spacing: 0.02em; white-space: nowrap; }
.collapse-btn { flex: 0 0 30px; width: 30px; height: 30px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface-deep); color: var(--primary-light); cursor: pointer; font-size: 16px; transition: all var(--transition-fast); }
.collapse-btn:hover { background: var(--primary); color: #041522; border-color: var(--primary); }
.sidebar-nav { flex: 1; overflow-y: auto; padding: 14px 10px; }
/* 分组标题行:标题 + 右侧折叠按钮 */
.nav-group { display: block; }
.nav-group:first-of-type .nav-group-header { margin-top: 14px; padding-top: 12px; border-top: 1px solid var(--border-light); }
.nav-group-header { display: flex; align-items: center; justify-content: space-between; gap: 6px; margin: 16px 4px 6px; padding: 0 6px; }
.nav-group-title { flex: 1; margin: 0; color: var(--text-tertiary); font-size: 12px; font-weight: 600; letter-spacing: 0.04em; }
.nav-group-toggle { display: flex; flex: 0 0 auto; align-items: center; justify-content: center; width: 24px; height: 24px; padding: 0; border: 1px solid transparent; border-radius: var(--radius-sm); background: transparent; color: var(--text-tertiary); cursor: pointer; transition: color var(--transition-fast), background var(--transition-fast), border-color var(--transition-fast); }
.nav-group-toggle:hover { color: var(--primary-light); background: var(--surface-hover); border-color: var(--border-light); }
.nav-group-toggle svg { transition: transform var(--transition-base); }
.nav-group-toggle:not(.open) svg { transform: rotate(-90deg); }
.sidebar.collapsed .nav-group-header { display: none; }
.nav-item { position: relative; display: flex; align-items: center; gap: 12px; min-height: 42px; margin: 3px 0; padding: 10px 12px; border: 1px solid transparent; border-radius: var(--radius-md); color: var(--text-secondary); text-decoration: none; font-size: 14px; font-weight: 600; transition: all var(--transition-fast); }
.nav-item:hover { background: var(--surface-hover); border-color: var(--border-light); color: var(--text-primary); }
.nav-item.active { background: linear-gradient(90deg, rgba(42, 167, 220, 0.22), rgba(42, 167, 220, 0.06)); border-color: var(--primary-border); color: var(--text-primary); box-shadow: var(--shadow-inset); }
.nav-item.active::before { content: ""; position: absolute; left: -1px; top: 8px; bottom: 8px; width: 3px; border-radius: 0 2px 2px 0; background: var(--primary-light); }
.nav-item .icon { display: flex; align-items: center; justify-content: center; min-width: 20px; color: var(--primary-light); }
.sidebar.collapsed .nav-item { justify-content: center; padding: 10px; }
.sidebar.collapsed .nav-item span:not(.icon) { display: none; }
.sidebar-footer { display: flex; align-items: center; gap: 10px; padding: 15px 16px; border-top: 1px solid var(--border-light); }
.sidebar-footer .user-info { flex: 1; overflow: hidden; color: var(--text-secondary); font-size: 12px; font-weight: 600; white-space: nowrap; }
.settings-btn { display: flex; align-items: center; justify-content: center; width: 32px; height: 32px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: transparent; color: var(--text-tertiary); cursor: pointer; transition: all var(--transition-fast); }
.settings-btn:hover { color: var(--primary-light); border-color: var(--primary-border); background: var(--primary-bg); }
.sidebar.collapsed .sidebar-footer { justify-content: center; padding: 15px 10px; }
.sidebar.collapsed .user-info { display: none; }
/* 主内容 */
.main-content { min-width: 0; flex: 1; overflow-y: auto; padding: 34px clamp(20px, 4vw, 52px) 52px; background-color: var(--bg-primary); background-image: linear-gradient(rgba(126, 190, 221, 0.035) 1px, transparent 1px), linear-gradient(90deg, rgba(126, 190, 221, 0.035) 1px, transparent 1px); background-size: 28px 28px; }
.page-container, .dashboard-container, .prospect-container { width: min(1440px, 100%); margin: 0 auto; }
.page-header { display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; margin-bottom: 26px; }
.page-title { margin: 0 0 24px; color: var(--text-primary); font-family: var(--font-display); font-size: clamp(24px, 3vw, 34px); font-weight: 700; letter-spacing: -0.02em; }
.page-header .page-title { margin-bottom: 0; }
.page-title::after { content: ""; display: block; width: 42px; height: 3px; margin-top: 10px; background: var(--primary); }
/* 卡片与统计 */
.card, .dashboard-card, .panel { margin-bottom: 20px; padding: 20px; border: 1px solid var(--border); border-radius: var(--radius-lg); background: var(--surface); box-shadow: var(--shadow-raise); }
.card-header { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 17px; padding-bottom: 13px; border-bottom: 1px solid var(--border-light); }
.card-header h2, .panel-title { display: flex; align-items: center; gap: 9px; margin: 0; color: var(--text-primary); font-family: var(--font-display); font-size: 15px; font-weight: 700; }
.card-header h2 svg { color: var(--primary-light); }
.card-content { color: var(--text-secondary); }
.panel-title { margin-bottom: 14px; }
.panel-title::after { content: ""; flex: 1; height: 1px; background: var(--border-light); }
.stats-grid { display: grid; grid-template-columns: repeat(6, minmax(150px, 1fr)); gap: 14px; margin-bottom: 24px; }
.stat-card { display: flex; align-items: center; min-height: 106px; gap: 14px; padding: 18px; border: 1px solid var(--border); border-radius: var(--radius-lg); background: linear-gradient(145deg, var(--surface-raised), var(--surface)); box-shadow: var(--shadow-raise); transition: transform var(--transition-fast), border-color var(--transition-fast); }
.stat-card:hover { transform: translateY(-2px); border-color: var(--primary-border); }
.stat-icon { display: flex; align-items: center; justify-content: center; flex: 0 0 38px; color: var(--primary-light); }
.stat-label { margin-bottom: 5px; color: var(--text-tertiary); font-size: 12px; font-weight: 600; }
.stat-value { color: var(--text-primary); font-family: var(--font-mono); font-size: 27px; font-weight: 700; line-height: 1; }
.dashboard-grid { display: grid; grid-template-columns: minmax(0, 1.25fr) minmax(360px, 0.75fr); gap: 20px; margin-bottom: 20px; }
/* 工具栏、表单、搜索 */
.toolbar { display: flex; align-items: center; gap: 10px; margin-bottom: 16px; flex-wrap: wrap; }
.toolbar-search { flex: 1; min-width: 230px; max-width: 450px; }
.toolbar-actions { display: flex; align-items: center; gap: 8px; margin-left: auto; flex-wrap: wrap; }
.form-row { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; }
.form-group { margin-bottom: 16px; }
.form-group label { display: block; margin-bottom: 7px; color: var(--text-secondary); font-size: 12px; font-weight: 700; }
.search-input { width: 100%; }
.mono { font-family: var(--font-mono); }
.number, .price { font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
.bold { font-weight: 700; color: var(--text-primary); }
.secondary { color: var(--text-tertiary); }
/* 空状态、待办、活动、日程 */
.empty-state { padding: 48px 20px; color: var(--text-tertiary); text-align: center; }
.empty-state-icon { margin-bottom: 14px; color: var(--text-disabled); font-size: 40px; }
.empty-state-title { margin-bottom: 6px; color: var(--text-secondary); font-weight: 700; }
.empty-state-hint { color: var(--text-tertiary); font-size: 13px; }
.todo-list, .activity-list, .holiday-list { margin: 0; padding: 0; list-style: none; }
.todo-item, .activity-item { display: flex; align-items: flex-start; gap: 11px; padding: 12px 0; border-bottom: 1px solid var(--border-light); }
.todo-item:last-child, .activity-item:last-child { border-bottom: 0; }
.todo-item input[type="checkbox"] { flex: 0 0 auto; width: 16px; height: 16px; margin-top: 3px; accent-color: var(--primary); }
.todo-content { min-width: 0; flex: 1; }
.todo-title { color: var(--text-primary); font-size: 13px; font-weight: 600; }
.todo-meta, .activity-time { margin-top: 3px; color: var(--text-tertiary); font-size: 11px; }
.activity-content { color: var(--text-secondary); font-size: 13px; }
.calendar-widget { padding: 3px 0; }
.current-date { margin-bottom: 14px; padding: 12px; border: 1px solid var(--border-light); border-radius: var(--radius-md); background: var(--surface-deep); color: var(--primary-light); font-family: var(--font-mono); font-size: 13px; font-weight: 700; text-align: center; }
.holiday-item { display: flex; align-items: center; gap: 10px; padding: 9px 0; border-bottom: 1px solid var(--border-light); }
.holiday-item:last-child { border-bottom: 0; }
.holiday-flag { display: flex; align-items: center; color: var(--primary-light); }
.holiday-name { flex: 1; color: var(--text-secondary); font-size: 13px; }
.holiday-date { color: var(--text-tertiary); font-family: var(--font-mono); font-size: 12px; }
.info-box { margin-top: 18px; padding: 15px 17px; border: 1px solid var(--info-border); border-left: 3px solid var(--info); border-radius: var(--radius-md); background: var(--info-bg); color: var(--text-secondary); }
.info-box h3 { margin: 0 0 9px; color: var(--info); font-size: 14px; }
.info-box ul { margin: 8px 0 0 18px; padding: 0; }
.info-box li { margin-bottom: 5px; }
/* 设置页 */
.settings-modal { display: flex; width: min(1000px, calc(100vw - 32px)); height: min(720px, calc(100vh - 32px)); overflow: hidden; border-radius: var(--radius-lg); }
.settings-sidebar { display: flex; flex-direction: column; width: 230px; flex: 0 0 230px; background: var(--surface-deep); border-right: 1px solid var(--border); }
.settings-header h2 { margin: 0; font-family: var(--font-display); font-size: 17px; }
.settings-nav { flex: 1; padding: 12px; overflow-y: auto; }
.settings-nav-item { width: 100%; margin-bottom: 4px; padding: 10px 12px; border: 1px solid transparent; border-radius: var(--radius-md); background: transparent; color: var(--text-secondary); cursor: pointer; text-align: left; font-size: 13px; font-weight: 600; transition: all var(--transition-fast); }
.settings-nav-item:hover { background: var(--surface-hover); color: var(--text-primary); }
.settings-nav-item.active { background: var(--primary-bg); border-color: var(--primary-border); color: var(--primary-light); }
.settings-content { flex: 1; overflow-y: auto; padding: 26px; }
.settings-section h3 { margin: 0 0 7px; color: var(--text-primary); font-family: var(--font-display); font-size: 22px; }
.settings-description { margin: 0 0 26px; color: var(--text-tertiary); font-size: 13px; }
.settings-group { margin-bottom: 26px; padding-bottom: 23px; border-bottom: 1px solid var(--border-light); }
.settings-group:last-child { border-bottom: 0; }
.settings-group h4 { margin: 0 0 13px; color: var(--text-secondary); font-size: 13px; }
.settings-actions { display: flex; flex-wrap: wrap; gap: 9px; }
.file-label { cursor: pointer; }
.help-text { margin: 9px 0 0; color: var(--text-tertiary); font-size: 12px; }
.checkbox-group label { display: flex; align-items: center; gap: 9px; cursor: pointer; }
.checkbox-group input[type="checkbox"] { width: 16px; height: 16px; accent-color: var(--primary); }
.checkbox-group span { color: var(--text-secondary); font-size: 13px; }
.loading { display: flex; align-items: center; justify-content: center; min-height: 220px; color: var(--text-tertiary); font-size: 13px; }
.spinner { width: 20px; height: 20px; border: 2px solid var(--border); border-top-color: var(--primary); border-radius: 50%; animation: spin 700ms linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
@media (max-width: 1180px) { .stats-grid { grid-template-columns: repeat(3, minmax(160px, 1fr)); } }
@media (max-width: 900px) { .sidebar { width: var(--sidebar-collapsed-width); flex-basis: var(--sidebar-collapsed-width); } .sidebar-header { justify-content: center; padding: 18px 10px; } .sidebar-header h1 { display: none; } .sidebar-nav { padding-inline: 9px; } .nav-group-header, .nav-item span:not(.icon), .user-info { display: none; } .nav-item { justify-content: center; padding-inline: 10px; } .sidebar-footer { justify-content: center; padding-inline: 10px; } .dashboard-grid { grid-template-columns: 1fr; } }
@media (max-width: 620px) { .main-content { padding: 24px 14px 36px; } .stats-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; } .stat-card { min-height: 88px; padding: 13px; } .stat-icon { flex-basis: 30px; } .stat-value { font-size: 22px; } .dashboard-grid { display: block; } .form-row { grid-template-columns: 1fr; } .settings-modal { height: calc(100vh - 20px); width: calc(100vw - 20px); flex-direction: column; } .settings-sidebar { width: auto; flex-basis: auto; } .settings-header { padding: 14px 18px; } .settings-nav { display: flex; gap: 8px; overflow-x: auto; } .settings-nav-item { width: auto; white-space: nowrap; margin: 0; } .settings-content { padding: 20px 18px; } }
/* 产品页使用的原生 table 同样复用工业表格视觉 */
.table-wrap table { width: 100%; border-collapse: separate; border-spacing: 0; font-size: 13px; }
.table-wrap thead th { position: sticky; top: 0; z-index: 1; padding: 11px 14px; background: var(--surface-raised); color: var(--text-secondary); border-bottom: 1px solid var(--border-strong); text-align: left; font-size: 11px; letter-spacing: 0.08em; text-transform: uppercase; }
.table-wrap tbody td { padding: 12px 14px; color: var(--text-secondary); border-bottom: 1px solid var(--border-light); vertical-align: middle; }
.table-wrap tbody tr { cursor: pointer; transition: background var(--transition-fast); }
.table-wrap tbody tr:hover td { background: var(--primary-bg); color: var(--text-primary); }
.table-wrap tbody tr:last-child td { border-bottom: 0; }
/* ============================================================
供应商管理(由 SKU 迁移为原生 React 页面)
============================================================ */
.page-sub { margin: 6px 0 0; color: var(--text-secondary); font-size: 14px; }
.header-actions { display: flex; align-items: center; gap: 8px; }
.notice-inline { margin-bottom: 16px; padding: 10px 14px; border: 1px solid var(--danger-border); border-left: 3px solid var(--danger); border-radius: var(--radius-md); background: var(--danger-bg); color: var(--danger); font-size: 13px; }
.supplier-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(258px, 1fr)); gap: 14px; }
.supplier-card { display: flex; flex-direction: column; gap: 7px; padding: 16px 18px; border: 1px solid var(--border); border-radius: var(--radius-lg); background: var(--surface); cursor: pointer; transition: transform var(--transition-fast), border-color var(--transition-fast), background var(--transition-fast); }
.supplier-card:hover { transform: translateY(-2px); border-color: var(--primary-border); background: var(--surface-raised); }
.supplier-card.is-unassigned { border-style: dashed; }
.sc-name { color: var(--text-primary); font-size: 15px; font-weight: 600; }
.sc-contact, .sc-loc { overflow: hidden; color: var(--text-secondary); font-size: 13px; text-overflow: ellipsis; white-space: nowrap; }
.sc-foot { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-top: auto; padding-top: 9px; border-top: 1px solid var(--border-light); color: var(--text-tertiary); font-size: 12px; }
.sc-count b { color: var(--text-primary); font-size: 13px; }
.muted-note { color: var(--text-tertiary); font-size: 12px; }
.chip-row { display: flex; flex-wrap: wrap; gap: 6px; }
.chip { padding: 2px 9px; border: 1px solid var(--border-light); border-radius: var(--radius-pill); background: var(--surface-deep); color: var(--text-secondary); font-size: 12px; white-space: nowrap; }
.kv-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); gap: 12px 24px; }
.kv { display: flex; flex-direction: column; gap: 3px; padding-bottom: 9px; border-bottom: 1px solid var(--border-light); }
.kv-label { color: var(--text-tertiary); font-size: 12px; }
.kv-value { color: var(--text-primary); font-size: 14px; word-break: break-word; }
.kv-value.is-empty { color: var(--text-disabled); }
.pill { display: inline-flex; align-items: center; padding: 2px 9px; border-radius: var(--radius-pill); font-size: 12px; font-weight: 600; }
.pill-ok { border: 1px solid var(--success-border); background: var(--success-bg); color: var(--success); }
.pill-warn { border: 1px solid var(--warning-border); background: var(--warning-bg); color: var(--warning); }
.pill-mute { border: 1px solid var(--border-light); background: var(--surface-deep); color: var(--text-tertiary); }
.thumb { display: block; width: 38px; height: 38px; object-fit: cover; border: 1px solid var(--border-light); border-radius: var(--radius-sm); }
.thumb-empty { background: var(--surface-deep); }
.form-actions { display: flex; align-items: center; gap: 9px; margin-top: 6px; }
.form-actions .btn-danger { margin-left: auto; }
/* ============================================================
产品管理(由 SKU 迁移为原生 React 页面)
============================================================ */
/* 表头改成中文友好:原来 11px + 0.08em 字距是给全大写英文设计的,中文会被拉散 */
.data-table thead th { font-size: 12px; letter-spacing: 0.02em; text-transform: none; }
/* 排列方式分段控件 */
.view-toggle { display: inline-flex; padding: 2px; border: 1px solid var(--border); border-radius: var(--radius-md); background: var(--surface-deep); }
.view-toggle button { display: inline-flex; align-items: center; justify-content: center; width: 30px; height: 26px; border: 0; border-radius: calc(var(--radius-md) - 2px); background: transparent; color: var(--text-tertiary); cursor: pointer; transition: all var(--transition-fast); }
.view-toggle button:hover { background: var(--surface-hover); color: var(--text-primary); }
.view-toggle button.active { background: var(--primary-bg); color: var(--primary-light); }
/* 可点击表格行 */
.supplier-table tbody tr, .product-table tbody tr { cursor: pointer; }
.supplier-table tbody tr.row-unassigned { border-left: 3px dashed var(--border-strong); }
.product-table tbody tr.row-picked td { background: var(--primary-bg); }
.product-table tbody tr.row-hit td { background: var(--warning-bg); }
.check-inline { display: inline-flex; align-items: center; gap: 7px; color: var(--text-secondary); font-size: 13px; white-space: nowrap; cursor: pointer; }
.check-inline input { width: 15px; height: 15px; accent-color: var(--primary); }
/* 批量操作条 */
.batch-bar { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; padding: 10px 14px; border: 1px solid var(--primary-border); border-radius: var(--radius-md); background: var(--primary-bg); color: var(--text-secondary); font-size: 13px; }
.batch-bar b { color: var(--text-primary); }
.hit-line { margin-top: 3px; color: var(--text-tertiary); font-size: 12px; }
.thumb-wrap { position: relative; display: inline-block; }
.thumb-badge { position: absolute; right: -5px; bottom: -5px; min-width: 16px; padding: 0 4px; border-radius: var(--radius-pill); background: var(--primary); color: #04141d; font-size: 10px; font-weight: 700; line-height: 16px; text-align: center; }
.thumb-empty { display: inline-flex; align-items: center; justify-content: center; color: var(--text-disabled); border: 1px dashed var(--border); border-radius: var(--radius-sm); background: var(--surface-deep); }
.unit { margin-right: 1px; color: var(--text-tertiary); font-size: 0.85em; }
.icon-btn { display: inline-flex; align-items: center; justify-content: center; width: 28px; height: 28px; border: 1px solid transparent; border-radius: var(--radius-sm); background: transparent; color: var(--text-tertiary); cursor: pointer; transition: all var(--transition-fast); }
.icon-btn:hover { border-color: var(--border); background: var(--surface-hover); color: var(--text-primary); }
/* 抽屉补充 */
.drawer-wide { width: min(940px, 96vw); }
.drawer-head-main { min-width: 0; }
.drawer-sub { margin: 5px 0 0; color: var(--text-tertiary); font-size: 13px; }
/* 参数编辑器(参数名 / 值 / 删除) */
.attr-editor { display: flex; flex-direction: column; gap: 8px; }
.attr-rows { display: flex; flex-direction: column; gap: 7px; }
.attr-row { display: grid; grid-template-columns: 1fr 1fr 30px; align-items: center; gap: 8px; }
.attr-row input { width: 100%; }
.attr-editor > .btn { align-self: flex-start; }
/* 图片画廊 */
.gallery { display: grid; grid-template-columns: repeat(auto-fill, minmax(124px, 1fr)); gap: 12px; }
.gallery-item { position: relative; overflow: hidden; border: 1px solid var(--border); border-radius: var(--radius-md); background: var(--surface-deep); }
.gallery-item img { display: block; width: 100%; height: 108px; object-fit: cover; cursor: zoom-in; }
.gallery-flag { position: absolute; top: 6px; left: 6px; padding: 1px 7px; border-radius: var(--radius-pill); background: var(--primary); color: #04141d; font-size: 11px; font-weight: 700; }
.gallery-actions { display: flex; gap: 6px; padding: 7px; border-top: 1px solid var(--border-light); }
.gallery-actions .btn { flex: 1; }
.add-image { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 6px; min-height: 142px; border: 1px dashed var(--border-strong); border-radius: var(--radius-md); color: var(--text-tertiary); font-size: 12px; cursor: pointer; transition: all var(--transition-fast); }
.add-image:hover { border-color: var(--primary-border); background: var(--primary-bg); color: var(--primary-light); }
.add-image input { display: none; }
.lightbox { position: fixed; inset: 0; z-index: 60; display: flex; align-items: center; justify-content: center; padding: 40px; background: rgba(2, 11, 18, 0.86); cursor: zoom-out; }
.lightbox img { max-width: 100%; max-height: 100%; border-radius: var(--radius-lg); box-shadow: var(--shadow-md); }
/* 说明块 */
.notice-inline.notice-ok { border-color: var(--success-border); border-left-color: var(--success); background: var(--success-bg); color: var(--success); }
.note { display: flex; gap: 9px; margin-bottom: 14px; padding: 11px 14px; border-radius: var(--radius-md); border-left: 3px solid var(--border-strong); background: var(--surface-deep); color: var(--text-secondary); font-size: 13px; line-height: 1.6; }
.note-info { border-left-color: var(--primary); }
.note-warn { border-left-color: var(--warning); background: var(--warning-bg); }
.spec-text { white-space: pre-wrap; color: var(--text-secondary); font-size: 13px; line-height: 1.75; }
.terms summary { color: var(--text-secondary); font-size: 13px; cursor: pointer; }
.terms .spec-text { margin-top: 9px; }
/* 调价时间线 */
.timeline { display: flex; flex-direction: column; gap: 14px; }
.timeline-item { display: flex; gap: 12px; }
.timeline-dot { flex: 0 0 8px; width: 8px; height: 8px; margin-top: 6px; border-radius: 50%; background: var(--primary); }
.timeline-title { color: var(--text-primary); font-size: 13px; }
.timeline-meta { margin-top: 2px; color: var(--text-tertiary); font-size: 12px; }
/* 小推车 */
.cart-badge { display: inline-flex; align-items: center; justify-content: center; min-width: 18px; height: 18px; margin-left: 6px; padding: 0 5px; border-radius: var(--radius-pill); background: var(--primary); color: #04141d; font-size: 11px; font-weight: 700; }
.cart-list { display: flex; flex-direction: column; gap: 10px; }
.cart-row { display: flex; align-items: center; gap: 12px; padding: 10px 12px; border: 1px solid var(--border); border-radius: var(--radius-md); background: var(--surface); }
.cart-thumb { flex: 0 0 auto; }
.cart-main { flex: 1; min-width: 0; }
.cart-name { overflow: hidden; color: var(--text-primary); font-size: 14px; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; }
.cart-meta { overflow: hidden; margin-top: 2px; color: var(--text-tertiary); font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
.cart-meta code { font-family: var(--font-mono); }
.cart-price { flex: 0 0 auto; min-width: 96px; text-align: right; color: var(--text-primary); font-size: 14px; }
.cart-qty { display: flex; flex-direction: column; gap: 3px; flex: 0 0 auto; }
.cart-qty span { color: var(--text-tertiary); font-size: 11px; }
.cart-qty input { width: 84px; }
.cart-total { align-self: center; color: var(--text-secondary); font-size: 13px; }
.cart-total b { color: var(--text-primary); font-size: 16px; }
/* 窄屏:批量条与抽屉内表单换行 */
@media (max-width: 720px) {
.batch-bar { flex-wrap: wrap; }
.attr-row { grid-template-columns: 1fr 1fr 28px; }
.cart-row { flex-wrap: wrap; }
}
+224
View File
@@ -0,0 +1,224 @@
/* ============================================================
工业蓝色设计系统
高对比度、小圆角、清晰边界;阴影仅用于层级与交互反馈。
============================================================ */
:root {
--bg-primary: #081522;
--bg-secondary: #0d2132;
--bg-elevated: #102a40;
--surface: #102a40;
--surface-raised: #143650;
--surface-hover: #193f5d;
--surface-deep: #0b2031;
--surface-alt: #0d263a;
--border-light: rgba(163, 211, 235, 0.16);
--border: rgba(163, 211, 235, 0.28);
--border-strong: rgba(163, 211, 235, 0.52);
--line: rgba(126, 190, 221, 0.2);
--text-primary: #f3f9fc;
--text-secondary: #c1d5df;
--text-tertiary: #89a7b7;
--text-disabled: #607d8c;
--primary: #2aa7dc;
--primary-dark: #1684b7;
--primary-light: #7ed7f5;
--primary-bg: rgba(42, 167, 220, 0.15);
--primary-border: rgba(126, 215, 245, 0.42);
--success: #53d69b;
--success-bg: rgba(83, 214, 155, 0.14);
--success-border: rgba(83, 214, 155, 0.42);
--warning: #ffc857;
--warning-bg: rgba(255, 200, 87, 0.14);
--warning-border: rgba(255, 200, 87, 0.42);
--danger: #ff7777;
--danger-bg: rgba(255, 119, 119, 0.14);
--danger-border: rgba(255, 119, 119, 0.42);
--info: #63b8ff;
--info-bg: rgba(99, 184, 255, 0.14);
--info-border: rgba(99, 184, 255, 0.42);
--radius-sm: 3px;
--radius-md: 5px;
--radius-lg: 8px;
--radius-xl: 10px;
--radius-pill: 999px;
--shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.22);
--shadow-md: 0 10px 26px rgba(0, 0, 0, 0.28);
--shadow-lg: 0 22px 52px rgba(0, 0, 0, 0.42);
--shadow-raise: -4px -4px 12px rgba(101, 175, 211, 0.06), 6px 8px 18px rgba(0, 0, 0, 0.28);
--shadow-inset: inset 2px 2px 7px rgba(0, 0, 0, 0.24), inset -2px -2px 7px rgba(93, 171, 208, 0.04);
/* 字体策略:
1) 英文/数字字形统一 Arial(保证中英混排时拉丁部分字形一致、笔画清晰);
2) Arial 不含 CJK 字形,中文自动回退到后面的中文字体;
3) 夜间(深底)优先微软雅黑——笔画更饱满,深底不显发虚;
日间(浅底)优先苹方/思源黑体——笔画更利落,浅底不糊。 */
--font-body: Arial, "Microsoft YaHei", "PingFang SC", "Hiragino Sans GB", "Source Han Sans SC", "Noto Sans SC", "Segoe UI", system-ui, sans-serif;
--font-display: Arial, "Microsoft YaHei", "PingFang SC", "Source Han Sans SC", "Noto Sans SC", sans-serif;
--font-mono: "Cascadia Mono", "Consolas", "SFMono-Regular", "Courier New", monospace;
--transition-fast: 140ms cubic-bezier(0.2, 0.8, 0.2, 1);
--transition-base: 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
/* legacy aliases retained for existing screens */
--primary-color: var(--primary);
--primary-hover: var(--primary-light);
--secondary-color: var(--text-secondary);
--success-color: var(--success);
--warning-color: var(--warning);
--danger-color: var(--danger);
--bg-color: var(--bg-primary);
--card-bg: var(--surface);
--border-color: var(--border);
--text-primary-legacy: var(--text-primary);
--text-secondary-legacy: var(--text-secondary);
--sidebar-width: 264px;
--sidebar-collapsed-width: 76px;
}
[data-theme="light"] {
--bg-primary: #eaf2f6;
--bg-secondary: #dceaf1;
--bg-elevated: #f6fafc;
--surface: #f6fafc;
--surface-raised: #ffffff;
--surface-hover: #e6f2f8;
--surface-deep: #d9e8f0;
--surface-alt: #edf5f8;
--border-light: rgba(13, 44, 64, 0.12);
--border: rgba(13, 44, 64, 0.24);
--border-strong: rgba(13, 44, 64, 0.42);
--line: rgba(13, 44, 64, 0.16);
--text-primary: #102536;
--text-secondary: #345267;
--text-tertiary: #607b8c;
--text-disabled: #93a9b5;
--shadow-sm: 0 2px 8px rgba(19, 54, 75, 0.13);
--shadow-md: 0 10px 26px rgba(19, 54, 75, 0.16);
--shadow-lg: 0 22px 52px rgba(19, 54, 75, 0.2);
/* 日间配色字体栈:浅底优先字形利落的苹方 / 思源黑体,英文仍为 Arial */
--font-body: Arial, "PingFang SC", "Hiragino Sans GB", "Source Han Sans SC", "Noto Sans SC", "Microsoft YaHei", system-ui, sans-serif;
--font-display: Arial, "PingFang SC", "Source Han Sans SC", "Noto Sans SC", "Microsoft YaHei", sans-serif;
}
/* 浅色底用次像素渲染更锐利;深色底由 body 默认的 antialiased 处理 */
[data-theme="light"] body { -webkit-font-smoothing: auto; }
* { box-sizing: border-box; }
html, body, #root { min-height: 100%; }
body {
margin: 0;
font-family: var(--font-body);
background: var(--bg-primary);
color: var(--text-primary);
line-height: 1.55;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
button, input, select, textarea { font: inherit; }
button { color: inherit; }
button:focus-visible, a:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible {
outline: 2px solid var(--primary-light);
outline-offset: 2px;
}
a { color: inherit; }
/* 通用控件 */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 36px;
padding: 8px 14px;
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--surface);
color: var(--text-primary);
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: transform var(--transition-fast), background var(--transition-fast), border-color var(--transition-fast), box-shadow var(--transition-fast);
}
.btn:hover { background: var(--surface-hover); border-color: var(--border-strong); box-shadow: var(--shadow-sm); transform: translateY(-1px); }
.btn:active { transform: translateY(0); box-shadow: var(--shadow-inset); }
.btn-primary { background: var(--primary); border-color: var(--primary); color: #031521; box-shadow: 0 4px 12px rgba(42, 167, 220, 0.22); }
.btn-primary:hover { background: var(--primary-light); border-color: var(--primary-light); color: #062033; }
.btn-secondary { background: var(--surface-raised); }
.btn-danger { color: var(--danger); }
.btn-danger:hover { background: var(--danger-bg); border-color: var(--danger-border); }
.btn-sm { min-height: 30px; padding: 5px 10px; font-size: 12px; }
.btn-icon { min-width: 34px; padding: 7px; }
.btn-link { border: 0; background: transparent; color: var(--primary-light); font-weight: 600; cursor: pointer; padding: 5px 0; }
.btn-link:hover { color: var(--text-primary); }
input[type="text"], input[type="search"], input[type="number"], input[type="email"], input[type="tel"], input[type="url"], input[type="password"], select, textarea, .form-input, .form-select {
width: 100%;
min-height: 38px;
padding: 9px 11px;
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--surface-deep);
color: var(--text-primary);
transition: border-color var(--transition-fast), box-shadow var(--transition-fast), background var(--transition-fast);
}
input::placeholder, textarea::placeholder { color: var(--text-tertiary); }
input:focus, select:focus, textarea:focus, .form-input:focus, .form-select:focus {
outline: none;
border-color: var(--primary);
background: var(--surface);
box-shadow: 0 0 0 3px rgba(42, 167, 220, 0.14);
}
/* 标签、状态、表格 */
.badge, .status-badge, .priority-badge {
display: inline-flex;
align-items: center;
min-height: 24px;
padding: 2px 8px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
font-size: 12px;
font-weight: 700;
white-space: nowrap;
}
.badge-primary, .status-active, .status-confirmed { background: var(--primary-bg); color: var(--primary-light); border-color: var(--primary-border); }
.badge-success, .status-completed { background: var(--success-bg); color: var(--success); border-color: var(--success-border); }
.badge-warning, .status-production, .priority-medium { background: var(--warning-bg); color: var(--warning); border-color: var(--warning-border); }
.badge-danger, .status-shipped, .priority-high { background: var(--danger-bg); color: var(--danger); border-color: var(--danger-border); }
.badge-neutral, .status-draft, .priority-low { background: var(--surface-alt); color: var(--text-secondary); }
.data-table { width: 100%; border-collapse: separate; border-spacing: 0; font-size: 13px; }
.data-table thead th { position: sticky; top: 0; z-index: 1; padding: 11px 14px; background: var(--surface-raised); color: var(--text-secondary); border-bottom: 1px solid var(--border-strong); text-align: left; font-size: 11px; letter-spacing: 0.08em; text-transform: uppercase; }
.data-table tbody td { padding: 12px 14px; color: var(--text-secondary); border-bottom: 1px solid var(--border-light); vertical-align: middle; }
.data-table tbody tr:hover td { background: var(--primary-bg); color: var(--text-primary); }
.data-table tbody tr:last-child td { border-bottom: 0; }
.table-container, .table-wrap { overflow: auto; border: 1px solid var(--border); border-radius: var(--radius-md); background: var(--surface-deep); }
/* 抽屉、弹窗、提示 */
.modal-overlay, .settings-overlay, .drawer-backdrop { position: fixed; inset: 0; z-index: 40; display: flex; align-items: center; justify-content: center; background: rgba(2, 11, 18, 0.78); backdrop-filter: blur(5px); }
.modal-content, .settings-modal, .drawer { background: var(--surface); border: 1px solid var(--border-strong); box-shadow: var(--shadow-lg); }
.modal-content { width: min(720px, calc(100vw - 32px)); max-height: calc(100vh - 32px); overflow: auto; border-radius: var(--radius-lg); }
.modal-header, .drawer-header, .settings-header { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 18px 22px; border-bottom: 1px solid var(--border); }
.modal-header h2, .drawer-title { margin: 0; font-family: var(--font-display); font-size: 18px; }
.modal-close, .close-btn { border: 0; background: transparent; color: var(--text-tertiary); cursor: pointer; font-size: 24px; line-height: 1; padding: 2px 6px; }
.modal-close:hover, .close-btn:hover { color: var(--text-primary); background: var(--surface-hover); }
.modal-content form, .modal-footer { padding: 18px 22px; }
.modal-footer, .drawer-footer { display: flex; justify-content: flex-end; gap: 10px; border-top: 1px solid var(--border); }
.drawer-backdrop { display: block; }
.drawer { position: fixed; top: 0; right: 0; bottom: 0; z-index: 41; width: min(620px, 92vw); display: flex; flex-direction: column; border-radius: var(--radius-lg) 0 0 var(--radius-lg); animation: slideIn 240ms var(--transition-base); }
.drawer-body { flex: 1; overflow: auto; padding: 22px; }
.drawer-footer { padding: 14px 22px; }
@keyframes slideIn { from { transform: translateX(100%); } to { transform: translateX(0); } }
.toast { position: fixed; right: 22px; bottom: 22px; z-index: 50; max-width: 400px; padding: 13px 16px; border: 1px solid var(--border); border-left: 3px solid var(--primary); border-radius: var(--radius-md); background: var(--surface); box-shadow: var(--shadow-lg); }
.toast-success { border-left-color: var(--success); } .toast-warning { border-left-color: var(--warning); } .toast-error { border-left-color: var(--danger); }
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; }
}
+354
View File
@@ -0,0 +1,354 @@
/* Dark Luxury / steel-blue edition. Loaded after the legacy layout.
Keep data dense and legible; use the reference's soft panels and subtle light,
not landing-page marquees, animated counters, or decorative fake status data. */
:root {
color-scheme: dark;
--bg-primary: #0c1018;
--bg-secondary: #101620;
--surface: #161d29;
--surface-raised: #1b2534;
--surface-deep: #101721;
--surface-alt: #141d2a;
--surface-hover: #2b3e59;
--text-primary: #edf2fc;
--text-secondary: #bbc8dc;
--text-tertiary: #96a7bf;
--primary: #84b5ff;
--primary-dark: #6297ea;
--primary-light: #aecfff;
--primary-bg: #26466b;
--primary-border: #638ac0;
--border-light: #293447;
--border: #45546b;
--border-strong: #71829b;
--radius-sm: 6px;
--radius-md: 10px;
--radius-lg: 16px;
--radius-xl: 24px;
--accent-glow: rgba(103, 157, 237, .14);
--panel-shadow: inset 0 1px 0 rgba(218, 230, 255, .09), 0 12px 30px rgba(0, 0, 0, .18);
--panel-highlight: rgba(218, 230, 255, .09);
--ambient: rgba(77, 115, 174, .12);
--grain-opacity: .025;
}
[data-theme="light"] {
color-scheme: light;
--bg-primary: #f0f4fa;
--bg-secondary: #e9eff7;
--surface: #ffffff;
--surface-raised: #f4f7fc;
--surface-deep: #f1f5fa;
--surface-alt: #f7f9fd;
--surface-hover: #d3e2f6;
--text-primary: #17283f;
--text-secondary: #40546f;
--text-tertiary: #546780;
--text-disabled: #72819a;
--primary: #265fbd;
--primary-dark: #1d4d9d;
--primary-light: #265fbd;
--primary-bg: #cfe2fb;
--primary-border: #799cd0;
--border-light: #dbe3ef;
--border: #97a7bd;
--border-strong: #7085a0;
--success: #12613e;
--success-bg: #e1f4e9;
--success-border: #70a989;
--warning: #805000;
--warning-bg: #fff1d4;
--warning-border: #b89754;
--danger: #ab3038;
--danger-bg: #ffeaed;
--danger-border: #c57c86;
--info: #265fbd;
--info-bg: #e8f0ff;
--info-border: #799cd0;
--accent-glow: rgba(57, 105, 181, .1);
--panel-shadow: inset 0 1px 0 #fff, 0 8px 28px rgba(41, 69, 111, .07);
--panel-highlight: #fff;
--ambient: rgba(144, 178, 230, .13);
--grain-opacity: .016;
}
/* Fine grain is deliberately subtle and never intercepts clicks. */
body::before {
content: ""; position: fixed; inset: 0; pointer-events: none; z-index: 9999;
opacity: var(--grain-opacity); mix-blend-mode: soft-light;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
background-size: 128px;
}
body { font-size: 14px; }
.sidebar { background: var(--bg-secondary); box-shadow: none; border-color: var(--border-light); }
.sidebar::before { background: transparent; }
.sidebar-header { min-height: 92px; }
.sidebar.collapsed .sidebar-header h1 { display: none; }
.sidebar.collapsed .sidebar-header { justify-content: center; }
.nav-item { min-height: 46px; font-weight: 600; }
.nav-item.active { border-color: transparent; background: var(--primary-bg); box-shadow: inset 0 1px 0 var(--panel-highlight); }
.nav-item.active::before { display: none; }
.nav-item .icon { color: var(--text-tertiary); }
.nav-item.active .icon { color: var(--primary-light); }
.collapse-btn { border: 0; background: transparent; border-radius: 50%; color: var(--text-secondary); }
.main-content { background: radial-gradient(ellipse at 50% 100%, var(--ambient), transparent 65%), var(--bg-primary); background-size: auto; }
.page-title { font-size: clamp(24px, 2.5vw, 32px); font-weight: 700; }
.page-title::after { display: none; }
.card, .dashboard-card, .panel, .stat-card { background: var(--surface); border: 0; border-radius: var(--radius-lg); box-shadow: var(--panel-shadow); }
.stat-card { gap: 14px; }
.stat-label { color: var(--text-secondary); }
.stat-icon { color: var(--primary-light); }
.card-header { border-color: var(--border-light); }
.table-container, .table-wrap { border-color: var(--border-light); background: var(--surface); border-radius: 14px; box-shadow: var(--panel-shadow); }
.table-wrap table, .data-table { min-width: 680px; }
.data-table td.empty-state { padding: 48px 20px; text-align: center; }
.settings-btn { width: 40px; height: 40px; border-radius: 50%; border-color: var(--border-light); background: var(--surface); color: var(--text-secondary); box-shadow: inset 0 1px 0 var(--panel-highlight); }
.settings-btn svg { width: 20px; height: 20px; }
.settings-btn:hover { background: var(--primary-bg); color: var(--primary-light); border-color: var(--primary-border); }
/* Include standalone legacy buttons as well as .btn components. */
.btn, .btn-primary, .btn-secondary {
display: inline-flex; align-items: center; justify-content: center; gap: 8px;
min-height: 38px; padding: 9px 15px; border: 1px solid var(--border);
border-radius: 10px; font: 600 13px var(--font-body); cursor: pointer;
transition: background 150ms ease, box-shadow 150ms ease, transform 150ms ease;
}
.btn-secondary { color: var(--text-primary); background: var(--surface); box-shadow: none; }
.btn-primary { background: var(--surface-deep); border-color: var(--primary-border); color: var(--primary-light); box-shadow: 0 0 2px var(--accent-glow), 0 3px 16px var(--accent-glow), inset 0 1px 0 var(--panel-highlight); }
.btn-primary:hover { background: var(--primary-bg); color: var(--text-primary); border-color: var(--primary); box-shadow: 0 0 4px var(--accent-glow), 0 3px 24px var(--accent-glow); }
.btn-secondary:hover { background: var(--surface-hover); }
.btn-sm { min-height: 32px; padding: 5px 10px; }
.modal-content, .drawer { border-color: var(--border-light); border-radius: 22px; }
.drawer { border-radius: 22px 0 0 22px; }
input:focus-visible, select:focus-visible, textarea:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; }
/* Settings shell: a single rounded, softly illuminated surface. */
.settings-overlay { padding: 24px; background: rgba(4, 9, 18, .64); backdrop-filter: blur(9px); }
.settings-modal.luxury-settings {
position: relative; width: min(1020px, 100%); height: min(700px, calc(100dvh - 48px));
display: flex; flex-direction: row; overflow: hidden; border: 0;
border-radius: 26px; background: var(--surface);
box-shadow: inset 0 1px 0 var(--panel-highlight), 0 35px 100px rgba(0, 0, 0, .3);
animation: settings-enter 220ms ease-out;
}
@keyframes settings-enter { from { opacity: 0; transform: translateY(8px) scale(.99); } to { opacity: 1; transform: none; } }
.luxury-settings .settings-sidebar { width: 224px; flex: 0 0 224px; padding-bottom: 24px; background: var(--bg-secondary); border-color: var(--border-light); }
.luxury-settings .settings-header { padding: 32px 22px 26px; display: block; border: 0; }
.luxury-settings .settings-header h2 { font-size: 22px; letter-spacing: -.03em; }
.section-label { display: block; color: var(--text-tertiary); font: 10px var(--font-mono); letter-spacing: .12em; margin-bottom: 12px; }
.luxury-settings .settings-header .close-btn { position: absolute; right: 18px; top: 18px; z-index: 2; width: 34px; height: 34px; display: grid; place-items: center; border-radius: 50%; background: var(--surface-deep); color: var(--text-secondary); }
.luxury-settings .settings-header .close-btn:hover { background: var(--surface-hover); }
.luxury-settings .settings-nav { padding: 8px 14px; }
.luxury-settings .settings-nav-item { display: flex; align-items: center; gap: 10px; min-height: 44px; border: 0; border-radius: 11px; padding: 12px; margin-bottom: 6px; font-size: 13px; }
.luxury-settings .settings-nav-item.active { color: var(--primary-light); background: var(--primary-bg); box-shadow: inset 0 1px 0 var(--panel-highlight); }
.settings-nav-icon { display: flex; align-items: center; flex: 0 0 18px; }
.settings-sidebar-status { display: flex; align-items: flex-start; gap: 9px; margin: auto 22px 0; color: var(--text-tertiary); font-size: 11px; }
.settings-sidebar-status small { display: block; margin-top: 7px; font: 9px var(--font-mono); letter-spacing: .06em; }
.luxury-settings .settings-content { min-width: 0; padding: 42px 38px 30px; background: radial-gradient(ellipse at 50% 100%, var(--ambient), transparent 65%); }
.luxury-settings .settings-section h3 { font-size: 28px; font-weight: 700; letter-spacing: -.04em; }
.luxury-settings .settings-description { color: var(--text-secondary); font-size: 13px; line-height: 1.8; margin-bottom: 28px; }
.luxury-settings .settings-group { margin: 0 0 22px; padding: 20px; border: 0; border-radius: 16px; background: var(--surface-deep); box-shadow: inset 0 1px 0 var(--panel-highlight); }
.luxury-settings .settings-group h4 { margin: 0 0 12px; font-size: 13px; color: var(--text-primary); }
.theme-selector { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px; margin-bottom: 26px; }
.theme-option { position: relative; padding: 10px 10px 15px; min-width: 0; text-align: left; color: var(--text-primary); background: var(--surface-deep); border: 1px solid var(--border); border-radius: 17px; cursor: pointer; transition: box-shadow 180ms, border-color 180ms; }
.theme-option.active { border-color: var(--primary); box-shadow: 0 0 0 1px var(--primary), 0 5px 22px var(--accent-glow); }
.theme-option:hover { border-color: var(--primary); }
.theme-option > span:not(.theme-preview):not(.theme-check) { display: block; margin: 13px 8px 0; }
.theme-option strong, .theme-option small { display: block; }
.theme-option strong { font-size: 13px; }
.theme-option small { color: var(--text-tertiary); font-size: 11px; margin-top: 5px; }
.theme-check { position: absolute; bottom: 27px; right: 15px; display: grid; place-items: center; width: 20px; height: 20px; border: 1px solid var(--border); border-radius: 50%; }
.theme-option.active .theme-check { color: var(--surface-deep); background: var(--primary); border-color: var(--primary); }
/* Miniature previews deliberately retain their own colors across both themes. */
.theme-preview { display: flex; height: 120px; overflow: hidden; border-radius: 10px; }
.theme-preview-dark { --mini-bg: #0c1018; --mini-panel: #1b273a; --mini-line: #3e5677; --mini-accent: #aecfff; background: var(--mini-bg); }
.theme-preview-light { --mini-bg: #edf2fa; --mini-panel: #fff; --mini-line: #c5d3e9; --mini-accent: #265fbd; background: var(--mini-bg); }
.mini-sidebar { flex: 0 0 22%; padding: 18px 8px; background: var(--mini-panel); }
.mini-sidebar i { display: block; height: 4px; margin-bottom: 10px; border-radius: 2px; background: var(--mini-line); }
.mini-workspace { flex: 1; padding: 12px; color: var(--mini-accent); }
.mini-stats { display: flex; gap: 5px; margin: 8px 0; }
.mini-stats i { flex: 1; height: 20px; border-radius: 4px; background: var(--mini-panel); box-shadow: inset 0 1px 0 var(--mini-line); }
.mini-table { display: block; padding: 5px 8px; border-radius: 4px; background: var(--mini-panel); }
.mini-table i { display: block; height: 2px; margin: 4px 0; background: var(--mini-line); }
.setting-toggle, .setting-info { position: relative; display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 13px 0; border-top: 1px solid var(--border-light); }
.setting-toggle { cursor: pointer; }
.setting-toggle strong, .setting-info strong { display: block; font-size: 13px; color: var(--text-primary); font-weight: 500; }
.setting-toggle small, .setting-info small { display: block; color: var(--text-tertiary); font-size: 12px; line-height: 1.7; margin-top: 3px; }
.preference-badge { flex-shrink: 0; font-size: 10px; color: var(--text-secondary); background: var(--surface-raised); border-radius: 20px; padding: 4px 8px; }
.setting-toggle input { position: absolute; width: 42px; height: 24px; right: 0; opacity: 0; }
.setting-toggle i { position: relative; pointer-events: none; width: 42px; height: 24px; flex: 0 0 42px; border: 1px solid var(--border-strong); border-radius: 99px; background: var(--surface-hover); transition: background 150ms; }
.setting-toggle i::after { content: ""; position: absolute; width: 16px; height: 16px; left: 3px; top: 3px; border-radius: 50%; background: var(--text-secondary); transition: transform 150ms; }
.setting-toggle input:checked + i { background: var(--primary); border-color: var(--primary); }
.setting-toggle input:checked + i::after { transform: translateX(18px); background: var(--surface); }
.setting-toggle input:focus-visible + i { outline: 2px solid var(--primary); outline-offset: 3px; }
.appearance-note { display: flex; align-items: center; gap: 7px; font-size: 11px; color: var(--text-tertiary); margin: 0; }
.file-label input[type="file"] { display: none; }
@media (max-width: 1100px) {
.stats-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
}
@media (max-width: 760px) {
.settings-overlay { padding: 12px; }
.settings-modal.luxury-settings { height: calc(100dvh - 24px); flex-direction: column; border-radius: 22px; }
.luxury-settings .settings-sidebar { width: auto; flex: 0 0 auto; padding: 0; border: 0; }
.luxury-settings .settings-header { padding: 20px 22px 12px; }
.luxury-settings .settings-header .section-label { display: none; }
.luxury-settings .settings-nav { display: flex; gap: 6px; padding: 0 16px 12px; overflow-x: auto; }
.luxury-settings .settings-nav-item { white-space: nowrap; width: auto; flex: 1 0 auto; margin: 0; padding: 10px; }
.settings-sidebar-status { display: none; }
.luxury-settings .settings-content { padding: 24px 22px; }
.theme-selector { gap: 12px; }
.theme-preview { height: 96px; }
}
@media (max-width: 620px) {
.page-header { flex-wrap: wrap; align-items: center; }
.stats-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.stat-card { gap: 8px; padding: 12px; }
.stat-icon { flex-basis: 22px; }
.stat-icon svg { width: 22px; height: 22px; }
.stat-label { font-size: 11px; }
.toolbar-search { min-width: 0; flex-basis: 100%; }
.settings-nav-item .settings-nav-icon { display: none; }
.luxury-settings .settings-group { padding: 16px; }
.theme-option { padding: 8px 8px 12px; }
.theme-option > span:not(.theme-preview):not(.theme-check) { margin-left: 0; }
.theme-check { right: 10px; bottom: 14px; width: 16px; height: 16px; }
.theme-option small { font-size: 10px; }
.setting-info { flex-wrap: wrap; gap: 6px; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { animation-duration: .01ms !important; transition-duration: .01ms !important; scroll-behavior: auto !important; }
}
[data-motion="reduced"] *, [data-motion="reduced"] *::before, [data-motion="reduced"] *::after { animation-duration: .01ms !important; transition-duration: .01ms !important; scroll-behavior: auto !important; }
/* ============================================================
Global header + calendar dashboard + AI assistant
============================================================ */
.app-container { display: flex; min-height: 100vh; }
.main-content { min-height: 100vh; overflow: hidden; padding: 0; }
.route-content { min-height: calc(100vh - 72px); overflow-y: auto; padding: 32px clamp(18px, 3.6vw, 52px) 56px; }
.global-header { position: sticky; top: 0; z-index: 25; display: flex; align-items: center; gap: 22px; min-height: 72px; padding: 0 clamp(18px, 3.6vw, 52px); background: color-mix(in srgb, var(--bg-primary) 90%, transparent); border-bottom: 1px solid var(--border-light); backdrop-filter: blur(18px) saturate(1.25); }
.global-header-brand { display: flex; align-items: center; gap: 10px; min-width: 238px; }
.global-header-brand strong, .global-header-brand span { display: block; }
.global-header-brand strong { font-size: 13px; letter-spacing: .02em; }
.global-header-brand span:not(.header-status-dot) { color: var(--text-tertiary); font: 9px var(--font-mono); letter-spacing: .12em; margin-top: 3px; }
.header-status-dot { width: 8px; height: 8px; flex: 0 0 8px; border-radius: 50%; background: var(--success); box-shadow: 0 0 10px var(--success); }
.global-search { display: flex; align-items: center; gap: 9px; width: min(420px, 35vw); min-height: 38px; padding: 0 11px; color: var(--text-tertiary); background: var(--surface-deep); border: 1px solid var(--border-light); border-radius: 11px; transition: border-color 150ms, box-shadow 150ms; }
.global-search:focus-within { border-color: var(--primary-border); box-shadow: 0 0 0 3px var(--accent-glow); }
.global-search input { min-height: 0; padding: 0; border: 0; background: transparent; box-shadow: none; color: var(--text-primary); }
.global-search input:focus { box-shadow: none; }
.global-search kbd { margin-left: auto; padding: 2px 5px; color: var(--text-tertiary); background: var(--surface); border: 1px solid var(--border-light); border-radius: 5px; font: 10px var(--font-mono); }
.global-header-actions { display: flex; align-items: center; gap: 9px; margin-left: auto; }
.header-clock { display: flex; align-items: center; gap: 8px; margin-right: 8px; color: var(--text-tertiary); font: 11px var(--font-mono); white-space: nowrap; }
.header-clock b { color: var(--text-primary); font-size: 13px; font-weight: 500; }
.quick-create-btn, .ai-entry-btn, .mobile-search-btn { display: inline-flex; align-items: center; gap: 7px; min-height: 36px; padding: 8px 11px; border: 1px solid transparent; border-radius: 10px; background: transparent; color: var(--text-secondary); cursor: pointer; font-size: 12px; transition: background 150ms, border-color 150ms, color 150ms; }
.quick-create-btn:hover, .mobile-search-btn:hover { color: var(--text-primary); background: var(--surface-hover); border-color: var(--border-light); }
.ai-entry-btn { color: var(--primary-light); background: var(--primary-bg); border-color: var(--primary-border); box-shadow: 0 0 18px var(--accent-glow); }
.ai-entry-btn:hover { color: var(--text-primary); background: var(--surface-hover); }
.ai-entry-btn i { padding: 1px 4px; border-radius: 4px; background: var(--primary); color: var(--surface-deep); font: 9px var(--font-mono); font-style: normal; }
.header-avatar { width: 34px; height: 34px; border: 1px solid var(--primary-border); border-radius: 50%; background: linear-gradient(135deg, var(--primary), var(--surface-raised)); color: var(--surface-deep); cursor: pointer; font-weight: 800; }
.mobile-search-btn { display: none; }
.dashboard-container { width: min(1480px, 100%); margin: 0 auto; }
.dashboard-welcome { display: flex; align-items: flex-end; justify-content: space-between; gap: 18px; margin-bottom: 22px; }
.dashboard-welcome .page-title { margin: 0; }
.dashboard-welcome p { margin: 7px 0 0; color: var(--text-secondary); font-size: 13px; }
.dashboard-date-chip { display: grid; gap: 5px; min-width: 150px; padding: 12px 14px; border-radius: 12px; background: var(--surface); box-shadow: var(--panel-shadow); text-align: right; }
.dashboard-date-chip span { color: var(--text-tertiary); font: 10px var(--font-mono); letter-spacing: .08em; }
.dashboard-date-chip strong { color: var(--text-primary); font-size: 13px; font-weight: 600; }
.dashboard-panel { margin-bottom: 18px; padding: 20px; border-radius: 16px; background: var(--surface); box-shadow: var(--panel-shadow); }
.dashboard-panel-header { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 16px; }
.dashboard-panel-header h2 { display: flex; align-items: center; gap: 8px; margin: 0; color: var(--text-primary); font-size: 15px; }
.dashboard-panel-header .section-label { margin-bottom: 7px; }
.calendar-board { overflow: hidden; padding: 0; }
.calendar-board-header { padding: 20px 22px 16px; margin: 0; border-bottom: 1px solid var(--border-light); }
.calendar-toolbar { display: flex; align-items: center; gap: 8px; color: var(--text-secondary); }
.calendar-toolbar button { display: inline-grid; place-items: center; min-width: 30px; height: 30px; border: 1px solid transparent; border-radius: 8px; background: transparent; color: var(--text-secondary); cursor: pointer; }
.calendar-toolbar button:hover { background: var(--surface-hover); border-color: var(--border-light); color: var(--text-primary); }
.calendar-toolbar strong { min-width: 94px; color: var(--text-primary); text-align: center; font-size: 13px; }
.calendar-toolbar .calendar-today-btn, .calendar-toolbar .calendar-view-btn { padding: 0 9px; border-color: var(--border-light); background: var(--surface-deep); font-size: 11px; }
.calendar-week-row, .calendar-grid { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); }
.calendar-week-row { background: var(--surface-deep); border-bottom: 1px solid var(--border-light); }
.calendar-week-row > div { padding: 10px 12px; color: var(--text-tertiary); font: 10px var(--font-mono); letter-spacing: .08em; }
.calendar-day { min-height: 92px; padding: 8px 9px; border-right: 1px solid var(--border-light); border-bottom: 1px solid var(--border-light); background: var(--surface); }
.calendar-day:nth-child(7n) { border-right: 0; }
.calendar-day.is-outside { background: var(--surface-deep); opacity: .58; }
.calendar-day.is-today { background: linear-gradient(180deg, var(--primary-bg), var(--surface)); }
.calendar-day-number { display: inline-grid; place-items: center; min-width: 22px; height: 22px; color: var(--text-secondary); font: 11px var(--font-mono); }
.calendar-day.is-today .calendar-day-number { border-radius: 50%; background: var(--primary); color: var(--surface-deep); font-weight: 800; }
.calendar-events { display: grid; gap: 4px; margin-top: 5px; }
.calendar-event { display: block; overflow: hidden; padding: 3px 5px; border-left: 2px solid var(--primary); border-radius: 3px; background: var(--primary-bg); color: var(--text-secondary); font-size: 10px; line-height: 1.35; text-overflow: ellipsis; white-space: nowrap; }
.calendar-event.danger { border-color: var(--danger); background: var(--danger-bg); color: var(--danger); }
.calendar-event.holiday { border-color: var(--success); background: var(--success-bg); color: var(--success); }
.dashboard-content-grid { display: grid; grid-template-columns: minmax(0, 1.52fr) minmax(280px, .72fr); gap: 18px; align-items: start; }
.dashboard-primary-column, .dashboard-secondary-column { min-width: 0; }
.ai-digest-panel { background: linear-gradient(135deg, color-mix(in srgb, var(--primary-bg) 82%, var(--surface)), var(--surface)); }
.ai-digest-body { padding: 16px 18px; border-radius: 12px; background: color-mix(in srgb, var(--surface) 70%, transparent); box-shadow: inset 0 1px 0 var(--panel-highlight); }
.ai-digest-body strong { color: var(--text-primary); font-size: 14px; }
.ai-digest-body p { margin: 8px 0 12px; color: var(--text-secondary); line-height: 1.75; }
.ai-digest-body b { color: var(--primary-light); }
.digest-tags { display: flex; flex-wrap: wrap; gap: 7px; }
.digest-tags span, .panel-period { padding: 4px 8px; border-radius: 99px; background: var(--surface-raised); color: var(--text-tertiary); font: 10px var(--font-mono); }
.spark-mark { color: var(--primary-light); }
.panel-live-status { display: inline-flex; align-items: center; gap: 6px; color: var(--success); font: 10px var(--font-mono); }
.panel-live-status i, .dot { display: inline-block; width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
.panel-link { border: 0; background: transparent; color: var(--primary-light); cursor: pointer; font-size: 12px; }
.dashboard-empty { padding: 26px 8px; color: var(--text-tertiary); text-align: center; }
.stat-rail .stat-line { display: grid; grid-template-columns: 26px 1fr auto; align-items: center; gap: 8px; padding: 13px 0; border-top: 1px solid var(--border-light); color: var(--text-secondary); font-size: 12px; }
.stat-line-icon { display: grid; place-items: center; color: var(--primary-light); }
.stat-line strong { color: var(--text-primary); font: 700 19px var(--font-mono); }
.completion-panel { text-align: center; }
.completion-panel .dashboard-panel-header { text-align: left; }
.completion-ring { display: grid; place-items: center; width: 142px; height: 142px; margin: 18px auto; border-radius: 50%; background: conic-gradient(var(--primary) var(--progress), var(--surface-deep) 0); }
.completion-ring::before { content: ""; position: absolute; width: 106px; height: 106px; border-radius: 50%; background: var(--surface); }
.completion-ring > div { position: relative; display: grid; gap: 3px; }
.completion-ring strong { color: var(--text-primary); font: 700 27px var(--font-mono); }
.completion-ring span { color: var(--text-tertiary); font-size: 11px; }
.completion-legend { display: flex; justify-content: center; gap: 16px; color: var(--text-tertiary); font-size: 11px; }
.completion-legend span { display: flex; align-items: center; gap: 6px; }
.completion-legend .done { color: var(--primary); }.completion-legend .pending { color: var(--text-disabled); }
.quick-panel > button { display: flex; align-items: center; gap: 8px; width: 100%; padding: 11px 0; border: 0; border-top: 1px solid var(--border-light); background: transparent; color: var(--text-secondary); cursor: pointer; text-align: left; font-size: 12px; }.quick-panel > button:hover { color: var(--primary-light); }
/* AI assistant drawer */
.assistant-layer { position: fixed; inset: 0; z-index: 80; background: rgba(3, 8, 15, .46); backdrop-filter: blur(5px); }
.ai-assistant-panel { position: absolute; top: 14px; right: 14px; bottom: 14px; display: flex; flex-direction: column; width: min(430px, calc(100vw - 28px)); overflow: hidden; border: 1px solid var(--border-light); border-radius: 22px; background: var(--surface); box-shadow: 0 28px 80px rgba(0,0,0,.36), inset 0 1px 0 var(--panel-highlight); animation: assistant-in 220ms var(--ease-out); }
@keyframes assistant-in { from { opacity: 0; transform: translateX(22px); } to { opacity: 1; transform: translateX(0); } }
.ai-panel-header { display: flex; align-items: center; justify-content: space-between; padding: 20px 20px 14px; }
.ai-panel-title { display: flex; align-items: center; gap: 11px; }.ai-panel-title h2 { margin: 0; font-size: 21px; }.ai-panel-title .section-label { margin-bottom: 4px; }
.ai-orb { display: grid; place-items: center; width: 42px; height: 42px; border-radius: 15px; color: var(--primary-light); background: var(--primary-bg); box-shadow: 0 0 20px var(--accent-glow); }
.ai-close-btn { display: grid; place-items: center; width: 32px; height: 32px; border: 0; border-radius: 50%; background: var(--surface-deep); color: var(--text-secondary); cursor: pointer; }.ai-close-btn:hover { background: var(--surface-hover); color: var(--text-primary); }
.ai-access-banner { display: flex; align-items: center; gap: 10px; margin: 0 20px 14px; padding: 10px 12px; border: 1px solid var(--primary-border); border-radius: 12px; background: var(--primary-bg); }.ai-access-banner strong, .ai-access-banner small { display: block; }.ai-access-banner strong { color: var(--text-primary); font-size: 12px; }.ai-access-banner small { margin-top: 3px; color: var(--text-secondary); font-size: 10px; }.ai-live-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--success); box-shadow: 0 0 8px var(--success); }
.ai-panel-body { flex: 1; overflow-y: auto; padding: 0 20px 16px; }.ai-capability-card { padding: 13px; border-radius: 14px; background: var(--surface-deep); box-shadow: inset 0 1px 0 var(--panel-highlight); }.ai-capability-heading { display: flex; align-items: center; gap: 6px; margin-bottom: 10px; color: var(--primary-light); font-size: 12px; font-weight: 700; }.ai-capability-row { display: flex; gap: 7px; padding: 4px 0; color: var(--text-secondary); font-size: 11px; }.ai-capability-row span { color: var(--success); }
.ai-context-strip { display: flex; flex-wrap: wrap; gap: 7px; margin: 12px 0; color: var(--text-tertiary); font-size: 10px; }.ai-context-strip span { margin-right: auto; }.ai-context-strip b { padding: 3px 6px; border-radius: 5px; color: var(--text-secondary); background: var(--surface-deep); font-weight: 500; }
.ai-message-list { display: grid; gap: 13px; margin-top: 14px; }.ai-message { display: flex; align-items: flex-start; gap: 8px; }.ai-message.user { flex-direction: row-reverse; }.ai-message-avatar { display: grid; place-items: center; width: 25px; height: 25px; flex: 0 0 25px; border-radius: 9px; background: var(--surface-deep); color: var(--primary-light); font-size: 10px; font-weight: 800; }.ai-message.user .ai-message-avatar { color: var(--surface-deep); background: var(--primary); }.ai-message-content { max-width: 82%; padding: 9px 11px; border-radius: 4px 13px 13px 13px; background: var(--surface-deep); }.ai-message.user .ai-message-content { border-radius: 13px 4px 13px 13px; background: var(--primary-bg); }.ai-message-content p { margin: 0; color: var(--text-secondary); font-size: 12px; line-height: 1.7; }.ai-message-content small { display: block; margin-top: 5px; color: var(--text-tertiary); font: 9px var(--font-mono); }.ai-thinking { display: flex; gap: 4px; align-items: center; min-height: 34px; }.ai-thinking span { width: 5px; height: 5px; border-radius: 50%; background: var(--primary); animation: ai-bounce 1s infinite ease-in-out; }.ai-thinking span:nth-child(2) { animation-delay: .15s; }.ai-thinking span:nth-child(3) { animation-delay: .3s; }@keyframes ai-bounce { 0%,80%,100% { transform: translateY(0); opacity:.45; } 40% { transform: translateY(-4px); opacity:1; } }
.ai-suggestion-row { display: flex; gap: 7px; padding: 8px 20px; overflow-x: auto; }.ai-suggestion-row button { flex: 0 0 auto; padding: 6px 9px; border: 1px solid var(--border-light); border-radius: 99px; background: var(--surface-deep); color: var(--text-secondary); cursor: pointer; font-size: 10px; }.ai-suggestion-row button:hover { color: var(--primary-light); border-color: var(--primary-border); }
.ai-input-row { display: flex; gap: 8px; padding: 10px 16px; border-top: 1px solid var(--border-light); }.ai-input-row input { min-height: 40px; background: var(--surface-deep); }.ai-input-row button { display: grid; place-items: center; width: 40px; min-width: 40px; border: 0; border-radius: 11px; background: var(--primary); color: var(--surface-deep); cursor: pointer; }.ai-input-row button:disabled { cursor: not-allowed; opacity: .4; }.ai-panel-footer { padding: 8px 20px 13px; color: var(--text-tertiary); font-size: 10px; text-align: center; }
@media (max-width: 980px) { .global-header-brand { min-width: auto; }.global-header-brand div { display: none; }.global-search { width: min(360px, 40vw); }.header-clock span { display: none; }.dashboard-content-grid { grid-template-columns: 1fr; }.dashboard-secondary-column { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 18px; }.dashboard-secondary-column .dashboard-panel { margin-bottom: 0; } }
@media (max-width: 680px) { .global-header { gap: 8px; min-height: 62px; padding: 0 12px; }.global-search { display: none; }.global-search.is-open { position: absolute; left: 12px; right: 12px; top: 65px; display: flex; width: auto; background: var(--surface); }.mobile-search-btn { display: inline-flex; }.quick-create-btn span, .ai-entry-btn span, .header-clock { display: none; }.ai-entry-btn { padding: 8px; }.route-content { min-height: calc(100vh - 62px); padding: 22px 12px 42px; }.dashboard-welcome { align-items: flex-start; flex-direction: column; }.dashboard-date-chip { text-align: left; }.calendar-day { min-height: 68px; padding: 5px 4px; }.calendar-week-row > div { padding: 8px 4px; }.calendar-event { font-size: 8px; }.calendar-day-number { font-size: 10px; }.dashboard-secondary-column { display: block; }.dashboard-secondary-column .dashboard-panel { margin-bottom: 18px; }.ai-assistant-panel { top: 8px; right: 8px; bottom: 8px; width: calc(100vw - 16px); } }
/* Requested compact navigation controls. */
.collapse-btn { display: grid; place-items: center; width: 32px; height: 32px; padding: 0; border: 1px solid var(--border-strong); border-radius: 8px; background: var(--surface-deep); color: var(--text-secondary); box-shadow: inset 0 1px 0 var(--panel-highlight); }
.collapse-btn:hover { color: var(--primary-light); border-color: var(--primary-border); background: var(--primary-bg); }
.collapse-btn svg { width: 19px; height: 19px; }
.sidebar-footer { gap: 8px; }
.theme-toggle-btn { display: grid; place-items: center; width: 40px; height: 40px; margin-left: auto; padding: 0; border: 1px solid var(--border-light); border-radius: 50%; background: var(--surface); color: var(--primary-light); box-shadow: inset 0 1px 0 var(--panel-highlight); cursor: pointer; transition: background 150ms, border-color 150ms, transform 150ms; }
.theme-toggle-btn:hover { border-color: var(--primary-border); background: var(--primary-bg); transform: translateY(-1px); }
.sidebar.collapsed .theme-toggle-btn { margin-left: 0; }
/* Search everything: results are scoped to ordinary business data only. */
.global-search { position: relative; }
.global-search-results { position: absolute; top: calc(100% + 9px); left: 0; width: min(580px, calc(100vw - 32px)); max-height: min(520px, calc(100vh - 100px)); overflow-y: auto; padding: 7px; border: 1px solid var(--border); border-radius: 14px; background: var(--surface); box-shadow: 0 20px 52px rgba(0, 0, 0, .25), inset 0 1px 0 var(--panel-highlight); }
.global-search-results button { display: grid; grid-template-columns: 58px minmax(0, 1fr); gap: 10px; width: 100%; padding: 10px; border: 0; border-radius: 10px; background: transparent; color: var(--text-primary); cursor: pointer; text-align: left; }
.global-search-results button:hover, .global-search-results button:focus-visible { background: var(--surface-hover); outline: none; }
.search-result-mark { align-self: start; padding: 3px 5px; border-radius: 5px; background: var(--surface-deep); color: var(--text-tertiary); font-size: 10px; text-align: center; }
.search-result-mark.product, .search-result-mark.customer { color: var(--primary-light); background: var(--primary-bg); }
.search-result-mark.order, .search-result-mark.deal { color: var(--warning); background: var(--warning-bg); }
.search-result-mark.todo, .search-result-mark.activity { color: var(--success); background: var(--success-bg); }
.global-search-results strong, .global-search-results small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.global-search-results strong { font-size: 12px; }
.global-search-results small { margin-top: 3px; color: var(--text-tertiary); font-size: 10px; }
.global-search-status { padding: 17px 12px; color: var(--text-tertiary); font-size: 12px; text-align: center; }
.global-search-footnote { margin: 4px 3px 1px; padding: 9px 8px 4px; border-top: 1px solid var(--border-light); color: var(--text-tertiary); font-size: 10px; }
.product-category-summary { display: flex; flex-wrap: wrap; gap: 7px; margin: -4px 0 15px; }
.product-category-summary button { padding: 5px 9px; border: 1px solid var(--border-light); border-radius: 99px; background: var(--surface); color: var(--text-secondary); cursor: pointer; font-size: 11px; }
.product-category-summary button:hover, .product-category-summary button.active { border-color: var(--primary-border); background: var(--primary-bg); color: var(--primary-light); }
@media (max-width: 680px) { .global-search-results { position: fixed; top: 70px; left: 12px; width: calc(100vw - 24px); }.sidebar-footer { flex-wrap: wrap; }.theme-toggle-btn { margin-left: 0; } }
/* Preserve the dense industrial table's column hierarchy on smaller desktops. */
.table-wrap table { min-width: 980px; }