diff --git a/App.jsx b/App.jsx
new file mode 100644
index 0000000..66cdf41
--- /dev/null
+++ b/App.jsx
@@ -0,0 +1,3402 @@
+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: '', extraNames: [], brand: '', category: '', type: '',
+ machineVariant: '', // 标机 / 长机,仅UPS大类适用,独立于细分品类
+ powerOrCapacity: '', // 非UPS大类的功率/容量,或UPS大类下"迷你UPS"的最大输出功率
+ ratedPower: '', ratedPowerUnit: 'VA', powerFactor: '', // UPS大类下除"迷你UPS"外的三段式功率
+ voltage: '', costPrice: '', notes: '',
+});
+
+function isUpsCategory(cat) {
+ return !!cat && (cat.id === 'ups' || cat.name === 'UPS不间断电源');
+}
+function computeMaxPowerKW(ratedPower, ratedPowerUnit, powerFactor) {
+ const rp = parseFloat(ratedPower);
+ const pf = parseFloat(powerFactor);
+ if (!rp || !pf) return null;
+ const ratedKVA = ratedPowerUnit === 'kVA' ? rp : rp / 1000;
+ const kw = ratedKVA * pf;
+ return Math.round(kw * 1000) / 1000;
+}
+function powerInfoItems(p, categories) {
+ const cat = (categories || []).find(c => c.id === p.category);
+ if (isUpsCategory(cat) && p.type !== '迷你UPS') {
+ const maxKW = computeMaxPowerKW(p.ratedPower, p.ratedPowerUnit, p.powerFactor);
+ return [
+ { key: 'rated', label: '视载功率', value: p.ratedPower ? `${p.ratedPower} ${p.ratedPowerUnit || 'VA'}` : '—' },
+ { key: 'pf', label: '功率因数', value: p.powerFactor || '—' },
+ { key: 'maxkw', label: '最大功率', value: maxKW !== null ? `${maxKW} kW` : '—' },
+ ];
+ }
+ if (isUpsCategory(cat) && p.type === '迷你UPS') {
+ return [{ key: 'maxout', label: '最大输出功率', value: p.powerOrCapacity || '—' }];
+ }
+ return [{ key: 'power', label: '功率 / 容量', value: p.powerOrCapacity || '—' }];
+}
+
+/* ------------------------------- 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);
+ });
+
+ // 老数据兼容:如果之前版本把"标机/长机"误存进了某个类目的 types 列表里,这里清掉,
+ // 现在标机/长机是独立字段(machineVariant),不再作为细分品类的一个选项。
+ cats.forEach(c => { c.types = c.types.filter(t => t !== '标机' && t !== '长机'); });
+
+ 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 (
+
+ );
+}
+
+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: '箱单', 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 (
+
+ );
+}
+
+/* --------------------------------- 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, extraNames: [] }); setSelectedProductId(p.id); }
+ function cancelProductDraft() { setProductDraft(null); }
+ function saveProductDraft() {
+ if (!productDraft.name.trim()) { notify('请填写产品型号/名称'); return; }
+ if (!productDraft.category) { notify('请选择产品大类'); return; }
+ const extraNames = (productDraft.extraNames || []).map(n => n.trim()).filter(Boolean);
+ const { extraNames: _drop, ...primary } = productDraft;
+ const extras = extraNames.map((n, i) => ({
+ ...primary,
+ id: 'p_' + Date.now() + '_' + i + '_' + Math.random().toString(36).slice(2, 7),
+ name: n,
+ }));
+ setProducts(prev => {
+ const exists = prev.some(p => p.id === primary.id);
+ const withPrimary = exists ? prev.map(p => (p.id === primary.id ? primary : p)) : [...prev, primary];
+ return [...withPrimary, ...extras];
+ });
+ setSelectedProductId(primary.id);
+ setProductDraft(null);
+ notify(extras.length > 0 ? `已保存,共新建 ${1 + extras.length} 个产品` : '已保存');
+ }
+ 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 duplicateQuote(quote) {
+ const copy = {
+ ...quote,
+ id: 'q_' + Date.now() + '_' + Math.random().toString(36).slice(2, 7),
+ lineItems: quote.lineItems.map(li => ({ ...li, id: 'li_' + Date.now() + '_' + Math.random().toString(36).slice(2, 6) })),
+ };
+ copy.number = suggestDocNumber(copy.date, quotes, copy.type);
+ setQuoteDraft(copy);
+ setQuoteSubView('editor');
+ notify('已复制为新草稿,请检查单号后保存');
+ }
+ 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 duplicatePackingList(pl) {
+ const copy = {
+ ...pl,
+ id: 'pl_' + Date.now() + '_' + Math.random().toString(36).slice(2, 7),
+ lineItems: pl.lineItems.map(li => ({ ...li, id: 'pli_' + Date.now() + '_' + Math.random().toString(36).slice(2, 6) })),
+ };
+ copy.number = suggestDocNumber(copy.date, packingLists, 'packing');
+ setPackingDraft(copy);
+ setPackingSubView('editor');
+ notify('已复制为新草稿,请检查单号后保存');
+ }
+ 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('已基于该文档创建箱单草稿,补充箱数/重量/体积后保存');
+ }
+ 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('确定删除这份箱单?此操作不可恢复。')) 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}
+
+ );
+ })}
+
+
+
+
+
+
+
+ {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 &&
}
+ {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) => {
+ return (
+
setDragCatalogIdx(idx)}
+ onDragOver={(e) => e.preventDefault()}
+ onDrop={() => handleCatalogDrop(idx)}
+ onDragEnd={() => setDragCatalogIdx(null)}
+ >
+
+
+
{p.name || '(未命名)'}
+
+
{categoryBreadcrumb(p, productCategories)}
+ {p.brand &&
品牌:{p.brand}
}
+ {isUpsCategory(productCategories.find(c => c.id === p.category)) && p.machineVariant &&
机型:{p.machineVariant}
}
+ {powerInfoItems(p, productCategories).map(item => item.value !== '—' && (
+
{item.label}:{item.value}
+ ))}
+ {p.voltage &&
电压:{p.voltage}
}
+ {p.costPrice &&
正本价格:¥{p.costPrice}
}
+
+ );
+ })}
+
+ )}
+ >
+ )}
+
+ {view === 'certs' && (
+ <>
+
+ {certifications.length === 0 ? (
+
+ ) : (
+
+ {certifications.map(cert => (
+
+
+
+
+
+
+
+
+ {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 ? (
+
暂无箱单,点击上方按钮新建一份,也可以到"报价单"里把Quote/Invoice转成箱单
+ ) : [...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' && (
+ <>
+
+
+
+
+
+
+
+
+
+ 说明:邮件功能用于生成可复制的英文邮件文案,需要你手动粘贴到自己的邮箱发送。报价单制作正在开发中,下一轮会上线。
+
+
+
+
+
+
+
+
当前登录账号:{username}
+
+
+
+ 这个系统现在挂在公网上,定期更换密码、不要把密码告诉别人。
+
+
+
+
+
+
+
+
+ 状态:{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' : '消息内容'}
+
+
+
+ {aiDraftModal.channel === 'email' && }
+
+
+
+
+ )}
+
+ {emailModal && (
+
setEmailModal(null)}>
+
e.stopPropagation()}>
+
邮件文案
+
+
+
+
+
+
+
+ )}
+
+ {exportOpen && (
+
setExportOpen(false)}>
+
e.stopPropagation()}>
+
数据备份(JSON)
+
+
+
+
+
+
+
+
+ )}
+
+ {sellerManagerOpen && (
+
setSellerManagerOpen(false)}>
+
e.stopPropagation()}>
+
管理卖方信息
+
+ {sellerProfiles.map(sp => (
+
+
+
+
公司名称 updateSellerProfile(sp.id, { name: e.target.value })} />
+
+
Logo
+
+ {sp.logoFileName &&

}
+
+
+
+
+
地址
+
银行信息(仅Invoice使用,可留空)
+
+
+
+ ))}
+
+
+
+
+
+
+
+ )}
+
+ {taxManageMode && (() => {
+ const targetCat = productDraft ? productCategories.find(c => c.id === productDraft.category) : null;
+ const closeModal = () => { setTaxManageMode(null); cancelAddPill(); };
+ const titleMap = { category: '产品大类管理', brand: '品牌管理', type: '品类管理' };
+ return (
+
+
e.stopPropagation()}>
+
+
{titleMap[taxManageMode]}{targetCat && taxManageMode !== 'category' ? ` · ${targetCat.name}` : ''}
+
+
+
+ {taxManageMode === 'category' ? (
+ <>
+ {productCategories.length === 0 &&
暂无类目
}
+ {productCategories.map(c => (
+
+
+
{c.name}
+
{c.brands.length} 个品牌 · {c.types.length} 个品类
+
+
+
+ ))}
+
+ {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="新类目名称,如:精密空调"
+ />
+ ) : (
+
+ )}
+
+ >
+ ) : targetCat ? (
+
+ ) : (
+
请先在表单里选择产品大类,再来管理{taxManageMode === 'brand' ? '品牌' : '品类'}
+ )}
+
+
+
+
+
+
+ );
+ })()}
+
+ {toast &&
{toast}
}
+
+ );
+}
+
+/* ----------------------------- customer form / detail ----------------------------- */
+
+function CustomerForm({ draft, setDraft, onSave, onCancel }) {
+ const set = (patch) => setDraft(d => ({ ...d, ...patch }));
+
+ function handleCountryChange(value) {
+ const match = COUNTRY_BY_NAME[value];
+ if (match) {
+ setDraft(d => ({
+ ...d,
+ country: value,
+ region: match.region,
+ // 国家代码只在电话号码当前为空时自动填入,避免覆盖已经填好的号码
+ phone: d.phone && d.phone.trim() ? d.phone : match.dial + ' ',
+ }));
+ } else {
+ set({ country: value });
+ }
+ }
+
+ return (
+
+
{draft.company ? '编辑客户' : '新建客户'}
+
+
+
+
关注产品线 set({ productLines: v })} />
+ {draft.productLines.length > 0 && (
+
+ )}
+
+
+
备注
+
+
+
+ 箱唛 / 备注
+
+
+
客户背景信息调查
+
+
+
+ );
+}
+
+function ContactInfo({ icon, label, value, active, onToggle }) {
+ return (
+
+
+ {icon}{label}{active ? '(常用)' : ''}
+
+
{value}
+
+ );
+}
+
+function CollapsibleSection({ title, count, defaultOpen, children }) {
+ const [open, setOpen] = useState(!!defaultOpen);
+ return (
+
+
setOpen(o => !o)}
+ >
+ {title}{typeof count === 'number' ? `(${count})` : ''}
+
+
+ {open &&
{children}
}
+
+ );
+}
+
+function CustomerDetail({
+ customer: c, settings, now, onEdit, onDelete, onMarkContacted, onEmail,
+ onTogglePreferred, onToggleVip, onToggleOrdered,
+ attachmentNote, setAttachmentNote, attachmentCategory, setAttachmentCategory,
+ attachmentFileName, onPickAttachment, uploading, fileInputKey, onUploadAttachment, onDeleteAttachment,
+ fileDragActive, onFileDragOver, onFileDragLeave, onFileDrop,
+ aiConfigHasKey, aiGenerating, onGenerateAi,
+}) {
+ const s = statusOf(c, settings);
+ const lt = localTimeInfo(c.country, now);
+ const preferred = c.preferredContacts || [];
+ const [fileSearch, setFileSearch] = useState('');
+ const [fileCategoryFilter, setFileCategoryFilter] = useState('all');
+ const filteredAttachments = (c.attachments || []).filter(a => {
+ if (fileCategoryFilter !== 'all' && a.category !== fileCategoryFilter) return false;
+ if (fileSearch.trim()) {
+ const q = fileSearch.trim().toLowerCase();
+ return (a.originalName || '').toLowerCase().includes(q) || (a.note || '').toLowerCase().includes(q);
+ }
+ return true;
+ });
+ return (
+
+
+
+
+ {customerTitle(c)}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
} label="公司名称" value={c.company || '—'} />
+
} label="联系人" value={c.contact || '—'} />
+
} label="地区 / 国家" value={`${regionLabel(c.region)} · ${c.country || '—'}`} />
+
} label="邮箱" value={c.email || '—'} active={preferred.includes('email')} onToggle={() => onTogglePreferred('email')} />
+
} label="电话" value={c.phone || '—'} active={preferred.includes('phone')} onToggle={() => onTogglePreferred('phone')} />
+
} label="当地时间" value={lt ? `${lt.time}(${lt.en})` : '—'} />
+
} label="网站" value={c.website ?
{c.website} : '—'} />
+
+ {c.source &&
客户来源{c.source}
}
+ {c.productLines && c.productLines.length > 0 && (
+
+
关注产品线
+
{c.productLines.map(p => {p})}
+ {(c.intendedModel || c.intendedQty) && (
+
+ 意向型号:{c.intendedModel || '—'}{c.intendedQty ? ` × ${c.intendedQty}` : ''}
+
+ )}
+
+ )}
+ {c.tags &&
}
+
+
+ {!(c.warehouseAddress || c.freightContact || c.shippingMarkNote) ? (
+ 暂无货代信息
+ ) : (
+ <>
+
+ {c.warehouseAddress && }
+ {c.freightContact && }
+
+ {c.shippingMarkNote && 箱唛 / 备注:{c.shippingMarkNote}
}
+ >
+ )}
+
+
+ {c.notes &&
}
+
+
+ 上次联系:{fmtDate(c.lastContact)} | 状态:{statusLabel(s)}
+
+
+
+
+
+
+
+ {!aiConfigHasKey && 未配置DeepSeek,去"设置"里填API Key}
+
+
+
+
客户文件管理
+
+
+
+
+
setAttachmentNote(e.target.value)}
+ />
+
+
+
+
+
0}>
+ {(!c.attachments || c.attachments.length === 0) ? (
+ 暂无文件
+ ) : (
+ <>
+
+ setFileSearch(e.target.value)}
+ />
+
+
+ {filteredAttachments.length === 0 ? (
+ 没有匹配的文件
+ ) : filteredAttachments.map((a, i) => (
+
+
+
+
{a.date}
+ {' '}
{a.category || 'Other'}
+
+
+
+
+ ))}
+ >
+ )}
+
+
+
+ );
+}
+
+/* ----------------------------- product form / detail ----------------------------- */
+
+function ProductForm({ draft, setDraft, onSave, onCancel, categories, onManage }) {
+ const set = (patch) => setDraft(d => ({ ...d, ...patch }));
+ const cat = (categories || []).find(c => c.id === draft.category);
+ const brandOptions = cat ? cat.brands : [];
+ const typeOptions = cat ? cat.types : [];
+ const isUps = isUpsCategory(cat);
+ const isMiniUps = isUps && draft.type === '迷你UPS';
+ const maxKW = computeMaxPowerKW(draft.ratedPower, draft.ratedPowerUnit, draft.powerFactor);
+ const extraNames = draft.extraNames || [];
+ function addExtraName() { set({ extraNames: [...extraNames, ''] }); }
+ function updateExtraName(idx, val) { const arr = [...extraNames]; arr[idx] = val; set({ extraNames: arr }); }
+ function removeExtraName(idx) { set({ extraNames: extraNames.filter((_, i) => i !== idx) }); }
+ return (
+
+
{draft.name ? '编辑产品' : '新建产品'}
+
+
+
型号 / 产品名称 * set({ name: e.target.value })} />
+
+
+ 产品大类 *
+ onManage('category')}>大类管理
+
+
+
+
+
+
+
+
同系列其他型号(可选,一次性批量新建)
+
添加型号
+
+ {extraNames.length === 0 ? (
+
比如同一系列有4个型号,规格/品牌/类型都一样,填这里可以一次性建好4个产品档案
+ ) : extraNames.map((n, idx) => (
+
+ updateExtraName(idx, e.target.value)} placeholder="型号" />
+
+
+ ))}
+
+
+
+
+
+ 品牌
+ onManage('brand')}>品牌管理
+
+
+
+
+
+ 细分品类
+ onManage('type')}>品类管理
+
+
+
+
+
+ {isUps && (
+
+ 机型
+
+
+ )}
+
+ {isUps && !isMiniUps ? (
+
+
+
视载功率
+
+ set({ ratedPower: e.target.value })} placeholder="如:1000" />
+
+
+
+
功率因数 set({ powerFactor: e.target.value })} placeholder="如:0.9 / 1" />
+
最大功率(kW,自动计算)
+
电压 set({ voltage: e.target.value })} placeholder="如:48V / 192V / 220V" />
+
+ ) : isUps && isMiniUps ? (
+
+ ) : (
+
+ )}
+
+
正本价格(元) set({ costPrice: e.target.value })} placeholder="采购/成本价,人民币" />
+
备注 / 卖点说明
+
+
+
+ );
+}
+
+function ProductDetail({ product: p, onEdit, onDelete, categories }) {
+ const cat = (categories || []).find(c => c.id === p.category);
+ const showMachineVariant = isUpsCategory(cat) && p.machineVariant;
+ return (
+
+
+
+
+ } label="分类" value={categoryBreadcrumb(p, categories)} />
+ } label="品牌" value={p.brand || '—'} />
+ {showMachineVariant && } label="机型" value={p.machineVariant} />}
+ {powerInfoItems(p, categories).map(item => (
+ } label={item.label} value={item.value} />
+ ))}
+ } label="电压" value={p.voltage || '—'} />
+ } label="正本价格(元)" value={p.costPrice || '—'} />
+
+ {p.notes &&
}
+
+
+ );
+}
+
+/* ----------------------------- quote / invoice editor ----------------------------- */
+
+function QuoteEditor({
+ draft, onUpdate, onBack, onSave, onPrint, onExportPdf, pdfExporting,
+ customers, sellerProfiles, onLinkCustomer, onSellerChange, onCurrencyChange,
+ onAddLineItem, onRemoveLineItem, onUpdateLineItem, onManageSellers, onToggleNoteLine,
+ priceHistory,
+}) {
+ const isInvoice = draft.type === 'invoice';
+ const seller = sellerProfiles.find(sp => sp.id === draft.sellerProfileId) || sellerProfiles[0];
+ const { total } = quoteTotals(draft);
+ const dueDate = addOneMonth(draft.date);
+ const logoUrl = seller && seller.logoFileName ? `/uploads/${seller.id}/${seller.logoFileName}` : null;
+ const sortedCustomers = [...customers].sort((a, b) => quoteLinkLabel(a).localeCompare(quoteLinkLabel(b)));
+ const activeNoteLines = (draft.notes || '').split('\n').map(l => l.trim()).filter(Boolean);
+ const [suggestLineId, setSuggestLineId] = useState(null);
+
+ function suggestionsFor(li) {
+ const firstLine = (li.description || '').split('\n')[0].trim();
+ if (firstLine.length < 2) return [];
+ const q = firstLine.toUpperCase();
+ return (priceHistory || []).filter(h => h.description.split('\n')[0].toUpperCase().includes(q)).slice(0, 5);
+ }
+ function applySuggestion(liId, hist) {
+ onUpdateLineItem(liId, { description: hist.description, rate: hist.rate });
+ setSuggestLineId(null);
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
{isInvoice ? 'Invoice 信息' : 'Quote 信息'}
+
+
+
+ 卖方
+
+
+
+ 关联客户档案(可选,仅用于自动填名称)
+
+
+
单号 onUpdate({ number: e.target.value })} />
+
日期 onUpdate({ date: e.target.value })} />
+
+ 币种
+
+
+ {isInvoice && (
+ <>
+
+ Payment Terms
+
+
+
+ Due Date(自动计算:日期 + 1 个月)
+
+
+ >
+ )}
+
+
+
+
+
+ {isInvoice ? 'Bill To 名称' : 'Quote To 名称'}(可换行,第一行作为主标题)
+
+ {isInvoice &&
Ship To 名称(留空则单据上不显示这一项) onUpdate({ shipToName: e.target.value })} />
}
+
+
+
产品明细
+ {draft.lineItems.map(li => {
+ const suggestions = suggestLineId === li.id ? suggestionsFor(li) : [];
+ return (
+
+
+
onUpdateLineItem(li.id, { quantity: e.target.value })} />
+
onUpdateLineItem(li.id, { rate: e.target.value })} />
+
+ {formatMoney((parseFloat(li.quantity) || 0) * (parseFloat(li.rate) || 0), draft.currency)}
+
+
+
+ );
+ })}
+
+
+ {isInvoice && (
+
+
银行信息 Bank Information
+
签署人姓名 onUpdate({ signerName: e.target.value })} />
+
+ )}
+
+
{isInvoice ? 'Terms 条款' : 'Notes 备注'}
+
+
+
+
+
+
+
+ {logoUrl ? (
+

+ ) : (
+
{(seller && seller.name) || ''}
+ )}
+
+
+
{isInvoice ? 'INVOICE' : 'QUOTE'}
+
# {draft.number}
+
+
+
+
+
+
{seller && seller.name}
+
{seller && seller.address}
+
+
+
Date:{draft.date}
+ {isInvoice && (
+ <>
+ {draft.paymentTerms &&
Payment Terms:{draft.paymentTerms}
}
+
Due Date:{dueDate}
+
Balance Due:{formatMoney(total, draft.currency)}
+ >
+ )}
+
+
+
+ {!isInvoice ? (
+
+
Quote To:
+ {(() => {
+ const billLines = (draft.billToName || '').split('\n');
+ return (
+ <>
+
{billLines[0] || '—'}
+ {billLines.slice(1).map((l, i) => l.trim() &&
{l}
)}
+ >
+ );
+ })()}
+
+ ) : (
+
+
+
Bill To:
+ {(() => {
+ const billLines = (draft.billToName || '').split('\n');
+ return (
+ <>
+
{billLines[0] || '—'}
+ {billLines.slice(1).map((l, i) => l.trim() &&
{l}
)}
+ >
+ );
+ })()}
+
+ {draft.shipToName && draft.shipToName.trim() && (
+
+
Ship To:
+
{draft.shipToName}
+
+ )}
+
+ )}
+
+
+
+
+ | Item |
+ Quantity |
+ Rate |
+ Amount |
+
+
+
+ {draft.lineItems.map(li => {
+ const lines = (li.description || '').split('\n');
+ const amount = (parseFloat(li.quantity) || 0) * (parseFloat(li.rate) || 0);
+ return (
+
+ |
+ {lines[0] || '—'}
+ {lines.slice(1).map((l, i) => l.trim() && {l} )}
+ |
+ {li.quantity || '—'} |
+ {li.rate ? formatMoney(parseFloat(li.rate), draft.currency) : '—'} |
+ {formatMoney(amount, draft.currency)} |
+
+ );
+ })}
+
+
+
+
+
Total:{formatMoney(total, draft.currency)}
+
+
+ {isInvoice && (draft.bankInfo || (seller && seller.bankInfo)) && (
+
+
Bank Information:
+
{draft.bankInfo || (seller && seller.bankInfo)}
+
+ )}
+
+ {draft.notes && (
+
+
{isInvoice ? 'Terms:' : 'Notes:'}
+
{draft.notes}
+
+ )}
+
+ {isInvoice && (
+
+
For and on behalf of
+
{seller && seller.name}
+ {draft.signerName &&
{draft.signerName}
}
+
+
Authorized Signature(s)
+
+ )}
+
+
+ );
+}
+
+function PackingListEditor({
+ draft, onUpdate, onBack, onSave, onPrint, onExportPdf, pdfExporting,
+ customers, sellerProfiles, onLinkCustomer, onSellerChange,
+ onAddLineItem, onRemoveLineItem, onUpdateLineItem, onManageSellers,
+}) {
+ const seller = sellerProfiles.find(sp => sp.id === draft.sellerProfileId) || sellerProfiles[0];
+ const logoUrl = seller && seller.logoFileName ? `/uploads/${seller.id}/${seller.logoFileName}` : null;
+ const sortedCustomers = [...customers].sort((a, b) => quoteLinkLabel(a).localeCompare(quoteLinkLabel(b)));
+ const totals = packingTotals(draft);
+ const billLines = (draft.billToName || '').split('\n');
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 卖方
+
+
+
+
+
+ 关联客户档案(可选,仅用于自动填名称)
+
+
+
单号 onUpdate({ number: e.target.value })} />
+
日期 onUpdate({ date: e.target.value })} />
+
+
+
+
+
+ 客户名称 / 地址(可换行,第一行作为主标题)
+
+
+
+
+
装箱明细(按产品行汇总)
+ {draft.lineItems.map(li => (
+
+ ))}
+
+
+
备注
+
+
+
+
+
+
+ {draft.showSeller ? (
+ logoUrl ? (
+

+ ) : (
+
{(seller && seller.name) || ''}
+ )
+ ) :
}
+
+
+
PACKING LIST
+
# {draft.number}
+
+
+
+
+
+ {draft.showSeller && (
+ <>
+
{seller && seller.name}
+
{seller && seller.address}
+ >
+ )}
+
+
+
+
+ {draft.showBillTo && (
+
+
Consignee / Customer:
+
{billLines[0] || '—'}
+ {billLines.slice(1).map((l, i) => l.trim() &&
{l}
)}
+
+ )}
+
+
+
+
+ | Description of Goods |
+ Qty |
+ Cartons |
+ G.W. (kg) |
+ N.W. (kg) |
+ Volume (CBM) |
+
+
+
+ {draft.lineItems.map(li => {
+ const lines = (li.description || '').split('\n');
+ return (
+
+ |
+ {lines[0] || '—'}
+ {lines.slice(1).map((l, i) => l.trim() && {l} )}
+ |
+ {li.qty || '—'} |
+ {li.cartons || '—'} |
+ {li.grossWeight || '—'} |
+ {li.netWeight || '—'} |
+ {li.volume || '—'} |
+
+ );
+ })}
+
+
+
+
+
Total:{totals.qty} pcs · {totals.cartons} ctns · {totals.grossWeight} kg G.W. · {totals.netWeight} kg N.W. · {totals.volume} CBM
+
+
+ {draft.notes && (
+
+
Notes:
+
{draft.notes}
+
+ )}
+
+
+ );
+}
+
+function Info({ icon, label, value }) {
+ return (
+
+
{icon}{label}
+
{value}
+
+ );
+}