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 模块已并入后端),禁止硬编码独立端口。 */ 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) { const list = String(text || '') .split(/[、,,;;/|\s]+/) .map((t) => t.trim()) .filter(Boolean) /* 源数据里同一类目可能重复登记(如「游标卡尺;游标卡尺」), 去重后再渲染,顺带避免 React 因 key 重复而发出警告。 */ return [...new Set(list)] } /** 从完整地址里抽出省市,卡片上只显示大致方位 */ 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
未填主营类目
return (
{cats.map((c) => {c})}
) } function Kv({ label, value, mono }) { return (
{label} {value || '未填写'}
) } /* -------------------------------- 主页面 -------------------------------- */ 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([]) 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 [productQuery, setProductQuery] = useState('') 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('') setProductQuery('') 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 (

供应商管理

档案、联系方式、工厂所在地与主营类目

{canEdit && }
{notice &&
{notice}
}
setKeyword(e.target.value)} />
共 {filtered.length} 家
{loading ? ( ) : suppliers.length === 0 ? (
🏭
还没有供应商档案
建好供应商后,导入合同时就能把价格归到对应供应商名下,查价时也能按供应商筛选。
) : viewMode === 'table' ? (
{unassigned > 0 && ( navigate('/products')}> )} {filtered.map((s) => ( openDetail(s.id)}> ))}
供应商 联系人 电话 工厂所在地 主营类目 产品 货号 付款条件
未分类 还没归属供应商的产品 {unassigned} — —
{s.name} {s.contact_person || '—'} {s.phone || '—'} {shortLocation(s.address) || '—'} {splitCats(s.main_categories).length ?
{splitCats(s.main_categories).map((c) => {c})}
: 未填}
{s.group_count ?? s.sku_count ?? 0} {s.sku_count ?? 0} {s.payment_terms || '—'}
) : (
{unassigned > 0 && (
navigate('/products')}>
未分类
还没归属供应商的产品
{unassigned} 个产品
)} {filtered.map((s) => { const contact = [s.contact_person, s.phone].filter(Boolean).join(' · ') const loc = shortLocation(s.address) return (
openDetail(s.id)}>
{s.name}
{contact || 未填联系人}
{loc || 未填工厂所在地}
{s.group_count ?? s.sku_count} 个产品 · {s.sku_count} 个货号 {s.payment_terms && {s.payment_terms}}
) })}
)}
) } /* ------------------------------- 详情视图 ------------------------------- */ if (mode === 'detail') { 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 (

{s?.name || '供应商详情'}

{s && (

{s.short_name ? `${s.short_name} · ` : ''} 供货 {products.length} 个货号 · {contracts.length} 份合同记录

)} {s && }
{s && canEdit && }
{notice &&
{notice}
} {detailLoading || !s ? (
) : ( <>

联系方式

{s.notes && (
)}

供货清单

{productQuery.trim() ? `${visibleProducts.length} / ${products.length} 条` : `${products.length} 条`}
{products.length > 1 && (
)} {visibleProducts.length === 0 ? (
{products.length === 0 ? '还没有归到这家供应商的货号。导入合同时在「合同导入」页选上这家供应商即可。' : '没有匹配的货号,换个关键词试试。'}
) : (
{visibleProducts.map((p) => ( ))}
货号 所属产品 采购价 起订量 更新时间
{p.display_sku || p.sku} {p.group_name || p.name || '—'} ¥{money(p.price)} {p.confidence === 'low' && 待核对} {p.moq ?? '—'} {fmtDate(p.last_updated, false)}
)}

合同导入记录

{contracts.length} 条
{contracts.length === 0 ? (
还没有该供应商的合同导入记录。
) : (
{contracts.map((c) => ( ))}
文件名 类型 新增 更新 状态 时间
{c.has_file ? {c.filename} : c.filename} {FILE_TYPE_LABEL[c.file_type] || c.file_type} {c.rows_new} {c.rows_matched} {STATUS_LABEL[c.status] || c.status} {fmtDate(c.processed_at)}
)}
)}
) } /* ------------------------------- 表单视图 ------------------------------- */ return (

{editingId ? '编辑供应商' : '新建供应商'}

{editingId ? editingName : '填写基础档案,之后可以随时修改'}

{notice &&
{notice}
}

基础信息

{FIELDS.map(([key, label, required]) => (
setForm((f) => ({ ...f, [key]: e.target.value }))} />
))}
setForm((f) => ({ ...f, main_categories: e.target.value }))} />