// saas-search Frontend - Modern UI (Multi-Tenant) // API 基础地址策略(优先级从高到低): // 1. window.API_BASE_URL:若在 HTML / 部署层显式注入,则直接使用(可为绝对 URL 或相对路径,如 "/api") // 2. 默认:空前缀(同源调用),由 6003 前端服务在服务端转发到本机 6002 // // 说明:在“外网 web 服务器对 6002 另加认证”的场景下, // 浏览器不应直接调用 web:6002,而应调用同源 web:6003/search/*, // 再由 frontend_server.py 代理到 GPU 机本地 6002。 (function initApiBaseUrl() { let base; if (window.API_BASE_URL) { base = window.API_BASE_URL; } else { base = ''; } window.API_BASE_URL = base; const apiUrlEl = document.getElementById('apiUrl'); if (apiUrlEl) { apiUrlEl.textContent = base || '(same-origin)'; } })(); const API_BASE_URL = window.API_BASE_URL; // Get tenant ID from select function getTenantId() { const tenantSelect = document.getElementById('tenantSelect'); if (tenantSelect) { return tenantSelect.value.trim(); } return ''; } // Get sku_filter_dimension (as list) from input function getSkuFilterDimension() { const skuFilterInput = document.getElementById('skuFilterDimension'); if (skuFilterInput) { const value = skuFilterInput.value.trim(); if (!value.length) { return null; } // 支持用逗号分隔多个维度,例如:color,size 或 option1,color const parts = value.split(',').map(v => v.trim()).filter(v => v.length > 0); return parts.length > 0 ? parts : null; } return null; } // State Management let state = { query: '', currentPage: 1, pageSize: 20, totalResults: 0, filters: {}, rangeFilters: {}, sortBy: '', sortOrder: 'desc', facets: null, lastSearchData: null, debug: true // Always enable debug mode for test frontend }; // 弹层:展示 /search/ 返回的 results[] 单条元素(非 ES 原始文档) function openApiResultViewer(item) { let backdrop = document.getElementById('apiResultViewerBackdrop'); if (!backdrop) { backdrop = document.createElement('div'); backdrop.id = 'apiResultViewerBackdrop'; backdrop.className = 'api-result-viewer-backdrop'; backdrop.innerHTML = ` `; document.body.appendChild(backdrop); backdrop.addEventListener('click', (e) => { if (e.target === backdrop) { closeApiResultViewer(); } }); backdrop.querySelector('.api-result-viewer-close').addEventListener('click', closeApiResultViewer); document.addEventListener('keydown', (e) => { if (e.key === 'Escape') { closeApiResultViewer(); } }); } const pre = backdrop.querySelector('.api-result-viewer-pre'); try { pre.textContent = JSON.stringify(item, null, 2); } catch (err) { pre.textContent = String(item); } backdrop.style.display = 'flex'; } function closeApiResultViewer() { const backdrop = document.getElementById('apiResultViewerBackdrop'); if (backdrop) { backdrop.style.display = 'none'; } } function initProductGridResultViewer() { const grid = document.getElementById('productGrid'); if (!grid || grid.dataset.apiResultViewerBound === '1') { return; } grid.dataset.apiResultViewerBound = '1'; grid.addEventListener('click', (e) => { const btn = e.target.closest('.product-debug-btn-api-result'); if (!btn) { return; } e.preventDefault(); const idx = parseInt(btn.getAttribute('data-result-index'), 10); if (Number.isNaN(idx)) { return; } const results = state.lastSearchData && state.lastSearchData.results; if (!results || idx < 0 || idx >= results.length) { return; } openApiResultViewer(results[idx]); }); } // Initialize function initializeApp() { // 初始化租户下拉框和分面面板 console.log('Initializing app...'); initTenantSelect(); initProductGridResultViewer(); const searchInput = document.getElementById('searchInput'); if (searchInput) { searchInput.focus(); } } // 在 DOM 加载完成后初始化 if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', initializeApp); } else { // DOM 已经加载完成,直接执行 initializeApp(); } // 备用初始化:如果上面的初始化失败,在 window.onload 时再试一次 window.addEventListener('load', function() { const tenantList = document.getElementById('tenantList'); if (tenantList && tenantList.options.length === 0) { console.log('Retrying tenant select initialization on window.load...'); initTenantSelect(); } }); // 最后尝试:延迟执行,确保所有脚本都已加载 setTimeout(function() { const tenantList = document.getElementById('tenantList'); if (tenantList && tenantList.options.length === 0) { console.log('Final retry: Initializing tenant select after delay...'); if (typeof getAvailableTenantIds === 'function') { initTenantSelect(); } else { console.error('getAvailableTenantIds still not available after delay'); } } }, 100); // Keyboard handler function handleKeyPress(event) { if (event.key === 'Enter') { performSearch(); } } // 初始化租户输入框(带 162/170 等候选,可自行填写任意 tenant ID) function initTenantSelect() { const tenantSelect = document.getElementById('tenantSelect'); const tenantList = document.getElementById('tenantList'); if (!tenantSelect || !tenantList) { console.error('tenantSelect or tenantList element not found'); return; } // 检查函数是否可用 if (typeof getAvailableTenantIds !== 'function') { console.error('getAvailableTenantIds function not found. Make sure tenant_facets_config.js is loaded before app.js'); return; } const availableTenants = getAvailableTenantIds(); console.log('Available tenants:', availableTenants); // 清空 datalist 现有选项 tenantList.innerHTML = ''; if (availableTenants && availableTenants.length > 0) { availableTenants.forEach(tenantId => { const option = document.createElement('option'); option.value = tenantId; tenantList.appendChild(option); }); // 设置默认值(仅当输入框为空时) if (!tenantSelect.value.trim()) { tenantSelect.value = availableTenants.includes('170') ? '170' : availableTenants[0]; } } // 初始化分面面板 renderFacetsPanel(); } // 租户ID改变时的处理 function onTenantIdChange() { renderFacetsPanel(); // 清空当前的分面数据 clearFacetsData(); } // 根据当前 tenant_id 渲染分面面板结构 function renderFacetsPanel() { const tenantId = getTenantId(); const config = getTenantFacetsConfig(tenantId); const container = document.getElementById('specificationFacetsContainer'); if (!container) return; // 清空现有规格分面 container.innerHTML = ''; // 为每个规格字段创建分面行 config.specificationFields.forEach(specField => { const row = document.createElement('div'); row.className = 'filter-row'; row.setAttribute('data-facet-field', specField.field); row.innerHTML = `
${escapeHtml(specField.label)}:
`; container.appendChild(row); }); } // 清空所有分面数据(保留结构) function clearFacetsData() { const allTagContainers = document.querySelectorAll('.filter-tags'); allTagContainers.forEach(container => { container.innerHTML = ''; }); } // Escape HTML to prevent XSS function escapeHtml(text) { if (!text) return ''; const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } // Perform search async function performSearch(page = 1) { const query = document.getElementById('searchInput').value.trim(); const tenantId = getTenantId(); const skuFilterDimension = getSkuFilterDimension(); if (!query) { alert('Please enter search keywords'); return; } if (!tenantId) { alert('Please enter tenant ID'); return; } state.query = query; state.currentPage = page; state.pageSize = parseInt(document.getElementById('resultSize').value); const from = (page - 1) * state.pageSize; // Define facets // 使用 disjunctive 控制分面行为: // - 类目(category1/2/3)使用标准模式(disjunctive: false),用于层级下钻 // - 规格(color/size/material)使用 Multi-Select 模式(disjunctive: true) const facets = []; // 一级类目分面(始终存在,用于顶层类目导航) facets.push({ field: "category1_name", size: 15, type: "terms", disjunctive: false }); // 如果已选择一级类目,则在该过滤条件下请求二级类目分面 if (state.filters.category1_name && state.filters.category1_name.length > 0) { facets.push({ field: "category2_name", size: 15, type: "terms", disjunctive: false }); } // 如果已选择二级类目,则在进一步过滤条件下请求三级类目分面 if (state.filters.category2_name && state.filters.category2_name.length > 0) { facets.push({ field: "category3_name", size: 15, type: "terms", disjunctive: false }); } // 规格相关分面(Multi-Select 模式) // 根据 tenant_id 使用不同的配置 const tenantFacetsConfig = getTenantFacetsConfig(tenantId); tenantFacetsConfig.specificationFields.forEach(specField => { // 只发送查询参数,不包含显示相关的配置(label, containerId) facets.push({ field: specField.field, size: specField.size, type: specField.type, disjunctive: specField.disjunctive }); }); // Show loading document.getElementById('loading').style.display = 'block'; document.getElementById('productGrid').innerHTML = ''; try { const response = await fetch(`${API_BASE_URL}/search/`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Tenant-ID': tenantId, }, body: JSON.stringify({ query: query, size: state.pageSize, from: from, filters: Object.keys(state.filters).length > 0 ? state.filters : null, range_filters: Object.keys(state.rangeFilters).length > 0 ? state.rangeFilters : null, facets: facets, sort_by: state.sortBy || null, sort_order: state.sortOrder, sku_filter_dimension: skuFilterDimension, // 测试前端始终开启后端调试信息 debug: true }) }); if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } const data = await response.json(); state.lastSearchData = data; state.totalResults = data.total; state.facets = data.facets; displayResults(data); displayFacets(data.facets); displayPagination(); displayDebugInfo(data); updateProductCount(data.total); updateClearFiltersButton(); } catch (error) { console.error('Search error:', error); document.getElementById('productGrid').innerHTML = `
Search Error: ${error.message}

