441 lines
22 KiB
HTML
441 lines
22 KiB
HTML
|
|
<!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>
|