Update App.jsx via upload script - 2026-08-13 13:53:13

This commit is contained in:
mike 2026-08-13 13:53:33 +08:00
parent 4b8747d72f
commit 9a66d7c333
1 changed files with 268 additions and 242 deletions

510
App.jsx
View File

@ -4,13 +4,13 @@ import {
Settings as SettingsIcon, Copy, ChevronRight,
Building2, Globe, Phone, Save, RefreshCw, LayoutDashboard, Users,
Package, Layers, Zap, Tag, Inbox, Paperclip,
Menu, LogOut, GripVertical, BookOpen, FileText, FolderTree, Lock, Crown, Sparkles, Printer, Image as ImageIcon,
ShieldCheck, Settings2, Boxes, Download, Wifi, Radio, QrCode
Menu, LogOut, GripVertical, BookOpen, FileText, FolderTree, Lock, Sparkles, Printer, Image as ImageIcon,
ShieldCheck, Settings2, Boxes, Download, Wifi, Radio, QrCode, Folder, FolderPlus
} from 'lucide-react';
import {
storageGet, storageSet, uploadFile, deleteFile,
authCheck, authLogin, authLogout, authChangePassword,
getAiConfig, saveAiConfig, deleteAiConfig, testAiConfig, generateAiDraft,
getAiConfig, saveAiConfig, deleteAiConfig, testAiConfig,
parseCatalogFiles, translateTerms,
} from './storage';
@ -79,7 +79,6 @@ function buildDefaultProductCategories() {
}));
}
const DEFAULT_SETTINGS = { defaultFollowUpDays: 14, dueSoonWindow: 7 };
const CUSTOMER_SOURCES = ['Made-in-China', '官网', '展会', 'Other'];
const FILE_CATEGORIES = ['报价单', '合同', '聊天记录', '产品资料', '物流单据', 'Other'];
@ -246,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', settings: 'crm:settings', 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' };
const blankCustomer = () => ({
id: 'c_' + Date.now() + '_' + Math.random().toString(36).slice(2, 7),
@ -257,9 +256,10 @@ const blankCustomer = () => ({
website: '',
productLines: [], intendedModel: '', intendedQty: '',
source: '',
tags: '', notes: '', lastContact: '', followUpDays: '',
tags: '', notes: '',
warehouseAddress: '', freightContact: '', shippingMarkNote: '',
attachments: [],
fileFolders: [], // {id, name, parentId}parentIdnull
});
const OUTPUT_PORT_TYPES = ['USB', 'DC', 'DC1', 'DC2', 'DC3', 'POE', 'Type-C', 'DC interface', 'POE interface'];
@ -1277,23 +1277,6 @@ function todayStr() {
const d = new Date();
return d.toISOString().slice(0, 10);
}
function daysSince(dateStr) {
if (!dateStr) return Infinity;
const last = new Date(dateStr + 'T00:00:00');
const today = new Date(); today.setHours(0, 0, 0, 0);
return Math.floor((today - last) / 86400000);
}
function statusOf(customer, settings) {
if (!customer.lastContact) return 'overdue';
const d = daysSince(customer.lastContact);
const interval = Number(customer.followUpDays) || settings.defaultFollowUpDays || 30;
if (d >= interval) return 'overdue';
if (d >= interval - (settings.dueSoonWindow || 7)) return 'due';
return 'ok';
}
function statusLabel(s) {
return s === 'overdue' ? '已逾期' : s === 'due' ? '即将到期' : '状态良好';
}
function regionLabel(id) {
return (REGIONS.find(r => r.id === id) || {}).label || id;
}
@ -1336,9 +1319,13 @@ function reorderWithinCategory(products, category, fromIndex, toIndex) {
let i = 0;
return products.map(p => (p.category === category ? reordered[i++] : p));
}
function fmtDate(d) {
if (!d) return '—';
return d;
// VIPVIP""
// customers
function reorderCustomerGroup(customers, vipFlag, fromIndex, toIndex) {
const groupItems = customers.filter(c => !!c.isVip === vipFlag);
const reordered = reorderArray(groupItems, fromIndex, toIndex);
let i = 0;
return customers.map(c => (!!c.isVip === vipFlag ? reordered[i++] : c));
}
function categoryBreadcrumb(p, categories) {
const cat = (categories || []).find(c => c.id === p.category);
@ -1380,29 +1367,12 @@ function normalizeProductTaxonomy(products, categories) {
return { products: migratedProducts, categories: cats };
}
function generateFollowUpEmail(customer) {
const name = customer.contact || customer.company || 'there';
const product = customer.productLines && customer.productLines.length ? customer.productLines.join(', ') : 'our products';
const subject = `Following up ${customer.company || name}`;
const body = `Dear ${name},
I hope this message finds you well. It has been a little while since we last connected, and I wanted to check in regarding ${product}.
If you have any upcoming projects or requirements, I would be happy to share our latest pricing, lead times, and technical updates. Please let me know if there is anything I can help with.
Looking forward to hearing from you.
Best regards,
[Your name]`;
return { subject, body };
}
/* -------------------------------- storage ------------------------------ */
async function loadAll() {
let customers = [];
let products = [];
let settings = DEFAULT_SETTINGS;
let quotes = [];
let sellerProfiles = DEFAULT_SELLER_PROFILES;
let certifications = [];
@ -1417,10 +1387,6 @@ async function loadAll() {
const p = await storageGet(STORAGE_KEYS.products);
if (p && p.value) products = JSON.parse(p.value);
} catch (e) {}
try {
const s = await storageGet(STORAGE_KEYS.settings);
if (s && s.value) settings = { ...DEFAULT_SETTINGS, ...JSON.parse(s.value) };
} catch (e) {}
try {
const q = await storageGet(STORAGE_KEYS.quotes);
if (q && q.value) quotes = JSON.parse(q.value);
@ -1455,7 +1421,7 @@ async function loadAll() {
const bpt = await storageGet(STORAGE_KEYS.backPanelTemplates);
if (bpt && bpt.value) backPanelTemplates = JSON.parse(bpt.value);
} catch (e) {}
return { customers, products, settings, quotes, sellerProfiles, certifications, productCategories, packingLists, catalogDocs, jmsLines, backPanelTemplates };
return { customers, products, quotes, sellerProfiles, certifications, productCategories, packingLists, catalogDocs, jmsLines, backPanelTemplates };
}
/* -------------------------------- styles -------------------------------- */
@ -1830,11 +1796,28 @@ const Styles = () => (
.doc-preview { position: absolute; left: 0; top: 0; width: 100%; max-width: none; border: none; box-shadow: none; padding: 24px 40px; }
}
/* ---------- vip ---------- */
.vip-toggle { cursor: pointer; color: var(--line); display: inline-flex; align-items: center; transition: opacity 0.15s; }
.vip-toggle:hover { opacity: 0.65; }
.vip-toggle.active { color: #C9942E; }
.vip-toggle.active-red { color: var(--led-red); }
/* ---------- vip / status badges ---------- */
.tiny-badge { display: inline-flex; align-items: center; font-size: 10px; font-weight: 700; padding: 2px 7px; border-radius: 100px; letter-spacing: 0.02em; flex-shrink: 0; }
.tiny-badge-vip { background: #FBF1DD; color: #C9942E; }
.tiny-badge-red { background: var(--led-red-bg); color: var(--led-red); }
.tiny-badge-toggle { cursor: pointer; display: inline-flex; align-items: center; font-size: 11.5px; font-weight: 600; padding: 4px 11px; border-radius: 100px; border: 1.5px solid var(--line); color: var(--ink-soft); transition: all 0.15s; user-select: none; }
.tiny-badge-toggle:hover { border-color: var(--ink-soft); }
.tiny-badge-toggle.tiny-badge-vip.active { border-color: #C9942E; background: #FBF1DD; color: #C9942E; }
.tiny-badge-toggle.tiny-badge-red.active { border-color: var(--led-red); background: var(--led-red-bg); color: var(--led-red); }
/* ---------- customer file explorer (virtual folders) ---------- */
.file-breadcrumb { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; font-size: 12px; margin-bottom: 10px; padding-bottom: 8px; border-bottom: 1px solid var(--line); }
.file-breadcrumb-item { cursor: pointer; color: var(--ink-soft); padding: 2px 4px; border-radius: 4px; }
.file-breadcrumb-item:hover { background: var(--bg); color: var(--ink); }
.file-breadcrumb-item.current { color: var(--ink); font-weight: 700; cursor: default; }
.file-breadcrumb-item.current:hover { background: none; }
.folder-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); gap: 8px; margin-bottom: 14px; }
.folder-item { position: relative; border: 1.5px solid var(--line); border-radius: 10px; padding: 12px 8px 8px; text-align: center; cursor: pointer; transition: border-color 0.15s, background 0.15s; }
.folder-item:hover { border-color: var(--copper); background: var(--copper-soft); }
.folder-item-name { font-size: 12px; font-weight: 600; margin-top: 6px; word-break: break-word; line-height: 1.3; }
.folder-item-count { font-size: 10.5px; color: var(--ink-soft); margin-top: 2px; }
.folder-item-actions { display: flex; justify-content: center; gap: 4px; margin-top: 6px; opacity: 0; transition: opacity 0.15s; }
.folder-item:hover .folder-item-actions { opacity: 1; }
`}</style>
);
@ -1884,6 +1867,51 @@ function StatCard({ num, label, tone }) {
);
}
function CustomerMonitorGrid({ list, vipFlag, dragCustomerIdx, dragCustomerVip, onDragStart, onDrop, onDragEnd, now, onView }) {
if (list.length === 0) {
return <div className="panel"><div className="empty-state"><Inbox size={24} />{vipFlag ? '还没有VIP客户' : '暂无客户'}</div></div>;
}
return (
<div className="card-grid">
{list.map((c, idx) => {
const lt = localTimeInfo(c.country, now);
const modelQty = [c.intendedModel, c.intendedQty ? `× ${c.intendedQty}` : ''].filter(Boolean).join(' ');
const cardTitle = customerTitle(c);
const showCompanyLine = !!(modelQty && c.company);
const { showEmail, showPhone } = contactDisplayFlags(c);
const isDragging = dragCustomerIdx === idx && dragCustomerVip === vipFlag;
return (
<div
key={c.id}
className="monitor-card"
style={{ opacity: isDragging ? 0.5 : 1 }}
draggable
onDragStart={() => onDragStart(vipFlag, idx)}
onDragOver={(e) => e.preventDefault()}
onDrop={() => onDrop(vipFlag, idx)}
onDragEnd={onDragEnd}
>
<div className="monitor-card-head">
<GripVertical size={14} className="drag-handle" />
<div className="monitor-card-title">{cardTitle}</div>
{c.hasOrdered && <span className="tiny-badge tiny-badge-red">已下单</span>}
</div>
<div className="row-sub">{regionLabel(c.region)} · {c.country || '—'}</div>
{showCompanyLine && <div className="row-sub">{c.company}</div>}
{c.contact && <div className="row-sub">{c.contact}</div>}
{showEmail && <div className="row-sub" style={{ display: 'flex', alignItems: 'center', gap: 4 }}><Mail size={11} />{c.email}</div>}
{showPhone && <div className="row-sub" style={{ display: 'flex', alignItems: 'center', gap: 4 }}><Phone size={11} />{c.phone}</div>}
{lt && <div className="row-sub">当地时间 {lt.time}</div>}
<div style={{ display: 'flex', gap: 6, marginTop: 4 }}>
<button className="btn btn-sm" style={{ flex: 1, justifyContent: 'center' }} onClick={() => onView(c.id)}>查看</button>
</div>
</div>
);
})}
</div>
);
}
function ProductPills({ value, onChange }) {
const toggle = (p) => {
const has = value.includes(p);
@ -1922,7 +1950,7 @@ function RegionPills({ value, onChange }) {
const NAV_GROUPS = [
{ label: '客户', items: [
{ id: 'monitor', label: '客户监控', icon: LayoutDashboard },
{ id: 'monitor', label: '客户档案', icon: LayoutDashboard },
{ id: 'manage', label: '客户管理', icon: Users },
{ id: 'jms', label: 'Just My Socks', icon: Wifi },
] },
@ -2033,7 +2061,6 @@ function MainApp({ username, onLogout }) {
const [loaded, setLoaded] = useState(false);
const [customers, setCustomers] = useState([]);
const [products, setProducts] = useState([]);
const [settings, setSettings] = useState(DEFAULT_SETTINGS);
const [view, setView] = useState('monitor');
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
@ -2061,12 +2088,12 @@ function MainApp({ username, onLogout }) {
const [aiImportHintType, setAiImportHintType] = useState('');
const [aiImportTruncatedWarning, setAiImportTruncatedWarning] = useState(false);
const [emailModal, setEmailModal] = useState(null);
const [exportOpen, setExportOpen] = useState(false);
const [toast, setToast] = useState('');
const [now, setNow] = useState(() => new Date());
const [dragCustomerIdx, setDragCustomerIdx] = useState(null);
const [dragCustomerVip, setDragCustomerVip] = useState(false); // VIP
const [dragGroupState, setDragGroupState] = useState(null); // { category, index }
const [pwCurrent, setPwCurrent] = useState('');
@ -2077,8 +2104,6 @@ function MainApp({ username, onLogout }) {
const [aiKeyInput, setAiKeyInput] = useState('');
const [aiSaving, setAiSaving] = useState(false);
const [aiTesting, setAiTesting] = useState(false);
const [aiGenerating, setAiGenerating] = useState(false);
const [aiDraftModal, setAiDraftModal] = useState(null); // { channel, customerId, subject, body }
const [quotes, setQuotes] = useState([]);
const [sellerProfiles, setSellerProfiles] = useState(DEFAULT_SELLER_PROFILES);
@ -2125,7 +2150,6 @@ function MainApp({ username, onLogout }) {
setCustomers(data.customers);
setProducts(normalized.products);
setProductCategories(normalized.categories);
setSettings(data.settings);
setQuotes(data.quotes);
setSellerProfiles(data.sellerProfiles);
setCertifications(data.certifications);
@ -2138,7 +2162,6 @@ function MainApp({ username, onLogout }) {
}, []);
useEffect(() => { if (loaded) storageSet(STORAGE_KEYS.customers, JSON.stringify(customers)).catch(() => {}); }, [customers, loaded]);
useEffect(() => { if (loaded) storageSet(STORAGE_KEYS.products, JSON.stringify(products)).catch(() => {}); }, [products, loaded]);
useEffect(() => { if (loaded) storageSet(STORAGE_KEYS.settings, JSON.stringify(settings)).catch(() => {}); }, [settings, loaded]);
useEffect(() => { if (loaded) storageSet(STORAGE_KEYS.quotes, JSON.stringify(quotes)).catch(() => {}); }, [quotes, loaded]);
useEffect(() => { if (loaded) storageSet(STORAGE_KEYS.sellerProfiles, JSON.stringify(sellerProfiles)).catch(() => {}); }, [sellerProfiles, loaded]);
useEffect(() => { if (loaded) storageSet(STORAGE_KEYS.certifications, JSON.stringify(certifications)).catch(() => {}); }, [certifications, loaded]);
@ -2187,9 +2210,11 @@ function MainApp({ username, onLogout }) {
(c.country || '').toLowerCase().includes(q)
);
}
const order = { overdue: 0, due: 1, ok: 2 };
return [...list].sort((a, b) => order[statusOf(a, settings)] - order[statusOf(b, settings)] || (a.company || '').localeCompare(b.company || ''));
}, [customers, search, regionFilter, settings]);
return [...list].sort((a, b) => (b.isVip ? 1 : 0) - (a.isVip ? 1 : 0) || (a.company || '').localeCompare(b.company || ''));
}, [customers, search, regionFilter]);
const vipCustomers = useMemo(() => customers.filter(c => c.isVip), [customers]);
const normalCustomers = useMemo(() => customers.filter(c => !c.isVip), [customers]);
const filteredProducts = useMemo(() => {
let list = products;
@ -2206,11 +2231,9 @@ function MainApp({ username, onLogout }) {
}, [products, productSearch, productCategoryFilter, productCategories]);
const counts = useMemo(() => ({
overdue: customers.filter(c => statusOf(c, settings) === 'overdue').length,
due: customers.filter(c => statusOf(c, settings) === 'due').length,
total: customers.length,
products: products.length,
}), [customers, settings, products]);
}), [customers, products]);
const priceHistory = useMemo(() => buildPriceHistory(quotes), [quotes]);
@ -2219,9 +2242,9 @@ function MainApp({ username, onLogout }) {
return catalogDocs.filter(d => d.uploading || d.category === catalogCategoryFilter);
}, [catalogDocs, catalogCategoryFilter]);
function handleCustomerDrop(dropIdx) {
if (dragCustomerIdx === null || dragCustomerIdx === dropIdx) { setDragCustomerIdx(null); return; }
setCustomers(prev => reorderArray(prev, dragCustomerIdx, dropIdx));
function handleCustomerDrop(vipFlag, dropIdx) {
if (dragCustomerIdx === null || (dragCustomerIdx === dropIdx && dragCustomerVip === vipFlag)) { setDragCustomerIdx(null); return; }
setCustomers(prev => reorderCustomerGroup(prev, vipFlag, dragCustomerIdx, dropIdx));
setDragCustomerIdx(null);
}
async function handleAddCatalogFile(file) {
@ -2367,18 +2390,6 @@ function MainApp({ username, onLogout }) {
setAiConfigHasKey(false);
notify('已清除');
}
async function handleGenerateAi(customerId, channel) {
if (!aiConfigHasKey) { notify('请先在设置中配置DeepSeek API Key'); return; }
setAiGenerating(true);
try {
const result = await generateAiDraft(customerId, channel);
setAiDraftModal({ channel, customerId, subject: result.subject || '', body: result.body || '' });
} catch (e) {
notify('生成失败:' + (e.message || '请检查API Key和网络'));
} finally {
setAiGenerating(false);
}
}
function startNewCustomer() { setDraft(blankCustomer()); setSelectedId(null); }
function startEditCustomer(c) { setDraft({ ...c, productLines: [...(c.productLines || [])] }); setSelectedId(c.id); }
@ -2398,10 +2409,6 @@ function MainApp({ username, onLogout }) {
if (selectedId === id) setSelectedId(null);
notify('已删除');
}
function markContacted(id) {
setCustomers(prev => prev.map(c => (c.id === id ? { ...c, lastContact: todayStr() } : c)));
notify('已标记为今日已联系');
}
function togglePreferredContact(id, channel) {
setCustomers(prev => prev.map(c => {
if (c.id !== id) return c;
@ -2423,7 +2430,7 @@ function MainApp({ username, onLogout }) {
const f = e.dataTransfer.files && e.dataTransfer.files[0];
if (f) setAttachmentFile(f);
}
async function handleUploadAttachment(customerId) {
async function handleUploadAttachment(customerId, folderId) {
if (!attachmentFile) { notify('请先选择文件'); return; }
setUploading(true);
try {
@ -2436,6 +2443,7 @@ function MainApp({ username, onLogout }) {
category: attachmentCategory,
note: attachmentNote.trim(),
date: todayStr(),
folderId: folderId || null,
};
return { ...c, attachments: [entry, ...(c.attachments || [])] };
}));
@ -2461,7 +2469,42 @@ function MainApp({ username, onLogout }) {
c.id === customerId ? { ...c, attachments: (c.attachments || []).filter(a => a.fileName !== fileName) } : c
)));
}
function openFollowUpEmail(c) { setEmailModal({ ...generateFollowUpEmail(c), customerId: c.id }); }
function moveAttachmentToFolder(customerId, fileName, folderId) {
setCustomers(prev => prev.map(c => (
c.id === customerId
? { ...c, attachments: (c.attachments || []).map(a => (a.fileName === fileName ? { ...a, folderId: folderId || null } : a)) }
: c
)));
}
//
// {id, name, parentId}folderIdnull=
// //
function createFolder(customerId, parentId, name) {
const trimmed = (name || '').trim();
if (!trimmed) return;
const folder = { id: 'folder_' + Date.now() + '_' + Math.random().toString(36).slice(2, 7), name: trimmed, parentId: parentId || null };
setCustomers(prev => prev.map(c => (
c.id === customerId ? { ...c, fileFolders: [...(c.fileFolders || []), folder] } : c
)));
}
function renameFolder(customerId, folderId, name) {
const trimmed = (name || '').trim();
if (!trimmed) return;
setCustomers(prev => prev.map(c => (
c.id === customerId ? { ...c, fileFolders: (c.fileFolders || []).map(f => (f.id === folderId ? { ...f, name: trimmed } : f)) } : c
)));
}
function deleteFolder(customerId, folderId) {
const customer = customers.find(c => c.id === customerId);
if (!customer) return;
const hasSubfolder = (customer.fileFolders || []).some(f => f.parentId === folderId);
const hasFile = (customer.attachments || []).some(a => a.folderId === folderId);
if (hasSubfolder || hasFile) { notify('文件夹不是空的,先移走或删除里面的文件/子文件夹'); return; }
if (!window.confirm('确定删除这个文件夹?')) return;
setCustomers(prev => prev.map(c => (
c.id === customerId ? { ...c, fileFolders: (c.fileFolders || []).filter(f => f.id !== folderId) } : c
)));
}
function startNewProduct() { setProductDraft(blankProduct()); setSelectedProductId(null); }
function startEditProduct(p) { setProductDraft({ ...p, extraNames: [] }); setSelectedProductId(p.id); }
@ -3111,61 +3154,42 @@ function MainApp({ username, onLogout }) {
{view === 'monitor' && (
<>
<div className="stat-row">
<StatCard num={counts.overdue} label="已逾期跟进" tone="red" />
<StatCard num={counts.due} label="即将到期" tone="amber" />
<StatCard num={counts.total} label="客户总数" tone="green" />
<StatCard num={vipCustomers.length} label="VIP客户" tone="amber" />
<StatCard num={counts.products} label="产品档案数量" tone="blue" />
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 10 }}>
<div className="panel-title">全部客户</div>
<span style={{ fontSize: 11.5, color: 'var(--ink-soft)' }}>拖动卡片可调整顺序</span>
<div className="panel-title">VIP客户</div>
<span style={{ fontSize: 11.5, color: 'var(--ink-soft)' }}>拖动卡片可调整组内顺序</span>
</div>
{customers.length === 0 ? (
<div className="panel"><div className="empty-state"><Inbox size={28} />暂无客户"客户管理"添加第一个</div></div>
) : (
<div className="card-grid">
{customers.map((c, idx) => {
const s = statusOf(c, settings);
const lt = localTimeInfo(c.country, now);
const modelQty = [c.intendedModel, c.intendedQty ? `× ${c.intendedQty}` : ''].filter(Boolean).join(' ');
const cardTitle = customerTitle(c);
const showCompanyLine = !!(modelQty && c.company);
const { showEmail, showPhone } = contactDisplayFlags(c);
return (
<div
key={c.id}
className="monitor-card"
style={{ opacity: dragCustomerIdx === idx ? 0.5 : 1 }}
draggable
onDragStart={() => setDragCustomerIdx(idx)}
onDragOver={(e) => e.preventDefault()}
onDrop={() => handleCustomerDrop(idx)}
onDragEnd={() => setDragCustomerIdx(null)}
>
<div className="monitor-card-head">
<GripVertical size={14} className="drag-handle" />
<div className={`led-dot ${s === 'overdue' ? 'red' : s === 'due' ? 'amber' : 'green'}`} />
<div className="monitor-card-title">{cardTitle}</div>
{c.isVip && <Crown size={14} fill="#C9942E" color="#C9942E" style={{ flexShrink: 0 }} />}
{c.hasOrdered && <Crown size={14} fill="#C2403A" color="#C2403A" style={{ flexShrink: 0 }} />}
</div>
<div className="row-sub">{regionLabel(c.region)} · {c.country || '—'}</div>
{showCompanyLine && <div className="row-sub">{c.company}</div>}
{c.contact && <div className="row-sub">{c.contact}</div>}
{showEmail && <div className="row-sub" style={{ display: 'flex', alignItems: 'center', gap: 4 }}><Mail size={11} />{c.email}</div>}
{showPhone && <div className="row-sub" style={{ display: 'flex', alignItems: 'center', gap: 4 }}><Phone size={11} />{c.phone}</div>}
{lt && <div className="row-sub">当地时间 {lt.time}</div>}
<div className="row-sub">上次联系 {fmtDate(c.lastContact)}</div>
<div style={{ display: 'flex', gap: 6, marginTop: 4 }}>
<button className="btn btn-sm" style={{ flex: 1, justifyContent: 'center' }} onClick={() => { setView('manage'); setDraft(null); setSelectedId(c.id); }}>查看</button>
<button className="btn btn-sm" title="标记今日已联系" onClick={() => markContacted(c.id)}><Check size={12} /></button>
</div>
</div>
);
})}
</div>
)}
<CustomerMonitorGrid
list={vipCustomers}
vipFlag={true}
dragCustomerIdx={dragCustomerIdx}
dragCustomerVip={dragCustomerVip}
onDragStart={(vipFlag, idx) => { setDragCustomerIdx(idx); setDragCustomerVip(vipFlag); }}
onDrop={handleCustomerDrop}
onDragEnd={() => setDragCustomerIdx(null)}
now={now}
onView={(id) => { setView('manage'); setDraft(null); setSelectedId(id); }}
/>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', margin: '22px 0 10px' }}>
<div className="panel-title">全部客户</div>
<span style={{ fontSize: 11.5, color: 'var(--ink-soft)' }}>拖动卡片可调整组内顺序</span>
</div>
<CustomerMonitorGrid
list={normalCustomers}
vipFlag={false}
dragCustomerIdx={dragCustomerIdx}
dragCustomerVip={dragCustomerVip}
onDragStart={(vipFlag, idx) => { setDragCustomerIdx(idx); setDragCustomerVip(vipFlag); }}
onDrop={handleCustomerDrop}
onDragEnd={() => setDragCustomerIdx(null)}
now={now}
onView={(id) => { setView('manage'); setDraft(null); setSelectedId(id); }}
/>
</>
)}
@ -3185,13 +3209,15 @@ function MainApp({ username, onLogout }) {
{filteredCustomers.length === 0 ? (
<div className="empty-state"><Inbox size={26} />暂无客户点击上方按钮添加第一个</div>
) : filteredCustomers.map(c => {
const s = statusOf(c, settings);
const showCompanyInSub = customerTitleIsModel(c) && c.company;
return (
<div key={c.id} className="row" style={{ cursor: 'pointer', background: selectedId === c.id ? '#FAFBFC' : undefined }} onClick={() => { setDraft(null); setSelectedId(c.id); }}>
<div className={`led-dot ${s === 'overdue' ? 'red' : s === 'due' ? 'amber' : 'green'}`} />
<div className="row-main">
<div className="row-title">{c.isVip && <Crown size={12} fill="#C9942E" color="#C9942E" style={{ marginRight: 4, verticalAlign: -1 }} />}{c.hasOrdered && <Crown size={12} fill="#C2403A" color="#C2403A" style={{ marginRight: 4, verticalAlign: -1 }} />}{customerTitle(c)}</div>
<div className="row-title">
{c.isVip && <span className="tiny-badge tiny-badge-vip" style={{ marginRight: 5 }}>VIP</span>}
{c.hasOrdered && <span className="tiny-badge tiny-badge-red" style={{ marginRight: 5 }}>已下单</span>}
{customerTitle(c)}
</div>
<div className="row-sub">{showCompanyInSub && `${c.company} · `}{regionLabel(c.region)} · {c.country || '—'}{localTimeInfo(c.country, now) && ` · 当地 ${localTimeInfo(c.country, now).time}`}</div>
</div>
<ChevronRight size={15} color="var(--ink-soft)" />
@ -3208,12 +3234,9 @@ function MainApp({ username, onLogout }) {
) : selected ? (
<CustomerDetail
customer={selected}
settings={settings}
now={now}
onEdit={() => startEditCustomer(selected)}
onDelete={() => deleteCustomer(selected.id)}
onMarkContacted={() => markContacted(selected.id)}
onEmail={() => openFollowUpEmail(selected)}
onTogglePreferred={(channel) => togglePreferredContact(selected.id, channel)}
onToggleVip={() => toggleVip(selected.id)}
onToggleOrdered={() => toggleOrdered(selected.id)}
@ -3225,15 +3248,16 @@ function MainApp({ username, onLogout }) {
onPickAttachment={(f) => setAttachmentFile(f)}
uploading={uploading}
fileInputKey={fileInputKey}
onUploadAttachment={() => handleUploadAttachment(selected.id)}
onUploadAttachment={(folderId) => handleUploadAttachment(selected.id, folderId)}
onDeleteAttachment={(fileName) => handleDeleteAttachment(selected.id, fileName)}
onMoveAttachment={(fileName, folderId) => moveAttachmentToFolder(selected.id, fileName, folderId)}
onCreateFolder={(parentId, name) => createFolder(selected.id, parentId, name)}
onRenameFolder={(folderId, name) => renameFolder(selected.id, folderId, name)}
onDeleteFolder={(folderId) => deleteFolder(selected.id, folderId)}
fileDragActive={fileDragActive}
onFileDragOver={handleFileDragOver}
onFileDragLeave={handleFileDragLeave}
onFileDrop={handleFileDrop}
aiConfigHasKey={aiConfigHasKey}
aiGenerating={aiGenerating}
onGenerateAi={(channel) => handleGenerateAi(selected.id, channel)}
/>
) : (
<div className="panel"><div className="empty-state"><Users size={28} />选择左侧客户查看详情或新建一个客户档案</div></div>
@ -3665,29 +3689,19 @@ function MainApp({ username, onLogout }) {
{view === 'settings' && (
<>
<div className="panel section-gap">
<div className="panel-header"><div className="panel-title">跟进与提醒设置</div></div>
<div className="panel-header"><div className="panel-title">数据管理</div></div>
<div className="panel-body" style={{ padding: 18 }}>
<div className="grid-2">
<div className="field">
<span className="field-label">默认跟进周期</span>
<input type="number" min="1" className="input" value={settings.defaultFollowUpDays} onChange={e => setSettings(s => ({ ...s, defaultFollowUpDays: Number(e.target.value) || 1 }))} />
</div>
<div className="field">
<span className="field-label">"即将到期"提前提醒天数</span>
<input type="number" min="1" className="input" value={settings.dueSoonWindow} onChange={e => setSettings(s => ({ ...s, dueSoonWindow: Number(e.target.value) || 1 }))} />
</div>
</div>
<div style={{ display: 'flex', gap: 10, marginTop: 6 }}>
<button className="btn" onClick={() => setExportOpen(true)}><Save size={13} />导出数据备份</button>
<button className="btn btn-danger" onClick={() => {
if (window.confirm('将清空全部客户与产品档案数据,且不可恢复,确定继续?')) {
setCustomers([]); setProducts([]); setSettings(DEFAULT_SETTINGS);
setCustomers([]); setProducts([]);
notify('已清空');
}
}}><RefreshCw size={13} />清空所有数据</button>
</div>
<div className="disclaimer" style={{ marginTop: 18 }}>
说明邮件功能用于<b>生成可复制的英文邮件文案</b>需要你手动粘贴到自己的邮箱发送报价单制作正在开发中下一轮会上线
说明报价单制作正在开发中下一轮会上线
</div>
</div>
</div>
@ -3726,7 +3740,7 @@ function MainApp({ username, onLogout }) {
{aiConfigHasKey && <button className="btn btn-danger" onClick={handleClearAiKey}>清除</button>}
</div>
<div className="disclaimer" style={{ marginTop: 14 }}>
用于客户详情页的"AI生成邮件 / AI生成WhatsApp话术"功能调用DeepSeek官方API生成成本很低单次生成大概几分钱以内人民币按你自己DeepSeek账户余额实际扣费Key只会加密保存在服务器本地不会显示给任何人
用于"产品管理"里的<b>产品目录批量导入</b>功能调用DeepSeek官方API识别目录文档生成产品字段成本很低单次生成大概几分钱以内人民币按你自己DeepSeek账户余额实际扣费Key只会加密保存在服务器本地不会显示给任何人
</div>
</div>
</div>
@ -3738,58 +3752,15 @@ function MainApp({ username, onLogout }) {
</div>
</div>
{aiDraftModal && (
<div className="modal-overlay" onClick={() => setAiDraftModal(null)}>
<div className="modal-box" onClick={e => e.stopPropagation()}>
<div className="modal-head">
<div className="panel-title"><Sparkles size={15} />{aiDraftModal.channel === 'email' ? 'AI生成邮件' : 'AI生成WhatsApp话术'}</div>
<button className="btn btn-ghost btn-sm" onClick={() => setAiDraftModal(null)}><X size={14} /></button>
</div>
<div className="modal-body">
{aiDraftModal.channel === 'email' && (
<div className="field"><span className="field-label">主题 Subject</span><input className="input" value={aiDraftModal.subject} onChange={e => setAiDraftModal(m => ({ ...m, subject: e.target.value }))} /></div>
)}
<div className="field">
<span className="field-label">{aiDraftModal.channel === 'email' ? '正文 Body' : '消息内容'}</span>
<textarea
className="textarea" rows={aiDraftModal.channel === 'email' ? 10 : 6}
value={aiDraftModal.body} onChange={e => setAiDraftModal(m => ({ ...m, body: e.target.value }))}
/>
</div>
</div>
<div className="modal-foot">
{aiDraftModal.channel === 'email' && <button className="btn" onClick={() => copyText(aiDraftModal.subject)}><Copy size={12} />复制主题</button>}
<button className="btn btn-primary" onClick={() => copyText(aiDraftModal.body)}><Copy size={12} />复制{aiDraftModal.channel === 'email' ? '正文' : '内容'}</button>
</div>
</div>
</div>
)}
{emailModal && (
<div className="modal-overlay" onClick={() => setEmailModal(null)}>
<div className="modal-box" onClick={e => e.stopPropagation()}>
<div className="modal-head"><div className="panel-title"><Mail size={15} />邮件文案</div><button className="btn btn-ghost btn-sm" onClick={() => setEmailModal(null)}><X size={14} /></button></div>
<div className="modal-body">
<div className="field"><span className="field-label">主题 Subject</span><input className="input" readOnly value={emailModal.subject} /></div>
<div className="field"><span className="field-label">正文 Body</span><textarea className="textarea" rows={10} value={emailModal.body} onChange={e => setEmailModal(m => ({ ...m, body: e.target.value }))} /></div>
</div>
<div className="modal-foot">
<button className="btn" onClick={() => copyText(emailModal.subject)}><Copy size={12} />复制主题</button>
<button className="btn btn-primary" onClick={() => copyText(emailModal.body)}><Copy size={12} />复制正文</button>
</div>
</div>
</div>
)}
{exportOpen && (
<div className="modal-overlay" onClick={() => setExportOpen(false)}>
<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, settings, 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 }, null, 2)} />
</div>
<div className="modal-foot">
<button className="btn btn-primary" onClick={() => copyText(JSON.stringify({ customers, products, settings, 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 }, null, 2))}><Copy size={12} />复制全部</button>
</div>
</div>
</div>
@ -4087,10 +4058,6 @@ function CustomerForm({ draft, setDraft, onSave, onCancel }) {
</div>
)}
<div className="grid-2">
<div className="field"><span className="field-label">上次联系日期</span><input type="date" className="input" value={draft.lastContact} onChange={e => set({ lastContact: e.target.value })} /></div>
<div className="field"><span className="field-label">跟进周期留空用默认值</span><input type="number" min="1" className="input" value={draft.followUpDays} onChange={e => set({ followUpDays: e.target.value })} /></div>
</div>
<div className="field"><span className="field-label">备注</span><textarea className="textarea" style={{ fontFamily: 'inherit', fontSize: 13 }} value={draft.tags} onChange={e => set({ tags: e.target.value })} /></div>
<CollapsibleSection title="货代信息" defaultOpen={!!(draft.warehouseAddress || draft.freightContact || draft.shippingMarkNote)}>
@ -4140,20 +4107,43 @@ function CollapsibleSection({ title, count, defaultOpen, children }) {
);
}
// parentId"/2026/"""
//
function folderPathLabel(folders, folderId) {
const byId = Object.fromEntries(folders.map(f => [f.id, f]));
const parts = [];
let cur = byId[folderId];
while (cur) {
parts.unshift(cur.name);
cur = cur.parentId ? byId[cur.parentId] : null;
}
return parts.join(' / ');
}
function CustomerDetail({
customer: c, settings, now, onEdit, onDelete, onMarkContacted, onEmail,
customer: c, now, onEdit, onDelete,
onTogglePreferred, onToggleVip, onToggleOrdered,
attachmentNote, setAttachmentNote, attachmentCategory, setAttachmentCategory,
attachmentFileName, onPickAttachment, uploading, fileInputKey, onUploadAttachment, onDeleteAttachment,
attachmentFileName, onPickAttachment, uploading, fileInputKey, onUploadAttachment, onDeleteAttachment, onMoveAttachment,
onCreateFolder, onRenameFolder, onDeleteFolder,
fileDragActive, onFileDragOver, onFileDragLeave, onFileDrop,
aiConfigHasKey, aiGenerating, onGenerateAi,
}) {
const s = statusOf(c, settings);
const lt = localTimeInfo(c.country, now);
const preferred = c.preferredContacts || [];
const [fileSearch, setFileSearch] = useState('');
const [fileCategoryFilter, setFileCategoryFilter] = useState('all');
const filteredAttachments = (c.attachments || []).filter(a => {
const [currentFolderId, setCurrentFolderId] = useState(null);
const folders = c.fileFolders || [];
const attachments = c.attachments || [];
const subfolders = folders.filter(f => (f.parentId || null) === currentFolderId);
const byId = Object.fromEntries(folders.map(f => [f.id, f]));
const breadcrumb = [];
{ let cur = currentFolderId ? byId[currentFolderId] : null;
while (cur) { breadcrumb.unshift(cur); cur = cur.parentId ? byId[cur.parentId] : null; } }
const filteredAttachments = attachments.filter(a => {
if ((a.folderId || null) !== currentFolderId) return false;
if (fileCategoryFilter !== 'all' && a.category !== fileCategoryFilter) return false;
if (fileSearch.trim()) {
const q = fileSearch.trim().toLowerCase();
@ -4161,18 +4151,21 @@ function CustomerDetail({
}
return true;
});
function handleNewFolder() {
const name = window.prompt('新文件夹名称:');
if (name && name.trim()) onCreateFolder(currentFolderId, name);
}
function handleRenameFolder(f) {
const name = window.prompt('重命名文件夹:', f.name);
if (name && name.trim()) onRenameFolder(f.id, name);
}
return (
<div className="panel">
<div className="panel-header">
<div className="panel-title">
<div className={`led-dot ${s === 'overdue' ? 'red' : s === 'due' ? 'amber' : 'green'}`} />
{customerTitle(c)}
<span className={`vip-toggle ${c.isVip ? 'active' : ''}`} onClick={onToggleVip} title={c.isVip ? '取消重点客户标记' : '标记为重点客户'}>
<Crown size={15} fill={c.isVip ? '#C9942E' : 'none'} />
</span>
<span className={`vip-toggle ${c.hasOrdered ? 'active-red' : ''}`} onClick={onToggleOrdered} title={c.hasOrdered ? '取消已下单标记' : '标记为已下单'}>
<Crown size={15} fill={c.hasOrdered ? '#C2403A' : 'none'} />
</span>
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button className="btn btn-sm" onClick={onEdit}><Edit2 size={12} />编辑</button>
@ -4180,6 +4173,14 @@ function CustomerDetail({
</div>
</div>
<div className="panel-body" style={{ padding: 18 }}>
<div style={{ display: 'flex', gap: 8, marginBottom: 16 }}>
<span className={`tiny-badge-toggle ${c.isVip ? 'tiny-badge-vip active' : ''}`} onClick={onToggleVip}>
{c.isVip ? '★ VIP客户' : '标记为VIP客户'}
</span>
<span className={`tiny-badge-toggle ${c.hasOrdered ? 'tiny-badge-red active' : ''}`} onClick={onToggleOrdered}>
{c.hasOrdered ? '已下单' : '标记为已下单'}
</span>
</div>
<div className="grid-2" style={{ marginBottom: 16 }}>
<Info icon={<Building2 size={13} />} label="公司名称" value={c.company || '—'} />
<Info icon={<Building2 size={13} />} label="联系人" value={c.contact || '—'} />
@ -4219,24 +4220,40 @@ function CustomerDetail({
{c.notes && <div className="field"><span className="field-label">客户背景信息调查</span><div style={{ fontSize: 12.5, whiteSpace: 'pre-wrap' }}>{c.notes}</div></div>}
<div style={{ display: 'flex', gap: 8, margin: '14px 0' }}>
<span className="row-sub" style={{ flex: 1 }}>上次联系{fmtDate(c.lastContact)} 状态{statusLabel(s)}</span>
<button className="btn btn-sm" onClick={onMarkContacted}><Check size={12} />标记今日已联系</button>
<button className="btn btn-sm" disabled={!c.email} onClick={onEmail}><Mail size={12} />生成跟进邮件</button>
</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center', margin: '0 0 14px', flexWrap: 'wrap' }}>
<button className="btn btn-sm" disabled={!aiConfigHasKey || aiGenerating} onClick={() => onGenerateAi('email')}>
<Sparkles size={12} />{aiGenerating ? '生成中…' : 'AI生成邮件'}
</button>
<button className="btn btn-sm" disabled={!aiConfigHasKey || aiGenerating} onClick={() => onGenerateAi('whatsapp')}>
<Sparkles size={12} />{aiGenerating ? '生成中…' : 'AI生成WhatsApp话术'}
</button>
{!aiConfigHasKey && <span className="row-sub">未配置DeepSeek"设置"里填API Key</span>}
</div>
<div className="field">
<span className="field-label">客户文件管理</span>
<div className="file-breadcrumb">
<span className={`file-breadcrumb-item ${currentFolderId === null ? 'current' : ''}`} onClick={() => setCurrentFolderId(null)}>根目录</span>
{breadcrumb.map(f => (
<React.Fragment key={f.id}>
<ChevronRight size={12} color="var(--ink-soft)" />
<span className={`file-breadcrumb-item ${currentFolderId === f.id ? 'current' : ''}`} onClick={() => setCurrentFolderId(f.id)}>{f.name}</span>
</React.Fragment>
))}
<button className="btn btn-sm btn-ghost" style={{ marginLeft: 'auto' }} onClick={handleNewFolder}><FolderPlus size={12} />新建文件夹</button>
</div>
{subfolders.length > 0 && (
<div className="folder-grid">
{subfolders.map(f => {
const fileCount = attachments.filter(a => (a.folderId || null) === f.id).length;
const subCount = folders.filter(sf => sf.parentId === f.id).length;
return (
<div key={f.id} className="folder-item" onClick={() => setCurrentFolderId(f.id)}>
<Folder size={22} color="var(--copper)" />
<div className="folder-item-name">{f.name}</div>
<div className="folder-item-count">{fileCount + subCount} </div>
<div className="folder-item-actions" onClick={e => e.stopPropagation()}>
<button className="btn btn-sm btn-ghost" title="重命名" onClick={() => handleRenameFolder(f)}><Edit2 size={11} /></button>
<button className="btn btn-sm btn-ghost btn-danger" title="删除" onClick={() => onDeleteFolder(f.id)}><Trash2 size={11} /></button>
</div>
</div>
);
})}
</div>
)}
<div style={{ display: 'flex', gap: 8, alignItems: 'stretch', flexWrap: 'wrap', marginBottom: 8 }}>
<label
className={`dropzone ${fileDragActive ? 'drag-active' : ''}`}
@ -4258,14 +4275,14 @@ function CustomerDetail({
className="input" placeholder="备注(可选)"
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 className="btn btn-sm" disabled={uploading} onClick={() => onUploadAttachment(currentFolderId)} style={{ justifyContent: 'center' }}>
<Plus size={12} />{uploading ? '上传中…' : (currentFolderId ? '上传到当前文件夹' : '上传')}
</button>
</div>
</div>
</div>
<CollapsibleSection title="文件列表" count={(c.attachments || []).length} defaultOpen={(c.attachments || []).length > 0}>
{(!c.attachments || c.attachments.length === 0) ? (
<CollapsibleSection title="文件列表" count={filteredAttachments.length} defaultOpen={attachments.length > 0}>
{attachments.length === 0 ? (
<div className="row-sub" style={{ padding: '8px 0' }}>暂无文件</div>
) : (
<>
@ -4280,7 +4297,7 @@ function CustomerDetail({
</select>
</div>
{filteredAttachments.length === 0 ? (
<div className="row-sub" style={{ padding: '8px 0' }}>没有匹配的文件</div>
<div className="row-sub" style={{ padding: '8px 0' }}>当前文件夹没有匹配的文件</div>
) : filteredAttachments.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 }} />
@ -4292,6 +4309,15 @@ function CustomerDetail({
{a.note && <span style={{ color: 'var(--ink-soft)' }}> {a.note}</span>}
</div>
</div>
<select
className="select" style={{ fontSize: 11, width: 120, flexShrink: 0 }}
value={a.folderId || ''}
onChange={e => onMoveAttachment(a.fileName, e.target.value || null)}
title="移动到文件夹"
>
<option value="">根目录</option>
{folders.map(f => <option key={f.id} value={f.id}>{folderPathLabel(folders, f.id)}</option>)}
</select>
<button className="btn btn-sm btn-ghost btn-danger" onClick={() => onDeleteAttachment(a.fileName)}><Trash2 size={12} /></button>
</div>
))}