diff --git a/App.jsx b/App.jsx
deleted file mode 100644
index bd683c3..0000000
--- a/App.jsx
+++ /dev/null
@@ -1,1796 +0,0 @@
-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
-} 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' };
-
-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: [], productLinesNote: '',
- 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: '',
-});
-
-/* ------------------------------- 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 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;
- 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) {}
- return { customers, products, settings };
-}
-
-/* -------------------------------- styles -------------------------------- */
-
-const Styles = () => (
-
-);
-
-/* -------------------------------- pieces -------------------------------- */
-
-function StatCard({ num, label, tone }) {
- return (
-
- );
-}
-
-function ProductPills({ value, onChange }) {
- const toggle = (p) => {
- const has = value.includes(p);
- onChange(has ? value.filter(x => x !== p) : [...value, p]);
- };
- return (
-
- {PRODUCT_LINES.map(p => (
-
toggle(p)}>
- {value.includes(p) && } {p}
-
- ))}
-
- );
-}
-
-function RegionPills({ value, onChange }) {
- const toggle = (id) => {
- const has = value.includes(id);
- onChange(has ? value.filter(x => x !== id) : [...value, id]);
- };
- return (
-
- {REGIONS.map(r => (
-
toggle(r.id)}>
- {value.includes(r.id) && } {r.label}
-
- ))}
-
- );
-}
-
-/* --------------------------------- app ---------------------------------- */
-
-/* --------------------------------- nav --------------------------------- */
-
-const NAV_GROUPS = [
- { label: '客户', items: [
- { id: 'monitor', label: '客户监控', icon: LayoutDashboard },
- { id: 'manage', label: '客户管理', icon: Users },
- ] },
- { label: '产品', items: [
- { id: 'groups', label: '产品分组', icon: FolderTree },
- { id: 'products', label: '产品管理', icon: Package },
- { id: 'catalog', label: '产品目录', icon: BookOpen },
- ] },
-];
-const STANDALONE_NAV = [
- { id: 'quotes', label: '报价单制作', icon: FileText },
- { id: 'settings', label: '设置', icon: SettingsIcon },
-];
-function viewTitle(view) {
- const all = [...NAV_GROUPS.flatMap(g => g.items), ...STANDALONE_NAV];
- const found = all.find(i => i.id === view);
- return found ? found.label : '客户档案系统';
-}
-
-/* ------------------------------- login screen ----------------------------- */
-
-function LoginScreen({ onSuccess }) {
- const [username, setUsername] = useState('');
- const [password, setPassword] = useState('');
- const [error, setError] = useState('');
- const [loading, setLoading] = useState(false);
-
- async function handleSubmit(e) {
- e.preventDefault();
- if (loading) return;
- setError('');
- setLoading(true);
- try {
- const res = await authLogin(username, password);
- onSuccess(res.username || username);
- } catch (e) {
- setError('用户名或密码不正确');
- } finally {
- setLoading(false);
- }
- }
-
- return (
-
- );
-}
-
-/* --------------------------------- root --------------------------------- */
-
-export default function CustomerCRM() {
- const [authState, setAuthState] = useState('checking'); // checking | out | in
- const [username, setUsername] = useState(null);
-
- useEffect(() => {
- authCheck()
- .then(res => {
- if (res.authed) { setUsername(res.username); setAuthState('in'); }
- else setAuthState('out');
- })
- .catch(() => setAuthState('out'));
- }, []);
-
- function handleLogout() {
- authLogout().finally(() => {
- setUsername(null);
- setAuthState('out');
- });
- }
-
- if (authState === 'checking') {
- return (
-
- );
- }
- if (authState === 'out') {
- return { setUsername(name); setAuthState('in'); }} />;
- }
- return ;
-}
-
-export { LoginScreen, MainApp };
-
-function MainApp({ username, onLogout }) {
- const [loaded, setLoaded] = useState(false);
- const [customers, setCustomers] = useState([]);
- const [products, setProducts] = useState([]);
- const [settings, setSettings] = useState(DEFAULT_SETTINGS);
- const [view, setView] = useState('monitor');
- const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
-
- const [selectedId, setSelectedId] = useState(null);
- const [draft, setDraft] = useState(null);
- const [search, setSearch] = useState('');
- const [regionFilter, setRegionFilter] = useState('all');
- const [attachmentNote, setAttachmentNote] = useState('');
- const [attachmentCategory, setAttachmentCategory] = useState(FILE_CATEGORIES[0]);
- const [attachmentFile, setAttachmentFile] = useState(null);
- const [uploading, setUploading] = useState(false);
- const [fileInputKey, setFileInputKey] = useState(0);
- const [fileDragActive, setFileDragActive] = useState(false);
-
- const [selectedProductId, setSelectedProductId] = useState(null);
- const [productDraft, setProductDraft] = useState(null);
- const [productSearch, setProductSearch] = useState('');
- const [productCategoryFilter, setProductCategoryFilter] = useState('all');
-
- const [emailModal, setEmailModal] = useState(null);
- const [exportOpen, setExportOpen] = useState(false);
- const [toast, setToast] = useState('');
- const [now, setNow] = useState(() => new Date());
-
- const [dragCustomerIdx, setDragCustomerIdx] = useState(null);
- const [dragCatalogIdx, setDragCatalogIdx] = useState(null);
- const [dragGroupState, setDragGroupState] = useState(null); // { category, index }
-
- const [pwCurrent, setPwCurrent] = useState('');
- const [pwNew, setPwNew] = useState('');
- const [pwBusy, setPwBusy] = useState(false);
-
- const [aiConfigHasKey, setAiConfigHasKey] = useState(false);
- const [aiKeyInput, setAiKeyInput] = useState('');
- const [aiSaving, setAiSaving] = useState(false);
- const [aiTesting, setAiTesting] = useState(false);
- const [aiGenerating, setAiGenerating] = useState(false);
- const [aiDraftModal, setAiDraftModal] = useState(null); // { channel, customerId, subject, body }
-
- 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);
- 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]);
-
- 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('已删除');
- }
-
- if (!loaded) {
- return (
-
- );
- }
-
- return (
-
-
-
-
-
-
- {viewTitle(view)}
-
- {mobileMenuOpen &&
setMobileMenuOpen(false)} />}
-
-
-
-
客户档案系统
-
Customer Console
-
-
- {NAV_GROUPS.map(group => (
-
-
{group.label}
- {group.items.map(item => {
- const Icon = item.icon;
- return (
-
{ setView(item.id); setMobileMenuOpen(false); }}
- >
- {item.label}
-
- );
- })}
-
- ))}
-
- {STANDALONE_NAV.map(item => {
- const Icon = item.icon;
- return (
-
{ setView(item.id); setMobileMenuOpen(false); }}
- >
- {item.label}
-
- );
- })}
-
-
-
-
-
-
-
- {view === 'monitor' && (
- <>
-
-
-
-
-
-
-
-
- {customers.length === 0 ? (
-
- ) : (
-
- {customers.map((c, idx) => {
- const s = statusOf(c, settings);
- const lt = localTimeInfo(c.country, now);
- const cardTitle = c.productLinesNote || c.company || '(未命名)';
- const showCompanyLine = !!(c.productLinesNote && c.company);
- const { showEmail, showPhone } = contactDisplayFlags(c);
- return (
-
setDragCustomerIdx(idx)}
- onDragOver={(e) => e.preventDefault()}
- onDrop={() => handleCustomerDrop(idx)}
- onDragEnd={() => setDragCustomerIdx(null)}
- >
-
-
-
-
{cardTitle}
- {c.isVip &&
}
- {c.hasOrdered &&
}
-
-
{regionLabel(c.region)} · {c.country || '—'}
- {showCompanyLine &&
{c.company}
}
- {showEmail &&
{c.email}
}
- {showPhone &&
}
- {lt &&
当地时间 {lt.time}
}
-
上次联系 {fmtDate(c.lastContact)}
-
-
-
-
-
- );
- })}
-
- )}
- >
- )}
-
- {view === 'manage' && (
-
-
-
setSearch(e.target.value)} />
-
-
-
-
-
-
- {filteredCustomers.length === 0 ? (
-
暂无客户,点击上方按钮添加第一个
- ) : filteredCustomers.map(c => {
- const s = statusOf(c, settings);
- return (
-
{ setDraft(null); setSelectedId(c.id); }}>
-
-
-
{c.isVip && }{c.hasOrdered && }{c.company || '(未命名)'}
-
{regionLabel(c.region)} · {c.country || '—'}{localTimeInfo(c.country, now) && ` · 当地 ${localTimeInfo(c.country, now).time}`}
-
-
-
- );
- })}
-
-
-
-
-
- {draft ? (
-
- ) : selected ? (
-
startEditCustomer(selected)}
- onDelete={() => deleteCustomer(selected.id)}
- onMarkContacted={() => markContacted(selected.id)}
- onEmail={() => openFollowUpEmail(selected)}
- onTogglePreferred={(channel) => togglePreferredContact(selected.id, channel)}
- onToggleVip={() => toggleVip(selected.id)}
- onToggleOrdered={() => toggleOrdered(selected.id)}
- attachmentNote={attachmentNote}
- setAttachmentNote={setAttachmentNote}
- attachmentCategory={attachmentCategory}
- setAttachmentCategory={setAttachmentCategory}
- attachmentFileName={attachmentFile ? attachmentFile.name : ''}
- onPickAttachment={(f) => setAttachmentFile(f)}
- uploading={uploading}
- fileInputKey={fileInputKey}
- onUploadAttachment={() => handleUploadAttachment(selected.id)}
- onDeleteAttachment={(fileName) => handleDeleteAttachment(selected.id, fileName)}
- fileDragActive={fileDragActive}
- onFileDragOver={handleFileDragOver}
- onFileDragLeave={handleFileDragLeave}
- onFileDrop={handleFileDrop}
- aiConfigHasKey={aiConfigHasKey}
- aiGenerating={aiGenerating}
- onGenerateAi={(channel) => handleGenerateAi(selected.id, channel)}
- />
- ) : (
-
- )}
-
-
- )}
-
- {view === 'groups' && (
- <>
-
按产品大类分组浏览,每组内的产品可以拖拽调整顺序。
- {PRODUCT_CATEGORIES.map(cat => {
- const items = products.filter(p => p.category === cat.id);
- return (
-
-
{cat.short}({items.length})
-
- {items.length === 0 ? (
-
暂无产品
- ) : items.map((p, idx) => (
-
setDragGroupState({ category: cat.id, index: idx })}
- onDragOver={(e) => e.preventDefault()}
- onDrop={() => handleGroupDrop(cat.id, idx)}
- onDragEnd={() => setDragGroupState(null)}
- >
-
-
-
{p.name || '(未命名)'}
-
{categoryBreadcrumb(p)}{p.brand ? ` · ${p.brand}` : ''}
-
-
-
- ))}
-
-
- );
- })}
- >
- )}
-
- {view === 'products' && (
-
-
-
setProductSearch(e.target.value)} />
-
-
-
-
-
-
- {filteredProducts.length === 0 ? (
-
- ) : filteredProducts.map(p => (
-
{ setProductDraft(null); setSelectedProductId(p.id); }}>
-
-
-
{p.name || '(未命名)'}
-
{categoryBreadcrumb(p)}{p.brand ? ` · ${p.brand}` : ''}
-
-
-
- ))}
-
-
-
-
-
- {productDraft ? (
-
- ) : selectedProduct ? (
-
startEditProduct(selectedProduct)} onDelete={() => deleteProduct(selectedProduct.id)} />
- ) : (
-
- )}
-
-
- )}
-
- {view === 'catalog' && (
- <>
-
拖拽卡片调整展示顺序,方便整理成给客户看的产品介绍顺序。
- {products.length === 0 ? (
-
- ) : (
-
- {products.map((p, idx) => {
- const certs = (p.certifications || '').split(',').map(s => s.trim()).filter(Boolean);
- return (
-
setDragCatalogIdx(idx)}
- onDragOver={(e) => e.preventDefault()}
- onDrop={() => handleCatalogDrop(idx)}
- onDragEnd={() => setDragCatalogIdx(null)}
- >
-
-
-
{p.name || '(未命名)'}
-
-
{categoryBreadcrumb(p)}
- {p.brand &&
品牌:{p.brand}
}
- {p.powerOrCapacity &&
功率 / 容量:{p.powerOrCapacity}
}
- {p.voltage &&
电压:{p.voltage}
}
- {certs.length > 0 &&
{certs.map(cc => {cc})}
}
-
- );
- })}
-
- )}
- >
- )}
-
- {view === 'quotes' && (
-
-
-
-
报价单制作 · 下一步开发
-
计划:选客户 + 选产品 + 填价格,生成一份报价单。
这次先把导航位置占好,具体页面下一轮再做。
-
-
- )}
-
- {view === 'settings' && (
- <>
-
-
-
-
-
-
-
-
-
- 说明:邮件功能用于生成可复制的英文邮件文案,需要你手动粘贴到自己的邮箱发送。报价单制作正在开发中,下一轮会上线。
-
-
-
-
-
-
-
-
当前登录账号:{username}
-
-
-
- 这个系统现在挂在公网上,定期更换密码、不要把密码告诉别人。
-
-
-
-
-
-
-
-
- 状态:{aiConfigHasKey ? 已配置 : 未配置}
-
-
- DeepSeek API Key
- setAiKeyInput(e.target.value)}
- placeholder={aiConfigHasKey ? '已设置,重新填写可覆盖' : '在 platform.deepseek.com 申请'}
- />
-
-
-
-
- {aiConfigHasKey && }
-
-
- 用于客户详情页的"AI生成邮件 / AI生成WhatsApp话术"功能,调用DeepSeek官方API生成。成本很低(单次生成大概几分钱以内人民币,按你自己DeepSeek账户余额实际扣费),Key只会加密保存在服务器本地,不会显示给任何人。
-
-
-
- >
- )}
-
-
-
-
-
- {aiDraftModal && (
-
setAiDraftModal(null)}>
-
e.stopPropagation()}>
-
-
{aiDraftModal.channel === 'email' ? 'AI生成邮件' : 'AI生成WhatsApp话术'}
-
-
-
- {aiDraftModal.channel === 'email' && (
-
主题 Subject setAiDraftModal(m => ({ ...m, subject: e.target.value }))} />
- )}
-
- {aiDraftModal.channel === 'email' ? '正文 Body' : '消息内容'}
-
-
-
- {aiDraftModal.channel === 'email' && }
-
-
-
-
- )}
-
- {emailModal && (
-
setEmailModal(null)}>
-
e.stopPropagation()}>
-
邮件文案
-
-
-
-
-
-
-
- )}
-
- {exportOpen && (
-
setExportOpen(false)}>
-
e.stopPropagation()}>
-
数据备份(JSON)
-
-
-
-
-
-
-
-
- )}
-
- {toast &&
{toast}
}
-
- );
-}
-
-/* ----------------------------- customer form / detail ----------------------------- */
-
-function CustomerForm({ draft, setDraft, onSave, onCancel }) {
- const set = (patch) => setDraft(d => ({ ...d, ...patch }));
-
- function handleCountryChange(value) {
- const match = COUNTRY_BY_NAME[value];
- if (match) {
- setDraft(d => ({
- ...d,
- country: value,
- region: match.region,
- // 国家代码只在电话号码当前为空时自动填入,避免覆盖已经填好的号码
- phone: d.phone && d.phone.trim() ? d.phone : match.dial + ' ',
- }));
- } else {
- set({ country: value });
- }
- }
-
- return (
-
-
{draft.company ? '编辑客户' : '新建客户'}
-
-
-
-
关注产品线 set({ productLines: v })} />
- {draft.productLines.length > 0 && (
-
- 意向型号备注
- set({ productLinesNote: e.target.value })}
- />
-
- )}
-
-
-
标签(逗号分隔) set({ tags: e.target.value })} />
-
-
-
- 箱唛 / 备注
-
-
-
备注
-
-
-
- );
-}
-
-function ContactInfo({ icon, label, value, active, onToggle }) {
- return (
-
-
- {icon}{label}{active ? '(常用)' : ''}
-
-
{value}
-
- );
-}
-
-function CollapsibleSection({ title, count, defaultOpen, children }) {
- const [open, setOpen] = useState(!!defaultOpen);
- return (
-
-
setOpen(o => !o)}
- >
- {title}{typeof count === 'number' ? `(${count})` : ''}
-
-
- {open &&
{children}
}
-
- );
-}
-
-function CustomerDetail({
- customer: c, settings, now, onEdit, onDelete, onMarkContacted, onEmail,
- onTogglePreferred, onToggleVip, onToggleOrdered,
- attachmentNote, setAttachmentNote, attachmentCategory, setAttachmentCategory,
- attachmentFileName, onPickAttachment, uploading, fileInputKey, onUploadAttachment, onDeleteAttachment,
- fileDragActive, onFileDragOver, onFileDragLeave, onFileDrop,
- aiConfigHasKey, aiGenerating, onGenerateAi,
-}) {
- const s = statusOf(c, settings);
- const lt = localTimeInfo(c.country, now);
- const preferred = c.preferredContacts || [];
- const [fileSearch, setFileSearch] = useState('');
- const [fileCategoryFilter, setFileCategoryFilter] = useState('all');
- const filteredAttachments = (c.attachments || []).filter(a => {
- if (fileCategoryFilter !== 'all' && a.category !== fileCategoryFilter) return false;
- if (fileSearch.trim()) {
- const q = fileSearch.trim().toLowerCase();
- return (a.originalName || '').toLowerCase().includes(q) || (a.note || '').toLowerCase().includes(q);
- }
- return true;
- });
- return (
-
-
-
-
- {c.company || '(未命名)'}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
} label="联系人" value={c.contact || '—'} />
-
} label="地区 / 国家" value={`${regionLabel(c.region)} · ${c.country || '—'}`} />
-
} label="邮箱" value={c.email || '—'} active={preferred.includes('email')} onToggle={() => onTogglePreferred('email')} />
-
} label="电话" value={c.phone || '—'} active={preferred.includes('phone')} onToggle={() => onTogglePreferred('phone')} />
-
} label="当地时间" value={lt ? `${lt.time}(${lt.en})` : '—'} />
-
} label="网站" value={c.website ?
{c.website} : '—'} />
-
- {c.source &&
客户来源{c.source}
}
- {c.productLines && c.productLines.length > 0 && (
-
-
关注产品线
-
{c.productLines.map(p => {p})}
- {c.productLinesNote &&
意向型号:{c.productLinesNote}
}
-
- )}
- {c.tags &&
}
-
-
- {!(c.warehouseAddress || c.freightContact || c.shippingMarkNote) ? (
- 暂无货代信息
- ) : (
- <>
-
- {c.warehouseAddress && }
- {c.freightContact && }
-
- {c.shippingMarkNote && 箱唛 / 备注:{c.shippingMarkNote}
}
- >
- )}
-
-
- {c.notes &&
}
-
-
- 上次联系:{fmtDate(c.lastContact)} | 状态:{statusLabel(s)}
-
-
-
-
-
-
-
- {!aiConfigHasKey && 未配置DeepSeek,去"设置"里填API Key}
-
-
-
-
客户文件管理
-
-
-
-
-
setAttachmentNote(e.target.value)}
- />
-
-
-
-
-
0}>
- {(!c.attachments || c.attachments.length === 0) ? (
- 暂无文件
- ) : (
- <>
-
- setFileSearch(e.target.value)}
- />
-
-
- {filteredAttachments.length === 0 ? (
- 没有匹配的文件
- ) : filteredAttachments.map((a, i) => (
-
-
-
-
{a.date}
- {' '}
{a.category || 'Other'}
-
-
-
-
- ))}
- >
- )}
-
-
-
- );
-}
-
-/* ----------------------------- product form / detail ----------------------------- */
-
-function ProductForm({ draft, setDraft, onSave, onCancel }) {
- const set = (patch) => setDraft(d => ({ ...d, ...patch }));
- const topCat = PRODUCT_CATEGORIES.find(c => c.id === draft.category);
- const subCat = topCat && topCat.subs.find(s => s.id === draft.subCategory);
- const hasSubSub = !!(subCat && subCat.subs && subCat.subs.length);
- return (
-
-
{draft.name ? '编辑产品' : '新建产品'}
-
-
-
-
- 产品大类 *
-
-
- {topCat && (
-
- 细分品类
-
-
- )}
-
- {hasSubSub && (
-
- 类型
-
-
- )}
-
-
认证(逗号分隔) set({ certifications: e.target.value })} placeholder="如:CE, FCC, UL, SASO" />
-
适用市场地区 set({ targetRegions: v })} />
-
备注 / 卖点说明
-
-
-
- );
-}
-
-function ProductDetail({ product: p, onEdit, onDelete }) {
- const certs = (p.certifications || '').split(',').map(s => s.trim()).filter(Boolean);
- return (
-
-
-
-
- } label="分类" value={categoryBreadcrumb(p)} />
- } label="品牌" value={p.brand || '—'} />
- } label="功率 / 容量" value={p.powerOrCapacity || '—'} />
- } label="电压" value={p.voltage || '—'} />
-
- {certs.length > 0 && (
-
- )}
- {p.targetRegions && p.targetRegions.length > 0 && (
-
适用市场地区{p.targetRegions.map(r => {regionLabel(r)})}
- )}
- {p.notes &&
}
-
-
- );
-}
-
-function Info({ icon, label, value }) {
- return (
-
-
{icon}{label}
-
{value}
-
- );
-}