feat: 多用户权限体系、AI 助手、SKU 目录与合同清洗(开源准备)

本地长期开发成果的整体入库快照,包含后端鉴权/权限、AI 接入、合同解析、
SKU 目录等模块,以及前端相应页面与样式改版。

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

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

开源准备
- 新增 AGPL-3.0 LICENSE
- README:移除未启用的 SQLCipher 加密声明(改为如实的「已知限制」)、
  补充环境变量说明、许可证由「私有项目」改为 AGPL-3.0
- 移除历史遗留的空文件 _index_backup.txt
- 文档中的项目路径更新为 happy-export
This commit is contained in:
Drsakura
2026-09-17 14:53:02 +08:00
parent 436519e0a6
commit 13d67c804f
77 changed files with 18407 additions and 955 deletions
+354 -118
View File
@@ -1,20 +1,14 @@
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 { BrowserRouter as Router, Routes, Route, NavLink, Navigate, useNavigate, useLocation } from 'react-router-dom'
import LoginPage from './pages/LoginPage'
import Settings from './components/Settings'
import GlobalHeader from './components/GlobalHeader'
import AIAssistant from './components/AIAssistant'
import { readPreference } from './preferences'
import { NAV_GROUPS } from './navigation'
import { AuthProvider, useAuth, hasPermission } from './auth'
import { loadBranding, useBranding, setPageTitle } from './branding'
import { NAV_GROUPS, ROUTE_PERMISSIONS, HOME_PATH, PAGE_LABELS } from './navigation'
import { getUnsavedGuard, isGuardDirty, clearUnsavedGuard } from './unsavedGuard'
import {
DashboardIcon,
ChevronDownIcon,
@@ -24,28 +18,180 @@ import {
SidebarLayoutIcon
} from './components/Icons'
function App() {
/* ---------------------------------------------------------------------------
* 路由级代码分割
*
* 改造前:18 个页面全是静态 import,Vite 把整站打进一个 index.js(约 530 KB)。
* 打开任何一页,浏览器都得先把整站的代码下载并解析完才看得到东西 ——
* 本地感觉不出来,上云走公网就是一两秒白屏。
*
* 改成 React.lazy 后每页一个独立 chunk,首屏只加载当前页那一份。
* 下面几个刻意保持静态 import,不拆,理由各自不同:
* · LoginPage —— 未登录时它本身就是首屏,拆了反而多一次往返
* · GlobalHeader / Settings / AIAssistant —— 属于外壳,首屏就要用
* --------------------------------------------------------------------------- */
const Dashboard = React.lazy(() => import('./pages/Dashboard'))
const ProductManagement = React.lazy(() => import('./pages/ProductManagement'))
const SupplierManagement = React.lazy(() => import('./pages/SupplierManagement'))
const CustomerManagement = React.lazy(() => import('./pages/CustomerManagement'))
const CustomerDetail = React.lazy(() => import('./pages/CustomerDetail'))
const InquiryManagement = React.lazy(() => import('./pages/InquiryManagement'))
const InquiryDetail = React.lazy(() => import('./pages/InquiryDetail'))
const ContractCleanup = React.lazy(() => import('./pages/ContractCleanup'))
const OrderManagement = React.lazy(() => import('./pages/OrderManagement'))
const ProspectManagement = React.lazy(() => import('./pages/ProspectManagement'))
const QuoteManagement = React.lazy(() => import('./pages/QuoteManagement'))
const QuoteEditor = React.lazy(() => import('./pages/QuoteEditor'))
const PiContractManagement = React.lazy(() => import('./pages/PiContractManagement'))
const PurchaseOrderManagement = React.lazy(() => import('./pages/PurchaseOrderManagement'))
const CustomerPool = React.lazy(() => import('./pages/CustomerPool'))
const ProfilePage = React.lazy(() => import('./pages/ProfilePage'))
const UserManagement = React.lazy(() => import('./pages/UserManagement'))
const PermissionGroups = React.lazy(() => import('./pages/PermissionGroups'))
const SystemLogs = React.lazy(() => import('./pages/SystemLogs'))
/* -------------------------------------------------------------- 启动与兜底 */
function BootScreen() {
const brand = useBranding()
return (
<div className="app-boot">
<span className="app-boot-mark" />
<p>正在载入 {brand.name}…</p>
</div>
)
}
/* 详情页路径不在导航表里,给它们一批前缀标题,免得浏览器标签只剩系统名 */
const DETAIL_TITLES = [
['/customers/', '客户详情'],
['/inquiries/', '询盘详情'],
['/products/', '产品详情'],
['/suppliers/', '供应商详情']
]
function pageTitleFor(pathname) {
if (PAGE_LABELS[pathname]) return PAGE_LABELS[pathname]
const hit = DETAIL_TITLES.find(([prefix]) => pathname.startsWith(prefix))
return hit ? hit[1] : ''
}
/** 路由懒加载时的过渡态:不白屏,给一条顶部进度线 + 一句提示 */
function RouteFallback() {
return (
<div className="route-fallback" role="status" aria-live="polite">
<div className="route-fallback-bar" />
<p>正在载入…</p>
</div>
)
}
function NoPermission() {
return (
<div className="page-container">
<div className="panel">
<div className="panel-title">无访问权限</div>
<p className="muted-note">
当前角色所属的权限组没有该模块的查看权限。请让管理员在「权限组管理」里把该角色指派到有权限的组。
</p>
</div>
</div>
)
}
/** 路由级权限守卫:与侧栏用的是同一份 ROUTE_PERMISSIONS,不会两处不一致 */
function Guarded({ permission, children }) {
const { user } = useAuth()
if (permission && !hasPermission(user, permission)) return <NoPermission />
return children
}
/* -------------------------------------------------------------- 主外壳 */
function Shell({ theme, onThemeChange }) {
const { user } = useAuth()
const navigate = useNavigate()
const location = useLocation()
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 [pendingNav, setPendingNav] = React.useState(null)
const [guardBusy, setGuardBusy] = React.useState(false)
const [reducedMotion, setReducedMotion] = React.useState(
() => readPreference('tms-motion', ['full', 'reduced'], 'full') === 'reduced'
)
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])
/* 浏览器标签标题跟着当前页走 —— 页面组件内部也会设一次(同值),
这里兜住那些没自己设标题的页面,避免标签一直停在上一页的名字。 */
React.useEffect(() => {
setPageTitle(pageTitleFor(location.pathname))
}, [location.pathname])
/* ------------------------------------------------------------------
未保存改动拦截
编辑页(个人资料)挂载时会在 unsavedGuard 里登记「我脏了吗 / 帮我存 / 帮我丢弃」。
两道防线:
1) 所有应用内跳转都走 guardedNavigate,跳之前先问一句;
2) 浏览器后退/前进这类不走我们的导航,靠监听 location 变化把它弹回来。
------------------------------------------------------------------ */
const guardedNavigate = React.useCallback((path) => {
if (!path || path === location.pathname) return
if (isGuardDirty()) { setPendingNav(path); return }
navigate(path)
}, [navigate, location.pathname])
const lastPathRef = React.useRef(location.pathname)
React.useEffect(() => {
const previous = lastPathRef.current
lastPathRef.current = location.pathname
/* 只在「刚刚离开编辑页」且「它还有未保存改动」时弹回来 */
if (previous === location.pathname || previous !== '/profile') return
if (!isGuardDirty()) return
setPendingNav(location.pathname)
navigate('/profile', { replace: true })
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [location.pathname])
React.useEffect(() => {
const handler = event => {
if (!isGuardDirty()) return
event.preventDefault()
event.returnValue = ''
}
window.addEventListener('beforeunload', handler)
return () => window.removeEventListener('beforeunload', handler)
}, [])
const resolvePending = async (action) => {
const target = pendingNav
const guard = getUnsavedGuard()
setPendingNav(null)
if (action === 'cancel') return
if (action === 'save') {
setGuardBusy(true)
try {
await guard?.save()
} catch {
setGuardBusy(false)
return
}
setGuardBusy(false)
} else {
guard?.discard()
}
clearUnsavedGuard()
if (target) navigate(target)
}
const navClass = ({ isActive }) => `nav-item${isActive ? ' active' : ''}`
/* 工作台额外带一个类,供「展开/收起导航按钮挂在它右侧」定位 */
const homeNavClass = ({ isActive }) => `nav-item nav-item-home${isActive ? ' active' : ''}`
@@ -54,113 +200,203 @@ function App() {
setCollapsedGroups(current => current.includes(id) ? current.filter(x => x !== id) : [...current, id])
}, [])
/* 侧栏只显示当前权限组可见的菜单;整组都不可见就整组隐藏 */
const visibleGroups = React.useMemo(
() => NAV_GROUPS
.map(group => ({ ...group, items: group.items.filter(item => hasPermission(user, item.permission)) }))
.filter(group => group.items.length > 0),
[user]
)
const guard = (path, element) => <Guarded permission={ROUTE_PERMISSIONS[path]}>{element}</Guarded>
return (
<Router>
<div className="app-container">
{/* 只有设置是模态;「小皮」是浮窗,不能再 inert 主界面,否则没法边聊边操作 */}
<div className="app-shell" inert={settingsOpen ? "" : undefined}>
{/* 顶栏横跨整宽,压在侧边栏之上 */}
<GlobalHeader onOpenAssistant={() => setAssistantOpen(true)} />
<div className="app-container">
{/* 只有设置是模态;「小皮」是浮窗,不能再 inert 主界面,否则没法边聊边操作 */}
<div className="app-shell" inert={settingsOpen ? "" : undefined}>
{/* 顶栏横跨整宽,压在侧边栏之上 */}
<GlobalHeader onOpenAssistant={() => setAssistantOpen(true)} onNavigate={guardedNavigate} />
<div className="app-body">
<aside className={`sidebar ${collapsed ? 'collapsed' : ''}`}>
{/* 工作台置顶;展开/收起导航按钮挂在它右侧 */}
<div className="sidebar-top">
<NavLink aria-label="工作台" title="工作台" to="/" end className={homeNavClass}>
<span className="icon"><DashboardIcon size={18} /></span>
{!collapsed && <span>工作台</span>}
</NavLink>
<button
onClick={() => setCollapsed(!collapsed)}
className="collapse-btn"
aria-label={collapsed ? '展开导航' : '收起导航'}
title={collapsed ? '展开导航' : '收起导航'}
>
<SidebarLayoutIcon size={18} />
</button>
</div>
<div className="app-body">
<aside className={`sidebar ${collapsed ? 'collapsed' : ''}`}>
{/* 工作台置顶;展开/收起导航按钮挂在它右侧 */}
<div className="sidebar-top">
<NavLink aria-label="工作台" title="工作台" to="/" end className={homeNavClass}>
<span className="icon"><DashboardIcon size={18} /></span>
{!collapsed && <span>工作台</span>}
</NavLink>
<button
onClick={() => setCollapsed(!collapsed)}
className="collapse-btn"
aria-label={collapsed ? '展开导航' : '收起导航'}
title={collapsed ? '展开导航' : '收起导航'}
>
<SidebarLayoutIcon size={18} />
</button>
</div>
<nav className="sidebar-nav">
{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>
)
})}
<nav className="sidebar-nav">
{visibleGroups.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>
)
})}
</nav>
{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}
onClick={event => { event.preventDefault(); guardedNavigate(item.to) }}
>
<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' ? <MoonStarsIcon size={20} /> : <SunIcon size={20} />}
</button>
<button className="settings-btn" onClick={() => setSettingsOpen(true)} title="设置" aria-label="设置">
<TuneIcon size={18} />
</button>
</div>
</aside>
<div className="sidebar-footer">
{/* 用户信息只在右上角 header 显示,此处不再重复;只留日夜切换与设置 */}
<button
className="theme-toggle-btn"
onClick={() => onThemeChange(theme === 'light' ? 'dark' : 'light')}
title={theme === 'light' ? '切换至夜间模式' : '切换至日间模式'}
aria-label={theme === 'light' ? '切换至夜间模式' : '切换至日间模式'}
>
{theme === 'light' ? <MoonStarsIcon size={20} /> : <SunIcon size={20} />}
</button>
<button className="settings-btn" onClick={() => setSettingsOpen(true)} title="设置" aria-label="设置">
<TuneIcon size={18} />
</button>
</div>
</aside>
<main className="main-content">
<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" theme={theme} />} />
<Route path="/customers" element={<CustomerManagement />} />
<Route path="/orders" element={<OrderManagement />} />
<Route path="/prospects" element={<ProspectManagement />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</div>
</main>
<main className="main-content">
<div className="route-content">
{/* 页面 chunk 加载期间显示过渡态,避免白屏 */}
<React.Suspense fallback={<RouteFallback />}>
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/inquiries" element={guard('/inquiries', <InquiryManagement />)} />
<Route path="/inquiries/:id" element={guard('/inquiries', <InquiryDetail />)} />
<Route path="/quotes" element={guard('/quotes', <QuoteManagement />)} />
<Route path="/quotes/new" element={guard('/quotes/new', <QuoteEditor />)} />
<Route path="/pi-contracts" element={guard('/pi-contracts', <PiContractManagement />)} />
<Route path="/purchase-orders" element={guard('/purchase-orders', <PurchaseOrderManagement />)} />
<Route path="/orders" element={guard('/orders', <OrderManagement />)} />
<Route path="/products" element={guard('/products', <ProductManagement />)} />
<Route path="/suppliers" element={guard('/suppliers', <SupplierManagement />)} />
<Route path="/contract-cleanup" element={guard('/contract-cleanup', <ContractCleanup />)} />
<Route path="/customers" element={guard('/customers', <CustomerManagement />)} />
<Route path="/customers/:id" element={guard('/customers', <CustomerDetail />)} />
<Route path="/prospects" element={guard('/prospects', <ProspectManagement />)} />
<Route path="/customer-pool" element={guard('/customer-pool', <CustomerPool />)} />
<Route path="/profile" element={<ProfilePage />} />
<Route path="/users" element={guard('/users', <UserManagement />)} />
<Route path="/permission-groups" element={guard('/permission-groups', <PermissionGroups />)} />
<Route path="/logs" element={guard('/logs', <SystemLogs />)} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</React.Suspense>
</div>
</main>
</div>
</div>
{/* 未保存改动的离开确认:保存并离开 / 放弃修改 / 取消 */}
{pendingNav && (
<div className="modal-overlay" onClick={() => resolvePending('cancel')}>
<div className="modal-content modal-content-sm" onClick={event => event.stopPropagation()}>
<div className="modal-header">
<h2>个人资料还没保存</h2>
</div>
<div className="modal-body">
<p>你修改了个人资料但还没有保存。要保存后再离开吗?</p>
<p className="muted-note">选「放弃修改」会丢掉刚才的改动,恢复成保存前的样子。</p>
</div>
<div className="modal-footer">
<button type="button" className="btn btn-ghost" onClick={() => resolvePending('cancel')}>取消</button>
<button type="button" className="btn btn-secondary" onClick={() => resolvePending('discard')}>放弃修改</button>
<button type="button" className="btn btn-primary" onClick={() => resolvePending('save')} disabled={guardBusy}>
{guardBusy ? '保存中…' : '保存并离开'}
</button>
</div>
</div>
</div>
)}
<AIAssistant isOpen={assistantOpen} onClose={closeAssistant} />
<Settings isOpen={settingsOpen} onClose={closeSettings} theme={theme} onThemeChange={setTheme} reducedMotion={reducedMotion} onReducedMotionChange={setReducedMotion} />
</div>
</Router>
<AIAssistant isOpen={assistantOpen} onClose={closeAssistant} />
<Settings
isOpen={settingsOpen}
onClose={closeSettings}
theme={theme}
onThemeChange={onThemeChange}
reducedMotion={reducedMotion}
onReducedMotionChange={setReducedMotion}
/>
</div>
)
}
/* -------------------------------------------------------------- 分流 */
function AppRoutes({ theme, onThemeChange }) {
const { user, loading } = useAuth()
if (loading) return <BootScreen />
if (!user) {
/* 未登录时任何路径都落到登录页 */
return (
<Routes>
<Route path="*" element={<LoginPage />} />
</Routes>
)
}
return <Shell theme={theme} onThemeChange={onThemeChange} />
}
function App() {
const [theme, setTheme] = React.useState(() => readPreference('tms-theme', ['dark', 'light'], 'dark'))
/* 品牌信息(系统显示名/副标题)启动就拉一次:登录页与顶栏都要用,且改完要即时生效。
它是公开接口,未登录也能拿到;失败就用兜底名,不影响进系统。 */
React.useEffect(() => { loadBranding() }, [])
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])
return (
<AuthProvider>
<Router>
<AppRoutes theme={theme} onThemeChange={setTheme} />
</Router>
</AuthProvider>
)
}
export default App
+142
View File
@@ -0,0 +1,142 @@
/**
* 金额英文大写(外贸单证用)
*
* 目标格式与信用证 / 发票上的写法一致:
* 255.36 USD → SAY US DOLLARS TWO HUNDRED AND FIFTY-FIVE AND THIRTY-SIX CENTS ONLY
* 1000 CNY → SAY CHINESE YUAN ONE THOUSAND ONLY
* 0.5 EUR → SAY EUROS ZERO AND FIFTY CENTS ONLY
*
* 几条实务约定(不是「随便翻译一下」):
* 1. 整句以 SAY 开头、以 ONLY 结尾,这是单证上的固定套路。
* 2. 百位与十位之间用 AND(英式写法),如 TWO HUNDRED AND FIFTY-FIVE。
* 「组」与「组」之间不重复加 AND,避免 ONE THOUSAND AND TWO HUNDRED AND ... 这种啰嗦写法。
* 3. 货币名固定用复数形式(US DOLLARS / EUROS),因为这里是货币名称而非计量词,
* 单证上不写 ONE US DOLLAR。
* 4. 日元等无小数币种不带辅币段;小数位按币种配置取。
*/
/* 0-19 单独记,20 以上由十位 + 个位拼 */
const ONES = [
'', 'ONE', 'TWO', 'THREE', 'FOUR', 'FIVE', 'SIX', 'SEVEN', 'EIGHT', 'NINE',
'TEN', 'ELEVEN', 'TWELVE', 'THIRTEEN', 'FOURTEEN', 'FIFTEEN', 'SIXTEEN',
'SEVENTEEN', 'EIGHTEEN', 'NINETEEN'
]
const TENS = ['', '', 'TWENTY', 'THIRTY', 'FORTY', 'FIFTY', 'SIXTY', 'SEVENTY', 'EIGHTY', 'NINETY']
const SCALES = ['', 'THOUSAND', 'MILLION', 'BILLION', 'TRILLION']
/**
* 币种配置。
* head —— 大写句里跟在 SAY 后面的货币名(固定复数)
* centsHead —— 辅币名(固定复数)
* decimals —— 该币种的小数位(日元等为 0)
*/
const CURRENCY_WORDS = {
USD: { head: 'US DOLLARS', centsHead: 'CENTS', decimals: 2 },
CNY: { head: 'CHINESE YUAN', centsHead: 'FEN', decimals: 2 },
EUR: { head: 'EUROS', centsHead: 'CENTS', decimals: 2 },
GBP: { head: 'POUNDS STERLING', centsHead: 'PENCE', decimals: 2 },
HKD: { head: 'HONG KONG DOLLARS', centsHead: 'CENTS', decimals: 2 },
JPY: { head: 'JAPANESE YEN', centsHead: 'SEN', decimals: 0 },
AUD: { head: 'AUSTRALIAN DOLLARS', centsHead: 'CENTS', decimals: 2 },
CAD: { head: 'CANADIAN DOLLARS', centsHead: 'CENTS', decimals: 2 },
SGD: { head: 'SINGAPORE DOLLARS', centsHead: 'CENTS', decimals: 2 },
NZD: { head: 'NEW ZEALAND DOLLARS', centsHead: 'CENTS', decimals: 2 },
CHF: { head: 'SWISS FRANCS', centsHead: 'CENTIMES', decimals: 2 },
KRW: { head: 'SOUTH KOREAN WON', centsHead: 'JEON', decimals: 0 },
TWD: { head: 'NEW TAIWAN DOLLARS', centsHead: 'CENTS', decimals: 2 },
MOP: { head: 'MACAO PATACAS', centsHead: 'AVOS', decimals: 2 },
AED: { head: 'UAE DIRHAMS', centsHead: 'FILS', decimals: 2 },
SAR: { head: 'SAUDI RIYALS', centsHead: 'HALALAS', decimals: 2 },
MYR: { head: 'MALAYSIAN RINGGIT', centsHead: 'SEN', decimals: 2 },
THB: { head: 'THAI BAHT', centsHead: 'SATANG', decimals: 2 },
VND: { head: 'VIETNAMESE DONG', centsHead: 'XU', decimals: 0 },
IDR: { head: 'INDONESIAN RUPIAH', centsHead: 'SEN', decimals: 0 },
PHP: { head: 'PHILIPPINE PESOS', centsHead: 'CENTAVOS', decimals: 2 },
INR: { head: 'INDIAN RUPEES', centsHead: 'PAISE', decimals: 2 },
RUB: { head: 'RUSSIAN RUBLES', centsHead: 'KOPECKS', decimals: 2 },
BRL: { head: 'BRAZILIAN REALS', centsHead: 'CENTAVOS', decimals: 2 },
MXN: { head: 'MEXICAN PESOS', centsHead: 'CENTAVOS', decimals: 2 },
ZAR: { head: 'SOUTH AFRICAN RAND', centsHead: 'CENTS', decimals: 2 },
TRY: { head: 'TURKISH LIRA', centsHead: 'KURUS', decimals: 2 },
PLN: { head: 'POLISH ZLOTYS', centsHead: 'GROSZY', decimals: 2 },
SEK: { head: 'SWEDISH KRONOR', centsHead: 'ORE', decimals: 2 },
NOK: { head: 'NORWEGIAN KRONER', centsHead: 'ORE', decimals: 2 },
DKK: { head: 'DANISH KRONER', centsHead: 'ORE', decimals: 2 },
CZK: { head: 'CZECH KORUNAS', centsHead: 'HALERU', decimals: 2 },
HUF: { head: 'HUNGARIAN FORINTS', centsHead: 'FILLER', decimals: 2 },
EGP: { head: 'EGYPTIAN POUNDS', centsHead: 'PIASTRES', decimals: 2 }
}
/** 1–999 转英文。百位后若有非零余数,按英式写法补 AND。 */
function belowThousand(n) {
const parts = []
const hundreds = Math.floor(n / 100)
const rest = n % 100
if (hundreds) parts.push(`${ONES[hundreds]} HUNDRED`)
if (rest) {
if (hundreds) parts.push('AND')
if (rest < 20) parts.push(ONES[rest])
else {
const ten = Math.floor(rest / 10)
const one = rest % 10
parts.push(one ? `${TENS[ten]}-${ONES[one]}` : TENS[ten])
}
}
return parts.join(' ')
}
/** 非负整数转英文,按千分位分组。 */
function integerToWords(value) {
if (!Number.isFinite(value) || value <= 0) return 'ZERO'
const groups = []
let rest = Math.floor(value)
let scale = 0
while (rest > 0) {
const group = rest % 1000
if (group) {
const words = belowThousand(group)
groups.unshift(SCALES[scale] ? `${words} ${SCALES[scale]}` : words)
}
rest = Math.floor(rest / 1000)
scale += 1
}
return groups.join(' ')
}
/** 取币种配置;没登记的币种按 2 位小数 + 代码本身当货币名处理。 */
export function currencyWordsMeta(code) {
const key = String(code || 'USD').toUpperCase()
return CURRENCY_WORDS[key] || { head: key, centsHead: 'CENTS', decimals: 2 }
}
/**
* 金额转英文大写。
* @param {number|string} amount 金额
* @param {string} currency 币种代码
* @returns {string} 形如 "SAY US DOLLARS ... ONLY";输入无法识别时返回空串
*/
export function amountToWords(amount, currency = 'USD') {
const num = typeof amount === 'number' ? amount : Number(String(amount ?? '').replace(/[,\s]/g, ''))
if (!Number.isFinite(num)) return ''
const meta = currencyWordsMeta(currency)
const factor = Math.pow(10, meta.decimals)
/* 先四舍五入到「最小单位」,避免 255.36 这类浮点误差把小数位算成 35.999… */
const total = Math.round(Math.abs(num) * factor)
const major = Math.floor(total / factor)
const minor = total % factor
const parts = [`SAY ${meta.head}`]
if (major > 0 || minor === 0) parts.push(integerToWords(major))
if (minor > 0) {
// 有辅币时,主币为 0 也要读作 ZERO(单证上不会省略)
if (major === 0) parts.push('ZERO')
parts.push('AND')
parts.push(integerToWords(minor))
parts.push(meta.centsHead)
}
parts.push('ONLY')
/* 负数在贸易单证里没有位置,但真出现了也别把符号吞掉 */
return (num < 0 ? 'MINUS ' : '') + parts.join(' ')
}
+103
View File
@@ -0,0 +1,103 @@
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'
import axios from 'axios'
/**
* 会话上下文:全局唯一的「当前用户是谁」答案来源。
*
* 为什么不把用户信息塞进 localStorage:
* 改了个人资料 / 换了角色之后,前端必须立刻拿到新值。所以只依赖后端的会话 cookie,
* 启动时打一次 /api/auth/me,之后由各处的 setUser / refresh 回写。
*/
const AuthContext = createContext(null)
export function AuthProvider({ children }) {
const [user, setUser] = useState(null)
const [loading, setLoading] = useState(true)
/* 启动时确定登录态 */
useEffect(() => {
let alive = true
;(async () => {
try {
const { data } = await axios.get('/api/auth/me')
if (alive) setUser(data?.user || null)
} catch {
if (alive) setUser(null)
} finally {
if (alive) setLoading(false)
}
})()
return () => { alive = false }
}, [])
/* 任何接口返回 401(会话过期 / 被踢),就地清空登录态,界面自然回登录页 */
useEffect(() => {
const id = axios.interceptors.response.use(
response => response,
error => {
const status = error?.response?.status
const url = String(error?.config?.url || '')
const isAuthProbe = url.includes('/api/auth/login') || url.includes('/api/auth/me')
if (status === 401 && !isAuthProbe) setUser(null)
return Promise.reject(error)
}
)
return () => axios.interceptors.response.eject(id)
}, [])
const refresh = useCallback(async () => {
try {
const { data } = await axios.get('/api/auth/me')
setUser(data?.user || null)
return data?.user || null
} catch {
setUser(null)
return null
}
}, [])
const login = useCallback(async (username, password) => {
const { data } = await axios.post('/api/auth/login', { username, password })
setUser(data.user)
return data.user
}, [])
const logout = useCallback(async () => {
try {
await axios.post('/api/auth/logout')
} catch { /* 会话可能已过期,忽略 */ }
setUser(null)
}, [])
const value = useMemo(
() => ({ user, loading, login, logout, refresh, setUser }),
[user, loading, login, logout, refresh]
)
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
}
export function useAuth() {
const context = useContext(AuthContext)
if (!context) throw new Error('useAuth 必须在 AuthProvider 内使用')
return context
}
/** 权限判断:系统管理员兜底全通过,其余按角色所属权限组的权限点判断 */
export function hasPermission(user, key) {
if (!user) return false
if (user.role === 'admin') return true
return Array.isArray(user.permissions) && user.permissions.includes(key)
}
/** 角色英文值 → 中文标签 */
export const ROLE_LABELS = {
admin: '系统管理员',
chairman: '董事长',
general_manager: '总经理',
sales_manager: '销售经理',
sales_rep: '销售业务员'
}
export function roleLabel(role) {
return ROLE_LABELS[role] || role || '—'
}
+73
View File
@@ -0,0 +1,73 @@
import { useEffect, useState } from 'react'
import axios from 'axios'
/**
* 系统标识与公司名 —— 全局唯一来源。
*
* 口径(Wayne 2026-09-15 定):
* - **系统名固定不可改**:`name` / `subtitle` 恒为后端给的固定署名,
* 登录页、顶栏、启动页、浏览器标题全用它,也**不跟随组织名**(引流用)。
* - **公司名(组织名)可改**:只在设置页与组织下拉里出现,不影响界面牌子。
*
* 用法:
* 组件里 const brand = useBranding() ← brand.name / brand.subtitle 是只读的
* 设置页标题 setPageTitle('用户管理') ← 页面别自己拼 document.title
*/
const FALLBACK = {
name: 'happy出口通',
subtitle: 'Ai Export System',
system_locked: true,
org_name: '',
org_id: null
}
let state = { ...FALLBACK }
let currentPage = null
let loading = null
const listeners = new Set()
function emit() {
for (const listener of listeners) {
try { listener(state) } catch { /* 某个订阅者出错不该拖垮其他订阅者 */ }
}
}
/** 页面标题统一在这里拼:品牌名变了,标题里的旧名也会跟着更新 */
export function setPageTitle(page) {
currentPage = page || null
document.title = currentPage ? `${currentPage} · ${state.name}` : state.name
}
export function getBranding() {
return state
}
/** 保存成功后立刻套用,不用等下次刷新 */
export function applyBranding(patch) {
state = { ...state, ...(patch || {}) }
setPageTitle(currentPage)
emit()
return state
}
/** 启动时拉一次;失败就用兜底值(后端挂了也不该白屏) */
export function loadBranding() {
if (loading) return loading
loading = axios
.get('/api/system/branding')
.then(({ data }) => applyBranding(data))
.catch(() => state)
return loading
}
export function useBranding() {
const [value, setValue] = useState(state)
useEffect(() => {
setValue(state)
listeners.add(setValue)
loadBranding()
return () => listeners.delete(setValue)
}, [])
return value
}
+17 -11
View File
@@ -1,9 +1,9 @@
import React, { useEffect, useMemo, useRef, useState } from 'react'
import axios from 'axios'
import { BotIcon, CloseIcon, MinusIcon, SendIcon, SparkIcon } from './Icons'
import { AIBotIcon, CloseIcon, MinusIcon, SendIcon, UserIcon } from './Icons'
const POS_KEY = 'happy.aiPos'
const WIN_WIDTH = 348
const WIN_WIDTH = 440
const starterMessages = [
{ role: 'assistant', text: '我是小皮,你的外贸业务助理。可以帮你查客户、产品、供应商、订单、商机和待办,也能代你发起系统内的操作。' },
@@ -135,22 +135,27 @@ function AIAssistant({ isOpen, onClose }) {
onDoubleClick={() => setMinimized(value => !value)}
>
<div className="ai-panel-title">
<span className="ai-orb"><BotIcon size={16} /></span>
<div><span className="section-label">[COPILOT]</span><h2 id="ai-assistant-title">小皮</h2></div>
<span className="ai-orb"><AIBotIcon size={16} /></span>
<div className="ai-panel-heading">
<span className="ai-panel-kicker">BUSINESS COPILOT</span>
<h2 id="ai-assistant-title">小皮</h2>
<span className="ai-panel-subtitle">你的外贸业务助理</span>
</div>
</div>
<div className="ai-window-actions">
{!minimized && <span className="ai-live-dot" title="已连接系统业务层" />}
{!minimized && <span className="ai-status-label"><span className="ai-live-dot" />在线</span>}
<button
type="button"
className="ai-icon-btn"
className="ai-icon-btn ai-minimize-btn"
onClick={() => setMinimized(value => !value)}
aria-label={minimized ? '展开小皮' : '最小化小皮'}
title={minimized ? '展开' : '最小化(也可以双击标题栏)'}
>
{minimized ? <SparkIcon size={15} /> : <MinusIcon size={16} />}
{minimized ? <AIBotIcon size={15} /> : <MinusIcon size={16} />}
</button>
<button type="button" className="ai-icon-btn" onClick={onClose} aria-label="关闭小皮" title="关闭">
<button type="button" className="ai-close-btn" onClick={onClose} aria-label="关闭小皮" title="关闭小皮">
<CloseIcon size={15} />
<span>关闭</span>
</button>
</div>
</div>
@@ -159,11 +164,11 @@ function AIAssistant({ isOpen, onClose }) {
<React.Fragment>
<div className="ai-panel-body">
<div className="ai-access-banner"><span className="ai-live-dot" /><div><strong>已连接系统业务层</strong><small>可读取模块数据,敏感写操作会先请求确认</small></div></div>
<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>
<div className="ai-capability-card"><div className="ai-capability-heading">我能帮你</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>}
{messages.map((message, index) => <div className={`ai-message ${message.role}`} key={`${message.role}-${index}`}><div className="ai-message-avatar">{message.role === 'assistant' ? <AIBotIcon size={15} /> : <UserIcon size={15} />}</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"><AIBotIcon size={15} /></div><div className="ai-message-content ai-thinking"><span /><span /><span /></div></div>}
</div>
</div>
@@ -177,3 +182,4 @@ function AIAssistant({ isOpen, onClose }) {
}
export default AIAssistant
@@ -0,0 +1,721 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import axios from 'axios'
import {
SparkIcon, PlusIcon, RefreshIcon, CheckIcon, CloseIcon, SaveIcon, SendIcon
} from './Icons'
/**
* 智能配置 —— 多模型接入与用途分配。
*
* 交互主线(Wayne 2026-09-15 定的四步):
* 1. 选一个常用接口预设(自动填地址/兼容模式/推荐模型)
* 2. 填密钥 → 保存(保存后自动跑一次连通性测试)
* 3. 「获取模型」→ 勾选这个接入要用哪些模型
* 4. 到「按用途分配」把不同活派给不同模型(可以同时接多家)
*
* 所有配置都在后端,前端不做任何本地持久化 —— 换台电脑登录照样是这套配置。
*/
const VALUE_SEP = '::'
const encodeChoice = (providerId, model) => (providerId && model ? `${providerId}${VALUE_SEP}${model}` : '')
const decodeChoice = (value) => {
if (!value) return null
const index = value.indexOf(VALUE_SEP)
if (index < 0) return null
return { provider_id: value.slice(0, index), model: value.slice(index + VALUE_SEP.length) }
}
function fmtTime(value) {
if (!value) return ''
try {
return new Date(value).toLocaleString('zh-CN', { hour12: false })
} catch {
return String(value)
}
}
/**
* Base URL 的即时体检。
*
* 两种兼容模式的地址写法**不一样**,这里拦最常见的两类填错:
* ① 把完整接口路径也填了进来(/chat/completions、/messages)—— 系统还会再拼一次;
* ② Anthropic 协议漏了 /v1 —— 各家 SDK 文档里的 base_url 是写到 /anthropic 为止的
* (SDK 自己补 /v1),本系统不补,直接照抄必然 404。
*/
function baseUrlAdvice(mode, value) {
const url = String(value || '').trim()
if (!url) return null
const tail = url.replace(/\/+$/, '')
if (/\/(chat\/completions|completions)$/i.test(tail)) {
return { level: 'error', text: '不要带 /chat/completions —— 系统会自动拼上它,填到服务根路径就行。' }
}
if (/\/messages$/i.test(tail)) {
return { level: 'error', text: '不要带 /messages —— 系统会自动拼上它,填到服务根路径就行。' }
}
if (mode === 'anthropic' && !/\/v1$/i.test(tail)) {
return {
level: 'warn',
text: 'Anthropic 协议通常要写到 /v1 结尾(如 …/anthropic/v1)—— 系统是在这一层的后面拼 /messages,'
+ '漏掉 /v1 多半会 404。'
}
}
return null
}
function AIConfigSettings() {
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [notice, setNotice] = useState('')
const [config, setConfig] = useState({
presets: [], modes: [], purposes: [], providers: [], assignments: {}, overview: {}
})
const [editor, setEditor] = useState(null)
const [busy, setBusy] = useState('')
const [modelPanel, setModelPanel] = useState(null)
const [draft, setDraft] = useState({})
const [probe, setProbe] = useState({ prompt: '', reply: '', error: '', busy: false })
const load = useCallback(async () => {
setLoading(true)
try {
const { data } = await axios.get('/api/ai/config')
setConfig(data)
const next = {}
for (const [purpose, value] of Object.entries(data.assignments || {})) {
next[purpose] = encodeChoice(value.provider_id, value.model)
}
setDraft(next)
setError('')
} catch (err) {
setError(err?.response?.data?.error || '读取智能配置失败')
} finally {
setLoading(false)
}
}, [])
useEffect(() => { load() }, [load])
const flash = (text) => {
setNotice(text)
window.setTimeout(() => setNotice(''), 3500)
}
/* 所有启用中的接入 × 它们的模型,供用途分配下拉用 */
const choices = useMemo(() => (config.providers || [])
.filter(provider => provider.enabled)
.map(provider => ({
provider,
models: provider.models || [],
label: `${provider.name}${provider.has_key ? '' : '(未填密钥)'}`
})), [config.providers])
const assignedCount = useMemo(
() => Object.values(draft).filter(Boolean).length,
[draft]
)
/* ---------------------------------------------------------------- 接入 */
const openCreate = (presetKey = '') => {
const preset = (config.presets || []).find(item => item.key === presetKey) || null
setEditor({
mode: 'create',
form: {
preset: preset?.key || 'openai',
name: preset?.label || '',
base_url: preset?.base_url || '',
mode: preset?.mode || 'openai',
api_key: '',
note: '',
enabled: true
},
modelsText: (preset?.models || []).join('\n')
})
}
const openEdit = (provider) => {
setEditor({
mode: 'edit',
id: provider.id,
form: {
preset: provider.preset,
name: provider.name,
base_url: provider.base_url,
mode: provider.mode,
api_key: '',
note: provider.note || '',
enabled: provider.enabled
},
keyMasked: provider.key_masked,
modelsText: (provider.models || []).join('\n')
})
}
const changePreset = (presetKey) => {
const preset = (config.presets || []).find(item => item.key === presetKey)
if (!preset) return
setEditor(current => ({
...current,
form: {
...current.form,
preset: preset.key,
name: preset.label,
base_url: preset.base_url,
mode: preset.mode
},
modelsText: (preset.models || []).join('\n')
}))
}
const saveProvider = async (event) => {
event.preventDefault()
if (!editor) return
setBusy('save-provider')
try {
const payload = {
...editor.form,
models: editor.modelsText.split('\n').map(line => line.trim()).filter(Boolean)
}
if (editor.mode === 'edit' && !payload.api_key) delete payload.api_key
const { data } = editor.mode === 'edit'
? await axios.patch(`/api/ai/providers/${editor.id}`, payload)
: await axios.post('/api/ai/providers', payload)
setEditor(null)
await load()
flash(`「${data.provider.name}」已保存`)
/* 保存完顺手测一次:用户填完 key 最想知道的就是「通不通」 */
await runTest(data.provider.id, { silent: true })
} catch (err) {
setError(err?.response?.data?.error || '保存模型接入失败')
} finally {
setBusy('')
}
}
const runTest = async (id, { silent } = {}) => {
setBusy(`test:${id}`)
try {
const { data } = await axios.post(`/api/ai/providers/${id}/test`)
if (!silent) {
flash(data.result.ok
? `连通正常(${data.result.latency_ms}ms):${data.result.detail}`
: `连通失败:${data.result.detail}`)
}
setConfig(current => ({
...current,
providers: current.providers.map(item => (item.id === id ? data.provider : item))
}))
return data.result
} catch (err) {
const text = err?.response?.data?.error || '测试失败'
if (!silent) setError(text)
return { ok: false, detail: text }
} finally {
setBusy('')
}
}
const fetchModels = async (provider) => {
setBusy(`models:${provider.id}`)
try {
const { data } = await axios.post(`/api/ai/providers/${provider.id}/models`)
/* 勾选默认值:优先沿用之前存过的模型;一个都没对上(换了服务商/换了模型名)就全选,
否则用户会看到一个「获取成功但一个都没勾」的空面板,还得手工点一遍。 */
const previous = (provider.models || []).filter(model => data.models.includes(model))
setModelPanel({
providerId: provider.id,
providerName: provider.name,
detail: data.detail,
source: data.source,
selected: previous.length ? previous : data.models,
models: data.models
})
} catch (err) {
setError(err?.response?.data?.error || '获取模型清单失败')
} finally {
setBusy('')
}
}
const saveModels = async () => {
if (!modelPanel) return
setBusy('save-models')
try {
await axios.patch(`/api/ai/providers/${modelPanel.providerId}`, { models: modelPanel.selected })
setModelPanel(null)
await load()
flash('可用模型已更新')
} catch (err) {
setError(err?.response?.data?.error || '保存模型清单失败')
} finally {
setBusy('')
}
}
const toggleProvider = async (provider) => {
setBusy(`toggle:${provider.id}`)
try {
await axios.patch(`/api/ai/providers/${provider.id}`, { enabled: !provider.enabled })
await load()
flash(`「${provider.name}」已${provider.enabled ? '停用' : '启用'}`)
} catch (err) {
setError(err?.response?.data?.error || '操作失败')
} finally {
setBusy('')
}
}
const removeProvider = async (provider) => {
if (!window.confirm(`移除模型接入「${provider.name}」?\n\n指向它的用途分配会一起清掉。`)) return
setBusy(`remove:${provider.id}`)
try {
await axios.delete(`/api/ai/providers/${provider.id}`)
await load()
flash(`「${provider.name}」已移除`)
} catch (err) {
setError(err?.response?.data?.error || '移除失败')
} finally {
setBusy('')
}
}
/* ---------------------------------------------------------------- 分配 */
const saveAssignments = async () => {
setBusy('assignments')
try {
const assignments = {}
for (const [purpose, value] of Object.entries(draft)) {
const choice = decodeChoice(value)
if (choice) assignments[purpose] = choice
}
const { data } = await axios.put('/api/ai/assignments', { assignments })
setConfig(current => ({ ...current, assignments: data.assignments, overview: data.overview }))
flash('用途分配已保存')
} catch (err) {
setError(err?.response?.data?.error || '保存分配失败')
} finally {
setBusy('')
}
}
const runProbe = async () => {
const prompt = probe.prompt.trim()
if (!prompt) return
setProbe(current => ({ ...current, busy: true, reply: '', error: '' }))
try {
const { data } = await axios.post('/api/ai/chat', { prompt, purpose: 'assistant_chat' })
setProbe(current => ({ ...current, busy: false, reply: data.text || '(模型返回空内容)' }))
} catch (err) {
setProbe(current => ({
...current,
busy: false,
error: err?.response?.data?.error || '试跑失败'
}))
}
}
/* ---------------------------------------------------------------- 渲染 */
if (loading) {
return (
<div className="settings-section">
<h3>智能配置</h3>
<p className="settings-description">正在读取模型接入配置…</p>
</div>
)
}
const { overview = {} } = config
/* 编辑弹窗里跟着「接口兼容模式」走派生值:示例、后缀说明、填错提示 */
const activeMode = editor
? (config.modes || []).find(item => item.value === editor.form.mode) || null
: null
const baseAdvice = editor ? baseUrlAdvice(editor.form.mode, editor.form.base_url) : null
return (
<div className="settings-section">
<h3>智能配置</h3>
<p className="settings-description">
接入一家或多家大模型服务,再把不同用途派给不同模型 —— 合同解析用长文本强的,
图片识别用多模态的,日常对话用便宜的。全部配置存在本机数据库,换电脑登录即生效。
</p>
<div className="ai-overview">
<div><strong>{overview.provider_count || 0}</strong><small>已接入</small></div>
<div><strong>{overview.healthy_count || 0}</strong><small>测试通过</small></div>
<div><strong>{overview.assigned_purposes?.length || 0}/{config.purposes.length}</strong><small>用途已分配</small></div>
<div><strong>{overview.last_test_at ? fmtTime(overview.last_test_at).slice(5, 16) : '—'}</strong><small>最近测试</small></div>
</div>
{notice && <p className="ai-banner is-ok"><CheckIcon size={14} /> {notice}</p>}
{error && <p className="ai-banner is-error"><CloseIcon size={14} /> {error}</p>}
{/* ---------------- 模型接入 ---------------- */}
<div className="settings-group">
<div className="ai-group-head">
<h4>模型接入</h4>
<div className="ai-group-actions">
<button className="btn btn-secondary btn-sm" onClick={load} disabled={Boolean(busy)}>
<RefreshIcon size={14} /> 刷新
</button>
<button className="btn btn-primary btn-sm" onClick={() => openCreate('deepseek')}>
<PlusIcon size={14} /> 新增接入
</button>
</div>
</div>
{config.providers.length === 0
? <p className="help-text">
还没有接入任何模型服务。可以从下面的常用预设开始(DeepSeek / 通义 / Kimi / Claude / 本地 Ollama 等),
也可以完全自定义。不接也能用系统 —— 只是「小皮」会退回到本地规则直答。
</p>
: (
<ul className="ai-provider-list">
{config.providers.map(provider => {
const test = provider.last_test
return (
<li key={provider.id} className={`ai-provider${provider.enabled ? '' : ' is-off'}`}>
<div className="ai-provider-main">
<div className="ai-provider-title">
<strong>{provider.name}</strong>
<span className="ai-tag">{provider.preset_label}</span>
<span className="ai-tag is-mode">{provider.mode === 'anthropic' ? 'Anthropic 协议' : 'OpenAI 兼容'}</span>
{!provider.enabled && <span className="ai-tag is-off">已停用</span>}
</div>
<div className="ai-provider-meta">
<code>{provider.base_url}</code>
<span>密钥 {provider.has_key ? provider.key_masked : '(未填写,本地服务可留空)'}</span>
<span>{provider.models.length} 个可用模型</span>
</div>
{test && (
<div className={`ai-provider-test${test.ok ? ' is-ok' : ' is-error'}`}>
{test.ok ? <CheckIcon size={13} /> : <CloseIcon size={13} />}
<span>{test.detail}</span>
<small>{fmtTime(test.at)} · {test.latency_ms}ms</small>
</div>
)}
</div>
<div className="ai-provider-actions">
<button
className="btn btn-secondary btn-sm"
onClick={() => runTest(provider.id)}
disabled={Boolean(busy)}
>
{busy === `test:${provider.id}` ? '测试中…' : '连通性测试'}
</button>
<button
className="btn btn-secondary btn-sm"
onClick={() => fetchModels(provider)}
disabled={Boolean(busy)}
>
{busy === `models:${provider.id}` ? '获取中…' : '获取模型'}
</button>
<button className="btn btn-secondary btn-sm" onClick={() => openEdit(provider)} disabled={Boolean(busy)}>
编辑
</button>
<button className="btn-link" onClick={() => toggleProvider(provider)} disabled={Boolean(busy)}>
{provider.enabled ? '停用' : '启用'}
</button>
<button className="btn-link is-danger" onClick={() => removeProvider(provider)} disabled={Boolean(busy)}>
移除
</button>
</div>
{modelPanel?.providerId === provider.id && (
<div className="ai-model-panel">
<div className="ai-model-panel-head">
<span>可用模型({modelPanel.models.length})</span>
<small>{modelPanel.detail}{modelPanel.source === 'preset' ? ' · 预设推荐' : ''}</small>
</div>
<div className="ai-model-grid">
{modelPanel.models.map(model => {
const checked = modelPanel.selected.includes(model)
return (
<label key={model} className={`ai-model-item${checked ? ' is-on' : ''}`}>
<input
type="checkbox"
checked={checked}
onChange={() => setModelPanel(current => ({
...current,
selected: checked
? current.selected.filter(item => item !== model)
: [...current.selected, model]
}))}
/>
<span>{model}</span>
</label>
)
})}
</div>
<div className="ai-model-panel-foot">
<button className="btn btn-secondary btn-sm" onClick={() => setModelPanel(null)}>取消</button>
<button className="btn btn-primary btn-sm" onClick={saveModels} disabled={busy === 'save-models'}>
<SaveIcon size={14} /> 保存为可用模型({modelPanel.selected.length})
</button>
</div>
</div>
)}
</li>
)
})}
</ul>
)}
</div>
{/* ---------------- 用途分配 ---------------- */}
<div className="settings-group">
<div className="ai-group-head">
<h4>按用途分配模型</h4>
<div className="ai-group-actions">
<span className="help-text">已分配 {assignedCount}/{config.purposes.length}</span>
<button
className="btn btn-primary btn-sm"
onClick={saveAssignments}
disabled={Boolean(busy) || config.providers.length === 0}
>
<SaveIcon size={14} /> {busy === 'assignments' ? '保存中…' : '保存分配'}
</button>
</div>
</div>
{config.providers.length === 0
? <p className="help-text">先在上面接入至少一家模型服务,这里就能选了。</p>
: (
<table className="data-table ai-assign-table">
<thead>
<tr><th>用途</th><th>说明</th><th>使用的模型</th><th /></tr>
</thead>
<tbody>
{config.purposes.map(purpose => (
<tr key={purpose.key}>
<td>
<strong>{purpose.label}</strong>
{purpose.vision && <span className="ai-tag is-vision">需多模态</span>}
</td>
<td className="ai-assign-hint">{purpose.hint}</td>
<td>
<select
className="form-select"
value={draft[purpose.key] || ''}
onChange={event => setDraft(current => ({ ...current, [purpose.key]: event.target.value }))}
>
<option value="">—— 未分配(走本地规则)——</option>
{choices.map(group => (
<optgroup key={group.provider.id} label={group.label}>
{group.models.length === 0 && (
<option value="" disabled>(该接入还没有可用模型,先点「获取模型」)</option>
)}
{group.models.map(model => (
<option key={model} value={encodeChoice(group.provider.id, model)}>
{model}
</option>
))}
</optgroup>
))}
</select>
</td>
<td>
{draft[purpose.key] && (
<button
className="btn-link"
onClick={() => setDraft(current => ({ ...current, [purpose.key]: '' }))}
>
清空
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
{/* ---------------- 试跑 ---------------- */}
<div className="settings-group">
<h4>验证一下</h4>
<p className="help-text">
用「智能助手对话」分配到的模型发一句话,确认配置真的通了。未分配时会提示先去做分配。
</p>
<div className="ai-probe">
<input
type="text"
className="form-input"
placeholder="例如:用一句话介绍我这边的客户情况"
value={probe.prompt}
onChange={event => setProbe(current => ({ ...current, prompt: event.target.value }))}
onKeyDown={event => { if (event.key === 'Enter') runProbe() }}
/>
<button className="btn btn-primary" onClick={runProbe} disabled={probe.busy || !probe.prompt.trim()}>
<SendIcon size={14} /> {probe.busy ? '调用中…' : '试跑'}
</button>
</div>
{probe.reply && <pre className="ai-probe-reply">{probe.reply}</pre>}
{probe.error && <p className="ai-banner is-error"><CloseIcon size={14} /> {probe.error}</p>}
</div>
<p className="appearance-note">
<SparkIcon size={14} /> 密钥以明文保存在本机数据库文件里,请勿把 data/trade.db 外发;
对外接口只会返回打码后的密钥。
</p>
{/* ---------------- 新增 / 编辑弹窗 ---------------- */}
{editor && (
<div className="modal-overlay" onClick={() => !busy && setEditor(null)}>
<div className="modal-content" onClick={event => event.stopPropagation()}>
<div className="modal-header">
<h2>{editor.mode === 'edit' ? `编辑接入「${editor.form.name}」` : '新增模型接入'}</h2>
<button className="close-btn" onClick={() => setEditor(null)} aria-label="关闭">
<CloseIcon size={18} />
</button>
</div>
<form onSubmit={saveProvider}>
<div className="form-group">
<label>常用接口预设</label>
<select
value={editor.form.preset}
onChange={event => changePreset(event.target.value)}
>
{config.presets.map(preset => (
<option key={preset.key} value={preset.key}>{preset.label}</option>
))}
</select>
{(() => {
const preset = config.presets.find(item => item.key === editor.form.preset)
if (!preset?.note) return null
return (
<p className="form-hint">
{preset.note}
{preset.docs && (
<>
{' · '}
<a href={preset.docs} target="_blank" rel="noreferrer">去申请密钥</a>
</>
)}
</p>
)
})()}
</div>
<div className="form-row">
<div className="form-group">
<label>接入名称</label>
<input
type="text"
value={editor.form.name}
onChange={event => setEditor(current => ({ ...current, form: { ...current.form, name: event.target.value } }))}
placeholder="例如 DeepSeek 生产"
/>
</div>
<div className="form-group">
<label>接口兼容模式</label>
<select
value={editor.form.mode}
onChange={event => setEditor(current => ({ ...current, form: { ...current.form, mode: event.target.value } }))}
>
{config.modes.map(mode => (
<option key={mode.value} value={mode.value}>{mode.label} —— {mode.hint}</option>
))}
</select>
</div>
</div>
<div className="form-group">
<label>
接口地址(Base URL)
{activeMode?.base_suffix && <span className="ai-url-suffix">系统会自动拼 {activeMode.base_suffix}</span>}
</label>
<input
type="text"
value={editor.form.base_url}
onChange={event => setEditor(current => ({ ...current, form: { ...current.form, base_url: event.target.value } }))}
placeholder={activeMode?.base_placeholder || 'https://api.example.com/v1'}
/>
{activeMode?.base_hint && <p className="form-hint">{activeMode.base_hint}</p>}
{baseAdvice && (
<p className={`form-hint is-${baseAdvice.level}`}>{baseAdvice.text}</p>
)}
{activeMode?.base_examples?.length > 0 && (
<div className="ai-url-examples">
<span className="ai-url-examples-label">{activeMode.label}常见地址(点一下填入):</span>
{activeMode.base_examples.map(item => (
<button
key={item.url}
type="button"
className="ai-url-chip"
title={`填入 ${item.url}`}
onClick={() => setEditor(current => ({
...current,
form: { ...current.form, base_url: item.url }
}))}
>
<em>{item.label}</em>
<code>{item.url}</code>
</button>
))}
</div>
)}
</div>
<div className="form-group">
<label>API Key</label>
<input
type="password"
value={editor.form.api_key}
onChange={event => setEditor(current => ({ ...current, form: { ...current.form, api_key: event.target.value } }))}
placeholder={editor.mode === 'edit' && editor.keyMasked
? `已保存:${editor.keyMasked}(留空表示不修改)`
: 'sk-...'}
/>
</div>
<div className="form-group">
<label>可用模型(一行一个;也可以保存后点「获取模型」自动填充)</label>
<textarea
className="ai-models-textarea"
rows={5}
value={editor.modelsText}
onChange={event => setEditor(current => ({ ...current, modelsText: event.target.value }))}
placeholder={'deepseek-chat\ndeepseek-reasoner'}
/>
</div>
<div className="form-group">
<label>备注</label>
<input
type="text"
value={editor.form.note}
onChange={event => setEditor(current => ({ ...current, form: { ...current.form, note: event.target.value } }))}
placeholder="例如:给合同解析用"
/>
</div>
<label className="setting-toggle">
<span><strong>启用该接入</strong><small>停用后不会被任何用途调用</small></span>
<input
type="checkbox"
checked={editor.form.enabled}
onChange={event => setEditor(current => ({ ...current, form: { ...current.form, enabled: event.target.checked } }))}
/>
<i aria-hidden="true" />
</label>
<div className="modal-footer">
<button type="button" className="btn btn-secondary" onClick={() => setEditor(null)} disabled={Boolean(busy)}>
取消
</button>
<button type="submit" className="btn btn-primary" disabled={Boolean(busy)}>
{busy === 'save-provider' ? '保存中…' : '保存并测试连通性'}
</button>
</div>
</form>
</div>
</div>
)}
</div>
)
}
export default AIConfigSettings
+228
View File
@@ -0,0 +1,228 @@
import React from 'react'
/**
* 币种小国旗(纯 SVG 绘制,不使用 Emoji)
*
* 为什么不用 Emoji:🇺🇸 这类「区域指示符」在 Windows 的 Segoe UI Emoji 上
* 根本不渲染成旗子,只会显示成 US 两个字母方块;而且颜色不可控、尺寸不齐。
* 所以这里手绘 13 面旗,统一 viewBox 0 0 24 16,靠 CSS 控制大小与描边。
*
* 关于中国香港 / 中国台湾:香港用香港特别行政区区旗;台湾地区没有经
* 国家认可的区旗,这里用红底白字「台」作为**区划标识**,不是国旗。
*/
/** 五角星:单位坐标,几何中心在原点,外接圆半径 1(内接半径取 0.382) */
const STAR =
'0,-1 0.2246,-0.309 0.9511,-0.309 0.3633,0.118 0.5878,0.809 0,0.382 ' +
'-0.5878,0.809 -0.3633,0.118 -0.9511,-0.309 -0.2246,-0.309'
/** 欧元旗 12 颗星的位置(以 12,8 为中心、半径 5 的圆上均分) */
const EU_STARS = [
[17, 8], [16.33, 10.5], [14.5, 12.33], [12, 13], [9.5, 12.33], [7.67, 10.5],
[7, 8], [7.67, 5.5], [9.5, 3.67], [12, 3], [14.5, 3.67], [16.33, 5.5]
]
/** 简明国旗集:只画能一眼认出来的特征,不追求像素级还原 */
const FLAGS = {
/* 美国:13 道红白条纹 + 蓝色星区 */
USD: (
<>
<rect width="24" height="16" fill="#ffffff" />
<g fill="#b22234">
<rect y="0" width="24" height="1.23" />
<rect y="2.46" width="24" height="1.23" />
<rect y="4.92" width="24" height="1.23" />
<rect y="7.38" width="24" height="1.23" />
<rect y="9.85" width="24" height="1.23" />
<rect y="12.31" width="24" height="1.23" />
<rect y="14.77" width="24" height="1.23" />
</g>
<rect width="9.8" height="8.62" fill="#3c3b6e" />
<g fill="#ffffff">
{[0, 1, 2, 3, 4].map(i => <circle key={`a${i}`} cx={1.4 + i * 1.75} cy="1.4" r="0.48" />)}
{[0, 1, 2, 3].map(i => <circle key={`b${i}`} cx={2.28 + i * 1.75} cy="2.95" r="0.48" />)}
{[0, 1, 2, 3, 4].map(i => <circle key={`c${i}`} cx={1.4 + i * 1.75} cy="4.5" r="0.48" />)}
{[0, 1, 2, 3].map(i => <circle key={`d${i}`} cx={2.28 + i * 1.75} cy="6.05" r="0.48" />)}
{[0, 1, 2].map(i => <circle key={`e${i}`} cx={2.28 + i * 1.75} cy="7.55" r="0.48" />)}
</g>
</>
),
/* 中国:五星红旗 */
CNY: (
<>
<rect width="24" height="16" fill="#de2910" />
<g fill="#ffde00">
<polygon points={STAR} transform="translate(4.4,5.4) scale(2.6)" />
<polygon points={STAR} transform="translate(9,1.9) scale(0.85)" />
<polygon points={STAR} transform="translate(10.9,3.9) scale(0.85)" />
<polygon points={STAR} transform="translate(10.9,6.5) scale(0.85)" />
<polygon points={STAR} transform="translate(9,8.5) scale(0.85)" />
</g>
</>
),
/* 欧元区:蓝底 + 12 颗黄星围成一圈 */
EUR: (
<>
<rect width="24" height="16" fill="#003399" />
<g fill="#ffcc00">
{EU_STARS.map(([x, y], i) => (
<polygon key={i} points={STAR} transform={`translate(${x},${y}) scale(0.92)`} />
))}
</g>
</>
),
/* 中国香港:香港特别行政区区旗(洋紫荆) */
HKD: (
<>
<rect width="24" height="16" fill="#de2910" />
<g fill="#ffffff">
{[0, 72, 144, 216, 288].map(deg => (
<ellipse key={deg} cx="12" cy="5.15" rx="1.15" ry="2.85" transform={`rotate(${deg} 12 8)`} />
))}
</g>
<circle cx="12" cy="8" r="0.95" fill="#de2910" />
</>
),
/* 日本:白底红日 */
JPY: (
<>
<rect width="24" height="16" fill="#ffffff" />
<circle cx="12" cy="8" r="4.3" fill="#bc002d" />
</>
),
/* 俄罗斯:白蓝红三横条 */
RUB: (
<>
<rect width="24" height="16" fill="#ffffff" />
<rect y="5.33" width="24" height="5.34" fill="#0039a6" />
<rect y="10.67" width="24" height="5.33" fill="#d52b1e" />
</>
),
/* 马来西亚:红白条纹 + 蓝底黄月黄星 */
MYR: (
<>
<rect width="24" height="16" fill="#ffffff" />
<g fill="#cc0001">
<rect y="0" width="24" height="2.29" />
<rect y="4.57" width="24" height="2.29" />
<rect y="9.14" width="24" height="2.29" />
<rect y="13.71" width="24" height="2.29" />
</g>
<rect width="12" height="9.15" fill="#010066" />
<circle cx="4.7" cy="4.57" r="2.75" fill="#ffcc00" />
<circle cx="5.95" cy="4.57" r="2.35" fill="#010066" />
<polygon points={STAR} transform="translate(8.5,4.57) scale(1.02)" fill="#ffcc00" />
</>
),
/* 新加坡:上红下白 + 白色月与五星 */
SGD: (
<>
<rect width="24" height="8" fill="#ed2939" />
<rect y="8" width="24" height="8" fill="#ffffff" />
<circle cx="5.1" cy="4" r="2.9" fill="#ffffff" />
<circle cx="6.5" cy="4" r="2.5" fill="#ed2939" />
<g fill="#ffffff">
{[[8.6, 6.05], [6.62, 4.65], [7.37, 2.33], [9.83, 2.33], [10.58, 4.65]].map(([x, y], i) => (
<polygon key={i} points={STAR} transform={`translate(${x},${y}) scale(0.58)`} />
))}
</g>
</>
),
/* 中国台湾:台湾地区无国家认可的区旗,用红底白字「台」作区划标识 */
TWD: (
<>
<rect width="24" height="16" fill="#de2910" />
<text x="12" y="12.3" textAnchor="middle" fontSize="11.5" fontWeight="700" fill="#ffffff">台</text>
</>
),
/* 澳大利亚:蓝底 + 米字旗区 + 南十字星 */
AUD: (
<>
<rect width="24" height="16" fill="#00247d" />
<g>
<path d="M0 0 L12 8 M12 0 L0 8" stroke="#ffffff" strokeWidth="1.9" />
<path d="M0 0 L12 8 M12 0 L0 8" stroke="#cf142b" strokeWidth="1" />
<path d="M6 0 V8 M0 4 H12" stroke="#ffffff" strokeWidth="2.8" />
<path d="M6 0 V8 M0 4 H12" stroke="#cf142b" strokeWidth="1.6" />
</g>
<g fill="#ffffff">
<polygon points={STAR} transform="translate(5.6,12.1) scale(1.5)" />
<polygon points={STAR} transform="translate(17.6,4.6) scale(0.72)" />
<polygon points={STAR} transform="translate(20.3,7.7) scale(0.72)" />
<polygon points={STAR} transform="translate(17.7,10.7) scale(0.72)" />
<polygon points={STAR} transform="translate(15,7.7) scale(0.6)" />
<polygon points={STAR} transform="translate(19.2,8.6) scale(0.48)" />
</g>
</>
),
/* 阿联酋:左侧红竖条 + 绿白黑三横条 */
AED: (
<>
<rect width="24" height="16" fill="#ffffff" />
<rect width="24" height="5.33" fill="#00732f" />
<rect y="10.67" width="24" height="5.33" fill="#000000" />
<rect width="6" height="16" fill="#ff0000" />
</>
),
/* 沙特:绿底 + 白色清真言与宝刀(简形) */
SAR: (
<>
<rect width="24" height="16" fill="#006c35" />
<g fill="#ffffff">
<rect x="3.4" y="4.1" width="17.2" height="1.5" rx="0.75" />
<rect x="3.4" y="6.5" width="14" height="1.5" rx="0.75" />
<rect x="3.4" y="10.9" width="14.6" height="1.25" rx="0.6" />
<polygon points="18.7,10.25 22.4,11.5 18.7,12.8" />
</g>
</>
),
/* 加拿大:两侧红竖条 + 中间红枫叶 */
CAD: (
<>
<rect width="24" height="16" fill="#ffffff" />
<rect width="6" height="16" fill="#d80621" />
<rect x="18" width="6" height="16" fill="#d80621" />
<path
d="M50 5 L58 30 L75 25 L68 45 L90 42 L72 58 L95 72 L62 68 L68 95 L50 78 L32 95 L38 68 L5 72 L28 58 L10 42 L32 45 L25 25 L42 30 Z"
fill="#d80621"
transform="translate(12,8) scale(0.15) translate(-50,-50)"
/>
</>
)
}
/**
* @param {string} code ISO 4217 三字币种代码
* @param {number} width 显示宽度(px),高度按 3:2 自动算
*/
export default function CurrencyFlag({ code, width = 21 }) {
const body = FLAGS[code]
const height = Math.round((width * 16) / 24)
if (!body) {
return <span className="fx-flag is-blank" style={{ width, height }} />
}
return (
<svg
className="fx-flag"
viewBox="0 0 24 16"
width={width}
height={height}
aria-hidden="true"
focusable="false"
>
{body}
</svg>
)
}
+33
View File
@@ -0,0 +1,33 @@
import React, { useEffect } from 'react'
import { CloseIcon } from './Icons'
/**
* 右侧抽屉。产品页原本各自内联了一份同样的结构,这里抽出来给新页面复用,
* 保持「遮罩点击关闭 / Esc 关闭 / 正文独立滚动」三件事的行为一致。
*/
function Drawer({ title, sub, onClose, children, footer, wide = false }) {
useEffect(() => {
const onKeyDown = (event) => { if (event.key === 'Escape') onClose() }
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [onClose])
return (
<>
<div className="drawer-backdrop" onClick={onClose} />
<aside className={`drawer${wide ? ' drawer-wide' : ''}`} role="dialog" aria-modal="true" aria-label={title}>
<div className="drawer-header">
<div className="drawer-head-main">
<h2 className="drawer-title">{title}</h2>
{sub && <p className="drawer-sub">{sub}</p>}
</div>
<button type="button" className="icon-btn" onClick={onClose} aria-label="关闭"><CloseIcon size={16} /></button>
</div>
<div className="drawer-body">{children}</div>
{footer && <div className="drawer-footer">{footer}</div>}
</aside>
</>
)
}
export default Drawer
+48 -3
View File
@@ -1,9 +1,11 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import axios from 'axios'
import { CalculatorIcon, ChevronDownIcon, RefreshIcon, SwapIcon } from './Icons'
import CurrencyFlag from './CurrencyFlag'
import {
buildCurrencyOptions, crossRate, currencyLabel, formatAmount, formatRate, parseAmount
buildPrimaryCurrencyOptions, crossRate, currencyLabel, formatAmount, formatRate, parseAmount
} from '../currencies'
import { amountToWords } from '../amountInWords'
const DEFAULT_FROM = 'USD'
const DEFAULT_TO = 'CNY'
@@ -41,9 +43,16 @@ function CurrencyPicker({ value, options, onChange, align }) {
const filtered = useMemo(() => {
const term = keyword.trim().toUpperCase()
if (!term) return options
return options.filter(item => item.code.includes(term) || (item.name && item.name.includes(keyword.trim())))
const raw = keyword.trim()
return options.filter(item =>
item.code.includes(term)
|| (item.name && item.name.includes(raw))
|| (item.region && item.region.includes(raw))
)
}, [options, keyword])
const labelOf = item => `${item.code} ${item.name}${item.region ? ` · ${item.region}` : ''}`
return (
<div className={`fx-picker ${align}`} ref={boxRef}>
<button
@@ -54,6 +63,7 @@ function CurrencyPicker({ value, options, onChange, align }) {
aria-haspopup="listbox"
aria-expanded={open}
>
<CurrencyFlag code={value} width={20} />
<b>{value}</b>
<span>{currencyLabel(value).slice(4) || '—'}</span>
<ChevronDownIcon size={13} />
@@ -78,9 +88,11 @@ function CurrencyPicker({ value, options, onChange, align }) {
type="button"
role="option"
aria-selected={item.code === value}
title={labelOf(item)}
className={`fx-picker-item${item.code === value ? ' active' : ''}`}
onClick={() => { onChange(item.code); setOpen(false) }}
>
<CurrencyFlag code={item.code} width={20} />
<b>{item.code}</b>
<span>{item.name || '—'}</span>
</button>
@@ -105,6 +117,7 @@ function FxCalculator() {
/* 只存「用户正在输入的那一侧」的原始文本,另一侧永远是算出来的。
这样不存在两个输入框互相改、改出抖动的可能。 */
const [entry, setEntry] = useState({ value: '1000', side: 'from' })
const [copied, setCopied] = useState(false)
const panelRef = useRef(null)
const loadedRef = useRef(false)
@@ -164,7 +177,8 @@ function FxCalculator() {
try { localStorage.setItem(PAIR_STORAGE_KEY, JSON.stringify(pair)) } catch { /* 忽略 */ }
}, [pair])
const options = useMemo(() => buildCurrencyOptions(rates, [DEFAULT_FROM, DEFAULT_TO, 'EUR', 'GBP', 'JPY']), [rates])
/* 只给主流 13 种币种(见 currencies.js 的 PRIMARY_CURRENCIES),不跟随接口的全量列表 */
const options = useMemo(() => buildPrimaryCurrencyOptions(), [])
const rate = crossRate(rates, from, to) // 1 from = ? to
const inverse = crossRate(rates, to, from) // 1 to = ? from
const num = parseAmount(entry.value)
@@ -200,6 +214,23 @@ function FxCalculator() {
? new Date(meta.updatedAt).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
: ''
/* 英文大写金额跟随左侧(from)的金额与币种 —— 单证上写的就是报价币种那一侧 */
const words = useMemo(() => {
const value = parseAmount(fromDisplay)
return value === null ? '' : amountToWords(value, from)
}, [fromDisplay, from])
const copyWords = async () => {
if (!words) return
try {
await navigator.clipboard.writeText(words)
setCopied(true)
window.setTimeout(() => setCopied(false), 1600)
} catch {
/* 剪贴板不可用(非安全上下文等)时不打断使用,手动选中也能复制 */
}
}
return (
<div className="fx-calc" ref={panelRef}>
<button
@@ -271,6 +302,20 @@ function FxCalculator() {
: '—'}
</div>
<div className="fx-words">
<div className="fx-words-head">
<span className="fx-words-label">英文大写金额</span>
{words && (
<button type="button" className="fx-copy" onClick={copyWords}>
{copied ? '已复制' : '复制'}
</button>
)}
</div>
<p className={`fx-words-text${words ? '' : ' is-empty'}`}>
{words || '在左侧输入金额后自动生成'}
</p>
</div>
<div className="fx-panel-foot">
{meta?.warning
? <span className="fx-warn">{meta.warning}</span>
+46 -10
View File
@@ -1,24 +1,29 @@
import React, { useEffect, useRef, useState } from 'react'
import { useLocation, useNavigate } from 'react-router-dom'
import axios from 'axios'
import { BotIcon, CloudDbIcon, PlusCircleIcon, SearchIcon } from './Icons'
import { BotIcon, CloudDbIcon, PlusCircleIcon, SearchIcon, TrashIcon } from './Icons'
import { useAuth, roleLabel } from '../auth'
import { useBranding } from '../branding'
import TimeZoneClock from './TimeZoneClock'
import FxCalculator from './FxCalculator'
import { HOME_PATH, PAGE_LABELS } from '../navigation'
const TABS_STORAGE_KEY = 'happy.openTabs'
const MAX_TABS = 10
/* 工作台恒定占第一位,后面最多再挂 4 个页面 */
const MAX_TAB_PAGES = 4
const MAX_TABS = MAX_TAB_PAGES + 1
/* 从本地缓存读取上次打开的页面(只保留仍然存在的路由,工作台恒定在首位) */
function readStoredTabs() {
try {
const raw = JSON.parse(localStorage.getItem(TABS_STORAGE_KEY) || '[]')
const list = Array.isArray(raw) ? raw.filter(path => PAGE_LABELS[path]) : []
return [HOME_PATH, ...list.filter(path => path !== HOME_PATH)]
const list = Array.isArray(raw) ? raw.filter(path => PAGE_LABELS[path] && path !== HOME_PATH) : []
return [HOME_PATH, ...list.slice(0, MAX_TAB_PAGES)]
} catch { return [HOME_PATH] }
}
function GlobalHeader({ onOpenAssistant }) {
function GlobalHeader({ onOpenAssistant, onNavigate }) {
const { user } = useAuth()
const [now, setNow] = useState(new Date())
const [searchOpen, setSearchOpen] = useState(false)
const [query, setQuery] = useState('')
@@ -59,7 +64,16 @@ function GlobalHeader({ onOpenAssistant }) {
const next = openTabs.filter(item => item !== path)
const result = next.length ? next : [HOME_PATH]
setOpenTabs(result)
if (path === location.pathname) navigate(result[0])
if (path === location.pathname) {
/* 退到「最近使用过的另一个页面」,全关光了才回工作台 */
navigate(result.find(item => item !== HOME_PATH) || HOME_PATH)
}
}
/* 清空快捷标签:工作台常驻不动,其余一次性摘掉;当前页若被清掉就回工作台 */
const clearTabs = () => {
setOpenTabs([HOME_PATH])
if (location.pathname !== HOME_PATH && PAGE_LABELS[location.pathname]) navigate(HOME_PATH)
}
useEffect(() => {
@@ -88,11 +102,22 @@ function GlobalHeader({ onOpenAssistant }) {
return () => window.removeEventListener('keydown', onKeyDown)
}, [])
const selectResult = result => { navigate(result.path); setQuery(''); setSearchOpen(false) }
/* 页内所有跳转都走外壳的 guardedNavigate:个人资料有未保存改动时会先弹确认 */
const go = React.useCallback(path => {
if (onNavigate) onNavigate(path)
else navigate(path)
}, [onNavigate, navigate])
const selectResult = result => { go(result.path); setQuery(''); setSearchOpen(false) }
const displayName = user?.display_name || user?.username || '未登录'
const brand = useBranding()
const positionText = user?.position || roleLabel(user?.role)
const avatarText = (displayName || '?').slice(0, 1).toUpperCase()
return (
<header className="global-header">
<div className="global-header-brand"><span className="brand-mark" title="happy出口通"><CloudDbIcon size={36} /><span className="header-status-dot" /></span><div><strong>happy出口通</strong><span>EXPORT OPERATIONS CONSOLE</span></div></div>
<div className="global-header-brand"><span className="brand-mark" title={brand.name}><CloudDbIcon size={36} /><span className="header-status-dot" /></span><div><strong>{brand.name}</strong><span>{brand.subtitle}</span></div></div>
<div className={`global-search ${searchOpen ? 'is-open' : ''}`}>
<SearchIcon size={17} />
<input type="text" ref={searchRef} aria-label="搜索 everything" value={query} onFocus={() => setSearchOpen(true)} onChange={event => setQuery(event.target.value)} placeholder="搜索 everything" autoComplete="off" />
@@ -110,13 +135,24 @@ function GlobalHeader({ onOpenAssistant }) {
const active = location.pathname === path
return (
<div key={path} className={`page-tab${active ? ' active' : ''}`}>
<button type="button" className="page-tab-label" title={label} aria-current={active ? 'page' : undefined} onClick={() => navigate(path)}>{label}</button>
<button type="button" className="page-tab-label" title={label} aria-current={active ? 'page' : undefined} onClick={() => go(path)}>{label}</button>
{!isHome && <button type="button" className="page-tab-close" aria-label={`关闭${label}`} title={`关闭${label}`} onClick={event => closeTab(event, path)}>×</button>}
</div>
)
})}
</nav>
<div className="global-header-actions"><TimeZoneClock now={now} /><FxCalculator /><button className="quick-create-btn" title="快速新建"><PlusCircleIcon size={19} /><span>快速新建</span></button><button className="ai-entry-btn" onClick={onOpenAssistant} title="打开小皮"><BotIcon size={19} /><span>小皮</span><i>β</i></button><button 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>
{openTabs.length > 1 && (
<button
type="button"
className="page-tabs-clear"
onClick={clearTabs}
aria-label="清空快捷标签"
title="清空快捷标签(工作台保留)"
>
<TrashIcon size={14} />
</button>
)}
<div className="global-header-actions"><TimeZoneClock now={now} /><FxCalculator /><button className="quick-create-btn" title="快速新建"><PlusCircleIcon size={19} /><span>快速新建</span></button><button className="ai-entry-btn" onClick={onOpenAssistant} title="打开小皮"><BotIcon size={19} /><span>小皮</span><i>β</i></button><button type="button" className="header-user" onClick={() => go('/profile')} title={`${displayName} · ${positionText} · 个人资料`} aria-label="个人资料"><span className="header-avatar">{user?.avatar ? <img src={user.avatar} alt="" /> : avatarText}</span><span className="header-user-text"><b>{displayName}</b><small>{positionText}</small></span></button><button className="mobile-search-btn" onClick={() => { setSearchOpen(v => !v); window.setTimeout(() => searchRef.current?.focus(), 0) }} title="搜索"><SearchIcon size={18} /></button></div>
</header>
)
}
+160
View File
@@ -191,6 +191,23 @@ export const PlusCircleIcon = ({ size = 20, className = '' }) => (
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>
)
/* 小皮专属标志 —— 跟 lucide 通用 BotIcon 区分:
* - 头部加 16% 浅填充(视觉重量更足,缩略尺寸下也认得出)
* - 眼睛从「点」改「实心圆」(更可爱)
* - 嘴巴从「横线」改「弧线」(更友善)
* - 去掉 lucide Bot 两侧那两根短横线(视觉上像「耳朵」又不像,反而显得莫名)
* - 头顶触角末端加一颗实心小星点(暗示 AI) */
export const AIBotIcon = ({ size = 20, 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="4" y="9" width="16" height="11" rx="3.5" fill="currentColor" fillOpacity="0.16" />
<path d="M12 5v4" />
<circle cx="12" cy="4.5" r="0.9" fill="currentColor" stroke="none" />
<circle cx="9" cy="14" r="1.2" fill="currentColor" stroke="none" />
<circle cx="15" cy="14" r="1.2" fill="currentColor" stroke="none" />
<path d="M9.5 17.2c.6.7 4.4.7 5 0" />
</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>
)
@@ -244,6 +261,22 @@ export const QuoteIcon = ({ size = 20, className = '' }) => (
</svg>
)
/* 询盘:来信气泡 + 内含报价行 */
export const InquiryIcon = ({ 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="M21 12.4a7.6 7.6 0 0 1-7.6 7.6H8.4L3 22.2V8.8A7.6 7.6 0 0 1 10.6 1.2h2.8A7.6 7.6 0 0 1 21 8.8z" />
<path d="M8 8.6h8M8 12h5" />
</svg>
)
/* 合同清洗:漏斗过滤 + 落料 */
export const CleanupIcon = ({ 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="M3 4.4h18l-7.2 8.4v6.2l-3.6 2.2v-8.4z" />
<path d="M6.6 8.1h10.8" />
</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">
@@ -329,3 +362,130 @@ export const PencilIcon = ({ size = 16, className = '' }) => (
<path d="m14.7 5.5 3.8 3.8" />
</svg>
)
/* 返回 / 左箭头 */
export const ArrowLeftIcon = ({ 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="M19 12H5" />
<path d="m11 18-6-6 6-6" />
</svg>
)
/* 盾牌 / 权限 */
export const ShieldIcon = ({ 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="M12 3l7 3v6c0 4.3-2.9 7.8-7 9-4.1-1.2-7-4.7-7-9V6z" />
<path d="m9 12 2.2 2.2L15.5 10" />
</svg>
)
/* 地球 / 公海 */
export const GlobeIcon = ({ 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="M3.2 9h17.6M3.2 15h17.6" />
<path d="M12 3c2.4 2.5 3.6 5.5 3.6 9s-1.2 6.5-3.6 9c-2.4-2.5-3.6-5.5-3.6-9S9.6 5.5 12 3z" />
</svg>
)
/* 单人 / 个人资料 */
export const UserIcon = ({ 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="8.2" r="3.7" />
<path d="M4.6 20c.6-3.6 3.6-5.6 7.4-5.6s6.8 2 7.4 5.6" />
</svg>
)
/* 钥匙 / 密码 */
export const KeyIcon = ({ 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">
<circle cx="8" cy="15" r="4" />
<path d="m11 12 8.5-8.5M17 6l2 2M14.5 8.5l2 2" />
</svg>
)
/* 建筑 / 组织 */
export const BuildingIcon = ({ 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="M4 21V5.2A1.2 1.2 0 0 1 5.2 4h8.6a1.2 1.2 0 0 1 1.2 1.2V21" />
<path d="M15 10h3.8A1.2 1.2 0 0 1 20 11.2V21M2.5 21h19" />
<path d="M7.5 8h4M7.5 12h4M7.5 16h4" />
</svg>
)
/* 保存 / 勾选存盘 */
export const SaveIcon = ({ 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="M5 3.8h11L20.2 8v12.2A1.8 1.8 0 0 1 18.4 22H5a1.8 1.8 0 0 1-1.8-1.8V5.6A1.8 1.8 0 0 1 5 3.8z" />
<path d="M7.6 3.8v5.4h7.6V3.8M7.6 22v-6.2h8.8V22" />
</svg>
)
/* 登出 */
export const LogoutIcon = ({ 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="M14.5 4.5H6.8A1.8 1.8 0 0 0 5 6.3v11.4a1.8 1.8 0 0 0 1.8 1.8h7.7" />
<path d="M18 12H10M15.4 8.6 18.8 12l-3.4 3.4" />
</svg>
)
/* 相机 / 换头像 */
export const CameraIcon = ({ 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 8.4h3l1.4-2.2h6.2L15.5 8.4h4A1.5 1.5 0 0 1 21 9.9v8.2a1.5 1.5 0 0 1-1.5 1.5h-15A1.5 1.5 0 0 1 3 18.1V9.9a1.5 1.5 0 0 1 1.5-1.5z" />
<circle cx="12" cy="13.6" r="3.2" />
</svg>
)
/* 眼睛 / 显示密码 */
export const EyeIcon = ({ size = 16, className = '' }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true">
<path d="M2.5 12S6 5.8 12 5.8 21.5 12 21.5 12 18 18.2 12 18.2 2.5 12 2.5 12z" />
<circle cx="12" cy="12" r="3" />
</svg>
)
/* 眼睛(划线)/ 隐藏密码 */
export const EyeOffIcon = ({ size = 16, className = '' }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true">
<path d="M4 4l16 16" />
<path d="M9.6 5.9A9.6 9.6 0 0 1 12 5.8c6 0 9.5 6.2 9.5 6.2a17 17 0 0 1-2.7 3.5M6.4 8.2A16.8 16.8 0 0 0 2.5 12S6 18.2 12 18.2c1.3 0 2.4-.3 3.4-.7" />
<path d="M10 10.2a3 3 0 0 0 4 4" />
</svg>
)
/* 加号 / 新增 */
export const PlusIcon = ({ size = 16, className = '' }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true">
<path d="M12 5v14M5 12h14" />
</svg>
)
/* 上传 / 上传者 */
export const UploadIcon = ({ size = 14, className = '' }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true">
<path d="M12 16V4" />
<path d="m7.5 8.5 4.5-4.5 4.5 4.5" />
<path d="M4 15v3.5A1.5 1.5 0 0 0 5.5 20h13a1.5 1.5 0 0 0 1.5-1.5V15" />
</svg>
)
/* 副本 / 复制 */
export const CopyIcon = ({ size = 14, className = '' }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true">
<rect x="9" y="9" width="11" height="11" rx="2" />
<path d="M15 6.5V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v7a2 2 0 0 0 2 2h.5" />
</svg>
)
/* 操作人 / 用户痕迹 */
export const FingerprintIcon = ({ size = 14, 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="M12 4a8 8 0 0 0-8 8v3" />
<path d="M20 15v-3a8 8 0 0 0-4-6.93" />
<path d="M12 11a1.5 1.5 0 0 0-1.5 1.5V17a3 3 0 0 0 3 3" />
<path d="M16 17v-4.5a4 4 0 0 0-2-3.46" />
<path d="M7 17.5V12a5 5 0 0 1 7.5-4.33" />
</svg>
)
@@ -0,0 +1,145 @@
import React, { useCallback, useEffect, useState } from 'react'
import axios from 'axios'
import { CheckIcon, CloseIcon, RefreshIcon, SaveIcon, UsersIcon } from './Icons'
/**
* 组织与品牌 —— 只管「公司 / 组织」这块**可改**的业务信息。
*
* 界面上的系统名(登录页、顶栏、浏览器标题)是作者固定署名,由后端
* `lib/branding.js` 提供:设置页不展示、也没有修改入口,改公司名也不会顶掉它。
* (Wayne 明确要求设置页里不要出现系统标识展示与任何解释文字,别再往这加。)
*/
function OrgBrandingSettings() {
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [notice, setNotice] = useState('')
const [orgs, setOrgs] = useState([])
const [orgDraft, setOrgDraft] = useState({})
const [busy, setBusy] = useState('')
const load = useCallback(async () => {
setLoading(true)
try {
const { data } = await axios.get('/api/organizations')
const list = data?.organizations || []
setOrgs(list)
const draft = {}
for (const org of list) {
draft[org.id] = { name: org.name || '', code: org.code || '', description: org.description || '' }
}
setOrgDraft(draft)
setError('')
} catch (err) {
setError(err?.response?.data?.error || '读取组织信息失败')
} finally {
setLoading(false)
}
}, [])
useEffect(() => { load() }, [load])
const flash = (text) => {
setNotice(text)
window.setTimeout(() => setNotice(''), 3500)
}
const saveOrg = async (id) => {
setBusy(`org:${id}`)
try {
const { data } = await axios.patch(`/api/organizations/${id}`, orgDraft[id])
setOrgs(current => current.map(org => (org.id === id ? { ...org, ...data.organization } : org)))
flash(`公司名已改为「${data.organization.name}」`)
} catch (err) {
setError(err?.response?.data?.error || '保存组织信息失败')
} finally {
setBusy('')
}
}
if (loading) {
return (
<div className="settings-section">
<h3>组织与品牌</h3>
<p className="settings-description">正在读取组织信息…</p>
</div>
)
}
return (
<div className="settings-section">
<h3>组织与品牌</h3>
<p className="settings-description">
公司名可以随时改;界面上的系统名由作者固定署名,不随公司名变化。
</p>
{notice && <p className="ai-banner is-ok"><CheckIcon size={14} /> {notice}</p>}
{error && <p className="ai-banner is-error"><CloseIcon size={14} /> {error}</p>}
<div className="settings-group">
<div className="ai-group-head">
<h4>公司 / 组织</h4>
<button className="btn btn-secondary btn-sm" onClick={load} disabled={Boolean(busy)}>
<RefreshIcon size={14} /> 刷新
</button>
</div>
<p className="help-text">
<UsersIcon size={13} /> 同事归属哪个组织,就与同组织成员共享客户公海与产品库。
</p>
{orgs.length === 0 && <p className="help-text">还没有组织记录。</p>}
{orgs.map(org => (
<div className="org-card" key={org.id}>
<div className="form-row">
<div className="form-group">
<label>组织名称</label>
<input
type="text"
value={orgDraft[org.id]?.name ?? ''}
onChange={event => setOrgDraft(current => ({
...current,
[org.id]: { ...current[org.id], name: event.target.value }
}))}
/>
</div>
<div className="form-group">
<label>英文代码</label>
<input
type="text"
value={orgDraft[org.id]?.code ?? ''}
onChange={event => setOrgDraft(current => ({
...current,
[org.id]: { ...current[org.id], code: event.target.value }
}))}
placeholder="例如 HAPPY"
/>
</div>
</div>
<div className="form-group">
<label>描述</label>
<input
type="text"
value={orgDraft[org.id]?.description ?? ''}
onChange={event => setOrgDraft(current => ({
...current,
[org.id]: { ...current[org.id], description: event.target.value }
}))}
placeholder="这行说明会显示在权限组/用户的组织下拉里"
/>
</div>
<div className="org-card-foot">
<span className="help-text">{org.member_count || 0} 名成员</span>
<button
className="btn btn-primary btn-sm"
onClick={() => saveOrg(org.id)}
disabled={Boolean(busy) || orgDraft[org.id]?.name === org.name}
>
<SaveIcon size={14} /> {busy === `org:${org.id}` ? '保存中…' : '保存公司信息'}
</button>
</div>
</div>
))}
</div>
</div>
)
}
export default OrgBrandingSettings
+263
View File
@@ -0,0 +1,263 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import axios from 'axios'
import { PackageIcon, RefreshIcon } from './Icons'
/* 常用柜型内容积(m³)。取行业常用的保守值,宁少不溢柜。 */
const CONTAINERS = [
{ key: '20GP', label: '20GP', volume: 28 },
{ key: '40GP', label: '40GP', volume: 58 },
{ key: '40HQ', label: '40HQ', volume: 68 }
]
const FIELDS = [
{ key: 'inner_pack_qty', label: '内盒装量', unit: 'pcs/内盒', group: 'pack' },
{ key: 'carton_qty', label: '外箱装量', unit: 'pcs/箱', group: 'pack' },
{ key: 'carton_length', label: '外箱长', unit: 'cm', group: 'carton' },
{ key: 'carton_width', label: '外箱宽', unit: 'cm', group: 'carton' },
{ key: 'carton_height', label: '外箱高', unit: 'cm', group: 'carton' },
{ key: 'carton_weight', label: '外箱毛重', unit: 'kg', group: 'carton' },
{ key: 'unit_length', label: '单品长', unit: 'cm', group: 'unit' },
{ key: 'unit_width', label: '单品宽', unit: 'cm', group: 'unit' },
{ key: 'unit_height', label: '单品高', unit: 'cm', group: 'unit' },
{ key: 'unit_weight', label: '单品净重', unit: 'kg', group: 'unit' }
]
const EMPTY = FIELDS.reduce((acc, field) => { acc[field.key] = ''; return acc }, { packaging_material: '', notes: '' })
/** 算外箱体积与各柜型装柜量;字段不全就返回 null,不猜。 */
function calcLoading(form) {
const l = Number(form.carton_length)
const w = Number(form.carton_width)
const h = Number(form.carton_height)
const perCarton = Number(form.carton_qty)
if (![l, w, h].every(value => Number.isFinite(value) && value > 0)) return null
const cartonVolume = (l * w * h) / 1000000
if (!(cartonVolume > 0)) return null
const rows = CONTAINERS.map(container => {
const cartons = Math.floor(container.volume / cartonVolume)
return {
...container,
cartons,
pieces: Number.isFinite(perCarton) && perCarton > 0 ? cartons * perCarton : null
}
})
let totalWeight = null
const gross = Number(form.carton_weight)
if (Number.isFinite(gross) && gross > 0) totalWeight = rows.map(row => row.cartons * gross)
return { cartonVolume, rows, totalWeight }
}
/** 把接口返回的对象转成受控表单值(null → 空串,避免 React 受控告警) */
function toForm(data) {
const next = { ...EMPTY }
FIELDS.forEach(field => {
const value = data?.[field.key]
next[field.key] = value === null || value === undefined ? '' : String(value)
})
next.packaging_material = data?.packaging_material || ''
next.notes = data?.notes || ''
return next
}
/**
* 结构化包装信息面板。
* 只负责给「包装」这块数据提供录入与展示,SKU 是否存在由调用方保证
* (后端 PUT 会自己向 SKU 服务核验一次)。
*/
function PackagingPanel({ sku, readOnly = false, onSaved, canEdit }) {
const [form, setForm] = useState(EMPTY)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
const [notice, setNotice] = useState('')
const [available, setAvailable] = useState(true)
const load = useCallback(async () => {
if (!sku) return
setLoading(true)
setError('')
try {
const { data } = await axios.get(`/api/products/${encodeURIComponent(sku)}/packaging`)
setForm(toForm(data))
setAvailable(true)
} catch (loadError) {
setAvailable(false)
setError(loadError?.response?.data?.error || '读取包装信息失败')
} finally {
setLoading(false)
}
}, [sku])
useEffect(() => { load() }, [load])
useEffect(() => { setNotice('') }, [sku])
const loadingPlan = useMemo(() => calcLoading(form), [form])
// 空白输入一律按「未填」处理,不要变成 0 —— 0 和没填在装柜测算里含义完全不同
const set = (key, value) => setForm(current => ({ ...current, [key]: value }))
const save = async (event) => {
event.preventDefault()
if (!sku) return
setSaving(true)
setError('')
setNotice('')
const payload = {}
FIELDS.forEach(field => {
const raw = form[field.key]
payload[field.key] = raw === '' ? null : Number(raw)
})
payload.packaging_material = form.packaging_material.trim() || null
payload.notes = form.notes.trim() || null
try {
const { data } = await axios.put(`/api/products/${encodeURIComponent(sku)}/packaging`, payload)
setForm(toForm(data.packaging))
setNotice('包装信息已保存')
if (onSaved) onSaved(data.packaging)
} catch (saveError) {
setError(saveError?.response?.data?.error || '保存失败,请稍后重试')
} finally {
setSaving(false)
}
}
if (loading) return <p className="pkg-loading">读取包装信息…</p>
if (!available) {
return (
<div className="pkg-unavailable">
<p>{error}</p>
<button type="button" className="btn btn-secondary btn-sm" onClick={load}>
<RefreshIcon size={14} /> 重试
</button>
</div>
)
}
return (
<form className="pkg-panel" onSubmit={save}>
<div className="pkg-section">
<h4 className="pkg-section-title"><PackageIcon size={14} /> 装箱结构</h4>
<div className="pkg-grid">
{FIELDS.filter(field => field.group === 'pack').map(field => (
<label className="pkg-field" key={field.key}>
<span>{field.label}</span>
<span className="pkg-input">
<input
type="number" min="0" step="1" inputMode="numeric"
value={form[field.key]} disabled={readOnly}
onChange={event => set(field.key, event.target.value)}
/>
<em>{field.unit}</em>
</span>
</label>
))}
</div>
</div>
<div className="pkg-section">
<h4 className="pkg-section-title">外箱尺寸与重量</h4>
<div className="pkg-grid">
{FIELDS.filter(field => field.group === 'carton').map(field => (
<label className="pkg-field" key={field.key}>
<span>{field.label}</span>
<span className="pkg-input">
<input
type="number" min="0" step="0.1" inputMode="decimal"
value={form[field.key]} disabled={readOnly}
onChange={event => set(field.key, event.target.value)}
/>
<em>{field.unit}</em>
</span>
</label>
))}
</div>
</div>
<div className="pkg-section">
<h4 className="pkg-section-title">单品尺寸与重量</h4>
<div className="pkg-grid">
{FIELDS.filter(field => field.group === 'unit').map(field => (
<label className="pkg-field" key={field.key}>
<span>{field.label}</span>
<span className="pkg-input">
<input
type="number" min="0" step="0.1" inputMode="decimal"
value={form[field.key]} disabled={readOnly}
onChange={event => set(field.key, event.target.value)}
/>
<em>{field.unit}</em>
</span>
</label>
))}
</div>
</div>
<div className="pkg-section">
<h4 className="pkg-section-title">包装与备注</h4>
<div className="pkg-grid">
<label className="pkg-field pkg-field-wide">
<span>包装材质</span>
<input
type="text" value={form.packaging_material} disabled={readOnly}
placeholder="例如:彩盒 + 五层瓦楞纸箱"
onChange={event => set('packaging_material', event.target.value)}
/>
</label>
<label className="pkg-field pkg-field-wide">
<span>包装备注</span>
<input
type="text" value={form.notes} disabled={readOnly}
placeholder="唛头要求、客户指定箱规等"
onChange={event => set('notes', event.target.value)}
/>
</label>
</div>
</div>
<div className="pkg-section">
<h4 className="pkg-section-title">装柜测算</h4>
{!loadingPlan ? (
<p className="pkg-loading-hint">填好外箱长宽高即可自动推算各柜型装柜量。</p>
) : (
<>
<p className="pkg-volume">
单箱体积 <b>{loadingPlan.cartonVolume.toFixed(4)}</b> m³
</p>
<div className="pkg-container-grid">
{loadingPlan.rows.map((row, index) => (
<div className="pkg-container" key={row.key}>
<span className="pkg-container-name">{row.label}</span>
<span className="pkg-container-main">
<b>{row.cartons.toLocaleString('zh-CN')}</b> 箱
</span>
<span className="pkg-container-sub">
{row.pieces === null ? '装量未知' : `${row.pieces.toLocaleString('zh-CN')} pcs`}
{loadingPlan.totalWeight ? ` · 毛重 ${(loadingPlan.totalWeight[index] / 1000).toFixed(2)} t` : ''}
</span>
</div>
))}
</div>
<p className="pkg-caveat">按柜内容积 28 / 58 / 68 m³ 估算,未计托盘与装载空隙,实际订舱请以货代确认为准。</p>
</>
)}
</div>
{error && <p className="pkg-error">{error}</p>}
{notice && <p className="pkg-notice">{notice}</p>}
{!readOnly && canEdit && (
<div className="pkg-actions">
<button type="button" className="btn btn-secondary btn-sm" onClick={load} disabled={saving}>
<RefreshIcon size={14} /> 还原
</button>
<button type="submit" className="btn btn-primary btn-sm" disabled={saving}>
{saving ? '保存中…' : '保存包装信息'}
</button>
</div>
)}
</form>
)
}
export default PackagingPanel
+260
View File
@@ -0,0 +1,260 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { SearchIcon, ChevronDownIcon, CloseIcon } from './Icons'
/**
* 可搜索下拉(combobox)
*
* 供应商 272 家、产品 300+ 个,原生 <select> 只能一格一格滚。这里把触发区做成
* 一个搜索框:点开就能打字,候选实时收窄,↑↓ 选择、回车确认、Esc 收起。
*
* 实现要点:
* - 菜单用 createPortal 挂到 body + position:fixed —— 抽屉的 .drawer-body 是
* overflow:auto,就地绝对定位会被它裁掉;fixed 还能跟着滚动/缩放重新定位。
* - 触发区沿用全局「无方框」输入框规则:靠底色区分,不做描边与聚焦光环。
* - 展开时按实时位置决定朝上还是朝下弹,空间不够就压 max-height 自身滚动。
*
* props
* value / onChange(value, option)
* options [{ value, label, hint? }],hint 显示在右侧(灰)
* placeholder 未选中时的提示
* searchPlaceholder 展开后的提示
* emptyText 无匹配时的文案
* size 'md'(38px) | 'sm'(32px)
* clearable 选中后是否给出清除按钮
*/
const MENU_MAX = 268
const GAP = 6
function SearchSelect({
value,
onChange,
options = [],
placeholder = '请选择',
searchPlaceholder = '输入关键词搜索…',
emptyText = '没有匹配项',
size = 'md',
disabled = false,
clearable = false,
style,
className = ''
}) {
const [open, setOpen] = useState(false)
const [query, setQuery] = useState('')
const [active, setActive] = useState(0)
const [pos, setPos] = useState(null)
const rootRef = useRef(null)
const menuRef = useRef(null)
const inputRef = useRef(null)
const listRef = useRef(null)
const selected = useMemo(
() => options.find((o) => String(o.value) === String(value ?? '')) || null,
[options, value]
)
const filtered = useMemo(() => {
const kw = query.trim().toLowerCase()
if (!kw) return options
return options.filter((o) => `${o.label || ''} ${o.hint || ''}`.toLowerCase().includes(kw))
}, [options, query])
/* 按触发区的实时位置定位菜单:下方空间不够就翻到上面 */
const place = useCallback(() => {
const el = rootRef.current
if (!el) return
const r = el.getBoundingClientRect()
const spaceBelow = window.innerHeight - r.bottom - GAP - 8
const spaceAbove = r.top - GAP - 8
const up = spaceBelow < 180 && spaceAbove > spaceBelow
setPos({
left: r.left,
width: r.width,
top: up ? undefined : r.bottom + GAP,
bottom: up ? window.innerHeight - r.top + GAP : undefined,
maxHeight: Math.max(120, Math.min(MENU_MAX, up ? spaceAbove : spaceBelow))
})
}, [])
const close = useCallback(() => {
setOpen(false)
setQuery('')
}, [])
const openMenu = useCallback(() => {
if (disabled || open) return
place()
setQuery('')
const idx = options.findIndex((o) => String(o.value) === String(value ?? ''))
setActive(idx >= 0 ? idx : 0)
setOpen(true)
}, [disabled, open, options, place, value])
const pick = useCallback((option) => {
onChange?.(option.value, option)
setOpen(false)
setQuery('')
}, [onChange])
/* 展开期间:点外部收起、滚动/缩放时跟住触发区 */
useEffect(() => {
if (!open) return
const onDocDown = (e) => {
if (rootRef.current?.contains(e.target)) return
if (menuRef.current?.contains(e.target)) return
close()
}
const onReflow = () => place()
document.addEventListener('mousedown', onDocDown)
window.addEventListener('resize', onReflow)
window.addEventListener('scroll', onReflow, true)
return () => {
document.removeEventListener('mousedown', onDocDown)
window.removeEventListener('resize', onReflow)
window.removeEventListener('scroll', onReflow, true)
}
}, [open, close, place])
/* 键盘走到看不见的项时,把它滚进视野 */
useEffect(() => {
if (!open) return
const el = listRef.current?.querySelector('.ss-opt.is-active')
el?.scrollIntoView({ block: 'nearest' })
}, [open, active])
const onKeyDown = (e) => {
// Esc 只收候选:必须拦住,否则抽屉/浮层的全局 Esc 会顺手把整层关掉
if (e.key === 'Escape') { e.stopPropagation(); close(); return }
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault()
if (!open) { openMenu(); return }
if (filtered.length === 0) return
setActive((i) => {
const next = e.key === 'ArrowDown' ? i + 1 : i - 1
return (next + filtered.length) % filtered.length
})
return
}
if (e.key === 'Enter') {
if (!open) return
e.preventDefault()
const option = filtered[active]
if (option) pick(option)
}
}
const showClear = clearable && !disabled && selected
return (
<div
ref={rootRef}
className={`ss-root ss-${size}${open ? ' is-open' : ''}${disabled ? ' is-disabled' : ''}${className ? ` ${className}` : ''}`}
style={style}
onMouseDown={(e) => {
if (disabled) return
if (e.target.closest('.ss-clear')) return
if (inputRef.current && document.activeElement !== inputRef.current) inputRef.current.focus()
if (!open) openMenu()
}}
// mousedown 之外再兜一层 click:真实鼠标两者都会来(第二次是 no-op),
// 但程序化 el.click() / 触摸设备只发 click,不补这层会「点了没反应」
onClick={() => { if (!open) openMenu() }}
>
<span className="ss-icon" aria-hidden="true"><SearchIcon size={size === 'sm' ? 13 : 14} /></span>
<input
ref={inputRef}
type="text"
value={open ? query : (selected?.label || '')}
placeholder={open ? searchPlaceholder : placeholder}
readOnly={!open}
disabled={disabled}
autoComplete="off"
role="combobox"
aria-expanded={open}
aria-autocomplete="list"
onChange={(e) => { setQuery(e.target.value); setActive(0) }}
onFocus={openMenu}
onKeyDown={onKeyDown}
/>
{showClear && (
<button
type="button"
className="ss-clear"
title="清除"
aria-label="清除"
onMouseDown={(e) => e.preventDefault()}
onClick={() => { onChange?.('', null); setQuery(''); inputRef.current?.focus() }}
>
<CloseIcon size={12} />
</button>
)}
<span className={`ss-caret${open ? ' is-up' : ''}`} aria-hidden="true"><ChevronDownIcon size={13} /></span>
{open && pos && createPortal(
<div
ref={menuRef}
className="ss-menu"
role="listbox"
style={{ left: pos.left, width: pos.width, top: pos.top, bottom: pos.bottom, maxHeight: pos.maxHeight }}
>
{filtered.length === 0 ? (
<div className="ss-empty">{emptyText}</div>
) : (
<div ref={listRef}>
{filtered.map((o, i) => (
<div
key={String(o.value)}
role="option"
aria-selected={i === active}
className={`ss-opt${i === active ? ' is-active' : ''}${selected && String(o.value) === String(value ?? '') ? ' is-selected' : ''}`}
onMouseEnter={() => setActive(i)}
onMouseDown={(e) => { e.preventDefault(); pick(o) }}
onClick={() => pick(o)}
>
<span className="ss-label">{o.label}</span>
{o.hint != null && o.hint !== '' && <span className="ss-hint">{o.hint}</span>}
</div>
))}
</div>
)}
</div>,
document.body
)}
</div>
)
}
/**
* 列表上方的内联搜索框:用于「列表本身」的即时过滤(供应商详情的供货清单、
* 产品详情里的货号表这类)。和 SearchSelect 同一套外观语言。
*/
export function SearchField({ value, onChange, placeholder = '搜索…', size = 'md', style, className = '' }) {
return (
<div className={`sf-root sf-${size}${className ? ` ${className}` : ''}`} style={style}>
<span className="ss-icon" aria-hidden="true"><SearchIcon size={size === 'sm' ? 13 : 14} /></span>
<input
type="text"
value={value}
placeholder={placeholder}
autoComplete="off"
onChange={(e) => onChange(e.target.value)}
/>
{value !== '' && value != null && (
<button
type="button"
className="ss-clear"
title="清除"
aria-label="清除"
onMouseDown={(e) => e.preventDefault()}
onClick={() => onChange('')}
>
<CloseIcon size={12} />
</button>
)}
</div>
)
}
export default SearchSelect
+49 -40
View File
@@ -1,10 +1,34 @@
import React, { useState, useEffect, useRef } from 'react'
import React, { useState, useEffect, useRef, Suspense } from 'react'
import axios from 'axios'
import { MoonStarsIcon, SunIcon, TuneIcon, CheckIcon, CloseIcon, ExchangeIcon, SparkIcon, RefreshIcon } from './Icons'
import { MoonStarsIcon, SunIcon, TuneIcon, CheckIcon, CloseIcon, ExchangeIcon, SparkIcon, RefreshIcon, BuildingIcon } from './Icons'
import { useAuth, hasPermission } from '../auth'
import { useBranding } from '../branding'
/* 这两块只有系统管理账号会看,按需加载:设置本身在外壳里是静态引入的,
跟着首屏一起下发等于给所有人多下载十几 KB。 */
const AIConfigSettings = React.lazy(() => import('./AIConfigSettings'))
const OrgBrandingSettings = React.lazy(() => import('./OrgBrandingSettings'))
/* 设置分类。带 permission 的项只对有该权限点的人显示 ——
智能配置里有接口地址与密钥,组织品牌是系统管理动作,都不该摆在业务员眼前。 */
const CATEGORIES = [
{ key: 'appearance', label: '外观与主题', icon: TuneIcon },
{ key: 'organization', label: '组织与品牌', icon: BuildingIcon, permission: 'branding.manage' },
{ key: 'import-export', label: '数据交换', icon: ExchangeIcon },
{ key: 'ai-config', label: '智能配置', icon: SparkIcon, permission: 'ai.manage' },
{ key: 'system-update', label: '系统与更新', icon: RefreshIcon }
]
function Settings({ isOpen, onClose, theme, onThemeChange, reducedMotion, onReducedMotionChange }) {
const { user } = useAuth()
const [activeCategory, setActiveCategory] = useState('appearance')
const visibleCategories = CATEGORIES.filter(item => !item.permission || hasPermission(user, item.permission))
/* 打开着设置时权限被收回(换了角色),当前分类可能已经不可见,退回第一项 */
const current = visibleCategories.some(item => item.key === activeCategory)
? activeCategory
: visibleCategories[0]?.key
const modalRef = useRef(null)
useEffect(() => {
if (!isOpen) return
@@ -39,37 +63,36 @@ function Settings({ isOpen, onClose, theme, onThemeChange, reducedMotion, onRedu
<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>
<button className={`settings-nav-item ${activeCategory === 'system-update' ? 'active' : ''}`} onClick={() => setActiveCategory('system-update')}>
<span className="settings-nav-icon"><RefreshIcon size={18} /></span>
<span>系统与更新</span>
</button>
{visibleCategories.map(item => {
const Icon = item.icon
return (
<button
key={item.key}
className={`settings-nav-item ${current === item.key ? 'active' : ''}`}
onClick={() => setActiveCategory(item.key)}
>
<span className="settings-nav-icon"><Icon size={17} /></span>
<span>{item.label}</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 />}
{activeCategory === 'system-update' && <SystemUpdateSettings />}
<Suspense fallback={<div className="settings-section"><p className="settings-description">正在载入…</p></div>}>
{current === 'appearance' && <AppearanceSettings theme={theme} onThemeChange={onThemeChange} reducedMotion={reducedMotion} onReducedMotionChange={onReducedMotionChange} />}
{current === 'organization' && <OrgBrandingSettings />}
{current === 'import-export' && <ImportExportSettings />}
{current === 'ai-config' && <AIConfigSettings />}
{current === 'system-update' && <SystemUpdateSettings />}
</Suspense>
</section>
</div>
</div>
@@ -79,7 +102,6 @@ function Settings({ isOpen, onClose, theme, onThemeChange, reducedMotion, onRedu
function AppearanceSettings({ theme, onThemeChange, reducedMotion, onReducedMotionChange }) {
return (
<div className="settings-section">
<span className="section-label">[APPEARANCE]</span>
<h3>外观与主题</h3>
<p className="settings-description">选择适合工作环境的显示模式,设置会自动保存在本机。</p>
@@ -109,7 +131,6 @@ function AppearanceSettings({ theme, onThemeChange, reducedMotion, onReducedMoti
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>
@@ -119,19 +140,7 @@ function ImportExportSettings() {
)
}
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>
)
}
/* 智能配置已拆到 ./AIConfigSettings(多模型接入 + 用途分配)与 ./OrgBrandingSettings(组织与品牌) */
/* ---------------------------------------------------------------------------
系统与更新
@@ -139,6 +148,7 @@ function AIConfigSettings() {
版本号以顶层 package.json 为准;远端取 Release(优先)或 tag。
--------------------------------------------------------------------------- */
function SystemUpdateSettings() {
const brand = useBranding()
const [info, setInfo] = useState(null)
const [repo, setRepo] = useState('')
const [loadingInfo, setLoadingInfo] = useState(true)
@@ -194,7 +204,6 @@ function SystemUpdateSettings() {
return (
<div className="settings-section">
<span className="section-label">[SYSTEM]</span>
<h3>系统与更新</h3>
<p className="settings-description">查看当前版本与运行环境,并从官方仓库检查是否有新版本。</p>
@@ -202,7 +211,7 @@ function SystemUpdateSettings() {
<h4>当前版本</h4>
<div className="setting-info">
<span>
<strong>{info ? `happy 出口通 v${info.version}` : '正在读取版本…'}</strong>
<strong>{info ? `${brand.name} v${info.version}` : '正在读取版本…'}</strong>
<small>{info ? `运行环境 Node ${info.node} · ${info.platform} ${info.arch}` : '读取本机信息中'}</small>
</span>
<span className="preference-badge">正式版</span>
+96
View File
@@ -0,0 +1,96 @@
import React from 'react'
/* ---------------------------------------------------------------------------
* 骨架屏
*
* 为什么要有它:接口回来之前,页面原本只显示一句「正在载入…」。
* 用户看到的是「什么都没有」+ 一行字,主观上就是把整个加载时间都算成了等待。
* 换成与真实内容同构的灰色骨架后,视线先落在结构上,感知到的等待会短很多
* ——这不是玄学,是布局稳定(不会突然跳一下)带来的直接体感差别。
*
* 设计原则:
* · 骨架的行高、列宽必须**贴近真实内容**,否则加载完成时页面会大幅跳动,
* 那比显示一句「正在载入…」还难受。所以宁可少画几行,也不要画得不像。
* · 纯静态 + 透明度呼吸动画,不用渐变扫光(工业风不需要花活,且省 CPU)。
* · 尊重「减少动态效果」设置,开了就静止。
* --------------------------------------------------------------------------- */
/** 骨架基础块。宽高直接给数字(px)或字符串('60%')。 */
export default function Skeleton({ width = '100%', height = 12, radius = 4, className = '', style }) {
return (
<span
className={`skeleton-block ${className}`.trim()}
style={{ width, height, borderRadius: radius, ...style }}
aria-hidden="true"
/>
)
}
/**
* 表格骨架:必须放在 <tbody> 里。
* widths 传每列的相对宽度(数字数组),不传就均分。
* 第一列默认画长一点,因为表格第一列通常是名称/编号。
*/
export function SkeletonTableRows({ rows = 6, widths = null, colSpan = 1 }) {
const cells = widths || [2.2, 1, 1, 1]
const total = cells.reduce((sum, n) => sum + n, 0)
return Array.from({ length: rows }).map((_, rowIndex) => (
<tr key={rowIndex} className="skeleton-row" aria-hidden="true">
{colSpan > 1 ? (
<td colSpan={colSpan} style={{ padding: '12px 14px' }}>
<span className="skeleton-inline">
{cells.map((weight, cellIndex) => (
<Skeleton
key={cellIndex}
width={`${((weight / total) * 100).toFixed(2)}%`}
height={12}
/>
))}
</span>
</td>
) : (
cells.map((weight, cellIndex) => (
<td key={cellIndex} style={{ padding: '12px 14px' }}>
<Skeleton width={`${Math.min(100, (weight / Math.max(...cells)) * 100).toFixed(0)}%`} height={12} />
</td>
))
)}
</tr>
))
}
/**
* 卡片网格骨架:给工作台那种「一排指标卡」用。
* 数量与真实卡片一致,避免加载完成时整页重排。
*/
export function SkeletonCards({ count = 4, height = 76 }) {
return (
<div className="skeleton-cards" aria-hidden="true">
{Array.from({ length: count }).map((_, index) => (
<div key={index} className="skeleton-card">
<Skeleton width="46%" height={11} />
<Skeleton width="68%" height={22} style={{ marginTop: 10 }} />
</div>
))}
</div>
)
}
/**
* 面板骨架:给「一张面板里几行文本」用。
*/
export function SkeletonPanel({ lines = 3, title = true }) {
return (
<div className="skeleton-panel" aria-hidden="true">
{title && <Skeleton width="30%" height={14} style={{ marginBottom: 14 }} />}
{Array.from({ length: lines }).map((_, index) => (
<Skeleton
key={index}
width={index === lines - 1 ? '55%' : '100%'}
height={12}
style={{ marginBottom: 10 }}
/>
))}
</div>
)
}
+168
View File
@@ -0,0 +1,168 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import axios from 'axios'
import { SearchIcon, PackageIcon, CloseIcon, ChevronDownIcon } from './Icons'
import { SkuThumb, useSkuThumbs } from './SkuThumb'
/**
* SKU 选择器
*
* 数据来自 happy 后端自己的 /api/sku(SKU 模块已并入),不直连端口。搜索返回的是「产品组」,
* 点开产品组再看到具体货号 —— 这是 SKU 服务本来的数据结构,硬拍平成一张 SKU 列表
* 会丢掉归属关系,业务上分不清同一个产品的不同规格。
*
* 同时允许直接输入完整货号回车选中:老手知道自己要哪个货号时不该被逼着点两层。
*/
function SkuPicker({ onPick, onClose, excludeSkus = [] }) {
const [keyword, setKeyword] = useState('')
const [groups, setGroups] = useState([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [expanded, setExpanded] = useState(null)
const [itemsCache, setItemsCache] = useState({})
const [itemsLoading, setItemsLoading] = useState(false)
const timerRef = useRef(null)
const excluded = useMemo(() => new Set(excludeSkus.map(sku => String(sku).toUpperCase())), [excludeSkus])
const search = useCallback(async (query) => {
setLoading(true)
setError('')
try {
const { data } = await axios.get('/api/sku/search', { params: { q: query } })
setGroups(Array.isArray(data) ? data : [])
} catch (searchError) {
setGroups([])
setError(searchError?.response?.data?.error || 'SKU 服务暂时不可用,请确认采购模块已启动')
} finally {
setLoading(false)
}
}, [])
// 首次直接拉全量(SKU 服务默认返回前 300 组),输入时再防抖查
useEffect(() => {
search('')
}, [search])
useEffect(() => {
if (timerRef.current) clearTimeout(timerRef.current)
timerRef.current = setTimeout(() => search(keyword.trim()), 260)
return () => { if (timerRef.current) clearTimeout(timerRef.current) }
}, [keyword, search])
const toggleGroup = async (group) => {
if (expanded === group.id) { setExpanded(null); return }
setExpanded(group.id)
if (itemsCache[group.id]) return
setItemsLoading(true)
try {
const { data } = await axios.get(`/api/sku/group/${group.id}`)
setItemsCache(current => ({ ...current, [group.id]: data.items || [] }))
} catch (loadError) {
setError(loadError?.response?.data?.error || '读取产品货号失败')
} finally {
setItemsLoading(false)
}
}
// 输入框里直接敲完整货号时,优先按货号选中,不去猜产品组
const submitDirect = (event) => {
event.preventDefault()
const value = keyword.trim()
if (!value) return
onPick({ sku: value.toUpperCase(), display_sku: value.toUpperCase(), source: 'manual' })
}
return (
<div className="sku-picker">
<div className="sku-picker-head">
<h3><PackageIcon size={16} /> 选择已有货号</h3>
<button type="button" className="icon-btn" onClick={onClose} aria-label="关闭"><CloseIcon size={15} /></button>
</div>
<form className="sku-picker-search" onSubmit={submitDirect}>
<span className="sku-picker-search-icon"><SearchIcon size={15} /></span>
<input
type="text"
autoFocus
value={keyword}
placeholder="搜产品名 / 货号 / 规格,或直接敲完整货号回车"
aria-label="搜索货号"
onChange={event => setKeyword(event.target.value)}
/>
</form>
{error && <p className="sku-picker-error">{error}</p>}
<div className="sku-picker-body">
{loading && !groups.length && <p className="sku-picker-hint">正在读取 SKU 目录…</p>}
{!loading && !error && !groups.length && (
<p className="sku-picker-hint">没有匹配的产品。可以直接敲完整货号回车强制选用。</p>
)}
<ul className="sku-group-list">
{groups.map(group => {
const isOpen = expanded === group.id
const items = itemsCache[group.id] || []
const priceText = group.price_min === null || group.price_min === undefined
? '未报价'
: group.price_min === group.price_max
? `${group.price_min}`
: `${group.price_min} ~ ${group.price_max}`
return (
<li className="sku-group" key={group.id}>
<button
type="button"
className={`sku-group-head${isOpen ? ' is-open' : ''}`}
onClick={() => toggleGroup(group)}
aria-expanded={isOpen}
>
<span className={`sku-group-caret${isOpen ? ' is-open' : ''}`}><ChevronDownIcon size={13} /></span>
<span className="sku-group-name">{group.name || '未命名产品'}</span>
<span className="sku-group-meta">
{group.supplier_short || group.supplier_name || '未指派供应商'}
{' · '}
{group.item_count} 个货号
{' · '}
{priceText}
</span>
</button>
{isOpen && (
<div className="sku-group-items">
{itemsLoading && !items.length && <p className="sku-picker-hint">读取货号中…</p>}
{items.map(item => {
const already = excluded.has(String(item.sku).toUpperCase())
return (
<button
type="button"
key={item.sku}
className={`sku-item${already ? ' is-used' : ''}`}
disabled={already}
onClick={() => onPick(item)}
title={already ? '该货号已在本次询盘中' : `选用 ${item.sku}`}
>
<SkuThumb sku={item.sku} filename={thumbs[item.sku]} size={32} />
<span className="sku-item-code">{item.display_sku || item.sku}</span>
<span className="sku-item-spec">{item.spec || item.name || '—'}</span>
<span className="sku-item-price">
{item.price === null || item.price === undefined
? <em>未报价</em>
: <><b>{item.price}</b><span className="unit"> {item.currency || 'CNY'}</span></>}
</span>
{already && <span className="sku-item-flag">已添加</span>}
</button>
)
})}
{!itemsLoading && !items.length && <p className="sku-picker-hint">这个产品下还没有货号。</p>}
</div>
)}
</li>
)
})}
</ul>
</div>
</div>
)
}
export default SkuPicker
+104
View File
@@ -0,0 +1,104 @@
import React, { useEffect, useMemo, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import axios from 'axios'
import { ImageIcon } from './Icons'
/**
* 货号缩略图 + 鼠标悬浮预览。
*
* 图片来自 happy 后端(4300)的 /product-images/*,同源访问,已无独立 SKU 服务。
* 图片挂在**产品组**上(不是每个货号一张),所以同一个组里的货号会共享同一张主图。
*/
export const thumbSrc = filename => (filename ? `/product-images/${encodeURIComponent(filename)}` : '')
const PREVIEW_WIDTH = 268
const PREVIEW_HEIGHT = 268
/**
* @param {string} sku 货号(用于预览卡上的标注)
* @param {string} filename 图片文件名(不带路径)
* @param {number} size 缩略图边长
* @param {boolean} preview 是否开启悬浮预览
*/
export function SkuThumb({ sku, filename, size = 34, preview = true, className = '' }) {
const [open, setOpen] = useState(false)
const [position, setPosition] = useState(null)
const anchorRef = useRef(null)
const src = thumbSrc(filename)
/* 预览是 fixed 浮层:滚动或改变视口就收起,免得悬在错位的半空中 */
useEffect(() => {
if (!open) return
const close = () => setOpen(false)
window.addEventListener('scroll', close, true)
window.addEventListener('resize', close)
return () => {
window.removeEventListener('scroll', close, true)
window.removeEventListener('resize', close)
}
}, [open])
const show = () => {
const element = anchorRef.current
if (!element || !src) return
const rect = element.getBoundingClientRect()
let left = rect.right + 14
if (left + PREVIEW_WIDTH > window.innerWidth - 12) {
left = Math.max(12, rect.left - PREVIEW_WIDTH - 14)
}
let top = rect.top + rect.height / 2 - PREVIEW_HEIGHT / 2
top = Math.max(12, Math.min(top, window.innerHeight - PREVIEW_HEIGHT - 12))
setPosition({ left, top })
setOpen(true)
}
return (
<>
<span
ref={anchorRef}
className={`sku-thumb${src ? '' : ' is-empty'}${className ? ' ' + className : ''}`}
style={{ width: size, height: size }}
onMouseEnter={preview && src ? show : undefined}
onMouseLeave={preview ? () => setOpen(false) : undefined}
title={sku ? `货号 ${sku}` : undefined}
>
{src
? <img src={src} alt="" loading="lazy" />
: <ImageIcon size={Math.max(13, Math.round(size * 0.42))} />}
</span>
{open && position && createPortal(
<span className="sku-thumb-preview" style={{ left: position.left, top: position.top }}>
<img src={src} alt="" />
{sku ? <b>{sku}</b> : null}
</span>,
document.body
)}
</>
)
}
/**
* 批量取货号缩略图。
* 只关心「货号 → 文件名」这一层映射,一次请求拿一批,避免每个货号打一次接口。
*/
export function useSkuThumbs(skus) {
const [map, setMap] = useState({})
const key = useMemo(
() => [...new Set((skus || []).filter(Boolean).map(item => String(item)))].sort().join(','),
[skus]
)
useEffect(() => {
if (!key) { setMap({}); return }
let alive = true
axios.get('/api/sku/thumbs', { params: { skus: key } })
.then(({ data }) => { if (alive) setMap(data?.thumbs || {}) })
.catch(() => { if (alive) setMap({}) })
return () => { alive = false }
}, [key])
return map
}
export default SkuThumb
+33
View File
@@ -30,6 +30,39 @@ export const CURRENCY_NAMES = {
HRK: '克罗地亚库纳', ALL: '阿尔巴尼亚列克', MKD: '北马其顿第纳尔'
}
/**
* 汇率计算器用的「主流币种」白名单,按 2024 年中国对其出口金额从高到低排。
*
* 排序依据:联合国 COMTRADE / Trading Economics 2024 年中国出口目的地金额。
* 美国 5257 亿 > 中国香港 2910 亿 > 日本 1520 亿 > 俄罗斯 1153 亿 >
* 马来西亚 1015 亿 > 新加坡 791 亿 > 中国台湾 753 亿 > 澳大利亚 708 亿 >
* 阿联酋 656 亿 > 沙特 500 亿 > 加拿大 465 亿。
* 欧元按欧元区合计(德 1071 亿 + 荷 912 亿 + 意 462 亿 + 法 450 亿 + 西 409 亿 …)
* 计入,量级仅次于美国,所以排在美国之后。人民币是本币,按需求紧随美元。
*/
export const PRIMARY_CURRENCIES = [
'USD', 'CNY', 'EUR', 'HKD', 'JPY', 'RUB', 'MYR', 'SGD', 'TWD', 'AUD', 'AED', 'SAR', 'CAD'
]
/** 币种对应的国家 / 地区(中国香港、中国台湾按中国区划标注)。 */
export const CURRENCY_REGIONS = {
USD: '美国', CNY: '中国', EUR: '欧元区', HKD: '中国香港', JPY: '日本',
RUB: '俄罗斯', MYR: '马来西亚', SGD: '新加坡', TWD: '中国台湾', AUD: '澳大利亚',
AED: '阿联酋', SAR: '沙特阿拉伯', CAD: '加拿大'
}
/**
* 汇率计算器的币种下拉:只给主流 13 种,顺序完全由 PRIMARY_CURRENCIES 决定。
* 不用看接口返回的 160+ 币种 —— 这是「常用币种快捷入口」,不是全量币种表。
*/
export function buildPrimaryCurrencyOptions() {
return PRIMARY_CURRENCIES.map(code => ({
code,
name: CURRENCY_NAMES[code] || '',
region: CURRENCY_REGIONS[code] || ''
}))
}
/** 下拉排序权重:外贸结算最常见的排前面。 */
const ORDER = [
'USD', 'CNY', 'EUR', 'GBP', 'HKD', 'JPY', 'AUD', 'CAD', 'SGD', 'CHF', 'NZD', 'KRW',
+99
View File
@@ -0,0 +1,99 @@
import { useEffect, useRef } from 'react'
/**
* 把「浮层 / 编辑态」接入浏览器历史。
*
* 解决什么问题:Chrome / Edge 的**鼠标侧键(后退键)、Alt + ←、触控板双指横滑、
* 系统的返回手势**走的都是浏览器历史(history.back),不是页面里的按钮。
* 所以只要把浮层状态压进历史,这些操作就天然能"退回上一层",
* 不需要去单独做鼠标按键识别(浏览器也不会把侧键事件交给页面)。
*
* 两个关键设计(改之前务必读懂,否则很容易踩出"按几次返回直接退出应用"):
*
* 1. **关闭动作一律走历史,状态只由 popstate 回写。**
* 不写"点了 X 就 setState(null)",而是"点了 X 就 history.go(-n)",
* 由 popstate 统一把状态改成已关闭。这样不需要在内存里维护"层数栈",
* React 18 StrictMode 的双挂载、快速连点都不会让内存栈和真实历史错位。
*
* 2. **层数用「随组件存活的活计数」,不读历史里的计数器。**
* 历史里可能留着上一次挂载的孤儿条目:用户开着抽屉直接点侧栏跳到别的页面,
* 抽屉会随组件卸载而消失,但压进去的历史条目还在。如果层数从历史里读,
* 下次回到本页就会读到 2 这种脏值,关抽屉时 go(-2) 会多退几条、直接退出应用。
* 所以层数由页面自己按当前 UI 状态算(见 ProductManagement 的 layerCount)。
*
* 配套地,每条历史条目都带上**本次挂载的令牌**(mountId)。
* 读到令牌对不上的条目(上一次挂载留下的孤儿)一律当作"本层已关闭",
* 这样不会冒出幽灵抽屉。
*/
/** 本次挂载的令牌。每次挂载生成一个新的,用来把上一次挂载的孤儿条目认出来 */
export function makeMountId() {
return `m${Date.now().toString(36)}${Math.random().toString(36).slice(2, 7)}`
}
/** 当前历史条目是不是本次挂载压进去的层 */
function isOwnLayer(mountId) {
const s = window.history.state
return !!(s && s.__tmsMount === mountId && typeof s.__tmsDepth === 'number' && s.__tmsDepth > 0)
}
/**
* 打开一层:压一条历史,把「返回后该恢复成什么」存进这条历史本身。
* @param {string} mountId 本次挂载的令牌
* @param {string} key 本层的键名(读回来时用它取值,各层用各自的键就不会串)
* @param {object} snapshot 本层快照,`null` 表示"已关闭"
*/
export function pushLayer(mountId, key, snapshot) {
const cur = window.history.state
const depth = cur && cur.__tmsMount === mountId && typeof cur.__tmsDepth === 'number'
? cur.__tmsDepth
: 0
window.history.pushState({ __tmsMount: mountId, __tmsDepth: depth + 1, [key]: snapshot }, '')
}
/**
* 只退一层 —— 给「返回上一层」按钮用(与鼠标侧键效果完全一致)。
* @returns {boolean} 是否真的走了历史;false 表示当前没有本页的层,调用方自己兜底收起
*/
export function goBackOne(mountId) {
if (!isOwnLayer(mountId)) return false
window.history.back()
return true
}
/**
* 一次退掉 n 层,回到这些层之前那条历史。
* go(-n) 只会触发**一次** popstate(带着最终条目的 state),所以中间那层不会各自响应。
*/
export function popLayers(n) {
if (n > 0) window.history.go(-n)
}
/**
* 监听浏览器返回,把本层状态回写回来。
*
* @param {string} mountId 本次挂载的令牌;读到的条目令牌对不上就当作本层已关闭
* @param {string} key 本层的键名;历史条目里读不到这个键 = 本层已关闭
* @param {function} onState 收到快照(或 null)后恢复 UI 状态
* @param {string} [guardPath] 只在当前路径等于它时才响应。
* 用户开着抽屉直接点侧栏跳到别的页面时,历史里还留着我们的条目;
* 这时往回退不应该在产品页之外乱动抽屉。
*/
export function useLayerRestore(mountId, key, onState, guardPath) {
const onStateRef = useRef(onState)
onStateRef.current = onState
const guardRef = useRef(guardPath ?? window.location.pathname)
if (guardPath) guardRef.current = guardPath
useEffect(() => {
const onPop = () => {
// 已经离开本页:交给路由自己处理,别去动本页的浮层状态
if (window.location.pathname !== guardRef.current) return
const s = window.history.state
const alive = s && s.__tmsMount === mountId && typeof s.__tmsDepth === 'number'
onStateRef.current(alive ? (s[key] ?? null) : null)
}
window.addEventListener('popstate', onPop)
return () => window.removeEventListener('popstate', onPop)
}, [mountId, key])
}
+1
View File
@@ -4,6 +4,7 @@ import App from './App'
import './styles/industrial.css'
import './styles/index.css'
import './styles/luxury.css'
import './styles/stage1.css'
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
+58 -13
View File
@@ -4,43 +4,68 @@ import {
UsersIcon,
TargetIcon,
ClipboardIcon,
DownloadIcon,
QuoteIcon,
ContractIcon,
PurchaseIcon
PurchaseIcon,
InquiryIcon,
CleanupIcon,
GlobeIcon,
ShieldIcon,
ActivityIcon
} from './components/Icons'
/* 工作台独立置顶,不属于任何分组 */
export const HOME_PATH = '/'
export const HOME_LABEL = '工作台'
/* 侧边栏分组配置 */
/**
* 侧边栏分组配置。
*
* 顺序即业务发生顺序:询盘 → 报价 → PI,订单作为业务闭环的收口放在业务管理最后;
* 采购单连同品类 / 供应商 / 合同导入统一归到「采购管理」组(2026-09-15 Wayne 调整)。
* 每项带 permission:侧栏按当前用户权限组过滤,路由层做同样的守卫,两处同源。
*/
export 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 }
{ to: '/inquiries', label: '询盘管理', icon: InquiryIcon, permission: 'inquiry.view' },
{ to: '/quotes', label: '客户报价单管理', icon: QuoteIcon, permission: 'quote.view' },
{ to: '/pi-contracts', label: 'PI合同管理', icon: ContractIcon, permission: 'contract.view' },
{ to: '/orders', label: '订单管理', icon: ClipboardIcon, permission: 'order.view' }
]
},
{
id: 'customer',
title: '客户管理',
items: [
{ to: '/customers', label: '客户管理', icon: UsersIcon },
{ to: '/prospects', label: '新客开发', icon: TargetIcon },
{ to: '/orders', label: '订单管理', icon: ClipboardIcon }
{ to: '/customers', label: '我的客户', icon: UsersIcon, permission: 'customer.view' },
{ to: '/prospects', label: '新客开发', icon: TargetIcon, permission: 'prospect.view' },
{ to: '/customer-pool', label: '客户公海', icon: GlobeIcon, permission: 'pool.view' }
]
},
{
id: 'procurement',
title: '采购管理',
items: [
{ to: '/products', label: '产品管理', icon: PackageIcon },
{ to: '/suppliers', label: '供应商管理', icon: FactoryIcon },
{ to: '/procurement/import', label: '合同导入', icon: DownloadIcon }
/* 采购单是采购侧的主单据,放在本组最前;下面三项是支撑它的品类 / 供应商 / 合同导入。
2026-09-15 从「业务管理」组挪过来(Wayne:采购单管理放到采购管理目录下)。 */
{ to: '/purchase-orders', label: '采购单管理', icon: PurchaseIcon, permission: 'purchase.view' },
{ to: '/products', label: '产品管理', icon: PackageIcon, permission: 'product.view' },
{ to: '/suppliers', label: '供应商管理', icon: FactoryIcon, permission: 'supplier.view' },
/* 合同清洗中心 = 合同导入与复核队列(上传 → 解析 → 待复核 → 通过/驳回) */
{ to: '/contract-cleanup', label: '合同清洗中心', icon: CleanupIcon, permission: 'cleanup.view' }
]
},
{
id: 'system',
title: '系统管理',
items: [
{ to: '/users', label: '用户管理', icon: UsersIcon, permission: 'user.manage' },
{ to: '/permission-groups', label: '权限组管理', icon: ShieldIcon, permission: 'perm.manage' },
/* 审计轨迹。Wayne 口径:只有系统管理员能看,故认 audit.view(该点只授予超级管理员组)。 */
{ to: '/logs', label: '系统日志', icon: ActivityIcon, permission: 'audit.view' }
]
}
]
@@ -51,5 +76,25 @@ export const PAGE_LABELS = {
...NAV_GROUPS.reduce((acc, group) => {
group.items.forEach(item => { acc[item.to] = item.label })
return acc
}, {})
}, {}),
'/profile': '个人资料'
}
/* 路由 → 所需权限(路由守卫用)。个人资料页人人可进,故不在表内。 */
export const ROUTE_PERMISSIONS = {
'/inquiries': 'inquiry.view',
'/quotes': 'quote.view',
'/quotes/new': 'quote.edit',
'/pi-contracts': 'contract.view',
'/purchase-orders': 'purchase.view',
'/orders': 'order.view',
'/customers': 'customer.view',
'/prospects': 'prospect.view',
'/customer-pool': 'pool.view',
'/products': 'product.view',
'/suppliers': 'supplier.view',
'/contract-cleanup': 'cleanup.view',
'/users': 'user.manage',
'/permission-groups': 'perm.manage',
'/logs': 'audit.view'
}
+747
View File
@@ -0,0 +1,747 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import axios from 'axios'
import {
CleanupIcon, DownloadIcon, RefreshIcon, CheckIcon, CloseIcon, PencilIcon, SparkIcon, ContractIcon
} from '../components/Icons'
import { SkuThumb, useSkuThumbs } from '../components/SkuThumb'
const JOB_STATUS = {
queued: { label: '排队中', tone: 'mute' },
parsing: { label: '解析中', tone: 'warn' },
pending_review: { label: '待复核', tone: 'warn' },
reviewed: { label: '已复核', tone: 'ok' },
parse_failed: { label: '解析失败', tone: 'danger' }
}
const ITEM_STATUS = {
pending_review: { label: '待复核', tone: 'warn' },
approved: { label: '已通过', tone: 'ok' },
rejected: { label: '已驳回', tone: 'danger' }
}
/** 一条明细的状态显示:入库过的优先显示「已入库」——它比 approved 更具体 */
function itemStatusMeta(item) {
if (item.committed_at) return { label: '已入库', tone: 'ok' }
return ITEM_STATUS[item.status] || { label: item.status, tone: 'mute' }
}
function formatMoment(value) {
if (!value) return '—'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return String(value)
return date.toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
}
function fmtSize(bytes) {
if (!Number.isFinite(bytes)) return '—'
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
}
/** 解析结果表头认了哪几列 —— 规则引擎唯一能自证的现场,认错了就看这里 */
function ColumnMapPanel({ raw }) {
const map = useMemo(() => {
if (!raw) return null
try {
const parsed = JSON.parse(raw)
const entries = Object.entries(parsed || {})
return entries.length ? entries : null
} catch {
return null
}
}, [raw])
if (!map) {
return <p className="muted-note">没有认出任一列 —— 可以用「AI 解析」或逐条手工补录。</p>
}
return (
<div className="col-map">
{map.map(([field, hit]) => (
<span className="col-map-item" key={field}>
<b>{field}</b>
<span className="col-map-arrow">←</span>
<span className="col-map-head">{hit.header}</span>
<em>{hit.score} 分</em>
</span>
))}
</div>
)
}
/** 单行复核编辑器:点「修正」后就地变成可编辑,避免为了改一个价格再开一层弹窗 */
function ReviewRow({ item, selected, onToggle, onReviewed, onCommitted, thumbs }) {
const [editing, setEditing] = useState(false)
const [form, setForm] = useState({
sku: item.sku || '',
product_name: item.product_name || '',
spec: item.spec || '',
purchase_price: item.purchase_price === null || item.purchase_price === undefined ? '' : String(item.purchase_price),
currency: item.currency || '',
quantity: item.quantity === null || item.quantity === undefined ? '' : String(item.quantity),
moq: item.moq === null || item.moq === undefined ? '' : String(item.moq),
review_note: item.review_note || ''
})
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
useEffect(() => {
setForm({
sku: item.sku || '',
product_name: item.product_name || '',
spec: item.spec || '',
purchase_price: item.purchase_price === null || item.purchase_price === undefined ? '' : String(item.purchase_price),
currency: item.currency || '',
quantity: item.quantity === null || item.quantity === undefined ? '' : String(item.quantity),
moq: item.moq === null || item.moq === undefined ? '' : String(item.moq),
review_note: item.review_note || ''
})
setEditing(false)
setError('')
}, [item])
const payload = status => ({
status,
review_note: form.review_note.trim() || null,
sku: form.sku.trim() || null,
product_name: form.product_name.trim() || null,
spec: form.spec.trim() || null,
purchase_price: form.purchase_price === '' ? null : Number(form.purchase_price),
currency: form.currency.trim() || null,
quantity: form.quantity === '' ? null : Number(form.quantity),
moq: form.moq === '' ? null : Number(form.moq)
})
/** 只改状态(驳回 / 放回待复核) */
const send = async (status) => {
setSaving(true)
setError('')
try {
await axios.patch(`/api/contracts/import-items/${item.id}/review`, payload(status))
setEditing(false)
onReviewed()
} catch (reviewError) {
setError(reviewError?.response?.data?.error || '操作失败,请稍后重试')
} finally {
setSaving(false)
}
}
/** 保存修正并直接入库:先把改过的字段存回队列,再走入库接口 */
const saveAndCommit = async () => {
setSaving(true)
setError('')
try {
await axios.patch(`/api/contracts/import-items/${item.id}/review`, payload('pending_review'))
await axios.post('/api/contracts/import-items/commit', { ids: [item.id] })
setEditing(false)
onCommitted()
} catch (commitError) {
setError(commitError?.response?.data?.error || '入库失败,请稍后重试')
} finally {
setSaving(false)
}
}
/** 不改任何字段,直接把这一条入库 */
const commitOne = async () => {
setSaving(true)
setError('')
try {
await axios.post('/api/contracts/import-items/commit', { ids: [item.id] })
onCommitted()
} catch (commitError) {
setError(commitError?.response?.data?.error || '入库失败,请稍后重试')
} finally {
setSaving(false)
}
}
const statusMeta = itemStatusMeta(item)
const committed = Boolean(item.committed_at)
const renamed = committed && item.product_sku && item.sku && item.product_sku !== item.sku
if (editing) {
return (
<tr className="import-row is-editing">
<td />
<td className="mono">{item.filename}{item.row_no ? `#${item.row_no}` : ''}</td>
<td>
<input className="cell-input" type="text" value={form.sku} placeholder="货号"
onChange={event => setForm({ ...form, sku: event.target.value })} />
</td>
<td>
<input className="cell-input" type="text" value={form.product_name} placeholder="产品名称"
onChange={event => setForm({ ...form, product_name: event.target.value })} />
</td>
<td>
<input className="cell-input" type="text" value={form.spec} placeholder="规格"
onChange={event => setForm({ ...form, spec: event.target.value })} />
</td>
<td>
<input className="cell-input" type="number" step="0.01" value={form.purchase_price} placeholder="采购价"
onChange={event => setForm({ ...form, purchase_price: event.target.value })} />
</td>
<td>
<input className="cell-input" type="text" value={form.currency} placeholder="币种"
onChange={event => setForm({ ...form, currency: event.target.value })} />
</td>
<td>
<input className="cell-input" type="number" step="1" value={form.quantity} placeholder="数量"
onChange={event => setForm({ ...form, quantity: event.target.value })} />
</td>
<td>
<input className="cell-input" type="number" step="1" value={form.moq} placeholder="起订量"
onChange={event => setForm({ ...form, moq: event.target.value })} />
</td>
<td colSpan={2}>
<input className="cell-input" type="text" value={form.review_note} placeholder="复核备注"
onChange={event => setForm({ ...form, review_note: event.target.value })} />
{error && <p className="form-error">{error}</p>}
</td>
<td className="col-actions">
<button type="button" className="btn btn-secondary btn-sm" onClick={() => setEditing(false)} disabled={saving}>取消</button>
<button type="button" className="btn btn-primary btn-sm" onClick={saveAndCommit} disabled={saving}>
{saving ? '入库中…' : '保存并入库'}
</button>
</td>
</tr>
)
}
return (
<tr className={`import-row${item.status === 'rejected' ? ' is-rejected' : ''}${selected ? ' is-picked' : ''}`}>
<td className="col-pick">
<input
className="pick-box" type="checkbox" checked={selected}
onChange={() => onToggle(item.id)}
aria-label={`选择 ${item.sku || item.filename + '#' + item.row_no}`}
/>
</td>
<td className="mono">
<span className="cell-file" title={`${item.filename}${item.row_no ? ` #${item.row_no}` : ''}`}>
{item.filename}{item.row_no ? `#${item.row_no}` : ''}
</span>
</td>
<td className="mono bold">
{item.sku ? (
<span className="sku-cell">
<SkuThumb sku={item.sku} filename={thumbs[item.sku]} size={30} />
{item.sku}
{renamed && <em className="sku-renamed" title="货号已存在,入库时自动落成副本">→ {item.product_sku}</em>}
</span>
) : <span className="muted-note">未识别</span>}
</td>
<td>{item.product_name || <span className="muted-note">未识别</span>}</td>
<td>{item.spec || <span className="muted-note">—</span>}</td>
<td className="num">
{item.purchase_price === null || item.purchase_price === undefined
? <span className="muted-note">—</span>
: item.purchase_price}
</td>
<td>{item.currency || <span className="muted-note">—</span>}</td>
<td className="num">{item.quantity === null || item.quantity === undefined ? '—' : item.quantity}</td>
<td className="num">{item.moq === null || item.moq === undefined ? '—' : item.moq}</td>
<td>
<span className={`pill pill-${statusMeta.tone}`}>{statusMeta.label}</span>
{item.parse_source === 'ai' && <span className="pill pill-mute src-pill">AI</span>}
</td>
<td className="import-note">{item.review_note || <span className="muted-note">—</span>}</td>
<td className="col-actions">
<button type="button" className="icon-btn" title="修正后入库" onClick={() => setEditing(true)}>
<PencilIcon size={14} />
</button>
<button
type="button" className="icon-btn" title="直接入库" disabled={committed || saving}
onClick={commitOne}
>
<CheckIcon size={14} />
</button>
<button
type="button" className="icon-btn" title="驳回" disabled={item.status === 'rejected' || saving}
onClick={() => send('rejected')}
>
<CloseIcon size={14} />
</button>
</td>
</tr>
)
}
function ContractCleanup() {
const [jobs, setJobs] = useState([])
const [activeJobId, setActiveJobId] = useState(null)
const [jobDetail, setJobDetail] = useState(null)
const [loadingJobs, setLoadingJobs] = useState(true)
const [loadingDetail, setLoadingDetail] = useState(false)
const [uploading, setUploading] = useState(false)
const [error, setError] = useState('')
const [notice, setNotice] = useState('')
const [dragging, setDragging] = useState(false)
const [statusFilter, setStatusFilter] = useState('')
const [selected, setSelected] = useState(() => new Set())
const [committing, setCommitting] = useState(false)
const [aiRunning, setAiRunning] = useState(false)
const [commitReport, setCommitReport] = useState(null)
const [originals, setOriginals] = useState(null)
const [showAll, setShowAll] = useState(false)
const fileInputRef = useRef(null)
const headPickRef = useRef(null)
const PAGE_LIMIT = 300
const loadJobs = useCallback(async (preferId) => {
setLoadingJobs(true)
try {
const { data } = await axios.get('/api/contracts/import-jobs')
const rows = Array.isArray(data) ? data : []
setJobs(rows)
setActiveJobId(current => {
if (preferId) return preferId
if (current && rows.some(row => row.id === current)) return current
return rows[0]?.id ?? null
})
} catch (loadError) {
setError(loadError?.response?.data?.error || '读取导入任务失败')
} finally {
setLoadingJobs(false)
}
}, [])
useEffect(() => { loadJobs() }, [loadJobs])
const loadDetail = useCallback(async (jobId) => {
if (!jobId) { setJobDetail(null); return }
setLoadingDetail(true)
try {
const { data } = await axios.get(`/api/contracts/import-jobs/${jobId}`)
setJobDetail(data)
} catch (loadError) {
setError(loadError?.response?.data?.error || '读取任务明细失败')
setJobDetail(null)
} finally {
setLoadingDetail(false)
}
}, [])
useEffect(() => {
loadDetail(activeJobId)
setSelected(new Set())
setCommitReport(null)
setStatusFilter('')
setShowAll(false)
}, [activeJobId, loadDetail])
/* 原件只在服务器留 7 天,所以「还存在哪些原件」得单独问一次接口 */
const loadOriginals = useCallback(async (jobId) => {
if (!jobId) { setOriginals(null); return }
try {
const { data } = await axios.get(`/api/contracts/import-jobs/${jobId}/files`)
setOriginals(data)
} catch {
setOriginals(null)
}
}, [])
useEffect(() => { loadOriginals(activeJobId) }, [activeJobId, loadOriginals])
const upload = async (fileList) => {
const files = [...(fileList || [])]
if (!files.length) return
setUploading(true)
setError('')
setNotice('')
try {
const form = new FormData()
files.forEach(file => form.append('files', file))
const { data } = await axios.post('/api/contracts/import', form)
const jobId = data?.job?.id
await loadJobs(jobId)
if (jobId) { await loadDetail(jobId); loadOriginals(jobId) }
setNotice(`${files.length} 个文件已解析。${data?.message || ''}`)
} catch (uploadError) {
setError(uploadError?.response?.data?.error || '上传失败,请稍后重试')
} finally {
setUploading(false)
if (fileInputRef.current) fileInputRef.current.value = ''
}
}
const onDrop = (event) => {
event.preventDefault()
setDragging(false)
upload(event.dataTransfer?.files)
}
const refreshAfterReview = useCallback(async () => {
await loadDetail(activeJobId)
// 复核完最后一条,任务状态会从 pending_review 变 reviewed,列表也要跟着刷新
await loadJobs(activeJobId)
}, [activeJobId, loadDetail, loadJobs])
const refreshAfterCommit = useCallback(async () => {
setSelected(new Set())
await loadDetail(activeJobId)
await loadJobs(activeJobId)
}, [activeJobId, loadDetail, loadJobs])
const items = jobDetail?.items || []
const filteredItems = useMemo(
() => (statusFilter ? items.filter(item => item.status === statusFilter) : items),
[items, statusFilter]
)
const shownItems = useMemo(
() => (showAll ? filteredItems : filteredItems.slice(0, PAGE_LIMIT)),
[filteredItems, showAll]
)
const counts = useMemo(() => {
const result = { total: items.length, pending_review: 0, approved: 0, rejected: 0, committed: 0 }
items.forEach(item => {
result[item.status] = (result[item.status] || 0) + 1
if (item.committed_at) result.committed += 1
})
return result
}, [items])
const thumbs = useSkuThumbs(items.map(item => item.sku).filter(Boolean))
const pickableIds = useMemo(
() => filteredItems.filter(item => !item.committed_at).map(item => item.id),
[filteredItems]
)
const allPicked = pickableIds.length > 0 && pickableIds.every(id => selected.has(id))
const somePicked = pickableIds.some(id => selected.has(id))
useEffect(() => {
if (headPickRef.current) headPickRef.current.indeterminate = somePicked && !allPicked
}, [somePicked, allPicked])
const togglePick = (id) => {
setSelected(current => {
const next = new Set(current)
if (next.has(id)) next.delete(id); else next.add(id)
return next
})
}
const pickAll = () => setSelected(new Set(pickableIds))
const invertPick = () => setSelected(current => {
const next = new Set()
pickableIds.forEach(id => { if (!current.has(id)) next.add(id) })
return next
})
const clearPick = () => setSelected(new Set())
const commitSelected = async () => {
const ids = [...selected]
if (!ids.length) { setError('请先勾选要入库的明细'); return }
if (!window.confirm(`把勾选的 ${ids.length} 条写进产品库?\n\n已存在的货号会更新采购价并记入调价历史,库里没有的货号会新建。`)) return
setCommitting(true)
setError('')
setNotice('')
setCommitReport(null)
try {
const { data } = await axios.post('/api/contracts/import-items/commit', { ids })
setCommitReport(data)
setNotice(data?.message || '入库完成')
await refreshAfterCommit()
} catch (commitError) {
setError(commitError?.response?.data?.error || '入库失败,请稍后重试')
} finally {
setCommitting(false)
}
}
const runAiParse = async () => {
if (!activeJobId) return
if (!window.confirm('用模型重新解析这个任务?\n\n原来是规则解析出来的「待复核」行会被替换掉(已经复核或已入库的行不受影响)。')) return
setAiRunning(true)
setError('')
setNotice('')
try {
const { data } = await axios.post(`/api/contracts/import-jobs/${activeJobId}/ai-parse`)
setNotice(data?.message || 'AI 解析完成')
await refreshAfterCommit()
} catch (aiError) {
setError(aiError?.response?.data?.error || 'AI 解析失败')
} finally {
setAiRunning(false)
}
}
const downloadOriginal = async (name) => {
try {
const response = await axios.get(`/api/contracts/import-jobs/${activeJobId}/file`, {
params: { name }, responseType: 'blob'
})
const url = window.URL.createObjectURL(response.data)
const link = document.createElement('a')
link.href = url
link.download = name
document.body.appendChild(link)
link.click()
link.remove()
window.URL.revokeObjectURL(url)
} catch (downloadError) {
setError('原件下载失败')
}
}
const activeJob = jobDetail?.job
const jobMeta = activeJob ? (JOB_STATUS[activeJob.status] || { label: activeJob.status, tone: 'mute' }) : null
return (
<div className="page-container">
<div className="page-header">
<div>
<h1 className="page-title">合同清洗中心</h1>
<p className="page-sub">
供应商合同批量导入 → 按字符规则认列(不认行列位置)→ 人工审核入库。
规则认不出来的,可以交给 AI 解析。原件只在服务器保留 7 天后自动清理。
</p>
</div>
<div className="header-actions">
<button type="button" className="btn btn-secondary btn-sm" onClick={() => { loadJobs(activeJobId); loadDetail(activeJobId); loadOriginals(activeJobId) }}>
<RefreshIcon size={14} /> 刷新
</button>
</div>
</div>
<section
className={`drop-zone${dragging ? ' is-dragging' : ''}${uploading ? ' is-busy' : ''}`}
onDragOver={event => { event.preventDefault(); setDragging(true) }}
onDragLeave={() => setDragging(false)}
onDrop={onDrop}
>
<span className="drop-zone-icon"><DownloadIcon size={26} /></span>
<div className="drop-zone-text">
<strong>{uploading ? '正在上传并解析…' : '把供应商合同 / 报价单拖到这里'}</strong>
<span>
支持 xlsx / xls / csv / tsv / txt。按标题里的字符特征认列(中英文与常见缩写都认),
表头不在第一行也能识别;认不准的可以改用 AI 解析或人工补录。
</span>
</div>
<button
type="button" className="btn btn-primary btn-sm"
disabled={uploading} onClick={() => fileInputRef.current?.click()}
>
选择文件
</button>
<input
ref={fileInputRef} type="file" multiple hidden
accept=".csv,.tsv,.txt,.xlsx,.xls,.xlsm"
onChange={event => upload(event.target.files)}
/>
</section>
{error && <p className="notice-inline">{error}</p>}
{notice && <p className="notice-inline notice-ok">{notice}</p>}
{commitReport?.skipped?.length > 0 && (
<div className="notice-inline">
<strong>有 {commitReport.skipped.length} 条没入库:</strong>
<ul className="skip-list">
{commitReport.skipped.slice(0, 8).map(row => (
<li key={row.id}>#{row.id}{row.sku ? `(${row.sku})` : ''}:{row.reason}</li>
))}
{commitReport.skipped.length > 8 && <li>…还有 {commitReport.skipped.length - 8} 条</li>}
</ul>
</div>
)}
<div className="cleanup-layout">
<aside className="cleanup-jobs">
<div className="dashboard-panel-header">
<div>
<h2><CleanupIcon size={17} /> 导入任务</h2>
</div>
</div>
{loadingJobs ? (
<p className="muted-note">读取中…</p>
) : jobs.length === 0 ? (
<p className="muted-note">还没有导入过合同。</p>
) : (
<ul className="job-list">
{jobs.map(job => {
const meta = JOB_STATUS[job.status] || { label: job.status, tone: 'mute' }
return (
<li key={job.id}>
<button
type="button"
className={`job-card${activeJobId === job.id ? ' is-active' : ''}`}
onClick={() => setActiveJobId(job.id)}
>
<span className="job-no mono">{job.job_no}</span>
<span className={`pill pill-${meta.tone}`}>{meta.label}</span>
{job.parse_source === 'ai' && <span className="pill pill-mute src-pill">AI</span>}
<span className="job-meta">
{job.file_count} 个文件 · {formatMoment(job.created_at)}
</span>
</button>
</li>
)
})}
</ul>
)}
</aside>
<section className="cleanup-detail dashboard-panel">
{!activeJob ? (
<div className="empty-state">
<p className="empty-state-title">选一个导入任务</p>
<p className="empty-state-hint">左边点一个任务,这里会列出逐条解析结果供复核入库。</p>
</div>
) : loadingDetail ? (
<p className="muted-note">读取明细中…</p>
) : (
<>
<div className="dashboard-panel-header">
<div>
<h2 className="mono">{activeJob.job_no} <span className={`pill pill-${jobMeta.tone}`}>{jobMeta.label}</span></h2>
<p className="panel-sub">
解析方式 {activeJob.parse_source === 'ai' ? 'AI 模型' : '字符规则'} · 规则版本 {activeJob.rule_version} · 创建 {formatMoment(activeJob.created_at)}
</p>
</div>
<div className="chip-filters">
<button
type="button" className={`chip-filter${statusFilter === '' ? ' is-active' : ''}`}
onClick={() => setStatusFilter('')}
>
全部 <b>{counts.total}</b>
</button>
{Object.entries(ITEM_STATUS).map(([value, meta]) => (
counts[value] ? (
<button
type="button" key={value}
className={`chip-filter${statusFilter === value ? ' is-active' : ''}`}
onClick={() => setStatusFilter(value)}
>
{meta.label} <b>{counts[value]}</b>
</button>
) : null
))}
{counts.committed > 0 && (
<span className="chip-filter is-static">已入库 <b>{counts.committed}</b></span>
)}
</div>
</div>
{activeJob.status === 'parse_failed' && activeJob.notes && (
<p className="notice-inline">上一次解析中断在:{activeJob.notes}。可以修正文件表头后重新导入。</p>
)}
<div className="cleanup-toolbar">
<div className="cleanup-toolbar-left">
<button type="button" className="btn btn-secondary btn-sm" onClick={pickAll} disabled={!pickableIds.length}>
全选 <b>{pickableIds.length}</b>
</button>
<button type="button" className="btn btn-secondary btn-sm" onClick={invertPick} disabled={!pickableIds.length}>反选</button>
<button type="button" className="btn btn-secondary btn-sm" onClick={clearPick} disabled={!selected.size}>清空</button>
<span className="muted-note">已勾选 {selected.size} 条</span>
</div>
<div className="cleanup-toolbar-right">
<button type="button" className="btn btn-secondary btn-sm" onClick={runAiParse} disabled={aiRunning}>
<SparkIcon size={14} /> {aiRunning ? 'AI 解析中…' : 'AI 解析'}
</button>
<button type="button" className="btn btn-primary btn-sm" onClick={commitSelected} disabled={committing || !selected.size}>
<CheckIcon size={14} /> {committing ? '入库中…' : `入库选中(${selected.size})`}
</button>
</div>
</div>
<details className="cleanup-meta" open={!counts.total}>
<summary>解析详情(认了哪几列 / 原件留存)</summary>
<div className="cleanup-meta-body">
<div>
<span className="meta-label">本次认列结果</span>
<ColumnMapPanel raw={activeJob.column_map} />
</div>
<div>
<span className="meta-label">原件留存</span>
{originals?.files?.length ? (
<div className="originals">
{originals.files.map(file => (
<button type="button" key={file.name} className="original-chip"
onClick={() => downloadOriginal(file.name)} title="下载原件核对">
<ContractIcon size={13} /> {file.name}
<em>{fmtSize(file.size)}</em>
</button>
))}
<span className="muted-note">
服务器只保留 {originals.retention_days} 天
{originals.expires_at ? `(${new Date(originals.expires_at).toLocaleDateString('zh-CN')} 起自动清理)` : ''}
</span>
</div>
) : (
<p className="muted-note">
{originals?.expired
? `原件已过保留期(${originals.retention_days} 天)被自动清理 —— 解析结果与已入库的价格不受影响。`
: '没有留存的原件。'}
</p>
)}
</div>
</div>
</details>
{counts.pending_review === 0 && counts.total > 0 && (
<p className="notice-inline notice-ok">
这个任务已经处理完毕{counts.committed ? `,其中 ${counts.committed} 条已入库` : ''}。
</p>
)}
{filteredItems.length === 0 ? (
<div className="empty-state">
<p className="empty-state-title">
{items.length ? '当前筛选下没有明细' : '这个任务没有解析出任何行'}
</p>
<p className="empty-state-hint">
{items.length
? '换个状态筛选看看。'
: '规则没认出来。可以点上面的「AI 解析」让模型试一次,或点「修正」手工补录。'}
</p>
</div>
) : (
<>
<div className="table-wrap">
<table className="data-table import-table">
<thead>
<tr>
<th className="col-pick">
<input
ref={headPickRef} className="pick-box" type="checkbox"
checked={allPicked} onChange={() => (allPicked ? clearPick() : pickAll())}
aria-label="全选"
/>
</th>
<th>来源</th><th>货号</th><th>产品名称</th><th>规格</th>
<th className="num">采购价</th><th>币种</th><th className="num">数量</th><th className="num">起订量</th>
<th>状态</th><th>复核备注</th>
<th className="col-actions">操作</th>
</tr>
</thead>
<tbody>
{shownItems.map(item => (
<ReviewRow
key={item.id} item={item} thumbs={thumbs}
selected={selected.has(item.id)} onToggle={togglePick}
onReviewed={refreshAfterReview} onCommitted={refreshAfterCommit}
/>
))}
</tbody>
</table>
</div>
{filteredItems.length > shownItems.length && (
<p className="muted-note table-more">
还有 {filteredItems.length - shownItems.length} 条未显示
<button type="button" className="btn btn-secondary btn-sm" onClick={() => setShowAll(true)}>全部显示</button>
</p>
)}
</>
)}
</>
)}
</section>
</div>
</div>
)
}
export default ContractCleanup
+933
View File
@@ -0,0 +1,933 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import { Link, useParams } from 'react-router-dom'
import axios from 'axios'
import {
UsersIcon, ClockIcon, PlusCircleIcon, InquiryIcon, QuoteIcon,
ClipboardIcon, RefreshIcon, TargetIcon, PencilIcon, CloseIcon
} from '../components/Icons'
import { useAuth, hasPermission } from '../auth'
const ACTIVITY_TYPES = ['邮件', '电话', '微信', 'WhatsApp', '拜访', '样品', '展会', '其他']
/* 联系人表单的空值模板:新增和「清空重置」共用一份,避免字段改动漏掉某处 */
const EMPTY_CONTACT = {
name: '', name_en: '', position: '', department: '',
email: '', phone: '', whatsapp: '', wechat: '', is_primary: false, notes: ''
}
const STATUS_LABELS = { active: '活跃', inactive: '非活跃', lead: '潜在' }
const INQUIRY_STATUS_LABELS = {
draft: '草稿', quoting: '报价中', quoted: '已报价', won: '已成交', lost: '已流失', closed: '已关闭'
}
const ORDER_STATUS_LABELS = {
pending: '待处理', confirmed: '已确认', producing: '生产中', shipped: '已发货',
completed: '已完成', cancelled: '已取消'
}
function todayKey() {
const now = new Date()
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`
}
function money(value, currency) {
const number = Number(value)
if (!Number.isFinite(number)) return '—'
return `${number.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}${currency ? ` ${currency}` : ''}`
}
function formatMoment(value) {
if (!value) return '—'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return String(value)
return date.toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
}
/** 下次跟进日期的语义化提示 —— 业务上「逾期」和「今天」要一眼能看出来 */
function dueState(dateKey) {
if (!dateKey) return null
const today = todayKey()
if (dateKey < today) return { tone: 'danger', label: '已逾期' }
if (dateKey === today) return { tone: 'warn', label: '今天' }
return { tone: 'ok', label: '待跟进' }
}
/** 快速记录跟进:默认收起成一个输入框,聚焦或输入时展开完整字段 */
function QuickFollowUp({ customer, contacts, inquiries, onSaved, canEdit }) {
const [open, setOpen] = useState(false)
const [form, setForm] = useState({
activity_type: ACTIVITY_TYPES[0], content: '', next_action: '',
next_action_date: '', contact_id: '', inquiry_id: ''
})
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
const submit = async (event) => {
event.preventDefault()
const content = form.content.trim()
if (!content) { setError('写点什么吧,至少留下一句跟进内容'); return }
setSaving(true)
setError('')
try {
await axios.post(`/api/customers/${customer.id}/follow-ups`, {
activity_type: form.activity_type,
content,
next_action: form.next_action.trim() || null,
next_action_date: form.next_action_date || null,
contact_id: form.contact_id ? Number(form.contact_id) : null,
inquiry_id: form.inquiry_id ? Number(form.inquiry_id) : null
})
setForm({
activity_type: ACTIVITY_TYPES[0], content: '', next_action: '',
next_action_date: '', contact_id: '', inquiry_id: ''
})
setOpen(false)
onSaved()
} catch (submitError) {
setError(submitError?.response?.data?.error || '记录失败,请稍后重试')
} finally {
setSaving(false)
}
}
return (
<form className={`quick-follow${open ? ' is-open' : ''}`} onSubmit={submit}>
<div className="quick-follow-head">
<span className="quick-follow-icon"><PlusCircleIcon size={15} /></span>
<input
type="text"
value={form.content}
placeholder="快速记录一条跟进,回车保存"
aria-label="快速记录跟进内容"
onFocus={() => setOpen(true)}
onChange={event => setForm({ ...form, content: event.target.value })}
/>
{!open && canEdit && <button type="submit" className="btn btn-primary btn-sm" disabled={saving}>记录</button>}
</div>
{open && (
<div className="quick-follow-body">
<div className="form-row">
<div className="form-group">
<label>跟进方式</label>
<select
value={form.activity_type}
onChange={event => setForm({ ...form, activity_type: event.target.value })}
>
{ACTIVITY_TYPES.map(type => <option key={type} value={type}>{type}</option>)}
</select>
</div>
<div className="form-group">
<label>对接联系人</label>
<select
value={form.contact_id}
onChange={event => setForm({ ...form, contact_id: event.target.value })}
>
<option value="">不指定</option>
{contacts.map(contact => (
<option key={contact.id} value={contact.id}>
{contact.name}{contact.position ? ` · ${contact.position}` : ''}
</option>
))}
</select>
</div>
</div>
<div className="form-row">
<div className="form-group">
<label>下一次跟进动作</label>
<input
type="text"
value={form.next_action}
placeholder="例如:电话确认包装要求"
onChange={event => setForm({ ...form, next_action: event.target.value })}
/>
</div>
<div className="form-group">
<label>下一次跟进日期</label>
<input
type="date"
value={form.next_action_date}
onChange={event => setForm({ ...form, next_action_date: event.target.value })}
/>
</div>
</div>
{inquiries.length > 0 && (
<div className="form-group">
<label>关联询盘</label>
<select
value={form.inquiry_id}
onChange={event => setForm({ ...form, inquiry_id: event.target.value })}
>
<option value="">不关联</option>
{inquiries.map(inquiry => (
<option key={inquiry.id} value={inquiry.id}>
{inquiry.inquiry_no} · {inquiry.title || '未命名'}
</option>
))}
</select>
</div>
)}
{error && <p className="form-error">{error}</p>}
<div className="quick-follow-actions">
<button type="button" className="btn btn-secondary btn-sm" onClick={() => { setOpen(false); setError('') }}>
收起
</button>
{canEdit && (
<button type="submit" className="btn btn-primary btn-sm" disabled={saving}>
{saving ? '保存中…' : '保存跟进'}
</button>
)}
</div>
</div>
)}
{!open && error && <p className="form-error">{error}</p>}
</form>
)
}
/** 编辑一条跟进记录 —— 字段与快速记录保持一致,避免两套表单口径不一 */
function FollowUpEditModal({ record, contacts, inquiries, onClose, onSaved }) {
const [form, setForm] = useState({
activity_type: record.activity_type || ACTIVITY_TYPES[0],
content: record.content || '',
next_action: record.next_action || '',
next_action_date: record.next_action_date ? String(record.next_action_date).slice(0, 10) : '',
contact_id: record.contact_id || '',
inquiry_id: record.inquiry_id || ''
})
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
const submit = async event => {
event.preventDefault()
const content = form.content.trim()
if (!content) { setError('跟进内容不能为空'); return }
setSaving(true)
setError('')
try {
await axios.patch(`/api/customers/${record.customer_id}/follow-ups/${record.id}`, {
activity_type: form.activity_type,
content,
next_action: form.next_action.trim() || null,
next_action_date: form.next_action_date || null,
contact_id: form.contact_id ? Number(form.contact_id) : null,
inquiry_id: form.inquiry_id ? Number(form.inquiry_id) : null
})
onSaved()
} catch (err) {
setError(err?.response?.data?.error || '保存失败,请稍后重试')
setSaving(false)
}
}
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal-content modal-content-wide" onClick={event => event.stopPropagation()}>
<div className="modal-header">
<h2>编辑跟进记录</h2>
<button type="button" className="modal-close" onClick={onClose} aria-label="关闭"><CloseIcon size={16} /></button>
</div>
<form onSubmit={submit}>
<div className="modal-body">
{error && <p className="form-error">{error}</p>}
<div className="form-row">
<div className="form-group">
<label>跟进方式</label>
<select value={form.activity_type} onChange={e => setForm({ ...form, activity_type: e.target.value })}>
{ACTIVITY_TYPES.map(type => <option key={type} value={type}>{type}</option>)}
</select>
</div>
<div className="form-group">
<label>对接联系人</label>
<select value={form.contact_id} onChange={e => setForm({ ...form, contact_id: e.target.value })}>
<option value="">不指定</option>
{contacts.map(contact => (
<option key={contact.id} value={contact.id}>
{contact.name}{contact.position ? ` · ${contact.position}` : ''}
</option>
))}
</select>
</div>
</div>
<div className="form-group">
<label>跟进内容 *</label>
<textarea rows="3" value={form.content} onChange={e => setForm({ ...form, content: e.target.value })} />
</div>
<div className="form-row">
<div className="form-group">
<label>下一次跟进动作</label>
<input
type="text" value={form.next_action}
placeholder="例如:电话确认包装要求"
onChange={e => setForm({ ...form, next_action: e.target.value })}
/>
</div>
<div className="form-group">
<label>下一次跟进日期</label>
<input
type="date" value={form.next_action_date}
onChange={e => setForm({ ...form, next_action_date: e.target.value })}
/>
</div>
</div>
{inquiries.length > 0 && (
<div className="form-group">
<label>关联询盘</label>
<select value={form.inquiry_id} onChange={e => setForm({ ...form, inquiry_id: e.target.value })}>
<option value="">不关联</option>
{inquiries.map(inquiry => (
<option key={inquiry.id} value={inquiry.id}>
{inquiry.inquiry_no} · {inquiry.title || '未命名'}
</option>
))}
</select>
</div>
)}
</div>
<div className="modal-footer">
<button type="button" className="btn btn-secondary" onClick={onClose}>取消</button>
<button type="submit" className="btn btn-primary" disabled={saving}>
{saving ? '保存中…' : '保存修改'}
</button>
</div>
</form>
</div>
</div>
)
}
/** 编辑客户基本信息(右侧「基本信息」面板的编辑按钮) */
function CustomerProfileModal({ customer, onClose, onSaved }) {
const [form, setForm] = useState({
company: customer.company || '',
company_en: customer.company_en || '',
country: customer.country || '',
industry: customer.industry || '',
website: customer.website || '',
address: customer.address || '',
email: customer.email || '',
phone: customer.phone || '',
notes: customer.notes || '',
status: customer.status === 'inactive' ? 'inactive' : 'active'
})
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
const set = (key, value) => setForm(current => ({ ...current, [key]: value }))
const submit = async event => {
event.preventDefault()
if (!form.company.trim()) { setError('公司名称不能为空'); return }
setSaving(true)
setError('')
try {
await axios.patch(`/api/customers/${customer.id}`, { ...form, company: form.company.trim() })
onSaved()
} catch (err) {
setError(err?.response?.data?.error || '保存失败,请稍后重试')
setSaving(false)
}
}
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal-content modal-content-wide" onClick={event => event.stopPropagation()}>
<div className="modal-header">
<h2>编辑客户信息</h2>
<button type="button" className="modal-close" onClick={onClose} aria-label="关闭"><CloseIcon size={16} /></button>
</div>
<form onSubmit={submit}>
<div className="modal-body">
{error && <p className="form-error">{error}</p>}
<div className="form-row">
<div className="form-group">
<label>公司名称 *</label>
<input type="text" value={form.company} onChange={e => set('company', e.target.value)} />
</div>
<div className="form-group">
<label>英文名称</label>
<input type="text" value={form.company_en} onChange={e => set('company_en', e.target.value)} />
</div>
</div>
<div className="form-row">
<div className="form-group">
<label>国家 / 地区</label>
<input type="text" value={form.country} onChange={e => set('country', e.target.value)} />
</div>
<div className="form-group">
<label>行业</label>
<input type="text" value={form.industry} onChange={e => set('industry', e.target.value)} />
</div>
</div>
<div className="form-group">
<label>网站</label>
<input type="text" value={form.website} onChange={e => set('website', e.target.value)} placeholder="https://" />
</div>
<div className="form-group">
<label>地址</label>
<input type="text" value={form.address} onChange={e => set('address', e.target.value)} placeholder="报价单抬头会用到" />
</div>
<div className="form-row">
<div className="form-group">
<label>邮箱</label>
<input type="email" value={form.email} onChange={e => set('email', e.target.value)} />
</div>
<div className="form-group">
<label>电话</label>
<input type="text" value={form.phone} onChange={e => set('phone', e.target.value)} />
</div>
</div>
<div className="form-group">
<label>客户状态</label>
<select value={form.status} onChange={e => set('status', e.target.value)}>
<option value="active">活跃(可正常跟进)</option>
<option value="inactive">非活跃</option>
</select>
</div>
<div className="form-group">
<label>备注</label>
<textarea rows="2" value={form.notes} onChange={e => set('notes', e.target.value)} />
</div>
</div>
<div className="modal-footer">
<button type="button" className="btn btn-secondary" onClick={onClose}>取消</button>
<button type="submit" className="btn btn-primary" disabled={saving}>
{saving ? '保存中…' : '保存修改'}
</button>
</div>
</form>
</div>
</div>
)
}
function CustomerDetail() {
const { id } = useParams()
const { user } = useAuth()
const canEdit = hasPermission(user, 'customer.edit')
const [data, setData] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [contactOpen, setContactOpen] = useState(false)
/* 为 null = 新增联系人;有值 = 正在编辑这条联系人 */
const [contactEditing, setContactEditing] = useState(null)
const [contactForm, setContactForm] = useState({ ...EMPTY_CONTACT })
const [contactError, setContactError] = useState('')
const [savingContact, setSavingContact] = useState(false)
const [profileOpen, setProfileOpen] = useState(false)
/* 正在编辑的跟进记录;null = 没打开 */
const [followUpEditing, setFollowUpEditing] = useState(null)
const load = useCallback(async () => {
setLoading(true)
setError('')
try {
const response = await axios.get(`/api/customers/${id}`)
setData(response.data)
} catch (loadError) {
setError(loadError?.response?.data?.error || '读取客户详情失败')
} finally {
setLoading(false)
}
}, [id])
useEffect(() => { load() }, [load])
/* 打开联系人弹窗:不传参数是新增,传了联系人就是编辑 */
const openContact = (contact = null) => {
setContactEditing(contact)
setContactError('')
setContactForm(contact ? {
name: contact.name || '',
name_en: contact.name_en || '',
position: contact.position || '',
department: contact.department || '',
email: contact.email || '',
phone: contact.phone || '',
whatsapp: contact.whatsapp || '',
wechat: contact.wechat || '',
is_primary: !!contact.is_primary,
notes: contact.notes || ''
} : { ...EMPTY_CONTACT })
setContactOpen(true)
}
const closeContact = () => {
setContactOpen(false)
setContactEditing(null)
setContactError('')
setContactForm({ ...EMPTY_CONTACT })
}
const submitContact = async (event) => {
event.preventDefault()
if (!contactForm.name.trim()) { setContactError('联系人姓名不能为空'); return }
setSavingContact(true)
setContactError('')
try {
const payload = { ...contactForm, name: contactForm.name.trim() }
if (contactEditing) await axios.patch(`/api/customers/${id}/contacts/${contactEditing.id}`, payload)
else await axios.post(`/api/customers/${id}/contacts`, payload)
closeContact()
await load()
} catch (submitError) {
setContactError(submitError?.response?.data?.error || (contactEditing ? '保存联系人失败' : '新增联系人失败'))
} finally {
setSavingContact(false)
}
}
const customer = data?.customer
const followups = data?.followups || []
const contacts = data?.contacts || []
const inquiries = data?.inquiries || []
// 下一次跟进 = 还没过期或被跳过的记录里最近的那一条
const nextFollowUp = useMemo(() => {
const dated = followups.filter(record => record.next_action_date)
if (!dated.length) return null
return [...dated].sort((a, b) => String(a.next_action_date).localeCompare(String(b.next_action_date)))
.find(record => record.next_action_date >= todayKey()) || dated[0]
}, [followups])
if (loading) return <div className="loading">加载客户详情…</div>
if (!customer) {
return (
<div className="page-container">
<p className="notice-inline">{error || '客户不存在'}</p>
<Link className="btn btn-secondary btn-sm" to="/customers">返回客户列表</Link>
</div>
)
}
const nextDue = nextFollowUp ? dueState(String(nextFollowUp.next_action_date).slice(0, 10)) : null
return (
<div className="page-container">
<div className="breadcrumb">
<Link to="/customers">客户管理</Link>
<span>/</span>
<span>{customer.company}</span>
</div>
<div className="page-header">
<div>
<h1 className="page-title">{customer.company}</h1>
<p className="page-sub">
{[customer.country, customer.industry].filter(Boolean).join(' · ') || '未填写国家与行业'}
{' · '}
<span className={`pill pill-${customer.status === 'active' ? 'ok' : 'mute'}`}>
{STATUS_LABELS[customer.status] || customer.status || '未标记'}
</span>
</p>
</div>
<div className="header-actions">
<button type="button" className="btn btn-secondary btn-sm" onClick={load}>
<RefreshIcon size={14} /> 刷新
</button>
<Link className="btn btn-primary btn-sm" to="/inquiries">
<InquiryIcon size={15} /> 去询盘报价
</Link>
</div>
</div>
{error && <p className="notice-inline">{error}</p>}
<div className="stat-rail inquiry-rail">
<div className="stat-line">
<span className="stat-line-icon"><ClockIcon size={17} /></span>
<span>最近联系</span>
<strong>{customer.last_contact_at ? formatMoment(customer.last_contact_at).slice(0, 16) : '—'}</strong>
</div>
<div className="stat-line">
<span className="stat-line-icon"><TargetIcon size={17} /></span>
<span>下次跟进</span>
<strong>
{nextFollowUp
? <>{String(nextFollowUp.next_action_date).slice(0, 10)} <span className={`pill pill-${nextDue?.tone || 'mute'}`}>{nextDue?.label}</span></>
: '未安排'}
</strong>
</div>
<div className="stat-line">
<span className="stat-line-icon"><InquiryIcon size={17} /></span>
<span>询盘</span><strong>{inquiries.length}</strong>
</div>
<div className="stat-line">
<span className="stat-line-icon"><QuoteIcon size={17} /></span>
<span>报价单</span>
<strong>
{data.quoteOverview?.count || 0}
{Number(data.quoteOverview?.total_amount) > 0 && ` · ${money(data.quoteOverview.total_amount)}`}
</strong>
</div>
<div className="stat-line">
<span className="stat-line-icon"><ClipboardIcon size={17} /></span>
<span>订单</span>
<strong>
{data.orderOverview?.count || 0}
{Number(data.orderOverview?.total_amount) > 0 && ` · ${money(data.orderOverview.total_amount)}`}
</strong>
</div>
</div>
<div className="detail-grid">
<div className="detail-main">
<section className="dashboard-panel">
<div className="dashboard-panel-header">
<div>
<h2><ClockIcon size={17} /> 跟进时间线</h2>
</div>
<span className="panel-period">{followups.length} 条记录</span>
</div>
<QuickFollowUp
customer={customer}
contacts={contacts}
inquiries={inquiries}
onSaved={load}
canEdit={canEdit}
/>
{followups.length === 0 ? (
<div className="empty-state">
<p className="empty-state-title">还没有跟进记录</p>
<p className="empty-state-hint">上面那一行就是快速记录入口,写一句话按回车即可。</p>
</div>
) : (
<ol className="timeline">
{followups.map(record => {
const due = dueState(String(record.next_action_date || '').slice(0, 10))
return (
<li className="timeline-item" key={record.id}>
<span className="timeline-dot" />
<div className="timeline-body">
<div className="timeline-head">
<span className="timeline-type">{record.activity_type}</span>
<span className="timeline-time">{formatMoment(record.created_at)}</span>
{canEdit && (
<button
type="button"
className="timeline-edit"
onClick={() => setFollowUpEditing(record)}
title="编辑这条跟进"
aria-label="编辑这条跟进"
>
<PencilIcon size={13} />
</button>
)}
</div>
<p className="timeline-content">{record.content}</p>
{(record.next_action || record.next_action_date) && (
<div className="timeline-next">
<TargetIcon size={13} />
<span>
下次:{record.next_action || '跟进'}
{record.next_action_date ? ` · ${String(record.next_action_date).slice(0, 10)}` : ''}
</span>
{due && <span className={`pill pill-${due.tone}`}>{due.label}</span>}
</div>
)}
{(record.contact_name || record.inquiry_id) && (
<div className="timeline-meta">
{record.contact_name && <span>对接:{record.contact_name}</span>}
{record.inquiry_id && (
<Link to={`/inquiries/${record.inquiry_id}`}>关联询盘 #{record.inquiry_id}</Link>
)}
</div>
)}
</div>
</li>
)
})}
</ol>
)}
</section>
<section className="dashboard-panel">
<div className="dashboard-panel-header">
<div>
<h2><InquiryIcon size={17} /> 关联询盘</h2>
</div>
<Link className="btn btn-secondary btn-sm" to="/inquiries">全部询盘</Link>
</div>
{inquiries.length === 0 ? (
<div className="empty-state">
<p className="empty-state-title">还没有询盘</p>
<p className="empty-state-hint">在「询盘管理」里新建一条,把它挂到当前客户名下。</p>
</div>
) : (
<div className="table-wrap">
<table className="data-table">
<thead>
<tr>
<th>询盘号</th><th>标题</th><th>日期</th><th>币种</th><th>状态</th><th className="col-actions">操作</th>
</tr>
</thead>
<tbody>
{inquiries.map(inquiry => (
<tr key={inquiry.id}>
<td className="mono bold">{inquiry.inquiry_no}</td>
<td>{inquiry.title || <span className="muted-note">未命名</span>}</td>
<td className="mono">{inquiry.inquiry_date}</td>
<td>{inquiry.currency || '—'}</td>
<td>{INQUIRY_STATUS_LABELS[inquiry.status] || inquiry.status}</td>
<td className="col-actions">
<Link className="btn-link" to={`/inquiries/${inquiry.id}`}>查看报价</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
<section className="dashboard-panel">
<div className="dashboard-panel-header">
<div>
<h2><QuoteIcon size={17} /> 报价单与订单</h2>
</div>
</div>
<div className="split-lists">
<div>
<h4 className="split-title">客户报价单</h4>
{(data.quoteItems || []).length === 0 ? (
<p className="muted-note">还没有正式报价单。</p>
) : (
<ul className="mini-list">
{data.quoteItems.map(quote => (
<li key={quote.id}>
<span className="mono">{quote.quote_no}</span>
<span className="mini-list-sub">{quote.quote_date}</span>
<b>{money(quote.total_amount, quote.currency)}</b>
</li>
))}
</ul>
)}
</div>
<div>
<h4 className="split-title">订单</h4>
{(data.orderItems || []).length === 0 ? (
<p className="muted-note">还没有成交订单。</p>
) : (
<ul className="mini-list">
{data.orderItems.map(order => (
<li key={order.id}>
<span className="mono">{order.order_no}</span>
<span className="mini-list-sub">
{order.order_date} · {ORDER_STATUS_LABELS[order.status] || order.status}
</span>
<b>{money(order.total_amount, order.currency)}</b>
</li>
))}
</ul>
)}
</div>
</div>
</section>
</div>
<aside className="detail-side">
<section className="dashboard-panel">
<div className="dashboard-panel-header">
<div>
<h2>基本信息</h2>
</div>
{canEdit && (
<button type="button" className="btn btn-secondary btn-sm" onClick={() => setProfileOpen(true)}>
<PencilIcon size={14} /> 编辑
</button>
)}
</div>
<div className="kv-grid kv-grid-single">
<div className="kv"><span className="kv-label">公司名称</span><span className="kv-value">{customer.company}</span></div>
<div className="kv"><span className="kv-label">国家 / 地区</span><span className={`kv-value${customer.country ? '' : ' is-empty'}`}>{customer.country || '未填写'}</span></div>
<div className="kv"><span className="kv-label">行业</span><span className={`kv-value${customer.industry ? '' : ' is-empty'}`}>{customer.industry || '未填写'}</span></div>
<div className="kv"><span className="kv-label">网站</span><span className="kv-value">
{customer.website
? <a href={customer.website} target="_blank" rel="noopener noreferrer">{customer.website}</a>
: <span className="is-empty">未填写</span>}
</span></div>
<div className="kv"><span className="kv-label">邮箱</span><span className={`kv-value${customer.email ? '' : ' is-empty'}`}>{customer.email || '未填写'}</span></div>
<div className="kv"><span className="kv-label">电话</span><span className={`kv-value${customer.phone ? '' : ' is-empty'}`}>{customer.phone || '未填写'}</span></div>
<div className="kv"><span className="kv-label">负责人</span><span className={`kv-value${customer.owner_name ? '' : ' is-empty'}`}>{customer.owner_name || '未指派'}</span></div>
<div className="kv"><span className="kv-label">建档时间</span><span className="kv-value">{formatMoment(customer.created_at).slice(0, 10)}</span></div>
</div>
{customer.notes && <p className="notes-box">{customer.notes}</p>}
</section>
<section className="dashboard-panel">
<div className="dashboard-panel-header">
<div>
<h2><UsersIcon size={17} /> 联系人</h2>
</div>
{canEdit && (
<button type="button" className="btn btn-secondary btn-sm" onClick={() => openContact()}>
<PlusCircleIcon size={14} /> 新增
</button>
)}
</div>
{contacts.length === 0 ? (
<p className="muted-note">还没有联系人。报价和跟进都挂在具体的人身上,建议先补一个。</p>
) : (
<ul className="contact-list">
{contacts.map(contact => (
<li className="contact-card" key={contact.id}>
<div className="contact-head">
<span className="contact-avatar">{(contact.name || '?').slice(0, 1)}</span>
<div>
<div className="contact-name">
{contact.name}
{contact.is_primary ? <span className="pill pill-ok">主要</span> : null}
</div>
<div className="contact-role">
{[contact.position, contact.department].filter(Boolean).join(' · ') || '未填写职位'}
</div>
</div>
{canEdit && (
<button
type="button"
className="contact-edit"
onClick={() => openContact(contact)}
title="编辑联系人"
aria-label={`编辑联系人 ${contact.name}`}
>
<PencilIcon size={13} />
</button>
)}
</div>
<div className="contact-lines">
{contact.email && <span>✉ {contact.email}</span>}
{contact.phone && <span>☎ {contact.phone}</span>}
{contact.whatsapp && <span>WA {contact.whatsapp}</span>}
{contact.wechat && <span>微信 {contact.wechat}</span>}
</div>
</li>
))}
</ul>
)}
</section>
</aside>
</div>
{contactOpen && (
<div className="modal-overlay" onClick={closeContact}>
<div className="modal-content" onClick={event => event.stopPropagation()}>
<div className="modal-header">
<h2>{contactEditing ? '编辑联系人' : '新增联系人'}</h2>
<button type="button" className="icon-btn" onClick={closeContact} aria-label="关闭">×</button>
</div>
<form onSubmit={submitContact}>
<div className="form-row">
<div className="form-group">
<label>姓名 *</label>
<input
type="text" required value={contactForm.name}
onChange={event => setContactForm({ ...contactForm, name: event.target.value })}
/>
</div>
<div className="form-group">
<label>英文名</label>
<input
type="text" value={contactForm.name_en}
onChange={event => setContactForm({ ...contactForm, name_en: event.target.value })}
/>
</div>
</div>
<div className="form-row">
<div className="form-group">
<label>职位</label>
<input
type="text" value={contactForm.position}
onChange={event => setContactForm({ ...contactForm, position: event.target.value })}
/>
</div>
<div className="form-group">
<label>部门</label>
<input
type="text" value={contactForm.department}
onChange={event => setContactForm({ ...contactForm, department: event.target.value })}
/>
</div>
</div>
<div className="form-row">
<div className="form-group">
<label>邮箱</label>
<input
type="email" value={contactForm.email}
onChange={event => setContactForm({ ...contactForm, email: event.target.value })}
/>
</div>
<div className="form-group">
<label>电话</label>
<input
type="text" value={contactForm.phone}
onChange={event => setContactForm({ ...contactForm, phone: event.target.value })}
/>
</div>
</div>
<div className="form-row">
<div className="form-group">
<label>WhatsApp</label>
<input
type="text" value={contactForm.whatsapp}
onChange={event => setContactForm({ ...contactForm, whatsapp: event.target.value })}
/>
</div>
<div className="form-group">
<label>微信</label>
<input
type="text" value={contactForm.wechat}
onChange={event => setContactForm({ ...contactForm, wechat: event.target.value })}
/>
</div>
</div>
<div className="form-group">
<label>备注</label>
<textarea
rows={2} value={contactForm.notes}
onChange={event => setContactForm({ ...contactForm, notes: event.target.value })}
/>
</div>
<label className="check-inline">
<input
type="checkbox" checked={contactForm.is_primary}
onChange={event => setContactForm({ ...contactForm, is_primary: event.target.checked })}
/>
设为主要联系人
</label>
{contactError && <p className="form-error">{contactError}</p>}
<div className="modal-footer">
<button type="button" className="btn btn-secondary" onClick={closeContact}>取消</button>
<button type="submit" className="btn btn-primary" disabled={savingContact}>
{savingContact ? '保存中…' : (contactEditing ? '保存修改' : '保存')}
</button>
</div>
</form>
</div>
</div>
)}
{profileOpen && (
<CustomerProfileModal
customer={customer}
onClose={() => setProfileOpen(false)}
onSaved={async () => { setProfileOpen(false); await load() }}
/>
)}
{followUpEditing && (
<FollowUpEditModal
record={followUpEditing}
contacts={contacts}
inquiries={inquiries}
onClose={() => setFollowUpEditing(null)}
onSaved={async () => { setFollowUpEditing(null); await load() }}
/>
)}
</div>
)
}
export default CustomerDetail
+244 -77
View File
@@ -1,10 +1,33 @@
import React, { useState, useEffect } from 'react'
import React, { useState, useEffect, useMemo, useCallback } from 'react'
import { useNavigate } from 'react-router-dom'
import axios from 'axios'
import { SkeletonTableRows } from '../components/Skeleton'
import { PlusCircleIcon, RefreshIcon, SearchIcon, GlobeIcon, UserIcon } from '../components/Icons'
import { useAuth, hasPermission } from '../auth'
const STATUS_LABELS = { active: '活跃', inactive: '非活跃', lead: '潜在' }
/**
* 我的客户。
*
* 归属口径(Wayne 2026-09-14):客户有「所属」,谁建的归谁,默认只有归属人看得见;
* 创建人主动丢进公海后同组织可见可领;其余客户只有拿到 customer.view_all 的
* 最高级别管理账号能看到 —— 所以这里默认只查 scope=mine,
* 有权限的账号多一个「全部客户」看板。
*/
function CustomerManagement() {
const navigate = useNavigate()
const { user } = useAuth()
const canViewAll = hasPermission(user, 'customer.view_all')
const canEdit = hasPermission(user, 'customer.edit')
const [scope, setScope] = useState('mine')
const [customers, setCustomers] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [keyword, setKeyword] = useState('')
const [showAddModal, setShowAddModal] = useState(false)
const [saving, setSaving] = useState(false)
const [formError, setFormError] = useState('')
const [formData, setFormData] = useState({
company: '',
country: '',
@@ -15,56 +38,152 @@ function CustomerManagement() {
notes: ''
})
useEffect(() => {
fetchCustomers()
}, [])
const fetchCustomers = async () => {
const fetchCustomers = useCallback(async (nextScope = scope) => {
setError('')
setLoading(true)
try {
const response = await axios.get('/api/customers')
setCustomers(response.data)
} catch (error) {
console.error('获取客户列表失败:', error)
const response = await axios.get('/api/customers', { params: { scope: nextScope } })
setCustomers(Array.isArray(response.data) ? response.data : [])
} catch (loadError) {
setError(loadError?.response?.data?.error || '获取客户列表失败')
setCustomers([])
} finally {
setLoading(false)
}
}
}, [scope])
useEffect(() => { fetchCustomers(scope) }, [scope, fetchCustomers])
const filtered = useMemo(() => {
const term = keyword.trim().toLowerCase()
if (!term) return customers
return customers.filter(customer =>
[customer.company, customer.country, customer.industry, customer.email, customer.phone, customer.owner_name]
.filter(Boolean)
.some(value => String(value).toLowerCase().includes(term))
)
}, [customers, keyword])
const handleSubmit = async (e) => {
e.preventDefault()
setSaving(true)
setFormError('')
try {
await axios.post('/api/customers', formData)
const response = 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)
setFormData({ company: '', country: '', industry: '', website: '', email: '', phone: '', notes: '' })
const newId = response.data?.id
await fetchCustomers(scope)
// 新建后直接进详情页 —— 下一步九成是记第一条跟进,不该让用户再自己找
if (newId) navigate(`/customers/${newId}`)
} catch (submitError) {
setFormError(submitError?.response?.data?.error || submitError?.message || '创建失败')
} finally {
setSaving(false)
}
}
if (loading) {
return <div className="loading">加载中...</div>
/* 丢入公海:归属人主动放弃,客户变成组织共享资源,同组织同事可见可领。
乐观更新:先把行从列表拿掉(mine 范围)或打上公海标记(all 范围),
后台失败再放回原位 —— 用户点完立刻看到结果,不用等网络往返。 */
const handleToPool = async (customer) => {
const ok = window.confirm(
`把「${customer.company}」丢进客户公海?\n\n` +
'丢入后它会从「我的客户」移到「客户公海」,同组织的同事都能看到并领取。\n' +
'一旦有同事领取,这条客户就不再归你了。'
)
if (!ok) return
setError('')
const index = customers.findIndex(item => item.id === customer.id)
const rollback = () => setCustomers(prev => {
if (prev.some(item => item.id === customer.id)) {
return prev.map(item => (item.id === customer.id ? customer : item))
}
const next = [...prev]
next.splice(Math.min(Math.max(index, 0), next.length), 0, customer)
return next
})
if (scope === 'mine') {
setCustomers(prev => prev.filter(item => item.id !== customer.id))
} else {
setCustomers(prev => prev.map(item => (item.id === customer.id ? { ...item, pool_status: 'public' } : item)))
}
try {
await axios.post(`/api/customers/${customer.id}/to-pool`)
} catch (err) {
rollback()
setError(err?.response?.data?.error || '丢入公海失败,已恢复原状')
}
}
const openAdd = () => {
setFormError('')
setShowAddModal(true)
}
return (
<div className="page-container">
<div className="page-header">
<h1 className="page-title">客户管理</h1>
<button className="btn-primary" onClick={() => setShowAddModal(true)}>
+ 新增客户
</button>
<div>
<h1 className="page-title">我的客户</h1>
<p className="page-sub">
客户默认只有创建人(归属人)可见。想让团队一起跟,就把客户丢进「客户公海」,
同组织成员都能看到并领取。
</p>
</div>
<div className="header-actions">
<button className="btn btn-secondary btn-sm" onClick={() => fetchCustomers(scope)}>
<RefreshIcon size={14} /> 刷新
</button>
{canEdit && (
<button className="btn btn-primary btn-sm" onClick={openAdd}>
<PlusCircleIcon size={15} /> 新增客户
</button>
)}
</div>
</div>
<div className="table-container">
<div className="filter-bar">
{canViewAll ? (
<div className="scope-tabs" role="tablist" aria-label="客户范围">
<button
type="button"
role="tab"
aria-selected={scope === 'mine'}
className={`scope-tab${scope === 'mine' ? ' is-active' : ''}`}
onClick={() => setScope('mine')}
>
我的客户
</button>
<button
type="button"
role="tab"
aria-selected={scope === 'all'}
className={`scope-tab${scope === 'all' ? ' is-active' : ''}`}
onClick={() => setScope('all')}
>
全部客户
</button>
</div>
) : (
<span className="scope-hint"><UserIcon size={13} /> 仅显示我名下的客户</span>
)}
<span className="toolbar-search">
<SearchIcon size={15} />
<input
type="search"
value={keyword}
placeholder="搜公司 / 国家 / 行业 / 联系方式"
aria-label="搜索客户"
onChange={event => setKeyword(event.target.value)}
/>
</span>
</div>
{error && <p className="notice-inline">{error}</p>}
<div className="table-container table-wrap">
<table className="data-table">
<thead>
<tr>
@@ -73,57 +192,99 @@ function CustomerManagement() {
<th>行业</th>
<th>网站</th>
<th>联系方式</th>
<th>负责人</th>
<th>归属</th>
<th>状态</th>
<th>操作</th>
<th className="col-actions">操作</th>
</tr>
</thead>
<tbody>
{customers.length === 0 ? (
{loading ? (
/* 骨架屏:列宽贴近真实表格,加载完成时整页不会跳一下 */
<SkeletonTableRows rows={6} widths={[2.4, 1, 1.2, 1.6, 1.8, 1, 1, 1.2]} />
) : filtered.length === 0 ? (
<tr>
<td colSpan="8" className="empty-state">暂无客户数据</td>
<td colSpan="8" className="empty-state">
{customers.length
? '没有匹配的客户'
: scope === 'all'
? '系统里还没有客户'
: '你名下还没有客户。点右上角「新增客户」建档,或到「客户公海」领取同事放出的资源。'}
</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>
))
filtered.map(customer => {
const isMine = user && Number(customer.owner_id) === Number(user.id)
const inPool = customer.pool_status === 'public'
const canDrop = (isMine || canViewAll) && !inPool && canEdit
return (
<tr key={customer.id} onClick={() => navigate(`/customers/${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"
onClick={event => event.stopPropagation()}
>
{customer.website}
</a>
) : '-'}
</td>
<td>
{customer.email && <div>{customer.email}</div>}
{customer.phone && <div>{customer.phone}</div>}
</td>
<td>
{inPool ? (
<span className="pill pill-mute" title="已丢入公海,同组织成员可见可领">
<GlobeIcon size={11} /> 公海
</span>
) : (
<span className="owner-cell">
<UserIcon size={11} /> {customer.owner_name || '未指定'}
{isMine && <span className="owner-self">我</span>}
</span>
)}
</td>
<td>
<span className={`pill pill-${customer.status === 'active' ? 'ok' : 'mute'}`}>
{STATUS_LABELS[customer.status] || customer.status || '未标记'}
</span>
</td>
<td className="col-actions">
{canDrop && (
<button
className="btn-link"
title="放弃归属,同组织成员可见并可领取"
onClick={event => { event.stopPropagation(); handleToPool(customer) }}
>
丢入公海
</button>
)}
<button
className="btn-link"
onClick={event => { event.stopPropagation(); navigate(`/customers/${customer.id}`) }}
>
查看详情
</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>
<button className="icon-btn" onClick={() => setShowAddModal(false)} aria-label="关闭">×</button>
</div>
<form onSubmit={handleSubmit}>
<div className="form-group">
@@ -132,16 +293,19 @@ function CustomerManagement() {
type="text"
required
value={formData.company}
onChange={e => setFormData({...formData, company: e.target.value})}
onChange={e => setFormData({ ...formData, company: e.target.value })}
/>
</div>
<p className="form-hint">
新建的客户归属你自己:只有你能看到,除非你把它丢进客户公海。
</p>
<div className="form-row">
<div className="form-group">
<label>国家</label>
<input
type="text"
value={formData.country}
onChange={e => setFormData({...formData, country: e.target.value})}
onChange={e => setFormData({ ...formData, country: e.target.value })}
/>
</div>
<div className="form-group">
@@ -149,7 +313,7 @@ function CustomerManagement() {
<input
type="text"
value={formData.industry}
onChange={e => setFormData({...formData, industry: e.target.value})}
onChange={e => setFormData({ ...formData, industry: e.target.value })}
/>
</div>
</div>
@@ -159,7 +323,7 @@ function CustomerManagement() {
type="url"
placeholder="https://"
value={formData.website}
onChange={e => setFormData({...formData, website: e.target.value})}
onChange={e => setFormData({ ...formData, website: e.target.value })}
/>
</div>
<div className="form-row">
@@ -168,7 +332,7 @@ function CustomerManagement() {
<input
type="email"
value={formData.email}
onChange={e => setFormData({...formData, email: e.target.value})}
onChange={e => setFormData({ ...formData, email: e.target.value })}
/>
</div>
<div className="form-group">
@@ -176,7 +340,7 @@ function CustomerManagement() {
<input
type="text"
value={formData.phone}
onChange={e => setFormData({...formData, phone: e.target.value})}
onChange={e => setFormData({ ...formData, phone: e.target.value })}
/>
</div>
</div>
@@ -185,16 +349,19 @@ function CustomerManagement() {
<textarea
rows="3"
value={formData.notes}
onChange={e => setFormData({...formData, notes: e.target.value})}
onChange={e => setFormData({ ...formData, notes: e.target.value })}
/>
</div>
{formError && <p className="form-error">{formError}</p>}
<div className="modal-footer">
<button type="button" className="btn-secondary" onClick={() => setShowAddModal(false)}>
<button type="button" className="btn btn-secondary" onClick={() => setShowAddModal(false)}>
取消
</button>
<button type="submit" className="btn-primary">
保存
</button>
{canEdit && (
<button type="submit" className="btn btn-primary" disabled={saving}>
{saving ? '保存中…' : '保存并进入详情'}
</button>
)}
</div>
</form>
</div>
+205
View File
@@ -0,0 +1,205 @@
import React, { useState, useEffect, useCallback } from 'react'
import { useNavigate } from 'react-router-dom'
import axios from 'axios'
import { GlobeIcon, RefreshIcon, UserIcon, SearchIcon, CheckIcon } from '../components/Icons'
import { SkeletonTableRows } from '../components/Skeleton'
import { useAuth, hasPermission } from '../auth'
import { setPageTitle } from '../branding'
function fmtDate(value) {
if (!value) return '—'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return '—'
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
}
/**
* 客户公海 —— 组织(团队)共享的客户资源池。
*
* 数据就是 customers 里 pool_status = 'public' 的那批:
* 创建人主动把客户丢进来,同组织成员可见、可领取,谁先领归谁。
*/
function CustomerPool() {
const navigate = useNavigate()
const { user } = useAuth()
const canClaim = hasPermission(user, 'pool.claim')
const [rows, setRows] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [notice, setNotice] = useState('')
const [keyword, setKeyword] = useState('')
useEffect(() => {
setPageTitle('客户公海')
}, [])
const fetchPool = useCallback(async () => {
setLoading(true)
setError('')
try {
const { data } = await axios.get('/api/customers', { params: { scope: 'pool' } })
setRows(Array.isArray(data) ? data : [])
} catch (loadError) {
setError(loadError?.response?.data?.error || '获取公海客户失败')
setRows([])
} finally {
setLoading(false)
}
}, [])
useEffect(() => { fetchPool() }, [fetchPool])
/* 领取:乐观更新 —— 先把行从公海拿掉,后台失败再放回原位。
409 = 被同事抢先,回滚后顺手刷一次对齐真实状态。 */
const claim = async (customer) => {
setError('')
setNotice('')
const index = rows.findIndex(row => row.id === customer.id)
setRows(prev => prev.filter(row => row.id !== customer.id))
setNotice(`「${customer.company}」已领取,现在归你名下。`)
try {
await axios.post(`/api/customers/${customer.id}/claim`)
} catch (err) {
setRows(prev => {
if (prev.some(row => row.id === customer.id)) return prev
const next = [...prev]
next.splice(Math.min(Math.max(index, 0), next.length), 0, customer)
return next
})
setNotice('')
setError(err?.response?.data?.error || '领取失败,已恢复原状')
if (err?.response?.status === 409) fetchPool()
}
}
const filtered = keyword.trim()
? rows.filter(row =>
[row.company, row.country, row.industry, row.email, row.created_by_name]
.filter(Boolean)
.some(value => String(value).toLowerCase().includes(keyword.trim().toLowerCase()))
)
: rows
return (
<div className="page-container pool-page">
<div className="page-header">
<div>
<h1 className="page-title">客户公海</h1>
<p className="page-subtitle">
与组织(团队)共用的客户资源池:同事主动放出来的客户都躺在下面,同组织成员都能看到,谁先领取就归谁。
</p>
</div>
<div className="header-actions">
<button className="btn btn-secondary btn-sm" onClick={fetchPool}>
<RefreshIcon size={14} /> 刷新
</button>
</div>
</div>
<div className="pool-rules">
<div className="pool-rule-card">
<span className="pool-rule-index">01</span>
<b>主动放出</b>
<p>客户默认只有创建人可见。创建人在「我的客户」里点「丢入公海」,这条就进入团队资源池。</p>
</div>
<div className="pool-rule-card">
<span className="pool-rule-index">02</span>
<b>团队可见</b>
<p>公海客户对同组织成员可见,联系方式与历史跟进都能查,方便快速判断值不值得跟。</p>
</div>
<div className="pool-rule-card">
<span className="pool-rule-index">03</span>
<b>领取跟进</b>
<p>点「领取」即归属到自己名下并进入「我的客户」,同组织其他人不会再领到。</p>
</div>
</div>
<div className="panel">
<div className="panel-title">
<GlobeIcon size={15} /> 公海客户
<span className="panel-count">{rows.length}</span>
</div>
<div className="filter-bar">
<span className="toolbar-search">
<SearchIcon size={15} />
<input
type="search"
value={keyword}
placeholder="搜公司 / 国家 / 行业 / 联系方式"
aria-label="搜索公海客户"
onChange={event => setKeyword(event.target.value)}
/>
</span>
</div>
{notice && <p className="notice-inline notice-ok"><CheckIcon size={13} /> {notice}</p>}
{error && <p className="notice-inline">{error}</p>}
<div className="table-container table-wrap">
<table className="data-table">
<thead>
<tr>
<th>公司名称</th>
<th>国家</th>
<th>行业</th>
<th>联系方式</th>
<th>原归属</th>
<th>丢入时间</th>
<th className="col-actions">操作</th>
</tr>
</thead>
<tbody>
{loading ? (
<SkeletonTableRows rows={5} widths={[2.2, 0.8, 1, 1.6, 1, 1, 1]} />
) : filtered.length === 0 ? (
<tr>
<td colSpan="7" className="empty-state">
<GlobeIcon size={28} />
<p style={{ margin: '8px 0 0' }}>
{rows.length ? '没有匹配的公海客户' : '公海现在是空的 —— 同事把客户丢进来后会出现在这里。'}
</p>
</td>
</tr>
) : (
filtered.map(row => (
<tr key={row.id} onClick={() => navigate(`/customers/${row.id}`)}>
<td>
<strong>{row.company}</strong>
{row.pool_note && <div className="muted-note">放出说明:{row.pool_note}</div>}
</td>
<td>{row.country || '-'}</td>
<td>{row.industry || '-'}</td>
<td>
{row.email && <div>{row.email}</div>}
{row.phone && <div>{row.phone}</div>}
</td>
<td>
<span className="owner-cell"><UserIcon size={11} /> {row.owner_name || '未指定'}</span>
{row.created_by_name && <div className="muted-note">创建人:{row.created_by_name}</div>}
</td>
<td className="mono">{fmtDate(row.pool_at)}</td>
<td className="col-actions">
{canClaim ? (
<button
className="btn btn-primary btn-sm"
onClick={event => { event.stopPropagation(); claim(row) }}
>
领取
</button>
) : (
<span className="muted-note">无领取权限</span>
)}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
</div>
)
}
export default CustomerPool
+199 -10
View File
@@ -1,21 +1,28 @@
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { Link } from 'react-router-dom'
import axios from 'axios'
import {
ActivityIcon,
CalendarIcon,
CheckIcon,
CleanupIcon,
ClipboardIcon,
ClockIcon,
CloseIcon,
DollarIcon,
FactoryIcon,
FlagIcon,
InquiryIcon,
PackageIcon,
PinIcon,
PlusCircleIcon,
QuoteIcon,
SparkIcon,
TrashIcon,
UsersIcon
} from '../components/Icons'
import MonthYearWheel from '../components/MonthYearWheel'
import { SkeletonPanel } from '../components/Skeleton'
import { useAuth, hasPermission } from '../auth'
const WEEKDAYS = ['周一', '周二', '周三', '周四', '周五', '周六', '周日']
const WEEKDAY_SHORT = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
@@ -699,7 +706,6 @@ function CalendarBoard({ todos: seedTodos, onTodoCreated }) {
<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>
<p className="calendar-hint">单击日期选中,再次单击添加日程 · 悬浮日程或节日查看详情</p>
</div>
@@ -985,10 +991,48 @@ function CalendarBoard({ todos: seedTodos, onTodoCreated }) {
)
}
const INQUIRY_STATUS_LABELS = {
draft: '草稿', quoting: '报价中', quoted: '已报价', won: '已成交', lost: '已流失', closed: '已关闭'
}
const CONTRACT_STATUS_LABELS = {
queued: '排队中', parsing: '解析中', pending_review: '待复核', reviewed: '已复核', parse_failed: '解析失败'
}
/** 今日/逾期跟进条目:客户名 + 跟进动作 + 日期,点进去就是客户详情 */
function FollowUpRow({ record, overdue }) {
return (
<li className="followup-row">
<span className={`followup-dot${overdue ? ' is-overdue' : ''}`} />
<div className="followup-body">
<Link className="followup-customer" to={`/customers/${record.customer_id}`}>
{record.customer_name || `客户 #${record.customer_id}`}
</Link>
<span className="followup-action">
{record.next_action || `${record.activity_type || '跟进'}`}
</span>
</div>
<span className={`followup-date${overdue ? ' is-overdue' : ''}`}>
{String(record.next_action_date || '').slice(0, 10)}
</span>
</li>
)
}
function Dashboard() {
const { user } = useAuth()
const [data, setData] = useState({ stats: {}, recentActivities: [], todos: [] })
const [loading, setLoading] = useState(true)
/* 工作台是跨模块的汇总页,里面的入口必须跟侧栏用同一套口径 ——
没有那个模块的权限,入口就不出现在这里(否则侧栏藏了、工作台还漏一个)。 */
const canInquiry = hasPermission(user, 'inquiry.view')
const canQuote = hasPermission(user, 'quote.view')
const canCleanup = hasPermission(user, 'cleanup.view')
const canCustomer = hasPermission(user, 'customer.view')
const canEditInquiry = hasPermission(user, 'inquiry.edit')
const canEditCustomer = hasPermission(user, 'customer.edit')
const loadDashboard = useCallback(async () => {
try {
const response = await axios.get('/api/dashboard')
@@ -1002,28 +1046,173 @@ function Dashboard() {
useEffect(() => { loadDashboard() }, [loadDashboard])
if (loading) return <div className="loading">加载工作台数据…</div>
if (loading) {
return (
<div className="dashboard-container">
<div className="dashboard-welcome"><div><h1 className="page-title">工作台</h1><p>把今天的节奏、客户和订单放在同一个视野里。</p></div></div>
<div className="dashboard-content-grid">
<div className="dashboard-primary-column">
<section className="dashboard-panel"><SkeletonPanel lines={4} /></section>
<section className="dashboard-panel"><SkeletonPanel lines={3} /></section>
</div>
</div>
</div>
)
}
const { stats, recentActivities, todos } = data
const {
stats, recentActivities, todos,
todayFollowUps = [], overdueFollowUps = [], recentInquiries = [],
recentQuotes = [], pendingContractJobs = []
} = data
const completion = todos.length ? Math.max(12, Math.round((todos.filter(todo => todo.status === 'completed').length / todos.length) * 100)) : 0
const followUpCount = todayFollowUps.length + overdueFollowUps.length
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>
<div className="dashboard-welcome"><div><h1 className="page-title">工作台</h1><p>把今天的节奏、客户和订单放在同一个视野里。</p></div></div>
<CalendarBoard todos={todos} onTodoCreated={loadDashboard} />
{/* 跟进卡放日历下面一栏(Wayne 2026-09-15 要求):先看日程,再看今天该催谁 */}
{followUpCount > 0 && canCustomer && (
<section className="followup-strip">
<div className="followup-card is-today">
<div className="followup-card-head">
<h3><ClockIcon size={15} /> 今日待跟进</h3>
<span className="followup-count">{todayFollowUps.length}</span>
</div>
{todayFollowUps.length === 0
? <p className="followup-empty">今天没有安排跟进。</p>
: <ul className="followup-list">{todayFollowUps.map(record => <FollowUpRow key={record.id} record={record} />)}</ul>}
</div>
<div className="followup-card is-overdue">
<div className="followup-card-head">
<h3><FlagIcon size={15} /> 逾期未跟进</h3>
<span className="followup-count">{overdueFollowUps.length}</span>
</div>
{overdueFollowUps.length === 0
? <p className="followup-empty">没有逾期项,节奏很好。</p>
: <ul className="followup-list">{overdueFollowUps.map(record => <FollowUpRow key={record.id} record={record} overdue />)}</ul>}
</div>
</section>
)}
<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> 笔进行中订单。打开右上角的小皮,可以直接用自然语言查阅和操作系统。</p><div className="digest-tags"><span>供应链</span><span>客户跟进</span><span>订单节奏</span></div></div></section>
<section className="dashboard-panel ai-digest-panel"><div className="dashboard-panel-header"><div><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>{followUpCount}</b> 个待跟进客户、<b>{recentInquiries.length}</b> 条最近询盘、<b>{stats.pendingContractJobs || 0}</b> 个待复核合同任务。打开右上角的小皮,可以直接用自然语言查阅和操作系统。</p><div className="digest-tags"><span>供应链</span><span>客户跟进</span><span>询盘报价</span><span>订单节奏</span></div></div></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>
<section className="dashboard-panel">
<div className="dashboard-panel-header">
<div><h2><InquiryIcon size={17} /> 最近询盘</h2></div>
{canInquiry && <Link className="panel-link" to="/inquiries">全部询盘</Link>}
</div>
{recentInquiries.length === 0
? <div className="dashboard-empty">还没有询盘记录</div>
: (
<ul className="compact-list">
{recentInquiries.map(inquiry => (
<li key={inquiry.id}>
{canInquiry ? (
<Link className="compact-main" to={`/inquiries/${inquiry.id}`}>
<span className="compact-title">{inquiry.title || '未命名询盘'}</span>
<span className="compact-sub">
<code>{inquiry.inquiry_no}</code>
{' · '}
{inquiry.customer_name || '未知客户'}
{' · '}
{inquiry.inquiry_date}
</span>
</Link>
) : (
<span className="compact-main">
<span className="compact-title">{inquiry.title || '未命名询盘'}</span>
<span className="compact-sub">
<code>{inquiry.inquiry_no}</code>
{' · '}
{inquiry.customer_name || '未知客户'}
{' · '}
{inquiry.inquiry_date}
</span>
</span>
)}
<span className="compact-tag">{INQUIRY_STATUS_LABELS[inquiry.status] || inquiry.status}</span>
</li>
))}
</ul>
)}
</section>
<section className="dashboard-panel">
<div className="dashboard-panel-header">
<div><h2><QuoteIcon size={17} /> 最近报价单</h2></div>
{canQuote && <Link className="panel-link" to="/quotes">报价单管理</Link>}
</div>
{recentQuotes.length === 0
? <div className="dashboard-empty">还没有客户报价单</div>
: (
<ul className="compact-list">
{recentQuotes.map(quote => (
<li key={quote.id}>
<span className="compact-main">
<span className="compact-title"><code>{quote.quote_no}</code></span>
<span className="compact-sub">
{quote.customer_name || '未知客户'}
{' · '}
{quote.quote_date}
{quote.valid_until ? ` · 有效期至 ${quote.valid_until}` : ''}
</span>
</span>
<span className="compact-tag">{quote.currency || ''}</span>
</li>
))}
</ul>
)}
</section>
<section className="dashboard-panel">
<div className="dashboard-panel-header">
<div><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>
<section className="dashboard-panel stat-rail"><div className="dashboard-panel-header"><div><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={<InquiryIcon size={17} />} label="待跟进" value={followUpCount} /><StatLine icon={<CleanupIcon size={17} />} label="待复核合同" value={stats.pendingContractJobs || 0} /></section>
<section className="dashboard-panel">
<div className="dashboard-panel-header">
<div><h2><CleanupIcon size={17} /> 待审核合同</h2></div>
{canCleanup && <Link className="panel-link" to="/contract-cleanup">去复核</Link>}
</div>
{pendingContractJobs.length === 0
? <div className="dashboard-empty">没有待复核的导入任务</div>
: (
<ul className="compact-list">
{pendingContractJobs.map(job => (
<li key={job.id}>
{canCleanup ? (
<Link className="compact-main" to="/contract-cleanup">
<span className="compact-title"><code>{job.job_no}</code></span>
<span className="compact-sub">
{job.file_count} 个文件 · {new Date(job.created_at).toLocaleString('zh-CN')}
</span>
</Link>
) : (
<span className="compact-main">
<span className="compact-title"><code>{job.job_no}</code></span>
<span className="compact-sub">
{job.file_count} 个文件 · {new Date(job.created_at).toLocaleString('zh-CN')}
</span>
</span>
)}
<span className="compact-tag">{CONTRACT_STATUS_LABELS[job.status] || job.status}</span>
</li>
))}
</ul>
)}
</section>
<section className="dashboard-panel completion-panel"><div className="dashboard-panel-header"><div><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><h2>快捷入口</h2></div></div>{canEditInquiry && <Link to="/inquiries"><InquiryIcon size={16} /> 新建询盘</Link>}{canEditCustomer && <Link to="/customers"><UsersIcon size={16} /> 新增客户</Link>}{canCleanup && <Link to="/contract-cleanup"><CleanupIcon size={16} /> 导入合同</Link>}</section>
</aside>
</div>
</div>
-141
View File
@@ -1,141 +0,0 @@
import React, { useCallback, useEffect, useRef, useState } from 'react'
import { RefreshIcon } from '../components/Icons'
/**
* SKU 功能嵌入页(整合过渡期方案)。
*
* 合并策略是「先嵌后重写」:SKU 的能力先以嵌入方式跑通,
* 之后再逐步重写为原生 React 页面。
*
* 现在嵌入的是独立的 sku-manager 服务(3300);等后端合并(P1/P2)完成后,
* 把 SKU_ORIGIN 换成同源子路径(如 /sku)即可,页面组件本身不用动。
*
* 外观一体:跨源 iframe 读不到宿主的 CSS 变量,所以由宿主把当前主题下
* 这些变量的实际取值读出来 postMessage 过去,嵌入页照单贴到自己对应的
* 变量上(嵌入页侧不含任何宿主变量名,见 sku-manager/public/embed.css)。
*/
const SKU_ORIGIN = import.meta.env.VITE_SKU_ORIGIN || 'http://127.0.0.1:3300'
/**
* 设计令牌桥:左边是嵌入页的 CSS 变量名,右边是宿主这边的取值来源。
* 只有这一处维护映射 —— 嵌入页负责结构,宿主负责配色与字体。
*/
const TOKEN_BRIDGE = {
'--bg': '--bg-primary', // 卡片底色 = 外壳底色 → 嵌入页"消失"
'--panel': '--surface', // 卡片、输入框默认底
'--panel-2': '--surface-deep', // 比卡片再深一档:输入框、拖入区
'--raised': '--surface-hover', // 悬浮、拖拽经过
'--line': '--border',
'--line-soft': '--border-light',
'--fg': '--text-primary',
'--fg-dim': '--text-secondary',
'--fg-mute': '--text-tertiary',
'--accent': '--primary',
'--accent-ink': '--primary',
'--accent-line': '--primary-border',
'--accent-glow': '--accent-glow',
'--accent-fill': '--primary-bg', // 输入框聚焦时的实底
'--on-accent': '--bg-primary', // 强调色上的文字:取宿主底色,两种主题都自动是反色
'--font-display': '--font-body',
'--font-body': '--font-body',
'--font-mono': '--font-mono',
'--r-sm': '--radius-sm',
'--r-md': '--radius-md'
}
const VIEW_META = {
search: {
title: '产品管理',
sub: '按货号、品名、规格、材质或参数检索产品与采购价'
},
suppliers: {
title: '供应商管理',
sub: '供应商档案、联系方式、工厂所在地与主营类目'
},
import: {
title: '合同导入',
sub: '批量导入采购合同,自动解析价格、产品与参数'
}
}
function EmbeddedSku({ view = 'search', theme = 'dark' }) {
const [loaded, setLoaded] = useState(false)
const frameRef = useRef(null)
const meta = VIEW_META[view] || VIEW_META.search
/* src 里的 theme 只当"首帧用哪个主题"的初始值:钉死成首次渲染时的值。
否则换主题时 React 会改 src,iframe 整页重载白闪一下。 */
const initialTheme = useRef(theme).current
const pushTheme = useCallback(() => {
const frame = frameRef.current
if (!frame?.contentWindow) return
const computed = window.getComputedStyle(document.documentElement)
const vars = {}
Object.entries(TOKEN_BRIDGE).forEach(([skuVar, hostVar]) => {
const value = computed.getPropertyValue(hostVar).trim()
/* 宿主改了变量名而这里没跟上时,嵌入页会悄悄回落到自己的旧配色 ——
必须喊出来,否则要对着截图猜半天。 */
if (!value) console.warn(`[embed] 宿主找不到变量 ${hostVar},嵌入页的 ${skuVar} 将回落默认值`)
vars[skuVar] = value
})
frame.contentWindow.postMessage({ type: 'happy:embed', theme, vars }, '*')
}, [theme])
useEffect(() => {
if (!loaded) return undefined
/* 放到下一个宏任务:App 是在自己的 effect 里才把 data-theme 写到 <html> 上,
而子组件的 effect 先于父组件执行,此刻读到的还是上一个主题的令牌。 */
const id = window.setTimeout(pushTheme, 0)
return () => window.clearTimeout(id)
}, [loaded, pushTheme])
const reload = () => {
setLoaded(false)
frameRef.current?.contentWindow?.location.reload()
}
const src = `${SKU_ORIGIN}/?embed=1&view=${encodeURIComponent(view)}&theme=${initialTheme}`
return (
<div className="page-container embed-shell">
<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>
<button type="button" className="embed-refresh" onClick={reload} title="重新加载" aria-label="重新加载">
<RefreshIcon size={16} />
</button>
</div>
<div className="embed-frame">
{!loaded && (
<div className="loading" style={{ position: 'absolute', inset: 0, zIndex: 1 }}>
<div className="spinner"></div>
<span style={{ marginLeft: '12px' }}>正在加载{meta.title}…</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
+601
View File
@@ -0,0 +1,601 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import { Link, useParams } from 'react-router-dom'
import axios from 'axios'
import Drawer from '../components/Drawer'
import SkuPicker from '../components/SkuPicker'
import { SkuThumb, useSkuThumbs } from '../components/SkuThumb'
import {
PlusCircleIcon, PencilIcon, TrashIcon, PackageIcon, ClockIcon,
InquiryIcon, DollarIcon, RefreshIcon
} from '../components/Icons'
import { useAuth, hasPermission } from '../auth'
const STATUS_LABELS = {
draft: '草稿',
quoting: '报价中',
quoted: '已报价',
won: '已成交',
lost: '已流失',
closed: '已关闭'
}
const STATUS_TONE = {
draft: 'mute',
quoting: 'warn',
quoted: 'ok',
won: 'ok',
lost: 'danger',
closed: 'mute'
}
const CURRENCIES = ['CNY', 'USD', 'EUR', 'GBP', 'JPY', 'AUD', 'CAD']
/**
* 报价合计。
* 口径说明:inquiry_items.currency 存的是「采购币种」,而 EXW 是拿采购价除以汇率算出来的,
* 所以 EXW / 小计 / 合计都落在「报价币种」上,也就是 inquiry.currency。
* 这里不做跨币种换算 —— 一张询盘的报价币种是固定的(汇率那一栏的分母就是它)。
*/
function summarize(items) {
return items.reduce((sum, item) => sum + Number(item.exw || 0) * Number(item.quantity || 0), 0)
}
function money(value, digits = 2) {
const number = Number(value)
if (!Number.isFinite(number)) return '—'
return number.toLocaleString('zh-CN', { minimumFractionDigits: digits, maximumFractionDigits: digits })
}
/** 包装快照读出来做「一行摘要」,让报价行不用展开就知道箱规 */
function packagingBrief(snapshot) {
if (!snapshot) return null
let data = null
try { data = JSON.parse(snapshot) } catch { return null }
if (!data) return null
const parts = []
if (data.carton_qty) parts.push(`${data.carton_qty} pcs/箱`)
if (data.carton_length && data.carton_width && data.carton_height) {
parts.push(`${data.carton_length}×${data.carton_width}×${data.carton_height} cm`)
}
if (data.carton_weight) parts.push(`毛重 ${data.carton_weight} kg`)
if (data.packaging_material) parts.push(data.packaging_material)
return parts.length ? parts : null
}
/**
* 询盘明细编辑器。
* 两种输入方式:
* 自动 —— 填加价率,EXW 由「采购价 ÷ 汇率 ×(1+加价率)」算出(这是报价的标准姿势)
* 手动 —— 直接给 EXW,反算加价率(客户先给目标价时用)
* 两者互斥,切换时保留当前能推导的量,避免把用户已经填好的数字清掉。
*/
function ItemEditor({ inquiry, item, existingSkus = [], onClose, onSaved, canEdit }) {
const isNew = !item
const [skuChoice, setSkuChoice] = useState(isNew ? null : { sku: item.sku, display_sku: item.sku, source: 'existing' })
const [pickerOpen, setPickerOpen] = useState(isNew)
const [mode, setMode] = useState('auto')
const [form, setForm] = useState(() => ({
purchase_price: item ? String(item.purchase_price ?? '') : '',
// 采购币种默认取人民币 —— 供应商报价绝大多数是 CNY,拿询盘的报价币种当默认值会把人带偏
currency: item?.currency || 'CNY',
exchange_rate: item ? String(item.exchange_rate ?? '') : '',
markup_rate: item ? String(((Number(item.markup_rate) || 0) * 100).toFixed(2).replace(/\.?0+$/, '')) : '25',
manual_exw: '',
quantity: item ? String(item.quantity ?? '') : '1'
}))
const [error, setError] = useState('')
const [saving, setSaving] = useState(false)
const purchasePrice = Number(form.purchase_price)
const exchangeRate = Number(form.exchange_rate)
const markupRate = Number(form.markup_rate) / 100
const manualExw = Number(form.manual_exw)
const base = Number.isFinite(purchasePrice) && Number.isFinite(exchangeRate) && exchangeRate > 0
? purchasePrice / exchangeRate
: null
const preview = useMemo(() => {
if (base === null) return null
if (mode === 'manual') {
if (!Number.isFinite(manualExw) || manualExw <= 0) return null
return { exw: manualExw, markup: manualExw / base - 1 }
}
if (!Number.isFinite(markupRate) || markupRate < -1) return null
return { exw: base * (1 + markupRate), markup: markupRate }
}, [mode, base, manualExw, markupRate])
const pickSku = async (choice) => {
setSkuChoice(choice)
setPickerOpen(false)
setError('')
// 命中 SKU 服务时返回的是完整货号,顺手把采购价/币种带出来,省一次手输
if (choice.source === 'manual') return
setForm(current => ({
...current,
purchase_price: choice.price === null || choice.price === undefined ? current.purchase_price : String(choice.price),
currency: choice.currency || current.currency
}))
}
const submit = async (event) => {
event.preventDefault()
if (!skuChoice?.sku) { setError('请先选择货号'); return }
const quantity = Number(form.quantity)
if (!Number.isInteger(quantity) || quantity <= 0) { setError('数量必须是大于 0 的整数'); return }
if (!Number.isFinite(purchasePrice) || purchasePrice <= 0) { setError('采购价必须是大于 0 的数字'); return }
if (!Number.isFinite(exchangeRate) || exchangeRate <= 0) { setError('汇率必须是大于 0 的数字'); return }
const payload = {
sku: skuChoice.sku,
purchase_price: purchasePrice,
currency: form.currency,
exchange_rate: exchangeRate,
quantity
}
if (mode === 'manual') {
if (!Number.isFinite(manualExw) || manualExw <= 0) { setError('手动 EXW 必须是大于 0 的数字'); return }
payload.manual_exw = manualExw
} else {
payload.markup_rate = markupRate
}
setSaving(true)
setError('')
try {
if (isNew) await axios.post(`/api/inquiries/${inquiry.id}/items`, payload)
else await axios.patch(`/api/inquiry-items/${item.id}`, payload)
onSaved()
} catch (saveError) {
setError(saveError?.response?.data?.error || '保存失败,请稍后重试')
} finally {
setSaving(false)
}
}
const brief = item ? packagingBrief(item.packaging_snapshot) : null
return (
<>
<Drawer
wide
title={isNew ? '添加报价明细' : `编辑明细 · ${item.sku}`}
sub={isNew ? `询盘 ${inquiry?.inquiry_no || ''}` : item.product_name}
onClose={onClose}
footer={(
<>
<button type="button" className="btn btn-secondary" onClick={onClose}>取消</button>
{canEdit && (
<button type="submit" form="inquiry-item-form" className="btn btn-primary" disabled={saving}>
{saving ? '保存中…' : '保存明细'}
</button>
)}
</>
)}
>
<form id="inquiry-item-form" onSubmit={submit}>
<div className="panel-title">货号</div>
<div className="sku-chosen">
{skuChoice ? (
<>
<div className="sku-chosen-main">
<SkuThumb sku={skuChoice.sku} filename={skuChoice.thumb} size={36} />
<b>{skuChoice.display_sku || skuChoice.sku}</b>
<span>{skuChoice.name || skuChoice.spec || (skuChoice.source === 'manual' ? '手工输入货号' : '')}</span>
</div>
{canEdit && (
<button type="button" className="btn btn-secondary btn-sm" onClick={() => setPickerOpen(true)}>
更换货号
</button>
)}
</>
) : (
<p className="muted-note">还没有选择货号。</p>
)}
</div>
<div className="panel-title">报价参数</div>
<div className="form-row">
<div className="form-group">
<label>采购价(含税单价)</label>
<input
type="number" min="0" step="0.01" inputMode="decimal"
value={form.purchase_price}
onChange={event => setForm({ ...form, purchase_price: event.target.value })}
/>
</div>
<div className="form-group">
<label>采购币种</label>
<select value={form.currency} onChange={event => setForm({ ...form, currency: event.target.value })}>
{CURRENCIES.map(currency => <option key={currency} value={currency}>{currency}</option>)}
</select>
</div>
</div>
<div className="form-row">
<div className="form-group">
<label>汇率(1 {inquiry?.currency || '报价币'} = ? {form.currency})</label>
<input
type="number" min="0" step="0.0001" inputMode="decimal"
value={form.exchange_rate}
placeholder="例如 6.7"
onChange={event => setForm({ ...form, exchange_rate: event.target.value })}
/>
</div>
<div className="form-group">
<label>数量</label>
<input
type="number" min="1" step="1" inputMode="numeric"
value={form.quantity}
onChange={event => setForm({ ...form, quantity: event.target.value })}
/>
</div>
</div>
<div className="panel-title">定价方式</div>
<div className="mode-switch" role="tablist">
<button
type="button" role="tab" aria-selected={mode === 'auto'}
className={mode === 'auto' ? 'is-active' : ''}
onClick={() => setMode('auto')}
>
按加价率算 EXW
</button>
<button
type="button" role="tab" aria-selected={mode === 'manual'}
className={mode === 'manual' ? 'is-active' : ''}
onClick={() => setMode('manual')}
>
手填 EXW 反算加价率
</button>
</div>
<div className="form-row">
{mode === 'auto' ? (
<div className="form-group">
<label>加价率</label>
<span className="pkg-input">
<input
type="number" step="0.1" inputMode="decimal"
value={form.markup_rate}
placeholder="例如 25"
onChange={event => setForm({ ...form, markup_rate: event.target.value })}
/>
<em>%</em>
</span>
</div>
) : (
<div className="form-group">
<label>目标 EXW 单价</label>
<input
type="number" min="0" step="0.01" inputMode="decimal"
value={form.manual_exw}
placeholder="客户给的目标价"
onChange={event => setForm({ ...form, manual_exw: event.target.value })}
/>
</div>
)}
<div className="form-group">
<label>{mode === 'auto' ? '加价系数' : '反算加价系数'}</label>
<div className="readout">
{preview ? (preview.markup + 1).toFixed(4) : '—'}
</div>
</div>
</div>
<div className="exw-preview">
<div className="exw-preview-formula">
EXW = 采购价 ÷ 汇率 ×(1 + 加价率)
</div>
<div className="exw-preview-main">
<span>
EXW 单价
<em className="exw-preview-currency">报价币种 {inquiry?.currency || '—'}</em>
</span>
<strong>{preview ? `${money(preview.exw, 4)} ${inquiry?.currency || ''}` : '—'}</strong>
</div>
<div className="exw-preview-sub">
{base === null ? (
<span>填好采购价和汇率后自动计算。</span>
) : (
<>
<span>采购价折报价币 <b>{money(base, 4)}</b></span>
<span>加价率 <b>{preview ? `${(preview.markup * 100).toFixed(2)}%` : '—'}</b></span>
<span>小计 <b>{preview ? `${money(preview.exw * Number(form.quantity || 0), 2)} ${inquiry?.currency || ''}` : '—'}</b></span>
</>
)}
</div>
</div>
{brief && (
<>
<div className="panel-title">包装(本次报价时的快照)</div>
<ul className="snapshot-list">
{brief.map(text => <li key={text}><PackageIcon size={13} /> {text}</li>)}
</ul>
<p className="muted-note">快照在报价那一刻定格,之后产品包装信息再改也不会动这行报价。</p>
</>
)}
{error && <p className="form-error">{error}</p>}
</form>
</Drawer>
{pickerOpen && (
<div className="modal-overlay" onClick={() => (isNew ? onClose() : setPickerOpen(false))}>
<div className="modal-content sku-picker-modal" onClick={event => event.stopPropagation()}>
<SkuPicker
excludeSkus={existingSkus}
onClose={() => (isNew ? onClose() : setPickerOpen(false))}
onPick={pickSku}
/>
</div>
</div>
)}
</>
)
}
function InquiryDetail() {
const { id } = useParams()
const { user } = useAuth()
const canEdit = hasPermission(user, 'inquiry.edit')
const [data, setData] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [editor, setEditor] = useState(null) // { item } 或 { item: null } 表示新增
const [expandedPackaging, setExpandedPackaging] = useState(null)
/* 明细里的货号只在本地库里,图片得按货号批量问 SKU 服务要 */
const itemThumbs = useSkuThumbs((data?.items || []).map(row => row.sku))
const load = useCallback(async () => {
setLoading(true)
setError('')
try {
const response = await axios.get(`/api/inquiries/${id}`)
setData(response.data)
} catch (loadError) {
setError(loadError?.response?.data?.error || '读取询盘失败')
} finally {
setLoading(false)
}
}, [id])
useEffect(() => { load() }, [load])
const updateStatus = async (status) => {
try {
await axios.patch(`/api/inquiries/${id}`, { status })
await load()
} catch (statusError) {
setError(statusError?.response?.data?.error || '更新状态失败')
}
}
const removeItem = async (item) => {
if (!window.confirm(`删除明细「${item.product_name || item.sku}」?删除后不可恢复。`)) return
try {
await axios.delete(`/api/inquiry-items/${item.id}`)
await load()
} catch (removeError) {
setError(removeError?.response?.data?.error || '删除失败,请稍后重试')
}
}
const inquiry = data?.inquiry
const items = data?.items || []
const totals = useMemo(() => summarize(items), [items])
if (loading) return <div className="loading">加载询盘…</div>
if (!inquiry) {
return (
<div className="page-container">
<p className="notice-inline">{error || '询盘不存在'}</p>
<Link className="btn btn-secondary btn-sm" to="/inquiries">返回询盘列表</Link>
</div>
)
}
return (
<div className="page-container">
<div className="breadcrumb">
<Link to="/inquiries">询盘管理</Link>
<span>/</span>
<span className="mono">{inquiry.inquiry_no}</span>
</div>
<div className="page-header">
<div>
<h1 className="page-title">{inquiry.title || '未命名询盘'}</h1>
<p className="page-sub">
<Link to={`/customers/${inquiry.customer_id}`}>{inquiry.customer_name || '未知客户'}</Link>
{' · '}
{inquiry.inquiry_date}
{' · '}
<span className={`pill pill-${STATUS_TONE[inquiry.status] || 'mute'}`}>
{STATUS_LABELS[inquiry.status] || inquiry.status}
</span>
</p>
</div>
<div className="header-actions">
<button type="button" className="btn btn-secondary btn-sm" onClick={load}>
<RefreshIcon size={14} /> 刷新
</button>
{canEdit && (
<button type="button" className="btn btn-primary btn-sm" onClick={() => setEditor({ item: null })}>
<PlusCircleIcon size={15} /> 添加货号
</button>
)}
</div>
</div>
<div className="stat-rail inquiry-rail">
<div className="stat-line">
<span className="stat-line-icon"><InquiryIcon size={17} /></span>
<span>报价明细</span><strong>{items.length}</strong>
</div>
<div className="stat-line">
<span className="stat-line-icon"><PackageIcon size={17} /></span>
<span>总数量</span>
<strong>{items.reduce((sum, item) => sum + Number(item.quantity || 0), 0).toLocaleString('zh-CN')}</strong>
</div>
<div className="stat-line">
<span className="stat-line-icon"><DollarIcon size={17} /></span>
<span>报价合计</span>
<strong>
{items.length ? `${money(totals, 2)} ${inquiry.currency || ''}` : '—'}
</strong>
</div>
</div>
{error && <p className="notice-inline">{error}</p>}
<section className="dashboard-panel">
<div className="dashboard-panel-header">
<div>
<h2><DollarIcon size={17} /> 报价明细</h2>
</div>
<div className="header-actions">
{canEdit ? (
<label className="inline-field">
<span>询盘状态</span>
<select value={inquiry.status} onChange={event => updateStatus(event.target.value)}>
{Object.entries(STATUS_LABELS).map(([value, label]) => (
<option key={value} value={value}>{label}</option>
))}
</select>
</label>
) : (
<span className={`pill pill-${STATUS_TONE[inquiry.status] || 'mute'}`}>
{STATUS_LABELS[inquiry.status] || inquiry.status}
</span>
)}
</div>
</div>
{items.length === 0 ? (
<div className="empty-state">
<p className="empty-state-title">这次询盘还没有报价行</p>
<p className="empty-state-hint">点右上角「添加货号」,从已有 SKU 里选,或直接敲完整货号。</p>
</div>
) : (
<div className="table-wrap">
<table className="data-table quote-table">
<thead>
<tr>
<th>货号</th>
<th>产品 / 供应商</th>
<th className="num">采购价</th>
<th className="num">汇率</th>
<th className="num">加价率</th>
<th className="num">加价系数</th>
<th className="num">EXW<span className="th-unit">{inquiry.currency}</span></th>
<th className="num">数量</th>
<th className="num">小计<span className="th-unit">{inquiry.currency}</span></th>
<th>包装</th>
<th className="col-actions">操作</th>
</tr>
</thead>
<tbody>
{items.map(item => {
const brief = packagingBrief(item.packaging_snapshot)
const open = expandedPackaging === item.id
const subtotal = Number(item.exw || 0) * Number(item.quantity || 0)
return (
<React.Fragment key={item.id}>
<tr>
<td className="mono bold">
<span className="sku-cell">
<SkuThumb sku={item.sku} filename={itemThumbs[item.sku]} size={30} />
{item.sku}
</span>
</td>
<td>
<div>{item.product_name || '—'}</div>
<div className="cell-sub">{item.supplier_name || '未指派供应商'}</div>
</td>
<td className="num">{money(item.purchase_price, 2)} <span className="unit">{item.currency}</span></td>
<td className="num">{money(item.exchange_rate, 4)}</td>
<td className="num">{(Number(item.markup_rate || 0) * 100).toFixed(2)}%</td>
<td className="num">{money(item.markup_factor, 4)}</td>
<td className="num strong-cell">{money(item.exw, 4)}</td>
<td className="num">{Number(item.quantity || 0).toLocaleString('zh-CN')}</td>
<td className="num">{money(subtotal, 2)}</td>
<td>
{brief ? (
<button
type="button"
className={`packaging-chip${open ? ' is-open' : ''}`}
onClick={() => setExpandedPackaging(open ? null : item.id)}
title="展开包装快照"
>
<PackageIcon size={13} /> {brief[0]}
</button>
) : (
<span className="muted-note">未维护</span>
)}
</td>
<td className="col-actions">
{canEdit && (
<button type="button" className="icon-btn" title="编辑明细" onClick={() => setEditor({ item })}>
<PencilIcon size={14} />
</button>
)}
{canEdit && (
<button type="button" className="icon-btn" title="删除明细" onClick={() => removeItem(item)}>
<TrashIcon size={14} />
</button>
)}
</td>
</tr>
{open && brief && (
<tr className="snapshot-row">
<td colSpan={11}>
<div className="snapshot-detail">
<span className="snapshot-detail-title"><PackageIcon size={13} /> 报价时的包装快照</span>
<ul className="snapshot-list">
{brief.map(text => <li key={text}>{text}</li>)}
</ul>
</div>
</td>
</tr>
)}
</React.Fragment>
)
})}
</tbody>
</table>
</div>
)}
</section>
<section className="dashboard-panel">
<div className="dashboard-panel-header">
<div>
<h2><ClockIcon size={17} /> 跟进记录</h2>
</div>
<Link className="btn btn-secondary btn-sm" to={`/customers/${inquiry.customer_id}`}>
去客户详情记录跟进
</Link>
</div>
<p className="muted-note">
跟进记录统一挂在客户身上,记录时可以在「关联询盘」里选中本次询盘({inquiry.inquiry_no}),
这样客户时间线和工作台待跟进都能对上。
</p>
</section>
{editor && (
<ItemEditor
inquiry={inquiry}
item={editor.item}
existingSkus={items.filter(row => row.id !== editor.item?.id).map(row => row.sku)}
onClose={() => setEditor(null)}
onSaved={() => { setEditor(null); load() }}
canEdit={canEdit}
/>
)}
</div>
)
}
export default InquiryDetail
+357
View File
@@ -0,0 +1,357 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import axios from 'axios'
import { InquiryIcon, PlusCircleIcon, SearchIcon, RefreshIcon, UsersIcon } from '../components/Icons'
import { SkeletonTableRows } from '../components/Skeleton'
import { useAuth, hasPermission } from '../auth'
const STATUS_LABELS = {
draft: '草稿',
quoting: '报价中',
quoted: '已报价',
won: '已成交',
lost: '已流失',
closed: '已关闭'
}
const STATUS_TONE = {
draft: 'mute',
quoting: 'warn',
quoted: 'ok',
won: 'ok',
lost: 'danger',
closed: 'mute'
}
const SOURCES = ['展会', '邮件开发', '老客户复购', '阿里国际站', '客户主动询价', '其他']
function InquiryManagement() {
const navigate = useNavigate()
const { user } = useAuth()
const canEdit = hasPermission(user, 'inquiry.edit')
const [inquiries, setInquiries] = useState([])
const [customers, setCustomers] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [keyword, setKeyword] = useState('')
const [statusFilter, setStatusFilter] = useState('')
const [showAdd, setShowAdd] = useState(false)
const [form, setForm] = useState({
customer_id: '', title: '', inquiry_date: new Date().toISOString().slice(0, 10),
currency: 'USD', source: SOURCES[0], notes: ''
})
const [saving, setSaving] = useState(false)
const [formError, setFormError] = useState('')
const load = useCallback(async () => {
setLoading(true)
setError('')
try {
const [inquiryResponse, customerResponse] = await Promise.all([
axios.get('/api/inquiries'),
axios.get('/api/customers')
])
setInquiries(Array.isArray(inquiryResponse.data) ? inquiryResponse.data : [])
setCustomers(Array.isArray(customerResponse.data) ? customerResponse.data : [])
} catch (loadError) {
setError(loadError?.response?.data?.error || '读取询盘列表失败')
} finally {
setLoading(false)
}
}, [])
useEffect(() => { load() }, [load])
const filtered = useMemo(() => {
const term = keyword.trim().toLowerCase()
return inquiries.filter(inquiry => {
if (statusFilter && inquiry.status !== statusFilter) return false
if (!term) return true
return [inquiry.inquiry_no, inquiry.title, inquiry.customer_name]
.filter(Boolean)
.some(value => String(value).toLowerCase().includes(term))
})
}, [inquiries, keyword, statusFilter])
const counts = useMemo(() => {
const result = { total: inquiries.length }
inquiries.forEach(inquiry => { result[inquiry.status] = (result[inquiry.status] || 0) + 1 })
return result
}, [inquiries])
const openAdd = () => {
setForm({
customer_id: customers[0]?.id ? String(customers[0].id) : '',
title: '', inquiry_date: new Date().toISOString().slice(0, 10),
currency: 'USD', source: SOURCES[0], notes: ''
})
setFormError('')
setShowAdd(true)
}
const submit = async (event) => {
event.preventDefault()
if (!form.customer_id) { setFormError('请选择客户'); return }
if (!customers.length) { setFormError('还没有客户,请先到「客户管理」新增一个客户'); return }
setSaving(true)
setFormError('')
try {
const response = await axios.post('/api/inquiries', {
customer_id: Number(form.customer_id),
title: form.title.trim() || null,
inquiry_date: form.inquiry_date,
currency: form.currency,
source: form.source || null,
notes: form.notes.trim() || null
})
const created = response.data?.inquiry
setShowAdd(false)
if (created?.id) navigate(`/inquiries/${created.id}`)
else load()
} catch (submitError) {
setFormError(submitError?.response?.data?.error || '创建询盘失败')
} finally {
setSaving(false)
}
}
return (
<div className="page-container">
<div className="page-header">
<div>
<h1 className="page-title">询盘管理</h1>
<p className="page-sub">客户的第一封询价、绑定的货号、最终的 EXW 报价,都在这里串起来。</p>
</div>
<div className="header-actions">
<button type="button" className="btn btn-secondary btn-sm" onClick={load}>
<RefreshIcon size={14} /> 刷新
</button>
{canEdit && (
<button type="button" className="btn btn-primary btn-sm" onClick={openAdd}>
<PlusCircleIcon size={15} /> 新建询盘
</button>
)}
</div>
</div>
<div className="filter-bar">
<span className="toolbar-search">
<SearchIcon size={15} />
<input
type="search"
value={keyword}
placeholder="搜询盘号 / 标题 / 客户"
aria-label="搜索询盘"
onChange={event => setKeyword(event.target.value)}
/>
</span>
<div className="chip-filters">
<button
type="button"
className={`chip-filter${statusFilter === '' ? ' is-active' : ''}`}
onClick={() => setStatusFilter('')}
>
全部 <b>{counts.total || 0}</b>
</button>
{Object.entries(STATUS_LABELS).map(([value, label]) => (
counts[value] ? (
<button
type="button"
key={value}
className={`chip-filter${statusFilter === value ? ' is-active' : ''}`}
onClick={() => setStatusFilter(value)}
>
{label} <b>{counts[value]}</b>
</button>
) : null
))}
</div>
</div>
{error && <p className="notice-inline">{error}</p>}
{loading ? (
<div className="table-container">
<table className="data-table">
<thead>
<tr>
<th>询盘号</th>
<th>客户</th>
<th>标题</th>
<th>询盘日期</th>
<th className="num">货号数</th>
<th>报价币种</th>
<th>来源</th>
<th>状态</th>
<th className="col-actions">操作</th>
</tr>
</thead>
<tbody>
<SkeletonTableRows rows={6} widths={[1.2, 1.2, 1.8, 1, 0.7, 0.9, 0.9, 0.9, 1]} />
</tbody>
</table>
</div>
) : filtered.length === 0 ? (
<div className="dashboard-panel">
<div className="empty-state">
<p className="empty-state-title">
{inquiries.length ? '没有符合条件的询盘' : '还没有任何询盘'}
</p>
<p className="empty-state-hint">
{inquiries.length
? '换个关键词或状态试试。'
: '客户来了第一封询价时,先建一条询盘,再往里面挂货号。'}
</p>
{!inquiries.length && canEdit && (
<button type="button" className="btn btn-primary btn-sm" onClick={openAdd} style={{ marginTop: 14 }}>
<PlusCircleIcon size={15} /> 新建询盘
</button>
)}
</div>
</div>
) : (
<div className="table-container">
<table className="data-table">
<thead>
<tr>
<th>询盘号</th>
<th>客户</th>
<th>标题</th>
<th>询盘日期</th>
<th className="num">货号数</th>
<th>报价币种</th>
<th>来源</th>
<th>状态</th>
<th className="col-actions">操作</th>
</tr>
</thead>
<tbody>
{filtered.map(inquiry => (
<tr key={inquiry.id} onClick={() => navigate(`/inquiries/${inquiry.id}`)}>
<td className="mono bold">{inquiry.inquiry_no}</td>
<td>{inquiry.customer_name || '—'}</td>
<td>{inquiry.title || <span className="muted-note">未命名</span>}</td>
<td className="mono">{inquiry.inquiry_date}</td>
<td className="num">{inquiry.item_count ?? 0}</td>
<td>{inquiry.currency || '—'}</td>
<td>{inquiry.source || '—'}</td>
<td>
<span className={`pill pill-${STATUS_TONE[inquiry.status] || 'mute'}`}>
{STATUS_LABELS[inquiry.status] || inquiry.status}
</span>
</td>
<td className="col-actions">
<Link
className="btn-link"
to={`/inquiries/${inquiry.id}`}
onClick={event => event.stopPropagation()}
>
查看报价
</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{showAdd && (
<div className="modal-overlay" onClick={() => setShowAdd(false)}>
<div className="modal-content" onClick={event => event.stopPropagation()}>
<div className="modal-header">
<h2><InquiryIcon size={18} /> 新建询盘</h2>
<button type="button" className="icon-btn" onClick={() => setShowAdd(false)} aria-label="关闭">×</button>
</div>
<form onSubmit={submit}>
{customers.length === 0 && (
<p className="notice-inline">
还没有客户档案。先去
<Link to="/customers" onClick={() => setShowAdd(false)}> 客户管理 </Link>
建一个客户,询盘必须挂在客户身上。
</p>
)}
<div className="form-group">
<label>客户 *</label>
<select
value={form.customer_id}
onChange={event => setForm({ ...form, customer_id: event.target.value })}
>
<option value="">请选择客户</option>
{customers.map(customer => (
<option key={customer.id} value={customer.id}>
{customer.company}{customer.country ? ` · ${customer.country}` : ''}
</option>
))}
</select>
</div>
<div className="form-group">
<label>询盘标题</label>
<input
type="text"
maxLength={200}
value={form.title}
placeholder="例如:2026 春季手动工具首轮询价"
onChange={event => setForm({ ...form, title: event.target.value })}
/>
</div>
<div className="form-row">
<div className="form-group">
<label>询盘日期</label>
<input
type="date"
value={form.inquiry_date}
onChange={event => setForm({ ...form, inquiry_date: event.target.value })}
/>
</div>
<div className="form-group">
<label>报价币种</label>
<select
value={form.currency}
onChange={event => setForm({ ...form, currency: event.target.value })}
>
{['USD', 'EUR', 'CNY', 'GBP', 'JPY', 'AUD'].map(currency => (
<option key={currency} value={currency}>{currency}</option>
))}
</select>
</div>
</div>
<div className="form-group">
<label>来源渠道</label>
<select value={form.source} onChange={event => setForm({ ...form, source: event.target.value })}>
{SOURCES.map(source => <option key={source} value={source}>{source}</option>)}
</select>
</div>
<div className="form-group">
<label>备注</label>
<textarea
rows={3}
maxLength={1000}
value={form.notes}
placeholder="客户提到的交期、认证、目标价等"
onChange={event => setForm({ ...form, notes: event.target.value })}
/>
</div>
{formError && <p className="form-error">{formError}</p>}
<div className="modal-footer">
<button type="button" className="btn btn-secondary" onClick={() => setShowAdd(false)}>取消</button>
{canEdit && (
<button type="submit" className="btn btn-primary" disabled={saving || !customers.length}>
{saving ? '创建中…' : '创建并去报价'}
</button>
)}
</div>
</form>
</div>
</div>
)}
{!loading && customers.length > 0 && inquiries.length > 0 && (
<p className="muted-note table-foot-note">
<UsersIcon size={13} /> 共 {filtered.length} 条询盘
{filtered.some(inquiry => !inquiry.item_count) && ' · 有询盘还没有报价行,点进去添加货号'}
</p>
)}
</div>
)
}
export default InquiryManagement
+106
View File
@@ -0,0 +1,106 @@
import React, { useEffect, useRef, useState } from 'react'
import { useLocation, useNavigate } from 'react-router-dom'
import { CloudDbIcon, UsersIcon } from '../components/Icons'
import { useAuth } from '../auth'
import { useBranding, setPageTitle } from '../branding'
/**
* 登录页。
*
* 用 replace 跳转:登录成功后不该能用浏览器返回键退回登录页。
* 若是因为「会话过期」被动登出(顶层守卫带过来的 from),登录后回到原来那一页。
*/
function LoginPage() {
const { login } = useAuth()
const brand = useBranding()
const navigate = useNavigate()
const location = useLocation()
const [form, setForm] = useState({ username: '', password: '' })
const [error, setError] = useState('')
const [busy, setBusy] = useState(false)
const userRef = useRef(null)
useEffect(() => {
setPageTitle('登录')
userRef.current?.focus()
}, [])
const from = location.state?.from && location.state.from !== '/login' ? location.state.from : '/'
const submit = async event => {
event.preventDefault()
if (!form.username.trim()) { setError('请输入用户名'); return }
if (!form.password) { setError('请输入密码'); return }
setBusy(true)
setError('')
try {
await login(form.username.trim(), form.password)
navigate(from, { replace: true })
} catch (submitError) {
setError(submitError?.response?.data?.error || '登录失败,请稍后重试')
} finally {
setBusy(false)
}
}
return (
<div className="login-page">
<div className="login-card">
<aside className="login-brand">
<span className="login-brand-mark"><CloudDbIcon size={44} /></span>
<h1>{brand.name}</h1>
<p className="login-brand-sub">{brand.subtitle}</p>
<ul className="login-brand-points">
<li>询盘 → 报价 → PI → 采购,一条链路跑完</li>
<li>产品与供应商统一目录,团队共享可参考</li>
<li>汇率、英文大写金额、包装参数内置</li>
</ul>
<p className="login-brand-foot">v1 · 第一阶段</p>
</aside>
<form className="login-form" onSubmit={submit}>
<header>
<h2>账号登录</h2>
<p className="login-hint"><UsersIcon size={14} /> 请使用组织分配给你的账号</p>
</header>
<div className="form-group">
<label htmlFor="login-username">用户名</label>
<input
id="login-username"
ref={userRef}
type="text"
autoComplete="username"
value={form.username}
onChange={event => setForm(current => ({ ...current, username: event.target.value }))}
placeholder="请输入用户名"
/>
</div>
<div className="form-group">
<label htmlFor="login-password">密码</label>
<input
id="login-password"
type="password"
autoComplete="current-password"
value={form.password}
onChange={event => setForm(current => ({ ...current, password: event.target.value }))}
placeholder="请输入密码"
/>
</div>
{error && <p className="form-error login-error">{error}</p>}
<button type="submit" className="btn btn-primary login-submit" disabled={busy}>
{busy ? '登录中…' : '登 录'}
</button>
<p className="login-tip">首次使用默认账号 <code>admin</code>,登录后请在「个人资料」里修改密码。</p>
</form>
</div>
<p className="login-copyright">{brand.name} · 外贸供应链管理系统</p>
</div>
)
}
export default LoginPage
+31 -3
View File
@@ -1,9 +1,13 @@
import React, { useState, useEffect } from 'react'
import axios from 'axios'
import { SkeletonTableRows } from '../components/Skeleton'
import { useAuth, hasPermission } from '../auth'
function OrderManagement() {
const [orders, setOrders] = useState([])
const [loading, setLoading] = useState(true)
const { user } = useAuth()
const canEdit = hasPermission(user, 'order.edit')
useEffect(() => {
fetchOrders()
@@ -33,14 +37,38 @@ function OrderManagement() {
}
if (loading) {
return <div className="loading">加载中...</div>
return (
<div className="page-container">
<div className="page-header">
<h1 className="page-title">订单管理</h1>
</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>
<SkeletonTableRows rows={5} widths={[1.2, 1.6, 1, 1, 1.2, 0.8, 1]} />
</tbody>
</table>
</div>
</div>
)
}
return (
<div className="page-container">
<div className="page-header">
<h1 className="page-title">订单管理</h1>
<button className="btn-primary">+ 新建订单</button>
{canEdit && <button className="btn-primary">+ 新建订单</button>}
</div>
<div className="table-container">
@@ -84,7 +112,7 @@ function OrderManagement() {
</td>
<td>
<button className="btn-link">查看</button>
<button className="btn-link">编辑</button>
{canEdit && <button className="btn-link">编辑</button>}
</td>
</tr>
))
+280
View File
@@ -0,0 +1,280 @@
import React, { useEffect, useMemo, useState } from 'react'
import axios from 'axios'
import { ShieldIcon, PlusIcon, TrashIcon, SaveIcon } from '../components/Icons'
import { useAuth } from '../auth'
import { setPageTitle } from '../branding'
/**
* 权限组管理。
*
* 口径:角色(董事长/总经理/销售经理/销售业务员)只是职位标签,本身不带权限。
* 权限组的「权限范围」勾选决定了能访问哪些模块;把角色指派进组,该角色下的用户即生效。
* 一个角色同一时间只能属于一个组 —— 所以角色复选框是互斥语义,勾到别的组会把它移过来。
*/
function PermissionGroups() {
const { refresh } = useAuth()
const [groups, setGroups] = useState([])
const [catalog, setCatalog] = useState({ groups: [], roles: [] })
const [selectedId, setSelectedId] = useState(null)
const [draft, setDraft] = useState(null)
const [loading, setLoading] = useState(true)
const [busy, setBusy] = useState(false)
const [notice, setNotice] = useState(null)
const load = async (keepId) => {
const [groupRes, catalogRes] = await Promise.all([
axios.get('/api/permission-groups'),
axios.get('/api/permissions')
])
const list = groupRes.data.groups || []
setGroups(list)
setCatalog({ groups: catalogRes.data.groups || [], roles: catalogRes.data.roles || [] })
const target = keepId ?? selectedId
const pick = list.find(item => item.id === target) || list[0] || null
setSelectedId(pick?.id ?? null)
setDraft(pick ? { ...pick, permissions: [...pick.permissions], roles: [...pick.roles] } : null)
return list
}
useEffect(() => {
setPageTitle('权限组管理')
load().catch(() => setNotice({ type: 'error', text: '加载权限组失败' })).finally(() => setLoading(false))
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const roleGroupMap = useMemo(() => {
const map = new Map()
for (const group of groups) {
for (const role of group.roles) map.set(role, group.id)
}
return map
}, [groups])
const select = id => {
const target = groups.find(item => item.id === id)
if (!target) return
setSelectedId(id)
setDraft({ ...target, permissions: [...target.permissions], roles: [...target.roles] })
setNotice(null)
}
const togglePermission = key => {
setDraft(current => {
const has = current.permissions.includes(key)
return {
...current,
permissions: has ? current.permissions.filter(item => item !== key) : [...current.permissions, key]
}
})
}
const toggleModulePermissions = (moduleGroups, checked) => {
const keys = moduleGroups.items.map(item => item.key)
setDraft(current => ({
...current,
permissions: checked
? [...new Set([...current.permissions, ...keys])]
: current.permissions.filter(item => !keys.includes(item))
}))
}
const toggleRole = role => {
setDraft(current => {
const has = current.roles.includes(role)
return { ...current, roles: has ? current.roles.filter(item => item !== role) : [...current.roles, role] }
})
}
const save = async () => {
if (!draft) return
if (!draft.name.trim()) { setNotice({ type: 'error', text: '权限组名称不能为空' }); return }
setBusy(true)
setNotice(null)
try {
const { data } = await axios.patch(`/api/permission-groups/${draft.id}`, {
name: draft.name.trim(),
description: draft.description,
permissions: draft.permissions,
roles: draft.roles
})
await load(data.group.id)
await refresh() /* 可能改到了自己的角色所属组,刷新自身权限 */
setNotice({ type: 'ok', text: '权限组已保存' })
} catch (error) {
setNotice({ type: 'error', text: error?.response?.data?.error || '保存失败' })
} finally {
setBusy(false)
}
}
const createGroup = async () => {
const name = window.prompt('新权限组名称', '新权限组')
if (name === null) return
if (!name.trim()) return
setBusy(true)
try {
const { data } = await axios.post('/api/permission-groups', {
name: name.trim(),
description: '',
permissions: ['dashboard.view'],
roles: []
})
await load(data.group.id)
setNotice({ type: 'ok', text: `权限组「${data.group.name}」已创建` })
} catch (error) {
setNotice({ type: 'error', text: error?.response?.data?.error || '创建失败' })
} finally {
setBusy(false)
}
}
const removeGroup = async () => {
if (!draft) return
if (!window.confirm(`确定删除权限组「${draft.name}」?该操作不可撤销。`)) return
setBusy(true)
try {
await axios.delete(`/api/permission-groups/${draft.id}`)
await load(null)
setNotice({ type: 'ok', text: '权限组已删除' })
} catch (error) {
setNotice({ type: 'error', text: error?.response?.data?.error || '删除失败' })
} finally {
setBusy(false)
}
}
if (loading) return <div className="page-container"><p className="muted-note">正在加载权限组…</p></div>
return (
<div className="page-container perm-page">
<div className="page-header">
<div>
<h1 className="page-title">权限组管理</h1>
<p className="page-subtitle">
角色只是职位标签;把角色放进某个权限组,它就获得该组勾选的权限范围。一个角色同时只能属于一个组。
</p>
</div>
<button type="button" className="btn btn-secondary" onClick={createGroup} disabled={busy}>
<PlusIcon size={15} /> 新建权限组
</button>
</div>
{notice && <p className={`profile-notice ${notice.type}`}>{notice.text}</p>}
<div className="perm-layout">
{/* ---------------- 左:组列表 ---------------- */}
<aside className="perm-list panel">
<div className="panel-title">权限组({groups.length})</div>
{groups.map(group => (
<button
key={group.id}
type="button"
className={`perm-list-item${group.id === selectedId ? ' active' : ''}`}
onClick={() => select(group.id)}
>
<span className="perm-list-name">
<ShieldIcon size={14} /> {group.name}
{group.is_system ? <i className="perm-tag">系统</i> : null}
</span>
<span className="perm-list-meta">
{group.permissions.length} 项权限 · {group.roles.length} 个角色
</span>
</button>
))}
</aside>
{/* ---------------- 右:编辑区 ---------------- */}
<section className="panel perm-editor">
{!draft ? (
<p className="muted-note">左侧选择一个权限组,或新建一个。</p>
) : (
<>
<div className="panel-title">
{draft.name}
{draft.is_system ? <i className="perm-tag">系统内置 · 不可删除</i> : null}
</div>
<div className="perm-basic">
<div className="form-group">
<label>名称</label>
<input type="text" value={draft.name} onChange={event => setDraft({ ...draft, name: event.target.value })} />
</div>
<div className="form-group">
<label>说明</label>
<input type="text" value={draft.description} placeholder="这个组大致管什么" onChange={event => setDraft({ ...draft, description: event.target.value })} />
</div>
</div>
<div className="perm-section-title">权限范围</div>
<div className="perm-modules">
{catalog.groups.map(module => {
const keys = module.items.map(item => item.key)
const checkedCount = keys.filter(key => draft.permissions.includes(key)).length
const allChecked = checkedCount === keys.length
return (
<div className="perm-module" key={module.title}>
<label className="perm-module-head">
<input
type="checkbox"
checked={allChecked}
ref={element => { if (element) element.indeterminate = checkedCount > 0 && !allChecked }}
onChange={event => toggleModulePermissions(module, event.target.checked)}
/>
<b>{module.title}</b>
<span className="perm-module-count">{checkedCount}/{keys.length}</span>
</label>
<div className="perm-module-items">
{module.items.map(item => (
<label className="perm-item" key={item.key}>
<input
type="checkbox"
checked={draft.permissions.includes(item.key)}
onChange={() => togglePermission(item.key)}
/>
<span>{item.label}</span>
</label>
))}
</div>
</div>
)
})}
</div>
<div className="perm-section-title">角色指派(放进本组的角色即获得上面的权限)</div>
<div className="perm-roles">
{catalog.roles.map(role => {
const owner = roleGroupMap.get(role.value)
const here = draft.roles.includes(role.value) || owner === draft.id
const elsewhere = owner && owner !== draft.id
const otherName = elsewhere ? groups.find(group => group.id === owner)?.name : null
return (
<label className={`perm-role${here ? ' checked' : ''}`} key={role.value}>
<input type="checkbox" checked={!!here} onChange={() => toggleRole(role.value)} />
<span>
<b>{role.label}</b>
{otherName ? <small>当前在「{otherName}」,勾选将改派到本组</small> : <small>{here ? '已属于本组' : '未指派'}</small>}
</span>
</label>
)
})}
</div>
<div className="perm-actions">
<button type="button" className="btn btn-primary" onClick={save} disabled={busy}>
<SaveIcon size={15} /> {busy ? '保存中…' : '保存权限组'}
</button>
{!draft.is_system && (
<button type="button" className="btn btn-danger" onClick={removeGroup} disabled={busy}>
<TrashIcon size={14} /> 删除该组
</button>
)}
</div>
</>
)}
</section>
</div>
</div>
)
}
export default PermissionGroups
+7 -3
View File
@@ -1,11 +1,15 @@
import React, { useState, useEffect, useMemo } from 'react'
import axios from 'axios'
import { SkeletonTableRows } from '../components/Skeleton'
import { useAuth, hasPermission } from '../auth'
/**
* PI 合同管理(Proforma Invoice)
* 数据来源:happy 后端 /api/pi-contracts(P1 后端合并阶段开放)
*/
function PiContractManagement() {
const { user } = useAuth()
const canEdit = hasPermission(user, 'contract.edit')
const [contracts, setContracts] = useState([])
const [loading, setLoading] = useState(true)
const [keyword, setKeyword] = useState('')
@@ -31,7 +35,7 @@ function PiContractManagement() {
<div className="page-container">
<div className="page-header">
<h1 className="page-title">PI 合同管理</h1>
<button className="btn btn-primary">+ 新建 PI</button>
{canEdit && <button className="btn btn-primary">+ 新建 PI</button>}
</div>
<div className="toolbar">
@@ -62,7 +66,7 @@ function PiContractManagement() {
</thead>
<tbody>
{loading ? (
<tr><td colSpan="7" className="empty-state">加载中...</td></tr>
<SkeletonTableRows rows={5} widths={[1.2, 1.6, 1, 1, 1.2, 0.8, 1]} />
) : rows.length === 0 ? (
<tr>
<td colSpan="7" className="empty-state">
@@ -81,7 +85,7 @@ function PiContractManagement() {
<td><span className={`status-badge status-${c.status}`}>{statusText(c.status)}</span></td>
<td>
<button className="btn-link">查看</button>
<button className="btn-link">生成单据</button>
{canEdit && <button className="btn-link">生成单据</button>}
</td>
</tr>
))
+525 -118
View File
@@ -1,13 +1,23 @@
import React, { useState, useEffect, useMemo, useCallback } from 'react'
import React, { useState, useEffect, useMemo, useCallback, useRef } from 'react'
import axios from 'axios'
import { useNavigate } from 'react-router-dom'
import {
CartIcon,
ImageIcon,
TrashIcon,
PencilIcon,
CloseIcon,
PlusCircleIcon
PlusCircleIcon,
ArrowLeftIcon,
UploadIcon,
CopyIcon,
FingerprintIcon
} from '../components/Icons'
import SearchSelect, { SearchField } from '../components/SearchSelect'
import PackagingPanel from '../components/PackagingPanel'
import { SkuThumb } from '../components/SkuThumb'
import { makeMountId, pushLayer, goBackOne, popLayers, useLayerRestore } from '../hooks/useLayerHistory'
import { useAuth, hasPermission } from '../auth'
/**
* 产品管理(原生 React 版)
@@ -15,11 +25,18 @@ import {
* 由 SKU Manager 的「产品查询」视图迁移而来,功能对齐:
* 检索 / 供应商与待核对筛选 / 反选批量 / 产品详情(货号×参数对比) / 产品与货号编辑
* / 图片管理 / 小推车。数据统一经 /api/sku/* 同源代理,禁止硬编码 SKU 端口。
*
* 两级浮层(产品详情抽屉 → 编辑货号)都挂进了浏览器历史,
* 所以鼠标侧键 / Alt+← / 系统返回手势都能逐级退回,详见 hooks/useLayerHistory.js。
*/
const API = '/api/sku'
const ATTR_LIST_ID = 'sku-attr-names'
/* 两级浮层在历史条目里的键名(各自读取,互不干扰) */
const LAYER_PANEL = 'tmsProductPanel'
const LAYER_GROUP_MODE = 'tmsGroupMode'
/* ------------------------------ 格式化工具 ------------------------------ */
const money = (n) => (n == null ? '—' : Number(n).toFixed(2))
@@ -51,8 +68,9 @@ const thumbSrc = (filename) => `/product-images/${encodeURIComponent(filename)}`
/* ------------------------------ 小部件 ------------------------------ */
/** 关键:图片文件名来自后端,统一走同源代理地址,不拼 3300 */
function Thumb({ filename, count, size = 38 }) {
/** 关键:图片文件名来自后端,统一走同源地址 /product-images/*,不拼端口。
* 悬浮可弹出大图预览(sku 传进去只为预览卡上能标出货号)。 */
function Thumb({ filename, count, size = 38, sku }) {
const style = { width: size, height: size }
if (!filename) {
return (
@@ -61,12 +79,15 @@ function Thumb({ filename, count, size = 38 }) {
</span>
)
}
return (
<span className="thumb-wrap" style={style}>
<img className="thumb" style={style} src={thumbSrc(filename)} alt="" loading="lazy" />
{count > 1 && <span className="thumb-badge">{count}</span>}
</span>
)
if (count > 1) {
return (
<span className="thumb-wrap" style={style}>
<SkuThumb sku={sku} filename={filename} size={size} />
<span className="thumb-badge">{count}</span>
</span>
)
}
return <SkuThumb sku={sku} filename={filename} size={size} />
}
/** 参数编辑器:一组「参数名 / 值」,支持增删 */
@@ -110,32 +131,76 @@ function AttrEditor({ attrs, onChange }) {
)
}
/** 供应商下拉:空值 = 未指定 */
/** 主营类目取前两段当副标题,方便在候选里区分同名/近名供应商 */
function categoryHint(text, take = 2) {
const parts = String(text || '').split(/[、,,;;/|\s]+/).filter(Boolean)
return parts.slice(0, take).join(' / ')
}
/** 供应商候选:把「未指定」与真实档案统一成一组下拉项 */
function supplierOptionList(suppliers, noneLabel = '未指定供应商') {
return [
{ value: '', label: noneLabel },
...suppliers.map((s) => ({
value: String(s.id),
label: s.name,
hint: categoryHint(s.main_categories)
}))
]
}
/** 供应商下拉:空值 = 未指定;供应商几百家,必须能打字搜 */
function SupplierSelect({ value, onChange, suppliers, style }) {
const options = useMemo(() => supplierOptionList(suppliers), [suppliers])
return (
<select value={value ?? ''} onChange={onChange} style={style}>
<option value="">未指定供应商</option>
{suppliers.map((s) => (
<option key={s.id} value={s.id}>{s.name}</option>
))}
</select>
<SearchSelect
value={value ?? ''}
onChange={onChange}
options={options}
placeholder="未指定供应商"
searchPlaceholder="输入供应商名 / 主营类目搜索…"
emptyText="没有匹配的供应商"
clearable
style={style}
/>
)
}
/* ------------------------------ 抽屉外壳 ------------------------------ */
function Drawer({ wide, title, sub, onClose, headerExtra, footer, children }) {
/**
* 通用抽屉。
* onBack 给「有下一层」的场景用,会在标题左侧出现一个返回键
* (与鼠标侧键等价,都走浏览器历史,见 useLayerHistory)。
* onEscape Esc 的行为。有下一层时应该"退一层",没有时才"关抽屉"。
*/
function Drawer({ wide, title, sub, onBack, onEscape, onClose, headerExtra, footer, children }) {
// 用 ref 存回调,键盘监听只挂一次;否则每次重渲染都要重新绑定
const escRef = useRef(onEscape || onClose)
escRef.current = onEscape || onClose
useEffect(() => {
const onKey = (e) => { if (e.key === 'Escape') onClose() }
const onKey = (e) => { if (e.key === 'Escape') escRef.current() }
document.addEventListener('keydown', onKey)
return () => document.removeEventListener('keydown', onKey)
}, [onClose])
}, [])
return (
<>
<div className="drawer-backdrop" onClick={onClose} />
<aside className={`drawer${wide ? ' drawer-wide' : ''}`} role="dialog" aria-modal="true">
<div className="drawer-header">
{onBack && (
<button
type="button"
className="drawer-back"
onClick={onBack}
title="返回上一层(鼠标侧键 / Alt + ← 同样可用)"
>
<ArrowLeftIcon size={15} />
<span>返回</span>
</button>
)}
<div className="drawer-head-main">
<h2 className="drawer-title">{title}</h2>
{sub && <p className="drawer-sub">{sub}</p>}
@@ -156,7 +221,7 @@ function Drawer({ wide, title, sub, onClose, headerExtra, footer, children }) {
/* ============================ 产品详情抽屉 ============================ */
function GroupDrawer({ groupId, highlightSku, suppliers, onClose, onChanged }) {
function GroupDrawer({ mountId, groupId, highlightSku, suppliers, onClose, onChanged, onModeChange, canEdit }) {
const [data, setData] = useState(null)
const [mode, setMode] = useState({ kind: 'view' })
const [loading, setLoading] = useState(true)
@@ -164,6 +229,31 @@ function GroupDrawer({ groupId, highlightSku, suppliers, onClose, onChanged }) {
const [lightbox, setLightbox] = useState('')
const [busy, setBusy] = useState(false)
/* 勾选要加入小推车的货号,以及"已经在车里"的货号 —— 后者让勾选框能显示成不可重复添加 */
const [picked, setPicked] = useState(() => new Set())
const [cartSkus, setCartSkus] = useState(() => new Set())
const [cartBusy, setCartBusy] = useState(false)
const [cartNotice, setCartNotice] = useState('')
/* 把当前编辑态报给父级:父级据此算出「本页现在压了几层历史」,
关抽屉时才能精确退回去(层数不能从历史里读,见 hooks/useLayerHistory.js) */
useEffect(() => { onModeChange?.(mode) }, [mode, onModeChange])
/* 编辑态挂进浏览器历史:顶部返回键、鼠标侧键、Alt + ←、系统返回手势走的是同一条路 */
const openMode = useCallback((next) => {
pushLayer(mountId, LAYER_GROUP_MODE, next)
setMode(next)
}, [mountId])
const backOne = useCallback(() => {
// 有本页的层就走历史(状态由 popstate 回写),没有才直接收 —— 兜底,正常不会走到
if (!goBackOne(mountId)) setMode({ kind: 'view' })
}, [mountId])
useLayerRestore(mountId, LAYER_GROUP_MODE, (snapshot) => {
setMode(snapshot && snapshot.kind ? snapshot : { kind: 'view' })
}, '/products')
const load = useCallback(async () => {
setLoading(true)
setError('')
@@ -196,6 +286,75 @@ function GroupDrawer({ groupId, highlightSku, suppliers, onClose, onChanged }) {
return cols
}, [items])
/* 货号一多(一个产品几十个货号)也要能搜:货号 / 规格 / 参数都能命中 */
const [itemQuery, setItemQuery] = useState('')
const visibleItems = useMemo(() => {
const kw = itemQuery.trim().toLowerCase()
if (!kw) return items
return items.filter((it) => [
it.display_sku,
it.sku,
it.spec,
...(it.attributes || []).map((a) => `${a.name} ${a.value}`)
].filter(Boolean).join(' ').toLowerCase().includes(kw))
}, [items, itemQuery])
/* --------------------------- 货号勾选 / 加入小推车 --------------------------- */
const loadCart = useCallback(async () => {
try {
const { data } = await axios.get(`${API}/cart`)
setCartSkus(new Set((Array.isArray(data) ? data : []).map((r) => r.sku)))
} catch { /* 小推车读不到不影响看货号,勾选时再加也不迟 */ }
}, [])
useEffect(() => { loadCart() }, [loadCart])
const togglePick = (sku) => {
setPicked((prev) => {
const next = new Set(prev)
if (next.has(sku)) next.delete(sku)
else next.add(sku)
return next
})
}
/* 全选 / 反选只作用于「当前筛出来的货号」,否则搜完再全选会勾到看不见的货号 */
const pickableVisible = useMemo(
() => visibleItems.filter((it) => !cartSkus.has(it.sku)),
[visibleItems, cartSkus]
)
const allVisiblePicked = pickableVisible.length > 0 && pickableVisible.every((it) => picked.has(it.sku))
const someVisiblePicked = pickableVisible.some((it) => picked.has(it.sku)) && !allVisiblePicked
const toggleAllVisible = () => {
setPicked((prev) => {
const next = new Set(prev)
if (allVisiblePicked) pickableVisible.forEach((it) => next.delete(it.sku))
else pickableVisible.forEach((it) => next.add(it.sku))
return next
})
}
const addPickedToCart = async () => {
if (!picked.size || cartBusy) return
const skus = [...picked]
setCartBusy(true)
setCartNotice('')
setError('')
try {
const { data } = await axios.post(`${API}/cart`, { skus })
setCartSkus((prev) => new Set([...prev, ...skus]))
setPicked(new Set())
setCartNotice(data.added ? `已加入 ${data.added} 个货号到小推车` : '选中的货号都已经在小推车里了')
onChanged?.()
} catch (e) {
setError(e?.response?.data?.error || `加入小推车失败:${e.message}`)
} finally {
setCartBusy(false)
}
}
const uploadImages = async (e) => {
const files = Array.from(e.target.files || [])
if (!files.length) return
@@ -263,9 +422,11 @@ function GroupDrawer({ groupId, highlightSku, suppliers, onClose, onChanged }) {
if (mode.kind === 'view') {
headerExtra = (
<button className="btn btn-sm" onClick={() => setMode({ kind: 'editGroup' })}>
<PencilIcon size={14} /> 编辑产品
</button>
canEdit && (
<button className="btn btn-sm" onClick={() => openMode({ kind: 'editGroup' })}>
<PencilIcon size={14} /> 编辑产品
</button>
)
)
body = (
@@ -292,10 +453,44 @@ function GroupDrawer({ groupId, highlightSku, suppliers, onClose, onChanged }) {
<div className="panel">
<div className="panel-title">货号与参数</div>
{items.length > 1 && (
<div className="panel-search-row">
<SearchField size="sm" value={itemQuery} onChange={setItemQuery} placeholder="搜货号 / 规格 / 参数…" />
{itemQuery !== '' && <span className="secondary">{visibleItems.length} / {items.length} 个货号</span>}
</div>
)}
{cartNotice && <div className="notice-inline notice-ok" style={{ marginBottom: 10 }}>{cartNotice}</div>}
{picked.size > 0 && (
<div className="batch-bar">
<span>已选 <b>{picked.size}</b> 个货号</span>
<button className="btn btn-sm" onClick={() => setPicked(new Set(pickableVisible.map((it) => it.sku)))}>
全选当前 {pickableVisible.length} 个
</button>
<button className="btn btn-sm" onClick={() => setPicked(new Set())}>取消选择</button>
<span style={{ flex: 1 }} />
{canEdit && (
<button className="btn btn-sm btn-primary" onClick={addPickedToCart} disabled={cartBusy}>
<CartIcon size={14} /> {cartBusy ? '加入中…' : '加入小推车'}
</button>
)}
</div>
)}
<div className="table-container" style={{ maxHeight: 380 }}>
<table className="data-table">
<thead>
<tr>
<th style={{ width: 42 }}>
<input
type="checkbox"
checked={allVisiblePicked}
ref={(el) => { if (el) el.indeterminate = someVisiblePicked }}
onChange={toggleAllVisible}
disabled={pickableVisible.length === 0}
title="全选 / 取消全选当前货号"
/>
</th>
<th>货号</th>
<th>规格</th>
<th style={{ textAlign: 'right' }}>采购价</th>
@@ -305,18 +500,51 @@ function GroupDrawer({ groupId, highlightSku, suppliers, onClose, onChanged }) {
</tr>
</thead>
<tbody>
{items.length === 0 ? (
<tr><td colSpan={5 + attrCols.length} className="secondary" style={{ textAlign: 'center' }}>还没有货号</td></tr>
) : items.map((it) => {
{visibleItems.length === 0 ? (
<tr><td colSpan={6 + attrCols.length} className="secondary" style={{ textAlign: 'center' }}>
{items.length === 0 ? '还没有货号' : '没有匹配的货号'}
</td></tr>
) : visibleItems.map((it) => {
const attrMap = {}
for (const a of it.attributes || []) attrMap[a.name] = a.value
const inCart = cartSkus.has(it.sku)
return (
<tr
key={it.sku}
className={highlightSku && it.sku === highlightSku ? 'row-hit' : ''}
onClick={() => setMode({ kind: 'editItem', sku: it.sku })}
onClick={() => openMode({ kind: 'editItem', sku: it.sku })}
>
<td className="mono bold">{it.display_sku || it.sku}</td>
{/* 勾选框只用来挑「加入小推车」,点它不该顺手进编辑页,所以掐掉冒泡 */}
<td onClick={(e) => e.stopPropagation()}>
<input
type="checkbox"
checked={inCart || picked.has(it.sku)}
disabled={inCart}
onChange={() => togglePick(it.sku)}
title={inCart ? '已在小推车里' : '勾选后可加入小推车'}
/>
</td>
<td className="mono bold">
<span className="sku-cell">
<SkuThumb sku={it.sku} filename={it.thumb} size={30} />
<span className="sku-cell-text">
{it.display_sku || it.sku}
{it.duplicated_from && (
<span className="pill pill-copy" title={`上传时货号 ${it.duplicated_from} 已存在,系统自动建了这个副本`}>
<CopyIcon size={11} /> 副本
</span>
)}
{inCart && <span className="pill pill-ok">已在车</span>}
</span>
</span>
{/* 谁传的:产品库是组织共享的,来源必须一眼看得见 */}
<span
className={`sku-uploader${it.uploader ? '' : ' is-quiet'}`}
title={it.uploader ? `由 ${it.uploader} 上传` : '这条是留痕功能上线前录入的,没有上传者信息'}
>
<UploadIcon size={11} /> {it.uploader || '来源未记录'}
</span>
</td>
<td className="secondary">{it.spec || '—'}</td>
<td className="number" style={{ textAlign: 'right' }}>
<span className="unit">¥</span>{money(it.price)}
@@ -324,7 +552,7 @@ function GroupDrawer({ groupId, highlightSku, suppliers, onClose, onChanged }) {
</td>
<td className="number" style={{ textAlign: 'right' }}>{it.moq ?? '—'}</td>
{attrCols.map((c) => <td key={c} className="secondary">{attrMap[c] || '—'}</td>)}
<td><button className="btn btn-sm">编辑</button></td>
<td>{canEdit && <button className="btn btn-sm">编辑</button>}</td>
</tr>
)
})}
@@ -332,9 +560,11 @@ function GroupDrawer({ groupId, highlightSku, suppliers, onClose, onChanged }) {
</table>
</div>
<div style={{ marginTop: 11 }}>
<button className="btn btn-sm" onClick={() => setMode({ kind: 'newItem' })}>
<PlusCircleIcon size={14} /> 新增货号
</button>
{canEdit && (
<button className="btn btn-sm" onClick={() => openMode({ kind: 'newItem' })}>
<PlusCircleIcon size={14} /> 新增货号
</button>
)}
</div>
</div>
@@ -350,20 +580,24 @@ function GroupDrawer({ groupId, highlightSku, suppliers, onClose, onChanged }) {
/>
{img.is_primary === 1 && <span className="gallery-flag">封面</span>}
<div className="gallery-actions">
{img.is_primary !== 1 && (
{img.is_primary !== 1 && canEdit && (
<button className="btn btn-sm" onClick={() => setPrimary(img.id)}>设封面</button>
)}
<button className="btn btn-sm btn-danger" onClick={() => removeImage(img.id)}>
<TrashIcon size={13} />
</button>
{canEdit && (
<button className="btn btn-sm btn-danger" onClick={() => removeImage(img.id)}>
<TrashIcon size={13} />
</button>
)}
</div>
</div>
))}
<label className="add-image">
<PlusCircleIcon size={18} />
<span>{busy ? '上传中…' : '添加图片'}</span>
<input type="file" accept="image/*" multiple onChange={uploadImages} disabled={busy} />
</label>
{canEdit && (
<label className="add-image">
<PlusCircleIcon size={18} />
<span>{busy ? '上传中…' : '添加图片'}</span>
<input type="file" accept="image/*" multiple onChange={uploadImages} disabled={busy} />
</label>
)}
</div>
</div>
</>
@@ -376,8 +610,9 @@ function GroupDrawer({ groupId, highlightSku, suppliers, onClose, onChanged }) {
group={g}
groupAttributes={groupAttributes}
suppliers={suppliers}
onCancel={() => setMode({ kind: 'view' })}
onSaved={async () => { setMode({ kind: 'view' }); await refresh() }}
onCancel={backOne}
onSaved={async () => { backOne(); await refresh() }}
canEdit={canEdit}
/>
)
} else if (mode.kind === 'newItem') {
@@ -388,8 +623,18 @@ function GroupDrawer({ groupId, highlightSku, suppliers, onClose, onChanged }) {
groupId={groupId}
group={g}
items={items}
onCancel={() => setMode({ kind: 'view' })}
onSaved={async () => { setMode({ kind: 'view' }); await refresh() }}
onCancel={backOne}
canEdit={canEdit}
onSaved={async (info) => {
backOne()
await refresh()
/* 撞号时后端不会再报错,而是落成副本 —— 得明说一句,否则用户会以为写错了货号 */
if (info?.duplicated_from) {
setCartNotice(`货号 ${info.duplicated_from} 已存在,已自动建成副本「${info.display_sku}」,并记下上传者`)
} else {
setCartNotice('货号已新增,并记下了上传者')
}
}}
/>
)
} else if (mode.kind === 'editItem') {
@@ -400,16 +645,30 @@ function GroupDrawer({ groupId, highlightSku, suppliers, onClose, onChanged }) {
sku={mode.sku}
groupId={groupId}
suppliers={suppliers}
onBack={() => setMode({ kind: 'view' })}
onSaved={async () => { setMode({ kind: 'view' }); await refresh() }}
onBack={backOne}
onSaved={async () => { backOne(); await refresh() }}
onDeleted={async () => { onClose(); onChanged?.() }}
canEdit={canEdit}
/>
)
}
/* 在子层(编辑产品 / 新增货号 / 编辑货号)时,标题左侧出现返回键;
Esc 也退一层,退到最外层再按一次才关抽屉 —— 免得改了一半直接被关掉 */
const inSubLayer = mode.kind !== 'view'
return (
<>
<Drawer wide title={title} sub={sub} onClose={onClose} headerExtra={headerExtra} footer={footer}>
<Drawer
wide
title={title}
sub={sub}
onBack={inSubLayer ? backOne : undefined}
onEscape={inSubLayer ? backOne : onClose}
onClose={onClose}
headerExtra={headerExtra}
footer={footer}
>
{body}
</Drawer>
{lightbox && (
@@ -423,7 +682,7 @@ function GroupDrawer({ groupId, highlightSku, suppliers, onClose, onChanged }) {
/* ---------------------------- 产品编辑表单 ---------------------------- */
function GroupForm({ group, groupAttributes, suppliers, onCancel, onSaved }) {
function GroupForm({ group, groupAttributes, suppliers, onCancel, onSaved, canEdit }) {
const [form, setForm] = useState({
name: group.name || '',
brand: group.brand || '',
@@ -479,7 +738,7 @@ function GroupForm({ group, groupAttributes, suppliers, onCancel, onSaved }) {
</div>
<div className="form-group">
<label>供应商</label>
<SupplierSelect value={form.supplier_id} suppliers={suppliers} onChange={(e) => set('supplier_id', e.target.value)} />
<SupplierSelect value={form.supplier_id} suppliers={suppliers} onChange={(v) => set('supplier_id', v)} />
</div>
<div className="form-group">
<label>产品说明</label>
@@ -491,9 +750,11 @@ function GroupForm({ group, groupAttributes, suppliers, onCancel, onSaved }) {
</div>
<div className="form-actions">
<button className="btn btn-primary" onClick={save} disabled={saving}>
{saving ? '保存中…' : '保存'}
</button>
{canEdit && (
<button className="btn btn-primary" onClick={save} disabled={saving}>
{saving ? '保存中…' : '保存'}
</button>
)}
<button className="btn" onClick={onCancel} disabled={saving}>取消</button>
</div>
</div>
@@ -502,7 +763,7 @@ function GroupForm({ group, groupAttributes, suppliers, onCancel, onSaved }) {
/* ---------------------------- 货号新增表单 ---------------------------- */
function ItemForm({ groupId, group, items, onCancel, onSaved }) {
function ItemForm({ groupId, group, items, onCancel, onSaved, canEdit }) {
const [form, setForm] = useState({ sku: '', spec: '', price: '', moq: '', description: '' })
const [attrs, setAttrs] = useState([])
const [error, setError] = useState('')
@@ -527,8 +788,7 @@ function ItemForm({ groupId, group, items, onCancel, onSaved }) {
description: form.description,
name: group.name,
attributes: attrs.filter((a) => a.name.trim())
})
await onSaved()
}).then(({ data }) => onSaved(data))
} catch (e) {
setError(e?.response?.data?.error || `创建失败:${e.message}`)
} finally {
@@ -568,12 +828,18 @@ function ItemForm({ groupId, group, items, onCancel, onSaved }) {
{items.length > 0 && (
<div className="form-group">
<label>从现有货号复制参数</label>
<select defaultValue="" onChange={(e) => copyFrom(e.target.value)}>
<option value="">不复制</option>
{items.map((it) => (
<option key={it.sku} value={it.sku}>{it.display_sku || it.sku}</option>
))}
</select>
<SearchSelect
value=""
onChange={(v) => { if (v) copyFrom(v) }}
options={items.map((it) => ({
value: it.sku,
label: it.display_sku || it.sku,
hint: it.spec || ''
}))}
placeholder="不复制"
searchPlaceholder="输入货号 / 规格搜索…"
emptyText="没有匹配的货号"
/>
</div>
)}
<div className="form-group">
@@ -582,9 +848,11 @@ function ItemForm({ groupId, group, items, onCancel, onSaved }) {
</div>
<div className="form-actions">
<button className="btn btn-primary" onClick={save} disabled={saving}>
{saving ? '创建中…' : '创建'}
</button>
{canEdit && (
<button className="btn btn-primary" onClick={save} disabled={saving}>
{saving ? '创建中…' : '创建'}
</button>
)}
<button className="btn" onClick={onCancel} disabled={saving}>取消</button>
</div>
</div>
@@ -593,7 +861,7 @@ function ItemForm({ groupId, group, items, onCancel, onSaved }) {
/* ---------------------------- 货号编辑器 ---------------------------- */
function ItemEditor({ sku, groupId, suppliers, onBack, onSaved, onDeleted }) {
function ItemEditor({ sku, groupId, suppliers, onBack, onSaved, onDeleted, canEdit }) {
const [product, setProduct] = useState(null)
const [history, setHistory] = useState([])
const [groupChoices, setGroupChoices] = useState([])
@@ -700,6 +968,18 @@ function ItemEditor({ sku, groupId, suppliers, onBack, onSaved, onDeleted }) {
<div className="panel-title">编辑货号 {product.display_sku || product.sku}</div>
{error && <div className="notice-inline">{error}</div>}
{/* 溯源条:产品库是组织共享的,谁传的、是不是撞号生成的副本必须一眼看得见 */}
<div className="provenance">
{product.uploader
? <span className="provenance-item"><UploadIcon size={13} /> 上传者 <b>{product.uploader}</b></span>
: <span className="provenance-item is-quiet" title="这条产品是在留痕功能上线前录入的"><UploadIcon size={13} /> 上传者未记录</span>}
{product.duplicated_from && (
<span className="provenance-item is-copy" title={`上传时货号 ${product.duplicated_from} 已存在,系统自动建了这个副本`}>
<CopyIcon size={13} /> 同货号副本 · 复制自 <b>{product.duplicated_from}</b>
</span>
)}
</div>
<div className="form-row">
<div className="form-group">
<label>采购价{priceEditable ? '' : '(合同锁定)'}</label>
@@ -733,17 +1013,24 @@ function ItemEditor({ sku, groupId, suppliers, onBack, onSaved, onDeleted }) {
<div className="form-group">
<label>所属产品</label>
<select value={form.group_id} onChange={(e) => set('group_id', e.target.value)}>
{groupChoices.map((gc) => (
<option key={gc.id} value={gc.id}>
{gc.name}({gc.item_count} 个货号)
</option>
))}
{String(product.group_id) && !groupChoices.some((gc) => String(gc.id) === String(product.group_id)) && (
<option value={product.group_id}>{product.group_name || '当前产品'}</option>
)}
<option value="new">— 独立成新产品 —</option>
</select>
<SearchSelect
value={form.group_id}
onChange={(v) => set('group_id', v)}
options={[
...groupChoices.map((gc) => ({
value: String(gc.id),
label: gc.name,
hint: `${gc.item_count} 个货号`
})),
...(String(product.group_id) && !groupChoices.some((gc) => String(gc.id) === String(product.group_id))
? [{ value: String(product.group_id), label: product.group_name || '当前产品' }]
: []),
{ value: 'new', label: '— 独立成新产品 —' }
]}
placeholder="选择所属产品"
searchPlaceholder="输入产品名搜索…"
emptyText="没有匹配的产品"
/>
</div>
<div className="form-group">
@@ -752,13 +1039,17 @@ function ItemEditor({ sku, groupId, suppliers, onBack, onSaved, onDeleted }) {
</div>
<div className="form-actions">
<button className="btn btn-primary" onClick={save} disabled={saving}>
{saving ? '保存中…' : '保存'}
</button>
{canEdit && (
<button className="btn btn-primary" onClick={save} disabled={saving}>
{saving ? '保存中…' : '保存'}
</button>
)}
<button className="btn" onClick={onBack} disabled={saving}>返回</button>
<button className="btn btn-danger" onClick={remove} disabled={saving}>
<TrashIcon size={14} /> 删除货号
</button>
{canEdit && (
<button className="btn btn-danger" onClick={remove} disabled={saving}>
<TrashIcon size={14} /> 删除货号
</button>
)}
</div>
</div>
@@ -771,6 +1062,13 @@ function ItemEditor({ sku, groupId, suppliers, onBack, onSaved, onDeleted }) {
</div>
)}
{/* 包装信息:结构化录入单品/内盒/外箱/重量,并据此推算装柜量。
数据存在 happy 自己的库(product_packaging),与 SKU 服务的价格数据分开维护。 */}
<div className="panel">
<div className="panel-title">包装信息</div>
<PackagingPanel sku={product.sku} canEdit={canEdit} />
</div>
<div className="panel">
<div className="panel-title">调价记录</div>
{history.length === 0 ? (
@@ -792,6 +1090,13 @@ function ItemEditor({ sku, groupId, suppliers, onBack, onSaved, onDeleted }) {
{h.source_contract ? ` · ${h.source_contract}` : ''}
{h.supplier_name ? ` · ${h.supplier_name}` : ''}
</div>
{/* 谁改的价:每次调价都要能追到操作人 */}
<div className="timeline-op">
<FingerprintIcon size={12} />
{h.changed_by
? <>操作人 <b>{h.changed_by}</b></>
: <span className="is-quiet">操作人未记录</span>}
</div>
</div>
</div>
))}
@@ -804,7 +1109,7 @@ function ItemEditor({ sku, groupId, suppliers, onBack, onSaved, onDeleted }) {
/* ---------------------------- 新增产品抽屉 ---------------------------- */
function NewProductDrawer({ suppliers, onClose, onCreated }) {
function NewProductDrawer({ suppliers, onClose, onCreated, canEdit }) {
const [form, setForm] = useState({
name: '', brand: '', category: '', supplier_id: '', description: '',
sku: '', spec: '', price: '', moq: '', itemDesc: ''
@@ -835,7 +1140,7 @@ function NewProductDrawer({ suppliers, onClose, onCreated }) {
attributes: attrs.filter((a) => a.name.trim())
}
})
onCreated?.(data.group_id)
onCreated?.(data)
} catch (e) {
setError(e?.response?.data?.error || `创建失败:${e.message}`)
} finally {
@@ -850,9 +1155,11 @@ function NewProductDrawer({ suppliers, onClose, onCreated }) {
onClose={onClose}
footer={
<>
<button className="btn btn-primary" onClick={save} disabled={saving}>
{saving ? '创建中…' : '创建'}
</button>
{canEdit && (
<button className="btn btn-primary" onClick={save} disabled={saving}>
{saving ? '创建中…' : '创建'}
</button>
)}
<button className="btn" onClick={onClose} disabled={saving}>取消</button>
</>
}
@@ -877,7 +1184,7 @@ function NewProductDrawer({ suppliers, onClose, onCreated }) {
</div>
<div className="form-group">
<label>供应商</label>
<SupplierSelect value={form.supplier_id} suppliers={suppliers} onChange={(e) => set('supplier_id', e.target.value)} />
<SupplierSelect value={form.supplier_id} suppliers={suppliers} onChange={(v) => set('supplier_id', v)} />
</div>
<div className="form-group">
<label>产品说明</label>
@@ -922,7 +1229,8 @@ function NewProductDrawer({ suppliers, onClose, onCreated }) {
/* ------------------------------ 小推车抽屉 ------------------------------ */
function CartDrawer({ onClose, onChanged }) {
function CartDrawer({ onClose, onChanged, canEdit, canQuoteEdit }) {
const navigate = useNavigate()
const [rows, setRows] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
@@ -991,10 +1299,15 @@ function CartDrawer({ onClose, onChanged }) {
{noPrice > 0 && <span className="secondary" style={{ marginLeft: 10 }}>{noPrice} 个货号库里没有采购价</span>}
</span>
<span style={{ flex: 1 }} />
<button className="btn btn-sm btn-danger" onClick={clear}>清空</button>
<button className="btn btn-primary" onClick={() => window.alert('汇总报价下一步接入:需要先定「从 CRM 选客户 / 自动带出单证字段」的方案,方案定了我就把报价单那步补上。')}>
汇总报价 →
</button>
{canEdit && <button className="btn btn-sm btn-danger" onClick={clear}>清空</button>}
{/* 这里刻意不走 onClose(那会触发 history.go)。
直接切路由,产品页整体卸载、抽屉一并消失即可;
留在历史里的那条层条目靠 mountId 认得出来,回头不会冒出幽灵抽屉。 */}
{canQuoteEdit && (
<button className="btn btn-primary" onClick={() => navigate('/quotes/new')}>
汇总报价 →
</button>
)}
</>
) : null
}
@@ -1035,17 +1348,35 @@ function CartDrawer({ onClose, onChanged }) {
type="number"
min="1"
step="1"
data-cart-qty=""
placeholder={r.moq ? String(r.moq) : ''}
defaultValue={r.qty ?? ''}
onBlur={(e) => {
if (String(r.qty ?? '') !== e.target.value) setQty(r.sku, e.target.value)
}}
onKeyDown={(e) => { if (e.key === 'Enter') e.target.blur() }}
onKeyDown={(e) => {
if (e.key !== 'Enter') return
e.preventDefault()
// 回车 = 存下这一行并跳到下一行的数量框,连续录入不用碰鼠标。
// 焦点一移开,上面的 onBlur 就会把当前值写回去。
const list = e.currentTarget.closest('.cart-list')
const inputs = list ? Array.from(list.querySelectorAll('input[data-cart-qty]')) : []
const next = inputs[inputs.indexOf(e.currentTarget) + 1]
if (next) {
next.focus()
next.select() // 选中已有数字,直接输入即可覆盖
next.scrollIntoView({ block: 'nearest' })
} else {
e.currentTarget.blur() // 最后一行:回车收尾
}
}}
/>
</label>
<button className="btn btn-sm btn-icon btn-danger" title="移出小推车" onClick={() => removeRow(r.sku)}>
<CloseIcon size={14} />
</button>
{canEdit && (
<button className="btn btn-sm btn-icon btn-danger" title="移出小推车" onClick={() => removeRow(r.sku)}>
<CloseIcon size={14} />
</button>
)}
</div>
)
})}
@@ -1063,6 +1394,10 @@ function ProductManagement() {
const [supplierFilter, setSupplierFilter] = useState('')
const [onlyLow, setOnlyLow] = useState(false)
const { user } = useAuth()
const canEdit = hasPermission(user, 'product.edit')
const canQuoteEdit = hasPermission(user, 'quote.edit')
const [rows, setRows] = useState([])
const [loading, setLoading] = useState(true)
const [notice, setNotice] = useState('')
@@ -1072,8 +1407,60 @@ function ProductManagement() {
const [attrNames, setAttrNames] = useState([])
const [cartCount, setCartCount] = useState(0)
/* 筛选用的供应商候选:多两个「全部 / 未分类」的伪选项 */
const supplierFilterOptions = useMemo(() => [
{ value: '', label: '全部供应商' },
{ value: 'none', label: '未分类(无供应商)' },
...suppliers.map((s) => ({
value: String(s.id),
label: s.name,
hint: `${s.sku_count ?? 0} 个货号${categoryHint(s.main_categories, 1) ? ` · ${categoryHint(s.main_categories, 1)}` : ''}`
}))
], [suppliers])
const [selected, setSelected] = useState(() => new Set())
const [panel, setPanel] = useState(null)
/* 抽屉里的编辑态(由 GroupDrawer 报上来),用来算历史层数 */
const [groupMode, setGroupMode] = useState({ kind: 'view' })
/* 本次挂载的令牌:用来认出上一次挂载残留在历史里的孤儿条目 */
const [mountId] = useState(() => makeMountId())
/* 本页现在压了几层历史:抽屉 1 层,抽屉里的编辑态再加 1 层。
刻意用「当前 UI 状态」算,而不是从 history.state 里读计数器 ——
否则用户开着抽屉点侧栏跳走再回来,会读到上一次挂载留下的脏层数,
关抽屉时多退几条,直接把人退出应用。 */
const layerCount = panel ? (groupMode && groupMode.kind !== 'view' ? 2 : 1) : 0
/* 抽屉也挂进浏览器历史:侧键返回时先收抽屉,而不是直接跳走 */
const openPanel = useCallback((next) => {
pushLayer(mountId, LAYER_PANEL, next)
setGroupMode({ kind: 'view' })
setPanel(next)
}, [mountId])
const closePanel = useCallback(() => {
// 一次退掉本页所有层(抽屉 + 抽屉里的编辑态),回到进入产品页时那条历史
if (layerCount > 0) popLayers(layerCount)
else setPanel(null)
setGroupMode({ kind: 'view' })
}, [layerCount])
useLayerRestore(mountId, LAYER_PANEL, (snapshot) => {
setPanel(snapshot && snapshot.kind ? snapshot : null)
if (!snapshot || !snapshot.kind) setGroupMode({ kind: 'view' })
}, '/products')
/* 挂载时擦掉当前历史条目上「上一次挂载」留下的浮层快照。
场景:开着抽屉直接点侧栏跳走,抽屉随组件卸载消失,但压进历史的条目还在;
回到本页时那条历史就是孤儿,留着会让 history.state 一直显示"抽屉开着",
也让反复按返回时多逛几条无效历史。擦成干净条目即可(那些条目是我们自己 push 的,
不带路由 state,清成 null 不影响 React Router)。 */
useEffect(() => {
const s = window.history.state
if (s && typeof s.__tmsDepth === 'number' && s.__tmsMount !== mountId) {
window.history.replaceState(null, '')
}
}, [mountId])
/* 关键词防抖 250ms */
useEffect(() => {
@@ -1193,12 +1580,16 @@ function ProductManagement() {
<p className="page-sub">按货号、品名、规格或参数检索产品与采购价</p>
</div>
<div className="header-actions">
<button className="btn" onClick={() => setPanel({ kind: 'cart' })}>
<CartIcon size={16} /> 小推车{cartCount > 0 && <span className="cart-badge">{cartCount}</span>}
</button>
<button className="btn btn-primary" onClick={() => setPanel({ kind: 'new' })}>
<PlusCircleIcon size={16} /> 新增产品
</button>
{canEdit && (
<button className="btn" onClick={() => openPanel({ kind: 'cart' })}>
<CartIcon size={16} /> 小推车{cartCount > 0 && <span className="cart-badge">{cartCount}</span>}
</button>
)}
{canEdit && (
<button className="btn btn-primary" onClick={() => openPanel({ kind: 'new' })}>
<PlusCircleIcon size={16} /> 新增产品
</button>
)}
</div>
</div>
@@ -1212,11 +1603,15 @@ function ProductManagement() {
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
/>
<select value={supplierFilter} onChange={(e) => setSupplierFilter(e.target.value)}>
<option value="">全部供应商</option>
<option value="none">未分类(无供应商)</option>
{suppliers.map((s) => <option key={s.id} value={s.id}>{s.name}</option>)}
</select>
<SearchSelect
className="toolbar-supplier-filter"
value={supplierFilter}
onChange={(v) => setSupplierFilter(v)}
options={supplierFilterOptions}
placeholder="全部供应商"
searchPlaceholder="输入供应商名 / 主营类目搜索…"
emptyText="没有匹配的供应商"
/>
<label className="check-inline">
<input type="checkbox" checked={onlyLow} onChange={(e) => setOnlyLow(e.target.checked)} />
只看待核对
@@ -1232,8 +1627,8 @@ function ProductManagement() {
<button className="btn btn-sm" onClick={invert}>反选</button>
<button className="btn btn-sm" onClick={clearSel}>取消选择</button>
<span style={{ flex: 1 }} />
<button className="btn btn-sm btn-primary" onClick={addToCart}>加入小推车</button>
<button className="btn btn-sm btn-danger" onClick={deleteSelected}>删除</button>
{canEdit && <button className="btn btn-sm btn-primary" onClick={addToCart}>加入小推车</button>}
{canEdit && <button className="btn btn-sm btn-danger" onClick={deleteSelected}>删除</button>}
</div>
)}
@@ -1282,7 +1677,7 @@ function ProductManagement() {
<tr
key={g.id}
className={picked ? 'row-picked' : ''}
onClick={() => setPanel({ kind: 'group', groupId: g.id, highlightSku: g.matched_items?.[0]?.sku || null })}
onClick={() => openPanel({ kind: 'group', groupId: g.id, highlightSku: g.matched_items?.[0]?.sku || null })}
>
<td onClick={(e) => e.stopPropagation()}>
<input
@@ -1319,24 +1714,36 @@ function ProductManagement() {
{panel?.kind === 'group' && (
<GroupDrawer
mountId={mountId}
groupId={panel.groupId}
highlightSku={panel.highlightSku}
suppliers={suppliers}
onClose={() => setPanel(null)}
onClose={closePanel}
onChanged={afterMutate}
onModeChange={setGroupMode}
canEdit={canEdit}
/>
)}
{panel?.kind === 'new' && (
<NewProductDrawer
suppliers={suppliers}
onClose={() => setPanel(null)}
onCreated={() => { setPanel(null); afterMutate() }}
/>
onClose={closePanel}
canEdit={canEdit}
onCreated={(info) => {
closePanel()
afterMutate()
/* 撞号时后端自动落副本,明确告诉用户新建到手的到底是什么货号 */
if (info?.duplicated_from) {
setNoticeOk(true)
setNotice(`货号 ${info.duplicated_from} 已存在,已自动建成副本「${info.display_sku}」,并记下上传者`)
}
}}
/>
)}
{panel?.kind === 'cart' && (
<CartDrawer onClose={() => { setPanel(null); refreshCart() }} onChanged={refreshCart} />
<CartDrawer onClose={() => { closePanel(); refreshCart() }} onChanged={refreshCart} canEdit={canEdit} canQuoteEdit={canQuoteEdit} />
)}
</div>
)
+345
View File
@@ -0,0 +1,345 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import axios from 'axios'
import {
SaveIcon, KeyIcon, CameraIcon, LogoutIcon, BuildingIcon, ShieldIcon,
EyeIcon, EyeOffIcon, UserIcon
} from '../components/Icons'
import { useAuth, roleLabel } from '../auth'
import { setPageTitle } from '../branding'
import { setUnsavedGuard, clearUnsavedGuard } from '../unsavedGuard'
/* 把用户对象裁成表单结构 */
function toForm(user) {
return {
display_name: user?.display_name || '',
position: user?.position || '',
email: user?.email || '',
phone: user?.phone || '',
address: user?.address || '',
org_id: user?.org?.id ? String(user.org.id) : '',
role: user?.role || 'sales_rep',
avatar: user?.avatar || ''
}
}
/** 头像压到 160×160 的 JPEG,直接存 dataURL —— 本地阶段省掉一套文件接口与存储 */
function compressImage(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onerror = () => reject(new Error('读取图片失败'))
reader.onload = () => {
const image = new Image()
image.onerror = () => reject(new Error('这个图片格式浏览器读不了'))
image.onload = () => {
const size = 160
const canvas = document.createElement('canvas')
canvas.width = size
canvas.height = size
const context = canvas.getContext('2d')
const scale = Math.max(size / image.width, size / image.height)
const width = image.width * scale
const height = image.height * scale
context.drawImage(image, (size - width) / 2, (size - height) / 2, width, height)
resolve(canvas.toDataURL('image/jpeg', 0.85))
}
image.src = reader.result
}
reader.readAsDataURL(file)
})
}
function ProfilePage() {
const { user, setUser, logout } = useAuth()
const navigate = useNavigate()
const [form, setForm] = useState(() => toForm(user))
const [saved, setSaved] = useState(() => toForm(user))
const [organizations, setOrganizations] = useState([])
const [roles, setRoles] = useState([])
const [busy, setBusy] = useState(false)
const [notice, setNotice] = useState(null)
const fileRef = useRef(null)
const [password, setPassword] = useState({ current: '', next: '', confirm: '' })
const [passwordBusy, setPasswordBusy] = useState(false)
const [passwordNotice, setPasswordNotice] = useState(null)
const [reveal, setReveal] = useState({ current: false, next: false, confirm: false })
const dirty = useMemo(
() => JSON.stringify(form) !== JSON.stringify(saved),
[form, saved]
)
/* guard 要读到最新值,用 ref 兜住(注册只在挂载/卸载时做一次) */
const dirtyRef = useRef(dirty)
const formRef = useRef(form)
const savedRef = useRef(saved)
dirtyRef.current = dirty
formRef.current = form
savedRef.current = saved
useEffect(() => {
setPageTitle('个人资料')
}, [])
useEffect(() => {
let alive = true
;(async () => {
try {
const [orgRes, roleRes] = await Promise.all([
axios.get('/api/organizations'),
axios.get('/api/roles')
])
if (!alive) return
setOrganizations(orgRes.data.organizations || [])
setRoles(roleRes.data.roles || [])
} catch { /* 下拉拉不到不影响主流程 */ }
})()
return () => { alive = false }
}, [])
/* 保存动作抽成 callback,供页面按钮与「离开前保存」共用 */
const submitSave = useCallback(async () => {
const payload = formRef.current
const response = await axios.patch('/api/me', {
display_name: payload.display_name,
position: payload.position,
email: payload.email,
phone: payload.phone,
address: payload.address,
avatar: payload.avatar,
role: payload.role,
org_id: payload.org_id === '' ? undefined : Number(payload.org_id)
})
const updated = response.data.user
setUser(updated)
setSaved(toForm(updated))
return updated
}, [setUser])
/* 让外壳在跳转前能问一句「要不要先保存」 */
useEffect(() => {
setUnsavedGuard({
isDirty: () => dirtyRef.current,
save: () => submitSave(),
discard: () => setForm(savedRef.current)
})
return () => clearUnsavedGuard()
}, [submitSave])
const save = async () => {
if (!form.display_name.trim()) { setNotice({ type: 'error', text: '显示名不能为空' }); return }
setBusy(true)
setNotice(null)
try {
await submitSave()
setNotice({ type: 'ok', text: '个人资料已保存' })
} catch (error) {
setNotice({ type: 'error', text: error?.response?.data?.error || '保存失败' })
} finally {
setBusy(false)
}
}
const pickAvatar = async event => {
const file = event.target.files?.[0]
event.target.value = ''
if (!file) return
if (!/^image\//.test(file.type)) { setNotice({ type: 'error', text: '请选择图片文件' }); return }
try {
const dataUrl = await compressImage(file)
setForm(current => ({ ...current, avatar: dataUrl }))
setNotice({ type: 'info', text: '头像已选择,记得点右上角「保存个人资料」' })
} catch (error) {
setNotice({ type: 'error', text: error.message })
}
}
const submitPassword = async event => {
event.preventDefault()
if (!password.current) { setPasswordNotice({ type: 'error', text: '请输入当前密码' }); return }
if (!password.next) { setPasswordNotice({ type: 'error', text: '请输入新密码' }); return }
if (password.next.length < 6) { setPasswordNotice({ type: 'error', text: '新密码至少 6 位' }); return }
if (password.next !== password.confirm) { setPasswordNotice({ type: 'error', text: '两次输入的新密码不一致' }); return }
setPasswordBusy(true)
setPasswordNotice(null)
try {
await axios.patch('/api/me/password', {
current_password: password.current,
new_password: password.next,
confirm_password: password.confirm
})
setPassword({ current: '', next: '', confirm: '' })
setPasswordNotice({ type: 'ok', text: '密码已修改,其它设备上的登录已失效' })
} catch (error) {
setPasswordNotice({ type: 'error', text: error?.response?.data?.error || '修改密码失败' })
} finally {
setPasswordBusy(false)
}
}
const signOut = async () => {
if (dirty) setForm(saved)
await logout()
navigate('/login', { replace: true })
}
const setField = (key, value) => setForm(current => ({ ...current, [key]: value }))
const currentRole = roles.find(item => item.value === form.role)
const orgName = organizations.find(item => String(item.id) === String(form.org_id))?.name || user?.org?.name || '—'
const avatarText = (form.display_name || user?.username || '?').slice(0, 1).toUpperCase()
return (
<div className="page-container profile-page">
<div className="profile-head">
<div className="profile-head-left">
<span className="profile-avatar">
{form.avatar ? <img src={form.avatar} alt="" /> : avatarText}
</span>
<div className="profile-head-text">
<h1>{form.display_name || user?.username}</h1>
<p>{form.position || roleLabel(form.role)} · {orgName}</p>
</div>
</div>
<div className="profile-head-actions">
{dirty && <span className="profile-dirty-hint">● 有未保存的改动</span>}
<button type="button" className="btn btn-secondary" disabled={!dirty} onClick={() => { setForm(saved); setNotice(null) }}>
放弃修改
</button>
<button type="button" className="btn btn-primary" disabled={!dirty || busy} onClick={save}>
<SaveIcon size={15} /> {busy ? '保存中…' : '保存个人资料'}
</button>
</div>
</div>
{notice && (
<p className={`profile-notice ${notice.type}`}>{notice.text}</p>
)}
<div className="profile-grid">
{/* ---------------- 基本资料 ---------------- */}
<section className="panel">
<div className="panel-title"><UserIcon size={15} /> 基本资料</div>
<div className="profile-avatar-row">
<span className="profile-avatar profile-avatar-sm">
{form.avatar ? <img src={form.avatar} alt="" /> : avatarText}
</span>
<div className="profile-avatar-actions">
<button type="button" className="btn btn-secondary btn-sm" onClick={() => fileRef.current?.click()}>
<CameraIcon size={14} /> 更换头像
</button>
{form.avatar && (
<button type="button" className="btn btn-ghost btn-sm" onClick={() => setField('avatar', '')}>
移除
</button>
)}
<p className="field-hint">支持 jpg / png,会自动裁成方形并压缩</p>
</div>
<input ref={fileRef} type="file" accept="image/*" hidden onChange={pickAvatar} />
</div>
<div className="profile-fields">
<div className="form-group">
<label>显示名 <i className="required">*</i></label>
<input type="text" value={form.display_name} onChange={event => setField('display_name', event.target.value)} placeholder="在系统里显示的名字" />
</div>
<div className="form-group">
<label>职位</label>
<input type="text" value={form.position} onChange={event => setField('position', event.target.value)} placeholder="如:外贸业务主管" />
</div>
<div className="form-group">
<label>联系邮箱</label>
<input type="email" value={form.email} onChange={event => setField('email', event.target.value)} placeholder="[email protected]" />
</div>
<div className="form-group">
<label>电话</label>
<input type="text" value={form.phone} onChange={event => setField('phone', event.target.value)} placeholder="手机或座机" />
</div>
<div className="form-group">
<label><BuildingIcon size={13} /> 所属组织</label>
<select value={form.org_id} onChange={event => setField('org_id', event.target.value)}>
<option value="">未归属</option>
{organizations.map(org => (
<option key={org.id} value={org.id}>{org.name}{org.member_count ? `(${org.member_count} 人)` : ''}</option>
))}
</select>
</div>
<div className="form-group">
<label><ShieldIcon size={13} /> 权限角色</label>
<select value={form.role} onChange={event => setField('role', event.target.value)}>
{roles.map(role => (
<option key={role.value} value={role.value}>{role.label}</option>
))}
</select>
<p className="field-hint">
角色本身不带权限,它决定你进入哪个权限组 →
<b> {currentRole?.group_name || '尚未指派权限组'}</b>
</p>
</div>
<div className="form-group profile-field-full">
<label>地址</label>
<textarea rows="2" value={form.address} onChange={event => setField('address', event.target.value)} placeholder="公司或办公地址" />
</div>
</div>
<div className="profile-meta">
<span>账号:<b>{user?.username}</b></span>
<span>权限组:<b>{user?.permission_group?.name || '—'}</b></span>
<span>可访问权限点:<b>{user?.permissions?.length || 0}</b></span>
<span>上次登录:<b>{user?.last_login_at ? new Date(user.last_login_at).toLocaleString('zh-CN') : '—'}</b></span>
</div>
</section>
{/* ---------------- 修改密码 ---------------- */}
<section className="panel">
<div className="panel-title"><KeyIcon size={15} /> 修改密码</div>
<form className="profile-fields profile-fields-single" onSubmit={submitPassword}>
{[
['current', '当前密码', '请输入当前密码'],
['next', '新密码', '至少 6 位'],
['confirm', '再次输入新密码', '与上面保持一致']
].map(([key, label, placeholder]) => (
<div className="form-group" key={key}>
<label>{label}</label>
<div className="password-input">
<input
type={reveal[key] ? 'text' : 'password'}
autoComplete={key === 'current' ? 'current-password' : 'new-password'}
value={password[key]}
onChange={event => setPassword(current => ({ ...current, [key]: event.target.value }))}
placeholder={placeholder}
/>
<button
type="button"
className="password-reveal"
onClick={() => setReveal(current => ({ ...current, [key]: !current[key] }))}
title={reveal[key] ? '隐藏' : '显示'}
aria-label={reveal[key] ? '隐藏密码' : '显示密码'}
>
{reveal[key] ? <EyeOffIcon size={15} /> : <EyeIcon size={15} />}
</button>
</div>
</div>
))}
{passwordNotice && <p className={`profile-notice ${passwordNotice.type}`}>{passwordNotice.text}</p>}
<button type="submit" className="btn btn-primary" disabled={passwordBusy}>
<KeyIcon size={15} /> {passwordBusy ? '提交中…' : '确认修改密码'}
</button>
<p className="field-hint">改密码需要当前密码验证;修改成功后其它设备上的登录会被强制退出。</p>
</form>
<div className="profile-signout">
<button type="button" className="btn btn-secondary" onClick={signOut}>
<LogoutIcon size={15} /> 退出登录
</button>
</div>
</section>
</div>
</div>
)
}
export default ProfilePage
+221 -87
View File
@@ -1,108 +1,242 @@
import React, { useState } from 'react'
import React, { useCallback, useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import axios from 'axios'
import {
TargetIcon, RefreshIcon, UsersIcon, PlusCircleIcon, SearchIcon, ArrowLeftIcon
} from '../components/Icons'
import { SkeletonTableRows } from '../components/Skeleton'
import { setPageTitle } from '../branding'
const TABS = [
{ key: 'pending', label: '未成交', hint: '还在谈的客户' },
{ key: 'won', label: '已成交', hint: '已经下单的客户' },
{ key: 'lost', label: '已流失', hint: '明确放弃或长期无响应的客户' },
{ key: 'all', label: '全部', hint: '不分成败的所有开发客户' }
]
const DEAL_LABELS = { pending: '未成交', won: '已成交', lost: '已流失' }
function formatDate(value) {
if (!value) return '—'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return String(value).slice(0, 10)
return date.toLocaleDateString('zh-CN')
}
function ProspectManagement() {
const [formData, setFormData] = useState({
productKeywords: '',
countries: '',
customerTypes: '',
excludeKeywords: '',
resultLimit: 100
})
const navigate = useNavigate()
const [tab, setTab] = useState('pending')
const [keyword, setKeyword] = useState('')
const [items, setItems] = useState([])
const [stats, setStats] = useState({ pending: 0, won: 0, lost: 0 })
const [loading, setLoading] = useState(true)
const [busyId, setBusyId] = useState(null)
const [notice, setNotice] = useState(null)
const handleSubmit = async (e) => {
e.preventDefault()
alert('自动获客功能开发中...')
const load = useCallback(async () => {
setLoading(true)
try {
const { data } = await axios.get('/api/prospects', {
params: { deal_status: tab === 'all' ? '' : tab, q: keyword.trim() }
})
setItems(data.items || [])
setStats(data.stats || { pending: 0, won: 0, lost: 0 })
} catch (error) {
setNotice({ type: 'error', text: error?.response?.data?.error || '加载开发客户失败' })
} finally {
setLoading(false)
}
}, [tab, keyword])
useEffect(() => {
setPageTitle('新客开发')
}, [])
useEffect(() => {
const timer = window.setTimeout(load, keyword ? 260 : 0)
return () => window.clearTimeout(timer)
}, [load, keyword])
/* 标记成交/流失:乐观更新 —— 先按新状态改界面(不匹配当前标签页的行直接消失、
顶部计数同步增减),后台失败再把行和计数整体放回原状。 */
const mark = async (item, dealStatus) => {
setBusyId(item.id)
const previous = item.deal_status
const index = items.findIndex(row => row.id === item.id)
const nextStats = { ...stats }
if (previous && nextStats[previous] !== undefined) {
nextStats[previous] = Math.max(0, (nextStats[previous] || 0) - 1)
}
nextStats[dealStatus] = (nextStats[dealStatus] || 0) + 1
setStats(nextStats)
if (tab !== 'all' && dealStatus !== tab) {
setItems(prev => prev.filter(row => row.id !== item.id))
} else {
setItems(prev => prev.map(row => (row.id === item.id ? { ...row, deal_status: dealStatus } : row)))
}
setNotice({ type: 'ok', text: `「${item.company}」已标记为${DEAL_LABELS[dealStatus]}` })
try {
await axios.patch(`/api/prospects/${item.id}`, { deal_status: dealStatus })
} catch (error) {
setStats(stats)
setItems(prev => {
if (prev.some(row => row.id === item.id)) return prev.map(row => (row.id === item.id ? item : row))
const next = [...prev]
next.splice(Math.min(Math.max(index, 0), next.length), 0, item)
return next
})
setNotice({ type: 'error', text: error?.response?.data?.error || '标记失败,已恢复原状态' })
} finally {
setBusyId(null)
}
}
const openCreate = () => navigate('/customers')
return (
<div className="page-container">
<div className="page-container prospect-page">
<div className="page-header">
<h1 className="page-title">新客开发(自动获客)</h1>
<div>
<h1 className="page-title">新客开发</h1>
<p className="page-subtitle">
上半部分是自动获客线索池(待上线);下半部分管理开发结果 —— 谈成的进「已成交」,谈不动的进「已流失」,其余留在「未成交」继续跟。
和「我的客户」同一份数据、同一套归属:只显示你自己名下的客户。
</p>
</div>
<button type="button" className="btn btn-secondary" onClick={openCreate}>
<PlusCircleIcon size={15} /> 手工新增客户
</button>
</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>
{/* ---------------- 自动获客(线索池,待上线) ---------------- */}
<section className="panel">
<div className="panel-title"><TargetIcon size={15} /> 自动获客线索池</div>
<div className="prospect-pool-note">
<p>
线索池将按「产品关键词 + 目标国家 + 客户类型」从公开数据源(GLEIF、Companies House、搜索引擎)批量抓取,
再由 AI 做去重与意向评分,合格线索一键转成客户。
</p>
<span className="pill pill-warn">待上线</span>
</div>
</section>
<div className="form-group">
<label>目标国家</label>
<input
type="text"
placeholder="如:德国, 英国, 美国"
value={formData.countries}
onChange={e => setFormData({...formData, countries: e.target.value})}
/>
</div>
{/* ---------------- 开发结果管理 ---------------- */}
<section className="panel">
<div className="panel-title"><UsersIcon size={15} /> 开发结果管理</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 className="prospect-stats">
<button type="button" className={`prospect-stat${tab === 'pending' ? ' active' : ''}`} onClick={() => setTab('pending')}>
<b>{stats.pending}</b>
<span>未成交</span>
</button>
<button type="button" className={`prospect-stat${tab === 'won' ? ' active' : ''}`} onClick={() => setTab('won')}>
<b className="is-won">{stats.won}</b>
<span>已成交</span>
</button>
<button type="button" className={`prospect-stat${tab === 'lost' ? ' active' : ''}`} onClick={() => setTab('lost')}>
<b className="is-lost">{stats.lost}</b>
<span>已流失</span>
</button>
</div>
{/* 搜索结果 */}
<div className="card">
<h2>搜索结果</h2>
<div className="empty-state">
请配置搜索条件并点击"开始搜索"
<div className="prospect-toolbar">
<div className="prospect-tabs" role="tablist">
{TABS.map(item => (
<button
key={item.key}
type="button"
role="tab"
aria-selected={tab === item.key}
className={`prospect-tab${tab === item.key ? ' active' : ''}`}
title={item.hint}
onClick={() => setTab(item.key)}
>
{item.label}
</button>
))}
</div>
<label className="prospect-search">
<SearchIcon size={14} />
<input
type="text"
value={keyword}
onChange={event => setKeyword(event.target.value)}
placeholder="搜索公司 / 国家 / 邮箱"
aria-label="搜索开发客户"
/>
</label>
<button type="button" className="btn btn-secondary btn-sm" onClick={load} disabled={loading}>
<RefreshIcon size={14} /> 刷新
</button>
</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>
{notice && <p className={`profile-notice ${notice.type}`}>{notice.text}</p>}
<div className="data-table-wrap">
<table className="data-table">
<thead>
<tr>
<th>公司</th>
<th>国家</th>
<th className="num">联系人</th>
<th className="num">报价单</th>
<th>最后跟进</th>
<th>成交状态</th>
<th className="num">操作</th>
</tr>
</thead>
<tbody>
{loading ? (
<SkeletonTableRows rows={5} widths={[2.2, 0.8, 0.6, 0.6, 1, 1, 1.4]} />
) : items.length === 0 ? (
<tr><td colSpan="7" className="empty-state">
{keyword ? '没有匹配的客户' : `「${TABS.find(item => item.key === tab)?.label}」下暂无客户`}
</td></tr>
) : items.map(item => (
<tr key={item.id}>
<td>
<strong>{item.company}</strong>
{item.company_en ? <div className="muted-note">{item.company_en}</div> : null}
</td>
<td>{item.country || '—'}</td>
<td className="num">{item.contact_count || 0}</td>
<td className="num">{item.quote_count || 0}</td>
<td>{formatDate(item.last_follow_at)}</td>
<td>
<span className={`pill ${item.deal_status === 'won' ? 'pill-ok' : item.deal_status === 'lost' ? 'pill-danger' : 'pill-mute'}`}>
{DEAL_LABELS[item.deal_status] || '未成交'}
</span>
</td>
<td className="num">
<div className="prospect-actions">
{item.deal_status !== 'won' && (
<button type="button" className="btn btn-secondary btn-sm" disabled={busyId === item.id} onClick={() => mark(item, 'won')}>
标记成交
</button>
)}
{item.deal_status !== 'lost' && (
<button type="button" className="btn btn-secondary btn-sm" disabled={busyId === item.id} onClick={() => mark(item, 'lost')}>
标记流失
</button>
)}
{item.deal_status !== 'pending' && (
<button type="button" className="btn btn-ghost btn-sm" disabled={busyId === item.id} onClick={() => mark(item, 'pending')}>
退回跟进
</button>
)}
<button type="button" className="btn btn-ghost btn-sm" onClick={() => navigate(`/customers/${item.id}`)}>
<ArrowLeftIcon size={13} /> 查看
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
</div>
)
}
export default ProspectManagement
@@ -1,11 +1,15 @@
import React, { useState, useEffect, useMemo } from 'react'
import axios from 'axios'
import { SkeletonTableRows } from '../components/Skeleton'
import { useAuth, hasPermission } from '../auth'
/**
* 采购单管理
* 数据来源:happy 后端 /api/purchase-orders(P1 后端合并阶段开放)
*/
function PurchaseOrderManagement() {
const { user } = useAuth()
const canEdit = hasPermission(user, 'purchase.edit')
const [orders, setOrders] = useState([])
const [loading, setLoading] = useState(true)
const [keyword, setKeyword] = useState('')
@@ -31,7 +35,7 @@ function PurchaseOrderManagement() {
<div className="page-container">
<div className="page-header">
<h1 className="page-title">采购单管理</h1>
<button className="btn btn-primary">+ 新建采购单</button>
{canEdit && <button className="btn btn-primary">+ 新建采购单</button>}
</div>
<div className="toolbar">
@@ -62,7 +66,7 @@ function PurchaseOrderManagement() {
</thead>
<tbody>
{loading ? (
<tr><td colSpan="7" className="empty-state">加载中...</td></tr>
<SkeletonTableRows rows={5} widths={[1.2, 1.6, 1, 1, 1.2, 0.8, 1]} />
) : rows.length === 0 ? (
<tr>
<td colSpan="7" className="empty-state">
@@ -81,7 +85,7 @@ function PurchaseOrderManagement() {
<td><span className={`status-badge status-${o.status}`}>{statusText(o.status)}</span></td>
<td>
<button className="btn-link">查看</button>
<button className="btn-link">编辑</button>
{canEdit && <button className="btn-link">编辑</button>}
</td>
</tr>
))
+807
View File
@@ -0,0 +1,807 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import axios from 'axios'
import { useNavigate } from 'react-router-dom'
import {
ArrowLeftIcon, CartIcon, CheckIcon, CloseIcon, PlusCircleIcon, QuoteIcon, SearchIcon, UsersIcon
} from '../components/Icons'
import { buildCurrencyOptions, crossRate, currencyLabel, formatRate, parseAmount } from '../currencies'
import { SkuThumb } from '../components/SkuThumb'
import { useAuth, hasPermission } from '../auth'
/**
* 汇总报价 —— 把小推车里的货号变成一张正式报价单。
*
* 页面顺序刻意做成「先客户、后货、再条款」:
* 报价单是发给客户的凭证,抬头必须先立住(公司、地址、联系方式),
* 后面所有条款都是围绕这个抬头展开的。
*
* 单价口径与询盘报价完全一致:采购价 ÷ 汇率 × (1 + 加价率),
* 汇率是「1 报价币 = ? 采购币(CNY)」。不要在这里另立一套算法。
*/
const API = '/api'
const SKU_API = '/api/sku'
function today() {
return new Date().toISOString().slice(0, 10)
}
/** 从某个日期键(YYYY-MM-DD)往后推 n 天 */
function addDays(dateKey, days) {
const base = Date.parse(dateKey)
if (!Number.isFinite(base)) return ''
return new Date(base + Number(days) * 86400000).toISOString().slice(0, 10)
}
/* 条款选项 —— 都是外贸报价里最常用的几种,不做成自由填空 */
const PAYMENT_METHODS = ['T/T', 'L/C', 'D/P', 'D/A', 'O/A', 'Western Union', 'PayPal']
const DEPOSIT_OPTIONS = [30, 50, 70, 100]
const DELIVERY_METHODS = ['EXW', 'FOB', 'CIF', 'CFR', 'DDP', 'DAP', 'FCA']
function money(value, digits = 2) {
const num = Number(value)
if (!Number.isFinite(num)) return '—'
return num.toLocaleString('zh-CN', { minimumFractionDigits: digits, maximumFractionDigits: digits })
}
/* ------------------------------------------------------------------ */
/* 客户搜索选择 */
/* ------------------------------------------------------------------ */
function CustomerPicker({ customers, selectedId, onSelect, onCreateNew, canEditCustomer }) {
const [open, setOpen] = useState(false)
const [keyword, setKeyword] = useState('')
const boxRef = useRef(null)
const inputRef = useRef(null)
useEffect(() => {
if (!open) { setKeyword(''); return }
const timer = window.setTimeout(() => inputRef.current?.focus(), 0)
const onPointerDown = event => {
if (!boxRef.current?.contains(event.target)) setOpen(false)
}
document.addEventListener('mousedown', onPointerDown)
return () => { window.clearTimeout(timer); document.removeEventListener('mousedown', onPointerDown) }
}, [open])
const filtered = useMemo(() => {
const term = keyword.trim().toLowerCase()
if (!term) return customers.slice(0, 60)
return customers
.filter(c => [c.company, c.company_en, c.country, c.email, c.phone].filter(Boolean).join(' ').toLowerCase().includes(term))
.slice(0, 60)
}, [customers, keyword])
const selected = customers.find(c => c.id === selectedId)
return (
<div className="quote-customer-picker" ref={boxRef}>
<button type="button" className={`quote-customer-btn${open ? ' is-open' : ''}`} onClick={() => setOpen(v => !v)}>
<UsersIcon size={17} />
<span className="quote-customer-btn-text">
{selected
? <><b>{selected.company}</b><em>{[selected.country, selected.email].filter(Boolean).join(' · ') || '未填联系方式'}</em></>
: '选择客户'}
</span>
<span className="quote-customer-btn-hint">搜索 / 更换</span>
</button>
{open && (
<div className="quote-customer-pop">
<div className="quote-customer-search">
<SearchIcon size={15} />
<input
ref={inputRef}
type="text"
value={keyword}
onChange={event => setKeyword(event.target.value)}
placeholder="搜索公司 / 国家 / 邮箱"
aria-label="搜索客户"
/>
</div>
<div className="quote-customer-list">
{filtered.length === 0 ? (
<div className="quote-customer-empty">
<p>{customers.length === 0 ? '还没有客户' : '没有匹配的客户'}</p>
{canEditCustomer && (
<button type="button" className="btn btn-sm btn-primary" onClick={() => { setOpen(false); onCreateNew(keyword.trim()) }}>
<PlusCircleIcon size={14} /> 新建客户
</button>
)}
</div>
) : (
<>
{filtered.map(c => (
<button
type="button"
key={c.id}
className={`quote-customer-item${c.id === selectedId ? ' active' : ''}`}
onClick={() => { onSelect(c.id); setOpen(false) }}
>
<b>{c.company}</b>
<span>{[c.country, c.email, c.phone].filter(Boolean).join(' · ') || '未填联系方式'}</span>
</button>
))}
{canEditCustomer && (
<button type="button" className="quote-customer-new" onClick={() => { setOpen(false); onCreateNew(keyword.trim()) }}>
<PlusCircleIcon size={14} /> 找不到?新建客户
</button>
)}
</>
)}
</div>
</div>
)}
</div>
)
}
/* ------------------------------------------------------------------ */
/* 客户抬头(自动带出地址与联系方式) */
/* ------------------------------------------------------------------ */
function CustomerCard({ customer, contacts, onChangeCustomer }) {
if (!customer) return null
const primary = contacts?.[0]
const rows = [
['公司名称', customer.company],
['英文名称', customer.company_en],
['国家 / 地区', customer.country],
['地址', customer.address],
['邮箱', customer.email],
['电话', customer.phone],
['联系人', primary ? [primary.name, primary.position].filter(Boolean).join(' · ') : null],
['联系人邮箱', primary?.email],
['联系人电话', primary?.phone]
]
return (
<div className="quote-head">
<div className="quote-head-top">
<div>
<span className="quote-head-company">{customer.company}</span>
{customer.company_en && <span className="quote-head-company-en">{customer.company_en}</span>}
</div>
<div className="quote-head-actions">
{customer.status !== 'active' && <span className="pill pill-warn">未启用</span>}
<button type="button" className="btn btn-sm" onClick={onChangeCustomer}>更换客户</button>
</div>
</div>
<dl className="quote-head-grid">
{rows.map(([label, value]) => (
<div className="quote-head-cell" key={label}>
<dt>{label}</dt>
<dd className={value ? '' : 'is-empty'}>{value || '未填写'}</dd>
</div>
))}
</dl>
<p className="quote-head-note">以上信息取自客户档案,开单时会一并写入报价单抬头。</p>
</div>
)
}
/* ------------------------------------------------------------------ */
/* 新建客户 */
/* ------------------------------------------------------------------ */
function NewCustomerModal({ initialCompany, onClose, onCreated, canEditCustomer }) {
const [form, setForm] = useState({
company: initialCompany || '',
company_en: '',
country: '',
address: '',
email: '',
phone: '',
contact_name: '',
contact_position: '',
notes: ''
})
const [saving, setSaving] = useState('')
const [error, setError] = useState('')
const set = (key, value) => setForm(current => ({ ...current, [key]: value }))
/**
* @param {'active'|'inactive'} status
* active = 保存并启用(立刻可作为正式客户继续跟进)
* inactive = 仅保存(先建档,还没确认要不要投入跟进)
*/
const save = async status => {
if (!form.company.trim()) { setError('公司名称不能为空'); return }
setError('')
setSaving(status)
try {
const { data } = await axios.post(`${API}/customers`, {
company: form.company.trim(),
company_en: form.company_en.trim() || null,
country: form.country.trim() || null,
address: form.address.trim() || null,
email: form.email.trim() || null,
phone: form.phone.trim() || null,
notes: form.notes.trim() || null,
source: '报价单',
status
})
/* 填了联系人姓名就顺手建一条主联系人 —— 报价单抬头要用 */
if (form.contact_name.trim()) {
try {
await axios.post(`${API}/customers/${data.id}/contacts`, {
name: form.contact_name.trim(),
position: form.contact_position.trim() || null,
email: form.email.trim() || null,
phone: form.phone.trim() || null,
is_primary: true
})
} catch { /* 联系人建失败不该拦住客户本身 */ }
}
onCreated(data.id)
} catch (err) {
setError(err?.response?.data?.error || err.message)
setSaving('')
}
}
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal-content modal-content-wide" onClick={event => event.stopPropagation()}>
<div className="modal-header">
<h2>新建客户</h2>
<button type="button" className="modal-close" onClick={onClose}><CloseIcon size={16} /></button>
</div>
<div className="modal-body">
{error && <div className="notice-inline">{error}</div>}
<div className="form-group">
<label>公司名称 *</label>
<input type="text" value={form.company} onChange={e => set('company', e.target.value)} placeholder="客户公司全称" />
</div>
<div className="form-row">
<div className="form-group">
<label>英文名称</label>
<input type="text" value={form.company_en} onChange={e => set('company_en', e.target.value)} />
</div>
<div className="form-group">
<label>国家 / 地区</label>
<input type="text" value={form.country} onChange={e => set('country', e.target.value)} />
</div>
</div>
<div className="form-group">
<label>地址</label>
<input type="text" value={form.address} onChange={e => set('address', e.target.value)} placeholder="用于报价单抬头" />
</div>
<div className="form-row">
<div className="form-group">
<label>邮箱</label>
<input type="email" value={form.email} onChange={e => set('email', e.target.value)} />
</div>
<div className="form-group">
<label>电话</label>
<input type="text" value={form.phone} onChange={e => set('phone', e.target.value)} />
</div>
</div>
<div className="form-row">
<div className="form-group">
<label>联系人</label>
<input type="text" value={form.contact_name} onChange={e => set('contact_name', e.target.value)} />
</div>
<div className="form-group">
<label>联系人职务</label>
<input type="text" value={form.contact_position} onChange={e => set('contact_position', e.target.value)} />
</div>
</div>
<div className="form-group">
<label>备注</label>
<textarea rows="2" value={form.notes} onChange={e => set('notes', e.target.value)} />
</div>
</div>
<div className="modal-footer">
<button type="button" className="btn" onClick={onClose}>取消</button>
<span style={{ flex: 1 }} />
{canEditCustomer && (
<button type="button" className="btn" onClick={() => save('inactive')} disabled={!!saving}>
{saving === 'inactive' ? '保存中…' : '仅保存'}
</button>
)}
{canEditCustomer && (
<button type="button" className="btn btn-primary" onClick={() => save('active')} disabled={!!saving}>
{saving === 'active' ? '保存中…' : '保存并启用'}
</button>
)}
</div>
</div>
</div>
)
}
/* ------------------------------------------------------------------ */
/* 主页面 */
/* ------------------------------------------------------------------ */
function QuoteEditor() {
const navigate = useNavigate()
const { user } = useAuth()
const canEditQuote = hasPermission(user, 'quote.edit')
const canEditCustomer = hasPermission(user, 'customer.edit')
const [cart, setCart] = useState([])
const [customers, setCustomers] = useState([])
const [contacts, setContacts] = useState([])
const [customerId, setCustomerId] = useState(null)
const [selectedCustomer, setSelectedCustomer] = useState(null)
const [rates, setRates] = useState(null)
const [loading, setLoading] = useState(true)
const [newCustomerOpen, setNewCustomerOpen] = useState(false)
const [newCustomerSeed, setNewCustomerSeed] = useState('')
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
const [notice, setNotice] = useState('')
const [header, setHeader] = useState({
pi_no: '',
quote_date: today(),
/* 有效期以「距报价日期多少天」为准,截止日期由它推出来 */
validity_days: '30',
currency: 'USD',
exchange_rate: '',
markup_rate: '25',
payment_method: 'T/T',
deposit_percent: '30',
delivery_method: 'FOB',
delivery_port: 'Shanghai',
order_notes: '',
internal_notes: ''
})
const [qtyOverride, setQtyOverride] = useState({})
const setField = (key, value) => setHeader(current => ({ ...current, [key]: value }))
/* ---------- 初始数据:小推车 + 客户 + 汇率 ---------- */
useEffect(() => {
let alive = true
;(async () => {
try {
const [cartRes, customerRes, fxRes] = await Promise.all([
axios.get(`${SKU_API}/cart`),
axios.get(`${API}/customers`),
axios.get(`${API}/fx/rates`).catch(() => null)
])
if (!alive) return
setCart(Array.isArray(cartRes.data) ? cartRes.data : [])
setCustomers(Array.isArray(customerRes.data) ? customerRes.data : [])
if (fxRes?.data?.ok && fxRes.data.rates) setRates(fxRes.data.rates)
} catch (err) {
if (alive) setError(err?.response?.data?.error || `加载数据失败:${err.message}`)
} finally {
if (alive) setLoading(false)
}
})()
return () => { alive = false }
}, [])
/* ---------- 换了报价币种就把汇率换成实时值 ---------- */
useEffect(() => {
if (!rates || !header.currency) return
const rate = crossRate(rates, header.currency, 'CNY')
if (!rate) return
setHeader(current => ({ ...current, exchange_rate: String(Number(rate.toFixed(4))) }))
}, [rates, header.currency])
/* ---------- 选中客户后拉它的地址 / 联系人 ---------- */
const loadCustomer = useCallback(async id => {
setCustomerId(id)
try {
const { data } = await axios.get(`${API}/customers/${id}`)
setSelectedCustomer(data.customer)
setContacts(data.contacts || [])
} catch (err) {
setError(err?.response?.data?.error || err.message)
}
}, [])
/* ---------- 明细与单价 ---------- */
const priceOf = useCallback(cost => {
const rate = parseAmount(header.exchange_rate)
const markup = parseAmount(header.markup_rate)
const value = Number(cost)
if (!Number.isFinite(value) || value <= 0 || !rate || rate <= 0) return null
const factor = Number.isFinite(markup) ? markup / 100 : 0
return value / rate * (1 + factor)
}, [header.exchange_rate, header.markup_rate])
const lines = useMemo(() => cart.map(row => {
const cost = row.price == null ? null : Number(row.price)
const rawQty = qtyOverride[row.sku] !== undefined ? qtyOverride[row.sku] : row.qty
const qty = Number(rawQty) || 0
const unit = priceOf(cost)
return {
key: row.sku,
sku: row.display_sku || row.sku,
rawSku: row.sku,
/* 小推车接口带主图文件名,直接带到明细里给缩略图用 */
image: row.image || null,
product_name: row.product_name || row.display_sku || row.sku,
spec: row.spec || null,
cost,
qty,
unit,
total: unit != null && qty > 0 ? unit * qty : null
}
}), [cart, priceOf, qtyOverride])
const grandTotal = lines.reduce((sum, line) => sum + (line.total || 0), 0)
const missingQty = lines.filter(line => !line.qty).length
const missingPrice = lines.filter(line => line.unit == null).length
/* 有效期:天数 → 截止日期。天数给人改,日期给人看,提交时两个都带上 */
const validUntil = useMemo(
() => addDays(header.quote_date, header.validity_days || 0),
[header.quote_date, header.validity_days]
)
/* 分期付款:只有 T/T 走「预付 + 尾款」这套,尾款比例自动补足到 100% */
const isTT = header.payment_method === 'T/T'
const depositPercent = Number(header.deposit_percent)
const splitPayment = isTT && Number.isFinite(depositPercent) && depositPercent > 0 && depositPercent <= 100
const balancePercent = splitPayment ? Number((100 - depositPercent).toFixed(4)) : null
const depositAmount = splitPayment ? grandTotal * depositPercent / 100 : null
const balanceAmount = splitPayment ? grandTotal * (balancePercent || 0) / 100 : null
const currencyOptions = useMemo(
() => buildCurrencyOptions(rates, ['USD', 'CNY', 'EUR', 'GBP', 'HKD', 'JPY']),
[rates]
)
const submit = async () => {
setError('')
setNotice('')
if (!customerId) { setError('请先在顶部选择客户'); return }
if (!lines.length) { setError('小推车是空的,先到产品管理里加入货号'); return }
if (missingQty) { setError(`还有 ${missingQty} 行没填数量`); return }
if (missingPrice) { setError('有明细算不出单价,请检查汇率与加价率是否填齐'); return }
setSaving(true)
try {
const { data } = await axios.post(`${API}/quotes`, {
customer_id: customerId,
currency: header.currency,
quote_date: header.quote_date,
valid_until: validUntil || null,
validity_days: Number(header.validity_days) || null,
pi_no: header.pi_no || null,
payment_method: header.payment_method || null,
deposit_percent: splitPayment ? depositPercent : null,
balance_percent: splitPayment ? balancePercent : null,
delivery_method: header.delivery_method || null,
delivery_port: header.delivery_port || null,
notes: header.order_notes || null,
internal_notes: header.internal_notes || null,
status: 'draft',
items: lines.map(line => ({
sku: line.rawSku,
product_name: line.product_name,
spec: line.spec,
qty: line.qty,
unit_price: Number(line.unit.toFixed(4)),
purchase_price: line.cost,
exchange_rate: parseAmount(header.exchange_rate),
markup_rate: (parseAmount(header.markup_rate) || 0) / 100
}))
})
setNotice(`报价单 ${data.quote.quote_no} 已保存,正在跳转到报价单列表…`)
window.setTimeout(() => navigate('/quotes'), 1100)
} catch (err) {
setError(err?.response?.data?.error || err.message)
setSaving(false)
}
}
if (loading) return <div className="page-container"><div className="loading"><span className="spinner" /> 正在读取小推车与客户…</div></div>
return (
<div className="page-container quote-editor">
<div className="page-header quote-editor-header">
<div className="quote-editor-title">
<button type="button" className="btn btn-sm btn-icon" onClick={() => navigate(-1)} title="返回">
<ArrowLeftIcon size={16} />
</button>
<div>
<h1 className="page-title">汇总报价</h1>
</div>
</div>
<div className="quote-editor-actions">
<span className="secondary">{lines.length} 个货号</span>
{canEditQuote && (
<button type="button" className="btn btn-primary" onClick={submit} disabled={saving}>
<CheckIcon size={15} /> {saving ? '保存中…' : '保存报价单'}
</button>
)}
</div>
</div>
{error && <div className="notice-inline">{error}</div>}
{notice && <div className="notice-inline notice-ok">{notice}</div>}
{/* ---------- ① 客户抬头 ---------- */}
<section className="panel">
<div className="panel-title">客户</div>
{selectedCustomer ? (
<CustomerCard
customer={selectedCustomer}
contacts={contacts}
onChangeCustomer={() => { setSelectedCustomer(null); setCustomerId(null); setContacts([]) }}
/>
) : (
<div className="quote-customer-empty-state">
<p>报价单要发给谁?先选一个客户 —— 选中后会自动带出地址与联系方式。</p>
<CustomerPicker
customers={customers}
selectedId={customerId}
onSelect={loadCustomer}
onCreateNew={seed => { setNewCustomerSeed(seed); setNewCustomerOpen(true) }}
canEditCustomer={canEditCustomer}
/>
</div>
)}
</section>
{/* ---------- ② 报价条件 ---------- */}
<section className="panel">
<div className="panel-title">报价条件</div>
<div className="quote-terms-grid">
<div className="form-group">
<label>PI 号</label>
<input
type="text"
value={header.pi_no}
onChange={e => setField('pi_no', e.target.value)}
placeholder="选填,例如 PI-20260914-01"
/>
</div>
<div className="form-group">
<label>报价日期</label>
<input type="date" value={header.quote_date} onChange={e => setField('quote_date', e.target.value)} />
</div>
<div className="form-group">
<label>报价有效期(距报价日期天数)</label>
<input
type="number" min="1" step="1"
value={header.validity_days}
onChange={e => setField('validity_days', e.target.value)}
placeholder="例如 30"
/>
<span className="field-hint">
{validUntil
? `自 ${header.quote_date} 起 ${header.validity_days} 天,有效期至 ${validUntil}`
: '填一个大于 0 的天数'}
</span>
</div>
<div className="form-group">
<label>报价币种</label>
<select value={header.currency} onChange={e => setField('currency', e.target.value)}>
{currencyOptions.map(option => (
<option key={option.code} value={option.code}>{currencyLabel(option.code)}</option>
))}
</select>
</div>
<div className="form-group">
<label>汇率(1 {header.currency} = ? CNY)</label>
<input
type="text"
inputMode="decimal"
value={header.exchange_rate}
onChange={e => setField('exchange_rate', e.target.value)}
placeholder="自动取实时汇率"
/>
</div>
<div className="form-group">
<label>加价率(%)</label>
<input
type="text"
inputMode="decimal"
value={header.markup_rate}
onChange={e => setField('markup_rate', e.target.value)}
placeholder="例如 25"
/>
</div>
<div className="form-group">
<label>付款方式</label>
<select value={header.payment_method} onChange={e => setField('payment_method', e.target.value)}>
{PAYMENT_METHODS.map(method => <option key={method} value={method}>{method}</option>)}
</select>
</div>
<div className="form-group">
<label>交货方式</label>
<div className="field-inline">
<select value={header.delivery_method} onChange={e => setField('delivery_method', e.target.value)}>
{DELIVERY_METHODS.map(method => <option key={method} value={method}>{method}</option>)}
</select>
<input
type="text"
value={header.delivery_port}
onChange={e => setField('delivery_port', e.target.value)}
placeholder="港口 / 地点,例如 Shanghai"
/>
</div>
</div>
</div>
{/* 选 T/T 才展开分期:预付款点一下定比例,尾款百分比与金额自动算出 */}
{isTT && (
<div className="payment-split">
<div className="payment-split-head">
<span>预付款比例</span>
</div>
<div className="payment-split-options">
{DEPOSIT_OPTIONS.map(percent => (
<button
key={percent}
type="button"
className={`payment-split-btn${depositPercent === percent ? ' active' : ''}`}
onClick={() => setField('deposit_percent', String(percent))}
>
{percent}%
</button>
))}
<input
type="number" min="1" max="100" step="1"
className="payment-split-input"
value={header.deposit_percent}
onChange={e => setField('deposit_percent', e.target.value)}
aria-label="自定义预付款比例"
/>
<span className="payment-split-unit">% 自定义</span>
</div>
<div className="payment-split-result">
{splitPayment ? (
<>
<span>预付款 <b>{depositPercent}%</b><em>{money(depositAmount)} {header.currency}</em></span>
<span>尾款 <b>{balancePercent}%</b><em>{money(balanceAmount)} {header.currency}</em></span>
{balancePercent === 0 && <span className="payment-split-note">全额预付,无尾款</span>}
</>
) : (
<span className="quote-warn">预付款比例请填 1–100 之间的数字</span>
)}
</div>
</div>
)}
<p className="quote-formula">
单价 = 采购价 ÷ 汇率 ×(1 + 加价率),与询盘报价同一套口径。
{rates && header.exchange_rate
? ` 当前按 1 ${header.currency} = ${formatRate(parseAmount(header.exchange_rate) || 0)} CNY 换算。`
: ' 汇率取不到时可以手动填。'}
</p>
</section>
{/* ---------- ③ 报价明细 ---------- */}
<section className="panel">
<div className="panel-title">
报价明细
<span className="panel-title-hint">货号来自小推车,数量可在此微调(不会改动小推车)</span>
</div>
{lines.length === 0 ? (
<div className="empty-state">
<div className="empty-state-icon"><CartIcon size={40} /></div>
<div className="empty-state-title">小推车是空的</div>
<div className="empty-state-hint">先到「产品管理」里勾选货号加入小推车,再回来出报价单。</div>
</div>
) : (
<>
<div className="table-container">
<table className="data-table">
<thead>
<tr>
<th>货号</th>
<th>产品 / 规格</th>
<th className="num">采购价</th>
<th className="num">数量</th>
<th className="num">单价</th>
<th className="num">小计</th>
</tr>
</thead>
<tbody>
{lines.map(line => (
<tr key={line.key} className={line.qty ? '' : 'row-pending'}>
<td className="mono bold">
<span className="sku-cell">
<SkuThumb sku={line.sku} filename={line.image} size={30} />
{line.sku}
</span>
</td>
<td>
<div>{line.product_name}</div>
{line.spec && <div className="secondary">{line.spec}</div>}
</td>
<td className="num">
{line.cost == null ? <span className="secondary">无采购价</span> : <>¥{money(line.cost)}</>}
</td>
<td className="num">
<input
type="number"
min="1"
step="1"
className="quote-qty-input"
value={qtyOverride[line.key] ?? line.qty ?? ''}
placeholder="必填"
onChange={event => setQtyOverride(current => ({ ...current, [line.key]: event.target.value }))}
/>
</td>
<td className="num">
{line.unit == null ? <span className="secondary">—</span> : <b>{money(line.unit, 4)}</b>}
</td>
<td className="num">
{line.total == null ? <span className="secondary">—</span> : `${money(line.total)} ${header.currency}`}
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="quote-total-bar">
{missingQty > 0 && <span className="quote-warn">还有 {missingQty} 行没填数量</span>}
{missingPrice > 0 && <span className="quote-warn">{missingPrice} 行算不出单价(汇率或加价率未填)</span>}
<span style={{ flex: 1 }} />
<span className="quote-grand-total">
报价合计 <b>{money(grandTotal)} {header.currency}</b>
</span>
</div>
</>
)}
</section>
{/* ---------- ④ 备注:对外 / 对内分开 ---------- */}
<section className="panel">
<div className="panel-title">备注</div>
<div className="quote-notes-grid">
<div className="form-group">
<label>订单备注 <span className="label-badge">会显示在报价单</span></label>
<textarea
rows="3"
className="quote-notes"
value={header.order_notes}
onChange={e => setField('order_notes', e.target.value)}
placeholder="给客户看的补充说明,例如包装要求、交期、唛头。导出时以 Notes: 开头"
/>
{header.order_notes.trim() && (
<span className="field-hint">报价单上呈现为:Notes: {header.order_notes.trim()}</span>
)}
</div>
<div className="form-group">
<label>系统备注 <span className="label-badge label-badge-internal">仅内部可见</span></label>
<textarea
rows="3"
className="quote-notes"
value={header.internal_notes}
onChange={e => setField('internal_notes', e.target.value)}
placeholder="内部备注:成本底线、客户砍价习惯、验厂情况等。不会出现在报价单上"
/>
<span className="field-hint">系统备注只留在系统里,报价单和导出文件都不带它。</span>
</div>
</div>
</section>
<div className="quote-editor-footer">
<button type="button" className="btn" onClick={() => navigate(-1)}>返回</button>
{canEditQuote && (
<button type="button" className="btn btn-primary" onClick={submit} disabled={saving}>
<QuoteIcon size={15} /> {saving ? '保存中…' : '保存报价单'}
</button>
)}
</div>
{newCustomerOpen && (
<NewCustomerModal
initialCompany={newCustomerSeed}
onClose={() => setNewCustomerOpen(false)}
onCreated={async id => {
setNewCustomerOpen(false)
const { data } = await axios.get(`${API}/customers`)
setCustomers(Array.isArray(data) ? data : [])
await loadCustomer(id)
setNotice('客户已创建,抬头信息已带出')
}}
canEditCustomer={canEditCustomer}
/>
)}
</div>
)
}
export default QuoteEditor
+11 -6
View File
@@ -1,12 +1,17 @@
import React, { useState, useEffect, useMemo } from 'react'
import axios from 'axios'
import { useNavigate } from 'react-router-dom'
import { SkeletonTableRows } from '../components/Skeleton'
import { useAuth, hasPermission } from '../auth'
/**
* 客户报价单管理
* 数据来源:happy 后端 /api/quotes(P1 后端合并阶段开放)
* 未接入前保持可用的空态,不做假数据。
* 数据来源:happy 后端 /api/quotes
*/
function QuoteManagement() {
const navigate = useNavigate()
const { user } = useAuth()
const canEdit = hasPermission(user, 'quote.edit')
const [quotes, setQuotes] = useState([])
const [loading, setLoading] = useState(true)
const [keyword, setKeyword] = useState('')
@@ -32,7 +37,7 @@ function QuoteManagement() {
<div className="page-container">
<div className="page-header">
<h1 className="page-title">客户报价单管理</h1>
<button className="btn btn-primary">+ 新建报价单</button>
{canEdit && <button className="btn btn-primary" onClick={() => navigate('/quotes/new')}>+ 新建报价单</button>}
</div>
<div className="toolbar">
@@ -63,12 +68,12 @@ function QuoteManagement() {
</thead>
<tbody>
{loading ? (
<tr><td colSpan="7" className="empty-state">加载中...</td></tr>
<SkeletonTableRows rows={5} widths={[1.2, 1.6, 1, 1, 1.2, 0.8, 1]} />
) : rows.length === 0 ? (
<tr>
<td colSpan="7" className="empty-state">
<div className="empty-state-title">暂无报价单</div>
<div className="empty-state-hint">报价单接口待接入(P1 后端合并阶段开放)</div>
<div className="empty-state-hint">在「产品管理」里把货号加入小推车,点「汇总报价」就能生成第一张。</div>
</td>
</tr>
) : (
@@ -82,7 +87,7 @@ function QuoteManagement() {
<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>
{canEdit && <button className="btn-link">转 PI</button>}
</td>
</tr>
))
+46 -16
View File
@@ -1,14 +1,18 @@
import React, { useState, useEffect, useMemo, useCallback } from 'react'
import { useNavigate } from 'react-router-dom'
import axios from 'axios'
import { useAuth, hasPermission } from '../auth'
import { GridIcon, ListIcon } from '../components/Icons'
import { SkeletonCards, SkeletonPanel } from '../components/Skeleton'
import { SearchField } from '../components/SearchSelect'
import { SkuThumb } from '../components/SkuThumb'
/**
* 供应商管理(原生 React 版)
*
* 由 SKU Manager 的「供应商管理」视图迁移而来,功能对齐:
* 卡片列表 · 未分类入口 · 新建/编辑 · 删除 · 详情(联系方式 / 供货清单 / 合同记录)。
* 数据经 /api/sku/* 同源代理访问,禁止硬编码 SKU 的独立端口。
* 数据走 /api/sku/* 同源接口(SKU 模块已并入后端),禁止硬编码独立端口。
*/
const API = '/api/sku'
@@ -106,6 +110,8 @@ function Kv({ label, value, mono }) {
function SupplierManagement() {
const navigate = useNavigate()
const { user } = useAuth()
const canEdit = hasPermission(user, 'supplier.edit')
const [mode, setMode] = useState('list') // list | detail | form
const [suppliers, setSuppliers] = useState([])
@@ -125,6 +131,8 @@ function SupplierManagement() {
const [detail, setDetail] = useState(null)
const [detailLoading, setDetailLoading] = useState(false)
// 详情页「供货清单」的即时过滤(一家供应商可能供几十上百个货号)
const [productQuery, setProductQuery] = useState('')
const [form, setForm] = useState(EMPTY_FORM)
const [editingId, setEditingId] = useState(null)
@@ -165,6 +173,7 @@ function SupplierManagement() {
setDetailLoading(true)
setDetail(null)
setNotice('')
setProductQuery('')
try {
const { data } = await axios.get(`${API}/suppliers/${id}`)
setDetail(data)
@@ -246,7 +255,7 @@ function SupplierManagement() {
<h1 className="page-title">供应商管理</h1>
<p className="page-sub">档案、联系方式、工厂所在地与主营类目</p>
</div>
<button className="btn btn-primary" onClick={startCreate}>+ 新建供应商</button>
{canEdit && <button className="btn btn-primary" onClick={startCreate}>+ 新建供应商</button>}
</div>
{notice && <div className="notice-inline">{notice}</div>}
@@ -285,7 +294,7 @@ function SupplierManagement() {
</div>
{loading ? (
<div className="loading">加载中…</div>
<SkeletonCards count={8} />
) : suppliers.length === 0 ? (
<div className="empty-state">
<div className="empty-state-icon">🏭</div>
@@ -385,6 +394,11 @@ function SupplierManagement() {
const s = detail?.supplier
const products = detail?.products || []
const contracts = detail?.contracts || []
const pk = productQuery.trim().toLowerCase()
const visibleProducts = pk
? products.filter((p) => [p.display_sku, p.sku, p.group_name, p.name, p.spec]
.filter(Boolean).join(' ').toLowerCase().includes(pk))
: products
return (
<div className="page-container">
@@ -401,14 +415,16 @@ function SupplierManagement() {
</div>
<div className="header-actions">
<button className="btn" onClick={() => { setDetail(null); setMode('list') }}>← 供应商列表</button>
{s && <button className="btn btn-primary" onClick={() => startEdit(s)}>编辑档案</button>}
{s && canEdit && <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">
<SkeletonPanel lines={4} />
</div>
) : (
<>
<div className="card">
@@ -431,12 +447,26 @@ function SupplierManagement() {
<div className="card">
<div className="card-header">
<h2>供货清单</h2>
<span className="secondary">{products.length} 条</span>
<span className="secondary">
{productQuery.trim() ? `${visibleProducts.length} / ${products.length} 条` : `${products.length} 条`}
</span>
</div>
{products.length === 0 ? (
{products.length > 1 && (
<div className="panel-search-row">
<SearchField
size="sm"
value={productQuery}
onChange={setProductQuery}
placeholder="搜货号 / 所属产品 / 规格…"
/>
</div>
)}
{visibleProducts.length === 0 ? (
<div className="empty-state" style={{ padding: '28px 12px' }}>
<div className="empty-state-hint">
还没有归到这家供应商的货号。导入合同时在「合同导入」页选上这家供应商即可。
{products.length === 0
? '还没有归到这家供应商的货号。导入合同时在「合同导入」页选上这家供应商即可。'
: '没有匹配的货号,换个关键词试试。'}
</div>
</div>
) : (
@@ -453,12 +483,10 @@ function SupplierManagement() {
</tr>
</thead>
<tbody>
{products.map((p) => (
{visibleProducts.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" />}
<SkuThumb sku={p.sku} filename={p.thumb} size={34} />
</td>
<td className="mono">{p.display_sku || p.sku}</td>
<td>{p.group_name || p.name || '—'}</td>
@@ -578,10 +606,12 @@ function SupplierManagement() {
</div>
<div className="form-actions">
<button className="btn btn-primary" onClick={save} disabled={saving}>
{saving ? '保存中…' : '保存'}
</button>
{editingId && (
{canEdit && (
<button className="btn btn-primary" onClick={save} disabled={saving}>
{saving ? '保存中…' : '保存'}
</button>
)}
{editingId && canEdit && (
<button
className="btn btn-danger"
onClick={() => removeSupplier(editingId, editingName)}
+257
View File
@@ -0,0 +1,257 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import axios from 'axios'
import { ActivityIcon, DownloadIcon, RefreshIcon, SearchIcon, ClockIcon, ShieldIcon } from '../components/Icons'
import { setPageTitle } from '../branding'
/* ---------------------------------------------------------------------------
系统日志(审计轨迹)—— 侧栏「系统管理」下的独立页面。
参考 GoodJob 的系统日志页:四格统计 + 等级/分类筛选 + 搜索 + 导出 + 列表 + 展开详情。
数据源 GET /api/logs(后端按 action 前缀归分类、按 ok 归等级)。
入口与路由都认 audit.view —— 按 Wayne 的口径,仅系统管理员可见。
--------------------------------------------------------------------------- */
const LEVELS = [
{ key: '', label: '全部' },
{ key: 'info', label: '信息' },
{ key: 'success', label: '成功' },
{ key: 'warning', label: '警告' },
{ key: 'error', label: '错误' }
]
const PAGE_SIZE = 50
function fmtTime(value) {
if (!value) return ''
try {
return new Date(value).toLocaleString('zh-CN', { hour12: false })
} catch {
return String(value)
}
}
function SystemLogs() {
const [level, setLevel] = useState('')
const [category, setCategory] = useState('')
const [keyword, setKeyword] = useState('')
const [search, setSearch] = useState('')
const [offset, setOffset] = useState(0)
const [data, setData] = useState(null)
const [openId, setOpenId] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [exporting, setExporting] = useState(false)
const alive = useRef(true)
useEffect(() => { setPageTitle('系统日志') }, [])
/* 搜索框防抖,避免每敲一个字打一次接口 */
useEffect(() => {
const timer = setTimeout(() => { setSearch(keyword.trim()); setOffset(0) }, 300)
return () => clearTimeout(timer)
}, [keyword])
const load = useCallback(async () => {
setLoading(true)
setError('')
try {
const { data: payload } = await axios.get('/api/logs', {
params: { level: level || undefined, category: category || undefined, q: search || undefined, limit: PAGE_SIZE, offset }
})
if (!alive.current) return
setData(payload)
} catch (e) {
if (alive.current) setError(e?.response?.data?.error || e.message)
} finally {
if (alive.current) setLoading(false)
}
}, [level, category, search, offset])
useEffect(() => { alive.current = true; return () => { alive.current = false } }, [])
useEffect(() => { load() }, [load])
const items = data?.items || []
const stats = data?.stats || { total: 0, today: 0, warning: 0, error: 0 }
const total = data?.total || 0
const rangeText = useMemo(() => {
if (!total) return '暂无记录'
const from = offset + 1
const to = Math.min(offset + PAGE_SIZE, total)
return `第 ${from}–${to} 条 / 共 ${total} 条`
}, [offset, total])
const exportCsv = async () => {
setExporting(true)
setError('')
try {
const response = await axios.get('/api/logs/export', {
params: { level: level || undefined, category: category || undefined, q: search || undefined },
responseType: 'blob'
})
const url = URL.createObjectURL(response.data)
const link = document.createElement('a')
link.href = url
link.download = `system-logs-${new Date().toISOString().slice(0, 10)}.csv`
document.body.appendChild(link)
link.click()
link.remove()
URL.revokeObjectURL(url)
} catch (e) {
setError(e?.response?.data?.error || '导出失败')
} finally {
setExporting(false)
}
}
return (
<div className="page-container logs-page">
<div className="page-header">
<div>
<h1 className="page-title">系统日志</h1>
<p className="page-subtitle">
账号登录与核心业务操作的审计轨迹,用于追溯与排障。仅系统管理员可见。
{total > 0 && ` 共 ${total} 条记录。`}
</p>
</div>
<div className="header-actions">
<button type="button" className="btn btn-secondary" onClick={load} disabled={loading}>
<RefreshIcon size={14} /> {loading ? '刷新中…' : '刷新'}
</button>
<button type="button" className="btn btn-primary" onClick={exportCsv} disabled={exporting || !total}>
<DownloadIcon size={14} /> {exporting ? '导出中…' : '导出 CSV'}
</button>
</div>
</div>
<div className="log-stats">
<div className="log-stat">
<b>{stats.total}</b>
<span>记录</span>
</div>
<div className="log-stat">
<b>{stats.today}</b>
<span>今日</span>
</div>
<div className="log-stat is-warn">
<b>{stats.warning}</b>
<span>警告</span>
</div>
<div className="log-stat is-err">
<b>{stats.error}</b>
<span>错误</span>
</div>
</div>
<div className="panel">
<div className="log-toolbar">
<div className="log-chips" role="tablist" aria-label="按等级筛选">
{LEVELS.map(item => (
<button
key={item.key || 'all'}
type="button"
role="tab"
aria-selected={level === item.key}
className={`log-chip ${level === item.key ? 'is-active' : ''} ${item.key ? 'is-' + item.key : ''}`}
onClick={() => { setLevel(item.key); setOffset(0) }}
>
{item.label}
</button>
))}
</div>
</div>
<div className="log-toolbar">
<div className="log-chips" role="tablist" aria-label="按分类筛选">
<button
type="button"
role="tab"
aria-selected={category === ''}
className={`log-chip ${category === '' ? 'is-active' : ''}`}
onClick={() => { setCategory(''); setOffset(0) }}
>
全部
</button>
{(data?.categories || []).map(item => (
<button
key={item.key}
type="button"
role="tab"
aria-selected={category === item.key}
className={`log-chip ${category === item.key ? 'is-active' : ''}`}
onClick={() => { setCategory(item.key); setOffset(0) }}
>
{item.label}<small>{item.count}</small>
</button>
))}
</div>
</div>
<div className="log-actions">
<label className="log-search">
<SearchIcon size={15} />
<input
type="search"
value={keyword}
placeholder="搜索操作人 / 动作 / 说明 / IP"
onChange={e => setKeyword(e.target.value)}
/>
</label>
</div>
{error && <p className="log-error"><ShieldIcon size={14} /> {error}</p>}
{!loading && !items.length && (
<div className="log-empty">
<ActivityIcon size={22} />
<strong>暂无日志记录</strong>
<small>登录系统或改动用户、权限、客户、商机与模型接入时,操作会自动记录到这里。</small>
</div>
)}
{!!items.length && (
<ul className="log-list">
{items.map(row => (
<li key={row.id} className={`log-row is-${row.level}`}>
<button
type="button"
className="log-row-main"
aria-expanded={openId === row.id}
onClick={() => setOpenId(openId === row.id ? null : row.id)}
>
<span className={`log-dot is-${row.level}`} aria-hidden="true" />
<span className="log-time"><ClockIcon size={12} />{fmtTime(row.at)}</span>
<span className={`log-badge is-${row.level}`}>{row.ok ? '' : '失败 · '}{row.action_label}</span>
<span className="log-detail">{row.detail || '—'}</span>
<span className="log-actor">{row.username || '—'}</span>
<span className="log-cat">{row.category}</span>
</button>
{openId === row.id && (
<dl className="log-more">
<div><dt>动作标识</dt><dd>{row.action}</dd></div>
<div><dt>操作人</dt><dd>{row.username || '—'}</dd></div>
<div><dt>来源 IP</dt><dd>{row.ip || '—'}</dd></div>
<div><dt>结果</dt><dd>{row.ok ? '成功' : '失败'}</dd></div>
<div className="full"><dt>说明</dt><dd>{row.detail || '—'}</dd></div>
<div className="full"><dt>User-Agent</dt><dd>{row.user_agent || '—'}</dd></div>
</dl>
)}
</li>
))}
</ul>
)}
{(total > PAGE_SIZE || offset > 0) && (
<div className="log-pager">
<span>{rangeText}</span>
<div>
<button type="button" className="btn btn-secondary" disabled={offset === 0 || loading} onClick={() => setOffset(Math.max(offset - PAGE_SIZE, 0))}>上一页</button>
<button type="button" className="btn btn-secondary" disabled={offset + PAGE_SIZE >= total || loading} onClick={() => setOffset(offset + PAGE_SIZE)}>下一页</button>
</div>
</div>
)}
</div>
</div>
)
}
export default SystemLogs
+423
View File
@@ -0,0 +1,423 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import axios from 'axios'
import { UserIcon, PlusIcon, RefreshIcon, SaveIcon } from '../components/Icons'
import { SkeletonTableRows } from '../components/Skeleton'
import { useAuth } from '../auth'
import { setPageTitle } from '../branding'
/**
* 用户管理(user.manage)。
*
* 口径(与权限组管理同一套推导):
* - 角色只是职位标签 + 授权键;用户填角色,角色被指派进某个权限组,即获得该组权限。
* - 所以「改用户角色」就是换权限的唯一入口,页面上要把「该角色现在落在哪个组」亮出来,
* 免得管理员改完角色还以为权限没生效。
* - 停用/改密会踢掉该用户已有会话(后端做),管理员自己的会话保留。
*/
const EMPTY_NEW = {
username: '',
display_name: '',
password: '',
role: 'sales_rep',
org_id: '',
position: '',
email: ''
}
function fmtDate(value) {
if (!value) return '—'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return '—'
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
}
function UserManagement() {
const { user: me, refresh } = useAuth()
const [users, setUsers] = useState([])
const [roles, setRoles] = useState([])
const [orgs, setOrgs] = useState([])
const [loading, setLoading] = useState(true)
const [busy, setBusy] = useState(false)
const [notice, setNotice] = useState(null)
const [showCreate, setShowCreate] = useState(false)
const [createForm, setCreateForm] = useState({ ...EMPTY_NEW })
const [createError, setCreateError] = useState('')
const [editing, setEditing] = useState(null)
const [editForm, setEditForm] = useState(null)
const [editError, setEditError] = useState('')
const load = useCallback(async () => {
setLoading(true)
try {
const [usersRes, rolesRes, orgsRes] = await Promise.all([
axios.get('/api/users'),
axios.get('/api/roles'),
axios.get('/api/organizations')
])
setUsers(usersRes.data.users || [])
setRoles(rolesRes.data.roles || [])
setOrgs(orgsRes.data.organizations || [])
} catch (error) {
setNotice({ type: 'error', text: error?.response?.data?.error || '加载用户列表失败' })
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
setPageTitle('用户管理')
load()
}, [load])
const roleLabel = useCallback(value => (
roles.find(item => item.value === value)?.label || value || '—'
), [roles])
const activeCount = useMemo(() => users.filter(item => item.is_active).length, [users])
/* ---------------- 新建 ---------------- */
const submitCreate = async (event) => {
event.preventDefault()
if (!createForm.username.trim() || !createForm.password) {
setCreateError('账号和初始密码必填')
return
}
setBusy(true)
setCreateError('')
try {
const { data } = await axios.post('/api/users', {
...createForm,
org_id: createForm.org_id === '' ? null : Number(createForm.org_id)
})
setNotice({ type: 'ok', text: `用户「${data.user.username}」已创建,可交由对方登录后自行改密` })
setShowCreate(false)
setCreateForm({ ...EMPTY_NEW })
await load()
} catch (error) {
setCreateError(error?.response?.data?.error || '创建失败')
} finally {
setBusy(false)
}
}
/* ---------------- 编辑 ---------------- */
const openEdit = (row) => {
setEditing(row)
setEditError('')
setEditForm({
display_name: row.display_name || '',
position: row.position || '',
email: row.email || '',
phone: row.phone || '',
role: row.role,
org_id: row.org_id ?? ''
})
}
const submitEdit = async (event) => {
event.preventDefault()
if (!editing) return
setBusy(true)
setEditError('')
try {
await axios.patch(`/api/users/${editing.id}`, {
...editForm,
org_id: editForm.org_id === '' ? null : Number(editForm.org_id)
})
setNotice({ type: 'ok', text: `「${editing.username}」的资料已保存` })
setEditing(null)
await load()
if (editing.id === me.id) await refresh() /* 改到自己身上时刷新自身权限显示 */
} catch (error) {
setEditError(error?.response?.data?.error || '保存失败')
} finally {
setBusy(false)
}
}
/* ---------------- 重置密码 / 停用 ---------------- */
const resetPassword = async (row) => {
const password = window.prompt(`给「${row.username}」设置新密码(至少 6 位):`)
if (password === null) return
if (password.length < 6) {
setNotice({ type: 'error', text: '密码至少 6 位,未做修改' })
return
}
setBusy(true)
try {
await axios.patch(`/api/users/${row.id}`, { password })
setNotice({ type: 'ok', text: `「${row.username}」的密码已重置,对方旧登录态已失效` })
await load()
} catch (error) {
setNotice({ type: 'error', text: error?.response?.data?.error || '重置失败' })
} finally {
setBusy(false)
}
}
const toggleActive = async (row) => {
const next = !row.is_active
const ok = window.confirm(
next
? `启用「${row.username}」?启用后对方即可正常登录。`
: `停用「${row.username}」?停用后对方立即掉线、无法登录,客户等数据保留。`
)
if (!ok) return
setBusy(true)
try {
await axios.patch(`/api/users/${row.id}`, { is_active: next })
setNotice({ type: 'ok', text: `「${row.username}」已${next ? '启用' : '停用'}` })
await load()
} catch (error) {
setNotice({ type: 'error', text: error?.response?.data?.error || '操作失败' })
} finally {
setBusy(false)
}
}
return (
<div className="page-container user-page">
<div className="page-header">
<div>
<h1 className="page-title">用户管理</h1>
<p className="page-subtitle">
账号的增删改都在这里。角色决定权限(角色 → 权限组的推导见「权限组管理」),
停用会立即把对方踢下线。共 {users.length} 个账号,{activeCount} 个启用中。
</p>
</div>
<div className="header-actions">
<button type="button" className="btn btn-secondary" onClick={load} disabled={busy}>
<RefreshIcon size={14} /> 刷新
</button>
<button type="button" className="btn btn-primary" onClick={() => { setShowCreate(true); setCreateError('') }} disabled={busy}>
<PlusIcon size={14} /> 新建用户
</button>
</div>
</div>
{notice && <p className={`profile-notice ${notice.type}`}>{notice.text}</p>}
<div className="table-container">
<table className="data-table">
<thead>
<tr>
<th>账号 / 姓名</th>
<th>角色</th>
<th>权限组</th>
<th>组织</th>
<th>最近登录</th>
<th>状态</th>
<th className="col-actions">操作</th>
</tr>
</thead>
<tbody>
{loading ? (
<SkeletonTableRows rows={4} widths={[1.8, 1, 1, 1, 1, 0.7, 1.6]} />
) : users.length === 0 ? (
<tr><td colSpan="7" className="empty-state">还没有用户</td></tr>
) : users.map(row => {
const isSelf = row.id === me.id
return (
<tr key={row.id} className={row.is_active ? '' : 'row-muted'}>
<td>
<strong>{row.username}</strong>
{isSelf && <span className="owner-self" style={{ marginLeft: 6 }}>我</span>}
<div className="muted-note">
{row.display_name || '—'}
{row.position ? ` · ${row.position}` : ''}
</div>
</td>
<td>
<span className="owner-cell"><UserIcon size={11} /> {roleLabel(row.role)}</span>
</td>
<td>{row.permission_group_name || <span className="muted-note">该角色未指派组</span>}</td>
<td>{row.org_name || <span className="muted-note">未指定</span>}</td>
<td className="mono">{fmtDate(row.last_login_at)}</td>
<td>
<span className={`pill ${row.is_active ? 'pill-ok' : 'pill-mute'}`}>
{row.is_active ? '启用' : '停用'}
</span>
</td>
<td className="col-actions">
<button type="button" className="btn-link" onClick={() => openEdit(row)}>编辑</button>
<button type="button" className="btn-link" disabled={busy} onClick={() => resetPassword(row)}>重置密码</button>
{!isSelf && (
<button type="button" className="btn-link" disabled={busy} onClick={() => toggleActive(row)}>
{row.is_active ? '停用' : '启用'}
</button>
)}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
{/* ---------------- 新建用户 ---------------- */}
{showCreate && (
<div className="modal-overlay" onClick={() => !busy && setShowCreate(false)}>
<div className="modal-content" onClick={event => event.stopPropagation()}>
<div className="modal-header">
<h2>新建用户</h2>
<button type="button" className="icon-btn" onClick={() => setShowCreate(false)} aria-label="关闭">×</button>
</div>
<form onSubmit={submitCreate}>
<div className="form-row">
<div className="form-group">
<label>登录账号 *</label>
<input
type="text" value={createForm.username} autoFocus
placeholder="字母/数字,如 zhangsan"
onChange={event => setCreateForm({ ...createForm, username: event.target.value })}
/>
</div>
<div className="form-group">
<label>姓名</label>
<input
type="text" value={createForm.display_name}
placeholder="对外显示的名字"
onChange={event => setCreateForm({ ...createForm, display_name: event.target.value })}
/>
</div>
</div>
<div className="form-row">
<div className="form-group">
<label>初始密码 *</label>
<input
type="text" value={createForm.password}
placeholder="至少 6 位,交给对方后自行修改"
onChange={event => setCreateForm({ ...createForm, password: event.target.value })}
/>
</div>
<div className="form-group">
<label>角色(决定权限)</label>
<select
value={createForm.role}
onChange={event => setCreateForm({ ...createForm, role: event.target.value })}
>
{roles.map(item => (
<option key={item.value} value={item.value}>
{item.label}{item.group_name ? `(${item.group_name})` : '(未指派组)'}
</option>
))}
</select>
</div>
</div>
<div className="form-row">
<div className="form-group">
<label>所属组织</label>
<select
value={createForm.org_id}
onChange={event => setCreateForm({ ...createForm, org_id: event.target.value })}
>
<option value="">未指定</option>
{orgs.map(org => <option key={org.id} value={String(org.id)}>{org.name}</option>)}
</select>
</div>
<div className="form-group">
<label>职位(对外称谓)</label>
<input
type="text" value={createForm.position}
placeholder="如:外贸业务员"
onChange={event => setCreateForm({ ...createForm, position: event.target.value })}
/>
</div>
</div>
{createError && <p className="form-error">{createError}</p>}
<div className="modal-footer">
<button type="button" className="btn btn-secondary" onClick={() => setShowCreate(false)} disabled={busy}>取消</button>
<button type="submit" className="btn btn-primary" disabled={busy}>
<SaveIcon size={14} /> {busy ? '创建中…' : '创建用户'}
</button>
</div>
</form>
</div>
</div>
)}
{/* ---------------- 编辑用户 ---------------- */}
{editing && editForm && (
<div className="modal-overlay" onClick={() => !busy && setEditing(null)}>
<div className="modal-content" onClick={event => event.stopPropagation()}>
<div className="modal-header">
<h2>编辑「{editing.username}」</h2>
<button type="button" className="icon-btn" onClick={() => setEditing(null)} aria-label="关闭">×</button>
</div>
<form onSubmit={submitEdit}>
<div className="form-row">
<div className="form-group">
<label>姓名</label>
<input
type="text" value={editForm.display_name}
onChange={event => setEditForm({ ...editForm, display_name: event.target.value })}
/>
</div>
<div className="form-group">
<label>职位</label>
<input
type="text" value={editForm.position}
onChange={event => setEditForm({ ...editForm, position: event.target.value })}
/>
</div>
</div>
<div className="form-row">
<div className="form-group">
<label>角色(决定权限)</label>
<select
value={editForm.role}
disabled={editing.id === me.id}
title={editing.id === me.id ? '不能修改自己的角色' : ''}
onChange={event => setEditForm({ ...editForm, role: event.target.value })}
>
{roles.map(item => (
<option key={item.value} value={item.value}>
{item.label}{item.group_name ? `(${item.group_name})` : '(未指派组)'}
</option>
))}
</select>
</div>
<div className="form-group">
<label>所属组织</label>
<select
value={editForm.org_id === null ? '' : editForm.org_id}
onChange={event => setEditForm({ ...editForm, org_id: event.target.value })}
>
<option value="">未指定</option>
{orgs.map(org => <option key={org.id} value={String(org.id)}>{org.name}</option>)}
</select>
</div>
</div>
<div className="form-row">
<div className="form-group">
<label>邮箱</label>
<input
type="text" value={editForm.email}
onChange={event => setEditForm({ ...editForm, email: event.target.value })}
/>
</div>
<div className="form-group">
<label>电话</label>
<input
type="text" value={editForm.phone}
onChange={event => setEditForm({ ...editForm, phone: event.target.value })}
/>
</div>
</div>
{editError && <p className="form-error">{editError}</p>}
<div className="modal-footer">
<button type="button" className="btn btn-secondary" onClick={() => setEditing(null)} disabled={busy}>取消</button>
<button type="submit" className="btn btn-primary" disabled={busy}>
<SaveIcon size={14} /> {busy ? '保存中…' : '保存'}
</button>
</div>
</form>
</div>
</div>
)}
</div>
)
}
export default UserManagement
+73 -62
View File
@@ -11,18 +11,21 @@
对比度策略参考 GoodJob 的 Command Deck —— 底色固定深色、不随日夜主题反转,
文字亮度拉高,hover 近白、选中近白 + 品牌色强调条,两种主题下都清晰。 */
.sidebar {
/* 侧栏专属色板:不引用随主题翻转的变量,日夜两套主题共用同一份深色底 */
--sb-bg-top: #0a1d2b;
--sb-bg-bottom: #05101a;
--sb-border: rgba(126, 215, 245, 0.16);
--sb-text: #c3d7e3;
--sb-text-dim: #86a3b4;
/* 侧栏专属色板:不引用随主题翻转的变量,日夜两套主题共用同一份深色底。
⚠️ 这套变量定义在 .sidebar 上,不是 :root —— 要覆盖必须写在同级或更具体的选择器。
2026-09-15:原本是青色系(强调 #7ed7f5、文字 #c3d7e3),跟主区的钢蓝不是同一套色相,
同屏看像两个界面;这里统一到钢蓝,并把文字灰提亮一档。 */
--sb-bg-top: #0a1826;
--sb-bg-bottom: #050c14;
--sb-border: rgba(160, 180, 215, 0.16);
--sb-text: #d5dfec;
--sb-text-dim: #a8b6c9;
--sb-text-strong: #ffffff;
--sb-icon: #8fb4c7;
--sb-hover-bg: #16323f;
--sb-active-bg: rgba(126, 215, 245, 0.16);
--sb-active-ring: rgba(126, 215, 245, 0.38);
--sb-accent: #7ed7f5;
--sb-icon: #a3b2c7;
--sb-hover-bg: #1b2b40;
--sb-active-bg: rgba(132, 181, 255, 0.15);
--sb-active-ring: rgba(132, 181, 255, 0.40);
--sb-accent: #9ec5ff;
position: sticky;
top: var(--header-height);
width: var(--sidebar-width);
@@ -49,11 +52,20 @@
.collapse-btn { flex: 0 0 30px; width: 30px; height: 30px; border: 1px solid var(--sb-border); border-radius: var(--radius-sm); background: transparent; color: var(--sb-text); cursor: pointer; font-size: 16px; transition: all var(--transition-fast); }
.collapse-btn:hover { background: var(--sb-accent); color: #04131c; border-color: var(--sb-accent); }
.sidebar-nav { flex: 1; overflow-y: auto; padding: 6px 10px 14px; }
/* 侧栏菜单滚动条:默认系统白条在深色侧栏上很突兀,改成平时隐身、悬停才露出的细条。
侧栏两主题都固定深色,拇指色用白色半透明即可通吃。 */
.sidebar-nav { scrollbar-width: thin; scrollbar-color: transparent transparent; }
.sidebar-nav::-webkit-scrollbar { width: 6px; }
.sidebar-nav::-webkit-scrollbar-track { background: transparent; }
.sidebar-nav::-webkit-scrollbar-thumb { background: transparent; border-radius: 999px; }
.sidebar-nav:hover { scrollbar-color: rgba(255, 255, 255, 0.18) transparent; }
.sidebar-nav:hover::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.18); }
.sidebar-nav::-webkit-scrollbar-thumb:hover { background: rgba(255, 255, 255, 0.32); }
/* 分组标题行:标题 + 右侧折叠按钮 */
.nav-group { display: block; }
.nav-group:first-of-type .nav-group-header { margin-top: 6px; padding-top: 0; }
.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(--sb-text-dim); font-size: 12px; font-weight: 600; letter-spacing: 0.04em; }
.nav-group-title { flex: 1; margin: 0; color: var(--sb-text-dim); font-size: 13px; 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(--sb-text-dim); cursor: pointer; transition: color var(--transition-fast), background var(--transition-fast), border-color var(--transition-fast); }
.nav-group-toggle:hover { color: var(--sb-text-strong); background: var(--sb-hover-bg); border-color: var(--sb-border); }
.nav-group-toggle svg { transition: transform var(--transition-base); }
@@ -62,17 +74,16 @@
.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(--sb-text); text-decoration: none; font-size: 14px; font-weight: 600; transition: all var(--transition-fast); }
.nav-item:hover { background: var(--sb-hover-bg); border-color: transparent; color: var(--sb-text-strong); }
.nav-item.active { background: var(--sb-active-bg); border-color: transparent; color: var(--sb-text-strong); box-shadow: inset 0 0 0 1px var(--sb-active-ring); }
.nav-item.active::before { content: ""; position: absolute; left: -1px; top: 8px; bottom: 8px; width: 3px; border-radius: 0 2px 2px 0; background: var(--sb-accent); box-shadow: 0 0 10px rgba(126, 215, 245, 0.55); }
.nav-item.active::before { content: ""; position: absolute; left: -1px; top: 8px; bottom: 8px; width: 3px; border-radius: 0 2px 2px 0; background: var(--sb-accent); box-shadow: 0 0 10px rgba(158, 197, 255, 0.5); }
.nav-item .icon { display: flex; align-items: center; justify-content: center; min-width: 20px; color: var(--sb-icon); transition: color var(--transition-fast); }
.nav-item:hover .icon, .nav-item.active .icon { color: var(--sb-accent); }
.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(--sb-border); }
.sidebar-footer .user-info { flex: 1; overflow: hidden; color: var(--sb-text); font-size: 12px; font-weight: 600; white-space: nowrap; }
/* 用户信息只在右上角 header 显示;此处只放日夜切换与设置,右对齐以保持原有位置 */
.sidebar-footer { display: flex; align-items: center; justify-content: flex-end; gap: 10px; padding: 15px 16px; border-top: 1px solid var(--sb-border); }
.settings-btn { display: flex; align-items: center; justify-content: center; width: 32px; height: 32px; border: 1px solid var(--sb-border); border-radius: var(--radius-sm); background: transparent; color: var(--sb-text); cursor: pointer; transition: all var(--transition-fast); }
.settings-btn:hover { color: var(--sb-accent); border-color: var(--sb-active-ring); background: var(--sb-hover-bg); }
.sidebar.collapsed .sidebar-footer { justify-content: center; padding: 15px 10px; }
.sidebar.collapsed .user-info { display: none; }
/* 收起后侧栏只有 76px 宽,两个 40px 圆形按钮并排会互相挤压变形 —— 只留日夜切换 */
.sidebar.collapsed .settings-btn { display: none; }
@@ -96,7 +107,7 @@
.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-label { margin-bottom: 5px; color: var(--text-tertiary); font-size: 13px; 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; }
@@ -106,7 +117,7 @@
.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; }
.form-group label { display: block; margin-bottom: 7px; color: var(--text-secondary); font-size: 13px; 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; }
@@ -117,22 +128,22 @@
.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; }
.empty-state-hint { color: var(--text-tertiary); font-size: 14px; }
.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; }
.todo-title { color: var(--text-primary); font-size: 14px; font-weight: 600; }
.todo-meta, .activity-time { margin-top: 3px; color: var(--text-tertiary); font-size: 12.5px; }
.activity-content { color: var(--text-secondary); font-size: 14px; }
.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; }
.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: 14px; 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; }
.holiday-name { flex: 1; color: var(--text-secondary); font-size: 14px; }
.holiday-date { color: var(--text-tertiary); font-family: var(--font-mono); font-size: 13px; }
.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; }
@@ -143,33 +154,33 @@
.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 { 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: 14px; 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-description { margin: 0 0 26px; color: var(--text-tertiary); font-size: 14px; }
.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-group h4 { margin: 0 0 13px; color: var(--text-secondary); font-size: 14px; }
.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; }
.help-text { margin: 9px 0 0; color: var(--text-tertiary); font-size: 13px; }
.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; }
.checkbox-group span { color: var(--text-secondary); font-size: 14px; }
.loading { display: flex; align-items: center; justify-content: center; min-height: 220px; color: var(--text-tertiary); font-size: 13px; }
.loading { display: flex; align-items: center; justify-content: center; min-height: 220px; color: var(--text-tertiary); font-size: 14px; }
.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-top { flex-direction: column; gap: 8px; padding: 8px; } .sidebar-top .nav-item-home { width: 100%; flex: 0 0 auto; } .sidebar-top .collapse-btn svg { transform: rotate(180deg); } .sidebar-nav { padding-inline: 9px; } .nav-group-header, .nav-item span:not(.icon), .user-info, .settings-btn { 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: 900px) { .sidebar { width: var(--sidebar-collapsed-width); flex-basis: var(--sidebar-collapsed-width); } .sidebar-top { flex-direction: column; gap: 8px; padding: 8px; } .sidebar-top .nav-item-home { width: 100%; flex: 0 0 auto; } .sidebar-top .collapse-btn svg { transform: rotate(180deg); } .sidebar-nav { padding-inline: 9px; } .nav-group-header, .nav-item span:not(.icon), .settings-btn { 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 table { width: 100%; border-collapse: separate; border-spacing: 0; font-size: 14px; }
.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: 12.5px; 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); }
@@ -181,28 +192,28 @@
.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; }
.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: 14px; }
.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; }
.sc-contact, .sc-loc { overflow: hidden; color: var(--text-secondary); font-size: 14px; 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: 13px; }
.sc-count b { color: var(--text-primary); font-size: 14px; }
.muted-note { color: var(--text-tertiary); font-size: 13px; }
.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; }
.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: 13px; 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-label { color: var(--text-tertiary); font-size: 13px; }
.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 { display: inline-flex; align-items: center; padding: 2px 9px; border-radius: var(--radius-pill); font-size: 13px; 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); }
@@ -218,7 +229,7 @@
============================================================ */
/* 表头改成中文友好:原来 11px + 0.08em 字距是给全大写英文设计的,中文会被拉散 */
.data-table thead th { font-size: 12px; letter-spacing: 0.02em; text-transform: none; }
.data-table thead th { font-size: 13px; 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); }
@@ -232,17 +243,17 @@
.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 { display: inline-flex; align-items: center; gap: 7px; color: var(--text-secondary); font-size: 14px; 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 { 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: 14px; }
.batch-bar b { color: var(--text-primary); }
.hit-line { margin-top: 3px; color: var(--text-tertiary); font-size: 12px; }
.hit-line { margin-top: 3px; color: var(--text-tertiary); font-size: 13px; }
.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-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: 12px; 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; }
@@ -252,7 +263,7 @@
/* 抽屉补充 */
.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; }
.drawer-sub { margin: 5px 0 0; color: var(--text-tertiary); font-size: 14px; }
/* 参数编辑器(参数名 / 值 / 删除) */
.attr-editor { display: flex; flex-direction: column; gap: 8px; }
@@ -265,10 +276,10 @@
.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-flag { position: absolute; top: 6px; left: 6px; padding: 1px 7px; border-radius: var(--radius-pill); background: var(--primary); color: #04141d; font-size: 12.5px; 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 { 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: 13px; 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; }
@@ -277,38 +288,38 @@
/* 说明块 */
.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 { 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: 14px; 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; }
.spec-text { white-space: pre-wrap; color: var(--text-secondary); font-size: 14px; line-height: 1.75; }
.terms summary { color: var(--text-secondary); font-size: 14px; 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; }
.timeline-title { color: var(--text-primary); font-size: 14px; }
.timeline-meta { margin-top: 2px; color: var(--text-tertiary); font-size: 13px; }
/* 小推车 */
.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-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: 12.5px; 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 { overflow: hidden; margin-top: 2px; color: var(--text-tertiary); font-size: 13px; 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 span { color: var(--text-tertiary); font-size: 12.5px; }
.cart-qty input { width: 84px; }
.cart-total { align-self: center; color: var(--text-secondary); font-size: 13px; }
.cart-total { align-self: center; color: var(--text-secondary); font-size: 14px; }
.cart-total b { color: var(--text-primary); font-size: 16px; }
/* 设置页:系统与更新 */
.update-status { display: flex; align-items: center; gap: 8px; margin-top: 12px; padding: 10px 13px; border: 1px solid var(--border-light); border-radius: var(--radius-md); background: var(--surface-deep); color: var(--text-secondary); font-size: 13px; line-height: 1.5; }
.update-status { display: flex; align-items: center; gap: 8px; margin-top: 12px; padding: 10px 13px; border: 1px solid var(--border-light); border-radius: var(--radius-md); background: var(--surface-deep); color: var(--text-secondary); font-size: 14px; line-height: 1.5; }
.update-status b { color: var(--text-primary); font-family: var(--font-mono); }
.update-status .spinner { width: 14px; height: 14px; border-width: 2px; }
.update-status.is-checking { color: var(--text-tertiary); }
@@ -316,8 +327,8 @@
.update-status.is-new { border-color: var(--primary-border); background: var(--primary-bg); color: var(--primary-light); }
.update-status.is-error { border-color: var(--danger-border); background: var(--danger-bg); color: var(--danger); }
.update-links { display: flex; flex-wrap: wrap; gap: 18px; margin-top: 10px; }
.update-links .btn-link { text-decoration: none; font-size: 13px; }
.update-notes { margin: 10px 0 0; max-height: 230px; overflow: auto; padding: 12px 14px; border: 1px solid var(--border-light); border-radius: var(--radius-md); background: var(--surface-deep); color: var(--text-secondary); font-family: var(--font-body); font-size: 12px; line-height: 1.65; white-space: pre-wrap; word-break: break-word; }
.update-links .btn-link { text-decoration: none; font-size: 14px; }
.update-notes { margin: 10px 0 0; max-height: 230px; overflow: auto; padding: 12px 14px; border: 1px solid var(--border-light); border-radius: var(--radius-md); background: var(--surface-deep); color: var(--text-secondary); font-family: var(--font-body); font-size: 13px; line-height: 1.65; white-space: pre-wrap; word-break: break-word; }
/* 窄屏:批量条与抽屉内表单换行 */
@media (max-width: 720px) {
+5 -5
View File
@@ -143,7 +143,7 @@ a { color: inherit; }
border-radius: var(--radius-md);
background: var(--surface);
color: var(--text-primary);
font-size: 13px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: transform var(--transition-fast), background var(--transition-fast), border-color var(--transition-fast), box-shadow var(--transition-fast);
@@ -155,7 +155,7 @@ a { color: inherit; }
.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-sm { min-height: 30px; padding: 5px 10px; font-size: 13px; }
.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); }
@@ -191,7 +191,7 @@ input:focus, input:not([type]):focus, select:focus, textarea:focus, .form-input:
padding: 2px 8px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
font-size: 12px;
font-size: 13px;
font-weight: 700;
white-space: nowrap;
}
@@ -201,8 +201,8 @@ input:focus, input:not([type]):focus, select:focus, textarea:focus, .form-input:
.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 { width: 100%; border-collapse: separate; border-spacing: 0; font-size: 14px; }
.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: 12.5px; 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; }
+212 -107
View File
@@ -133,28 +133,27 @@ input:focus-visible, select:focus-visible, textarea:focus-visible { outline: non
.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 { display: flex; align-items: center; gap: 10px; min-height: 44px; border: 0; border-radius: 11px; padding: 12px; margin-bottom: 6px; font-size: 14px; }
.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; }
.settings-sidebar-status { display: flex; align-items: flex-start; gap: 9px; margin: auto 22px 0; color: var(--text-tertiary); font-size: 12.5px; }
.settings-sidebar-status small { display: block; margin-top: 7px; font: 12px 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-description { color: var(--text-secondary); font-size: 14px; 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); }
.luxury-settings .settings-group h4 { margin: 0 0 12px; font-size: 14px; 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-option strong { font-size: 14px; }
.theme-option small { color: var(--text-tertiary); font-size: 12.5px; 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. */
@@ -170,16 +169,16 @@ input:focus-visible, select:focus-visible, textarea:focus-visible { outline: non
.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 strong, .setting-info strong { display: block; font-size: 14px; color: var(--text-primary); font-weight: 500; }
.setting-toggle small, .setting-info small { display: block; color: var(--text-tertiary); font-size: 13px; line-height: 1.7; margin-top: 3px; }
.preference-badge { flex-shrink: 0; font-size: 12px; 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; }
.appearance-note { display: flex; align-items: center; gap: 7px; font-size: 12.5px; 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)); }
@@ -189,7 +188,6 @@ input:focus-visible, select:focus-visible, textarea:focus-visible { outline: non
.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; }
@@ -203,14 +201,14 @@ input:focus-visible, select:focus-visible, textarea:focus-visible { outline: non
.stat-card { gap: 8px; padding: 12px; }
.stat-icon { flex-basis: 22px; }
.stat-icon svg { width: 22px; height: 22px; }
.stat-label { font-size: 11px; }
.stat-label { font-size: 12.5px; }
.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; }
.theme-option small { font-size: 12px; }
.setting-info { flex-wrap: wrap; gap: 6px; }
}
@media (prefers-reduced-motion: reduce) {
@@ -224,34 +222,27 @@ input:focus-visible, select:focus-visible, textarea:focus-visible { outline: non
.app-container { min-height: 100vh; }
.main-content { min-height: 0; overflow: hidden; padding: 0; }
.route-content { min-height: calc(100vh - var(--header-height)); overflow-y: auto; padding: 32px clamp(18px, 3.6vw, 52px) 56px; }
/* 嵌入页(合同导入):让 iframe 撑满路由区,只留一层滚动。
宿主和嵌入页各有一条滚动条时,「嵌进来」的感觉最明显。 */
.route-content:has(.embed-shell) { display: flex; flex-direction: column; height: calc(100vh - var(--header-height)); min-height: 0; overflow: hidden; padding-bottom: 0; }
.embed-shell { display: flex; flex-direction: column; flex: 1 1 auto; min-height: 0; }
.embed-frame { position: relative; flex: 1 1 auto; min-height: 0; overflow: hidden; border-radius: var(--radius-lg) var(--radius-lg) 0 0; background: var(--bg-primary); }
.embed-refresh { display: inline-flex; align-items: center; justify-content: center; flex: 0 0 auto; width: 32px; height: 32px; border: 0; border-radius: var(--radius-sm); background: transparent; color: var(--text-tertiary); cursor: pointer; transition: color var(--transition-fast), background var(--transition-fast); }
.embed-refresh:hover { background: var(--surface-hover); color: var(--text-primary); }
.global-header { position: sticky; top: 0; z-index: 25; display: flex; align-items: center; gap: 22px; min-height: var(--header-height); 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: 13px; min-width: 240px; }
.global-header-brand strong, .global-header-brand span { display: block; }
.global-header-brand strong { font-size: 19px; letter-spacing: .01em; line-height: 1.2; }
.global-header-brand span:not(.header-status-dot):not(.brand-mark) { color: var(--text-tertiary); font: 10px var(--font-mono); letter-spacing: .14em; margin-top: 2px; }
.global-header-brand span:not(.header-status-dot):not(.brand-mark) { color: var(--text-tertiary); font: 12px var(--font-mono); letter-spacing: .14em; margin-top: 2px; }
.header-status-dot { width: 9px; height: 9px; flex: 0 0 9px; border-radius: 50%; background: var(--success); box-shadow: 0 0 10px var(--success); }
.global-search { display: flex; align-items: center; gap: 9px; flex: 0 0 auto; width: min(360px, 30vw); min-height: 38px; padding: 0 11px; color: var(--text-tertiary); background: var(--surface-deep); border: 0; border-radius: 11px; transition: background var(--transition-fast); }
.global-search:focus-within { background: var(--primary-bg); }
.global-search input { min-width: 0; 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; flex: 0 0 auto; white-space: nowrap; 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-search kbd { margin-left: auto; flex: 0 0 auto; white-space: nowrap; padding: 2px 5px; color: var(--text-tertiary); background: var(--surface); border: 1px solid var(--border-light); border-radius: 5px; font: 12px var(--font-mono); }
/* ---- 已打开页面标签:搜索框后的快速跳转(参考 GoodJob 的顶部标签条) ---- */
.page-tabs { display: flex; align-items: center; gap: 3px; flex: 0 1 auto; min-width: 0; max-width: min(520px, 30vw); padding: 4px; border: 1px solid var(--border-light); border-radius: 11px; background: var(--surface-deep); overflow-x: auto; scrollbar-width: none; }
.page-tabs { display: flex; align-items: center; gap: 3px; flex: 0 1 auto; min-width: 0; max-width: min(580px, 38vw); padding: 4px; border: 1px solid var(--border-light); border-radius: 11px; background: var(--surface-deep); overflow-x: auto; scrollbar-width: none; }
.page-tabs::-webkit-scrollbar { display: none; }
.page-tab { display: inline-flex; align-items: center; flex: 0 0 auto; max-width: 176px; height: 30px; border-radius: 8px; background: transparent; transition: background 150ms, box-shadow 150ms; }
.page-tab:hover { background: var(--surface-hover); }
.page-tab.active { background: var(--surface); box-shadow: inset 0 1px 0 var(--panel-highlight), inset 0 0 0 1px var(--primary-border); }
.page-tab-label { max-width: 132px; height: 100%; padding: 0 6px 0 10px; border: 0; background: transparent; color: var(--text-tertiary); cursor: pointer; font-size: 12px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; transition: color 150ms; }
.page-tab-label { max-width: 106px; height: 100%; padding: 0 4px 0 9px; border: 0; background: transparent; color: var(--text-tertiary); cursor: pointer; font-size: 13px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; transition: color 150ms; }
.page-tab:hover .page-tab-label { color: var(--text-primary); }
.page-tab.active .page-tab-label { color: var(--text-primary); }
.page-tab-close { display: grid; place-items: center; width: 17px; height: 17px; margin-right: 4px; padding: 0; border: 0; border-radius: 5px; background: transparent; color: var(--text-tertiary); cursor: pointer; font-size: 13px; line-height: 1; transition: background 150ms, color 150ms; }
.page-tab-close { display: grid; place-items: center; width: 17px; height: 17px; margin-right: 4px; padding: 0; border: 0; border-radius: 5px; background: transparent; color: var(--text-tertiary); cursor: pointer; font-size: 14px; line-height: 1; transition: background 150ms, color 150ms; }
.page-tab-close:hover { background: var(--danger-bg); color: var(--danger); }
.page-tab.active .page-tab-close { color: var(--text-secondary); }
.page-tab.active .page-tab-close:hover { color: var(--danger); }
@@ -260,67 +251,66 @@ input:focus-visible, select:focus-visible, textarea:focus-visible { outline: non
.clock-trigger { display: flex; align-items: center; gap: 8px; padding: 5px 10px; border: 1px solid transparent; border-radius: 11px; background: transparent; color: var(--text-tertiary); cursor: pointer; white-space: nowrap; transition: background 150ms, border-color 150ms; }
.clock-trigger:hover, .clock-trigger.is-open { background: var(--surface-hover); border-color: var(--border-light); }
.clock-trigger > svg:first-child { flex: 0 0 auto; }
.clock-zone { color: var(--text-secondary); font-size: 11.5px; letter-spacing: .01em; }
.clock-zone { color: var(--text-secondary); font-size: 13px; letter-spacing: .01em; }
.clock-caret { flex: 0 0 auto; color: var(--text-tertiary); transition: transform 180ms; }
.clock-trigger.is-open .clock-caret { transform: rotate(180deg); }
.clock-readout { display: grid; justify-items: end; gap: 2px; }
.clock-time { color: var(--text-primary); font: 600 20px/1 var(--font-mono); font-variant-numeric: tabular-nums; letter-spacing: .01em; }
.clock-date { color: var(--text-tertiary); font-size: 10px; font-style: normal; line-height: 1; }
.clock-date { color: var(--text-tertiary); font-size: 12px; font-style: normal; line-height: 1; }
.clock-pop { position: absolute; top: calc(100% + 9px); right: 0; z-index: 60; width: 318px; padding: 6px; 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); }
.clock-pop-head { padding: 8px 10px 7px; color: var(--text-tertiary); font-size: 10px; letter-spacing: .1em; }
.clock-pop-head { padding: 8px 10px 7px; color: var(--text-tertiary); font-size: 12px; letter-spacing: .1em; }
.clock-pop-list { max-height: 344px; overflow-y: auto; }
.clock-zone-item { display: flex; align-items: center; justify-content: space-between; gap: 10px; width: 100%; padding: 7px 10px; border: 0; border-radius: 8px; background: transparent; color: var(--text-secondary); cursor: pointer; font-size: 12px; text-align: left; transition: background 120ms, color 120ms; }
.clock-zone-item { display: flex; align-items: center; justify-content: space-between; gap: 10px; width: 100%; padding: 7px 10px; border: 0; border-radius: 8px; background: transparent; color: var(--text-secondary); cursor: pointer; font-size: 13px; text-align: left; transition: background 120ms, color 120ms; }
.clock-zone-item:hover { background: var(--surface-hover); color: var(--text-primary); }
.clock-zone-item.active { background: var(--primary-bg); color: var(--text-primary); }
.clock-zone-name { white-space: nowrap; }
.clock-zone-right { display: flex; flex: 0 0 auto; align-items: baseline; gap: 7px; }
.clock-zone-right b { color: var(--text-primary); font: 500 12px var(--font-mono); font-variant-numeric: tabular-nums; }
.clock-zone-item.active .clock-zone-right b { color: var(--primary-light); }
.clock-zone-right em { color: var(--text-tertiary); font-size: 9.5px; font-style: normal; }
.quick-create-btn, .ai-entry-btn, .mobile-search-btn { display: inline-flex; align-items: center; gap: 7px; flex: 0 0 auto; min-height: 36px; padding: 8px 11px; border: 1px solid transparent; border-radius: 10px; background: transparent; color: var(--text-secondary); cursor: pointer; font-size: 12px; white-space: nowrap; transition: background 150ms, border-color 150ms, color 150ms; }
.clock-zone-right em { color: var(--text-tertiary); font-size: 12px; font-style: normal; }
.quick-create-btn, .ai-entry-btn, .mobile-search-btn { display: inline-flex; align-items: center; gap: 7px; flex: 0 0 auto; min-height: 36px; padding: 8px 11px; border: 1px solid transparent; border-radius: 10px; background: transparent; color: var(--text-secondary); cursor: pointer; font-size: 13px; white-space: nowrap; 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; }
.ai-entry-btn i { padding: 1px 4px; border-radius: 4px; background: var(--primary); color: var(--surface-deep); font: 12px 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-welcome p { margin: 7px 0 0; color: var(--text-secondary); font-size: 14px; }
.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: 12px 16px 10px; margin: 0; border-bottom: 1px solid var(--border-light); }
.calendar-toolbar { display: flex; align-items: center; gap: 7px; color: var(--text-secondary); }
.calendar-toolbar button { display: inline-grid; place-items: center; min-width: 28px; height: 28px; border: 1px solid transparent; border-radius: 8px; background: transparent; color: var(--text-secondary); cursor: pointer; transition: background var(--transition-fast), border-color var(--transition-fast), color var(--transition-fast); }
.calendar-toolbar button:hover { background: var(--surface-hover); border-color: var(--border-light); color: var(--text-primary); }
.calendar-toolbar .calendar-month-btn { display: inline-flex; align-items: center; justify-content: center; min-width: 88px; height: 28px; padding: 0 9px; color: var(--text-primary); font-size: 12.5px; font-variant-numeric: tabular-nums; }
.calendar-toolbar .calendar-month-btn { display: inline-flex; align-items: center; justify-content: center; min-width: 88px; height: 28px; padding: 0 9px; color: var(--text-primary); font-size: 13.5px; font-variant-numeric: tabular-nums; }
.calendar-toolbar .calendar-month-btn:hover { border-color: var(--primary-border); background: var(--primary-bg); color: var(--primary-light); }
.calendar-toolbar .calendar-month-btn.is-open { border-color: var(--primary); background: var(--primary-bg); color: var(--primary-light); }
.calendar-toolbar .calendar-today-btn { padding: 0 9px; border-color: var(--border-light); background: var(--surface-deep); font-size: 11px; }
.calendar-toolbar .calendar-today-btn { padding: 0 9px; border-color: var(--border-light); background: var(--surface-deep); font-size: 12.5px; }
/* 快速跳转日期:平时是低调的日历图标,悬浮/展开变蓝 */
.calendar-toolbar .calendar-jump-btn { border-color: var(--border-light); background: var(--surface-deep); }
.calendar-toolbar .calendar-jump-btn:hover, .calendar-toolbar .calendar-jump-btn.is-open { border-color: var(--primary); background: var(--primary); color: var(--surface-deep); box-shadow: 0 0 14px var(--accent-glow); }
.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: 5px 8px; color: var(--text-tertiary); font: 9.5px var(--font-mono); letter-spacing: .08em; }
.calendar-week-row > div { padding: 5px 8px; color: var(--text-tertiary); font: 12px var(--font-mono); letter-spacing: .08em; }
.calendar-day { min-height: 60px; padding: 4px 6px; 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: 17px; height: 17px; color: var(--text-secondary); font: 10px var(--font-mono); transition: background var(--transition-base), color var(--transition-base), box-shadow var(--transition-base), transform var(--transition-base); }
.calendar-day-number { display: inline-grid; place-items: center; min-width: 17px; height: 17px; color: var(--text-secondary); font: 12px var(--font-mono); transition: background var(--transition-base), color var(--transition-base), box-shadow var(--transition-base), transform var(--transition-base); }
.calendar-day.is-today .calendar-day-number { border-radius: 50%; box-shadow: inset 0 0 0 1.5px var(--primary); color: var(--primary-light); font-weight: 800; }
.calendar-events { display: grid; gap: 2px; margin-top: 2px; }
.calendar-event { display: block; overflow: hidden; padding: 1px 5px; border-left: 2px solid var(--primary); border-radius: 3px; background: var(--primary-bg); color: var(--text-secondary); font-size: 9.5px; line-height: 1.25; text-overflow: ellipsis; white-space: nowrap; }
.calendar-event { display: block; overflow: hidden; padding: 1px 5px; border-left: 2px solid var(--primary); border-radius: 3px; background: var(--primary-bg); color: var(--text-secondary); font-size: 12px; line-height: 1.25; 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); }
/* ---------- 工作日历交互:单击选中 → 再次单击添加日程;悬浮日程/节日看详情 ---------- */
.calendar-hint { margin: 5px 0 0; color: var(--text-tertiary); font-size: 10.5px; line-height: 1.4; }
.calendar-hint { margin: 5px 0 0; color: var(--text-tertiary); font-size: 12.5px; line-height: 1.4; }
.calendar-day { position: relative; cursor: pointer; transition: background var(--transition-fast), box-shadow var(--transition-fast); }
.calendar-day:hover { background: var(--surface-hover); }
.calendar-day.is-outside:hover { background: var(--surface-alt); opacity: .82; }
@@ -333,7 +323,7 @@ input:focus-visible, select:focus-visible, textarea:focus-visible { outline: non
.calendar-event { cursor: pointer; transition: filter var(--transition-fast), transform var(--transition-fast); }
.calendar-event:hover { filter: brightness(1.15); transform: translateX(1px); }
.calendar-event.done { border-color: var(--text-tertiary); background: var(--surface-deep); color: var(--text-tertiary); text-decoration: line-through; }
.calendar-more { justify-self: start; padding: 1px 5px; border-radius: 3px; background: var(--surface-deep); color: var(--text-tertiary); font: 9px var(--font-mono); cursor: pointer; }
.calendar-more { justify-self: start; padding: 1px 5px; border-radius: 3px; background: var(--surface-deep); color: var(--text-tertiary); font: 12px var(--font-mono); cursor: pointer; }
.calendar-more:hover { color: var(--text-secondary); }
/* ---------- 日历右侧:今日 to do(备忘录)+ 选中日待办 ---------- */
@@ -343,13 +333,13 @@ input:focus-visible, select:focus-visible, textarea:focus-visible { outline: non
.calendar-side-block { padding: 13px 15px 14px; border-bottom: 1px solid var(--border-light); }
.calendar-side-block:last-child { border-bottom: 0; }
.calendar-side-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; margin-bottom: 9px; }
.calendar-side-head h3 { display: flex; align-items: center; gap: 6px; margin: 0; color: var(--text-primary); font-size: 13px; font-weight: 700; }
.calendar-side-head h3 { display: flex; align-items: center; gap: 6px; margin: 0; color: var(--text-primary); font-size: 14px; font-weight: 700; }
.calendar-side-head h3 svg { color: var(--primary-light); }
.calendar-side-sub { display: block; margin-top: 3px; color: var(--text-tertiary); font: 10px var(--font-mono); letter-spacing: .03em; }
.calendar-side-count { flex: 0 0 auto; padding: 2px 8px; border-radius: var(--radius-pill); background: var(--primary-bg); color: var(--primary-light); font: 10px var(--font-mono); }
.calendar-side-add { display: inline-flex; align-items: center; gap: 4px; flex: 0 0 auto; height: 25px; padding: 0 8px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: transparent; color: var(--text-secondary); font-size: 11px; cursor: pointer; transition: border-color var(--transition-fast), color var(--transition-fast); }
.calendar-side-sub { display: block; margin-top: 3px; color: var(--text-tertiary); font: 12px var(--font-mono); letter-spacing: .03em; }
.calendar-side-count { flex: 0 0 auto; padding: 2px 8px; border-radius: var(--radius-pill); background: var(--primary-bg); color: var(--primary-light); font: 12px var(--font-mono); }
.calendar-side-add { display: inline-flex; align-items: center; gap: 4px; flex: 0 0 auto; height: 25px; padding: 0 8px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: transparent; color: var(--text-secondary); font-size: 12.5px; cursor: pointer; transition: border-color var(--transition-fast), color var(--transition-fast); }
.calendar-side-add:hover { border-color: var(--primary); color: var(--primary-light); }
.calendar-side-empty { margin: 0; padding: 14px 0 8px; color: var(--text-tertiary); font-size: 11.5px; text-align: center; }
.calendar-side-empty { margin: 0; padding: 14px 0 8px; color: var(--text-tertiary); font-size: 13px; text-align: center; }
/* 今日 to do */
/* 两块按列高平分(to do 略高),列表自己滚,不再缩在角落留一片空地 */
@@ -363,7 +353,7 @@ input:focus-visible, select:focus-visible, textarea:focus-visible { outline: non
.memo-check:hover { border-color: var(--primary); }
.memo-check:active { transform: scale(.86); }
.memo-item.is-done .memo-check { border-color: var(--primary); background: var(--primary); color: var(--surface-deep); }
.memo-text { flex: 1; min-width: 0; color: var(--text-primary); font-size: 12.5px; line-height: 1.45; word-break: break-word; transition: color var(--transition-base), opacity var(--transition-base); }
.memo-text { flex: 1; min-width: 0; color: var(--text-primary); font-size: 13.5px; line-height: 1.45; word-break: break-word; transition: color var(--transition-base), opacity var(--transition-base); }
.memo-item.is-done .memo-text { color: var(--text-tertiary); opacity: .6; text-decoration: line-through; }
.memo-remove { flex: 0 0 auto; display: grid; place-items: center; width: 19px; height: 19px; padding: 0; border: 0; border-radius: var(--radius-sm); background: transparent; color: var(--text-disabled); cursor: pointer; opacity: 0; transition: opacity var(--transition-fast), color var(--transition-fast); }
.memo-item:hover .memo-remove, .memo-remove:focus-visible { opacity: 1; }
@@ -373,7 +363,7 @@ input:focus-visible, select:focus-visible, textarea:focus-visible { outline: non
.memo-add[data-saving="true"] { opacity: .6; }
.memo-add-icon { display: grid; place-items: center; flex: 0 0 auto; color: var(--text-tertiary); transition: color var(--transition-fast); }
.memo-add:focus-within .memo-add-icon { color: var(--primary-light); }
.memo-add input { flex: 1; min-width: 0; border: 0; background: transparent; color: var(--text-primary); font: 12.5px var(--font-body); outline: none; }
.memo-add input { flex: 1; min-width: 0; border: 0; background: transparent; color: var(--text-primary); font: 13.5px var(--font-body); outline: none; }
.memo-add input::placeholder { color: var(--text-disabled); }
/* 选中日待办 */
@@ -386,31 +376,31 @@ input:focus-visible, select:focus-visible, textarea:focus-visible { outline: non
.day-dot.holiday { background: var(--success); }
.day-dot.done { background: var(--text-tertiary); }
.day-body { display: grid; gap: 3px; min-width: 0; }
.day-title { color: var(--text-primary); font-size: 12.5px; font-weight: 600; line-height: 1.4; word-break: break-word; }
.day-meta { color: var(--text-tertiary); font-size: 10.5px; line-height: 1.4; }
.day-note { color: var(--text-secondary); font-size: 11px; line-height: 1.5; }
.day-title { color: var(--text-primary); font-size: 13.5px; font-weight: 600; line-height: 1.4; word-break: break-word; }
.day-meta { color: var(--text-tertiary); font-size: 12.5px; line-height: 1.4; }
.day-note { color: var(--text-secondary); font-size: 12.5px; line-height: 1.5; }
.calendar-popover { position: fixed; z-index: 70; border: 1px solid var(--border); border-radius: 14px; background: var(--surface-raised); box-shadow: 0 20px 52px rgba(0, 0, 0, .26), inset 0 1px 0 var(--panel-highlight); }
.calendar-composer { width: min(306px, calc(100vw - 24px)); padding: 14px; }
.calendar-composer-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; margin-bottom: 11px; }
.calendar-composer-head strong { display: block; color: var(--text-primary); font-size: 13px; }
.calendar-composer-head small { display: block; margin-top: 3px; color: var(--text-tertiary); font: 10px var(--font-mono); letter-spacing: .06em; }
.calendar-composer-head strong { display: block; color: var(--text-primary); font-size: 14px; }
.calendar-composer-head small { display: block; margin-top: 3px; color: var(--text-tertiary); font: 12px var(--font-mono); letter-spacing: .06em; }
.calendar-popover-close { display: grid; place-items: center; width: 24px; height: 24px; flex: 0 0 24px; border: 1px solid var(--border-light); border-radius: var(--radius-sm); background: transparent; color: var(--text-tertiary); cursor: pointer; }
.calendar-popover-close:hover { border-color: var(--border); color: var(--text-primary); }
.calendar-field { display: block; margin-bottom: 10px; }
.calendar-field > span { display: block; margin-bottom: 5px; color: var(--text-tertiary); font: 10px var(--font-mono); letter-spacing: .08em; }
.calendar-field input, .calendar-field textarea { width: 100%; padding: 8px 10px; border: 0; border-radius: 9px; background: var(--surface-deep); color: var(--text-primary); font: 12px var(--font-body); }
.calendar-field > span { display: block; margin-bottom: 5px; color: var(--text-tertiary); font: 12px var(--font-mono); letter-spacing: .08em; }
.calendar-field input, .calendar-field textarea { width: 100%; padding: 8px 10px; border: 0; border-radius: 9px; background: var(--surface-deep); color: var(--text-primary); font: 13px var(--font-body); }
.calendar-field textarea { min-height: 56px; resize: vertical; }
.calendar-field input::placeholder, .calendar-field textarea::placeholder { color: var(--text-disabled); }
.calendar-field input:focus, .calendar-field textarea:focus { outline: none; border: 0; background: var(--primary-bg); box-shadow: none; }
.calendar-priority-row { display: flex; gap: 6px; }
.calendar-priority-row button { flex: 1; height: 30px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface-deep); color: var(--text-secondary); font-size: 11px; cursor: pointer; transition: border-color var(--transition-fast), background var(--transition-fast), color var(--transition-fast); }
.calendar-priority-row button { flex: 1; height: 30px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface-deep); color: var(--text-secondary); font-size: 12.5px; cursor: pointer; transition: border-color var(--transition-fast), background var(--transition-fast), color var(--transition-fast); }
.calendar-priority-row button:hover { border-color: var(--border-strong); color: var(--text-primary); }
.calendar-priority-row button.is-active { border-color: var(--primary); background: var(--primary-bg); color: var(--primary-light); font-weight: 700; }
.calendar-composer-error { margin: 0 0 8px; color: var(--danger); font-size: 11px; }
.calendar-composer-error { margin: 0 0 8px; color: var(--danger); font-size: 12.5px; }
.calendar-composer-actions { display: flex; gap: 8px; margin-top: 12px; }
.calendar-composer-actions button { flex: 1; height: 32px; border-radius: var(--radius-sm); font-size: 12px; cursor: pointer; }
.calendar-composer-actions button { flex: 1; height: 32px; border-radius: var(--radius-sm); font-size: 13px; cursor: pointer; }
.calendar-composer-actions .is-ghost { border: 1px solid var(--border); background: transparent; color: var(--text-secondary); }
.calendar-composer-actions .is-ghost:hover { border-color: var(--border-strong); color: var(--text-primary); }
.calendar-composer-actions .is-primary { border: 1px solid var(--primary); background: var(--primary); color: var(--surface-deep); font-weight: 700; }
@@ -418,18 +408,18 @@ input:focus-visible, select:focus-visible, textarea:focus-visible { outline: non
.calendar-tip { width: min(268px, calc(100vw - 24px)); padding: 12px; pointer-events: none; }
.calendar-tip-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 8px; }
.calendar-tip-kind { padding: 2px 8px; border-radius: 999px; font: 9px var(--font-mono); letter-spacing: .1em; }
.calendar-tip-kind { padding: 2px 8px; border-radius: 999px; font: 12px var(--font-mono); letter-spacing: .1em; }
.calendar-tip-kind.is-todo { background: var(--primary-bg); color: var(--primary-light); }
.calendar-tip-kind.is-holiday { background: var(--success-bg); color: var(--success); }
.calendar-tip-date { color: var(--text-tertiary); font: 10px var(--font-mono); }
.calendar-tip-title { color: var(--text-primary); font-size: 13px; font-weight: 700; line-height: 1.38; }
.calendar-tip-date { color: var(--text-tertiary); font: 12px var(--font-mono); }
.calendar-tip-title { color: var(--text-primary); font-size: 14px; font-weight: 700; line-height: 1.38; }
.calendar-tip-body { display: grid; gap: 5px; margin: 9px 0 0; }
.calendar-tip-row { display: flex; gap: 10px; font-size: 11px; }
.calendar-tip-row { display: flex; gap: 10px; font-size: 12.5px; }
.calendar-tip-row dt { flex: 0 0 44px; color: var(--text-tertiary); }
.calendar-tip-row dd { margin: 0; color: var(--text-secondary); }
.calendar-tip-note { margin: 9px 0 0; padding-top: 9px; border-top: 1px solid var(--border-light); color: var(--text-secondary); font-size: 11px; line-height: 1.55; }
.calendar-tip-note { margin: 9px 0 0; padding-top: 9px; border-top: 1px solid var(--border-light); color: var(--text-secondary); font-size: 12.5px; line-height: 1.55; }
.calendar-tip-more { display: grid; gap: 4px; margin-top: 8px; padding-top: 8px; border-top: 1px solid var(--border-light); }
.calendar-tip-more span { color: var(--text-secondary); font-size: 11px; line-height: 1.4; }
.calendar-tip-more span { color: var(--text-secondary); font-size: 12.5px; line-height: 1.4; }
.calendar-side-block.is-day .calendar-side-empty { display: grid; place-items: center; flex: 1 1 auto; padding: 10px 0; }
/* ---------- 快速跳转日期浮层(工具条上的小日历按钮) ---------- */
@@ -437,19 +427,19 @@ input:focus-visible, select:focus-visible, textarea:focus-visible { outline: non
.jump-head { display: flex; align-items: center; justify-content: space-between; gap: 6px; margin-bottom: 7px; }
.jump-head button { display: grid; place-items: center; width: 24px; height: 24px; border: 1px solid transparent; border-radius: 7px; background: transparent; color: var(--text-secondary); cursor: pointer; }
.jump-head button:hover { border-color: var(--border-light); background: var(--surface-hover); color: var(--text-primary); }
.jump-head strong { flex: 1; color: var(--text-primary); text-align: center; font-size: 12px; }
.jump-head strong { flex: 1; color: var(--text-primary); text-align: center; font-size: 13px; }
.jump-week, .jump-grid { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); }
.jump-week span { padding: 3px 0; color: var(--text-tertiary); text-align: center; font: 9.5px var(--font-mono); }
.jump-week span { padding: 3px 0; color: var(--text-tertiary); text-align: center; font: 12px var(--font-mono); }
.jump-grid { gap: 1px; }
.jump-day { display: grid; place-items: center; height: 27px; border: 1px solid transparent; border-radius: 7px; background: transparent; color: var(--text-secondary); font: 11px var(--font-mono); cursor: pointer; transition: background var(--transition-fast), border-color var(--transition-fast), color var(--transition-fast); }
.jump-day { display: grid; place-items: center; height: 27px; border: 1px solid transparent; border-radius: 7px; background: transparent; color: var(--text-secondary); font: 12.5px var(--font-mono); cursor: pointer; transition: background var(--transition-fast), border-color var(--transition-fast), color var(--transition-fast); }
.jump-day:hover { border-color: var(--primary-border); background: var(--primary-bg); color: var(--primary-light); }
.jump-day.is-outside { color: var(--text-disabled); }
.jump-day.is-today { box-shadow: inset 0 0 0 1.5px var(--primary); color: var(--primary-light); font-weight: 800; }
.jump-day.is-selected { border-color: var(--primary); background: var(--primary); color: var(--surface-deep); font-weight: 800; }
.jump-today { width: 100%; height: 27px; margin-top: 8px; border: 1px solid var(--border-light); border-radius: 7px; background: var(--surface-deep); color: var(--text-secondary); font-size: 11px; cursor: pointer; }
.jump-today { width: 100%; height: 27px; margin-top: 8px; border: 1px solid var(--border-light); border-radius: 7px; background: var(--surface-deep); color: var(--text-secondary); font-size: 12.5px; cursor: pointer; }
.jump-today:hover { border-color: var(--primary); color: var(--primary-light); }
/* 跳转浮层标题 = 直接输入年月(前 4 位年 + 后 1~2 位月,回车跳转);无描边,只靠底色 */
.jump-head .jump-month-input { display: block; flex: 1; width: auto; min-width: 0; min-height: 0; height: 26px; padding: 0 8px; border: 0; border-radius: 8px; background: var(--surface-deep); box-shadow: none; color: var(--text-primary); font: 12px var(--font-body); font-variant-numeric: tabular-nums; text-align: center; outline: none; cursor: text; transition: background var(--transition-fast), color var(--transition-fast); }
.jump-head .jump-month-input { display: block; flex: 1; width: auto; min-width: 0; min-height: 0; height: 26px; padding: 0 8px; border: 0; border-radius: 8px; background: var(--surface-deep); box-shadow: none; color: var(--text-primary); font: 13px var(--font-body); font-variant-numeric: tabular-nums; text-align: center; outline: none; cursor: text; transition: background var(--transition-fast), color var(--transition-fast); }
.jump-head .jump-month-input:hover { background: var(--primary-bg); color: var(--primary-light); }
.jump-head .jump-month-input:focus { background: var(--primary-bg); color: var(--primary-light); font-weight: 700; letter-spacing: .3px; }
.jump-head .jump-month-input.is-invalid { background: var(--danger-bg); color: var(--danger); }
@@ -467,8 +457,8 @@ input:focus-visible, select:focus-visible, textarea:focus-visible { outline: non
.ymw-cell:hover { color: var(--text-secondary); }
.ymw-cell.is-active { color: var(--primary-light); font-weight: 800; }
.ymw-pad { height: var(--wheel-item); }
.ymw-unit { color: var(--text-tertiary); font-size: 12px; }
.ymw-today { width: 100%; height: 27px; border: 1px solid var(--border-light); border-radius: 7px; background: var(--surface-deep); color: var(--text-secondary); font-size: 11px; cursor: pointer; transition: border-color var(--transition-fast), color var(--transition-fast); }
.ymw-unit { color: var(--text-tertiary); font-size: 13px; }
.ymw-today { width: 100%; height: 27px; border: 1px solid var(--border-light); border-radius: 7px; background: var(--surface-deep); color: var(--text-secondary); font-size: 12.5px; cursor: pointer; transition: border-color var(--transition-fast), color var(--transition-fast); }
.ymw-today:hover { border-color: var(--primary); color: var(--primary-light); }
.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; }
@@ -478,13 +468,13 @@ input:focus-visible, select:focus-visible, textarea:focus-visible { outline: non
.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); }
.digest-tags span, .panel-period { padding: 4px 8px; border-radius: 99px; background: var(--surface-raised); color: var(--text-tertiary); font: 12px 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 { display: inline-flex; align-items: center; gap: 6px; color: var(--success); font: 12px 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; }
.panel-link { border: 0; background: transparent; color: var(--primary-light); cursor: pointer; font-size: 13px; }
.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-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: 13px; }
.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; }
@@ -493,33 +483,36 @@ input:focus-visible, select:focus-visible, textarea:focus-visible { outline: non
.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-ring span { color: var(--text-tertiary); font-size: 12.5px; }
.completion-legend { display: flex; justify-content: center; gap: 16px; color: var(--text-tertiary); font-size: 12.5px; }
.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); }
.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: 13px; }.quick-panel > button:hover { color: var(--primary-light); }
/* AI assistant —— 右上角浮动小窗。没有遮罩、不 inert 主界面,可以边聊边自己点系统 */
.ai-window { position: fixed; top: calc(var(--header-height) + 14px); right: 16px; z-index: 78; display: flex; flex-direction: column; width: min(348px, calc(100vw - 24px)); max-height: min(560px, calc(100vh - var(--header-height) - 28px)); overflow: hidden; border: 1px solid var(--border); border-radius: 16px; background: var(--surface); box-shadow: 0 22px 60px rgba(0, 0, 0, .34), inset 0 1px 0 var(--panel-highlight); animation: ai-window-in 200ms var(--ease-out); }
.ai-window { position: fixed; top: calc(var(--header-height) + 14px); right: 16px; z-index: 78; display: flex; flex-direction: column; width: min(440px, calc(100vw - 24px)); max-height: min(720px, calc(100vh - var(--header-height) - 28px)); overflow: hidden; border: 1px solid var(--border); border-radius: 16px; background: var(--surface); box-shadow: 0 22px 60px rgba(0, 0, 0, .34), inset 0 1px 0 var(--panel-highlight); animation: ai-window-in 200ms var(--ease-out); }
@keyframes ai-window-in { from { opacity: 0; transform: translateY(-10px) scale(.98); } to { opacity: 1; transform: none; } }
.ai-window.is-minimized { width: min(258px, calc(100vw - 24px)); }
.ai-window-bar { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 9px 9px 9px 12px; border-bottom: 1px solid var(--border-light); background: var(--surface-deep); cursor: grab; touch-action: none; user-select: none; }
.ai-window-bar:active { cursor: grabbing; }
.ai-window.is-minimized .ai-window-bar { border-bottom: 0; }
.ai-window-actions { display: flex; align-items: center; gap: 5px; flex: 0 0 auto; }
.ai-icon-btn { display: grid; place-items: center; width: 25px; height: 25px; padding: 0; border: 1px solid transparent; border-radius: 7px; background: transparent; color: var(--text-tertiary); cursor: pointer; transition: background var(--transition-fast), border-color var(--transition-fast), color var(--transition-fast); }
/* 之前默认色用 --text-tertiary 在深色主题下基本看不见(Wayne 2026-09-16 吐槽「关闭按钮看不到」)。
升到 --text-secondary 让最小化 / 关闭两个按钮一眼能找到;
关闭按钮单独加危险色 hover(红字 + 红底 + 红边)让"关闭窗口"动作有反馈。 */
.ai-icon-btn { display: grid; place-items: center; width: 25px; height: 25px; padding: 0; border: 1px solid transparent; border-radius: 7px; background: transparent; color: var(--text-secondary); cursor: pointer; transition: background var(--transition-fast), border-color var(--transition-fast), color var(--transition-fast); }
.ai-icon-btn:hover { border-color: var(--border-light); background: var(--surface-hover); color: var(--text-primary); }
.ai-icon-btn.ai-close-btn:hover { color: #ff5d6c; border-color: rgba(255, 93, 108, .4); background: rgba(255, 93, 108, .12); }
.ai-panel-title { display: flex; align-items: center; gap: 9px; min-width: 0; }
.ai-panel-title h2 { margin: 0; font-size: 15px; }
.ai-panel-title .section-label { margin-bottom: 1px; font-size: 8.5px; }
.ai-orb { display: grid; place-items: center; width: 30px; height: 30px; flex: 0 0 30px; border-radius: 10px; color: var(--primary-light); background: var(--primary-bg); box-shadow: 0 0 16px var(--accent-glow); }
.ai-access-banner { display: flex; align-items: center; gap: 9px; margin: 0 0 10px; padding: 8px 10px; border: 1px solid var(--primary-border); border-radius: 10px; background: var(--primary-bg); }.ai-access-banner strong, .ai-access-banner small { display: block; }.ai-access-banner strong { color: var(--text-primary); font-size: 11.5px; }.ai-access-banner small { margin-top: 2px; color: var(--text-secondary); font-size: 9.5px; }.ai-live-dot { width: 7px; height: 7px; flex: 0 0 7px; border-radius: 50%; background: var(--success); box-shadow: 0 0 8px var(--success); }
.ai-panel-body { flex: 1 1 auto; min-height: 0; overflow-y: auto; padding: 12px 13px; }.ai-capability-card { padding: 10px 11px; border-radius: 11px; 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: 7px; color: var(--primary-light); font-size: 11.5px; font-weight: 700; }.ai-capability-row { display: flex; gap: 7px; padding: 3px 0; color: var(--text-secondary); font-size: 10.5px; }.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: 11px; margin-top: 12px; }.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: 8px 10px; border-radius: 4px 12px 12px 12px; background: var(--surface-deep); }.ai-message.user .ai-message-content { border-radius: 12px 4px 12px 12px; background: var(--primary-bg); }.ai-message-content p { margin: 0; color: var(--text-secondary); font-size: 11.5px; line-height: 1.65; }.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: 32px; }.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: 7px 13px; overflow-x: auto; }.ai-suggestion-row button { flex: 0 0 auto; padding: 5px 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-access-banner { display: flex; align-items: center; gap: 9px; margin: 0 0 10px; padding: 8px 10px; border: 1px solid var(--primary-border); border-radius: 10px; background: var(--primary-bg); }.ai-access-banner strong, .ai-access-banner small { display: block; }.ai-access-banner strong { color: var(--text-primary); font-size: 13px; }.ai-access-banner small { margin-top: 2px; color: var(--text-secondary); font-size: 12px; }.ai-live-dot { width: 7px; height: 7px; flex: 0 0 7px; border-radius: 50%; background: var(--success); box-shadow: 0 0 8px var(--success); }
.ai-panel-body { flex: 1 1 auto; min-height: 0; overflow-y: auto; padding: 12px 13px; }.ai-capability-card { padding: 10px 11px; border-radius: 11px; 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: 7px; color: var(--primary-light); font-size: 13px; font-weight: 700; }.ai-capability-row { display: flex; gap: 7px; padding: 3px 0; color: var(--text-secondary); font-size: 12.5px; }.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: 12px; }.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: 11px; margin-top: 12px; }.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: 12px; font-weight: 800; }.ai-message.user .ai-message-avatar { color: var(--surface-deep); background: var(--primary); }.ai-message-content { max-width: 82%; padding: 8px 10px; border-radius: 4px 12px 12px 12px; background: var(--surface-deep); }.ai-message.user .ai-message-content { border-radius: 12px 4px 12px 12px; background: var(--primary-bg); }.ai-message-content p { margin: 0; color: var(--text-secondary); font-size: 13px; line-height: 1.65; }.ai-message-content small { display: block; margin-top: 5px; color: var(--text-tertiary); font: 12px var(--font-mono); }.ai-thinking { display: flex; gap: 4px; align-items: center; min-height: 32px; }.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: 7px 13px; overflow-x: auto; }.ai-suggestion-row button { flex: 0 0 auto; padding: 5px 9px; border: 1px solid var(--border-light); border-radius: 99px; background: var(--surface-deep); color: var(--text-secondary); cursor: pointer; font-size: 12px; }.ai-suggestion-row button:hover { color: var(--primary-light); border-color: var(--primary-border); }
.ai-input-row { display: flex; gap: 8px; padding: 9px 12px; border-top: 1px solid var(--border-light); }.ai-input-row input { min-height: 36px; padding: 0 12px; border: 0; border-radius: 12px; background: var(--surface-deep); }
.ai-input-row input:focus { background: var(--primary-bg); }.ai-input-row button { display: grid; place-items: center; width: 36px; min-width: 36px; border: 0; border-radius: 10px; background: var(--primary); color: var(--surface-deep); cursor: pointer; }.ai-input-row button:disabled { cursor: not-allowed; opacity: .4; }.ai-panel-footer { padding: 6px 13px 10px; color: var(--text-tertiary); font-size: 9.5px; text-align: center; }
.ai-input-row input:focus { background: var(--primary-bg); }.ai-input-row button { display: grid; place-items: center; width: 36px; min-width: 36px; border: 0; border-radius: 10px; background: var(--primary); color: var(--surface-deep); cursor: pointer; }.ai-input-row button:disabled { cursor: not-allowed; opacity: .4; }.ai-panel-footer { padding: 6px 13px 10px; color: var(--text-tertiary); font-size: 12px; text-align: center; }
/* ---------- 汇率计算器(顶栏) ---------- */
.fx-calc { position: relative; flex: 0 0 auto; }
.header-tool-btn { display: grid; place-items: center; width: 36px; height: 36px; padding: 0; border: 1px solid transparent; border-radius: 10px; background: transparent; color: var(--text-secondary); cursor: pointer; transition: background 150ms, border-color 150ms, color 150ms; }
@@ -527,7 +520,7 @@ input:focus-visible, select:focus-visible, textarea:focus-visible { outline: non
.header-tool-btn.is-open { color: var(--primary-light); background: var(--primary-bg); border-color: var(--primary-border); }
.fx-panel { position: absolute; top: calc(100% + 9px); right: 0; z-index: 60; width: min(396px, calc(100vw - 24px)); padding: 14px; border: 1px solid var(--border); border-radius: 16px; background: var(--surface); box-shadow: 0 20px 52px rgba(0, 0, 0, .25), inset 0 1px 0 var(--panel-highlight); }
.fx-panel-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; }
.fx-panel-title { display: inline-flex; align-items: center; gap: 7px; color: var(--text-primary); font-size: 12.5px; font-weight: 600; }
.fx-panel-title { display: inline-flex; align-items: center; gap: 7px; color: var(--text-primary); font-size: 13.5px; font-weight: 600; }
.fx-refresh { display: grid; place-items: center; width: 26px; height: 26px; padding: 0; border: 1px solid var(--border-light); border-radius: 8px; background: transparent; color: var(--text-tertiary); cursor: pointer; transition: color 150ms, border-color 150ms; }
.fx-refresh:hover { color: var(--primary-light); border-color: var(--primary-border); }
.fx-refresh:disabled { opacity: .45; cursor: default; }
@@ -540,27 +533,27 @@ input:focus-visible, select:focus-visible, textarea:focus-visible { outline: non
.fx-picker { position: relative; }
.fx-picker-btn { display: flex; align-items: center; gap: 6px; width: 100%; height: 34px; padding: 0 9px; border: 1px solid var(--border-light); border-radius: 10px; background: var(--surface-deep); color: var(--text-secondary); cursor: pointer; transition: border-color 150ms, color 150ms; }
.fx-picker-btn:hover, .fx-picker-btn.is-open { border-color: var(--primary-border); color: var(--text-primary); }
.fx-picker-btn b { flex: 0 0 auto; color: var(--text-primary); font-size: 12.5px; font-weight: 600; letter-spacing: .02em; }
.fx-picker-btn span { flex: 1 1 auto; overflow: hidden; color: var(--text-tertiary); font-size: 11px; text-align: left; text-overflow: ellipsis; white-space: nowrap; }
.fx-picker-btn b { flex: 0 0 auto; color: var(--text-primary); font-size: 13.5px; font-weight: 600; letter-spacing: .02em; }
.fx-picker-btn span { flex: 1 1 auto; overflow: hidden; color: var(--text-tertiary); font-size: 12.5px; text-align: left; text-overflow: ellipsis; white-space: nowrap; }
.fx-picker-btn svg { flex: 0 0 auto; color: var(--text-tertiary); }
.fx-picker-pop { position: absolute; top: calc(100% + 5px); z-index: 5; width: 232px; padding: 6px; border: 1px solid var(--border); border-radius: 12px; background: var(--surface); box-shadow: 0 20px 52px rgba(0, 0, 0, .25), inset 0 1px 0 var(--panel-highlight); }
.fx-picker.left .fx-picker-pop { left: 0; }
.fx-picker.right .fx-picker-pop { right: 0; }
.fx-picker-search { width: 100%; height: 30px; margin-bottom: 5px; padding: 0 9px; border: 1px solid var(--border-light); border-radius: 8px; background: var(--surface-deep); color: var(--text-primary); font-size: 11.5px; }
.fx-picker-search { width: 100%; height: 30px; margin-bottom: 5px; padding: 0 9px; border: 1px solid var(--border-light); border-radius: 8px; background: var(--surface-deep); color: var(--text-primary); font-size: 13px; }
.fx-picker-search:focus { outline: none; border-color: var(--primary-border); }
.fx-picker-list { max-height: 220px; overflow-y: auto; }
.fx-picker-item { display: flex; align-items: baseline; gap: 7px; width: 100%; padding: 6px 9px; border: 0; border-radius: 7px; background: transparent; color: var(--text-secondary); cursor: pointer; text-align: left; }
.fx-picker-item:hover { background: var(--surface-hover); color: var(--text-primary); }
.fx-picker-item.active { background: var(--primary-bg); }
.fx-picker-item b { flex: 0 0 auto; color: var(--text-primary); font-size: 12px; font-weight: 600; }
.fx-picker-item b { flex: 0 0 auto; color: var(--text-primary); font-size: 13px; font-weight: 600; }
.fx-picker-item.active b { color: var(--primary-light); }
.fx-picker-item span { overflow: hidden; font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
.fx-picker-empty { padding: 12px 9px; color: var(--text-tertiary); font-size: 11px; text-align: center; }
.fx-rate-line { margin-top: 12px; padding-top: 11px; border-top: 1px solid var(--border-light); color: var(--text-secondary); font-size: 12px; }
.fx-rate-line b { color: var(--primary-light); font-family: var(--font-mono); font-size: 13px; }
.fx-rate-line em { margin-left: 7px; color: var(--text-tertiary); font-size: 10.5px; font-style: normal; }
.fx-picker-item span { overflow: hidden; font-size: 12.5px; text-overflow: ellipsis; white-space: nowrap; }
.fx-picker-empty { padding: 12px 9px; color: var(--text-tertiary); font-size: 12.5px; text-align: center; }
.fx-rate-line { margin-top: 12px; padding-top: 11px; border-top: 1px solid var(--border-light); color: var(--text-secondary); font-size: 13px; }
.fx-rate-line b { color: var(--primary-light); font-family: var(--font-mono); font-size: 14px; }
.fx-rate-line em { margin-left: 7px; color: var(--text-tertiary); font-size: 12.5px; font-style: normal; }
.fx-error, .fx-warn { color: var(--warning); }
.fx-panel-foot { margin-top: 7px; color: var(--text-tertiary); font-size: 10px; }
.fx-panel-foot { margin-top: 7px; color: var(--text-tertiary); font-size: 12px; }
/* 时钟与汇率入口加进来后顶栏变宽,中等屏先收紧留白与品牌区,避免把右侧按钮挤出去 */
@media (max-width: 1440px) {
.global-header { gap: 16px; padding: 0 clamp(14px, 2.2vw, 34px); }
@@ -588,7 +581,7 @@ input:focus-visible, select:focus-visible, textarea:focus-visible { outline: non
.calendar-toolbar { justify-content: space-between; }
}
@media (max-width: 980px) { .global-header-brand { min-width: auto; }.global-header-brand div { display: none; }.global-search { width: min(360px, 40vw); }.clock-zone, .clock-caret { 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) { :root { --header-height: 62px; }.global-header { gap: 8px; 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; }.fx-panel { position: fixed; top: calc(var(--header-height) + 8px); left: 12px; right: 12px; width: auto; max-height: calc(100vh - var(--header-height) - 24px); overflow-y: auto; }.route-content { min-height: calc(100vh - var(--header-height)); padding: 22px 12px 42px; }.dashboard-welcome { align-items: flex-start; flex-direction: column; }.calendar-day { min-height: 48px; padding: 3px 4px; }.calendar-week-row > div { padding: 4px 4px; }.calendar-event { font-size: 8.5px; }.calendar-day-number { font-size: 9.5px; }.calendar-side-block { padding: 12px; }.memo-list, .day-list { max-height: 180px; }.dashboard-secondary-column { display: block; }.dashboard-secondary-column .dashboard-panel { margin-bottom: 18px; }.ai-window { top: calc(var(--header-height) + 8px); right: 8px; width: calc(100vw - 16px); max-height: min(64vh, 460px); } }
@media (max-width: 680px) { :root { --header-height: 62px; }.global-header { gap: 8px; 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; }.fx-panel { position: fixed; top: calc(var(--header-height) + 8px); left: 12px; right: 12px; width: auto; max-height: calc(100vh - var(--header-height) - 24px); overflow-y: auto; }.route-content { min-height: calc(100vh - var(--header-height)); padding: 22px 12px 42px; }.dashboard-welcome { align-items: flex-start; flex-direction: column; }.calendar-day { min-height: 48px; padding: 3px 4px; }.calendar-week-row > div { padding: 4px 4px; }.calendar-event { font-size: 12.5px; }.calendar-day-number { font-size: 12px; }.calendar-side-block { padding: 12px; }.memo-list, .day-list { max-height: 180px; }.dashboard-secondary-column { display: block; }.dashboard-secondary-column .dashboard-panel { margin-bottom: 18px; }.ai-window { top: calc(var(--header-height) + 8px); right: 8px; width: calc(100vw - 16px); max-height: min(72vh, 540px); } }
/* Requested compact navigation controls. */
.collapse-btn { display: grid; place-items: center; width: 32px; height: 32px; padding: 0; border: 1px solid var(--sb-border); border-radius: 8px; background: transparent; color: var(--sb-text); box-shadow: none; }
@@ -604,18 +597,18 @@ input:focus-visible, select:focus-visible, textarea:focus-visible { outline: non
.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 { align-self: start; padding: 3px 5px; border-radius: 5px; background: var(--surface-deep); color: var(--text-tertiary); font-size: 12px; 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; }
.global-search-results strong { font-size: 13px; }
.global-search-results small { margin-top: 3px; color: var(--text-tertiary); font-size: 12px; }
.global-search-status { padding: 17px 12px; color: var(--text-tertiary); font-size: 13px; 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: 12px; }
.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 { padding: 5px 9px; border: 1px solid var(--border-light); border-radius: 99px; background: var(--surface); color: var(--text-secondary); cursor: pointer; font-size: 12.5px; }
.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; } }
@@ -626,3 +619,115 @@ input:focus-visible, select:focus-visible, textarea:focus-visible { outline: non
.global-header-brand .brand-mark { position: relative; display: inline-flex; align-items: center; justify-content: center; color: var(--text-primary); }
.global-header-brand .brand-mark svg { display: block; }
.global-header-brand .brand-mark .header-status-dot { position: absolute; right: -1px; bottom: -1px; width: 9px; height: 9px; border-radius: 50%; background: var(--success); box-shadow: 0 0 0 3px var(--bg-primary), 0 0 10px var(--success); }
/* ---- 可搜索下拉 SearchSelect / 内联搜索 SearchField ----
供应商 272 家、产品 300+ 个,原生 <select> 只能一格一格滚。
触发区做成搜索框:点开即打字过滤。样式沿用全局「无方框」规则——只靠底色区分。 */
.ss-root, .sf-root { position: relative; display: flex; align-items: center; gap: 8px; min-width: 0; height: 38px; padding: 0 10px; border-radius: var(--radius-md); background: var(--surface-deep); color: var(--text-primary); cursor: text; transition: background var(--transition-fast); }
.ss-root.ss-sm, .sf-root.sf-sm { height: 32px; padding: 0 8px; border-radius: 9px; }
.ss-root:hover, .ss-root.is-open, .sf-root:hover, .sf-root:focus-within { background: var(--primary-bg); }
.ss-root.is-disabled { opacity: .5; cursor: not-allowed; }
.ss-root .ss-icon, .sf-root .ss-icon { display: inline-flex; flex: 0 0 auto; color: var(--text-tertiary); }
.ss-root.is-open .ss-icon, .sf-root:focus-within .ss-icon { color: var(--primary-light); }
.ss-root input[type="text"], .sf-root input[type="text"] { flex: 1 1 auto; width: auto; min-width: 0; min-height: 0; height: auto; padding: 0; border: 0; background: transparent; box-shadow: none; color: inherit; font: 14px var(--font-body); outline: none; cursor: inherit; }
.ss-root.ss-sm input[type="text"], .sf-root.sf-sm input[type="text"] { font-size: 13px; }
.ss-root input[type="text"]:focus, .sf-root input[type="text"]:focus { background: transparent; box-shadow: none; }
.ss-root input::placeholder, .sf-root input::placeholder { color: var(--text-tertiary); }
.ss-root .ss-caret { display: inline-flex; flex: 0 0 auto; color: var(--text-tertiary); transition: transform var(--transition-fast); }
.ss-root .ss-caret.is-up { transform: rotate(180deg); }
.ss-root .ss-clear, .sf-root .ss-clear { display: inline-grid; place-items: center; flex: 0 0 auto; width: 18px; height: 18px; padding: 0; border: 0; border-radius: 50%; background: transparent; color: var(--text-tertiary); cursor: pointer; }
.ss-root .ss-clear:hover, .sf-root .ss-clear:hover { background: var(--surface-hover); color: var(--text-primary); }
/* 菜单用 fixed + portal:抽屉的 .drawer-body 是 overflow:auto,就地绝对定位会被裁掉 */
.ss-menu { position: fixed; z-index: 86; overflow: auto; padding: 5px; border: 1px solid var(--border-light); border-radius: 12px; background: var(--surface-raised); box-shadow: 0 18px 44px rgba(0, 0, 0, .3); }
.ss-opt { display: flex; align-items: baseline; gap: 8px; padding: 7px 9px; border-radius: 8px; color: var(--text-secondary); font: 13.5px var(--font-body); cursor: pointer; }
.ss-opt.is-active { background: var(--primary-bg); color: var(--text-primary); }
.ss-opt.is-selected .ss-label { color: var(--primary-light); font-weight: 700; }
.ss-opt .ss-label { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ss-opt .ss-hint { flex: 0 0 auto; margin-left: auto; padding-left: 10px; color: var(--text-tertiary); font-size: 12.5px; font-variant-numeric: tabular-nums; }
.ss-empty { padding: 14px 10px; color: var(--text-tertiary); font-size: 13px; text-align: center; }
/* 工具条/面板里给下拉定宽,别把搜索框挤没 */
.toolbar .ss-root { flex: 0 1 230px; min-width: 190px; }
.panel-search-row { display: flex; align-items: center; gap: 10px; margin-bottom: 11px; }
.panel-search-row .sf-root { flex: 1 1 auto; max-width: 340px; }
/* ---------- 小皮浮窗优化:清晰的标题层级 + 始终可见的关闭入口 ---------- */
.ai-window {
width: min(456px, calc(100vw - 24px));
max-height: min(720px, calc(100dvh - var(--header-height) - 24px));
border: 1px solid color-mix(in srgb, var(--primary-border) 48%, var(--border));
border-radius: 20px;
background: color-mix(in srgb, var(--surface) 94%, #07111f);
box-shadow: 0 28px 80px rgba(0, 0, 0, .42), 0 0 0 1px rgba(255, 255, 255, .03), inset 0 1px 0 var(--panel-highlight);
}
.ai-window-bar {
min-height: 66px;
padding: 12px 12px 12px 14px;
border-bottom: 1px solid color-mix(in srgb, var(--primary-border) 30%, var(--border-light));
background: linear-gradient(135deg, color-mix(in srgb, var(--primary-bg) 68%, var(--surface-deep)), var(--surface-deep) 62%);
}
.ai-panel-title { gap: 11px; min-width: 0; flex: 1 1 auto; }
.ai-orb {
width: 36px;
height: 36px;
flex-basis: 36px;
border: 1px solid var(--primary-border);
border-radius: 12px;
background: linear-gradient(145deg, var(--primary-bg), color-mix(in srgb, var(--surface-raised) 76%, var(--primary-bg)));
box-shadow: 0 8px 20px var(--accent-glow), inset 0 1px 0 rgba(255,255,255,.12);
}
.ai-panel-heading { min-width: 0; display: grid; grid-template-columns: auto auto; align-items: baseline; column-gap: 8px; row-gap: 1px; }
.ai-panel-kicker { grid-column: 1 / -1; color: var(--primary-light); font: 700 9px/1.2 var(--font-mono); letter-spacing: .14em; opacity: .9; }
.ai-panel-title h2 { color: var(--text-primary); font-size: 17px; line-height: 1.15; }
.ai-panel-subtitle { min-width: 0; overflow: hidden; color: var(--text-tertiary); font-size: 11.5px; text-overflow: ellipsis; white-space: nowrap; }
.ai-window-actions { gap: 7px; }
.ai-status-label { display: inline-flex; align-items: center; gap: 6px; margin-right: 2px; color: var(--success); font: 600 11px/1 var(--font-mono); white-space: nowrap; }
.ai-live-dot { width: 7px; height: 7px; flex-basis: 7px; }
.ai-icon-btn,
.ai-close-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 5px;
height: 32px;
min-width: 32px;
padding: 0 8px;
border: 1px solid var(--border-light);
border-radius: 9px;
background: color-mix(in srgb, var(--surface) 56%, transparent);
color: var(--text-secondary);
cursor: pointer;
transition: background var(--transition-fast), border-color var(--transition-fast), color var(--transition-fast), transform var(--transition-fast);
}
.ai-icon-btn:hover { border-color: var(--primary-border); background: var(--primary-bg); color: var(--primary-light); transform: translateY(-1px); }
.ai-close-btn { min-width: 62px; border-color: rgba(255, 113, 124, .42); background: rgba(255, 93, 108, .12); color: #ff9aa2; font-size: 12px; font-weight: 700; }
.ai-close-btn:hover { border-color: rgba(255, 113, 124, .8); background: rgba(255, 93, 108, .22); color: #ffd7da; transform: translateY(-1px); }
.ai-close-btn:focus-visible,
.ai-icon-btn:focus-visible { outline: 2px solid var(--primary-light); outline-offset: 2px; }
.ai-panel-body { padding: 15px; }
.ai-access-banner { margin-bottom: 12px; padding: 11px 12px; border-radius: 13px; background: linear-gradient(135deg, color-mix(in srgb, var(--primary-bg) 92%, var(--surface)), color-mix(in srgb, var(--surface-raised) 74%, var(--primary-bg))); }
.ai-capability-card { padding: 11px 12px; border: 1px solid var(--border-light); border-radius: 13px; background: color-mix(in srgb, var(--surface-deep) 82%, transparent); }
.ai-capability-heading { margin-bottom: 8px; font-size: 12.5px; letter-spacing: .02em; }
.ai-capability-row { padding: 4px 0; font-size: 12.5px; }
.ai-context-strip { margin: 13px 0 8px; }
.ai-message-list { gap: 12px; margin-top: 10px; }
.ai-message-content { padding: 10px 12px; border: 1px solid var(--border-light); border-radius: 5px 14px 14px 14px; background: color-mix(in srgb, var(--surface-deep) 88%, transparent); }
.ai-message.user .ai-message-content { border-color: var(--primary-border); background: var(--primary-bg); }
.ai-suggestion-row { gap: 8px; padding: 9px 15px 8px; }
.ai-suggestion-row button { padding: 7px 11px; border-radius: 10px; }
.ai-input-row { padding: 10px 15px; }
.ai-input-row input { min-height: 40px; border: 1px solid var(--border-light); }
.ai-input-row input:focus { border-color: var(--primary-border); }
.ai-input-row button { width: 40px; min-width: 40px; height: 40px; border-radius: 11px; box-shadow: 0 5px 14px var(--accent-glow); }
.ai-panel-footer { padding: 8px 15px 12px; border-top: 1px solid var(--border-light); }
@media (max-width: 680px) {
.ai-window { top: calc(var(--header-height) + 8px); right: 8px; width: calc(100vw - 16px); max-height: min(76dvh, 620px); border-radius: 17px; }
.ai-window-bar { min-height: 62px; padding: 10px; }
.ai-panel-subtitle, .ai-status-label { display: none; }
.ai-close-btn { min-width: 34px; padding: 0; }
.ai-close-btn span { display: none; }
.ai-panel-body { padding: 12px; }
.ai-suggestion-row, .ai-input-row, .ai-panel-footer { padding-left: 12px; padding-right: 12px; }
}
File diff suppressed because it is too large Load Diff
+26
View File
@@ -0,0 +1,26 @@
/**
* 全局「未保存改动」守卫。
*
* 为什么不用 react-router 的 useBlocker:它只在 data router(createBrowserRouter)下可用,
* 本项目用的是 <BrowserRouter> + <Routes>,调它会直接抛错。所以这里用一个模块级单例:
* 编辑页挂载时注册「我脏了吗 / 帮我存一下 / 帮我丢弃」,由外层外壳负责在跳转时询问。
*/
let active = null
export function setUnsavedGuard(guard) {
active = guard
}
export function clearUnsavedGuard() {
active = null
}
export function getUnsavedGuard() {
return active
}
/** 是否处于「有未保存改动」状态(没有编辑页时恒为 false) */
export function isGuardDirty() {
try {
return !!(active && active.isDirty())
} catch {
return false
}
}
+22 -6
View File
@@ -6,15 +6,15 @@ export default defineConfig({
server: {
port: 5300,
proxy: {
// SKU 能力代理(过渡期):前端统一以 /api/sku/* 访问,避免跨域与端口耦合。
// 后端正式合并(P1)完成后可直接删除本段。
// 产品目录(SKU)与产品图现在都由 happy 后端(4300)提供 —— 独立 SKU 服务已并入,
// 3300 不再存在。前缀与路径保持原样(/api/sku/groups → 4300/api/sku/groups),
// 前端代码零改动。写操作要记「上传者 / 调价人」,必须走 4300 才有人可记。
'/api/sku': {
target: 'http://127.0.0.1:3300',
changeOrigin: true,
rewrite: (p) => p.replace(/^\/api\/sku/, '/api')
target: 'http://127.0.0.1:4300',
changeOrigin: true
},
'/product-images': {
target: 'http://127.0.0.1:3300',
target: 'http://127.0.0.1:4300',
changeOrigin: true
},
'/api': {
@@ -22,5 +22,21 @@ export default defineConfig({
changeOrigin: true
}
}
},
build: {
rollupOptions: {
output: {
/* 把「很少变」的第三方库拆成独立 chunk:
* 1) 首屏只需再下载业务代码,React 这些体积大头可以长期缓存,发版不用重新下载;
* 2) 配合页面懒加载,首屏体积能降一大截。
* 只分两组,不按库细分 —— 拆太碎会变成一堆小请求,反而更慢。 */
manualChunks: {
'vendor-react': ['react', 'react-dom', 'react-router-dom'],
'vendor-axios': ['axios']
}
}
},
/* 业务代码已按页面拆分,单块都不大;阈值放宽,免得控制台刷一堆体积警告 */
chunkSizeWarningLimit: 700
}
})