dev #2

Merged
dindang merged 11 commits from dev into main 2026-07-27 08:12:46 +00:00
9 changed files with 1654 additions and 0 deletions
Showing only changes of commit 3fa3561bb2 - Show all commits

25
.env.example Normal file
View 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
View 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
View 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>

View File

@ -11,3 +11,13 @@ func Index(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = 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))
}

View File

@ -4,3 +4,6 @@ import _ "embed"
//go:embed index.html
var indexHTML string
//go:embed questionnaire.html
var questionnaireHTML string

View 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">&lt; $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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[m]))}
bindChips();
$('budgetMin').addEventListener('input',budgetInputs);
$('budgetMax').addEventListener('input',budgetInputs);
$('resetBtn').addEventListener('click',resetAll);
$('submitBtn').addEventListener('click',recommend);
loadOptions();
setProgress();
</script>
</body>
</html>

View File

@ -32,6 +32,8 @@ func RegisterRoutes(mux *http.ServeMux, client *openrouter.Client, db *sql.DB, a
productOpts = handler.ProductOptions(db)
filterOpts = handler.FilterOptions(db)
recommend = handler.Recommend(db)
questionnaireRec = handler.QuestionnaireRecommend(db)
questionnaireOpt = handler.QuestionnaireOptions(db)
floorGenerate = handler.FloorGenerate(client, db)
floorOptions = handler.FloorOptions(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("/filter-options", filterOpts)
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("/floor/generate", floorGenerate)
mux.HandleFunc("/floor/status", handler.FloorStatus)

View 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
}

View File

@ -71,6 +71,15 @@ func QueryAllProducts(d *sql.DB) ([]model.Product, error) {
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) {
if p.Limit <= 0 {
p.Limit = 20