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, Settings as SettingsIcon, Copy, ChevronRight,
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, Crown, Sparkles, Printer, Image as ImageIcon, Menu, LogOut, GripVertical, BookOpen, FileText, FolderTree, Lock, Sparkles, Printer, Image as ImageIcon,
ShieldCheck, Settings2, Boxes, Download, Wifi, Radio, QrCode ShieldCheck, Settings2, Boxes, Download, Wifi, Radio, QrCode, Folder, FolderPlus
} from 'lucide-react'; } from 'lucide-react';
import { import {
storageGet, storageSet, uploadFile, deleteFile, storageGet, storageSet, uploadFile, deleteFile,
authCheck, authLogin, authLogout, authChangePassword, authCheck, authLogin, authLogout, authChangePassword,
getAiConfig, saveAiConfig, deleteAiConfig, testAiConfig, generateAiDraft, getAiConfig, saveAiConfig, deleteAiConfig, testAiConfig,
parseCatalogFiles, translateTerms, parseCatalogFiles, translateTerms,
} from './storage'; } from './storage';
@ -79,7 +79,6 @@ function buildDefaultProductCategories() {
})); }));
} }
const DEFAULT_SETTINGS = { defaultFollowUpDays: 14, dueSoonWindow: 7 };
const CUSTOMER_SOURCES = ['Made-in-China', '官网', '展会', 'Other']; const CUSTOMER_SOURCES = ['Made-in-China', '官网', '展会', 'Other'];
const FILE_CATEGORIES = ['报价单', '合同', '聊天记录', '产品资料', '物流单据', 'Other']; const FILE_CATEGORIES = ['报价单', '合同', '聊天记录', '产品资料', '物流单据', 'Other'];
@ -246,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', 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 = () => ({ const blankCustomer = () => ({
id: 'c_' + Date.now() + '_' + Math.random().toString(36).slice(2, 7), id: 'c_' + Date.now() + '_' + Math.random().toString(36).slice(2, 7),
@ -257,9 +256,10 @@ const blankCustomer = () => ({
website: '', website: '',
productLines: [], intendedModel: '', intendedQty: '', productLines: [], intendedModel: '', intendedQty: '',
source: '', source: '',
tags: '', notes: '', lastContact: '', followUpDays: '', tags: '', notes: '',
warehouseAddress: '', freightContact: '', shippingMarkNote: '', warehouseAddress: '', freightContact: '', shippingMarkNote: '',
attachments: [], attachments: [],
fileFolders: [], // {id, name, parentId}parentIdnull
}); });
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'];
@ -1277,23 +1277,6 @@ function todayStr() {
const d = new Date(); const d = new Date();
return d.toISOString().slice(0, 10); 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) { function regionLabel(id) {
return (REGIONS.find(r => r.id === id) || {}).label || id; return (REGIONS.find(r => r.id === id) || {}).label || id;
} }
@ -1336,9 +1319,13 @@ function reorderWithinCategory(products, category, fromIndex, toIndex) {
let i = 0; let i = 0;
return products.map(p => (p.category === category ? reordered[i++] : p)); return products.map(p => (p.category === category ? reordered[i++] : p));
} }
function fmtDate(d) { // VIPVIP""
if (!d) return '—'; // customers
return d; 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) { function categoryBreadcrumb(p, categories) {
const cat = (categories || []).find(c => c.id === p.category); const cat = (categories || []).find(c => c.id === p.category);
@ -1380,29 +1367,12 @@ function normalizeProductTaxonomy(products, categories) {
return { products: migratedProducts, categories: cats }; 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 ------------------------------ */ /* -------------------------------- storage ------------------------------ */
async function loadAll() { async function loadAll() {
let customers = []; let customers = [];
let products = []; let products = [];
let settings = DEFAULT_SETTINGS;
let quotes = []; let quotes = [];
let sellerProfiles = DEFAULT_SELLER_PROFILES; let sellerProfiles = DEFAULT_SELLER_PROFILES;
let certifications = []; let certifications = [];
@ -1417,10 +1387,6 @@ async function loadAll() {
const p = await storageGet(STORAGE_KEYS.products); const p = await storageGet(STORAGE_KEYS.products);
if (p && p.value) products = JSON.parse(p.value); if (p && p.value) products = JSON.parse(p.value);
} catch (e) {} } catch (e) {}
try {
const s = await storageGet(STORAGE_KEYS.settings);
if (s && s.value) settings = { ...DEFAULT_SETTINGS, ...JSON.parse(s.value) };
} catch (e) {}
try { try {
const q = await storageGet(STORAGE_KEYS.quotes); const q = await storageGet(STORAGE_KEYS.quotes);
if (q && q.value) quotes = JSON.parse(q.value); if (q && q.value) quotes = JSON.parse(q.value);
@ -1455,7 +1421,7 @@ 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, settings, quotes, sellerProfiles, certifications, productCategories, packingLists, catalogDocs, jmsLines, backPanelTemplates }; return { customers, products, quotes, sellerProfiles, certifications, productCategories, packingLists, catalogDocs, jmsLines, backPanelTemplates };
} }
/* -------------------------------- styles -------------------------------- */ /* -------------------------------- 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; } .doc-preview { position: absolute; left: 0; top: 0; width: 100%; max-width: none; border: none; box-shadow: none; padding: 24px 40px; }
} }
/* ---------- vip ---------- */ /* ---------- vip / status badges ---------- */
.vip-toggle { cursor: pointer; color: var(--line); display: inline-flex; align-items: center; transition: opacity 0.15s; } .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; }
.vip-toggle:hover { opacity: 0.65; } .tiny-badge-vip { background: #FBF1DD; color: #C9942E; }
.vip-toggle.active { color: #C9942E; } .tiny-badge-red { background: var(--led-red-bg); color: var(--led-red); }
.vip-toggle.active-red { 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> `}</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 }) { function ProductPills({ value, onChange }) {
const toggle = (p) => { const toggle = (p) => {
const has = value.includes(p); const has = value.includes(p);
@ -1922,7 +1950,7 @@ function RegionPills({ value, onChange }) {
const NAV_GROUPS = [ const NAV_GROUPS = [
{ label: '客户', items: [ { label: '客户', items: [
{ id: 'monitor', label: '客户监控', icon: LayoutDashboard }, { id: 'monitor', label: '客户档案', icon: LayoutDashboard },
{ id: 'manage', label: '客户管理', icon: Users }, { id: 'manage', label: '客户管理', icon: Users },
{ id: 'jms', label: 'Just My Socks', icon: Wifi }, { id: 'jms', label: 'Just My Socks', icon: Wifi },
] }, ] },
@ -2033,7 +2061,6 @@ function MainApp({ username, onLogout }) {
const [loaded, setLoaded] = useState(false); const [loaded, setLoaded] = useState(false);
const [customers, setCustomers] = useState([]); const [customers, setCustomers] = useState([]);
const [products, setProducts] = useState([]); const [products, setProducts] = useState([]);
const [settings, setSettings] = useState(DEFAULT_SETTINGS);
const [view, setView] = useState('monitor'); const [view, setView] = useState('monitor');
const [mobileMenuOpen, setMobileMenuOpen] = useState(false); const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
@ -2061,12 +2088,12 @@ function MainApp({ username, onLogout }) {
const [aiImportHintType, setAiImportHintType] = useState(''); const [aiImportHintType, setAiImportHintType] = useState('');
const [aiImportTruncatedWarning, setAiImportTruncatedWarning] = useState(false); const [aiImportTruncatedWarning, setAiImportTruncatedWarning] = useState(false);
const [emailModal, setEmailModal] = useState(null);
const [exportOpen, setExportOpen] = useState(false); const [exportOpen, setExportOpen] = useState(false);
const [toast, setToast] = useState(''); const [toast, setToast] = useState('');
const [now, setNow] = useState(() => new Date()); const [now, setNow] = useState(() => new Date());
const [dragCustomerIdx, setDragCustomerIdx] = useState(null); const [dragCustomerIdx, setDragCustomerIdx] = useState(null);
const [dragCustomerVip, setDragCustomerVip] = useState(false); // VIP
const [dragGroupState, setDragGroupState] = useState(null); // { category, index } const [dragGroupState, setDragGroupState] = useState(null); // { category, index }
const [pwCurrent, setPwCurrent] = useState(''); const [pwCurrent, setPwCurrent] = useState('');
@ -2077,8 +2104,6 @@ function MainApp({ username, onLogout }) {
const [aiKeyInput, setAiKeyInput] = useState(''); const [aiKeyInput, setAiKeyInput] = useState('');
const [aiSaving, setAiSaving] = useState(false); const [aiSaving, setAiSaving] = useState(false);
const [aiTesting, setAiTesting] = 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 [quotes, setQuotes] = useState([]);
const [sellerProfiles, setSellerProfiles] = useState(DEFAULT_SELLER_PROFILES); const [sellerProfiles, setSellerProfiles] = useState(DEFAULT_SELLER_PROFILES);
@ -2125,7 +2150,6 @@ function MainApp({ username, onLogout }) {
setCustomers(data.customers); setCustomers(data.customers);
setProducts(normalized.products); setProducts(normalized.products);
setProductCategories(normalized.categories); setProductCategories(normalized.categories);
setSettings(data.settings);
setQuotes(data.quotes); setQuotes(data.quotes);
setSellerProfiles(data.sellerProfiles); setSellerProfiles(data.sellerProfiles);
setCertifications(data.certifications); 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.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.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.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.sellerProfiles, JSON.stringify(sellerProfiles)).catch(() => {}); }, [sellerProfiles, loaded]);
useEffect(() => { if (loaded) storageSet(STORAGE_KEYS.certifications, JSON.stringify(certifications)).catch(() => {}); }, [certifications, 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) (c.country || '').toLowerCase().includes(q)
); );
} }
const order = { overdue: 0, due: 1, ok: 2 }; return [...list].sort((a, b) => (b.isVip ? 1 : 0) - (a.isVip ? 1 : 0) || (a.company || '').localeCompare(b.company || ''));
return [...list].sort((a, b) => order[statusOf(a, settings)] - order[statusOf(b, settings)] || (a.company || '').localeCompare(b.company || '')); }, [customers, search, regionFilter]);
}, [customers, search, regionFilter, settings]);
const vipCustomers = useMemo(() => customers.filter(c => c.isVip), [customers]);
const normalCustomers = useMemo(() => customers.filter(c => !c.isVip), [customers]);
const filteredProducts = useMemo(() => { const filteredProducts = useMemo(() => {
let list = products; let list = products;
@ -2206,11 +2231,9 @@ function MainApp({ username, onLogout }) {
}, [products, productSearch, productCategoryFilter, productCategories]); }, [products, productSearch, productCategoryFilter, productCategories]);
const counts = useMemo(() => ({ const counts = useMemo(() => ({
overdue: customers.filter(c => statusOf(c, settings) === 'overdue').length,
due: customers.filter(c => statusOf(c, settings) === 'due').length,
total: customers.length, total: customers.length,
products: products.length, products: products.length,
}), [customers, settings, products]); }), [customers, products]);
const priceHistory = useMemo(() => buildPriceHistory(quotes), [quotes]); const priceHistory = useMemo(() => buildPriceHistory(quotes), [quotes]);
@ -2219,9 +2242,9 @@ function MainApp({ username, onLogout }) {
return catalogDocs.filter(d => d.uploading || d.category === catalogCategoryFilter); return catalogDocs.filter(d => d.uploading || d.category === catalogCategoryFilter);
}, [catalogDocs, catalogCategoryFilter]); }, [catalogDocs, catalogCategoryFilter]);
function handleCustomerDrop(dropIdx) { function handleCustomerDrop(vipFlag, dropIdx) {
if (dragCustomerIdx === null || dragCustomerIdx === dropIdx) { setDragCustomerIdx(null); return; } if (dragCustomerIdx === null || (dragCustomerIdx === dropIdx && dragCustomerVip === vipFlag)) { setDragCustomerIdx(null); return; }
setCustomers(prev => reorderArray(prev, dragCustomerIdx, dropIdx)); setCustomers(prev => reorderCustomerGroup(prev, vipFlag, dragCustomerIdx, dropIdx));
setDragCustomerIdx(null); setDragCustomerIdx(null);
} }
async function handleAddCatalogFile(file) { async function handleAddCatalogFile(file) {
@ -2367,18 +2390,6 @@ function MainApp({ username, onLogout }) {
setAiConfigHasKey(false); setAiConfigHasKey(false);
notify('已清除'); 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 startNewCustomer() { setDraft(blankCustomer()); setSelectedId(null); }
function startEditCustomer(c) { setDraft({ ...c, productLines: [...(c.productLines || [])] }); setSelectedId(c.id); } function startEditCustomer(c) { setDraft({ ...c, productLines: [...(c.productLines || [])] }); setSelectedId(c.id); }
@ -2398,10 +2409,6 @@ function MainApp({ username, onLogout }) {
if (selectedId === id) setSelectedId(null); if (selectedId === id) setSelectedId(null);
notify('已删除'); notify('已删除');
} }
function markContacted(id) {
setCustomers(prev => prev.map(c => (c.id === id ? { ...c, lastContact: todayStr() } : c)));
notify('已标记为今日已联系');
}
function togglePreferredContact(id, channel) { function togglePreferredContact(id, channel) {
setCustomers(prev => prev.map(c => { setCustomers(prev => prev.map(c => {
if (c.id !== id) return c; if (c.id !== id) return c;
@ -2423,7 +2430,7 @@ function MainApp({ username, onLogout }) {
const f = e.dataTransfer.files && e.dataTransfer.files[0]; const f = e.dataTransfer.files && e.dataTransfer.files[0];
if (f) setAttachmentFile(f); if (f) setAttachmentFile(f);
} }
async function handleUploadAttachment(customerId) { async function handleUploadAttachment(customerId, folderId) {
if (!attachmentFile) { notify('请先选择文件'); return; } if (!attachmentFile) { notify('请先选择文件'); return; }
setUploading(true); setUploading(true);
try { try {
@ -2436,6 +2443,7 @@ function MainApp({ username, onLogout }) {
category: attachmentCategory, category: attachmentCategory,
note: attachmentNote.trim(), note: attachmentNote.trim(),
date: todayStr(), date: todayStr(),
folderId: folderId || null,
}; };
return { ...c, attachments: [entry, ...(c.attachments || [])] }; 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 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 startNewProduct() { setProductDraft(blankProduct()); setSelectedProductId(null); }
function startEditProduct(p) { setProductDraft({ ...p, extraNames: [] }); setSelectedProductId(p.id); } function startEditProduct(p) { setProductDraft({ ...p, extraNames: [] }); setSelectedProductId(p.id); }
@ -3111,61 +3154,42 @@ function MainApp({ username, onLogout }) {
{view === 'monitor' && ( {view === 'monitor' && (
<> <>
<div className="stat-row"> <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={counts.total} label="客户总数" tone="green" />
<StatCard num={vipCustomers.length} label="VIP客户" tone="amber" />
<StatCard num={counts.products} label="产品档案数量" tone="blue" /> <StatCard num={counts.products} label="产品档案数量" tone="blue" />
</div> </div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 10 }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 10 }}>
<div className="panel-title">全部客户</div> <div className="panel-title">VIP客户</div>
<span style={{ fontSize: 11.5, color: 'var(--ink-soft)' }}>拖动卡片可调整顺序</span> <span style={{ fontSize: 11.5, color: 'var(--ink-soft)' }}>拖动卡片可调整组内顺序</span>
</div> </div>
{customers.length === 0 ? ( <CustomerMonitorGrid
<div className="panel"><div className="empty-state"><Inbox size={28} />暂无客户"客户管理"添加第一个</div></div> list={vipCustomers}
) : ( vipFlag={true}
<div className="card-grid"> dragCustomerIdx={dragCustomerIdx}
{customers.map((c, idx) => { dragCustomerVip={dragCustomerVip}
const s = statusOf(c, settings); onDragStart={(vipFlag, idx) => { setDragCustomerIdx(idx); setDragCustomerVip(vipFlag); }}
const lt = localTimeInfo(c.country, now); onDrop={handleCustomerDrop}
const modelQty = [c.intendedModel, c.intendedQty ? `× ${c.intendedQty}` : ''].filter(Boolean).join(' '); onDragEnd={() => setDragCustomerIdx(null)}
const cardTitle = customerTitle(c); now={now}
const showCompanyLine = !!(modelQty && c.company); onView={(id) => { setView('manage'); setDraft(null); setSelectedId(id); }}
const { showEmail, showPhone } = contactDisplayFlags(c); />
return (
<div <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', margin: '22px 0 10px' }}>
key={c.id} <div className="panel-title">全部客户</div>
className="monitor-card" <span style={{ fontSize: 11.5, color: 'var(--ink-soft)' }}>拖动卡片可调整组内顺序</span>
style={{ opacity: dragCustomerIdx === idx ? 0.5 : 1 }} </div>
draggable <CustomerMonitorGrid
onDragStart={() => setDragCustomerIdx(idx)} list={normalCustomers}
onDragOver={(e) => e.preventDefault()} vipFlag={false}
onDrop={() => handleCustomerDrop(idx)} dragCustomerIdx={dragCustomerIdx}
onDragEnd={() => setDragCustomerIdx(null)} dragCustomerVip={dragCustomerVip}
> onDragStart={(vipFlag, idx) => { setDragCustomerIdx(idx); setDragCustomerVip(vipFlag); }}
<div className="monitor-card-head"> onDrop={handleCustomerDrop}
<GripVertical size={14} className="drag-handle" /> onDragEnd={() => setDragCustomerIdx(null)}
<div className={`led-dot ${s === 'overdue' ? 'red' : s === 'due' ? 'amber' : 'green'}`} /> now={now}
<div className="monitor-card-title">{cardTitle}</div> onView={(id) => { setView('manage'); setDraft(null); setSelectedId(id); }}
{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>
)}
</> </>
)} )}
@ -3185,13 +3209,15 @@ function MainApp({ username, onLogout }) {
{filteredCustomers.length === 0 ? ( {filteredCustomers.length === 0 ? (
<div className="empty-state"><Inbox size={26} />暂无客户点击上方按钮添加第一个</div> <div className="empty-state"><Inbox size={26} />暂无客户点击上方按钮添加第一个</div>
) : filteredCustomers.map(c => { ) : filteredCustomers.map(c => {
const s = statusOf(c, settings);
const showCompanyInSub = customerTitleIsModel(c) && c.company; const showCompanyInSub = customerTitleIsModel(c) && c.company;
return ( return (
<div key={c.id} className="row" style={{ cursor: 'pointer', background: selectedId === c.id ? '#FAFBFC' : undefined }} onClick={() => { setDraft(null); setSelectedId(c.id); }}> <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-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 className="row-sub">{showCompanyInSub && `${c.company} · `}{regionLabel(c.region)} · {c.country || '—'}{localTimeInfo(c.country, now) && ` · 当地 ${localTimeInfo(c.country, now).time}`}</div>
</div> </div>
<ChevronRight size={15} color="var(--ink-soft)" /> <ChevronRight size={15} color="var(--ink-soft)" />
@ -3208,12 +3234,9 @@ function MainApp({ username, onLogout }) {
) : selected ? ( ) : selected ? (
<CustomerDetail <CustomerDetail
customer={selected} customer={selected}
settings={settings}
now={now} now={now}
onEdit={() => startEditCustomer(selected)} onEdit={() => startEditCustomer(selected)}
onDelete={() => deleteCustomer(selected.id)} onDelete={() => deleteCustomer(selected.id)}
onMarkContacted={() => markContacted(selected.id)}
onEmail={() => openFollowUpEmail(selected)}
onTogglePreferred={(channel) => togglePreferredContact(selected.id, channel)} onTogglePreferred={(channel) => togglePreferredContact(selected.id, channel)}
onToggleVip={() => toggleVip(selected.id)} onToggleVip={() => toggleVip(selected.id)}
onToggleOrdered={() => toggleOrdered(selected.id)} onToggleOrdered={() => toggleOrdered(selected.id)}
@ -3225,15 +3248,16 @@ function MainApp({ username, onLogout }) {
onPickAttachment={(f) => setAttachmentFile(f)} onPickAttachment={(f) => setAttachmentFile(f)}
uploading={uploading} uploading={uploading}
fileInputKey={fileInputKey} fileInputKey={fileInputKey}
onUploadAttachment={() => handleUploadAttachment(selected.id)} onUploadAttachment={(folderId) => handleUploadAttachment(selected.id, folderId)}
onDeleteAttachment={(fileName) => handleDeleteAttachment(selected.id, fileName)} 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} fileDragActive={fileDragActive}
onFileDragOver={handleFileDragOver} onFileDragOver={handleFileDragOver}
onFileDragLeave={handleFileDragLeave} onFileDragLeave={handleFileDragLeave}
onFileDrop={handleFileDrop} onFileDrop={handleFileDrop}
aiConfigHasKey={aiConfigHasKey}
aiGenerating={aiGenerating}
onGenerateAi={(channel) => handleGenerateAi(selected.id, channel)}
/> />
) : ( ) : (
<div className="panel"><div className="empty-state"><Users size={28} />选择左侧客户查看详情或新建一个客户档案</div></div> <div className="panel"><div className="empty-state"><Users size={28} />选择左侧客户查看详情或新建一个客户档案</div></div>
@ -3665,29 +3689,19 @@ function MainApp({ username, onLogout }) {
{view === 'settings' && ( {view === 'settings' && (
<> <>
<div className="panel section-gap"> <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="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 }}> <div style={{ display: 'flex', gap: 10, marginTop: 6 }}>
<button className="btn" onClick={() => setExportOpen(true)}><Save size={13} />导出数据备份</button> <button className="btn" onClick={() => setExportOpen(true)}><Save size={13} />导出数据备份</button>
<button className="btn btn-danger" onClick={() => { <button className="btn btn-danger" onClick={() => {
if (window.confirm('将清空全部客户与产品档案数据,且不可恢复,确定继续?')) { if (window.confirm('将清空全部客户与产品档案数据,且不可恢复,确定继续?')) {
setCustomers([]); setProducts([]); setSettings(DEFAULT_SETTINGS); setCustomers([]); setProducts([]);
notify('已清空'); notify('已清空');
} }
}}><RefreshCw size={13} />清空所有数据</button> }}><RefreshCw size={13} />清空所有数据</button>
</div> </div>
<div className="disclaimer" style={{ marginTop: 18 }}> <div className="disclaimer" style={{ marginTop: 18 }}>
说明邮件功能用于<b>生成可复制的英文邮件文案</b>需要你手动粘贴到自己的邮箱发送报价单制作正在开发中下一轮会上线 说明报价单制作正在开发中下一轮会上线
</div> </div>
</div> </div>
</div> </div>
@ -3726,7 +3740,7 @@ function MainApp({ username, onLogout }) {
{aiConfigHasKey && <button className="btn btn-danger" onClick={handleClearAiKey}>清除</button>} {aiConfigHasKey && <button className="btn btn-danger" onClick={handleClearAiKey}>清除</button>}
</div> </div>
<div className="disclaimer" style={{ marginTop: 14 }}> <div className="disclaimer" style={{ marginTop: 14 }}>
用于客户详情页的"AI生成邮件 / AI生成WhatsApp话术"功能调用DeepSeek官方API生成成本很低单次生成大概几分钱以内人民币按你自己DeepSeek账户余额实际扣费Key只会加密保存在服务器本地不会显示给任何人 用于"产品管理"里的<b>产品目录批量导入</b>功能调用DeepSeek官方API识别目录文档生成产品字段成本很低单次生成大概几分钱以内人民币按你自己DeepSeek账户余额实际扣费Key只会加密保存在服务器本地不会显示给任何人
</div> </div>
</div> </div>
</div> </div>
@ -3738,58 +3752,15 @@ function MainApp({ username, onLogout }) {
</div> </div>
</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 && ( {exportOpen && (
<div className="modal-overlay" onClick={() => setExportOpen(false)}> <div className="modal-overlay" onClick={() => setExportOpen(false)}>
<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, 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>
<div className="modal-foot"> <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> </div>
</div> </div>
@ -4087,10 +4058,6 @@ function CustomerForm({ draft, setDraft, onSave, onCancel }) {
</div> </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> <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)}> <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({ function CustomerDetail({
customer: c, settings, now, onEdit, onDelete, onMarkContacted, onEmail, customer: c, now, onEdit, onDelete,
onTogglePreferred, onToggleVip, onToggleOrdered, onTogglePreferred, onToggleVip, onToggleOrdered,
attachmentNote, setAttachmentNote, attachmentCategory, setAttachmentCategory, attachmentNote, setAttachmentNote, attachmentCategory, setAttachmentCategory,
attachmentFileName, onPickAttachment, uploading, fileInputKey, onUploadAttachment, onDeleteAttachment, attachmentFileName, onPickAttachment, uploading, fileInputKey, onUploadAttachment, onDeleteAttachment, onMoveAttachment,
onCreateFolder, onRenameFolder, onDeleteFolder,
fileDragActive, onFileDragOver, onFileDragLeave, onFileDrop, fileDragActive, onFileDragOver, onFileDragLeave, onFileDrop,
aiConfigHasKey, aiGenerating, onGenerateAi,
}) { }) {
const s = statusOf(c, settings);
const lt = localTimeInfo(c.country, now); const lt = localTimeInfo(c.country, now);
const preferred = c.preferredContacts || []; const preferred = c.preferredContacts || [];
const [fileSearch, setFileSearch] = useState(''); const [fileSearch, setFileSearch] = useState('');
const [fileCategoryFilter, setFileCategoryFilter] = useState('all'); 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 (fileCategoryFilter !== 'all' && a.category !== fileCategoryFilter) return false;
if (fileSearch.trim()) { if (fileSearch.trim()) {
const q = fileSearch.trim().toLowerCase(); const q = fileSearch.trim().toLowerCase();
@ -4161,18 +4151,21 @@ function CustomerDetail({
} }
return true; 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 ( return (
<div className="panel"> <div className="panel">
<div className="panel-header"> <div className="panel-header">
<div className="panel-title"> <div className="panel-title">
<div className={`led-dot ${s === 'overdue' ? 'red' : s === 'due' ? 'amber' : 'green'}`} />
{customerTitle(c)} {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>
<div style={{ display: 'flex', gap: 8 }}> <div style={{ display: 'flex', gap: 8 }}>
<button className="btn btn-sm" onClick={onEdit}><Edit2 size={12} />编辑</button> <button className="btn btn-sm" onClick={onEdit}><Edit2 size={12} />编辑</button>
@ -4180,6 +4173,14 @@ function CustomerDetail({
</div> </div>
</div> </div>
<div className="panel-body" style={{ padding: 18 }}> <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 }}> <div className="grid-2" style={{ marginBottom: 16 }}>
<Info icon={<Building2 size={13} />} label="公司名称" value={c.company || '—'} /> <Info icon={<Building2 size={13} />} label="公司名称" value={c.company || '—'} />
<Info icon={<Building2 size={13} />} label="联系人" value={c.contact || '—'} /> <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>} {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"> <div className="field">
<span className="field-label">客户文件管理</span> <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 }}> <div style={{ display: 'flex', gap: 8, alignItems: 'stretch', flexWrap: 'wrap', marginBottom: 8 }}>
<label <label
className={`dropzone ${fileDragActive ? 'drag-active' : ''}`} className={`dropzone ${fileDragActive ? 'drag-active' : ''}`}
@ -4258,14 +4275,14 @@ function CustomerDetail({
className="input" placeholder="备注(可选)" className="input" placeholder="备注(可选)"
value={attachmentNote} onChange={e => setAttachmentNote(e.target.value)} value={attachmentNote} onChange={e => setAttachmentNote(e.target.value)}
/> />
<button className="btn btn-sm" disabled={uploading} onClick={onUploadAttachment} style={{ justifyContent: 'center' }}> <button className="btn btn-sm" disabled={uploading} onClick={() => onUploadAttachment(currentFolderId)} style={{ justifyContent: 'center' }}>
<Plus size={12} />{uploading ? '上传中…' : '上传'} <Plus size={12} />{uploading ? '上传中…' : (currentFolderId ? '上传到当前文件夹' : '上传')}
</button> </button>
</div> </div>
</div> </div>
</div> </div>
<CollapsibleSection title="文件列表" count={(c.attachments || []).length} defaultOpen={(c.attachments || []).length > 0}> <CollapsibleSection title="文件列表" count={filteredAttachments.length} defaultOpen={attachments.length > 0}>
{(!c.attachments || c.attachments.length === 0) ? ( {attachments.length === 0 ? (
<div className="row-sub" style={{ padding: '8px 0' }}>暂无文件</div> <div className="row-sub" style={{ padding: '8px 0' }}>暂无文件</div>
) : ( ) : (
<> <>
@ -4280,7 +4297,7 @@ function CustomerDetail({
</select> </select>
</div> </div>
{filteredAttachments.length === 0 ? ( {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) => ( ) : 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' }}> <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 }} /> <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>} {a.note && <span style={{ color: 'var(--ink-soft)' }}> {a.note}</span>}
</div> </div>
</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> <button className="btn btn-sm btn-ghost btn-danger" onClick={() => onDeleteAttachment(a.fileName)}><Trash2 size={12} /></button>
</div> </div>
))} ))}