diff --git a/App.jsx b/App.jsx deleted file mode 100644 index bd683c3..0000000 --- a/App.jsx +++ /dev/null @@ -1,1796 +0,0 @@ -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' : '消息内容'} -