// SearchEngine Frontend - Modern UI (Multi-Tenant)
const API_BASE_URL = window.API_BASE_URL || 'http://localhost:6002';
if (document.getElementById('apiUrl')) {
document.getElementById('apiUrl').textContent = API_BASE_URL;
}
// Get tenant ID from input
function getTenantId() {
const tenantInput = document.getElementById('tenantInput');
if (tenantInput) {
return tenantInput.value.trim();
}
return '1'; // Default fallback
}
// Get sku_filter_dimension from input
function getSkuFilterDimension() {
const skuFilterInput = document.getElementById('skuFilterDimension');
if (skuFilterInput) {
const value = skuFilterInput.value.trim();
// Return the value if not empty, otherwise return null
return value.length > 0 ? value : 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
};
// Initialize
document.addEventListener('DOMContentLoaded', function() {
document.getElementById('searchInput').focus();
});
// Keyboard handler
function handleKeyPress(event) {
if (event.key === 'Enter') {
performSearch();
}
}
// Toggle filters visibility
function toggleFilters() {
const filterSection = document.getElementById('filterSection');
filterSection.classList.toggle('hidden');
}
// 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 (一级分类 + 三个属性分面)
const facets = [
"category1_name", // 一级分类
"specifications.color", // 颜色属性
"specifications.size", // 尺寸属性
"specifications.material" // 材质属性
];
// 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: state.debug
})
});
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 = '';
data.results.forEach((result) => {
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 || '';
html += `
${imageUrl ? `
` : `
No Image
`}
${price !== 'N/A' ? `¥${price}` : 'N/A'}
${escapeHtml(title)}
${category ? escapeHtml(category) : ''}
${vendor ? ' | ' + escapeHtml(vendor) : ''}
`;
});
grid.innerHTML = html;
}
// Display facets as filter tags (一级分类 + 三个属性分面)
function displayFacets(facets) {
if (!facets || !Array.isArray(facets)) {
return;
}
facets.forEach((facet) => {
// 根据字段名找到对应的容器
let containerId = null;
let maxDisplay = 10;
// 一级分类
if (facet.field === 'category1_name') {
containerId = 'category1Tags';
maxDisplay = 10;
}
// 颜色属性分面 (specifications.color)
else if (facet.field === 'specifications.color') {
containerId = 'colorTags';
maxDisplay = 10;
}
// 尺寸属性分面 (specifications.size)
else if (facet.field === 'specifications.size') {
containerId = 'sizeTags';
maxDisplay = 10;
}
// 材质属性分面 (specifications.material)
else if (facet.field === 'specifications.material') {
containerId = 'materialTags';
maxDisplay = 10;
}
if (!containerId) {
return;
}
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等)
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 = `
← Previous
`;
// 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 += `1 `;
if (startPage > 2) {
html += `... `;
}
}
for (let i = startPage; i <= endPage; i++) {
html += `
${i}
`;
}
if (endPage < totalPages) {
if (endPage < totalPages - 1) {
html += `... `;
}
html += `${totalPages} `;
}
html += `
Next →
`;
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: ${getLanguageName(data.query_info.detected_language)}
`;
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 += `
normalized_query: ${escapeHtml(debugInfo.query_analysis.normalized_query || 'N/A')}
`;
html += `
rewritten_query: ${escapeHtml(debugInfo.query_analysis.rewritten_query || 'N/A')}
`;
html += `
detected_language: ${getLanguageName(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 += `${getLanguageName(lang)}: ${escapeHtml(translation)}; `;
}
}
html += '
';
}
if (debugInfo.query_analysis.boolean_ast) {
html += `
boolean_ast: ${escapeHtml(debugInfo.query_analysis.boolean_ast)}
`;
}
html += `
has_vector: ${debugInfo.query_analysis.has_vector ? 'enabled' : 'disabled'}
`;
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 escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
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;
}
}
function getLanguageName(code) {
const names = {
'zh': '中文',
'en': 'English',
'ru': 'Русский',
'ar': 'العربية',
'ja': '日本語',
'unknown': 'Unknown'
};
return names[code] || code;
}