diff --git a/App.jsx b/App.jsx new file mode 100644 index 0000000..04113da --- /dev/null +++ b/App.jsx @@ -0,0 +1,2370 @@ +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, Printer, Image as ImageIcon +} 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', quotes: 'crm:quotes', sellerProfiles: 'crm:sellerProfiles' }; + +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: [], intendedModel: '', intendedQty: '', + 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: '', +}); + +/* ------------------------------- quotes/invoices ------------------------------ */ + +const CURRENCIES = [ + { code: 'USD', symbol: 'US$' }, + { code: 'CNY', symbol: '¥' }, + { code: 'RUB', symbol: '₽' }, +]; + +const DEFAULT_SELLER_PROFILES = [ + { + id: 'seller_1', name: 'BEIJING JINYIDA BORUI TECHNOLOGY DEVELOPMENT CO., LTD.', + address: '313, Floor 3, Building 1, Yard 4, Fuze Road, Fangshan, Beijing, China', + logoFileName: '', bankInfo: '', + }, + { + id: 'seller_2', name: 'XINHUA CHUIXUE COMPUTER CO., LTD', + address: 'No.8 Wenyuan Street Shijiazhuang, Hebei, China', + logoFileName: '', bankInfo: '', + }, + { + id: 'seller_3', name: 'Shenzhen Pengcheng Electric Co., Ltd.', + address: "No. 6093, Bao'an Avenue, Xinhe Community, Fuhai Street, Bao'an District, Shenzhen, China", + logoFileName: '', bankInfo: '', + }, +]; + +const PAYMENT_TERMS_OPTIONS = [ + '100% IN ADVANCE T/T', + '50% IN ADVANCE T/T 50% BEFORE DELIVERY', + '30% IN ADVANCE T/T 70% BEFORE DELIVERY', +]; + +const QUOTE_NOTE_DEFAULTS = ['BRAND NEW ORIGINAL PACKED BY CARTON', 'PRICE BASED ON EXW TERM']; +const QUOTE_NOTE_OPTIONS = ['IN STOCK', 'LEAD TIME 1 WEEK', 'LEAD TIME 2 WEEKS', 'WARRANTY 1 YEAR', 'WARRANTY 2 YEARS', 'WARRANTY 3 YEARS', 'WARRANTY 5 YEARS']; + +const blankLineItem = () => ({ id: 'li_' + Date.now() + '_' + Math.random().toString(36).slice(2, 6), description: '', quantity: '', rate: '' }); + +const blankQuote = (type) => ({ + id: 'q_' + Date.now() + '_' + Math.random().toString(36).slice(2, 7), + type, // 'quote' | 'invoice' + number: '', + date: todayStr(), + currency: 'USD', + sellerProfileId: 'seller_1', + customerId: '', + billToName: '', + shipToName: '', + paymentTerms: '', + bankInfo: '', signerName: '', + lineItems: [blankLineItem()], + notes: type === 'quote' ? QUOTE_NOTE_DEFAULTS.join('\n') : '', + createdAt: todayStr(), +}); + +function suggestDocNumber(date, existingQuotes, type) { + const d = new Date((date || todayStr()) + 'T00:00:00'); + const yyyy = d.getFullYear(); + const mm = String(d.getMonth() + 1).padStart(2, '0'); + const dd = String(d.getDate()).padStart(2, '0'); + const base = `${yyyy}${mm}${dd}`; + const sameDay = (existingQuotes || []).filter(q => q.type === type && (q.number || '').startsWith(base)); + const letter = String.fromCharCode(65 + sameDay.length); + return `${base}${letter}`; +} + +function addOneMonth(dateStr) { + if (!dateStr) return ''; + const d = new Date(dateStr + 'T00:00:00'); + d.setMonth(d.getMonth() + 1); + return d.toISOString().slice(0, 10); +} + +function formatMoney(amount, currencyCode) { + const found = CURRENCIES.find(c => c.code === currencyCode); + const symbol = found ? found.symbol : ''; + const safeAmount = Number.isFinite(amount) ? amount : 0; + return `${symbol}${safeAmount.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; +} + +function quoteTotals(quote) { + const total = (quote.lineItems || []).reduce((sum, li) => { + const qty = parseFloat(li.quantity) || 0; + const rate = parseFloat(li.rate) || 0; + return sum + qty * rate; + }, 0); + return { total }; +} + +// 客户档案在下拉框里展示的标签:意向型号 - 公司名字 - 客户姓名,方便辨认是哪一个客户 +function quoteLinkLabel(c) { + const modelQty = [c.intendedModel, c.intendedQty ? `×${c.intendedQty}` : ''].filter(Boolean).join(' '); + return [modelQty, c.company, c.contact].filter(Boolean).join(' - ') || '(未命名)'; +} +// 真正要打印在报价单/Invoice上的客户名称:用公司名/联系人,不能用型号(型号只是用来辨认客户,不是收件人名字) +function billPartyName(c) { + return c.company || c.contact || ''; +} + +/* ------------------------------- 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 customerTitle(c) { + const modelQty = [c.intendedModel, c.intendedQty ? `× ${c.intendedQty}` : ''].filter(Boolean).join(' '); + return modelQty || c.company || '(未命名)'; +} +function customerTitleIsModel(c) { + return !!(c.intendedModel || c.intendedQty); +} +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; + let quotes = []; + let sellerProfiles = DEFAULT_SELLER_PROFILES; + 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) {} + try { + const q = await storageGet(STORAGE_KEYS.quotes); + if (q && q.value) quotes = JSON.parse(q.value); + } catch (e) {} + try { + const sp = await storageGet(STORAGE_KEYS.sellerProfiles); + if (sp && sp.value) sellerProfiles = JSON.parse(sp.value); + } catch (e) {} + return { customers, products, settings, quotes, sellerProfiles }; +} + +/* -------------------------------- 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 } + + const [quotes, setQuotes] = useState([]); + const [sellerProfiles, setSellerProfiles] = useState(DEFAULT_SELLER_PROFILES); + const [quoteSubView, setQuoteSubView] = useState('list'); // 'list' | 'editor' + const [quoteDraft, setQuoteDraft] = useState(null); + const [quoteTypeFilter, setQuoteTypeFilter] = useState('all'); + const [sellerManagerOpen, setSellerManagerOpen] = useState(false); + const [sellerLogoUploading, setSellerLogoUploading] = useState(false); + const [pdfExporting, setPdfExporting] = useState(false); + + 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); + setQuotes(data.quotes); + setSellerProfiles(data.sellerProfiles); + 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]); + 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]); + + 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('已删除'); + } + + /* --------------------------- quotes / invoices --------------------------- */ + + function startNewQuote(type) { + const draft = blankQuote(type); + draft.number = suggestDocNumber(draft.date, quotes, type); + setQuoteDraft(draft); + setQuoteSubView('editor'); + } + function startEditQuote(q) { + setQuoteDraft({ ...q, lineItems: q.lineItems.map(li => ({ ...li })) }); + setQuoteSubView('editor'); + } + function backToQuoteList() { + setQuoteDraft(null); + setQuoteSubView('list'); + } + function updateQuoteDraft(patch) { + setQuoteDraft(d => ({ ...d, ...patch })); + } + function handleLinkCustomer(customerId) { + if (!customerId) { updateQuoteDraft({ customerId: '', billToName: '' }); return; } + const cust = customers.find(c => c.id === customerId); + if (!cust) return; + updateQuoteDraft({ customerId, billToName: billPartyName(cust) }); + } + function addLineItem() { + setQuoteDraft(d => ({ ...d, lineItems: [...d.lineItems, blankLineItem()] })); + } + function removeLineItem(liId) { + setQuoteDraft(d => ({ ...d, lineItems: d.lineItems.filter(li => li.id !== liId) })); + } + function updateLineItem(liId, patch) { + setQuoteDraft(d => ({ ...d, lineItems: d.lineItems.map(li => (li.id === liId ? { ...li, ...patch } : li)) })); + } + function saveQuoteDraft() { + if (!quoteDraft.number.trim()) { notify('请填写单号'); return; } + setQuotes(prev => { + const exists = prev.some(q => q.id === quoteDraft.id); + return exists ? prev.map(q => (q.id === quoteDraft.id ? quoteDraft : q)) : [...prev, quoteDraft]; + }); + notify('已保存'); + setQuoteDraft(null); + setQuoteSubView('list'); + } + function deleteQuote(id) { + if (!window.confirm('确定删除这份文档?此操作不可恢复。')) return; + setQuotes(prev => prev.filter(q => q.id !== id)); + notify('已删除'); + } + function handlePrint() { + window.print(); + } + function toggleQuoteNoteLine(line) { + const lines = quoteDraft.notes.split('\n').map(l => l.trim()).filter(Boolean); + if (lines.includes(line)) { + updateQuoteDraft({ notes: lines.filter(l => l !== line).join('\n') }); + } else { + updateQuoteDraft({ notes: [...lines, line].join('\n') }); + } + } + async function handleExportPdf() { + setPdfExporting(true); + try { + const mod = await import('html2pdf.js'); + const html2pdf = mod.default || mod; + const el = document.querySelector('.doc-preview'); + if (!el) { notify('找不到预览内容'); return; } + const filename = `${quoteDraft.type === 'invoice' ? 'Invoice' : 'Quote'}_${quoteDraft.number || 'draft'}.pdf`; + await html2pdf().set({ + margin: 10, + filename, + image: { type: 'jpeg', quality: 0.98 }, + html2canvas: { scale: 2 }, + jsPDF: { unit: 'mm', format: 'a4', orientation: 'portrait' }, + }).from(el).save(); + } catch (e) { + notify('导出PDF失败,请重试'); + } finally { + setPdfExporting(false); + } + } + + /* ------------------------------ seller profiles ----------------------------- */ + + function updateSellerProfile(id, patch) { + setSellerProfiles(prev => prev.map(sp => (sp.id === id ? { ...sp, ...patch } : sp))); + } + function addSellerProfile() { + const id = 'seller_' + Date.now().toString(36); + setSellerProfiles(prev => [...prev, { id, name: '新公司名称', address: '', logoFileName: '', bankInfo: '' }]); + } + function deleteSellerProfile(id) { + if (sellerProfiles.length <= 1) { notify('至少要保留一个卖方信息'); return; } + if (!window.confirm('确定删除这个卖方信息?')) return; + setSellerProfiles(prev => prev.filter(sp => sp.id !== id)); + } + async function handleSellerLogoPick(id, file) { + if (!file) return; + setSellerLogoUploading(true); + try { + const res = await uploadFile(id, file); + updateSellerProfile(id, { logoFileName: res.fileName }); + notify('Logo已上传'); + } catch (e) { + notify('上传失败'); + } finally { + setSellerLogoUploading(false); + } + } + + 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 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 &&
{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); + const showCompanyInSub = customerTitleIsModel(c) && c.company; + return ( +
{ setDraft(null); setSelectedId(c.id); }}> +
+
+
{c.isVip && }{c.hasOrdered && }{customerTitle(c)}
+
{showCompanyInSub && `${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' && quoteSubView === 'list' && ( + <> +
+ + + + +
+
+
+ {quotes.filter(q => quoteTypeFilter === 'all' || q.type === quoteTypeFilter).length === 0 ? ( +
暂无文档,点击上方按钮新建一份
+ ) : [...quotes].filter(q => quoteTypeFilter === 'all' || q.type === quoteTypeFilter).reverse().map(q => { + const { total } = quoteTotals(q); + return ( +
startEditQuote(q)}> + {q.type === 'quote' ? 'Quote' : 'Invoice'} +
+
{q.number || '(无单号)'} · {q.billToName || '(未填写客户)'}
+
{q.date} · {formatMoney(total, q.currency)}
+
+ +
+ ); + })} +
+
+ + )} + + {view === 'quotes' && quoteSubView === 'editor' && quoteDraft && ( + setSellerManagerOpen(true)} + onToggleNoteLine={toggleQuoteNoteLine} + /> + )} + + {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' : '消息内容'} +