FloorVisualizer/internal/api/index.html
dindang 1754e99919 feat(ui): 优化地板选项界面并支持多颜色勾缝线和踢脚线
- 将踢脚线和勾缝线选项由复选框改为下拉选择,支持多种颜色选择
- 更新BuildCombinedFloorPrompt函数,支持传入颜色参数并显示对应中文名称
- 修改前端元素ID及相关逻辑,保持命名一致性和代码规范
- 新增material_source请求参数,支持图片来源选择
- 调整日志中跳过轮询请求,减少无用日志输出
- 修复部分函数命名和调用错误,提升代码健壮性
2026-07-27 17:06:06 +08:00

467 lines
23 KiB
HTML
Raw Permalink 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="roomOptions"></div>
</div>
<div class="panel">
<h2>地板类型</h2>
<div class="options" id="typeOptions">
<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="patternOptions"></div>
</div>
<div class="panel" id="sizePanel" style="display:none">
<h2>尺寸</h2>
<div class="options" id="sizeOptions"></div>
</div>
<div class="panel">
<h2 id="floorTitle">选择地板款式</h2>
<div class="toolbar"><input id="floorSearch" placeholder="搜索款式、颜色、材质、SKU"></div>
<div class="floor-grid" id="floorOptions"></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">
<div class="toggle" style="display:flex;flex-direction:column;align-items:flex-start;gap:6px;cursor:default">
<span style="font-weight:700">勾缝线</span>
<select id="groutSelect" style="width:100%;background:#0f3460;color:#eee;border:1px solid #34456c;border-radius:6px;padding:6px">
<option value="">不使用</option>
<option value="white">白色</option>
<option value="tile">与瓷砖同色</option>
<option value="lightgray">浅灰色</option>
<option value="gray">灰色</option>
<option value="beige">米色</option>
<option value="darkgray">深灰色</option>
</select>
</div>
<div class="toggle" style="display:flex;flex-direction:column;align-items:flex-start;gap:6px;cursor:default">
<span style="font-weight:700">踢脚线</span>
<select id="baseboardSelect" style="width:100%;background:#0f3460;color:#eee;border:1px solid #34456c;border-radius:6px;padding:6px">
<option value="">不使用</option>
<option value="white">白色</option>
<option value="floor">与地板同色</option>
<option value="cream">米白</option>
<option value="lightgray">浅灰</option>
<option value="darkgray">深灰</option>
<option value="wood">木色(深棕)</option>
</select>
</div>
</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 showError(title, detail){
$('errorBox').style.display='block';
$('errorDetail').textContent=(title||'生成失败')+(detail?'\n\n'+detail:'');
$('resultPlaceholder').style.display='flex';
$('resultPlaceholder').innerHTML='生成失败';
}
function clearError(){ $('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);
}
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;
};
// 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=$('sizeOptions'); 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=$('roomOptions'); 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 renderPatterns(){
const el=$('patternOptions'); 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=$('floorOptions'); 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;
[...$('typeOptions').querySelectorAll('.option')].forEach(x=>x.classList.toggle('selected',x.dataset.type===type));
$('floorTitle').textContent=type==='tile'?'选择瓷砖款式':type==='vinyl'?'选择乙烯基地板款式':type==='special'?'特殊测试地板 (含AI描述)':'选择木地板款式';
const groutDisabled=type!=='tile'&&type!=='special';
$('groutSelect').disabled=groutDisabled;
if(groutDisabled)$('groutSelect').value='';
renderPatterns(); renderFloors(); checkReady();
}
async function loadOptions(){
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])};
$('typeOptions').onclick=e=>{const opt=e.target.closest('.option');if(opt)setType(opt.dataset.type)};
$('floorSearch').oninput=renderFloors;
$('generateBtn').onclick=async()=>{
if(!state.file||!state.floor||!state.pattern)return;
clearInterval(state.timer); clearError();
$('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',$('groutSelect').value||'');
fd.append('white_baseboard',$('baseboardSelect').value||'');
fd.append('material_source',state.materialSource||'image');
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;
showError(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';
showError(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'});
loadOptions().catch(err=>showError('选项加载失败',err.message));
</script>
</body>
</html>