1776 lines
94 KiB
JavaScript
1776 lines
94 KiB
JavaScript
import React, { useState, useEffect, useMemo } from 'react';
|
||
import {
|
||
Search, Plus, X, Mail, Edit2, Trash2, Check, Clock,
|
||
Settings as SettingsIcon, Copy, ChevronRight,
|
||
Building2, Globe, Phone, Save, RefreshCw, LayoutDashboard, Users,
|
||
Package, Layers, Zap, Tag, Inbox, Paperclip,
|
||
Menu, LogOut, GripVertical, BookOpen, FileText, FolderTree, Lock, Crown, Sparkles
|
||
} 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,
|
||
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 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 = () => (
|
||
<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); }
|
||
|
||
/* ---------- vip ---------- */
|
||
.vip-toggle { cursor: pointer; color: var(--line); display: inline-flex; align-items: center; }
|
||
.vip-toggle.active { color: #C9942E; }
|
||
.vip-toggle:hover { color: #C9942E; opacity: 0.8; }
|
||
`}</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>
|
||
<div style={{ fontSize: 11.5, color: 'var(--ink-soft)', marginTop: 14 }}>
|
||
首次使用?默认账号 admin / admin123,登录后请去"设置"里修改密码。
|
||
</div>
|
||
</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 }
|
||
|
||
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 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 (
|
||
<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);
|
||
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">{c.notes ? c.notes : (c.company || '(未命名)')}</div>
|
||
{c.isVip && <Crown size={14} fill="#C9942E" color="#C9942E" style={{ flexShrink: 0 }} />}
|
||
</div>
|
||
<div className="row-sub">{regionLabel(c.region)} · {c.country || '—'}</div>
|
||
{c.notes && c.company && <div className="row-sub">{c.company}</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);
|
||
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.company || '(未命名)'}</div>
|
||
<div className="row-sub">{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)}
|
||
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' && (
|
||
<div className="panel">
|
||
<div className="empty-state">
|
||
<FileText size={32} />
|
||
<div style={{ fontWeight: 700, fontSize: 14, marginTop: 8, color: 'var(--ink)' }}>报价单制作 · 下一步开发</div>
|
||
<div style={{ marginTop: 6 }}>计划:选客户 + 选产品 + 填价格,生成一份报价单。<br />这次先把导航位置占好,具体页面下一轮再做。</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{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 }, null, 2)} />
|
||
</div>
|
||
<div className="modal-foot">
|
||
<button className="btn btn-primary" onClick={() => copyText(JSON.stringify({ customers, products, settings }, null, 2))}><Copy size={12} />复制全部</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="field">
|
||
<span className="field-label">意向型号备注</span>
|
||
<input
|
||
className="input" value={draft.productLinesNote} onChange={e => set({ productLinesNote: e.target.value })}
|
||
/>
|
||
</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><input className="input" 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,
|
||
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'}`} />
|
||
{c.company || '(未命名)'}
|
||
<span className={`vip-toggle ${c.isVip ? 'active' : ''}`} onClick={onToggleVip} title={c.isVip ? '取消重点客户标记' : '标记为重点客户'}>
|
||
<Crown size={15} fill={c.isVip ? '#C9942E' : '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.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.productLinesNote && <div style={{ fontSize: 12.5, marginTop: 6, color: 'var(--ink-soft)' }}>意向型号:{c.productLinesNote}</div>}
|
||
</div>
|
||
)}
|
||
{c.tags && <div className="field"><span className="field-label">标签</span><div style={{ fontSize: 12.5 }}>{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>
|
||
);
|
||
}
|
||
|
||
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>
|
||
);
|
||
}
|