633 lines
26 KiB
HTML
633 lines
26 KiB
HTML
{% extends "ewoooc_base.html" %}
|
|
|
|
{% block title %}比價系統 - EwoooC{% endblock %}
|
|
|
|
{% block content %}
|
|
<div class="container-fluid py-4">
|
|
<div class="row mb-4">
|
|
<div class="col">
|
|
<h2><i class="fas fa-balance-scale me-2"></i>PChome vs MOMO 比價</h2>
|
|
<p class="text-muted">比較 PChome 24h 和 MOMO 美妝商品價格</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 操作區 -->
|
|
<div class="row mb-4">
|
|
<!-- Step 1: 選擇品牌 -->
|
|
<div class="col-lg-4 mb-3">
|
|
<div class="card h-100">
|
|
<div class="card-header bg-primary text-white">
|
|
<i class="fas fa-tag me-2"></i>Step 1: 選擇品牌
|
|
</div>
|
|
<div class="card-body">
|
|
<div class="mb-3">
|
|
<select class="form-select" id="brandSelect">
|
|
<option value="">-- 選擇品牌 --</option>
|
|
</select>
|
|
</div>
|
|
<div class="mb-3">
|
|
<label class="form-label">或輸入品牌/關鍵字</label>
|
|
<input type="text" class="form-control" id="customKeyword" placeholder="如: 理膚寶水、雅漾">
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Step 2: 取得 PChome 商品 -->
|
|
<div class="col-lg-4 mb-3">
|
|
<div class="card h-100">
|
|
<div class="card-header bg-info text-white">
|
|
<i class="fas fa-shopping-cart me-2"></i>Step 2: PChome 商品
|
|
</div>
|
|
<div class="card-body">
|
|
<button class="btn btn-info w-100 mb-2" id="fetchPchomeBtn">
|
|
<i class="fas fa-download me-1"></i>自動爬取 PChome
|
|
</button>
|
|
<div class="text-center my-2"><small class="text-muted">或</small></div>
|
|
<button class="btn btn-outline-info w-100" id="manualPchomeBtn" data-bs-toggle="modal" data-bs-target="#manualInputModal" data-source="pchome">
|
|
<i class="fas fa-keyboard me-1"></i>手動輸入
|
|
</button>
|
|
<div class="mt-3">
|
|
<span class="badge bg-secondary" id="pchomeCount">0 筆商品</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Step 3: 取得 MOMO 商品 -->
|
|
<div class="col-lg-4 mb-3">
|
|
<div class="card h-100">
|
|
<div class="card-header bg-warning text-dark">
|
|
<i class="fas fa-store me-2"></i>Step 3: MOMO 商品
|
|
</div>
|
|
<div class="card-body">
|
|
<button class="btn btn-warning w-100 mb-2" id="uploadMomoBtn" data-bs-toggle="modal" data-bs-target="#uploadModal">
|
|
<i class="fas fa-file-excel me-1"></i>上傳 Excel
|
|
</button>
|
|
<div class="text-center my-2"><small class="text-muted">或</small></div>
|
|
<button class="btn btn-outline-warning w-100" id="manualMomoBtn" data-bs-toggle="modal" data-bs-target="#manualInputModal" data-source="momo">
|
|
<i class="fas fa-keyboard me-1"></i>手動輸入
|
|
</button>
|
|
<div class="mt-3">
|
|
<span class="badge bg-secondary" id="momoCount">0 筆商品</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 比價按鈕 -->
|
|
<div class="row mb-4">
|
|
<div class="col text-center">
|
|
<button class="btn btn-lg btn-success px-5" id="compareBtn" disabled>
|
|
<i class="fas fa-balance-scale me-2"></i>開始比價
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 進度顯示 -->
|
|
<div class="row mb-4" id="progressSection" style="display: none;">
|
|
<div class="col">
|
|
<div class="card">
|
|
<div class="card-body">
|
|
<div class="d-flex align-items-center">
|
|
<div class="spinner-border text-primary me-3" role="status"></div>
|
|
<div>
|
|
<strong id="progressText">處理中...</strong>
|
|
<br><small class="text-muted" id="progressDetail"></small>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 比價結果 -->
|
|
<div class="row" id="resultSection" style="display: none;">
|
|
<div class="col">
|
|
<!-- 統計卡片 -->
|
|
<div class="row mb-4">
|
|
<div class="col-md-3 mb-3">
|
|
<div class="card bg-light">
|
|
<div class="card-body text-center">
|
|
<h4 class="mb-0" id="matchedCount">0</h4>
|
|
<small class="text-muted">成功匹配</small>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-md-3 mb-3">
|
|
<div class="card bg-info text-white">
|
|
<div class="card-body text-center">
|
|
<h4 class="mb-0" id="pchomeCheaperCount">0</h4>
|
|
<small>PChome 較便宜</small>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-md-3 mb-3">
|
|
<div class="card bg-warning text-dark">
|
|
<div class="card-body text-center">
|
|
<h4 class="mb-0" id="momoCheaperCount">0</h4>
|
|
<small>MOMO 較便宜</small>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-md-3 mb-3">
|
|
<div class="card bg-secondary text-white">
|
|
<div class="card-body text-center">
|
|
<h4 class="mb-0" id="avgPriceDiff">$0</h4>
|
|
<small>平均價差</small>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 結果表格 -->
|
|
<div class="card">
|
|
<div class="card-header d-flex justify-content-between align-items-center">
|
|
<span><i class="fas fa-list me-2"></i>比價結果</span>
|
|
<div>
|
|
<button class="btn btn-sm btn-outline-primary" id="exportResultBtn">
|
|
<i class="fas fa-file-excel me-1"></i>匯出 Excel
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div class="card-body p-0">
|
|
<div class="table-responsive">
|
|
<table class="table table-hover table-striped mb-0">
|
|
<thead class="table-dark">
|
|
<tr>
|
|
<th style="width: 35%;">PChome 商品</th>
|
|
<th style="width: 35%;">MOMO 商品</th>
|
|
<th class="text-center">相似度</th>
|
|
<th class="text-end">PChome 價</th>
|
|
<th class="text-end">MOMO 價</th>
|
|
<th class="text-end">價差</th>
|
|
<th class="text-center">推薦</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="resultBody">
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 上傳 Excel Modal -->
|
|
<div class="modal fade" id="uploadModal" tabindex="-1">
|
|
<div class="modal-dialog">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title"><i class="fas fa-file-excel me-2"></i>上傳 MOMO 商品 Excel</h5>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<div class="mb-3">
|
|
<label class="form-label">選擇 Excel 檔案</label>
|
|
<input type="file" class="form-control" id="momoExcelFile" accept=".xlsx,.xls,.csv">
|
|
</div>
|
|
<div class="alert alert-info">
|
|
<i class="fas fa-info-circle me-1"></i>
|
|
Excel 需包含「商品名稱」和「售價」欄位。可選包含「商品編號」和「連結」。
|
|
</div>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button>
|
|
<button type="button" class="btn btn-warning" id="parseExcelBtn">
|
|
<i class="fas fa-upload me-1"></i>上傳並解析
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 手動輸入 Modal -->
|
|
<div class="modal fade" id="manualInputModal" tabindex="-1">
|
|
<div class="modal-dialog modal-lg">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title"><i class="fas fa-keyboard me-2"></i>手動輸入商品</h5>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<div class="alert alert-info py-2">
|
|
<i class="fas fa-info-circle me-1"></i>
|
|
<strong>格式說明:</strong> 每行一筆商品,用<strong>逗號</strong>或 <strong>Tab</strong> 分隔<br>
|
|
<code>商品名稱,價格</code> 或 <code>商品名稱,價格,商品連結(選填)</code>
|
|
</div>
|
|
<div class="mb-3">
|
|
<label class="form-label">商品資料</label>
|
|
<textarea class="form-control" id="manualInput" rows="10" placeholder="理膚寶水 多容安舒緩濕潤乳液 40ml,850,https://www.momoshop.com.tw/goods/GoodsDetail.jsp?i_code=12345
|
|
理膚寶水 B5全面修復霜 100ml,680
|
|
La Roche-Posay 安得利防曬液 50ml,920
|
|
|
|
也可以直接從 Excel 複製貼上 (Tab 分隔)"></textarea>
|
|
</div>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button>
|
|
<button type="button" class="btn btn-primary" id="parseManualBtn">
|
|
<i class="fas fa-check me-1"></i>確認
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% endblock %}
|
|
|
|
{% block extra_js %}
|
|
<script>
|
|
let pchomeProducts = [];
|
|
let momoProducts = [];
|
|
let comparisonResult = null;
|
|
let currentManualSource = null;
|
|
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
loadBrands();
|
|
bindEvents();
|
|
});
|
|
|
|
async function loadBrands() {
|
|
try {
|
|
const response = await fetchWithCSRF('/api/price_comparison/brands');
|
|
const data = await response.json();
|
|
|
|
if (data.success) {
|
|
const select = document.getElementById('brandSelect');
|
|
for (const brand of data.data.brands) {
|
|
const option = document.createElement('option');
|
|
option.value = brand.code;
|
|
option.textContent = brand.name;
|
|
select.appendChild(option);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('載入品牌失敗:', error);
|
|
}
|
|
}
|
|
|
|
function bindEvents() {
|
|
// 品牌選擇
|
|
document.getElementById('brandSelect').addEventListener('change', function() {
|
|
const selected = this.options[this.selectedIndex];
|
|
if (selected.value) {
|
|
document.getElementById('customKeyword').value = selected.textContent;
|
|
}
|
|
});
|
|
|
|
// 爬取 PChome
|
|
document.getElementById('fetchPchomeBtn').addEventListener('click', fetchPchome);
|
|
|
|
// 上傳 Excel
|
|
document.getElementById('parseExcelBtn').addEventListener('click', parseMomoExcel);
|
|
|
|
// 手動輸入來源
|
|
document.querySelectorAll('[data-bs-target="#manualInputModal"]').forEach(btn => {
|
|
btn.addEventListener('click', function() {
|
|
currentManualSource = this.dataset.source;
|
|
});
|
|
});
|
|
|
|
// 解析手動輸入
|
|
document.getElementById('parseManualBtn').addEventListener('click', parseManualInput);
|
|
|
|
// 比價
|
|
document.getElementById('compareBtn').addEventListener('click', runComparison);
|
|
|
|
// 匯出
|
|
document.getElementById('exportResultBtn').addEventListener('click', exportResult);
|
|
}
|
|
|
|
function getKeyword() {
|
|
const custom = document.getElementById('customKeyword').value.trim();
|
|
if (custom) return custom;
|
|
|
|
const select = document.getElementById('brandSelect');
|
|
return select.options[select.selectedIndex]?.textContent || '';
|
|
}
|
|
|
|
async function fetchPchome() {
|
|
const keyword = getKeyword();
|
|
if (!keyword) {
|
|
showToast('請先選擇品牌或輸入關鍵字', 'warning');
|
|
return;
|
|
}
|
|
|
|
showProgress('爬取 PChome...', `搜尋: ${keyword}`);
|
|
|
|
try {
|
|
const response = await fetchWithCSRF('/api/price_comparison/fetch_pchome', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ keyword, limit: 100 })
|
|
});
|
|
|
|
const data = await response.json();
|
|
hideProgress();
|
|
|
|
if (data.success) {
|
|
pchomeProducts = data.data.products;
|
|
document.getElementById('pchomeCount').textContent = `${pchomeProducts.length} 筆商品`;
|
|
document.getElementById('pchomeCount').classList.replace('bg-secondary', 'bg-info');
|
|
updateCompareButton();
|
|
showToast(`成功取得 ${pchomeProducts.length} 筆 PChome 商品`, 'success');
|
|
} else {
|
|
showToast(data.message, 'danger');
|
|
}
|
|
} catch (error) {
|
|
hideProgress();
|
|
showToast('爬取失敗: ' + error.message, 'danger');
|
|
}
|
|
}
|
|
|
|
async function parseMomoExcel() {
|
|
const fileInput = document.getElementById('momoExcelFile');
|
|
if (!fileInput.files.length) {
|
|
showToast('請選擇檔案', 'warning');
|
|
return;
|
|
}
|
|
|
|
const formData = new FormData();
|
|
formData.append('file', fileInput.files[0]);
|
|
|
|
try {
|
|
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
|
|
const response = await fetch('/api/price_comparison/parse_momo_excel', {
|
|
method: 'POST',
|
|
headers: { 'X-CSRFToken': csrfToken },
|
|
body: formData
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
if (data.success) {
|
|
momoProducts = data.data.products;
|
|
document.getElementById('momoCount').textContent = `${momoProducts.length} 筆商品`;
|
|
document.getElementById('momoCount').classList.replace('bg-secondary', 'bg-warning');
|
|
updateCompareButton();
|
|
bootstrap.Modal.getInstance(document.getElementById('uploadModal')).hide();
|
|
showToast(`成功解析 ${momoProducts.length} 筆 MOMO 商品`, 'success');
|
|
} else {
|
|
showToast(data.message, 'danger');
|
|
}
|
|
} catch (error) {
|
|
showToast('解析失敗: ' + error.message, 'danger');
|
|
}
|
|
}
|
|
|
|
function parseManualInput() {
|
|
const input = document.getElementById('manualInput').value.trim();
|
|
if (!input) {
|
|
showToast('請輸入商品資料', 'warning');
|
|
return;
|
|
}
|
|
|
|
const lines = input.split('\n');
|
|
const products = [];
|
|
|
|
for (let i = 0; i < lines.length; i++) {
|
|
const line = lines[i].trim();
|
|
if (!line) continue;
|
|
|
|
// 支援逗號或 Tab 分隔
|
|
let parts = line.split(',');
|
|
if (parts.length < 2) {
|
|
parts = line.split('\t');
|
|
}
|
|
if (parts.length < 2) {
|
|
showToast(`第 ${i + 1} 行格式錯誤: 請使用「商品名稱,價格」格式<br><small>例如: 理膚寶水 B5修復霜,680</small><br><small>您輸入的: ${escapeHtml(line.substring(0, 50))}...</small>`, 'warning');
|
|
return;
|
|
}
|
|
|
|
// 驗證價格是否為數字
|
|
const price = parseInt(parts[1].trim());
|
|
if (isNaN(price) || price <= 0) {
|
|
showToast(`第 ${i + 1} 行價格格式錯誤: 「${escapeHtml(parts[1].trim())}」不是有效的價格<br><small>請輸入數字,例如: 680</small>`, 'warning');
|
|
return;
|
|
}
|
|
|
|
const rawUrl = parts[2]?.trim() || '';
|
|
const productId = currentManualSource === 'momo'
|
|
? extractMomoCodeFromUrl(rawUrl)
|
|
: `manual_${i}`;
|
|
|
|
products.push({
|
|
name: parts[0].trim(),
|
|
price: price,
|
|
product_id: productId,
|
|
url: rawUrl
|
|
});
|
|
}
|
|
|
|
if (currentManualSource === 'pchome') {
|
|
pchomeProducts = products;
|
|
document.getElementById('pchomeCount').textContent = `${products.length} 筆商品`;
|
|
document.getElementById('pchomeCount').classList.replace('bg-secondary', 'bg-info');
|
|
} else {
|
|
momoProducts = products;
|
|
document.getElementById('momoCount').textContent = `${products.length} 筆商品`;
|
|
document.getElementById('momoCount').classList.replace('bg-secondary', 'bg-warning');
|
|
}
|
|
|
|
updateCompareButton();
|
|
bootstrap.Modal.getInstance(document.getElementById('manualInputModal')).hide();
|
|
document.getElementById('manualInput').value = '';
|
|
showToast(`成功新增 ${products.length} 筆商品`, 'success');
|
|
}
|
|
|
|
function updateCompareButton() {
|
|
const btn = document.getElementById('compareBtn');
|
|
btn.disabled = !(pchomeProducts.length > 0 && momoProducts.length > 0);
|
|
}
|
|
|
|
async function runComparison() {
|
|
showProgress('比價中...', '分析商品相似度');
|
|
|
|
try {
|
|
const response = await fetchWithCSRF('/api/price_comparison/quick_compare', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
pchome_products: pchomeProducts,
|
|
momo_products: momoProducts
|
|
})
|
|
});
|
|
|
|
const data = await response.json();
|
|
hideProgress();
|
|
|
|
if (data.success) {
|
|
comparisonResult = data.data;
|
|
displayResult(comparisonResult);
|
|
showToast(`比價完成,匹配 ${comparisonResult.matched_count} 筆`, 'success');
|
|
} else {
|
|
showToast(data.message, 'danger');
|
|
}
|
|
} catch (error) {
|
|
hideProgress();
|
|
showToast('比價失敗: ' + error.message, 'danger');
|
|
}
|
|
}
|
|
|
|
function displayResult(result) {
|
|
// 更新統計
|
|
document.getElementById('matchedCount').textContent = result.matched_count;
|
|
document.getElementById('pchomeCheaperCount').textContent = result.stats.pchome_cheaper_count;
|
|
document.getElementById('momoCheaperCount').textContent = result.stats.momo_cheaper_count;
|
|
document.getElementById('avgPriceDiff').textContent = `$${result.stats.avg_price_diff}`;
|
|
|
|
// 建立表格
|
|
const tbody = document.getElementById('resultBody');
|
|
tbody.innerHTML = '';
|
|
|
|
for (const m of result.matches) {
|
|
const row = document.createElement('tr');
|
|
|
|
const similarityClass = m.similarity >= 0.8 ? 'text-success' : (m.similarity >= 0.6 ? 'text-warning' : 'text-danger');
|
|
const cheaperBadge = m.cheaper_at === 'pchome'
|
|
? '<span class="badge bg-info">PChome</span>'
|
|
: (m.cheaper_at === 'momo' ? '<span class="badge bg-warning text-dark">MOMO</span>' : '<span class="badge bg-secondary">相同</span>');
|
|
|
|
// 處理 URL (可能是 url 或 product_url)
|
|
const pchomeUrl = m.pchome.url || m.pchome.product_url || '';
|
|
const momoUrl = m.momo.url || m.momo.product_url || '';
|
|
|
|
row.innerHTML = `
|
|
<td>
|
|
<div class="d-flex align-items-start">
|
|
<div class="flex-grow-1">
|
|
<small class="text-truncate d-block" style="max-width: 220px;" title="${escapeHtml(m.pchome.name)}">
|
|
${escapeHtml(m.pchome.name)}
|
|
</small>
|
|
<small class="text-muted">${m.pchome.product_id || ''}</small>
|
|
</div>
|
|
${pchomeUrl ? `<a href="${pchomeUrl}" target="_blank" class="btn btn-sm btn-outline-info ms-1" title="前往 PChome 查看"><i class="fas fa-external-link-alt"></i></a>` : ''}
|
|
</div>
|
|
</td>
|
|
<td>
|
|
<div class="d-flex align-items-start">
|
|
<div class="flex-grow-1">
|
|
<small class="text-truncate d-block" style="max-width: 220px;" title="${escapeHtml(m.momo.name)}">
|
|
${escapeHtml(m.momo.name)}
|
|
</small>
|
|
<small class="text-muted">${m.momo.product_id || ''}</small>
|
|
</div>
|
|
${momoUrl ? `<a
|
|
href="${momoUrl}"
|
|
target="_blank"
|
|
class="btn btn-sm btn-outline-warning ms-1 momo-tracked-link"
|
|
title="前往 MOMO 查看"
|
|
data-momo-original-url="${momoUrl}"
|
|
data-track-platform="momo"
|
|
data-track-source="price-comparison"
|
|
data-track-product-id="${escapeHtml((m.momo.product_id || '').toString())}"
|
|
data-track-icode="${escapeHtml((m.momo.product_id || '').toString())}"
|
|
data-track-product-name="${escapeHtml(m.momo.name)}">
|
|
<i class="fas fa-external-link-alt"></i>
|
|
</a>` : ''}
|
|
</div>
|
|
</td>
|
|
<td class="text-center ${similarityClass}">
|
|
<strong>${Math.round(m.similarity * 100)}%</strong>
|
|
</td>
|
|
<td class="text-end ${m.cheaper_at === 'pchome' ? 'text-success fw-bold' : ''}">
|
|
$${m.pchome.price.toLocaleString()}
|
|
</td>
|
|
<td class="text-end ${m.cheaper_at === 'momo' ? 'text-warning fw-bold' : ''}">
|
|
$${m.momo.price.toLocaleString()}
|
|
</td>
|
|
<td class="text-end ${m.price_diff < 0 ? 'text-success' : (m.price_diff > 0 ? 'text-danger' : '')}">
|
|
${m.price_diff > 0 ? '+' : ''}$${m.price_diff}
|
|
<br><small>(${m.price_diff_percent}%)</small>
|
|
</td>
|
|
<td class="text-center">
|
|
${cheaperBadge}
|
|
</td>
|
|
`;
|
|
tbody.appendChild(row);
|
|
}
|
|
|
|
document.getElementById('resultSection').style.display = 'block';
|
|
}
|
|
|
|
function exportResult() {
|
|
if (!comparisonResult || !comparisonResult.matches.length) {
|
|
showToast('沒有資料可匯出', 'warning');
|
|
return;
|
|
}
|
|
|
|
const headers = ['PChome商品', 'MOMO商品', '相似度%', 'PChome價', 'MOMO價', '價差', '較便宜'];
|
|
const rows = comparisonResult.matches.map(m => [
|
|
`"${m.pchome.name.replace(/"/g, '""')}"`,
|
|
`"${m.momo.name.replace(/"/g, '""')}"`,
|
|
Math.round(m.similarity * 100),
|
|
m.pchome.price,
|
|
m.momo.price,
|
|
m.price_diff,
|
|
m.cheaper_at
|
|
]);
|
|
|
|
const csv = '\uFEFF' + [headers.join(','), ...rows.map(r => r.join(','))].join('\n');
|
|
downloadFile(csv, 'price_comparison.csv', 'text/csv;charset=utf-8');
|
|
}
|
|
|
|
function showProgress(title, detail) {
|
|
document.getElementById('progressText').textContent = title;
|
|
document.getElementById('progressDetail').textContent = detail;
|
|
document.getElementById('progressSection').style.display = 'block';
|
|
}
|
|
|
|
function hideProgress() {
|
|
document.getElementById('progressSection').style.display = 'none';
|
|
}
|
|
|
|
function downloadFile(content, filename, type) {
|
|
const blob = new Blob([content], { type });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = filename;
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
}
|
|
|
|
function escapeHtml(text) {
|
|
const div = document.createElement('div');
|
|
div.textContent = text;
|
|
return div.innerHTML;
|
|
}
|
|
|
|
function extractMomoCodeFromUrl(url) {
|
|
const target = (url || '').trim();
|
|
if (!target) {
|
|
return '';
|
|
}
|
|
try {
|
|
const parsed = new URL(target, location.origin);
|
|
const iCode = parsed.searchParams.get('i_code');
|
|
if (iCode) {
|
|
return iCode.trim();
|
|
}
|
|
} catch (error) {
|
|
// ignore
|
|
}
|
|
const match = /[?&]i_code=([^&#]+)/i.exec(target);
|
|
return match ? decodeURIComponent(match[1] || '').trim() : '';
|
|
}
|
|
|
|
function showToast(message, type = 'info') {
|
|
const toast = document.createElement('div');
|
|
toast.className = `alert alert-${type} position-fixed`;
|
|
toast.style.cssText = 'top: 20px; right: 20px; z-index: 9999; min-width: 300px;';
|
|
toast.innerHTML = `
|
|
<button type="button" class="btn-close float-end" onclick="this.parentElement.remove()"></button>
|
|
${message}
|
|
`;
|
|
document.body.appendChild(toast);
|
|
setTimeout(() => toast.remove(), 5000);
|
|
}
|
|
</script>
|
|
{% endblock %}
|