diff --git a/App.jsx b/App.jsx
new file mode 100644
index 0000000..fb8826b
--- /dev/null
+++ b/App.jsx
@@ -0,0 +1,2305 @@
+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
+} from 'lucide-react';
+import {
+ storageGet, storageSet, uploadFile, deleteFile,
+ authCheck, authLogin, authLogout, authChangePassword,
+ getAiConfig, saveAiConfig, deleteAiConfig, testAiConfig, generateAiDraft,
+} from './storage';
+
+/* ----------------------------- constants ----------------------------- */
+
+const REGIONS = [
+ { id: 'africa', label: 'Africa / 非洲' },
+ { id: 'middle_east', label: 'Middle East / 中东' },
+ { id: 'australia', label: 'Australia / 大洋洲' },
+ { id: 'europe', label: 'Europe / 欧洲' },
+ { id: 'sea', label: 'SE Asia / 东南亚' },
+ { id: 'other', label: 'Other / 其他' },
+];
+
+const PRODUCT_LINES = ['UPS不间断电源', '光伏逆变器', '储能电池', 'Other'];
+
+const PRODUCT_CATEGORIES = [
+ {
+ id: 'ups', label: 'UPS不间断电源 / UPS', short: 'UPS不间断电源',
+ subs: [
+ { id: 'mini', label: '迷你UPS / Mini UPS', short: '迷你UPS' },
+ { id: 'offline', label: '离线式UPS / Offline UPS', short: '离线式UPS' },
+ { id: 'line_interactive', label: '在线互动式UPS / Line-Interactive UPS', short: '在线互动式UPS' },
+ {
+ id: 'online', label: '在线式UPS / Online UPS', short: '在线式UPS',
+ subs: [
+ { id: 'rack', label: '机架式UPS / Rack-mount UPS', short: '机架式UPS' },
+ { id: 'high_freq_tower', label: '高频塔式UPS / High-Frequency Tower UPS', short: '高频塔式UPS' },
+ { id: 'low_freq', label: '工频UPS / Low-Frequency UPS', short: '工频UPS' },
+ { id: 'modular', label: '模块化UPS / Modular UPS', short: '模块化UPS' },
+ ],
+ },
+ ],
+ },
+ {
+ id: 'inverter', label: '光伏逆变器 / Solar Inverter', short: '光伏逆变器',
+ subs: [
+ { id: 'hybrid', label: '太阳能混网逆变器 / Hybrid Solar Inverter', short: '太阳能混网逆变器' },
+ { id: 'grid_off_grid', label: '并网/离网逆变器 / Grid-tied / Off-grid Inverter', short: '并网/离网逆变器' },
+ ],
+ },
+ {
+ id: 'battery', label: '储能电池 / Energy Storage Battery', short: '储能电池',
+ subs: [
+ { id: 'lead_acid', label: '铅酸电池 / Lead-acid Battery', short: '铅酸电池' },
+ { id: 'lifepo4', label: '磷酸铁锂电池 / LiFePO4 Battery', short: '磷酸铁锂电池' },
+ ],
+ },
+];
+
+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' },
+
+ // 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' },
+
+ // 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' },
+
+ // 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' },
+];
+
+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' };
+
+const blankCustomer = () => ({
+ id: 'c_' + Date.now() + '_' + Math.random().toString(36).slice(2, 7),
+ company: '', contact: '', country: '', region: 'middle_east', email: '', phone: '',
+ isVip: false,
+ hasOrdered: false,
+ preferredContacts: [],
+ website: '',
+ productLines: [], intendedModel: '', intendedQty: '',
+ source: '',
+ tags: '', notes: '', lastContact: '', followUpDays: '',
+ warehouseAddress: '', freightContact: '', shippingMarkNote: '',
+ attachments: [],
+});
+
+const blankProduct = () => ({
+ id: 'p_' + Date.now() + '_' + Math.random().toString(36).slice(2, 7),
+ name: '', brand: '', category: '', subCategory: '', subSubCategory: '',
+ powerOrCapacity: '', voltage: '', certifications: '', targetRegions: [], notes: '',
+});
+
+/* ------------------------------- quotes/invoices ------------------------------ */
+
+const CURRENCIES = [
+ { code: 'USD', symbol: 'US$' },
+ { code: 'CNY', symbol: '¥' },
+ { code: 'RUB', symbol: '₽' },
+];
+
+const DEFAULT_SELLER_PROFILES = [
+ {
+ id: 'seller_1', name: 'BEIJING JINYIDA BORUI TECHNOLOGY DEVELOPMENT CO., LTD.',
+ address: '313, Floor 3, Building 1, Yard 4, Fuze Road, Fangshan, Beijing, China',
+ logoFileName: '', bankInfo: '',
+ },
+ {
+ id: 'seller_2', name: 'XINHUA CHUIXUE COMPUTER CO., LTD',
+ address: 'No.8 Wenyuan Street Shijiazhuang, Hebei, China',
+ logoFileName: '', bankInfo: '',
+ },
+ {
+ id: 'seller_3', name: 'Shenzhen Pengcheng Electric Co., Ltd.',
+ address: "No. 6093, Bao'an Avenue, Xinhe Community, Fuhai Street, Bao'an District, Shenzhen, China",
+ logoFileName: '', bankInfo: '',
+ },
+];
+
+const blankLineItem = () => ({ id: 'li_' + Date.now() + '_' + Math.random().toString(36).slice(2, 6), description: '', quantity: '', rate: '' });
+
+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: '', billToAddress: '',
+ shipToName: '', shipToAddress: '',
+ paymentTerms: '', dueDate: '',
+ bankInfo: '', signerName: '',
+ lineItems: [blankLineItem()],
+ taxRate: '0',
+ notes: '',
+ 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 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 subtotal = (quote.lineItems || []).reduce((sum, li) => {
+ const qty = parseFloat(li.quantity) || 0;
+ const rate = parseFloat(li.rate) || 0;
+ return sum + qty * rate;
+ }, 0);
+ const taxRate = parseFloat(quote.taxRate) || 0;
+ const taxAmount = subtotal * taxRate / 100;
+ const total = subtotal + taxAmount;
+ return { subtotal, taxAmount, total, taxRate };
+}
+
+/* ------------------------------- 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 getTopCategory(id) { return PRODUCT_CATEGORIES.find(c => c.id === id); }
+function categoryBreadcrumb(p) {
+ const top = getTopCategory(p.category);
+ if (!top) return '未分类';
+ const sub = top.subs.find(s => s.id === p.subCategory);
+ if (!sub) return top.short;
+ const subsub = sub.subs && sub.subs.find(s => s.id === p.subSubCategory);
+ return [top.short, sub.short, subsub && subsub.short].filter(Boolean).join(' › ');
+}
+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;
+ 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) {}
+ return { customers, products, settings, quotes, sellerProfiles };
+}
+
+/* -------------------------------- styles -------------------------------- */
+
+const Styles = () => (
+
+);
+
+/* -------------------------------- pieces -------------------------------- */
+
+function StatCard({ num, label, tone }) {
+ return (
+
+ );
+}
+
+function ProductPills({ value, onChange }) {
+ const toggle = (p) => {
+ const has = value.includes(p);
+ onChange(has ? value.filter(x => x !== p) : [...value, p]);
+ };
+ return (
+
+ {PRODUCT_LINES.map(p => (
+
toggle(p)}>
+ {value.includes(p) && } {p}
+
+ ))}
+
+ );
+}
+
+function RegionPills({ value, onChange }) {
+ const toggle = (id) => {
+ const has = value.includes(id);
+ onChange(has ? value.filter(x => x !== id) : [...value, id]);
+ };
+ return (
+
+ {REGIONS.map(r => (
+
toggle(r.id)}>
+ {value.includes(r.id) && } {r.label}
+
+ ))}
+
+ );
+}
+
+/* --------------------------------- app ---------------------------------- */
+
+/* --------------------------------- nav --------------------------------- */
+
+const NAV_GROUPS = [
+ { label: '客户', items: [
+ { id: 'monitor', label: '客户监控', icon: LayoutDashboard },
+ { id: 'manage', label: '客户管理', icon: Users },
+ ] },
+ { label: '产品', items: [
+ { id: 'groups', label: '产品分组', icon: FolderTree },
+ { id: 'products', label: '产品管理', icon: Package },
+ { id: 'catalog', label: '产品目录', icon: BookOpen },
+ ] },
+];
+const STANDALONE_NAV = [
+ { id: 'quotes', label: '报价单制作', icon: FileText },
+ { id: 'settings', label: '设置', icon: SettingsIcon },
+];
+function viewTitle(view) {
+ const all = [...NAV_GROUPS.flatMap(g => g.items), ...STANDALONE_NAV];
+ const found = all.find(i => i.id === view);
+ return found ? found.label : '客户档案系统';
+}
+
+/* ------------------------------- login screen ----------------------------- */
+
+function LoginScreen({ onSuccess }) {
+ const [username, setUsername] = useState('');
+ const [password, setPassword] = useState('');
+ const [error, setError] = useState('');
+ const [loading, setLoading] = useState(false);
+
+ async function handleSubmit(e) {
+ e.preventDefault();
+ if (loading) return;
+ setError('');
+ setLoading(true);
+ try {
+ const res = await authLogin(username, password);
+ onSuccess(res.username || username);
+ } catch (e) {
+ setError('用户名或密码不正确');
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ return (
+
+ );
+}
+
+/* --------------------------------- root --------------------------------- */
+
+export default function CustomerCRM() {
+ const [authState, setAuthState] = useState('checking'); // checking | out | in
+ const [username, setUsername] = useState(null);
+
+ useEffect(() => {
+ authCheck()
+ .then(res => {
+ if (res.authed) { setUsername(res.username); setAuthState('in'); }
+ else setAuthState('out');
+ })
+ .catch(() => setAuthState('out'));
+ }, []);
+
+ function handleLogout() {
+ authLogout().finally(() => {
+ setUsername(null);
+ setAuthState('out');
+ });
+ }
+
+ if (authState === 'checking') {
+ return (
+
+ );
+ }
+ if (authState === 'out') {
+ return { setUsername(name); setAuthState('in'); }} />;
+ }
+ return ;
+}
+
+export { LoginScreen, MainApp };
+
+function MainApp({ username, onLogout }) {
+ const [loaded, setLoaded] = useState(false);
+ const [customers, setCustomers] = useState([]);
+ const [products, setProducts] = useState([]);
+ const [settings, setSettings] = useState(DEFAULT_SETTINGS);
+ const [view, setView] = useState('monitor');
+ const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
+
+ const [selectedId, setSelectedId] = useState(null);
+ const [draft, setDraft] = useState(null);
+ const [search, setSearch] = useState('');
+ const [regionFilter, setRegionFilter] = useState('all');
+ const [attachmentNote, setAttachmentNote] = useState('');
+ const [attachmentCategory, setAttachmentCategory] = useState(FILE_CATEGORIES[0]);
+ const [attachmentFile, setAttachmentFile] = useState(null);
+ const [uploading, setUploading] = useState(false);
+ const [fileInputKey, setFileInputKey] = useState(0);
+ const [fileDragActive, setFileDragActive] = useState(false);
+
+ const [selectedProductId, setSelectedProductId] = useState(null);
+ const [productDraft, setProductDraft] = useState(null);
+ const [productSearch, setProductSearch] = useState('');
+ const [productCategoryFilter, setProductCategoryFilter] = useState('all');
+
+ const [emailModal, setEmailModal] = useState(null);
+ const [exportOpen, setExportOpen] = useState(false);
+ const [toast, setToast] = useState('');
+ const [now, setNow] = useState(() => new Date());
+
+ const [dragCustomerIdx, setDragCustomerIdx] = useState(null);
+ const [dragCatalogIdx, setDragCatalogIdx] = useState(null);
+ const [dragGroupState, setDragGroupState] = useState(null); // { category, index }
+
+ const [pwCurrent, setPwCurrent] = useState('');
+ const [pwNew, setPwNew] = useState('');
+ const [pwBusy, setPwBusy] = useState(false);
+
+ const [aiConfigHasKey, setAiConfigHasKey] = useState(false);
+ const [aiKeyInput, setAiKeyInput] = useState('');
+ const [aiSaving, setAiSaving] = useState(false);
+ const [aiTesting, setAiTesting] = useState(false);
+ const [aiGenerating, setAiGenerating] = useState(false);
+ const [aiDraftModal, setAiDraftModal] = useState(null); // { channel, customerId, subject, body }
+
+ const [quotes, setQuotes] = useState([]);
+ const [sellerProfiles, setSellerProfiles] = useState(DEFAULT_SELLER_PROFILES);
+ const [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);
+
+ 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();
+ setCustomers(data.customers);
+ setProducts(data.products);
+ setSettings(data.settings);
+ setQuotes(data.quotes);
+ setSellerProfiles(data.sellerProfiles);
+ 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]);
+
+ function notify(msg) { setToast(msg); setTimeout(() => setToast(''), 2200); }
+
+ function copyText(text) {
+ navigator.clipboard.writeText(text).then(
+ () => notify('已复制到剪贴板'),
+ () => notify('复制失败,请手动选择文本复制')
+ );
+ }
+
+ const selected = customers.find(c => c.id === selectedId) || null;
+ const selectedProduct = products.find(p => p.id === selectedProductId) || null;
+
+ const filteredCustomers = useMemo(() => {
+ let list = customers;
+ if (regionFilter !== 'all') list = list.filter(c => c.region === regionFilter);
+ if (search.trim()) {
+ const q = search.trim().toLowerCase();
+ list = list.filter(c =>
+ (c.company || '').toLowerCase().includes(q) ||
+ (c.contact || '').toLowerCase().includes(q) ||
+ (c.email || '').toLowerCase().includes(q) ||
+ (c.country || '').toLowerCase().includes(q)
+ );
+ }
+ const order = { overdue: 0, due: 1, ok: 2 };
+ return [...list].sort((a, b) => order[statusOf(a, settings)] - order[statusOf(b, settings)] || (a.company || '').localeCompare(b.company || ''));
+ }, [customers, search, regionFilter, settings]);
+
+ const filteredProducts = useMemo(() => {
+ let list = products;
+ if (productCategoryFilter !== 'all') list = list.filter(p => p.category === productCategoryFilter);
+ if (productSearch.trim()) {
+ const q = productSearch.trim().toLowerCase();
+ list = list.filter(p =>
+ (p.name || '').toLowerCase().includes(q) ||
+ (p.brand || '').toLowerCase().includes(q) ||
+ categoryBreadcrumb(p).toLowerCase().includes(q)
+ );
+ }
+ return [...list].sort((a, b) => categoryBreadcrumb(a).localeCompare(categoryBreadcrumb(b)) || (a.name || '').localeCompare(b.name || ''));
+ }, [products, productSearch, productCategoryFilter]);
+
+ 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]);
+
+ function handleCustomerDrop(dropIdx) {
+ if (dragCustomerIdx === null || dragCustomerIdx === dropIdx) { setDragCustomerIdx(null); return; }
+ setCustomers(prev => reorderArray(prev, dragCustomerIdx, dropIdx));
+ setDragCustomerIdx(null);
+ }
+ function handleCatalogDrop(dropIdx) {
+ if (dragCatalogIdx === null || dragCatalogIdx === dropIdx) { setDragCatalogIdx(null); return; }
+ setProducts(prev => reorderArray(prev, dragCatalogIdx, dropIdx));
+ setDragCatalogIdx(null);
+ }
+ function handleGroupDrop(category, dropIdx) {
+ if (!dragGroupState || dragGroupState.category !== category || dragGroupState.index === dropIdx) { setDragGroupState(null); return; }
+ setProducts(prev => reorderWithinCategory(prev, category, dragGroupState.index, dropIdx));
+ setDragGroupState(null);
+ }
+
+ async function handleChangePassword() {
+ if (!pwCurrent || !pwNew) { notify('请填写当前密码和新密码'); return; }
+ if (pwNew.length < 4) { notify('新密码至少4位'); return; }
+ setPwBusy(true);
+ try {
+ await authChangePassword(pwCurrent, pwNew);
+ setPwCurrent('');
+ setPwNew('');
+ notify('密码已修改');
+ } catch (e) {
+ notify('修改失败:当前密码不正确');
+ } finally {
+ setPwBusy(false);
+ }
+ }
+
+ async function handleSaveAiKey() {
+ if (!aiKeyInput.trim()) { notify('请输入API Key'); return; }
+ setAiSaving(true);
+ try {
+ await saveAiConfig(aiKeyInput.trim());
+ setAiConfigHasKey(true);
+ setAiKeyInput('');
+ notify('已保存');
+ } catch (e) {
+ notify('保存失败');
+ } finally {
+ setAiSaving(false);
+ }
+ }
+ async function handleTestAiKey() {
+ setAiTesting(true);
+ try {
+ await testAiConfig();
+ notify('连接成功');
+ } catch (e) {
+ notify('连接失败,请检查API Key');
+ } finally {
+ setAiTesting(false);
+ }
+ }
+ async function handleClearAiKey() {
+ if (!window.confirm('确定清除已保存的DeepSeek API Key?')) return;
+ await deleteAiConfig();
+ setAiConfigHasKey(false);
+ notify('已清除');
+ }
+ async function handleGenerateAi(customerId, channel) {
+ if (!aiConfigHasKey) { notify('请先在设置中配置DeepSeek API Key'); return; }
+ setAiGenerating(true);
+ try {
+ const result = await generateAiDraft(customerId, channel);
+ setAiDraftModal({ channel, customerId, subject: result.subject || '', body: result.body || '' });
+ } catch (e) {
+ notify('生成失败:' + (e.message || '请检查API Key和网络'));
+ } finally {
+ setAiGenerating(false);
+ }
+ }
+
+ function startNewCustomer() { setDraft(blankCustomer()); setSelectedId(null); }
+ function startEditCustomer(c) { setDraft({ ...c, productLines: [...(c.productLines || [])] }); setSelectedId(c.id); }
+ function cancelDraft() { setDraft(null); }
+ function saveDraft() {
+ setCustomers(prev => {
+ const exists = prev.some(c => c.id === draft.id);
+ return exists ? prev.map(c => (c.id === draft.id ? draft : c)) : [...prev, draft];
+ });
+ setSelectedId(draft.id);
+ setDraft(null);
+ notify('已保存');
+ }
+ function deleteCustomer(id) {
+ if (!window.confirm('确定删除该客户档案?此操作不可恢复。')) return;
+ setCustomers(prev => prev.filter(c => c.id !== id));
+ if (selectedId === id) setSelectedId(null);
+ notify('已删除');
+ }
+ function markContacted(id) {
+ setCustomers(prev => prev.map(c => (c.id === id ? { ...c, lastContact: todayStr() } : c)));
+ notify('已标记为今日已联系');
+ }
+ function togglePreferredContact(id, channel) {
+ setCustomers(prev => prev.map(c => {
+ if (c.id !== id) return c;
+ const has = (c.preferredContacts || []).includes(channel);
+ return { ...c, preferredContacts: has ? c.preferredContacts.filter(x => x !== channel) : [...(c.preferredContacts || []), channel] };
+ }));
+ }
+ function toggleVip(id) {
+ setCustomers(prev => prev.map(c => (c.id === id ? { ...c, isVip: !c.isVip } : c)));
+ }
+ function toggleOrdered(id) {
+ setCustomers(prev => prev.map(c => (c.id === id ? { ...c, hasOrdered: !c.hasOrdered } : c)));
+ }
+ function handleFileDragOver(e) { e.preventDefault(); setFileDragActive(true); }
+ function handleFileDragLeave() { setFileDragActive(false); }
+ function handleFileDrop(e) {
+ e.preventDefault();
+ setFileDragActive(false);
+ const f = e.dataTransfer.files && e.dataTransfer.files[0];
+ if (f) setAttachmentFile(f);
+ }
+ async function handleUploadAttachment(customerId) {
+ if (!attachmentFile) { notify('请先选择文件'); return; }
+ setUploading(true);
+ try {
+ const res = await uploadFile(customerId, attachmentFile);
+ setCustomers(prev => prev.map(c => {
+ if (c.id !== customerId) return c;
+ const entry = {
+ fileName: res.fileName,
+ originalName: res.originalName,
+ category: attachmentCategory,
+ note: attachmentNote.trim(),
+ date: todayStr(),
+ };
+ return { ...c, attachments: [entry, ...(c.attachments || [])] };
+ }));
+ setAttachmentFile(null);
+ setAttachmentNote('');
+ setAttachmentCategory(FILE_CATEGORIES[0]);
+ setFileInputKey(k => k + 1);
+ notify('已上传');
+ } catch (e) {
+ notify('上传失败,请重试');
+ } finally {
+ setUploading(false);
+ }
+ }
+ async function handleDeleteAttachment(customerId, fileName) {
+ if (!window.confirm('确定删除这个附件?此操作不可恢复。')) return;
+ try {
+ await deleteFile(customerId, fileName);
+ } catch (e) {
+ // 即使服务端删除失败(比如文件已经不存在),也继续把记录从客户档案里移除
+ }
+ setCustomers(prev => prev.map(c => (
+ c.id === customerId ? { ...c, attachments: (c.attachments || []).filter(a => a.fileName !== fileName) } : c
+ )));
+ }
+ function openFollowUpEmail(c) { setEmailModal({ ...generateFollowUpEmail(c), customerId: c.id }); }
+
+ function startNewProduct() { setProductDraft(blankProduct()); setSelectedProductId(null); }
+ function startEditProduct(p) { setProductDraft({ ...p, targetRegions: [...(p.targetRegions || [])] }); setSelectedProductId(p.id); }
+ function cancelProductDraft() { setProductDraft(null); }
+ function saveProductDraft() {
+ if (!productDraft.name.trim()) { notify('请填写产品型号/名称'); return; }
+ if (!productDraft.category) { notify('请选择产品大类'); return; }
+ setProducts(prev => {
+ const exists = prev.some(p => p.id === productDraft.id);
+ return exists ? prev.map(p => (p.id === productDraft.id ? productDraft : p)) : [...prev, productDraft];
+ });
+ setSelectedProductId(productDraft.id);
+ setProductDraft(null);
+ notify('已保存');
+ }
+ function deleteProduct(id) {
+ if (!window.confirm('确定删除该产品档案?此操作不可恢复。')) return;
+ setProducts(prev => prev.filter(p => p.id !== id));
+ if (selectedProductId === id) setSelectedProductId(null);
+ notify('已删除');
+ }
+
+ /* --------------------------- quotes / invoices --------------------------- */
+
+ function startNewQuote(type) {
+ const draft = blankQuote(type);
+ draft.number = suggestDocNumber(draft.date, quotes, type);
+ setQuoteDraft(draft);
+ setQuoteSubView('editor');
+ }
+ function startEditQuote(q) {
+ setQuoteDraft({ ...q, lineItems: q.lineItems.map(li => ({ ...li })) });
+ setQuoteSubView('editor');
+ }
+ function backToQuoteList() {
+ setQuoteDraft(null);
+ setQuoteSubView('list');
+ }
+ function updateQuoteDraft(patch) {
+ setQuoteDraft(d => ({ ...d, ...patch }));
+ }
+ function handleLinkCustomer(customerId) {
+ if (!customerId) { updateQuoteDraft({ customerId: '' }); return; }
+ const cust = customers.find(c => c.id === customerId);
+ if (!cust) return;
+ updateQuoteDraft({ customerId, billToName: customerTitle(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 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();
+ }
+
+ /* ------------------------------ 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);
+ }
+ }
+
+ if (!loaded) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+ {viewTitle(view)}
+
+ {mobileMenuOpen &&
setMobileMenuOpen(false)} />}
+
+
+
+
客户档案系统
+
Customer Console
+
+
+ {NAV_GROUPS.map(group => (
+
+
{group.label}
+ {group.items.map(item => {
+ const Icon = item.icon;
+ return (
+
{ setView(item.id); setMobileMenuOpen(false); }}
+ >
+ {item.label}
+
+ );
+ })}
+
+ ))}
+
+ {STANDALONE_NAV.map(item => {
+ const Icon = item.icon;
+ return (
+
{ setView(item.id); setMobileMenuOpen(false); }}
+ >
+ {item.label}
+
+ );
+ })}
+
+
+
+
+
+
+
+ {view === 'monitor' && (
+ <>
+
+
+
+
+
+
+
+
+ {customers.length === 0 ? (
+
+ ) : (
+
+ {customers.map((c, idx) => {
+ const s = statusOf(c, settings);
+ const lt = localTimeInfo(c.country, now);
+ const modelQty = [c.intendedModel, c.intendedQty ? `× ${c.intendedQty}` : ''].filter(Boolean).join(' ');
+ const cardTitle = customerTitle(c);
+ const showCompanyLine = !!(modelQty && c.company);
+ const { showEmail, showPhone } = contactDisplayFlags(c);
+ return (
+
setDragCustomerIdx(idx)}
+ onDragOver={(e) => e.preventDefault()}
+ onDrop={() => handleCustomerDrop(idx)}
+ onDragEnd={() => setDragCustomerIdx(null)}
+ >
+
+
+
+
{cardTitle}
+ {c.isVip &&
}
+ {c.hasOrdered &&
}
+
+
{regionLabel(c.region)} · {c.country || '—'}
+ {showCompanyLine &&
{c.company}
}
+ {c.contact &&
{c.contact}
}
+ {showEmail &&
{c.email}
}
+ {showPhone &&
}
+ {lt &&
当地时间 {lt.time}
}
+
上次联系 {fmtDate(c.lastContact)}
+
+
+
+
+
+ );
+ })}
+
+ )}
+ >
+ )}
+
+ {view === 'manage' && (
+
+
+
setSearch(e.target.value)} />
+
+
+
+
+
+
+ {filteredCustomers.length === 0 ? (
+
暂无客户,点击上方按钮添加第一个
+ ) : filteredCustomers.map(c => {
+ const s = statusOf(c, settings);
+ const showCompanyInSub = customerTitleIsModel(c) && c.company;
+ return (
+
{ setDraft(null); setSelectedId(c.id); }}>
+
+
+
{c.isVip && }{c.hasOrdered && }{customerTitle(c)}
+
{showCompanyInSub && `${c.company} · `}{regionLabel(c.region)} · {c.country || '—'}{localTimeInfo(c.country, now) && ` · 当地 ${localTimeInfo(c.country, now).time}`}
+
+
+
+ );
+ })}
+
+
+
+
+
+ {draft ? (
+
+ ) : selected ? (
+
startEditCustomer(selected)}
+ onDelete={() => deleteCustomer(selected.id)}
+ onMarkContacted={() => markContacted(selected.id)}
+ onEmail={() => openFollowUpEmail(selected)}
+ onTogglePreferred={(channel) => togglePreferredContact(selected.id, channel)}
+ onToggleVip={() => toggleVip(selected.id)}
+ onToggleOrdered={() => toggleOrdered(selected.id)}
+ attachmentNote={attachmentNote}
+ setAttachmentNote={setAttachmentNote}
+ attachmentCategory={attachmentCategory}
+ setAttachmentCategory={setAttachmentCategory}
+ attachmentFileName={attachmentFile ? attachmentFile.name : ''}
+ onPickAttachment={(f) => setAttachmentFile(f)}
+ uploading={uploading}
+ fileInputKey={fileInputKey}
+ onUploadAttachment={() => handleUploadAttachment(selected.id)}
+ onDeleteAttachment={(fileName) => handleDeleteAttachment(selected.id, fileName)}
+ fileDragActive={fileDragActive}
+ onFileDragOver={handleFileDragOver}
+ onFileDragLeave={handleFileDragLeave}
+ onFileDrop={handleFileDrop}
+ aiConfigHasKey={aiConfigHasKey}
+ aiGenerating={aiGenerating}
+ onGenerateAi={(channel) => handleGenerateAi(selected.id, channel)}
+ />
+ ) : (
+
+ )}
+
+
+ )}
+
+ {view === 'groups' && (
+ <>
+
按产品大类分组浏览,每组内的产品可以拖拽调整顺序。
+ {PRODUCT_CATEGORIES.map(cat => {
+ const items = products.filter(p => p.category === cat.id);
+ return (
+
+
{cat.short}({items.length})
+
+ {items.length === 0 ? (
+
暂无产品
+ ) : items.map((p, idx) => (
+
setDragGroupState({ category: cat.id, index: idx })}
+ onDragOver={(e) => e.preventDefault()}
+ onDrop={() => handleGroupDrop(cat.id, idx)}
+ onDragEnd={() => setDragGroupState(null)}
+ >
+
+
+
{p.name || '(未命名)'}
+
{categoryBreadcrumb(p)}{p.brand ? ` · ${p.brand}` : ''}
+
+
+
+ ))}
+
+
+ );
+ })}
+ >
+ )}
+
+ {view === 'products' && (
+
+
+
setProductSearch(e.target.value)} />
+
+
+
+
+
+
+ {filteredProducts.length === 0 ? (
+
+ ) : filteredProducts.map(p => (
+
{ setProductDraft(null); setSelectedProductId(p.id); }}>
+
+
+
{p.name || '(未命名)'}
+
{categoryBreadcrumb(p)}{p.brand ? ` · ${p.brand}` : ''}
+
+
+
+ ))}
+
+
+
+
+
+ {productDraft ? (
+
+ ) : selectedProduct ? (
+
startEditProduct(selectedProduct)} onDelete={() => deleteProduct(selectedProduct.id)} />
+ ) : (
+
+ )}
+
+
+ )}
+
+ {view === 'catalog' && (
+ <>
+
拖拽卡片调整展示顺序,方便整理成给客户看的产品介绍顺序。
+ {products.length === 0 ? (
+
+ ) : (
+
+ {products.map((p, idx) => {
+ const certs = (p.certifications || '').split(',').map(s => s.trim()).filter(Boolean);
+ return (
+
setDragCatalogIdx(idx)}
+ onDragOver={(e) => e.preventDefault()}
+ onDrop={() => handleCatalogDrop(idx)}
+ onDragEnd={() => setDragCatalogIdx(null)}
+ >
+
+
+
{p.name || '(未命名)'}
+
+
{categoryBreadcrumb(p)}
+ {p.brand &&
品牌:{p.brand}
}
+ {p.powerOrCapacity &&
功率 / 容量:{p.powerOrCapacity}
}
+ {p.voltage &&
电压:{p.voltage}
}
+ {certs.length > 0 &&
{certs.map(cc => {cc})}
}
+
+ );
+ })}
+
+ )}
+ >
+ )}
+
+ {view === 'quotes' && quoteSubView === 'list' && (
+ <>
+
+
+
+
+
+
+
+
+ {quotes.filter(q => quoteTypeFilter === 'all' || q.type === quoteTypeFilter).length === 0 ? (
+
暂无文档,点击上方按钮新建一份
+ ) : [...quotes].filter(q => quoteTypeFilter === 'all' || q.type === quoteTypeFilter).reverse().map(q => {
+ const { total } = quoteTotals(q);
+ return (
+
startEditQuote(q)}>
+
{q.type === 'quote' ? 'Quote' : 'Invoice'}
+
+
{q.number || '(无单号)'} · {q.billToName || '(未填写客户)'}
+
{q.date} · {formatMoney(total, q.currency)}
+
+
+
+ );
+ })}
+
+
+ >
+ )}
+
+ {view === 'quotes' && quoteSubView === 'editor' && quoteDraft && (
+
setSellerManagerOpen(true)}
+ />
+ )}
+
+ {view === 'settings' && (
+ <>
+
+
+
+
+
+
+
+
+
+ 说明:邮件功能用于生成可复制的英文邮件文案,需要你手动粘贴到自己的邮箱发送。报价单制作正在开发中,下一轮会上线。
+
+
+
+
+
+
+
+
当前登录账号:{username}
+
+
+
+ 这个系统现在挂在公网上,定期更换密码、不要把密码告诉别人。
+
+
+
+
+
+
+
+
+ 状态:{aiConfigHasKey ? 已配置 : 未配置}
+
+
+ DeepSeek API Key
+ setAiKeyInput(e.target.value)}
+ placeholder={aiConfigHasKey ? '已设置,重新填写可覆盖' : '在 platform.deepseek.com 申请'}
+ />
+
+
+
+
+ {aiConfigHasKey && }
+
+
+ 用于客户详情页的"AI生成邮件 / AI生成WhatsApp话术"功能,调用DeepSeek官方API生成。成本很低(单次生成大概几分钱以内人民币,按你自己DeepSeek账户余额实际扣费),Key只会加密保存在服务器本地,不会显示给任何人。
+
+
+
+ >
+ )}
+
+
+
+
+
+ {aiDraftModal && (
+
setAiDraftModal(null)}>
+
e.stopPropagation()}>
+
+
{aiDraftModal.channel === 'email' ? 'AI生成邮件' : 'AI生成WhatsApp话术'}
+
+
+
+ {aiDraftModal.channel === 'email' && (
+
主题 Subject setAiDraftModal(m => ({ ...m, subject: e.target.value }))} />
+ )}
+
+ {aiDraftModal.channel === 'email' ? '正文 Body' : '消息内容'}
+
+
+
+ {aiDraftModal.channel === 'email' && }
+
+
+
+
+ )}
+
+ {emailModal && (
+
setEmailModal(null)}>
+
e.stopPropagation()}>
+
邮件文案
+
+
+
+
+
+
+
+ )}
+
+ {exportOpen && (
+
setExportOpen(false)}>
+
e.stopPropagation()}>
+
数据备份(JSON)
+
+
+
+
+
+
+
+
+ )}
+
+ {sellerManagerOpen && (
+
setSellerManagerOpen(false)}>
+
e.stopPropagation()}>
+
管理卖方信息
+
+ {sellerProfiles.map(sp => (
+
+
+
+
公司名称 updateSellerProfile(sp.id, { name: e.target.value })} />
+
+
Logo
+
+ {sp.logoFileName &&

}
+
+
+
+
+
地址
+
银行信息(仅Invoice使用,可留空)
+
+
+
+ ))}
+
+
+
+
+
+
+
+ )}
+
+ {toast &&
{toast}
}
+
+ );
+}
+
+/* ----------------------------- customer form / detail ----------------------------- */
+
+function CustomerForm({ draft, setDraft, onSave, onCancel }) {
+ const set = (patch) => setDraft(d => ({ ...d, ...patch }));
+
+ function handleCountryChange(value) {
+ const match = COUNTRY_BY_NAME[value];
+ if (match) {
+ setDraft(d => ({
+ ...d,
+ country: value,
+ region: match.region,
+ // 国家代码只在电话号码当前为空时自动填入,避免覆盖已经填好的号码
+ phone: d.phone && d.phone.trim() ? d.phone : match.dial + ' ',
+ }));
+ } else {
+ set({ country: value });
+ }
+ }
+
+ return (
+
+
{draft.company ? '编辑客户' : '新建客户'}
+
+
+
+
关注产品线 set({ productLines: v })} />
+ {draft.productLines.length > 0 && (
+
+ )}
+
+
+
备注
+
+
+
+ 箱唛 / 备注
+
+
+
客户背景信息调查
+
+
+
+ );
+}
+
+function ContactInfo({ icon, label, value, active, onToggle }) {
+ return (
+
+
+ {icon}{label}{active ? '(常用)' : ''}
+
+
{value}
+
+ );
+}
+
+function CollapsibleSection({ title, count, defaultOpen, children }) {
+ const [open, setOpen] = useState(!!defaultOpen);
+ return (
+
+
setOpen(o => !o)}
+ >
+ {title}{typeof count === 'number' ? `(${count})` : ''}
+
+
+ {open &&
{children}
}
+
+ );
+}
+
+function CustomerDetail({
+ customer: c, settings, now, onEdit, onDelete, onMarkContacted, onEmail,
+ onTogglePreferred, onToggleVip, onToggleOrdered,
+ attachmentNote, setAttachmentNote, attachmentCategory, setAttachmentCategory,
+ attachmentFileName, onPickAttachment, uploading, fileInputKey, onUploadAttachment, onDeleteAttachment,
+ fileDragActive, onFileDragOver, onFileDragLeave, onFileDrop,
+ aiConfigHasKey, aiGenerating, onGenerateAi,
+}) {
+ const s = statusOf(c, settings);
+ const lt = localTimeInfo(c.country, now);
+ const preferred = c.preferredContacts || [];
+ const [fileSearch, setFileSearch] = useState('');
+ const [fileCategoryFilter, setFileCategoryFilter] = useState('all');
+ const filteredAttachments = (c.attachments || []).filter(a => {
+ if (fileCategoryFilter !== 'all' && a.category !== fileCategoryFilter) return false;
+ if (fileSearch.trim()) {
+ const q = fileSearch.trim().toLowerCase();
+ return (a.originalName || '').toLowerCase().includes(q) || (a.note || '').toLowerCase().includes(q);
+ }
+ return true;
+ });
+ return (
+
+
+
+
+ {customerTitle(c)}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
} label="公司名称" value={c.company || '—'} />
+
} label="联系人" value={c.contact || '—'} />
+
} label="地区 / 国家" value={`${regionLabel(c.region)} · ${c.country || '—'}`} />
+
} label="邮箱" value={c.email || '—'} active={preferred.includes('email')} onToggle={() => onTogglePreferred('email')} />
+
} label="电话" value={c.phone || '—'} active={preferred.includes('phone')} onToggle={() => onTogglePreferred('phone')} />
+
} label="当地时间" value={lt ? `${lt.time}(${lt.en})` : '—'} />
+
} label="网站" value={c.website ?
{c.website} : '—'} />
+
+ {c.source &&
客户来源{c.source}
}
+ {c.productLines && c.productLines.length > 0 && (
+
+
关注产品线
+
{c.productLines.map(p => {p})}
+ {(c.intendedModel || c.intendedQty) && (
+
+ 意向型号:{c.intendedModel || '—'}{c.intendedQty ? ` × ${c.intendedQty}` : ''}
+
+ )}
+
+ )}
+ {c.tags &&
}
+
+
+ {!(c.warehouseAddress || c.freightContact || c.shippingMarkNote) ? (
+ 暂无货代信息
+ ) : (
+ <>
+
+ {c.warehouseAddress && }
+ {c.freightContact && }
+
+ {c.shippingMarkNote && 箱唛 / 备注:{c.shippingMarkNote}
}
+ >
+ )}
+
+
+ {c.notes &&
}
+
+
+ 上次联系:{fmtDate(c.lastContact)} | 状态:{statusLabel(s)}
+
+
+
+
+
+
+
+ {!aiConfigHasKey && 未配置DeepSeek,去"设置"里填API Key}
+
+
+
+
客户文件管理
+
+
+
+
+
setAttachmentNote(e.target.value)}
+ />
+
+
+
+
+
0}>
+ {(!c.attachments || c.attachments.length === 0) ? (
+ 暂无文件
+ ) : (
+ <>
+
+ setFileSearch(e.target.value)}
+ />
+
+
+ {filteredAttachments.length === 0 ? (
+ 没有匹配的文件
+ ) : filteredAttachments.map((a, i) => (
+
+
+
+
{a.date}
+ {' '}
{a.category || 'Other'}
+
+
+
+
+ ))}
+ >
+ )}
+
+
+
+ );
+}
+
+/* ----------------------------- product form / detail ----------------------------- */
+
+function ProductForm({ draft, setDraft, onSave, onCancel }) {
+ const set = (patch) => setDraft(d => ({ ...d, ...patch }));
+ const topCat = PRODUCT_CATEGORIES.find(c => c.id === draft.category);
+ const subCat = topCat && topCat.subs.find(s => s.id === draft.subCategory);
+ const hasSubSub = !!(subCat && subCat.subs && subCat.subs.length);
+ return (
+
+
{draft.name ? '编辑产品' : '新建产品'}
+
+
+
+
+ 产品大类 *
+
+
+ {topCat && (
+
+ 细分品类
+
+
+ )}
+
+ {hasSubSub && (
+
+ 类型
+
+
+ )}
+
+
认证(逗号分隔) set({ certifications: e.target.value })} placeholder="如:CE, FCC, UL, SASO" />
+
适用市场地区 set({ targetRegions: v })} />
+
备注 / 卖点说明
+
+
+
+ );
+}
+
+function ProductDetail({ product: p, onEdit, onDelete }) {
+ const certs = (p.certifications || '').split(',').map(s => s.trim()).filter(Boolean);
+ return (
+
+
+
+
+ } label="分类" value={categoryBreadcrumb(p)} />
+ } label="品牌" value={p.brand || '—'} />
+ } label="功率 / 容量" value={p.powerOrCapacity || '—'} />
+ } label="电压" value={p.voltage || '—'} />
+
+ {certs.length > 0 && (
+
+ )}
+ {p.targetRegions && p.targetRegions.length > 0 && (
+
适用市场地区{p.targetRegions.map(r => {regionLabel(r)})}
+ )}
+ {p.notes &&
}
+
+
+ );
+}
+
+/* ----------------------------- quote / invoice editor ----------------------------- */
+
+function QuoteEditor({
+ draft, onUpdate, onBack, onSave, onPrint,
+ customers, sellerProfiles, onLinkCustomer,
+ onAddLineItem, onRemoveLineItem, onUpdateLineItem, onManageSellers,
+}) {
+ const isInvoice = draft.type === 'invoice';
+ const seller = sellerProfiles.find(sp => sp.id === draft.sellerProfileId) || sellerProfiles[0];
+ const { subtotal, taxAmount, total, taxRate } = quoteTotals(draft);
+ const logoUrl = seller && seller.logoFileName ? `/uploads/${seller.id}/${seller.logoFileName}` : null;
+ const sortedCustomers = [...customers].sort((a, b) => customerTitle(a).localeCompare(customerTitle(b)));
+
+ return (
+
+
+
+
+
{isInvoice ? 'Invoice 信息' : 'Quote 信息'}
+
+
+
+ 卖方
+
+
+
+ 关联客户档案(可选,仅用于自动填名称)
+
+
+
单号 onUpdate({ number: e.target.value })} />
+
日期 onUpdate({ date: e.target.value })} />
+
+ 币种
+
+
+
税率 % onUpdate({ taxRate: e.target.value })} />
+ {isInvoice && (
+ <>
+
Payment Terms onUpdate({ paymentTerms: e.target.value })} placeholder="如:100% IN ADVANCE T/T" />
+
Due Date onUpdate({ dueDate: e.target.value })} />
+ >
+ )}
+
+
+
+
+
{isInvoice ? 'Bill To 名称' : 'To 客户名称'} onUpdate({ billToName: e.target.value })} />
+ {isInvoice &&
Bill To 地址
}
+
+ {isInvoice && (
+
+
Ship To 名称 onUpdate({ shipToName: e.target.value })} />
+
Ship To 地址
+
+ )}
+
+
产品明细
+ {draft.lineItems.map(li => (
+
+
+
+
onUpdateLineItem(li.id, { quantity: e.target.value })} />
+
onUpdateLineItem(li.id, { rate: e.target.value })} />
+
+ {formatMoney((parseFloat(li.quantity) || 0) * (parseFloat(li.rate) || 0), draft.currency)}
+
+
+
+ ))}
+
+
+ {isInvoice && (
+
+
银行信息 Bank Information
+
签署人姓名 onUpdate({ signerName: e.target.value })} />
+
+ )}
+
+ {isInvoice ? 'Terms 条款' : 'Notes 备注'}
+
+
+
+
+
+
+
+ {logoUrl ? (
+

+ ) : (
+
{(seller && seller.name) || ''}
+ )}
+
+
+
{isInvoice ? 'INVOICE' : 'QUOTE'}
+
# {draft.number}
+
+
+
+
+
+
{seller && seller.name}
+
{seller && seller.address}
+
+
+
Date:{draft.date}
+ {isInvoice && (
+ <>
+ {draft.paymentTerms &&
Payment Terms:{draft.paymentTerms}
}
+ {draft.dueDate &&
Due Date:{draft.dueDate}
}
+
Balance Due:{formatMoney(total, draft.currency)}
+ >
+ )}
+
+
+
+ {!isInvoice ? (
+
+
To:
+
{draft.billToName || '—'}
+
+ ) : (
+
+
+
Bill To:
+
{draft.billToName || '—'}
+
{draft.billToAddress}
+
+
+
Ship To:
+
{draft.shipToName || '—'}
+
{draft.shipToAddress}
+
+
+ )}
+
+
+
+
+ | Item |
+ Quantity |
+ Rate |
+ Amount |
+
+
+
+ {draft.lineItems.map(li => {
+ const lines = (li.description || '').split('\n');
+ const amount = (parseFloat(li.quantity) || 0) * (parseFloat(li.rate) || 0);
+ return (
+
+ |
+ {lines[0] || '—'}
+ {lines.slice(1).map((l, i) => l.trim() && {l} )}
+ |
+ {li.quantity || '—'} |
+ {li.rate ? formatMoney(parseFloat(li.rate), draft.currency) : '—'} |
+ {formatMoney(amount, draft.currency)} |
+
+ );
+ })}
+
+
+
+
+
Subtotal:{formatMoney(subtotal, draft.currency)}
+
Tax ({taxRate}%):{formatMoney(taxAmount, draft.currency)}
+
Total:{formatMoney(total, draft.currency)}
+
+
+ {isInvoice && (draft.bankInfo || (seller && seller.bankInfo)) && (
+
+
Bank Information:
+
{draft.bankInfo || (seller && seller.bankInfo)}
+
+ )}
+
+ {draft.notes && (
+
+
{isInvoice ? 'Terms:' : 'Notes:'}
+
{draft.notes}
+
+ )}
+
+ {isInvoice && (
+
+
For and on behalf of
+
{seller && seller.name}
+ {draft.signerName &&
{draft.signerName}
}
+
+
Authorized Signature(s)
+
+ )}
+
+
+ );
+}
+
+function Info({ icon, label, value }) {
+ return (
+
+
{icon}{label}
+
{value}
+
+ );
+}