Files
happy-export/frontend/src/pages/SupplierManagement.jsx
T
Drsakura 13d67c804f 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
2026-09-17 14:53:02 +08:00

630 lines
24 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 <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 { 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 (
<div className="page-container">
<div className="page-header">
<div>
<h1 className="page-title">供应商管理</h1>
<p className="page-sub">档案、联系方式、工厂所在地与主营类目</p>
</div>
{canEdit && <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 ? (
<SkeletonCards count={8} />
) : 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 || []
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">
<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 && canEdit && <button className="btn btn-primary" onClick={() => startEdit(s)}>编辑档案</button>}
</div>
</div>
{notice && <div className="notice-inline">{notice}</div>}
{detailLoading || !s ? (
<div className="card">
<SkeletonPanel lines={4} />
</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">
{productQuery.trim() ? `${visibleProducts.length} / ${products.length} 条` : `${products.length} 条`}
</span>
</div>
{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>
) : (
<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>
{visibleProducts.map((p) => (
<tr key={`${p.sku}-${p.group_id ?? ''}`}>
<td>
<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>
<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">
{canEdit && (
<button className="btn btn-primary" onClick={save} disabled={saving}>
{saving ? '保存中…' : '保存'}
</button>
)}
{editingId && canEdit && (
<button
className="btn btn-danger"
onClick={() => removeSupplier(editingId, editingName)}
disabled={saving}
>
删除该供应商
</button>
)}
</div>
</div>
</div>
)
}
export default SupplierManagement