问卷
This commit is contained in:
parent
202e36027a
commit
1e1ffb23f3
25
.env.example
Normal file
25
.env.example
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
# ─── 服务器 ───
|
||||||
|
PORT=8099
|
||||||
|
|
||||||
|
# ─── 数据库 ───
|
||||||
|
DATABASE_URL=postgres://postgres:your_password@localhost:5432/floorvisualizer?sslmode=disable
|
||||||
|
|
||||||
|
# ─── Redis ───
|
||||||
|
REDIS_ADDR=localhost:6379
|
||||||
|
|
||||||
|
# ─── JWT ───
|
||||||
|
JWT_SECRET=change-this-to-a-strong-secret
|
||||||
|
|
||||||
|
# ─── AI ───
|
||||||
|
OPENROUTER_API_KEY=sk-or-v1-your-openrouter-key-here
|
||||||
|
|
||||||
|
# ─── 代理(国内环境用,留空则直连) ───
|
||||||
|
PROXY_URL=http://127.0.0.1:7897
|
||||||
|
# DISABLE_PROXY=true
|
||||||
|
|
||||||
|
# ─── 队列 ───
|
||||||
|
WORKER_COUNT=5
|
||||||
|
|
||||||
|
# ─── 日志 ───
|
||||||
|
LOG_DIR=logs
|
||||||
|
LOG_LEVEL=INFO
|
||||||
90
clip_server/server.py
Normal file
90
clip_server/server.py
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
"""CLIP-based indoor room classifier. Runs ViT-B/32 on CPU, ~100ms per image."""
|
||||||
|
|
||||||
|
import io
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
|
||||||
|
import open_clip
|
||||||
|
import torch
|
||||||
|
from PIL import Image
|
||||||
|
from flask import Flask, jsonify, request
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
||||||
|
log = logging.getLogger("clip-server")
|
||||||
|
app = Flask(__name__)
|
||||||
|
app.config["MAX_CONTENT_LENGTH"] = 50 * 1024 * 1024 # 50 MB max upload
|
||||||
|
|
||||||
|
# Load once at startup
|
||||||
|
MODEL_NAME = os.getenv("CLIP_MODEL", "ViT-B-32")
|
||||||
|
PRETRAINED = os.getenv("CLIP_PRETRAINED", "laion2b_s34b_b79k")
|
||||||
|
DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
|
||||||
|
|
||||||
|
log.info(f"Loading CLIP model: {MODEL_NAME} pretrained={PRETRAINED} device={DEVICE}")
|
||||||
|
model, _, preprocess = open_clip.create_model_and_transforms(MODEL_NAME, pretrained=PRETRAINED)
|
||||||
|
model = model.to(DEVICE).eval()
|
||||||
|
tokenizer = open_clip.get_tokenizer(MODEL_NAME)
|
||||||
|
|
||||||
|
# Text prompts for indoor/outdoor classification
|
||||||
|
PROMPTS = [
|
||||||
|
"a photo of an indoor room interior with floor visible",
|
||||||
|
"a photo of an outdoor scene, landscape, street, or non-room image",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/health", methods=["GET"])
|
||||||
|
def health():
|
||||||
|
return jsonify({"status": "ok", "model": MODEL_NAME, "device": DEVICE})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/check", methods=["POST"])
|
||||||
|
def check():
|
||||||
|
"""Classify whether an image is an indoor room photo suitable for floor replacement.
|
||||||
|
Expects raw image bytes as the request body. Returns {score: 0-10, passed: bool}."""
|
||||||
|
if not request.data:
|
||||||
|
return jsonify({"error": "no image data"}), 400
|
||||||
|
|
||||||
|
try:
|
||||||
|
img = Image.open(io.BytesIO(request.data)).convert("RGB")
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"error": f"bad image: {e}"}), 400
|
||||||
|
|
||||||
|
# Multiple prompt pairs for robust ensemble (indoor vs outdoor)
|
||||||
|
prompt_pairs = [
|
||||||
|
("a photo of an indoor room interior with floor", "a photo of an outdoor scene"),
|
||||||
|
("interior room photograph, real estate photo", "exterior photograph, outdoor photo"),
|
||||||
|
("an indoor space with visible flooring", "an outdoor space, landscape, street view"),
|
||||||
|
]
|
||||||
|
|
||||||
|
image_input = preprocess(img).unsqueeze(0).to(DEVICE)
|
||||||
|
wins = 0
|
||||||
|
gap_sum = 0.0
|
||||||
|
for indoor_text, outdoor_text in prompt_pairs:
|
||||||
|
text_input = tokenizer([indoor_text, outdoor_text]).to(DEVICE)
|
||||||
|
with torch.no_grad():
|
||||||
|
image_features = model.encode_image(image_input)
|
||||||
|
text_features = model.encode_text(text_input)
|
||||||
|
image_features /= image_features.norm(dim=-1, keepdim=True)
|
||||||
|
text_features /= text_features.norm(dim=-1, keepdim=True)
|
||||||
|
raw = (image_features @ text_features.T)[0] # cosine similarities
|
||||||
|
indoor_sim = float(raw[0].item())
|
||||||
|
outdoor_sim = float(raw[1].item())
|
||||||
|
if indoor_sim > outdoor_sim:
|
||||||
|
wins += 1
|
||||||
|
gap_sum += indoor_sim - outdoor_sim # positive = indoor wins
|
||||||
|
|
||||||
|
indoor_wins = wins >= 2 # majority vote (2 out of 3)
|
||||||
|
avg_gap = gap_sum / len(prompt_pairs)
|
||||||
|
# Map avg_gap to 1-10: gap ~0.05 = score 6, gap ~0.15+ = score 10
|
||||||
|
score_1_10 = max(1, min(10, int(5 + avg_gap * 40)))
|
||||||
|
passed = indoor_wins
|
||||||
|
|
||||||
|
log.info(
|
||||||
|
f"wins={wins}/3 avg_gap={avg_gap:.3f} score_1_10={score_1_10} passed={passed}"
|
||||||
|
)
|
||||||
|
return jsonify({"score": score_1_10, "passed": passed})
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
port = int(os.getenv("CLIP_PORT", "5100"))
|
||||||
|
log.info(f"CLIP server listening on :{port}")
|
||||||
|
app.run(host="0.0.0.0", port=port, debug=False)
|
||||||
440
internal/api/index.html
Normal file
440
internal/api/index.html
Normal file
@ -0,0 +1,440 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<title>FloorVisualizer Demo</title>
|
||||||
|
<style>
|
||||||
|
*{box-sizing:border-box;margin:0;padding:0}
|
||||||
|
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","Microsoft YaHei",Arial,sans-serif;background:#1a1a2e;color:#eee;min-height:100vh}
|
||||||
|
header{background:#16213e;padding:20px;text-align:center;border-bottom:2px solid #e94560}
|
||||||
|
header h1{font-size:24px;color:#e94560}
|
||||||
|
header p{font-size:14px;color:#aaa;margin-top:6px}
|
||||||
|
.container{max-width:1280px;margin:0 auto;padding:20px;display:grid;grid-template-columns:1fr 1fr;gap:20px}
|
||||||
|
.panel{background:#16213e;border-radius:12px;padding:22px;margin-bottom:16px}
|
||||||
|
.panel h2{font-size:18px;margin-bottom:16px;color:#e94560}
|
||||||
|
.upload-zone{border:2px dashed #444;border-radius:10px;padding:32px;min-height:220px;display:flex;align-items:center;justify-content:center;text-align:center;cursor:pointer;transition:.2s}
|
||||||
|
.upload-zone:hover,.upload-zone.dragover{border-color:#e94560;background:rgba(233,68,96,.06)}
|
||||||
|
.upload-zone img{max-width:100%;max-height:320px;border-radius:8px}
|
||||||
|
.upload-zone span{color:#888;font-size:14px;line-height:1.7}
|
||||||
|
.options{display:grid;grid-template-columns:repeat(2,1fr);gap:10px}
|
||||||
|
.option{padding:12px;border:2px solid #333;border-radius:8px;cursor:pointer;text-align:center;transition:.2s;user-select:none;min-height:64px;display:flex;flex-direction:column;align-items:center;justify-content:center}
|
||||||
|
.option:hover{border-color:#e94560}
|
||||||
|
.option.selected{border-color:#e94560;background:rgba(233,68,96,.15)}
|
||||||
|
.option.disabled{opacity:.45;cursor:not-allowed}
|
||||||
|
.option .name{font-size:14px;font-weight:700;line-height:1.35}
|
||||||
|
.option .meta{font-size:12px;color:#aaa;margin-top:4px;line-height:1.35}
|
||||||
|
.floor-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:10px;max-height:430px;overflow:auto;padding-right:4px}
|
||||||
|
.floor-card{border:2px solid #333;border-radius:8px;padding:10px;cursor:pointer;transition:.2s;background:#11172d}
|
||||||
|
.floor-card:hover{border-color:#e94560}
|
||||||
|
.floor-card.selected{border-color:#e94560;background:rgba(233,68,96,.14)}
|
||||||
|
.floor-card img{width:100%;height:70px;object-fit:cover;border-radius:6px;background:#222;margin-bottom:8px}
|
||||||
|
.floor-card .title{font-size:13px;font-weight:700;line-height:1.3}
|
||||||
|
.floor-card .desc{font-size:11px;color:#aaa;margin-top:4px;line-height:1.35}
|
||||||
|
.toolbar{display:flex;gap:10px;margin-bottom:10px}
|
||||||
|
.toolbar input{flex:1;background:#0f3460;border:1px solid #34456c;border-radius:8px;color:#eee;padding:10px}
|
||||||
|
.toggle-row{display:grid;grid-template-columns:1fr 1fr;gap:10px}
|
||||||
|
.toggle{border:2px dashed #444;border-radius:8px;padding:12px;cursor:pointer;background:#11172d}
|
||||||
|
.toggle.selected{border-color:#e94560;background:rgba(233,68,96,.14)}
|
||||||
|
.toggle.disabled{opacity:.45;cursor:not-allowed}
|
||||||
|
.toggle input{margin-right:8px}
|
||||||
|
.btn{width:100%;padding:14px;margin-top:4px;border:0;border-radius:8px;background:#e94560;color:#fff;font-size:16px;font-weight:700;cursor:pointer}
|
||||||
|
.btn:hover{background:#ff647b}
|
||||||
|
.btn:disabled{background:#555;cursor:not-allowed}
|
||||||
|
.spinner{display:none;width:18px;height:18px;border:2px solid #fff;border-top-color:transparent;border-radius:50%;animation:spin .8s linear infinite;vertical-align:middle;margin-right:8px}
|
||||||
|
.loading .spinner{display:inline-block}
|
||||||
|
.loading .btn-text{display:none}
|
||||||
|
@keyframes spin{to{transform:rotate(360deg)}}
|
||||||
|
.result-panel{display:flex;flex-direction:column;min-height:720px}
|
||||||
|
.placeholder{flex:1;min-height:360px;border:1px dashed #333;border-radius:8px;display:flex;align-items:center;justify-content:center;text-align:center;color:#777;line-height:1.8}
|
||||||
|
.result-img{display:none;width:100%;border-radius:8px;margin-top:12px}
|
||||||
|
.compare{position:relative;width:100%;overflow:hidden;border-radius:8px;margin-top:12px;display:none;user-select:none;background:#111;cursor:ew-resize}
|
||||||
|
.compare img.base{display:block;width:100%;height:auto}
|
||||||
|
.clip{position:absolute;left:0;top:0;width:50%;height:100%;overflow:hidden}
|
||||||
|
.clip img{display:block;height:100%;object-fit:cover;object-position:left top;max-width:none}
|
||||||
|
.handle{position:absolute;top:0;bottom:0;left:50%;transform:translateX(-50%);width:4px;background:#e94560;z-index:3;pointer-events:none}
|
||||||
|
.knob{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);width:42px;height:42px;border-radius:50%;background:#e94560;display:grid;place-items:center;box-shadow:0 2px 16px rgba(0,0,0,.5)}
|
||||||
|
.labels{position:absolute;left:0;right:0;bottom:14px;display:flex;justify-content:space-between;padding:0 16px;pointer-events:none;z-index:2}
|
||||||
|
.labels span{background:rgba(0,0,0,.76);color:#fff;padding:4px 12px;border-radius:4px;font-size:12px;font-weight:700}
|
||||||
|
.status-box{display:none;background:#0f3460;border-radius:8px;padding:16px;margin-top:12px}
|
||||||
|
.step-label{font-size:13px;color:#e94560;font-weight:700;margin-bottom:6px}
|
||||||
|
.step-msg{font-size:14px;color:#eee;line-height:1.5;white-space:pre-wrap}
|
||||||
|
.progress{height:6px;background:#333;border-radius:3px;margin-top:12px;overflow:hidden}
|
||||||
|
.fill{height:100%;width:0;background:linear-gradient(90deg,#e94560,#f0a500);transition:width .35s}
|
||||||
|
.error-box{display:none;margin-top:12px;border:1px solid #90323d;background:#2b1220;color:#ffb4bd;border-radius:8px;padding:14px}
|
||||||
|
.error-box h3{font-size:14px;margin-bottom:8px;color:#ff6b81}
|
||||||
|
.error-box pre{white-space:pre-wrap;word-break:break-word;font-family:Consolas,monospace;font-size:12px;line-height:1.5;color:#ffd6dc}
|
||||||
|
.small{font-size:12px;color:#888;margin-top:8px}
|
||||||
|
@media(max-width:900px){.container{grid-template-columns:1fr;padding:14px}.floor-grid{grid-template-columns:1fr}.options,.toggle-row{grid-template-columns:1fr}}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<h1>FloorVisualizer AI 换地板 Demo</h1>
|
||||||
|
<p>上传房间照片,选择真实产品、铺设方式和可选效果,生成写实换地板预览</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<div>
|
||||||
|
<div class="panel">
|
||||||
|
<h2>第一步:上传房间照片</h2>
|
||||||
|
<div class="upload-zone" id="uploadZone">
|
||||||
|
<input id="fileInput" type="file" accept="image/*" hidden>
|
||||||
|
<img id="previewImg" alt="" style="display:none">
|
||||||
|
<span id="uploadHint">点击或拖拽上传图片<br>建议使用能清楚看到地面的室内照片</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
<h2>房间类型</h2>
|
||||||
|
<div class="options" id="room可选效果"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
<h2>地板类型</h2>
|
||||||
|
<div class="options" id="type可选效果">
|
||||||
|
<div class="option selected" data-type="wood"><div class="name">木地板</div><div class="meta">Hardwood</div></div>
|
||||||
|
<div class="option" data-type="tile"><div class="name">瓷砖</div><div class="meta">Tile / Stone</div></div>
|
||||||
|
<div class="option" data-type="vinyl"><div class="name">乙烯基地板</div><div class="meta">Luxury Vinyl / SPC</div></div>
|
||||||
|
<div class="option" data-type="special"><div class="name">特殊测试</div><div class="meta">7款 · 含AI描述</div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
<h2>铺设方式</h2>
|
||||||
|
<div class="options" id="pattern可选效果"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel" id="sizePanel" style="display:none">
|
||||||
|
<h2>尺寸</h2>
|
||||||
|
<div class="options" id="size可选效果"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
<h2 id="floorTitle">选择地板款式</h2>
|
||||||
|
<div class="toolbar"><input id="floorSearch" placeholder="搜索款式、颜色、材质、SKU"></div>
|
||||||
|
<div class="floor-grid" id="floor可选效果"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
<h2>材质参考方式</h2>
|
||||||
|
<div class="options" id="materialSourceOptions">
|
||||||
|
<div class="option selected" data-source="image"><div class="name">仅图片</div><div class="meta">参考图</div></div>
|
||||||
|
<div class="option" data-source="json"><div class="name">仅JSON</div><div class="meta">材质描述</div></div>
|
||||||
|
<div class="option" data-source="both"><div class="name">图片+JSON</div><div class="meta">两者都用</div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
<h2>可选效果</h2>
|
||||||
|
<div class="toggle-row">
|
||||||
|
<label class="toggle" id="groutToggle"><input id="grout" type="checkbox">瓷砖勾缝线<div class="small">仅瓷砖可用</div></label>
|
||||||
|
<label class="toggle" id="baseboardToggle"><input id="baseboard" type="checkbox">白色踢脚线<div class="small">沿墙地交界生成</div></label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="btn" id="generateBtn" disabled><span class="spinner"></span><span class="btn-text">生成预览图</span></button>
|
||||||
|
<div class="status-box" id="statusBox">
|
||||||
|
<div class="step-label" id="stepLabel"></div>
|
||||||
|
<div class="step-msg" id="stepMsg"></div>
|
||||||
|
<div class="live-timer" id="liveTimer" style="display:none;color:#f0a500;font-size:13px;margin-top:8px;font-weight:700"></div>
|
||||||
|
<div class="progress"><div class="fill" id="progressFill"></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="error-box" id="errorBox">
|
||||||
|
<h3>生成失败原因</h3>
|
||||||
|
<pre id="errorDetail"></pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel result-panel">
|
||||||
|
<h2>生成结果</h2>
|
||||||
|
<div class="placeholder" id="resultPlaceholder">上传图片并选择地板选项后<br>点击生成预览图查看效果</div>
|
||||||
|
<img class="result-img" id="resultImg" alt="生成结果">
|
||||||
|
<div class="compare" id="compare">
|
||||||
|
<img class="base" id="afterImg" alt="生成图">
|
||||||
|
<div class="clip" id="clip"><img id="beforeImg" alt="原图"></div>
|
||||||
|
<div class="handle" id="handle"><div class="knob">↔</div></div>
|
||||||
|
<div class="labels"><span>原图</span><span>生成图</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const $=id=>document.getElementById(id);
|
||||||
|
const state={options:null,type:'wood',file:null,originalUrl:null,room:null,pattern:null,floor:null,timer:null,lastStatus:null};
|
||||||
|
|
||||||
|
function show错误(title, detail){
|
||||||
|
$('errorBox').style.display='block';
|
||||||
|
$('errorDetail').textContent=(title||'生成失败')+(detail?'\n\n'+detail:'');
|
||||||
|
$('resultPlaceholder').style.display='flex';
|
||||||
|
$('resultPlaceholder').innerHTML='生成失败';
|
||||||
|
}
|
||||||
|
function clear错误(){ $('errorBox').style.display='none'; $('errorDetail').textContent=''; }
|
||||||
|
function setProgress(step,msg,width){
|
||||||
|
$('statusBox').style.display='block';
|
||||||
|
$('stepLabel').textContent=step;
|
||||||
|
$('stepMsg').textContent=msg||'';
|
||||||
|
$('progressFill').style.width=width;
|
||||||
|
}
|
||||||
|
function apiData(json){ return json && json.data ? json.data : json; }
|
||||||
|
function floorList(){
|
||||||
|
if(!state.options)return[];
|
||||||
|
if(state.type==='tile')return state.options.tile_floors||[];
|
||||||
|
if(state.type==='vinyl')return state.options.vinyl_floors||[];
|
||||||
|
if(state.type==='special')return state.options.special_floors||[];
|
||||||
|
return state.options.wood_floors||[];
|
||||||
|
}
|
||||||
|
function patternList(){
|
||||||
|
if(!state.options)return[];
|
||||||
|
return state.type==='tile' ? (state.options.tile_patterns||[]) : (state.options.wood_patterns||[]);
|
||||||
|
}
|
||||||
|
function selectIn(container,item,el,key){
|
||||||
|
[...container.querySelectorAll(key)].forEach(x=>x.classList.remove('selected'));
|
||||||
|
el.classList.add('selected');
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
function checkReady(){
|
||||||
|
$('generateBtn').disabled=!(state.file&&state.pattern&&state.floor);
|
||||||
|
}
|
||||||
|
// Material source selector
|
||||||
|
state.materialSource='image';
|
||||||
|
$('materialSourceOptions').onclick=e=>{
|
||||||
|
const opt=e.target.closest('.option');
|
||||||
|
if(!opt)return;
|
||||||
|
[...$('materialSourceOptions').querySelectorAll('.option')].forEach(x=>x.classList.remove('selected'));
|
||||||
|
opt.classList.add('selected');
|
||||||
|
state.materialSource=opt.dataset.source;
|
||||||
|
};
|
||||||
|
function render尺寸s(floor){
|
||||||
|
const el=$('size可选效果'); el.innerHTML=''; state.sizeVariant=null;
|
||||||
|
const variants=floor.variants||[];
|
||||||
|
$('sizePanel').style.display='block';
|
||||||
|
// Collect all sizes: main entry first, then variants
|
||||||
|
const mainLabel=floor.size_label||'默认尺寸';
|
||||||
|
const all=[{sku:floor.sku,label:mainLabel},...variants.map(v=>({sku:v.sku,label:v.size_label||v.sku}))];
|
||||||
|
all.forEach((v,i)=>{
|
||||||
|
const div=document.createElement('div');
|
||||||
|
div.className='option';
|
||||||
|
div.innerHTML=`<div class="name">${v.label}</div><div class="meta">${v.sku}</div>`;
|
||||||
|
div.onclick=()=>{state.sizeVariant=selectIn(el,v,div,'.option')};
|
||||||
|
el.appendChild(div);
|
||||||
|
if(i===0){div.click();}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function cardText(f){
|
||||||
|
return [f.name,f.color_tone,f.finish,f.material,f.sku].filter(Boolean).join(' ');
|
||||||
|
}
|
||||||
|
function renderRooms(){
|
||||||
|
const el=$('room可选效果'); el.innerHTML='';
|
||||||
|
(state.options.rooms||[]).forEach((r,i)=>{
|
||||||
|
const div=document.createElement('div');
|
||||||
|
div.className='option';
|
||||||
|
div.innerHTML=`<div class="name">${r.name}</div><div class="meta">code ${r.code}</div>`;
|
||||||
|
div.onclick=()=>{state.room=selectIn(el,r,div,'.option')};
|
||||||
|
el.appendChild(div);
|
||||||
|
if(i===0)div.click();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function render铺设方式s(){
|
||||||
|
const el=$('pattern可选效果'); el.innerHTML=''; state.pattern=null;
|
||||||
|
patternList().forEach((p,i)=>{
|
||||||
|
const div=document.createElement('div');
|
||||||
|
div.className='option';
|
||||||
|
div.innerHTML=`<div class="name">${p.name}</div><div class="meta">code ${p.code}</div>`;
|
||||||
|
div.onclick=()=>{state.pattern=selectIn(el,p,div,'.option');checkReady()};
|
||||||
|
el.appendChild(div);
|
||||||
|
if(i===0)div.click();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function renderFloors(){
|
||||||
|
const el=$('floor可选效果'); el.innerHTML=''; state.floor=null;
|
||||||
|
const q=$('floorSearch').value.trim().toLowerCase();
|
||||||
|
const list=floorList().filter(f=>!q||cardText(f).toLowerCase().includes(q)).slice(0,200);
|
||||||
|
if(list.length===0){el.innerHTML='<div class="small">没有匹配的地板款式</div>';checkReady();return}
|
||||||
|
list.forEach(f=>{
|
||||||
|
const div=document.createElement('div');
|
||||||
|
div.className='floor-card';
|
||||||
|
const img=f.image_url ? `<img src="${f.image_url}" alt="">` : '<img alt="">';
|
||||||
|
div.innerHTML=`${img}<div class="title">${f.name||f.sku}</div><div class="desc">${[f.color_tone,f.finish,f.material,f.sku].filter(Boolean).join(' · ')}</div>`;
|
||||||
|
div.onclick=()=>{state.floor=selectIn(el,f,div,'.floor-card');render尺寸s(f);checkReady()};
|
||||||
|
el.appendChild(div);
|
||||||
|
});
|
||||||
|
el.querySelector('.floor-card')?.click();
|
||||||
|
}
|
||||||
|
function setType(type){
|
||||||
|
state.type=type;
|
||||||
|
[...$('type可选效果').querySelectorAll('.option')].forEach(x=>x.classList.toggle('selected',x.dataset.type===type));
|
||||||
|
$('floorTitle').textContent=type==='tile'?'选择Tile / Stone款式':type==='vinyl'?'选择Luxury Vinyl款式':type==='special'?'AI Test地板 (含AI描述)':'选择Hardwood款式';
|
||||||
|
const groutDisabled=type!=='tile'&&type!=='vinyl'&&type!=='special';
|
||||||
|
$('grout').disabled=groutDisabled;
|
||||||
|
$('groutToggle').classList.toggle('disabled',groutDisabled);
|
||||||
|
if(groutDisabled){$('grout').checked=false;$('groutToggle').classList.remove('selected')}
|
||||||
|
render铺设方式s(); renderFloors(); checkReady();
|
||||||
|
}
|
||||||
|
async function load可选效果(){
|
||||||
|
setProgress('加载选项','正在读取数据库中的地板款式...', '8%');
|
||||||
|
const res=await fetch('/floor/options');
|
||||||
|
const text=await res.text();
|
||||||
|
let json; try{json=JSON.parse(text)}catch{throw new 错误('选项接口返回非 JSON\n'+text)}
|
||||||
|
if(!res.ok||json.code!==200)throw new 错误(json.error||text);
|
||||||
|
state.options=apiData(json);
|
||||||
|
renderRooms(); setType('wood');
|
||||||
|
$('statusBox').style.display='none';
|
||||||
|
}
|
||||||
|
function handleFile(file){
|
||||||
|
state.file=file; state.originalUrl=URL.createObjectURL(file);
|
||||||
|
$('previewImg').src=state.originalUrl;
|
||||||
|
$('previewImg').style.display='block';
|
||||||
|
$('uploadHint').style.display='none';
|
||||||
|
$('compare').style.display='none';
|
||||||
|
$('resultImg').style.display='none';
|
||||||
|
$('resultPlaceholder').style.display='flex';
|
||||||
|
$('resultPlaceholder').innerHTML='已选择图片,等待生成';
|
||||||
|
checkReady();
|
||||||
|
}
|
||||||
|
async function readJSON(res){
|
||||||
|
const text=await res.text();
|
||||||
|
try{return {json:JSON.parse(text),raw:text}}catch{return {json:null,raw:text}}
|
||||||
|
}
|
||||||
|
function progressInfo(data){
|
||||||
|
if(data.step==='check')return ['第一步:内容审核','20%'];
|
||||||
|
if(data.step==='mask')return ['第二步:生成地板蒙版','45%'];
|
||||||
|
if(data.step==='inpaint')return ['第三步:铺设地板','75%'];
|
||||||
|
if(data.step==='generate')return ['第二步:AI生成地板','75%'];
|
||||||
|
if(data.step==='done')return ['完成','100%'];
|
||||||
|
if(data.step==='error')return ['错误','100%'];
|
||||||
|
return ['处理中','35%'];
|
||||||
|
}
|
||||||
|
function formatMs(ms){
|
||||||
|
if(!ms)return '';
|
||||||
|
return ms>=60000 ? (ms/60000).toFixed(0)+'m'+(ms%60000/1000).toFixed(0)+'s' : ms>=1000 ? (ms/1000).toFixed(1)+'s' : ms+'ms';
|
||||||
|
}
|
||||||
|
function formatTimings(data){
|
||||||
|
const timings=data.timings_ms||{};
|
||||||
|
const stepNames={check:'内容审核',mask:'蒙版生成',inpaint:'地板铺设',generate:'AI生成',cleanup:'清理',total:'总耗时'};
|
||||||
|
const parts=[];
|
||||||
|
for(const key of ['check','mask','inpaint','generate','total']){
|
||||||
|
if(timings[key])parts.push(`${stepNames[key]||key}: ${formatMs(timings[key])}`);
|
||||||
|
}
|
||||||
|
return parts.length ? '\n⏱️ '+parts.join(' | ') : '';
|
||||||
|
}
|
||||||
|
// Live elapsed timer
|
||||||
|
function startLiveTimer(){
|
||||||
|
if(state.liveInterval)clearInterval(state.liveInterval);
|
||||||
|
state.liveStart=Date.now();
|
||||||
|
state.liveInterval=setInterval(()=>{
|
||||||
|
const elapsed=Date.now()-state.liveStart;
|
||||||
|
const el=$('liveTimer'); if(!el)return;
|
||||||
|
el.textContent='⏱️ 已运行: '+formatMs(elapsed);
|
||||||
|
el.style.display='block';
|
||||||
|
},1000);
|
||||||
|
}
|
||||||
|
async function poll(jobId){
|
||||||
|
const res=await fetch('/floor/status?job_id='+encodeURIComponent(jobId));
|
||||||
|
const {json,raw}=await readJSON(res);
|
||||||
|
const data=apiData(json);
|
||||||
|
state.lastStatus=data;
|
||||||
|
if(!res.ok||!json)throw new 错误(`状态接口错误 HTTP ${res.status}\n${raw}`);
|
||||||
|
const [label,width]=progressInfo(data);
|
||||||
|
setProgress(label,`job_id: ${jobId}\n状态: ${data.status||''}\n步骤: ${data.step||''}\n消息: ${data.message||''}${formatTimings(data)}`,width);
|
||||||
|
if(data.status==='done'||data.status==='error'){
|
||||||
|
if(state.liveInterval)clearInterval(state.liveInterval);
|
||||||
|
}
|
||||||
|
if(data.status==='error'||data.step==='error'){
|
||||||
|
throw new 错误(`${data.error||data.message||'未知错误'}\n\n原始状态响应:\n${JSON.stringify(json,null,2)}`);
|
||||||
|
}
|
||||||
|
if(data.status==='done'&&data.result){
|
||||||
|
clearInterval(state.timer); state.timer=null;
|
||||||
|
$('generateBtn').classList.remove('loading');
|
||||||
|
$('generateBtn').disabled=false;
|
||||||
|
const url=data.result+'?t='+Date.now();
|
||||||
|
$('resultPlaceholder').style.display='none';
|
||||||
|
$('resultImg').src=url; $('resultImg').style.display='block';
|
||||||
|
setupCompare(state.originalUrl,url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function setupCompare(before,after){
|
||||||
|
$('beforeImg').src=before; $('afterImg').src=after;
|
||||||
|
$('compare').style.display='block';
|
||||||
|
requestAnimationFrame(()=>$('beforeImg').style.width=$('compare').offsetWidth+'px');
|
||||||
|
}
|
||||||
|
function updateCompare(clientX){
|
||||||
|
const rect=$('compare').getBoundingClientRect();
|
||||||
|
let pct=(clientX-rect.left)/rect.width*100;
|
||||||
|
pct=Math.max(2,Math.min(98,pct));
|
||||||
|
$('clip').style.width=pct+'%';
|
||||||
|
$('handle').style.left=pct+'%';
|
||||||
|
}
|
||||||
|
|
||||||
|
$('uploadZone').onclick=()=>$('fileInput').click();
|
||||||
|
$('fileInput').onchange=()=>{if($('fileInput').files[0])handleFile($('fileInput').files[0])};
|
||||||
|
$('uploadZone').ondragover=e=>{e.preventDefault();$('uploadZone').classList.add('dragover')};
|
||||||
|
$('uploadZone').ondragleave=()=>$('uploadZone').classList.remove('dragover');
|
||||||
|
$('uploadZone').ondrop=e=>{e.preventDefault();$('uploadZone').classList.remove('dragover');if(e.dataTransfer.files[0])handleFile(e.dataTransfer.files[0])};
|
||||||
|
$('type可选效果').onclick=e=>{const opt=e.target.closest('.option');if(opt)setType(opt.dataset.type)};
|
||||||
|
$('floorSearch').oninput=renderFloors;
|
||||||
|
$('grout').onchange=()=>$('groutToggle').classList.toggle('selected',$('grout').checked);
|
||||||
|
$('baseboard').onchange=()=>$('baseboardToggle').classList.toggle('selected',$('baseboard').checked);
|
||||||
|
$('groutToggle').onclick=e=>{if(e.target.tagName!=='INPUT'&&!$('grout').disabled){$('grout').checked=!$('grout').checked;$('grout').onchange()}};
|
||||||
|
$('baseboardToggle').onclick=e=>{if(e.target.tagName!=='INPUT'){$('baseboard').checked=!$('baseboard').checked;$('baseboard').onchange()}};
|
||||||
|
|
||||||
|
$('generateBtn').onclick=async()=>{
|
||||||
|
if(!state.file||!state.floor||!state.pattern)return;
|
||||||
|
clearInterval(state.timer); clear错误();
|
||||||
|
$('generateBtn').classList.add('loading');
|
||||||
|
$('generateBtn').disabled=true;
|
||||||
|
$('compare').style.display='none';
|
||||||
|
$('resultImg').style.display='none';
|
||||||
|
$('resultPlaceholder').style.display='flex';
|
||||||
|
$('resultPlaceholder').innerHTML='正在提交任务...';
|
||||||
|
setProgress('提交任务','正在上传图片...', '10%');
|
||||||
|
startLiveTimer();
|
||||||
|
try{
|
||||||
|
const floorId=(state.sizeVariant&&state.sizeVariant.sku)||state.floor.id||state.floor.sku||'';
|
||||||
|
const patternCode=state.pattern.code||'';
|
||||||
|
const roomCode=state.room?state.room.code:0;
|
||||||
|
const fd=new FormData();
|
||||||
|
fd.append('image',state.file);
|
||||||
|
fd.append('floor_id',floorId);
|
||||||
|
fd.append('pattern_code',patternCode);
|
||||||
|
fd.append('room_code',roomCode);
|
||||||
|
fd.append('grout_lines',$('grout').checked?'true':'false');
|
||||||
|
fd.append('white_baseboard',$('baseboard').checked?'true':'false');
|
||||||
|
fd.append('material_source',state.materialSource||'image');
|
||||||
|
const submitted=`提交参数:\nfloor_id: ${floorId}\npattern_code: ${patternCode}\nroom_code: ${roomCode}\ngrout_lines: ${$('grout').checked}\nwhite_baseboard: ${$('baseboard').checked}`;
|
||||||
|
setProgress('提交任务',submitted, '10%');
|
||||||
|
const res=await fetch('/floor/generate',{method:'POST',body:fd});
|
||||||
|
const {json,raw}=await readJSON(res);
|
||||||
|
if(!res.ok||!json||json.code!==200){
|
||||||
|
throw new 错误(`${json?.error||'提交失败'}\n\n${submitted}\n\nHTTP ${res.status}\n${raw}`);
|
||||||
|
}
|
||||||
|
const jobId=json.data.job_id;
|
||||||
|
await poll(jobId);
|
||||||
|
state.timer=setInterval(()=>poll(jobId).catch(err=>{
|
||||||
|
clearInterval(state.timer); state.timer=null;
|
||||||
|
$('generateBtn').classList.remove('loading');
|
||||||
|
$('generateBtn').disabled=false;
|
||||||
|
show错误(err.message, state.lastStatus ? JSON.stringify(state.lastStatus,null,2) : '');
|
||||||
|
}),2500);
|
||||||
|
}catch(err){
|
||||||
|
if(state.liveInterval){clearInterval(state.liveInterval);$('liveTimer').style.display='none'}
|
||||||
|
$('generateBtn').classList.remove('loading');
|
||||||
|
$('generateBtn').disabled=false;
|
||||||
|
$('statusBox').style.display='none';
|
||||||
|
show错误(err.message,'');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let dragging=false;
|
||||||
|
$('compare').addEventListener('mousedown',e=>{dragging=true;updateCompare(e.clientX);e.preventDefault()});
|
||||||
|
window.addEventListener('mousemove',e=>{if(dragging)updateCompare(e.clientX)});
|
||||||
|
window.addEventListener('mouseup',()=>dragging=false);
|
||||||
|
$('compare').addEventListener('touchstart',e=>{dragging=true;updateCompare(e.touches[0].clientX);e.preventDefault()},{passive:false});
|
||||||
|
window.addEventListener('touchmove',e=>{if(dragging)updateCompare(e.touches[0].clientX)},{passive:false});
|
||||||
|
window.addEventListener('touchend',()=>dragging=false);
|
||||||
|
window.addEventListener('resize',()=>{if($('compare').style.display==='block')$('beforeImg').style.width=$('compare').offsetWidth+'px'});
|
||||||
|
|
||||||
|
load可选效果().catch(err=>show错误('选项加载失败',err.message));
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@ -11,3 +11,13 @@ func Index(w http.ResponseWriter, r *http.Request) {
|
|||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
_, _ = w.Write([]byte(indexHTML))
|
_, _ = w.Write([]byte(indexHTML))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func Questionnaire(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/questionnaire" {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = w.Write([]byte(questionnaireHTML))
|
||||||
|
}
|
||||||
|
|||||||
@ -4,3 +4,6 @@ import _ "embed"
|
|||||||
|
|
||||||
//go:embed index.html
|
//go:embed index.html
|
||||||
var indexHTML string
|
var indexHTML string
|
||||||
|
|
||||||
|
//go:embed questionnaire.html
|
||||||
|
var questionnaireHTML string
|
||||||
|
|||||||
359
internal/api/questionnaire.html
Normal file
359
internal/api/questionnaire.html
Normal file
@ -0,0 +1,359 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<title>地板偏好问卷 - FloorVisualizer</title>
|
||||||
|
<style>
|
||||||
|
*{box-sizing:border-box}
|
||||||
|
body{margin:0;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","Microsoft YaHei",Arial,sans-serif;background:#f5f3ee;color:#24211d}
|
||||||
|
button,input,select{font:inherit}
|
||||||
|
.shell{min-height:100vh;display:grid;grid-template-columns:390px 1fr}
|
||||||
|
.sidebar{background:#faf9f6;border-right:1px solid #ddd6c8;padding:24px;position:sticky;top:0;height:100vh;overflow:auto}
|
||||||
|
.brand{display:flex;align-items:center;justify-content:space-between;gap:16px;margin-bottom:22px}
|
||||||
|
.brand h1{font-size:20px;line-height:1.2;margin:0;color:#1e3832}
|
||||||
|
.brand a{color:#51645f;text-decoration:none;font-size:13px}
|
||||||
|
.progress{height:8px;background:#e4ded1;border-radius:999px;overflow:hidden;margin:10px 0 20px}
|
||||||
|
.progress span{display:block;height:100%;width:0;background:#2f6f64;transition:width .2s}
|
||||||
|
.group{padding:18px 0;border-top:1px solid #e3dccf}
|
||||||
|
.group:first-of-type{border-top:0}
|
||||||
|
.group h2{font-size:15px;margin:0 0 12px;color:#2d2a25}
|
||||||
|
.group .optional{font-size:12px;color:#867d70;font-weight:400;margin-left:6px}
|
||||||
|
.chips{display:flex;flex-wrap:wrap;gap:8px}
|
||||||
|
.chip{border:1px solid #cfc5b4;background:#fff;padding:8px 10px;border-radius:8px;color:#39342d;cursor:pointer;min-height:38px}
|
||||||
|
.chip:hover{border-color:#2f6f64}
|
||||||
|
.chip.selected{border-color:#2f6f64;background:#dfeee9;color:#183b34}
|
||||||
|
.chip.swatch{display:flex;align-items:center;gap:8px}
|
||||||
|
.dot{width:15px;height:15px;border-radius:50%;border:1px solid rgba(0,0,0,.18);flex:0 0 auto}
|
||||||
|
.row{display:grid;grid-template-columns:1fr 1fr;gap:10px}
|
||||||
|
.field{display:flex;flex-direction:column;gap:6px}
|
||||||
|
.field label{font-size:12px;color:#6f675d}
|
||||||
|
.field input,.field select{width:100%;border:1px solid #cfc5b4;background:#fff;border-radius:8px;padding:10px;color:#24211d}
|
||||||
|
.actions{display:flex;gap:10px;position:sticky;bottom:0;background:linear-gradient(180deg,rgba(250,249,246,0),#faf9f6 20%);padding-top:18px}
|
||||||
|
.primary,.secondary{border:0;border-radius:8px;padding:12px 14px;cursor:pointer;font-weight:700}
|
||||||
|
.primary{background:#2f6f64;color:white;flex:1}
|
||||||
|
.primary:hover{background:#24594f}
|
||||||
|
.primary:disabled{background:#9aa5a1;cursor:not-allowed}
|
||||||
|
.secondary{background:#e9e2d6;color:#3f3931}
|
||||||
|
.main{padding:28px;overflow:auto}
|
||||||
|
.summary{display:grid;grid-template-columns:1fr auto;gap:20px;align-items:end;margin-bottom:22px}
|
||||||
|
.summary h2{font-size:24px;margin:0 0 8px;color:#1d332f}
|
||||||
|
.summary p{margin:0;color:#6b6258;line-height:1.5}
|
||||||
|
.stats{display:flex;gap:12px;flex-wrap:wrap;justify-content:flex-end}
|
||||||
|
.stat{background:#fff;border:1px solid #ddd6c8;border-radius:8px;padding:10px 12px;min-width:110px}
|
||||||
|
.stat strong{display:block;font-size:18px;color:#2f6f64}
|
||||||
|
.stat span{font-size:12px;color:#777064}
|
||||||
|
.empty{min-height:420px;border:1px dashed #cfc5b4;border-radius:8px;background:#fffaf2;display:flex;align-items:center;justify-content:center;text-align:center;color:#6b6258;line-height:1.7;padding:24px}
|
||||||
|
.results{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px}
|
||||||
|
.card{background:#fff;border:1px solid #ddd6c8;border-radius:8px;display:grid;grid-template-columns:160px 1fr;min-height:176px;overflow:hidden}
|
||||||
|
.media{background:#e6e0d4;position:relative}
|
||||||
|
.media img{width:100%;height:100%;object-fit:cover;display:block}
|
||||||
|
.rank{position:absolute;top:10px;left:10px;background:#1e3832;color:white;border-radius:999px;padding:4px 8px;font-size:12px;font-weight:700}
|
||||||
|
.score{position:absolute;right:10px;bottom:10px;background:#d99c35;color:#20170b;border-radius:999px;padding:4px 8px;font-size:12px;font-weight:800}
|
||||||
|
.content{padding:14px;display:flex;flex-direction:column;gap:8px}
|
||||||
|
.meta{display:flex;flex-wrap:wrap;gap:6px}
|
||||||
|
.pill{background:#eee8dc;border-radius:999px;padding:4px 8px;font-size:12px;color:#5d5449}
|
||||||
|
.title{font-size:16px;font-weight:800;line-height:1.3;color:#25211b}
|
||||||
|
.sub{font-size:13px;color:#6f675d;line-height:1.35}
|
||||||
|
.reasons{display:flex;flex-wrap:wrap;gap:6px;margin-top:auto}
|
||||||
|
.reason{border:1px solid #cbded8;background:#eef8f5;color:#24594f;border-radius:999px;padding:4px 8px;font-size:12px}
|
||||||
|
.links{display:flex;gap:8px;margin-top:2px}
|
||||||
|
.links a{color:#2f6f64;font-size:13px;text-decoration:none;font-weight:700}
|
||||||
|
.loading{opacity:.7;pointer-events:none}
|
||||||
|
.notice{display:none;margin-bottom:14px;padding:12px 14px;border:1px solid #d9b978;background:#fff4d8;border-radius:8px;color:#5c4518}
|
||||||
|
@media(max-width:1100px){.shell{grid-template-columns:1fr}.sidebar{position:relative;height:auto}.summary{grid-template-columns:1fr}.stats{justify-content:flex-start}.results{grid-template-columns:1fr}}
|
||||||
|
@media(max-width:620px){.sidebar,.main{padding:18px}.card{grid-template-columns:1fr}.media{height:210px}.row{grid-template-columns:1fr}}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="shell">
|
||||||
|
<aside class="sidebar">
|
||||||
|
<div class="brand">
|
||||||
|
<h1>地板偏好问卷</h1>
|
||||||
|
<a href="/">返回 Demo</a>
|
||||||
|
</div>
|
||||||
|
<div class="progress"><span id="progress"></span></div>
|
||||||
|
|
||||||
|
<section class="group" data-required="rooms">
|
||||||
|
<h2>铺设空间</h2>
|
||||||
|
<div class="chips" data-key="rooms" data-multi="true">
|
||||||
|
<button class="chip" data-value="living_room">客厅</button>
|
||||||
|
<button class="chip" data-value="bedroom">卧室</button>
|
||||||
|
<button class="chip" data-value="kitchen">厨房</button>
|
||||||
|
<button class="chip" data-value="bathroom">卫生间</button>
|
||||||
|
<button class="chip" data-value="basement">地下室</button>
|
||||||
|
<button class="chip" data-value="rental">出租房/高频使用</button>
|
||||||
|
<button class="chip" data-value="commercial">商业空间</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="group" data-required="styles">
|
||||||
|
<h2>整体风格</h2>
|
||||||
|
<div class="chips" data-key="styles" data-multi="true" data-max="2">
|
||||||
|
<button class="chip" data-value="modern">现代简约</button>
|
||||||
|
<button class="chip" data-value="warm_natural">温暖自然</button>
|
||||||
|
<button class="chip" data-value="classic">经典传统</button>
|
||||||
|
<button class="chip" data-value="rustic">乡村/复古</button>
|
||||||
|
<button class="chip" data-value="luxury_stone">轻奢石纹</button>
|
||||||
|
<button class="chip" data-value="industrial">工业冷调</button>
|
||||||
|
<button class="chip" data-value="coastal">明亮海岸风</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="group" data-required="looks">
|
||||||
|
<h2>外观类型</h2>
|
||||||
|
<div class="chips" data-key="looks" data-multi="true">
|
||||||
|
<button class="chip" data-value="wood_look">木纹</button>
|
||||||
|
<button class="chip" data-value="stone_tile">石纹/瓷砖感</button>
|
||||||
|
<button class="chip" data-value="real_hardwood">真实硬木</button>
|
||||||
|
<button class="chip" data-value="light_clean">浅色干净</button>
|
||||||
|
<button class="chip" data-value="dark_rich">深色稳重</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="group" data-required="color_tones">
|
||||||
|
<h2>颜色倾向</h2>
|
||||||
|
<div class="chips" data-key="color_tones" data-multi="true">
|
||||||
|
<button class="chip swatch" data-value="Natural"><span class="dot" style="background:#b98b58"></span>自然木色</button>
|
||||||
|
<button class="chip swatch" data-value="Light Gray"><span class="dot" style="background:#b9bbb8"></span>浅灰</button>
|
||||||
|
<button class="chip swatch" data-value="Off White"><span class="dot" style="background:#eee8da"></span>米白</button>
|
||||||
|
<button class="chip swatch" data-value="Dark Brown"><span class="dot" style="background:#5a3928"></span>深棕</button>
|
||||||
|
<button class="chip swatch" data-value="Taupe"><span class="dot" style="background:#9b8d7a"></span>灰褐</button>
|
||||||
|
<button class="chip swatch" data-value="Cherry"><span class="dot" style="background:#8d3f31"></span>樱桃色</button>
|
||||||
|
<button class="chip swatch" data-value="Black Walnut"><span class="dot" style="background:#31241d"></span>黑胡桃</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="group" data-required="budget">
|
||||||
|
<h2>预算范围</h2>
|
||||||
|
<div class="chips" data-key="budgetPreset">
|
||||||
|
<button class="chip" data-min="0" data-max="5">< $5/sqft</button>
|
||||||
|
<button class="chip" data-min="5" data-max="8">$5-$8</button>
|
||||||
|
<button class="chip" data-min="8" data-max="12">$8-$12</button>
|
||||||
|
<button class="chip" data-min="12" data-max="20">$12-$20</button>
|
||||||
|
<button class="chip" data-min="20" data-max="0">$20+</button>
|
||||||
|
</div>
|
||||||
|
<div class="row" style="margin-top:10px">
|
||||||
|
<div class="field"><label>最低</label><input id="budgetMin" type="number" min="0" step="0.5" placeholder="0"></div>
|
||||||
|
<div class="field"><label>最高</label><input id="budgetMax" type="number" min="0" step="0.5" placeholder="不限"></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="group">
|
||||||
|
<h2>性能偏好<span class="optional">可跳过</span></h2>
|
||||||
|
<div class="chips" data-key="performance" data-multi="true">
|
||||||
|
<button class="chip" data-value="waterproof">防水/防潮</button>
|
||||||
|
<button class="chip" data-value="scratch_resistant">耐磨耐刮</button>
|
||||||
|
<button class="chip" data-value="easy_clean">容易清洁</button>
|
||||||
|
<button class="chip" data-value="pet_kid">适合宠物/孩子</button>
|
||||||
|
<button class="chip" data-value="comfort_quiet">脚感舒适/安静</button>
|
||||||
|
<button class="chip" data-value="eco">环保倾向</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="group">
|
||||||
|
<h2>表面质感<span class="optional">可跳过</span></h2>
|
||||||
|
<div class="chips" data-key="finishes" data-multi="true">
|
||||||
|
<button class="chip" data-value="Matte">哑光</button>
|
||||||
|
<button class="chip" data-value="Gloss">亮面</button>
|
||||||
|
<button class="chip" data-value="Distressed/Embossed">复古做旧</button>
|
||||||
|
<button class="chip" data-value="Wire Brushed">拉丝纹理</button>
|
||||||
|
<button class="chip" data-value="Textured">触感纹理</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="group">
|
||||||
|
<h2>规格视觉<span class="optional">可跳过</span></h2>
|
||||||
|
<div class="chips" data-key="size_preference">
|
||||||
|
<button class="chip" data-value="wide_plank">宽板木纹</button>
|
||||||
|
<button class="chip" data-value="standard_plank">标准木板</button>
|
||||||
|
<button class="chip" data-value="large_tile">大规格石材</button>
|
||||||
|
<button class="chip" data-value="small_tile">小砖/马赛克</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="group">
|
||||||
|
<h2>品牌<span class="optional">可跳过</span></h2>
|
||||||
|
<div class="chips" id="brandChips" data-key="brands" data-multi="true"></div>
|
||||||
|
<div class="field" style="margin-top:10px">
|
||||||
|
<label>品牌处理方式</label>
|
||||||
|
<select id="brandMode">
|
||||||
|
<option value="boost">偏好加分</option>
|
||||||
|
<option value="only">只看所选品牌</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="group">
|
||||||
|
<h2>优先级<span class="optional">可跳过</span></h2>
|
||||||
|
<div class="chips" data-key="priority">
|
||||||
|
<button class="chip" data-value="style">风格最重要</button>
|
||||||
|
<button class="chip" data-value="budget">价格最重要</button>
|
||||||
|
<button class="chip" data-value="durability">耐用最重要</button>
|
||||||
|
<button class="chip" data-value="brand">品牌最重要</button>
|
||||||
|
<button class="chip" data-value="color">颜色最重要</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<button class="secondary" id="resetBtn">重置</button>
|
||||||
|
<button class="primary" id="submitBtn">推荐 Top 10</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main class="main">
|
||||||
|
<div class="summary">
|
||||||
|
<div>
|
||||||
|
<h2>问卷推荐结果</h2>
|
||||||
|
<p id="summaryText">回答左侧核心问题后,系统会对产品库打分、合并同款不同尺寸,并返回最匹配的 10 款。</p>
|
||||||
|
</div>
|
||||||
|
<div class="stats">
|
||||||
|
<div class="stat"><strong id="totalStat">-</strong><span>候选 SKU</span></div>
|
||||||
|
<div class="stat"><strong id="uniqueStat">-</strong><span>去重款式</span></div>
|
||||||
|
<div class="stat"><strong id="answeredStat">0%</strong><span>问卷完成度</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="notice" id="notice"></div>
|
||||||
|
<div class="empty" id="empty">请选择你的空间、风格、外观、颜色和预算。</div>
|
||||||
|
<div class="results" id="results"></div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const state={rooms:[],styles:[],looks:[],color_tones:[],performance:[],finishes:[],brands:[],size_preference:'',priority:'',budget:{min:0,max:0}};
|
||||||
|
const required=['rooms','styles','looks','color_tones','budget'];
|
||||||
|
const $=id=>document.getElementById(id);
|
||||||
|
|
||||||
|
function selectedValues(key){return state[key]||[]}
|
||||||
|
function setProgress(){
|
||||||
|
let done=0;
|
||||||
|
for(const key of required){
|
||||||
|
if(key==='budget'){ if(state.budget.min>0||state.budget.max>0) done++; }
|
||||||
|
else if((state[key]||[]).length) done++;
|
||||||
|
}
|
||||||
|
$('progress').style.width=(done/required.length*100)+'%';
|
||||||
|
$('answeredStat').textContent=Math.round(done/required.length*100)+'%';
|
||||||
|
}
|
||||||
|
function bindChips(root=document){
|
||||||
|
root.querySelectorAll('.chips').forEach(group=>{
|
||||||
|
group.addEventListener('click',e=>{
|
||||||
|
const chip=e.target.closest('.chip');
|
||||||
|
if(!chip)return;
|
||||||
|
const key=group.dataset.key;
|
||||||
|
if(key==='budgetPreset'){
|
||||||
|
group.querySelectorAll('.chip').forEach(c=>c.classList.remove('selected'));
|
||||||
|
chip.classList.add('selected');
|
||||||
|
state.budget={min:Number(chip.dataset.min||0),max:Number(chip.dataset.max||0)};
|
||||||
|
$('budgetMin').value=state.budget.min||'';
|
||||||
|
$('budgetMax').value=state.budget.max||'';
|
||||||
|
setProgress();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const multi=group.dataset.multi==='true';
|
||||||
|
const max=Number(group.dataset.max||0);
|
||||||
|
const value=chip.dataset.value;
|
||||||
|
if(multi){
|
||||||
|
const list=selectedValues(key);
|
||||||
|
const i=list.indexOf(value);
|
||||||
|
if(i>=0){list.splice(i,1);chip.classList.remove('selected');}
|
||||||
|
else{
|
||||||
|
if(max&&list.length>=max){
|
||||||
|
const removed=list.shift();
|
||||||
|
group.querySelector(`.chip[data-value="${CSS.escape(removed)}"]`)?.classList.remove('selected');
|
||||||
|
}
|
||||||
|
list.push(value);chip.classList.add('selected');
|
||||||
|
}
|
||||||
|
}else{
|
||||||
|
group.querySelectorAll('.chip').forEach(c=>c.classList.remove('selected'));
|
||||||
|
if(state[key]===value){state[key]='';}
|
||||||
|
else{state[key]=value;chip.classList.add('selected');}
|
||||||
|
}
|
||||||
|
setProgress();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function budgetInputs(){
|
||||||
|
state.budget={min:Number($('budgetMin').value||0),max:Number($('budgetMax').value||0)};
|
||||||
|
document.querySelectorAll('[data-key="budgetPreset"] .chip').forEach(c=>c.classList.remove('selected'));
|
||||||
|
setProgress();
|
||||||
|
}
|
||||||
|
function resetAll(){
|
||||||
|
for(const k of ['rooms','styles','looks','color_tones','performance','finishes','brands'])state[k]=[];
|
||||||
|
state.size_preference='';state.priority='';state.budget={min:0,max:0};
|
||||||
|
document.querySelectorAll('.chip').forEach(c=>c.classList.remove('selected'));
|
||||||
|
$('budgetMin').value='';$('budgetMax').value='';$('brandMode').value='boost';
|
||||||
|
$('results').innerHTML='';$('empty').style.display='flex';$('notice').style.display='none';
|
||||||
|
$('totalStat').textContent='-';$('uniqueStat').textContent='-';setProgress();
|
||||||
|
}
|
||||||
|
async function loadOptions(){
|
||||||
|
try{
|
||||||
|
const res=await fetch('/questionnaire/options');
|
||||||
|
const json=await res.json();
|
||||||
|
const data=json.data||json;
|
||||||
|
const brands=(data.brands||[]).slice(0,12);
|
||||||
|
$('brandChips').innerHTML=brands.map(b=>`<button class="chip" data-value="${escapeHtml(b.value)}">${escapeHtml(b.value)} <span class="optional">${b.count}</span></button>`).join('');
|
||||||
|
bindChips($('brandChips').parentElement);
|
||||||
|
}catch(e){
|
||||||
|
$('brandChips').innerHTML='<button class="chip" data-value="Shaw Floors">Shaw Floors</button><button class="chip" data-value="COREtec">COREtec</button><button class="chip" data-value="Bruce">Bruce</button>';
|
||||||
|
bindChips($('brandChips').parentElement);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function recommend(){
|
||||||
|
budgetInputs();
|
||||||
|
const payload={...state,brand_mode:$('brandMode').value,limit:10};
|
||||||
|
$('submitBtn').disabled=true;
|
||||||
|
$('submitBtn').textContent='推荐中...';
|
||||||
|
document.body.classList.add('loading');
|
||||||
|
$('notice').style.display='none';
|
||||||
|
try{
|
||||||
|
const res=await fetch('/questionnaire/recommend',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});
|
||||||
|
const json=await res.json();
|
||||||
|
if(!res.ok)throw new Error(json.error||'推荐失败');
|
||||||
|
renderResults(json.data||json);
|
||||||
|
}catch(e){
|
||||||
|
$('notice').style.display='block';
|
||||||
|
$('notice').textContent=e.message;
|
||||||
|
}finally{
|
||||||
|
$('submitBtn').disabled=false;
|
||||||
|
$('submitBtn').textContent='推荐 Top 10';
|
||||||
|
document.body.classList.remove('loading');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function renderResults(data){
|
||||||
|
const recs=data.recommendations||[];
|
||||||
|
$('totalStat').textContent=data.total_candidates||0;
|
||||||
|
$('uniqueStat').textContent=data.unique_candidates||0;
|
||||||
|
$('empty').style.display=recs.length?'none':'flex';
|
||||||
|
$('empty').textContent=recs.length?'':'没有找到匹配产品,可以放宽品牌或预算条件。';
|
||||||
|
$('summaryText').textContent=recs.length?`已按你的偏好筛出 ${recs.length} 款推荐,分数来自风格、颜色、预算、材质、性能和品牌等维度。`:'没有找到匹配产品。';
|
||||||
|
$('results').innerHTML=recs.map((r,i)=>cardHtml(r,i)).join('');
|
||||||
|
}
|
||||||
|
function cardHtml(r,i){
|
||||||
|
const p=r.product||{};
|
||||||
|
const img=p.room_image_url||p.main_image_url||'';
|
||||||
|
const price=p.price_per_sqft?`$${Number(p.price_per_sqft).toFixed(2)}/sqft`:'价格待确认';
|
||||||
|
const specs=r.spec_count>1?`${r.spec_count} 个规格`:(p.size_label||'规格待确认');
|
||||||
|
const reasons=(r.reasons||[]).map(x=>`<span class="reason">${escapeHtml(x)}</span>`).join('');
|
||||||
|
return `<article class="card">
|
||||||
|
<div class="media">${img?`<img src="${escapeHtml(img)}" alt="">`:''}<span class="rank">#${i+1}</span><span class="score">${Math.round(r.score)}分</span></div>
|
||||||
|
<div class="content">
|
||||||
|
<div class="meta"><span class="pill">${escapeHtml(p.brand||'')}</span><span class="pill">${escapeHtml(p.category||'')}</span><span class="pill">${escapeHtml(price)}</span></div>
|
||||||
|
<div class="title">${escapeHtml(p.style_name||p.series_name||p.sku)}</div>
|
||||||
|
<div class="sub">${escapeHtml([p.series_name,p.color_tone,p.material,p.finish,specs].filter(Boolean).join(' · '))}</div>
|
||||||
|
<div class="reasons">${reasons}</div>
|
||||||
|
<div class="links">${p.sku?`<a href="/products/${encodeURIComponent(p.sku)}" target="_blank">查看详情</a>`:''}${p.source_url?`<a href="${escapeHtml(p.source_url)}" target="_blank">品牌页面</a>`:''}</div>
|
||||||
|
</div>
|
||||||
|
</article>`;
|
||||||
|
}
|
||||||
|
function escapeHtml(s){return String(s??'').replace(/[&<>"']/g,m=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m]))}
|
||||||
|
|
||||||
|
bindChips();
|
||||||
|
$('budgetMin').addEventListener('input',budgetInputs);
|
||||||
|
$('budgetMax').addEventListener('input',budgetInputs);
|
||||||
|
$('resetBtn').addEventListener('click',resetAll);
|
||||||
|
$('submitBtn').addEventListener('click',recommend);
|
||||||
|
loadOptions();
|
||||||
|
setProgress();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@ -32,6 +32,8 @@ func RegisterRoutes(mux *http.ServeMux, client *openrouter.Client, db *sql.DB, a
|
|||||||
productOpts = handler.ProductOptions(db)
|
productOpts = handler.ProductOptions(db)
|
||||||
filterOpts = handler.FilterOptions(db)
|
filterOpts = handler.FilterOptions(db)
|
||||||
recommend = handler.Recommend(db)
|
recommend = handler.Recommend(db)
|
||||||
|
questionnaireRec = handler.QuestionnaireRecommend(db)
|
||||||
|
questionnaireOpt = handler.QuestionnaireOptions(db)
|
||||||
floorGenerate = handler.FloorGenerate(client, db)
|
floorGenerate = handler.FloorGenerate(client, db)
|
||||||
floorOptions = handler.FloorOptions(db)
|
floorOptions = handler.FloorOptions(db)
|
||||||
articleRecommend = handler.ArticleRecommend(db)
|
articleRecommend = handler.ArticleRecommend(db)
|
||||||
@ -56,6 +58,9 @@ func RegisterRoutes(mux *http.ServeMux, client *openrouter.Client, db *sql.DB, a
|
|||||||
mux.HandleFunc("/product-options", productOpts)
|
mux.HandleFunc("/product-options", productOpts)
|
||||||
mux.HandleFunc("/filter-options", filterOpts)
|
mux.HandleFunc("/filter-options", filterOpts)
|
||||||
mux.HandleFunc("/recommend/api", recommend)
|
mux.HandleFunc("/recommend/api", recommend)
|
||||||
|
mux.HandleFunc("/questionnaire", Questionnaire)
|
||||||
|
mux.HandleFunc("/questionnaire/options", questionnaireOpt)
|
||||||
|
mux.HandleFunc("/questionnaire/recommend", questionnaireRec)
|
||||||
mux.HandleFunc("/calculator/calc", handler.Calc)
|
mux.HandleFunc("/calculator/calc", handler.Calc)
|
||||||
mux.HandleFunc("/floor/generate", floorGenerate)
|
mux.HandleFunc("/floor/generate", floorGenerate)
|
||||||
mux.HandleFunc("/floor/status", handler.FloorStatus)
|
mux.HandleFunc("/floor/status", handler.FloorStatus)
|
||||||
|
|||||||
713
internal/handler/questionnaire_handler.go
Normal file
713
internal/handler/questionnaire_handler.go
Normal file
@ -0,0 +1,713 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"net/http"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"floorvisualizer/internal/model"
|
||||||
|
"floorvisualizer/internal/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
type QuestionnaireBudget struct {
|
||||||
|
Min float64 `json:"min"`
|
||||||
|
Max float64 `json:"max"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type QuestionnaireRequest struct {
|
||||||
|
Rooms []string `json:"rooms"`
|
||||||
|
Styles []string `json:"styles"`
|
||||||
|
Looks []string `json:"looks"`
|
||||||
|
ColorTones []string `json:"color_tones"`
|
||||||
|
Budget QuestionnaireBudget `json:"budget"`
|
||||||
|
Brands []string `json:"brands"`
|
||||||
|
BrandMode string `json:"brand_mode"`
|
||||||
|
Performance []string `json:"performance"`
|
||||||
|
Finishes []string `json:"finishes"`
|
||||||
|
SizePreference string `json:"size_preference"`
|
||||||
|
Priority string `json:"priority"`
|
||||||
|
Answers map[string]any `json:"answers,omitempty"`
|
||||||
|
Limit int `json:"limit"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type QuestionnaireRecommendation struct {
|
||||||
|
Product model.Product `json:"product"`
|
||||||
|
Score float64 `json:"score"`
|
||||||
|
Reasons []string `json:"reasons"`
|
||||||
|
SpecCount int `json:"spec_count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type QuestionnaireResponse struct {
|
||||||
|
Recommendations []QuestionnaireRecommendation `json:"recommendations"`
|
||||||
|
TotalCandidates int `json:"total_candidates"`
|
||||||
|
UniqueCandidates int `json:"unique_candidates"`
|
||||||
|
AnsweredWeights float64 `json:"answered_weights"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type questionnaireScoredProduct struct {
|
||||||
|
product model.Product
|
||||||
|
score float64
|
||||||
|
reasons []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func QuestionnaireOptions(db *sql.DB) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if db == nil {
|
||||||
|
writeErr(w, http.StatusServiceUnavailable, "database is not available")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out := map[string]any{
|
||||||
|
"brands": queryOptionCounts(db, "brand", 20),
|
||||||
|
"categories": queryOptionCounts(db, "category", 20),
|
||||||
|
"materials": queryOptionCounts(db, "material", 40),
|
||||||
|
"color_tones": queryOptionCounts(db, "color_tone", 30),
|
||||||
|
"finishes": queryOptionCounts(db, "finish", 20),
|
||||||
|
"price_stats": queryPriceStats(db),
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func QuestionnaireRecommend(db *sql.DB) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if db == nil {
|
||||||
|
writeErr(w, http.StatusServiceUnavailable, "database is not available")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
writeErr(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req QuestionnaireRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeErr(w, http.StatusBadRequest, "invalid questionnaire payload")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
normalizeQuestionnaireRequest(&req)
|
||||||
|
if req.Limit <= 0 || req.Limit > 30 {
|
||||||
|
req.Limit = 10
|
||||||
|
}
|
||||||
|
|
||||||
|
products, err := repository.QueryAllProductRows(db)
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
brandOnly := req.BrandMode == "only" && len(req.Brands) > 0
|
||||||
|
brandSet := toSet(req.Brands)
|
||||||
|
scored := make([]questionnaireScoredProduct, 0, len(products))
|
||||||
|
for _, p := range products {
|
||||||
|
if !recommendableProduct(p) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if brandOnly && !brandSet[p.Brand] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
score, reasons, answeredWeight := scoreQuestionnaireProduct(req, p)
|
||||||
|
if answeredWeight == 0 {
|
||||||
|
score = neutralProductScore(p)
|
||||||
|
}
|
||||||
|
scored = append(scored, questionnaireScoredProduct{product: p, score: roundOne(score), reasons: reasons})
|
||||||
|
}
|
||||||
|
|
||||||
|
unique := dedupeQuestionnaireResults(scored)
|
||||||
|
sort.SliceStable(unique, func(i, j int) bool {
|
||||||
|
if unique[i].score != unique[j].score {
|
||||||
|
return unique[i].score > unique[j].score
|
||||||
|
}
|
||||||
|
if unique[i].product.IsOfficialPrice != unique[j].product.IsOfficialPrice {
|
||||||
|
return unique[i].product.IsOfficialPrice
|
||||||
|
}
|
||||||
|
if req.Priority == "budget" && unique[i].product.PricePerSqft != unique[j].product.PricePerSqft {
|
||||||
|
return unique[i].product.PricePerSqft < unique[j].product.PricePerSqft
|
||||||
|
}
|
||||||
|
return unique[i].product.MainImageURL != "" && unique[j].product.MainImageURL == ""
|
||||||
|
})
|
||||||
|
|
||||||
|
selected := diversifyQuestionnaireResults(unique, req.Limit, brandOnly)
|
||||||
|
skus := make([]string, 0, len(selected))
|
||||||
|
for _, r := range selected {
|
||||||
|
skus = append(skus, r.product.SKU)
|
||||||
|
}
|
||||||
|
specCounts := repository.GetSpecCounts(db, skus)
|
||||||
|
|
||||||
|
recs := make([]QuestionnaireRecommendation, 0, len(selected))
|
||||||
|
for _, r := range selected {
|
||||||
|
key := r.product.Brand + "|" + r.product.StyleName + "|" + r.product.SeriesName
|
||||||
|
recs = append(recs, QuestionnaireRecommendation{
|
||||||
|
Product: r.product,
|
||||||
|
Score: r.score,
|
||||||
|
Reasons: fallbackReasons(r.reasons, r.product),
|
||||||
|
SpecCount: specCounts[key],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
_, _, answeredWeight := scoreQuestionnaireProduct(req, model.Product{})
|
||||||
|
writeJSON(w, http.StatusOK, QuestionnaireResponse{
|
||||||
|
Recommendations: recs,
|
||||||
|
TotalCandidates: len(scored),
|
||||||
|
UniqueCandidates: len(unique),
|
||||||
|
AnsweredWeights: roundOne(answeredWeight),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeQuestionnaireRequest(req *QuestionnaireRequest) {
|
||||||
|
req.Rooms = cleanList(req.Rooms)
|
||||||
|
req.Styles = cleanList(req.Styles)
|
||||||
|
req.Looks = cleanList(req.Looks)
|
||||||
|
req.ColorTones = cleanList(req.ColorTones)
|
||||||
|
req.Brands = cleanList(req.Brands)
|
||||||
|
req.Performance = cleanList(req.Performance)
|
||||||
|
req.Finishes = cleanList(req.Finishes)
|
||||||
|
req.SizePreference = strings.TrimSpace(req.SizePreference)
|
||||||
|
req.Priority = strings.TrimSpace(req.Priority)
|
||||||
|
req.BrandMode = strings.TrimSpace(req.BrandMode)
|
||||||
|
}
|
||||||
|
|
||||||
|
func scoreQuestionnaireProduct(req QuestionnaireRequest, p model.Product) (float64, []string, float64) {
|
||||||
|
weights := map[string]float64{
|
||||||
|
"color": 20,
|
||||||
|
"style": 20,
|
||||||
|
"look": 15,
|
||||||
|
"budget": 15,
|
||||||
|
"room": 10,
|
||||||
|
"performance": 10,
|
||||||
|
"brand": 7,
|
||||||
|
"finish": 5,
|
||||||
|
"size": 3,
|
||||||
|
}
|
||||||
|
adjustWeights(weights, req.Priority)
|
||||||
|
|
||||||
|
totalWeight := 0.0
|
||||||
|
weighted := 0.0
|
||||||
|
reasons := []string{}
|
||||||
|
|
||||||
|
add := func(name string, answered bool, score float64, reason string) {
|
||||||
|
if !answered {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w := weights[name]
|
||||||
|
totalWeight += w
|
||||||
|
weighted += clampScore(score) * w
|
||||||
|
if score >= 78 && reason != "" {
|
||||||
|
reasons = append(reasons, reason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
add("color", len(req.ColorTones) > 0, scoreExactOrRelated(p.ColorTone, req.ColorTones, colorRelatedGroups()), fmt.Sprintf("颜色接近 %s", p.ColorTone))
|
||||||
|
add("style", len(req.Styles) > 0, scoreStyles(p, req.Styles), "风格偏好匹配")
|
||||||
|
add("look", len(req.Looks) > 0, scoreLooks(p, req.Looks), "外观类型匹配")
|
||||||
|
add("budget", req.Budget.Min > 0 || req.Budget.Max > 0, scoreBudget(p.PricePerSqft, req.Budget), priceReason(p))
|
||||||
|
add("room", len(req.Rooms) > 0, scoreRooms(p, req.Rooms), "适合选择的使用空间")
|
||||||
|
add("performance", len(req.Performance) > 0, scorePerformance(p, req.Performance), "性能偏好匹配")
|
||||||
|
add("brand", len(req.Brands) > 0 && req.BrandMode != "only", scoreExactOrRelated(p.Brand, req.Brands, nil), fmt.Sprintf("匹配品牌 %s", p.Brand))
|
||||||
|
add("finish", len(req.Finishes) > 0, scoreExactOrRelated(p.Finish, req.Finishes, [][]string{{"Matte", "Textured"}, {"Distressed/Embossed", "Wire Brushed"}}), fmt.Sprintf("表面质感为 %s", p.Finish))
|
||||||
|
add("size", req.SizePreference != "", scoreSize(p, req.SizePreference), "规格视觉匹配")
|
||||||
|
|
||||||
|
if totalWeight == 0 {
|
||||||
|
return 0, reasons, 0
|
||||||
|
}
|
||||||
|
|
||||||
|
score := weighted / totalWeight
|
||||||
|
if p.MainImageURL != "" || p.RoomImageURL != "" {
|
||||||
|
score += 1.5
|
||||||
|
}
|
||||||
|
if p.IsOfficialPrice {
|
||||||
|
score += 1
|
||||||
|
}
|
||||||
|
return clampScore(score), compactReasons(reasons), totalWeight
|
||||||
|
}
|
||||||
|
|
||||||
|
func adjustWeights(weights map[string]float64, priority string) {
|
||||||
|
switch priority {
|
||||||
|
case "style":
|
||||||
|
weights["style"] += 8
|
||||||
|
weights["color"] += 4
|
||||||
|
case "budget":
|
||||||
|
weights["budget"] += 12
|
||||||
|
case "durability":
|
||||||
|
weights["performance"] += 10
|
||||||
|
weights["room"] += 4
|
||||||
|
case "brand":
|
||||||
|
weights["brand"] += 12
|
||||||
|
case "color":
|
||||||
|
weights["color"] += 12
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func recommendableProduct(p model.Product) bool {
|
||||||
|
status := strings.ToLower(strings.TrimSpace(p.Status))
|
||||||
|
if strings.Contains(status, "discontinued") || strings.Contains(status, "inactive") || strings.Contains(status, "下架") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return p.SKU != "" && p.StyleName != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func neutralProductScore(p model.Product) float64 {
|
||||||
|
score := 60.0
|
||||||
|
if p.MainImageURL != "" || p.RoomImageURL != "" {
|
||||||
|
score += 8
|
||||||
|
}
|
||||||
|
if p.PricePerSqft > 0 {
|
||||||
|
score += 5
|
||||||
|
}
|
||||||
|
if p.IsOfficialPrice {
|
||||||
|
score += 3
|
||||||
|
}
|
||||||
|
return score
|
||||||
|
}
|
||||||
|
|
||||||
|
func scoreExactOrRelated(value string, selected []string, related [][]string) float64 {
|
||||||
|
if value == "" || len(selected) == 0 {
|
||||||
|
return 50
|
||||||
|
}
|
||||||
|
for _, s := range selected {
|
||||||
|
if strings.EqualFold(value, s) {
|
||||||
|
return 100
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, group := range related {
|
||||||
|
if containsFold(group, value) {
|
||||||
|
for _, s := range selected {
|
||||||
|
if containsFold(group, s) {
|
||||||
|
return 70
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 15
|
||||||
|
}
|
||||||
|
|
||||||
|
func colorRelatedGroups() [][]string {
|
||||||
|
return [][]string{
|
||||||
|
{"Natural", "Warm Yellow", "Taupe"},
|
||||||
|
{"Light Gray", "Off White", "Taupe"},
|
||||||
|
{"Dark Brown", "Black Walnut", "Cherry"},
|
||||||
|
{"Green", "Blue", "Light Gray"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func scoreStyles(p model.Product, styles []string) float64 {
|
||||||
|
best := 0.0
|
||||||
|
for _, style := range styles {
|
||||||
|
best = math.Max(best, scoreStyle(p, style))
|
||||||
|
}
|
||||||
|
return best
|
||||||
|
}
|
||||||
|
|
||||||
|
func scoreStyle(p model.Product, style string) float64 {
|
||||||
|
text := productText(p)
|
||||||
|
switch style {
|
||||||
|
case "modern":
|
||||||
|
return tagScore(p, []string{"Light Gray", "Off White", "Natural"}, []string{"Maple", "Ash", "Porcelain"}, []string{"modern", "clean", "minimal", "airy", "sophisticated"}, text)
|
||||||
|
case "warm_natural":
|
||||||
|
return tagScore(p, []string{"Natural", "Warm Yellow", "Taupe"}, []string{"Oak", "Hickory", "Pine", "Ash"}, []string{"warm", "natural", "wood", "honey"}, text)
|
||||||
|
case "classic":
|
||||||
|
return tagScore(p, []string{"Cherry", "Dark Brown", "Black Walnut", "Natural"}, []string{"Oak", "Walnut", "Maple", "Hickory"}, []string{"classic", "traditional", "timeless"}, text)
|
||||||
|
case "rustic":
|
||||||
|
score := tagScore(p, []string{"Natural", "Dark Brown", "Taupe"}, []string{"Hickory", "Oak", "Pine"}, []string{"rustic", "scraped", "weathered", "reclaimed", "vintage", "barn"}, text)
|
||||||
|
if p.Finish == "Distressed/Embossed" || p.Finish == "Wire Brushed" {
|
||||||
|
score += 20
|
||||||
|
}
|
||||||
|
return clampScore(score)
|
||||||
|
case "luxury_stone":
|
||||||
|
score := tagScore(p, []string{"Off White", "Light Gray", "Natural"}, []string{"Porcelain", "Stone Look"}, []string{"marble", "travertine", "polished", "stone", "luxe"}, text)
|
||||||
|
if p.Category == "Tile/Stone" {
|
||||||
|
score += 25
|
||||||
|
}
|
||||||
|
if p.Finish == "Gloss" {
|
||||||
|
score += 10
|
||||||
|
}
|
||||||
|
return clampScore(score)
|
||||||
|
case "industrial":
|
||||||
|
return tagScore(p, []string{"Light Gray", "Black Walnut", "Dark Brown"}, []string{"Porcelain", "Stone Look"}, []string{"concrete", "slate", "smoke", "graphite", "cement", "industrial"}, text)
|
||||||
|
case "coastal":
|
||||||
|
return tagScore(p, []string{"Off White", "Light Gray", "Natural"}, []string{"Oak", "Maple", "Ash"}, []string{"beach", "coastal", "sand", "airy", "white"}, text)
|
||||||
|
}
|
||||||
|
return 50
|
||||||
|
}
|
||||||
|
|
||||||
|
func tagScore(p model.Product, colors, materials, keywords []string, text string) float64 {
|
||||||
|
score := 15.0
|
||||||
|
if containsFold(colors, p.ColorTone) {
|
||||||
|
score += 30
|
||||||
|
}
|
||||||
|
if containsFold(materials, p.Material) || materialFamilyHit(p.Material, materials) {
|
||||||
|
score += 25
|
||||||
|
}
|
||||||
|
for _, kw := range keywords {
|
||||||
|
if strings.Contains(text, kw) {
|
||||||
|
score += 15
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if p.Finish == "Matte" {
|
||||||
|
score += 8
|
||||||
|
}
|
||||||
|
return clampScore(score)
|
||||||
|
}
|
||||||
|
|
||||||
|
func scoreLooks(p model.Product, looks []string) float64 {
|
||||||
|
best := 0.0
|
||||||
|
text := productText(p)
|
||||||
|
for _, look := range looks {
|
||||||
|
score := 0.0
|
||||||
|
switch look {
|
||||||
|
case "wood_look":
|
||||||
|
if isWoodLike(p, text) {
|
||||||
|
score = 100
|
||||||
|
} else if p.Category == "SPC-LVP" || p.Category == "Laminate" {
|
||||||
|
score = 70
|
||||||
|
} else {
|
||||||
|
score = 20
|
||||||
|
}
|
||||||
|
case "stone_tile":
|
||||||
|
if p.Category == "Tile/Stone" || strings.Contains(text, "stone") || strings.Contains(text, "marble") || strings.Contains(text, "travertine") {
|
||||||
|
score = 100
|
||||||
|
} else {
|
||||||
|
score = 15
|
||||||
|
}
|
||||||
|
case "real_hardwood":
|
||||||
|
if p.Category == "Solid Hardwood" || p.Category == "Engineered Hardwood" {
|
||||||
|
score = 100
|
||||||
|
} else if isWoodLike(p, text) {
|
||||||
|
score = 55
|
||||||
|
} else {
|
||||||
|
score = 10
|
||||||
|
}
|
||||||
|
case "light_clean":
|
||||||
|
score = scoreExactOrRelated(p.ColorTone, []string{"Off White", "Light Gray", "Natural"}, colorRelatedGroups())
|
||||||
|
case "dark_rich":
|
||||||
|
score = scoreExactOrRelated(p.ColorTone, []string{"Dark Brown", "Black Walnut", "Cherry"}, colorRelatedGroups())
|
||||||
|
}
|
||||||
|
best = math.Max(best, score)
|
||||||
|
}
|
||||||
|
return best
|
||||||
|
}
|
||||||
|
|
||||||
|
func scoreBudget(price float64, budget QuestionnaireBudget) float64 {
|
||||||
|
if price <= 0 {
|
||||||
|
return 50
|
||||||
|
}
|
||||||
|
min, max := budget.Min, budget.Max
|
||||||
|
if min > 0 && price < min {
|
||||||
|
return clampScore(100 - ((min-price)/math.Max(min, 1))*80)
|
||||||
|
}
|
||||||
|
if max > 0 && price > max {
|
||||||
|
return clampScore(100 - ((price-max)/math.Max(max, 1))*90)
|
||||||
|
}
|
||||||
|
return 100
|
||||||
|
}
|
||||||
|
|
||||||
|
func scoreRooms(p model.Product, rooms []string) float64 {
|
||||||
|
best := 0.0
|
||||||
|
for _, room := range rooms {
|
||||||
|
score := 50.0
|
||||||
|
switch room {
|
||||||
|
case "bathroom", "kitchen", "basement", "commercial":
|
||||||
|
score = scorePerformance(p, []string{"waterproof", "easy_clean"})
|
||||||
|
case "living_room", "bedroom":
|
||||||
|
score = math.Max(scoreStyles(p, []string{"warm_natural", "modern"}), 60)
|
||||||
|
case "rental":
|
||||||
|
score = scorePerformance(p, []string{"scratch_resistant", "easy_clean"})
|
||||||
|
}
|
||||||
|
best = math.Max(best, score)
|
||||||
|
}
|
||||||
|
return best
|
||||||
|
}
|
||||||
|
|
||||||
|
func scorePerformance(p model.Product, performance []string) float64 {
|
||||||
|
best := 0.0
|
||||||
|
text := productText(p)
|
||||||
|
for _, perf := range performance {
|
||||||
|
score := 20.0
|
||||||
|
switch perf {
|
||||||
|
case "waterproof":
|
||||||
|
if p.Category == "Tile/Stone" || p.Category == "SPC-LVP" || containsAnyFold(p.Material, []string{"porcelain", "vinyl", "rigid core", "spc"}) || strings.Contains(text, "water") || strings.Contains(text, "moisture") {
|
||||||
|
score = 100
|
||||||
|
}
|
||||||
|
case "scratch_resistant":
|
||||||
|
if p.Category == "Tile/Stone" || p.Category == "Laminate" || p.Category == "SPC-LVP" || containsAnyFold(text, []string{"scratch", "durable", "superguard", "wear"}) {
|
||||||
|
score = 95
|
||||||
|
}
|
||||||
|
case "easy_clean":
|
||||||
|
if p.Category == "Tile/Stone" || p.Category == "SPC-LVP" || p.Finish == "Matte" || containsAnyFold(text, []string{"clean", "maintenance"}) {
|
||||||
|
score = 90
|
||||||
|
}
|
||||||
|
case "pet_kid":
|
||||||
|
score = math.Max(scorePerformance(p, []string{"waterproof"}), scorePerformance(p, []string{"scratch_resistant"}))
|
||||||
|
case "comfort_quiet":
|
||||||
|
if p.Brand == "COREtec" || p.Category == "SPC-LVP" || containsAnyFold(text, []string{"quiet", "comfort", "soft", "underlayment"}) {
|
||||||
|
score = 90
|
||||||
|
}
|
||||||
|
case "eco":
|
||||||
|
if containsAnyFold(text, []string{"recycled", "eco", "green", "low voc"}) {
|
||||||
|
score = 90
|
||||||
|
} else {
|
||||||
|
score = 45
|
||||||
|
}
|
||||||
|
}
|
||||||
|
best = math.Max(best, score)
|
||||||
|
}
|
||||||
|
return best
|
||||||
|
}
|
||||||
|
|
||||||
|
func scoreSize(p model.Product, pref string) float64 {
|
||||||
|
switch pref {
|
||||||
|
case "wide_plank":
|
||||||
|
if p.WidthIn >= 7 && p.LengthIn >= 48 {
|
||||||
|
return 100
|
||||||
|
}
|
||||||
|
if p.WidthIn >= 5 {
|
||||||
|
return 70
|
||||||
|
}
|
||||||
|
case "standard_plank":
|
||||||
|
if p.WidthIn >= 4 && p.WidthIn < 7 && p.LengthIn >= 36 {
|
||||||
|
return 100
|
||||||
|
}
|
||||||
|
return 55
|
||||||
|
case "large_tile":
|
||||||
|
if p.Category == "Tile/Stone" && p.WidthIn >= 12 && p.LengthIn >= 24 {
|
||||||
|
return 100
|
||||||
|
}
|
||||||
|
if p.Category == "Tile/Stone" {
|
||||||
|
return 65
|
||||||
|
}
|
||||||
|
case "small_tile":
|
||||||
|
if p.Category == "Tile/Stone" && (p.WidthIn <= 6 || p.LengthIn <= 12) {
|
||||||
|
return 100
|
||||||
|
}
|
||||||
|
if p.Category == "Tile/Stone" {
|
||||||
|
return 60
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 20
|
||||||
|
}
|
||||||
|
|
||||||
|
func dedupeQuestionnaireResults(results []questionnaireScoredProduct) []questionnaireScoredProduct {
|
||||||
|
best := map[string]questionnaireScoredProduct{}
|
||||||
|
for _, r := range results {
|
||||||
|
key := strings.ToLower(r.product.Brand + "|" + r.product.SeriesName + "|" + r.product.StyleName + "|" + r.product.ColorTone)
|
||||||
|
if old, ok := best[key]; !ok || r.score > old.score || (r.score == old.score && r.product.MainImageURL != "" && old.product.MainImageURL == "") {
|
||||||
|
best[key] = r
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out := make([]questionnaireScoredProduct, 0, len(best))
|
||||||
|
for _, r := range best {
|
||||||
|
out = append(out, r)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func diversifyQuestionnaireResults(results []questionnaireScoredProduct, limit int, brandOnly bool) []questionnaireScoredProduct {
|
||||||
|
if len(results) <= limit {
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
maxPerBrand := 4
|
||||||
|
if brandOnly {
|
||||||
|
maxPerBrand = limit
|
||||||
|
}
|
||||||
|
brandCounts := map[string]int{}
|
||||||
|
selected := make([]questionnaireScoredProduct, 0, limit)
|
||||||
|
for _, r := range results {
|
||||||
|
if brandCounts[r.product.Brand] >= maxPerBrand {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
selected = append(selected, r)
|
||||||
|
brandCounts[r.product.Brand]++
|
||||||
|
if len(selected) == limit {
|
||||||
|
return selected
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, r := range results {
|
||||||
|
exists := false
|
||||||
|
for _, s := range selected {
|
||||||
|
if s.product.SKU == r.product.SKU {
|
||||||
|
exists = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !exists {
|
||||||
|
selected = append(selected, r)
|
||||||
|
}
|
||||||
|
if len(selected) == limit {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return selected
|
||||||
|
}
|
||||||
|
|
||||||
|
func fallbackReasons(reasons []string, p model.Product) []string {
|
||||||
|
reasons = compactReasons(reasons)
|
||||||
|
if len(reasons) > 0 {
|
||||||
|
return reasons
|
||||||
|
}
|
||||||
|
out := []string{}
|
||||||
|
if p.ColorTone != "" {
|
||||||
|
out = append(out, "颜色为 "+p.ColorTone)
|
||||||
|
}
|
||||||
|
if p.Material != "" {
|
||||||
|
out = append(out, "材质为 "+p.Material)
|
||||||
|
}
|
||||||
|
if p.PricePerSqft > 0 {
|
||||||
|
out = append(out, priceReason(p))
|
||||||
|
}
|
||||||
|
return compactReasons(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func compactReasons(reasons []string) []string {
|
||||||
|
seen := map[string]bool{}
|
||||||
|
out := []string{}
|
||||||
|
for _, r := range reasons {
|
||||||
|
r = strings.TrimSpace(r)
|
||||||
|
if r == "" || seen[r] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[r] = true
|
||||||
|
out = append(out, r)
|
||||||
|
if len(out) >= 4 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func priceReason(p model.Product) string {
|
||||||
|
if p.PricePerSqft <= 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("价格约 $%.2f/sqft", p.PricePerSqft)
|
||||||
|
}
|
||||||
|
|
||||||
|
func productText(p model.Product) string {
|
||||||
|
return strings.ToLower(strings.Join([]string{
|
||||||
|
p.Brand, p.SeriesName, p.StyleName, p.Category, p.Material, p.ColorTone, p.Finish, p.Description,
|
||||||
|
}, " "))
|
||||||
|
}
|
||||||
|
|
||||||
|
func isWoodLike(p model.Product, text string) bool {
|
||||||
|
if p.Category == "Solid Hardwood" || p.Category == "Engineered Hardwood" || p.Category == "Laminate" || p.Category == "SPC-LVP" {
|
||||||
|
if materialFamilyHit(p.Material, []string{"Oak", "Maple", "Hickory", "Walnut", "Ash", "Pine", "Elm", "Teak", "Birch", "Luxury Vinyl", "Rigid Core"}) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return containsAnyFold(text, []string{"oak", "maple", "hickory", "walnut", "ash", "pine", "wood", "plank"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func materialFamilyHit(material string, choices []string) bool {
|
||||||
|
m := strings.ToLower(material)
|
||||||
|
for _, choice := range choices {
|
||||||
|
c := strings.ToLower(choice)
|
||||||
|
if m == c || strings.Contains(m, c) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func queryOptionCounts(db *sql.DB, col string, limit int) []map[string]any {
|
||||||
|
rows, err := db.Query("SELECT "+col+", COUNT(*) FROM products WHERE "+col+" != '' GROUP BY "+col+" ORDER BY COUNT(*) DESC, "+col+" LIMIT $1", limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
out := []map[string]any{}
|
||||||
|
for rows.Next() {
|
||||||
|
var value string
|
||||||
|
var count int
|
||||||
|
if err := rows.Scan(&value, &count); err == nil {
|
||||||
|
out = append(out, map[string]any{"value": value, "count": count})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func queryPriceStats(db *sql.DB) map[string]float64 {
|
||||||
|
rows, err := db.Query(`
|
||||||
|
SELECT
|
||||||
|
COALESCE(MIN(price_per_sqft), 0),
|
||||||
|
COALESCE(PERCENTILE_CONT(0.25) WITHIN GROUP (ORDER BY price_per_sqft), 0),
|
||||||
|
COALESCE(PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY price_per_sqft), 0),
|
||||||
|
COALESCE(PERCENTILE_CONT(0.75) WITHIN GROUP (ORDER BY price_per_sqft), 0),
|
||||||
|
COALESCE(MAX(price_per_sqft), 0)
|
||||||
|
FROM products WHERE price_per_sqft > 0
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
return map[string]float64{}
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
stats := map[string]float64{}
|
||||||
|
if rows.Next() {
|
||||||
|
var min, p25, median, p75, max float64
|
||||||
|
if err := rows.Scan(&min, &p25, &median, &p75, &max); err == nil {
|
||||||
|
stats["min"] = roundOne(min)
|
||||||
|
stats["p25"] = roundOne(p25)
|
||||||
|
stats["median"] = roundOne(median)
|
||||||
|
stats["p75"] = roundOne(p75)
|
||||||
|
stats["max"] = roundOne(max)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return stats
|
||||||
|
}
|
||||||
|
|
||||||
|
func cleanList(values []string) []string {
|
||||||
|
out := []string{}
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, v := range values {
|
||||||
|
v = strings.TrimSpace(v)
|
||||||
|
if v == "" || seen[v] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[v] = true
|
||||||
|
out = append(out, v)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func toSet(values []string) map[string]bool {
|
||||||
|
out := map[string]bool{}
|
||||||
|
for _, v := range values {
|
||||||
|
out[v] = true
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsFold(values []string, target string) bool {
|
||||||
|
for _, v := range values {
|
||||||
|
if strings.EqualFold(v, target) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsAnyFold(value string, needles []string) bool {
|
||||||
|
v := strings.ToLower(value)
|
||||||
|
for _, n := range needles {
|
||||||
|
if strings.Contains(v, strings.ToLower(n)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func clampScore(v float64) float64 {
|
||||||
|
if v < 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if v > 100 {
|
||||||
|
return 100
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
func roundOne(v float64) float64 {
|
||||||
|
return math.Round(v*10) / 10
|
||||||
|
}
|
||||||
@ -71,6 +71,15 @@ func QueryAllProducts(d *sql.DB) ([]model.Product, error) {
|
|||||||
return prods, err
|
return prods, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func QueryAllProductRows(d *sql.DB) ([]model.Product, error) {
|
||||||
|
rows, err := d.Query("SELECT " + selectCols + " FROM products ORDER BY id")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
return ScanProducts(rows)
|
||||||
|
}
|
||||||
|
|
||||||
func QueryFilteredProducts(d *sql.DB, p FilterParams) ([]model.Product, int, error) {
|
func QueryFilteredProducts(d *sql.DB, p FilterParams) ([]model.Product, int, error) {
|
||||||
if p.Limit <= 0 {
|
if p.Limit <= 0 {
|
||||||
p.Limit = 20
|
p.Limit = 20
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user