diff --git a/App.jsx b/App.jsx
index 5ce1989..8c5620e 100644
--- a/App.jsx
+++ b/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; }
`}
);
@@ -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
{vipFlag ? '还没有VIP客户' : '暂无客户'}
;
+ }
+ return (
+
+ {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 (
+
onDragStart(vipFlag, idx)}
+ onDragOver={(e) => e.preventDefault()}
+ onDrop={() => onDrop(vipFlag, idx)}
+ onDragEnd={onDragEnd}
+ >
+
+
+
{cardTitle}
+ {c.hasOrdered &&
已下单}
+
+
{regionLabel(c.region)} · {c.country || '—'}
+ {showCompanyLine &&
{c.company}
}
+ {c.contact &&
{c.contact}
}
+ {showEmail &&
{c.email}
}
+ {showPhone &&
}
+ {lt &&
当地时间 {lt.time}
}
+
+
+
+
+ );
+ })}
+
+ );
+}
+
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' && (
<>
-
-
+
-
全部客户
-
拖动卡片可调整顺序
+
VIP客户
+
拖动卡片可调整组内顺序
- {customers.length === 0 ? (
-
- ) : (
-
- {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 (
-
setDragCustomerIdx(idx)}
- onDragOver={(e) => e.preventDefault()}
- onDrop={() => handleCustomerDrop(idx)}
- onDragEnd={() => setDragCustomerIdx(null)}
- >
-
-
-
-
{cardTitle}
- {c.isVip &&
}
- {c.hasOrdered &&
}
-
-
{regionLabel(c.region)} · {c.country || '—'}
- {showCompanyLine &&
{c.company}
}
- {c.contact &&
{c.contact}
}
- {showEmail &&
{c.email}
}
- {showPhone &&
}
- {lt &&
当地时间 {lt.time}
}
-
上次联系 {fmtDate(c.lastContact)}
-
-
-
-
-
- );
- })}
-
- )}
+ { setDragCustomerIdx(idx); setDragCustomerVip(vipFlag); }}
+ onDrop={handleCustomerDrop}
+ onDragEnd={() => setDragCustomerIdx(null)}
+ now={now}
+ onView={(id) => { setView('manage'); setDraft(null); setSelectedId(id); }}
+ />
+
+
+ { 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 ? (
暂无客户,点击上方按钮添加第一个
) : filteredCustomers.map(c => {
- const s = statusOf(c, settings);
const showCompanyInSub = customerTitleIsModel(c) && c.company;
return (
{ setDraft(null); setSelectedId(c.id); }}>
-
-
{c.isVip && }{c.hasOrdered && }{customerTitle(c)}
+
+ {c.isVip && VIP}
+ {c.hasOrdered && 已下单}
+ {customerTitle(c)}
+
{showCompanyInSub && `${c.company} · `}{regionLabel(c.region)} · {c.country || '—'}{localTimeInfo(c.country, now) && ` · 当地 ${localTimeInfo(c.country, now).time}`}
@@ -3208,12 +3234,9 @@ function MainApp({ username, onLogout }) {
) : selected ? (
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)}
/>
) : (
@@ -3665,29 +3689,19 @@ function MainApp({ username, onLogout }) {
{view === 'settings' && (
<>
-
+
-
- 说明:邮件功能用于生成可复制的英文邮件文案,需要你手动粘贴到自己的邮箱发送。报价单制作正在开发中,下一轮会上线。
+ 说明:报价单制作正在开发中,下一轮会上线。
@@ -3726,7 +3740,7 @@ function MainApp({ username, onLogout }) {
{aiConfigHasKey && }
- 用于客户详情页的"AI生成邮件 / AI生成WhatsApp话术"功能,调用DeepSeek官方API生成。成本很低(单次生成大概几分钱以内人民币,按你自己DeepSeek账户余额实际扣费),Key只会加密保存在服务器本地,不会显示给任何人。
+ 用于"产品管理"里的产品目录批量导入功能,调用DeepSeek官方API识别目录文档生成产品字段。成本很低(单次生成大概几分钱以内人民币,按你自己DeepSeek账户余额实际扣费),Key只会加密保存在服务器本地,不会显示给任何人。
@@ -3738,58 +3752,15 @@ function MainApp({ username, onLogout }) {
- {aiDraftModal && (
- setAiDraftModal(null)}>
-
e.stopPropagation()}>
-
-
{aiDraftModal.channel === 'email' ? 'AI生成邮件' : 'AI生成WhatsApp话术'}
-
-
-
- {aiDraftModal.channel === 'email' && (
-
主题 Subject setAiDraftModal(m => ({ ...m, subject: e.target.value }))} />
- )}
-
- {aiDraftModal.channel === 'email' ? '正文 Body' : '消息内容'}
-
-
-
- {aiDraftModal.channel === 'email' && }
-
-
-
-
- )}
-
- {emailModal && (
- setEmailModal(null)}>
-
e.stopPropagation()}>
-
邮件文案
-
-
-
-
-
-
-
- )}
-
{exportOpen && (
setExportOpen(false)}>
e.stopPropagation()}>
数据备份(JSON)
-
+
-
+
@@ -4087,10 +4058,6 @@ function CustomerForm({ draft, setDraft, onSave, onCancel }) {
)}
-
备注
@@ -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 (
-
{customerTitle(c)}
-
-
-
-
-
-
@@ -4180,6 +4173,14 @@ function CustomerDetail({
+
+
+ {c.isVip ? '★ VIP客户' : '标记为VIP客户'}
+
+
+ {c.hasOrdered ? '已下单' : '标记为已下单'}
+
+
} label="公司名称" value={c.company || '—'} />
} label="联系人" value={c.contact || '—'} />
@@ -4219,24 +4220,40 @@ function CustomerDetail({
{c.notes &&
}
-
- 上次联系:{fmtDate(c.lastContact)} | 状态:{statusLabel(s)}
-
-
-
-
-
-
-
- {!aiConfigHasKey && 未配置DeepSeek,去"设置"里填API Key}
-
-
客户文件管理
+
+
+ setCurrentFolderId(null)}>根目录
+ {breadcrumb.map(f => (
+
+
+ setCurrentFolderId(f.id)}>{f.name}
+
+ ))}
+
+
+
+ {subfolders.length > 0 && (
+
+ {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 (
+
setCurrentFolderId(f.id)}>
+
+
{f.name}
+
{fileCount + subCount} 项
+
e.stopPropagation()}>
+
+
+
+
+ );
+ })}
+
+ )}
+
-
0}>
- {(!c.attachments || c.attachments.length === 0) ? (
+ 0}>
+ {attachments.length === 0 ? (
暂无文件
) : (
<>
@@ -4280,7 +4297,7 @@ function CustomerDetail({
{filteredAttachments.length === 0 ? (
-
没有匹配的文件
+
当前文件夹没有匹配的文件
) : filteredAttachments.map((a, i) => (
@@ -4292,6 +4309,15 @@ function CustomerDetail({
{a.note &&
— {a.note}}
+
onDeleteAttachment(a.fileName)}>
))}