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
+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