FloorVisualizer/internal/api/index.html

441 lines
22 KiB
HTML
Raw Permalink Normal View History

2026-07-27 07:34:32 +00:00
<!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>