FloorVisualizer/internal/api/index.html
2026-07-27 16:11:15 +08:00

441 lines
22 KiB
HTML
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!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>