Update App.jsx via upload script - 2026-08-21 17:23:49
This commit is contained in:
parent
d493486b76
commit
fb9bf6dbe2
257
App.jsx
257
App.jsx
|
|
@ -5,7 +5,7 @@ import {
|
||||||
Building2, Globe, Phone, Save, RefreshCw, LayoutDashboard, Users,
|
Building2, Globe, Phone, Save, RefreshCw, LayoutDashboard, Users,
|
||||||
Package, Layers, Zap, Tag, Inbox, Paperclip,
|
Package, Layers, Zap, Tag, Inbox, Paperclip,
|
||||||
Menu, LogOut, GripVertical, BookOpen, FileText, FolderTree, Lock, Sparkles, Printer, Image as ImageIcon,
|
Menu, LogOut, GripVertical, BookOpen, FileText, FolderTree, Lock, Sparkles, Printer, Image as ImageIcon,
|
||||||
ShieldCheck, Settings2, Boxes, Download, Wifi, Radio, QrCode, Folder, FolderPlus
|
ShieldCheck, Settings2, Boxes, Download, Wifi, Radio, QrCode, Folder, FolderPlus, Factory
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
storageGet, storageSet, uploadFile, deleteFile,
|
storageGet, storageSet, uploadFile, deleteFile,
|
||||||
|
|
@ -245,7 +245,7 @@ const COUNTRIES = [
|
||||||
|
|
||||||
const COUNTRY_BY_NAME = Object.fromEntries(COUNTRIES.map(c => [c.name, c]));
|
const COUNTRY_BY_NAME = Object.fromEntries(COUNTRIES.map(c => [c.name, c]));
|
||||||
|
|
||||||
const STORAGE_KEYS = { customers: 'crm:customers', products: 'crm:products', quotes: 'crm:quotes', sellerProfiles: 'crm:sellerProfiles', certifications: 'crm:certifications', productCategories: 'crm:productCategories', packingLists: 'crm:packingLists', catalogDocs: 'crm:catalogDocs', jmsLines: 'crm:jmsLines', backPanelTemplates: 'crm:backPanelTemplates' };
|
const STORAGE_KEYS = { customers: 'crm:customers', products: 'crm:products', quotes: 'crm:quotes', sellerProfiles: 'crm:sellerProfiles', certifications: 'crm:certifications', productCategories: 'crm:productCategories', packingLists: 'crm:packingLists', catalogDocs: 'crm:catalogDocs', jmsLines: 'crm:jmsLines', backPanelTemplates: 'crm:backPanelTemplates', suppliers: 'crm:suppliers' };
|
||||||
|
|
||||||
const blankCustomer = () => ({
|
const blankCustomer = () => ({
|
||||||
id: 'c_' + Date.now() + '_' + Math.random().toString(36).slice(2, 7),
|
id: 'c_' + Date.now() + '_' + Math.random().toString(36).slice(2, 7),
|
||||||
|
|
@ -262,6 +262,13 @@ const blankCustomer = () => ({
|
||||||
fileFolders: [], // 虚拟文件夹:{id, name, parentId},parentId为null表示根目录下的文件夹
|
fileFolders: [], // 虚拟文件夹:{id, name, parentId},parentId为null表示根目录下的文件夹
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const blankSupplier = () => ({
|
||||||
|
id: 'sup_' + Date.now() + '_' + Math.random().toString(36).slice(2, 7),
|
||||||
|
name: '', contact: '', phone: '', email: '', wechatOrWhatsapp: '', address: '', website: '',
|
||||||
|
mainProducts: '', notes: '',
|
||||||
|
attachments: [], // 厂家资料:PDF/图片等,{fileName, originalName, note, date}
|
||||||
|
});
|
||||||
|
|
||||||
const OUTPUT_PORT_TYPES = ['USB', 'DC', 'DC1', 'DC2', 'DC3', 'POE', 'Type-C', 'DC interface', 'POE interface'];
|
const OUTPUT_PORT_TYPES = ['USB', 'DC', 'DC1', 'DC2', 'DC3', 'POE', 'Type-C', 'DC interface', 'POE interface'];
|
||||||
|
|
||||||
const EXPORT_LANGUAGES = [
|
const EXPORT_LANGUAGES = [
|
||||||
|
|
@ -1424,7 +1431,12 @@ async function loadAll() {
|
||||||
const bpt = await storageGet(STORAGE_KEYS.backPanelTemplates);
|
const bpt = await storageGet(STORAGE_KEYS.backPanelTemplates);
|
||||||
if (bpt && bpt.value) backPanelTemplates = JSON.parse(bpt.value);
|
if (bpt && bpt.value) backPanelTemplates = JSON.parse(bpt.value);
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
return { customers, products, quotes, sellerProfiles, certifications, productCategories, packingLists, catalogDocs, jmsLines, backPanelTemplates };
|
let suppliers = [];
|
||||||
|
try {
|
||||||
|
const sup = await storageGet(STORAGE_KEYS.suppliers);
|
||||||
|
if (sup && sup.value) suppliers = JSON.parse(sup.value);
|
||||||
|
} catch (e) {}
|
||||||
|
return { customers, products, quotes, sellerProfiles, certifications, productCategories, packingLists, catalogDocs, jmsLines, backPanelTemplates, suppliers };
|
||||||
}
|
}
|
||||||
|
|
||||||
/* -------------------------------- styles -------------------------------- */
|
/* -------------------------------- styles -------------------------------- */
|
||||||
|
|
@ -1966,6 +1978,7 @@ const NAV_GROUPS = [
|
||||||
];
|
];
|
||||||
const STANDALONE_NAV = [
|
const STANDALONE_NAV = [
|
||||||
{ id: 'certs', label: '授权管理', icon: ShieldCheck },
|
{ id: 'certs', label: '授权管理', icon: ShieldCheck },
|
||||||
|
{ id: 'suppliers', label: '供应商名录', icon: Factory },
|
||||||
{ id: 'catalog', label: '产品目录', icon: BookOpen },
|
{ id: 'catalog', label: '产品目录', icon: BookOpen },
|
||||||
{ id: 'quotes', label: '报价单', icon: FileText },
|
{ id: 'quotes', label: '报价单', icon: FileText },
|
||||||
{ id: 'packing', label: '箱单', icon: Boxes },
|
{ id: 'packing', label: '箱单', icon: Boxes },
|
||||||
|
|
@ -2119,6 +2132,17 @@ function MainApp({ username, onLogout }) {
|
||||||
const [certUploading, setCertUploading] = useState(false);
|
const [certUploading, setCertUploading] = useState(false);
|
||||||
const [editingCertId, setEditingCertId] = useState(null);
|
const [editingCertId, setEditingCertId] = useState(null);
|
||||||
const [certBrandDraft, setCertBrandDraft] = useState('');
|
const [certBrandDraft, setCertBrandDraft] = useState('');
|
||||||
|
|
||||||
|
const [suppliers, setSuppliers] = useState([]);
|
||||||
|
const [selectedSupplierId, setSelectedSupplierId] = useState(null);
|
||||||
|
const [supplierDraft, setSupplierDraft] = useState(null);
|
||||||
|
const [supplierSearch, setSupplierSearch] = useState('');
|
||||||
|
const [supplierAttachmentNote, setSupplierAttachmentNote] = useState('');
|
||||||
|
const [supplierAttachmentFile, setSupplierAttachmentFile] = useState(null);
|
||||||
|
const [supplierUploading, setSupplierUploading] = useState(false);
|
||||||
|
const [supplierFileInputKey, setSupplierFileInputKey] = useState(0);
|
||||||
|
const [supplierFileDragActive, setSupplierFileDragActive] = useState(false);
|
||||||
|
|
||||||
const [quoteSubView, setQuoteSubView] = useState('list'); // 'list' | 'editor'
|
const [quoteSubView, setQuoteSubView] = useState('list'); // 'list' | 'editor'
|
||||||
const [quoteDraft, setQuoteDraft] = useState(null);
|
const [quoteDraft, setQuoteDraft] = useState(null);
|
||||||
const [quoteTypeFilter, setQuoteTypeFilter] = useState('all');
|
const [quoteTypeFilter, setQuoteTypeFilter] = useState('all');
|
||||||
|
|
@ -2161,6 +2185,7 @@ function MainApp({ username, onLogout }) {
|
||||||
setCatalogDocs((data.catalogDocs || []).filter(d => d.fileName));
|
setCatalogDocs((data.catalogDocs || []).filter(d => d.fileName));
|
||||||
setJmsLines(data.jmsLines || []);
|
setJmsLines(data.jmsLines || []);
|
||||||
setBackPanelTemplates(data.backPanelTemplates || []);
|
setBackPanelTemplates(data.backPanelTemplates || []);
|
||||||
|
setSuppliers(data.suppliers || []);
|
||||||
setLoaded(true);
|
setLoaded(true);
|
||||||
})();
|
})();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
@ -2174,6 +2199,7 @@ function MainApp({ username, onLogout }) {
|
||||||
useEffect(() => { if (loaded) storageSet(STORAGE_KEYS.catalogDocs, JSON.stringify(catalogDocs)).catch(() => {}); }, [catalogDocs, loaded]);
|
useEffect(() => { if (loaded) storageSet(STORAGE_KEYS.catalogDocs, JSON.stringify(catalogDocs)).catch(() => {}); }, [catalogDocs, loaded]);
|
||||||
useEffect(() => { if (loaded) storageSet(STORAGE_KEYS.jmsLines, JSON.stringify(jmsLines)).catch(() => {}); }, [jmsLines, loaded]);
|
useEffect(() => { if (loaded) storageSet(STORAGE_KEYS.jmsLines, JSON.stringify(jmsLines)).catch(() => {}); }, [jmsLines, loaded]);
|
||||||
useEffect(() => { if (loaded) storageSet(STORAGE_KEYS.backPanelTemplates, JSON.stringify(backPanelTemplates)).catch(() => {}); }, [backPanelTemplates, loaded]);
|
useEffect(() => { if (loaded) storageSet(STORAGE_KEYS.backPanelTemplates, JSON.stringify(backPanelTemplates)).catch(() => {}); }, [backPanelTemplates, loaded]);
|
||||||
|
useEffect(() => { if (loaded) storageSet(STORAGE_KEYS.suppliers, JSON.stringify(suppliers)).catch(() => {}); }, [suppliers, loaded]);
|
||||||
|
|
||||||
function notify(msg) { setToast(msg); setTimeout(() => setToast(''), 2200); }
|
function notify(msg) { setToast(msg); setTimeout(() => setToast(''), 2200); }
|
||||||
|
|
||||||
|
|
@ -2186,6 +2212,7 @@ function MainApp({ username, onLogout }) {
|
||||||
|
|
||||||
const selected = customers.find(c => c.id === selectedId) || null;
|
const selected = customers.find(c => c.id === selectedId) || null;
|
||||||
const selectedProduct = products.find(p => p.id === selectedProductId) || null;
|
const selectedProduct = products.find(p => p.id === selectedProductId) || null;
|
||||||
|
const selectedSupplier = suppliers.find(s => s.id === selectedSupplierId) || null;
|
||||||
const [productExportLang, setProductExportLang] = useState('zh');
|
const [productExportLang, setProductExportLang] = useState('zh');
|
||||||
const [productExportLabels, setProductExportLabels] = useState({});
|
const [productExportLabels, setProductExportLabels] = useState({});
|
||||||
const [productExportLoading, setProductExportLoading] = useState(false);
|
const [productExportLoading, setProductExportLoading] = useState(false);
|
||||||
|
|
@ -2234,6 +2261,19 @@ function MainApp({ username, onLogout }) {
|
||||||
return [...list].sort((a, b) => categoryBreadcrumb(a, productCategories).localeCompare(categoryBreadcrumb(b, productCategories)) || (a.name || '').localeCompare(b.name || ''));
|
return [...list].sort((a, b) => categoryBreadcrumb(a, productCategories).localeCompare(categoryBreadcrumb(b, productCategories)) || (a.name || '').localeCompare(b.name || ''));
|
||||||
}, [products, productSearch, productCategoryFilter, productCategories]);
|
}, [products, productSearch, productCategoryFilter, productCategories]);
|
||||||
|
|
||||||
|
const filteredSuppliers = useMemo(() => {
|
||||||
|
let list = suppliers;
|
||||||
|
if (supplierSearch.trim()) {
|
||||||
|
const q = supplierSearch.trim().toLowerCase();
|
||||||
|
list = list.filter(s =>
|
||||||
|
(s.name || '').toLowerCase().includes(q) ||
|
||||||
|
(s.contact || '').toLowerCase().includes(q) ||
|
||||||
|
(s.mainProducts || '').toLowerCase().includes(q)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return [...list].sort((a, b) => (a.name || '').localeCompare(b.name || ''));
|
||||||
|
}, [suppliers, supplierSearch]);
|
||||||
|
|
||||||
const counts = useMemo(() => ({
|
const counts = useMemo(() => ({
|
||||||
total: customers.length,
|
total: customers.length,
|
||||||
products: products.length,
|
products: products.length,
|
||||||
|
|
@ -2552,6 +2592,62 @@ function MainApp({ username, onLogout }) {
|
||||||
notify('已删除');
|
notify('已删除');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --------------------------------- 供应商名录 --------------------------------- */
|
||||||
|
|
||||||
|
function startNewSupplier() { setSupplierDraft(blankSupplier()); setSelectedSupplierId(null); }
|
||||||
|
function startEditSupplier(s) { setSupplierDraft({ ...s }); setSelectedSupplierId(null); }
|
||||||
|
function cancelSupplierDraft() { setSupplierDraft(null); }
|
||||||
|
function saveSupplierDraft(s) {
|
||||||
|
setSuppliers(prev => {
|
||||||
|
const exists = prev.some(x => x.id === s.id);
|
||||||
|
return exists ? prev.map(x => (x.id === s.id ? s : x)) : [s, ...prev];
|
||||||
|
});
|
||||||
|
setSupplierDraft(null);
|
||||||
|
setSelectedSupplierId(s.id);
|
||||||
|
notify('已保存');
|
||||||
|
}
|
||||||
|
function deleteSupplier(id) {
|
||||||
|
if (!window.confirm('确定删除这家供应商?此操作不可恢复。')) return;
|
||||||
|
setSuppliers(prev => prev.filter(s => s.id !== id));
|
||||||
|
if (selectedSupplierId === id) setSelectedSupplierId(null);
|
||||||
|
notify('已删除');
|
||||||
|
}
|
||||||
|
function handleSupplierFileDragOver(e) { e.preventDefault(); setSupplierFileDragActive(true); }
|
||||||
|
function handleSupplierFileDragLeave() { setSupplierFileDragActive(false); }
|
||||||
|
function handleSupplierFileDrop(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
setSupplierFileDragActive(false);
|
||||||
|
const f = e.dataTransfer.files && e.dataTransfer.files[0];
|
||||||
|
if (f) setSupplierAttachmentFile(f);
|
||||||
|
}
|
||||||
|
async function handleUploadSupplierAttachment(supplierId) {
|
||||||
|
if (!supplierAttachmentFile) { notify('请先选择文件'); return; }
|
||||||
|
setSupplierUploading(true);
|
||||||
|
try {
|
||||||
|
const res = await uploadFile(supplierId, supplierAttachmentFile);
|
||||||
|
setSuppliers(prev => prev.map(s => {
|
||||||
|
if (s.id !== supplierId) return s;
|
||||||
|
const entry = { fileName: res.fileName, originalName: res.originalName, note: supplierAttachmentNote.trim(), date: todayStr() };
|
||||||
|
return { ...s, attachments: [entry, ...(s.attachments || [])] };
|
||||||
|
}));
|
||||||
|
setSupplierAttachmentFile(null);
|
||||||
|
setSupplierAttachmentNote('');
|
||||||
|
setSupplierFileInputKey(k => k + 1);
|
||||||
|
notify('已上传');
|
||||||
|
} catch (e) {
|
||||||
|
notify('上传失败,请重试');
|
||||||
|
} finally {
|
||||||
|
setSupplierUploading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function handleDeleteSupplierAttachment(supplierId, fileName) {
|
||||||
|
if (!window.confirm('确定删除这个文件?此操作不可恢复。')) return;
|
||||||
|
try { await deleteFile(supplierId, fileName); } catch (e) {}
|
||||||
|
setSuppliers(prev => prev.map(s => (
|
||||||
|
s.id === supplierId ? { ...s, attachments: (s.attachments || []).filter(a => a.fileName !== fileName) } : s
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
/* --------------------------------- Just My Socks --------------------------------- */
|
/* --------------------------------- Just My Socks --------------------------------- */
|
||||||
|
|
||||||
function saveJmsLine(line, editingId) {
|
function saveJmsLine(line, editingId) {
|
||||||
|
|
@ -3579,6 +3675,56 @@ function MainApp({ username, onLogout }) {
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{view === 'suppliers' && (
|
||||||
|
<div className="crm-layout">
|
||||||
|
<div>
|
||||||
|
<div className="search-box"><Search size={14} color="var(--ink-soft)" /><input placeholder="搜索厂家名称 / 联系人 / 主营产品" value={supplierSearch} onChange={e => setSupplierSearch(e.target.value)} /></div>
|
||||||
|
<button className="btn btn-primary" style={{ width: '100%', justifyContent: 'center', marginBottom: 12 }} onClick={startNewSupplier}><Plus size={14} />新建供应商</button>
|
||||||
|
<div className="panel">
|
||||||
|
<div className="panel-body">
|
||||||
|
{filteredSuppliers.length === 0 ? (
|
||||||
|
<div className="empty-state"><Factory size={26} />暂无供应商,点击上方按钮添加第一个</div>
|
||||||
|
) : filteredSuppliers.map(s => (
|
||||||
|
<div key={s.id} className="row" style={{ cursor: 'pointer', background: selectedSupplierId === s.id ? '#FAFBFC' : undefined }} onClick={() => { setSupplierDraft(null); setSelectedSupplierId(s.id); }}>
|
||||||
|
<div className="row-main">
|
||||||
|
<div className="row-title">{s.name || '(未命名)'}</div>
|
||||||
|
<div className="row-sub">{s.contact && `${s.contact} · `}{s.mainProducts || '—'}</div>
|
||||||
|
</div>
|
||||||
|
<ChevronRight size={15} color="var(--ink-soft)" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
{supplierDraft ? (
|
||||||
|
<SupplierForm draft={supplierDraft} setDraft={setSupplierDraft} onSave={saveSupplierDraft} onCancel={cancelSupplierDraft} />
|
||||||
|
) : selectedSupplier ? (
|
||||||
|
<SupplierDetail
|
||||||
|
supplier={selectedSupplier}
|
||||||
|
onEdit={() => startEditSupplier(selectedSupplier)}
|
||||||
|
onDelete={() => deleteSupplier(selectedSupplier.id)}
|
||||||
|
attachmentNote={supplierAttachmentNote}
|
||||||
|
setAttachmentNote={setSupplierAttachmentNote}
|
||||||
|
attachmentFileName={supplierAttachmentFile ? supplierAttachmentFile.name : ''}
|
||||||
|
onPickAttachment={(f) => setSupplierAttachmentFile(f)}
|
||||||
|
uploading={supplierUploading}
|
||||||
|
fileInputKey={supplierFileInputKey}
|
||||||
|
onUploadAttachment={() => handleUploadSupplierAttachment(selectedSupplier.id)}
|
||||||
|
onDeleteAttachment={(fileName) => handleDeleteSupplierAttachment(selectedSupplier.id, fileName)}
|
||||||
|
fileDragActive={supplierFileDragActive}
|
||||||
|
onFileDragOver={handleSupplierFileDragOver}
|
||||||
|
onFileDragLeave={handleSupplierFileDragLeave}
|
||||||
|
onFileDrop={handleSupplierFileDrop}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="panel"><div className="empty-state"><Factory size={28} />选择左侧供应商查看详情,或新建一个供应商档案</div></div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{view === 'quotes' && quoteSubView === 'list' && (
|
{view === 'quotes' && quoteSubView === 'list' && (
|
||||||
<>
|
<>
|
||||||
<div style={{ display: 'flex', gap: 8, marginBottom: 14, flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', gap: 8, marginBottom: 14, flexWrap: 'wrap' }}>
|
||||||
|
|
@ -3761,10 +3907,10 @@ function MainApp({ username, onLogout }) {
|
||||||
<div className="modal-box" onClick={e => e.stopPropagation()}>
|
<div className="modal-box" onClick={e => e.stopPropagation()}>
|
||||||
<div className="modal-head"><div className="panel-title">数据备份(JSON)</div><button className="btn btn-ghost btn-sm" onClick={() => setExportOpen(false)}><X size={14} /></button></div>
|
<div className="modal-head"><div className="panel-title">数据备份(JSON)</div><button className="btn btn-ghost btn-sm" onClick={() => setExportOpen(false)}><X size={14} /></button></div>
|
||||||
<div className="modal-body">
|
<div className="modal-body">
|
||||||
<textarea className="textarea" rows={14} readOnly value={JSON.stringify({ customers, products, quotes, sellerProfiles, certifications, productCategories, packingLists, catalogDocs }, null, 2)} />
|
<textarea className="textarea" rows={14} readOnly value={JSON.stringify({ customers, products, quotes, sellerProfiles, certifications, productCategories, packingLists, catalogDocs, suppliers }, null, 2)} />
|
||||||
</div>
|
</div>
|
||||||
<div className="modal-foot">
|
<div className="modal-foot">
|
||||||
<button className="btn btn-primary" onClick={() => copyText(JSON.stringify({ customers, products, quotes, sellerProfiles, certifications, productCategories, packingLists, catalogDocs }, null, 2))}><Copy size={12} />复制全部</button>
|
<button className="btn btn-primary" onClick={() => copyText(JSON.stringify({ customers, products, quotes, sellerProfiles, certifications, productCategories, packingLists, catalogDocs, suppliers }, null, 2))}><Copy size={12} />复制全部</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -3997,6 +4143,107 @@ function MainApp({ username, onLogout }) {
|
||||||
|
|
||||||
/* ----------------------------- customer form / detail ----------------------------- */
|
/* ----------------------------- customer form / detail ----------------------------- */
|
||||||
|
|
||||||
|
function SupplierForm({ draft, setDraft, onSave, onCancel }) {
|
||||||
|
const set = (patch) => setDraft(d => ({ ...d, ...patch }));
|
||||||
|
return (
|
||||||
|
<div className="panel">
|
||||||
|
<div className="panel-header"><div className="panel-title"><Edit2 size={14} />{draft.name ? '编辑供应商' : '新建供应商'}</div></div>
|
||||||
|
<div className="panel-body" style={{ padding: 18 }}>
|
||||||
|
<div className="grid-2">
|
||||||
|
<div className="field"><span className="field-label">厂家 / 公司名称</span><input className="input" value={draft.name} onChange={e => set({ name: e.target.value })} /></div>
|
||||||
|
<div className="field"><span className="field-label">联系人</span><input className="input" value={draft.contact} onChange={e => set({ contact: e.target.value })} /></div>
|
||||||
|
<div className="field"><span className="field-label">电话</span><input className="input" value={draft.phone} onChange={e => set({ phone: e.target.value })} /></div>
|
||||||
|
<div className="field"><span className="field-label">邮箱</span><input className="input" value={draft.email} onChange={e => set({ email: e.target.value })} /></div>
|
||||||
|
<div className="field"><span className="field-label">微信 / WhatsApp</span><input className="input" value={draft.wechatOrWhatsapp} onChange={e => set({ wechatOrWhatsapp: e.target.value })} /></div>
|
||||||
|
<div className="field"><span className="field-label">网站</span><input className="input" value={draft.website} onChange={e => set({ website: e.target.value })} placeholder="https://" /></div>
|
||||||
|
</div>
|
||||||
|
<div className="field"><span className="field-label">地址</span><input className="input" value={draft.address} onChange={e => set({ address: e.target.value })} /></div>
|
||||||
|
<div className="field"><span className="field-label">主营产品</span><input className="input" value={draft.mainProducts} onChange={e => set({ mainProducts: e.target.value })} placeholder="如:UPS电源、锂电池模块" /></div>
|
||||||
|
<div className="field"><span className="field-label">备注</span><textarea className="textarea" style={{ fontFamily: 'inherit', fontSize: 13 }} value={draft.notes} onChange={e => set({ notes: e.target.value })} /></div>
|
||||||
|
</div>
|
||||||
|
<div className="modal-foot" style={{ borderTop: '1px solid var(--line)', padding: 14 }}>
|
||||||
|
<button className="btn" onClick={onCancel}>取消</button>
|
||||||
|
<button className="btn btn-primary" onClick={() => onSave({ ...draft, name: draft.name.trim() })} disabled={!draft.name.trim()}><Save size={13} />保存</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SupplierDetail({
|
||||||
|
supplier: s, onEdit, onDelete,
|
||||||
|
attachmentNote, setAttachmentNote, attachmentFileName, onPickAttachment, uploading, fileInputKey,
|
||||||
|
onUploadAttachment, onDeleteAttachment, fileDragActive, onFileDragOver, onFileDragLeave, onFileDrop,
|
||||||
|
}) {
|
||||||
|
const attachments = s.attachments || [];
|
||||||
|
return (
|
||||||
|
<div className="panel">
|
||||||
|
<div className="panel-header">
|
||||||
|
<div className="panel-title">{s.name || '(未命名)'}</div>
|
||||||
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
|
<button className="btn btn-sm" onClick={onEdit}><Edit2 size={12} />编辑</button>
|
||||||
|
<button className="btn btn-sm btn-ghost btn-danger" onClick={onDelete}><Trash2 size={12} /></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="panel-body" style={{ padding: 18 }}>
|
||||||
|
<div className="grid-2" style={{ marginBottom: 16 }}>
|
||||||
|
<Info icon={<Building2 size={13} />} label="联系人" value={s.contact || '—'} />
|
||||||
|
<Info icon={<Phone size={13} />} label="电话" value={s.phone || '—'} />
|
||||||
|
<Info icon={<Mail size={13} />} label="邮箱" value={s.email || '—'} />
|
||||||
|
<Info icon={<Phone size={13} />} label="微信 / WhatsApp" value={s.wechatOrWhatsapp || '—'} />
|
||||||
|
<Info icon={<Globe size={13} />} label="网站" value={s.website ? <a href={s.website} target="_blank" rel="noreferrer" style={{ color: 'var(--blue)' }}>{s.website}</a> : '—'} />
|
||||||
|
<Info icon={<Building2 size={13} />} label="地址" value={s.address || '—'} />
|
||||||
|
</div>
|
||||||
|
{s.mainProducts && <div className="field"><span className="field-label">主营产品</span><div style={{ fontSize: 12.5 }}>{s.mainProducts}</div></div>}
|
||||||
|
{s.notes && <div className="field"><span className="field-label">备注</span><div style={{ fontSize: 12.5, whiteSpace: 'pre-wrap' }}>{s.notes}</div></div>}
|
||||||
|
|
||||||
|
<div className="field">
|
||||||
|
<span className="field-label">厂家资料(PDF / 图片等)</span>
|
||||||
|
<div style={{ display: 'flex', gap: 8, alignItems: 'stretch', flexWrap: 'wrap', marginBottom: 8 }}>
|
||||||
|
<label
|
||||||
|
className={`dropzone ${fileDragActive ? 'drag-active' : ''}`}
|
||||||
|
onDragOver={onFileDragOver} onDragLeave={onFileDragLeave} onDrop={onFileDrop}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
key={fileInputKey} type="file"
|
||||||
|
onChange={e => onPickAttachment(e.target.files && e.target.files[0])}
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
/>
|
||||||
|
<Paperclip size={15} style={{ marginBottom: 4 }} />
|
||||||
|
<div>{fileDragActive ? '松开鼠标完成选择' : attachmentFileName ? `已选择:${attachmentFileName}` : '点击选择文件,或把文件拖到这里'}</div>
|
||||||
|
</label>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, flex: '1 1 160px' }}>
|
||||||
|
<input
|
||||||
|
className="input" placeholder="备注(可选,如:2026版价格表)"
|
||||||
|
value={attachmentNote} onChange={e => setAttachmentNote(e.target.value)}
|
||||||
|
/>
|
||||||
|
<button className="btn btn-sm" disabled={uploading} onClick={onUploadAttachment} style={{ justifyContent: 'center' }}>
|
||||||
|
<Plus size={12} />{uploading ? '上传中…' : '上传'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<CollapsibleSection title="文件列表" count={attachments.length} defaultOpen={attachments.length > 0}>
|
||||||
|
{attachments.length === 0 ? (
|
||||||
|
<div className="row-sub" style={{ padding: '8px 0' }}>暂无文件</div>
|
||||||
|
) : attachments.map((a, i) => (
|
||||||
|
<div key={a.fileName + i} style={{ display: 'flex', gap: 10, padding: '8px 0', borderBottom: '1px solid var(--line)', alignItems: 'flex-start' }}>
|
||||||
|
<Paperclip size={13} color="var(--ink-soft)" style={{ marginTop: 2, flexShrink: 0 }} />
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<span className="crm-mono" style={{ fontSize: 11.5, color: 'var(--ink-soft)' }}>{a.date}</span>
|
||||||
|
<div style={{ fontSize: 12.5, wordBreak: 'break-word', marginTop: 2 }}>
|
||||||
|
<a href={`/uploads/${s.id}/${a.fileName}`} target="_blank" rel="noreferrer" download={a.originalName} style={{ color: 'var(--blue)' }}>{a.originalName}</a>
|
||||||
|
{a.note && <span style={{ color: 'var(--ink-soft)' }}> — {a.note}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button className="btn btn-sm btn-ghost btn-danger" onClick={() => onDeleteAttachment(a.fileName)}><Trash2 size={12} /></button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</CollapsibleSection>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function CustomerForm({ draft, setDraft, onSave, onCancel }) {
|
function CustomerForm({ draft, setDraft, onSave, onCancel }) {
|
||||||
const set = (patch) => setDraft(d => ({ ...d, ...patch }));
|
const set = (patch) => setDraft(d => ({ ...d, ...patch }));
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue