4675 lines
278 KiB
JavaScript
4675 lines
278 KiB
JavaScript
import React, { useState, useEffect, useMemo } from 'react';
|
||
import {
|
||
Search, Plus, X, Mail, Edit2, Trash2, Check, Clock,
|
||
Settings as SettingsIcon, Copy, ChevronRight,
|
||
Building2, Globe, Phone, Save, RefreshCw, LayoutDashboard, Users,
|
||
Package, Layers, Zap, Tag, Inbox, Paperclip,
|
||
Menu, LogOut, GripVertical, BookOpen, FileText, FolderTree, Lock, Crown, Sparkles, Printer, Image as ImageIcon,
|
||
ShieldCheck, Settings2, Boxes, Download
|
||
} from 'lucide-react';
|
||
import {
|
||
storageGet, storageSet, uploadFile, deleteFile,
|
||
authCheck, authLogin, authLogout, authChangePassword,
|
||
getAiConfig, saveAiConfig, deleteAiConfig, testAiConfig, generateAiDraft,
|
||
parseCatalogFiles, translateTerms,
|
||
} from './storage';
|
||
|
||
/* ----------------------------- constants ----------------------------- */
|
||
|
||
const REGIONS = [
|
||
{ id: 'africa', label: 'Africa / 非洲' },
|
||
{ id: 'middle_east', label: 'Middle East / 中东' },
|
||
{ id: 'australia', label: 'Australia / 大洋洲' },
|
||
{ id: 'europe', label: 'Europe / 欧洲' },
|
||
{ id: 'sea', label: 'SE Asia / 东南亚' },
|
||
{ id: 'other', label: 'Other / 其他' },
|
||
];
|
||
|
||
const PRODUCT_LINES = ['UPS不间断电源', '光伏逆变器', '储能电池', 'Other'];
|
||
|
||
// LEGACY_PRODUCT_TAXONOMY:旧版写死的三级分类树,仅用于首次迁移生成默认的
|
||
// 动态类目数据(类目/品牌/类型现在都存在 crm:productCategories 里,用户可自行增删)。
|
||
const LEGACY_PRODUCT_TAXONOMY = [
|
||
{
|
||
id: 'ups', label: 'UPS不间断电源 / UPS', short: 'UPS不间断电源',
|
||
subs: [
|
||
{ id: 'mini', label: '迷你UPS / Mini UPS', short: '迷你UPS' },
|
||
{ id: 'offline', label: '离线式UPS / Offline UPS', short: '离线式UPS' },
|
||
{ id: 'line_interactive', label: '在线互动式UPS / Line-Interactive UPS', short: '在线互动式UPS' },
|
||
{
|
||
id: 'online', label: '在线式UPS / Online UPS', short: '在线式UPS',
|
||
subs: [
|
||
{ id: 'rack', label: '机架式UPS / Rack-mount UPS', short: '机架式UPS' },
|
||
{ id: '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: '磷酸铁锂电池' },
|
||
],
|
||
},
|
||
];
|
||
|
||
function flattenLegacyTypes(top) {
|
||
const out = [];
|
||
(top.subs || []).forEach(s => {
|
||
out.push(s.short);
|
||
(s.subs || []).forEach(ss => out.push(ss.short));
|
||
});
|
||
return out;
|
||
}
|
||
function buildDefaultProductCategories() {
|
||
return LEGACY_PRODUCT_TAXONOMY.map(top => ({
|
||
id: top.id, name: top.short, brands: [], types: flattenLegacyTypes(top),
|
||
}));
|
||
}
|
||
|
||
const DEFAULT_SETTINGS = { defaultFollowUpDays: 14, dueSoonWindow: 7 };
|
||
|
||
const CUSTOMER_SOURCES = ['Made-in-China', '官网', '展会', 'Other'];
|
||
const FILE_CATEGORIES = ['报价单', '合同', '聊天记录', '产品资料', '物流单据', 'Other'];
|
||
|
||
// 国家 -> 地区 / 电话国家代码 / 时区(以首都为准)映射表
|
||
// 用于:选择国家后自动匹配所属大洲、电话区号前缀、当地时间
|
||
const COUNTRIES = [
|
||
// Africa / 非洲
|
||
{ name: '尼日利亚', en: 'Nigeria', region: 'africa', dial: '+234', tz: 'Africa/Lagos' },
|
||
{ name: '埃及', en: 'Egypt', region: 'africa', dial: '+20', tz: 'Africa/Cairo' },
|
||
{ name: '南非', en: 'South Africa', region: 'africa', dial: '+27', tz: 'Africa/Johannesburg' },
|
||
{ name: '肯尼亚', en: 'Kenya', region: 'africa', dial: '+254', tz: 'Africa/Nairobi' },
|
||
{ name: '加纳', en: 'Ghana', region: 'africa', dial: '+233', tz: 'Africa/Accra' },
|
||
{ name: '阿尔及利亚', en: 'Algeria', region: 'africa', dial: '+213', tz: 'Africa/Algiers' },
|
||
{ name: '摩洛哥', en: 'Morocco', region: 'africa', dial: '+212', tz: 'Africa/Casablanca' },
|
||
{ name: '突尼斯', en: 'Tunisia', region: 'africa', dial: '+216', tz: 'Africa/Tunis' },
|
||
{ name: '埃塞俄比亚', en: 'Ethiopia', region: 'africa', dial: '+251', tz: 'Africa/Addis_Ababa' },
|
||
{ name: '坦桑尼亚', en: 'Tanzania', region: 'africa', dial: '+255', tz: 'Africa/Dar_es_Salaam' },
|
||
{ name: '乌干达', en: 'Uganda', region: 'africa', dial: '+256', tz: 'Africa/Kampala' },
|
||
{ name: '安哥拉', en: 'Angola', region: 'africa', dial: '+244', tz: 'Africa/Luanda' },
|
||
{ name: '莫桑比克', en: 'Mozambique', region: 'africa', dial: '+258', tz: 'Africa/Maputo' },
|
||
{ name: '赞比亚', en: 'Zambia', region: 'africa', dial: '+260', tz: 'Africa/Lusaka' },
|
||
{ name: '津巴布韦', en: 'Zimbabwe', region: 'africa', dial: '+263', tz: 'Africa/Harare' },
|
||
{ name: '塞内加尔', en: 'Senegal', region: 'africa', dial: '+221', tz: 'Africa/Dakar' },
|
||
{ name: '科特迪瓦', en: "Cote d'Ivoire", region: 'africa', dial: '+225', tz: 'Africa/Abidjan' },
|
||
{ name: '喀麦隆', en: 'Cameroon', region: 'africa', dial: '+237', tz: 'Africa/Douala' },
|
||
{ name: '刚果(金)', en: 'DR Congo', region: 'africa', dial: '+243', tz: 'Africa/Kinshasa' },
|
||
{ name: '刚果(布)', en: 'Congo', region: 'africa', dial: '+242', tz: 'Africa/Brazzaville' },
|
||
{ name: '利比亚', en: 'Libya', region: 'africa', dial: '+218', tz: 'Africa/Tripoli' },
|
||
{ name: '苏丹', en: 'Sudan', region: 'africa', dial: '+249', tz: 'Africa/Khartoum' },
|
||
{ name: '卢旺达', en: 'Rwanda', region: 'africa', dial: '+250', tz: 'Africa/Kigali' },
|
||
{ name: '博茨瓦纳', en: 'Botswana', region: 'africa', dial: '+267', tz: 'Africa/Gaborone' },
|
||
{ name: '纳米比亚', en: 'Namibia', region: 'africa', dial: '+264', tz: 'Africa/Windhoek' },
|
||
{ name: '马里', en: 'Mali', region: 'africa', dial: '+223', tz: 'Africa/Bamako' },
|
||
{ name: '尼日尔', en: 'Niger', region: 'africa', dial: '+227', tz: 'Africa/Niamey' },
|
||
{ name: '贝宁', en: 'Benin', region: 'africa', dial: '+229', tz: 'Africa/Porto-Novo' },
|
||
{ name: '布基纳法索', en: 'Burkina Faso', region: 'africa', dial: '+226', tz: 'Africa/Ouagadougou' },
|
||
{ name: '多哥', en: 'Togo', region: 'africa', dial: '+228', tz: 'Africa/Lome' },
|
||
{ name: '几内亚', en: 'Guinea', region: 'africa', dial: '+224', tz: 'Africa/Conakry' },
|
||
{ name: '塞拉利昂', en: 'Sierra Leone', region: 'africa', dial: '+232', tz: 'Africa/Freetown' },
|
||
{ name: '利比里亚', en: 'Liberia', region: 'africa', dial: '+231', tz: 'Africa/Monrovia' },
|
||
{ name: '乍得', en: 'Chad', region: 'africa', dial: '+235', tz: 'Africa/Ndjamena' },
|
||
{ name: '索马里', en: 'Somalia', region: 'africa', dial: '+252', tz: 'Africa/Mogadishu' },
|
||
{ name: '吉布提', en: 'Djibouti', region: 'africa', dial: '+253', tz: 'Africa/Djibouti' },
|
||
{ name: '南苏丹', en: 'South Sudan', region: 'africa', dial: '+211', tz: 'Africa/Juba' },
|
||
{ name: '马拉维', en: 'Malawi', region: 'africa', dial: '+265', tz: 'Africa/Blantyre' },
|
||
{ name: '马达加斯加', en: 'Madagascar', region: 'africa', dial: '+261', tz: 'Indian/Antananarivo' },
|
||
{ name: '毛里求斯', en: 'Mauritius', region: 'africa', dial: '+230', tz: 'Indian/Mauritius' },
|
||
{ name: '加蓬', en: 'Gabon', region: 'africa', dial: '+241', tz: 'Africa/Libreville' },
|
||
{ name: '布隆迪', en: 'Burundi', region: 'africa', dial: '+257', tz: 'Africa/Bujumbura' },
|
||
{ name: '斯威士兰', en: 'Eswatini', region: 'africa', dial: '+268', tz: 'Africa/Mbabane' },
|
||
{ name: '厄立特里亚', en: 'Eritrea', region: 'africa', dial: '+291', tz: 'Africa/Asmara' },
|
||
|
||
// Middle East / 中东
|
||
{ name: '沙特阿拉伯', en: 'Saudi Arabia', region: 'middle_east', dial: '+966', tz: 'Asia/Riyadh' },
|
||
{ name: '阿联酋', en: 'UAE', region: 'middle_east', dial: '+971', tz: 'Asia/Dubai' },
|
||
{ name: '卡塔尔', en: 'Qatar', region: 'middle_east', dial: '+974', tz: 'Asia/Qatar' },
|
||
{ name: '科威特', en: 'Kuwait', region: 'middle_east', dial: '+965', tz: 'Asia/Kuwait' },
|
||
{ name: '巴林', en: 'Bahrain', region: 'middle_east', dial: '+973', tz: 'Asia/Bahrain' },
|
||
{ name: '阿曼', en: 'Oman', region: 'middle_east', dial: '+968', tz: 'Asia/Muscat' },
|
||
{ name: '伊拉克', en: 'Iraq', region: 'middle_east', dial: '+964', tz: 'Asia/Baghdad' },
|
||
{ name: '伊朗', en: 'Iran', region: 'middle_east', dial: '+98', tz: 'Asia/Tehran' },
|
||
{ name: '约旦', en: 'Jordan', region: 'middle_east', dial: '+962', tz: 'Asia/Amman' },
|
||
{ name: '黎巴嫩', en: 'Lebanon', region: 'middle_east', dial: '+961', tz: 'Asia/Beirut' },
|
||
{ name: '以色列', en: 'Israel', region: 'middle_east', dial: '+972', tz: 'Asia/Jerusalem' },
|
||
{ name: '也门', en: 'Yemen', region: 'middle_east', dial: '+967', tz: 'Asia/Aden' },
|
||
{ name: '叙利亚', en: 'Syria', region: 'middle_east', dial: '+963', tz: 'Asia/Damascus' },
|
||
{ name: '土耳其', en: 'Turkey', region: 'middle_east', dial: '+90', tz: 'Europe/Istanbul' },
|
||
{ name: '巴勒斯坦', en: 'Palestine', region: 'middle_east', dial: '+970', tz: 'Asia/Gaza' },
|
||
|
||
// Australia / 大洋洲
|
||
{ name: '澳大利亚', en: 'Australia', region: 'australia', dial: '+61', tz: 'Australia/Sydney' },
|
||
{ name: '新西兰', en: 'New Zealand', region: 'australia', dial: '+64', tz: 'Pacific/Auckland' },
|
||
{ name: '巴布亚新几内亚', en: 'Papua New Guinea', region: 'australia', dial: '+675', tz: 'Pacific/Port_Moresby' },
|
||
{ name: '斐济', en: 'Fiji', region: 'australia', dial: '+679', tz: 'Pacific/Fiji' },
|
||
|
||
// Europe / 欧洲
|
||
{ name: '俄罗斯', en: 'Russia', region: 'europe', dial: '+7', tz: 'Europe/Moscow' },
|
||
{ name: '德国', en: 'Germany', region: 'europe', dial: '+49', tz: 'Europe/Berlin' },
|
||
{ name: '法国', en: 'France', region: 'europe', dial: '+33', tz: 'Europe/Paris' },
|
||
{ name: '英国', en: 'United Kingdom', region: 'europe', dial: '+44', tz: 'Europe/London' },
|
||
{ name: '意大利', en: 'Italy', region: 'europe', dial: '+39', tz: 'Europe/Rome' },
|
||
{ name: '西班牙', en: 'Spain', region: 'europe', dial: '+34', tz: 'Europe/Madrid' },
|
||
{ name: '波兰', en: 'Poland', region: 'europe', dial: '+48', tz: 'Europe/Warsaw' },
|
||
{ name: '乌克兰', en: 'Ukraine', region: 'europe', dial: '+380', tz: 'Europe/Kyiv' },
|
||
{ name: '荷兰', en: 'Netherlands', region: 'europe', dial: '+31', tz: 'Europe/Amsterdam' },
|
||
{ name: '比利时', en: 'Belgium', region: 'europe', dial: '+32', tz: 'Europe/Brussels' },
|
||
{ name: '瑞典', en: 'Sweden', region: 'europe', dial: '+46', tz: 'Europe/Stockholm' },
|
||
{ name: '挪威', en: 'Norway', region: 'europe', dial: '+47', tz: 'Europe/Oslo' },
|
||
{ name: '芬兰', en: 'Finland', region: 'europe', dial: '+358', tz: 'Europe/Helsinki' },
|
||
{ name: '丹麦', en: 'Denmark', region: 'europe', dial: '+45', tz: 'Europe/Copenhagen' },
|
||
{ name: '瑞士', en: 'Switzerland', region: 'europe', dial: '+41', tz: 'Europe/Zurich' },
|
||
{ name: '奥地利', en: 'Austria', region: 'europe', dial: '+43', tz: 'Europe/Vienna' },
|
||
{ name: '葡萄牙', en: 'Portugal', region: 'europe', dial: '+351', tz: 'Europe/Lisbon' },
|
||
{ name: '希腊', en: 'Greece', region: 'europe', dial: '+30', tz: 'Europe/Athens' },
|
||
{ name: '罗马尼亚', en: 'Romania', region: 'europe', dial: '+40', tz: 'Europe/Bucharest' },
|
||
{ name: '匈牙利', en: 'Hungary', region: 'europe', dial: '+36', tz: 'Europe/Budapest' },
|
||
{ name: '捷克', en: 'Czech Republic', region: 'europe', dial: '+420', tz: 'Europe/Prague' },
|
||
{ name: '塞尔维亚', en: 'Serbia', region: 'europe', dial: '+381', tz: 'Europe/Belgrade' },
|
||
{ name: '保加利亚', en: 'Bulgaria', region: 'europe', dial: '+359', tz: 'Europe/Sofia' },
|
||
{ name: '克罗地亚', en: 'Croatia', region: 'europe', dial: '+385', tz: 'Europe/Zagreb' },
|
||
{ name: '爱尔兰', en: 'Ireland', region: 'europe', dial: '+353', tz: 'Europe/Dublin' },
|
||
{ name: '白俄罗斯', en: 'Belarus', region: 'europe', dial: '+375', tz: 'Europe/Minsk' },
|
||
{ name: '摩尔多瓦', en: 'Moldova', region: 'europe', dial: '+373', tz: 'Europe/Chisinau' },
|
||
{ name: '立陶宛', en: 'Lithuania', region: 'europe', dial: '+370', tz: 'Europe/Vilnius' },
|
||
{ name: '拉脱维亚', en: 'Latvia', region: 'europe', dial: '+371', tz: 'Europe/Riga' },
|
||
{ name: '爱沙尼亚', en: 'Estonia', region: 'europe', dial: '+372', tz: 'Europe/Tallinn' },
|
||
{ name: '斯洛伐克', en: 'Slovakia', region: 'europe', dial: '+421', tz: 'Europe/Bratislava' },
|
||
{ name: '斯洛文尼亚', en: 'Slovenia', region: 'europe', dial: '+386', tz: 'Europe/Ljubljana' },
|
||
{ name: '波黑', en: 'Bosnia and Herzegovina', region: 'europe', dial: '+387', tz: 'Europe/Sarajevo' },
|
||
{ name: '北马其顿', en: 'North Macedonia', region: 'europe', dial: '+389', tz: 'Europe/Skopje' },
|
||
{ name: '阿尔巴尼亚', en: 'Albania', region: 'europe', dial: '+355', tz: 'Europe/Tirane' },
|
||
{ name: '黑山', en: 'Montenegro', region: 'europe', dial: '+382', tz: 'Europe/Podgorica' },
|
||
{ name: '冰岛', en: 'Iceland', region: 'europe', dial: '+354', tz: 'Atlantic/Reykjavik' },
|
||
{ name: '马耳他', en: 'Malta', region: 'europe', dial: '+356', tz: 'Europe/Malta' },
|
||
{ name: '塞浦路斯', en: 'Cyprus', region: 'europe', dial: '+357', tz: 'Asia/Nicosia' },
|
||
{ name: '卢森堡', en: 'Luxembourg', region: 'europe', dial: '+352', tz: 'Europe/Luxembourg' },
|
||
|
||
// SE Asia / 东南亚
|
||
{ name: '印度尼西亚', en: 'Indonesia', region: 'sea', dial: '+62', tz: 'Asia/Jakarta' },
|
||
{ name: '马来西亚', en: 'Malaysia', region: 'sea', dial: '+60', tz: 'Asia/Kuala_Lumpur' },
|
||
{ name: '泰国', en: 'Thailand', region: 'sea', dial: '+66', tz: 'Asia/Bangkok' },
|
||
{ name: '越南', en: 'Vietnam', region: 'sea', dial: '+84', tz: 'Asia/Ho_Chi_Minh' },
|
||
{ name: '菲律宾', en: 'Philippines', region: 'sea', dial: '+63', tz: 'Asia/Manila' },
|
||
{ name: '新加坡', en: 'Singapore', region: 'sea', dial: '+65', tz: 'Asia/Singapore' },
|
||
{ name: '缅甸', en: 'Myanmar', region: 'sea', dial: '+95', tz: 'Asia/Yangon' },
|
||
{ name: '柬埔寨', en: 'Cambodia', region: 'sea', dial: '+855', tz: 'Asia/Phnom_Penh' },
|
||
{ name: '老挝', en: 'Laos', region: 'sea', dial: '+856', tz: 'Asia/Vientiane' },
|
||
{ name: '文莱', en: 'Brunei', region: 'sea', dial: '+673', tz: 'Asia/Brunei' },
|
||
|
||
// Other / 其他
|
||
{ name: '美国', en: 'United States', region: 'other', dial: '+1', tz: 'America/New_York' },
|
||
{ name: '加拿大', en: 'Canada', region: 'other', dial: '+1', tz: 'America/Toronto' },
|
||
{ name: '巴西', en: 'Brazil', region: 'other', dial: '+55', tz: 'America/Sao_Paulo' },
|
||
{ name: '墨西哥', en: 'Mexico', region: 'other', dial: '+52', tz: 'America/Mexico_City' },
|
||
{ name: '阿根廷', en: 'Argentina', region: 'other', dial: '+54', tz: 'America/Argentina/Buenos_Aires' },
|
||
{ name: '印度', en: 'India', region: 'other', dial: '+91', tz: 'Asia/Kolkata' },
|
||
{ name: '巴基斯坦', en: 'Pakistan', region: 'other', dial: '+92', tz: 'Asia/Karachi' },
|
||
{ name: '孟加拉国', en: 'Bangladesh', region: 'other', dial: '+880', tz: 'Asia/Dhaka' },
|
||
{ name: '中国', en: 'China', region: 'other', dial: '+86', tz: 'Asia/Shanghai' },
|
||
{ name: '韩国', en: 'South Korea', region: 'other', dial: '+82', tz: 'Asia/Seoul' },
|
||
{ name: '日本', en: 'Japan', region: 'other', dial: '+81', tz: 'Asia/Tokyo' },
|
||
{ name: '乌兹别克斯坦', en: 'Uzbekistan', region: 'other', dial: '+998', tz: 'Asia/Tashkent' },
|
||
{ name: '哈萨克斯坦', en: 'Kazakhstan', region: 'other', dial: '+7', tz: 'Asia/Almaty' },
|
||
{ name: '吉尔吉斯斯坦', en: 'Kyrgyzstan', region: 'other', dial: '+996', tz: 'Asia/Bishkek' },
|
||
{ name: '塔吉克斯坦', en: 'Tajikistan', region: 'other', dial: '+992', tz: 'Asia/Dushanbe' },
|
||
{ name: '土库曼斯坦', en: 'Turkmenistan', region: 'other', dial: '+993', tz: 'Asia/Ashgabat' },
|
||
{ name: '格鲁吉亚', en: 'Georgia', region: 'other', dial: '+995', tz: 'Asia/Tbilisi' },
|
||
{ name: '亚美尼亚', en: 'Armenia', region: 'other', dial: '+374', tz: 'Asia/Yerevan' },
|
||
{ name: '阿塞拜疆', en: 'Azerbaijan', region: 'other', dial: '+994', tz: 'Asia/Baku' },
|
||
{ name: '斯里兰卡', en: 'Sri Lanka', region: 'other', dial: '+94', tz: 'Asia/Colombo' },
|
||
{ name: '尼泊尔', en: 'Nepal', region: 'other', dial: '+977', tz: 'Asia/Kathmandu' },
|
||
{ name: '阿富汗', en: 'Afghanistan', region: 'other', dial: '+93', tz: 'Asia/Kabul' },
|
||
{ name: '蒙古', en: 'Mongolia', region: 'other', dial: '+976', tz: 'Asia/Ulaanbaatar' },
|
||
{ name: '智利', en: 'Chile', region: 'other', dial: '+56', tz: 'America/Santiago' },
|
||
{ name: '秘鲁', en: 'Peru', region: 'other', dial: '+51', tz: 'America/Lima' },
|
||
{ name: '哥伦比亚', en: 'Colombia', region: 'other', dial: '+57', tz: 'America/Bogota' },
|
||
{ name: '厄瓜多尔', en: 'Ecuador', region: 'other', dial: '+593', tz: 'America/Guayaquil' },
|
||
{ name: '委内瑞拉', en: 'Venezuela', region: 'other', dial: '+58', tz: 'America/Caracas' },
|
||
{ name: '玻利维亚', en: 'Bolivia', region: 'other', dial: '+591', tz: 'America/La_Paz' },
|
||
{ name: '巴拉圭', en: 'Paraguay', region: 'other', dial: '+595', tz: 'America/Asuncion' },
|
||
{ name: '乌拉圭', en: 'Uruguay', region: 'other', dial: '+598', tz: 'America/Montevideo' },
|
||
];
|
||
|
||
const COUNTRY_BY_NAME = Object.fromEntries(COUNTRIES.map(c => [c.name, c]));
|
||
|
||
const STORAGE_KEYS = { customers: 'crm:customers', products: 'crm:products', settings: 'crm:settings', quotes: 'crm:quotes', sellerProfiles: 'crm:sellerProfiles', certifications: 'crm:certifications', productCategories: 'crm:productCategories', packingLists: 'crm:packingLists', catalogDocs: 'crm:catalogDocs' };
|
||
|
||
const blankCustomer = () => ({
|
||
id: 'c_' + Date.now() + '_' + Math.random().toString(36).slice(2, 7),
|
||
company: '', contact: '', country: '', region: 'middle_east', email: '', phone: '',
|
||
isVip: false,
|
||
hasOrdered: false,
|
||
preferredContacts: [],
|
||
website: '',
|
||
productLines: [], intendedModel: '', intendedQty: '',
|
||
source: '',
|
||
tags: '', notes: '', lastContact: '', followUpDays: '',
|
||
warehouseAddress: '', freightContact: '', shippingMarkNote: '',
|
||
attachments: [],
|
||
});
|
||
|
||
const OUTPUT_PORT_TYPES = ['USB', 'DC', 'DC1', 'DC2', 'DC3', 'POE', 'Type-C', 'DC interface', 'POE interface'];
|
||
|
||
const EXPORT_LANGUAGES = [
|
||
{ code: 'zh', label: '中文', rtl: false },
|
||
{ code: 'en', label: 'English', rtl: false },
|
||
{ code: 'fr', label: 'Français', rtl: false },
|
||
{ code: 'ru', label: 'Русский', rtl: false },
|
||
{ code: 'es', label: 'Español', rtl: false },
|
||
{ code: 'ar', label: 'العربية', rtl: true },
|
||
];
|
||
|
||
// 多语言PDF导出用的内置专业翻译词典:固定字段名 + 已知的品类/机型名称,人工核对过的翻译,
|
||
// 不依赖AI、不占用DeepSeek额度、永远一致准确。词典里没有的词(比如用户自己新建的品类/品牌名、
|
||
// 或者产品备注这种自由文本)才会走AI翻译并缓存(见 resolveExportTranslations)。
|
||
const TRANSLATION_DICTIONARY = {
|
||
// 文档通用
|
||
'产品规格书': { en: 'Specifications', fr: 'Spécifications', ru: 'Технические характеристики', es: 'Especificaciones', ar: 'المواصفات' },
|
||
'* 产品规格如有变动,恕不另行通知': {
|
||
en: 'Product specifications are subject to change without notice.',
|
||
fr: "Les spécifications du produit peuvent être modifiées sans préavis.",
|
||
ru: 'Технические характеристики могут быть изменены без предварительного уведомления.',
|
||
es: 'Las especificaciones del producto están sujetas a cambios sin previo aviso.',
|
||
ar: 'تخضع مواصفات المنتج للتغيير دون إشعار مسبق.',
|
||
},
|
||
// 固定字段名
|
||
'型号': { en: 'Model', fr: 'Modèle', ru: 'Модель', es: 'Modelo', ar: 'الطراز' },
|
||
'品牌': { en: 'Brand', fr: 'Marque', ru: 'Бренд', es: 'Marca', ar: 'العلامة التجارية' },
|
||
'产品大类': { en: 'Category', fr: 'Catégorie', ru: 'Категория', es: 'Categoría', ar: 'الفئة' },
|
||
'细分品类': { en: 'Type', fr: 'Type', ru: 'Тип', es: 'Tipo', ar: 'النوع' },
|
||
'机型': { en: 'Machine Type', fr: "Type d'appareil", ru: 'Тип модели', es: 'Tipo de equipo', ar: 'طراز الجهاز' },
|
||
'标机': { en: 'Standard', fr: 'Standard', ru: 'Стандартный', es: 'Estándar', ar: 'قياسي' },
|
||
'长机': { en: 'Extended', fr: 'Prolongé', ru: 'Увеличенный', es: 'Extendido', ar: 'ممتد' },
|
||
'输出功率': { en: 'Output Power', fr: 'Puissance de sortie', ru: 'Выходная мощность', es: 'Potencia de salida', ar: 'طاقة الخرج' },
|
||
'功率 / 容量': { en: 'Power / Capacity', fr: 'Puissance / Capacité', ru: 'Мощность / Ёмкость', es: 'Potencia / Capacidad', ar: 'الطاقة / السعة' },
|
||
'输入电压': { en: 'Input Voltage', fr: "Tension d'entrée", ru: 'Входное напряжение', es: 'Voltaje de entrada', ar: 'جهد الدخل' },
|
||
'容量': { en: 'Capacity', fr: 'Capacité', ru: 'Ёмкость', es: 'Capacidad', ar: 'السعة' },
|
||
'输入电压范围': { en: 'Input Voltage Range', fr: "Plage de tension d'entrée", ru: 'Диапазон входного напряжения', es: 'Rango de voltaje de entrada', ar: 'نطاق جهد الدخل' },
|
||
'输入频率': { en: 'Input Frequency', fr: "Fréquence d'entrée", ru: 'Входная частота', es: 'Frecuencia de entrada', ar: 'تردد الدخل' },
|
||
'输出电压': { en: 'Output Voltage', fr: 'Tension de sortie', ru: 'Выходное напряжение', es: 'Voltaje de salida', ar: 'جهد الخرج' },
|
||
'输出电压范围': { en: 'Output Voltage Range', fr: 'Plage de tension de sortie', ru: 'Диапазон выходного напряжения', es: 'Rango de voltaje de salida', ar: 'نطاق جهد الخرج' },
|
||
'转换时间': { en: 'Transfer Time', fr: 'Temps de transfert', ru: 'Время переключения', es: 'Tiempo de transferencia', ar: 'وقت التحويل' },
|
||
'波形': { en: 'Waveform', fr: "Forme d'onde", ru: 'Форма волны', es: 'Forma de onda', ar: 'شكل الموجة' },
|
||
'电池电压': { en: 'Battery Voltage', fr: 'Tension de batterie', ru: 'Напряжение батареи', es: 'Voltaje de batería', ar: 'جهد البطارية' },
|
||
'电池型号和数量': { en: 'Battery Model & Qty', fr: 'Modèle et quantité de batterie', ru: 'Модель и кол-во батарей', es: 'Modelo y cantidad de batería', ar: 'طراز وعدد البطاريات' },
|
||
'充电电流': { en: 'Charge Current', fr: 'Courant de charge', ru: 'Ток зарядки', es: 'Corriente de carga', ar: 'تيار الشحن' },
|
||
'频率范围(电池模式)': { en: 'Frequency Range (Battery Mode)', fr: 'Plage de fréquence (mode batterie)', ru: 'Диапазон частот (режим батареи)', es: 'Rango de frecuencia (modo batería)', ar: 'نطاق التردد (وضع البطارية)' },
|
||
'充电时间': { en: 'Charge Time', fr: 'Temps de charge', ru: 'Время зарядки', es: 'Tiempo de carga', ar: 'وقت الشحن' },
|
||
'LCD显示': { en: 'LCD Display', fr: 'Affichage LCD', ru: 'ЖК-дисплей', es: 'Pantalla LCD', ar: 'شاشة LCD' },
|
||
'LED显示': { en: 'LED Display', fr: 'Affichage LED', ru: 'Светодиодный индикатор', es: 'Indicador LED', ar: 'مؤشر LED' },
|
||
'全面保护': { en: 'Full Protection', fr: 'Protection complète', ru: 'Полная защита', es: 'Protección completa', ar: 'حماية شاملة' },
|
||
'警告提示': { en: 'Alarm', fr: 'Alarme', ru: 'Сигнализация', es: 'Alarma', ar: 'إنذار' },
|
||
'控制管理': { en: 'Interface', fr: 'Interface', ru: 'Интерфейс', es: 'Interfaz', ar: 'الواجهة' },
|
||
'湿度': { en: 'Humidity', fr: 'Humidité', ru: 'Влажность', es: 'Humedad', ar: 'الرطوبة' },
|
||
'噪音': { en: 'Noise Level', fr: 'Niveau sonore', ru: 'Уровень шума', es: 'Nivel de ruido', ar: 'مستوى الضوضاء' },
|
||
'相数': { en: '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: '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 (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 gatherExportTerms(p, categories) {
|
||
const cat = (categories || []).find(c => c.id === p.category);
|
||
const terms = new Set(['产品规格书', '* 产品规格如有变动,恕不另行通知', '型号', '品牌', '产品大类']);
|
||
if (cat) terms.add(cat.name);
|
||
if (p.type) { terms.add('细分品类'); terms.add(p.type); }
|
||
if (isUpsCategory(cat) && p.machineVariant) { terms.add('机型'); terms.add(p.machineVariant); }
|
||
const rows = productExportRows(p, categories);
|
||
let hasPortRow = false;
|
||
rows.forEach(r => {
|
||
if (r.labelKey === '__port__') hasPortRow = true;
|
||
else terms.add(r.labelKey);
|
||
// 数值本身如果混了中文说明文字,也要一起收集去翻译(比如"380/400/415VAC(三相五线)"
|
||
// 里的"三相五线"),纯数字/英文的值不会被这个判断收进来
|
||
if (containsChinese(r.value)) terms.add(r.value);
|
||
});
|
||
if (hasPortRow) terms.add('输出');
|
||
// 对客卖点/主要特点:每行一条,都是要给客户看的文字,需要翻译
|
||
(p.sellingPoints || '').split('\n').map(l => l.trim()).filter(Boolean).forEach(line => terms.add(line));
|
||
// 注意:备注(notes)字段故意不收集、不导出——这里经常被用来记内部信息(成本价、
|
||
// 采购渠道、库存数量这类),一旦跟着PDF发给客户后果很麻烦,所以规格书导出永远不碰这个字段。
|
||
return Array.from(terms);
|
||
}
|
||
|
||
const DEFAULT_MINI_UPS_OUTPUT_TYPES = ['USB', 'DC1', 'DC2', 'DC3', 'POE'];
|
||
|
||
const blankProduct = () => ({
|
||
id: 'p_' + Date.now() + '_' + Math.random().toString(36).slice(2, 7),
|
||
name: '', extraNames: [], brand: '', category: '', type: '',
|
||
machineVariant: '', // 标机 / 长机,仅UPS大类适用,独立于细分品类(迷你UPS固定为标机)
|
||
powerOrCapacity: '', // 非UPS大类的功率/容量;迷你UPS的输出功率;离线式/在线互动式UPS的容量(如400VA/240W)
|
||
ratedPower: '', ratedPowerUnit: 'VA', powerFactor: '', // UPS大类下机架式/高频塔式/工频/模块化UPS的三段式功率
|
||
voltage: '', // 非迷你UPS的通用大类:电压;迷你UPS:输入电压(默认 100~240Vac/50-60Hz,可修改)
|
||
outputPortCount: '', // 迷你UPS专属:输出口数量 1-6
|
||
outputPortTypes: [], // 迷你UPS专属:输出口类型(多选)
|
||
outputPorts: [], // 迷你UPS专属:每个已选输出口类型对应的输出电压/电流,如 [{type:'USB', voltage:'5Vdc', current:'3.0A'}]
|
||
// 离线式UPS / 在线互动式UPS 专属:输入输出参数(这两个细分品类共用同一套字段)
|
||
inputVoltage: '', // 输入电压,如 "110V/120Vac or 220V/230Vac"
|
||
inputVoltageRange: '', // 输入电压范围,如 "81-134Vac/89-145Vac or 162-268Vac/170-280Vac"
|
||
inputFrequency: '', // 输入频率,如 "50/60Hz(自动识别)"
|
||
outputVoltage: '', // 输出电压,如 "110V/120Vac or 220V/230Vac"
|
||
outputVoltageRange: '', // 输出电压范围,如 "±10%"
|
||
transferTime: '', // 转换时间,如 "Typical 2-8ms, 13ms max"
|
||
waveform: '', // 波形(电池模式),如 "Simulated Sine Wave" / "Sine Wave"
|
||
batteryVoltage: '', // 电池电压,如 "12V" / "24V"
|
||
chargeCurrent: '', // 充电电流,如 "1A"
|
||
frequencyRangeBatteryMode: '', // 频率范围(电池模式),如 "50/60Hz±1Hz"
|
||
chargeTime: '', // 充电时间,如 "8小时恢复到90%容量"
|
||
lcdDisplay: '', // LCD显示内容,如 "AC mode, Battery mode, Load level, Battery level, Input voltage, Output voltage, Overload and low battery"
|
||
ledDisplay: '', // LED显示说明,如 "AC mode: Green / Battery mode: Yellow / Fault: Red"
|
||
fullProtection: '', // 全面保护,如 "Discharge, Short circuit and overload protection"
|
||
alarmInfo: '', // 警告提示(电池模式/低电量/过载/故障这几种蜂鸣提示,合并成一个多行字段)
|
||
interfacePort: '', // 控制管理/接口,如 "USB/RS232 port(optional), support windows xp/vista, 7/8/11 and MAC"
|
||
humidity: '', // 湿度,如 "0-90% RH@0-40℃(Non-condensing)"
|
||
noiseLevel: '', // 噪音,如 "Less than 40dB(1m spacing)"
|
||
// 机架式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"
|
||
batterySpec: '', // 电池型号和数量,如 "12V4.5AH*1"(迷你UPS/离线式/在线互动式UPS共用)
|
||
dimensions: '', // 尺寸(mm)(迷你UPS/离线式/在线互动式UPS共用)
|
||
netWeight: '', // 净重(kg)(迷你UPS/离线式/在线互动式UPS共用)
|
||
productImageFileName: '', productImageOriginalName: '', // 产品主图,导出多语言PDF时展示
|
||
sellingPoints: '', // 对客卖点/主要特点,每行一条,专门给客户看的(和下面的costPrice/notes内部信息彻底分开,不会一起导出)
|
||
costPrice: '', notes: '',
|
||
});
|
||
|
||
function isUpsCategory(cat) {
|
||
return !!cat && (cat.id === 'ups' || cat.name === 'UPS不间断电源');
|
||
}
|
||
function spaceToAsterisk(v) { return (v || '').replace(/ /g, '*'); }
|
||
function computeMaxPowerKW(ratedPower, ratedPowerUnit, powerFactor) {
|
||
const rp = parseFloat(ratedPower);
|
||
const pf = parseFloat(powerFactor);
|
||
if (!rp || !pf) return null;
|
||
const ratedKVA = ratedPowerUnit === 'kVA' ? rp : rp / 1000;
|
||
const kw = ratedKVA * pf;
|
||
return Math.round(kw * 1000) / 1000;
|
||
}
|
||
function powerInfoItems(p, categories) {
|
||
const cat = (categories || []).find(c => c.id === p.category);
|
||
if (isUpsCategory(cat) && (p.type === '离线式UPS' || p.type === '在线互动式UPS')) {
|
||
const items = [];
|
||
if (p.powerOrCapacity) items.push({ key: 'capacity', label: '容量', value: p.powerOrCapacity });
|
||
if (p.inputVoltage) items.push({ key: 'inputvoltage', label: '输入电压', value: p.inputVoltage });
|
||
if (p.inputVoltageRange) items.push({ key: 'inputvoltagerange', label: '输入电压范围', value: p.inputVoltageRange });
|
||
if (p.inputFrequency) items.push({ key: 'inputfreq', label: '输入频率', value: p.inputFrequency });
|
||
if (p.outputVoltage) items.push({ key: 'outputvoltage', label: '输出电压', value: p.outputVoltage });
|
||
if (p.outputVoltageRange) items.push({ key: 'outputvoltagerange', label: '输出电压范围', value: p.outputVoltageRange });
|
||
if (p.transferTime) items.push({ key: 'transfertime', label: '转换时间', value: p.transferTime });
|
||
if (p.waveform) items.push({ key: 'waveform', label: '波形', value: p.waveform });
|
||
if (p.batteryVoltage) items.push({ key: 'batteryvoltage', label: '电池电压', value: p.batteryVoltage });
|
||
if (p.batterySpec) items.push({ key: 'batteryspec', label: '电池型号和数量', value: p.batterySpec });
|
||
if (p.chargeCurrent) items.push({ key: 'chargecurrent', label: '充电电流', value: p.chargeCurrent });
|
||
if (p.frequencyRangeBatteryMode) items.push({ key: 'freqrange', label: '频率范围(电池模式)', value: p.frequencyRangeBatteryMode });
|
||
if (p.chargeTime) items.push({ key: 'chargetime', label: '充电时间', value: p.chargeTime });
|
||
if (p.lcdDisplay) items.push({ key: 'lcddisplay', label: 'LCD显示', value: p.lcdDisplay });
|
||
if (p.ledDisplay) items.push({ key: 'leddisplay', label: 'LED显示', value: p.ledDisplay });
|
||
if (p.fullProtection) items.push({ key: 'fullprotection', label: '全面保护', value: p.fullProtection });
|
||
if (p.alarmInfo) items.push({ key: 'alarminfo', label: '警告提示', value: p.alarmInfo });
|
||
if (p.interfacePort) items.push({ key: 'interfaceport', label: '控制管理', value: p.interfacePort });
|
||
if (p.humidity) items.push({ key: 'humidity', label: '湿度', value: p.humidity });
|
||
if (p.noiseLevel) items.push({ key: 'noiselevel', label: '噪音', value: p.noiseLevel });
|
||
if (p.dimensions) items.push({ key: 'dim', label: '尺寸(mm)', value: p.dimensions });
|
||
if (p.netWeight) items.push({ key: 'weight', label: '净重(kg)', value: p.netWeight });
|
||
return items;
|
||
}
|
||
if (isUpsCategory(cat) && (p.type === '机架式UPS' || 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 => t !== '标机' && t !== '长机'); });
|
||
|
||
return { products: migratedProducts, categories: cats };
|
||
}
|
||
function generateFollowUpEmail(customer) {
|
||
const name = customer.contact || customer.company || 'there';
|
||
const product = customer.productLines && customer.productLines.length ? customer.productLines.join(', ') : 'our products';
|
||
const subject = `Following up – ${customer.company || name}`;
|
||
const body = `Dear ${name},
|
||
|
||
I hope this message finds you well. It has been a little while since we last connected, and I wanted to check in regarding ${product}.
|
||
|
||
If you have any upcoming projects or requirements, I would be happy to share our latest pricing, lead times, and technical updates. Please let me know if there is anything I can help with.
|
||
|
||
Looking forward to hearing from you.
|
||
|
||
Best regards,
|
||
[Your name]`;
|
||
return { subject, body };
|
||
}
|
||
|
||
/* -------------------------------- storage ------------------------------ */
|
||
|
||
async function loadAll() {
|
||
let customers = [];
|
||
let products = [];
|
||
let settings = DEFAULT_SETTINGS;
|
||
let quotes = [];
|
||
let sellerProfiles = DEFAULT_SELLER_PROFILES;
|
||
let certifications = [];
|
||
let productCategories = [];
|
||
let packingLists = [];
|
||
let catalogDocs = [];
|
||
try {
|
||
const c = await storageGet(STORAGE_KEYS.customers);
|
||
if (c && c.value) customers = JSON.parse(c.value);
|
||
} catch (e) {}
|
||
try {
|
||
const p = await storageGet(STORAGE_KEYS.products);
|
||
if (p && p.value) products = JSON.parse(p.value);
|
||
} catch (e) {}
|
||
try {
|
||
const s = await storageGet(STORAGE_KEYS.settings);
|
||
if (s && s.value) settings = { ...DEFAULT_SETTINGS, ...JSON.parse(s.value) };
|
||
} catch (e) {}
|
||
try {
|
||
const q = await storageGet(STORAGE_KEYS.quotes);
|
||
if (q && q.value) quotes = JSON.parse(q.value);
|
||
} catch (e) {}
|
||
try {
|
||
const sp = await storageGet(STORAGE_KEYS.sellerProfiles);
|
||
if (sp && sp.value) sellerProfiles = JSON.parse(sp.value);
|
||
} catch (e) {}
|
||
try {
|
||
const cf = await storageGet(STORAGE_KEYS.certifications);
|
||
if (cf && cf.value) certifications = JSON.parse(cf.value);
|
||
} catch (e) {}
|
||
try {
|
||
const pc = await storageGet(STORAGE_KEYS.productCategories);
|
||
if (pc && pc.value) productCategories = JSON.parse(pc.value);
|
||
} catch (e) {}
|
||
try {
|
||
const pl = await storageGet(STORAGE_KEYS.packingLists);
|
||
if (pl && pl.value) packingLists = JSON.parse(pl.value);
|
||
} catch (e) {}
|
||
try {
|
||
const cd = await storageGet(STORAGE_KEYS.catalogDocs);
|
||
if (cd && cd.value) catalogDocs = JSON.parse(cd.value);
|
||
} catch (e) {}
|
||
return { customers, products, settings, quotes, sellerProfiles, certifications, productCategories, packingLists, catalogDocs };
|
||
}
|
||
|
||
/* -------------------------------- styles -------------------------------- */
|
||
|
||
const Styles = () => (
|
||
<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; }
|
||
|
||
.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-two-col { display: flex; gap: 32px; align-items: flex-start; }
|
||
.spec-sheet-left { flex: 0 0 240px; }
|
||
.spec-sheet-right { flex: 1; min-width: 0; }
|
||
.spec-sheet-image { width: 100%; aspect-ratio: 1 / 1; object-fit: cover; border-radius: 6px; border: 1px solid var(--copper-soft); }
|
||
.spec-sheet-points { margin: 14px 0 0; padding-left: 18px; font-size: 12.5px; color: #444; line-height: 1.7; }
|
||
.spec-sheet-points li { margin-bottom: 4px; }
|
||
[dir="rtl"] .spec-sheet-two-col { flex-direction: row-reverse; }
|
||
[dir="rtl"] .spec-sheet-points { padding-left: 0; padding-right: 18px; }
|
||
|
||
|
||
@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 },
|
||
] },
|
||
{ 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 [pwCurrent, setPwCurrent] = useState('');
|
||
const [pwNew, setPwNew] = useState('');
|
||
const [pwBusy, setPwBusy] = useState(false);
|
||
|
||
const [aiConfigHasKey, setAiConfigHasKey] = useState(false);
|
||
const [aiKeyInput, setAiKeyInput] = useState('');
|
||
const [aiSaving, setAiSaving] = useState(false);
|
||
const [aiTesting, setAiTesting] = useState(false);
|
||
const [aiGenerating, setAiGenerating] = useState(false);
|
||
const [aiDraftModal, setAiDraftModal] = useState(null); // { channel, customerId, subject, body }
|
||
|
||
const [quotes, setQuotes] = useState([]);
|
||
const [sellerProfiles, setSellerProfiles] = useState(DEFAULT_SELLER_PROFILES);
|
||
const [productCategories, setProductCategories] = useState([]);
|
||
const [addingPill, setAddingPill] = useState(null); // { catId, kind: 'brand' | 'type' | 'category' }
|
||
const [addingPillValue, setAddingPillValue] = useState('');
|
||
const [taxManageMode, setTaxManageMode] = useState(null); // 'category' | 'brand' | 'type' | null
|
||
const [certifications, setCertifications] = useState([]);
|
||
const [certUploading, setCertUploading] = useState(false);
|
||
const [editingCertId, setEditingCertId] = useState(null);
|
||
const [certBrandDraft, setCertBrandDraft] = useState('');
|
||
const [quoteSubView, setQuoteSubView] = useState('list'); // 'list' | 'editor'
|
||
const [quoteDraft, setQuoteDraft] = useState(null);
|
||
const [quoteTypeFilter, setQuoteTypeFilter] = useState('all');
|
||
const [sellerManagerOpen, setSellerManagerOpen] = useState(false);
|
||
const [sellerLogoUploading, setSellerLogoUploading] = useState(false);
|
||
const [pdfExporting, setPdfExporting] = useState(false);
|
||
const [packingLists, setPackingLists] = useState([]);
|
||
const [packingSubView, setPackingSubView] = useState('list'); // 'list' | 'editor'
|
||
const [packingDraft, setPackingDraft] = useState(null);
|
||
const [packingPdfExporting, setPackingPdfExporting] = useState(false);
|
||
const [catalogDocs, setCatalogDocs] = useState([]);
|
||
const [catalogDragActive, setCatalogDragActive] = useState(false);
|
||
const [dragCatalogDocIdx, setDragCatalogDocIdx] = useState(null);
|
||
const [editingCatalogId, setEditingCatalogId] = useState(null);
|
||
const [catalogEditDraft, setCatalogEditDraft] = useState({ title: '', category: '', brand: '', type: '' });
|
||
const [catalogCategoryFilter, setCatalogCategoryFilter] = useState('all');
|
||
|
||
useEffect(() => {
|
||
getAiConfig().then((res) => setAiConfigHasKey(!!res.hasKey)).catch(() => {});
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
const id = setInterval(() => setNow(new Date()), 30000);
|
||
return () => clearInterval(id);
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
(async () => {
|
||
const data = await loadAll();
|
||
const normalized = normalizeProductTaxonomy(data.products, data.productCategories);
|
||
setCustomers(data.customers);
|
||
setProducts(normalized.products);
|
||
setProductCategories(normalized.categories);
|
||
setSettings(data.settings);
|
||
setQuotes(data.quotes);
|
||
setSellerProfiles(data.sellerProfiles);
|
||
setCertifications(data.certifications);
|
||
setPackingLists(data.packingLists);
|
||
setCatalogDocs((data.catalogDocs || []).filter(d => d.fileName));
|
||
setLoaded(true);
|
||
})();
|
||
}, []);
|
||
useEffect(() => { if (loaded) storageSet(STORAGE_KEYS.customers, JSON.stringify(customers)).catch(() => {}); }, [customers, loaded]);
|
||
useEffect(() => { if (loaded) storageSet(STORAGE_KEYS.products, JSON.stringify(products)).catch(() => {}); }, [products, loaded]);
|
||
useEffect(() => { if (loaded) storageSet(STORAGE_KEYS.settings, JSON.stringify(settings)).catch(() => {}); }, [settings, loaded]);
|
||
useEffect(() => { if (loaded) storageSet(STORAGE_KEYS.quotes, JSON.stringify(quotes)).catch(() => {}); }, [quotes, loaded]);
|
||
useEffect(() => { if (loaded) storageSet(STORAGE_KEYS.sellerProfiles, JSON.stringify(sellerProfiles)).catch(() => {}); }, [sellerProfiles, loaded]);
|
||
useEffect(() => { if (loaded) storageSet(STORAGE_KEYS.certifications, JSON.stringify(certifications)).catch(() => {}); }, [certifications, loaded]);
|
||
useEffect(() => { if (loaded) storageSet(STORAGE_KEYS.productCategories, JSON.stringify(productCategories)).catch(() => {}); }, [productCategories, loaded]);
|
||
useEffect(() => { if (loaded) storageSet(STORAGE_KEYS.packingLists, JSON.stringify(packingLists)).catch(() => {}); }, [packingLists, loaded]);
|
||
useEffect(() => { if (loaded) storageSet(STORAGE_KEYS.catalogDocs, JSON.stringify(catalogDocs)).catch(() => {}); }, [catalogDocs, loaded]);
|
||
|
||
function notify(msg) { setToast(msg); setTimeout(() => setToast(''), 2200); }
|
||
|
||
function copyText(text) {
|
||
navigator.clipboard.writeText(text).then(
|
||
() => notify('已复制到剪贴板'),
|
||
() => notify('复制失败,请手动选择文本复制')
|
||
);
|
||
}
|
||
|
||
const selected = customers.find(c => c.id === selectedId) || null;
|
||
const selectedProduct = products.find(p => p.id === selectedProductId) || null;
|
||
const [productExportLang, setProductExportLang] = useState('zh');
|
||
const [productExportLabels, setProductExportLabels] = useState({});
|
||
const [productExportLoading, setProductExportLoading] = useState(false);
|
||
const [productExportPdfBusy, setProductExportPdfBusy] = useState(false);
|
||
|
||
useEffect(() => {
|
||
if (!selectedProduct) { setProductExportLabels({}); return; }
|
||
let cancelled = false;
|
||
setProductExportLoading(true);
|
||
const terms = gatherExportTerms(selectedProduct, productCategories);
|
||
resolveExportTranslations(terms, productExportLang)
|
||
.then(map => { if (!cancelled) setProductExportLabels(map); })
|
||
.finally(() => { if (!cancelled) setProductExportLoading(false); });
|
||
return () => { cancelled = true; };
|
||
}, [selectedProduct && selectedProduct.id, productExportLang, productCategories]);
|
||
|
||
const filteredCustomers = useMemo(() => {
|
||
let list = customers;
|
||
if (regionFilter !== 'all') list = list.filter(c => c.region === regionFilter);
|
||
if (search.trim()) {
|
||
const q = search.trim().toLowerCase();
|
||
list = list.filter(c =>
|
||
(c.company || '').toLowerCase().includes(q) ||
|
||
(c.contact || '').toLowerCase().includes(q) ||
|
||
(c.email || '').toLowerCase().includes(q) ||
|
||
(c.country || '').toLowerCase().includes(q)
|
||
);
|
||
}
|
||
const order = { overdue: 0, due: 1, ok: 2 };
|
||
return [...list].sort((a, b) => order[statusOf(a, settings)] - order[statusOf(b, settings)] || (a.company || '').localeCompare(b.company || ''));
|
||
}, [customers, search, regionFilter, settings]);
|
||
|
||
const filteredProducts = useMemo(() => {
|
||
let list = products;
|
||
if (productCategoryFilter !== 'all') list = list.filter(p => p.category === productCategoryFilter);
|
||
if (productSearch.trim()) {
|
||
const q = productSearch.trim().toLowerCase();
|
||
list = list.filter(p =>
|
||
(p.name || '').toLowerCase().includes(q) ||
|
||
(p.brand || '').toLowerCase().includes(q) ||
|
||
categoryBreadcrumb(p, productCategories).toLowerCase().includes(q)
|
||
);
|
||
}
|
||
return [...list].sort((a, b) => categoryBreadcrumb(a, productCategories).localeCompare(categoryBreadcrumb(b, productCategories)) || (a.name || '').localeCompare(b.name || ''));
|
||
}, [products, productSearch, productCategoryFilter, productCategories]);
|
||
|
||
const counts = useMemo(() => ({
|
||
overdue: customers.filter(c => statusOf(c, settings) === 'overdue').length,
|
||
due: customers.filter(c => statusOf(c, settings) === 'due').length,
|
||
total: customers.length,
|
||
products: products.length,
|
||
}), [customers, settings, products]);
|
||
|
||
const priceHistory = useMemo(() => buildPriceHistory(quotes), [quotes]);
|
||
|
||
const filteredCatalogDocs = useMemo(() => {
|
||
if (catalogCategoryFilter === 'all') return catalogDocs;
|
||
return catalogDocs.filter(d => d.uploading || d.category === catalogCategoryFilter);
|
||
}, [catalogDocs, catalogCategoryFilter]);
|
||
|
||
function handleCustomerDrop(dropIdx) {
|
||
if (dragCustomerIdx === null || dragCustomerIdx === dropIdx) { setDragCustomerIdx(null); return; }
|
||
setCustomers(prev => reorderArray(prev, dragCustomerIdx, dropIdx));
|
||
setDragCustomerIdx(null);
|
||
}
|
||
async function handleAddCatalogFile(file) {
|
||
if (!file) return;
|
||
const tempId = 'catdoc_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
|
||
// 先插入一张"上传中"的占位卡片,让用户能实时看到这个文件正在传,传完再替换成真实数据
|
||
setCatalogDocs(prev => [{ id: tempId, title: '', fileName: '', originalName: file.name, category: '', brand: '', type: '', uploading: true }, ...prev]);
|
||
try {
|
||
const res = await uploadFile(tempId, file);
|
||
setCatalogDocs(prev => prev.map(d => (d.id === tempId ? { ...d, fileName: res.fileName, originalName: res.originalName, uploading: false } : d)));
|
||
setEditingCatalogId(tempId);
|
||
setCatalogEditDraft({ title: '', category: '', brand: '', type: '' });
|
||
} catch (e) {
|
||
notify(`《${file.name}》上传失败,请重试`);
|
||
setCatalogDocs(prev => prev.filter(d => d.id !== tempId));
|
||
}
|
||
}
|
||
function handleCatalogFilesSelected(files) {
|
||
Array.from(files || []).forEach(f => handleAddCatalogFile(f));
|
||
}
|
||
function handleCatalogDragOver(e) { e.preventDefault(); setCatalogDragActive(true); }
|
||
function handleCatalogDragLeave(e) {
|
||
if (e.currentTarget.contains(e.relatedTarget)) return;
|
||
setCatalogDragActive(false);
|
||
}
|
||
function handleCatalogDrop(e) {
|
||
e.preventDefault();
|
||
setCatalogDragActive(false);
|
||
const files = Array.from((e.dataTransfer && e.dataTransfer.files) || []).filter(f => f.type === 'application/pdf' || /\.pdf$/i.test(f.name));
|
||
if (files.length === 0) { notify('只能上传PDF文件'); return; }
|
||
handleCatalogFilesSelected(files);
|
||
}
|
||
function startEditCatalogDoc(doc) {
|
||
setEditingCatalogId(doc.id);
|
||
setCatalogEditDraft({ title: doc.title || '', category: doc.category || '', brand: doc.brand || '', type: doc.type || '' });
|
||
}
|
||
function updateCatalogEditDraft(patch) {
|
||
setCatalogEditDraft(d => ({ ...d, ...patch }));
|
||
}
|
||
function saveCatalogEdit() {
|
||
setCatalogDocs(prev => prev.map(d => (d.id === editingCatalogId
|
||
? { ...d, title: catalogEditDraft.title.trim(), category: catalogEditDraft.category, brand: catalogEditDraft.brand, type: catalogEditDraft.type }
|
||
: d)));
|
||
setEditingCatalogId(null);
|
||
setCatalogEditDraft({ title: '', category: '', brand: '', type: '' });
|
||
}
|
||
async function deleteCatalogDoc(doc) {
|
||
if (!window.confirm('确定删除这份产品目录PDF?此操作不可恢复。')) return;
|
||
try { await deleteFile(doc.id, doc.fileName); } catch (e) {}
|
||
setCatalogDocs(prev => prev.filter(d => d.id !== doc.id));
|
||
if (editingCatalogId === doc.id) setEditingCatalogId(null);
|
||
}
|
||
function handleCatalogDocDrop(dropIdx) {
|
||
if (dragCatalogDocIdx === null || dragCatalogDocIdx === dropIdx) { setDragCatalogDocIdx(null); return; }
|
||
setCatalogDocs(prev => reorderArray(prev, dragCatalogDocIdx, dropIdx));
|
||
setDragCatalogDocIdx(null);
|
||
}
|
||
function handleGroupDrop(category, dropIdx) {
|
||
if (!dragGroupState || dragGroupState.category !== category || dragGroupState.index === dropIdx) { setDragGroupState(null); return; }
|
||
setProducts(prev => reorderWithinCategory(prev, category, dragGroupState.index, dropIdx));
|
||
setDragGroupState(null);
|
||
}
|
||
|
||
function openAddPill(catId, kind) { setAddingPill({ catId, kind }); setAddingPillValue(''); }
|
||
function cancelAddPill() { setAddingPill(null); setAddingPillValue(''); }
|
||
function confirmAddPill() {
|
||
if (!addingPill) return;
|
||
const val = addingPillValue.trim();
|
||
if (!val) { cancelAddPill(); return; }
|
||
if (addingPill.kind === 'category') {
|
||
const exists = productCategories.some(c => c.name === val);
|
||
if (!exists) {
|
||
const id = 'cat_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 5);
|
||
setProductCategories(prev => [...prev, { id, name: val, brands: [], types: [] }]);
|
||
}
|
||
} else {
|
||
const listKey = addingPill.kind === 'brand' ? 'brands' : 'types';
|
||
setProductCategories(prev => prev.map(c => {
|
||
if (c.id !== addingPill.catId) return c;
|
||
if (c[listKey].includes(val)) return c;
|
||
return { ...c, [listKey]: [...c[listKey], val] };
|
||
}));
|
||
}
|
||
cancelAddPill();
|
||
}
|
||
function removeTaxonomyValue(catId, kind, value) {
|
||
const listKey = kind === 'brand' ? 'brands' : 'types';
|
||
setProductCategories(prev => prev.map(c => (c.id === catId ? { ...c, [listKey]: c[listKey].filter(v => v !== value) } : c)));
|
||
}
|
||
function deleteProductCategory(cat) {
|
||
const count = products.filter(p => p.category === cat.id).length;
|
||
const msg = count > 0
|
||
? `确定删除类目"${cat.name}"?该类目下还有 ${count} 个产品,删除后这些产品会显示为"未分类",产品本身不会被删除。`
|
||
: `确定删除类目"${cat.name}"?`;
|
||
if (!window.confirm(msg)) return;
|
||
setProductCategories(prev => prev.filter(c => c.id !== cat.id));
|
||
}
|
||
|
||
async function handleChangePassword() {
|
||
if (!pwCurrent || !pwNew) { notify('请填写当前密码和新密码'); return; }
|
||
if (pwNew.length < 4) { notify('新密码至少4位'); return; }
|
||
setPwBusy(true);
|
||
try {
|
||
await authChangePassword(pwCurrent, pwNew);
|
||
setPwCurrent('');
|
||
setPwNew('');
|
||
notify('密码已修改');
|
||
} catch (e) {
|
||
notify('修改失败:当前密码不正确');
|
||
} finally {
|
||
setPwBusy(false);
|
||
}
|
||
}
|
||
|
||
async function handleSaveAiKey() {
|
||
if (!aiKeyInput.trim()) { notify('请输入API Key'); return; }
|
||
setAiSaving(true);
|
||
try {
|
||
await saveAiConfig(aiKeyInput.trim());
|
||
setAiConfigHasKey(true);
|
||
setAiKeyInput('');
|
||
notify('已保存');
|
||
} catch (e) {
|
||
notify('保存失败');
|
||
} finally {
|
||
setAiSaving(false);
|
||
}
|
||
}
|
||
async function handleTestAiKey() {
|
||
setAiTesting(true);
|
||
try {
|
||
await testAiConfig();
|
||
notify('连接成功');
|
||
} catch (e) {
|
||
notify('连接失败,请检查API Key');
|
||
} finally {
|
||
setAiTesting(false);
|
||
}
|
||
}
|
||
async function handleClearAiKey() {
|
||
if (!window.confirm('确定清除已保存的DeepSeek API Key?')) return;
|
||
await deleteAiConfig();
|
||
setAiConfigHasKey(false);
|
||
notify('已清除');
|
||
}
|
||
async function handleGenerateAi(customerId, channel) {
|
||
if (!aiConfigHasKey) { notify('请先在设置中配置DeepSeek API Key'); return; }
|
||
setAiGenerating(true);
|
||
try {
|
||
const result = await generateAiDraft(customerId, channel);
|
||
setAiDraftModal({ channel, customerId, subject: result.subject || '', body: result.body || '' });
|
||
} catch (e) {
|
||
notify('生成失败:' + (e.message || '请检查API Key和网络'));
|
||
} finally {
|
||
setAiGenerating(false);
|
||
}
|
||
}
|
||
|
||
function startNewCustomer() { setDraft(blankCustomer()); setSelectedId(null); }
|
||
function startEditCustomer(c) { setDraft({ ...c, productLines: [...(c.productLines || [])] }); setSelectedId(c.id); }
|
||
function cancelDraft() { setDraft(null); }
|
||
function saveDraft() {
|
||
setCustomers(prev => {
|
||
const exists = prev.some(c => c.id === draft.id);
|
||
return exists ? prev.map(c => (c.id === draft.id ? draft : c)) : [...prev, draft];
|
||
});
|
||
setSelectedId(draft.id);
|
||
setDraft(null);
|
||
notify('已保存');
|
||
}
|
||
function deleteCustomer(id) {
|
||
if (!window.confirm('确定删除该客户档案?此操作不可恢复。')) return;
|
||
setCustomers(prev => prev.filter(c => c.id !== id));
|
||
if (selectedId === id) setSelectedId(null);
|
||
notify('已删除');
|
||
}
|
||
function markContacted(id) {
|
||
setCustomers(prev => prev.map(c => (c.id === id ? { ...c, lastContact: todayStr() } : c)));
|
||
notify('已标记为今日已联系');
|
||
}
|
||
function togglePreferredContact(id, channel) {
|
||
setCustomers(prev => prev.map(c => {
|
||
if (c.id !== id) return c;
|
||
const has = (c.preferredContacts || []).includes(channel);
|
||
return { ...c, preferredContacts: has ? c.preferredContacts.filter(x => x !== channel) : [...(c.preferredContacts || []), channel] };
|
||
}));
|
||
}
|
||
function toggleVip(id) {
|
||
setCustomers(prev => prev.map(c => (c.id === id ? { ...c, isVip: !c.isVip } : c)));
|
||
}
|
||
function toggleOrdered(id) {
|
||
setCustomers(prev => prev.map(c => (c.id === id ? { ...c, hasOrdered: !c.hasOrdered } : c)));
|
||
}
|
||
function handleFileDragOver(e) { e.preventDefault(); setFileDragActive(true); }
|
||
function handleFileDragLeave() { setFileDragActive(false); }
|
||
function handleFileDrop(e) {
|
||
e.preventDefault();
|
||
setFileDragActive(false);
|
||
const f = e.dataTransfer.files && e.dataTransfer.files[0];
|
||
if (f) setAttachmentFile(f);
|
||
}
|
||
async function handleUploadAttachment(customerId) {
|
||
if (!attachmentFile) { notify('请先选择文件'); return; }
|
||
setUploading(true);
|
||
try {
|
||
const res = await uploadFile(customerId, attachmentFile);
|
||
setCustomers(prev => prev.map(c => {
|
||
if (c.id !== customerId) return c;
|
||
const entry = {
|
||
fileName: res.fileName,
|
||
originalName: res.originalName,
|
||
category: attachmentCategory,
|
||
note: attachmentNote.trim(),
|
||
date: todayStr(),
|
||
};
|
||
return { ...c, attachments: [entry, ...(c.attachments || [])] };
|
||
}));
|
||
setAttachmentFile(null);
|
||
setAttachmentNote('');
|
||
setAttachmentCategory(FILE_CATEGORIES[0]);
|
||
setFileInputKey(k => k + 1);
|
||
notify('已上传');
|
||
} catch (e) {
|
||
notify('上传失败,请重试');
|
||
} finally {
|
||
setUploading(false);
|
||
}
|
||
}
|
||
async function handleDeleteAttachment(customerId, fileName) {
|
||
if (!window.confirm('确定删除这个附件?此操作不可恢复。')) return;
|
||
try {
|
||
await deleteFile(customerId, fileName);
|
||
} catch (e) {
|
||
// 即使服务端删除失败(比如文件已经不存在),也继续把记录从客户档案里移除
|
||
}
|
||
setCustomers(prev => prev.map(c => (
|
||
c.id === customerId ? { ...c, attachments: (c.attachments || []).filter(a => a.fileName !== fileName) } : c
|
||
)));
|
||
}
|
||
function openFollowUpEmail(c) { setEmailModal({ ...generateFollowUpEmail(c), customerId: c.id }); }
|
||
|
||
function startNewProduct() { setProductDraft(blankProduct()); setSelectedProductId(null); }
|
||
function startEditProduct(p) { setProductDraft({ ...p, extraNames: [] }); setSelectedProductId(p.id); }
|
||
function cancelProductDraft() { setProductDraft(null); }
|
||
function saveProductDraft() {
|
||
if (!productDraft.name.trim()) { notify('请填写产品型号/名称'); return; }
|
||
if (!productDraft.category) { notify('请选择产品大类'); return; }
|
||
const extraNames = (productDraft.extraNames || []).map(n => n.trim()).filter(Boolean);
|
||
const { extraNames: _drop, ...primary } = productDraft;
|
||
const extras = extraNames.map((n, i) => ({
|
||
...primary,
|
||
id: 'p_' + Date.now() + '_' + i + '_' + Math.random().toString(36).slice(2, 7),
|
||
name: n,
|
||
}));
|
||
let working = products;
|
||
let newCount = 0;
|
||
let collisionCount = 0;
|
||
let primarySelectId = primary.id;
|
||
[primary, ...extras].forEach((prod, i) => {
|
||
const res = upsertProductByName(working, prod);
|
||
working = res.list;
|
||
if (res.collision) collisionCount++;
|
||
else if (res.isNew) newCount++;
|
||
if (i === 0) primarySelectId = res.resultId;
|
||
});
|
||
setProducts(working);
|
||
setSelectedProductId(primarySelectId);
|
||
setProductDraft(null);
|
||
if (extras.length > 0) {
|
||
notify(`已保存:新建 ${newCount} 个${collisionCount > 0 ? `,型号已存在覆盖更新 ${collisionCount} 个` : ''}`);
|
||
} else if (collisionCount > 0) {
|
||
notify('型号已存在,已用最新参数覆盖更新(图片/卖点/成本价/备注保留原样)');
|
||
} else {
|
||
notify('已保存');
|
||
}
|
||
}
|
||
function deleteProduct(id) {
|
||
if (!window.confirm('确定删除该产品档案?此操作不可恢复。')) return;
|
||
setProducts(prev => prev.filter(p => p.id !== id));
|
||
if (selectedProductId === id) setSelectedProductId(null);
|
||
notify('已删除');
|
||
}
|
||
|
||
async function handleExportProductPdf() {
|
||
if (!selectedProduct) return;
|
||
setProductExportPdfBusy(true);
|
||
const el = document.querySelector('.doc-preview');
|
||
let originalBorder, originalBoxShadow;
|
||
try {
|
||
const mod = await import('html2pdf.js');
|
||
const html2pdf = mod.default || mod;
|
||
if (!el) { notify('找不到预览内容'); return; }
|
||
originalBorder = el.style.border;
|
||
originalBoxShadow = el.style.boxShadow;
|
||
el.style.border = 'none';
|
||
el.style.boxShadow = 'none';
|
||
const langLabel = (EXPORT_LANGUAGES.find(l => l.code === productExportLang) || {}).code || 'zh';
|
||
const filename = `${selectedProduct.name || 'product'}_${langLabel}.pdf`;
|
||
await html2pdf().set({
|
||
margin: 10,
|
||
filename,
|
||
image: { type: 'jpeg', quality: 0.98 },
|
||
html2canvas: { scale: 2 },
|
||
jsPDF: { unit: 'mm', format: 'a4', orientation: 'portrait' },
|
||
}).from(el).save();
|
||
} catch (e) {
|
||
notify('导出PDF失败,请重试');
|
||
} finally {
|
||
if (el) { el.style.border = originalBorder || ''; el.style.boxShadow = originalBoxShadow || ''; }
|
||
setProductExportPdfBusy(false);
|
||
}
|
||
}
|
||
|
||
/* ------------------------------ ai bulk import ----------------------------- */
|
||
|
||
function openAiImport() {
|
||
setAiImportOpen(true); setAiImportDrafts(null); setAiImportError('');
|
||
setAiImportHintCategory(''); setAiImportHintBrand(''); setAiImportHintType('');
|
||
setAiImportTruncatedWarning(false);
|
||
}
|
||
function closeAiImport() { setAiImportOpen(false); setAiImportDrafts(null); setAiImportError(''); setAiImportBusy(false); }
|
||
|
||
async function handleAiImportFiles(files) {
|
||
if (!files || !files.length) return;
|
||
setAiImportBusy(true);
|
||
setAiImportError('');
|
||
try {
|
||
const res = await parseCatalogFiles(files, {
|
||
category: aiImportHintCategory.trim(),
|
||
brand: aiImportHintBrand.trim(),
|
||
type: aiImportHintType.trim(),
|
||
});
|
||
const parsed = (res && res.products) || [];
|
||
if (!parsed.length) { setAiImportError('没有从文件里识别出任何产品,换一份更清晰的文件试试'); return; }
|
||
setAiImportTruncatedWarning(!!(res && res.truncated));
|
||
const drafts = parsed.map((p, i) => ({
|
||
_localId: 'aidraft_' + i + '_' + Math.random().toString(36).slice(2, 6),
|
||
include: true,
|
||
name: p.name || '', brand: p.brand || '', category: p.category || '', type: p.type || '',
|
||
machineVariant: p.machineVariant || '', powerOrCapacity: p.powerOrCapacity || '', voltage: p.voltage || '',
|
||
ratedPower: p.ratedPower || '', ratedPowerUnit: p.ratedPowerUnit || 'VA', powerFactor: p.powerFactor || '',
|
||
outputPortCount: p.outputPortCount || '',
|
||
outputPortTypes: Array.isArray(p.outputPortTypes) ? p.outputPortTypes : [],
|
||
outputPorts: Array.isArray(p.outputPorts) ? p.outputPorts : [],
|
||
inputVoltage: p.inputVoltage || '', inputVoltageRange: p.inputVoltageRange || '', inputFrequency: p.inputFrequency || '',
|
||
outputVoltage: p.outputVoltage || '', outputVoltageRange: p.outputVoltageRange || '',
|
||
transferTime: p.transferTime || '', waveform: p.waveform || '',
|
||
batteryVoltage: p.batteryVoltage || '', chargeCurrent: p.chargeCurrent || '',
|
||
frequencyRangeBatteryMode: p.frequencyRangeBatteryMode || '', chargeTime: p.chargeTime || '',
|
||
lcdDisplay: p.lcdDisplay || '', ledDisplay: p.ledDisplay || '', fullProtection: p.fullProtection || '',
|
||
alarmInfo: p.alarmInfo || '', interfacePort: p.interfacePort || '',
|
||
humidity: p.humidity || '', noiseLevel: p.noiseLevel || '',
|
||
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 || '',
|
||
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 || '',
|
||
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 === '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>
|
||
</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或图片形式的产品目录,可以一次选多个文件(比如一份多页PDF,或者拍的多张照片)。
|
||
AI会自动识别里面有几个产品型号、并提取型号/品牌/规格参数,之后可以在预览里检查修改,确认无误再批量建档。
|
||
</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/*" 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或图片(可多选)</>
|
||
)}
|
||
</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('/')}` : '',
|
||
].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>
|
||
);
|
||
}
|
||
|
||
/* ----------------------------- 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 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>
|
||
)}
|
||
|
||
{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 mode:Sounding every 4 seconds\nLow battery:Sounding every second\nOverload:Sounding twice every second\nFault:Continuously 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 mode:Green lighting\nBattery mode:Yellow lighting\nFault:Red 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 mode:Sounding every 10 seconds\nLow battery:Sounding every second\nOverload:Sounding every 0.5 seconds\nFault:Continuously 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} />}
|
||
{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 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(p.machineVariant)}</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 (p.productImageFileName) {
|
||
return (
|
||
<div className="spec-sheet-two-col">
|
||
<div className="spec-sheet-left">
|
||
<img className="spec-sheet-image" src={`/uploads/${p.id}/${p.productImageFileName}`} alt={p.name || ''} />
|
||
<div style={{ fontSize: 22, fontWeight: 800, marginTop: 14 }}>{p.name || '—'}</div>
|
||
{p.brand && <div style={{ color: '#888', marginTop: 2 }}>{p.brand}</div>}
|
||
{sellingPointLines.length > 0 && (
|
||
<ul className="spec-sheet-points">
|
||
{sellingPointLines.map((line, i) => <li key={i}>{L(line)}</li>)}
|
||
</ul>
|
||
)}
|
||
</div>
|
||
<div className="spec-sheet-right">
|
||
<div className="spec-sheet-bar">{L('产品规格书')}</div>
|
||
{specTable}
|
||
{disclaimer}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
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>
|
||
{sellingPointLines.length > 0 && (
|
||
<ul className="spec-sheet-points" style={{ marginBottom: 18 }}>
|
||
{sellingPointLines.map((line, i) => <li key={i}>{L(line)}</li>)}
|
||
</ul>
|
||
)}
|
||
<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>
|
||
);
|
||
}
|