import React, { useState, useEffect, useMemo } from 'react'; import { Search, Plus, X, Mail, Edit2, Trash2, Check, Clock, 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 } from 'lucide-react'; import { storageGet, storageSet, uploadFile, deleteFile, authCheck, authLogin, authLogout, authChangePassword, getAiConfig, saveAiConfig, deleteAiConfig, testAiConfig, generateAiDraft, } from './storage'; /* ----------------------------- constants ----------------------------- */ const REGIONS = [ { id: 'africa', label: 'Africa / 非洲' }, { id: 'middle_east', label: 'Middle East / 中东' }, { id: 'australia', label: 'Australia / 大洋洲' }, { id: 'europe', label: 'Europe / 欧洲' }, { id: 'sea', label: 'SE Asia / 东南亚' }, { id: 'other', label: 'Other / 其他' }, ]; const PRODUCT_LINES = ['UPS不间断电源', '光伏逆变器', '储能电池', 'Other']; const PRODUCT_CATEGORIES = [ { id: 'ups', label: 'UPS不间断电源 / UPS', short: 'UPS不间断电源', subs: [ { id: 'mini', label: '迷你UPS / Mini UPS', short: '迷你UPS' }, { id: 'offline', label: '离线式UPS / Offline UPS', short: '离线式UPS' }, { id: 'line_interactive', label: '在线互动式UPS / Line-Interactive UPS', short: '在线互动式UPS' }, { id: 'online', label: '在线式UPS / Online UPS', short: '在线式UPS', subs: [ { id: 'rack', label: '机架式UPS / Rack-mount UPS', short: '机架式UPS' }, { id: 'high_freq_tower', label: '高频塔式UPS / High-Frequency Tower UPS', short: '高频塔式UPS' }, { id: 'low_freq', label: '工频UPS / Low-Frequency UPS', short: '工频UPS' }, { id: 'modular', label: '模块化UPS / Modular UPS', short: '模块化UPS' }, ], }, ], }, { id: 'inverter', label: '光伏逆变器 / Solar Inverter', short: '光伏逆变器', subs: [ { id: 'hybrid', label: '太阳能混网逆变器 / Hybrid Solar Inverter', short: '太阳能混网逆变器' }, { id: 'grid_off_grid', label: '并网/离网逆变器 / Grid-tied / Off-grid Inverter', short: '并网/离网逆变器' }, ], }, { id: 'battery', label: '储能电池 / Energy Storage Battery', short: '储能电池', subs: [ { id: 'lead_acid', label: '铅酸电池 / Lead-acid Battery', short: '铅酸电池' }, { id: 'lifepo4', label: '磷酸铁锂电池 / LiFePO4 Battery', short: '磷酸铁锂电池' }, ], }, ]; const DEFAULT_SETTINGS = { defaultFollowUpDays: 14, dueSoonWindow: 7 }; const CUSTOMER_SOURCES = ['Made-in-China', '官网', '展会', 'Other']; const FILE_CATEGORIES = ['报价单', '合同', '聊天记录', '产品资料', '物流单据', 'Other']; // 国家 -> 地区 / 电话国家代码 / 时区(以首都为准)映射表 // 用于:选择国家后自动匹配所属大洲、电话区号前缀、当地时间 const COUNTRIES = [ // Africa / 非洲 { name: '尼日利亚', en: 'Nigeria', region: 'africa', dial: '+234', tz: 'Africa/Lagos' }, { name: '埃及', en: 'Egypt', region: 'africa', dial: '+20', tz: 'Africa/Cairo' }, { name: '南非', en: 'South Africa', region: 'africa', dial: '+27', tz: 'Africa/Johannesburg' }, { name: '肯尼亚', en: 'Kenya', region: 'africa', dial: '+254', tz: 'Africa/Nairobi' }, { name: '加纳', en: 'Ghana', region: 'africa', dial: '+233', tz: 'Africa/Accra' }, { name: '阿尔及利亚', en: 'Algeria', region: 'africa', dial: '+213', tz: 'Africa/Algiers' }, { name: '摩洛哥', en: 'Morocco', region: 'africa', dial: '+212', tz: 'Africa/Casablanca' }, { name: '突尼斯', en: 'Tunisia', region: 'africa', dial: '+216', tz: 'Africa/Tunis' }, { name: '埃塞俄比亚', en: 'Ethiopia', region: 'africa', dial: '+251', tz: 'Africa/Addis_Ababa' }, { name: '坦桑尼亚', en: 'Tanzania', region: 'africa', dial: '+255', tz: 'Africa/Dar_es_Salaam' }, { name: '乌干达', en: 'Uganda', region: 'africa', dial: '+256', tz: 'Africa/Kampala' }, { name: '安哥拉', en: 'Angola', region: 'africa', dial: '+244', tz: 'Africa/Luanda' }, { name: '莫桑比克', en: 'Mozambique', region: 'africa', dial: '+258', tz: 'Africa/Maputo' }, { name: '赞比亚', en: 'Zambia', region: 'africa', dial: '+260', tz: 'Africa/Lusaka' }, { name: '津巴布韦', en: 'Zimbabwe', region: 'africa', dial: '+263', tz: 'Africa/Harare' }, { name: '塞内加尔', en: 'Senegal', region: 'africa', dial: '+221', tz: 'Africa/Dakar' }, { name: '科特迪瓦', en: "Cote d'Ivoire", region: 'africa', dial: '+225', tz: 'Africa/Abidjan' }, { name: '喀麦隆', en: 'Cameroon', region: 'africa', dial: '+237', tz: 'Africa/Douala' }, { name: '刚果(金)', en: 'DR Congo', region: 'africa', dial: '+243', tz: 'Africa/Kinshasa' }, { name: '刚果(布)', en: 'Congo', region: 'africa', dial: '+242', tz: 'Africa/Brazzaville' }, { name: '利比亚', en: 'Libya', region: 'africa', dial: '+218', tz: 'Africa/Tripoli' }, { name: '苏丹', en: 'Sudan', region: 'africa', dial: '+249', tz: 'Africa/Khartoum' }, { name: '卢旺达', en: 'Rwanda', region: 'africa', dial: '+250', tz: 'Africa/Kigali' }, { name: '博茨瓦纳', en: 'Botswana', region: 'africa', dial: '+267', tz: 'Africa/Gaborone' }, { name: '纳米比亚', en: 'Namibia', region: 'africa', dial: '+264', tz: 'Africa/Windhoek' }, { name: '马里', en: 'Mali', region: 'africa', dial: '+223', tz: 'Africa/Bamako' }, { name: '尼日尔', en: 'Niger', region: 'africa', dial: '+227', tz: 'Africa/Niamey' }, // Middle East / 中东 { name: '沙特阿拉伯', en: 'Saudi Arabia', region: 'middle_east', dial: '+966', tz: 'Asia/Riyadh' }, { name: '阿联酋', en: 'UAE', region: 'middle_east', dial: '+971', tz: 'Asia/Dubai' }, { name: '卡塔尔', en: 'Qatar', region: 'middle_east', dial: '+974', tz: 'Asia/Qatar' }, { name: '科威特', en: 'Kuwait', region: 'middle_east', dial: '+965', tz: 'Asia/Kuwait' }, { name: '巴林', en: 'Bahrain', region: 'middle_east', dial: '+973', tz: 'Asia/Bahrain' }, { name: '阿曼', en: 'Oman', region: 'middle_east', dial: '+968', tz: 'Asia/Muscat' }, { name: '伊拉克', en: 'Iraq', region: 'middle_east', dial: '+964', tz: 'Asia/Baghdad' }, { name: '伊朗', en: 'Iran', region: 'middle_east', dial: '+98', tz: 'Asia/Tehran' }, { name: '约旦', en: 'Jordan', region: 'middle_east', dial: '+962', tz: 'Asia/Amman' }, { name: '黎巴嫩', en: 'Lebanon', region: 'middle_east', dial: '+961', tz: 'Asia/Beirut' }, { name: '以色列', en: 'Israel', region: 'middle_east', dial: '+972', tz: 'Asia/Jerusalem' }, { name: '也门', en: 'Yemen', region: 'middle_east', dial: '+967', tz: 'Asia/Aden' }, { name: '叙利亚', en: 'Syria', region: 'middle_east', dial: '+963', tz: 'Asia/Damascus' }, { name: '土耳其', en: 'Turkey', region: 'middle_east', dial: '+90', tz: 'Europe/Istanbul' }, // Australia / 大洋洲 { name: '澳大利亚', en: 'Australia', region: 'australia', dial: '+61', tz: 'Australia/Sydney' }, { name: '新西兰', en: 'New Zealand', region: 'australia', dial: '+64', tz: 'Pacific/Auckland' }, { name: '巴布亚新几内亚', en: 'Papua New Guinea', region: 'australia', dial: '+675', tz: 'Pacific/Port_Moresby' }, { name: '斐济', en: 'Fiji', region: 'australia', dial: '+679', tz: 'Pacific/Fiji' }, // Europe / 欧洲 { name: '俄罗斯', en: 'Russia', region: 'europe', dial: '+7', tz: 'Europe/Moscow' }, { name: '德国', en: 'Germany', region: 'europe', dial: '+49', tz: 'Europe/Berlin' }, { name: '法国', en: 'France', region: 'europe', dial: '+33', tz: 'Europe/Paris' }, { name: '英国', en: 'United Kingdom', region: 'europe', dial: '+44', tz: 'Europe/London' }, { name: '意大利', en: 'Italy', region: 'europe', dial: '+39', tz: 'Europe/Rome' }, { name: '西班牙', en: 'Spain', region: 'europe', dial: '+34', tz: 'Europe/Madrid' }, { name: '波兰', en: 'Poland', region: 'europe', dial: '+48', tz: 'Europe/Warsaw' }, { name: '乌克兰', en: 'Ukraine', region: 'europe', dial: '+380', tz: 'Europe/Kyiv' }, { name: '荷兰', en: 'Netherlands', region: 'europe', dial: '+31', tz: 'Europe/Amsterdam' }, { name: '比利时', en: 'Belgium', region: 'europe', dial: '+32', tz: 'Europe/Brussels' }, { name: '瑞典', en: 'Sweden', region: 'europe', dial: '+46', tz: 'Europe/Stockholm' }, { name: '挪威', en: 'Norway', region: 'europe', dial: '+47', tz: 'Europe/Oslo' }, { name: '芬兰', en: 'Finland', region: 'europe', dial: '+358', tz: 'Europe/Helsinki' }, { name: '丹麦', en: 'Denmark', region: 'europe', dial: '+45', tz: 'Europe/Copenhagen' }, { name: '瑞士', en: 'Switzerland', region: 'europe', dial: '+41', tz: 'Europe/Zurich' }, { name: '奥地利', en: 'Austria', region: 'europe', dial: '+43', tz: 'Europe/Vienna' }, { name: '葡萄牙', en: 'Portugal', region: 'europe', dial: '+351', tz: 'Europe/Lisbon' }, { name: '希腊', en: 'Greece', region: 'europe', dial: '+30', tz: 'Europe/Athens' }, { name: '罗马尼亚', en: 'Romania', region: 'europe', dial: '+40', tz: 'Europe/Bucharest' }, { name: '匈牙利', en: 'Hungary', region: 'europe', dial: '+36', tz: 'Europe/Budapest' }, { name: '捷克', en: 'Czech Republic', region: 'europe', dial: '+420', tz: 'Europe/Prague' }, { name: '塞尔维亚', en: 'Serbia', region: 'europe', dial: '+381', tz: 'Europe/Belgrade' }, { name: '保加利亚', en: 'Bulgaria', region: 'europe', dial: '+359', tz: 'Europe/Sofia' }, { name: '克罗地亚', en: 'Croatia', region: 'europe', dial: '+385', tz: 'Europe/Zagreb' }, { name: '爱尔兰', en: 'Ireland', region: 'europe', dial: '+353', tz: 'Europe/Dublin' }, // SE Asia / 东南亚 { name: '印度尼西亚', en: 'Indonesia', region: 'sea', dial: '+62', tz: 'Asia/Jakarta' }, { name: '马来西亚', en: 'Malaysia', region: 'sea', dial: '+60', tz: 'Asia/Kuala_Lumpur' }, { name: '泰国', en: 'Thailand', region: 'sea', dial: '+66', tz: 'Asia/Bangkok' }, { name: '越南', en: 'Vietnam', region: 'sea', dial: '+84', tz: 'Asia/Ho_Chi_Minh' }, { name: '菲律宾', en: 'Philippines', region: 'sea', dial: '+63', tz: 'Asia/Manila' }, { name: '新加坡', en: 'Singapore', region: 'sea', dial: '+65', tz: 'Asia/Singapore' }, { name: '缅甸', en: 'Myanmar', region: 'sea', dial: '+95', tz: 'Asia/Yangon' }, { name: '柬埔寨', en: 'Cambodia', region: 'sea', dial: '+855', tz: 'Asia/Phnom_Penh' }, { name: '老挝', en: 'Laos', region: 'sea', dial: '+856', tz: 'Asia/Vientiane' }, { name: '文莱', en: 'Brunei', region: 'sea', dial: '+673', tz: 'Asia/Brunei' }, // Other / 其他 { name: '美国', en: 'United States', region: 'other', dial: '+1', tz: 'America/New_York' }, { name: '加拿大', en: 'Canada', region: 'other', dial: '+1', tz: 'America/Toronto' }, { name: '巴西', en: 'Brazil', region: 'other', dial: '+55', tz: 'America/Sao_Paulo' }, { name: '墨西哥', en: 'Mexico', region: 'other', dial: '+52', tz: 'America/Mexico_City' }, { name: '阿根廷', en: 'Argentina', region: 'other', dial: '+54', tz: 'America/Argentina/Buenos_Aires' }, { name: '印度', en: 'India', region: 'other', dial: '+91', tz: 'Asia/Kolkata' }, { name: '巴基斯坦', en: 'Pakistan', region: 'other', dial: '+92', tz: 'Asia/Karachi' }, { name: '孟加拉国', en: 'Bangladesh', region: 'other', dial: '+880', tz: 'Asia/Dhaka' }, { name: '中国', en: 'China', region: 'other', dial: '+86', tz: 'Asia/Shanghai' }, { name: '韩国', en: 'South Korea', region: 'other', dial: '+82', tz: 'Asia/Seoul' }, { name: '日本', en: 'Japan', region: 'other', dial: '+81', tz: 'Asia/Tokyo' }, ]; const COUNTRY_BY_NAME = Object.fromEntries(COUNTRIES.map(c => [c.name, c])); const STORAGE_KEYS = { customers: 'crm:customers', products: 'crm:products', settings: 'crm:settings' }; const blankCustomer = () => ({ id: 'c_' + Date.now() + '_' + Math.random().toString(36).slice(2, 7), company: '', contact: '', country: '', region: 'middle_east', email: '', phone: '', isVip: false, hasOrdered: false, preferredContacts: [], website: '', productLines: [], productLinesNote: '', source: '', tags: '', notes: '', lastContact: '', followUpDays: '', warehouseAddress: '', freightContact: '', shippingMarkNote: '', attachments: [], }); const blankProduct = () => ({ id: 'p_' + Date.now() + '_' + Math.random().toString(36).slice(2, 7), name: '', brand: '', category: '', subCategory: '', subSubCategory: '', powerOrCapacity: '', voltage: '', certifications: '', targetRegions: [], notes: '', }); /* ------------------------------- helpers ------------------------------ */ 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; } function localTimeInfo(countryName, now) { const c = COUNTRY_BY_NAME[countryName]; if (!c) return null; try { const time = new Intl.DateTimeFormat('zh-CN', { timeZone: c.tz, hour: '2-digit', minute: '2-digit', hour12: false }).format(now); return { time, en: c.en, tz: c.tz }; } catch (e) { return null; } } // 没有手动标记常用联系方式时,邮箱和电话都展示;标了哪个就只展示哪个 function contactDisplayFlags(c) { const preferred = c.preferredContacts || []; const none = preferred.length === 0; return { showEmail: (none || preferred.includes('email')) && !!c.email, showPhone: (none || preferred.includes('phone')) && !!c.phone, }; } function reorderArray(arr, fromIndex, toIndex) { const copy = [...arr]; const [moved] = copy.splice(fromIndex, 1); copy.splice(toIndex, 0, moved); return copy; } function reorderWithinCategory(products, category, fromIndex, toIndex) { const catItems = products.filter(p => p.category === category); const reordered = reorderArray(catItems, fromIndex, toIndex); let i = 0; return products.map(p => (p.category === category ? reordered[i++] : p)); } function fmtDate(d) { if (!d) return '—'; return d; } function getTopCategory(id) { return PRODUCT_CATEGORIES.find(c => c.id === id); } function categoryBreadcrumb(p) { const top = getTopCategory(p.category); if (!top) return '未分类'; const sub = top.subs.find(s => s.id === p.subCategory); if (!sub) return top.short; const subsub = sub.subs && sub.subs.find(s => s.id === p.subSubCategory); return [top.short, sub.short, subsub && subsub.short].filter(Boolean).join(' › '); } 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; try { const c = await storageGet(STORAGE_KEYS.customers); if (c && c.value) customers = JSON.parse(c.value); } catch (e) {} try { 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) {} return { customers, products, settings }; } /* -------------------------------- styles -------------------------------- */ const Styles = () => ( ); /* -------------------------------- pieces -------------------------------- */ function StatCard({ num, label, tone }) { return (
{num}
{label}
); } function ProductPills({ value, onChange }) { const toggle = (p) => { const has = value.includes(p); onChange(has ? value.filter(x => x !== p) : [...value, p]); }; return (
{PRODUCT_LINES.map(p => (
toggle(p)}> {value.includes(p) && } {p}
))}
); } function RegionPills({ value, onChange }) { const toggle = (id) => { const has = value.includes(id); onChange(has ? value.filter(x => x !== id) : [...value, id]); }; return (
{REGIONS.map(r => (
toggle(r.id)}> {value.includes(r.id) && } {r.label}
))}
); } /* --------------------------------- app ---------------------------------- */ /* --------------------------------- nav --------------------------------- */ const NAV_GROUPS = [ { label: '客户', items: [ { id: 'monitor', label: '客户监控', icon: LayoutDashboard }, { id: 'manage', label: '客户管理', icon: Users }, ] }, { label: '产品', items: [ { id: 'groups', label: '产品分组', icon: FolderTree }, { id: 'products', label: '产品管理', icon: Package }, { id: 'catalog', label: '产品目录', icon: BookOpen }, ] }, ]; const STANDALONE_NAV = [ { id: 'quotes', label: '报价单制作', icon: FileText }, { id: 'settings', label: '设置', icon: SettingsIcon }, ]; function viewTitle(view) { const all = [...NAV_GROUPS.flatMap(g => g.items), ...STANDALONE_NAV]; const found = all.find(i => i.id === view); return found ? found.label : '客户档案系统'; } /* ------------------------------- login screen ----------------------------- */ function LoginScreen({ onSuccess }) { const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); const [error, setError] = useState(''); const [loading, setLoading] = useState(false); async function handleSubmit(e) { e.preventDefault(); if (loading) return; setError(''); setLoading(true); try { const res = await authLogin(username, password); onSuccess(res.username || username); } catch (e) { setError('用户名或密码不正确'); } finally { setLoading(false); } } return (
客户档案系统
请登录后继续
用户名 setUsername(e.target.value)} autoFocus autoCapitalize="off" />
密码 setPassword(e.target.value)} />
{error &&
{error}
}
); } /* --------------------------------- root --------------------------------- */ export default function CustomerCRM() { const [authState, setAuthState] = useState('checking'); // checking | out | in const [username, setUsername] = useState(null); useEffect(() => { authCheck() .then(res => { if (res.authed) { setUsername(res.username); setAuthState('in'); } else setAuthState('out'); }) .catch(() => setAuthState('out')); }, []); function handleLogout() { authLogout().finally(() => { setUsername(null); setAuthState('out'); }); } if (authState === 'checking') { return (
加载中…
); } if (authState === 'out') { return { setUsername(name); setAuthState('in'); }} />; } return ; } export { LoginScreen, MainApp }; 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); const [selectedId, setSelectedId] = useState(null); const [draft, setDraft] = useState(null); const [search, setSearch] = useState(''); const [regionFilter, setRegionFilter] = useState('all'); const [attachmentNote, setAttachmentNote] = useState(''); const [attachmentCategory, setAttachmentCategory] = useState(FILE_CATEGORIES[0]); const [attachmentFile, setAttachmentFile] = useState(null); const [uploading, setUploading] = useState(false); const [fileInputKey, setFileInputKey] = useState(0); const [fileDragActive, setFileDragActive] = useState(false); const [selectedProductId, setSelectedProductId] = useState(null); const [productDraft, setProductDraft] = useState(null); const [productSearch, setProductSearch] = useState(''); const [productCategoryFilter, setProductCategoryFilter] = useState('all'); 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 [dragCatalogIdx, setDragCatalogIdx] = useState(null); const [dragGroupState, setDragGroupState] = useState(null); // { category, index } const [pwCurrent, setPwCurrent] = useState(''); const [pwNew, setPwNew] = useState(''); const [pwBusy, setPwBusy] = useState(false); const [aiConfigHasKey, setAiConfigHasKey] = useState(false); 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 } useEffect(() => { getAiConfig().then((res) => setAiConfigHasKey(!!res.hasKey)).catch(() => {}); }, []); useEffect(() => { const id = setInterval(() => setNow(new Date()), 30000); return () => clearInterval(id); }, []); useEffect(() => { (async () => { const data = await loadAll(); setCustomers(data.customers); setProducts(data.products); setSettings(data.settings); setLoaded(true); })(); }, []); 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]); function notify(msg) { setToast(msg); setTimeout(() => setToast(''), 2200); } function copyText(text) { navigator.clipboard.writeText(text).then( () => notify('已复制到剪贴板'), () => notify('复制失败,请手动选择文本复制') ); } const selected = customers.find(c => c.id === selectedId) || null; const selectedProduct = products.find(p => p.id === selectedProductId) || null; const filteredCustomers = useMemo(() => { let list = customers; if (regionFilter !== 'all') list = list.filter(c => c.region === regionFilter); if (search.trim()) { const q = search.trim().toLowerCase(); list = list.filter(c => (c.company || '').toLowerCase().includes(q) || (c.contact || '').toLowerCase().includes(q) || (c.email || '').toLowerCase().includes(q) || (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]); const filteredProducts = useMemo(() => { let list = products; if (productCategoryFilter !== 'all') list = list.filter(p => p.category === productCategoryFilter); if (productSearch.trim()) { const q = productSearch.trim().toLowerCase(); list = list.filter(p => (p.name || '').toLowerCase().includes(q) || (p.brand || '').toLowerCase().includes(q) || categoryBreadcrumb(p).toLowerCase().includes(q) ); } return [...list].sort((a, b) => categoryBreadcrumb(a).localeCompare(categoryBreadcrumb(b)) || (a.name || '').localeCompare(b.name || '')); }, [products, productSearch, productCategoryFilter]); 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]); function handleCustomerDrop(dropIdx) { if (dragCustomerIdx === null || dragCustomerIdx === dropIdx) { setDragCustomerIdx(null); return; } setCustomers(prev => reorderArray(prev, dragCustomerIdx, dropIdx)); setDragCustomerIdx(null); } function handleCatalogDrop(dropIdx) { if (dragCatalogIdx === null || dragCatalogIdx === dropIdx) { setDragCatalogIdx(null); return; } setProducts(prev => reorderArray(prev, dragCatalogIdx, dropIdx)); setDragCatalogIdx(null); } function handleGroupDrop(category, dropIdx) { if (!dragGroupState || dragGroupState.category !== category || dragGroupState.index === dropIdx) { setDragGroupState(null); return; } setProducts(prev => reorderWithinCategory(prev, category, dragGroupState.index, dropIdx)); setDragGroupState(null); } async function handleChangePassword() { if (!pwCurrent || !pwNew) { notify('请填写当前密码和新密码'); return; } if (pwNew.length < 4) { notify('新密码至少4位'); return; } setPwBusy(true); try { await authChangePassword(pwCurrent, pwNew); setPwCurrent(''); setPwNew(''); notify('密码已修改'); } catch (e) { notify('修改失败:当前密码不正确'); } finally { setPwBusy(false); } } async function handleSaveAiKey() { if (!aiKeyInput.trim()) { notify('请输入API Key'); return; } setAiSaving(true); try { await saveAiConfig(aiKeyInput.trim()); setAiConfigHasKey(true); setAiKeyInput(''); notify('已保存'); } catch (e) { notify('保存失败'); } finally { setAiSaving(false); } } async function handleTestAiKey() { setAiTesting(true); try { await testAiConfig(); notify('连接成功'); } catch (e) { notify('连接失败,请检查API Key'); } finally { setAiTesting(false); } } async function handleClearAiKey() { if (!window.confirm('确定清除已保存的DeepSeek API Key?')) return; await deleteAiConfig(); 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); } function cancelDraft() { setDraft(null); } function saveDraft() { setCustomers(prev => { const exists = prev.some(c => c.id === draft.id); return exists ? prev.map(c => (c.id === draft.id ? draft : c)) : [...prev, draft]; }); setSelectedId(draft.id); setDraft(null); notify('已保存'); } function deleteCustomer(id) { if (!window.confirm('确定删除该客户档案?此操作不可恢复。')) return; setCustomers(prev => prev.filter(c => c.id !== id)); 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; const has = (c.preferredContacts || []).includes(channel); return { ...c, preferredContacts: has ? c.preferredContacts.filter(x => x !== channel) : [...(c.preferredContacts || []), channel] }; })); } function toggleVip(id) { setCustomers(prev => prev.map(c => (c.id === id ? { ...c, isVip: !c.isVip } : c))); } function toggleOrdered(id) { setCustomers(prev => prev.map(c => (c.id === id ? { ...c, hasOrdered: !c.hasOrdered } : c))); } function handleFileDragOver(e) { e.preventDefault(); setFileDragActive(true); } function handleFileDragLeave() { setFileDragActive(false); } function handleFileDrop(e) { e.preventDefault(); setFileDragActive(false); const f = e.dataTransfer.files && e.dataTransfer.files[0]; if (f) setAttachmentFile(f); } async function handleUploadAttachment(customerId) { if (!attachmentFile) { notify('请先选择文件'); return; } setUploading(true); try { const res = await uploadFile(customerId, attachmentFile); setCustomers(prev => prev.map(c => { if (c.id !== customerId) return c; const entry = { fileName: res.fileName, originalName: res.originalName, category: attachmentCategory, note: attachmentNote.trim(), date: todayStr(), }; return { ...c, attachments: [entry, ...(c.attachments || [])] }; })); setAttachmentFile(null); setAttachmentNote(''); setAttachmentCategory(FILE_CATEGORIES[0]); setFileInputKey(k => k + 1); notify('已上传'); } catch (e) { notify('上传失败,请重试'); } finally { setUploading(false); } } async function handleDeleteAttachment(customerId, fileName) { if (!window.confirm('确定删除这个附件?此操作不可恢复。')) return; try { await deleteFile(customerId, fileName); } catch (e) { // 即使服务端删除失败(比如文件已经不存在),也继续把记录从客户档案里移除 } setCustomers(prev => prev.map(c => ( c.id === customerId ? { ...c, attachments: (c.attachments || []).filter(a => a.fileName !== fileName) } : c ))); } function openFollowUpEmail(c) { setEmailModal({ ...generateFollowUpEmail(c), customerId: c.id }); } function startNewProduct() { setProductDraft(blankProduct()); setSelectedProductId(null); } function startEditProduct(p) { setProductDraft({ ...p, targetRegions: [...(p.targetRegions || [])] }); setSelectedProductId(p.id); } function cancelProductDraft() { setProductDraft(null); } function saveProductDraft() { if (!productDraft.name.trim()) { notify('请填写产品型号/名称'); return; } if (!productDraft.category) { notify('请选择产品大类'); return; } setProducts(prev => { const exists = prev.some(p => p.id === productDraft.id); return exists ? prev.map(p => (p.id === productDraft.id ? productDraft : p)) : [...prev, productDraft]; }); setSelectedProductId(productDraft.id); setProductDraft(null); notify('已保存'); } function deleteProduct(id) { if (!window.confirm('确定删除该产品档案?此操作不可恢复。')) return; setProducts(prev => prev.filter(p => p.id !== id)); if (selectedProductId === id) setSelectedProductId(null); notify('已删除'); } if (!loaded) { return (
加载中…
); } return (
{viewTitle(view)}
{mobileMenuOpen &&
setMobileMenuOpen(false)} />}
客户档案系统
Customer Console
{NAV_GROUPS.map(group => (
{group.label}
{group.items.map(item => { const Icon = item.icon; return (
{ setView(item.id); setMobileMenuOpen(false); }} > {item.label}
); })}
))}
{STANDALONE_NAV.map(item => { const Icon = item.icon; return (
{ setView(item.id); setMobileMenuOpen(false); }} > {item.label}
); })}
{username}
{view === 'monitor' && ( <>
全部客户
拖动卡片可调整顺序
{customers.length === 0 ? (
暂无客户,去"客户管理"添加第一个
) : (
{customers.map((c, idx) => { const s = statusOf(c, settings); const lt = localTimeInfo(c.country, now); const cardTitle = c.productLinesNote || c.company || '(未命名)'; const showCompanyLine = !!(c.productLinesNote && 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}
} {showEmail &&
{c.email}
} {showPhone &&
{c.phone}
} {lt &&
当地时间 {lt.time}
}
上次联系 {fmtDate(c.lastContact)}
); })}
)} )} {view === 'manage' && (
setSearch(e.target.value)} />
{filteredCustomers.length === 0 ? (
暂无客户,点击上方按钮添加第一个
) : filteredCustomers.map(c => { const s = statusOf(c, settings); return (
{ setDraft(null); setSelectedId(c.id); }}>
{c.isVip && }{c.hasOrdered && }{c.company || '(未命名)'}
{regionLabel(c.region)} · {c.country || '—'}{localTimeInfo(c.country, now) && ` · 当地 ${localTimeInfo(c.country, now).time}`}
); })}
{draft ? ( ) : 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)} attachmentNote={attachmentNote} setAttachmentNote={setAttachmentNote} attachmentCategory={attachmentCategory} setAttachmentCategory={setAttachmentCategory} attachmentFileName={attachmentFile ? attachmentFile.name : ''} onPickAttachment={(f) => setAttachmentFile(f)} uploading={uploading} fileInputKey={fileInputKey} onUploadAttachment={() => handleUploadAttachment(selected.id)} onDeleteAttachment={(fileName) => handleDeleteAttachment(selected.id, fileName)} fileDragActive={fileDragActive} onFileDragOver={handleFileDragOver} onFileDragLeave={handleFileDragLeave} onFileDrop={handleFileDrop} aiConfigHasKey={aiConfigHasKey} aiGenerating={aiGenerating} onGenerateAi={(channel) => handleGenerateAi(selected.id, channel)} /> ) : (
选择左侧客户查看详情,或新建一个客户档案
)}
)} {view === 'groups' && ( <>
按产品大类分组浏览,每组内的产品可以拖拽调整顺序。
{PRODUCT_CATEGORIES.map(cat => { const items = products.filter(p => p.category === cat.id); return (
{cat.short}({items.length})
{items.length === 0 ? (
暂无产品
) : items.map((p, idx) => (
setDragGroupState({ category: cat.id, index: idx })} onDragOver={(e) => e.preventDefault()} onDrop={() => handleGroupDrop(cat.id, idx)} onDragEnd={() => setDragGroupState(null)} >
{p.name || '(未命名)'}
{categoryBreadcrumb(p)}{p.brand ? ` · ${p.brand}` : ''}
))}
); })} )} {view === 'products' && (
setProductSearch(e.target.value)} />
{filteredProducts.length === 0 ? (
暂无产品,点击上方按钮添加第一个
) : filteredProducts.map(p => (
{ setProductDraft(null); setSelectedProductId(p.id); }}>
{p.name || '(未命名)'}
{categoryBreadcrumb(p)}{p.brand ? ` · ${p.brand}` : ''}
))}
{productDraft ? ( ) : selectedProduct ? ( startEditProduct(selectedProduct)} onDelete={() => deleteProduct(selectedProduct.id)} /> ) : (
选择左侧产品查看详情,或新建一个产品档案
)}
)} {view === 'catalog' && ( <>
拖拽卡片调整展示顺序,方便整理成给客户看的产品介绍顺序。
{products.length === 0 ? (
暂无产品,去"产品管理"添加第一个
) : (
{products.map((p, idx) => { const certs = (p.certifications || '').split(',').map(s => s.trim()).filter(Boolean); return (
setDragCatalogIdx(idx)} onDragOver={(e) => e.preventDefault()} onDrop={() => handleCatalogDrop(idx)} onDragEnd={() => setDragCatalogIdx(null)} >
{p.name || '(未命名)'}
{categoryBreadcrumb(p)}
{p.brand &&
品牌:{p.brand}
} {p.powerOrCapacity &&
功率 / 容量:{p.powerOrCapacity}
} {p.voltage &&
电压:{p.voltage}
} {certs.length > 0 &&
{certs.map(cc => {cc})}
}
); })}
)} )} {view === 'quotes' && (
报价单制作 · 下一步开发
计划:选客户 + 选产品 + 填价格,生成一份报价单。
这次先把导航位置占好,具体页面下一轮再做。
)} {view === 'settings' && ( <>
跟进与提醒设置
默认跟进周期(天) setSettings(s => ({ ...s, defaultFollowUpDays: Number(e.target.value) || 1 }))} />
"即将到期"提前提醒天数 setSettings(s => ({ ...s, dueSoonWindow: Number(e.target.value) || 1 }))} />
说明:邮件功能用于生成可复制的英文邮件文案,需要你手动粘贴到自己的邮箱发送。报价单制作正在开发中,下一轮会上线。
账号与密码
当前登录账号:{username}
当前密码 setPwCurrent(e.target.value)} />
新密码(至少4位) setPwNew(e.target.value)} />
这个系统现在挂在公网上,定期更换密码、不要把密码告诉别人。
AI 跟进文案(DeepSeek)
状态:{aiConfigHasKey ? 已配置 : 未配置}
DeepSeek API Key setAiKeyInput(e.target.value)} placeholder={aiConfigHasKey ? '已设置,重新填写可覆盖' : '在 platform.deepseek.com 申请'} />
{aiConfigHasKey && }
用于客户详情页的"AI生成邮件 / AI生成WhatsApp话术"功能,调用DeepSeek官方API生成。成本很低(单次生成大概几分钱以内人民币,按你自己DeepSeek账户余额实际扣费),Key只会加密保存在服务器本地,不会显示给任何人。
)}
{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' : '消息内容'}