Update App.jsx via upload script - 2026-08-13 13:53:13
This commit is contained in:
parent
4b8747d72f
commit
9a66d7c333
510
App.jsx
510
App.jsx
|
|
@ -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},parentId为null表示根目录下的文件夹
|
||||
});
|
||||
|
||||
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;
|
||||
// VIP客户和非VIP客户在"客户档案"看板里分两组展示,拖拽排序只在各自组内生效——
|
||||
// 取出该组成员按新顺序重排,再按原来在整个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},附件通过folderId关联到某个文件夹(null=根目录),前端做成
|
||||
// 资源管理器的样子——新建/重命名/删除文件夹、点进去浏览、面包屑导航。
|
||||
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>
|
||||
))}
|
||||
|
|
|
|||
Loading…
Reference in New Issue