From b140948f4da1b8b998295c2114054459f5558f15 Mon Sep 17 00:00:00 2001 From: mike Date: Sun, 26 Jul 2026 21:14:37 +0800 Subject: [PATCH] Update App.jsx via upload script - 2026-07-26 21:14:12 --- App.jsx | 311 ++++++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 291 insertions(+), 20 deletions(-) diff --git a/App.jsx b/App.jsx index 77bbf85..34a005d 100644 --- a/App.jsx +++ b/App.jsx @@ -261,6 +261,160 @@ const blankCustomer = () => ({ }); const OUTPUT_PORT_TYPES = ['USB', 'DC', 'DC1', 'DC2', 'DC3', 'POE', 'Type-C', 'DC interface', 'POE interface']; + +const EXPORT_LANGUAGES = [ + { code: 'zh', label: '中文', rtl: false }, + { code: 'en', label: 'English', rtl: false }, + { code: 'fr', label: 'Français', rtl: false }, + { code: 'ru', label: 'Русский', rtl: false }, + { code: 'es', label: 'Español', rtl: false }, + { code: 'ar', label: 'العربية', rtl: true }, +]; + +// 多语言PDF导出用的内置专业翻译词典:固定字段名 + 已知的品类/机型名称,人工核对过的翻译, +// 不依赖AI、不占用DeepSeek额度、永远一致准确。词典里没有的词(比如用户自己新建的品类/品牌名、 +// 或者产品备注这种自由文本)才会走AI翻译并缓存(见 resolveExportTranslations)。 +const TRANSLATION_DICTIONARY = { + // 文档通用 + '产品规格书': { en: 'Specifications', fr: 'Spécifications', ru: 'Технические характеристики', es: 'Especificaciones', ar: 'المواصفات' }, + '* 产品规格如有变动,恕不另行通知': { + en: 'Product specifications are subject to change without notice.', + fr: "Les spécifications du produit peuvent être modifiées sans préavis.", + ru: 'Технические характеристики могут быть изменены без предварительного уведомления.', + es: 'Las especificaciones del producto están sujetas a cambios sin previo aviso.', + ar: 'تخضع مواصفات المنتج للتغيير دون إشعار مسبق.', + }, + // 固定字段名 + '型号': { en: 'Model', fr: 'Modèle', ru: 'Модель', es: 'Modelo', ar: 'الطراز' }, + '品牌': { en: 'Brand', fr: 'Marque', ru: 'Бренд', es: 'Marca', ar: 'العلامة التجارية' }, + '产品大类': { en: 'Category', fr: 'Catégorie', ru: 'Категория', es: 'Categoría', ar: 'الفئة' }, + '细分品类': { en: 'Type', fr: 'Type', ru: 'Тип', es: 'Tipo', ar: 'النوع' }, + '机型': { en: 'Machine Type', fr: "Type d'appareil", ru: 'Тип модели', es: 'Tipo de equipo', ar: 'طراز الجهاز' }, + '标机': { en: 'Standard', fr: 'Standard', ru: 'Стандартный', es: 'Estándar', ar: 'قياسي' }, + '长机': { en: 'Extended', fr: 'Prolongé', ru: 'Увеличенный', es: 'Extendido', ar: 'ممتد' }, + '输出功率': { en: 'Output Power', fr: 'Puissance de sortie', ru: 'Выходная мощность', es: 'Potencia de salida', ar: 'طاقة الخرج' }, + '功率 / 容量': { en: 'Power / Capacity', fr: 'Puissance / Capacité', ru: 'Мощность / Ёмкость', es: 'Potencia / Capacidad', ar: 'الطاقة / السعة' }, + '输入电压': { en: 'Input Voltage', fr: "Tension d'entrée", ru: 'Входное напряжение', es: 'Voltaje de entrada', ar: 'جهد الدخل' }, + '电压': { en: 'Voltage', fr: 'Tension', ru: 'Напряжение', es: 'Voltaje', ar: 'الجهد' }, + '视载功率': { en: 'Rated Power (VA)', fr: 'Puissance nominale (VA)', ru: 'Номинальная мощность (ВА)', es: 'Potencia nominal (VA)', ar: 'الطاقة الاسمية (فولت أمبير)' }, + '功率因数': { en: 'Power Factor', fr: 'Facteur de puissance', ru: 'Коэффициент мощности', es: 'Factor de potencia', ar: 'معامل القدرة' }, + '最大功率': { en: 'Max Power', fr: 'Puissance maximale', ru: 'Максимальная мощность', es: 'Potencia máxima', ar: 'أقصى طاقة' }, + '输出口数量': { en: 'Output Ports', fr: 'Nombre de sorties', ru: 'Количество выходов', es: 'Número de salidas', ar: 'عدد منافذ الخرج' }, + '输出口类型': { en: 'Output Types', fr: 'Types de sortie', ru: 'Типы выходов', es: 'Tipos de salida', ar: 'أنواع منافذ الخرج' }, + '电池数量和容量': { en: 'Battery Qty & Capacity', fr: 'Nombre et capacité de batterie', ru: 'Кол-во и ёмкость батарей', es: 'Cantidad y capacidad de batería', ar: 'عدد وسعة البطارية' }, + '尺寸(mm)': { en: 'Dimensions (mm)', fr: 'Dimensions (mm)', ru: 'Габариты (мм)', es: 'Dimensiones (mm)', ar: 'الأبعاد (مم)' }, + '净重(kg)': { en: 'Net Weight (kg)', fr: 'Poids net (kg)', ru: 'Вес нетто (кг)', es: 'Peso neto (kg)', ar: 'الوزن الصافي (كجم)' }, + '备注': { en: 'Notes', fr: 'Remarques', ru: 'Примечания', es: 'Notas', ar: 'ملاحظات' }, + '输出': { en: 'Output', fr: 'Sortie', ru: 'Выход', es: 'Salida', ar: 'الخرج' }, + // 已知产品大类 + 'UPS不间断电源': { en: 'UPS (Uninterruptible Power Supply)', fr: 'Onduleur (UPS)', ru: 'ИБП (Источник бесперебойного питания)', es: 'SAI (Sistema de Alimentación Ininterrumpida)', ar: 'مزود طاقة غير منقطعة (UPS)' }, + '光伏逆变器': { en: 'Solar Inverter', fr: 'Onduleur solaire', ru: 'Солнечный инвертор', es: 'Inversor solar', ar: 'عاكس شمسي' }, + '储能电池': { en: 'Energy Storage Battery', fr: "Batterie de stockage d'énergie", ru: 'Аккумулятор для хранения энергии', es: 'Batería de almacenamiento de energía', ar: 'بطارية تخزين الطاقة' }, + // 已知细分品类 + '迷你UPS': { en: 'Mini UPS', fr: 'Mini onduleur', ru: 'Мини-ИБП', es: 'Mini SAI', ar: 'يو بي إس صغير' }, + '离线式UPS': { en: 'Offline UPS', fr: 'Onduleur hors ligne', ru: 'Резервный ИБП (Offline)', es: 'SAI fuera de línea', ar: 'يو بي إس غير متصل' }, + '在线互动式UPS': { en: 'Line-Interactive UPS', fr: 'Onduleur interactif', ru: 'Линейно-интерактивный ИБП', es: 'SAI interactivo de línea', ar: 'يو بي إس تفاعلي الخط' }, + '高频塔式UPS': { en: 'High-Frequency Tower UPS', fr: 'Onduleur tour haute fréquence', ru: 'Высокочастотный башенный ИБП', es: 'SAI torre de alta frecuencia', ar: 'يو بي إس برجي عالي التردد' }, + '工频UPS': { en: 'Low-Frequency UPS', fr: 'Onduleur basse fréquence', ru: 'Низкочастотный ИБП', es: 'SAI de baja frecuencia', ar: 'يو بي إس منخفض التردد' }, + '模块化UPS': { en: 'Modular UPS', fr: 'Onduleur modulaire', ru: 'Модульный ИБП', es: 'SAI modular', ar: 'يو بي إس معياري' }, + '机架式UPS': { en: 'Rack-mount UPS', fr: 'Onduleur rackable', ru: 'Стоечный ИБП', es: 'SAI para rack', ar: 'يو بي إس للتركيب في الرف' }, + '太阳能混网逆变器': { en: 'Hybrid Solar Inverter', fr: 'Onduleur solaire hybride', ru: 'Гибридный солнечный инвертор', es: 'Inversor solar híbrido', ar: 'عاكس شمسي هجين' }, + '并网/离网逆变器': { en: 'Grid-tied / Off-grid Inverter', fr: 'Onduleur raccordé / hors réseau', ru: 'Сетевой / автономный инвертор', es: 'Inversor conectado a la red / aislado', ar: 'عاكس متصل بالشبكة / خارج الشبكة' }, + '铅酸电池': { en: 'Lead-acid Battery', fr: 'Batterie au plomb', ru: 'Свинцово-кислотная батарея', es: 'Batería de plomo-ácido', ar: 'بطارية الرصاص الحمضية' }, + '磷酸铁锂电池': { en: 'LiFePO4 Battery', fr: 'Batterie LiFePO4', ru: 'Аккумулятор LiFePO4', es: 'Batería LiFePO4', ar: 'بطارية ليثيوم فوسفات الحديد' }, +}; + +// 解析一批中文词条在目标语言下的翻译:优先用上面内置的专业词典(永远准确、不占用AI额度), +// 词典里没有的再查本地缓存(之前AI翻译过的词,存在 crm:translationGlossary 里), +// 缓存也没有的才真的调用DeepSeek翻译,翻完存回缓存,下次同一个词就不用再调了。 +async function resolveExportTranslations(terms, lang) { + if (lang === 'zh' || !terms.length) { + const map = {}; + terms.forEach(t => { map[t] = t; }); + return map; + } + let glossary = {}; + try { + const res = await storageGet('crm:translationGlossary'); + glossary = JSON.parse(res.value) || {}; + } catch (e) {} + + const map = {}; + const misses = []; + terms.forEach(t => { + if (TRANSLATION_DICTIONARY[t] && TRANSLATION_DICTIONARY[t][lang]) { + map[t] = TRANSLATION_DICTIONARY[t][lang]; + } else if (glossary[t] && glossary[t][lang]) { + map[t] = glossary[t][lang]; + } else { + misses.push(t); + } + }); + + if (misses.length) { + try { + const translations = await translateTerms(misses, lang); + const newGlossary = { ...glossary }; + misses.forEach(t => { + const translated = (translations && translations[t]) || t; + map[t] = translated; + newGlossary[t] = { ...(newGlossary[t] || {}), [lang]: translated }; + }); + try { await storageSet('crm:translationGlossary', JSON.stringify(newGlossary)); } catch (e) {} + } catch (e) { + misses.forEach(t => { map[t] = t; }); // 翻译失败就用原文兜底,不阻塞导出 + } + } + return map; +} + +// 把某个产品需要展示的规格行整理出来(和ProductDetail的powerInfoItems逻辑对齐,但拆分出 +// 独立的label/value结构,方便多语言渲染时分别处理"字段名要翻译"和"数值一般不用翻译") +function productExportRows(p, categories) { + const cat = (categories || []).find(c => c.id === p.category); + const rows = []; + if (isUpsCategory(cat) && p.type !== '迷你UPS') { + const maxKW = computeMaxPowerKW(p.ratedPower, p.ratedPowerUnit, p.powerFactor); + if (p.ratedPower) rows.push({ labelKey: '视载功率', value: `${p.ratedPower} ${p.ratedPowerUnit || 'VA'}` }); + if (p.powerFactor) rows.push({ labelKey: '功率因数', value: p.powerFactor }); + if (maxKW !== null) rows.push({ labelKey: '最大功率', value: `${maxKW} kW` }); + if (p.voltage) rows.push({ labelKey: '电压', value: p.voltage }); + } else if (isUpsCategory(cat) && p.type === '迷你UPS') { + if (p.powerOrCapacity) rows.push({ labelKey: '输出功率', value: p.powerOrCapacity }); + if (p.voltage) rows.push({ labelKey: '输入电压', value: p.voltage }); + if (p.outputPortCount) rows.push({ labelKey: '输出口数量', value: p.outputPortCount }); + if (p.outputPortTypes && p.outputPortTypes.length) rows.push({ labelKey: '输出口类型', value: p.outputPortTypes.join(' / ') }); + (p.outputPorts || []).forEach(port => { + if (port.voltage || port.current) rows.push({ labelKey: '__port__', portType: port.type, value: [port.voltage, port.current].filter(Boolean).join(' / ') }); + }); + if (p.batterySpec) rows.push({ labelKey: '电池数量和容量', value: p.batterySpec }); + if (p.dimensions) rows.push({ labelKey: '尺寸(mm)', value: p.dimensions }); + if (p.netWeight) rows.push({ labelKey: '净重(kg)', value: p.netWeight }); + } else { + if (p.powerOrCapacity) rows.push({ labelKey: '功率 / 容量', value: p.powerOrCapacity }); + if (p.voltage) rows.push({ labelKey: '电压', value: p.voltage }); + } + return rows; +} + +// 收集导出这份产品规格书需要翻译的全部词条(字段名 + 品类/机型这些中文数值 + 备注自由文本) +function gatherExportTerms(p, categories) { + const cat = (categories || []).find(c => c.id === p.category); + const terms = new Set(['产品规格书', '* 产品规格如有变动,恕不另行通知', '型号', '品牌', '产品大类']); + if (cat) terms.add(cat.name); + if (p.type) { terms.add('细分品类'); terms.add(p.type); } + if (isUpsCategory(cat) && p.machineVariant) { terms.add('机型'); terms.add(p.machineVariant); } + const rows = productExportRows(p, categories); + let hasPortRow = false; + rows.forEach(r => { + if (r.labelKey === '__port__') hasPortRow = true; + else terms.add(r.labelKey); + }); + if (hasPortRow) terms.add('输出'); + if (p.notes && p.notes.trim()) { terms.add('备注'); terms.add(p.notes.trim()); } + return Array.from(terms); +} + const DEFAULT_MINI_UPS_OUTPUT_TYPES = ['USB', 'DC1', 'DC2', 'DC3', 'POE']; const blankProduct = () => ({ @@ -950,6 +1104,16 @@ const Styles = () => ( .doc-totals-row.total { font-weight: 700; color: #222; border-top: 1px solid #ddd; margin-top: 4px; padding-top: 8px; } .doc-balance-due { background: #f2f2f2; border-radius: 6px; padding: 10px 14px; margin-top: 6px; } + /* ---------- multi-language product spec sheet export ---------- */ + .spec-sheet-bar { + background: var(--copper); color: #fff; font-weight: 700; font-size: 15px; + padding: 10px 16px; border-radius: 4px 4px 0 0; letter-spacing: 0.02em; + } + .spec-sheet-table { margin-top: 0; border: 1px solid var(--copper-soft); border-top: none; } + .spec-sheet-table td.spec-label { background: #F7F1E8; font-weight: 600; width: 38%; color: #6b5a3e; } + .spec-sheet-table tr:nth-child(even) td { background: #FBF8F3; } + + @media print { body * { visibility: hidden; } .doc-preview, .doc-preview * { visibility: visible; } @@ -1183,6 +1347,7 @@ function MainApp({ username, onLogout }) { const [aiImportHintCategory, setAiImportHintCategory] = useState(''); const [aiImportHintBrand, setAiImportHintBrand] = useState(''); const [aiImportHintType, setAiImportHintType] = useState(''); + const [aiImportTruncatedWarning, setAiImportTruncatedWarning] = useState(false); const [emailModal, setEmailModal] = useState(null); const [exportOpen, setExportOpen] = useState(false); @@ -1276,6 +1441,21 @@ function MainApp({ username, onLogout }) { const selected = customers.find(c => c.id === selectedId) || null; const selectedProduct = products.find(p => p.id === selectedProductId) || null; + const [productExportLang, setProductExportLang] = useState('zh'); + const [productExportLabels, setProductExportLabels] = useState({}); + const [productExportLoading, setProductExportLoading] = useState(false); + const [productExportPdfBusy, setProductExportPdfBusy] = useState(false); + + useEffect(() => { + if (!selectedProduct) { setProductExportLabels({}); return; } + let cancelled = false; + setProductExportLoading(true); + const terms = gatherExportTerms(selectedProduct, productCategories); + resolveExportTranslations(terms, productExportLang) + .then(map => { if (!cancelled) setProductExportLabels(map); }) + .finally(() => { if (!cancelled) setProductExportLoading(false); }); + return () => { cancelled = true; }; + }, [selectedProduct && selectedProduct.id, productExportLang, productCategories]); const filteredCustomers = useMemo(() => { let list = customers; @@ -1594,11 +1774,42 @@ function MainApp({ username, onLogout }) { notify('已删除'); } + async function handleExportProductPdf() { + if (!selectedProduct) return; + setProductExportPdfBusy(true); + const el = document.querySelector('.doc-preview'); + let originalBorder, originalBoxShadow; + try { + const mod = await import('html2pdf.js'); + const html2pdf = mod.default || mod; + if (!el) { notify('找不到预览内容'); return; } + originalBorder = el.style.border; + originalBoxShadow = el.style.boxShadow; + el.style.border = 'none'; + el.style.boxShadow = 'none'; + const langLabel = (EXPORT_LANGUAGES.find(l => l.code === productExportLang) || {}).code || 'zh'; + const filename = `${selectedProduct.name || 'product'}_${langLabel}.pdf`; + await html2pdf().set({ + margin: 10, + filename, + image: { type: 'jpeg', quality: 0.98 }, + html2canvas: { scale: 2 }, + jsPDF: { unit: 'mm', format: 'a4', orientation: 'portrait' }, + }).from(el).save(); + } catch (e) { + notify('导出PDF失败,请重试'); + } finally { + if (el) { el.style.border = originalBorder || ''; el.style.boxShadow = originalBoxShadow || ''; } + setProductExportPdfBusy(false); + } + } + /* ------------------------------ ai bulk import ----------------------------- */ function openAiImport() { setAiImportOpen(true); setAiImportDrafts(null); setAiImportError(''); setAiImportHintCategory(''); setAiImportHintBrand(''); setAiImportHintType(''); + setAiImportTruncatedWarning(false); } function closeAiImport() { setAiImportOpen(false); setAiImportDrafts(null); setAiImportError(''); setAiImportBusy(false); } @@ -1614,6 +1825,7 @@ function MainApp({ username, onLogout }) { }); const parsed = (res && res.products) || []; if (!parsed.length) { setAiImportError('没有从文件里识别出任何产品,换一份更清晰的文件试试'); return; } + setAiImportTruncatedWarning(!!(res && res.truncated)); const drafts = parsed.map((p, i) => ({ _localId: 'aidraft_' + i + '_' + Math.random().toString(36).slice(2, 6), include: true, @@ -2296,7 +2508,13 @@ function MainApp({ username, onLogout }) { {productDraft ? ( setTaxManageMode(mode)} /> ) : selectedProduct ? ( - startEditProduct(selectedProduct)} onDelete={() => deleteProduct(selectedProduct.id)} categories={productCategories} /> + startEditProduct(selectedProduct)} onDelete={() => deleteProduct(selectedProduct.id)} + categories={productCategories} + exportLang={productExportLang} onExportLangChange={setProductExportLang} + exportLabels={productExportLabels} exportLoading={productExportLoading} + onExportPdf={handleExportProductPdf} exportPdfBusy={productExportPdfBusy} + /> ) : (
选择左侧产品查看详情,或新建一个产品档案
)} @@ -2882,6 +3100,12 @@ function MainApp({ username, onLogout }) { 识别出 {aiImportDrafts.length} 个产品,请检查型号/品牌/大类/细分品类,勾掉不需要导入的行,确认后点"确认导入"。 其余规格字段(功率/电压/输出口/电池/尺寸等)已经一并识别好,导入后可以在产品详情里继续核对修改。 + {aiImportTruncatedWarning && ( +
+ ⚠️ AI这次输出的内容超出了长度限制,可能还有没识别完的产品被截断丢弃了。建议核对一下上面这 {aiImportDrafts.length} 个是不是齐了, + 如果少了,把剩下的文件单独再上传一次(比如分成更小的批次,每批3-5个文件)。 +
+ )}
{aiImportDrafts.map(d => (
@@ -3430,32 +3654,79 @@ function ProductForm({ draft, setDraft, onSave, onCancel, categories, onManage } ); } -function ProductDetail({ product: p, onEdit, onDelete, categories }) { +function ProductDetail({ product: p, onEdit, onDelete, categories, exportLang, onExportLangChange, exportLabels, exportLoading, onExportPdf, exportPdfBusy }) { const cat = (categories || []).find(c => c.id === p.category); const showMachineVariant = isUpsCategory(cat) && p.machineVariant; + const L = (key) => (exportLabels && exportLabels[key]) || key; + const langInfo = EXPORT_LANGUAGES.find(l => l.code === exportLang) || EXPORT_LANGUAGES[0]; + const exportRows = productExportRows(p, categories); return ( -
-
-
{p.name || '(未命名)'}
-
- - + <> +
+
+
{p.name || '(未命名)'}
+
+ + +
+
+
+
+ } label="分类" value={categoryBreadcrumb(p, categories)} /> + } label="品牌" value={p.brand || '—'} /> + {showMachineVariant && } label="机型" value={p.machineVariant} />} + {powerInfoItems(p, categories).map(item => ( + } label={item.label} value={item.value} /> + ))} + } label="电压" value={p.voltage || '—'} /> + } label="正本价格(元)" value={p.costPrice || '—'} /> +
+ {p.notes &&
备注
{p.notes}
}
-
-
- } label="分类" value={categoryBreadcrumb(p, categories)} /> - } label="品牌" value={p.brand || '—'} /> - {showMachineVariant && } label="机型" value={p.machineVariant} />} - {powerInfoItems(p, categories).map(item => ( - } label={item.label} value={item.value} /> - ))} - } label="电压" value={p.voltage || '—'} /> - } label="正本价格(元)" value={p.costPrice || '—'} /> + +
+
多语言PDF资料导出
+
+
+
+ 导出语言 + +
+ +
+ +
+
+
{p.name || '—'}
+ {p.brand &&
{p.brand}
} +
+
{L('产品规格书')}
+ + + + {p.brand && } + + {p.type && } + {showMachineVariant && } + {exportRows.map((r, i) => ( + + + + + ))} + {p.notes && p.notes.trim() && } + +
{L('型号')}{p.name || '—'}
{L('品牌')}{p.brand}
{L('产品大类')}{cat ? L(cat.name) : '—'}
{L('细分品类')}{L(p.type)}
{L('机型')}{L(p.machineVariant)}
{r.labelKey === '__port__' ? `${r.portType} ${L('输出')}` : L(r.labelKey)}{r.value}
{L('备注')}{L(p.notes.trim())}
+
{L('* 产品规格如有变动,恕不另行通知')}
+
- {p.notes &&
备注
{p.notes}
}
-
+ ); }