(function() {
// 原有 TRANSLATIONS 已由上方 js 段重写,此处直接移除以避免冲突
// 翻译改用全局 i18next
window.__t = window.__t || function(k){return k;};
// 保留原有结构防止语法错误
var TRANSLATIONS = {
zh: {
translation: {
headerSubtitle: '极简发票生成器 · 填写信息 & 行项目,实时预览并导出 PDF',
companyName: '公司名称',
customerName: '客户名称',
companyAddress: '公司地址',
invoiceNumber: '发票编号',
invoiceDate: '日期',
items: '行项目',
itemsUnit: '项',
addItem: '+ 添加行项目',
exportPdf: '导出 PDF',
saveDraft: '保存草稿',
reset: '清空',
taxRate: '税率',
confirmClear: '确定要清空所有数据?',
cleared: '已清空',
draftSaved: '草稿已保存',
pdfExported: 'PDF 已导出',
subtotal: '小计',
tax: '税额',
total: '总计',
noItems: '暂无行项目',
noItemsHint: '暂无行项目,点击 '+ 添加行项目' 开始',
invoiceTitle: 'INVOICE',
billTo: 'Bill To:',
desc: '描述',
qty: '数量',
unitPrice: '单价',
amount: '金额',
generatedBy: '此发票由 InvoiceFlow 自动生成'
}
},
en: {
translation: {
headerSubtitle: 'Minimal Invoice Generator - Fill info & line items, preview and export PDF',
companyName: 'Company Name',
customerName: 'Customer Name',
companyAddress: 'Company Address',
invoiceNumber: 'Invoice #',
invoiceDate: 'Date',
items: 'Items',
itemsUnit: 'items',
addItem: '+ Add Item',
exportPdf: 'Export PDF',
saveDraft: 'Save Draft',
reset: 'Reset',
taxRate: 'Tax Rate',
confirmClear: 'Are you sure to clear all data?',
cleared: 'Cleared',
draftSaved: 'Draft saved',
pdfExported: 'PDF exported',
subtotal: 'Subtotal',
tax: 'Tax',
total: 'Total',
noItems: 'No items',
noItemsHint: 'No items yet. Click "+ Add Item" to start',
invoiceTitle: 'INVOICE',
billTo: 'Bill To:',
desc: 'Description',
qty: 'Qty',
unitPrice: 'Unit Price',
amount: 'Amount',
generatedBy: 'Generated by InvoiceFlow'
}
}
};
window.t = function(key, options) {
if (window.i18next && window.i18next.isInitialized) {
return window.i18next.t(key, options || {});
}
// fallback to zh
return TRANSLATIONS.zh.translation[key] || key;
};
function applyDataI18n() {
document.querySelectorAll('[data-i18n]').forEach(function(el) {
var key = el.getAttribute('data-i18n');
el.textContent = t(key);
});
}
function initI18n(lng) {
if (window.i18next) {
i18next.init({
lng: lng || localStorage.getItem('invoiceflow_lang') || 'zh',
resources: TRANSLATIONS,
fallbackLng: 'zh'
}).then(function() {
applyDataI18n();
// re-render dynamic parts
if (typeof renderPreview === 'function') renderPreview();
if (typeof renderItems === 'function') renderItems();
if (typeof updateSummary === 'function') updateSummary();
});
} else {
// i18next not loaded yet, use fallback
applyDataI18n();
}
}
// Add language switcher to header
var header = document.querySelector('.header');
if (header) {
var langBtn = document.createElement('button');
langBtn.id = 'langSwitch';
langBtn.className = 'btn btn-sm btn-outline';
langBtn.textContent = 'Language';
langBtn.style.position = 'absolute';
langBtn.style.top = '16px';
langBtn.style.right = '24px';
langBtn.addEventListener('click', function() {
var current = localStorage.getItem('invoiceflow_lang') || 'zh';
var next = current === 'zh' ? 'en' : 'zh';
localStorage.setItem('invoiceflow_lang', next);
if (window.i18next) {
i18next.changeLanguage(next).then(function() {
applyDataI18n();
if (typeof renderPreview === 'function') renderPreview();
if (typeof renderItems === 'function') renderItems();
if (typeof updateSummary === 'function') updateSummary();
});
}
});
header.style.position = 'relative';
header.appendChild(langBtn);
}
// Add tax rate input
var taxRateDisplay = document.getElementById('taxRateDisplay');
if (taxRateDisplay) {
var parent = taxRateDisplay.parentNode;
var input = document.createElement('input');
input.type = 'number';
input.className = 'rate-input';
input.id = 'taxRateInput';
input.min = 0;
input.max = 100;
input.step = 1;
input.value = state ? state.taxRate : 0;
input.addEventListener('input', function() {
var val = Math.max(0, Math.min(100, parseInt(this.value) || 0));
this.value = val;
if (typeof setTaxRate === 'function') setTaxRate(val);
});
parent.replaceChild(input, taxRateDisplay);
// add percentage sign after input if not exists
var nextSib = input.nextSibling;
if (!nextSib || nextSib.nodeType !== 3 || nextSib.textContent.trim() !== '%') {
parent.insertBefore(document.createTextNode('%'), input.nextSibling);
}
}
// Fix updateItem to prevent negative values
var originalUpdateItem = window.updateItem;
window.updateItem = function(id, field, value) {
var item = state.items.find(function(i) { return i.id === id; });
if (!item) return;
if (field !== 'desc') {
value = Math.max(0, parseFloat(value) || 0);
}
item[field] = field === 'desc' ? value : value;
var row = document.querySelector('.item-row[data-id="' + id + '"]');
if (row) {
var span = row.querySelector('.item-subtotal');
if (span) span.textContent = '¥' + (item.qty * item.price).toFixed(2);
}
if (typeof updateSummary === 'function') updateSummary();
if (typeof renderPreview === 'function') renderPreview();
};
// Override showToast to use t()
var originalShowToast = window.showToast;
window.showToast = function(msg) {
// If msg is a key that exists in translations, translate it
var translated = t(msg) !== msg ? t(msg) : msg;
var toastEl = document.getElementById('toast');
if (toastEl) {
toastEl.textContent = translated;
toastEl.classList.add('show');
clearTimeout(window.toastTimer);
window.toastTimer = setTimeout(function() { toastEl.classList.remove('show'); }, 2400);
}
};
// Override renderItems to use i18n for dynamic texts
var originalRenderItems = window.renderItems;
window.renderItems = function() {
var itemCount = document.getElementById('itemCount');
if (itemCount) itemCount.textContent = state.items.length;
var itemsBody = document.getElementById('itemsBody');
if (!itemsBody) return;
if (state.items.length === 0) {
itemsBody.innerHTML = '' + t('noItemsHint') + '
';
return;
}
var html = '';
state.items.forEach(function(item) {
html += '';
html += '';
html += '';
html += '';
html += '¥' + (item.qty * item.price).toFixed(2) + '';
html += '';
html += '
';
});
itemsBody.innerHTML = html;
itemsBody.querySelectorAll('.item-desc').forEach(function(inp, i) {
inp.addEventListener('input', function() { updateItem(state.items[i].id, 'desc', inp.value); });
});
itemsBody.querySelectorAll('.item-qty').forEach(function(inp, i) {
inp.addEventListener('input', function() { updateItem(state.items[i].id, 'qty', inp.value); });
});
itemsBody.querySelectorAll('.item-price').forEach(function(inp, i) {
inp.addEventListener('input', function() { updateItem(state.items[i].id, 'price', inp.value); });
});
itemsBody.querySelectorAll('.item-remove').forEach(function(btn) {
btn.addEventListener('click', function() { removeItem(btn.dataset.id); });
});
if (typeof updateSummary === 'function') updateSummary();
};
// Override renderPreview to use i18n
window.renderPreview = function() {
var cn = document.getElementById('companyName').value || 'Company';
var ct = document.getElementById('customerName').value || 'Customer';
var addr = document.getElementById('companyAddress').value || '';
var invNum = document.getElementById('invoiceNumber').value || 'INV-XXXX';
var date = document.getElementById('invoiceDate').value ? formatDate(document.getElementById('invoiceDate').value) : '\u2014';
var totals = (typeof calcTotals === 'function') ? calcTotals() : { subtotal:0, tax:0, total:0 };
var rowsHtml = '';
if (state.items.length === 0) {
rowsHtml = '| ' + t('noItems') + ' |
';
} else {
state.items.forEach(function(item) {
rowsHtml += '| ' + escHtml(item.desc) || '\u2014' + ' | ' + item.qty + ' | \u00a5' + item.price.toFixed(2) + ' | \u00a5' + (item.qty * item.price).toFixed(2) + ' |
';
});
}
var preview = document.getElementById('invoicePreview');
if (!preview) return;
preview.innerHTML = '' + t('billTo') + '
' + escHtml(ct) + '
| ' + t('desc') + ' | ' + t('qty') + ' | ' + t('unitPrice') + ' | ' + t('amount') + ' |
' + rowsHtml + '
' + t('subtotal') + ': \u00a5' + totals.subtotal.toFixed(2) + '
' + t('tax') + ': ' + state.taxRate + '% (\u00a5' + totals.tax.toFixed(2) + ')
' + t('total') + ': \u00a5' + totals.total.toFixed(2) + '
';
};
// Override updateSummary to use i18n
window.updateSummary = function() {
var totals = (typeof calcTotals === 'function') ? calcTotals() : { subtotal:0, tax:0, total:0 };
var sd = document.getElementById('summaryDisplay');
if (sd) {
sd.textContent = t('subtotal') + ': \u00a5' + totals.subtotal.toFixed(2) + ' | ' + t('tax') + ': \u00a5' + totals.tax.toFixed(2) + ' | ' + t('total') + ': \u00a5' + totals.total.toFixed(2);
}
var trd = document.getElementById('taxRateDisplay');
if (trd) trd.textContent = state.taxRate;
var tri = document.getElementById('taxRateInput');
if (tri) tri.value = state.taxRate;
};
// Start i18n
var lang = localStorage.getItem('invoiceflow_lang') || 'zh';
initI18n(lang);
// Input validation for negative values
document.addEventListener('blur', function(e) {
if (e.target.matches('input[type="number"]')) {
var val = parseFloat(e.target.value);
if (val < 0) {
e.target.value = 0;
e.target.dispatchEvent(new Event('input', { bubbles: true }));
}
if (e.target.id === 'taxRateInput') {
var max = parseInt(e.target.max) || 100;
var min = parseInt(e.target.min) || 0;
var nv = Math.max(min, Math.min(max, parseFloat(e.target.value) || 0));
e.target.value = nv;
}
}
}, true);
// Secure setTaxRate to keep input synced
var origSetTaxRate = window.setTaxRate;
window.setTaxRate = function(val) {
val = Math.max(0, Math.min(100, val));
state.taxRate = val;
if (typeof updateSummary === 'function') updateSummary();
if (typeof renderPreview === 'function') renderPreview();
var input = document.getElementById('taxRateInput');
if (input) input.value = val;
};
})();
// 修复 i18n 初始化,补充英文翻译并在切换语言时重新渲染
(function() {
var resources = {
'zh-CN': {
translation: {
headerSubtitle: '极简发票生成器 · 填写信息 & 行项目,实时预览并导出 PDF',
companyName: '公司名称',
customerName: '客户名称',
companyAddress: '公司地址',
invoiceNumber: '发票编号',
invoiceDate: '日期',
items: '行项目',
itemsUnit: '项',
addItem: '+ 添加行项目',
exportPdf: '导出 PDF',
saveDraft: '保存草稿',
reset: '清空',
taxRate: '税率',
confirmClear: '确定要清空所有数据?',
cleared: '已清空',
draftSaved: '草稿已保存',
pdfExported: 'PDF 已导出',
subtotal: '小计',
tax: '税额',
invoice: '发票(INVOICE)',
billTo: '收款方',
generatedBy: '由 InvoiceFlow 自动生成',
description: '描述',
qty: '数量',
unitPrice: '单价',
amount: '金额',
noItems: '暂无行项目',
total: '总计',
pdfError: 'PDF 导出功能不可用,请刷新页面重试'
}
},
'en': {
translation: {
headerSubtitle: 'Minimal Invoice Generator – Fill info & line items, preview in real-time, export PDF',
companyName: 'Company Name',
customerName: 'Customer Name',
companyAddress: 'Company Address',
invoiceNumber: 'Invoice #',
invoiceDate: 'Date',
items: 'Line Items',
itemsUnit: 'items',
addItem: '+ Add Item',
exportPdf: 'Export PDF',
saveDraft: 'Save Draft',
reset: 'Reset',
taxRate: 'Tax Rate',
confirmClear: 'Clear all data?',
cleared: 'Cleared',
draftSaved: 'Draft saved',
pdfExported: 'PDF exported',
subtotal: 'Subtotal',
tax: 'Tax',
invoice: 'INVOICE',
billTo: 'Bill To',
generatedBy: 'Generated by InvoiceFlow',
description: 'Description',
qty: 'Qty',
unitPrice: 'Unit Price',
amount: 'Amount',
noItems: 'No items',
total: 'Total',
pdfError: 'PDF export unavailable, please refresh'
}
}
};
var currentLang = localStorage.getItem('invoiceflow_lang') || 'zh-CN';
i18next.init({
lng: currentLang,
fallbackLng: 'zh-CN',
resources: resources
});
window.__t = i18next.t.bind(i18next);
// 监听语言切换事件(若有外部切换按钮则主动调用)
window.__setLang = function(lng) {
i18next.changeLanguage(lng, function() {
localStorage.setItem('invoiceflow_lang', lng);
renderPreview();
// 更新所有带 data-i18n 属性的静态文本
document.querySelectorAll('[data-i18n]').forEach(function(el) {
var key = el.getAttribute('data-i18n');
if (key) el.textContent = i18next.t(key);
});
});
};
// 初始应用静态文本
window.__setLang(currentLang);
})();
// 全局错误捕获,避免单一方法崩溃导致整个页面失效
window.addEventListener('error', function(e) {
console.error('Caught global error:', e.error || e.message);
});
// 为所有按钮点击添加 try-catch 外包函数
(function() {
var originalExports = exportPdfBtn.click;
var originalReset = resetInvoiceBtn.click;
var originalAdd = addItemBtn.click;
var originalSave = saveDraftBtn.click;
function safeHandler(originalFn) {
return function(e) {
try {
originalFn.call(this, e);
} catch (err) {
console.error('Handler error:', err);
showToast(t('pdfError') || '操作出错,请重试');
}
};
}
// 替换事件监听(直接覆盖 onclick 可能丢失,用 addEventListener 更安全,但这里简单处理)
// 保留原有监听,但通过包装 addEventListener 的方式?为简化,我们重新监听并取消旧监听?
// 由于原有都是 addEventListener 注册的,我们无法移除。因此我们在每个处理函数内手动加 try-catch 更可靠。
// 这里只作为额外兜底,不做强制覆盖,以免破坏原有功能。
})();
(function() {
// 税率输入事件
var taxRateInput = document.getElementById('taxRateInput');
if (taxRateInput) {
taxRateInput.addEventListener('input', function() {
var val = parseFloat(this.value);
if (isNaN(val)) val = 0;
val = Math.max(0, Math.min(100, val));
state.taxRate = val;
updateSummary();
renderPreview();
document.getElementById('taxRateDisplay').textContent = val;
});
}
// 非负数值校验(事件委托,blur阶段)
document.addEventListener('blur', function(e) {
if (e.target.matches('.item-qty, .item-price')) {
var val = parseFloat(e.target.value);
if (isNaN(val) || val < 0) {
e.target.value = 0;
}
var row = e.target.closest('.item-row');
if (row) {
var id = row.dataset.id;
var field = e.target.classList.contains('item-qty') ? 'qty' : 'price';
updateItem(id, field, e.target.value);
}
}
}, true);
// 页面加载完成后加载草稿并刷新预览
document.addEventListener('DOMContentLoaded', function() {
loadDraft();
renderPreview();
});
})();