client/App.jsx

5648 lines
355 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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, Wifi, Radio, QrCode, Send
} from 'lucide-react';
import {
storageGet, storageSet, uploadFile, deleteFile,
authCheck, authLogin, authLogout, authChangePassword,
getAiConfig, saveAiConfig, deleteAiConfig, testAiConfig, generateAiDraft,
parseCatalogFiles, translateTerms,
getTelegramConfig, saveTelegramConfig, deleteTelegramConfig, testTelegramConfig,
checkJmsLine,
} 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: 'online_high_freq', label: '在线式高频UPS / Online High-Frequency 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: '磷酸铁锂电池' },
{ id: 'ups_lithium_system', label: 'UPS配套锂电池系统 / UPS-matched Lithium Battery System', short: 'UPS配套锂电池系统' },
],
},
];
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', jmsLines: 'crm:jmsLines' };
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 Model', fr: 'Modèle standard', ru: 'Стандартная модель', es: 'Modelo estándar', ar: 'الطراز القياسي' },
'长延时机型': { en: 'Long Backup Time Model', fr: 'Modèle longue autonomie', ru: 'Модель увеличенной автономности', es: 'Modelo de larga autonomía', 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: 'Main Features', fr: 'Caractéristiques principales', ru: 'Основные характеристики', es: 'Características principales', ar: 'الميزات الرئيسية' },
'相数': { en: 'Phase', fr: 'Phase', ru: 'Фаза', es: 'Fase', ar: 'الطور' },
'输入功率因数': { en: 'Input Power Factor', fr: "Facteur de puissance d'entrée", ru: 'Входной коэффициент мощности', es: 'Factor de potencia de entrada', ar: 'معامل قدرة الدخل' },
'转换时间(市电到电池)': { en: 'Transfer Time (AC to Battery)', fr: 'Temps de transfert (secteur vers batterie)', ru: 'Время переключения (сеть на батарею)', es: 'Tiempo de transferencia (red a batería)', ar: 'وقت التحويل (من الشبكة إلى البطارية)' },
'峰值电流比': { en: 'Current Crest Ratio', fr: 'Facteur de crête de courant', ru: 'Пик-фактор тока', es: 'Relación de cresta de corriente', ar: 'نسبة ذروة التيار' },
'谐波失真': { en: 'Harmonic Distortion', fr: 'Distorsion harmonique', ru: 'Гармонические искажения', es: 'Distorsión armónica', ar: 'التشويه التوافقي' },
'效率(市电模式)': { en: 'Efficiency (AC Mode)', fr: 'Rendement (mode secteur)', ru: 'КПД (сетевой режим)', es: 'Eficiencia (modo red)', ar: 'الكفاءة (وضع الشبكة)' },
'效率(电池模式)': { en: 'Efficiency (Battery Mode)', fr: 'Rendement (mode batterie)', ru: 'КПД (режим батареи)', es: 'Eficiencia (modo batería)', ar: 'الكفاءة (وضع البطارية)' },
'充电电压': { en: 'Charging Voltage', fr: 'Tension de charge', ru: 'Напряжение зарядки', es: 'Voltaje de carga', ar: 'جهد الشحن' },
'可选SNMP': { en: 'Optional SNMP', fr: 'SNMP en option', ru: 'SNMP (опционально)', es: 'SNMP opcional', ar: 'SNMP اختياري' },
'输出功率因数': { en: 'Output Power Factor', fr: 'Facteur de puissance de sortie', ru: 'Выходной коэффициент мощности', es: 'Factor de potencia de salida', ar: 'معامل قدرة الخرج' },
'可并联台数': { en: 'Parallel Units', fr: 'Unités en parallèle', ru: 'Количество параллельных блоков', es: 'Unidades en paralelo', ar: 'عدد الوحدات المتوازية' },
'过载保护': { en: 'Overload Protection', fr: 'Protection contre les surcharges', ru: 'Защита от перегрузки', es: 'Protección contra sobrecarga', ar: 'حماية من الحمل الزائد' },
'UPS配套锂电池系统': { en: 'UPS-matched Lithium Battery System', fr: 'Système de batterie lithium pour UPS', ru: 'Литиевая батарейная система для ИБП', es: 'Sistema de batería de litio para SAI', ar: 'نظام بطارية ليثيوم متوافق مع UPS' },
'模块类型': { en: 'Module Type', fr: 'Type de module', ru: 'Тип модуля', es: 'Tipo de módulo', ar: 'نوع الوحدة' },
'额定容量': { en: 'Rated Capacity', fr: 'Capacité nominale', ru: 'Номинальная ёмкость', es: 'Capacidad nominal', ar: 'السعة الاسمية' },
'电芯类型': { en: 'Cell Type', fr: 'Type de cellule', ru: 'Тип элемента', es: 'Tipo de celda', ar: 'نوع الخلية' },
'额定电压': { en: 'Rated Voltage', fr: 'Tension nominale', ru: 'Номинальное напряжение', es: 'Voltaje nominal', ar: 'الجهد الاسمي' },
'放电倍率': { en: 'Discharge Rate', fr: 'Taux de décharge', ru: 'Скорость разряда', es: 'Tasa de descarga', ar: 'معدل التفريغ' },
'充电倍率': { en: 'Charge Rate', fr: 'Taux de charge', ru: 'Скорость заряда', es: 'Tasa de carga', ar: 'معدل الشحن' },
'模块最大能量(kWh)': { en: 'Module Max Energy (kWh)', fr: 'Énergie max du module (kWh)', ru: 'Макс. энергия модуля (кВт·ч)', es: 'Energía máxima del módulo (kWh)', ar: 'أقصى طاقة للوحدة (كيلوواط ساعة)' },
'模块输出功率(kW)': { en: 'Module Output Power (kW)', fr: 'Puissance de sortie du module (kW)', ru: 'Выходная мощность модуля (кВт)', es: 'Potencia de salida del módulo (kW)', ar: 'طاقة خرج الوحدة (كيلوواط)' },
'模块尺寸(mm)': { en: 'Module Dimensions (mm)', fr: 'Dimensions du module (mm)', ru: 'Габариты модуля (мм)', es: 'Dimensiones del módulo (mm)', ar: 'أبعاد الوحدة (مم)' },
'模块重量(kg)': { en: 'Module Weight (kg)', fr: 'Poids du module (kg)', ru: 'Вес модуля (кг)', es: 'Peso del módulo (kg)', ar: 'وزن الوحدة (كجم)' },
'电池最大能量(kWh)': { en: 'Battery Max Energy (kWh)', fr: 'Énergie max de la batterie (kWh)', ru: 'Макс. энергия батареи (кВт·ч)', es: 'Energía máxima de la batería (kWh)', ar: 'أقصى طاقة للبطارية (كيلوواط ساعة)' },
'额定输出电压(V)': { en: 'Rated Output Voltage (V)', fr: 'Tension de sortie nominale (V)', ru: 'Номинальное выходное напряжение (В)', es: 'Voltaje de salida nominal (V)', ar: 'جهد الخرج الاسمي (فولت)' },
'循环寿命': { en: 'Cycle Life', fr: 'Durée de vie (cycles)', ru: 'Циклический ресурс', es: 'Vida útil (ciclos)', ar: 'عمر الدورة' },
'通信接口': { en: 'Communication Interface', fr: 'Interface de communication', ru: 'Интерфейс связи', es: 'Interfaz de comunicación', ar: 'واجهة الاتصال' },
'保护功能': { en: 'Protection Functions', fr: 'Fonctions de protection', ru: 'Функции защиты', es: 'Funciones de protección', ar: 'وظائف الحماية' },
'运行温度': { en: 'Operating Temperature', fr: 'Température de fonctionnement', ru: 'Рабочая температура', es: 'Temperatura de funcionamiento', ar: 'درجة حرارة التشغيل' },
'颜色': { en: 'Color', fr: 'Couleur', ru: 'Цвет', es: 'Color', ar: 'اللون' },
'系统柜尺寸(mm)': { en: 'Cabinet Dimensions (mm)', fr: "Dimensions de l'armoire (mm)", ru: 'Габариты шкафа (мм)', es: 'Dimensiones del gabinete (mm)', ar: 'أبعاد الخزانة (مم)' },
'系统柜重量(kg)': { en: 'Cabinet Weight (kg)', fr: "Poids de l'armoire (kg)", ru: 'Вес шкафа (кг)', es: 'Peso del gabinete (kg)', ar: 'وزن الخزانة (كجم)' },
'海拔(m)': { en: 'Altitude (m)', fr: 'Altitude (m)', ru: 'Высота над уровнем моря (м)', es: 'Altitud (m)', ar: 'الارتفاع (م)' },
'自放电率': { en: 'Self-discharge Rate', fr: "Taux d'autodécharge", ru: 'Скорость саморазряда', es: 'Tasa de autodescarga', ar: 'معدل التفريغ الذاتي' },
'电池类型': { en: 'Battery Type', fr: 'Type de batterie', ru: 'Тип батареи', es: 'Tipo de batería', ar: 'نوع البطارية' },
'最大充电电压': { en: 'Max Charging Voltage', fr: 'Tension de charge maximale', ru: 'Макс. напряжение зарядки', es: 'Voltaje máximo de carga', ar: 'أقصى جهد شحن' },
'最大充放电电流': { en: 'Max Charge/Discharge Current', fr: 'Courant max de charge/décharge', ru: 'Макс. ток заряда/разряда', es: 'Corriente máxima de carga/descarga', ar: 'أقصى تيار شحن/تفريغ' },
'最大直流输入功率': { en: 'Max DC Input Power', fr: "Puissance d'entrée CC maximale", ru: 'Макс. мощность входа пост. тока', es: 'Potencia máxima de entrada CC', ar: 'أقصى طاقة دخل التيار المستمر' },
'最大直流输入电压': { en: 'Max DC Input Voltage', fr: "Tension d'entrée CC maximale", ru: 'Макс. входное напряжение пост. тока', es: 'Voltaje máximo de entrada CC', ar: 'أقصى جهد دخل التيار المستمر' },
'MPPT工作电压范围': { en: 'MPPT Operating Voltage Range', fr: 'Plage de tension MPPT', ru: 'Диапазон рабочего напряжения MPPT', es: 'Rango de voltaje de operación MPPT', ar: 'نطاق جهد تشغيل MPPT' },
'启动电压': { en: 'Starting Voltage', fr: 'Tension de démarrage', ru: 'Напряжение запуска', es: 'Voltaje de arranque', ar: 'جهد البدء' },
'最大输入电流': { en: 'Max Input Current', fr: "Courant d'entrée maximal", ru: 'Макс. входной ток', es: 'Corriente de entrada máxima', ar: 'أقصى تيار دخل' },
'MPPT路数': { en: 'Number of MPPT Trackers', fr: 'Nombre de trackers MPPT', ru: 'Кол-во трекеров MPPT', es: 'Número de rastreadores MPPT', ar: 'عدد متتبعات MPPT' },
'并网最大输出视在功率': { en: 'Max Output Apparent Power (On-Grid)', fr: 'Puissance apparente max de sortie (Réseau)', ru: 'Макс. полная выходная мощность (сеть)', es: 'Potencia aparente máxima de salida (Red)', ar: 'أقصى قدرة ظاهرية للخرج (متصل بالشبكة)' },
'额定输出频率': { en: 'Rated Output Frequency', fr: 'Fréquence de sortie nominale', ru: 'Номинальная выходная частота', es: 'Frecuencia de salida nominal', ar: 'تردد الخرج الاسمي' },
'并网最大输出电流': { en: 'Max Output Current (On-Grid)', fr: 'Courant de sortie max (Réseau)', ru: 'Макс. выходной ток (сеть)', es: 'Corriente de salida máxima (Red)', ar: 'أقصى تيار خرج (متصل بالشبكة)' },
'离网额定输出视在功率': { en: 'Rated Output Apparent Power (Off-Grid)', fr: 'Puissance apparente nominale de sortie (Hors réseau)', ru: 'Номинальная полная выходная мощность (авт.)', es: 'Potencia aparente nominal de salida (Aislada)', ar: 'القدرة الظاهرية الاسمية للخرج (خارج الشبكة)' },
'离网最大输出视在功率': { en: 'Max Output Apparent Power (Off-Grid)', fr: 'Puissance apparente max de sortie (Hors réseau)', ru: 'Макс. полная выходная мощность (авт.)', es: 'Potencia aparente máxima de salida (Aislada)', ar: 'أقصى قدرة ظاهرية للخرج (خارج الشبكة)' },
'离网最大输出电流': { en: 'Max Output Current (Off-Grid)', fr: 'Courant de sortie max (Hors réseau)', ru: 'Макс. выходной ток (авт.)', es: 'Corriente de salida máxima (Aislada)', ar: 'أقصى تيار خرج (خارج الشبكة)' },
'最大效率(MPPT)': { en: 'Max Efficiency (MPPT)', fr: 'Rendement max (MPPT)', ru: 'Макс. КПД (MPPT)', es: 'Eficiencia máxima (MPPT)', ar: 'أقصى كفاءة (MPPT)' },
'最大效率': { en: 'Max Efficiency', fr: 'Rendement maximal', ru: 'Максимальный КПД', es: 'Eficiencia máxima', ar: 'أقصى كفاءة' },
'欧洲效率': { en: 'European Efficiency', fr: 'Rendement européen', ru: 'Европейский КПД', es: 'Eficiencia europea', ar: 'الكفاءة الأوروبية' },
'存储温度': { en: 'Storage Temperature', fr: 'Température de stockage', ru: 'Температура хранения', es: 'Temperatura de almacenamiento', ar: 'درجة حرارة التخزين' },
'散热方式': { en: 'Cooling', fr: 'Refroidissement', ru: 'Охлаждение', es: 'Refrigeración', ar: 'التبريد' },
'防护等级': { en: 'Protection Class', fr: 'Indice de protection', ru: 'Класс защиты', es: 'Clase de protección', ar: 'درجة الحماية' },
'拓扑结构': { en: 'Topology', fr: 'Topologie', ru: 'Топология', es: 'Topología', ar: 'البنية الطوبولوجية' },
'安规/EMC标准': { en: 'Safety/EMC Standard', fr: 'Norme de sécurité/CEM', ru: 'Стандарт безопасности/ЭМС', es: 'Norma de seguridad/EMC', ar: 'معيار السلامة/التوافق الكهرومغناطيسي' },
'并网标准': { en: 'Grid-connected Standard', fr: 'Norme de raccordement au réseau', ru: 'Стандарт подключения к сети', es: 'Norma de conexión a red', ar: 'معيار الاتصال بالشبكة' },
'其他标准': { en: 'Other Standard', fr: 'Autre norme', ru: 'Другой стандарт', es: 'Otra norma', ar: 'معيار آخر' },
'在线式高频UPS': { en: 'Online High-Frequency UPS', fr: 'Onduleur en ligne haute fréquence', ru: 'Онлайн высокочастотный ИБП', es: 'SAI en línea de alta frecuencia', 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 (isInverterCategory(cat) && p.type === '太阳能混网逆变器') {
if (p.batteryType) rows.push({ labelKey: '电池类型', value: p.batteryType });
if (p.batteryVoltage) rows.push({ labelKey: '电池电压', value: p.batteryVoltage });
if (p.maxChargingVoltage) rows.push({ labelKey: '最大充电电压', value: p.maxChargingVoltage });
if (p.maxChargeDischargeCurrent) rows.push({ labelKey: '最大充放电电流', value: p.maxChargeDischargeCurrent });
if (p.maxDcInputPower) rows.push({ labelKey: '最大直流输入功率', value: p.maxDcInputPower });
if (p.maxDcInputVoltage) rows.push({ labelKey: '最大直流输入电压', value: p.maxDcInputVoltage });
if (p.mpptVoltageRange) rows.push({ labelKey: 'MPPT工作电压范围', value: p.mpptVoltageRange });
if (p.startingVoltage) rows.push({ labelKey: '启动电压', value: p.startingVoltage });
if (p.maxInputCurrent) rows.push({ labelKey: '最大输入电流', value: p.maxInputCurrent });
if (p.mpptNumber) rows.push({ labelKey: 'MPPT路数', value: p.mpptNumber });
if (p.onGridMaxApparentPower) rows.push({ labelKey: '并网最大输出视在功率', value: p.onGridMaxApparentPower });
if (p.outputVoltage) rows.push({ labelKey: '输出电压', value: p.outputVoltage });
if (p.ratedOutputFrequency) rows.push({ labelKey: '额定输出频率', value: p.ratedOutputFrequency });
if (p.onGridMaxOutputCurrent) rows.push({ labelKey: '并网最大输出电流', value: p.onGridMaxOutputCurrent });
if (p.outputPowerFactor) rows.push({ labelKey: '输出功率因数', value: p.outputPowerFactor });
if (p.offGridRatedApparentPower) rows.push({ labelKey: '离网额定输出视在功率', value: p.offGridRatedApparentPower });
if (p.offGridMaxApparentPower) rows.push({ labelKey: '离网最大输出视在功率', value: p.offGridMaxApparentPower });
if (p.offGridMaxOutputCurrent) rows.push({ labelKey: '离网最大输出电流', value: p.offGridMaxOutputCurrent });
if (p.efficiencyMppt) rows.push({ labelKey: '最大效率(MPPT)', value: p.efficiencyMppt });
if (p.efficiencyMax) rows.push({ labelKey: '最大效率', value: p.efficiencyMax });
if (p.efficiencyEurope) rows.push({ labelKey: '欧洲效率', value: p.efficiencyEurope });
if (p.protectionFunctions) rows.push({ labelKey: '保护功能', value: p.protectionFunctions });
if (p.operatingTemp) rows.push({ labelKey: '运行温度', value: p.operatingTemp });
if (p.storageTemp) rows.push({ labelKey: '存储温度', value: p.storageTemp });
if (p.humidity) rows.push({ labelKey: '湿度', value: p.humidity });
if (p.altitude) rows.push({ labelKey: '海拔(m)', value: p.altitude });
if (p.cooling) rows.push({ labelKey: '散热方式', value: p.cooling });
if (p.noiseLevel) rows.push({ labelKey: '噪音', value: p.noiseLevel });
if (p.netWeight) rows.push({ labelKey: '净重(kg)', value: p.netWeight });
if (p.dimensions) rows.push({ labelKey: '尺寸(mm)', value: p.dimensions });
if (p.protectionClass) rows.push({ labelKey: '防护等级', value: p.protectionClass });
if (p.topology) rows.push({ labelKey: '拓扑结构', value: p.topology });
if (p.safetyStandard) rows.push({ labelKey: '安规/EMC标准', value: p.safetyStandard });
if (p.gridStandard) rows.push({ labelKey: '并网标准', value: p.gridStandard });
if (p.otherStandard) rows.push({ labelKey: '其他标准', value: p.otherStandard });
} else if (isBatteryCategory(cat) && p.type === 'UPS配套锂电池系统') {
if (p.moduleType) rows.push({ labelKey: '模块类型', value: p.moduleType });
if (p.moduleCapacity) rows.push({ labelKey: '额定容量', value: p.moduleCapacity });
if (p.cellType) rows.push({ labelKey: '电芯类型', value: p.cellType });
if (p.moduleVoltage) rows.push({ labelKey: '额定电压', value: p.moduleVoltage });
if (p.dischargeRate) rows.push({ labelKey: '放电倍率', value: p.dischargeRate });
if (p.chargeRate) rows.push({ labelKey: '充电倍率', value: p.chargeRate });
if (p.moduleMaxEnergy) rows.push({ labelKey: '模块最大能量(kWh)', value: p.moduleMaxEnergy });
if (p.moduleOutputPower) rows.push({ labelKey: '模块输出功率(kW)', value: p.moduleOutputPower });
if (p.moduleDimensions) rows.push({ labelKey: '模块尺寸(mm)', value: p.moduleDimensions });
if (p.moduleWeight) rows.push({ labelKey: '模块重量(kg)', value: p.moduleWeight });
if (p.cabinetMaxEnergy) rows.push({ labelKey: '电池最大能量(kWh)', value: p.cabinetMaxEnergy });
if (p.cabinetRatedVoltage) rows.push({ labelKey: '额定输出电压(V)', value: p.cabinetRatedVoltage });
if (p.cycleLife) rows.push({ labelKey: '循环寿命', value: p.cycleLife });
if (p.commInterface) rows.push({ labelKey: '通信接口', value: p.commInterface });
if (p.protectionFunctions) rows.push({ labelKey: '保护功能', value: p.protectionFunctions });
if (p.operatingTemp) rows.push({ labelKey: '运行温度', value: p.operatingTemp });
if (p.humidity) rows.push({ labelKey: '湿度', value: p.humidity });
if (p.color) rows.push({ labelKey: '颜色', value: p.color });
if (p.cabinetDimensions) rows.push({ labelKey: '系统柜尺寸(mm)', value: p.cabinetDimensions });
if (p.cabinetWeight) rows.push({ labelKey: '系统柜重量(kg)', value: p.cabinetWeight });
if (p.altitude) rows.push({ labelKey: '海拔(m)', value: p.altitude });
if (p.selfDischargeRate) rows.push({ labelKey: '自放电率', value: p.selfDischargeRate });
} else 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' || p.type === '在线式高频UPS')) {
if (p.phase) rows.push({ labelKey: '相数', value: p.phase });
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.inputPowerFactor) rows.push({ labelKey: '输入功率因数', value: p.inputPowerFactor });
if (p.parallelUnits) rows.push({ labelKey: '可并联台数', value: p.parallelUnits });
if (p.outputVoltage) rows.push({ labelKey: '输出电压', value: p.outputVoltage });
if (p.outputPowerFactor) rows.push({ labelKey: '输出功率因数', value: p.outputPowerFactor });
if (p.outputVoltageRange) rows.push({ labelKey: '输出电压范围', value: p.outputVoltageRange });
if (p.frequencyRangeBatteryMode) rows.push({ labelKey: '频率范围(电池模式)', value: p.frequencyRangeBatteryMode });
if (p.currentCrestRatio) rows.push({ labelKey: '峰值电流比', value: p.currentCrestRatio });
if (p.harmonicDistortion) rows.push({ labelKey: '谐波失真', value: p.harmonicDistortion });
if (p.transferTimeAcToBattery) rows.push({ labelKey: '转换时间(市电到电池)', value: p.transferTimeAcToBattery });
if (p.transferTime) rows.push({ labelKey: '转换时间', value: p.transferTime });
if (p.waveform) rows.push({ labelKey: '波形', value: p.waveform });
if (p.overload) rows.push({ labelKey: '过载保护', value: p.overload });
if (p.efficiencyAcMode) rows.push({ labelKey: '效率(市电模式)', value: p.efficiencyAcMode });
if (p.efficiencyBatteryMode) rows.push({ labelKey: '效率(电池模式)', value: p.efficiencyBatteryMode });
if (p.batterySpec) rows.push({ labelKey: '电池型号和数量', value: p.batterySpec });
if (p.chargeCurrent) rows.push({ labelKey: '充电电流', value: p.chargeCurrent });
if (p.chargeTime) rows.push({ labelKey: '充电时间', value: p.chargeTime });
if (p.chargingVoltage) rows.push({ labelKey: '充电电压', value: p.chargingVoltage });
if (p.lcdDisplay) rows.push({ labelKey: 'LCD显示', value: p.lcdDisplay });
if (p.alarmInfo) rows.push({ labelKey: '警告提示', value: p.alarmInfo });
if (p.dimensions) rows.push({ labelKey: '尺寸(mm)', value: p.dimensions });
if (p.netWeight) rows.push({ labelKey: '净重(kg)', value: p.netWeight });
if (p.humidity) rows.push({ labelKey: '湿度', value: p.humidity });
if (p.noiseLevel) rows.push({ labelKey: '噪音', value: p.noiseLevel });
if (p.interfacePort) rows.push({ labelKey: '控制管理', value: p.interfacePort });
if (p.optionalSnmp) rows.push({ labelKey: '可选SNMP', value: p.optionalSnmp });
} 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 };
}
// 判断一段文字里有没有中文字符——用来决定"这个数值要不要走翻译":纯数字/英文的技术写法
// (比如 220V、50Hz、12V9AH*1不用碰只有真的混了中文说明文字比如"三相五线"、"非凝露"
// 这种AI从原文里保留下来的注释才需要送去翻译避免不分青红皂白地把所有数值都送去翻译、
// 反而影响本来就没问题的技术数值的准确性。
function containsChinese(str) {
return /[\u4e00-\u9fa5]/.test(String(str || ''));
}
// 机型改名:老数据存的是"标机"/"长机",新数据统一存"标准机型"/"长延时机型"——这里做一层
// 兼容转换,不管产品是什么时候建的,显示的时候都会自动变成新名字,不需要跑数据迁移。
function normalizeMachineVariant(v) {
if (v === '标机') return '标准机型';
if (v === '长机') return '长延时机型';
return v;
}
// 机型旁边固定带的英文注释,说明有没有内置电池——这个注释始终保持英文,不随导出语言翻译
// (类似行业里通用的技术标注惯例)
const MACHINE_VARIANT_NOTES = {
'标准机型': '(*with built-in battery)',
'长延时机型': '(*external battery pack required)',
};
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(normalizeMachineVariant(p.machineVariant)); }
const rows = productExportRows(p, categories);
let hasPortRow = false;
rows.forEach(r => {
if (r.labelKey === '__port__') hasPortRow = true;
else terms.add(r.labelKey);
// 数值本身如果混了中文说明文字,也要一起收集去翻译(比如"380/400/415VAC三相五线"
// 里的"三相五线"),纯数字/英文的值不会被这个判断收进来
if (containsChinese(r.value)) terms.add(r.value);
});
if (hasPortRow) terms.add('输出');
// 对客卖点/主要特点:每行一条,都是要给客户看的文字,需要翻译
const sellingPointLines = (p.sellingPoints || '').split('\n').map(l => l.trim()).filter(Boolean);
if (sellingPointLines.length > 0) terms.add('主要特点');
sellingPointLines.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)"
// 机架式UPS真双变换在线式专属字段
phase: '', // 相数,如 "1-phase input/1-phase output"
inputPowerFactor: '', // 输入功率因数,如 "≥0.99@100% load"
transferTimeAcToBattery: '', // 转换时间(市电到电池),如 "0ms"
currentCrestRatio: '', // 峰值电流比,如 "3:1"
harmonicDistortion: '', // 谐波失真,如 "≤3% THD (Linear Load), ≤5% THD (Non-linear Load)"
efficiencyAcMode: '', // 效率(市电模式),如 "88%"
efficiencyBatteryMode: '', // 效率(电池模式),如 "83%"
chargingVoltage: '', // 充电电压,如 "27.4VDC±1%"
optionalSnmp: '', // 可选SNMP如 "Power management from SNMP manager and web browser"
// 在线式高频UPS专属字段在机架式UPS那套字段基础上再加这3个
outputPowerFactor: '', // 输出功率因数,如 "0.9" 或 "1"
parallelUnits: '', // 可并联台数,如 "Not supported" 或具体数字
overload: '', // 过载保护,如 "105%-125% 6-25s; >150% 200ms"
// UPS配套锂电池系统专属字段储能电池大类——模块级参数
moduleType: '', // 模块类型,如 "长延时型"
moduleCapacity: '', // 模块额定容量(Ah),如 "100"
cellType: '', // 电芯类型,如 "磷酸铁锂电池LFP"
moduleVoltage: '', // 模块额定电压(V),如 "51.2"
dischargeRate: '', // 放电倍率(C),如 "2"
chargeRate: '', // 充电倍率(C),如 "1"
moduleMaxEnergy: '', // 模块最大能量(kWh),如 "5.12"
moduleOutputPower: '', // 模块输出功率(kW),如 "10.24"
moduleDimensions: '', // 模块尺寸(mm),如 "440*440*131"
moduleWeight: '', // 模块重量(kg),如 "43±2"
// UPS配套锂电池系统专属字段——电池系统柜级参数
cabinetMaxEnergy: '', // 电池最大能量(kWh),如 "40.96"
cabinetRatedVoltage: '', // 额定输出电压(V),如 "409.6/±204.8/460.8/512/±256/563.2/614.4/±307.2"
cycleLife: '', // 循环寿命,如 "5000次@50%DOD"
commInterface: '', // 通信接口,如 "LAN/CAN/RS485/干接点"
protectionFunctions: '', // 保护功能,如 "过充、过放、过温、过流、短路等"
operatingTemp: '', // 运行温度,如 "0~40℃(推荐20~25℃运行)"
color: '', // 颜色,如 "RAL9004"
cabinetDimensions: '', // 系统柜尺寸(mm),如 "600×850×2000"
cabinetWeight: '', // 系统柜重量(kg),如 "544±108模组配置"
altitude: '', // 海拔(m),如 "≤4000>2000降额使用"
selfDischargeRate: '', // 自放电率,如 "<5%环境温度030℃/30天"
// 太阳能混网逆变器专属字段光伏逆变器大类——电池输入、PV输入、AC输出(并网/离网)、效率、标准
batteryType: '', // 电池类型,如 "Lithium or lead acid battery"
maxChargingVoltage: '', // 最大充电电压,如 "≤60 (Configurable)"
maxChargeDischargeCurrent: '', // 最大充放电电流,如 "190A"
maxDcInputPower: '', // 最大直流输入功率,如 "16000W"
maxDcInputVoltage: '', // 最大直流输入电压,如 "1000V"
mpptVoltageRange: '', // MPPT工作电压范围如 "200~800V"
startingVoltage: '', // 启动电压,如 "150V"
maxInputCurrent: '', // 最大输入电流,如 "18/18A"
mpptNumber: '', // MPPT路数如 "2"
onGridMaxApparentPower: '', // 并网最大输出视在功率,如 "8800W"
ratedOutputFrequency: '', // 额定输出频率,如 "50/60Hz"
onGridMaxOutputCurrent: '', // 并网最大输出电流,如 "13.3A"
offGridRatedApparentPower: '', // 离网额定输出视在功率,如 "8000W"
offGridMaxApparentPower: '', // 离网最大输出视在功率,如 ">200%,15sec"
offGridMaxOutputCurrent: '', // 离网最大输出电流,如 "13.3A"
efficiencyMppt: '', // 最大效率(MPPT),如 "98%"
efficiencyMax: '', // 最大效率,如 "94.5%"
efficiencyEurope: '', // 欧洲效率,如 "97.5%"
storageTemp: '', // 存储温度,如 "-30~65°C"
cooling: '', // 散热方式,如 "Intelligent forced air cooling"
protectionClass: '', // 防护等级,如 "IP66"
topology: '', // 拓扑结构,如 "HF isolation(Battery side)"
safetyStandard: '', // 安规/EMC标准
gridStandard: '', // 并网标准
otherStandard: '', // 其他标准
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 isBatteryCategory(cat) {
return !!cat && (cat.id === 'battery' || cat.name === '储能电池');
}
function isInverterCategory(cat) {
return !!cat && (cat.id === 'inverter' || cat.name === '光伏逆变器');
}
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 (isInverterCategory(cat) && p.type === '太阳能混网逆变器') {
const items = [];
if (p.batteryType) items.push({ key: 'batterytype', label: '电池类型', value: p.batteryType });
if (p.batteryVoltage) items.push({ key: 'batteryvoltage', label: '电池电压', value: p.batteryVoltage });
if (p.maxChargingVoltage) items.push({ key: 'maxchargingvoltage', label: '最大充电电压', value: p.maxChargingVoltage });
if (p.maxChargeDischargeCurrent) items.push({ key: 'maxchargedischarge', label: '最大充放电电流', value: p.maxChargeDischargeCurrent });
if (p.maxDcInputPower) items.push({ key: 'maxdcpower', label: '最大直流输入功率', value: p.maxDcInputPower });
if (p.maxDcInputVoltage) items.push({ key: 'maxdcvoltage', label: '最大直流输入电压', value: p.maxDcInputVoltage });
if (p.mpptVoltageRange) items.push({ key: 'mpptrange', label: 'MPPT工作电压范围', value: p.mpptVoltageRange });
if (p.startingVoltage) items.push({ key: 'startvoltage', label: '启动电压', value: p.startingVoltage });
if (p.maxInputCurrent) items.push({ key: 'maxinputcurrent', label: '最大输入电流', value: p.maxInputCurrent });
if (p.mpptNumber) items.push({ key: 'mpptnum', label: 'MPPT路数', value: p.mpptNumber });
if (p.onGridMaxApparentPower) items.push({ key: 'ongridpower', label: '并网最大输出视在功率', value: p.onGridMaxApparentPower });
if (p.outputVoltage) items.push({ key: 'outputvoltage', label: '输出电压', value: p.outputVoltage });
if (p.ratedOutputFrequency) items.push({ key: 'outputfreq', label: '额定输出频率', value: p.ratedOutputFrequency });
if (p.onGridMaxOutputCurrent) items.push({ key: 'ongridcurrent', label: '并网最大输出电流', value: p.onGridMaxOutputCurrent });
if (p.outputPowerFactor) items.push({ key: 'outputpf', label: '输出功率因数', value: p.outputPowerFactor });
if (p.offGridRatedApparentPower) items.push({ key: 'offgridrated', label: '离网额定输出视在功率', value: p.offGridRatedApparentPower });
if (p.offGridMaxApparentPower) items.push({ key: 'offgridmax', label: '离网最大输出视在功率', value: p.offGridMaxApparentPower });
if (p.offGridMaxOutputCurrent) items.push({ key: 'offgridcurrent', label: '离网最大输出电流', value: p.offGridMaxOutputCurrent });
if (p.efficiencyMppt) items.push({ key: 'effmppt', label: '最大效率(MPPT)', value: p.efficiencyMppt });
if (p.efficiencyMax) items.push({ key: 'effmax', label: '最大效率', value: p.efficiencyMax });
if (p.efficiencyEurope) items.push({ key: 'effeurope', label: '欧洲效率', value: p.efficiencyEurope });
if (p.protectionFunctions) items.push({ key: 'protectionfunc', label: '保护功能', value: p.protectionFunctions });
if (p.operatingTemp) items.push({ key: 'operatingtemp', label: '运行温度', value: p.operatingTemp });
if (p.storageTemp) items.push({ key: 'storagetemp', label: '存储温度', value: p.storageTemp });
if (p.humidity) items.push({ key: 'humidity', label: '湿度', value: p.humidity });
if (p.altitude) items.push({ key: 'altitude', label: '海拔(m)', value: p.altitude });
if (p.cooling) items.push({ key: 'cooling', label: '散热方式', value: p.cooling });
if (p.noiseLevel) items.push({ key: 'noiselevel', label: '噪音', value: p.noiseLevel });
if (p.netWeight) items.push({ key: 'weight', label: '净重(kg)', value: p.netWeight });
if (p.dimensions) items.push({ key: 'dim', label: '尺寸(mm)', value: p.dimensions });
if (p.protectionClass) items.push({ key: 'protectionclass', label: '防护等级', value: p.protectionClass });
if (p.topology) items.push({ key: 'topology', label: '拓扑结构', value: p.topology });
if (p.safetyStandard) items.push({ key: 'safetystd', label: '安规/EMC标准', value: p.safetyStandard });
if (p.gridStandard) items.push({ key: 'gridstd', label: '并网标准', value: p.gridStandard });
if (p.otherStandard) items.push({ key: 'otherstd', label: '其他标准', value: p.otherStandard });
return items;
}
if (isBatteryCategory(cat) && p.type === 'UPS配套锂电池系统') {
const items = [];
if (p.moduleType) items.push({ key: 'moduletype', label: '模块类型', value: p.moduleType });
if (p.moduleCapacity) items.push({ key: 'modulecap', label: '额定容量', value: p.moduleCapacity });
if (p.cellType) items.push({ key: 'celltype', label: '电芯类型', value: p.cellType });
if (p.moduleVoltage) items.push({ key: 'modulevoltage', label: '额定电压', value: p.moduleVoltage });
if (p.dischargeRate) items.push({ key: 'dischargerate', label: '放电倍率', value: p.dischargeRate });
if (p.chargeRate) items.push({ key: 'chargerate', label: '充电倍率', value: p.chargeRate });
if (p.moduleMaxEnergy) items.push({ key: 'modulemaxenergy', label: '模块最大能量(kWh)', value: p.moduleMaxEnergy });
if (p.moduleOutputPower) items.push({ key: 'moduleoutputpower', label: '模块输出功率(kW)', value: p.moduleOutputPower });
if (p.moduleDimensions) items.push({ key: 'moduledim', label: '模块尺寸(mm)', value: p.moduleDimensions });
if (p.moduleWeight) items.push({ key: 'moduleweight', label: '模块重量(kg)', value: p.moduleWeight });
if (p.cabinetMaxEnergy) items.push({ key: 'cabinetmaxenergy', label: '电池最大能量(kWh)', value: p.cabinetMaxEnergy });
if (p.cabinetRatedVoltage) items.push({ key: 'cabinetvoltage', label: '额定输出电压(V)', value: p.cabinetRatedVoltage });
if (p.cycleLife) items.push({ key: 'cyclelife', label: '循环寿命', value: p.cycleLife });
if (p.commInterface) items.push({ key: 'comminterface', label: '通信接口', value: p.commInterface });
if (p.protectionFunctions) items.push({ key: 'protectionfunc', label: '保护功能', value: p.protectionFunctions });
if (p.operatingTemp) items.push({ key: 'operatingtemp', label: '运行温度', value: p.operatingTemp });
if (p.humidity) items.push({ key: 'humidity', label: '湿度', value: p.humidity });
if (p.color) items.push({ key: 'color', label: '颜色', value: p.color });
if (p.cabinetDimensions) items.push({ key: 'cabinetdim', label: '系统柜尺寸(mm)', value: p.cabinetDimensions });
if (p.cabinetWeight) items.push({ key: 'cabinetweight', label: '系统柜重量(kg)', value: p.cabinetWeight });
if (p.altitude) items.push({ key: 'altitude', label: '海拔(m)', value: p.altitude });
if (p.selfDischargeRate) items.push({ key: 'selfdischarge', label: '自放电率', value: p.selfDischargeRate });
return items;
}
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' || p.type === '在线式高频UPS')) {
const items = [];
if (p.phase) items.push({ key: 'phase', label: '相数', value: p.phase });
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.inputPowerFactor) items.push({ key: 'inputpf', label: '输入功率因数', value: p.inputPowerFactor });
if (p.parallelUnits) items.push({ key: 'parallel', label: '可并联台数', value: p.parallelUnits });
if (p.outputVoltage) items.push({ key: 'outputvoltage', label: '输出电压', value: p.outputVoltage });
if (p.outputPowerFactor) items.push({ key: 'outputpf', label: '输出功率因数', value: p.outputPowerFactor });
if (p.outputVoltageRange) items.push({ key: 'outputvoltagerange', label: '输出电压范围', value: p.outputVoltageRange });
if (p.frequencyRangeBatteryMode) items.push({ key: 'freqrange', label: '频率范围(电池模式)', value: p.frequencyRangeBatteryMode });
if (p.currentCrestRatio) items.push({ key: 'crestratio', label: '峰值电流比', value: p.currentCrestRatio });
if (p.harmonicDistortion) items.push({ key: 'thd', label: '谐波失真', value: p.harmonicDistortion });
if (p.transferTimeAcToBattery) items.push({ key: 'transferacbat', label: '转换时间(市电到电池)', value: p.transferTimeAcToBattery });
if (p.transferTime) items.push({ key: 'transfertime', label: '转换时间', value: p.transferTime });
if (p.waveform) items.push({ key: 'waveform', label: '波形', value: p.waveform });
if (p.overload) items.push({ key: 'overload', label: '过载保护', value: p.overload });
if (p.efficiencyAcMode) items.push({ key: 'effac', label: '效率(市电模式)', value: p.efficiencyAcMode });
if (p.efficiencyBatteryMode) items.push({ key: 'effbat', label: '效率(电池模式)', value: p.efficiencyBatteryMode });
if (p.batterySpec) items.push({ key: 'batteryspec', label: '电池型号和数量', value: p.batterySpec });
if (p.chargeCurrent) items.push({ key: 'chargecurrent', label: '充电电流', value: p.chargeCurrent });
if (p.chargeTime) items.push({ key: 'chargetime', label: '充电时间', value: p.chargeTime });
if (p.chargingVoltage) items.push({ key: 'chargingvoltage', label: '充电电压', value: p.chargingVoltage });
if (p.lcdDisplay) items.push({ key: 'lcddisplay', label: 'LCD显示', value: p.lcdDisplay });
if (p.alarmInfo) items.push({ key: 'alarminfo', label: '警告提示', value: p.alarmInfo });
if (p.dimensions) items.push({ key: 'dim', label: '尺寸(mm)', value: p.dimensions });
if (p.netWeight) items.push({ key: 'weight', label: '净重(kg)', value: p.netWeight });
if (p.humidity) items.push({ key: 'humidity', label: '湿度', value: p.humidity });
if (p.noiseLevel) items.push({ key: 'noiselevel', label: '噪音', value: p.noiseLevel });
if (p.interfacePort) items.push({ key: 'interfaceport', label: '控制管理', value: p.interfacePort });
if (p.optionalSnmp) items.push({ key: 'snmp', label: '可选SNMP', value: p.optionalSnmp });
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 => !['标机', '长机', '标准机型', '长延时机型'].includes(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) {}
let jmsLines = [];
try {
const jl = await storageGet(STORAGE_KEYS.jmsLines);
if (jl && jl.value) jmsLines = JSON.parse(jl.value);
} catch (e) {}
return { customers, products, settings, quotes, sellerProfiles, certifications, productCategories, packingLists, catalogDocs, jmsLines };
}
/* -------------------------------- styles -------------------------------- */
const Styles = () => (
<style>{`
@import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;600;700&family=IBM+Plex+Mono:wght@400;500&family=Inter:wght@400;500;600&display=swap');
.crm-root {
--bg: #EDF1F4;
--panel: #FFFFFF;
--ink: #18233A;
--ink-soft: #5B6B82;
--line: #D9E1E8;
--copper: #B5651D;
--copper-soft: #F1E0CC;
--blue: #3D6E99;
--led-green: #2F8F5B;
--led-amber: #C9842E;
--led-red: #C2403A;
--led-green-bg: #E4F3EA;
--led-amber-bg: #FAEFDD;
--led-red-bg: #FAE5E3;
--led-blue-bg: #E4ECF2;
font-family: 'Inter', system-ui, -apple-system, sans-serif;
color: var(--ink);
background:
linear-gradient(var(--bg), var(--bg)),
repeating-linear-gradient(0deg, rgba(61,110,153,0.05) 0 1px, transparent 1px 28px),
repeating-linear-gradient(90deg, rgba(61,110,153,0.05) 0 1px, transparent 1px 28px);
min-height: 100vh;
width: 100%;
}
.crm-display { font-family: 'Space Grotesk', 'Inter', sans-serif; }
.crm-mono { font-family: 'IBM Plex Mono', ui-monospace, monospace; }
.crm-header {
display: flex; align-items: center; justify-content: space-between;
padding: 18px 24px; border-bottom: 1px solid var(--line);
background: var(--panel);
}
.crm-title { font-size: 19px; font-weight: 700; letter-spacing: -0.01em; }
.crm-subtitle { font-size: 12.5px; color: var(--ink-soft); margin-top: 2px; }
.crm-tabs { display: flex; gap: 4px; padding: 10px 24px 0; background: var(--panel); border-bottom: 1px solid var(--line); }
.crm-tab {
display: flex; align-items: center; gap: 6px;
padding: 9px 14px; font-size: 13.5px; font-weight: 600; color: var(--ink-soft);
border: none; background: transparent; cursor: pointer;
border-bottom: 2px solid transparent; margin-bottom: -1px;
}
.crm-tab.active { color: var(--copper); border-bottom-color: var(--copper); }
.crm-tab:hover:not(.active) { color: var(--ink); }
.crm-body { padding: 22px 24px 60px; max-width: 1180px; margin: 0 auto; }
.stat-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin-bottom: 22px; }
.stat-card {
background: var(--panel); border: 1px solid var(--line); border-radius: 10px;
padding: 14px 16px; display: flex; align-items: center; gap: 12px;
}
.stat-num { font-family: 'IBM Plex Mono', monospace; font-size: 26px; font-weight: 500; line-height: 1; }
.stat-label { font-size: 11.5px; color: var(--ink-soft); margin-top: 3px; }
.led-dot { width: 9px; height: 9px; border-radius: 50%; flex-shrink: 0; display: inline-block; }
.led-dot.green { background: var(--led-green); box-shadow: 0 0 0 3px var(--led-green-bg); }
.led-dot.amber { background: var(--led-amber); box-shadow: 0 0 0 3px var(--led-amber-bg); }
.led-dot.red { background: var(--led-red); box-shadow: 0 0 0 3px var(--led-red-bg); }
.led-dot.blue { background: var(--blue); box-shadow: 0 0 0 3px var(--led-blue-bg); }
@media (prefers-reduced-motion: no-preference) {
.led-dot.red.pulse { animation: led-pulse 1.8s ease-in-out infinite; }
}
@keyframes led-pulse { 0%,100% { box-shadow: 0 0 0 3px var(--led-red-bg); } 50% { box-shadow: 0 0 0 5px var(--led-red-bg); } }
.panel { background: var(--panel); border: 1px solid var(--line); border-radius: 10px; }
.panel-header { padding: 14px 18px; border-bottom: 1px solid var(--line); display: flex; align-items: center; justify-content: space-between; }
.panel-title { font-size: 14px; font-weight: 700; display: flex; align-items: center; gap: 8px; }
.panel-body { padding: 6px 0; }
.section-gap { margin-bottom: 18px; }
.row { display: flex; align-items: center; gap: 12px; padding: 12px 18px; border-bottom: 1px solid var(--line); }
.row:last-child { border-bottom: none; }
.row:hover { background: #FAFBFC; }
.row-main { flex: 1; min-width: 0; }
.row-title { font-size: 13.5px; font-weight: 600; }
.row-sub { font-size: 12px; color: var(--ink-soft); margin-top: 2px; font-family: 'IBM Plex Mono', monospace; }
.btn {
display: inline-flex; align-items: center; gap: 6px;
font-size: 12.5px; font-weight: 600; padding: 7px 12px; border-radius: 7px;
border: 1px solid var(--line); background: var(--panel); color: var(--ink); cursor: pointer;
white-space: nowrap;
}
.btn:hover { background: #F4F6F8; }
.btn:disabled { opacity: 0.45; cursor: not-allowed; }
.btn-primary { background: var(--copper); border-color: var(--copper); color: white; }
.btn-primary:hover { background: #9c5418; }
.btn-ghost { border-color: transparent; background: transparent; }
.btn-danger { color: var(--led-red); }
.btn-sm { padding: 5px 9px; font-size: 11.5px; }
.empty-state { text-align: center; padding: 50px 20px; color: var(--ink-soft); }
.empty-state svg { margin: 0 auto 10px; opacity: 0.5; }
.field-label { font-size: 11.5px; font-weight: 600; color: var(--ink-soft); margin-bottom: 5px; display: block; text-transform: uppercase; letter-spacing: 0.03em; }
.field { margin-bottom: 14px; }
.input, .select, .textarea {
width: 100%; font-size: 13.5px; padding: 8px 10px; border: 1px solid var(--line);
border-radius: 7px; background: white; color: var(--ink); font-family: inherit;
}
.input:focus, .select:focus, .textarea:focus { outline: 2px solid var(--blue); outline-offset: 0; border-color: var(--blue); }
.textarea { resize: vertical; min-height: 70px; font-family: 'IBM Plex Mono', monospace; font-size: 12.5px; line-height: 1.6; }
.checkbox-row { display: flex; flex-wrap: wrap; gap: 10px; }
.checkbox-pill {
display: flex; align-items: center; gap: 6px; font-size: 12px; padding: 5px 10px;
border: 1px solid var(--line); border-radius: 16px; cursor: pointer; user-select: none;
}
.checkbox-pill.checked { background: var(--copper-soft); border-color: var(--copper); }
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.crm-layout { display: grid; grid-template-columns: 320px 1fr; gap: 16px; align-items: start; }
@media (max-width: 760px) {
.crm-layout { grid-template-columns: 1fr; }
.stat-row { grid-template-columns: repeat(2, 1fr); }
.grid-2 { grid-template-columns: 1fr; }
}
.search-box { display: flex; align-items: center; gap: 8px; padding: 9px 12px; border: 1px solid var(--line); border-radius: 8px; background: white; margin-bottom: 10px; }
.search-box input { border: none; outline: none; font-size: 13px; flex: 1; }
/* ---------- Just My Socks ---------- */
.page-head-row { display: flex; align-items: flex-end; justify-content: space-between; gap: 20px; margin-bottom: 20px; flex-wrap: wrap; }
.jms-stats-row { display: flex; gap: 12px; margin-bottom: 22px; flex-wrap: wrap; }
.stat-chip { background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: 12px 18px; display: flex; flex-direction: column; gap: 2px; min-width: 100px; }
.stat-num { font-family: 'Space Grotesk', sans-serif; font-size: 22px; font-weight: 700; }
.stat-lbl { font-size: 11.5px; color: var(--ink-soft); }
.jms-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 16px; }
.jms-card { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 16px 16px 14px; display: flex; flex-direction: column; gap: 12px; }
.jms-card-top { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
.jms-card-title { display: flex; align-items: center; gap: 8px; min-width: 0; }
.jms-card-name { font-weight: 700; font-size: 14px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.jms-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
.jms-dot-normal { background: var(--led-green); box-shadow: 0 0 0 3px var(--led-green-bg); }
.jms-dot-abnormal { background: var(--led-red); box-shadow: 0 0 0 3px var(--led-red-bg); animation: jms-pulse-red 1.6s ease-in-out infinite; }
.jms-dot-checking { background: var(--led-amber); box-shadow: 0 0 0 3px var(--led-amber-bg); animation: jms-pulse-amber 1s ease-in-out infinite; }
@keyframes jms-pulse-red { 0%,100% { opacity: 1; } 50% { opacity: 0.35; } }
@keyframes jms-pulse-amber { 0%,100% { opacity: 1; } 50% { opacity: 0.4; } }
.jms-proto-chip { font-size: 10px; font-weight: 700; letter-spacing: 0.03em; padding: 3px 9px; border-radius: 100px; text-transform: uppercase; flex-shrink: 0; }
.jms-proto-ss { background: var(--led-green-bg); color: var(--led-green); }
.jms-proto-vmess { background: var(--led-blue-bg); color: var(--blue); }
.jms-proto-vless-cdn { background: var(--copper-soft); color: var(--copper); }
.jms-proto-vless-reality { background: var(--led-red-bg); color: var(--led-red); }
.jms-monitor-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; font-size: 11.5px; color: var(--ink-soft); }
.jms-status-abnormal { color: var(--led-red); font-weight: 600; }
.jms-check-btn { background: none; border: none; color: var(--copper); font-size: 11.5px; font-weight: 600; cursor: pointer; padding: 2px 4px; font-family: inherit; }
.jms-check-btn:hover { text-decoration: underline; }
.jms-check-btn:disabled { color: var(--ink-soft); cursor: default; text-decoration: none; }
.jms-check-note { font-size: 10.5px; color: var(--ink-soft); background: var(--bg); border: 1px dashed var(--line); border-radius: 6px; padding: 6px 8px; }
.jms-terminal { background: var(--ink); border-radius: 9px; padding: 12px 14px; font-family: 'IBM Plex Mono', monospace; font-size: 12px; line-height: 1.85; }
.jms-terminal-row { display: flex; gap: 10px; align-items: baseline; }
.jms-terminal-key { color: rgba(255,255,255,0.5); flex-shrink: 0; width: 56px; }
.jms-terminal-val { color: #F0EDE6; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.jms-terminal-val.jms-ip { color: #8FD19E; }
.jms-terminal-val.jms-ip-pending { color: rgba(255,255,255,0.5); font-style: italic; }
.jms-refresh-btn { margin-left: auto; background: none; border: none; color: rgba(255,255,255,0.5); cursor: pointer; padding: 2px; display: flex; border-radius: 4px; flex-shrink: 0; }
.jms-refresh-btn:hover { color: #fff; background: rgba(255,255,255,0.1); }
.jms-spin { animation: jms-spin .8s linear infinite; }
@keyframes jms-spin { to { transform: rotate(360deg); } }
.jms-card-actions { display: flex; gap: 8px; flex-wrap: wrap; }
.jms-card-actions .btn { flex: 1; justify-content: center; }
.jms-group-select { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.jms-group-opt { border: 1.5px solid var(--line); border-radius: 9px; padding: 10px 8px; text-align: center; font-size: 12.5px; font-weight: 700; cursor: pointer; color: var(--ink-soft); }
.jms-group-selected.jms-group-selfbuilt { border-color: var(--copper); background: var(--copper-soft); color: var(--copper); }
.jms-group-selected.jms-group-jms { border-color: var(--blue); background: var(--led-blue-bg); color: var(--blue); }
.jms-proto-select { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
.jms-proto-opt { border: 1.5px solid var(--line); border-radius: 9px; padding: 9px 4px; text-align: center; font-size: 11px; font-weight: 700; cursor: pointer; letter-spacing: 0.02em; text-transform: uppercase; color: var(--ink-soft); }
.jms-proto-selected.jms-proto-opt-ss { border-color: var(--led-green); background: var(--led-green-bg); color: var(--led-green); }
.jms-proto-selected.jms-proto-opt-vmess { border-color: var(--blue); background: var(--led-blue-bg); color: var(--blue); }
.jms-proto-selected.jms-proto-opt-vless-reality { border-color: var(--led-red); background: var(--led-red-bg); color: var(--led-red); }
.jms-qr-link { font-family: 'IBM Plex Mono', monospace; font-size: 11px; word-break: break-all; color: var(--ink-soft); background: var(--bg); padding: 10px; border-radius: 8px; max-height: 90px; overflow-y: auto; text-align: left; margin: 12px 0; }
.modal-overlay {
position: fixed; inset: 0; background: rgba(24,35,58,0.45);
display: flex; align-items: center; justify-content: center; z-index: 50; padding: 20px;
}
.modal-box { background: white; border-radius: 12px; max-width: 520px; width: 100%; max-height: 86vh; overflow-y: auto; }
.modal-head { padding: 16px 20px; border-bottom: 1px solid var(--line); display: flex; align-items: center; justify-content: space-between; }
.modal-body { padding: 18px 20px; }
.modal-foot { padding: 14px 20px; border-top: 1px solid var(--line); display: flex; justify-content: flex-end; gap: 8px; }
.toast {
position: fixed; bottom: 22px; left: 50%; transform: translateX(-50%);
background: var(--ink); color: white; padding: 9px 16px; border-radius: 8px;
font-size: 12.5px; z-index: 60; box-shadow: 0 6px 18px rgba(0,0,0,0.2);
}
.badge { font-size: 10.5px; padding: 2px 7px; border-radius: 10px; font-weight: 600; }
.badge-region { background: #EAF1F6; color: var(--blue); }
.disclaimer { font-size: 12px; color: var(--ink-soft); background: var(--copper-soft); border-radius: 8px; padding: 10px 12px; margin-bottom: 14px; }
/* ---------- shell / sidebar ---------- */
.crm-shell { display: flex; min-height: 100vh; }
.crm-sidebar {
width: 226px; flex-shrink: 0; background: var(--panel); border-right: 1px solid var(--line);
display: flex; flex-direction: column;
}
.crm-sidebar-brand { padding: 18px 16px; border-bottom: 1px solid var(--line); }
.crm-sidebar-scroll { flex: 1; overflow-y: auto; padding-bottom: 10px; }
.crm-sidebar-group-label {
font-size: 10.5px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em;
color: var(--ink-soft); padding: 16px 16px 6px;
}
.crm-sidebar-item {
display: flex; align-items: center; gap: 9px; padding: 9px 16px; font-size: 13.5px; font-weight: 600;
color: var(--ink-soft); cursor: pointer; border-left: 3px solid transparent; user-select: none;
}
.crm-sidebar-item.active { color: var(--copper); background: var(--copper-soft); border-left-color: var(--copper); }
.crm-sidebar-item:hover:not(.active) { background: #F4F6F8; }
.crm-sidebar-footer { border-top: 1px solid var(--line); padding: 12px 16px; }
.crm-sidebar-user { font-size: 12px; color: var(--ink-soft); margin-bottom: 8px; display: flex; align-items: center; gap: 6px; }
.crm-main { flex: 1; min-width: 0; }
.crm-main-body { padding: 22px 24px 60px; max-width: 1180px; margin: 0 auto; }
.crm-topbar-mobile { display: none; align-items: center; gap: 10px; padding: 12px 16px; background: var(--panel); border-bottom: 1px solid var(--line); position: sticky; top: 0; z-index: 20; }
.crm-sidebar-overlay { display: none; }
@media (max-width: 880px) {
.crm-topbar-mobile { display: flex; }
.crm-sidebar {
position: fixed; top: 0; left: -260px; height: 100vh; width: 240px; z-index: 40;
transition: left 0.2s ease; box-shadow: 4px 0 24px rgba(0,0,0,0.18);
}
.crm-sidebar.open { left: 0; }
.crm-sidebar-overlay.show { display: block; position: fixed; inset: 0; background: rgba(18,25,40,0.4); z-index: 35; }
}
/* ---------- drag and drop ---------- */
.drag-handle { cursor: grab; color: var(--ink-soft); flex-shrink: 0; touch-action: none; }
.drag-handle:active { cursor: grabbing; }
.draggable-row.drag-over { outline: 2px dashed var(--blue); outline-offset: 2px; background: #F0F6FA; }
/* ---------- card grid (monitor / catalog) ---------- */
.card-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); gap: 12px; }
.monitor-card {
background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: 14px;
display: flex; flex-direction: column; gap: 8px;
}
.monitor-card-head { display: flex; align-items: center; gap: 8px; }
.monitor-card-title { font-size: 13.5px; font-weight: 700; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* ---------- dropzone ---------- */
.dropzone {
border: 1.5px dashed var(--line); border-radius: 8px; padding: 14px; text-align: center;
font-size: 12px; color: var(--ink-soft); flex: 1 1 160px; cursor: pointer; transition: border-color 0.15s, background 0.15s;
}
.dropzone.drag-active { border-color: var(--blue); background: #F0F6FA; color: var(--blue); }
/* ---------- certification gallery ---------- */
.cert-card {
background: var(--panel); border: 1px solid var(--line); border-radius: 10px;
overflow: hidden; display: flex; flex-direction: column;
}
.cert-card-img-wrap {
aspect-ratio: 3 / 4; background: #F4F6F8; position: relative; overflow: hidden;
display: flex; align-items: center; justify-content: center;
}
.cert-card-img-wrap img { width: 100%; height: 100%; object-fit: cover; display: block; }
.cert-card-delete {
position: absolute; top: 6px; right: 6px; padding: 5px 6px;
background: rgba(255,255,255,0.92); border-color: var(--line);
}
.cert-card-brand { padding: 10px 12px; text-align: center; }
.cert-card-brand-text { font-size: 13px; font-weight: 700; cursor: pointer; word-break: break-word; }
.cert-card-brand-text.empty { font-weight: 400; color: var(--ink-soft); }
.cert-card-brand input { text-align: center; font-weight: 700; }
/* ---------- catalog docs (PDF gallery: upload progress, actions, dnd reorder) ---------- */
.catalog-card-grid { grid-template-columns: repeat(auto-fill, minmax(170px, 1fr)); }
.catalog-doc-actions { position: absolute; top: 6px; right: 6px; display: flex; gap: 4px; }
.catalog-doc-actions .btn { padding: 5px 6px; background: rgba(255,255,255,0.92); border-color: var(--line); }
.catalog-doc-img-wrap { aspect-ratio: unset; height: 92px; }
.catalog-doc-link {
display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 6px;
width: 100%; height: 100%; padding: 10px; text-align: center; color: inherit; text-decoration: none;
}
.catalog-doc-filename { font-size: 10.5px; color: var(--ink-soft); word-break: break-word; line-height: 1.3; }
.catalog-doc-uploading {
display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 6px;
width: 100%; height: 100%; color: var(--ink-soft); font-size: 11px; padding: 10px; text-align: center;
}
.catalog-doc-spinner {
width: 20px; height: 20px; border-radius: 50%; border: 3px solid var(--line); border-top-color: var(--copper);
animation: catalog-doc-spin 0.8s linear infinite;
}
@keyframes catalog-doc-spin { to { transform: rotate(360deg); } }
.catalog-dropzone {
border-radius: 12px; transition: background 0.15s;
}
.catalog-dropzone.drag-active { outline: 2px dashed var(--blue); outline-offset: 6px; background: #F0F6FA; }
.catalog-doc-card { cursor: grab; }
.catalog-doc-card.dragging { opacity: 0.5; }
.catalog-doc-meta { font-size: 10.5px; color: var(--ink-soft); margin-top: 3px; text-align: center; word-break: break-word; }
.catalog-doc-edit-fields { display: flex; flex-direction: column; gap: 6px; }
.catalog-doc-edit-fields select { font-size: 11.5px; padding: 5px 8px; }
.catalog-filter-row { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; flex-wrap: wrap; }
.catalog-filter-row .select { width: auto; min-width: 160px; }
.field-label-row { display: flex; align-items: center; justify-content: space-between; }
.field-manage-link {
font-size: 11.5px; font-weight: 600; color: var(--blue); cursor: pointer;
display: inline-flex; align-items: center; gap: 3px; text-transform: none; letter-spacing: 0;
}
.field-manage-link:hover { text-decoration: underline; }
.tax-manage-list-row {
display: flex; align-items: center; justify-content: space-between; padding: 9px 12px;
border: 1px solid var(--line); border-radius: 8px; margin-bottom: 8px;
}
/* ---------- product taxonomy (groups view: brand / type / category management) ---------- */
.tax-row { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
.tax-section-label { font-size: 11px; font-weight: 600; color: var(--ink-soft); text-transform: uppercase; letter-spacing: 0.03em; margin-right: 2px; flex-shrink: 0; }
.tax-pill {
display: inline-flex; align-items: center; gap: 5px; font-size: 11.5px; font-weight: 600;
padding: 4px 6px 4px 10px; border-radius: 14px; background: var(--copper-soft); color: var(--copper);
white-space: nowrap;
}
.tax-pill-x { display: inline-flex; cursor: pointer; opacity: 0.55; border-radius: 50%; padding: 2px; }
.tax-pill-x:hover { opacity: 1; background: rgba(0,0,0,0.06); }
.tax-add-btn {
display: inline-flex; align-items: center; justify-content: center; width: 22px; height: 22px;
border-radius: 50%; border: 1.5px dashed var(--line); background: transparent; color: var(--ink-soft);
cursor: pointer; flex-shrink: 0;
}
.tax-add-btn:hover { border-color: var(--blue); color: var(--blue); }
.tax-add-input {
font-size: 11.5px; padding: 4px 10px; border: 1px solid var(--blue); border-radius: 14px;
width: 108px; font-family: inherit;
}
.tax-types-row { padding: 10px 18px; border-bottom: 1px solid var(--line); }
.tax-add-category {
border: 1.5px dashed var(--line); border-radius: 10px; padding: 16px; text-align: center;
color: var(--ink-soft); font-size: 13px; cursor: pointer; transition: border-color 0.15s, color 0.15s;
}
.tax-add-category:hover { border-color: var(--blue); color: var(--blue); }
.catalog-view-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; flex-wrap: wrap; gap: 10px; }
.cert-view-head { display: flex; align-items: center; justify-content: flex-end; margin-bottom: 14px; }
.cert-add-btn {
width: 36px; height: 36px; border-radius: 50%; padding: 0;
display: inline-flex; align-items: center; justify-content: center; cursor: pointer;
}
/* ---------- quote / invoice document preview (deliberately NOT themed like the app) ---------- */
.doc-preview {
background: #fff; color: #2d2d2d; padding: 46px 52px; max-width: 840px; margin: 0 auto;
font-family: 'Inter', Arial, sans-serif; border: 1px solid #e6e6e6; box-shadow: 0 2px 14px rgba(0,0,0,0.06);
border-radius: 4px;
}
.doc-row { display: flex; justify-content: space-between; gap: 24px; }
.doc-label { color: #9a9a9a; font-size: 12.5px; }
.doc-table { width: 100%; border-collapse: collapse; margin: 18px 0; }
.doc-table th { background: #2d2d2d; color: #fff; text-align: left; padding: 9px 12px; font-size: 12.5px; font-weight: 600; }
.doc-table th.num, .doc-table td.num { text-align: right; }
.doc-table td { padding: 10px 12px; border-bottom: 1px solid #eee; font-size: 13px; vertical-align: top; }
.doc-totals { width: 240px; margin-left: auto; font-size: 13px; }
.doc-totals-row { display: flex; justify-content: space-between; padding: 4px 0; color: #666; }
.doc-totals-row.total { font-weight: 700; color: #222; border-top: 1px solid #ddd; margin-top: 4px; padding-top: 8px; }
.doc-balance-due { background: #f2f2f2; border-radius: 6px; padding: 10px 14px; margin-top: 6px; }
/* ---------- multi-language product spec sheet export ---------- */
.spec-sheet-bar {
background: var(--copper); color: #fff; font-weight: 700; font-size: 15px;
padding: 10px 16px; border-radius: 4px 4px 0 0; letter-spacing: 0.02em;
}
.spec-sheet-table { margin-top: 0; border: 1px solid var(--copper-soft); border-top: none; }
.spec-sheet-table td.spec-label { background: #F7F1E8; font-weight: 600; width: 38%; color: #6b5a3e; }
.spec-sheet-table tr:nth-child(even) td { background: #FBF8F3; }
.spec-sheet-cover { text-align: center; padding: 20px 0 8px; }
.spec-sheet-cover .spec-sheet-bar { text-align: left; }
.spec-sheet-image-full { max-width: 70%; max-height: 320px; object-fit: contain; border-radius: 6px; }
.spec-sheet-points { margin: 14px 0 0; padding-left: 18px; font-size: 12.5px; color: #444; line-height: 1.7; text-align: left; }
.spec-sheet-points li { margin-bottom: 4px; }
.spec-sheet-page-break { break-before: page; page-break-before: always; }
[dir="rtl"] .spec-sheet-points { padding-left: 0; padding-right: 18px; text-align: right; }
[dir="rtl"] .spec-sheet-cover .spec-sheet-bar { text-align: right; }
@media print {
body * { visibility: hidden; }
.doc-preview, .doc-preview * { visibility: visible; }
.doc-preview { position: absolute; left: 0; top: 0; width: 100%; max-width: none; border: none; box-shadow: none; padding: 24px 40px; }
}
/* ---------- vip ---------- */
.vip-toggle { cursor: pointer; color: var(--line); display: inline-flex; align-items: center; transition: opacity 0.15s; }
.vip-toggle:hover { opacity: 0.65; }
.vip-toggle.active { color: #C9942E; }
.vip-toggle.active-red { color: var(--led-red); }
`}</style>
);
/* -------------------------------- 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 (
<div className="tax-row">
{label && <span className="tax-section-label">{label}</span>}
{values.map(v => (
<span key={v} className="tax-pill">
{v}
<span className="tax-pill-x" onClick={() => onRemove(catId, kind, v)}><X size={10} /></span>
</span>
))}
{isAdding ? (
<input
className="tax-add-input" autoFocus
value={addingPillValue}
onChange={e => setAddingPillValue(e.target.value)}
onBlur={onConfirmAdd}
onKeyDown={e => {
if (e.key === 'Enter') e.currentTarget.blur();
if (e.key === 'Escape') onCancelAdd();
}}
placeholder={kind === 'brand' ? '品牌名称' : '类型名称'}
/>
) : (
<span className="tax-add-btn" onClick={() => onOpenAdd(catId, kind)}><Plus size={12} /></span>
)}
</div>
);
}
function StatCard({ num, label, tone }) {
return (
<div className="stat-card">
<div className={`led-dot ${tone}`}></div>
<div>
<div className="stat-num">{num}</div>
<div className="stat-label">{label}</div>
</div>
</div>
);
}
function ProductPills({ value, onChange }) {
const toggle = (p) => {
const has = value.includes(p);
onChange(has ? value.filter(x => x !== p) : [...value, p]);
};
return (
<div className="checkbox-row">
{PRODUCT_LINES.map(p => (
<div key={p} className={`checkbox-pill ${value.includes(p) ? 'checked' : ''}`} onClick={() => toggle(p)}>
{value.includes(p) && <Check size={11} />} {p}
</div>
))}
</div>
);
}
function RegionPills({ value, onChange }) {
const toggle = (id) => {
const has = value.includes(id);
onChange(has ? value.filter(x => x !== id) : [...value, id]);
};
return (
<div className="checkbox-row">
{REGIONS.map(r => (
<div key={r.id} className={`checkbox-pill ${value.includes(r.id) ? 'checked' : ''}`} onClick={() => toggle(r.id)}>
{value.includes(r.id) && <Check size={11} />} {r.label}
</div>
))}
</div>
);
}
/* --------------------------------- app ---------------------------------- */
/* --------------------------------- nav --------------------------------- */
const NAV_GROUPS = [
{ label: '客户', items: [
{ id: 'monitor', label: '客户监控', icon: LayoutDashboard },
{ id: 'manage', label: '客户管理', icon: Users },
{ id: 'jms', label: 'Just My Socks', icon: Wifi },
] },
{ 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 (
<div className="crm-root" style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: '100vh', padding: 20 }}>
<Styles />
<form className="panel" style={{ width: 340, maxWidth: '100%', padding: 26 }} onSubmit={handleSubmit}>
<div className="crm-display" style={{ fontSize: 19, fontWeight: 700 }}>客户档案系统</div>
<div className="crm-subtitle" style={{ marginBottom: 18 }}>请登录后继续</div>
<div className="field">
<span className="field-label">用户名</span>
<input className="input" value={username} onChange={e => setUsername(e.target.value)} autoFocus autoCapitalize="off" />
</div>
<div className="field">
<span className="field-label">密码</span>
<input className="input" type="password" value={password} onChange={e => setPassword(e.target.value)} />
</div>
{error && <div style={{ color: 'var(--led-red)', fontSize: 12.5, marginBottom: 10 }}>{error}</div>}
<button className="btn btn-primary" type="submit" disabled={loading} style={{ width: '100%', justifyContent: 'center', marginTop: 4 }}>
{loading ? '登录中…' : '登录'}
</button>
</form>
</div>
);
}
/* --------------------------------- 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 (
<div className="crm-root" style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: '100vh' }}>
<Styles />
<div className="crm-mono" style={{ color: 'var(--ink-soft)', fontSize: 13 }}>加载中</div>
</div>
);
}
if (authState === 'out') {
return <LoginScreen onSuccess={(name) => { setUsername(name); setAuthState('in'); }} />;
}
return <MainApp username={username} onLogout={handleLogout} />;
}
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 [dragJmsIdx, setDragJmsIdx] = useState(null);
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 [tgTokenInput, setTgTokenInput] = useState('');
const [tgChatIdInput, setTgChatIdInput] = useState('');
const [tgSaving, setTgSaving] = useState(false);
const [tgTesting, setTgTesting] = 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');
const [jmsLines, setJmsLines] = useState([]);
const [telegramConfigHasToken, setTelegramConfigHasToken] = useState(false);
const [telegramConfigChatId, setTelegramConfigChatId] = useState('');
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));
setJmsLines(data.jmsLines || []);
setLoaded(true);
})();
}, []);
useEffect(() => {
getTelegramConfig().then(res => {
setTelegramConfigHasToken(!!res.hasToken);
setTelegramConfigChatId(res.chatId || '');
}).catch(() => {});
}, []);
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]);
useEffect(() => { if (loaded) storageSet(STORAGE_KEYS.jmsLines, JSON.stringify(jmsLines)).catch(() => {}); }, [jmsLines, 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);
}
function handleJmsDrop(dropIdx) {
if (dragJmsIdx === null || dragJmsIdx === dropIdx) { setDragJmsIdx(null); return; }
setJmsLines(prev => reorderArray(prev, dragJmsIdx, dropIdx));
setDragJmsIdx(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 handleSaveTelegram() {
if (!tgTokenInput.trim() || !tgChatIdInput.trim()) { notify('Bot Token和Chat ID都要填'); return; }
setTgSaving(true);
try {
await saveTelegramSettings(tgTokenInput.trim(), tgChatIdInput.trim());
setTgTokenInput('');
} finally {
setTgSaving(false);
}
}
async function handleTestTelegram() {
setTgTesting(true);
try { await testTelegramSettings(); } finally { setTgTesting(false); }
}
async function handleClearTelegram() {
if (!window.confirm('确定清除已保存的Telegram配置清除后线路异常将不再发送报警。')) return;
await removeTelegramSettings();
}
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('已删除');
}
/* --------------------------------- Just My Socks --------------------------------- */
function saveJmsLine(line, editingId) {
if (editingId) {
setJmsLines(prev => prev.map(l => (l.id === editingId ? { ...line, id: editingId } : l)));
notify('已保存修改');
} else {
setJmsLines(prev => [...prev, { ...line, id: 'jms_' + Date.now() + '_' + Math.random().toString(36).slice(2, 7) }]);
notify('线路已新增');
}
}
function deleteJmsLine(id) {
if (!window.confirm('确定删除这条线路?')) return;
setJmsLines(prev => prev.filter(l => l.id !== id));
notify('已删除');
}
async function checkJmsLineNow(id) {
const line = jmsLines.find(l => l.id === id);
if (!line) return;
setJmsLines(prev => prev.map(l => (l.id === id ? { ...l, monitorStatus: 'checking' } : l)));
try {
const res = await checkJmsLine(line);
setJmsLines(prev => prev.map(l => (l.id === id ? { ...l, monitorStatus: res.status, lastChecked: res.checkedAt } : l)));
if (res.status === 'abnormal') notify(`${line.name || line.addr} 检测异常${res.detail ? '' + res.detail : ''}`);
else notify(`${line.name || line.addr} 检测正常`);
} catch (e) {
setJmsLines(prev => prev.map(l => (l.id === id ? { ...l, monitorStatus: line.monitorStatus } : l)));
notify('检测失败:' + e.message);
}
}
async function saveTelegramSettings(botToken, chatId) {
try {
await saveTelegramConfig(botToken, chatId);
setTelegramConfigHasToken(true);
setTelegramConfigChatId(chatId);
notify('Telegram配置已保存');
} catch (e) {
notify('保存失败:' + e.message);
}
}
async function removeTelegramSettings() {
try {
await deleteTelegramConfig();
setTelegramConfigHasToken(false);
setTelegramConfigChatId('');
notify('已清除Telegram配置');
} catch (e) {
notify('清除失败:' + e.message);
}
}
async function testTelegramSettings() {
try {
await testTelegramConfig();
notify('测试消息已发送去Telegram看看');
} catch (e) {
notify('测试失败:' + e.message);
}
}
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' },
pagebreak: { mode: ['css'], before: '.spec-sheet-page-break' },
}).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 || '',
phase: p.phase || '', inputPowerFactor: p.inputPowerFactor || '',
transferTimeAcToBattery: p.transferTimeAcToBattery || '', currentCrestRatio: p.currentCrestRatio || '',
harmonicDistortion: p.harmonicDistortion || '', efficiencyAcMode: p.efficiencyAcMode || '',
efficiencyBatteryMode: p.efficiencyBatteryMode || '', chargingVoltage: p.chargingVoltage || '',
optionalSnmp: p.optionalSnmp || '',
outputPowerFactor: p.outputPowerFactor || '', parallelUnits: p.parallelUnits || '', overload: p.overload || '',
moduleType: p.moduleType || '', moduleCapacity: p.moduleCapacity || '', cellType: p.cellType || '',
moduleVoltage: p.moduleVoltage || '', dischargeRate: p.dischargeRate || '', chargeRate: p.chargeRate || '',
moduleMaxEnergy: p.moduleMaxEnergy || '', moduleOutputPower: p.moduleOutputPower || '',
moduleDimensions: p.moduleDimensions || '', moduleWeight: p.moduleWeight || '',
cabinetMaxEnergy: p.cabinetMaxEnergy || '', cabinetRatedVoltage: p.cabinetRatedVoltage || '',
cycleLife: p.cycleLife || '', commInterface: p.commInterface || '', protectionFunctions: p.protectionFunctions || '',
operatingTemp: p.operatingTemp || '', color: p.color || '',
cabinetDimensions: p.cabinetDimensions || '', cabinetWeight: p.cabinetWeight || '',
altitude: p.altitude || '', selfDischargeRate: p.selfDischargeRate || '',
batteryType: p.batteryType || '', maxChargingVoltage: p.maxChargingVoltage || '',
maxChargeDischargeCurrent: p.maxChargeDischargeCurrent || '', maxDcInputPower: p.maxDcInputPower || '',
maxDcInputVoltage: p.maxDcInputVoltage || '', mpptVoltageRange: p.mpptVoltageRange || '',
startingVoltage: p.startingVoltage || '', maxInputCurrent: p.maxInputCurrent || '', mpptNumber: p.mpptNumber || '',
onGridMaxApparentPower: p.onGridMaxApparentPower || '', ratedOutputFrequency: p.ratedOutputFrequency || '',
onGridMaxOutputCurrent: p.onGridMaxOutputCurrent || '', offGridRatedApparentPower: p.offGridRatedApparentPower || '',
offGridMaxApparentPower: p.offGridMaxApparentPower || '', offGridMaxOutputCurrent: p.offGridMaxOutputCurrent || '',
efficiencyMppt: p.efficiencyMppt || '', efficiencyMax: p.efficiencyMax || '', efficiencyEurope: p.efficiencyEurope || '',
storageTemp: p.storageTemp || '', cooling: p.cooling || '', protectionClass: p.protectionClass || '',
topology: p.topology || '', safetyStandard: p.safetyStandard || '', gridStandard: p.gridStandard || '',
otherStandard: p.otherStandard || '',
sellingPoints: p.sellingPoints || '', // AI如果在原文里认出了营销卖点/主要特点,会自动填这里
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 || '',
phase: d.phase || '', inputPowerFactor: d.inputPowerFactor || '',
transferTimeAcToBattery: d.transferTimeAcToBattery || '', currentCrestRatio: d.currentCrestRatio || '',
harmonicDistortion: d.harmonicDistortion || '', efficiencyAcMode: d.efficiencyAcMode || '',
efficiencyBatteryMode: d.efficiencyBatteryMode || '', chargingVoltage: d.chargingVoltage || '',
optionalSnmp: d.optionalSnmp || '',
outputPowerFactor: d.outputPowerFactor || '', parallelUnits: d.parallelUnits || '', overload: d.overload || '',
moduleType: d.moduleType || '', moduleCapacity: d.moduleCapacity || '', cellType: d.cellType || '',
moduleVoltage: d.moduleVoltage || '', dischargeRate: d.dischargeRate || '', chargeRate: d.chargeRate || '',
moduleMaxEnergy: d.moduleMaxEnergy || '', moduleOutputPower: d.moduleOutputPower || '',
moduleDimensions: d.moduleDimensions || '', moduleWeight: d.moduleWeight || '',
cabinetMaxEnergy: d.cabinetMaxEnergy || '', cabinetRatedVoltage: d.cabinetRatedVoltage || '',
cycleLife: d.cycleLife || '', commInterface: d.commInterface || '', protectionFunctions: d.protectionFunctions || '',
operatingTemp: d.operatingTemp || '', color: d.color || '',
cabinetDimensions: d.cabinetDimensions || '', cabinetWeight: d.cabinetWeight || '',
altitude: d.altitude || '', selfDischargeRate: d.selfDischargeRate || '',
batteryType: d.batteryType || '', maxChargingVoltage: d.maxChargingVoltage || '',
maxChargeDischargeCurrent: d.maxChargeDischargeCurrent || '', maxDcInputPower: d.maxDcInputPower || '',
maxDcInputVoltage: d.maxDcInputVoltage || '', mpptVoltageRange: d.mpptVoltageRange || '',
startingVoltage: d.startingVoltage || '', maxInputCurrent: d.maxInputCurrent || '', mpptNumber: d.mpptNumber || '',
onGridMaxApparentPower: d.onGridMaxApparentPower || '', ratedOutputFrequency: d.ratedOutputFrequency || '',
onGridMaxOutputCurrent: d.onGridMaxOutputCurrent || '', offGridRatedApparentPower: d.offGridRatedApparentPower || '',
offGridMaxApparentPower: d.offGridMaxApparentPower || '', offGridMaxOutputCurrent: d.offGridMaxOutputCurrent || '',
efficiencyMppt: d.efficiencyMppt || '', efficiencyMax: d.efficiencyMax || '', efficiencyEurope: d.efficiencyEurope || '',
storageTemp: d.storageTemp || '', cooling: d.cooling || '', protectionClass: d.protectionClass || '',
topology: d.topology || '', safetyStandard: d.safetyStandard || '', gridStandard: d.gridStandard || '',
otherStandard: d.otherStandard || '',
sellingPoints: d.sellingPoints || '',
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 (
<div className="crm-root" style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: 320 }}>
<div className="crm-mono" style={{ color: 'var(--ink-soft)', fontSize: 13 }}>加载中</div>
</div>
);
}
return (
<div className="crm-root">
<Styles />
<div className="crm-shell">
<div className="crm-topbar-mobile">
<button className="btn btn-ghost btn-sm" onClick={() => setMobileMenuOpen(true)}><Menu size={18} /></button>
<span className="crm-display" style={{ fontWeight: 700, fontSize: 14 }}>{viewTitle(view)}</span>
</div>
{mobileMenuOpen && <div className="crm-sidebar-overlay show" onClick={() => setMobileMenuOpen(false)} />}
<div className={`crm-sidebar ${mobileMenuOpen ? 'open' : ''}`}>
<div className="crm-sidebar-brand">
<div className="crm-display" style={{ fontWeight: 700, fontSize: 15 }}>客户档案系统</div>
<div style={{ fontSize: 11, color: 'var(--ink-soft)', marginTop: 2 }}>Customer Console</div>
</div>
<div className="crm-sidebar-scroll">
{NAV_GROUPS.map(group => (
<div key={group.label}>
<div className="crm-sidebar-group-label">{group.label}</div>
{group.items.map(item => {
const Icon = item.icon;
return (
<div
key={item.id}
className={`crm-sidebar-item ${view === item.id ? 'active' : ''}`}
onClick={() => { setView(item.id); setMobileMenuOpen(false); }}
>
<Icon size={15} />{item.label}
</div>
);
})}
</div>
))}
<div style={{ borderTop: '1px solid var(--line)', margin: '10px 16px' }} />
{STANDALONE_NAV.map(item => {
const Icon = item.icon;
return (
<div
key={item.id}
className={`crm-sidebar-item ${view === item.id ? 'active' : ''}`}
onClick={() => { setView(item.id); setMobileMenuOpen(false); }}
>
<Icon size={15} />{item.label}
</div>
);
})}
</div>
<div className="crm-sidebar-footer">
<div className="crm-sidebar-user"><Users size={12} />{username}</div>
<button className="btn btn-sm" style={{ width: '100%', justifyContent: 'center' }} onClick={onLogout}><LogOut size={12} />退出登录</button>
</div>
</div>
<div className="crm-main">
<div className="crm-main-body">
{view === 'monitor' && (
<>
<div className="stat-row">
<StatCard num={counts.overdue} label="已逾期跟进" tone="red" />
<StatCard num={counts.due} label="即将到期" tone="amber" />
<StatCard num={counts.total} label="客户总数" tone="green" />
<StatCard num={counts.products} label="产品档案数量" tone="blue" />
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 10 }}>
<div className="panel-title">全部客户</div>
<span style={{ fontSize: 11.5, color: 'var(--ink-soft)' }}>拖动卡片可调整顺序</span>
</div>
{customers.length === 0 ? (
<div className="panel"><div className="empty-state"><Inbox size={28} />暂无客户"客户管理"添加第一个</div></div>
) : (
<div className="card-grid">
{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 (
<div
key={c.id}
className="monitor-card"
style={{ opacity: dragCustomerIdx === idx ? 0.5 : 1 }}
draggable
onDragStart={() => setDragCustomerIdx(idx)}
onDragOver={(e) => e.preventDefault()}
onDrop={() => handleCustomerDrop(idx)}
onDragEnd={() => setDragCustomerIdx(null)}
>
<div className="monitor-card-head">
<GripVertical size={14} className="drag-handle" />
<div className={`led-dot ${s === 'overdue' ? 'red' : s === 'due' ? 'amber' : 'green'}`} />
<div className="monitor-card-title">{cardTitle}</div>
{c.isVip && <Crown size={14} fill="#C9942E" color="#C9942E" style={{ flexShrink: 0 }} />}
{c.hasOrdered && <Crown size={14} fill="#C2403A" color="#C2403A" style={{ flexShrink: 0 }} />}
</div>
<div className="row-sub">{regionLabel(c.region)} · {c.country || '—'}</div>
{showCompanyLine && <div className="row-sub">{c.company}</div>}
{c.contact && <div className="row-sub">{c.contact}</div>}
{showEmail && <div className="row-sub" style={{ display: 'flex', alignItems: 'center', gap: 4 }}><Mail size={11} />{c.email}</div>}
{showPhone && <div className="row-sub" style={{ display: 'flex', alignItems: 'center', gap: 4 }}><Phone size={11} />{c.phone}</div>}
{lt && <div className="row-sub">当地时间 {lt.time}</div>}
<div className="row-sub">上次联系 {fmtDate(c.lastContact)}</div>
<div style={{ display: 'flex', gap: 6, marginTop: 4 }}>
<button className="btn btn-sm" style={{ flex: 1, justifyContent: 'center' }} onClick={() => { setView('manage'); setDraft(null); setSelectedId(c.id); }}>查看</button>
<button className="btn btn-sm" title="标记今日已联系" onClick={() => markContacted(c.id)}><Check size={12} /></button>
</div>
</div>
);
})}
</div>
)}
</>
)}
{view === 'manage' && (
<div className="crm-layout">
<div>
<div className="search-box"><Search size={14} color="var(--ink-soft)" /><input placeholder="搜索公司 / 联系人 / 邮箱 / 国家" value={search} onChange={e => setSearch(e.target.value)} /></div>
<div className="field">
<select className="select" value={regionFilter} onChange={e => setRegionFilter(e.target.value)}>
<option value="all">全部地区</option>
{REGIONS.map(r => <option key={r.id} value={r.id}>{r.label}</option>)}
</select>
</div>
<button className="btn btn-primary" style={{ width: '100%', justifyContent: 'center', marginBottom: 12 }} onClick={startNewCustomer}><Plus size={14} />新建客户档案</button>
<div className="panel">
<div className="panel-body">
{filteredCustomers.length === 0 ? (
<div className="empty-state"><Inbox size={26} />暂无客户点击上方按钮添加第一个</div>
) : filteredCustomers.map(c => {
const s = statusOf(c, settings);
const showCompanyInSub = customerTitleIsModel(c) && c.company;
return (
<div key={c.id} className="row" style={{ cursor: 'pointer', background: selectedId === c.id ? '#FAFBFC' : undefined }} onClick={() => { setDraft(null); setSelectedId(c.id); }}>
<div className={`led-dot ${s === 'overdue' ? 'red' : s === 'due' ? 'amber' : 'green'}`} />
<div className="row-main">
<div className="row-title">{c.isVip && <Crown size={12} fill="#C9942E" color="#C9942E" style={{ marginRight: 4, verticalAlign: -1 }} />}{c.hasOrdered && <Crown size={12} fill="#C2403A" color="#C2403A" style={{ marginRight: 4, verticalAlign: -1 }} />}{customerTitle(c)}</div>
<div className="row-sub">{showCompanyInSub && `${c.company} · `}{regionLabel(c.region)} · {c.country || '—'}{localTimeInfo(c.country, now) && ` · 当地 ${localTimeInfo(c.country, now).time}`}</div>
</div>
<ChevronRight size={15} color="var(--ink-soft)" />
</div>
);
})}
</div>
</div>
</div>
<div>
{draft ? (
<CustomerForm draft={draft} setDraft={setDraft} onSave={saveDraft} onCancel={cancelDraft} />
) : selected ? (
<CustomerDetail
customer={selected}
settings={settings}
now={now}
onEdit={() => 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)}
/>
) : (
<div className="panel"><div className="empty-state"><Users size={28} />选择左侧客户查看详情或新建一个客户档案</div></div>
)}
</div>
</div>
)}
{view === 'jms' && (
<JmsPage
lines={jmsLines}
onSave={saveJmsLine}
onDelete={deleteJmsLine}
onCheck={checkJmsLineNow}
onNotify={notify}
dragIdx={dragJmsIdx}
onDragStart={setDragJmsIdx}
onDrop={handleJmsDrop}
onDragEnd={() => setDragJmsIdx(null)}
/>
)}
{view === 'groups' && (
<>
<div className="disclaimer">按产品大类分组浏览每个类目右上角管理品牌类目内第二行管理类型新建产品时先选类目再从对应的品牌 / 类型里选</div>
{productCategories.map(cat => {
const items = products.filter(p => p.category === cat.id);
return (
<div className="panel section-gap" key={cat.id}>
<div className="panel-header" style={{ flexWrap: 'wrap', gap: 10 }}>
<div className="panel-title"><Layers size={15} />{cat.name}{items.length}</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<TaxPillRow
label={null} values={cat.brands} catId={cat.id} kind="brand"
addingPill={addingPill} addingPillValue={addingPillValue} setAddingPillValue={setAddingPillValue}
onOpenAdd={openAddPill} onConfirmAdd={confirmAddPill} onCancelAdd={cancelAddPill} onRemove={removeTaxonomyValue}
/>
<button className="btn btn-sm btn-ghost btn-danger" title="删除类目" onClick={() => deleteProductCategory(cat)}>
<Trash2 size={12} />
</button>
</div>
</div>
<div className="tax-types-row">
<TaxPillRow
label="类型" values={cat.types} catId={cat.id} kind="type"
addingPill={addingPill} addingPillValue={addingPillValue} setAddingPillValue={setAddingPillValue}
onOpenAdd={openAddPill} onConfirmAdd={confirmAddPill} onCancelAdd={cancelAddPill} onRemove={removeTaxonomyValue}
/>
</div>
<div className="panel-body">
{items.length === 0 ? (
<div className="empty-state" style={{ padding: '20px 10px' }}>暂无产品</div>
) : items.map((p, idx) => (
<div
key={p.id}
className="row"
style={{ cursor: 'grab', opacity: (dragGroupState && dragGroupState.category === cat.id && dragGroupState.index === idx) ? 0.5 : 1 }}
draggable
onDragStart={() => setDragGroupState({ category: cat.id, index: idx })}
onDragOver={(e) => e.preventDefault()}
onDrop={() => handleGroupDrop(cat.id, idx)}
onDragEnd={() => setDragGroupState(null)}
>
<GripVertical size={14} className="drag-handle" />
<div className="row-main">
<div className="row-title">{p.name || '(未命名)'}</div>
<div className="row-sub">{categoryBreadcrumb(p, productCategories)}{p.brand ? ` · ${p.brand}` : ''}</div>
</div>
<button className="btn btn-sm" onClick={() => { setView('products'); setProductDraft(null); setSelectedProductId(p.id); }}>查看</button>
</div>
))}
</div>
</div>
);
})}
{addingPill && addingPill.kind === 'category' ? (
<div className="tax-add-category">
<input
className="input" autoFocus style={{ maxWidth: 260, margin: '0 auto', textAlign: 'center' }}
value={addingPillValue}
onChange={e => setAddingPillValue(e.target.value)}
onBlur={confirmAddPill}
onKeyDown={e => {
if (e.key === 'Enter') e.currentTarget.blur();
if (e.key === 'Escape') cancelAddPill();
}}
placeholder="新类目名称,如:精密空调"
/>
</div>
) : (
<div className="tax-add-category" onClick={() => openAddPill(null, 'category')}>
<Plus size={14} style={{ verticalAlign: -2, marginRight: 4 }} />新增类目
</div>
)}
</>
)}
{view === 'products' && (
<div className="crm-layout">
<div>
<div className="search-box"><Search size={14} color="var(--ink-soft)" /><input placeholder="搜索型号 / 品牌 / 类别" value={productSearch} onChange={e => setProductSearch(e.target.value)} /></div>
<div className="field">
<select className="select" value={productCategoryFilter} onChange={e => setProductCategoryFilter(e.target.value)}>
<option value="all">全部类别</option>
{productCategories.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
</div>
<button className="btn btn-primary" style={{ width: '100%', justifyContent: 'center', marginBottom: 8 }} onClick={startNewProduct}><Plus size={14} />新建产品档案</button>
<button className="btn" style={{ width: '100%', justifyContent: 'center', marginBottom: 12 }} onClick={openAiImport}><Sparkles size={14} />AI导入上传PDF/图片自动建档</button>
<div className="panel">
<div className="panel-body">
{filteredProducts.length === 0 ? (
<div className="empty-state"><Package size={26} />暂无产品点击上方按钮添加第一个</div>
) : filteredProducts.map(p => (
<div key={p.id} className="row" style={{ cursor: 'pointer', background: selectedProductId === p.id ? '#FAFBFC' : undefined }} onClick={() => { setProductDraft(null); setSelectedProductId(p.id); }}>
<div className="led-dot blue" />
<div className="row-main">
<div className="row-title">{p.name || '(未命名)'}</div>
<div className="row-sub">{categoryBreadcrumb(p, productCategories)}{p.brand ? ` · ${p.brand}` : ''}</div>
</div>
<ChevronRight size={15} color="var(--ink-soft)" />
</div>
))}
</div>
</div>
</div>
<div>
{productDraft ? (
<ProductForm draft={productDraft} setDraft={setProductDraft} onSave={saveProductDraft} onCancel={cancelProductDraft} categories={productCategories} onManage={(mode) => setTaxManageMode(mode)} onNotify={notify} />
) : selectedProduct ? (
<ProductDetail
product={selectedProduct} onEdit={() => startEditProduct(selectedProduct)} onDelete={() => deleteProduct(selectedProduct.id)}
categories={productCategories}
exportLang={productExportLang} onExportLangChange={setProductExportLang}
exportLabels={productExportLabels} exportLoading={productExportLoading}
onExportPdf={handleExportProductPdf} exportPdfBusy={productExportPdfBusy}
/>
) : (
<div className="panel"><div className="empty-state"><Package size={28} />选择左侧产品查看详情或新建一个产品档案</div></div>
)}
</div>
</div>
)}
{view === 'catalog' && (
<div
className={`catalog-dropzone ${catalogDragActive ? 'drag-active' : ''}`}
onDragOver={handleCatalogDragOver} onDragLeave={handleCatalogDragLeave} onDrop={handleCatalogDrop}
>
<div className="catalog-view-head">
<div className="catalog-filter-row" style={{ marginBottom: 0 }}>
<select className="select" value={catalogCategoryFilter} onChange={e => setCatalogCategoryFilter(e.target.value)}>
<option value="all">全部类别</option>
{productCategories.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
</div>
<label className="btn btn-primary cert-add-btn" title="上传产品目录PDF也可以把PDF直接拖到本页任意位置">
<input
type="file" accept="application/pdf" multiple style={{ display: 'none' }}
onChange={e => { handleCatalogFilesSelected(e.target.files); e.target.value = ''; }}
/>
<Plus size={16} />
</label>
</div>
{catalogDocs.length === 0 ? (
<div className="panel"><div className="empty-state"><BookOpen size={28} />暂无产品目录点击右上角"+"或把PDF拖到这里上传第一份样本</div></div>
) : filteredCatalogDocs.length === 0 ? (
<div className="panel"><div className="empty-state"><BookOpen size={28} />这个类别下还没有产品目录</div></div>
) : (
<div className="card-grid catalog-card-grid">
{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 (
<div
key={doc.id}
className={`cert-card catalog-doc-card ${dragCatalogDocIdx === idx ? 'dragging' : ''}`}
draggable={!doc.uploading}
onDragStart={(e) => { e.stopPropagation(); setDragCatalogDocIdx(idx); }}
onDragOver={(e) => { e.preventDefault(); e.stopPropagation(); }}
onDrop={(e) => { e.stopPropagation(); handleCatalogDocDrop(idx); }}
onDragEnd={() => setDragCatalogDocIdx(null)}
>
<div className="cert-card-img-wrap catalog-doc-img-wrap">
{doc.uploading ? (
<div className="catalog-doc-uploading">
<div className="catalog-doc-spinner" />
<div>正在上传</div>
<div className="catalog-doc-filename">{doc.originalName}</div>
</div>
) : (
<>
<a href={`/uploads/${doc.id}/${doc.fileName}`} target="_blank" rel="noreferrer" className="catalog-doc-link">
<FileText size={22} color="var(--copper)" />
<div className="catalog-doc-filename">{doc.originalName}</div>
</a>
<div className="catalog-doc-actions">
<a className="btn btn-sm btn-ghost" title="下载" href={`/uploads/${doc.id}/${doc.fileName}`} download={doc.originalName}>
<Download size={12} />
</a>
<button className="btn btn-sm btn-ghost btn-danger" title="删除" onClick={() => deleteCatalogDoc(doc)}>
<Trash2 size={12} />
</button>
</div>
</>
)}
</div>
<div className="cert-card-brand">
{isEditing ? (
<div className="catalog-doc-edit-fields">
<input
className="input" autoFocus
value={catalogEditDraft.title}
onChange={e => updateCatalogEditDraft({ title: e.target.value })}
onKeyDown={e => { if (e.key === 'Enter') saveCatalogEdit(); }}
placeholder="目录名称SKE产品样本"
/>
<select
className="select"
value={catalogEditDraft.category}
onChange={e => updateCatalogEditDraft({ category: e.target.value, brand: '', type: '' })}
>
<option value="">不选择大类</option>
{productCategories.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
{catalogEditDraft.category && (
<>
<select className="select" value={catalogEditDraft.brand} onChange={e => updateCatalogEditDraft({ brand: e.target.value })}>
<option value="">不选择品牌</option>
{brandOptions.map(b => <option key={b} value={b}>{b}</option>)}
</select>
<select className="select" value={catalogEditDraft.type} onChange={e => updateCatalogEditDraft({ type: e.target.value })}>
<option value="">不选择细分品类</option>
{typeOptions.map(t => <option key={t} value={t}>{t}</option>)}
</select>
</>
)}
<button className="btn btn-sm btn-primary" onClick={saveCatalogEdit}><Check size={12} />完成</button>
</div>
) : (
<div onClick={() => !doc.uploading && startEditCatalogDoc(doc)} style={{ cursor: doc.uploading ? 'default' : 'pointer' }}>
<div className={`cert-card-brand-text ${doc.title ? '' : 'empty'}`}>
{doc.uploading ? '上传完成后可填写' : (doc.title || '点击填写目录名称')}
</div>
{(doc.category || doc.brand || doc.type) && (
<div className="catalog-doc-meta">
{[cat && cat.name, doc.brand, doc.type].filter(Boolean).join(' · ')}
</div>
)}
</div>
)}
</div>
</div>
);
})}
</div>
)}
</div>
)}
{view === 'certs' && (
<>
<div className="cert-view-head">
<label className="btn btn-primary cert-add-btn" title={certUploading ? '上传中…' : '上传授权证书'}>
<input
type="file" accept="image/*" multiple style={{ display: 'none' }}
onChange={e => { handleCertFilesSelected(e.target.files); e.target.value = ''; }}
/>
<Plus size={16} />
</label>
</div>
{certifications.length === 0 ? (
<div className="panel"><div className="empty-state"><ShieldCheck size={28} />暂无授权证书点击右上角"+"上传第一张</div></div>
) : (
<div className="card-grid">
{certifications.map(cert => (
<div key={cert.id} className="cert-card">
<div className="cert-card-img-wrap">
<a href={`/uploads/${cert.id}/${cert.fileName}`} target="_blank" rel="noreferrer">
<img src={`/uploads/${cert.id}/${cert.fileName}`} alt={cert.brand || '授权证书'} />
</a>
<button className="btn btn-sm btn-ghost btn-danger cert-card-delete" onClick={() => deleteCertification(cert)}>
<Trash2 size={12} />
</button>
</div>
<div className="cert-card-brand">
{editingCertId === cert.id ? (
<input
className="input" autoFocus
value={certBrandDraft}
onChange={e => setCertBrandDraft(e.target.value)}
onBlur={saveCertBrand}
onKeyDown={e => { if (e.key === 'Enter') e.currentTarget.blur(); }}
placeholder="品牌名称"
/>
) : (
<div
className={`cert-card-brand-text ${cert.brand ? '' : 'empty'}`}
onClick={() => startEditCertBrand(cert)}
>
{cert.brand || '点击填写品牌名'}
</div>
)}
</div>
</div>
))}
</div>
)}
</>
)}
{view === 'quotes' && quoteSubView === 'list' && (
<>
<div style={{ display: 'flex', gap: 8, marginBottom: 14, flexWrap: 'wrap' }}>
<button className="btn btn-primary" onClick={() => startNewQuote('quote')}><Plus size={14} />新建报价单 Quote</button>
<button className="btn btn-primary" onClick={() => startNewQuote('invoice')}><Plus size={14} />新建 Invoice</button>
<button className="btn" onClick={() => setSellerManagerOpen(true)}><Building2 size={14} />管理卖方信息</button>
<select className="select" style={{ width: 140, marginLeft: 'auto' }} value={quoteTypeFilter} onChange={e => setQuoteTypeFilter(e.target.value)}>
<option value="all">全部类型</option>
<option value="quote"> Quote</option>
<option value="invoice"> Invoice</option>
</select>
</div>
<div className="panel">
<div className="panel-body">
{quotes.filter(q => quoteTypeFilter === 'all' || q.type === quoteTypeFilter).length === 0 ? (
<div className="empty-state"><FileText size={28} />暂无文档点击上方按钮新建一份</div>
) : [...quotes].filter(q => quoteTypeFilter === 'all' || q.type === quoteTypeFilter).reverse().map(q => {
const { total } = quoteTotals(q);
return (
<div key={q.id} className="row" style={{ cursor: 'pointer' }} onClick={() => startEditQuote(q)}>
<span className="badge badge-region" style={{ flexShrink: 0 }}>{q.type === 'quote' ? 'Quote' : 'Invoice'}</span>
<div className="row-main">
<div className="row-title">{q.number || '(无单号)'} · {q.billToName || '(未填写客户)'}</div>
<div className="row-sub">{q.date} · {formatMoney(total, q.currency)}</div>
</div>
<button className="btn btn-sm btn-ghost btn-danger" onClick={(e) => { e.stopPropagation(); deleteQuote(q.id); }}><Trash2 size={13} /></button>
<button className="btn btn-sm" onClick={(e) => { e.stopPropagation(); duplicateQuote(q); }}><Copy size={12} />复制</button>
<button className="btn btn-sm" onClick={(e) => { e.stopPropagation(); convertQuoteToPackingList(q); }}><Boxes size={12} />转为箱单</button>
{q.type === 'quote' && (
<button className="btn btn-sm" onClick={(e) => { e.stopPropagation(); convertQuoteToInvoice(q); }}>转为Invoice</button>
)}
</div>
);
})}
</div>
</div>
</>
)}
{view === 'quotes' && quoteSubView === 'editor' && quoteDraft && (
<QuoteEditor
draft={quoteDraft}
onUpdate={updateQuoteDraft}
onBack={backToQuoteList}
onSave={saveQuoteDraft}
onPrint={handlePrint}
onExportPdf={handleExportPdf}
pdfExporting={pdfExporting}
customers={customers}
sellerProfiles={sellerProfiles}
onLinkCustomer={handleLinkCustomer}
onSellerChange={handleSellerChange}
onCurrencyChange={handleCurrencyChange}
onAddLineItem={addLineItem}
onRemoveLineItem={removeLineItem}
onUpdateLineItem={updateLineItem}
onManageSellers={() => setSellerManagerOpen(true)}
onToggleNoteLine={toggleQuoteNoteLine}
priceHistory={priceHistory}
/>
)}
{view === 'packing' && packingSubView === 'list' && (
<>
<div style={{ display: 'flex', gap: 8, marginBottom: 14, flexWrap: 'wrap' }}>
<button className="btn btn-primary" onClick={startNewPackingList}><Plus size={14} />新建箱单</button>
<button className="btn" onClick={() => setSellerManagerOpen(true)}><Building2 size={14} />管理卖方信息</button>
</div>
<div className="panel">
<div className="panel-body">
{packingLists.length === 0 ? (
<div className="empty-state"><Boxes size={28} />暂无箱单点击上方按钮新建一份也可以到"报价单"里把Quote/Invoice转成箱单</div>
) : [...packingLists].reverse().map(pl => {
const t = packingTotals(pl);
return (
<div key={pl.id} className="row" style={{ cursor: 'pointer' }} onClick={() => startEditPackingList(pl)}>
<span className="badge badge-region" style={{ flexShrink: 0 }}>P/L</span>
<div className="row-main">
<div className="row-title">{pl.number || '(无单号)'} · {pl.billToName || '(未填写客户)'}</div>
<div className="row-sub">{pl.date} · {t.cartons || 0} · {t.grossWeight || 0} kg 毛重</div>
</div>
<button className="btn btn-sm btn-ghost btn-danger" onClick={(e) => { e.stopPropagation(); deletePackingList(pl.id); }}><Trash2 size={13} /></button>
<button className="btn btn-sm" onClick={(e) => { e.stopPropagation(); duplicatePackingList(pl); }}><Copy size={12} />复制</button>
</div>
);
})}
</div>
</div>
</>
)}
{view === 'packing' && packingSubView === 'editor' && packingDraft && (
<PackingListEditor
draft={packingDraft}
onUpdate={updatePackingDraft}
onBack={backToPackingList}
onSave={savePackingDraft}
onPrint={handlePrint}
onExportPdf={handleExportPackingPdf}
pdfExporting={packingPdfExporting}
customers={customers}
sellerProfiles={sellerProfiles}
onLinkCustomer={handleLinkPackingCustomer}
onSellerChange={(sellerId) => updatePackingDraft({ sellerProfileId: sellerId })}
onAddLineItem={addPackingLineItem}
onRemoveLineItem={removePackingLineItem}
onUpdateLineItem={updatePackingLineItem}
onManageSellers={() => setSellerManagerOpen(true)}
/>
)}
{view === 'settings' && (
<>
<div className="panel section-gap">
<div className="panel-header"><div className="panel-title">跟进与提醒设置</div></div>
<div className="panel-body" style={{ padding: 18 }}>
<div className="grid-2">
<div className="field">
<span className="field-label">默认跟进周期</span>
<input type="number" min="1" className="input" value={settings.defaultFollowUpDays} onChange={e => setSettings(s => ({ ...s, defaultFollowUpDays: Number(e.target.value) || 1 }))} />
</div>
<div className="field">
<span className="field-label">"即将到期"提前提醒天数</span>
<input type="number" min="1" className="input" value={settings.dueSoonWindow} onChange={e => setSettings(s => ({ ...s, dueSoonWindow: Number(e.target.value) || 1 }))} />
</div>
</div>
<div style={{ display: 'flex', gap: 10, marginTop: 6 }}>
<button className="btn" onClick={() => setExportOpen(true)}><Save size={13} />导出数据备份</button>
<button className="btn btn-danger" onClick={() => {
if (window.confirm('将清空全部客户与产品档案数据,且不可恢复,确定继续?')) {
setCustomers([]); setProducts([]); setSettings(DEFAULT_SETTINGS);
notify('已清空');
}
}}><RefreshCw size={13} />清空所有数据</button>
</div>
<div className="disclaimer" style={{ marginTop: 18 }}>
说明邮件功能用于<b>生成可复制的英文邮件文案</b>线
</div>
</div>
</div>
<div className="panel section-gap">
<div className="panel-header"><div className="panel-title"><Lock size={15} />账号与密码</div></div>
<div className="panel-body" style={{ padding: 18 }}>
<div style={{ fontSize: 12.5, color: 'var(--ink-soft)', marginBottom: 12 }}>当前登录账号{username}</div>
<div className="grid-2">
<div className="field"><span className="field-label">当前密码</span><input type="password" className="input" value={pwCurrent} onChange={e => setPwCurrent(e.target.value)} /></div>
<div className="field"><span className="field-label">新密码至少4位</span><input type="password" className="input" value={pwNew} onChange={e => setPwNew(e.target.value)} /></div>
</div>
<button className="btn btn-primary" disabled={pwBusy} onClick={handleChangePassword}><Lock size={13} />{pwBusy ? '修改中…' : '修改密码'}</button>
<div className="disclaimer" style={{ marginTop: 14 }}>
这个系统现在挂在公网上定期更换密码不要把密码告诉别人
</div>
</div>
</div>
<div className="panel">
<div className="panel-header"><div className="panel-title"><Sparkles size={15} />AI 跟进文案DeepSeek</div></div>
<div className="panel-body" style={{ padding: 18 }}>
<div style={{ fontSize: 12.5, color: 'var(--ink-soft)', marginBottom: 12 }}>
状态{aiConfigHasKey ? <span style={{ color: 'var(--led-green)', fontWeight: 600 }}>已配置</span> : <span></span>}
</div>
<div className="field">
<span className="field-label">DeepSeek API Key</span>
<input
type="password" className="input" value={aiKeyInput} onChange={e => setAiKeyInput(e.target.value)}
placeholder={aiConfigHasKey ? '已设置,重新填写可覆盖' : '在 platform.deepseek.com 申请'}
/>
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button className="btn btn-primary" disabled={aiSaving} onClick={handleSaveAiKey}><Save size={13} />保存</button>
<button className="btn" disabled={!aiConfigHasKey || aiTesting} onClick={handleTestAiKey}>{aiTesting ? '测试中…' : '测试连接'}</button>
{aiConfigHasKey && <button className="btn btn-danger" onClick={handleClearAiKey}>清除</button>}
</div>
<div className="disclaimer" style={{ marginTop: 14 }}>
用于客户详情页的"AI生成邮件 / AI生成WhatsApp话术"功能调用DeepSeek官方API生成成本很低单次生成大概几分钱以内人民币按你自己DeepSeek账户余额实际扣费Key只会加密保存在服务器本地不会显示给任何人
</div>
</div>
</div>
<div className="panel">
<div className="panel-header"><div className="panel-title"><Send size={15} />Telegram 报警Just My Socks线路监控</div></div>
<div className="panel-body" style={{ padding: 18 }}>
<div style={{ fontSize: 12.5, color: 'var(--ink-soft)', marginBottom: 12 }}>
状态{telegramConfigHasToken ? <span style={{ color: 'var(--led-green)', fontWeight: 600 }}>已配置Chat ID: {telegramConfigChatId}</span> : <span></span>}
</div>
<div className="grid-2">
<div className="field">
<span className="field-label">Bot Token</span>
<input
type="password" className="input" value={tgTokenInput} onChange={e => setTgTokenInput(e.target.value)}
placeholder={telegramConfigHasToken ? '已设置,重新填写可覆盖' : '找 @BotFather 申请'}
/>
</div>
<div className="field">
<span className="field-label">Chat ID</span>
<input className="input" value={tgChatIdInput} onChange={e => setTgChatIdInput(e.target.value)} placeholder="接收报警的对话ID" />
</div>
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button className="btn btn-primary" disabled={tgSaving} onClick={handleSaveTelegram}><Save size={13} />保存</button>
<button className="btn" disabled={!telegramConfigHasToken || tgTesting} onClick={handleTestTelegram}>{tgTesting ? '发送中…' : '发送测试消息'}</button>
{telegramConfigHasToken && <button className="btn btn-danger" onClick={handleClearTelegram}>清除</button>}
</div>
<div className="disclaimer" style={{ marginTop: 14 }}>
Just My Socks页面的线路每10分钟自动巡检一次只在状态发生变化正常异常时才发消息不会每次检测都刷屏Bot Token加密保存在服务器本地
</div>
</div>
</div>
</>
)}
</div>
</div>
</div>
{aiDraftModal && (
<div className="modal-overlay" onClick={() => setAiDraftModal(null)}>
<div className="modal-box" onClick={e => e.stopPropagation()}>
<div className="modal-head">
<div className="panel-title"><Sparkles size={15} />{aiDraftModal.channel === 'email' ? 'AI生成邮件' : 'AI生成WhatsApp话术'}</div>
<button className="btn btn-ghost btn-sm" onClick={() => setAiDraftModal(null)}><X size={14} /></button>
</div>
<div className="modal-body">
{aiDraftModal.channel === 'email' && (
<div className="field"><span className="field-label">主题 Subject</span><input className="input" value={aiDraftModal.subject} onChange={e => setAiDraftModal(m => ({ ...m, subject: e.target.value }))} /></div>
)}
<div className="field">
<span className="field-label">{aiDraftModal.channel === 'email' ? '正文 Body' : '消息内容'}</span>
<textarea
className="textarea" rows={aiDraftModal.channel === 'email' ? 10 : 6}
value={aiDraftModal.body} onChange={e => setAiDraftModal(m => ({ ...m, body: e.target.value }))}
/>
</div>
</div>
<div className="modal-foot">
{aiDraftModal.channel === 'email' && <button className="btn" onClick={() => copyText(aiDraftModal.subject)}><Copy size={12} />复制主题</button>}
<button className="btn btn-primary" onClick={() => copyText(aiDraftModal.body)}><Copy size={12} />复制{aiDraftModal.channel === 'email' ? '正文' : '内容'}</button>
</div>
</div>
</div>
)}
{emailModal && (
<div className="modal-overlay" onClick={() => setEmailModal(null)}>
<div className="modal-box" onClick={e => e.stopPropagation()}>
<div className="modal-head"><div className="panel-title"><Mail size={15} />邮件文案</div><button className="btn btn-ghost btn-sm" onClick={() => setEmailModal(null)}><X size={14} /></button></div>
<div className="modal-body">
<div className="field"><span className="field-label">主题 Subject</span><input className="input" readOnly value={emailModal.subject} /></div>
<div className="field"><span className="field-label">正文 Body</span><textarea className="textarea" rows={10} value={emailModal.body} onChange={e => setEmailModal(m => ({ ...m, body: e.target.value }))} /></div>
</div>
<div className="modal-foot">
<button className="btn" onClick={() => copyText(emailModal.subject)}><Copy size={12} />复制主题</button>
<button className="btn btn-primary" onClick={() => copyText(emailModal.body)}><Copy size={12} />复制正文</button>
</div>
</div>
</div>
)}
{exportOpen && (
<div className="modal-overlay" onClick={() => setExportOpen(false)}>
<div className="modal-box" onClick={e => e.stopPropagation()}>
<div className="modal-head"><div className="panel-title">数据备份JSON</div><button className="btn btn-ghost btn-sm" onClick={() => setExportOpen(false)}><X size={14} /></button></div>
<div className="modal-body">
<textarea className="textarea" rows={14} readOnly value={JSON.stringify({ customers, products, settings, quotes, sellerProfiles, certifications, productCategories, packingLists, catalogDocs }, null, 2)} />
</div>
<div className="modal-foot">
<button className="btn btn-primary" onClick={() => copyText(JSON.stringify({ customers, products, settings, quotes, sellerProfiles, certifications, productCategories, packingLists, catalogDocs }, null, 2))}><Copy size={12} />复制全部</button>
</div>
</div>
</div>
)}
{sellerManagerOpen && (
<div className="modal-overlay" onClick={() => setSellerManagerOpen(false)}>
<div className="modal-box" style={{ maxWidth: 620 }} onClick={e => e.stopPropagation()}>
<div className="modal-head"><div className="panel-title"><Building2 size={15} />管理卖方信息</div><button className="btn btn-ghost btn-sm" onClick={() => setSellerManagerOpen(false)}><X size={14} /></button></div>
<div className="modal-body">
{sellerProfiles.map(sp => (
<div key={sp.id} className="panel section-gap">
<div className="panel-body" style={{ padding: 14 }}>
<div className="grid-2">
<div className="field"><span className="field-label">公司名称</span><input className="input" value={sp.name} onChange={e => updateSellerProfile(sp.id, { name: e.target.value })} /></div>
<div className="field">
<span className="field-label">Logo</span>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
{sp.logoFileName && <img src={`/uploads/${sp.id}/${sp.logoFileName}`} alt="logo" style={{ height: 28, maxWidth: 90, objectFit: 'contain' }} />}
<label className="btn btn-sm" style={{ cursor: 'pointer' }}>
<ImageIcon size={12} />{sellerLogoUploading ? '上传中…' : '上传Logo'}
<input type="file" accept="image/*" style={{ display: 'none' }} onChange={e => handleSellerLogoPick(sp.id, e.target.files && e.target.files[0])} />
</label>
</div>
</div>
</div>
<div className="field"><span className="field-label">地址</span><textarea className="textarea" style={{ fontFamily: 'inherit', fontSize: 13, minHeight: 50 }} value={sp.address} onChange={e => updateSellerProfile(sp.id, { address: e.target.value })} /></div>
<div className="field"><span className="field-label">银行信息仅Invoice使用可留空</span><textarea className="textarea" style={{ fontFamily: 'inherit', fontSize: 13, minHeight: 50 }} value={sp.bankInfo} onChange={e => updateSellerProfile(sp.id, { bankInfo: e.target.value })} /></div>
<button className="btn btn-sm btn-danger" onClick={() => deleteSellerProfile(sp.id)}><Trash2 size={12} />删除这个卖方信息</button>
</div>
</div>
))}
<button className="btn" style={{ width: '100%', justifyContent: 'center' }} onClick={addSellerProfile}><Plus size={13} />新增卖方信息</button>
</div>
<div className="modal-foot">
<button className="btn btn-primary" onClick={() => setSellerManagerOpen(false)}>完成</button>
</div>
</div>
</div>
)}
{taxManageMode && (() => {
const targetCat = productDraft ? productCategories.find(c => c.id === productDraft.category) : null;
const closeModal = () => { setTaxManageMode(null); cancelAddPill(); };
const titleMap = { category: '产品大类管理', brand: '品牌管理', type: '品类管理' };
return (
<div className="modal-overlay" onClick={closeModal}>
<div className="modal-box" style={{ maxWidth: 460 }} onClick={e => e.stopPropagation()}>
<div className="modal-head">
<div className="panel-title"><Settings2 size={15} />{titleMap[taxManageMode]}{targetCat && taxManageMode !== 'category' ? ` · ${targetCat.name}` : ''}</div>
<button className="btn btn-ghost btn-sm" onClick={closeModal}><X size={14} /></button>
</div>
<div className="modal-body">
{taxManageMode === 'category' ? (
<>
{productCategories.length === 0 && <div className="empty-state" style={{ padding: '16px 0' }}>暂无类目</div>}
{productCategories.map(c => (
<div key={c.id} className="tax-manage-list-row">
<div>
<div style={{ fontWeight: 700, fontSize: 13 }}>{c.name}</div>
<div style={{ fontSize: 11.5, color: 'var(--ink-soft)', marginTop: 2 }}>{c.brands.length} 个品牌 · {c.types.length} 个品类</div>
</div>
<button className="btn btn-sm btn-ghost btn-danger" onClick={() => deleteProductCategory(c)}><Trash2 size={12} /></button>
</div>
))}
<div style={{ marginTop: 10 }}>
{addingPill && addingPill.kind === 'category' ? (
<input
className="input" autoFocus value={addingPillValue}
onChange={e => setAddingPillValue(e.target.value)}
onBlur={confirmAddPill}
onKeyDown={e => { if (e.key === 'Enter') e.currentTarget.blur(); if (e.key === 'Escape') cancelAddPill(); }}
placeholder="新类目名称,如:精密空调"
/>
) : (
<button className="btn" style={{ width: '100%', justifyContent: 'center' }} onClick={() => openAddPill(null, 'category')}><Plus size={13} />新增类目</button>
)}
</div>
</>
) : targetCat ? (
<TaxPillRow
label={null} values={taxManageMode === 'brand' ? targetCat.brands : targetCat.types}
catId={targetCat.id} kind={taxManageMode}
addingPill={addingPill} addingPillValue={addingPillValue} setAddingPillValue={setAddingPillValue}
onOpenAdd={openAddPill} onConfirmAdd={confirmAddPill} onCancelAdd={cancelAddPill} onRemove={removeTaxonomyValue}
/>
) : (
<div className="empty-state" style={{ padding: '16px 0' }}>请先在表单里选择产品大类再来管理{taxManageMode === 'brand' ? '品牌' : '品类'}</div>
)}
</div>
<div className="modal-foot">
<button className="btn btn-primary" onClick={closeModal}>完成</button>
</div>
</div>
</div>
);
})()}
{aiImportOpen && (
<div className="modal-overlay" onClick={aiImportBusy ? undefined : closeAiImport}>
<div className="modal-box" style={{ maxWidth: aiImportDrafts ? 760 : 480 }} onClick={e => e.stopPropagation()}>
<div className="modal-head">
<div className="panel-title"><Sparkles size={15} />AI批量导入产品</div>
<button className="btn btn-ghost btn-sm" onClick={closeAiImport}><X size={14} /></button>
</div>
<div className="modal-body">
{!aiImportDrafts ? (
<>
<div className="disclaimer">
支持上传PDF图片HTML或Word/PPT/Excel.docx/.pptx/.xlsx形式的产品目录可以一次选多个文件比如一份多页PDF或者拍的多张照片
AI会自动识别里面有几个产品型号并提取型号/品牌/规格参数之后可以在预览里检查修改确认无误再批量建档
<br />
PDF会先自动判断有没有文字层正式排版非扫描件的PDF会直接读取文字更快更准只有扫描件/纯图片排版的PDF才会走OCR
<br />
如果原始文件是水印很重排版复杂OCR容易识别错的图片可以先用其他AI工具如Gemini把表格转换成结构清晰的HTML文件再上传HTML会跳过OCR直接读取准确率通常明显更高
</div>
<div className="grid-2" style={{ marginBottom: 4 }}>
<div className="field">
<span className="field-label">这批产品的大类可选填了AI就不用自己猜</span>
<input
className="input" list="ai-import-category-list" value={aiImportHintCategory}
onChange={e => setAiImportHintCategory(e.target.value)}
placeholder="如UPS不间断电源" disabled={aiImportBusy}
/>
<datalist id="ai-import-category-list">
{productCategories.map(c => <option key={c.id} value={c.name} />)}
</datalist>
</div>
<div className="field">
<span className="field-label">品牌可选</span>
<input
className="input" list="ai-import-brand-list" value={aiImportHintBrand}
onChange={e => setAiImportHintBrand(e.target.value)}
placeholder="如:山克 SKE" disabled={aiImportBusy}
/>
<datalist id="ai-import-brand-list">
{(productCategories.find(c => c.name === aiImportHintCategory.trim())?.brands || []).map(b => <option key={b} value={b} />)}
</datalist>
</div>
</div>
<div className="field">
<span className="field-label">细分品类可选</span>
<input
className="input" list="ai-import-type-list" value={aiImportHintType}
onChange={e => setAiImportHintType(e.target.value)}
placeholder="如迷你UPS" disabled={aiImportBusy}
/>
<datalist id="ai-import-type-list">
{(productCategories.find(c => c.name === aiImportHintCategory.trim())?.types || []).map(t => <option key={t} value={t} />)}
</datalist>
</div>
<label className="tax-add-category" style={{ display: 'block', cursor: aiImportBusy ? 'default' : 'pointer' }}>
<input
type="file"
accept="application/pdf,image/*,.html,.htm,text/html,.docx,.pptx,.xlsx,application/vnd.openxmlformats-officedocument.wordprocessingml.document,application/vnd.openxmlformats-officedocument.presentationml.presentation,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
multiple style={{ display: 'none' }}
disabled={aiImportBusy}
onChange={e => { handleAiImportFiles(e.target.files); e.target.value = ''; }}
/>
{aiImportBusy ? (
<>
<div className="catalog-doc-spinner" style={{ margin: '0 auto 10px' }} />
正在识别中视文件大小可能需要几十秒
</>
) : (
<><Plus size={14} style={{ verticalAlign: -2, marginRight: 4 }} />点击选择PDF图片HTML或Word/PPT/Excel可多选</>
)}
</label>
{aiImportError && <div style={{ color: 'var(--led-red)', fontSize: 12.5, marginTop: 10 }}>{aiImportError}</div>}
</>
) : (
<>
<div className="disclaimer">
识别出 {aiImportDrafts.length} 个产品请检查型号/品牌/大类/细分品类勾掉不需要导入的行确认后点"确认导入"
其余规格字段功率/电压/输出口/电池/尺寸等已经一并识别好导入后可以在产品详情里继续核对修改
</div>
{aiImportTruncatedWarning && (
<div className="disclaimer" style={{ background: '#FDECEC', color: 'var(--led-red)', marginTop: -6 }}>
AI这次输出的内容超出了长度限制可能还有没识别完的产品被截断丢弃了建议核对一下上面这 {aiImportDrafts.length} 个是不是齐了
如果少了把剩下的文件单独再上传一次比如分成更小的批次每批3-5个文件
</div>
)}
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, maxHeight: 420, overflowY: 'auto' }}>
{aiImportDrafts.map(d => (
<div key={d._localId} className="tax-manage-list-row" style={{ flexDirection: 'column', alignItems: 'stretch', gap: 8 }}>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 8 }}>
<input
type="checkbox" checked={d.include}
onChange={e => updateAiImportDraft(d._localId, { include: e.target.checked })}
style={{ marginTop: 10 }}
/>
<div style={{ flex: 1, display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
<input className="input" value={d.name} onChange={e => updateAiImportDraft(d._localId, { name: e.target.value })} placeholder="型号" />
<input className="input" value={d.brand} onChange={e => updateAiImportDraft(d._localId, { brand: e.target.value })} placeholder="品牌" />
<input className="input" value={d.category} onChange={e => updateAiImportDraft(d._localId, { category: e.target.value })} placeholder="产品大类" />
<input className="input" value={d.type} onChange={e => updateAiImportDraft(d._localId, { type: e.target.value })} placeholder="细分品类" />
</div>
<button className="btn btn-sm btn-ghost btn-danger" onClick={() => removeAiImportDraft(d._localId)}><Trash2 size={12} /></button>
</div>
<div style={{ fontSize: 11.5, color: 'var(--ink-soft)', paddingLeft: 26 }}>
{[
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('/')}` : '',
d.sellingPoints ? `✓已识别对客卖点(${d.sellingPoints.split('\n').filter(Boolean).length}条)` : '',
].filter(Boolean).join(' · ') || '(无更多规格信息)'}
</div>
</div>
))}
</div>
</>
)}
</div>
<div className="modal-foot">
<button className="btn" onClick={closeAiImport}>取消</button>
{aiImportDrafts && (
<button className="btn btn-primary" onClick={confirmAiImport}>
<Check size={13} />确认导入 {aiImportDrafts.filter(d => d.include).length} 个产品
</button>
)}
</div>
</div>
</div>
)}
{toast && <div className="toast">{toast}</div>}
</div>
);
}
/* ----------------------------- 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 (
<div className="panel">
<div className="panel-header"><div className="panel-title"><Edit2 size={14} />{draft.company ? '编辑客户' : '新建客户'}</div></div>
<div className="panel-body" style={{ padding: 18 }}>
<div className="grid-2">
<div className="field"><span className="field-label">公司名称</span><input className="input" value={draft.company} onChange={e => set({ company: e.target.value })} /></div>
<div className="field"><span className="field-label">联系人</span><input className="input" value={draft.contact} onChange={e => set({ contact: e.target.value })} /></div>
<div className="field">
<span className="field-label">国家</span>
<input
className="input" list="country-options" value={draft.country}
onChange={e => handleCountryChange(e.target.value)}
placeholder="输入国家名称,会自动匹配地区和电话区号"
/>
<datalist id="country-options">
{COUNTRIES.map(c => <option key={c.name} value={c.name}>{c.en}</option>)}
</datalist>
</div>
<div className="field">
<span className="field-label">地区{COUNTRY_BY_NAME[draft.country] ? '(已自动匹配)' : ''}</span>
<select className="select" value={draft.region} onChange={e => set({ region: e.target.value })}>
{REGIONS.map(r => <option key={r.id} value={r.id}>{r.label}</option>)}
</select>
</div>
<div className="field"><span className="field-label">邮箱</span><input className="input" value={draft.email} onChange={e => set({ email: e.target.value })} /></div>
<div className="field">
<span className="field-label">电话 / WhatsApp{COUNTRY_BY_NAME[draft.country] ? `${COUNTRY_BY_NAME[draft.country].dial}` : ''}</span>
<input className="input" value={draft.phone} onChange={e => set({ phone: e.target.value })} />
</div>
<div className="field"><span className="field-label">网站</span><input className="input" value={draft.website} onChange={e => set({ website: e.target.value })} /></div>
<div className="field">
<span className="field-label">客户来源</span>
<select className="select" value={draft.source} onChange={e => set({ source: e.target.value })}>
<option value="">请选择</option>
{CUSTOMER_SOURCES.map(s => <option key={s} value={s}>{s}</option>)}
</select>
</div>
</div>
<div className="field"><span className="field-label">关注产品线</span><ProductPills value={draft.productLines} onChange={v => set({ productLines: v })} /></div>
{draft.productLines.length > 0 && (
<div className="grid-2">
<div className="field"><span className="field-label">意向型号</span><input className="input" value={draft.intendedModel} onChange={e => set({ intendedModel: e.target.value })} /></div>
<div className="field"><span className="field-label">数量</span><input className="input" value={draft.intendedQty} onChange={e => set({ intendedQty: e.target.value })} /></div>
</div>
)}
<div className="grid-2">
<div className="field"><span className="field-label">上次联系日期</span><input type="date" className="input" value={draft.lastContact} onChange={e => set({ lastContact: e.target.value })} /></div>
<div className="field"><span className="field-label">跟进周期留空用默认值</span><input type="number" min="1" className="input" value={draft.followUpDays} onChange={e => set({ followUpDays: e.target.value })} /></div>
</div>
<div className="field"><span className="field-label">备注</span><textarea className="textarea" style={{ fontFamily: 'inherit', fontSize: 13 }} value={draft.tags} onChange={e => set({ tags: e.target.value })} /></div>
<CollapsibleSection title="货代信息" defaultOpen={!!(draft.warehouseAddress || draft.freightContact || draft.shippingMarkNote)}>
<div className="grid-2">
<div className="field"><span className="field-label">仓库地址</span><input className="input" value={draft.warehouseAddress} onChange={e => set({ warehouseAddress: e.target.value })} /></div>
<div className="field"><span className="field-label">货代联系方式</span><input className="input" value={draft.freightContact} onChange={e => set({ freightContact: e.target.value })} /></div>
</div>
<div className="field"><span className="field-label">箱唛 / 备注</span><textarea className="textarea" style={{ fontFamily: 'inherit', fontSize: 13 }} value={draft.shippingMarkNote} onChange={e => set({ shippingMarkNote: e.target.value })} /></div>
</CollapsibleSection>
<div className="field"><span className="field-label">客户背景信息调查</span><textarea className="textarea" style={{ fontFamily: 'inherit', fontSize: 13 }} value={draft.notes} onChange={e => set({ notes: e.target.value })} /></div>
</div>
<div className="modal-foot"><button className="btn" onClick={onCancel}>取消</button><button className="btn btn-primary" onClick={onSave}><Save size={13} />保存</button></div>
</div>
);
}
function ContactInfo({ icon, label, value, active, onToggle }) {
return (
<div>
<div
className="field-label"
style={{ display: 'flex', alignItems: 'center', gap: 5, cursor: 'pointer', color: active ? 'var(--led-green)' : undefined, userSelect: 'none' }}
onClick={onToggle}
title="点击标记 / 取消为常用联系方式"
>
{icon}{label}{active ? '(常用)' : ''}
</div>
<div style={{ fontSize: 13 }}>{value}</div>
</div>
);
}
function CollapsibleSection({ title, count, defaultOpen, children }) {
const [open, setOpen] = useState(!!defaultOpen);
return (
<div className="field">
<div
style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', cursor: 'pointer', padding: '4px 0' }}
onClick={() => setOpen(o => !o)}
>
<span className="field-label" style={{ marginBottom: 0 }}>{title}{typeof count === 'number' ? `${count}` : ''}</span>
<ChevronRight size={14} color="var(--ink-soft)" style={{ transform: open ? 'rotate(90deg)' : 'none', transition: 'transform 0.15s' }} />
</div>
{open && <div style={{ marginTop: 6 }}>{children}</div>}
</div>
);
}
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 (
<div className="panel">
<div className="panel-header">
<div className="panel-title">
<div className={`led-dot ${s === 'overdue' ? 'red' : s === 'due' ? 'amber' : 'green'}`} />
{customerTitle(c)}
<span className={`vip-toggle ${c.isVip ? 'active' : ''}`} onClick={onToggleVip} title={c.isVip ? '取消重点客户标记' : '标记为重点客户'}>
<Crown size={15} fill={c.isVip ? '#C9942E' : 'none'} />
</span>
<span className={`vip-toggle ${c.hasOrdered ? 'active-red' : ''}`} onClick={onToggleOrdered} title={c.hasOrdered ? '取消已下单标记' : '标记为已下单'}>
<Crown size={15} fill={c.hasOrdered ? '#C2403A' : 'none'} />
</span>
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button className="btn btn-sm" onClick={onEdit}><Edit2 size={12} />编辑</button>
<button className="btn btn-sm btn-ghost btn-danger" onClick={onDelete}><Trash2 size={12} /></button>
</div>
</div>
<div className="panel-body" style={{ padding: 18 }}>
<div className="grid-2" style={{ marginBottom: 16 }}>
<Info icon={<Building2 size={13} />} label="公司名称" value={c.company || '—'} />
<Info icon={<Building2 size={13} />} label="联系人" value={c.contact || '—'} />
<Info icon={<Globe size={13} />} label="地区 / 国家" value={`${regionLabel(c.region)} · ${c.country || '—'}`} />
<ContactInfo icon={<Mail size={13} />} label="邮箱" value={c.email || '—'} active={preferred.includes('email')} onToggle={() => onTogglePreferred('email')} />
<ContactInfo icon={<Phone size={13} />} label="电话" value={c.phone || '—'} active={preferred.includes('phone')} onToggle={() => onTogglePreferred('phone')} />
<Info icon={<Clock size={13} />} label="当地时间" value={lt ? `${lt.time}${lt.en}` : '—'} />
<Info icon={<Globe size={13} />} label="网站" value={c.website ? <a href={c.website} target="_blank" rel="noreferrer" style={{ color: 'var(--blue)' }}>{c.website}</a> : '—'} />
</div>
{c.source && <div className="field"><span className="field-label">客户来源</span><span className="badge badge-region">{c.source}</span></div>}
{c.productLines && c.productLines.length > 0 && (
<div className="field">
<span className="field-label">关注产品线</span>
<div className="checkbox-row">{c.productLines.map(p => <span key={p} className="badge badge-region">{p}</span>)}</div>
{(c.intendedModel || c.intendedQty) && (
<div style={{ fontSize: 12.5, marginTop: 6, color: 'var(--ink-soft)' }}>
意向型号{c.intendedModel || '—'}{c.intendedQty ? ` × ${c.intendedQty}` : ''}
</div>
)}
</div>
)}
{c.tags && <div className="field"><span className="field-label">备注</span><div style={{ fontSize: 12.5, whiteSpace: 'pre-wrap' }}>{c.tags}</div></div>}
<CollapsibleSection title="货代信息">
{!(c.warehouseAddress || c.freightContact || c.shippingMarkNote) ? (
<div className="row-sub" style={{ padding: '8px 0' }}>暂无货代信息</div>
) : (
<>
<div className="grid-2">
{c.warehouseAddress && <Info label="仓库地址" value={c.warehouseAddress} />}
{c.freightContact && <Info label="货代联系方式" value={c.freightContact} />}
</div>
{c.shippingMarkNote && <div style={{ fontSize: 12.5, marginTop: 6, whiteSpace: 'pre-wrap' }}>箱唛 / 备注{c.shippingMarkNote}</div>}
</>
)}
</CollapsibleSection>
{c.notes && <div className="field"><span className="field-label">客户背景信息调查</span><div style={{ fontSize: 12.5, whiteSpace: 'pre-wrap' }}>{c.notes}</div></div>}
<div style={{ display: 'flex', gap: 8, margin: '14px 0' }}>
<span className="row-sub" style={{ flex: 1 }}>上次联系{fmtDate(c.lastContact)} 状态{statusLabel(s)}</span>
<button className="btn btn-sm" onClick={onMarkContacted}><Check size={12} />标记今日已联系</button>
<button className="btn btn-sm" disabled={!c.email} onClick={onEmail}><Mail size={12} />生成跟进邮件</button>
</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center', margin: '0 0 14px', flexWrap: 'wrap' }}>
<button className="btn btn-sm" disabled={!aiConfigHasKey || aiGenerating} onClick={() => onGenerateAi('email')}>
<Sparkles size={12} />{aiGenerating ? '生成中…' : 'AI生成邮件'}
</button>
<button className="btn btn-sm" disabled={!aiConfigHasKey || aiGenerating} onClick={() => onGenerateAi('whatsapp')}>
<Sparkles size={12} />{aiGenerating ? '生成中…' : 'AI生成WhatsApp话术'}
</button>
{!aiConfigHasKey && <span className="row-sub">未配置DeepSeek"设置"里填API Key</span>}
</div>
<div className="field">
<span className="field-label">客户文件管理</span>
<div style={{ display: 'flex', gap: 8, alignItems: 'stretch', flexWrap: 'wrap', marginBottom: 8 }}>
<label
className={`dropzone ${fileDragActive ? 'drag-active' : ''}`}
onDragOver={onFileDragOver} onDragLeave={onFileDragLeave} onDrop={onFileDrop}
>
<input
key={fileInputKey} type="file"
onChange={e => onPickAttachment(e.target.files && e.target.files[0])}
style={{ display: 'none' }}
/>
<Paperclip size={15} style={{ marginBottom: 4 }} />
<div>{fileDragActive ? '松开鼠标完成选择' : attachmentFileName ? `已选择:${attachmentFileName}` : '点击选择文件,或把文件拖到这里'}</div>
</label>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, flex: '1 1 160px' }}>
<select className="select" value={attachmentCategory} onChange={e => setAttachmentCategory(e.target.value)}>
{FILE_CATEGORIES.map(cat => <option key={cat} value={cat}>{cat}</option>)}
</select>
<input
className="input" placeholder="备注(可选)"
value={attachmentNote} onChange={e => setAttachmentNote(e.target.value)}
/>
<button className="btn btn-sm" disabled={uploading} onClick={onUploadAttachment} style={{ justifyContent: 'center' }}>
<Plus size={12} />{uploading ? '上传中…' : '上传'}
</button>
</div>
</div>
</div>
<CollapsibleSection title="文件列表" count={(c.attachments || []).length} defaultOpen={(c.attachments || []).length > 0}>
{(!c.attachments || c.attachments.length === 0) ? (
<div className="row-sub" style={{ padding: '8px 0' }}>暂无文件</div>
) : (
<>
<div style={{ display: 'flex', gap: 8, marginBottom: 8 }}>
<input
className="input" style={{ flex: 1 }} placeholder="搜索文件名 / 备注"
value={fileSearch} onChange={e => setFileSearch(e.target.value)}
/>
<select className="select" style={{ flex: '0 1 120px' }} value={fileCategoryFilter} onChange={e => setFileCategoryFilter(e.target.value)}>
<option value="all">全部类别</option>
{FILE_CATEGORIES.map(cat => <option key={cat} value={cat}>{cat}</option>)}
</select>
</div>
{filteredAttachments.length === 0 ? (
<div className="row-sub" style={{ padding: '8px 0' }}>没有匹配的文件</div>
) : filteredAttachments.map((a, i) => (
<div key={a.fileName + i} style={{ display: 'flex', gap: 10, padding: '8px 0', borderBottom: '1px solid var(--line)', alignItems: 'flex-start' }}>
<Paperclip size={13} color="var(--ink-soft)" style={{ marginTop: 2, flexShrink: 0 }} />
<div style={{ flex: 1, minWidth: 0 }}>
<span className="crm-mono" style={{ fontSize: 11.5, color: 'var(--ink-soft)' }}>{a.date}</span>
{' '}<span className="badge badge-region">{a.category || 'Other'}</span>
<div style={{ fontSize: 12.5, wordBreak: 'break-word', marginTop: 2 }}>
<a href={`/uploads/${c.id}/${a.fileName}`} target="_blank" rel="noreferrer" style={{ color: 'var(--blue)' }}>{a.originalName}</a>
{a.note && <span style={{ color: 'var(--ink-soft)' }}> {a.note}</span>}
</div>
</div>
<button className="btn btn-sm btn-ghost btn-danger" onClick={() => onDeleteAttachment(a.fileName)}><Trash2 size={12} /></button>
</div>
))}
</>
)}
</CollapsibleSection>
</div>
</div>
);
}
/* ----------------------------- Just My Socks ----------------------------- */
const JMS_PROTO_META = {
'ss': { label: 'SS' },
'vmess': { label: 'VMess' },
'vless-reality': { label: 'VLESS Reality' },
'vless-cdn': { label: 'VLESS CDN' },
};
const JMS_PROTOCOLS = ['ss', 'vless-reality'];
function jmsTimeAgo(ts) {
if (!ts) return '尚未检测';
const sec = Math.floor((Date.now() - ts) / 1000);
if (sec < 60) return `${sec}秒前`;
if (sec < 3600) return `${Math.floor(sec / 60)}分钟前`;
return `${Math.floor(sec / 3600)}小时前`;
}
// 按协议生成真实格式的订阅链接ss:// / vmess:// / vless://),一键导入和二维码都用这个
function jmsBuildLink(line) {
const { protocol, addr, port, name } = line;
const tag = encodeURIComponent(name || addr);
if (protocol === 'ss') {
const userinfo = btoa(`${line.method}:${line.password}`).replace(/=+$/, '');
return `ss://${userinfo}@${addr}:${port}#${tag}`;
}
if (protocol === 'vmess') {
const obj = {
v: '2', ps: name || addr, add: addr, port: String(port), id: line.uuid, aid: line.alterId || '0',
net: 'ws', type: 'none', host: line.host || addr, path: '/', tls: 'tls',
};
return 'vmess://' + btoa(JSON.stringify(obj));
}
if (protocol === 'vless-cdn') {
const q = new URLSearchParams({
encryption: 'none', security: 'tls', type: 'ws', fp: 'chrome',
host: line.host || addr, path: line.ws_path || '/stickrouter', sni: line.sni || addr,
});
return `vless://${line.uuid}@${addr}:${port}?${q.toString()}#${tag}`;
}
if (protocol === 'vless-reality') {
const q = new URLSearchParams({
encryption: 'none', security: 'reality', sni: line.sni, fp: line.fingerprint || 'chrome',
pbk: line.publicKey, sid: line.shortId, type: 'tcp', flow: line.flow || 'xtls-rprx-vision',
});
return `vless://${line.uuid}@${addr}:${port}?${q.toString()}#${tag}`;
}
return '';
}
async function jmsResolveIp(domain) {
const res = await fetch(`https://cloudflare-dns.com/dns-query?name=${encodeURIComponent(domain)}&type=A`, {
headers: { Accept: 'application/dns-json' },
});
const data = await res.json();
const answer = (data.Answer || []).find(a => a.type === 1);
return answer ? answer.data : null;
}
function JmsLineCard({ line, ip, onRefreshIp, onCheck, onCopy, onQr, onDuplicate, onEdit, onDelete, dragging, onCardDragStart, onCardDragOver, onCardDrop, onCardDragEnd }) {
const meta = JMS_PROTO_META[line.protocol];
const status = line.monitorStatus || 'normal';
const dotClass = status === 'abnormal' ? 'jms-dot-abnormal' : status === 'checking' ? 'jms-dot-checking' : 'jms-dot-normal';
const statusLabel = status === 'abnormal' ? '异常,无法连接' : status === 'checking' ? '检测中…' : '正常';
const isVlessCdn = line.protocol === 'vless-cdn';
const checkNote = isVlessCdn
? '检测方式TCP端口连通性测试HTTP状态码/WS握手结果仅作为附加参考不参与判定'
: '检测方式TCP端口连通性测试';
const ipDisplay = ip === 'resolving' ? '解析中…' : (ip || '试试解析当前IP');
return (
<div
className={`jms-card ${dragging ? 'jms-card-dragging' : ''}`}
style={{ opacity: dragging ? 0.5 : 1 }}
draggable
onDragStart={onCardDragStart}
onDragOver={onCardDragOver}
onDrop={onCardDrop}
onDragEnd={onCardDragEnd}
>
<div className="jms-card-top">
<div className="jms-card-title">
<GripVertical size={14} className="drag-handle" />
<span className={`jms-dot ${dotClass}`} />
<span className="jms-card-name">{line.name || line.addr}</span>
</div>
<span className={`jms-proto-chip jms-proto-${line.protocol}`}>{meta.label}</span>
</div>
<div className="jms-monitor-row">
<span className={`jms-status-text ${status === 'abnormal' ? 'jms-status-abnormal' : ''}`}> {statusLabel} · {jmsTimeAgo(line.lastChecked)}</span>
<button className="jms-check-btn" disabled={status === 'checking'} onClick={() => onCheck(line.id)}>
{status === 'checking' ? '检测中' : '立即检测'}
</button>
</div>
<div className="jms-check-note">{checkNote}</div>
<div className="jms-terminal">
<div className="jms-terminal-row"><span className="jms-terminal-key">域名</span><span className="jms-terminal-val">{line.addr}</span></div>
<div className="jms-terminal-row">
<span className="jms-terminal-key">解析IP</span>
<span className={`jms-terminal-val jms-ip ${!ip || ip === 'resolving' ? 'jms-ip-pending' : ''}`}>{ipDisplay}</span>
<button className="jms-refresh-btn" onClick={() => onRefreshIp(line.id, line.addr)} title="重新解析">
<RefreshCw size={13} className={ip === 'resolving' ? 'jms-spin' : ''} />
</button>
</div>
<div className="jms-terminal-row"><span className="jms-terminal-key">端口</span><span className="jms-terminal-val">{line.port}</span></div>
</div>
<div className="jms-card-actions">
<button className="btn btn-sm" onClick={() => onCopy(line)}><Copy size={13} />复制链接</button>
<button className="btn btn-sm" onClick={() => onQr(line)}><QrCode size={13} />二维码</button>
<button className="btn btn-sm btn-ghost" onClick={() => onDuplicate(line)} title="复制为新线路"><Layers size={13} /></button>
<button className="btn btn-sm btn-ghost" onClick={() => onEdit(line)} title="编辑"><Edit2 size={13} /></button>
<button className="btn btn-sm btn-ghost btn-danger" onClick={() => onDelete(line.id)} title="删除"><Trash2 size={13} /></button>
</div>
</div>
);
}
function JmsLineFormFields({ group, protocol, draft, setDraft }) {
const set = (patch) => setDraft(d => ({ ...d, ...patch }));
if (group === 'selfbuilt') {
return (
<>
<div className="field"><span className="field-label">UUID</span><input className="input" value={draft.uuid || ''} onChange={e => set({ uuid: e.target.value })} placeholder="用户UUID" /></div>
<div className="grid-2">
<div className="field"><span className="field-label">WS路径</span><input className="input" value={draft.ws_path || ''} onChange={e => set({ ws_path: e.target.value })} placeholder="/stickrouter" /></div>
<div className="field"><span className="field-label">SNI / Host</span><input className="input" value={draft.sni || ''} onChange={e => set({ sni: e.target.value })} placeholder="留空则用服务器地址" /></div>
</div>
<div className="disclaimer">* vless加密none · 勾选tls · 指纹chrome固定不需要手动填</div>
</>
);
}
if (protocol === 'ss') {
return (
<div className="grid-2">
<div className="field"><span className="field-label">加密方式</span><input className="input" value={draft.method || 'aes-256-gcm'} onChange={e => set({ method: e.target.value })} /></div>
<div className="field"><span className="field-label">密码</span><input className="input" value={draft.password || ''} onChange={e => set({ password: e.target.value })} placeholder="连接密码" /></div>
</div>
);
}
if (protocol === 'vless-reality') {
return (
<>
<div className="field"><span className="field-label">UUID</span><input className="input" value={draft.uuid || ''} onChange={e => set({ uuid: e.target.value })} placeholder="用户UUID" /></div>
<div className="grid-2">
<div className="field"><span className="field-label">Public Key</span><input className="input" value={draft.publicKey || ''} onChange={e => set({ publicKey: e.target.value })} /></div>
<div className="field"><span className="field-label">Short ID</span><input className="input" value={draft.shortId || ''} onChange={e => set({ shortId: e.target.value })} /></div>
</div>
<div className="grid-2">
<div className="field"><span className="field-label">SNI伪装域名</span><input className="input" value={draft.sni || ''} onChange={e => set({ sni: e.target.value })} placeholder="如www.microsoft.com" /></div>
<div className="field"><span className="field-label">Fingerprint</span><input className="input" value={draft.fingerprint || 'chrome'} onChange={e => set({ fingerprint: e.target.value })} /></div>
</div>
<div className="disclaimer">* 流控xtls-rprx-vision · 传输层安全reality固定不需要手动填</div>
</>
);
}
return null;
}
function JmsPage({ lines, onSave, onDelete, onCheck, onNotify, dragIdx, onDragStart, onDrop, onDragEnd }) {
const [ipMap, setIpMap] = useState({});
const [modalOpen, setModalOpen] = useState(false);
const [editingId, setEditingId] = useState(null);
const [group, setGroup] = useState('selfbuilt');
const [protocol, setProtocol] = useState('vless-cdn');
const [draft, setDraft] = useState({});
const [qrLine, setQrLine] = useState(null);
const [qrDataUrl, setQrDataUrl] = useState('');
const total = lines.length;
const ssCount = lines.filter(l => l.protocol === 'ss').length;
const vlessCount = lines.filter(l => l.protocol.startsWith('vless')).length;
async function refreshIp(id, addr) {
setIpMap(m => ({ ...m, [id]: 'resolving' }));
try {
const ip = await jmsResolveIp(addr);
setIpMap(m => ({ ...m, [id]: ip || '解析失败' }));
} catch (e) {
setIpMap(m => ({ ...m, [id]: '解析失败(网络问题)' }));
}
}
// 打开线路管理页面时自动解析所有线路当前DNS IP只对还没解析过的线路发请求
// 避免拖拽排序等操作触发的重渲染重复请求已解析过的域名)
useEffect(() => {
lines.forEach(line => {
if (!(line.id in ipMap)) refreshIp(line.id, line.addr);
});
}, [lines]);
function copyLink(line) {
const link = jmsBuildLink(line);
navigator.clipboard.writeText(link).then(() => onNotify('订阅链接已复制')).catch(() => onNotify('复制失败,请手动选择'));
}
async function openQr(line) {
setQrLine(line);
setQrDataUrl('');
try {
const mod = await import('qrcode');
const QRCode = mod.default || mod;
const link = jmsBuildLink(line);
const dataUrl = await QRCode.toDataURL(link, { width: 220, margin: 1, color: { dark: '#18233A', light: '#ffffff' } });
setQrDataUrl(dataUrl);
} catch (e) {
onNotify('生成二维码失败:' + e.message);
}
}
function openAddModal() {
setEditingId(null);
setGroup('selfbuilt');
setProtocol('vless-cdn');
setDraft({ name: '', addr: 'pb.impromx.com', port: '443', ws_path: '/stickrouter' });
setModalOpen(true);
}
function openEditModal(line) {
setEditingId(line.id);
setGroup(line.group);
setProtocol(line.protocol);
setDraft({ ...line });
setModalOpen(true);
}
function duplicateLine(line) {
setEditingId(null); // 不带id保存时会当成全新的一条线路不会覆盖原线路
setGroup(line.group);
setProtocol(line.protocol);
const { id, monitorStatus, lastChecked, ...rest } = line;
setDraft({ ...rest, name: line.name ? `${line.name} 副本` : '' });
setModalOpen(true);
onNotify('已复制配置,改一下地址/端口/名称就能存成新线路');
}
function closeModal() { setModalOpen(false); }
function handleGroupChange(g) {
setGroup(g);
if (g === 'selfbuilt') {
setProtocol('vless-cdn');
setDraft(d => ({ ...d, addr: d.addr || 'pb.impromx.com', port: d.port || '443', ws_path: d.ws_path || '/stickrouter' }));
} else {
setProtocol(p => JMS_PROTOCOLS.includes(p) ? p : 'ss');
setDraft(d => ({ ...d }));
}
}
function handleSave() {
if (!draft.addr || !draft.port) { onNotify('请填写服务器地址和端口'); return; }
const missing = [];
if (group === 'selfbuilt') {
if (!draft.uuid) missing.push('UUID');
} else if (protocol === 'ss') {
if (!draft.password) missing.push('密码');
} else if (protocol === 'vless-reality') {
if (!draft.uuid) missing.push('UUID');
if (!draft.publicKey) missing.push('Public Key');
if (!draft.shortId) missing.push('Short ID');
if (!draft.sni) missing.push('SNI');
}
if (missing.length) { onNotify(`请填写:${missing.join('、')}`); return; }
onSave({ ...draft, group, protocol }, editingId);
closeModal();
}
return (
<>
<div className="page-head-row">
<div>
<h2 className="crm-display" style={{ fontSize: 22, fontWeight: 700, margin: '0 0 4px' }}>Just My Socks 节点管理</h2>
</div>
<button className="btn btn-primary" onClick={openAddModal}><Plus size={14} />新增线路</button>
</div>
<div className="jms-stats-row">
<div className="stat-chip"><div className="stat-num">{total}</div><div className="stat-lbl">线</div></div>
<div className="stat-chip"><div className="stat-num">{ssCount}</div><div className="stat-lbl">SS</div></div>
<div className="stat-chip"><div className="stat-num">{vlessCount}</div><div className="stat-lbl">VLESS</div></div>
</div>
<div className="jms-grid">
{lines.length === 0 && <div className="empty-state" style={{ gridColumn: '1/-1' }}><Wifi size={28} />还没有线路点右上角"新增线路"添加</div>}
{lines.map((line, idx) => (
<JmsLineCard
key={line.id}
line={line}
ip={ipMap[line.id]}
onRefreshIp={refreshIp}
onCheck={onCheck}
onCopy={copyLink}
onQr={openQr}
onDuplicate={duplicateLine}
onEdit={openEditModal}
onDelete={onDelete}
dragging={dragIdx === idx}
onCardDragStart={() => onDragStart(idx)}
onCardDragOver={(e) => e.preventDefault()}
onCardDrop={() => onDrop(idx)}
onCardDragEnd={onDragEnd}
/>
))}
</div>
{modalOpen && (
<div className="modal-overlay" onClick={closeModal}>
<div className="modal-box" style={{ maxWidth: 460 }} onClick={e => e.stopPropagation()}>
<div className="modal-head">
<div className="panel-title"><Wifi size={15} />{editingId ? '编辑线路' : '新增线路'}</div>
<button className="btn btn-ghost btn-sm" onClick={closeModal}><X size={14} /></button>
</div>
<div className="modal-body">
<div className="field">
<span className="field-label">线路分组</span>
<div className="jms-group-select">
<div className={`jms-group-opt ${group === 'selfbuilt' ? 'jms-group-selected jms-group-selfbuilt' : ''}`} onClick={() => handleGroupChange('selfbuilt')}>自建 VLESS-CDN</div>
<div className={`jms-group-opt ${group === 'jms' ? 'jms-group-selected jms-group-jms' : ''}`} onClick={() => handleGroupChange('jms')}>Just My Socks</div>
</div>
</div>
{group === 'jms' && (
<div className="field">
<span className="field-label">协议</span>
<div className="jms-proto-select">
{JMS_PROTOCOLS.map(key => (
<div key={key} className={`jms-proto-opt jms-proto-opt-${key} ${protocol === key ? 'jms-proto-selected' : ''}`} onClick={() => setProtocol(key)}>{JMS_PROTO_META[key].label}</div>
))}
</div>
</div>
)}
<div className="field"><span className="field-label">名称备注</span><input className="input" value={draft.name || ''} onChange={e => setDraft(d => ({ ...d, name: e.target.value }))} placeholder="如SG-01 新加坡" /></div>
<div className="grid-2">
<div className="field"><span className="field-label">服务器地址域名</span><input className="input" value={draft.addr || ''} onChange={e => setDraft(d => ({ ...d, addr: e.target.value }))} placeholder="如sg1.jms-relay.net" /></div>
<div className="field"><span className="field-label">端口</span><input className="input" value={draft.port || ''} onChange={e => setDraft(d => ({ ...d, port: e.target.value }))} placeholder="8388" /></div>
</div>
<JmsLineFormFields group={group} protocol={protocol} draft={draft} setDraft={setDraft} />
</div>
<div className="modal-foot"><button className="btn" onClick={closeModal}>取消</button><button className="btn btn-primary" onClick={handleSave}><Save size={13} />保存</button></div>
</div>
</div>
)}
{qrLine && (
<div className="modal-overlay" onClick={() => setQrLine(null)}>
<div className="modal-box" style={{ maxWidth: 320, textAlign: 'center' }} onClick={e => e.stopPropagation()}>
<div className="modal-head">
<div className="panel-title">{qrLine.name || qrLine.addr} 订阅二维码</div>
<button className="btn btn-ghost btn-sm" onClick={() => setQrLine(null)}><X size={14} /></button>
</div>
<div className="modal-body" style={{ alignItems: 'center' }}>
{qrDataUrl ? <img src={qrDataUrl} alt="订阅二维码" style={{ width: 220, height: 220, borderRadius: 8 }} /> : <div style={{ padding: 40 }}>生成中</div>}
<div className="jms-qr-link">{jmsBuildLink(qrLine)}</div>
<button className="btn btn-primary" style={{ width: '100%' }} onClick={() => copyLink(qrLine)}><Copy size={13} />复制订阅链接</button>
</div>
</div>
</div>
)}
</>
);
}
/* ----------------------------- 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 isRackUps = isUps && draft.type === '机架式UPS';
const isOnlineHighFreq = isUps && draft.type === '在线式高频UPS';
const isRackLikeUps = isRackUps || isOnlineHighFreq;
const isBatterySystem = isBatteryCategory(cat) && draft.type === 'UPS配套锂电池系统';
const isSolarHybridInverter = isInverterCategory(cat) && draft.type === '太阳能混网逆变器';
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 (
<div className="panel">
<div className="panel-header"><div className="panel-title"><Edit2 size={14} />{draft.name ? '编辑产品' : '新建产品'}</div></div>
<div className="panel-body" style={{ padding: 18 }}>
<div className="grid-2">
<div className="field"><span className="field-label">型号 / 产品名称 *</span><input className="input" value={draft.name} onChange={e => set({ name: e.target.value })} /></div>
<div className="field">
<div className="field-label-row">
<span className="field-label">产品大类 *</span>
<span className="field-manage-link" onClick={() => onManage('category')}><Settings2 size={11} />大类管理</span>
</div>
<select
className="select" value={draft.category}
onChange={e => {
const newCatId = e.target.value;
const newCat = (categories || []).find(c => c.id === newCatId);
const defaultBrand = (newCat && newCat.brands && newCat.brands.length) ? newCat.brands[0] : '';
set({ category: newCatId, brand: defaultBrand, type: '', machineVariant: '' });
}}
>
<option value="">请选择</option>
{(categories || []).map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
</div>
</div>
<div className="field">
<div className="field-label-row">
<span className="field-label">同系列其他型号可选一次性批量新建</span>
<span className="field-manage-link" onClick={addExtraName}><Plus size={11} />添加型号</span>
</div>
{extraNames.length === 0 ? (
<div style={{ fontSize: 12, color: 'var(--ink-soft)' }}>比如同一系列有4个型号规格/品牌/类型都一样填这里可以一次性建好4个产品档案</div>
) : extraNames.map((n, idx) => (
<div key={idx} style={{ display: 'flex', gap: 8, marginBottom: 6 }}>
<input className="input" value={n} onChange={e => updateExtraName(idx, e.target.value)} placeholder="型号" />
<button className="btn btn-sm btn-ghost btn-danger" onClick={() => removeExtraName(idx)}><Trash2 size={12} /></button>
</div>
))}
</div>
<div className="grid-2">
<div className="field">
<div className="field-label-row">
<span className="field-label">品牌</span>
<span className="field-manage-link" onClick={() => onManage('brand')}><Settings2 size={11} />品牌管理</span>
</div>
<select className="select" value={draft.brand} disabled={!cat} onChange={e => set({ brand: e.target.value })}>
<option value="">{!cat ? '请先选择产品大类' : brandOptions.length === 0 ? '该类目暂无品牌,点右上角管理添加' : '请选择'}</option>
{brandOptions.map(b => <option key={b} value={b}>{b}</option>)}
</select>
</div>
<div className="field">
<div className="field-label-row">
<span className="field-label">细分品类</span>
<span className="field-manage-link" onClick={() => onManage('type')}><Settings2 size={11} />品类管理</span>
</div>
<select
className="select" value={draft.type} disabled={!cat}
onChange={e => {
const newType = e.target.value;
if (newType === '迷你UPS') {
set({
type: newType,
machineVariant: '标准机型',
voltage: draft.voltage || '100~240Vac/50-60Hz',
outputPortCount: draft.outputPortCount || '5',
outputPortTypes: (draft.outputPortTypes && draft.outputPortTypes.length) ? draft.outputPortTypes : [...DEFAULT_MINI_UPS_OUTPUT_TYPES],
outputPorts: (draft.outputPorts && draft.outputPorts.length) ? draft.outputPorts : DEFAULT_MINI_UPS_OUTPUT_TYPES.map(t => ({ type: t, voltage: '', current: '' })),
});
} else {
set({ type: newType });
}
}}
>
<option value="">{!cat ? '请先选择产品大类' : typeOptions.length === 0 ? '该类目暂无品类,点右上角管理添加' : '请选择'}</option>
{typeOptions.map(t => <option key={t} value={t}>{t}</option>)}
</select>
</div>
</div>
{isUps && (
<div className="field">
<span className="field-label">机型</span>
{isMiniUps ? (
<input className="input" value="标准机型" disabled readOnly />
) : (
<select className="select" value={draft.machineVariant} onChange={e => set({ machineVariant: e.target.value })}>
<option value="">请选择</option>
<option value="标准机型">标准机型内置电池</option>
<option value="长延时机型">长延时机型需外接电池</option>
</select>
)}
</div>
)}
{isSolarHybridInverter ? (
<>
<div className="grid-2">
<div className="field"><span className="field-label">电池类型</span><input className="input" value={draft.batteryType} onChange={e => set({ batteryType: e.target.value })} placeholder="如Lithium or lead acid battery" /></div>
<div className="field"><span className="field-label">电池电压</span><input className="input" value={draft.batteryVoltage} onChange={e => set({ batteryVoltage: e.target.value })} placeholder="如48V" /></div>
</div>
<div className="grid-2">
<div className="field"><span className="field-label">最大充电电压</span><input className="input" value={draft.maxChargingVoltage} onChange={e => set({ maxChargingVoltage: e.target.value })} placeholder="如≤60 (Configurable)" /></div>
<div className="field"><span className="field-label">最大充放电电流</span><input className="input" value={draft.maxChargeDischargeCurrent} onChange={e => set({ maxChargeDischargeCurrent: e.target.value })} placeholder="如190A" /></div>
</div>
<div className="grid-2">
<div className="field"><span className="field-label">最大直流输入功率</span><input className="input" value={draft.maxDcInputPower} onChange={e => set({ maxDcInputPower: e.target.value })} placeholder="如16000W" /></div>
<div className="field"><span className="field-label">最大直流输入电压</span><input className="input" value={draft.maxDcInputVoltage} onChange={e => set({ maxDcInputVoltage: e.target.value })} placeholder="如1000V" /></div>
</div>
<div className="grid-2">
<div className="field"><span className="field-label">MPPT工作电压范围</span><input className="input" value={draft.mpptVoltageRange} onChange={e => set({ mpptVoltageRange: e.target.value })} placeholder="如200~800V" /></div>
<div className="field"><span className="field-label">启动电压</span><input className="input" value={draft.startingVoltage} onChange={e => set({ startingVoltage: e.target.value })} placeholder="如150V" /></div>
</div>
<div className="grid-2">
<div className="field"><span className="field-label">最大输入电流</span><input className="input" value={draft.maxInputCurrent} onChange={e => set({ maxInputCurrent: e.target.value })} placeholder="18/18A" /></div>
<div className="field"><span className="field-label">MPPT路数</span><input className="input" value={draft.mpptNumber} onChange={e => set({ mpptNumber: e.target.value })} placeholder="如2" /></div>
</div>
<div className="grid-2">
<div className="field"><span className="field-label">并网最大输出视在功率</span><input className="input" value={draft.onGridMaxApparentPower} onChange={e => set({ onGridMaxApparentPower: e.target.value })} placeholder="如8800W" /></div>
<div className="field"><span className="field-label">输出电压</span><input className="input" value={draft.outputVoltage} onChange={e => set({ outputVoltage: e.target.value })} placeholder="380/400V" /></div>
</div>
<div className="grid-2">
<div className="field"><span className="field-label">额定输出频率</span><input className="input" value={draft.ratedOutputFrequency} onChange={e => set({ ratedOutputFrequency: e.target.value })} placeholder="50/60Hz" /></div>
<div className="field"><span className="field-label">并网最大输出电流</span><input className="input" value={draft.onGridMaxOutputCurrent} onChange={e => set({ onGridMaxOutputCurrent: e.target.value })} placeholder="如13.3A" /></div>
</div>
<div className="field"><span className="field-label">输出功率因数</span><input className="input" value={draft.outputPowerFactor} onChange={e => set({ outputPowerFactor: e.target.value })} placeholder="如1 (-0.8 leading~+0.8 lagging)" /></div>
<div className="grid-2">
<div className="field"><span className="field-label">离网额定输出视在功率</span><input className="input" value={draft.offGridRatedApparentPower} onChange={e => set({ offGridRatedApparentPower: e.target.value })} placeholder="如8000W" /></div>
<div className="field"><span className="field-label">离网最大输出视在功率</span><input className="input" value={draft.offGridMaxApparentPower} onChange={e => set({ offGridMaxApparentPower: e.target.value })} placeholder="如:>200%,15sec" /></div>
</div>
<div className="field"><span className="field-label">离网最大输出电流</span><input className="input" value={draft.offGridMaxOutputCurrent} onChange={e => set({ offGridMaxOutputCurrent: e.target.value })} placeholder="如13.3A" /></div>
<div className="grid-2">
<div className="field"><span className="field-label">最大效率(MPPT)</span><input className="input" value={draft.efficiencyMppt} onChange={e => set({ efficiencyMppt: e.target.value })} placeholder="如98%" /></div>
<div className="field"><span className="field-label">最大效率</span><input className="input" value={draft.efficiencyMax} onChange={e => set({ efficiencyMax: e.target.value })} placeholder="如94.5%" /></div>
</div>
<div className="field"><span className="field-label">欧洲效率</span><input className="input" value={draft.efficiencyEurope} onChange={e => set({ efficiencyEurope: e.target.value })} placeholder="如97.5%" /></div>
<div className="field"><span className="field-label">保护功能</span><input className="input" value={draft.protectionFunctions} onChange={e => set({ protectionFunctions: e.target.value })} placeholder="PVPV//" /></div>
<div className="grid-2">
<div className="field"><span className="field-label">运行温度</span><input className="input" value={draft.operatingTemp} onChange={e => set({ operatingTemp: e.target.value })} placeholder="如:-25°C~60°C" /></div>
<div className="field"><span className="field-label">存储温度</span><input className="input" value={draft.storageTemp} onChange={e => set({ storageTemp: e.target.value })} placeholder="如:-30~65°C" /></div>
</div>
<div className="grid-2">
<div className="field"><span className="field-label">湿度</span><input className="input" value={draft.humidity} onChange={e => set({ humidity: e.target.value })} placeholder="如0~95%" /></div>
<div className="field"><span className="field-label">海拔m</span><input className="input" value={draft.altitude} onChange={e => set({ altitude: e.target.value })} placeholder="如≤4000m" /></div>
</div>
<div className="grid-2">
<div className="field"><span className="field-label">散热方式</span><input className="input" value={draft.cooling} onChange={e => set({ cooling: e.target.value })} placeholder="如Intelligent forced air cooling" /></div>
<div className="field"><span className="field-label">噪音</span><input className="input" value={draft.noiseLevel} onChange={e => set({ noiseLevel: e.target.value })} placeholder="如:<55db" /></div>
</div>
<div className="grid-2">
<div className="field"><span className="field-label">净重kg</span><input className="input" value={draft.netWeight} onChange={e => set({ netWeight: e.target.value })} placeholder="如38" /></div>
<div className="field"><span className="field-label">尺寸mm</span><input className="input" value={draft.dimensions} onChange={e => set({ dimensions: spaceToAsterisk(e.target.value) })} placeholder="如475*683*256" /></div>
</div>
<div className="grid-2">
<div className="field"><span className="field-label">防护等级</span><input className="input" value={draft.protectionClass} onChange={e => set({ protectionClass: e.target.value })} placeholder="如IP66" /></div>
<div className="field"><span className="field-label">拓扑结构</span><input className="input" value={draft.topology} onChange={e => set({ topology: e.target.value })} placeholder="如HF isolation(Battery side)" /></div>
</div>
<div className="field"><span className="field-label">安规/EMC标准</span><input className="input" value={draft.safetyStandard} onChange={e => set({ safetyStandard: e.target.value })} placeholder="NB/T32004-2018, IEC62109, IEC61000" /></div>
<div className="field"><span className="field-label">并网标准</span><input className="input" value={draft.gridStandard} onChange={e => set({ gridStandard: e.target.value })} placeholder="如IEC61727, EN50549-1, VDE-4105" /></div>
<div className="field"><span className="field-label">其他标准</span><input className="input" value={draft.otherStandard} onChange={e => set({ otherStandard: e.target.value })} placeholder="如IEC61683, IEC62116, EN50530" /></div>
</>
) : isBatterySystem ? (
<>
<div className="grid-2">
<div className="field"><span className="field-label">模块类型</span><input className="input" value={draft.moduleType} onChange={e => set({ moduleType: e.target.value })} placeholder="如:长延时型" /></div>
<div className="field"><span className="field-label">电芯类型</span><input className="input" value={draft.cellType} onChange={e => set({ cellType: e.target.value })} placeholder="如磷酸铁锂电池LFP" /></div>
</div>
<div className="grid-2">
<div className="field"><span className="field-label">额定容量Ah</span><input className="input" value={draft.moduleCapacity} onChange={e => set({ moduleCapacity: e.target.value })} placeholder="如100" /></div>
<div className="field"><span className="field-label">额定电压V</span><input className="input" value={draft.moduleVoltage} onChange={e => set({ moduleVoltage: e.target.value })} placeholder="如51.2" /></div>
</div>
<div className="grid-2">
<div className="field"><span className="field-label">放电倍率C</span><input className="input" value={draft.dischargeRate} onChange={e => set({ dischargeRate: e.target.value })} placeholder="如2" /></div>
<div className="field"><span className="field-label">充电倍率C</span><input className="input" value={draft.chargeRate} onChange={e => set({ chargeRate: e.target.value })} placeholder="如1" /></div>
</div>
<div className="grid-2">
<div className="field"><span className="field-label">模块最大能量kWh</span><input className="input" value={draft.moduleMaxEnergy} onChange={e => set({ moduleMaxEnergy: e.target.value })} placeholder="如5.12" /></div>
<div className="field"><span className="field-label">模块输出功率kW</span><input className="input" value={draft.moduleOutputPower} onChange={e => set({ moduleOutputPower: e.target.value })} placeholder="如10.24" /></div>
</div>
<div className="grid-2">
<div className="field"><span className="field-label">模块尺寸mm</span><input className="input" value={draft.moduleDimensions} onChange={e => set({ moduleDimensions: spaceToAsterisk(e.target.value) })} placeholder="如440*440*131" /></div>
<div className="field"><span className="field-label">模块重量kg</span><input className="input" value={draft.moduleWeight} onChange={e => set({ moduleWeight: e.target.value })} placeholder="如43±2" /></div>
</div>
<div className="grid-2">
<div className="field"><span className="field-label">电池最大能量kWh</span><input className="input" value={draft.cabinetMaxEnergy} onChange={e => set({ cabinetMaxEnergy: e.target.value })} placeholder="如40.96" /></div>
<div className="field"><span className="field-label">额定输出电压V</span><input className="input" value={draft.cabinetRatedVoltage} onChange={e => set({ cabinetRatedVoltage: e.target.value })} placeholder="如409.6/±204.8/460.8/512" /></div>
</div>
<div className="grid-2">
<div className="field"><span className="field-label">循环寿命</span><input className="input" value={draft.cycleLife} onChange={e => set({ cycleLife: e.target.value })} placeholder="5000@50%DOD" /></div>
<div className="field"><span className="field-label">通信接口</span><input className="input" value={draft.commInterface} onChange={e => set({ commInterface: e.target.value })} placeholder="LAN/CAN/RS485/干接点" /></div>
</div>
<div className="field"><span className="field-label">保护功能</span><input className="input" value={draft.protectionFunctions} onChange={e => set({ protectionFunctions: e.target.value })} placeholder="过充过放过温过流短路等" /></div>
<div className="grid-2">
<div className="field"><span className="field-label">运行温度</span><input className="input" value={draft.operatingTemp} onChange={e => set({ operatingTemp: e.target.value })} placeholder="0~40推荐20~25运行" /></div>
<div className="field"><span className="field-label">湿度</span><input className="input" value={draft.humidity} onChange={e => set({ humidity: e.target.value })} placeholder="5%~95%无凝露" /></div>
</div>
<div className="grid-2">
<div className="field"><span className="field-label">颜色</span><input className="input" value={draft.color} onChange={e => set({ color: e.target.value })} placeholder="RAL9004" /></div>
<div className="field"><span className="field-label">海拔m</span><input className="input" value={draft.altitude} onChange={e => set({ altitude: e.target.value })} placeholder="4000>2000降额使用" /></div>
</div>
<div className="grid-2">
<div className="field"><span className="field-label">系统柜尺寸mm</span><input className="input" value={draft.cabinetDimensions} onChange={e => set({ cabinetDimensions: spaceToAsterisk(e.target.value) })} placeholder="600×850×2000" /></div>
<div className="field"><span className="field-label">系统柜重量kg</span><input className="input" value={draft.cabinetWeight} onChange={e => set({ cabinetWeight: e.target.value })} placeholder="544±108模组配置" /></div>
</div>
<div className="field"><span className="field-label">自放电率</span><input className="input" value={draft.selfDischargeRate} onChange={e => set({ selfDischargeRate: e.target.value })} placeholder="<5%环境温度030/30" /></div>
</>
) : isUps && isRackLikeUps ? (
<>
<div className="grid-2">
<div className="field"><span className="field-label">相数</span><input className="input" value={draft.phase} onChange={e => set({ phase: e.target.value })} placeholder="1-phase input/1-phase output" /></div>
<div className="field"><span className="field-label">容量</span><input className="input" value={draft.powerOrCapacity} onChange={e => set({ powerOrCapacity: e.target.value })} placeholder="1000VA/800W" /></div>
</div>
<div className="grid-2">
<div className="field"><span className="field-label">输入电压(标称电压)</span><input className="input" value={draft.inputVoltage} onChange={e => set({ inputVoltage: e.target.value })} placeholder="100/110/115/120/127VAC or 200/208/220/230/240VAC" /></div>
<div className="field"><span className="field-label">输入电压范围</span><input className="input" value={draft.inputVoltageRange} onChange={e => set({ inputVoltageRange: e.target.value })} placeholder="85-140VAC or 160-280VAC at100%load" /></div>
</div>
<div className="grid-2">
<div className="field"><span className="field-label">输入频率范围</span><input className="input" value={draft.inputFrequency} onChange={e => set({ inputFrequency: e.target.value })} placeholder="40Hz~70Hz" /></div>
<div className="field"><span className="field-label">输入功率因数</span><input className="input" value={draft.inputPowerFactor} onChange={e => set({ inputPowerFactor: e.target.value })} placeholder="0.99@100% load" /></div>
</div>
{isOnlineHighFreq && (
<div className="field"><span className="field-label">可并联台数</span><input className="input" value={draft.parallelUnits} onChange={e => set({ parallelUnits: e.target.value })} placeholder="Not supported或具体可并联数量" /></div>
)}
<div className="grid-2">
<div className="field"><span className="field-label">输出电压</span><input className="input" value={draft.outputVoltage} onChange={e => set({ outputVoltage: e.target.value })} placeholder="100/110/115/120/127VAC or 200/208/220/230/240VAC" /></div>
<div className="field"><span className="field-label">输出电压范围(电压调整率)</span><input className="input" value={draft.outputVoltageRange} onChange={e => set({ outputVoltageRange: e.target.value })} placeholder="±1%" /></div>
</div>
{isOnlineHighFreq && (
<div className="field"><span className="field-label">输出功率因数</span><input className="input" value={draft.outputPowerFactor} onChange={e => set({ outputPowerFactor: e.target.value })} placeholder="0.9 1" /></div>
)}
<div className="grid-2">
<div className="field"><span className="field-label">频率范围(电池模式)</span><input className="input" value={draft.frequencyRangeBatteryMode} onChange={e => set({ frequencyRangeBatteryMode: e.target.value })} placeholder="50Hz±0.25Hz or 60Hz±0.3Hz" /></div>
<div className="field"><span className="field-label">峰值电流比</span><input className="input" value={draft.currentCrestRatio} onChange={e => set({ currentCrestRatio: e.target.value })} placeholder="3:1" /></div>
</div>
<div className="field"><span className="field-label">谐波失真</span><input className="input" value={draft.harmonicDistortion} onChange={e => set({ harmonicDistortion: e.target.value })} placeholder="3% THD (Linear Load), 5% THD (Non-linear Load)" /></div>
<div className="grid-2">
<div className="field"><span className="field-label">转换时间(市电到电池)</span><input className="input" value={draft.transferTimeAcToBattery} onChange={e => set({ transferTimeAcToBattery: e.target.value })} placeholder="0ms" /></div>
<div className="field"><span className="field-label">转换时间(逆变到旁路)</span><input className="input" value={draft.transferTime} onChange={e => set({ transferTime: e.target.value })} placeholder="4ms (Typical)" /></div>
</div>
<div className="grid-2">
<div className="field"><span className="field-label">波形(电池模式)</span><input className="input" value={draft.waveform} onChange={e => set({ waveform: e.target.value })} placeholder="pure sine wave" /></div>
<div className="field"><span className="field-label">效率(市电模式)</span><input className="input" value={draft.efficiencyAcMode} onChange={e => set({ efficiencyAcMode: e.target.value })} placeholder="88%" /></div>
</div>
{isOnlineHighFreq && (
<div className="field"><span className="field-label">过载保护</span><input className="input" value={draft.overload} onChange={e => set({ overload: e.target.value })} placeholder="105%-125% 6-25s; >150% 200ms" /></div>
)}
<div className="grid-2">
<div className="field"><span className="field-label">效率(电池模式)</span><input className="input" value={draft.efficiencyBatteryMode} onChange={e => set({ efficiencyBatteryMode: e.target.value })} placeholder="83%" /></div>
<div className="field"><span className="field-label">充电电流</span><input className="input" value={draft.chargeCurrent} onChange={e => set({ chargeCurrent: e.target.value })} placeholder="1.0A" /></div>
</div>
<div className="grid-2">
<div className="field"><span className="field-label">充电电压</span><input className="input" value={draft.chargingVoltage} onChange={e => set({ chargingVoltage: e.target.value })} placeholder="27.4VDC±1%" /></div>
<div className="field"><span className="field-label">充电时间</span><input className="input" value={draft.chargeTime} onChange={e => set({ chargeTime: e.target.value })} placeholder="4 hours recover to 90% capacity" /></div>
</div>
<div className="field"><span className="field-label">电池型号和数量</span><input className="input" value={draft.batterySpec} onChange={e => set({ batterySpec: spaceToAsterisk(e.target.value) })} placeholder="12V/9AH x2" /></div>
<div className="grid-2">
<div className="field"><span className="field-label">尺寸mm</span><input className="input" value={draft.dimensions} onChange={e => set({ dimensions: spaceToAsterisk(e.target.value) })} placeholder="310*438*88[2U]" /></div>
<div className="field"><span className="field-label">净重kg</span><input className="input" value={draft.netWeight} onChange={e => set({ netWeight: e.target.value })} placeholder="12" /></div>
</div>
<div className="field"><span className="field-label">LCD显示能显示哪些信息</span><input className="input" value={draft.lcdDisplay} onChange={e => set({ lcdDisplay: e.target.value })} placeholder="UPS status, Load level, Battery level, Input/Output voltage" /></div>
<div className="field"><span className="field-label">警告提示(各种蜂鸣报警的规律)</span><textarea className="textarea" style={{ fontFamily: 'inherit', fontSize: 13 }} rows={4} value={draft.alarmInfo} onChange={e => set({ alarmInfo: e.target.value })} placeholder={'如:\nBattery modeSounding every 4 seconds\nLow batterySounding every second\nOverloadSounding twice every second\nFaultContinuously sounding'} /></div>
<div className="grid-2">
<div className="field"><span className="field-label">湿度</span><input className="input" value={draft.humidity} onChange={e => set({ humidity: e.target.value })} placeholder="20-90% RH@0-40(non-condensing)" /></div>
<div className="field"><span className="field-label">噪音</span><input className="input" value={draft.noiseLevel} onChange={e => set({ noiseLevel: e.target.value })} placeholder="Less than 50dB@1m" /></div>
</div>
<div className="field"><span className="field-label">控制管理 / 接口</span><input className="input" value={draft.interfacePort} onChange={e => set({ interfacePort: e.target.value })} placeholder="Smart RS-232/USB, supports Windows 2000/2003/XP/Vista/2008, 7/8, Linux, Unix, MAC" /></div>
<div className="field"><span className="field-label">可选SNMP</span><input className="input" value={draft.optionalSnmp} onChange={e => set({ optionalSnmp: e.target.value })} placeholder="Power management from SNMP manager and web browser" /></div>
</>
) : isUps && isOfflineOrLI ? (
<>
<div className="grid-2">
<div className="field"><span className="field-label">容量</span><input className="input" value={draft.powerOrCapacity} onChange={e => set({ powerOrCapacity: e.target.value })} placeholder="400VA/240W" /></div>
<div className="field"><span className="field-label">输入电压</span><input className="input" value={draft.inputVoltage} onChange={e => set({ inputVoltage: e.target.value })} placeholder="110V/120Vac or 220V/230Vac" /></div>
</div>
<div className="grid-2">
<div className="field"><span className="field-label">输入电压范围</span><input className="input" value={draft.inputVoltageRange} onChange={e => set({ inputVoltageRange: e.target.value })} placeholder="162-268Vac/170-280Vac" /></div>
<div className="field"><span className="field-label">输入频率</span><input className="input" value={draft.inputFrequency} onChange={e => set({ inputFrequency: e.target.value })} placeholder="50/60Hz自动识别" /></div>
</div>
<div className="grid-2">
<div className="field"><span className="field-label">输出电压</span><input className="input" value={draft.outputVoltage} onChange={e => set({ outputVoltage: e.target.value })} placeholder="110V/120Vac or 220V/230Vac" /></div>
<div className="field"><span className="field-label">输出电压范围</span><input className="input" value={draft.outputVoltageRange} onChange={e => set({ outputVoltageRange: e.target.value })} placeholder="±10%" /></div>
</div>
<div className="grid-2">
<div className="field"><span className="field-label">转换时间</span><input className="input" value={draft.transferTime} onChange={e => set({ transferTime: e.target.value })} placeholder="Typical 2-8ms, 13ms max" /></div>
<div className="field"><span className="field-label">波形(电池模式)</span><input className="input" value={draft.waveform} onChange={e => set({ waveform: e.target.value })} placeholder="Simulated Sine Wave" /></div>
</div>
<div className="field"><span className="field-label">频率范围(电池模式)</span><input className="input" value={draft.frequencyRangeBatteryMode} onChange={e => set({ frequencyRangeBatteryMode: e.target.value })} placeholder="50/60Hz±1Hz" /></div>
<div className="grid-2">
<div className="field"><span className="field-label">电池电压</span><input className="input" value={draft.batteryVoltage} onChange={e => set({ batteryVoltage: e.target.value })} placeholder="12V / 24V" /></div>
<div className="field"><span className="field-label">充电电流</span><input className="input" value={draft.chargeCurrent} onChange={e => set({ chargeCurrent: e.target.value })} placeholder="1A" /></div>
</div>
<div className="grid-2">
<div className="field"><span className="field-label">电池型号和数量</span><input className="input" value={draft.batterySpec} onChange={e => set({ batterySpec: spaceToAsterisk(e.target.value) })} placeholder="12V4.5AH*1" /></div>
<div className="field"><span className="field-label">充电时间</span><input className="input" value={draft.chargeTime} onChange={e => set({ chargeTime: e.target.value })} placeholder="8小时恢复到90%容量" /></div>
</div>
<div className="grid-2">
<div className="field"><span className="field-label">尺寸mm</span><input className="input" value={draft.dimensions} onChange={e => set({ dimensions: spaceToAsterisk(e.target.value) })} placeholder="315*100*143" /></div>
<div className="field"><span className="field-label">净重kg</span><input className="input" value={draft.netWeight} onChange={e => set({ netWeight: e.target.value })} placeholder="4" /></div>
</div>
<div className="field"><span className="field-label">LCD显示LCD屏能显示哪些信息</span><input className="input" value={draft.lcdDisplay} onChange={e => set({ lcdDisplay: e.target.value })} placeholder="AC mode, Battery mode, Load level, Input/Output voltage" /></div>
<div className="field"><span className="field-label">LED显示各指示灯含义</span><textarea className="textarea" style={{ fontFamily: 'inherit', fontSize: 13 }} rows={3} value={draft.ledDisplay} onChange={e => set({ ledDisplay: e.target.value })} placeholder={'如:\nAC modeGreen lighting\nBattery modeYellow lighting\nFaultRed lighting'} /></div>
<div className="field"><span className="field-label">全面保护</span><input className="input" value={draft.fullProtection} onChange={e => set({ fullProtection: e.target.value })} placeholder="Discharge, Short circuit and overload protection" /></div>
<div className="field"><span className="field-label">警告提示(各种蜂鸣报警的规律)</span><textarea className="textarea" style={{ fontFamily: 'inherit', fontSize: 13 }} rows={4} value={draft.alarmInfo} onChange={e => set({ alarmInfo: e.target.value })} placeholder={'如:\nBattery modeSounding every 10 seconds\nLow batterySounding every second\nOverloadSounding every 0.5 seconds\nFaultContinuously sounding'} /></div>
<div className="field"><span className="field-label">控制管理 / 接口</span><input className="input" value={draft.interfacePort} onChange={e => set({ interfacePort: e.target.value })} placeholder="USB/RS232 port(optional), support windows xp/vista, 7/8/11 and MAC" /></div>
<div className="grid-2">
<div className="field"><span className="field-label">湿度</span><input className="input" value={draft.humidity} onChange={e => set({ humidity: e.target.value })} placeholder="0-90% RH@0-40(Non-condensing)" /></div>
<div className="field"><span className="field-label">噪音</span><input className="input" value={draft.noiseLevel} onChange={e => set({ noiseLevel: e.target.value })} placeholder="Less than 40dB(1m spacing)" /></div>
</div>
</>
) : isUps && !isMiniUps ? (
<div className="grid-2">
<div className="field">
<span className="field-label">视载功率</span>
<div style={{ display: 'flex', gap: 8 }}>
<input className="input" value={draft.ratedPower} onChange={e => set({ ratedPower: e.target.value })} placeholder="1000" />
<select className="select" style={{ width: 90, flexShrink: 0 }} value={draft.ratedPowerUnit} onChange={e => set({ ratedPowerUnit: e.target.value })}>
<option value="VA">VA</option>
<option value="kVA">kVA</option>
</select>
</div>
</div>
<div className="field"><span className="field-label">功率因数</span><input className="input" value={draft.powerFactor} onChange={e => set({ powerFactor: e.target.value })} placeholder="0.9 / 1" /></div>
<div className="field"><span className="field-label">最大功率kW自动计算</span><input className="input" value={maxKW !== null ? `${maxKW} kW` : ''} readOnly disabled /></div>
<div className="field"><span className="field-label">电压</span><input className="input" value={draft.voltage} onChange={e => set({ voltage: e.target.value })} placeholder="48V / 192V / 220V" /></div>
</div>
) : isUps && isMiniUps ? (
<>
<div className="grid-2">
<div className="field"><span className="field-label">输出功率</span><input className="input" value={draft.powerOrCapacity} onChange={e => set({ powerOrCapacity: e.target.value })} placeholder="36W" /></div>
<div className="field"><span className="field-label">输入电压</span><input className="input" value={draft.voltage} onChange={e => set({ voltage: e.target.value })} placeholder="100~240Vac/50-60Hz" /></div>
</div>
<div className="grid-2">
<div className="field">
<span className="field-label">输出口数量</span>
<select className="select" value={draft.outputPortCount} onChange={e => set({ outputPortCount: e.target.value })}>
<option value="">请选择</option>
{[1, 2, 3, 4, 5, 6].map(n => <option key={n} value={String(n)}>{n}</option>)}
</select>
</div>
<div className="field">
<span className="field-label">电池数量和容量</span>
<input className="input" value={draft.batterySpec} onChange={e => set({ batterySpec: spaceToAsterisk(e.target.value) })} placeholder="5000mAh*4" />
</div>
</div>
<div className="field">
<span className="field-label">输出口类型</span>
<div className="checkbox-row">
{OUTPUT_PORT_TYPES.map(t => (
<div key={t} className={`checkbox-pill ${(draft.outputPortTypes || []).includes(t) ? 'checked' : ''}`} onClick={() => toggleOutputPortType(t)}>
{(draft.outputPortTypes || []).includes(t) && <Check size={11} />} {t}
</div>
))}
</div>
</div>
{(draft.outputPorts || []).length > 0 && (
<div className="field">
<span className="field-label">各输出口的输出电压 / 电流</span>
{draft.outputPorts.map(p => (
<div key={p.type} style={{ display: 'flex', gap: 8, alignItems: 'center', marginBottom: 6 }}>
<div style={{ width: 90, fontSize: 12.5, fontWeight: 700, flexShrink: 0 }}>{p.type}</div>
<input className="input" style={{ flex: 1 }} value={p.voltage} onChange={e => updateOutputPort(p.type, { voltage: e.target.value })} onBlur={e => normalizeOutputVoltageOnBlur(p.type, e.target.value)} />
<input className="input" style={{ flex: 1 }} value={p.current} onChange={e => updateOutputPort(p.type, { current: e.target.value })} onBlur={e => normalizeOutputCurrentOnBlur(p.type, e.target.value)} />
</div>
))}
</div>
)}
<div className="grid-2">
<div className="field"><span className="field-label">尺寸mm</span><input className="input" value={draft.dimensions} onChange={e => set({ dimensions: spaceToAsterisk(e.target.value) })} placeholder="210*130*40" /></div>
<div className="field"><span className="field-label">净重kg</span><input className="input" value={draft.netWeight} onChange={e => set({ netWeight: e.target.value })} placeholder="0.73" /></div>
</div>
</>
) : (
<div className="grid-2">
<div className="field"><span className="field-label">功率 / 容量</span><input className="input" value={draft.powerOrCapacity} onChange={e => set({ powerOrCapacity: e.target.value })} placeholder="10kVA / 100Ah / 5kWh" /></div>
<div className="field"><span className="field-label">电压</span><input className="input" value={draft.voltage} onChange={e => set({ voltage: e.target.value })} placeholder="48V / 192V / 220V" /></div>
</div>
)}
<div className="field">
<div className="field-label-row">
<span className="field-label">产品主图可选用于多语言PDF导出</span>
</div>
{draft.productImageFileName ? (
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<img
src={`/uploads/${draft.id}/${draft.productImageFileName}`} alt={draft.name}
style={{ width: 96, height: 96, objectFit: 'cover', borderRadius: 8, border: '1px solid var(--line)' }}
/>
<div style={{ fontSize: 12.5, color: 'var(--ink-soft)', flex: 1 }}>{draft.productImageOriginalName}</div>
<label className="btn btn-sm" style={{ cursor: imageUploading ? 'default' : 'pointer' }}>
{imageUploading ? '上传中…' : '替换'}
<input type="file" accept="image/*" style={{ display: 'none' }} disabled={imageUploading} onChange={e => handleImagePick(e.target.files && e.target.files[0])} />
</label>
<button className="btn btn-sm btn-ghost btn-danger" onClick={handleImageRemove}><Trash2 size={12} /></button>
</div>
) : (
<label className="tax-add-category" style={{ display: 'block', cursor: imageUploading ? 'default' : 'pointer' }}>
<input type="file" accept="image/*" style={{ display: 'none' }} disabled={imageUploading} onChange={e => handleImagePick(e.target.files && e.target.files[0])} />
<ImageIcon size={16} style={{ verticalAlign: -3, marginRight: 4 }} />{imageUploading ? '上传中…' : '点击上传产品图片'}
</label>
)}
</div>
<div className="field">
<span className="field-label">对客卖点 / 主要特点可选每行一条会显示在多语言PDF导出的资料里</span>
<textarea
className="textarea" style={{ fontFamily: 'inherit', fontSize: 13 }} rows={4}
value={draft.sellingPoints} onChange={e => set({ sellingPoints: e.target.value })}
placeholder={'如:\nHigh capacity provides longer back-up time\nSupports wide AC voltage range (100-240Vac)'}
/>
</div>
<div className="field"><span className="field-label">正本价格(元)</span><input className="input" value={draft.costPrice} onChange={e => set({ costPrice: e.target.value })} placeholder="采购/成本价,人民币" /></div>
<div className="field">
<span className="field-label">内部备注(仅自己可见,不会出现在任何导出的文档里)</span>
<textarea className="textarea" style={{ fontFamily: 'inherit', fontSize: 13 }} value={draft.notes} onChange={e => set({ notes: e.target.value })} placeholder="成本、货期、库存这类内部信息写这里" />
</div>
</div>
<div className="modal-foot"><button className="btn" onClick={onCancel}>取消</button><button className="btn btn-primary" onClick={onSave}><Save size={13} />保存</button></div>
</div>
);
}
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 V = (value) => (containsChinese(value) ? L(value) : value);
const langInfo = EXPORT_LANGUAGES.find(l => l.code === exportLang) || EXPORT_LANGUAGES[0];
const exportRows = productExportRows(p, categories);
return (
<>
<div className="panel section-gap">
<div className="panel-header">
<div className="panel-title"><div className="led-dot blue" />{p.name || '(未命名)'}</div>
<div style={{ display: 'flex', gap: 8 }}>
<button className="btn btn-sm" onClick={onEdit}><Edit2 size={12} />编辑</button>
<button className="btn btn-sm btn-ghost btn-danger" onClick={onDelete}><Trash2 size={12} /></button>
</div>
</div>
<div className="panel-body" style={{ padding: 18 }}>
<div className="grid-2" style={{ marginBottom: 16 }}>
<Info icon={<Layers size={13} />} label="分类" value={categoryBreadcrumb(p, categories)} />
<Info icon={<Building2 size={13} />} label="品牌" value={p.brand || '—'} />
{showMachineVariant && <Info icon={<Tag size={13} />} label="机型" value={p.machineVariant ? `${normalizeMachineVariant(p.machineVariant)} ${MACHINE_VARIANT_NOTES[normalizeMachineVariant(p.machineVariant)] || ''}`.trim() : '—'} />}
{powerInfoItems(p, categories).map(item => (
<Info key={item.key} icon={<Zap size={13} />} label={item.label} value={item.value} />
))}
<Info icon={<Zap size={13} />} label="电压" value={p.voltage || '—'} />
<Info icon={<Tag size={13} />} label="正本价格(元)" value={p.costPrice || '—'} />
</div>
{p.notes && <div className="field"><span className="field-label">备注</span><div style={{ fontSize: 12.5, whiteSpace: 'pre-wrap' }}>{p.notes}</div></div>}
</div>
</div>
<div className="panel section-gap">
<div className="panel-header"><div className="panel-title"><Globe size={15} />多语言PDF资料导出</div></div>
<div className="panel-body" style={{ padding: 18 }}>
<div className="grid-2" style={{ alignItems: 'flex-end', marginBottom: 16 }}>
<div className="field" style={{ marginBottom: 0 }}>
<span className="field-label">导出语言</span>
<select className="select" value={exportLang} onChange={e => onExportLangChange(e.target.value)}>
{EXPORT_LANGUAGES.map(l => <option key={l.code} value={l.code}>{l.label}</option>)}
</select>
</div>
<button className="btn btn-primary" disabled={exportPdfBusy || exportLoading} onClick={onExportPdf}>
<FileText size={13} />{exportPdfBusy ? '导出中…' : exportLoading ? '翻译准备中…' : '导出PDF资料'}
</button>
</div>
<div className="doc-preview" dir={langInfo.rtl ? 'rtl' : 'ltr'} style={{ textAlign: langInfo.rtl ? 'right' : 'left' }}>
{(() => {
const sellingPointLines = (p.sellingPoints || '').split('\n').map(l => l.trim()).filter(Boolean);
const hasCoverPage = !!(p.productImageFileName || sellingPointLines.length > 0);
const specTable = (
<table className="doc-table spec-sheet-table">
<tbody>
<tr><td className="spec-label">{L('型号')}</td><td>{p.name || '—'}</td></tr>
{p.brand && <tr><td className="spec-label">{L('品牌')}</td><td>{p.brand}</td></tr>}
<tr><td className="spec-label">{L('产品大类')}</td><td>{cat ? L(cat.name) : '—'}</td></tr>
{p.type && <tr><td className="spec-label">{L('细分品类')}</td><td>{L(p.type)}</td></tr>}
{showMachineVariant && <tr><td className="spec-label">{L('机型')}</td><td>{`${L(normalizeMachineVariant(p.machineVariant))} ${MACHINE_VARIANT_NOTES[normalizeMachineVariant(p.machineVariant)] || ''}`.trim()}</td></tr>}
{exportRows.map((r, i) => (
<tr key={i}>
<td className="spec-label">{r.labelKey === '__port__' ? `${r.portType} ${L('输出')}` : L(r.labelKey)}</td>
<td>{V(r.value)}</td>
</tr>
))}
</tbody>
</table>
);
const disclaimer = <div style={{ marginTop: 18, fontSize: 11, color: '#999' }}>{L('* 产品规格如有变动,恕不另行通知')}</div>;
if (hasCoverPage) {
return (
<>
<div className="spec-sheet-cover">
{p.productImageFileName && (
<img className="spec-sheet-image-full" src={`/uploads/${p.id}/${p.productImageFileName}`} alt={p.name || ''} />
)}
<div style={{ fontSize: 28, fontWeight: 800, marginTop: 20 }}>{p.name || '—'}</div>
{p.brand && <div style={{ color: '#888', marginTop: 4, fontSize: 15 }}>{p.brand}</div>}
{sellingPointLines.length > 0 && (
<>
<div className="spec-sheet-bar" style={{ marginTop: 24 }}>{L('主要特点')}</div>
<ul className="spec-sheet-points">
{sellingPointLines.map((line, i) => <li key={i}>{L(line)}</li>)}
</ul>
</>
)}
</div>
<div className="spec-sheet-bar spec-sheet-page-break">{L('产品规格书')}</div>
{specTable}
{disclaimer}
</>
);
}
return (
<>
<div style={{ marginBottom: 20 }}>
<div style={{ fontSize: 26, fontWeight: 800 }}>{p.name || '—'}</div>
{p.brand && <div style={{ color: '#888', marginTop: 4 }}>{p.brand}</div>}
</div>
<div className="spec-sheet-bar">{L('产品规格书')}</div>
{specTable}
{disclaimer}
</>
);
})()}
</div>
</div>
</div>
</>
);
}
/* ----------------------------- 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 (
<div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14, flexWrap: 'wrap', gap: 8 }}>
<button className="btn btn-sm" onClick={onBack}><ChevronRight size={13} style={{ transform: 'rotate(180deg)' }} />返回列表</button>
<div style={{ display: 'flex', gap: 8 }}>
<button className="btn" onClick={onPrint}><Printer size={13} />打印</button>
<button className="btn" disabled={pdfExporting} onClick={onExportPdf}><FileText size={13} />{pdfExporting ? '导出中…' : '导出PDF'}</button>
<button className="btn btn-primary" onClick={onSave}><Save size={13} />保存</button>
</div>
</div>
<div className="panel section-gap">
<div className="panel-header"><div className="panel-title">{isInvoice ? 'Invoice 信息' : 'Quote 信息'}</div></div>
<div className="panel-body" style={{ padding: 18 }}>
<div className="grid-2">
<div className="field">
<span className="field-label">卖方</span>
<select className="select" value={draft.sellerProfileId} onChange={e => onSellerChange(e.target.value)}>
{sellerProfiles.map(sp => <option key={sp.id} value={sp.id}>{sp.name}</option>)}
</select>
</div>
<div className="field">
<span className="field-label">关联客户档案可选仅用于自动填名称</span>
<select className="select" value={draft.customerId} onChange={e => onLinkCustomer(e.target.value)}>
<option value="">不关联手动填写</option>
{sortedCustomers.map(c => <option key={c.id} value={c.id}>{quoteLinkLabel(c)}</option>)}
</select>
</div>
<div className="field"><span className="field-label">单号</span><input className="input" value={draft.number} onChange={e => onUpdate({ number: e.target.value })} /></div>
<div className="field"><span className="field-label">日期</span><input type="date" className="input" value={draft.date} onChange={e => onUpdate({ date: e.target.value })} /></div>
<div className="field">
<span className="field-label">币种</span>
<select className="select" value={draft.currency} onChange={e => onCurrencyChange(e.target.value)}>
{CURRENCIES.map(c => <option key={c.code} value={c.code}>{c.code}{c.symbol}</option>)}
</select>
</div>
{isInvoice && (
<>
<div className="field">
<span className="field-label">Payment Terms</span>
<select className="select" value={draft.paymentTerms} onChange={e => onUpdate({ paymentTerms: e.target.value })}>
<option value="">请选择</option>
{PAYMENT_TERMS_OPTIONS.map(t => <option key={t} value={t}>{t}</option>)}
</select>
</div>
<div className="field">
<span className="field-label">Due Date自动计算日期 + 1 个月</span>
<input className="input" value={dueDate} readOnly disabled />
</div>
</>
)}
</div>
<button className="btn btn-sm" onClick={onManageSellers}><Building2 size={12} />管理卖方信息 / 上传Logo</button>
<div className="grid-2" style={{ marginTop: 16 }}>
<div className="field">
<span className="field-label">{isInvoice ? 'Bill To 名称' : 'Quote To 名称'}可换行第一行作为主标题</span>
<textarea className="textarea" style={{ fontFamily: 'inherit', fontSize: 13, minHeight: 50 }} value={draft.billToName} onChange={e => onUpdate({ billToName: e.target.value })} />
</div>
{isInvoice && <div className="field"><span className="field-label">Ship To 名称留空则单据上不显示这一项</span><input className="input" value={draft.shipToName} onChange={e => onUpdate({ shipToName: e.target.value })} /></div>}
</div>
<div className="field-label" style={{ marginTop: 18, marginBottom: 8 }}>产品明细</div>
{draft.lineItems.map(li => {
const suggestions = suggestLineId === li.id ? suggestionsFor(li) : [];
return (
<div key={li.id} style={{ display: 'grid', gridTemplateColumns: '2.4fr 0.8fr 1fr 1fr auto', gap: 10, padding: '10px 0', borderBottom: '1px solid var(--line)', alignItems: 'start' }}>
<div className="field" style={{ marginBottom: 0, position: 'relative' }}>
<textarea
className="textarea" style={{ fontFamily: 'inherit', fontSize: 13, minHeight: 40 }}
placeholder="产品描述,第一行会加粗显示,换行后的内容会作为附属说明(小字)"
value={li.description}
onChange={e => { onUpdateLineItem(li.id, { description: e.target.value }); setSuggestLineId(li.id); }}
onFocus={() => setSuggestLineId(li.id)}
onBlur={() => setTimeout(() => setSuggestLineId(s => (s === li.id ? null : s)), 150)}
/>
{suggestions.length > 0 && (
<div className="panel" style={{ position: 'absolute', zIndex: 10, left: 0, right: 0, top: '100%', marginTop: 2, maxHeight: 180, overflowY: 'auto' }}>
{suggestions.map((h, i) => (
<div key={i} className="row" style={{ cursor: 'pointer', padding: '8px 10px' }} onMouseDown={() => applySuggestion(li.id, h)}>
<div className="row-main">
<div className="row-title" style={{ fontSize: 12.5 }}>{h.description.split('\n')[0]}</div>
<div className="row-sub">历史单价 {h.rate} · {h.date}</div>
</div>
</div>
))}
</div>
)}
</div>
<div className="field" style={{ marginBottom: 0 }}><input className="input" placeholder="数量" value={li.quantity} onChange={e => onUpdateLineItem(li.id, { quantity: e.target.value })} /></div>
<div className="field" style={{ marginBottom: 0 }}><input className="input" placeholder="单价" value={li.rate} onChange={e => onUpdateLineItem(li.id, { rate: e.target.value })} /></div>
<div className="field" style={{ marginBottom: 0, fontSize: 13, paddingTop: 8, color: 'var(--ink-soft)' }}>
{formatMoney((parseFloat(li.quantity) || 0) * (parseFloat(li.rate) || 0), draft.currency)}
</div>
<button className="btn btn-sm btn-ghost btn-danger" onClick={() => onRemoveLineItem(li.id)}><Trash2 size={13} /></button>
</div>
);
})}
<button className="btn btn-sm" onClick={onAddLineItem} style={{ marginTop: 8 }}><Plus size={12} />添加一行</button>
{isInvoice && (
<div className="grid-2" style={{ marginTop: 16 }}>
<div className="field"><span className="field-label">银行信息 Bank Information</span><textarea className="textarea" style={{ fontFamily: 'inherit', fontSize: 13, minHeight: 70 }} value={draft.bankInfo} onChange={e => onUpdate({ bankInfo: e.target.value })} placeholder="/Invoice" /></div>
<div className="field"><span className="field-label">签署人姓名</span><input className="input" value={draft.signerName} onChange={e => onUpdate({ signerName: e.target.value })} /></div>
</div>
)}
<div className="field" style={{ marginTop: 16 }}>
<span className="field-label">{isInvoice ? 'Terms 条款' : 'Notes 备注'}</span>
<textarea className="textarea" style={{ fontFamily: 'inherit', fontSize: 13, minHeight: 70 }} value={draft.notes} onChange={e => onUpdate({ notes: e.target.value })} />
{!isInvoice && (
<div className="checkbox-row" style={{ marginTop: 8 }}>
{QUOTE_NOTE_OPTIONS.map(line => (
<div key={line} className={`checkbox-pill ${activeNoteLines.includes(line) ? 'checked' : ''}`} onClick={() => onToggleNoteLine(line)}>
{activeNoteLines.includes(line) && <Check size={11} />} {line}
</div>
))}
</div>
)}
</div>
</div>
</div>
<div className="doc-preview">
<div className="doc-row" style={{ alignItems: 'flex-start', marginBottom: 28 }}>
<div>
{logoUrl ? (
<img src={logoUrl} alt="logo" style={{ maxHeight: 64, maxWidth: 220, objectFit: 'contain' }} />
) : (
<div style={{ fontSize: 26, fontWeight: 800, letterSpacing: 1 }}>{(seller && seller.name) || ''}</div>
)}
</div>
<div style={{ textAlign: 'right' }}>
<div style={{ fontSize: 34, fontWeight: 300, color: '#333', letterSpacing: 2 }}>{isInvoice ? 'INVOICE' : 'QUOTE'}</div>
<div style={{ color: '#999', marginTop: 2 }}># {draft.number}</div>
</div>
</div>
<div className="doc-row" style={{ marginBottom: 24 }}>
<div>
<div style={{ fontWeight: 700 }}>{seller && seller.name}</div>
<div style={{ color: '#555', fontSize: 13, marginTop: 4, maxWidth: 380, whiteSpace: 'pre-wrap' }}>{seller && seller.address}</div>
</div>
<div style={{ textAlign: 'right', fontSize: 13, minWidth: 200 }}>
<div className="doc-row" style={{ marginBottom: 4 }}><span className="doc-label">Date:</span><span>{draft.date}</span></div>
{isInvoice && (
<>
{draft.paymentTerms && <div className="doc-row" style={{ marginBottom: 4 }}><span className="doc-label">Payment Terms:</span><span>{draft.paymentTerms}</span></div>}
<div className="doc-row" style={{ marginBottom: 4 }}><span className="doc-label">Due Date:</span><span>{dueDate}</span></div>
<div className="doc-balance-due doc-row"><span style={{ fontWeight: 700 }}>Balance Due:</span><span style={{ fontWeight: 700 }}>{formatMoney(total, draft.currency)}</span></div>
</>
)}
</div>
</div>
{!isInvoice ? (
<div style={{ marginBottom: 28 }}>
<div className="doc-label">Quote To:</div>
{(() => {
const billLines = (draft.billToName || '').split('\n');
return (
<>
<div style={{ fontWeight: 700, marginTop: 4, textTransform: 'uppercase' }}>{billLines[0] || '—'}</div>
{billLines.slice(1).map((l, i) => l.trim() && <div key={i} style={{ color: '#999', fontSize: 12, fontWeight: 400 }}>{l}</div>)}
</>
);
})()}
</div>
) : (
<div className="doc-row" style={{ marginBottom: 28 }}>
<div>
<div className="doc-label">Bill To:</div>
{(() => {
const billLines = (draft.billToName || '').split('\n');
return (
<>
<div style={{ fontWeight: 700, marginTop: 4, textTransform: 'uppercase' }}>{billLines[0] || '—'}</div>
{billLines.slice(1).map((l, i) => l.trim() && <div key={i} style={{ color: '#999', fontSize: 12, fontWeight: 400 }}>{l}</div>)}
</>
);
})()}
</div>
{draft.shipToName && draft.shipToName.trim() && (
<div>
<div className="doc-label">Ship To:</div>
<div style={{ fontWeight: 700, marginTop: 4 }}>{draft.shipToName}</div>
</div>
)}
</div>
)}
<table className="doc-table">
<thead>
<tr>
<th>Item</th>
<th className="num">Quantity</th>
<th className="num">Rate</th>
<th className="num">Amount</th>
</tr>
</thead>
<tbody>
{draft.lineItems.map(li => {
const lines = (li.description || '').split('\n');
const amount = (parseFloat(li.quantity) || 0) * (parseFloat(li.rate) || 0);
return (
<tr key={li.id}>
<td>
<div style={{ fontWeight: 700 }}>{lines[0] || '—'}</div>
{lines.slice(1).map((l, i) => l.trim() && <div key={i} style={{ color: '#999', fontSize: 12 }}>{l}</div>)}
</td>
<td className="num">{li.quantity || '—'}</td>
<td className="num">{li.rate ? formatMoney(parseFloat(li.rate), draft.currency) : '—'}</td>
<td className="num">{formatMoney(amount, draft.currency)}</td>
</tr>
);
})}
</tbody>
</table>
<div className="doc-totals">
<div className="doc-totals-row total"><span>Total:</span><span>{formatMoney(total, draft.currency)}</span></div>
</div>
{isInvoice && (draft.bankInfo || (seller && seller.bankInfo)) && (
<div style={{ margin: '24px 0' }}>
<div className="doc-label" style={{ marginBottom: 4 }}>Bank Information:</div>
<div style={{ fontSize: 13, whiteSpace: 'pre-wrap' }}>{draft.bankInfo || (seller && seller.bankInfo)}</div>
</div>
)}
{draft.notes && (
<div style={{ margin: '24px 0' }}>
<div className="doc-label" style={{ marginBottom: 4 }}>{isInvoice ? 'Terms:' : 'Notes:'}</div>
<div style={{ fontSize: 13, whiteSpace: 'pre-wrap' }}>{draft.notes}</div>
</div>
)}
{isInvoice && (
<div style={{ textAlign: 'right', marginTop: 30 }}>
<div style={{ color: '#3B5BA9', fontStyle: 'italic', fontSize: 13 }}>For and on behalf of</div>
<div style={{ color: '#3B5BA9', fontWeight: 700 }}>{seller && seller.name}</div>
{draft.signerName && <div style={{ fontStyle: 'italic', fontSize: 19, margin: '10px 0 2px', fontFamily: 'Georgia, serif' }}>{draft.signerName}</div>}
<div style={{ borderTop: '1px dotted #aaa', width: 200, marginLeft: 'auto', marginTop: 8 }} />
<div style={{ color: '#3B5BA9', fontStyle: 'italic', fontSize: 12, marginTop: 4 }}>Authorized Signature(s)</div>
</div>
)}
</div>
</div>
);
}
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 (
<div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14, flexWrap: 'wrap', gap: 8 }}>
<button className="btn btn-sm" onClick={onBack}><ChevronRight size={13} style={{ transform: 'rotate(180deg)' }} />返回列表</button>
<div style={{ display: 'flex', gap: 8 }}>
<button className="btn" onClick={onPrint}><Printer size={13} />打印</button>
<button className="btn" disabled={pdfExporting} onClick={onExportPdf}><FileText size={13} />{pdfExporting ? '导出中…' : '导出PDF'}</button>
<button className="btn btn-primary" onClick={onSave}><Save size={13} />保存</button>
</div>
</div>
<div className="panel section-gap">
<div className="panel-header"><div className="panel-title">箱单信息</div></div>
<div className="panel-body" style={{ padding: 18 }}>
<div className="grid-2">
<div className="field">
<div className="field-label-row">
<span className="field-label">卖方</span>
<label className="field-manage-link" style={{ cursor: 'pointer' }}>
<input type="checkbox" checked={draft.showSeller} onChange={e => onUpdate({ showSeller: e.target.checked })} style={{ marginRight: 3 }} />
单据上显示
</label>
</div>
<select className="select" value={draft.sellerProfileId} onChange={e => onSellerChange(e.target.value)}>
{sellerProfiles.map(sp => <option key={sp.id} value={sp.id}>{sp.name}</option>)}
</select>
</div>
<div className="field">
<span className="field-label">关联客户档案可选仅用于自动填名称</span>
<select className="select" value={draft.customerId} onChange={e => onLinkCustomer(e.target.value)}>
<option value="">不关联手动填写</option>
{sortedCustomers.map(c => <option key={c.id} value={c.id}>{quoteLinkLabel(c)}</option>)}
</select>
</div>
<div className="field"><span className="field-label">单号</span><input className="input" value={draft.number} onChange={e => onUpdate({ number: e.target.value })} /></div>
<div className="field"><span className="field-label">日期</span><input type="date" className="input" value={draft.date} onChange={e => onUpdate({ date: e.target.value })} /></div>
</div>
<button className="btn btn-sm" onClick={onManageSellers}><Building2 size={12} />管理卖方信息 / 上传Logo</button>
<div className="field" style={{ marginTop: 16 }}>
<div className="field-label-row">
<span className="field-label">客户名称 / 地址可换行第一行作为主标题</span>
<label className="field-manage-link" style={{ cursor: 'pointer' }}>
<input type="checkbox" checked={draft.showBillTo} onChange={e => onUpdate({ showBillTo: e.target.checked })} style={{ marginRight: 3 }} />
单据上显示
</label>
</div>
<textarea className="textarea" style={{ fontFamily: 'inherit', fontSize: 13, minHeight: 50 }} value={draft.billToName} onChange={e => onUpdate({ billToName: e.target.value })} />
</div>
<div className="field-label" style={{ marginTop: 18, marginBottom: 8 }}>装箱明细按产品行汇总</div>
{draft.lineItems.map(li => (
<div key={li.id} style={{ display: 'grid', gridTemplateColumns: '2fr 0.8fr 0.8fr 1fr 1fr 1fr auto', gap: 10, padding: '10px 0', borderBottom: '1px solid var(--line)', alignItems: 'start' }}>
<div className="field" style={{ marginBottom: 0 }}>
<textarea className="textarea" style={{ fontFamily: 'inherit', fontSize: 13, minHeight: 40 }} placeholder="品名" value={li.description} onChange={e => onUpdateLineItem(li.id, { description: e.target.value })} />
</div>
<div className="field" style={{ marginBottom: 0 }}><input className="input" placeholder="总件数" value={li.qty} onChange={e => onUpdateLineItem(li.id, { qty: e.target.value })} /></div>
<div className="field" style={{ marginBottom: 0 }}><input className="input" placeholder="总箱数" value={li.cartons} onChange={e => onUpdateLineItem(li.id, { cartons: e.target.value })} /></div>
<div className="field" style={{ marginBottom: 0 }}><input className="input" placeholder="总毛重(kg)" value={li.grossWeight} onChange={e => onUpdateLineItem(li.id, { grossWeight: e.target.value })} /></div>
<div className="field" style={{ marginBottom: 0 }}><input className="input" placeholder="总净重(kg)" value={li.netWeight} onChange={e => onUpdateLineItem(li.id, { netWeight: e.target.value })} /></div>
<div className="field" style={{ marginBottom: 0 }}><input className="input" placeholder="总体积(CBM)" value={li.volume} onChange={e => onUpdateLineItem(li.id, { volume: e.target.value })} /></div>
<button className="btn btn-sm btn-ghost btn-danger" style={{ marginTop: 2 }} onClick={() => onRemoveLineItem(li.id)}><Trash2 size={13} /></button>
</div>
))}
<button className="btn btn-sm" style={{ marginTop: 10 }} onClick={onAddLineItem}><Plus size={13} />新增一行</button>
<div className="field" style={{ marginTop: 16 }}><span className="field-label">备注</span><textarea className="textarea" style={{ fontFamily: 'inherit', fontSize: 13 }} value={draft.notes} onChange={e => onUpdate({ notes: e.target.value })} /></div>
</div>
</div>
<div className="doc-preview">
<div className="doc-row" style={{ alignItems: 'flex-start', marginBottom: 28 }}>
<div>
{draft.showSeller ? (
logoUrl ? (
<img src={logoUrl} alt="logo" style={{ maxHeight: 64, maxWidth: 220, objectFit: 'contain' }} />
) : (
<div style={{ fontSize: 26, fontWeight: 800, letterSpacing: 1 }}>{(seller && seller.name) || ''}</div>
)
) : <div />}
</div>
<div style={{ textAlign: 'right' }}>
<div style={{ fontSize: 30, fontWeight: 300, color: '#333', letterSpacing: 2 }}>PACKING LIST</div>
<div style={{ color: '#999', marginTop: 2 }}># {draft.number}</div>
</div>
</div>
<div className="doc-row" style={{ marginBottom: 24 }}>
<div>
{draft.showSeller && (
<>
<div style={{ fontWeight: 700 }}>{seller && seller.name}</div>
<div style={{ color: '#555', fontSize: 13, marginTop: 4, maxWidth: 380, whiteSpace: 'pre-wrap' }}>{seller && seller.address}</div>
</>
)}
</div>
<div style={{ textAlign: 'right', fontSize: 13, minWidth: 200 }}>
<div className="doc-row" style={{ marginBottom: 4 }}><span className="doc-label">Date:</span><span>{draft.date}</span></div>
</div>
</div>
{draft.showBillTo && (
<div style={{ marginBottom: 28 }}>
<div className="doc-label">Consignee / Customer:</div>
<div style={{ fontWeight: 700, marginTop: 4, textTransform: 'uppercase' }}>{billLines[0] || '—'}</div>
{billLines.slice(1).map((l, i) => l.trim() && <div key={i} style={{ color: '#999', fontSize: 12, fontWeight: 400 }}>{l}</div>)}
</div>
)}
<table className="doc-table">
<thead>
<tr>
<th>Description of Goods</th>
<th className="num">Qty</th>
<th className="num">Cartons</th>
<th className="num">G.W. (kg)</th>
<th className="num">N.W. (kg)</th>
<th className="num">Volume (CBM)</th>
</tr>
</thead>
<tbody>
{draft.lineItems.map(li => {
const lines = (li.description || '').split('\n');
return (
<tr key={li.id}>
<td>
<div style={{ fontWeight: 700 }}>{lines[0] || '—'}</div>
{lines.slice(1).map((l, i) => l.trim() && <div key={i} style={{ color: '#999', fontSize: 12 }}>{l}</div>)}
</td>
<td className="num">{li.qty || '—'}</td>
<td className="num">{li.cartons || '—'}</td>
<td className="num">{li.grossWeight || '—'}</td>
<td className="num">{li.netWeight || '—'}</td>
<td className="num">{li.volume || '—'}</td>
</tr>
);
})}
</tbody>
</table>
<div className="doc-totals">
<div className="doc-totals-row total"><span>Total:</span><span>{totals.qty} pcs · {totals.cartons} ctns · {totals.grossWeight} kg G.W. · {totals.netWeight} kg N.W. · {totals.volume} CBM</span></div>
</div>
{draft.notes && (
<div style={{ margin: '24px 0' }}>
<div className="doc-label" style={{ marginBottom: 4 }}>Notes:</div>
<div style={{ fontSize: 13, whiteSpace: 'pre-wrap' }}>{draft.notes}</div>
</div>
)}
</div>
</div>
);
}
function Info({ icon, label, value }) {
return (
<div>
<div className="field-label" style={{ display: 'flex', alignItems: 'center', gap: 5 }}>{icon}{label}</div>
<div style={{ fontSize: 13 }}>{value}</div>
</div>
);
}