client/App.jsx

2371 lines
126 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

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

import React, { useState, useEffect, useMemo } from 'react';
import {
Search, Plus, X, Mail, Edit2, Trash2, Check, Clock,
Settings as SettingsIcon, Copy, ChevronRight,
Building2, Globe, Phone, Save, RefreshCw, LayoutDashboard, Users,
Package, Layers, Zap, Tag, Inbox, Paperclip,
Menu, LogOut, GripVertical, BookOpen, FileText, FolderTree, Lock, Crown, Sparkles, Printer, Image as ImageIcon
} 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 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 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 };
}
// 客户档案在下拉框里展示的标签:意向型号 - 公司名字 - 客户姓名,方便辨认是哪一个客户
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 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 = () => (
<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, .catalog-card {
background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: 14px;
display: flex; flex-direction: column; gap: 8px;
}
.monitor-card-head, .catalog-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); }
/* ---------- 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; }
@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 -------------------------------- */
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 },
{ 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 (
<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 [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);
const [pdfExporting, setPdfExporting] = 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: '', 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 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);
try {
const mod = await import('html2pdf.js');
const html2pdf = mod.default || mod;
const el = document.querySelector('.doc-preview');
if (!el) { notify('找不到预览内容'); return; }
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 {
setPdfExporting(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);
}
}
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>
{PRODUCT_CATEGORIES.map(cat => {
const items = products.filter(p => p.category === cat.id);
return (
<div className="panel section-gap" key={cat.id}>
<div className="panel-header"><div className="panel-title"><Layers size={15} />{cat.short}{items.length}</div></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)}{p.brand ? ` · ${p.brand}` : ''}</div>
</div>
<button className="btn btn-sm" onClick={() => { setView('products'); setProductDraft(null); setSelectedProductId(p.id); }}>查看</button>
</div>
))}
</div>
</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>
{PRODUCT_CATEGORIES.map(c => <option key={c.id} value={c.id}>{c.short}</option>)}
</select>
</div>
<button className="btn btn-primary" style={{ width: '100%', justifyContent: 'center', marginBottom: 12 }} onClick={startNewProduct}><Plus size={14} />新建产品档案</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)}{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} />
) : selectedProduct ? (
<ProductDetail product={selectedProduct} onEdit={() => startEditProduct(selectedProduct)} onDelete={() => deleteProduct(selectedProduct.id)} />
) : (
<div className="panel"><div className="empty-state"><Package size={28} />选择左侧产品查看详情或新建一个产品档案</div></div>
)}
</div>
</div>
)}
{view === 'catalog' && (
<>
<div className="disclaimer">拖拽卡片调整展示顺序方便整理成给客户看的产品介绍顺序</div>
{products.length === 0 ? (
<div className="panel"><div className="empty-state"><BookOpen size={28} />暂无产品"产品管理"添加第一个</div></div>
) : (
<div className="card-grid">
{products.map((p, idx) => {
const certs = (p.certifications || '').split(',').map(s => s.trim()).filter(Boolean);
return (
<div
key={p.id}
className="catalog-card"
style={{ opacity: dragCatalogIdx === idx ? 0.5 : 1 }}
draggable
onDragStart={() => setDragCatalogIdx(idx)}
onDragOver={(e) => e.preventDefault()}
onDrop={() => handleCatalogDrop(idx)}
onDragEnd={() => setDragCatalogIdx(null)}
>
<div className="catalog-card-head">
<GripVertical size={14} className="drag-handle" />
<div className="monitor-card-title">{p.name || '(未命名)'}</div>
</div>
<div className="row-sub">{categoryBreadcrumb(p)}</div>
{p.brand && <div style={{ fontSize: 12.5 }}>品牌{p.brand}</div>}
{p.powerOrCapacity && <div style={{ fontSize: 12.5 }}>功率 / 容量{p.powerOrCapacity}</div>}
{p.voltage && <div style={{ fontSize: 12.5 }}>电压{p.voltage}</div>}
{certs.length > 0 && <div className="checkbox-row">{certs.map(cc => <span key={cc} className="badge badge-region">{cc}</span>)}</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>
</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}
onAddLineItem={addLineItem}
onRemoveLineItem={removeLineItem}
onUpdateLineItem={updateLineItem}
onManageSellers={() => setSellerManagerOpen(true)}
onToggleNoteLine={toggleQuoteNoteLine}
/>
)}
{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 }, null, 2)} />
</div>
<div className="modal-foot">
<button className="btn btn-primary" onClick={() => copyText(JSON.stringify({ customers, products, settings, quotes, sellerProfiles }, 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>
)}
{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 }) {
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 (
<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"><span className="field-label">品牌</span><input className="input" value={draft.brand} onChange={e => set({ brand: e.target.value })} placeholder="如Eltek / 华为 / 伊顿 / SKE / Baace" /></div>
</div>
<div className="grid-2">
<div className="field">
<span className="field-label">产品大类 *</span>
<select className="select" value={draft.category} onChange={e => set({ category: e.target.value, subCategory: '', subSubCategory: '' })}>
<option value="">请选择</option>
{PRODUCT_CATEGORIES.map(c => <option key={c.id} value={c.id}>{c.label}</option>)}
</select>
</div>
{topCat && (
<div className="field">
<span className="field-label">细分品类</span>
<select className="select" value={draft.subCategory} onChange={e => set({ subCategory: e.target.value, subSubCategory: '' })}>
<option value="">请选择</option>
{topCat.subs.map(s => <option key={s.id} value={s.id}>{s.label}</option>)}
</select>
</div>
)}
</div>
{hasSubSub && (
<div className="field">
<span className="field-label">类型</span>
<select className="select" value={draft.subSubCategory} onChange={e => set({ subSubCategory: e.target.value })}>
<option value="">请选择</option>
{subCat.subs.map(s => <option key={s.id} value={s.id}>{s.label}</option>)}
</select>
</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"><span className="field-label">认证(逗号分隔)</span><input className="input" value={draft.certifications} onChange={e => set({ certifications: e.target.value })} placeholder="CE, FCC, UL, SASO" /></div>
<div className="field"><span className="field-label">适用市场地区</span><RegionPills value={draft.targetRegions} onChange={v => set({ targetRegions: v })} /></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 })} /></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 }) {
const certs = (p.certifications || '').split(',').map(s => s.trim()).filter(Boolean);
return (
<div className="panel">
<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)} />
<Info icon={<Building2 size={13} />} label="品牌" value={p.brand || '—'} />
<Info icon={<Zap size={13} />} label="功率 / 容量" value={p.powerOrCapacity || '—'} />
<Info icon={<Zap size={13} />} label="电压" value={p.voltage || '—'} />
</div>
{certs.length > 0 && (
<div className="field"><span className="field-label" style={{ display: 'flex', alignItems: 'center', gap: 5 }}><Tag size={12} />认证</span><div className="checkbox-row">{certs.map(c => <span key={c} className="badge badge-region">{c}</span>)}</div></div>
)}
{p.targetRegions && p.targetRegions.length > 0 && (
<div className="field"><span className="field-label">适用市场地区</span><div className="checkbox-row">{p.targetRegions.map(r => <span key={r} className="badge badge-region">{regionLabel(r)}</span>)}</div></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>
);
}
/* ----------------------------- quote / invoice editor ----------------------------- */
function QuoteEditor({
draft, onUpdate, onBack, onSave, onPrint, onExportPdf, pdfExporting,
customers, sellerProfiles, onLinkCustomer,
onAddLineItem, onRemoveLineItem, onUpdateLineItem, onManageSellers, onToggleNoteLine,
}) {
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);
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 => onUpdate({ sellerProfileId: 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 => onUpdate({ currency: 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><input className="input" 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 => (
<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 }}>
<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.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="留空则使用卖方信息里预填的银行信息" /></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>
<div style={{ fontWeight: 700, marginTop: 4 }}>{draft.billToName || '—'}</div>
</div>
) : (
<div className="doc-row" style={{ marginBottom: 28 }}>
<div>
<div className="doc-label">Bill To:</div>
<div style={{ fontWeight: 700, marginTop: 4 }}>{draft.billToName || '—'}</div>
</div>
<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 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>
);
}