Please ensure backend service is running (${API_BASE_URL})
`; } finally { document.getElementById('loading').style.display = 'none'; } } // Display results in grid function displayResults(data) { const grid = document.getElementById('productGrid'); if (!data.results || data.results.length === 0) { grid.innerHTML = `

No Results Found

Try different keywords or filters

`; return; } let html = ''; // Build per-SPU debug lookup from debug_info.per_result (if present) let perResultDebugBySpu = {}; if (state.debug && data.debug_info && Array.isArray(data.debug_info.per_result)) { data.debug_info.per_result.forEach((item) => { if (item && item.spu_id) { perResultDebugBySpu[String(item.spu_id)] = item; } }); } const tenantId = getTenantId(); data.results.forEach((result, resultIndex) => { const product = result; const title = product.title || product.name || 'N/A'; const price = product.min_price || product.price || 'N/A'; const imageUrl = product.image_url || product.imageUrl || ''; const category = product.category || product.categoryName || ''; const vendor = product.vendor || product.brandName || ''; const spuId = product.spu_id || ''; const debug = spuId ? perResultDebugBySpu[String(spuId)] : null; let debugHtml = ''; if (debug) { const esScore = typeof debug.es_score === 'number' ? debug.es_score.toFixed(4) : String(debug.es_score ?? ''); const esNorm = typeof debug.es_score_normalized === 'number' ? debug.es_score_normalized.toFixed(4) : (debug.es_score_normalized == null ? '' : String(debug.es_score_normalized)); const esNormRerank = typeof debug.es_score_norm === 'number' ? debug.es_score_norm.toFixed(4) : (debug.es_score_norm == null ? '' : String(debug.es_score_norm)); const rerankScore = typeof debug.rerank_score === 'number' ? debug.rerank_score.toFixed(4) : (debug.rerank_score == null ? '' : String(debug.rerank_score)); const fusedScore = typeof debug.fused_score === 'number' ? debug.fused_score.toFixed(4) : (debug.fused_score == null ? '' : String(debug.fused_score)); // Build multilingual title info let titleLines = ''; if (debug.title_multilingual && typeof debug.title_multilingual === 'object') { Object.entries(debug.title_multilingual).forEach(([lang, val]) => { if (val) { titleLines += `
title.${escapeHtml(String(lang))}: ${escapeHtml(String(val))}
`; } }); } const rawUrl = `${API_BASE_URL}/search/es-doc/${encodeURIComponent(spuId)}?tenant_id=${encodeURIComponent(tenantId)}`; debugHtml = `
Ranking Debug
spu_id: ${escapeHtml(String(spuId || ''))}
ES score: ${esScore}
ES normalized: ${esNorm}
ES norm (rerank input): ${esNormRerank}
Rerank score: ${rerankScore}
Fused score: ${fusedScore}
${titleLines}
查看 ES 原始文档
`; } html += `
${imageUrl ? ` ${escapeHtml(title)} ` : `
No Image
`}
${price !== 'N/A' ? `¥${price}` : 'N/A'}
${escapeHtml(title)}
${category ? escapeHtml(category) : ''} ${vendor ? ' | ' + escapeHtml(vendor) : ''}
${debugHtml}
`; }); grid.innerHTML = html; } // Display facets as filter tags (一级分类 + 三个属性分面) function displayFacets(facets) { if (!facets || !Array.isArray(facets)) { return; } const tenantId = getTenantId(); facets.forEach((facet) => { // 根据配置获取分面显示信息 const displayConfig = getFacetDisplayConfig(tenantId, facet.field); if (!displayConfig) { // 如果没有配置,跳过该分面 return; } const containerId = displayConfig.containerId; const maxDisplay = displayConfig.maxDisplay; const container = document.getElementById(containerId); if (!container) { return; } // 检查values是否存在且是数组 if (!facet.values || !Array.isArray(facet.values) || facet.values.length === 0) { container.innerHTML = ''; return; } let html = ''; // 渲染分面值 facet.values.slice(0, maxDisplay).forEach((facetValue) => { if (!facetValue || typeof facetValue !== 'object') { return; } const value = facetValue.value; const count = facetValue.count; // 允许value为0或空字符串,但不允许undefined/null if (value === undefined || value === null) { return; } // 检查是否已选中 let selected = false; if (facet.field.startsWith('specifications.')) { // 检查specifications过滤 const specName = facet.field.split('.')[1]; if (state.filters.specifications) { const specs = Array.isArray(state.filters.specifications) ? state.filters.specifications : [state.filters.specifications]; selected = specs.some(spec => spec && spec.name === specName && spec.value === value); } } else { // 检查普通字段过滤 if (state.filters[facet.field]) { selected = state.filters[facet.field].includes(value); } } html += ` ${escapeHtml(String(value))} (${count || 0}) `; }); container.innerHTML = html; }); } // Toggle filter (支持specifications嵌套过滤) function toggleFilter(field, value) { // 处理specifications属性过滤 (specifications.color, specifications.size, specifications.material) if (field.startsWith('specifications.')) { const specName = field.split('.')[1]; // 提取name (color, size, material) // 初始化specifications过滤 if (!state.filters.specifications) { state.filters.specifications = []; } // 确保是数组格式 if (!Array.isArray(state.filters.specifications)) { // 如果已经是单个对象,转换为数组 state.filters.specifications = [state.filters.specifications]; } // 查找是否已存在相同的name和value组合 const existingIndex = state.filters.specifications.findIndex( spec => spec.name === specName && spec.value === value ); if (existingIndex > -1) { // 移除 state.filters.specifications.splice(existingIndex, 1); if (state.filters.specifications.length === 0) { delete state.filters.specifications; } else if (state.filters.specifications.length === 1) { // 如果只剩一个,可以保持为数组,或转换为单个对象(API都支持) // 这里保持为数组,更一致 } } else { // 添加 state.filters.specifications.push({ name: specName, value: value }); } } else { // 处理普通字段过滤 (category1_name 等) // 对类目字段使用互斥单选 + 层级重置逻辑 const isCategoryField = field === 'category1_name' || field === 'category2_name' || field === 'category3_name'; if (isCategoryField) { // 点击已选中的类目 -> 取消该层和以下层级 const currentValues = state.filters[field] || []; const isAlreadySelected = currentValues.includes(value); if (isAlreadySelected) { delete state.filters[field]; if (field === 'category1_name') { delete state.filters.category2_name; delete state.filters.category3_name; } else if (field === 'category2_name') { delete state.filters.category3_name; } } else { // 选择新的类目值:该层级单选,下级全部重置 state.filters[field] = [value]; if (field === 'category1_name') { delete state.filters.category2_name; delete state.filters.category3_name; } else if (field === 'category2_name') { delete state.filters.category3_name; } } } else { // 其他普通字段维持原有多选行为 if (!state.filters[field]) { state.filters[field] = []; } const index = state.filters[field].indexOf(value); if (index > -1) { state.filters[field].splice(index, 1); if (state.filters[field].length === 0) { delete state.filters[field]; } } else { state.filters[field].push(value); } } } performSearch(1); // Reset to page 1 } // Handle price filter (重构版 - 使用 rangeFilters) function handlePriceFilter(value) { if (!value) { delete state.rangeFilters.min_price; } else { const priceRanges = { '0-50': { lt: 50 }, '50-100': { gte: 50, lt: 100 }, '100-200': { gte: 100, lt: 200 }, '200+': { gte: 200 } }; if (priceRanges[value]) { state.rangeFilters.min_price = priceRanges[value]; } } performSearch(1); } // Handle time filter (重构版 - 使用 rangeFilters) function handleTimeFilter(value) { if (!value) { delete state.rangeFilters.create_time; } else { const now = new Date(); let fromDate; switch(value) { case 'today': fromDate = new Date(now.setHours(0, 0, 0, 0)); break; case 'week': fromDate = new Date(now.setDate(now.getDate() - 7)); break; case 'month': fromDate = new Date(now.setMonth(now.getMonth() - 1)); break; case '3months': fromDate = new Date(now.setMonth(now.getMonth() - 3)); break; case '6months': fromDate = new Date(now.setMonth(now.getMonth() - 6)); break; } if (fromDate) { state.rangeFilters.create_time = { gte: fromDate.toISOString() }; } } performSearch(1); } // Clear all filters function clearAllFilters() { state.filters = {}; state.rangeFilters = {}; document.getElementById('priceFilter').value = ''; document.getElementById('timeFilter').value = ''; performSearch(1); } // Update clear filters button visibility function updateClearFiltersButton() { const btn = document.getElementById('clearFiltersBtn'); if (Object.keys(state.filters).length > 0 || Object.keys(state.rangeFilters).length > 0) { btn.style.display = 'inline-block'; } else { btn.style.display = 'none'; } } // Update product count function updateProductCount(total) { document.getElementById('productCount').textContent = `${total.toLocaleString()} SPUs found`; } // Sort functions function setSortByDefault() { // Remove active from all buttons and arrows document.querySelectorAll('.sort-btn').forEach(b => b.classList.remove('active')); document.querySelectorAll('.arrow-up, .arrow-down').forEach(a => a.classList.remove('active')); // Set default button active const defaultBtn = document.querySelector('.sort-btn[data-sort=""]'); if (defaultBtn) defaultBtn.classList.add('active'); state.sortBy = ''; state.sortOrder = 'desc'; performSearch(1); } function sortByField(field, order) { state.sortBy = field; state.sortOrder = order; // Remove active from all buttons (but keep "By default" if no sort) document.querySelectorAll('.sort-btn').forEach(b => b.classList.remove('active')); // Remove active from all arrows document.querySelectorAll('.arrow-up, .arrow-down').forEach(a => a.classList.remove('active')); // Add active to clicked arrow const activeArrow = document.querySelector(`.arrow-up[data-field="${field}"][data-order="${order}"], .arrow-down[data-field="${field}"][data-order="${order}"]`); if (activeArrow) { activeArrow.classList.add('active'); } performSearch(state.currentPage); } // Pagination function displayPagination() { const paginationDiv = document.getElementById('pagination'); if (state.totalResults <= state.pageSize) { paginationDiv.style.display = 'none'; return; } paginationDiv.style.display = 'flex'; const totalPages = Math.ceil(state.totalResults / state.pageSize); const currentPage = state.currentPage; let html = ` `; // Page numbers const maxVisible = 5; let startPage = Math.max(1, currentPage - Math.floor(maxVisible / 2)); let endPage = Math.min(totalPages, startPage + maxVisible - 1); if (endPage - startPage < maxVisible - 1) { startPage = Math.max(1, endPage - maxVisible + 1); } if (startPage > 1) { html += ``; if (startPage > 2) { html += `...`; } } for (let i = startPage; i <= endPage; i++) { html += ` `; } if (endPage < totalPages) { if (endPage < totalPages - 1) { html += `...`; } html += ``; } html += ` `; html += ` Page ${currentPage} of ${totalPages} (${state.totalResults.toLocaleString()} results) `; paginationDiv.innerHTML = html; } function goToPage(page) { const totalPages = Math.ceil(state.totalResults / state.pageSize); if (page < 1 || page > totalPages) return; performSearch(page); // Scroll to top window.scrollTo({ top: 0, behavior: 'smooth' }); } // Display debug info function displayDebugInfo(data) { const debugInfoDiv = document.getElementById('debugInfo'); if (!state.debug || !data.debug_info) { // If debug mode is off or no debug info, show basic query info if (data.query_info) { let html = '
'; html += `
original_query: ${escapeHtml(data.query_info.original_query || 'N/A')}
`; html += `
detected_language: ${data.query_info.detected_languag}
`; html += '
'; debugInfoDiv.innerHTML = html; } else { debugInfoDiv.innerHTML = ''; } return; } // Display comprehensive debug info when debug mode is on const debugInfo = data.debug_info; let html = '
'; // Query Analysis if (debugInfo.query_analysis) { html += '
Query Analysis:'; html += `
original_query: ${escapeHtml(debugInfo.query_analysis.original_query || 'N/A')}
`; html += `
query_normalized: ${escapeHtml(debugInfo.query_analysis.query_normalized || 'N/A')}
`; html += `
rewritten_query: ${escapeHtml(debugInfo.query_analysis.rewritten_query || 'N/A')}
`; html += `
detected_language: ${debugInfo.query_analysis.detected_language}
`; html += `
domain: ${escapeHtml(debugInfo.query_analysis.domain || 'default')}
`; html += `
is_simple_query: ${debugInfo.query_analysis.is_simple_query ? 'yes' : 'no'}
`; if (debugInfo.query_analysis.translations && Object.keys(debugInfo.query_analysis.translations).length > 0) { html += '
translations: '; for (const [lang, translation] of Object.entries(debugInfo.query_analysis.translations)) { if (translation) { html += `${lang}: ${escapeHtml(translation)}; `; } } html += '
'; } if (debugInfo.query_analysis.boolean_ast) { html += `
boolean_ast: ${escapeHtml(debugInfo.query_analysis.boolean_ast)}
`; } html += '
'; } // Feature Flags if (debugInfo.feature_flags) { html += '
Feature Flags:'; html += `
translation_enabled: ${debugInfo.feature_flags.translation_enabled ? 'enabled' : 'disabled'}
`; html += `
embedding_enabled: ${debugInfo.feature_flags.embedding_enabled ? 'enabled' : 'disabled'}
`; html += `
rerank_enabled: ${debugInfo.feature_flags.rerank_enabled ? 'enabled' : 'disabled'}
`; html += '
'; } // ES Response if (debugInfo.es_response) { html += '
ES Response:'; html += `
took_ms: ${debugInfo.es_response.took_ms}ms
`; html += `
total_hits: ${debugInfo.es_response.total_hits}
`; html += `
max_score: ${debugInfo.es_response.max_score?.toFixed(3) || 0}
`; html += '
'; } // Stage Timings if (debugInfo.stage_timings) { html += '
Stage Timings:'; for (const [stage, duration] of Object.entries(debugInfo.stage_timings)) { html += `
${stage}: ${duration.toFixed(2)}ms
`; } html += '
'; } // ES Query if (debugInfo.es_query) { html += '
ES Query DSL:'; html += `
${escapeHtml(customStringify(debugInfo.es_query))}
`; html += '
'; } html += '
'; debugInfoDiv.innerHTML = html; } // Custom JSON stringify that compresses numeric arrays (like embeddings) to single line function customStringify(obj) { return JSON.stringify(obj, (key, value) => { if (Array.isArray(value)) { // Only collapse arrays that contain numbers (like embeddings) if (value.every(item => typeof item === 'number')) { return JSON.stringify(value); } } return value; }, 2).replace(/"\[/g, '[').replace(/\]"/g, ']'); } // Helper functions function escapeAttr(text) { if (!text) return ''; return text.replace(/'/g, "\\'").replace(/"/g, '"'); } function formatDate(dateStr) { if (!dateStr) return ''; try { const date = new Date(dateStr); return date.toLocaleDateString('zh-CN'); } catch { return dateStr; } }