Update App.jsx via upload script - 2026-08-21 17:23:49

This commit is contained in:
mike 2026-08-21 17:24:08 +08:00
parent d493486b76
commit fb9bf6dbe2
1 changed files with 252 additions and 5 deletions

257
App.jsx
View File

@ -5,7 +5,7 @@ import {
Building2, Globe, Phone, Save, RefreshCw, LayoutDashboard, Users,
Package, Layers, Zap, Tag, Inbox, Paperclip,
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';
import {
storageGet, storageSet, uploadFile, deleteFile,
@ -245,7 +245,7 @@ const COUNTRIES = [
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 = () => ({
id: 'c_' + Date.now() + '_' + Math.random().toString(36).slice(2, 7),
@ -262,6 +262,13 @@ const blankCustomer = () => ({
fileFolders: [], // {id, name, parentId}parentIdnull
});
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 EXPORT_LANGUAGES = [
@ -1424,7 +1431,12 @@ async function loadAll() {
const bpt = await storageGet(STORAGE_KEYS.backPanelTemplates);
if (bpt && bpt.value) backPanelTemplates = JSON.parse(bpt.value);
} 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 -------------------------------- */
@ -1966,6 +1978,7 @@ const NAV_GROUPS = [
];
const STANDALONE_NAV = [
{ id: 'certs', label: '授权管理', icon: ShieldCheck },
{ id: 'suppliers', label: '供应商名录', icon: Factory },
{ id: 'catalog', label: '产品目录', icon: BookOpen },
{ id: 'quotes', label: '报价单', icon: FileText },
{ id: 'packing', label: '箱单', icon: Boxes },
@ -2119,6 +2132,17 @@ function MainApp({ username, onLogout }) {
const [certUploading, setCertUploading] = useState(false);
const [editingCertId, setEditingCertId] = useState(null);
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 [quoteDraft, setQuoteDraft] = useState(null);
const [quoteTypeFilter, setQuoteTypeFilter] = useState('all');
@ -2161,6 +2185,7 @@ function MainApp({ username, onLogout }) {
setCatalogDocs((data.catalogDocs || []).filter(d => d.fileName));
setJmsLines(data.jmsLines || []);
setBackPanelTemplates(data.backPanelTemplates || []);
setSuppliers(data.suppliers || []);
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.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.suppliers, JSON.stringify(suppliers)).catch(() => {}); }, [suppliers, loaded]);
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 selectedProduct = products.find(p => p.id === selectedProductId) || null;
const selectedSupplier = suppliers.find(s => s.id === selectedSupplierId) || null;
const [productExportLang, setProductExportLang] = useState('zh');
const [productExportLabels, setProductExportLabels] = useState({});
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 || ''));
}, [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(() => ({
total: customers.length,
products: products.length,
@ -2552,6 +2592,62 @@ function MainApp({ username, onLogout }) {
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 --------------------------------- */
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' && (
<>
<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-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">
<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 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>
@ -3997,6 +4143,107 @@ function MainApp({ username, onLogout }) {
/* ----------------------------- 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 }) {
const set = (patch) => setDraft(d => ({ ...d, ...patch }));