Update App.jsx via upload script - 2026-08-13 13:53:13
This commit is contained in:
parent
4b8747d72f
commit
9a66d7c333
510
App.jsx
510
App.jsx
|
|
@ -4,13 +4,13 @@ import {
|
||||||
Settings as SettingsIcon, Copy, ChevronRight,
|
Settings as SettingsIcon, Copy, ChevronRight,
|
||||||
Building2, Globe, Phone, Save, RefreshCw, LayoutDashboard, Users,
|
Building2, Globe, Phone, Save, RefreshCw, LayoutDashboard, Users,
|
||||||
Package, Layers, Zap, Tag, Inbox, Paperclip,
|
Package, Layers, Zap, Tag, Inbox, Paperclip,
|
||||||
Menu, LogOut, GripVertical, BookOpen, FileText, FolderTree, Lock, Crown, Sparkles, Printer, Image as ImageIcon,
|
Menu, LogOut, GripVertical, BookOpen, FileText, FolderTree, Lock, Sparkles, Printer, Image as ImageIcon,
|
||||||
ShieldCheck, Settings2, Boxes, Download, Wifi, Radio, QrCode
|
ShieldCheck, Settings2, Boxes, Download, Wifi, Radio, QrCode, Folder, FolderPlus
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
storageGet, storageSet, uploadFile, deleteFile,
|
storageGet, storageSet, uploadFile, deleteFile,
|
||||||
authCheck, authLogin, authLogout, authChangePassword,
|
authCheck, authLogin, authLogout, authChangePassword,
|
||||||
getAiConfig, saveAiConfig, deleteAiConfig, testAiConfig, generateAiDraft,
|
getAiConfig, saveAiConfig, deleteAiConfig, testAiConfig,
|
||||||
parseCatalogFiles, translateTerms,
|
parseCatalogFiles, translateTerms,
|
||||||
} from './storage';
|
} from './storage';
|
||||||
|
|
||||||
|
|
@ -79,7 +79,6 @@ function buildDefaultProductCategories() {
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_SETTINGS = { defaultFollowUpDays: 14, dueSoonWindow: 7 };
|
|
||||||
|
|
||||||
const CUSTOMER_SOURCES = ['Made-in-China', '官网', '展会', 'Other'];
|
const CUSTOMER_SOURCES = ['Made-in-China', '官网', '展会', 'Other'];
|
||||||
const FILE_CATEGORIES = ['报价单', '合同', '聊天记录', '产品资料', '物流单据', 'Other'];
|
const FILE_CATEGORIES = ['报价单', '合同', '聊天记录', '产品资料', '物流单据', 'Other'];
|
||||||
|
|
@ -246,7 +245,7 @@ const COUNTRIES = [
|
||||||
|
|
||||||
const COUNTRY_BY_NAME = Object.fromEntries(COUNTRIES.map(c => [c.name, c]));
|
const COUNTRY_BY_NAME = Object.fromEntries(COUNTRIES.map(c => [c.name, c]));
|
||||||
|
|
||||||
const STORAGE_KEYS = { customers: 'crm:customers', products: 'crm:products', settings: 'crm:settings', quotes: 'crm:quotes', sellerProfiles: 'crm:sellerProfiles', certifications: 'crm:certifications', productCategories: 'crm:productCategories', packingLists: 'crm:packingLists', catalogDocs: 'crm:catalogDocs', jmsLines: 'crm:jmsLines', backPanelTemplates: 'crm:backPanelTemplates' };
|
const STORAGE_KEYS = { customers: 'crm:customers', products: 'crm:products', quotes: 'crm:quotes', sellerProfiles: 'crm:sellerProfiles', certifications: 'crm:certifications', productCategories: 'crm:productCategories', packingLists: 'crm:packingLists', catalogDocs: 'crm:catalogDocs', jmsLines: 'crm:jmsLines', backPanelTemplates: 'crm:backPanelTemplates' };
|
||||||
|
|
||||||
const blankCustomer = () => ({
|
const blankCustomer = () => ({
|
||||||
id: 'c_' + Date.now() + '_' + Math.random().toString(36).slice(2, 7),
|
id: 'c_' + Date.now() + '_' + Math.random().toString(36).slice(2, 7),
|
||||||
|
|
@ -257,9 +256,10 @@ const blankCustomer = () => ({
|
||||||
website: '',
|
website: '',
|
||||||
productLines: [], intendedModel: '', intendedQty: '',
|
productLines: [], intendedModel: '', intendedQty: '',
|
||||||
source: '',
|
source: '',
|
||||||
tags: '', notes: '', lastContact: '', followUpDays: '',
|
tags: '', notes: '',
|
||||||
warehouseAddress: '', freightContact: '', shippingMarkNote: '',
|
warehouseAddress: '', freightContact: '', shippingMarkNote: '',
|
||||||
attachments: [],
|
attachments: [],
|
||||||
|
fileFolders: [], // 虚拟文件夹:{id, name, parentId},parentId为null表示根目录下的文件夹
|
||||||
});
|
});
|
||||||
|
|
||||||
const OUTPUT_PORT_TYPES = ['USB', 'DC', 'DC1', 'DC2', 'DC3', 'POE', 'Type-C', 'DC interface', 'POE interface'];
|
const OUTPUT_PORT_TYPES = ['USB', 'DC', 'DC1', 'DC2', 'DC3', 'POE', 'Type-C', 'DC interface', 'POE interface'];
|
||||||
|
|
@ -1277,23 +1277,6 @@ function todayStr() {
|
||||||
const d = new Date();
|
const d = new Date();
|
||||||
return d.toISOString().slice(0, 10);
|
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) {
|
function regionLabel(id) {
|
||||||
return (REGIONS.find(r => r.id === id) || {}).label || id;
|
return (REGIONS.find(r => r.id === id) || {}).label || id;
|
||||||
}
|
}
|
||||||
|
|
@ -1336,9 +1319,13 @@ function reorderWithinCategory(products, category, fromIndex, toIndex) {
|
||||||
let i = 0;
|
let i = 0;
|
||||||
return products.map(p => (p.category === category ? reordered[i++] : p));
|
return products.map(p => (p.category === category ? reordered[i++] : p));
|
||||||
}
|
}
|
||||||
function fmtDate(d) {
|
// VIP客户和非VIP客户在"客户档案"看板里分两组展示,拖拽排序只在各自组内生效——
|
||||||
if (!d) return '—';
|
// 取出该组成员按新顺序重排,再按原来在整个customers数组里的位置依次塞回去,不影响另一组的顺序。
|
||||||
return d;
|
function reorderCustomerGroup(customers, vipFlag, fromIndex, toIndex) {
|
||||||
|
const groupItems = customers.filter(c => !!c.isVip === vipFlag);
|
||||||
|
const reordered = reorderArray(groupItems, fromIndex, toIndex);
|
||||||
|
let i = 0;
|
||||||
|
return customers.map(c => (!!c.isVip === vipFlag ? reordered[i++] : c));
|
||||||
}
|
}
|
||||||
function categoryBreadcrumb(p, categories) {
|
function categoryBreadcrumb(p, categories) {
|
||||||
const cat = (categories || []).find(c => c.id === p.category);
|
const cat = (categories || []).find(c => c.id === p.category);
|
||||||
|
|
@ -1380,29 +1367,12 @@ function normalizeProductTaxonomy(products, categories) {
|
||||||
|
|
||||||
return { products: migratedProducts, categories: cats };
|
return { products: migratedProducts, categories: cats };
|
||||||
}
|
}
|
||||||
function generateFollowUpEmail(customer) {
|
|
||||||
const name = customer.contact || customer.company || 'there';
|
|
||||||
const product = customer.productLines && customer.productLines.length ? customer.productLines.join(', ') : 'our products';
|
|
||||||
const subject = `Following up – ${customer.company || name}`;
|
|
||||||
const body = `Dear ${name},
|
|
||||||
|
|
||||||
I hope this message finds you well. It has been a little while since we last connected, and I wanted to check in regarding ${product}.
|
|
||||||
|
|
||||||
If you have any upcoming projects or requirements, I would be happy to share our latest pricing, lead times, and technical updates. Please let me know if there is anything I can help with.
|
|
||||||
|
|
||||||
Looking forward to hearing from you.
|
|
||||||
|
|
||||||
Best regards,
|
|
||||||
[Your name]`;
|
|
||||||
return { subject, body };
|
|
||||||
}
|
|
||||||
|
|
||||||
/* -------------------------------- storage ------------------------------ */
|
/* -------------------------------- storage ------------------------------ */
|
||||||
|
|
||||||
async function loadAll() {
|
async function loadAll() {
|
||||||
let customers = [];
|
let customers = [];
|
||||||
let products = [];
|
let products = [];
|
||||||
let settings = DEFAULT_SETTINGS;
|
|
||||||
let quotes = [];
|
let quotes = [];
|
||||||
let sellerProfiles = DEFAULT_SELLER_PROFILES;
|
let sellerProfiles = DEFAULT_SELLER_PROFILES;
|
||||||
let certifications = [];
|
let certifications = [];
|
||||||
|
|
@ -1417,10 +1387,6 @@ async function loadAll() {
|
||||||
const p = await storageGet(STORAGE_KEYS.products);
|
const p = await storageGet(STORAGE_KEYS.products);
|
||||||
if (p && p.value) products = JSON.parse(p.value);
|
if (p && p.value) products = JSON.parse(p.value);
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
try {
|
|
||||||
const s = await storageGet(STORAGE_KEYS.settings);
|
|
||||||
if (s && s.value) settings = { ...DEFAULT_SETTINGS, ...JSON.parse(s.value) };
|
|
||||||
} catch (e) {}
|
|
||||||
try {
|
try {
|
||||||
const q = await storageGet(STORAGE_KEYS.quotes);
|
const q = await storageGet(STORAGE_KEYS.quotes);
|
||||||
if (q && q.value) quotes = JSON.parse(q.value);
|
if (q && q.value) quotes = JSON.parse(q.value);
|
||||||
|
|
@ -1455,7 +1421,7 @@ async function loadAll() {
|
||||||
const bpt = await storageGet(STORAGE_KEYS.backPanelTemplates);
|
const bpt = await storageGet(STORAGE_KEYS.backPanelTemplates);
|
||||||
if (bpt && bpt.value) backPanelTemplates = JSON.parse(bpt.value);
|
if (bpt && bpt.value) backPanelTemplates = JSON.parse(bpt.value);
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
return { customers, products, settings, quotes, sellerProfiles, certifications, productCategories, packingLists, catalogDocs, jmsLines, backPanelTemplates };
|
return { customers, products, quotes, sellerProfiles, certifications, productCategories, packingLists, catalogDocs, jmsLines, backPanelTemplates };
|
||||||
}
|
}
|
||||||
|
|
||||||
/* -------------------------------- styles -------------------------------- */
|
/* -------------------------------- styles -------------------------------- */
|
||||||
|
|
@ -1830,11 +1796,28 @@ const Styles = () => (
|
||||||
.doc-preview { position: absolute; left: 0; top: 0; width: 100%; max-width: none; border: none; box-shadow: none; padding: 24px 40px; }
|
.doc-preview { position: absolute; left: 0; top: 0; width: 100%; max-width: none; border: none; box-shadow: none; padding: 24px 40px; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- vip ---------- */
|
/* ---------- vip / status badges ---------- */
|
||||||
.vip-toggle { cursor: pointer; color: var(--line); display: inline-flex; align-items: center; transition: opacity 0.15s; }
|
.tiny-badge { display: inline-flex; align-items: center; font-size: 10px; font-weight: 700; padding: 2px 7px; border-radius: 100px; letter-spacing: 0.02em; flex-shrink: 0; }
|
||||||
.vip-toggle:hover { opacity: 0.65; }
|
.tiny-badge-vip { background: #FBF1DD; color: #C9942E; }
|
||||||
.vip-toggle.active { color: #C9942E; }
|
.tiny-badge-red { background: var(--led-red-bg); color: var(--led-red); }
|
||||||
.vip-toggle.active-red { color: var(--led-red); }
|
.tiny-badge-toggle { cursor: pointer; display: inline-flex; align-items: center; font-size: 11.5px; font-weight: 600; padding: 4px 11px; border-radius: 100px; border: 1.5px solid var(--line); color: var(--ink-soft); transition: all 0.15s; user-select: none; }
|
||||||
|
.tiny-badge-toggle:hover { border-color: var(--ink-soft); }
|
||||||
|
.tiny-badge-toggle.tiny-badge-vip.active { border-color: #C9942E; background: #FBF1DD; color: #C9942E; }
|
||||||
|
.tiny-badge-toggle.tiny-badge-red.active { border-color: var(--led-red); background: var(--led-red-bg); color: var(--led-red); }
|
||||||
|
|
||||||
|
/* ---------- customer file explorer (virtual folders) ---------- */
|
||||||
|
.file-breadcrumb { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; font-size: 12px; margin-bottom: 10px; padding-bottom: 8px; border-bottom: 1px solid var(--line); }
|
||||||
|
.file-breadcrumb-item { cursor: pointer; color: var(--ink-soft); padding: 2px 4px; border-radius: 4px; }
|
||||||
|
.file-breadcrumb-item:hover { background: var(--bg); color: var(--ink); }
|
||||||
|
.file-breadcrumb-item.current { color: var(--ink); font-weight: 700; cursor: default; }
|
||||||
|
.file-breadcrumb-item.current:hover { background: none; }
|
||||||
|
.folder-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); gap: 8px; margin-bottom: 14px; }
|
||||||
|
.folder-item { position: relative; border: 1.5px solid var(--line); border-radius: 10px; padding: 12px 8px 8px; text-align: center; cursor: pointer; transition: border-color 0.15s, background 0.15s; }
|
||||||
|
.folder-item:hover { border-color: var(--copper); background: var(--copper-soft); }
|
||||||
|
.folder-item-name { font-size: 12px; font-weight: 600; margin-top: 6px; word-break: break-word; line-height: 1.3; }
|
||||||
|
.folder-item-count { font-size: 10.5px; color: var(--ink-soft); margin-top: 2px; }
|
||||||
|
.folder-item-actions { display: flex; justify-content: center; gap: 4px; margin-top: 6px; opacity: 0; transition: opacity 0.15s; }
|
||||||
|
.folder-item:hover .folder-item-actions { opacity: 1; }
|
||||||
`}</style>
|
`}</style>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -1884,6 +1867,51 @@ function StatCard({ num, label, tone }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function CustomerMonitorGrid({ list, vipFlag, dragCustomerIdx, dragCustomerVip, onDragStart, onDrop, onDragEnd, now, onView }) {
|
||||||
|
if (list.length === 0) {
|
||||||
|
return <div className="panel"><div className="empty-state"><Inbox size={24} />{vipFlag ? '还没有VIP客户' : '暂无客户'}</div></div>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="card-grid">
|
||||||
|
{list.map((c, idx) => {
|
||||||
|
const lt = localTimeInfo(c.country, now);
|
||||||
|
const modelQty = [c.intendedModel, c.intendedQty ? `× ${c.intendedQty}` : ''].filter(Boolean).join(' ');
|
||||||
|
const cardTitle = customerTitle(c);
|
||||||
|
const showCompanyLine = !!(modelQty && c.company);
|
||||||
|
const { showEmail, showPhone } = contactDisplayFlags(c);
|
||||||
|
const isDragging = dragCustomerIdx === idx && dragCustomerVip === vipFlag;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={c.id}
|
||||||
|
className="monitor-card"
|
||||||
|
style={{ opacity: isDragging ? 0.5 : 1 }}
|
||||||
|
draggable
|
||||||
|
onDragStart={() => onDragStart(vipFlag, idx)}
|
||||||
|
onDragOver={(e) => e.preventDefault()}
|
||||||
|
onDrop={() => onDrop(vipFlag, idx)}
|
||||||
|
onDragEnd={onDragEnd}
|
||||||
|
>
|
||||||
|
<div className="monitor-card-head">
|
||||||
|
<GripVertical size={14} className="drag-handle" />
|
||||||
|
<div className="monitor-card-title">{cardTitle}</div>
|
||||||
|
{c.hasOrdered && <span className="tiny-badge tiny-badge-red">已下单</span>}
|
||||||
|
</div>
|
||||||
|
<div className="row-sub">{regionLabel(c.region)} · {c.country || '—'}</div>
|
||||||
|
{showCompanyLine && <div className="row-sub">{c.company}</div>}
|
||||||
|
{c.contact && <div className="row-sub">{c.contact}</div>}
|
||||||
|
{showEmail && <div className="row-sub" style={{ display: 'flex', alignItems: 'center', gap: 4 }}><Mail size={11} />{c.email}</div>}
|
||||||
|
{showPhone && <div className="row-sub" style={{ display: 'flex', alignItems: 'center', gap: 4 }}><Phone size={11} />{c.phone}</div>}
|
||||||
|
{lt && <div className="row-sub">当地时间 {lt.time}</div>}
|
||||||
|
<div style={{ display: 'flex', gap: 6, marginTop: 4 }}>
|
||||||
|
<button className="btn btn-sm" style={{ flex: 1, justifyContent: 'center' }} onClick={() => onView(c.id)}>查看</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function ProductPills({ value, onChange }) {
|
function ProductPills({ value, onChange }) {
|
||||||
const toggle = (p) => {
|
const toggle = (p) => {
|
||||||
const has = value.includes(p);
|
const has = value.includes(p);
|
||||||
|
|
@ -1922,7 +1950,7 @@ function RegionPills({ value, onChange }) {
|
||||||
|
|
||||||
const NAV_GROUPS = [
|
const NAV_GROUPS = [
|
||||||
{ label: '客户', items: [
|
{ label: '客户', items: [
|
||||||
{ id: 'monitor', label: '客户监控', icon: LayoutDashboard },
|
{ id: 'monitor', label: '客户档案', icon: LayoutDashboard },
|
||||||
{ id: 'manage', label: '客户管理', icon: Users },
|
{ id: 'manage', label: '客户管理', icon: Users },
|
||||||
{ id: 'jms', label: 'Just My Socks', icon: Wifi },
|
{ id: 'jms', label: 'Just My Socks', icon: Wifi },
|
||||||
] },
|
] },
|
||||||
|
|
@ -2033,7 +2061,6 @@ function MainApp({ username, onLogout }) {
|
||||||
const [loaded, setLoaded] = useState(false);
|
const [loaded, setLoaded] = useState(false);
|
||||||
const [customers, setCustomers] = useState([]);
|
const [customers, setCustomers] = useState([]);
|
||||||
const [products, setProducts] = useState([]);
|
const [products, setProducts] = useState([]);
|
||||||
const [settings, setSettings] = useState(DEFAULT_SETTINGS);
|
|
||||||
const [view, setView] = useState('monitor');
|
const [view, setView] = useState('monitor');
|
||||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||||
|
|
||||||
|
|
@ -2061,12 +2088,12 @@ function MainApp({ username, onLogout }) {
|
||||||
const [aiImportHintType, setAiImportHintType] = useState('');
|
const [aiImportHintType, setAiImportHintType] = useState('');
|
||||||
const [aiImportTruncatedWarning, setAiImportTruncatedWarning] = useState(false);
|
const [aiImportTruncatedWarning, setAiImportTruncatedWarning] = useState(false);
|
||||||
|
|
||||||
const [emailModal, setEmailModal] = useState(null);
|
|
||||||
const [exportOpen, setExportOpen] = useState(false);
|
const [exportOpen, setExportOpen] = useState(false);
|
||||||
const [toast, setToast] = useState('');
|
const [toast, setToast] = useState('');
|
||||||
const [now, setNow] = useState(() => new Date());
|
const [now, setNow] = useState(() => new Date());
|
||||||
|
|
||||||
const [dragCustomerIdx, setDragCustomerIdx] = useState(null);
|
const [dragCustomerIdx, setDragCustomerIdx] = useState(null);
|
||||||
|
const [dragCustomerVip, setDragCustomerVip] = useState(false); // 正在拖拽的卡片属于VIP组还是普通组
|
||||||
const [dragGroupState, setDragGroupState] = useState(null); // { category, index }
|
const [dragGroupState, setDragGroupState] = useState(null); // { category, index }
|
||||||
|
|
||||||
const [pwCurrent, setPwCurrent] = useState('');
|
const [pwCurrent, setPwCurrent] = useState('');
|
||||||
|
|
@ -2077,8 +2104,6 @@ function MainApp({ username, onLogout }) {
|
||||||
const [aiKeyInput, setAiKeyInput] = useState('');
|
const [aiKeyInput, setAiKeyInput] = useState('');
|
||||||
const [aiSaving, setAiSaving] = useState(false);
|
const [aiSaving, setAiSaving] = useState(false);
|
||||||
const [aiTesting, setAiTesting] = useState(false);
|
const [aiTesting, setAiTesting] = useState(false);
|
||||||
const [aiGenerating, setAiGenerating] = useState(false);
|
|
||||||
const [aiDraftModal, setAiDraftModal] = useState(null); // { channel, customerId, subject, body }
|
|
||||||
|
|
||||||
const [quotes, setQuotes] = useState([]);
|
const [quotes, setQuotes] = useState([]);
|
||||||
const [sellerProfiles, setSellerProfiles] = useState(DEFAULT_SELLER_PROFILES);
|
const [sellerProfiles, setSellerProfiles] = useState(DEFAULT_SELLER_PROFILES);
|
||||||
|
|
@ -2125,7 +2150,6 @@ function MainApp({ username, onLogout }) {
|
||||||
setCustomers(data.customers);
|
setCustomers(data.customers);
|
||||||
setProducts(normalized.products);
|
setProducts(normalized.products);
|
||||||
setProductCategories(normalized.categories);
|
setProductCategories(normalized.categories);
|
||||||
setSettings(data.settings);
|
|
||||||
setQuotes(data.quotes);
|
setQuotes(data.quotes);
|
||||||
setSellerProfiles(data.sellerProfiles);
|
setSellerProfiles(data.sellerProfiles);
|
||||||
setCertifications(data.certifications);
|
setCertifications(data.certifications);
|
||||||
|
|
@ -2138,7 +2162,6 @@ function MainApp({ username, onLogout }) {
|
||||||
}, []);
|
}, []);
|
||||||
useEffect(() => { if (loaded) storageSet(STORAGE_KEYS.customers, JSON.stringify(customers)).catch(() => {}); }, [customers, loaded]);
|
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.products, JSON.stringify(products)).catch(() => {}); }, [products, loaded]);
|
||||||
useEffect(() => { if (loaded) storageSet(STORAGE_KEYS.settings, JSON.stringify(settings)).catch(() => {}); }, [settings, loaded]);
|
|
||||||
useEffect(() => { if (loaded) storageSet(STORAGE_KEYS.quotes, JSON.stringify(quotes)).catch(() => {}); }, [quotes, loaded]);
|
useEffect(() => { if (loaded) storageSet(STORAGE_KEYS.quotes, JSON.stringify(quotes)).catch(() => {}); }, [quotes, loaded]);
|
||||||
useEffect(() => { if (loaded) storageSet(STORAGE_KEYS.sellerProfiles, JSON.stringify(sellerProfiles)).catch(() => {}); }, [sellerProfiles, loaded]);
|
useEffect(() => { if (loaded) storageSet(STORAGE_KEYS.sellerProfiles, JSON.stringify(sellerProfiles)).catch(() => {}); }, [sellerProfiles, loaded]);
|
||||||
useEffect(() => { if (loaded) storageSet(STORAGE_KEYS.certifications, JSON.stringify(certifications)).catch(() => {}); }, [certifications, loaded]);
|
useEffect(() => { if (loaded) storageSet(STORAGE_KEYS.certifications, JSON.stringify(certifications)).catch(() => {}); }, [certifications, loaded]);
|
||||||
|
|
@ -2187,9 +2210,11 @@ function MainApp({ username, onLogout }) {
|
||||||
(c.country || '').toLowerCase().includes(q)
|
(c.country || '').toLowerCase().includes(q)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const order = { overdue: 0, due: 1, ok: 2 };
|
return [...list].sort((a, b) => (b.isVip ? 1 : 0) - (a.isVip ? 1 : 0) || (a.company || '').localeCompare(b.company || ''));
|
||||||
return [...list].sort((a, b) => order[statusOf(a, settings)] - order[statusOf(b, settings)] || (a.company || '').localeCompare(b.company || ''));
|
}, [customers, search, regionFilter]);
|
||||||
}, [customers, search, regionFilter, settings]);
|
|
||||||
|
const vipCustomers = useMemo(() => customers.filter(c => c.isVip), [customers]);
|
||||||
|
const normalCustomers = useMemo(() => customers.filter(c => !c.isVip), [customers]);
|
||||||
|
|
||||||
const filteredProducts = useMemo(() => {
|
const filteredProducts = useMemo(() => {
|
||||||
let list = products;
|
let list = products;
|
||||||
|
|
@ -2206,11 +2231,9 @@ function MainApp({ username, onLogout }) {
|
||||||
}, [products, productSearch, productCategoryFilter, productCategories]);
|
}, [products, productSearch, productCategoryFilter, productCategories]);
|
||||||
|
|
||||||
const counts = useMemo(() => ({
|
const counts = useMemo(() => ({
|
||||||
overdue: customers.filter(c => statusOf(c, settings) === 'overdue').length,
|
|
||||||
due: customers.filter(c => statusOf(c, settings) === 'due').length,
|
|
||||||
total: customers.length,
|
total: customers.length,
|
||||||
products: products.length,
|
products: products.length,
|
||||||
}), [customers, settings, products]);
|
}), [customers, products]);
|
||||||
|
|
||||||
const priceHistory = useMemo(() => buildPriceHistory(quotes), [quotes]);
|
const priceHistory = useMemo(() => buildPriceHistory(quotes), [quotes]);
|
||||||
|
|
||||||
|
|
@ -2219,9 +2242,9 @@ function MainApp({ username, onLogout }) {
|
||||||
return catalogDocs.filter(d => d.uploading || d.category === catalogCategoryFilter);
|
return catalogDocs.filter(d => d.uploading || d.category === catalogCategoryFilter);
|
||||||
}, [catalogDocs, catalogCategoryFilter]);
|
}, [catalogDocs, catalogCategoryFilter]);
|
||||||
|
|
||||||
function handleCustomerDrop(dropIdx) {
|
function handleCustomerDrop(vipFlag, dropIdx) {
|
||||||
if (dragCustomerIdx === null || dragCustomerIdx === dropIdx) { setDragCustomerIdx(null); return; }
|
if (dragCustomerIdx === null || (dragCustomerIdx === dropIdx && dragCustomerVip === vipFlag)) { setDragCustomerIdx(null); return; }
|
||||||
setCustomers(prev => reorderArray(prev, dragCustomerIdx, dropIdx));
|
setCustomers(prev => reorderCustomerGroup(prev, vipFlag, dragCustomerIdx, dropIdx));
|
||||||
setDragCustomerIdx(null);
|
setDragCustomerIdx(null);
|
||||||
}
|
}
|
||||||
async function handleAddCatalogFile(file) {
|
async function handleAddCatalogFile(file) {
|
||||||
|
|
@ -2367,18 +2390,6 @@ function MainApp({ username, onLogout }) {
|
||||||
setAiConfigHasKey(false);
|
setAiConfigHasKey(false);
|
||||||
notify('已清除');
|
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 startNewCustomer() { setDraft(blankCustomer()); setSelectedId(null); }
|
||||||
function startEditCustomer(c) { setDraft({ ...c, productLines: [...(c.productLines || [])] }); setSelectedId(c.id); }
|
function startEditCustomer(c) { setDraft({ ...c, productLines: [...(c.productLines || [])] }); setSelectedId(c.id); }
|
||||||
|
|
@ -2398,10 +2409,6 @@ function MainApp({ username, onLogout }) {
|
||||||
if (selectedId === id) setSelectedId(null);
|
if (selectedId === id) setSelectedId(null);
|
||||||
notify('已删除');
|
notify('已删除');
|
||||||
}
|
}
|
||||||
function markContacted(id) {
|
|
||||||
setCustomers(prev => prev.map(c => (c.id === id ? { ...c, lastContact: todayStr() } : c)));
|
|
||||||
notify('已标记为今日已联系');
|
|
||||||
}
|
|
||||||
function togglePreferredContact(id, channel) {
|
function togglePreferredContact(id, channel) {
|
||||||
setCustomers(prev => prev.map(c => {
|
setCustomers(prev => prev.map(c => {
|
||||||
if (c.id !== id) return c;
|
if (c.id !== id) return c;
|
||||||
|
|
@ -2423,7 +2430,7 @@ function MainApp({ username, onLogout }) {
|
||||||
const f = e.dataTransfer.files && e.dataTransfer.files[0];
|
const f = e.dataTransfer.files && e.dataTransfer.files[0];
|
||||||
if (f) setAttachmentFile(f);
|
if (f) setAttachmentFile(f);
|
||||||
}
|
}
|
||||||
async function handleUploadAttachment(customerId) {
|
async function handleUploadAttachment(customerId, folderId) {
|
||||||
if (!attachmentFile) { notify('请先选择文件'); return; }
|
if (!attachmentFile) { notify('请先选择文件'); return; }
|
||||||
setUploading(true);
|
setUploading(true);
|
||||||
try {
|
try {
|
||||||
|
|
@ -2436,6 +2443,7 @@ function MainApp({ username, onLogout }) {
|
||||||
category: attachmentCategory,
|
category: attachmentCategory,
|
||||||
note: attachmentNote.trim(),
|
note: attachmentNote.trim(),
|
||||||
date: todayStr(),
|
date: todayStr(),
|
||||||
|
folderId: folderId || null,
|
||||||
};
|
};
|
||||||
return { ...c, attachments: [entry, ...(c.attachments || [])] };
|
return { ...c, attachments: [entry, ...(c.attachments || [])] };
|
||||||
}));
|
}));
|
||||||
|
|
@ -2461,7 +2469,42 @@ function MainApp({ username, onLogout }) {
|
||||||
c.id === customerId ? { ...c, attachments: (c.attachments || []).filter(a => a.fileName !== fileName) } : c
|
c.id === customerId ? { ...c, attachments: (c.attachments || []).filter(a => a.fileName !== fileName) } : c
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
function openFollowUpEmail(c) { setEmailModal({ ...generateFollowUpEmail(c), customerId: c.id }); }
|
function moveAttachmentToFolder(customerId, fileName, folderId) {
|
||||||
|
setCustomers(prev => prev.map(c => (
|
||||||
|
c.id === customerId
|
||||||
|
? { ...c, attachments: (c.attachments || []).map(a => (a.fileName === fileName ? { ...a, folderId: folderId || null } : a)) }
|
||||||
|
: c
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
// 虚拟文件夹:不改变服务器上的实际文件存储位置,只是给客户记录挂一份文件夹树
|
||||||
|
// {id, name, parentId},附件通过folderId关联到某个文件夹(null=根目录),前端做成
|
||||||
|
// 资源管理器的样子——新建/重命名/删除文件夹、点进去浏览、面包屑导航。
|
||||||
|
function createFolder(customerId, parentId, name) {
|
||||||
|
const trimmed = (name || '').trim();
|
||||||
|
if (!trimmed) return;
|
||||||
|
const folder = { id: 'folder_' + Date.now() + '_' + Math.random().toString(36).slice(2, 7), name: trimmed, parentId: parentId || null };
|
||||||
|
setCustomers(prev => prev.map(c => (
|
||||||
|
c.id === customerId ? { ...c, fileFolders: [...(c.fileFolders || []), folder] } : c
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
function renameFolder(customerId, folderId, name) {
|
||||||
|
const trimmed = (name || '').trim();
|
||||||
|
if (!trimmed) return;
|
||||||
|
setCustomers(prev => prev.map(c => (
|
||||||
|
c.id === customerId ? { ...c, fileFolders: (c.fileFolders || []).map(f => (f.id === folderId ? { ...f, name: trimmed } : f)) } : c
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
function deleteFolder(customerId, folderId) {
|
||||||
|
const customer = customers.find(c => c.id === customerId);
|
||||||
|
if (!customer) return;
|
||||||
|
const hasSubfolder = (customer.fileFolders || []).some(f => f.parentId === folderId);
|
||||||
|
const hasFile = (customer.attachments || []).some(a => a.folderId === folderId);
|
||||||
|
if (hasSubfolder || hasFile) { notify('文件夹不是空的,先移走或删除里面的文件/子文件夹'); return; }
|
||||||
|
if (!window.confirm('确定删除这个文件夹?')) return;
|
||||||
|
setCustomers(prev => prev.map(c => (
|
||||||
|
c.id === customerId ? { ...c, fileFolders: (c.fileFolders || []).filter(f => f.id !== folderId) } : c
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
function startNewProduct() { setProductDraft(blankProduct()); setSelectedProductId(null); }
|
function startNewProduct() { setProductDraft(blankProduct()); setSelectedProductId(null); }
|
||||||
function startEditProduct(p) { setProductDraft({ ...p, extraNames: [] }); setSelectedProductId(p.id); }
|
function startEditProduct(p) { setProductDraft({ ...p, extraNames: [] }); setSelectedProductId(p.id); }
|
||||||
|
|
@ -3111,61 +3154,42 @@ function MainApp({ username, onLogout }) {
|
||||||
{view === 'monitor' && (
|
{view === 'monitor' && (
|
||||||
<>
|
<>
|
||||||
<div className="stat-row">
|
<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.total} label="客户总数" tone="green" />
|
||||||
|
<StatCard num={vipCustomers.length} label="VIP客户" tone="amber" />
|
||||||
<StatCard num={counts.products} label="产品档案数量" tone="blue" />
|
<StatCard num={counts.products} label="产品档案数量" tone="blue" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 10 }}>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 10 }}>
|
||||||
<div className="panel-title">全部客户</div>
|
<div className="panel-title">VIP客户</div>
|
||||||
<span style={{ fontSize: 11.5, color: 'var(--ink-soft)' }}>拖动卡片可调整顺序</span>
|
<span style={{ fontSize: 11.5, color: 'var(--ink-soft)' }}>拖动卡片可调整组内顺序</span>
|
||||||
</div>
|
</div>
|
||||||
{customers.length === 0 ? (
|
<CustomerMonitorGrid
|
||||||
<div className="panel"><div className="empty-state"><Inbox size={28} />暂无客户,去"客户管理"添加第一个</div></div>
|
list={vipCustomers}
|
||||||
) : (
|
vipFlag={true}
|
||||||
<div className="card-grid">
|
dragCustomerIdx={dragCustomerIdx}
|
||||||
{customers.map((c, idx) => {
|
dragCustomerVip={dragCustomerVip}
|
||||||
const s = statusOf(c, settings);
|
onDragStart={(vipFlag, idx) => { setDragCustomerIdx(idx); setDragCustomerVip(vipFlag); }}
|
||||||
const lt = localTimeInfo(c.country, now);
|
onDrop={handleCustomerDrop}
|
||||||
const modelQty = [c.intendedModel, c.intendedQty ? `× ${c.intendedQty}` : ''].filter(Boolean).join(' ');
|
onDragEnd={() => setDragCustomerIdx(null)}
|
||||||
const cardTitle = customerTitle(c);
|
now={now}
|
||||||
const showCompanyLine = !!(modelQty && c.company);
|
onView={(id) => { setView('manage'); setDraft(null); setSelectedId(id); }}
|
||||||
const { showEmail, showPhone } = contactDisplayFlags(c);
|
/>
|
||||||
return (
|
|
||||||
<div
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', margin: '22px 0 10px' }}>
|
||||||
key={c.id}
|
<div className="panel-title">全部客户</div>
|
||||||
className="monitor-card"
|
<span style={{ fontSize: 11.5, color: 'var(--ink-soft)' }}>拖动卡片可调整组内顺序</span>
|
||||||
style={{ opacity: dragCustomerIdx === idx ? 0.5 : 1 }}
|
</div>
|
||||||
draggable
|
<CustomerMonitorGrid
|
||||||
onDragStart={() => setDragCustomerIdx(idx)}
|
list={normalCustomers}
|
||||||
onDragOver={(e) => e.preventDefault()}
|
vipFlag={false}
|
||||||
onDrop={() => handleCustomerDrop(idx)}
|
dragCustomerIdx={dragCustomerIdx}
|
||||||
onDragEnd={() => setDragCustomerIdx(null)}
|
dragCustomerVip={dragCustomerVip}
|
||||||
>
|
onDragStart={(vipFlag, idx) => { setDragCustomerIdx(idx); setDragCustomerVip(vipFlag); }}
|
||||||
<div className="monitor-card-head">
|
onDrop={handleCustomerDrop}
|
||||||
<GripVertical size={14} className="drag-handle" />
|
onDragEnd={() => setDragCustomerIdx(null)}
|
||||||
<div className={`led-dot ${s === 'overdue' ? 'red' : s === 'due' ? 'amber' : 'green'}`} />
|
now={now}
|
||||||
<div className="monitor-card-title">{cardTitle}</div>
|
onView={(id) => { setView('manage'); setDraft(null); setSelectedId(id); }}
|
||||||
{c.isVip && <Crown size={14} fill="#C9942E" color="#C9942E" style={{ flexShrink: 0 }} />}
|
/>
|
||||||
{c.hasOrdered && <Crown size={14} fill="#C2403A" color="#C2403A" style={{ flexShrink: 0 }} />}
|
|
||||||
</div>
|
|
||||||
<div className="row-sub">{regionLabel(c.region)} · {c.country || '—'}</div>
|
|
||||||
{showCompanyLine && <div className="row-sub">{c.company}</div>}
|
|
||||||
{c.contact && <div className="row-sub">{c.contact}</div>}
|
|
||||||
{showEmail && <div className="row-sub" style={{ display: 'flex', alignItems: 'center', gap: 4 }}><Mail size={11} />{c.email}</div>}
|
|
||||||
{showPhone && <div className="row-sub" style={{ display: 'flex', alignItems: 'center', gap: 4 }}><Phone size={11} />{c.phone}</div>}
|
|
||||||
{lt && <div className="row-sub">当地时间 {lt.time}</div>}
|
|
||||||
<div className="row-sub">上次联系 {fmtDate(c.lastContact)}</div>
|
|
||||||
<div style={{ display: 'flex', gap: 6, marginTop: 4 }}>
|
|
||||||
<button className="btn btn-sm" style={{ flex: 1, justifyContent: 'center' }} onClick={() => { setView('manage'); setDraft(null); setSelectedId(c.id); }}>查看</button>
|
|
||||||
<button className="btn btn-sm" title="标记今日已联系" onClick={() => markContacted(c.id)}><Check size={12} /></button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -3185,13 +3209,15 @@ function MainApp({ username, onLogout }) {
|
||||||
{filteredCustomers.length === 0 ? (
|
{filteredCustomers.length === 0 ? (
|
||||||
<div className="empty-state"><Inbox size={26} />暂无客户,点击上方按钮添加第一个</div>
|
<div className="empty-state"><Inbox size={26} />暂无客户,点击上方按钮添加第一个</div>
|
||||||
) : filteredCustomers.map(c => {
|
) : filteredCustomers.map(c => {
|
||||||
const s = statusOf(c, settings);
|
|
||||||
const showCompanyInSub = customerTitleIsModel(c) && c.company;
|
const showCompanyInSub = customerTitleIsModel(c) && c.company;
|
||||||
return (
|
return (
|
||||||
<div key={c.id} className="row" style={{ cursor: 'pointer', background: selectedId === c.id ? '#FAFBFC' : undefined }} onClick={() => { setDraft(null); setSelectedId(c.id); }}>
|
<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-main">
|
||||||
<div className="row-title">{c.isVip && <Crown size={12} fill="#C9942E" color="#C9942E" style={{ marginRight: 4, verticalAlign: -1 }} />}{c.hasOrdered && <Crown size={12} fill="#C2403A" color="#C2403A" style={{ marginRight: 4, verticalAlign: -1 }} />}{customerTitle(c)}</div>
|
<div className="row-title">
|
||||||
|
{c.isVip && <span className="tiny-badge tiny-badge-vip" style={{ marginRight: 5 }}>VIP</span>}
|
||||||
|
{c.hasOrdered && <span className="tiny-badge tiny-badge-red" style={{ marginRight: 5 }}>已下单</span>}
|
||||||
|
{customerTitle(c)}
|
||||||
|
</div>
|
||||||
<div className="row-sub">{showCompanyInSub && `${c.company} · `}{regionLabel(c.region)} · {c.country || '—'}{localTimeInfo(c.country, now) && ` · 当地 ${localTimeInfo(c.country, now).time}`}</div>
|
<div className="row-sub">{showCompanyInSub && `${c.company} · `}{regionLabel(c.region)} · {c.country || '—'}{localTimeInfo(c.country, now) && ` · 当地 ${localTimeInfo(c.country, now).time}`}</div>
|
||||||
</div>
|
</div>
|
||||||
<ChevronRight size={15} color="var(--ink-soft)" />
|
<ChevronRight size={15} color="var(--ink-soft)" />
|
||||||
|
|
@ -3208,12 +3234,9 @@ function MainApp({ username, onLogout }) {
|
||||||
) : selected ? (
|
) : selected ? (
|
||||||
<CustomerDetail
|
<CustomerDetail
|
||||||
customer={selected}
|
customer={selected}
|
||||||
settings={settings}
|
|
||||||
now={now}
|
now={now}
|
||||||
onEdit={() => startEditCustomer(selected)}
|
onEdit={() => startEditCustomer(selected)}
|
||||||
onDelete={() => deleteCustomer(selected.id)}
|
onDelete={() => deleteCustomer(selected.id)}
|
||||||
onMarkContacted={() => markContacted(selected.id)}
|
|
||||||
onEmail={() => openFollowUpEmail(selected)}
|
|
||||||
onTogglePreferred={(channel) => togglePreferredContact(selected.id, channel)}
|
onTogglePreferred={(channel) => togglePreferredContact(selected.id, channel)}
|
||||||
onToggleVip={() => toggleVip(selected.id)}
|
onToggleVip={() => toggleVip(selected.id)}
|
||||||
onToggleOrdered={() => toggleOrdered(selected.id)}
|
onToggleOrdered={() => toggleOrdered(selected.id)}
|
||||||
|
|
@ -3225,15 +3248,16 @@ function MainApp({ username, onLogout }) {
|
||||||
onPickAttachment={(f) => setAttachmentFile(f)}
|
onPickAttachment={(f) => setAttachmentFile(f)}
|
||||||
uploading={uploading}
|
uploading={uploading}
|
||||||
fileInputKey={fileInputKey}
|
fileInputKey={fileInputKey}
|
||||||
onUploadAttachment={() => handleUploadAttachment(selected.id)}
|
onUploadAttachment={(folderId) => handleUploadAttachment(selected.id, folderId)}
|
||||||
onDeleteAttachment={(fileName) => handleDeleteAttachment(selected.id, fileName)}
|
onDeleteAttachment={(fileName) => handleDeleteAttachment(selected.id, fileName)}
|
||||||
|
onMoveAttachment={(fileName, folderId) => moveAttachmentToFolder(selected.id, fileName, folderId)}
|
||||||
|
onCreateFolder={(parentId, name) => createFolder(selected.id, parentId, name)}
|
||||||
|
onRenameFolder={(folderId, name) => renameFolder(selected.id, folderId, name)}
|
||||||
|
onDeleteFolder={(folderId) => deleteFolder(selected.id, folderId)}
|
||||||
fileDragActive={fileDragActive}
|
fileDragActive={fileDragActive}
|
||||||
onFileDragOver={handleFileDragOver}
|
onFileDragOver={handleFileDragOver}
|
||||||
onFileDragLeave={handleFileDragLeave}
|
onFileDragLeave={handleFileDragLeave}
|
||||||
onFileDrop={handleFileDrop}
|
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 className="panel"><div className="empty-state"><Users size={28} />选择左侧客户查看详情,或新建一个客户档案</div></div>
|
||||||
|
|
@ -3665,29 +3689,19 @@ function MainApp({ username, onLogout }) {
|
||||||
{view === 'settings' && (
|
{view === 'settings' && (
|
||||||
<>
|
<>
|
||||||
<div className="panel section-gap">
|
<div className="panel section-gap">
|
||||||
<div className="panel-header"><div className="panel-title">跟进与提醒设置</div></div>
|
<div className="panel-header"><div className="panel-title">数据管理</div></div>
|
||||||
<div className="panel-body" style={{ padding: 18 }}>
|
<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 }}>
|
<div style={{ display: 'flex', gap: 10, marginTop: 6 }}>
|
||||||
<button className="btn" onClick={() => setExportOpen(true)}><Save size={13} />导出数据备份</button>
|
<button className="btn" onClick={() => setExportOpen(true)}><Save size={13} />导出数据备份</button>
|
||||||
<button className="btn btn-danger" onClick={() => {
|
<button className="btn btn-danger" onClick={() => {
|
||||||
if (window.confirm('将清空全部客户与产品档案数据,且不可恢复,确定继续?')) {
|
if (window.confirm('将清空全部客户与产品档案数据,且不可恢复,确定继续?')) {
|
||||||
setCustomers([]); setProducts([]); setSettings(DEFAULT_SETTINGS);
|
setCustomers([]); setProducts([]);
|
||||||
notify('已清空');
|
notify('已清空');
|
||||||
}
|
}
|
||||||
}}><RefreshCw size={13} />清空所有数据</button>
|
}}><RefreshCw size={13} />清空所有数据</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="disclaimer" style={{ marginTop: 18 }}>
|
<div className="disclaimer" style={{ marginTop: 18 }}>
|
||||||
说明:邮件功能用于<b>生成可复制的英文邮件文案</b>,需要你手动粘贴到自己的邮箱发送。报价单制作正在开发中,下一轮会上线。
|
说明:报价单制作正在开发中,下一轮会上线。
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -3726,7 +3740,7 @@ function MainApp({ username, onLogout }) {
|
||||||
{aiConfigHasKey && <button className="btn btn-danger" onClick={handleClearAiKey}>清除</button>}
|
{aiConfigHasKey && <button className="btn btn-danger" onClick={handleClearAiKey}>清除</button>}
|
||||||
</div>
|
</div>
|
||||||
<div className="disclaimer" style={{ marginTop: 14 }}>
|
<div className="disclaimer" style={{ marginTop: 14 }}>
|
||||||
用于客户详情页的"AI生成邮件 / AI生成WhatsApp话术"功能,调用DeepSeek官方API生成。成本很低(单次生成大概几分钱以内人民币,按你自己DeepSeek账户余额实际扣费),Key只会加密保存在服务器本地,不会显示给任何人。
|
用于"产品管理"里的<b>产品目录批量导入</b>功能,调用DeepSeek官方API识别目录文档生成产品字段。成本很低(单次生成大概几分钱以内人民币,按你自己DeepSeek账户余额实际扣费),Key只会加密保存在服务器本地,不会显示给任何人。
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -3738,58 +3752,15 @@ function MainApp({ username, onLogout }) {
|
||||||
</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 && (
|
{exportOpen && (
|
||||||
<div className="modal-overlay" onClick={() => setExportOpen(false)}>
|
<div className="modal-overlay" onClick={() => setExportOpen(false)}>
|
||||||
<div className="modal-box" onClick={e => e.stopPropagation()}>
|
<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-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">
|
<div className="modal-body">
|
||||||
<textarea className="textarea" rows={14} readOnly value={JSON.stringify({ customers, products, settings, quotes, sellerProfiles, certifications, productCategories, packingLists, catalogDocs }, null, 2)} />
|
<textarea className="textarea" rows={14} readOnly value={JSON.stringify({ customers, products, quotes, sellerProfiles, certifications, productCategories, packingLists, catalogDocs }, null, 2)} />
|
||||||
</div>
|
</div>
|
||||||
<div className="modal-foot">
|
<div className="modal-foot">
|
||||||
<button className="btn btn-primary" onClick={() => copyText(JSON.stringify({ customers, products, settings, quotes, sellerProfiles, certifications, productCategories, packingLists, catalogDocs }, null, 2))}><Copy size={12} />复制全部</button>
|
<button className="btn btn-primary" onClick={() => copyText(JSON.stringify({ customers, products, quotes, sellerProfiles, certifications, productCategories, packingLists, catalogDocs }, null, 2))}><Copy size={12} />复制全部</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -4087,10 +4058,6 @@ function CustomerForm({ draft, setDraft, onSave, onCancel }) {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="grid-2">
|
|
||||||
<div className="field"><span className="field-label">上次联系日期</span><input type="date" className="input" value={draft.lastContact} onChange={e => set({ lastContact: e.target.value })} /></div>
|
|
||||||
<div className="field"><span className="field-label">跟进周期(天,留空用默认值)</span><input type="number" min="1" className="input" value={draft.followUpDays} onChange={e => set({ followUpDays: e.target.value })} /></div>
|
|
||||||
</div>
|
|
||||||
<div className="field"><span className="field-label">备注</span><textarea className="textarea" style={{ fontFamily: 'inherit', fontSize: 13 }} value={draft.tags} onChange={e => set({ tags: e.target.value })} /></div>
|
<div className="field"><span className="field-label">备注</span><textarea className="textarea" style={{ fontFamily: 'inherit', fontSize: 13 }} value={draft.tags} onChange={e => set({ tags: e.target.value })} /></div>
|
||||||
|
|
||||||
<CollapsibleSection title="货代信息" defaultOpen={!!(draft.warehouseAddress || draft.freightContact || draft.shippingMarkNote)}>
|
<CollapsibleSection title="货代信息" defaultOpen={!!(draft.warehouseAddress || draft.freightContact || draft.shippingMarkNote)}>
|
||||||
|
|
@ -4140,20 +4107,43 @@ function CollapsibleSection({ title, count, defaultOpen, children }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 根据parentId链路拼出文件夹全路径,比如"合同/2026年/一季度",用于"移动到"下拉框里
|
||||||
|
// 让人分得清嵌套很深的文件夹到底在哪
|
||||||
|
function folderPathLabel(folders, folderId) {
|
||||||
|
const byId = Object.fromEntries(folders.map(f => [f.id, f]));
|
||||||
|
const parts = [];
|
||||||
|
let cur = byId[folderId];
|
||||||
|
while (cur) {
|
||||||
|
parts.unshift(cur.name);
|
||||||
|
cur = cur.parentId ? byId[cur.parentId] : null;
|
||||||
|
}
|
||||||
|
return parts.join(' / ');
|
||||||
|
}
|
||||||
|
|
||||||
function CustomerDetail({
|
function CustomerDetail({
|
||||||
customer: c, settings, now, onEdit, onDelete, onMarkContacted, onEmail,
|
customer: c, now, onEdit, onDelete,
|
||||||
onTogglePreferred, onToggleVip, onToggleOrdered,
|
onTogglePreferred, onToggleVip, onToggleOrdered,
|
||||||
attachmentNote, setAttachmentNote, attachmentCategory, setAttachmentCategory,
|
attachmentNote, setAttachmentNote, attachmentCategory, setAttachmentCategory,
|
||||||
attachmentFileName, onPickAttachment, uploading, fileInputKey, onUploadAttachment, onDeleteAttachment,
|
attachmentFileName, onPickAttachment, uploading, fileInputKey, onUploadAttachment, onDeleteAttachment, onMoveAttachment,
|
||||||
|
onCreateFolder, onRenameFolder, onDeleteFolder,
|
||||||
fileDragActive, onFileDragOver, onFileDragLeave, onFileDrop,
|
fileDragActive, onFileDragOver, onFileDragLeave, onFileDrop,
|
||||||
aiConfigHasKey, aiGenerating, onGenerateAi,
|
|
||||||
}) {
|
}) {
|
||||||
const s = statusOf(c, settings);
|
|
||||||
const lt = localTimeInfo(c.country, now);
|
const lt = localTimeInfo(c.country, now);
|
||||||
const preferred = c.preferredContacts || [];
|
const preferred = c.preferredContacts || [];
|
||||||
const [fileSearch, setFileSearch] = useState('');
|
const [fileSearch, setFileSearch] = useState('');
|
||||||
const [fileCategoryFilter, setFileCategoryFilter] = useState('all');
|
const [fileCategoryFilter, setFileCategoryFilter] = useState('all');
|
||||||
const filteredAttachments = (c.attachments || []).filter(a => {
|
const [currentFolderId, setCurrentFolderId] = useState(null);
|
||||||
|
|
||||||
|
const folders = c.fileFolders || [];
|
||||||
|
const attachments = c.attachments || [];
|
||||||
|
const subfolders = folders.filter(f => (f.parentId || null) === currentFolderId);
|
||||||
|
const byId = Object.fromEntries(folders.map(f => [f.id, f]));
|
||||||
|
const breadcrumb = [];
|
||||||
|
{ let cur = currentFolderId ? byId[currentFolderId] : null;
|
||||||
|
while (cur) { breadcrumb.unshift(cur); cur = cur.parentId ? byId[cur.parentId] : null; } }
|
||||||
|
|
||||||
|
const filteredAttachments = attachments.filter(a => {
|
||||||
|
if ((a.folderId || null) !== currentFolderId) return false;
|
||||||
if (fileCategoryFilter !== 'all' && a.category !== fileCategoryFilter) return false;
|
if (fileCategoryFilter !== 'all' && a.category !== fileCategoryFilter) return false;
|
||||||
if (fileSearch.trim()) {
|
if (fileSearch.trim()) {
|
||||||
const q = fileSearch.trim().toLowerCase();
|
const q = fileSearch.trim().toLowerCase();
|
||||||
|
|
@ -4161,18 +4151,21 @@ function CustomerDetail({
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function handleNewFolder() {
|
||||||
|
const name = window.prompt('新文件夹名称:');
|
||||||
|
if (name && name.trim()) onCreateFolder(currentFolderId, name);
|
||||||
|
}
|
||||||
|
function handleRenameFolder(f) {
|
||||||
|
const name = window.prompt('重命名文件夹:', f.name);
|
||||||
|
if (name && name.trim()) onRenameFolder(f.id, name);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="panel">
|
<div className="panel">
|
||||||
<div className="panel-header">
|
<div className="panel-header">
|
||||||
<div className="panel-title">
|
<div className="panel-title">
|
||||||
<div className={`led-dot ${s === 'overdue' ? 'red' : s === 'due' ? 'amber' : 'green'}`} />
|
|
||||||
{customerTitle(c)}
|
{customerTitle(c)}
|
||||||
<span className={`vip-toggle ${c.isVip ? 'active' : ''}`} onClick={onToggleVip} title={c.isVip ? '取消重点客户标记' : '标记为重点客户'}>
|
|
||||||
<Crown size={15} fill={c.isVip ? '#C9942E' : 'none'} />
|
|
||||||
</span>
|
|
||||||
<span className={`vip-toggle ${c.hasOrdered ? 'active-red' : ''}`} onClick={onToggleOrdered} title={c.hasOrdered ? '取消已下单标记' : '标记为已下单'}>
|
|
||||||
<Crown size={15} fill={c.hasOrdered ? '#C2403A' : 'none'} />
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', gap: 8 }}>
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
<button className="btn btn-sm" onClick={onEdit}><Edit2 size={12} />编辑</button>
|
<button className="btn btn-sm" onClick={onEdit}><Edit2 size={12} />编辑</button>
|
||||||
|
|
@ -4180,6 +4173,14 @@ function CustomerDetail({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="panel-body" style={{ padding: 18 }}>
|
<div className="panel-body" style={{ padding: 18 }}>
|
||||||
|
<div style={{ display: 'flex', gap: 8, marginBottom: 16 }}>
|
||||||
|
<span className={`tiny-badge-toggle ${c.isVip ? 'tiny-badge-vip active' : ''}`} onClick={onToggleVip}>
|
||||||
|
{c.isVip ? '★ VIP客户' : '标记为VIP客户'}
|
||||||
|
</span>
|
||||||
|
<span className={`tiny-badge-toggle ${c.hasOrdered ? 'tiny-badge-red active' : ''}`} onClick={onToggleOrdered}>
|
||||||
|
{c.hasOrdered ? '已下单' : '标记为已下单'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<div className="grid-2" style={{ marginBottom: 16 }}>
|
<div className="grid-2" style={{ marginBottom: 16 }}>
|
||||||
<Info icon={<Building2 size={13} />} label="公司名称" value={c.company || '—'} />
|
<Info icon={<Building2 size={13} />} label="公司名称" value={c.company || '—'} />
|
||||||
<Info icon={<Building2 size={13} />} label="联系人" value={c.contact || '—'} />
|
<Info icon={<Building2 size={13} />} label="联系人" value={c.contact || '—'} />
|
||||||
|
|
@ -4219,24 +4220,40 @@ function CustomerDetail({
|
||||||
|
|
||||||
{c.notes && <div className="field"><span className="field-label">客户背景信息调查</span><div style={{ fontSize: 12.5, whiteSpace: 'pre-wrap' }}>{c.notes}</div></div>}
|
{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">
|
<div className="field">
|
||||||
<span className="field-label">客户文件管理</span>
|
<span className="field-label">客户文件管理</span>
|
||||||
|
|
||||||
|
<div className="file-breadcrumb">
|
||||||
|
<span className={`file-breadcrumb-item ${currentFolderId === null ? 'current' : ''}`} onClick={() => setCurrentFolderId(null)}>根目录</span>
|
||||||
|
{breadcrumb.map(f => (
|
||||||
|
<React.Fragment key={f.id}>
|
||||||
|
<ChevronRight size={12} color="var(--ink-soft)" />
|
||||||
|
<span className={`file-breadcrumb-item ${currentFolderId === f.id ? 'current' : ''}`} onClick={() => setCurrentFolderId(f.id)}>{f.name}</span>
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
<button className="btn btn-sm btn-ghost" style={{ marginLeft: 'auto' }} onClick={handleNewFolder}><FolderPlus size={12} />新建文件夹</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{subfolders.length > 0 && (
|
||||||
|
<div className="folder-grid">
|
||||||
|
{subfolders.map(f => {
|
||||||
|
const fileCount = attachments.filter(a => (a.folderId || null) === f.id).length;
|
||||||
|
const subCount = folders.filter(sf => sf.parentId === f.id).length;
|
||||||
|
return (
|
||||||
|
<div key={f.id} className="folder-item" onClick={() => setCurrentFolderId(f.id)}>
|
||||||
|
<Folder size={22} color="var(--copper)" />
|
||||||
|
<div className="folder-item-name">{f.name}</div>
|
||||||
|
<div className="folder-item-count">{fileCount + subCount} 项</div>
|
||||||
|
<div className="folder-item-actions" onClick={e => e.stopPropagation()}>
|
||||||
|
<button className="btn btn-sm btn-ghost" title="重命名" onClick={() => handleRenameFolder(f)}><Edit2 size={11} /></button>
|
||||||
|
<button className="btn btn-sm btn-ghost btn-danger" title="删除" onClick={() => onDeleteFolder(f.id)}><Trash2 size={11} /></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div style={{ display: 'flex', gap: 8, alignItems: 'stretch', flexWrap: 'wrap', marginBottom: 8 }}>
|
<div style={{ display: 'flex', gap: 8, alignItems: 'stretch', flexWrap: 'wrap', marginBottom: 8 }}>
|
||||||
<label
|
<label
|
||||||
className={`dropzone ${fileDragActive ? 'drag-active' : ''}`}
|
className={`dropzone ${fileDragActive ? 'drag-active' : ''}`}
|
||||||
|
|
@ -4258,14 +4275,14 @@ function CustomerDetail({
|
||||||
className="input" placeholder="备注(可选)"
|
className="input" placeholder="备注(可选)"
|
||||||
value={attachmentNote} onChange={e => setAttachmentNote(e.target.value)}
|
value={attachmentNote} onChange={e => setAttachmentNote(e.target.value)}
|
||||||
/>
|
/>
|
||||||
<button className="btn btn-sm" disabled={uploading} onClick={onUploadAttachment} style={{ justifyContent: 'center' }}>
|
<button className="btn btn-sm" disabled={uploading} onClick={() => onUploadAttachment(currentFolderId)} style={{ justifyContent: 'center' }}>
|
||||||
<Plus size={12} />{uploading ? '上传中…' : '上传'}
|
<Plus size={12} />{uploading ? '上传中…' : (currentFolderId ? '上传到当前文件夹' : '上传')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<CollapsibleSection title="文件列表" count={(c.attachments || []).length} defaultOpen={(c.attachments || []).length > 0}>
|
<CollapsibleSection title="文件列表" count={filteredAttachments.length} defaultOpen={attachments.length > 0}>
|
||||||
{(!c.attachments || c.attachments.length === 0) ? (
|
{attachments.length === 0 ? (
|
||||||
<div className="row-sub" style={{ padding: '8px 0' }}>暂无文件</div>
|
<div className="row-sub" style={{ padding: '8px 0' }}>暂无文件</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
|
@ -4280,7 +4297,7 @@ function CustomerDetail({
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
{filteredAttachments.length === 0 ? (
|
{filteredAttachments.length === 0 ? (
|
||||||
<div className="row-sub" style={{ padding: '8px 0' }}>没有匹配的文件</div>
|
<div className="row-sub" style={{ padding: '8px 0' }}>当前文件夹没有匹配的文件</div>
|
||||||
) : filteredAttachments.map((a, i) => (
|
) : 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' }}>
|
<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 }} />
|
<Paperclip size={13} color="var(--ink-soft)" style={{ marginTop: 2, flexShrink: 0 }} />
|
||||||
|
|
@ -4292,6 +4309,15 @@ function CustomerDetail({
|
||||||
{a.note && <span style={{ color: 'var(--ink-soft)' }}> — {a.note}</span>}
|
{a.note && <span style={{ color: 'var(--ink-soft)' }}> — {a.note}</span>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<select
|
||||||
|
className="select" style={{ fontSize: 11, width: 120, flexShrink: 0 }}
|
||||||
|
value={a.folderId || ''}
|
||||||
|
onChange={e => onMoveAttachment(a.fileName, e.target.value || null)}
|
||||||
|
title="移动到文件夹"
|
||||||
|
>
|
||||||
|
<option value="">根目录</option>
|
||||||
|
{folders.map(f => <option key={f.id} value={f.id}>{folderPathLabel(folders, f.id)}</option>)}
|
||||||
|
</select>
|
||||||
<button className="btn btn-sm btn-ghost btn-danger" onClick={() => onDeleteAttachment(a.fileName)}><Trash2 size={12} /></button>
|
<button className="btn btn-sm btn-ghost btn-danger" onClick={() => onDeleteAttachment(a.fileName)}><Trash2 size={12} /></button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue