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

This commit is contained in:
Drsakura
2026-09-10 16:16:59 +08:00
commit d8df2c3960
34 changed files with 9343 additions and 0 deletions
+207
View File
@@ -0,0 +1,207 @@
import React, { useState, useEffect } from 'react'
import axios from 'axios'
function CustomerManagement() {
const [customers, setCustomers] = useState([])
const [loading, setLoading] = useState(true)
const [showAddModal, setShowAddModal] = useState(false)
const [formData, setFormData] = useState({
company: '',
country: '',
industry: '',
website: '',
email: '',
phone: '',
notes: ''
})
useEffect(() => {
fetchCustomers()
}, [])
const fetchCustomers = async () => {
try {
const response = await axios.get('/api/customers')
setCustomers(response.data)
} catch (error) {
console.error('获取客户列表失败:', error)
} finally {
setLoading(false)
}
}
const handleSubmit = async (e) => {
e.preventDefault()
try {
await axios.post('/api/customers', formData)
setShowAddModal(false)
setFormData({
company: '',
country: '',
industry: '',
website: '',
email: '',
phone: '',
notes: ''
})
fetchCustomers()
} catch (error) {
console.error('创建客户失败:', error)
alert('创建失败: ' + error.message)
}
}
if (loading) {
return <div className="loading">加载中...</div>
}
return (
<div className="page-container">
<div className="page-header">
<h1 className="page-title">客户管理</h1>
<button className="btn-primary" onClick={() => setShowAddModal(true)}>
+ 新增客户
</button>
</div>
<div className="table-container">
<table className="data-table">
<thead>
<tr>
<th>公司名称</th>
<th>国家</th>
<th>行业</th>
<th>网站</th>
<th>联系方式</th>
<th>负责人</th>
<th>状态</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{customers.length === 0 ? (
<tr>
<td colSpan="8" className="empty-state">暂无客户数据</td>
</tr>
) : (
customers.map(customer => (
<tr key={customer.id}>
<td><strong>{customer.company}</strong></td>
<td>{customer.country || '-'}</td>
<td>{customer.industry || '-'}</td>
<td>
{customer.website ? (
<a href={customer.website} target="_blank" rel="noopener noreferrer">
{customer.website}
</a>
) : '-'}
</td>
<td>
{customer.email && <div>{customer.email}</div>}
{customer.phone && <div>{customer.phone}</div>}
</td>
<td>{customer.owner_name || '-'}</td>
<td>
<span className={`status-badge status-${customer.status}`}>
{customer.status === 'active' ? '活跃' : '非活跃'}
</span>
</td>
<td>
<button className="btn-link">查看</button>
<button className="btn-link">编辑</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{/* 新增客户模态框 */}
{showAddModal && (
<div className="modal-overlay" onClick={() => setShowAddModal(false)}>
<div className="modal-content" onClick={e => e.stopPropagation()}>
<div className="modal-header">
<h2>新增客户</h2>
<button className="modal-close" onClick={() => setShowAddModal(false)}>×</button>
</div>
<form onSubmit={handleSubmit}>
<div className="form-group">
<label>公司名称 *</label>
<input
type="text"
required
value={formData.company}
onChange={e => setFormData({...formData, company: e.target.value})}
/>
</div>
<div className="form-row">
<div className="form-group">
<label>国家</label>
<input
type="text"
value={formData.country}
onChange={e => setFormData({...formData, country: e.target.value})}
/>
</div>
<div className="form-group">
<label>行业</label>
<input
type="text"
value={formData.industry}
onChange={e => setFormData({...formData, industry: e.target.value})}
/>
</div>
</div>
<div className="form-group">
<label>网站</label>
<input
type="url"
placeholder="https://"
value={formData.website}
onChange={e => setFormData({...formData, website: e.target.value})}
/>
</div>
<div className="form-row">
<div className="form-group">
<label>邮箱</label>
<input
type="email"
value={formData.email}
onChange={e => setFormData({...formData, email: e.target.value})}
/>
</div>
<div className="form-group">
<label>电话</label>
<input
type="text"
value={formData.phone}
onChange={e => setFormData({...formData, phone: e.target.value})}
/>
</div>
</div>
<div className="form-group">
<label>备注</label>
<textarea
rows="3"
value={formData.notes}
onChange={e => setFormData({...formData, notes: e.target.value})}
/>
</div>
<div className="modal-footer">
<button type="button" className="btn-secondary" onClick={() => setShowAddModal(false)}>
取消
</button>
<button type="submit" className="btn-primary">
保存
</button>
</div>
</form>
</div>
</div>
)}
</div>
)
}
export default CustomerManagement
+125
View File
@@ -0,0 +1,125 @@
import React, { useEffect, useMemo, useState } from 'react'
import axios from 'axios'
import {
ActivityIcon,
BellIcon,
CalendarIcon,
CheckIcon,
ClipboardIcon,
DollarIcon,
FactoryIcon,
FlagIcon,
PackageIcon,
PinIcon,
SparkIcon,
UsersIcon
} from '../components/Icons'
const WEEKDAYS = ['周一', '周二', '周三', '周四', '周五', '周六', '周日']
function toDateKey(date) {
const year = date.getFullYear()
const month = `${date.getMonth() + 1}`.padStart(2, '0')
const day = `${date.getDate()}`.padStart(2, '0')
return `${year}-${month}-${day}`
}
function CalendarBoard({ todos }) {
const today = new Date()
const [month, setMonth] = useState(new Date(today.getFullYear(), today.getMonth(), 1))
const monthLabel = month.toLocaleDateString('zh-CN', { year: 'numeric', month: 'long' })
const monthStartDay = (month.getDay() + 6) % 7
const daysInMonth = new Date(month.getFullYear(), month.getMonth() + 1, 0).getDate()
const previousDays = new Date(month.getFullYear(), month.getMonth(), 0).getDate()
const calendarDays = useMemo(() => {
return Array.from({ length: 42 }, (_, index) => {
const offset = index - monthStartDay + 1
const date = new Date(month.getFullYear(), month.getMonth(), offset)
return { date, currentMonth: date.getMonth() === month.getMonth(), key: toDateKey(date) }
})
}, [month, monthStartDay])
const events = useMemo(() => {
const result = {}
todos.forEach(todo => {
if (!todo.due_date) return
const key = todo.due_date.slice(0, 10)
result[key] = [...(result[key] || []), { label: todo.title, tone: todo.priority === 'high' ? 'danger' : 'blue' }]
})
const year = month.getFullYear()
const holidays = [
{ date: `${year}-01-01`, label: '元旦 · 节日', tone: 'holiday' },
{ date: `${year}-05-01`, label: '劳动节 · 节日', tone: 'holiday' },
{ date: `${year}-10-01`, label: '国庆节 · 节日', tone: 'holiday' },
{ date: `${year}-12-25`, label: '圣诞节 · 节日', tone: 'holiday' },
]
holidays.forEach(event => { result[event.date] = [...(result[event.date] || []), event] })
return result
}, [month, todos])
const shiftMonth = (delta) => setMonth(current => new Date(current.getFullYear(), current.getMonth() + delta, 1))
const resetMonth = () => setMonth(new Date(today.getFullYear(), today.getMonth(), 1))
return (
<section className="calendar-board dashboard-panel">
<div className="dashboard-panel-header calendar-board-header">
<div><span className="section-label">[SCHEDULE]</span><h2><CalendarIcon size={18} /> 工作日历</h2></div>
<div className="calendar-toolbar"><button className="calendar-today-btn" onClick={resetMonth}>今天</button><button onClick={() => shiftMonth(-1)} aria-label="上个月">‹</button><strong>{monthLabel}</strong><button onClick={() => shiftMonth(1)} aria-label="下个月">›</button><button className="calendar-view-btn">月</button></div>
</div>
<div className="calendar-week-row">{WEEKDAYS.map(day => <div key={day}>{day}</div>)}</div>
<div className="calendar-grid">
{calendarDays.map(({ date, currentMonth, key }) => {
const dayEvents = events[key] || []
const isToday = key === toDateKey(today)
return <div className={`calendar-day ${currentMonth ? '' : 'is-outside'} ${isToday ? 'is-today' : ''}`} key={`${key}-${date.getTime()}`}>
<span className="calendar-day-number">{date.getDate()}</span>
<div className="calendar-events">{dayEvents.slice(0, 3).map((event, index) => <span className={`calendar-event ${event.tone}`} key={`${event.label}-${index}`} title={event.label}>{event.label}</span>)}</div>
</div>
})}
</div>
</section>
)
}
function Dashboard() {
const [data, setData] = useState({ stats: {}, recentActivities: [], todos: [] })
const [loading, setLoading] = useState(true)
useEffect(() => {
axios.get('/api/dashboard').then(response => setData(response.data)).catch(error => console.error('获取工作台数据失败:', error)).finally(() => setLoading(false))
}, [])
if (loading) return <div className="loading">加载工作台数据…</div>
const { stats, recentActivities, todos } = data
const completion = todos.length ? Math.max(12, Math.round((todos.filter(todo => todo.status === 'completed').length / todos.length) * 100)) : 0
return (
<div className="dashboard-container">
<div className="dashboard-welcome"><div><span className="section-label">[WORKSPACE / OVERVIEW]</span><h1 className="page-title">工作台</h1><p>把今天的节奏、客户和订单放在同一个视野里。</p></div><div className="dashboard-date-chip"><span>运行日期</span><strong>{new Date().toLocaleDateString('zh-CN', { year: 'numeric', month: 'long', day: 'numeric' })}</strong></div></div>
<CalendarBoard todos={todos} />
<div className="dashboard-content-grid">
<div className="dashboard-primary-column">
<section className="dashboard-panel ai-digest-panel"><div className="dashboard-panel-header"><div><span className="section-label">[AI BRIEFING]</span><h2><SparkIcon size={16} /> 今日工作摘要</h2></div><span className="panel-live-status"><i />实时</span></div><div className="ai-digest-body"><strong>系统已准备好协助你处理今天的业务。</strong><p>当前共有 <b>{stats.pendingTodos || 0}</b> 项待办、<b>{stats.activeDeals || 0}</b> 个进行中商机和 <b>{stats.orders || 0}</b> 笔进行中订单。打开右上角 AI 助手,可以直接用自然语言查阅和操作系统。</p><div className="digest-tags"><span>供应链</span><span>客户跟进</span><span>订单节奏</span></div></div></section>
<section className="dashboard-panel"><div className="dashboard-panel-header"><div><span className="section-label">[TASK QUEUE]</span><h2><PinIcon size={17} /> 待办事项</h2></div><button className="panel-link">查看全部</button></div>{todos.length === 0 ? <div className="dashboard-empty">暂无待办事项,今天可以保持从容。</div> : <ul className="todo-list">{todos.map(todo => <li key={todo.id} className="todo-item"><input type="checkbox" aria-label={`完成 ${todo.title}`} /><div className="todo-content"><div className="todo-title">{todo.title}</div><div className="todo-meta">{todo.customer_name ? `客户:${todo.customer_name}` : '内部任务'}{todo.due_date ? ` · 截止 ${new Date(todo.due_date).toLocaleDateString('zh-CN')}` : ''}</div></div><span className={`priority-badge priority-${todo.priority}`}>{todo.priority === 'high' ? '高' : todo.priority === 'medium' ? '中' : '低'}</span></li>)}</ul>}</section>
<section className="dashboard-panel"><div className="dashboard-panel-header"><div><span className="section-label">[ACTIVITY LOG]</span><h2><ActivityIcon size={17} /> 最近活动</h2></div><button className="panel-link">查看日志</button></div>{recentActivities.length === 0 ? <div className="dashboard-empty">暂无最近活动</div> : <ul className="activity-list">{recentActivities.map(activity => <li key={activity.id} className="activity-item"><span className="activity-time">{new Date(activity.created_at).toLocaleString('zh-CN')}</span><div className="activity-content"><strong>{activity.subject}</strong><span>{activity.content}</span></div></li>)}</ul>}</section>
</div>
<aside className="dashboard-secondary-column">
<section className="dashboard-panel stat-rail"><div className="dashboard-panel-header"><div><span className="section-label">[SYSTEM PULSE]</span><h2>业务概览</h2></div><span className="panel-period">本月</span></div><StatLine icon={<FactoryIcon size={17} />} label="供应商" value={stats.suppliers || 0} /><StatLine icon={<PackageIcon size={17} />} label="产品 SKU" value={stats.products || 0} /><StatLine icon={<UsersIcon size={17} />} label="客户" value={stats.customers || 0} /><StatLine icon={<ClipboardIcon size={17} />} label="进行中订单" value={stats.orders || 0} /><StatLine icon={<DollarIcon size={17} />} label="进行中商机" value={stats.activeDeals || 0} /></section>
<section className="dashboard-panel completion-panel"><div className="dashboard-panel-header"><div><span className="section-label">[TASK HEALTH]</span><h2>任务完成情况</h2></div><span className="panel-period">当前</span></div><div className="completion-ring" style={{ '--progress': `${completion}%` }}><div><strong>{completion}%</strong><span>完成率</span></div></div><div className="completion-legend"><span><i className="dot done" />已完成</span><span><i className="dot pending" />待处理</span></div></section>
<section className="dashboard-panel quick-panel"><div className="dashboard-panel-header"><div><span className="section-label">[QUICK ACCESS]</span><h2>快捷入口</h2></div></div><button><UsersIcon size={16} /> 新增客户</button><button><PackageIcon size={16} /> 添加产品</button><button><ClipboardIcon size={16} /> 新建订单</button></section>
</aside>
</div>
</div>
)
}
function StatLine({ icon, label, value }) { return <div className="stat-line"><span className="stat-line-icon">{icon}</span><span>{label}</span><strong>{value}</strong></div> }
export default Dashboard
+91
View File
@@ -0,0 +1,91 @@
import React, { useState, useRef } from 'react'
/**
* SKU 功能嵌入页(整合过渡期方案)。
*
* 合并策略是「先嵌后重写」:SKU 的能力先以嵌入方式跑通,
* 之后再逐步重写为原生 React 页面。
*
* 现在嵌入的是独立的 sku-manager 服务(3300);等后端合并(P1/P2)完成后,
* 把 SKU_ORIGIN 换成同源子路径(如 /sku)即可,页面组件本身不用动。
*/
const SKU_ORIGIN = import.meta.env.VITE_SKU_ORIGIN || 'http://127.0.0.1:3300'
const VIEW_META = {
search: {
title: '产品管理',
sub: '按货号、品名、规格、材质或参数检索产品与采购价'
},
suppliers: {
title: '供应商管理',
sub: '供应商档案、联系方式、工厂所在地与主营类目'
},
import: {
title: '合同导入',
sub: '批量导入采购合同,自动解析价格、产品与参数'
}
}
function EmbeddedSku({ view = 'search' }) {
const [loaded, setLoaded] = useState(false)
const frameRef = useRef(null)
const meta = VIEW_META[view] || VIEW_META.search
const src = `${SKU_ORIGIN}/?embed=1&view=${encodeURIComponent(view)}`
return (
<div className="page-container">
<div className="page-header">
<div>
<h1 className="page-title">{meta.title}</h1>
<p style={{ color: 'var(--text-secondary)', fontSize: '14px', marginTop: '6px' }}>
{meta.sub}
</p>
</div>
<div style={{ display: 'flex', gap: '8px' }}>
<button className="btn btn-sm" onClick={() => frameRef.current?.contentWindow?.location.reload()}>
刷新
</button>
<a className="btn btn-sm" href={src} target="_blank" rel="noreferrer" style={{ textDecoration: 'none' }}>
新窗口打开
</a>
</div>
</div>
<div
style={{
position: 'relative',
height: 'calc(100vh - 210px)',
minHeight: '560px',
border: '1px solid var(--border)',
borderRadius: 'var(--radius-lg)',
overflow: 'hidden',
background: 'var(--surface)'
}}
>
{!loaded && (
<div className="loading" style={{ position: 'absolute', inset: 0, zIndex: 1 }}>
<div className="spinner"></div>
<span style={{ marginLeft: '12px' }}>正在加载 SKU 模块…</span>
</div>
)}
<iframe
ref={frameRef}
title={meta.title}
src={src}
onLoad={() => setLoaded(true)}
allow="clipboard-write"
style={{
width: '100%',
height: '100%',
border: 0,
display: 'block',
opacity: loaded ? 1 : 0,
transition: 'opacity .18s ease'
}}
/>
</div>
</div>
)
}
export default EmbeddedSku
+99
View File
@@ -0,0 +1,99 @@
import React, { useState, useEffect } from 'react'
import axios from 'axios'
function OrderManagement() {
const [orders, setOrders] = useState([])
const [loading, setLoading] = useState(true)
useEffect(() => {
fetchOrders()
}, [])
const fetchOrders = async () => {
try {
const response = await axios.get('/api/orders')
setOrders(response.data)
} catch (error) {
console.error('获取订单列表失败:', error)
} finally {
setLoading(false)
}
}
const getStatusText = (status) => {
const statusMap = {
draft: '草稿',
confirmed: '已确认',
production: '生产中',
shipped: '已发货',
completed: '已完成',
cancelled: '已取消'
}
return statusMap[status] || status
}
if (loading) {
return <div className="loading">加载中...</div>
}
return (
<div className="page-container">
<div className="page-header">
<h1 className="page-title">订单管理</h1>
<button className="btn-primary">+ 新建订单</button>
</div>
<div className="table-container">
<table className="data-table">
<thead>
<tr>
<th>订单号</th>
<th>客户</th>
<th>订单日期</th>
<th>交货日期</th>
<th>金额</th>
<th>状态</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{orders.length === 0 ? (
<tr>
<td colSpan="7" className="empty-state">暂无订单数据</td>
</tr>
) : (
orders.map(order => (
<tr key={order.id}>
<td><strong>{order.order_no}</strong></td>
<td>{order.customer_name}</td>
<td>{new Date(order.order_date).toLocaleDateString('zh-CN')}</td>
<td>
{order.delivery_date
? new Date(order.delivery_date).toLocaleDateString('zh-CN')
: '-'}
</td>
<td>
{order.total_amount
? `${order.currency} ${order.total_amount.toFixed(2)}`
: '-'}
</td>
<td>
<span className={`status-badge status-${order.status}`}>
{getStatusText(order.status)}
</span>
</td>
<td>
<button className="btn-link">查看</button>
<button className="btn-link">编辑</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
)
}
export default OrderManagement
@@ -0,0 +1,96 @@
import React, { useState, useEffect, useMemo } from 'react'
import axios from 'axios'
/**
* PI 合同管理(Proforma Invoice)
* 数据来源:happy 后端 /api/pi-contracts(P1 后端合并阶段开放)
*/
function PiContractManagement() {
const [contracts, setContracts] = useState([])
const [loading, setLoading] = useState(true)
const [keyword, setKeyword] = useState('')
useEffect(() => {
let alive = true
axios.get('/api/pi-contracts')
.then(res => { if (alive) setContracts(Array.isArray(res.data) ? res.data : res.data?.items || []) })
.catch(() => { if (alive) setContracts([]) })
.finally(() => { if (alive) setLoading(false) })
return () => { alive = false }
}, [])
const rows = useMemo(() => {
const kw = keyword.trim().toLowerCase()
if (!kw) return contracts
return contracts.filter(c => [c.pi_no, c.customer_name, c.customer_company].filter(Boolean).join(' ').toLowerCase().includes(kw))
}, [contracts, keyword])
const statusText = (s) => ({ draft: '草稿', signed: '已签署', deposited: '已收定金', producing: '生产中', shipped: '已出运', closed: '已结案' }[s] || s || '—')
return (
<div className="page-container">
<div className="page-header">
<h1 className="page-title">PI 合同管理</h1>
<button className="btn btn-primary">+ 新建 PI</button>
</div>
<div className="toolbar">
<input
type="text"
className="toolbar-search"
placeholder="搜索 PI 编号 / 客户"
value={keyword}
onChange={e => setKeyword(e.target.value)}
/>
<div className="toolbar-actions">
<span className="secondary">{rows.length} 条</span>
</div>
</div>
<div className="table-container">
<table className="data-table">
<thead>
<tr>
<th>PI 编号</th>
<th>客户</th>
<th>签订日期</th>
<th>金额</th>
<th>收款条件</th>
<th>状态</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr><td colSpan="7" className="empty-state">加载中...</td></tr>
) : rows.length === 0 ? (
<tr>
<td colSpan="7" className="empty-state">
<div className="empty-state-title">暂无 PI 合同</div>
<div className="empty-state-hint">PI 合同接口待接入(P1 后端合并阶段开放)</div>
</td>
</tr>
) : (
rows.map(c => (
<tr key={c.id}>
<td><strong>{c.pi_no}</strong></td>
<td>{c.customer_name || c.customer_company || '—'}</td>
<td>{c.signed_date ? new Date(c.signed_date).toLocaleDateString('zh-CN') : '—'}</td>
<td>{c.total_amount ? `${c.currency || 'USD'} ${Number(c.total_amount).toFixed(2)}` : '—'}</td>
<td>{c.payment_terms || '—'}</td>
<td><span className={`status-badge status-${c.status}`}>{statusText(c.status)}</span></td>
<td>
<button className="btn-link">查看</button>
<button className="btn-link">生成单据</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
)
}
export default PiContractManagement
File diff suppressed because it is too large Load Diff
+108
View File
@@ -0,0 +1,108 @@
import React, { useState } from 'react'
function ProspectManagement() {
const [formData, setFormData] = useState({
productKeywords: '',
countries: '',
customerTypes: '',
excludeKeywords: '',
resultLimit: 100
})
const handleSubmit = async (e) => {
e.preventDefault()
alert('自动获客功能开发中...')
}
return (
<div className="page-container">
<div className="page-header">
<h1 className="page-title">新客开发(自动获客)</h1>
</div>
<div className="prospect-container">
{/* 搜索条件 */}
<div className="card">
<h2>搜索条件</h2>
<form onSubmit={handleSubmit}>
<div className="form-group">
<label>产品关键词</label>
<input
type="text"
placeholder="如:LED灯具, 五金工具"
value={formData.productKeywords}
onChange={e => setFormData({...formData, productKeywords: e.target.value})}
/>
</div>
<div className="form-group">
<label>目标国家</label>
<input
type="text"
placeholder="如:德国, 英国, 美国"
value={formData.countries}
onChange={e => setFormData({...formData, countries: e.target.value})}
/>
</div>
<div className="form-group">
<label>客户类型</label>
<input
type="text"
placeholder="如:经销商, 系统集成商, OEM"
value={formData.customerTypes}
onChange={e => setFormData({...formData, customerTypes: e.target.value})}
/>
</div>
<div className="form-group">
<label>排除关键词</label>
<input
type="text"
placeholder="如:招聘, 二手, 学校"
value={formData.excludeKeywords}
onChange={e => setFormData({...formData, excludeKeywords: e.target.value})}
/>
</div>
<div className="form-group">
<label>结果数量</label>
<input
type="number"
value={formData.resultLimit}
onChange={e => setFormData({...formData, resultLimit: e.target.value})}
/>
</div>
<button type="submit" className="btn-primary">
开始搜索
</button>
</form>
</div>
{/* 搜索结果 */}
<div className="card">
<h2>搜索结果</h2>
<div className="empty-state">
请配置搜索条件并点击"开始搜索"
</div>
</div>
</div>
<div className="info-box">
<h3>功能说明</h3>
<p>自动获客功能将帮助你从多个数据源自动搜索潜在客户:</p>
<ul>
<li>公开公司注册数据(GLEIF、Companies House)</li>
<li>网页搜索引擎</li>
<li>AI 智能解析和评分</li>
<li>自动去重和质量过滤</li>
</ul>
<p><strong>此功能将在后续版本中完善。</strong></p>
</div>
</div>
)
}
export default ProspectManagement
@@ -0,0 +1,96 @@
import React, { useState, useEffect, useMemo } from 'react'
import axios from 'axios'
/**
* 采购单管理
* 数据来源:happy 后端 /api/purchase-orders(P1 后端合并阶段开放)
*/
function PurchaseOrderManagement() {
const [orders, setOrders] = useState([])
const [loading, setLoading] = useState(true)
const [keyword, setKeyword] = useState('')
useEffect(() => {
let alive = true
axios.get('/api/purchase-orders')
.then(res => { if (alive) setOrders(Array.isArray(res.data) ? res.data : res.data?.items || []) })
.catch(() => { if (alive) setOrders([]) })
.finally(() => { if (alive) setLoading(false) })
return () => { alive = false }
}, [])
const rows = useMemo(() => {
const kw = keyword.trim().toLowerCase()
if (!kw) return orders
return orders.filter(o => [o.po_no, o.supplier_name].filter(Boolean).join(' ').toLowerCase().includes(kw))
}, [orders, keyword])
const statusText = (s) => ({ draft: '草稿', issued: '已下达', received: '已收货', partial: '部分到货', closed: '已关闭', cancelled: '已取消' }[s] || s || '—')
return (
<div className="page-container">
<div className="page-header">
<h1 className="page-title">采购单管理</h1>
<button className="btn btn-primary">+ 新建采购单</button>
</div>
<div className="toolbar">
<input
type="text"
className="toolbar-search"
placeholder="搜索采购单号 / 供应商"
value={keyword}
onChange={e => setKeyword(e.target.value)}
/>
<div className="toolbar-actions">
<span className="secondary">{rows.length} 条</span>
</div>
</div>
<div className="table-container">
<table className="data-table">
<thead>
<tr>
<th>采购单号</th>
<th>供应商</th>
<th>下单日期</th>
<th>预计交期</th>
<th>金额</th>
<th>状态</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr><td colSpan="7" className="empty-state">加载中...</td></tr>
) : rows.length === 0 ? (
<tr>
<td colSpan="7" className="empty-state">
<div className="empty-state-title">暂无采购单</div>
<div className="empty-state-hint">采购单接口待接入(P1 后端合并阶段开放)</div>
</td>
</tr>
) : (
rows.map(o => (
<tr key={o.id}>
<td><strong>{o.po_no}</strong></td>
<td>{o.supplier_name || '—'}</td>
<td>{o.order_date ? new Date(o.order_date).toLocaleDateString('zh-CN') : '—'}</td>
<td>{o.expected_date ? new Date(o.expected_date).toLocaleDateString('zh-CN') : '—'}</td>
<td>{o.total_amount ? `${o.currency || 'CNY'} ${Number(o.total_amount).toFixed(2)}` : '—'}</td>
<td><span className={`status-badge status-${o.status}`}>{statusText(o.status)}</span></td>
<td>
<button className="btn-link">查看</button>
<button className="btn-link">编辑</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
)
}
export default PurchaseOrderManagement
+97
View File
@@ -0,0 +1,97 @@
import React, { useState, useEffect, useMemo } from 'react'
import axios from 'axios'
/**
* 客户报价单管理
* 数据来源:happy 后端 /api/quotes(P1 后端合并阶段开放)
* 未接入前保持可用的空态,不做假数据。
*/
function QuoteManagement() {
const [quotes, setQuotes] = useState([])
const [loading, setLoading] = useState(true)
const [keyword, setKeyword] = useState('')
useEffect(() => {
let alive = true
axios.get('/api/quotes')
.then(res => { if (alive) setQuotes(Array.isArray(res.data) ? res.data : res.data?.items || []) })
.catch(() => { if (alive) setQuotes([]) })
.finally(() => { if (alive) setLoading(false) })
return () => { alive = false }
}, [])
const rows = useMemo(() => {
const kw = keyword.trim().toLowerCase()
if (!kw) return quotes
return quotes.filter(q => [q.quote_no, q.customer_name, q.customer_company].filter(Boolean).join(' ').toLowerCase().includes(kw))
}, [quotes, keyword])
const statusText = (s) => ({ draft: '草稿', sent: '已发送', accepted: '已接受', rejected: '已拒绝', expired: '已过期' }[s] || s || '—')
return (
<div className="page-container">
<div className="page-header">
<h1 className="page-title">客户报价单管理</h1>
<button className="btn btn-primary">+ 新建报价单</button>
</div>
<div className="toolbar">
<input
type="text"
className="toolbar-search"
placeholder="搜索报价单号 / 客户"
value={keyword}
onChange={e => setKeyword(e.target.value)}
/>
<div className="toolbar-actions">
<span className="secondary">{rows.length} 条</span>
</div>
</div>
<div className="table-container">
<table className="data-table">
<thead>
<tr>
<th>报价单号</th>
<th>客户</th>
<th>报价日期</th>
<th>有效期至</th>
<th>金额</th>
<th>状态</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr><td colSpan="7" className="empty-state">加载中...</td></tr>
) : rows.length === 0 ? (
<tr>
<td colSpan="7" className="empty-state">
<div className="empty-state-title">暂无报价单</div>
<div className="empty-state-hint">报价单接口待接入(P1 后端合并阶段开放)</div>
</td>
</tr>
) : (
rows.map(q => (
<tr key={q.id}>
<td><strong>{q.quote_no}</strong></td>
<td>{q.customer_name || q.customer_company || '—'}</td>
<td>{q.quote_date ? new Date(q.quote_date).toLocaleDateString('zh-CN') : '—'}</td>
<td>{q.valid_until ? new Date(q.valid_until).toLocaleDateString('zh-CN') : '—'}</td>
<td>{q.total_amount ? `${q.currency || 'USD'} ${Number(q.total_amount).toFixed(2)}` : '—'}</td>
<td><span className={`status-badge status-${q.status}`}>{statusText(q.status)}</span></td>
<td>
<button className="btn-link">查看</button>
<button className="btn-link">转 PI</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
)
}
export default QuoteManagement
+596
View File
@@ -0,0 +1,596 @@
import React, { useState, useEffect, useMemo, useCallback } from 'react'
import { useNavigate } from 'react-router-dom'
import axios from 'axios'
import { GridIcon, ListIcon } from '../components/Icons'
/**
* 供应商管理(原生 React 版)
*
* 由 SKU Manager 的「供应商管理」视图迁移而来,功能对齐:
* 卡片列表 · 未分类入口 · 新建/编辑 · 删除 · 详情(联系方式 / 供货清单 / 合同记录)。
* 数据经 /api/sku/* 同源代理访问,禁止硬编码 SKU 的独立端口。
*/
const API = '/api/sku'
/* ------------------------------ 格式化工具 ------------------------------ */
const money = (n) => (n == null ? '—' : Number(n).toFixed(2))
function fmtDate(iso, withTime = true) {
if (!iso) return '—'
const d = new Date(iso)
const pad = (n) => String(n).padStart(2, '0')
const base = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
return withTime ? `${base} ${pad(d.getHours())}:${pad(d.getMinutes())}` : base
}
function splitCats(text) {
return String(text || '')
.split(/[、,,;;/|\s]+/)
.map((t) => t.trim())
.filter(Boolean)
}
/** 从完整地址里抽出省市,卡片上只显示大致方位 */
function shortLocation(address) {
const a = String(address || '').trim()
if (!a) return ''
const m = a.match(/^(.{2,4}?(?:省|市|自治区|特别行政区))?\s*(.{2,6}?(?:市|自治州|地区|县|区))?/)
const parts = [m?.[1], m?.[2]].filter(Boolean)
if (parts.length) return parts.join('')
return a.length > 12 ? `${a.slice(0, 12)}…` : a
}
const FIELDS = [
['name', '供应商名称', true],
['short_name', '简称'],
['contact_person', '联系人'],
['phone', '电话'],
['email', '邮箱'],
['website', '网址'],
['payment_terms', '付款条件'],
['address', '地址']
]
const EMPTY_FORM = {
name: '',
short_name: '',
contact_person: '',
phone: '',
email: '',
website: '',
payment_terms: '',
address: '',
main_categories: '',
notes: ''
}
const FILE_TYPE_LABEL = {
excel: 'Excel',
'pdf-text': 'PDF 文字版',
'pdf-scan': 'PDF 扫描件',
unknown: '无法识别'
}
const STATUS_LABEL = { success: '成功', empty: '未识别到数据', failed: '失败' }
const STATUS_PILL = { success: 'pill pill-ok', empty: 'pill pill-mute', failed: 'pill pill-warn' }
/* -------------------------------- 小组件 -------------------------------- */
function CatChips({ text }) {
const cats = splitCats(text)
if (!cats.length) return <div className="chip-row"><span className="muted-note">未填主营类目</span></div>
return (
<div className="chip-row">
{cats.map((c) => <span className="chip" key={c}>{c}</span>)}
</div>
)
}
function Kv({ label, value, mono }) {
return (
<div className="kv">
<span className="kv-label">{label}</span>
<span className={`kv-value${value ? '' : ' is-empty'}`} style={mono && value ? { fontFamily: 'var(--font-mono)' } : undefined}>
{value || '未填写'}
</span>
</div>
)
}
/* -------------------------------- 主页面 -------------------------------- */
function SupplierManagement() {
const navigate = useNavigate()
const [mode, setMode] = useState('list') // list | detail | form
const [suppliers, setSuppliers] = useState([])
const [unassigned, setUnassigned] = useState(0)
const [loading, setLoading] = useState(true)
const [notice, setNotice] = useState('')
const [keyword, setKeyword] = useState('')
// 排列方式:卡片网格 / 横列表。选择记在本地,下次进来自动沿用。
const [viewMode, setViewMode] = useState(() => {
try { return localStorage.getItem('happy.supplierView') || 'card' } catch { return 'card' }
})
const changeView = useCallback((next) => {
setViewMode(next)
try { localStorage.setItem('happy.supplierView', next) } catch { /* 隐私模式下忽略 */ }
}, [])
const [detail, setDetail] = useState(null)
const [detailLoading, setDetailLoading] = useState(false)
const [form, setForm] = useState(EMPTY_FORM)
const [editingId, setEditingId] = useState(null)
const [editingName, setEditingName] = useState('')
const [saving, setSaving] = useState(false)
const loadList = useCallback(async () => {
try {
const [listRes, unassignedRes] = await Promise.all([
axios.get(`${API}/suppliers`),
axios.get(`${API}/suppliers/unassigned/count`).catch(() => ({ data: { count: 0 } }))
])
setSuppliers(Array.isArray(listRes.data) ? listRes.data : [])
setUnassigned(unassignedRes.data?.count || 0)
} catch (err) {
setNotice(err?.response?.data?.error || `加载供应商失败:${err.message}`)
} finally {
setLoading(false)
}
}, [])
useEffect(() => { loadList() }, [loadList])
const filtered = useMemo(() => {
const kw = keyword.trim().toLowerCase()
if (!kw) return suppliers
return suppliers.filter((s) =>
[s.name, s.short_name, s.contact_person, s.phone, s.address, s.main_categories]
.filter(Boolean)
.join(' ')
.toLowerCase()
.includes(kw)
)
}, [suppliers, keyword])
const openDetail = useCallback(async (id) => {
setMode('detail')
setDetailLoading(true)
setDetail(null)
setNotice('')
try {
const { data } = await axios.get(`${API}/suppliers/${id}`)
setDetail(data)
} catch (err) {
setNotice(err?.response?.data?.error || `加载详情失败:${err.message}`)
} finally {
setDetailLoading(false)
}
}, [])
const startCreate = () => {
setForm(EMPTY_FORM)
setEditingId(null)
setEditingName('')
setNotice('')
setMode('form')
}
const startEdit = (supplier) => {
setForm({ ...EMPTY_FORM, ...Object.fromEntries(Object.entries(supplier).map(([k, v]) => [k, v ?? ''])) })
setEditingId(supplier.id)
setEditingName(supplier.name)
setNotice('')
setMode('form')
}
const save = async () => {
const payload = {}
for (const [key] of FIELDS) payload[key] = form[key]?.trim() || null
payload.main_categories = form.main_categories?.trim() || null
payload.notes = form.notes?.trim() || null
if (!payload.name) {
setNotice('供应商名称不能为空')
return
}
setSaving(true)
setNotice('')
try {
if (editingId) {
await axios.put(`${API}/suppliers/${editingId}`, payload)
await loadList()
await openDetail(editingId)
} else {
const { data } = await axios.post(`${API}/suppliers`, payload)
await loadList()
await openDetail(data.id)
}
} catch (err) {
setNotice(err?.response?.data?.error || `保存失败:${err.message}`)
} finally {
setSaving(false)
}
}
const removeSupplier = async (id, name) => {
const ok = window.confirm(
`删除供应商「${name}」?\n\n该供应商名下的产品和价格会保留,只是变成「未指定供应商」。`
)
if (!ok) return
try {
await axios.delete(`${API}/suppliers/${id}`)
await loadList()
setDetail(null)
setMode('list')
} catch (err) {
setNotice(err?.response?.data?.error || `删除失败:${err.message}`)
}
}
/* ------------------------------- 列表视图 ------------------------------- */
if (mode === 'list') {
return (
<div className="page-container">
<div className="page-header">
<div>
<h1 className="page-title">供应商管理</h1>
<p className="page-sub">档案、联系方式、工厂所在地与主营类目</p>
</div>
<button className="btn btn-primary" onClick={startCreate}>+ 新建供应商</button>
</div>
{notice && <div className="notice-inline">{notice}</div>}
<div className="toolbar">
<input
type="text"
className="toolbar-search"
placeholder="按名称 / 联系人 / 电话 / 地址搜索"
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
/>
<div className="toolbar-actions">
<span className="secondary">共 {filtered.length} 家</span>
<div className="view-toggle" role="group" aria-label="切换排列方式">
<button
type="button"
className={viewMode === 'card' ? 'active' : ''}
onClick={() => changeView('card')}
title="卡片排列"
aria-pressed={viewMode === 'card'}
>
<GridIcon size={16} />
</button>
<button
type="button"
className={viewMode === 'table' ? 'active' : ''}
onClick={() => changeView('table')}
title="列表排列"
aria-pressed={viewMode === 'table'}
>
<ListIcon size={16} />
</button>
</div>
</div>
</div>
{loading ? (
<div className="loading">加载中…</div>
) : suppliers.length === 0 ? (
<div className="empty-state">
<div className="empty-state-icon">🏭</div>
<div className="empty-state-title">还没有供应商档案</div>
<div className="empty-state-hint">
建好供应商后,导入合同时就能把价格归到对应供应商名下,查价时也能按供应商筛选。
</div>
</div>
) : viewMode === 'table' ? (
<div className="table-container">
<table className="data-table supplier-table">
<thead>
<tr>
<th>供应商</th>
<th>联系人</th>
<th>电话</th>
<th>工厂所在地</th>
<th>主营类目</th>
<th style={{ textAlign: 'right', width: 72 }}>产品</th>
<th style={{ textAlign: 'right', width: 72 }}>货号</th>
<th>付款条件</th>
</tr>
</thead>
<tbody>
{unassigned > 0 && (
<tr className="row-unassigned" onClick={() => navigate('/products')}>
<td className="bold">未分类</td>
<td className="secondary" colSpan={4}>还没归属供应商的产品</td>
<td className="number" style={{ textAlign: 'right' }}>{unassigned}</td>
<td className="number" style={{ textAlign: 'right' }}>—</td>
<td className="secondary">—</td>
</tr>
)}
{filtered.map((s) => (
<tr key={s.id} onClick={() => openDetail(s.id)}>
<td className="bold">{s.name}</td>
<td className="secondary">{s.contact_person || '—'}</td>
<td className="secondary mono">{s.phone || '—'}</td>
<td className="secondary" title={s.address || ''}>
{shortLocation(s.address) || '—'}
</td>
<td>
{splitCats(s.main_categories).length
? <div className="chip-row">{splitCats(s.main_categories).map((c) => <span className="chip" key={c}>{c}</span>)}</div>
: <span className="muted-note">未填</span>}
</td>
<td className="number" style={{ textAlign: 'right' }}>{s.group_count ?? s.sku_count ?? 0}</td>
<td className="number" style={{ textAlign: 'right' }}>{s.sku_count ?? 0}</td>
<td className="secondary">{s.payment_terms || '—'}</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="supplier-grid">
{unassigned > 0 && (
<div className="supplier-card is-unassigned" onClick={() => navigate('/products')}>
<div className="sc-name">未分类</div>
<div className="sc-contact"><span className="muted-note">还没归属供应商的产品</span></div>
<div className="sc-foot">
<span className="sc-count"><b>{unassigned}</b> 个产品</span>
</div>
</div>
)}
{filtered.map((s) => {
const contact = [s.contact_person, s.phone].filter(Boolean).join(' · ')
const loc = shortLocation(s.address)
return (
<div className="supplier-card" key={s.id} onClick={() => openDetail(s.id)}>
<div className="sc-name">{s.name}</div>
<div className="sc-contact">
{contact || <span className="muted-note">未填联系人</span>}
</div>
<div className="sc-loc" title={s.address || ''}>
{loc || <span className="muted-note">未填工厂所在地</span>}
</div>
<CatChips text={s.main_categories} />
<div className="sc-foot">
<span className="sc-count">
<b>{s.group_count ?? s.sku_count}</b> 个产品 · {s.sku_count} 个货号
</span>
{s.payment_terms && <span className="chip">{s.payment_terms}</span>}
</div>
</div>
)
})}
</div>
)}
</div>
)
}
/* ------------------------------- 详情视图 ------------------------------- */
if (mode === 'detail') {
const s = detail?.supplier
const products = detail?.products || []
const contracts = detail?.contracts || []
return (
<div className="page-container">
<div className="page-header">
<div>
<h1 className="page-title">{s?.name || '供应商详情'}</h1>
{s && (
<p className="page-sub">
{s.short_name ? `${s.short_name} · ` : ''}
供货 {products.length} 个货号 · {contracts.length} 份合同记录
</p>
)}
{s && <CatChips text={s.main_categories} />}
</div>
<div className="header-actions">
<button className="btn" onClick={() => { setDetail(null); setMode('list') }}>← 供应商列表</button>
{s && <button className="btn btn-primary" onClick={() => startEdit(s)}>编辑档案</button>}
</div>
</div>
{notice && <div className="notice-inline">{notice}</div>}
{detailLoading || !s ? (
<div className="loading">加载中…</div>
) : (
<>
<div className="card">
<div className="card-header"><h2>联系方式</h2></div>
<div className="kv-grid">
<Kv label="联系人" value={s.contact_person} />
<Kv label="电话" value={s.phone} mono />
<Kv label="邮箱" value={s.email} mono />
<Kv label="付款条件" value={s.payment_terms} />
<Kv label="网址" value={s.website} mono />
<Kv label="地址" value={s.address} />
</div>
{s.notes && (
<div className="kv-grid" style={{ marginTop: 14 }}>
<Kv label="备注" value={s.notes} />
</div>
)}
</div>
<div className="card">
<div className="card-header">
<h2>供货清单</h2>
<span className="secondary">{products.length} 条</span>
</div>
{products.length === 0 ? (
<div className="empty-state" style={{ padding: '28px 12px' }}>
<div className="empty-state-hint">
还没有归到这家供应商的货号。导入合同时在「合同导入」页选上这家供应商即可。
</div>
</div>
) : (
<div className="table-container" style={{ maxHeight: 460 }}>
<table className="data-table">
<thead>
<tr>
<th style={{ width: 56 }}></th>
<th>货号</th>
<th>所属产品</th>
<th style={{ textAlign: 'right' }}>采购价</th>
<th style={{ textAlign: 'right' }}>起订量</th>
<th>更新时间</th>
</tr>
</thead>
<tbody>
{products.map((p) => (
<tr key={`${p.sku}-${p.group_id ?? ''}`}>
<td>
{p.thumb
? <img className="thumb" src={`/product-images/${p.thumb}`} alt="" loading="lazy" />
: <span className="thumb thumb-empty" />}
</td>
<td className="mono">{p.display_sku || p.sku}</td>
<td>{p.group_name || p.name || '—'}</td>
<td className="number" style={{ textAlign: 'right' }}>
<span className="secondary">¥</span>{money(p.price)}
{p.confidence === 'low' && <span className="pill pill-warn" style={{ marginLeft: 6 }}>待核对</span>}
</td>
<td className="number" style={{ textAlign: 'right' }}>{p.moq ?? '—'}</td>
<td className="secondary">{fmtDate(p.last_updated, false)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
<div className="card">
<div className="card-header">
<h2>合同导入记录</h2>
<span className="secondary">{contracts.length} 条</span>
</div>
{contracts.length === 0 ? (
<div className="empty-state" style={{ padding: '28px 12px' }}>
<div className="empty-state-hint">还没有该供应商的合同导入记录。</div>
</div>
) : (
<div className="table-container" style={{ maxHeight: 340 }}>
<table className="data-table">
<thead>
<tr>
<th>文件名</th>
<th>类型</th>
<th style={{ textAlign: 'right' }}>新增</th>
<th style={{ textAlign: 'right' }}>更新</th>
<th>状态</th>
<th>时间</th>
</tr>
</thead>
<tbody>
{contracts.map((c) => (
<tr key={c.id}>
<td className="mono">
{c.has_file
? <a href={`${API}/contracts/${c.id}/file`} title="下载原件">{c.filename}</a>
: c.filename}
</td>
<td className="secondary">{FILE_TYPE_LABEL[c.file_type] || c.file_type}</td>
<td className="number" style={{ textAlign: 'right' }}>{c.rows_new}</td>
<td className="number" style={{ textAlign: 'right' }}>{c.rows_matched}</td>
<td><span className={STATUS_PILL[c.status] || 'pill pill-mute'}>{STATUS_LABEL[c.status] || c.status}</span></td>
<td className="secondary">{fmtDate(c.processed_at)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</>
)}
</div>
)
}
/* ------------------------------- 表单视图 ------------------------------- */
return (
<div className="page-container">
<div className="page-header">
<div>
<h1 className="page-title">{editingId ? '编辑供应商' : '新建供应商'}</h1>
<p className="page-sub">
{editingId ? editingName : '填写基础档案,之后可以随时修改'}
</p>
</div>
<button className="btn" onClick={() => (editingId ? openDetail(editingId) : setMode('list'))}>
返回
</button>
</div>
{notice && <div className="notice-inline">{notice}</div>}
<div className="card" style={{ maxWidth: 860 }}>
<div className="card-header"><h2>基础信息</h2></div>
<div className="form-row">
{FIELDS.map(([key, label, required]) => (
<div className="form-group" key={key}>
<label>{label}{required ? ' *' : ''}</label>
<input
type="text"
value={form[key] ?? ''}
onChange={(e) => setForm((f) => ({ ...f, [key]: e.target.value }))}
/>
</div>
))}
</div>
<div className="form-group">
<label>主营产品类目</label>
<input
type="text"
placeholder="用顿号或逗号分隔,例如:长嘴钳、老虎钳、斜口钳、卡簧钳"
value={form.main_categories}
onChange={(e) => setForm((f) => ({ ...f, main_categories: e.target.value }))}
/>
</div>
<div className="form-group">
<label>备注</label>
<textarea
rows={3}
value={form.notes}
onChange={(e) => setForm((f) => ({ ...f, notes: e.target.value }))}
/>
</div>
<div className="form-actions">
<button className="btn btn-primary" onClick={save} disabled={saving}>
{saving ? '保存中…' : '保存'}
</button>
{editingId && (
<button
className="btn btn-danger"
onClick={() => removeSupplier(editingId, editingName)}
disabled={saving}
>
删除该供应商
</button>
)}
</div>
</div>
</div>
)
}
export default SupplierManagement