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, Download
} from 'lucide-react';
import {
storageGet, storageSet, uploadFile, deleteFile,
authCheck, authLogin, authLogout, authChangePassword,
getAiConfig, saveAiConfig, deleteAiConfig, testAiConfig, generateAiDraft,
parseCatalogFiles, translateTerms,
} 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', catalogDocs: 'crm:catalogDocs' };
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 OUTPUT_PORT_TYPES = ['USB', 'DC', 'DC1', 'DC2', 'DC3', 'POE', 'Type-C', 'DC interface', 'POE interface'];
const EXPORT_LANGUAGES = [
{ code: 'zh', label: '中文', rtl: false },
{ code: 'en', label: 'English', rtl: false },
{ code: 'fr', label: 'Français', rtl: false },
{ code: 'ru', label: 'Русский', rtl: false },
{ code: 'es', label: 'Español', rtl: false },
{ code: 'ar', label: 'العربية', rtl: true },
];
// 多语言PDF导出用的内置专业翻译词典:固定字段名 + 已知的品类/机型名称,人工核对过的翻译,
// 不依赖AI、不占用DeepSeek额度、永远一致准确。词典里没有的词(比如用户自己新建的品类/品牌名、
// 或者产品备注这种自由文本)才会走AI翻译并缓存(见 resolveExportTranslations)。
const TRANSLATION_DICTIONARY = {
// 文档通用
'产品规格书': { en: 'Specifications', fr: 'Spécifications', ru: 'Технические характеристики', es: 'Especificaciones', ar: 'المواصفات' },
'* 产品规格如有变动,恕不另行通知': {
en: 'Product specifications are subject to change without notice.',
fr: "Les spécifications du produit peuvent être modifiées sans préavis.",
ru: 'Технические характеристики могут быть изменены без предварительного уведомления.',
es: 'Las especificaciones del producto están sujetas a cambios sin previo aviso.',
ar: 'تخضع مواصفات المنتج للتغيير دون إشعار مسبق.',
},
// 固定字段名
'型号': { en: 'Model', fr: 'Modèle', ru: 'Модель', es: 'Modelo', ar: 'الطراز' },
'品牌': { en: 'Brand', fr: 'Marque', ru: 'Бренд', es: 'Marca', ar: 'العلامة التجارية' },
'产品大类': { en: 'Category', fr: 'Catégorie', ru: 'Категория', es: 'Categoría', ar: 'الفئة' },
'细分品类': { en: 'Type', fr: 'Type', ru: 'Тип', es: 'Tipo', ar: 'النوع' },
'机型': { en: 'Machine Type', fr: "Type d'appareil", ru: 'Тип модели', es: 'Tipo de equipo', ar: 'طراز الجهاز' },
'标机': { en: 'Standard', fr: 'Standard', ru: 'Стандартный', es: 'Estándar', ar: 'قياسي' },
'长机': { en: 'Extended', fr: 'Prolongé', ru: 'Увеличенный', es: 'Extendido', ar: 'ممتد' },
'输出功率': { en: 'Output Power', fr: 'Puissance de sortie', ru: 'Выходная мощность', es: 'Potencia de salida', ar: 'طاقة الخرج' },
'功率 / 容量': { en: 'Power / Capacity', fr: 'Puissance / Capacité', ru: 'Мощность / Ёмкость', es: 'Potencia / Capacidad', ar: 'الطاقة / السعة' },
'输入电压': { en: 'Input Voltage', fr: "Tension d'entrée", ru: 'Входное напряжение', es: 'Voltaje de entrada', ar: 'جهد الدخل' },
'容量': { en: 'Capacity', fr: 'Capacité', ru: 'Ёмкость', es: 'Capacidad', ar: 'السعة' },
'输入电压范围': { en: 'Input Voltage Range', fr: "Plage de tension d'entrée", ru: 'Диапазон входного напряжения', es: 'Rango de voltaje de entrada', ar: 'نطاق جهد الدخل' },
'输入频率': { en: 'Input Frequency', fr: "Fréquence d'entrée", ru: 'Входная частота', es: 'Frecuencia de entrada', ar: 'تردد الدخل' },
'输出电压': { en: 'Output Voltage', fr: 'Tension de sortie', ru: 'Выходное напряжение', es: 'Voltaje de salida', ar: 'جهد الخرج' },
'输出电压范围': { en: 'Output Voltage Range', fr: 'Plage de tension de sortie', ru: 'Диапазон выходного напряжения', es: 'Rango de voltaje de salida', ar: 'نطاق جهد الخرج' },
'转换时间': { en: 'Transfer Time', fr: 'Temps de transfert', ru: 'Время переключения', es: 'Tiempo de transferencia', ar: 'وقت التحويل' },
'波形': { en: 'Waveform', fr: "Forme d'onde", ru: 'Форма волны', es: 'Forma de onda', ar: 'شكل الموجة' },
'电池电压': { en: 'Battery Voltage', fr: 'Tension de batterie', ru: 'Напряжение батареи', es: 'Voltaje de batería', ar: 'جهد البطارية' },
'电池型号和数量': { en: 'Battery Model & Qty', fr: 'Modèle et quantité de batterie', ru: 'Модель и кол-во батарей', es: 'Modelo y cantidad de batería', ar: 'طراز وعدد البطاريات' },
'充电电流': { en: 'Charge Current', fr: 'Courant de charge', ru: 'Ток зарядки', es: 'Corriente de carga', ar: 'تيار الشحن' },
'频率范围(电池模式)': { en: 'Frequency Range (Battery Mode)', fr: 'Plage de fréquence (mode batterie)', ru: 'Диапазон частот (режим батареи)', es: 'Rango de frecuencia (modo batería)', ar: 'نطاق التردد (وضع البطارية)' },
'充电时间': { en: 'Charge Time', fr: 'Temps de charge', ru: 'Время зарядки', es: 'Tiempo de carga', ar: 'وقت الشحن' },
'LCD显示': { en: 'LCD Display', fr: 'Affichage LCD', ru: 'ЖК-дисплей', es: 'Pantalla LCD', ar: 'شاشة LCD' },
'LED显示': { en: 'LED Display', fr: 'Affichage LED', ru: 'Светодиодный индикатор', es: 'Indicador LED', ar: 'مؤشر LED' },
'全面保护': { en: 'Full Protection', fr: 'Protection complète', ru: 'Полная защита', es: 'Protección completa', ar: 'حماية شاملة' },
'警告提示': { en: 'Alarm', fr: 'Alarme', ru: 'Сигнализация', es: 'Alarma', ar: 'إنذار' },
'控制管理': { en: 'Interface', fr: 'Interface', ru: 'Интерфейс', es: 'Interfaz', ar: 'الواجهة' },
'湿度': { en: 'Humidity', fr: 'Humidité', ru: 'Влажность', es: 'Humedad', ar: 'الرطوبة' },
'噪音': { en: 'Noise Level', fr: 'Niveau sonore', ru: 'Уровень шума', es: 'Nivel de ruido', ar: 'مستوى الضوضاء' },
'电压': { en: 'Voltage', fr: 'Tension', ru: 'Напряжение', es: 'Voltaje', ar: 'الجهد' },
'视载功率': { en: 'Rated Power (VA)', fr: 'Puissance nominale (VA)', ru: 'Номинальная мощность (ВА)', es: 'Potencia nominal (VA)', ar: 'الطاقة الاسمية (فولت أمبير)' },
'功率因数': { en: 'Power Factor', fr: 'Facteur de puissance', ru: 'Коэффициент мощности', es: 'Factor de potencia', ar: 'معامل القدرة' },
'最大功率': { en: 'Max Power', fr: 'Puissance maximale', ru: 'Максимальная мощность', es: 'Potencia máxima', ar: 'أقصى طاقة' },
'输出口数量': { en: 'Output Ports', fr: 'Nombre de sorties', ru: 'Количество выходов', es: 'Número de salidas', ar: 'عدد منافذ الخرج' },
'输出口类型': { en: 'Output Types', fr: 'Types de sortie', ru: 'Типы выходов', es: 'Tipos de salida', ar: 'أنواع منافذ الخرج' },
'电池数量和容量': { en: 'Battery Qty & Capacity', fr: 'Nombre et capacité de batterie', ru: 'Кол-во и ёмкость батарей', es: 'Cantidad y capacidad de batería', ar: 'عدد وسعة البطارية' },
'尺寸(mm)': { en: 'Dimensions (mm)', fr: 'Dimensions (mm)', ru: 'Габариты (мм)', es: 'Dimensiones (mm)', ar: 'الأبعاد (مم)' },
'净重(kg)': { en: 'Net Weight (kg)', fr: 'Poids net (kg)', ru: 'Вес нетто (кг)', es: 'Peso neto (kg)', ar: 'الوزن الصافي (كجم)' },
'备注': { en: 'Notes', fr: 'Remarques', ru: 'Примечания', es: 'Notas', ar: 'ملاحظات' },
'输出': { en: 'Output', fr: 'Sortie', ru: 'Выход', es: 'Salida', ar: 'الخرج' },
// 已知产品大类
'UPS不间断电源': { en: 'UPS (Uninterruptible Power Supply)', fr: 'Onduleur (UPS)', ru: 'ИБП (Источник бесперебойного питания)', es: 'SAI (Sistema de Alimentación Ininterrumpida)', ar: 'مزود طاقة غير منقطعة (UPS)' },
'光伏逆变器': { en: 'Solar Inverter', fr: 'Onduleur solaire', ru: 'Солнечный инвертор', es: 'Inversor solar', ar: 'عاكس شمسي' },
'储能电池': { en: 'Energy Storage Battery', fr: "Batterie de stockage d'énergie", ru: 'Аккумулятор для хранения энергии', es: 'Batería de almacenamiento de energía', ar: 'بطارية تخزين الطاقة' },
// 已知细分品类
'迷你UPS': { en: 'Mini UPS', fr: 'Mini onduleur', ru: 'Мини-ИБП', es: 'Mini SAI', ar: 'يو بي إس صغير' },
'离线式UPS': { en: 'Offline UPS', fr: 'Onduleur hors ligne', ru: 'Резервный ИБП (Offline)', es: 'SAI fuera de línea', ar: 'يو بي إس غير متصل' },
'在线互动式UPS': { en: 'Line-Interactive UPS', fr: 'Onduleur interactif', ru: 'Линейно-интерактивный ИБП', es: 'SAI interactivo de línea', ar: 'يو بي إس تفاعلي الخط' },
'高频塔式UPS': { en: 'High-Frequency Tower UPS', fr: 'Onduleur tour haute fréquence', ru: 'Высокочастотный башенный ИБП', es: 'SAI torre de alta frecuencia', ar: 'يو بي إس برجي عالي التردد' },
'工频UPS': { en: 'Low-Frequency UPS', fr: 'Onduleur basse fréquence', ru: 'Низкочастотный ИБП', es: 'SAI de baja frecuencia', ar: 'يو بي إس منخفض التردد' },
'模块化UPS': { en: 'Modular UPS', fr: 'Onduleur modulaire', ru: 'Модульный ИБП', es: 'SAI modular', ar: 'يو بي إس معياري' },
'机架式UPS': { en: 'Rack-mount UPS', fr: 'Onduleur rackable', ru: 'Стоечный ИБП', es: 'SAI para rack', ar: 'يو بي إس للتركيب في الرف' },
'太阳能混网逆变器': { en: 'Hybrid Solar Inverter', fr: 'Onduleur solaire hybride', ru: 'Гибридный солнечный инвертор', es: 'Inversor solar híbrido', ar: 'عاكس شمسي هجين' },
'并网/离网逆变器': { en: 'Grid-tied / Off-grid Inverter', fr: 'Onduleur raccordé / hors réseau', ru: 'Сетевой / автономный инвертор', es: 'Inversor conectado a la red / aislado', ar: 'عاكس متصل بالشبكة / خارج الشبكة' },
'铅酸电池': { en: 'Lead-acid Battery', fr: 'Batterie au plomb', ru: 'Свинцово-кислотная батарея', es: 'Batería de plomo-ácido', ar: 'بطارية الرصاص الحمضية' },
'磷酸铁锂电池': { en: 'LiFePO4 Battery', fr: 'Batterie LiFePO4', ru: 'Аккумулятор LiFePO4', es: 'Batería LiFePO4', ar: 'بطارية ليثيوم فوسفات الحديد' },
};
// 解析一批中文词条在目标语言下的翻译:优先用上面内置的专业词典(永远准确、不占用AI额度),
// 词典里没有的再查本地缓存(之前AI翻译过的词,存在 crm:translationGlossary 里),
// 缓存也没有的才真的调用DeepSeek翻译,翻完存回缓存,下次同一个词就不用再调了。
async function resolveExportTranslations(terms, lang) {
if (lang === 'zh' || !terms.length) {
const map = {};
terms.forEach(t => { map[t] = t; });
return map;
}
let glossary = {};
try {
const res = await storageGet('crm:translationGlossary');
glossary = JSON.parse(res.value) || {};
} catch (e) {}
const map = {};
const misses = [];
terms.forEach(t => {
if (TRANSLATION_DICTIONARY[t] && TRANSLATION_DICTIONARY[t][lang]) {
map[t] = TRANSLATION_DICTIONARY[t][lang];
} else if (glossary[t] && glossary[t][lang]) {
map[t] = glossary[t][lang];
} else {
misses.push(t);
}
});
if (misses.length) {
try {
const translations = await translateTerms(misses, lang);
const newGlossary = { ...glossary };
misses.forEach(t => {
const translated = (translations && translations[t]) || t;
map[t] = translated;
newGlossary[t] = { ...(newGlossary[t] || {}), [lang]: translated };
});
try { await storageSet('crm:translationGlossary', JSON.stringify(newGlossary)); } catch (e) {}
} catch (e) {
misses.forEach(t => { map[t] = t; }); // 翻译失败就用原文兜底,不阻塞导出
}
}
return map;
}
// 把某个产品需要展示的规格行整理出来(和ProductDetail的powerInfoItems逻辑对齐,但拆分出
// 独立的label/value结构,方便多语言渲染时分别处理"字段名要翻译"和"数值一般不用翻译")
function productExportRows(p, categories) {
const cat = (categories || []).find(c => c.id === p.category);
const rows = [];
if (isUpsCategory(cat) && (p.type === '离线式UPS' || p.type === '在线互动式UPS')) {
if (p.powerOrCapacity) rows.push({ labelKey: '容量', value: p.powerOrCapacity });
if (p.inputVoltage) rows.push({ labelKey: '输入电压', value: p.inputVoltage });
if (p.inputVoltageRange) rows.push({ labelKey: '输入电压范围', value: p.inputVoltageRange });
if (p.inputFrequency) rows.push({ labelKey: '输入频率', value: p.inputFrequency });
if (p.outputVoltage) rows.push({ labelKey: '输出电压', value: p.outputVoltage });
if (p.outputVoltageRange) rows.push({ labelKey: '输出电压范围', value: p.outputVoltageRange });
if (p.transferTime) rows.push({ labelKey: '转换时间', value: p.transferTime });
if (p.waveform) rows.push({ labelKey: '波形', value: p.waveform });
if (p.batteryVoltage) rows.push({ labelKey: '电池电压', value: p.batteryVoltage });
if (p.batterySpec) rows.push({ labelKey: '电池型号和数量', value: p.batterySpec });
if (p.chargeCurrent) rows.push({ labelKey: '充电电流', value: p.chargeCurrent });
if (p.frequencyRangeBatteryMode) rows.push({ labelKey: '频率范围(电池模式)', value: p.frequencyRangeBatteryMode });
if (p.chargeTime) rows.push({ labelKey: '充电时间', value: p.chargeTime });
if (p.lcdDisplay) rows.push({ labelKey: 'LCD显示', value: p.lcdDisplay });
if (p.ledDisplay) rows.push({ labelKey: 'LED显示', value: p.ledDisplay });
if (p.fullProtection) rows.push({ labelKey: '全面保护', value: p.fullProtection });
if (p.alarmInfo) rows.push({ labelKey: '警告提示', value: p.alarmInfo });
if (p.interfacePort) rows.push({ labelKey: '控制管理', value: p.interfacePort });
if (p.humidity) rows.push({ labelKey: '湿度', value: p.humidity });
if (p.noiseLevel) rows.push({ labelKey: '噪音', value: p.noiseLevel });
if (p.dimensions) rows.push({ labelKey: '尺寸(mm)', value: p.dimensions });
if (p.netWeight) rows.push({ labelKey: '净重(kg)', value: p.netWeight });
} else if (isUpsCategory(cat) && p.type !== '迷你UPS') {
const maxKW = computeMaxPowerKW(p.ratedPower, p.ratedPowerUnit, p.powerFactor);
if (p.ratedPower) rows.push({ labelKey: '视载功率', value: `${p.ratedPower} ${p.ratedPowerUnit || 'VA'}` });
if (p.powerFactor) rows.push({ labelKey: '功率因数', value: p.powerFactor });
if (maxKW !== null) rows.push({ labelKey: '最大功率', value: `${maxKW} kW` });
if (p.voltage) rows.push({ labelKey: '电压', value: p.voltage });
} else if (isUpsCategory(cat) && p.type === '迷你UPS') {
if (p.powerOrCapacity) rows.push({ labelKey: '输出功率', value: p.powerOrCapacity });
if (p.voltage) rows.push({ labelKey: '输入电压', value: p.voltage });
if (p.outputPortCount) rows.push({ labelKey: '输出口数量', value: p.outputPortCount });
if (p.outputPortTypes && p.outputPortTypes.length) rows.push({ labelKey: '输出口类型', value: p.outputPortTypes.join(' / ') });
(p.outputPorts || []).forEach(port => {
if (port.voltage || port.current) rows.push({ labelKey: '__port__', portType: port.type, value: [port.voltage, port.current].filter(Boolean).join(' / ') });
});
if (p.batterySpec) rows.push({ labelKey: '电池数量和容量', value: p.batterySpec });
if (p.dimensions) rows.push({ labelKey: '尺寸(mm)', value: p.dimensions });
if (p.netWeight) rows.push({ labelKey: '净重(kg)', value: p.netWeight });
} else {
if (p.powerOrCapacity) rows.push({ labelKey: '功率 / 容量', value: p.powerOrCapacity });
if (p.voltage) rows.push({ labelKey: '电压', value: p.voltage });
}
return rows;
}
// 收集导出这份产品规格书需要翻译的全部词条(字段名 + 品类/机型这些中文数值 + 备注自由文本)
// 按型号名称去重合并:"同一个型号只能存在1个"——如果传入的产品和列表里某个已有产品
// 型号名称相同(但不是同一条记录本身),说明是重复建了同型号,这时用新参数覆盖旧记录
// (复用旧记录的id),但保留旧记录里手动维护的图片/对客卖点/正本价格/内部备注,不会被
// 这次导入/新建的技术参数覆盖清空(因为AI批量导入/新建产品表单都不会填这几项)。
// 如果传入的产品本身就是列表里已有的那一条(正常编辑保存),走普通按id更新。
function upsertProductByName(list, product) {
const dupIdx = list.findIndex(p => p.id !== product.id && p.name.trim() === product.name.trim());
const selfIdx = list.findIndex(p => p.id === product.id);
if (dupIdx !== -1) {
const existing = list[dupIdx];
const merged = {
...product,
id: existing.id,
productImageFileName: existing.productImageFileName,
productImageOriginalName: existing.productImageOriginalName,
sellingPoints: existing.sellingPoints,
costPrice: existing.costPrice,
notes: existing.notes,
};
let copy = list.map((p, i) => (i === dupIdx ? merged : p));
if (selfIdx !== -1 && selfIdx !== dupIdx) copy = copy.filter((_, i) => i !== selfIdx);
return { list: copy, collision: true, isNew: false, resultId: existing.id };
}
if (selfIdx !== -1) {
const copy = [...list];
copy[selfIdx] = product;
return { list: copy, collision: false, isNew: false, resultId: product.id };
}
return { list: [...list, product], collision: false, isNew: true, resultId: product.id };
}
function gatherExportTerms(p, categories) {
const cat = (categories || []).find(c => c.id === p.category);
const terms = new Set(['产品规格书', '* 产品规格如有变动,恕不另行通知', '型号', '品牌', '产品大类']);
if (cat) terms.add(cat.name);
if (p.type) { terms.add('细分品类'); terms.add(p.type); }
if (isUpsCategory(cat) && p.machineVariant) { terms.add('机型'); terms.add(p.machineVariant); }
const rows = productExportRows(p, categories);
let hasPortRow = false;
rows.forEach(r => {
if (r.labelKey === '__port__') hasPortRow = true;
else terms.add(r.labelKey);
});
if (hasPortRow) terms.add('输出');
// 对客卖点/主要特点:每行一条,都是要给客户看的文字,需要翻译
(p.sellingPoints || '').split('\n').map(l => l.trim()).filter(Boolean).forEach(line => terms.add(line));
// 注意:备注(notes)字段故意不收集、不导出——这里经常被用来记内部信息(成本价、
// 采购渠道、库存数量这类),一旦跟着PDF发给客户后果很麻烦,所以规格书导出永远不碰这个字段。
return Array.from(terms);
}
const DEFAULT_MINI_UPS_OUTPUT_TYPES = ['USB', 'DC1', 'DC2', 'DC3', 'POE'];
const blankProduct = () => ({
id: 'p_' + Date.now() + '_' + Math.random().toString(36).slice(2, 7),
name: '', extraNames: [], brand: '', category: '', type: '',
machineVariant: '', // 标机 / 长机,仅UPS大类适用,独立于细分品类(迷你UPS固定为标机)
powerOrCapacity: '', // 非UPS大类的功率/容量;迷你UPS的输出功率;离线式/在线互动式UPS的容量(如400VA/240W)
ratedPower: '', ratedPowerUnit: 'VA', powerFactor: '', // UPS大类下机架式/高频塔式/工频/模块化UPS的三段式功率
voltage: '', // 非迷你UPS的通用大类:电压;迷你UPS:输入电压(默认 100~240Vac/50-60Hz,可修改)
outputPortCount: '', // 迷你UPS专属:输出口数量 1-6
outputPortTypes: [], // 迷你UPS专属:输出口类型(多选)
outputPorts: [], // 迷你UPS专属:每个已选输出口类型对应的输出电压/电流,如 [{type:'USB', voltage:'5Vdc', current:'3.0A'}]
// 离线式UPS / 在线互动式UPS 专属:输入输出参数(这两个细分品类共用同一套字段)
inputVoltage: '', // 输入电压,如 "110V/120Vac or 220V/230Vac"
inputVoltageRange: '', // 输入电压范围,如 "81-134Vac/89-145Vac or 162-268Vac/170-280Vac"
inputFrequency: '', // 输入频率,如 "50/60Hz(自动识别)"
outputVoltage: '', // 输出电压,如 "110V/120Vac or 220V/230Vac"
outputVoltageRange: '', // 输出电压范围,如 "±10%"
transferTime: '', // 转换时间,如 "Typical 2-8ms, 13ms max"
waveform: '', // 波形(电池模式),如 "Simulated Sine Wave" / "Sine Wave"
batteryVoltage: '', // 电池电压,如 "12V" / "24V"
chargeCurrent: '', // 充电电流,如 "1A"
frequencyRangeBatteryMode: '', // 频率范围(电池模式),如 "50/60Hz±1Hz"
chargeTime: '', // 充电时间,如 "8小时恢复到90%容量"
lcdDisplay: '', // LCD显示内容,如 "AC mode, Battery mode, Load level, Battery level, Input voltage, Output voltage, Overload and low battery"
ledDisplay: '', // LED显示说明,如 "AC mode: Green / Battery mode: Yellow / Fault: Red"
fullProtection: '', // 全面保护,如 "Discharge, Short circuit and overload protection"
alarmInfo: '', // 警告提示(电池模式/低电量/过载/故障这几种蜂鸣提示,合并成一个多行字段)
interfacePort: '', // 控制管理/接口,如 "USB/RS232 port(optional), support windows xp/vista, 7/8/11 and MAC"
humidity: '', // 湿度,如 "0-90% RH@0-40℃(Non-condensing)"
noiseLevel: '', // 噪音,如 "Less than 40dB(1m spacing)"
batterySpec: '', // 电池型号和数量,如 "12V4.5AH*1"(迷你UPS/离线式/在线互动式UPS共用)
dimensions: '', // 尺寸(mm)(迷你UPS/离线式/在线互动式UPS共用)
netWeight: '', // 净重(kg)(迷你UPS/离线式/在线互动式UPS共用)
productImageFileName: '', productImageOriginalName: '', // 产品主图,导出多语言PDF时展示
sellingPoints: '', // 对客卖点/主要特点,每行一条,专门给客户看的(和下面的costPrice/notes内部信息彻底分开,不会一起导出)
costPrice: '', notes: '',
});
function isUpsCategory(cat) {
return !!cat && (cat.id === 'ups' || cat.name === 'UPS不间断电源');
}
function spaceToAsterisk(v) { return (v || '').replace(/ /g, '*'); }
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' || p.type === '在线互动式UPS')) {
const items = [];
if (p.powerOrCapacity) items.push({ key: 'capacity', label: '容量', value: p.powerOrCapacity });
if (p.inputVoltage) items.push({ key: 'inputvoltage', label: '输入电压', value: p.inputVoltage });
if (p.inputVoltageRange) items.push({ key: 'inputvoltagerange', label: '输入电压范围', value: p.inputVoltageRange });
if (p.inputFrequency) items.push({ key: 'inputfreq', label: '输入频率', value: p.inputFrequency });
if (p.outputVoltage) items.push({ key: 'outputvoltage', label: '输出电压', value: p.outputVoltage });
if (p.outputVoltageRange) items.push({ key: 'outputvoltagerange', label: '输出电压范围', value: p.outputVoltageRange });
if (p.transferTime) items.push({ key: 'transfertime', label: '转换时间', value: p.transferTime });
if (p.waveform) items.push({ key: 'waveform', label: '波形', value: p.waveform });
if (p.batteryVoltage) items.push({ key: 'batteryvoltage', label: '电池电压', value: p.batteryVoltage });
if (p.batterySpec) items.push({ key: 'batteryspec', label: '电池型号和数量', value: p.batterySpec });
if (p.chargeCurrent) items.push({ key: 'chargecurrent', label: '充电电流', value: p.chargeCurrent });
if (p.frequencyRangeBatteryMode) items.push({ key: 'freqrange', label: '频率范围(电池模式)', value: p.frequencyRangeBatteryMode });
if (p.chargeTime) items.push({ key: 'chargetime', label: '充电时间', value: p.chargeTime });
if (p.lcdDisplay) items.push({ key: 'lcddisplay', label: 'LCD显示', value: p.lcdDisplay });
if (p.ledDisplay) items.push({ key: 'leddisplay', label: 'LED显示', value: p.ledDisplay });
if (p.fullProtection) items.push({ key: 'fullprotection', label: '全面保护', value: p.fullProtection });
if (p.alarmInfo) items.push({ key: 'alarminfo', label: '警告提示', value: p.alarmInfo });
if (p.interfacePort) items.push({ key: 'interfaceport', label: '控制管理', value: p.interfacePort });
if (p.humidity) items.push({ key: 'humidity', label: '湿度', value: p.humidity });
if (p.noiseLevel) items.push({ key: 'noiselevel', label: '噪音', value: p.noiseLevel });
if (p.dimensions) items.push({ key: 'dim', label: '尺寸(mm)', value: p.dimensions });
if (p.netWeight) items.push({ key: 'weight', label: '净重(kg)', value: p.netWeight });
return items;
}
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') {
const items = [{ key: 'maxout', label: '输出功率', value: p.powerOrCapacity || '—' }];
items.push({ key: 'inputvoltage', label: '输入电压', value: p.voltage || '—' });
if (p.outputPortCount) items.push({ key: 'portcount', label: '输出口数量', value: p.outputPortCount });
if (p.outputPortTypes && p.outputPortTypes.length) items.push({ key: 'porttypes', label: '输出口类型', value: p.outputPortTypes.join(' / ') });
(p.outputPorts || []).forEach(port => {
if (port.voltage || port.current) {
items.push({ key: 'port_' + port.type, label: `${port.type} 输出`, value: [port.voltage, port.current].filter(Boolean).join(' ') });
}
});
if (p.batterySpec) items.push({ key: 'battery', label: '电池数量和容量', value: p.batterySpec });
if (p.dimensions) items.push({ key: 'dim', label: '尺寸(mm)', value: p.dimensions });
if (p.netWeight) items.push({ key: 'weight', label: '净重(kg)', value: p.netWeight });
return items;
}
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 = [];
let catalogDocs = [];
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) {}
try {
const cd = await storageGet(STORAGE_KEYS.catalogDocs);
if (cd && cd.value) catalogDocs = JSON.parse(cd.value);
} catch (e) {}
return { customers, products, settings, quotes, sellerProfiles, certifications, productCategories, packingLists, catalogDocs };
}
/* -------------------------------- 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 },
] },
];
const STANDALONE_NAV = [
{ id: 'certs', label: '授权管理', icon: ShieldCheck },
{ id: 'catalog', label: '产品目录', icon: BookOpen },
{ 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 [aiImportOpen, setAiImportOpen] = useState(false);
const [aiImportBusy, setAiImportBusy] = useState(false);
const [aiImportError, setAiImportError] = useState('');
const [aiImportDrafts, setAiImportDrafts] = useState(null); // null=还没解析;数组=进入预览确认阶段
const [aiImportHintCategory, setAiImportHintCategory] = useState('');
const [aiImportHintBrand, setAiImportHintBrand] = useState('');
const [aiImportHintType, setAiImportHintType] = useState('');
const [aiImportTruncatedWarning, setAiImportTruncatedWarning] = useState(false);
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 [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);
const [catalogDocs, setCatalogDocs] = useState([]);
const [catalogDragActive, setCatalogDragActive] = useState(false);
const [dragCatalogDocIdx, setDragCatalogDocIdx] = useState(null);
const [editingCatalogId, setEditingCatalogId] = useState(null);
const [catalogEditDraft, setCatalogEditDraft] = useState({ title: '', category: '', brand: '', type: '' });
const [catalogCategoryFilter, setCatalogCategoryFilter] = useState('all');
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);
setCatalogDocs((data.catalogDocs || []).filter(d => d.fileName));
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]);
useEffect(() => { if (loaded) storageSet(STORAGE_KEYS.catalogDocs, JSON.stringify(catalogDocs)).catch(() => {}); }, [catalogDocs, 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 [productExportLang, setProductExportLang] = useState('zh');
const [productExportLabels, setProductExportLabels] = useState({});
const [productExportLoading, setProductExportLoading] = useState(false);
const [productExportPdfBusy, setProductExportPdfBusy] = useState(false);
useEffect(() => {
if (!selectedProduct) { setProductExportLabels({}); return; }
let cancelled = false;
setProductExportLoading(true);
const terms = gatherExportTerms(selectedProduct, productCategories);
resolveExportTranslations(terms, productExportLang)
.then(map => { if (!cancelled) setProductExportLabels(map); })
.finally(() => { if (!cancelled) setProductExportLoading(false); });
return () => { cancelled = true; };
}, [selectedProduct && selectedProduct.id, productExportLang, productCategories]);
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]);
const filteredCatalogDocs = useMemo(() => {
if (catalogCategoryFilter === 'all') return catalogDocs;
return catalogDocs.filter(d => d.uploading || d.category === catalogCategoryFilter);
}, [catalogDocs, catalogCategoryFilter]);
function handleCustomerDrop(dropIdx) {
if (dragCustomerIdx === null || dragCustomerIdx === dropIdx) { setDragCustomerIdx(null); return; }
setCustomers(prev => reorderArray(prev, dragCustomerIdx, dropIdx));
setDragCustomerIdx(null);
}
async function handleAddCatalogFile(file) {
if (!file) return;
const tempId = 'catdoc_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
// 先插入一张"上传中"的占位卡片,让用户能实时看到这个文件正在传,传完再替换成真实数据
setCatalogDocs(prev => [{ id: tempId, title: '', fileName: '', originalName: file.name, category: '', brand: '', type: '', uploading: true }, ...prev]);
try {
const res = await uploadFile(tempId, file);
setCatalogDocs(prev => prev.map(d => (d.id === tempId ? { ...d, fileName: res.fileName, originalName: res.originalName, uploading: false } : d)));
setEditingCatalogId(tempId);
setCatalogEditDraft({ title: '', category: '', brand: '', type: '' });
} catch (e) {
notify(`《${file.name}》上传失败,请重试`);
setCatalogDocs(prev => prev.filter(d => d.id !== tempId));
}
}
function handleCatalogFilesSelected(files) {
Array.from(files || []).forEach(f => handleAddCatalogFile(f));
}
function handleCatalogDragOver(e) { e.preventDefault(); setCatalogDragActive(true); }
function handleCatalogDragLeave(e) {
if (e.currentTarget.contains(e.relatedTarget)) return;
setCatalogDragActive(false);
}
function handleCatalogDrop(e) {
e.preventDefault();
setCatalogDragActive(false);
const files = Array.from((e.dataTransfer && e.dataTransfer.files) || []).filter(f => f.type === 'application/pdf' || /\.pdf$/i.test(f.name));
if (files.length === 0) { notify('只能上传PDF文件'); return; }
handleCatalogFilesSelected(files);
}
function startEditCatalogDoc(doc) {
setEditingCatalogId(doc.id);
setCatalogEditDraft({ title: doc.title || '', category: doc.category || '', brand: doc.brand || '', type: doc.type || '' });
}
function updateCatalogEditDraft(patch) {
setCatalogEditDraft(d => ({ ...d, ...patch }));
}
function saveCatalogEdit() {
setCatalogDocs(prev => prev.map(d => (d.id === editingCatalogId
? { ...d, title: catalogEditDraft.title.trim(), category: catalogEditDraft.category, brand: catalogEditDraft.brand, type: catalogEditDraft.type }
: d)));
setEditingCatalogId(null);
setCatalogEditDraft({ title: '', category: '', brand: '', type: '' });
}
async function deleteCatalogDoc(doc) {
if (!window.confirm('确定删除这份产品目录PDF?此操作不可恢复。')) return;
try { await deleteFile(doc.id, doc.fileName); } catch (e) {}
setCatalogDocs(prev => prev.filter(d => d.id !== doc.id));
if (editingCatalogId === doc.id) setEditingCatalogId(null);
}
function handleCatalogDocDrop(dropIdx) {
if (dragCatalogDocIdx === null || dragCatalogDocIdx === dropIdx) { setDragCatalogDocIdx(null); return; }
setCatalogDocs(prev => reorderArray(prev, dragCatalogDocIdx, dropIdx));
setDragCatalogDocIdx(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,
}));
let working = products;
let newCount = 0;
let collisionCount = 0;
let primarySelectId = primary.id;
[primary, ...extras].forEach((prod, i) => {
const res = upsertProductByName(working, prod);
working = res.list;
if (res.collision) collisionCount++;
else if (res.isNew) newCount++;
if (i === 0) primarySelectId = res.resultId;
});
setProducts(working);
setSelectedProductId(primarySelectId);
setProductDraft(null);
if (extras.length > 0) {
notify(`已保存:新建 ${newCount} 个${collisionCount > 0 ? `,型号已存在覆盖更新 ${collisionCount} 个` : ''}`);
} else if (collisionCount > 0) {
notify('型号已存在,已用最新参数覆盖更新(图片/卖点/成本价/备注保留原样)');
} else {
notify('已保存');
}
}
function deleteProduct(id) {
if (!window.confirm('确定删除该产品档案?此操作不可恢复。')) return;
setProducts(prev => prev.filter(p => p.id !== id));
if (selectedProductId === id) setSelectedProductId(null);
notify('已删除');
}
async function handleExportProductPdf() {
if (!selectedProduct) return;
setProductExportPdfBusy(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 langLabel = (EXPORT_LANGUAGES.find(l => l.code === productExportLang) || {}).code || 'zh';
const filename = `${selectedProduct.name || 'product'}_${langLabel}.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 || ''; }
setProductExportPdfBusy(false);
}
}
/* ------------------------------ ai bulk import ----------------------------- */
function openAiImport() {
setAiImportOpen(true); setAiImportDrafts(null); setAiImportError('');
setAiImportHintCategory(''); setAiImportHintBrand(''); setAiImportHintType('');
setAiImportTruncatedWarning(false);
}
function closeAiImport() { setAiImportOpen(false); setAiImportDrafts(null); setAiImportError(''); setAiImportBusy(false); }
async function handleAiImportFiles(files) {
if (!files || !files.length) return;
setAiImportBusy(true);
setAiImportError('');
try {
const res = await parseCatalogFiles(files, {
category: aiImportHintCategory.trim(),
brand: aiImportHintBrand.trim(),
type: aiImportHintType.trim(),
});
const parsed = (res && res.products) || [];
if (!parsed.length) { setAiImportError('没有从文件里识别出任何产品,换一份更清晰的文件试试'); return; }
setAiImportTruncatedWarning(!!(res && res.truncated));
const drafts = parsed.map((p, i) => ({
_localId: 'aidraft_' + i + '_' + Math.random().toString(36).slice(2, 6),
include: true,
name: p.name || '', brand: p.brand || '', category: p.category || '', type: p.type || '',
machineVariant: p.machineVariant || '', powerOrCapacity: p.powerOrCapacity || '', voltage: p.voltage || '',
ratedPower: p.ratedPower || '', ratedPowerUnit: p.ratedPowerUnit || 'VA', powerFactor: p.powerFactor || '',
outputPortCount: p.outputPortCount || '',
outputPortTypes: Array.isArray(p.outputPortTypes) ? p.outputPortTypes : [],
outputPorts: Array.isArray(p.outputPorts) ? p.outputPorts : [],
inputVoltage: p.inputVoltage || '', inputVoltageRange: p.inputVoltageRange || '', inputFrequency: p.inputFrequency || '',
outputVoltage: p.outputVoltage || '', outputVoltageRange: p.outputVoltageRange || '',
transferTime: p.transferTime || '', waveform: p.waveform || '',
batteryVoltage: p.batteryVoltage || '', chargeCurrent: p.chargeCurrent || '',
frequencyRangeBatteryMode: p.frequencyRangeBatteryMode || '', chargeTime: p.chargeTime || '',
lcdDisplay: p.lcdDisplay || '', ledDisplay: p.ledDisplay || '', fullProtection: p.fullProtection || '',
alarmInfo: p.alarmInfo || '', interfacePort: p.interfacePort || '',
humidity: p.humidity || '', noiseLevel: p.noiseLevel || '',
batterySpec: p.batterySpec || '', dimensions: p.dimensions || '', netWeight: p.netWeight || '', notes: p.notes || '',
}));
setAiImportDrafts(drafts);
} catch (e) {
setAiImportError(e.message || '解析失败,请重试');
} finally {
setAiImportBusy(false);
}
}
function updateAiImportDraft(localId, patch) {
setAiImportDrafts(prev => prev.map(d => (d._localId === localId ? { ...d, ...patch } : d)));
}
function removeAiImportDraft(localId) {
setAiImportDrafts(prev => prev.filter(d => d._localId !== localId));
}
// 把预览确认过的草稿变成正式产品:大类/品牌/细分品类如果和现有taxonomy的名字不完全一致,
// 就当成新增(自动建一个新的类目/品牌/品类),和手动在"产品分组"里新增是同一套逻辑
function resolveCategoriesForImport(categories, drafts) {
let cats = categories.map(c => ({ ...c, brands: [...c.brands], types: [...c.types] }));
const resolved = drafts.map((d) => {
const catName = (d.category || '').trim();
let cat = catName ? cats.find(c => c.name === catName) : null;
if (!cat && catName) {
cat = { id: 'cat_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 5), name: catName, brands: [], types: [] };
cats.push(cat);
}
if (cat) {
if (d.brand && !cat.brands.includes(d.brand)) cat.brands.push(d.brand);
if (d.type && !cat.types.includes(d.type)) cat.types.push(d.type);
}
return { ...d, categoryId: cat ? cat.id : '' };
});
return { categories: cats, resolved };
}
function buildProductFromAiDraft(d, idx) {
return {
...blankProduct(),
id: 'p_' + Date.now() + '_' + idx + '_' + Math.random().toString(36).slice(2, 7),
name: d.name.trim(), brand: d.brand || '', category: d.categoryId || '', type: d.type || '',
machineVariant: d.machineVariant || '', powerOrCapacity: d.powerOrCapacity || '', voltage: d.voltage || '',
ratedPower: d.ratedPower || '', ratedPowerUnit: d.ratedPowerUnit || 'VA', powerFactor: d.powerFactor || '',
outputPortCount: d.outputPortCount || '', outputPortTypes: d.outputPortTypes || [], outputPorts: d.outputPorts || [],
inputVoltage: d.inputVoltage || '', inputVoltageRange: d.inputVoltageRange || '', inputFrequency: d.inputFrequency || '',
outputVoltage: d.outputVoltage || '', outputVoltageRange: d.outputVoltageRange || '',
transferTime: d.transferTime || '', waveform: d.waveform || '',
batteryVoltage: d.batteryVoltage || '', chargeCurrent: d.chargeCurrent || '',
frequencyRangeBatteryMode: d.frequencyRangeBatteryMode || '', chargeTime: d.chargeTime || '',
lcdDisplay: d.lcdDisplay || '', ledDisplay: d.ledDisplay || '', fullProtection: d.fullProtection || '',
alarmInfo: d.alarmInfo || '', interfacePort: d.interfacePort || '',
humidity: d.humidity || '', noiseLevel: d.noiseLevel || '',
batterySpec: d.batterySpec || '', dimensions: d.dimensions || '', netWeight: d.netWeight || '', notes: d.notes || '',
};
}
function confirmAiImport() {
const included = (aiImportDrafts || []).filter(d => d.include && d.name.trim());
if (!included.length) { notify('没有勾选任何要导入的产品'); return; }
const { categories: newCats, resolved } = resolveCategoriesForImport(productCategories, included);
let working = products;
let newCount = 0;
let collisionCount = 0;
resolved.forEach((d, idx) => {
const productData = buildProductFromAiDraft(d, idx);
const res = upsertProductByName(working, productData);
working = res.list;
if (res.collision) collisionCount++;
else if (res.isNew) newCount++;
});
setProducts(working);
setProductCategories(newCats);
const parts = [];
if (newCount) parts.push(`新建 ${newCount} 个`);
if (collisionCount) parts.push(`型号已存在,覆盖更新为最新参数 ${collisionCount} 个`);
notify(`导入完成:${parts.join(',')}`);
closeAiImport();
}
/* --------------------------- 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)} />}
{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)} onNotify={notify} />
) : selectedProduct ? (
startEditProduct(selectedProduct)} onDelete={() => deleteProduct(selectedProduct.id)}
categories={productCategories}
exportLang={productExportLang} onExportLangChange={setProductExportLang}
exportLabels={productExportLabels} exportLoading={productExportLoading}
onExportPdf={handleExportProductPdf} exportPdfBusy={productExportPdfBusy}
/>
) : (
)}
)}
{view === 'catalog' && (
{catalogDocs.length === 0 ? (
暂无产品目录,点击右上角"+"或把PDF拖到这里上传第一份样本
) : filteredCatalogDocs.length === 0 ? (
) : (
{filteredCatalogDocs.map((doc, idx) => {
const isEditing = editingCatalogId === doc.id;
const activeCategoryId = isEditing ? catalogEditDraft.category : doc.category;
const cat = productCategories.find(c => c.id === activeCategoryId);
const brandOptions = cat ? cat.brands : [];
const typeOptions = cat ? cat.types : [];
return (
{ e.stopPropagation(); setDragCatalogDocIdx(idx); }}
onDragOver={(e) => { e.preventDefault(); e.stopPropagation(); }}
onDrop={(e) => { e.stopPropagation(); handleCatalogDocDrop(idx); }}
onDragEnd={() => setDragCatalogDocIdx(null)}
>
{isEditing ? (
updateCatalogEditDraft({ title: e.target.value })}
onKeyDown={e => { if (e.key === 'Enter') saveCatalogEdit(); }}
placeholder="目录名称,如:SKE产品样本"
/>
{catalogEditDraft.category && (
<>
>
)}
) : (
!doc.uploading && startEditCatalogDoc(doc)} style={{ cursor: doc.uploading ? 'default' : 'pointer' }}>
{doc.uploading ? '上传完成后可填写' : (doc.title || '点击填写目录名称')}
{(doc.category || doc.brand || doc.type) && (
{[cat && cat.name, doc.brand, doc.type].filter(Boolean).join(' · ')}
)}
)}
);
})}
)}
)}
{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 => (
地址
银行信息(仅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' ? '品牌' : '品类'}
)}
);
})()}
{aiImportOpen && (
e.stopPropagation()}>
{!aiImportDrafts ? (
<>
支持上传PDF或图片形式的产品目录,可以一次选多个文件(比如一份多页PDF,或者拍的多张照片)。
AI会自动识别里面有几个产品型号、并提取型号/品牌/规格参数,之后可以在预览里检查修改,确认无误再批量建档。
细分品类(可选)
setAiImportHintType(e.target.value)}
placeholder="如:迷你UPS" disabled={aiImportBusy}
/>
{aiImportError &&
{aiImportError}
}
>
) : (
<>
识别出 {aiImportDrafts.length} 个产品,请检查型号/品牌/大类/细分品类,勾掉不需要导入的行,确认后点"确认导入"。
其余规格字段(功率/电压/输出口/电池/尺寸等)已经一并识别好,导入后可以在产品详情里继续核对修改。
{aiImportTruncatedWarning && (
⚠️ AI这次输出的内容超出了长度限制,可能还有没识别完的产品被截断丢弃了。建议核对一下上面这 {aiImportDrafts.length} 个是不是齐了,
如果少了,把剩下的文件单独再上传一次(比如分成更小的批次,每批3-5个文件)。
)}
{aiImportDrafts.map(d => (
{[
d.machineVariant && `机型:${d.machineVariant}`,
d.powerOrCapacity && `功率:${d.powerOrCapacity}`,
d.voltage && `电压:${d.voltage}`,
d.batterySpec && `电池:${d.batterySpec}`,
d.dimensions && `尺寸:${d.dimensions}`,
d.netWeight && `净重:${d.netWeight}`,
(d.outputPortTypes && d.outputPortTypes.length) ? `输出口:${d.outputPortTypes.join('/')}` : '',
].filter(Boolean).join(' · ') || '(无更多规格信息)'}
))}
>
)}
{aiImportDrafts && (
)}
)}
{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 (
} 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}
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, onNotify }) {
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 isOfflineOrLI = isUps && (draft.type === '离线式UPS' || draft.type === '在线互动式UPS');
const maxKW = computeMaxPowerKW(draft.ratedPower, draft.ratedPowerUnit, draft.powerFactor);
const extraNames = draft.extraNames || [];
const [imageUploading, setImageUploading] = useState(false);
async function handleImagePick(file) {
if (!file) return;
setImageUploading(true);
try {
const res = await uploadFile(draft.id, file);
set({ productImageFileName: res.fileName, productImageOriginalName: res.originalName });
} catch (e) {
if (onNotify) onNotify('图片上传失败,请重试');
} finally {
setImageUploading(false);
}
}
async function handleImageRemove() {
if (!draft.productImageFileName) return;
try { await deleteFile(draft.id, draft.productImageFileName); } catch (e) {}
set({ productImageFileName: '', productImageOriginalName: '' });
}
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) }); }
function toggleOutputPortType(t) {
const list = draft.outputPortTypes || [];
if (list.includes(t)) {
set({
outputPortTypes: list.filter(x => x !== t),
outputPorts: (draft.outputPorts || []).filter(p => p.type !== t),
});
} else {
set({
outputPortTypes: [...list, t],
outputPorts: [...(draft.outputPorts || []), { type: t, voltage: '', current: '' }],
});
}
}
function updateOutputPort(t, patch) {
set({ outputPorts: (draft.outputPorts || []).map(p => (p.type === t ? { ...p, ...patch } : p)) });
}
// 失焦时自动补单位:纯数字才补(比如"5"变"5Vdc"、"2.4"变"2.4A"),
// 如果用户已经手动填了完整格式(比如POE常见的"24Vdc/48Vdc"),保持原样不动,避免破坏。
function normalizeOutputVoltageOnBlur(t, rawValue) {
const trimmed = (rawValue || '').trim();
if (/^\d+(\.\d+)?$/.test(trimmed)) updateOutputPort(t, { voltage: trimmed + 'Vdc' });
}
function normalizeOutputCurrentOnBlur(t, rawValue) {
const trimmed = (rawValue || '').trim();
if (/^\d+(\.\d+)?$/.test(trimmed)) updateOutputPort(t, { current: trimmed + 'A' });
}
return (
{draft.name ? '编辑产品' : '新建产品'}
{extraNames.length === 0 ? (
比如同一系列有4个型号,规格/品牌/类型都一样,填这里可以一次性建好4个产品档案
) : extraNames.map((n, idx) => (
updateExtraName(idx, e.target.value)} placeholder="型号" />
))}
品牌
onManage('brand')}>品牌管理
细分品类
onManage('type')}>品类管理
{isUps && (
机型
{isMiniUps ? (
) : (
)}
)}
{isUps && isOfflineOrLI ? (
<>
频率范围(电池模式) set({ frequencyRangeBatteryMode: e.target.value })} placeholder="如:50/60Hz±1Hz" />
LCD显示(LCD屏能显示哪些信息) set({ lcdDisplay: e.target.value })} placeholder="如:AC mode, Battery mode, Load level, Input/Output voltage" />
LED显示(各指示灯含义)
全面保护 set({ fullProtection: e.target.value })} placeholder="如:Discharge, Short circuit and overload protection" />
警告提示(各种蜂鸣报警的规律)
控制管理 / 接口 set({ interfacePort: e.target.value })} placeholder="如:USB/RS232 port(optional), support windows xp/vista, 7/8/11 and MAC" />
>
) : isUps && !isMiniUps ? (
) : isUps && isMiniUps ? (
<>
输出口类型
{OUTPUT_PORT_TYPES.map(t => (
toggleOutputPortType(t)}>
{(draft.outputPortTypes || []).includes(t) && } {t}
))}
{(draft.outputPorts || []).length > 0 && (
各输出口的输出电压 / 电流
{draft.outputPorts.map(p => (
))}
)}
>
) : (
)}
产品主图(可选,用于多语言PDF导出)
{draft.productImageFileName ? (
) : (
)}
对客卖点 / 主要特点(可选,每行一条,会显示在多语言PDF导出的资料里)
正本价格(元) set({ costPrice: e.target.value })} placeholder="采购/成本价,人民币" />
内部备注(仅自己可见,不会出现在任何导出的文档里)
);
}
function ProductDetail({ product: p, onEdit, onDelete, categories, exportLang, onExportLangChange, exportLabels, exportLoading, onExportPdf, exportPdfBusy }) {
const cat = (categories || []).find(c => c.id === p.category);
const showMachineVariant = isUpsCategory(cat) && p.machineVariant;
const L = (key) => (exportLabels && exportLabels[key]) || key;
const langInfo = EXPORT_LANGUAGES.find(l => l.code === exportLang) || EXPORT_LANGUAGES[0];
const exportRows = productExportRows(p, categories);
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 &&
}
导出语言
{(() => {
const sellingPointLines = (p.sellingPoints || '').split('\n').map(l => l.trim()).filter(Boolean);
const specTable = (
| {L('型号')} | {p.name || '—'} |
{p.brand && | {L('品牌')} | {p.brand} |
}
| {L('产品大类')} | {cat ? L(cat.name) : '—'} |
{p.type && | {L('细分品类')} | {L(p.type)} |
}
{showMachineVariant && | {L('机型')} | {L(p.machineVariant)} |
}
{exportRows.map((r, i) => (
| {r.labelKey === '__port__' ? `${r.portType} ${L('输出')}` : L(r.labelKey)} |
{r.value} |
))}
);
const disclaimer =
{L('* 产品规格如有变动,恕不另行通知')}
;
if (p.productImageFileName) {
return (
{p.name || '—'}
{p.brand &&
{p.brand}
}
{sellingPointLines.length > 0 && (
{sellingPointLines.map((line, i) => - {L(line)}
)}
)}
{L('产品规格书')}
{specTable}
{disclaimer}
);
}
return (
<>
{p.name || '—'}
{p.brand &&
{p.brand}
}
{sellingPointLines.length > 0 && (
{sellingPointLines.map((line, i) => - {L(line)}
)}
)}
{L('产品规格书')}
{specTable}
{disclaimer}
>
);
})()}
>
);
}
/* ----------------------------- 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 (
);
})}
{isInvoice && (
)}
{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 (
装箱明细(按产品行汇总)
{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 && (
)}
);
}
function Info({ icon, label, value }) {
return (
);
}