本地长期开发成果的整体入库快照,包含后端鉴权/权限、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
630 lines
24 KiB
React
630 lines
24 KiB
React
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
|