Spaces:
Running
Running
Excel bulk-import: drop 'Ngày ... tháng ... năm ...' + 'Người lập báo giá' rows; add paste-from-clipboard / image-URL image picker
Browse files- bulk-import.ts: LABEL_START_RE now also rejects order/prepared-by rows —
'người lập', 'lập báo giá', 'báo giá', 'người duyệt', 'người kiểm',
'người giao', 'người xem' — so 'Ngày 18 tháng 8 năm 2026' /
'Người lập báo giá: ...' footer rows never become products.
- src/vaix-bulk-import.js: replacement per-row image picker with 3 inputs —
📁 upload, 📋 paste-image-from-clipboard (Ctrl+V, image blob → crop
directly; pasted text-URL → fetch), 🔗 paste external image URL →
load (CORS-safe via canvas + /api/img proxy fallback) → crop modal. Help
text updated. index.html cache-bust v9.
- bulk-import.ts +1 -1
- index.html +1 -1
- src/vaix-bulk-import.js +118 -21
bulk-import.ts
CHANGED
|
@@ -399,7 +399,7 @@ function _cellIsNoise(v: any): boolean {
|
|
| 399 |
// 3. The row is not a total/sum row anywhere.
|
| 400 |
// This completely removes non-product rows (section titles, notes, taxes, page
|
| 401 |
// footers, totals) while keeping every real product row.
|
| 402 |
-
const LABEL_START_RE = /^(?:tong cong|tong tien|tong so|tong|total|sub ?total|stt|so thu tu|cong tien|cong thanh|cong don|thanh tien|thanh toan|don vi|dvt|doi vi|don gia|ghi chu|luu y|ky hieu|loai|nhom|phan nhom|danh muc|muc luc|bang gia|toan bo|san pham|huong dan|ngay|thang|trang|page|hotline|lien he|dia chi|cong ty|thue|vat|phi|chiet khau|giam gia|khuyen mai|bao hanh|van chuyen|xem them|tai ve|mua ngay|ma don|so don|don hang|khach hang|khach|dien thoai|sdt|sdt khach|email|nguoi nhan|nguoi mua|dia chi giao|phuong thuc|hinh thuc|ngay dat|ngay giao|ky gui|len don|don vi ban)\b/;
|
| 403 |
// Does the normalized name start with a known label word? (diacritics already
|
| 404 |
// stripped by _normHeader → "Lưu ý" = "luu y", "Thuế VAT" = "thue vat".)
|
| 405 |
function _isLabelLine(n: string): boolean {
|
|
|
|
| 399 |
// 3. The row is not a total/sum row anywhere.
|
| 400 |
// This completely removes non-product rows (section titles, notes, taxes, page
|
| 401 |
// footers, totals) while keeping every real product row.
|
| 402 |
+
const LABEL_START_RE = /^(?:tong cong|tong tien|tong so|tong|total|sub ?total|stt|so thu tu|cong tien|cong thanh|cong don|thanh tien|thanh toan|don vi|dvt|doi vi|don gia|ghi chu|luu y|ky hieu|loai|nhom|phan nhom|danh muc|muc luc|bang gia|toan bo|san pham|huong dan|ngay|thang|trang|page|hotline|lien he|dia chi|cong ty|thue|vat|phi|chiet khau|giam gia|khuyen mai|bao hanh|van chuyen|xem them|tai ve|mua ngay|ma don|so don|don hang|khach hang|khach|dien thoai|sdt|sdt khach|email|nguoi nhan|nguoi mua|nguoi lap|nguoi duyet|nguoi kiem|nguoi giao|nguoi xem|lap bao gia|bao gia|dia chi giao|phuong thuc|hinh thuc|ngay dat|ngay giao|ky gui|len don|don vi ban)\b/;
|
| 403 |
// Does the normalized name start with a known label word? (diacritics already
|
| 404 |
// stripped by _normHeader → "Lưu ý" = "luu y", "Thuế VAT" = "thue vat".)
|
| 405 |
function _isLabelLine(n: string): boolean {
|
index.html
CHANGED
|
@@ -866,6 +866,6 @@
|
|
| 866 |
<!-- ── Thêm SP (Add Product) module: 100% port from V.AISTUDIO, gated by the
|
| 867 |
homepage V.AISTUDIO access code (window.isVaAdmin()). ── -->
|
| 868 |
<script defer src="./src/vaix-add-product.js?v=16"></script>
|
| 869 |
-
<script defer src="./src/vaix-bulk-import.js?v=
|
| 870 |
</body>
|
| 871 |
</html>
|
|
|
|
| 866 |
<!-- ── Thêm SP (Add Product) module: 100% port from V.AISTUDIO, gated by the
|
| 867 |
homepage V.AISTUDIO access code (window.isVaAdmin()). ── -->
|
| 868 |
<script defer src="./src/vaix-add-product.js?v=16"></script>
|
| 869 |
+
<script defer src="./src/vaix-bulk-import.js?v=9"></script>
|
| 870 |
</body>
|
| 871 |
</html>
|
src/vaix-bulk-import.js
CHANGED
|
@@ -370,7 +370,7 @@
|
|
| 370 |
tbody.innerHTML = rows;
|
| 371 |
updateCommitBtn();
|
| 372 |
var sum = $('bulkResultSummary');
|
| 373 |
-
if (sum) sum.textContent = 'Số dòng: ' + _bulkProducts.length + ' — bấm vào ô để sửa. Bấm "✂ Ảnh" để tải/
|
| 374 |
}
|
| 375 |
window.bulkRowSel = function (i, on) { if (_bulkProducts[i]) _bulkProducts[i]._sel = !!on; updateCommitBtn(); };
|
| 376 |
window.bulkTglAll = function (on) {
|
|
@@ -396,30 +396,127 @@
|
|
| 396 |
openBulkImagePicker(i);
|
| 397 |
};
|
| 398 |
|
| 399 |
-
// ──
|
| 400 |
-
var _bulkImgIdx = -1;
|
| 401 |
-
var
|
| 402 |
function openBulkImagePicker(i) {
|
|
|
|
| 403 |
_bulkImgIdx = i;
|
| 404 |
-
|
| 405 |
-
|
| 406 |
-
|
| 407 |
-
|
| 408 |
-
|
| 409 |
-
|
| 410 |
-
|
| 411 |
-
|
| 412 |
-
|
| 413 |
-
|
| 414 |
-
|
| 415 |
-
|
| 416 |
-
|
| 417 |
-
|
| 418 |
-
|
| 419 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 420 |
}
|
| 421 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 422 |
}
|
|
|
|
|
|
|
|
|
|
| 423 |
function openBulkCrop(src) {
|
| 424 |
var idx = _bulkImgIdx;
|
| 425 |
if (idx < 0) return;
|
|
|
|
| 370 |
tbody.innerHTML = rows;
|
| 371 |
updateCommitBtn();
|
| 372 |
var sum = $('bulkResultSummary');
|
| 373 |
+
if (sum) sum.textContent = 'Số dòng: ' + _bulkProducts.length + ' — bấm vào ô để sửa. Bấm "✂ Ảnh" để tải lên / dán ảnh (Ctrl+V) / dán link ảnh ngoài rồi cắt ảnh cho từng SP. Giá nhập số (VD: 5500000 hoặc 5.500.000).';
|
| 374 |
}
|
| 375 |
window.bulkRowSel = function (i, on) { if (_bulkProducts[i]) _bulkProducts[i]._sel = !!on; updateCommitBtn(); };
|
| 376 |
window.bulkTglAll = function (on) {
|
|
|
|
| 396 |
openBulkImagePicker(i);
|
| 397 |
};
|
| 398 |
|
| 399 |
+
// ── Spreadsheet-image source picker: upload / paste-from-clipboard / URL ──
|
| 400 |
+
var _bulkImgIdx = -1; // row index whose image is being set
|
| 401 |
+
var _bulkImgPick = null; // DOM overlay
|
| 402 |
function openBulkImagePicker(i) {
|
| 403 |
+
if (typeof _bulkProducts[i] === 'undefined') return;
|
| 404 |
_bulkImgIdx = i;
|
| 405 |
+
if (_bulkImgPick) { _bulkImgPick.style.display = 'flex'; return; }
|
| 406 |
+
var ov = document.createElement('div');
|
| 407 |
+
ov.style.cssText = 'position:fixed;inset:0;z-index:2000;background:rgba(15,23,42,0.55);display:flex;align-items:center;justify-content:center';
|
| 408 |
+
ov.id = 'bulkImgPickerOverlay';
|
| 409 |
+
var card = document.createElement('div');
|
| 410 |
+
card.style.cssText = 'background:#fff;border-radius:14px;box-shadow:0 20px 60px rgba(0,0,0,.3);padding:20px 22px;width:min(440px,92vw);font-family:inherit';
|
| 411 |
+
var html = ''
|
| 412 |
+
+ '<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:14px">'
|
| 413 |
+
+ '<b style="font-size:.95rem;color:#0f172a">Ảnh sản phẩm</b>'
|
| 414 |
+
+ '<button type="button" id="bulkImgPickClose" style="background:none;border:none;font-size:1.2rem;color:#64748b;cursor:pointer">✕</button></div>'
|
| 415 |
+
+ '<div style="display:flex;gap:6px;margin-bottom:12px">'
|
| 416 |
+
+ '<button type="button" id="bulkImgPickFile" style="flex:1;padding:8px;border:1px solid #b8d4e8;border-radius:8px;background:#f0f9ff;color:#1e40af;cursor:pointer;font:inherit">📁 Tải lên</button>'
|
| 417 |
+
+ '<button type="button" id="bulkImgPickPaste" style="flex:1;padding:8px;border:1px solid #d8b4fe;border-radius:8px;background:#faf5ff;color:#7e22ce;cursor:pointer;font:inherit">📋 Dán ảnh</button>'
|
| 418 |
+
+ '</div>'
|
| 419 |
+
+ '<label style="display:block;font-size:.72rem;color:#475569;margin-bottom:4px">Hoặc dán link ảnh ngoài:</label>'
|
| 420 |
+
+ '<div style="display:flex;gap:6px">'
|
| 421 |
+
+ '<input id="bulkImgPickUrl" type="text" placeholder="https://…/anh.jpg (Ctrl+V để dán link)" style="flex:1;padding:7px 9px;border:1px solid #cbd5e1;border-radius:8px;font:inherit;font-size:.82rem">'
|
| 422 |
+
+ '<button type="button" id="bulkImgPickGo" style="padding:7px 12px;border:1px solid #2563eb;border-radius:8px;background:#2563eb;color:#fff;cursor:pointer;font:inherit;font-size:.82rem">Tải</button>'
|
| 423 |
+
+ '</div>'
|
| 424 |
+
+ '<div id="bulkImgPickMsg" style="margin-top:8px;font-size:.75rem;color:#94a3b8;min-height:16px"></div>';
|
| 425 |
+
card.innerHTML = html;
|
| 426 |
+
ov.appendChild(card);
|
| 427 |
+
document.body.appendChild(ov);
|
| 428 |
+
_bulkImgPick = ov;
|
| 429 |
+
var msg = card.querySelector('#bulkImgPickMsg');
|
| 430 |
+
function setMsg(t, isErr) { msg.textContent = t; msg.style.color = isErr ? '#dc2626' : '#475569'; }
|
| 431 |
+
function closePick() { if (_bulkImgPick) _bulkImgPick.style.display = 'none'; }
|
| 432 |
+
card.querySelector('#bulkImgPickClose').addEventListener('click', closePick);
|
| 433 |
+
ov.addEventListener('click', function (e) { if (e.target === ov) closePick(); });
|
| 434 |
+
card.querySelector('#bulkImgPickFile').addEventListener('click', function () {
|
| 435 |
+
var input = $('bulkRowImgInput');
|
| 436 |
+
if (!input) {
|
| 437 |
+
input = document.createElement('input');
|
| 438 |
+
input.type = 'file'; input.id = 'bulkRowImgInput'; input.accept = 'image/*';
|
| 439 |
+
input.style.display = 'none';
|
| 440 |
+
document.body.appendChild(input);
|
| 441 |
+
input.addEventListener('change', function () {
|
| 442 |
+
var f = input.files && input.files[0];
|
| 443 |
+
input.value = '';
|
| 444 |
+
if (!f || _bulkImgIdx < 0) return;
|
| 445 |
+
var r = new FileReader();
|
| 446 |
+
r.onload = function (e) { openBulkCrop(e.target.result); };
|
| 447 |
+
r.readAsDataURL(f);
|
| 448 |
+
});
|
| 449 |
+
}
|
| 450 |
+
closePick();
|
| 451 |
+
input.click();
|
| 452 |
+
});
|
| 453 |
+
function loadFromUrl(url) {
|
| 454 |
+
url = String(url || '').trim();
|
| 455 |
+
if (!url) { setMsg('Nhập link ảnh để tải.', true); return; }
|
| 456 |
+
if (!/^https?:\/\//i.test(url)) { setMsg('Link phải bắt đầu bằng http:// hoặc https://', true); return; }
|
| 457 |
+
setMsg('⏳ Đang tải ảnh từ link…');
|
| 458 |
+
var im = new Image();
|
| 459 |
+
im.crossOrigin = 'anonymous';
|
| 460 |
+
im.onload = function () {
|
| 461 |
+
// draw to canvas to (a) get a clean data-URL for the crop modal and
|
| 462 |
+
// (b) bypass CORS/canvas-tainting so toDataURL below doesn't throw.
|
| 463 |
+
var cv = document.createElement('canvas');
|
| 464 |
+
cv.width = im.naturalWidth || im.width; cv.height = im.naturalHeight || im.height;
|
| 465 |
+
var ctx = cv.getContext('2d');
|
| 466 |
+
try { ctx.drawImage(im, 0, 0); } catch (e) { setMsg('Không tải được ảnh (CORS của trang nguồn).', true); return; }
|
| 467 |
+
try {
|
| 468 |
+
var du = cv.toDataURL('image/jpeg', 0.92);
|
| 469 |
+
closePick();
|
| 470 |
+
openBulkCrop(du);
|
| 471 |
+
setMsg('');
|
| 472 |
+
} catch (e2) {
|
| 473 |
+
// CORS blocked even after draw → proxy through the app's /api/img
|
| 474 |
+
// (server-side fetch w/ permissive CORS + cache) so hotlinked CDN
|
| 475 |
+
// images that block cross-origin browser loads still work.
|
| 476 |
+
fetch('/api/img?url=' + encodeURIComponent(url)).then(function (r2) {
|
| 477 |
+
if (!r2.ok) throw new Error('proxy ' + r2.status);
|
| 478 |
+
return r2.blob();
|
| 479 |
+
}).then(function (b) {
|
| 480 |
+
var fr = new FileReader();
|
| 481 |
+
fr.onload = function () { closePick(); openBulkCrop(fr.result); };
|
| 482 |
+
fr.readAsDataURL(b);
|
| 483 |
+
}).catch(function () { setMsg('Không tải được ảnh từ link này. Thử dán trực tiếp ảnh.', true); });
|
| 484 |
+
}
|
| 485 |
+
};
|
| 486 |
+
im.onerror = function () { setMsg('Không mở được ảnh từ link. Kiểm tra link hoặc mạng.', true); };
|
| 487 |
+
im.src = url;
|
| 488 |
}
|
| 489 |
+
card.querySelector('#bulkImgPickGo').addEventListener('click', function () { loadFromUrl(card.querySelector('#bulkImgPickUrl').value); });
|
| 490 |
+
card.querySelector('#bulkImgPickUrl').addEventListener('keydown', function (e) { if (e.key === 'Enter') loadFromUrl(this.value); });
|
| 491 |
+
card.querySelector('#bulkImgPickPaste').addEventListener('click', function () {
|
| 492 |
+
setMsg('Bấm Ctrl+V (hoặc chuột phải → Dán) vào đây để dán ảnh từ clipboard.');
|
| 493 |
+
var inp = card.querySelector('#bulkImgPickUrl');
|
| 494 |
+
inp.focus(); inp.select();
|
| 495 |
+
});
|
| 496 |
+
// Paste an IMAGE (not text) → open crop directly.
|
| 497 |
+
card.addEventListener('paste', function (e) {
|
| 498 |
+
var items = (e.clipboardData && e.clipboardData.items) || [];
|
| 499 |
+
for (var k = 0; k < items.length; k++) {
|
| 500 |
+
if (items[k].type && items[k].type.indexOf('image') === 0) {
|
| 501 |
+
e.preventDefault();
|
| 502 |
+
var blob = items[k].getAsFile();
|
| 503 |
+
if (!blob) continue;
|
| 504 |
+
setMsg('⏳ Đang đọc ảnh đã dán…');
|
| 505 |
+
var fr = new FileReader();
|
| 506 |
+
fr.onload = function (ev) { closePick(); openBulkCrop(ev.target.result); };
|
| 507 |
+
fr.readAsDataURL(blob);
|
| 508 |
+
return;
|
| 509 |
+
}
|
| 510 |
+
}
|
| 511 |
+
// Otherwise treat pasted text as a URL.
|
| 512 |
+
var txt = (e.clipboardData && e.clipboardData.getData('text')) || '';
|
| 513 |
+
if (txt.trim() && /^https?:\/\//i.test(txt.trim())) { e.preventDefault(); loadFromUrl(txt); return; }
|
| 514 |
+
});
|
| 515 |
+
return _bulkImgPick;
|
| 516 |
}
|
| 517 |
+
|
| 518 |
+
// Shared crop-modal state (declared here; _bulkImgIdx is set in picker)
|
| 519 |
+
var _bulkCropIdx = -1, _bulkCropImg = null, _bulkCrop = null;
|
| 520 |
function openBulkCrop(src) {
|
| 521 |
var idx = _bulkImgIdx;
|
| 522 |
if (idx < 0) return;
|