feat: show product identity in ai recommendations
All checks were successful
CD Pipeline / deploy (push) Successful in 1m5s
All checks were successful
CD Pipeline / deploy (push) Successful in 1m5s
This commit is contained in:
@@ -402,7 +402,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '')
|
|||||||
# ==========================================
|
# ==========================================
|
||||||
# 系統版本與路徑
|
# 系統版本與路徑
|
||||||
# ==========================================
|
# ==========================================
|
||||||
SYSTEM_VERSION = "V10.713"
|
SYSTEM_VERSION = "V10.714"
|
||||||
LOG_FILE_PATH = os.path.join(BASE_DIR, 'logs/system.log')
|
LOG_FILE_PATH = os.path.join(BASE_DIR, 'logs/system.log')
|
||||||
public_url = PUBLIC_URL # 用於模板顯示
|
public_url = PUBLIC_URL # 用於模板顯示
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
> **最後更新**: 2026-06-26 (台北時間)
|
> **最後更新**: 2026-06-26 (台北時間)
|
||||||
> **狀態**: 🟢 四 AI Agent 自動化閉環已落地;LLM 路由紅線升級為 Ollama-first 三主機級聯;PChome 後台業績匯入韌性已補強;產品定位正名為「PChome 業績成長自動化作戰系統」;外部市場來源正規化層、自動同步、作戰清單與價格參考表優先讀取、CSV 備援預檢、前台操作入口、高可見頁面繁中化守門、比價/作戰 UI 工作台化、跨平台來源治理與商品身份 UI 契約已建立,GCP embedding 熔斷延後處理、110 proxy rescue 與 direct host health skip 已建立
|
> **狀態**: 🟢 四 AI Agent 自動化閉環已落地;LLM 路由紅線升級為 Ollama-first 三主機級聯;PChome 後台業績匯入韌性已補強;產品定位正名為「PChome 業績成長自動化作戰系統」;外部市場來源正規化層、自動同步、作戰清單與價格參考表優先讀取、CSV 備援預檢、前台操作入口、高可見頁面繁中化守門、比價/作戰 UI 工作台化、跨平台來源治理與商品身份 UI 契約已建立,GCP embedding 熔斷延後處理、110 proxy rescue 與 direct host health skip 已建立
|
||||||
> **適用版本**: V10.713
|
> **適用版本**: V10.714
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -798,3 +798,4 @@ POSTGRES_HOST=momo-db
|
|||||||
| 2026-06-26 | 前台不得用爬蟲當使用者主語 | V10.711 起 PChome、設定、舊入口、market intel 與任務確認文字統一使用「商品監控 / 資料擷取 / 監控來源」,不再把「爬蟲」當頁首、導覽、CTA 或提示主語;內部 route/key 可保留以降低部署風險。 |
|
| 2026-06-26 | 前台不得用爬蟲當使用者主語 | V10.711 起 PChome、設定、舊入口、market intel 與任務確認文字統一使用「商品監控 / 資料擷取 / 監控來源」,不再把「爬蟲」當頁首、導覽、CTA 或提示主語;內部 route/key 可保留以降低部署風險。 |
|
||||||
| 2026-06-26 | 匯入與設定頁不得回吐 raw 後端錯誤 | V10.712 起自動匯入與系統設定頁的未知錯誤 fallback 只顯示使用者可執行的下一步;Google Drive、格式、同步與資料處理異常可分類成營運文案,但不得把 SQL、snapshot、資料表欄位、driver error 或原始 exception 直接顯示在前台。 |
|
| 2026-06-26 | 匯入與設定頁不得回吐 raw 後端錯誤 | V10.712 起自動匯入與系統設定頁的未知錯誤 fallback 只顯示使用者可執行的下一步;Google Drive、格式、同步與資料處理異常可分類成營運文案,但不得把 SQL、snapshot、資料表欄位、driver error 或原始 exception 直接顯示在前台。 |
|
||||||
| 2026-06-26 | 正式容器不得繼承互動式 Google Drive OAuth | V10.713 起 `momo-app`、`momo-scheduler`、`momo-telegram-bot` 的 `GOOGLE_DRIVE_ALLOW_INTERACTIVE_AUTH` 固定為 `false`,不再允許 `.env` 在主機重啟後把排程帶回瀏覽器 OAuth;人工授權只可用一次性腳本完成,正式匯入缺 token 時必須 fail-closed 並回可處理訊息。 |
|
| 2026-06-26 | 正式容器不得繼承互動式 Google Drive OAuth | V10.713 起 `momo-app`、`momo-scheduler`、`momo-telegram-bot` 的 `GOOGLE_DRIVE_ALLOW_INTERACTIVE_AUTH` 固定為 `false`,不再允許 `.env` 在主機重啟後把排程帶回瀏覽器 OAuth;人工授權只可用一次性腳本完成,正式匯入缺 token 時必須 fail-closed 並回可處理訊息。 |
|
||||||
|
| 2026-06-26 | AI 銷售建議商品區塊必須商品身份優先 | V10.714 起 `/ai_recommend` 熱銷商品參考改為商品卡,後端 bestseller API 回傳平台、商品 ID、賣場連結與圖片欄位;前端必須顯示縮圖、平台、商品 ID、價格與開賣場操作,缺圖/缺連結需顯示待補狀態,並把商品 ID / URL 帶入銷售建議上下文。 |
|
||||||
|
|||||||
@@ -773,11 +773,16 @@ def get_momo_bestsellers(category: str, limit: int = 5) -> Tuple[bool, str, List
|
|||||||
result = []
|
result = []
|
||||||
for p in products[:limit]:
|
for p in products[:limit]:
|
||||||
result.append({
|
result.append({
|
||||||
|
'id': p.product_id,
|
||||||
|
'product_id': p.product_id,
|
||||||
|
'platform': 'momo',
|
||||||
'name': p.name,
|
'name': p.name,
|
||||||
'price': p.price,
|
'price': p.price,
|
||||||
'original_price': p.original_price,
|
'original_price': p.original_price,
|
||||||
'discount': p.discount,
|
'discount': p.discount,
|
||||||
'url': p.product_url,
|
'url': p.product_url,
|
||||||
|
'product_url': p.product_url,
|
||||||
|
'image_url': p.image_url,
|
||||||
'image': p.image_url
|
'image': p.image_url
|
||||||
})
|
})
|
||||||
return True, f"成功取得 {len(result)} 個熱銷商品", result
|
return True, f"成功取得 {len(result)} 個熱銷商品", result
|
||||||
|
|||||||
@@ -563,11 +563,16 @@ def get_pchome_bestsellers(category: str, limit: int = 5) -> Tuple[bool, str, Li
|
|||||||
result = []
|
result = []
|
||||||
for p in products[:limit]:
|
for p in products[:limit]:
|
||||||
result.append({
|
result.append({
|
||||||
|
'id': p.product_id,
|
||||||
|
'product_id': p.product_id,
|
||||||
|
'platform': 'pchome',
|
||||||
'name': p.name,
|
'name': p.name,
|
||||||
'price': p.price,
|
'price': p.price,
|
||||||
'original_price': p.original_price,
|
'original_price': p.original_price,
|
||||||
'discount': p.discount,
|
'discount': p.discount,
|
||||||
'url': p.product_url,
|
'url': p.product_url,
|
||||||
|
'product_url': p.product_url,
|
||||||
|
'image_url': p.image_url,
|
||||||
'image': p.image_url
|
'image': p.image_url
|
||||||
})
|
})
|
||||||
return True, f"成功取得 {len(result)} 個熱銷商品", result
|
return True, f"成功取得 {len(result)} 個熱銷商品", result
|
||||||
|
|||||||
@@ -61,7 +61,7 @@
|
|||||||
<ul class="dropdown-menu dropdown-menu-end ar-dropdown">
|
<ul class="dropdown-menu dropdown-menu-end ar-dropdown">
|
||||||
<li><h6 class="dropdown-header">點選分類快速填入</h6></li>
|
<li><h6 class="dropdown-header">點選分類快速填入</h6></li>
|
||||||
{% for category in product_categories %}
|
{% for category in product_categories %}
|
||||||
<li><a class="dropdown-item" href="#" onclick="setProduct('{{ category }}')">{{ category }}</a></li>
|
<li><a class="dropdown-item" href="#" onclick="setProduct({{ category|tojson }})">{{ category }}</a></li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
@@ -277,14 +277,14 @@
|
|||||||
<header class="card-header ar-card__head ar-card__head--soft d-flex justify-content-between align-items-center py-2">
|
<header class="card-header ar-card__head ar-card__head--soft d-flex justify-content-between align-items-center py-2">
|
||||||
<div>
|
<div>
|
||||||
<h6 class="mb-0"><i class="fas fa-fire-alt me-2"></i>熱銷商品參考</h6>
|
<h6 class="mb-0"><i class="fas fa-fire-alt me-2"></i>熱銷商品參考</h6>
|
||||||
<small class="text-muted">點選商品快速填入</small>
|
<small class="text-muted">商品 ID、圖片與賣場連結可一眼確認</small>
|
||||||
</div>
|
</div>
|
||||||
<div class="d-flex align-items-center gap-1">
|
<div class="d-flex align-items-center gap-1">
|
||||||
<div class="btn-group btn-group-sm">
|
<div class="btn-group btn-group-sm">
|
||||||
<input type="radio" class="btn-check" name="platform" id="platformPchome" value="pchome" checked>
|
<input type="radio" class="btn-check" name="platform" id="platformPchome" value="pchome" checked>
|
||||||
<label class="btn btn-outline-primary btn-sm px-2" for="platformPchome">PChome</label>
|
<label class="btn btn-outline-primary btn-sm px-2" for="platformPchome">PChome</label>
|
||||||
<input type="radio" class="btn-check" name="platform" id="platformMomo" value="momo" disabled>
|
<input type="radio" class="btn-check" name="platform" id="platformMomo" value="momo">
|
||||||
<label class="btn btn-outline-secondary btn-sm px-2 ar-disabled" for="platformMomo">MOMO</label>
|
<label class="btn btn-outline-primary btn-sm px-2" for="platformMomo">MOMO</label>
|
||||||
</div>
|
</div>
|
||||||
<select class="form-select form-select-sm" id="bestsellersCategory" style="width: 80px;" onchange="loadBestsellers()">
|
<select class="form-select form-select-sm" id="bestsellersCategory" style="width: 80px;" onchange="loadBestsellers()">
|
||||||
<option value="面膜">面膜</option>
|
<option value="面膜">面膜</option>
|
||||||
|
|||||||
@@ -841,11 +841,22 @@ def test_ai_recommend_uses_v2_shell_and_runtime_category_data():
|
|||||||
assert "ai-recommend-page" in template
|
assert "ai-recommend-page" in template
|
||||||
assert "{% for category in product_categories[:4] %}" in template
|
assert "{% for category in product_categories[:4] %}" in template
|
||||||
assert "quickWebSearch({{ category|tojson }})" in template
|
assert "quickWebSearch({{ category|tojson }})" in template
|
||||||
|
assert "setProduct({{ category|tojson }})" in template
|
||||||
assert "quickWebSearch('保濕面膜')" not in template
|
assert "quickWebSearch('保濕面膜')" not in template
|
||||||
assert "fetch('/api/ai/generate_copy'" in page_js
|
assert "fetch('/api/ai/generate_copy'" in page_js
|
||||||
assert "fetch('/api/ai/web_search'" in page_js
|
assert "fetch('/api/ai/web_search'" in page_js
|
||||||
assert "fetch('/api/ai/product_insights'" in page_js
|
assert "fetch('/api/ai/product_insights'" in page_js
|
||||||
assert "fetch('/api/ai/gemini_usage?days=30')" in page_js
|
assert "fetch('/api/ai/gemini_usage?days=30')" in page_js
|
||||||
|
assert "renderBestsellerCard" in page_js
|
||||||
|
assert "ar-product-card__img" in page_js
|
||||||
|
assert "待補圖片" in page_js
|
||||||
|
assert "商品 ID" in page_js
|
||||||
|
assert "開賣場" in page_js
|
||||||
|
assert "product_id: el.dataset.productId" in page_js
|
||||||
|
assert "Object.assign(window" in page_js
|
||||||
|
assert "setProductFromCard" in page_js
|
||||||
|
assert "商品 ID、圖片與賣場連結可一眼確認" in template
|
||||||
|
assert 'id="platformMomo" value="momo">' in template
|
||||||
assert "mock" not in template.lower()
|
assert "mock" not in template.lower()
|
||||||
assert "假商品" not in template
|
assert "假商品" not in template
|
||||||
assert "PChome 銷售建議" in template
|
assert "PChome 銷售建議" in template
|
||||||
@@ -895,6 +906,8 @@ def test_ai_recommend_uses_v2_shell_and_runtime_category_data():
|
|||||||
assert "@ai_bp.route('/api/ai/generate_copy'" in route_source
|
assert "@ai_bp.route('/api/ai/generate_copy'" in route_source
|
||||||
assert "@ai_bp.route('/api/ai/web_search'" in route_source
|
assert "@ai_bp.route('/api/ai/web_search'" in route_source
|
||||||
assert "@ai_bp.route('/api/ai/product_insights'" in route_source
|
assert "@ai_bp.route('/api/ai/product_insights'" in route_source
|
||||||
|
assert "'product_id': p.product_id" in (ROOT / "services/pchome_crawler.py").read_text(encoding="utf-8")
|
||||||
|
assert "'product_id': p.product_id" in (ROOT / "services/momo_crawler.py").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
def test_monthly_summary_analysis_uses_v2_shell_and_real_monthly_api():
|
def test_monthly_summary_analysis_uses_v2_shell_and_real_monthly_api():
|
||||||
|
|||||||
@@ -123,6 +123,113 @@
|
|||||||
.ai-recommend-page .ar-card--insights { border-color: var(--momo-warm-caramel, #c96442) !important; }
|
.ai-recommend-page .ar-card--insights { border-color: var(--momo-warm-caramel, #c96442) !important; }
|
||||||
.ai-recommend-page .ar-card--trends { border-color: var(--momo-warm-olive, #6f7a4a) !important; }
|
.ai-recommend-page .ar-card--trends { border-color: var(--momo-warm-olive, #6f7a4a) !important; }
|
||||||
|
|
||||||
|
/* ── Product identity cards ────────────────────────── */
|
||||||
|
.ai-recommend-page .ar-product-card {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1.6rem 4.5rem minmax(0, 1fr) auto;
|
||||||
|
gap: 0.7rem;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 5.25rem;
|
||||||
|
padding: 0.7rem 0.8rem;
|
||||||
|
border-bottom: 1px solid var(--momo-border-subtle);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.ai-recommend-page .ar-product-card:hover {
|
||||||
|
background: color-mix(in srgb, var(--momo-page-accent) 5%, var(--momo-surface));
|
||||||
|
}
|
||||||
|
.ai-recommend-page .ar-product-card__rank {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 1.55rem;
|
||||||
|
height: 1.55rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--momo-text-strong);
|
||||||
|
color: var(--momo-on-accent, #fff8ef);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
.ai-recommend-page .ar-product-card__media {
|
||||||
|
width: 4.5rem;
|
||||||
|
height: 4.5rem;
|
||||||
|
}
|
||||||
|
.ai-recommend-page .ar-product-card__img,
|
||||||
|
.ai-recommend-page .ar-product-card__missing-img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border: 1px solid var(--momo-border-subtle);
|
||||||
|
border-radius: var(--momo-radius-sm, 6px);
|
||||||
|
background: var(--momo-surface-2);
|
||||||
|
}
|
||||||
|
.ai-recommend-page .ar-product-card__img {
|
||||||
|
display: block;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
.ai-recommend-page .ar-product-card__missing-img {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.2rem;
|
||||||
|
color: var(--momo-text-tertiary);
|
||||||
|
font-size: 0.68rem;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
.ai-recommend-page .ar-product-card__body {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.ai-recommend-page .ar-product-card__meta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
.ai-recommend-page .ar-product-card__id {
|
||||||
|
color: var(--momo-text-tertiary);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
.ai-recommend-page .ar-product-card__name {
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--momo-text-strong);
|
||||||
|
font-size: 0.86rem;
|
||||||
|
font-weight: 800;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.ai-recommend-page .ar-product-card__price {
|
||||||
|
margin-top: 0.2rem;
|
||||||
|
color: var(--momo-warm-rose, #a84428);
|
||||||
|
font-size: 0.86rem;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
.ai-recommend-page .ar-product-card__actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
min-width: 5.25rem;
|
||||||
|
}
|
||||||
|
.ai-recommend-page .ar-product-card__link {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.ai-recommend-page .ar-product-card__pending {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.ai-recommend-page .ar-product-card {
|
||||||
|
grid-template-columns: 1.6rem 3.8rem minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
.ai-recommend-page .ar-product-card__media {
|
||||||
|
width: 3.8rem;
|
||||||
|
height: 3.8rem;
|
||||||
|
}
|
||||||
|
.ai-recommend-page .ar-product-card__actions {
|
||||||
|
grid-column: 2 / -1;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Dropdown / quick tags / keywords ──────────────── */
|
/* ── Dropdown / quick tags / keywords ──────────────── */
|
||||||
.ai-recommend-page .ar-dropdown { max-height: 300px; overflow-y: auto; }
|
.ai-recommend-page .ar-dropdown { max-height: 300px; overflow-y: auto; }
|
||||||
.ai-recommend-page .ar-quick-tags { display: flex; flex-wrap: wrap; gap: 6px; }
|
.ai-recommend-page .ar-quick-tags { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||||
|
|||||||
@@ -49,7 +49,7 @@
|
|||||||
// 載入熱銷商品
|
// 載入熱銷商品
|
||||||
function loadBestsellers() {
|
function loadBestsellers() {
|
||||||
const category = document.getElementById('bestsellersCategory').value;
|
const category = document.getElementById('bestsellersCategory').value;
|
||||||
const platform = document.querySelector('input[name="platform"]:checked')?.value || 'momo';
|
const platform = document.querySelector('input[name="platform"]:checked')?.value || 'pchome';
|
||||||
const container = document.getElementById('bestsellersCard');
|
const container = document.getElementById('bestsellersCard');
|
||||||
|
|
||||||
container.innerHTML = '<div class="text-center py-3"><div class="spinner-border spinner-border-sm"></div></div>';
|
container.innerHTML = '<div class="text-center py-3"><div class="spinner-border spinner-border-sm"></div></div>';
|
||||||
@@ -61,16 +61,8 @@
|
|||||||
})
|
})
|
||||||
.then(data => {
|
.then(data => {
|
||||||
if (data.success && data.data.products?.length) {
|
if (data.success && data.data.products?.length) {
|
||||||
container.innerHTML = data.data.products.map((p, i) => `
|
container.innerHTML = data.data.products.map((p, i) => renderBestsellerCard(p, i, data.data.platform)).join('')
|
||||||
<div class="d-flex align-items-center px-3 py-2 border-bottom" style="cursor: pointer;" onclick="setProduct('${escapeHtml(p.name)}')">
|
+ `<div class="text-center text-muted small py-1">${escapeHtml(data.data.source || '')}</div>`;
|
||||||
<span class="badge bg-secondary me-2">${i + 1}</span>
|
|
||||||
<div class="flex-grow-1 overflow-hidden">
|
|
||||||
<small class="text-truncate d-block">${p.name}</small>
|
|
||||||
<small class="text-muted">$${p.price?.toLocaleString() || 'N/A'}</small>
|
|
||||||
</div>
|
|
||||||
<a href="${p.url}" target="_blank" class="btn btn-sm btn-outline-secondary" onclick="event.stopPropagation()" title="前往電商網站查看商品"><i class="fas fa-external-link-alt"></i></a>
|
|
||||||
</div>
|
|
||||||
`).join('') + `<div class="text-center text-muted small py-1">${data.data.source}</div>`;
|
|
||||||
} else {
|
} else {
|
||||||
container.innerHTML = '<p class="text-muted text-center py-3 mb-0">無法載入熱銷商品</p>';
|
container.innerHTML = '<p class="text-muted text-center py-3 mb-0">無法載入熱銷商品</p>';
|
||||||
}
|
}
|
||||||
@@ -78,6 +70,54 @@
|
|||||||
.catch(e => container.innerHTML = '<p class="text-danger text-center py-3 mb-0">載入失敗</p>');
|
.catch(e => container.innerHTML = '<p class="text-danger text-center py-3 mb-0">載入失敗</p>');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeBestsellerProduct(product, platform) {
|
||||||
|
const p = product || {};
|
||||||
|
return {
|
||||||
|
name: String(p.name || '').trim(),
|
||||||
|
productId: String(p.product_id || p.id || p.i_code || p.goodsCode || '').trim(),
|
||||||
|
platform: String(p.platform || platform || '').toLowerCase(),
|
||||||
|
price: Number(p.price || 0),
|
||||||
|
url: String(p.product_url || p.url || '').trim(),
|
||||||
|
imageUrl: String(p.image_url || p.image || '').trim()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function platformLabel(platform) {
|
||||||
|
return String(platform || '').toLowerCase() === 'momo' ? 'MOMO' : 'PChome';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderProductThumb(product) {
|
||||||
|
const image = product.imageUrl;
|
||||||
|
if (image && /^https?:\/\//i.test(image)) {
|
||||||
|
return `<img class="ar-product-card__img" src="${escapeHtml(image)}" alt="${escapeHtml(product.name)}" loading="lazy" referrerpolicy="no-referrer">`;
|
||||||
|
}
|
||||||
|
return `<div class="ar-product-card__missing-img"><i class="fas fa-image"></i><span>待補圖片</span></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderBestsellerCard(product, index, platform) {
|
||||||
|
const p = normalizeBestsellerProduct(product, platform);
|
||||||
|
const label = platformLabel(p.platform);
|
||||||
|
const price = p.price > 0 ? `NT$ ${p.price.toLocaleString()}` : '待補價格';
|
||||||
|
const productId = p.productId || '待補';
|
||||||
|
const storeAction = p.url
|
||||||
|
? `<a href="${escapeHtml(p.url)}" target="_blank" rel="noopener noreferrer" class="btn btn-sm btn-outline-primary ar-product-card__link" onclick="event.stopPropagation()" title="開啟賣場"><i class="fas fa-up-right-from-square me-1"></i>開賣場</a>`
|
||||||
|
: '<span class="badge bg-light text-muted border ar-product-card__pending">待補連結</span>';
|
||||||
|
return `
|
||||||
|
<div class="ar-product-card" data-product-name="${escapeHtml(p.name)}" data-product-id="${escapeHtml(p.productId)}" data-product-url="${escapeHtml(p.url)}" data-platform="${escapeHtml(p.platform)}" data-price="${p.price}" onclick="setProductFromCard(this)">
|
||||||
|
<div class="ar-product-card__rank">${index + 1}</div>
|
||||||
|
<div class="ar-product-card__media">${renderProductThumb(p)}</div>
|
||||||
|
<div class="ar-product-card__body">
|
||||||
|
<div class="ar-product-card__meta">
|
||||||
|
<span class="badge bg-light text-dark border">${label}</span>
|
||||||
|
<span class="ar-product-card__id">商品 ID ${escapeHtml(productId)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="ar-product-card__name">${escapeHtml(p.name || '待補商品名稱')}</div>
|
||||||
|
<div class="ar-product-card__price">${price}</div>
|
||||||
|
</div>
|
||||||
|
<div class="ar-product-card__actions">${storeAction}</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
// 載入 COSME 排行榜
|
// 載入 COSME 排行榜
|
||||||
function loadCosmeRankings() {
|
function loadCosmeRankings() {
|
||||||
const category = document.getElementById('cosmeCategory').value;
|
const category = document.getElementById('cosmeCategory').value;
|
||||||
@@ -93,7 +133,7 @@
|
|||||||
.then(data => {
|
.then(data => {
|
||||||
if (data.success && data.data.products?.length) {
|
if (data.success && data.data.products?.length) {
|
||||||
container.innerHTML = data.data.products.map((p, i) => `
|
container.innerHTML = data.data.products.map((p, i) => `
|
||||||
<div class="d-flex align-items-center px-2 py-1 border-bottom" style="cursor: pointer;" onclick="setProduct('${escapeHtml(p.name)}')">
|
<div class="d-flex align-items-center px-2 py-1 border-bottom" style="cursor: pointer;" data-product-name="${escapeHtml(p.name)}" onclick="setProductFromCard(this)">
|
||||||
<span class="badge ${i < 3 ? 'bg-warning text-dark' : 'bg-secondary'} me-2" style="font-size: 0.7rem;">${p.rank}</span>
|
<span class="badge ${i < 3 ? 'bg-warning text-dark' : 'bg-secondary'} me-2" style="font-size: 0.7rem;">${p.rank}</span>
|
||||||
<div class="flex-grow-1 overflow-hidden">
|
<div class="flex-grow-1 overflow-hidden">
|
||||||
<small class="text-truncate d-block" style="font-size: 0.8rem;">${p.brand} ${p.name}</small>
|
<small class="text-truncate d-block" style="font-size: 0.8rem;">${p.brand} ${p.name}</small>
|
||||||
@@ -123,7 +163,7 @@
|
|||||||
.then(data => {
|
.then(data => {
|
||||||
if (data.success && data.data.articles?.length) {
|
if (data.success && data.data.articles?.length) {
|
||||||
container.innerHTML = data.data.articles.map(a => `
|
container.innerHTML = data.data.articles.map(a => `
|
||||||
<div class="d-flex align-items-center px-2 py-1 border-bottom" style="cursor: pointer;" onclick="setProduct('${escapeHtml(a.title)}')">
|
<div class="d-flex align-items-center px-2 py-1 border-bottom" style="cursor: pointer;" data-product-name="${escapeHtml(a.title)}" onclick="setProductFromCard(this)">
|
||||||
<i class="fas fa-star text-success me-2" style="font-size: 0.7rem;"></i>
|
<i class="fas fa-star text-success me-2" style="font-size: 0.7rem;"></i>
|
||||||
<div class="flex-grow-1 overflow-hidden">
|
<div class="flex-grow-1 overflow-hidden">
|
||||||
<small class="text-truncate d-block" style="font-size: 0.8rem;">${a.title}</small>
|
<small class="text-truncate d-block" style="font-size: 0.8rem;">${a.title}</small>
|
||||||
@@ -212,7 +252,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
container.innerHTML = items.slice(0, 20).map(n => `
|
container.innerHTML = items.slice(0, 20).map(n => `
|
||||||
<div class="d-flex align-items-center px-3 py-2 border-bottom news-item" style="cursor: pointer;" onclick="setProduct('${escapeHtml(n.title || n.query || '')}')">
|
<div class="d-flex align-items-center px-3 py-2 border-bottom news-item" style="cursor: pointer;" data-product-name="${escapeHtml(n.title || n.query || '')}" onclick="setProductFromCard(this)">
|
||||||
<i class="fas ${icon} ${iconClass} me-2"></i>
|
<i class="fas ${icon} ${iconClass} me-2"></i>
|
||||||
<div class="flex-grow-1 overflow-hidden">
|
<div class="flex-grow-1 overflow-hidden">
|
||||||
<small class="text-truncate d-block fw-medium">${n.title || n.query || ''}</small>
|
<small class="text-truncate d-block fw-medium">${n.title || n.query || ''}</small>
|
||||||
@@ -319,6 +359,11 @@
|
|||||||
document.getElementById('productName').value = name.substring(0, 100);
|
document.getElementById('productName').value = name.substring(0, 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setProductFromCard(card) {
|
||||||
|
const name = card?.dataset?.productName || '';
|
||||||
|
if (name) setProduct(name);
|
||||||
|
}
|
||||||
|
|
||||||
// 切換關鍵字
|
// 切換關鍵字
|
||||||
function toggleKeyword(el) {
|
function toggleKeyword(el) {
|
||||||
el.classList.toggle('is-selected');
|
el.classList.toggle('is-selected');
|
||||||
@@ -347,12 +392,16 @@
|
|||||||
|
|
||||||
// 取得熱銷商品資訊
|
// 取得熱銷商品資訊
|
||||||
function getBestsellersForAPI() {
|
function getBestsellersForAPI() {
|
||||||
const items = document.querySelectorAll('#bestsellersCard > div.d-flex');
|
const items = document.querySelectorAll('#bestsellersCard .ar-product-card');
|
||||||
return Array.from(items).slice(0, 3).map(el => {
|
return Array.from(items).slice(0, 3).map(el => {
|
||||||
const name = el.querySelector('small.text-truncate')?.textContent || '';
|
const price = parseInt(el.dataset.price || '0', 10) || 0;
|
||||||
const priceText = el.querySelector('small.text-muted')?.textContent || '';
|
return {
|
||||||
const price = parseInt(priceText.replace(/[^0-9]/g, '')) || 0;
|
name: el.dataset.productName || '',
|
||||||
return { name, price };
|
price,
|
||||||
|
product_id: el.dataset.productId || '',
|
||||||
|
platform: el.dataset.platform || '',
|
||||||
|
url: el.dataset.productUrl || ''
|
||||||
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1001,6 +1050,27 @@
|
|||||||
document.getElementById('productName').value = title;
|
document.getElementById('productName').value = title;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Object.assign(window, {
|
||||||
|
addKeywordFromInsight,
|
||||||
|
copyCopyText,
|
||||||
|
doProductInsights,
|
||||||
|
doWebSearch,
|
||||||
|
generateCopy,
|
||||||
|
loadBestsellers,
|
||||||
|
loadCosmeRankings,
|
||||||
|
loadMybestArticles,
|
||||||
|
loadTrends,
|
||||||
|
onProviderChange,
|
||||||
|
quickWebSearch,
|
||||||
|
refreshTrends,
|
||||||
|
setProduct,
|
||||||
|
setProductFromCard,
|
||||||
|
switchTrendTab,
|
||||||
|
toggleKeyword,
|
||||||
|
useTrendForProduct,
|
||||||
|
useTrendKeyword
|
||||||
|
});
|
||||||
|
|
||||||
// 初始化
|
// 初始化
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
initAIProvider();
|
initAIProvider();
|
||||||
|
|||||||
Reference in New Issue
Block a user