diff --git a/internal/api/index.html b/internal/api/index.html index bc93fe8..d9649c8 100644 --- a/internal/api/index.html +++ b/internal/api/index.html @@ -87,12 +87,12 @@ header p{font-size:14px;color:#aaa;margin-top:6px}

房间类型

-
+

地板类型

-
+
木地板
Hardwood
瓷砖
Tile / Stone
乙烯基地板
Luxury Vinyl / SPC
@@ -102,18 +102,18 @@ header p{font-size:14px;color:#aaa;margin-top:6px}

铺设方式

-
+

选择地板款式

-
+
@@ -128,8 +128,30 @@ header p{font-size:14px;color:#aaa;margin-top:6px}

可选效果

- - +
+ 勾缝线 + +
+
+ 踢脚线 + +
@@ -163,13 +185,13 @@ header p{font-size:14px;color:#aaa;margin-top:6px} 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){ +function showError(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 clearError(){ $('errorBox').style.display='none'; $('errorDetail').textContent=''; } function setProgress(step,msg,width){ $('statusBox').style.display='block'; $('stepLabel').textContent=step; @@ -196,6 +218,14 @@ function selectIn(container,item,el,key){ 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=>{ @@ -206,7 +236,7 @@ $('materialSourceOptions').onclick=e=>{ state.materialSource=opt.dataset.source; }; function render尺寸s(floor){ - const el=$('size可选效果'); el.innerHTML=''; state.sizeVariant=null; + const el=$('sizeOptions'); el.innerHTML=''; state.sizeVariant=null; const variants=floor.variants||[]; $('sizePanel').style.display='block'; // Collect all sizes: main entry first, then variants @@ -225,7 +255,7 @@ 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=''; + const el=$('roomOptions'); el.innerHTML=''; (state.options.rooms||[]).forEach((r,i)=>{ const div=document.createElement('div'); div.className='option'; @@ -235,8 +265,8 @@ function renderRooms(){ if(i===0)div.click(); }); } -function render铺设方式s(){ - const el=$('pattern可选效果'); el.innerHTML=''; state.pattern=null; +function renderPatterns(){ + const el=$('patternOptions'); el.innerHTML=''; state.pattern=null; patternList().forEach((p,i)=>{ const div=document.createElement('div'); div.className='option'; @@ -247,7 +277,7 @@ function render铺设方式s(){ }); } function renderFloors(){ - const el=$('floor可选效果'); el.innerHTML=''; state.floor=null; + 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='
没有匹配的地板款式
';checkReady();return} @@ -263,15 +293,14 @@ function renderFloors(){ } 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(); + [...$('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 load可选效果(){ +async function loadOptions(){ setProgress('加载选项','正在读取数据库中的地板款式...', '8%'); const res=await fetch('/floor/options'); const text=await res.text(); @@ -371,16 +400,12 @@ $('fileInput').onchange=()=>{if($('fileInput').files[0])handleFile($('fileInput' $('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)}; +$('typeOptions').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错误(); + clearInterval(state.timer); clearError(); $('generateBtn').classList.add('loading'); $('generateBtn').disabled=true; $('compare').style.display='none'; @@ -398,8 +423,9 @@ $('generateBtn').onclick=async()=>{ 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('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%'); @@ -414,14 +440,14 @@ $('generateBtn').onclick=async()=>{ clearInterval(state.timer); state.timer=null; $('generateBtn').classList.remove('loading'); $('generateBtn').disabled=false; - show错误(err.message, state.lastStatus ? JSON.stringify(state.lastStatus,null,2) : ''); + 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'; - show错误(err.message,''); + showError(err.message,''); } }; @@ -434,7 +460,7 @@ window.addEventListener('touchmove',e=>{if(dragging)updateCompare(e.touches[0].c 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)); +loadOptions().catch(err=>showError('选项加载失败',err.message)); diff --git a/internal/handler/floor_handler.go b/internal/handler/floor_handler.go index ad8d8d0..9a37e89 100644 --- a/internal/handler/floor_handler.go +++ b/internal/handler/floor_handler.go @@ -219,8 +219,17 @@ func BuildFloorPromptPublic(f FloorOption, p PatternOption, room *RoomOption) st // BuildCombinedFloorPrompt builds a single prompt that identifies the floor area AND replaces it. // No separate mask generation step 閳?the AI does both in one pass. -func BuildCombinedFloorPrompt(f FloorOption, p PatternOption, room *RoomOption, groutLines, whiteBaseboard bool) string { - return buildCompactCombinedFloorPrompt(f, p, room, groutLines, whiteBaseboard) +var baseboardColorNames = map[string]string{ + "white": "白色", "floor": "与地板同色", "cream": "米白", + "lightgray": "浅灰", "darkgray": "深灰", "wood": "木色(深棕)", +} +var groutColorNames = map[string]string{ + "white": "白色", "tile": "与瓷砖同色", "lightgray": "浅灰色", + "gray": "灰色", "beige": "米色", "darkgray": "深灰色", +} + +func BuildCombinedFloorPrompt(f FloorOption, p PatternOption, room *RoomOption, groutColor, baseboardColor string) string { + return buildCompactCombinedFloorPrompt(f, p, room, groutColor, baseboardColor) } // buildPatternConstraint returns installation rules specific to each pattern code. @@ -472,18 +481,26 @@ func buildMaterialRules(floor FloorOption) string { return "地面必须保持同一个 SKU 的颜色、纹理、木纹或板纹和光泽。保留原图已有光照和阴影;不要新增反光、亮斑或阴影。板缝透视一致,纹理自然但不能明显重复。" } -func buildCompactCombinedFloorPrompt(f FloorOption, p PatternOption, room *RoomOption, groutLines, whiteBaseboard bool) string { +func buildCompactCombinedFloorPrompt(f FloorOption, p PatternOption, room *RoomOption, groutColor, baseboardColor string) string { roomName := "自动识别" if room != nil && room.Code != 0 { roomName = room.Name } var priority []string - if whiteBaseboard { - priority = append(priority, "最高优先级:必须生成白色踢脚线。\n踢脚线属于可编辑区域,不属于墙面保护区域。\n沿所有可见墙地交界处生成连续白色踢脚线,高约 4-6 英寸。\n踢脚线应为真实白色木质或漆面条带,有轻微厚度和接触阴影。\n如果没有明显、连续的白色踢脚线,结果视为失败。") + if baseboardColor != "" { + colorName := baseboardColorNames[baseboardColor] + if colorName == "" { + colorName = baseboardColor + } + priority = append(priority, fmt.Sprintf("最高优先级:必须生成踢脚线,颜色为%s。\n踢脚线属于可编辑区域,不属于墙面保护区域。\n沿所有可见墙地交界处生成连续踢脚线,高约 4-6 英寸。\n踢脚线应为真实木质或漆面条带,有轻微厚度和接触阴影。\n如果没有明显、连续的踢脚线,结果视为失败。", colorName)) } - if groutLines { - priority = append(priority, "必须生成清晰、等距、连续的瓷砖缝。") + if groutColor != "" { + colorName := groutColorNames[groutColor] + if colorName == "" { + colorName = groutColor + } + priority = append(priority, fmt.Sprintf("必须生成清晰、等距、连续的瓷砖缝,颜色为%s。", colorName)) } priorityText := "" @@ -492,8 +509,8 @@ func buildCompactCombinedFloorPrompt(f FloorOption, p PatternOption, room *RoomO } editScope := "只替换地面" - if whiteBaseboard { - editScope = "只替换地面和白色踢脚线" + if baseboardColor != "" { + editScope = "只替换地面和踢脚线" } return fmt.Sprintf("%s你是室内照片编辑工具,不是重新生成图片。\n房间类型:%s。\n\n任务:\n%s。墙面、家具、桌椅、门窗、天花板保持原图不变。\n保持原图尺寸、构图、相机角度、光照、阴影、白平衡不变。\n保持原图房间空间尺度:如果原图是小房间,生成后仍必须是同一间小房间;不要拉远视角,不要扩大房间深度,不要让瓷砖尺寸呈现大空间远视效果。\n\n%s\n\n输出:\n只返回完成后的图片,不要文字、不要 JSON。", diff --git a/internal/middleware/logger.go b/internal/middleware/logger.go index ca27ec4..d30f85e 100644 --- a/internal/middleware/logger.go +++ b/internal/middleware/logger.go @@ -2,6 +2,7 @@ package middleware import ( "net/http" + "strings" "time" "floorvisualizer/internal/logger" @@ -9,7 +10,7 @@ import ( type responseWriter struct { http.ResponseWriter - statusCode int + statusCode int wroteHeader bool } @@ -36,6 +37,10 @@ func Logging(next http.Handler) http.Handler { start := time.Now() rw := &responseWriter{ResponseWriter: w, statusCode: http.StatusOK} next.ServeHTTP(rw, r) + // Skip logging for polling endpoints to keep logs clean + if strings.HasPrefix(r.URL.Path, "/floor/status") { + return + } logger.Info("%s %s %d %s", r.Method, r.URL.Path, rw.statusCode, time.Since(start)) }) } diff --git a/internal/queue/worker.go b/internal/queue/worker.go index 203b7be..2b7134f 100644 --- a/internal/queue/worker.go +++ b/internal/queue/worker.go @@ -69,8 +69,8 @@ func processJob(ctx context.Context, rdb *redis.Client, client *openrouter.Clien floor := handler.FindFloorBySKUPublic(db, floorID) pattern := handler.FindPatternByCodePublic(patternCode) room := handler.FindRoomByCodePublic(roomCode) - groutLines := payloadBool(payload, "grout_lines") - whiteBaseboard := payloadBool(payload, "white_baseboard") + groutColor := strings.ToLower(strings.TrimSpace(payload["grout_lines"])) + baseboardColor := strings.ToLower(strings.TrimSpace(payload["white_baseboard"])) materialSource := payload["material_source"] // "image", "json", or "both" if materialSource == "" { materialSource = "image" @@ -137,7 +137,7 @@ func processJob(ctx context.Context, rdb *redis.Client, client *openrouter.Clien imageSize := floorImageSize() logger.Info("[%s] TIMER generate request material=%s image_size=%s input=%s output=%s ref=%s", jobID, materialSource, imageSize, workPath, outputPath, refImagePath) - floorPrompt := handler.BuildCombinedFloorPrompt(*floor, *pattern, room, groutLines, whiteBaseboard) + floorPrompt := handler.BuildCombinedFloorPrompt(*floor, *pattern, room, groutColor, baseboardColor) _, err = client.GenerateImageToFile(jobCtx, openrouter.ImageGenerationRequest{ Prompt: floorPrompt, OutputPath: outputPath, InputImagePath: workPath, ImageSize: imageSize, ReferenceImagePath: refImagePath,