diff --git a/App.jsx b/App.jsx deleted file mode 100644 index 8e190c9..0000000 --- a/App.jsx +++ /dev/null @@ -1,3277 +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, Printer, Image as ImageIcon, - ShieldCheck, Settings2, Boxes -} 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']; - -// LEGACY_PRODUCT_TAXONOMY:旧版写死的三级分类树,仅用于首次迁移生成默认的 -// 动态类目数据(类目/品牌/类型现在都存在 crm:productCategories 里,用户可自行增删)。 -const LEGACY_PRODUCT_TAXONOMY = [ - { - 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: '磷酸铁锂电池' }, - ], - }, -]; - -function flattenLegacyTypes(top) { - const out = []; - (top.subs || []).forEach(s => { - out.push(s.short); - (s.subs || []).forEach(ss => out.push(ss.short)); - }); - return out; -} -function buildDefaultProductCategories() { - return LEGACY_PRODUCT_TAXONOMY.map(top => ({ - id: top.id, name: top.short, brands: [], types: flattenLegacyTypes(top), - })); -} - -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' }, - { name: '贝宁', en: 'Benin', region: 'africa', dial: '+229', tz: 'Africa/Porto-Novo' }, - { name: '布基纳法索', en: 'Burkina Faso', region: 'africa', dial: '+226', tz: 'Africa/Ouagadougou' }, - { name: '多哥', en: 'Togo', region: 'africa', dial: '+228', tz: 'Africa/Lome' }, - { name: '几内亚', en: 'Guinea', region: 'africa', dial: '+224', tz: 'Africa/Conakry' }, - { name: '塞拉利昂', en: 'Sierra Leone', region: 'africa', dial: '+232', tz: 'Africa/Freetown' }, - { name: '利比里亚', en: 'Liberia', region: 'africa', dial: '+231', tz: 'Africa/Monrovia' }, - { name: '乍得', en: 'Chad', region: 'africa', dial: '+235', tz: 'Africa/Ndjamena' }, - { name: '索马里', en: 'Somalia', region: 'africa', dial: '+252', tz: 'Africa/Mogadishu' }, - { name: '吉布提', en: 'Djibouti', region: 'africa', dial: '+253', tz: 'Africa/Djibouti' }, - { name: '南苏丹', en: 'South Sudan', region: 'africa', dial: '+211', tz: 'Africa/Juba' }, - { name: '马拉维', en: 'Malawi', region: 'africa', dial: '+265', tz: 'Africa/Blantyre' }, - { name: '马达加斯加', en: 'Madagascar', region: 'africa', dial: '+261', tz: 'Indian/Antananarivo' }, - { name: '毛里求斯', en: 'Mauritius', region: 'africa', dial: '+230', tz: 'Indian/Mauritius' }, - { name: '加蓬', en: 'Gabon', region: 'africa', dial: '+241', tz: 'Africa/Libreville' }, - { name: '布隆迪', en: 'Burundi', region: 'africa', dial: '+257', tz: 'Africa/Bujumbura' }, - { name: '斯威士兰', en: 'Eswatini', region: 'africa', dial: '+268', tz: 'Africa/Mbabane' }, - { name: '厄立特里亚', en: 'Eritrea', region: 'africa', dial: '+291', tz: 'Africa/Asmara' }, - - // 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' }, - { name: '巴勒斯坦', en: 'Palestine', region: 'middle_east', dial: '+970', tz: 'Asia/Gaza' }, - - // 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' }, - { name: '白俄罗斯', en: 'Belarus', region: 'europe', dial: '+375', tz: 'Europe/Minsk' }, - { name: '摩尔多瓦', en: 'Moldova', region: 'europe', dial: '+373', tz: 'Europe/Chisinau' }, - { name: '立陶宛', en: 'Lithuania', region: 'europe', dial: '+370', tz: 'Europe/Vilnius' }, - { name: '拉脱维亚', en: 'Latvia', region: 'europe', dial: '+371', tz: 'Europe/Riga' }, - { name: '爱沙尼亚', en: 'Estonia', region: 'europe', dial: '+372', tz: 'Europe/Tallinn' }, - { name: '斯洛伐克', en: 'Slovakia', region: 'europe', dial: '+421', tz: 'Europe/Bratislava' }, - { name: '斯洛文尼亚', en: 'Slovenia', region: 'europe', dial: '+386', tz: 'Europe/Ljubljana' }, - { name: '波黑', en: 'Bosnia and Herzegovina', region: 'europe', dial: '+387', tz: 'Europe/Sarajevo' }, - { name: '北马其顿', en: 'North Macedonia', region: 'europe', dial: '+389', tz: 'Europe/Skopje' }, - { name: '阿尔巴尼亚', en: 'Albania', region: 'europe', dial: '+355', tz: 'Europe/Tirane' }, - { name: '黑山', en: 'Montenegro', region: 'europe', dial: '+382', tz: 'Europe/Podgorica' }, - { name: '冰岛', en: 'Iceland', region: 'europe', dial: '+354', tz: 'Atlantic/Reykjavik' }, - { name: '马耳他', en: 'Malta', region: 'europe', dial: '+356', tz: 'Europe/Malta' }, - { name: '塞浦路斯', en: 'Cyprus', region: 'europe', dial: '+357', tz: 'Asia/Nicosia' }, - { name: '卢森堡', en: 'Luxembourg', region: 'europe', dial: '+352', tz: 'Europe/Luxembourg' }, - - // 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' }, - { name: '乌兹别克斯坦', en: 'Uzbekistan', region: 'other', dial: '+998', tz: 'Asia/Tashkent' }, - { name: '哈萨克斯坦', en: 'Kazakhstan', region: 'other', dial: '+7', tz: 'Asia/Almaty' }, - { name: '吉尔吉斯斯坦', en: 'Kyrgyzstan', region: 'other', dial: '+996', tz: 'Asia/Bishkek' }, - { name: '塔吉克斯坦', en: 'Tajikistan', region: 'other', dial: '+992', tz: 'Asia/Dushanbe' }, - { name: '土库曼斯坦', en: 'Turkmenistan', region: 'other', dial: '+993', tz: 'Asia/Ashgabat' }, - { name: '格鲁吉亚', en: 'Georgia', region: 'other', dial: '+995', tz: 'Asia/Tbilisi' }, - { name: '亚美尼亚', en: 'Armenia', region: 'other', dial: '+374', tz: 'Asia/Yerevan' }, - { name: '阿塞拜疆', en: 'Azerbaijan', region: 'other', dial: '+994', tz: 'Asia/Baku' }, - { name: '斯里兰卡', en: 'Sri Lanka', region: 'other', dial: '+94', tz: 'Asia/Colombo' }, - { name: '尼泊尔', en: 'Nepal', region: 'other', dial: '+977', tz: 'Asia/Kathmandu' }, - { name: '阿富汗', en: 'Afghanistan', region: 'other', dial: '+93', tz: 'Asia/Kabul' }, - { name: '蒙古', en: 'Mongolia', region: 'other', dial: '+976', tz: 'Asia/Ulaanbaatar' }, - { name: '智利', en: 'Chile', region: 'other', dial: '+56', tz: 'America/Santiago' }, - { name: '秘鲁', en: 'Peru', region: 'other', dial: '+51', tz: 'America/Lima' }, - { name: '哥伦比亚', en: 'Colombia', region: 'other', dial: '+57', tz: 'America/Bogota' }, - { name: '厄瓜多尔', en: 'Ecuador', region: 'other', dial: '+593', tz: 'America/Guayaquil' }, - { name: '委内瑞拉', en: 'Venezuela', region: 'other', dial: '+58', tz: 'America/Caracas' }, - { name: '玻利维亚', en: 'Bolivia', region: 'other', dial: '+591', tz: 'America/La_Paz' }, - { name: '巴拉圭', en: 'Paraguay', region: 'other', dial: '+595', tz: 'America/Asuncion' }, - { name: '乌拉圭', en: 'Uruguay', region: 'other', dial: '+598', tz: 'America/Montevideo' }, -]; - -const COUNTRY_BY_NAME = Object.fromEntries(COUNTRIES.map(c => [c.name, c])); - -const STORAGE_KEYS = { customers: 'crm:customers', products: 'crm:products', settings: 'crm:settings', quotes: 'crm:quotes', sellerProfiles: 'crm:sellerProfiles', certifications: 'crm:certifications', productCategories: 'crm:productCategories', packingLists: 'crm:packingLists' }; - -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: '', type: '', - 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 blankPackingLineItem = () => ({ - id: 'pli_' + Date.now() + '_' + Math.random().toString(36).slice(2, 6), - description: '', qty: '', cartons: '', grossWeight: '', netWeight: '', volume: '', -}); - -const blankPackingList = () => ({ - id: 'pl_' + Date.now() + '_' + Math.random().toString(36).slice(2, 7), - type: 'packing', - number: '', - date: todayStr(), - sellerProfileId: 'seller_1', - customerId: '', - billToName: '', - showSeller: true, - showBillTo: true, - lineItems: [blankPackingLineItem()], - notes: '', - createdAt: todayStr(), -}); - -function packingTotals(pl) { - return (pl.lineItems || []).reduce((sum, li) => ({ - qty: sum.qty + (parseFloat(li.qty) || 0), - cartons: sum.cartons + (parseFloat(li.cartons) || 0), - grossWeight: sum.grossWeight + (parseFloat(li.grossWeight) || 0), - netWeight: sum.netWeight + (parseFloat(li.netWeight) || 0), - volume: sum.volume + (parseFloat(li.volume) || 0), - }), { qty: 0, cartons: 0, grossWeight: 0, netWeight: 0, volume: 0 }); -} - -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 }; -} - -// 从所有历史报价单/Invoice里提取"型号(首行) -> 完整描述+单价"的记录,按日期保留最新的一条, -// 方便录入新产品明细时按关键词自动带出之前报过的价格 -function buildPriceHistory(quotes) { - const map = new Map(); - const sorted = [...quotes].sort((a, b) => (a.date || '').localeCompare(b.date || '')); - for (const q of sorted) { - for (const li of q.lineItems || []) { - const firstLine = (li.description || '').split('\n')[0].trim(); - if (!firstLine || !li.rate) continue; - map.set(firstLine.toUpperCase(), { description: li.description, rate: li.rate, date: q.date }); - } - } - return Array.from(map.values()); -} - -// 客户档案在下拉框里展示的标签:意向型号 - 公司名字 - 客户姓名,方便辨认是哪一个客户 -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 categoryBreadcrumb(p, categories) { - const cat = (categories || []).find(c => c.id === p.category); - if (!cat) return '未分类'; - return [cat.name, p.type].filter(Boolean).join(' › '); -} -// 一次性迁移:把旧版 subCategory/subSubCategory(写死id)转换成新版的 type(自由文本, -// 存在类目自己的 types 列表里);把产品里已有的自由文本品牌,补进所属类目的 brands 列表, -// 这样老数据在新的下拉选择模型里仍然是合法可选项,不会丢失也不会报错。幂等:跑第二次不会重复添加。 -function normalizeProductTaxonomy(products, categories) { - let cats = (categories && categories.length ? categories : buildDefaultProductCategories()) - .map(c => ({ ...c, brands: [...(c.brands || [])], types: [...(c.types || [])] })); - - const migratedProducts = (products || []).map(p => { - let type = p.type || ''; - if (!type && (p.subCategory || p.subSubCategory)) { - const top = LEGACY_PRODUCT_TAXONOMY.find(t => t.id === p.category); - const sub = top && (top.subs || []).find(s => s.id === p.subCategory); - const subsub = sub && (sub.subs || []).find(s => s.id === p.subSubCategory); - type = (subsub && subsub.short) || (sub && sub.short) || ''; - } - return { ...p, type }; - }); - - migratedProducts.forEach(p => { - if (!p.category) return; - let cat = cats.find(c => c.id === p.category); - if (!cat) { - cat = { id: p.category, name: p.category, brands: [], types: [] }; - cats.push(cat); - } - if (p.brand && !cat.brands.includes(p.brand)) cat.brands.push(p.brand); - if (p.type && !cat.types.includes(p.type)) cat.types.push(p.type); - }); - - return { products: migratedProducts, categories: cats }; -} -function generateFollowUpEmail(customer) { - const name = customer.contact || customer.company || 'there'; - const product = customer.productLines && customer.productLines.length ? customer.productLines.join(', ') : 'our products'; - const subject = `Following up – ${customer.company || name}`; - const body = `Dear ${name}, - -I hope this message finds you well. It has been a little while since we last connected, and I wanted to check in regarding ${product}. - -If you have any upcoming projects or requirements, I would be happy to share our latest pricing, lead times, and technical updates. Please let me know if there is anything I can help with. - -Looking forward to hearing from you. - -Best regards, -[Your name]`; - return { subject, body }; -} - -/* -------------------------------- storage ------------------------------ */ - -async function loadAll() { - let customers = []; - let products = []; - let settings = DEFAULT_SETTINGS; - let quotes = []; - let sellerProfiles = DEFAULT_SELLER_PROFILES; - let certifications = []; - let productCategories = []; - let packingLists = []; - 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) {} - try { - const cf = await storageGet(STORAGE_KEYS.certifications); - if (cf && cf.value) certifications = JSON.parse(cf.value); - } catch (e) {} - try { - const pc = await storageGet(STORAGE_KEYS.productCategories); - if (pc && pc.value) productCategories = JSON.parse(pc.value); - } catch (e) {} - try { - const pl = await storageGet(STORAGE_KEYS.packingLists); - if (pl && pl.value) packingLists = JSON.parse(pl.value); - } catch (e) {} - return { customers, products, settings, quotes, sellerProfiles, certifications, productCategories, packingLists }; -} - -/* -------------------------------- styles -------------------------------- */ - -const Styles = () => ( - -); - -/* -------------------------------- pieces -------------------------------- */ - -// 品牌 / 类型标签行:展示某类目下的 tax-pill 列表,可点击 x 删除单个, -// 末尾的虚线圆圈 + 号点击后原地变成一个小输入框,回车/失焦保存,Esc取消。 -function TaxPillRow({ label, values, catId, kind, addingPill, addingPillValue, setAddingPillValue, onOpenAdd, onConfirmAdd, onCancelAdd, onRemove }) { - const isAdding = addingPill && addingPill.catId === catId && addingPill.kind === kind; - return ( -
- {label && {label}} - {values.map(v => ( - - {v} - onRemove(catId, kind, v)}> - - ))} - {isAdding ? ( - setAddingPillValue(e.target.value)} - onBlur={onConfirmAdd} - onKeyDown={e => { - if (e.key === 'Enter') e.currentTarget.blur(); - if (e.key === 'Escape') onCancelAdd(); - }} - placeholder={kind === 'brand' ? '品牌名称' : '类型名称'} - /> - ) : ( - onOpenAdd(catId, kind)}> - )} -
- ); -} - -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: 'certs', label: '授权管理', icon: ShieldCheck }, - { id: 'quotes', label: '报价单制作', icon: FileText }, - { id: 'packing', label: 'Packing List制作', icon: Boxes }, - { 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 [productCategories, setProductCategories] = useState([]); - const [addingPill, setAddingPill] = useState(null); // { catId, kind: 'brand' | 'type' | 'category' } - const [addingPillValue, setAddingPillValue] = useState(''); - const [taxManageMode, setTaxManageMode] = useState(null); // 'category' | 'brand' | 'type' | null - const [certifications, setCertifications] = useState([]); - const [certUploading, setCertUploading] = useState(false); - const [editingCertId, setEditingCertId] = useState(null); - const [certBrandDraft, setCertBrandDraft] = useState(''); - 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); - const [packingLists, setPackingLists] = useState([]); - const [packingSubView, setPackingSubView] = useState('list'); // 'list' | 'editor' - const [packingDraft, setPackingDraft] = useState(null); - const [packingPdfExporting, setPackingPdfExporting] = 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(); - const normalized = normalizeProductTaxonomy(data.products, data.productCategories); - setCustomers(data.customers); - setProducts(normalized.products); - setProductCategories(normalized.categories); - setSettings(data.settings); - setQuotes(data.quotes); - setSellerProfiles(data.sellerProfiles); - setCertifications(data.certifications); - setPackingLists(data.packingLists); - 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]); - useEffect(() => { if (loaded) storageSet(STORAGE_KEYS.certifications, JSON.stringify(certifications)).catch(() => {}); }, [certifications, loaded]); - useEffect(() => { if (loaded) storageSet(STORAGE_KEYS.productCategories, JSON.stringify(productCategories)).catch(() => {}); }, [productCategories, loaded]); - useEffect(() => { if (loaded) storageSet(STORAGE_KEYS.packingLists, JSON.stringify(packingLists)).catch(() => {}); }, [packingLists, 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, productCategories).toLowerCase().includes(q) - ); - } - return [...list].sort((a, b) => categoryBreadcrumb(a, productCategories).localeCompare(categoryBreadcrumb(b, productCategories)) || (a.name || '').localeCompare(b.name || '')); - }, [products, productSearch, productCategoryFilter, productCategories]); - - const counts = useMemo(() => ({ - overdue: customers.filter(c => statusOf(c, settings) === 'overdue').length, - due: customers.filter(c => statusOf(c, settings) === 'due').length, - total: customers.length, - products: products.length, - }), [customers, settings, products]); - - const priceHistory = useMemo(() => buildPriceHistory(quotes), [quotes]); - - 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); - } - - function openAddPill(catId, kind) { setAddingPill({ catId, kind }); setAddingPillValue(''); } - function cancelAddPill() { setAddingPill(null); setAddingPillValue(''); } - function confirmAddPill() { - if (!addingPill) return; - const val = addingPillValue.trim(); - if (!val) { cancelAddPill(); return; } - if (addingPill.kind === 'category') { - const exists = productCategories.some(c => c.name === val); - if (!exists) { - const id = 'cat_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 5); - setProductCategories(prev => [...prev, { id, name: val, brands: [], types: [] }]); - } - } else { - const listKey = addingPill.kind === 'brand' ? 'brands' : 'types'; - setProductCategories(prev => prev.map(c => { - if (c.id !== addingPill.catId) return c; - if (c[listKey].includes(val)) return c; - return { ...c, [listKey]: [...c[listKey], val] }; - })); - } - cancelAddPill(); - } - function removeTaxonomyValue(catId, kind, value) { - const listKey = kind === 'brand' ? 'brands' : 'types'; - setProductCategories(prev => prev.map(c => (c.id === catId ? { ...c, [listKey]: c[listKey].filter(v => v !== value) } : c))); - } - function deleteProductCategory(cat) { - const count = products.filter(p => p.category === cat.id).length; - const msg = count > 0 - ? `确定删除类目"${cat.name}"?该类目下还有 ${count} 个产品,删除后这些产品会显示为"未分类",产品本身不会被删除。` - : `确定删除类目"${cat.name}"?`; - if (!window.confirm(msg)) return; - setProductCategories(prev => prev.filter(c => c.id !== cat.id)); - } - - 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); - if (type === 'invoice') { - const seller = sellerProfiles.find(sp => sp.id === draft.sellerProfileId); - if (seller && seller.bankInfo) draft.bankInfo = seller.bankInfo; - } - setQuoteDraft(draft); - setQuoteSubView('editor'); - } - function startEditQuote(q) { - setQuoteDraft({ ...q, lineItems: q.lineItems.map(li => ({ ...li })) }); - setQuoteSubView('editor'); - } - function convertQuoteToInvoice(quote) { - const draft = blankQuote('invoice'); - draft.sellerProfileId = quote.sellerProfileId; - draft.customerId = quote.customerId; - draft.billToName = quote.billToName; - draft.currency = quote.currency; - draft.lineItems = quote.lineItems.map(li => ({ ...li, id: 'li_' + Date.now() + '_' + Math.random().toString(36).slice(2, 6) })); - draft.notes = quote.notes; - draft.number = suggestDocNumber(draft.date, quotes, 'invoice'); - const seller = sellerProfiles.find(sp => sp.id === draft.sellerProfileId); - if (seller && seller.bankInfo) draft.bankInfo = seller.bankInfo; - setQuoteDraft(draft); - setQuoteSubView('editor'); - notify('已基于该Quote创建Invoice草稿,补充Ship To / Payment Terms后保存'); - } - 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 handleSellerChange(sellerId) { - const seller = sellerProfiles.find(sp => sp.id === sellerId); - updateQuoteDraft({ - sellerProfileId: sellerId, - bankInfo: (seller && seller.bankInfo) || '', - }); - } - function handleCurrencyChange(newCurrency) { - if (!quoteDraft || newCurrency === quoteDraft.currency) return; - const oldCurrency = quoteDraft.currency; - const rateStr = window.prompt(`1 ${oldCurrency} = ? ${newCurrency}\n请输入汇率,所有产品单价会按这个汇率自动换算(不会改动数量)`, ''); - if (rateStr === null) return; - const rate = parseFloat(rateStr); - if (!rate || rate <= 0) { notify('汇率输入无效,币种未切换'); return; } - const newLineItems = quoteDraft.lineItems.map(li => ({ - ...li, - rate: li.rate !== '' && li.rate != null ? String(Math.round((parseFloat(li.rate) || 0) * rate * 100) / 100) : li.rate, - })); - updateQuoteDraft({ currency: newCurrency, lineItems: newLineItems }); - notify(`已按 1 ${oldCurrency} = ${rate} ${newCurrency} 换算单价`); - } - 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); - const el = document.querySelector('.doc-preview'); - let originalBorder, originalBoxShadow; - try { - const mod = await import('html2pdf.js'); - const html2pdf = mod.default || mod; - if (!el) { notify('找不到预览内容'); return; } - // html2canvas snapshots the on-screen styling as-is (it doesn't apply the - // @media print override), so the card border/shadow that's nice on screen - // would otherwise show up baked into the exported PDF — strip it first. - originalBorder = el.style.border; - originalBoxShadow = el.style.boxShadow; - el.style.border = 'none'; - el.style.boxShadow = 'none'; - 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 { - if (el) { el.style.border = originalBorder || ''; el.style.boxShadow = originalBoxShadow || ''; } - setPdfExporting(false); - } - } - - /* -------------------------------- packing lists -------------------------------- */ - - function startNewPackingList() { - const draft = blankPackingList(); - draft.number = suggestDocNumber(draft.date, packingLists, 'packing'); - setPackingDraft(draft); - setPackingSubView('editor'); - } - function startEditPackingList(pl) { - setPackingDraft({ ...pl, lineItems: pl.lineItems.map(li => ({ ...li })) }); - setPackingSubView('editor'); - } - function convertQuoteToPackingList(quote) { - const draft = blankPackingList(); - draft.sellerProfileId = quote.sellerProfileId; - draft.customerId = quote.customerId; - draft.billToName = quote.billToName; - draft.lineItems = quote.lineItems.map(li => ({ - id: 'pli_' + Date.now() + '_' + Math.random().toString(36).slice(2, 6), - description: li.description, qty: li.quantity, cartons: '', grossWeight: '', netWeight: '', volume: '', - })); - draft.number = suggestDocNumber(draft.date, packingLists, 'packing'); - setPackingDraft(draft); - setPackingSubView('editor'); - setView('packing'); - notify('已基于该文档创建Packing List草稿,补充箱数/重量/体积后保存'); - } - function backToPackingList() { - setPackingDraft(null); - setPackingSubView('list'); - } - function updatePackingDraft(patch) { - setPackingDraft(d => ({ ...d, ...patch })); - } - function handleLinkPackingCustomer(customerId) { - if (!customerId) { updatePackingDraft({ customerId: '', billToName: '' }); return; } - const cust = customers.find(c => c.id === customerId); - if (!cust) return; - updatePackingDraft({ customerId, billToName: billPartyName(cust) }); - } - function addPackingLineItem() { - setPackingDraft(d => ({ ...d, lineItems: [...d.lineItems, blankPackingLineItem()] })); - } - function removePackingLineItem(liId) { - setPackingDraft(d => ({ ...d, lineItems: d.lineItems.filter(li => li.id !== liId) })); - } - function updatePackingLineItem(liId, patch) { - setPackingDraft(d => ({ ...d, lineItems: d.lineItems.map(li => (li.id === liId ? { ...li, ...patch } : li)) })); - } - function savePackingDraft() { - if (!packingDraft.number.trim()) { notify('请填写单号'); return; } - setPackingLists(prev => { - const exists = prev.some(p => p.id === packingDraft.id); - return exists ? prev.map(p => (p.id === packingDraft.id ? packingDraft : p)) : [...prev, packingDraft]; - }); - notify('已保存'); - setPackingDraft(null); - setPackingSubView('list'); - } - function deletePackingList(id) { - if (!window.confirm('确定删除这份Packing List?此操作不可恢复。')) return; - setPackingLists(prev => prev.filter(p => p.id !== id)); - notify('已删除'); - } - async function handleExportPackingPdf() { - setPackingPdfExporting(true); - const el = document.querySelector('.doc-preview'); - let originalBorder, originalBoxShadow; - try { - const mod = await import('html2pdf.js'); - const html2pdf = mod.default || mod; - if (!el) { notify('找不到预览内容'); return; } - originalBorder = el.style.border; - originalBoxShadow = el.style.boxShadow; - el.style.border = 'none'; - el.style.boxShadow = 'none'; - const filename = `PackingList_${packingDraft.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 { - if (el) { el.style.border = originalBorder || ''; el.style.boxShadow = originalBoxShadow || ''; } - setPackingPdfExporting(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); - } - } - - async function handleAddCertificationFile(file) { - if (!file) return; - setCertUploading(true); - try { - const id = 'cert_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6); - const res = await uploadFile(id, file); - const entry = { id, brand: '', fileName: res.fileName }; - setCertifications(prev => [entry, ...prev]); - setEditingCertId(id); - setCertBrandDraft(''); - } catch (e) { - notify('上传失败,请重试'); - } finally { - setCertUploading(false); - } - } - function handleCertFilesSelected(files) { - Array.from(files || []).forEach(f => handleAddCertificationFile(f)); - } - function startEditCertBrand(cert) { - setEditingCertId(cert.id); - setCertBrandDraft(cert.brand || ''); - } - function saveCertBrand() { - setCertifications(prev => prev.map(c => (c.id === editingCertId ? { ...c, brand: certBrandDraft.trim() } : c))); - setEditingCertId(null); - setCertBrandDraft(''); - } - async function deleteCertification(cert) { - if (!window.confirm('确定删除这张授权证书?此操作不可恢复。')) return; - try { await deleteFile(cert.id, cert.fileName); } catch (e) {} - setCertifications(prev => prev.filter(c => c.id !== cert.id)); - if (editingCertId === cert.id) setEditingCertId(null); - } - - 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' && ( - <> -
按产品大类分组浏览;每个类目右上角管理品牌,类目内第二行管理类型;新建产品时先选类目,再从对应的品牌 / 类型里选。
- {productCategories.map(cat => { - const items = products.filter(p => p.category === cat.id); - return ( -
-
-
{cat.name}({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, productCategories)}{p.brand ? ` · ${p.brand}` : ''}
-
- -
- ))} -
-
- ); - })} - - {addingPill && addingPill.kind === 'category' ? ( -
- setAddingPillValue(e.target.value)} - onBlur={confirmAddPill} - onKeyDown={e => { - if (e.key === 'Enter') e.currentTarget.blur(); - if (e.key === 'Escape') cancelAddPill(); - }} - placeholder="新类目名称,如:精密空调" - /> -
- ) : ( -
openAddPill(null, 'category')}> - 新增类目 -
- )} - - )} - - {view === 'products' && ( -
-
-
setProductSearch(e.target.value)} />
-
- -
- -
-
- {filteredProducts.length === 0 ? ( -
暂无产品,点击上方按钮添加第一个
- ) : filteredProducts.map(p => ( -
{ setProductDraft(null); setSelectedProductId(p.id); }}> -
-
-
{p.name || '(未命名)'}
-
{categoryBreadcrumb(p, productCategories)}{p.brand ? ` · ${p.brand}` : ''}
-
- -
- ))} -
-
-
- -
- {productDraft ? ( - setTaxManageMode(mode)} /> - ) : selectedProduct ? ( - startEditProduct(selectedProduct)} onDelete={() => deleteProduct(selectedProduct.id)} categories={productCategories} /> - ) : ( -
选择左侧产品查看详情,或新建一个产品档案
- )} -
-
- )} - - {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, productCategories)}
- {p.brand &&
品牌:{p.brand}
} - {p.powerOrCapacity &&
功率 / 容量:{p.powerOrCapacity}
} - {p.voltage &&
电压:{p.voltage}
} - {certs.length > 0 &&
{certs.map(cc => {cc})}
} -
- ); - })} -
- )} - - )} - - {view === 'certs' && ( - <> -
- -
- {certifications.length === 0 ? ( -
暂无授权证书,点击右上角"+"上传第一张
- ) : ( -
- {certifications.map(cert => ( -
-
- - {cert.brand - - -
-
- {editingCertId === cert.id ? ( - setCertBrandDraft(e.target.value)} - onBlur={saveCertBrand} - onKeyDown={e => { if (e.key === 'Enter') e.currentTarget.blur(); }} - placeholder="品牌名称" - /> - ) : ( -
startEditCertBrand(cert)} - > - {cert.brand || '点击填写品牌名'} -
- )} -
-
- ))} -
- )} - - )} - - {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)}
-
- - - {q.type === 'quote' && ( - - )} -
- ); - })} -
-
- - )} - - {view === 'quotes' && quoteSubView === 'editor' && quoteDraft && ( - setSellerManagerOpen(true)} - onToggleNoteLine={toggleQuoteNoteLine} - priceHistory={priceHistory} - /> - )} - - {view === 'packing' && packingSubView === 'list' && ( - <> -
- - -
-
-
- {packingLists.length === 0 ? ( -
暂无Packing List,点击上方按钮新建一份,也可以到"报价单制作"里把Quote/Invoice转成Packing List
- ) : [...packingLists].reverse().map(pl => { - const t = packingTotals(pl); - return ( -
startEditPackingList(pl)}> - P/L -
-
{pl.number || '(无单号)'} · {pl.billToName || '(未填写客户)'}
-
{pl.date} · {t.cartons || 0} 箱 · {t.grossWeight || 0} kg 毛重
-
- -
- ); - })} -
-
- - )} - - {view === 'packing' && packingSubView === 'editor' && packingDraft && ( - updatePackingDraft({ sellerProfileId: sellerId })} - onAddLineItem={addPackingLineItem} - onRemoveLineItem={removePackingLineItem} - onUpdateLineItem={updatePackingLineItem} - onManageSellers={() => setSellerManagerOpen(true)} - /> - )} - - {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' : '消息内容'} -