Merge pull request #24 from simonxxooxxoo/feat/office-art-rebuild

feat: office rebuild v2 - home favorites, rollback UX, asset visibility polish
This commit is contained in:
ringhyacinth 2026-03-03 22:48:16 +08:00 committed by GitHub
commit 21402e7ce6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 588 additions and 22 deletions

View file

@ -38,6 +38,9 @@ ROOM_REFERENCE_IMAGE = (
else os.path.join(ROOT_DIR, "assets", "room-reference.png")
)
BG_HISTORY_DIR = os.path.join(ROOT_DIR, "assets", "bg-history")
HOME_FAVORITES_DIR = os.path.join(ROOT_DIR, "assets", "home-favorites")
HOME_FAVORITES_INDEX_FILE = os.path.join(HOME_FAVORITES_DIR, "index.json")
HOME_FAVORITES_MAX = 30
ASSET_POSITIONS_FILE = os.path.join(ROOT_DIR, "asset-positions.json")
ASSET_DEFAULTS_FILE = os.path.join(ROOT_DIR, "asset-defaults.json")
RUNTIME_CONFIG_FILE = os.path.join(ROOT_DIR, "runtime-config.json")
@ -369,7 +372,7 @@ def save_asset_defaults(data):
def load_runtime_config():
base = {
"gemini_api_key": os.getenv("GEMINI_API_KEY") or os.getenv("GOOGLE_API_KEY") or "",
"gemini_model": os.getenv("GEMINI_MODEL") or "gemini-3.1-flash-image-preview"
"gemini_model": os.getenv("GEMINI_MODEL") or "nanobanana-pro"
}
if os.path.exists(RUNTIME_CONFIG_FILE):
try:
@ -389,6 +392,31 @@ def save_runtime_config(data):
json.dump(cfg, f, ensure_ascii=False, indent=2)
def _ensure_home_favorites_index():
os.makedirs(HOME_FAVORITES_DIR, exist_ok=True)
if not os.path.exists(HOME_FAVORITES_INDEX_FILE):
with open(HOME_FAVORITES_INDEX_FILE, "w", encoding="utf-8") as f:
json.dump({"items": []}, f, ensure_ascii=False, indent=2)
def _load_home_favorites_index():
_ensure_home_favorites_index()
try:
with open(HOME_FAVORITES_INDEX_FILE, "r", encoding="utf-8") as f:
data = json.load(f)
if isinstance(data, dict) and isinstance(data.get("items"), list):
return data
except Exception:
pass
return {"items": []}
def _save_home_favorites_index(data):
_ensure_home_favorites_index()
with open(HOME_FAVORITES_INDEX_FILE, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
def load_join_keys():
if os.path.exists(JOIN_KEYS_FILE):
try:
@ -1322,6 +1350,163 @@ def assets_restore_reference_background():
return jsonify({"ok": False, "msg": str(e)}), 500
@app.route("/assets/restore-last-generated-background", methods=["POST"])
def assets_restore_last_generated_background():
"""Restore office_bg_small.webp from latest bg-history snapshot."""
guard = _require_asset_editor_auth()
if guard:
return guard
try:
target = FRONTEND_PATH / "office_bg_small.webp"
if not target.exists():
return jsonify({"ok": False, "msg": "office_bg_small.webp 不存在"}), 404
if not os.path.isdir(BG_HISTORY_DIR):
return jsonify({"ok": False, "msg": "暂无历史底图"}), 404
files = [
os.path.join(BG_HISTORY_DIR, x)
for x in os.listdir(BG_HISTORY_DIR)
if x.startswith("office_bg_small-") and x.endswith(".webp")
]
if not files:
return jsonify({"ok": False, "msg": "暂无历史底图"}), 404
latest = max(files, key=lambda p: os.path.getmtime(p))
bak = target.with_suffix(target.suffix + ".bak")
shutil.copy2(target, bak)
shutil.copy2(latest, target)
st = target.stat()
return jsonify({
"ok": True,
"path": "office_bg_small.webp",
"size": st.st_size,
"from": os.path.relpath(latest, ROOT_DIR),
"msg": "已回退到最近一次生成底图",
})
except Exception as e:
return jsonify({"ok": False, "msg": str(e)}), 500
@app.route("/assets/home-favorites/list", methods=["GET"])
def assets_home_favorites_list():
guard = _require_asset_editor_auth()
if guard:
return guard
try:
data = _load_home_favorites_index()
items = data.get("items") or []
out = []
for it in items:
rel = (it.get("path") or "").strip()
if not rel:
continue
abs_path = os.path.join(ROOT_DIR, rel)
if not os.path.exists(abs_path):
continue
fn = os.path.basename(rel)
out.append({
"id": it.get("id"),
"path": rel,
"url": f"/assets/home-favorites/file/{fn}",
"thumb_url": f"/assets/home-favorites/file/{fn}",
"created_at": it.get("created_at") or "",
})
out.sort(key=lambda x: x.get("created_at") or "", reverse=True)
return jsonify({"ok": True, "items": out})
except Exception as e:
return jsonify({"ok": False, "msg": str(e)}), 500
@app.route("/assets/home-favorites/file/<path:filename>", methods=["GET"])
def assets_home_favorites_file(filename):
guard = _require_asset_editor_auth()
if guard:
return guard
return send_from_directory(HOME_FAVORITES_DIR, filename)
@app.route("/assets/home-favorites/save-current", methods=["POST"])
def assets_home_favorites_save_current():
guard = _require_asset_editor_auth()
if guard:
return guard
try:
src = FRONTEND_PATH / "office_bg_small.webp"
if not src.exists():
return jsonify({"ok": False, "msg": "office_bg_small.webp 不存在"}), 404
_ensure_home_favorites_index()
ts = datetime.now().strftime("%Y%m%d-%H%M%S")
item_id = f"home-{ts}"
fn = f"{item_id}.webp"
dst = os.path.join(HOME_FAVORITES_DIR, fn)
shutil.copy2(str(src), dst)
idx = _load_home_favorites_index()
items = idx.get("items") or []
items.insert(0, {
"id": item_id,
"path": os.path.relpath(dst, ROOT_DIR),
"created_at": datetime.now().isoformat(timespec="seconds"),
})
# 控制收藏数量上限,清理最旧项
if len(items) > HOME_FAVORITES_MAX:
extra = items[HOME_FAVORITES_MAX:]
items = items[:HOME_FAVORITES_MAX]
for it in extra:
try:
p = os.path.join(ROOT_DIR, it.get("path") or "")
if os.path.exists(p):
os.remove(p)
except Exception:
pass
idx["items"] = items
_save_home_favorites_index(idx)
return jsonify({"ok": True, "id": item_id, "path": os.path.relpath(dst, ROOT_DIR), "msg": "已收藏当前地图"})
except Exception as e:
return jsonify({"ok": False, "msg": str(e)}), 500
@app.route("/assets/home-favorites/apply", methods=["POST"])
def assets_home_favorites_apply():
guard = _require_asset_editor_auth()
if guard:
return guard
try:
data = request.get_json(silent=True) or {}
item_id = (data.get("id") or "").strip()
if not item_id:
return jsonify({"ok": False, "msg": "缺少 id"}), 400
idx = _load_home_favorites_index()
items = idx.get("items") or []
hit = next((x for x in items if (x.get("id") or "") == item_id), None)
if not hit:
return jsonify({"ok": False, "msg": "收藏项不存在"}), 404
src = os.path.join(ROOT_DIR, hit.get("path") or "")
if not os.path.exists(src):
return jsonify({"ok": False, "msg": "收藏文件不存在"}), 404
target = FRONTEND_PATH / "office_bg_small.webp"
if not target.exists():
return jsonify({"ok": False, "msg": "office_bg_small.webp 不存在"}), 404
bak = target.with_suffix(target.suffix + ".bak")
shutil.copy2(str(target), str(bak))
shutil.copy2(src, str(target))
st = target.stat()
return jsonify({"ok": True, "path": "office_bg_small.webp", "size": st.st_size, "from": hit.get("path"), "msg": "已应用收藏地图"})
except Exception as e:
return jsonify({"ok": False, "msg": str(e)}), 500
@app.route("/assets/auth", methods=["POST"])
def assets_auth():
try:
@ -1337,7 +1522,11 @@ def assets_auth():
@app.route("/assets/auth/status", methods=["GET"])
def assets_auth_status():
return jsonify({"ok": True, "authed": _is_asset_editor_authed()})
return jsonify({
"ok": True,
"authed": _is_asset_editor_authed(),
"drawer_default_pass": ASSET_DRAWER_PASS_DEFAULT == "1234",
})
@app.route("/assets/positions", methods=["GET"])
@ -1433,7 +1622,7 @@ def gemini_config_get():
"ok": True,
"has_api_key": bool(key),
"api_key_masked": masked,
"gemini_model": cfg.get("gemini_model") or "gemini-3.1-flash-image-preview",
"gemini_model": cfg.get("gemini_model") or "nanobanana-pro",
})
except Exception as e:
return jsonify({"ok": False, "msg": str(e)}), 500
@ -1447,7 +1636,7 @@ def gemini_config_set():
try:
data = request.get_json(silent=True) or {}
api_key = (data.get("api_key") or "").strip()
model = (data.get("model") or "").strip() or "gemini-3.1-flash-image-preview"
model = (data.get("model") or "").strip() or "nanobanana-pro"
payload = {"gemini_model": model}
if api_key:
payload["gemini_api_key"] = api_key
@ -1457,6 +1646,72 @@ def gemini_config_set():
return jsonify({"ok": False, "msg": str(e)}), 500
@app.route("/assets/restore-default", methods=["POST"])
def assets_restore_default():
guard = _require_asset_editor_auth()
if guard:
return guard
try:
data = request.get_json(silent=True) or {}
rel_path = (data.get("path") or "").strip().lstrip("/")
if not rel_path:
return jsonify({"ok": False, "msg": "缺少 path"}), 400
target = (FRONTEND_PATH / rel_path).resolve()
try:
target.relative_to(FRONTEND_PATH.resolve())
except Exception:
return jsonify({"ok": False, "msg": "非法 path"}), 400
if not target.exists():
return jsonify({"ok": False, "msg": "目标文件不存在"}), 404
root, ext = os.path.splitext(str(target))
default_path = root + ext + ".default"
if not os.path.exists(default_path):
return jsonify({"ok": False, "msg": "未找到默认资产快照"}), 404
# 回滚前保留上一版
bak = str(target) + ".bak"
if os.path.exists(str(target)):
shutil.copy2(str(target), bak)
shutil.copy2(default_path, str(target))
st = os.stat(str(target))
return jsonify({"ok": True, "path": rel_path, "size": st.st_size, "msg": "已重置为默认资产"})
except Exception as e:
return jsonify({"ok": False, "msg": str(e)}), 500
@app.route("/assets/restore-prev", methods=["POST"])
def assets_restore_prev():
guard = _require_asset_editor_auth()
if guard:
return guard
try:
data = request.get_json(silent=True) or {}
rel_path = (data.get("path") or "").strip().lstrip("/")
if not rel_path:
return jsonify({"ok": False, "msg": "缺少 path"}), 400
target = (FRONTEND_PATH / rel_path).resolve()
try:
target.relative_to(FRONTEND_PATH.resolve())
except Exception:
return jsonify({"ok": False, "msg": "非法 path"}), 400
bak = str(target) + ".bak"
if not os.path.exists(bak):
return jsonify({"ok": False, "msg": "未找到上一版备份"}), 404
shutil.copy2(str(target), bak + ".tmp") if os.path.exists(str(target)) else None
shutil.copy2(bak, str(target))
st = os.stat(str(target))
return jsonify({"ok": True, "path": rel_path, "size": st.st_size, "msg": "已回退到上一版"})
except Exception as e:
return jsonify({"ok": False, "msg": str(e)}), 500
@app.route("/assets/upload", methods=["POST"])
def assets_upload():
guard = _require_asset_editor_auth()
@ -1483,6 +1738,15 @@ def assets_upload():
return jsonify({"ok": False, "msg": "目标文件不存在,请先从 /assets/list 选择 path"}), 404
target.parent.mkdir(parents=True, exist_ok=True)
# 首次上传前固化默认资产快照,供“重置为默认资产”使用
default_snap = Path(str(target) + ".default")
if not default_snap.exists():
try:
shutil.copy2(target, default_snap)
except Exception:
pass
if backup:
bak = target.with_suffix(target.suffix + ".bak")
shutil.copy2(target, bak)

View file

@ -1 +1,2 @@
flask==3.0.2
pillow==10.4.0

View file

@ -283,6 +283,19 @@
cursor: pointer;
}
.asset-item.active { border-color: #22c55e; box-shadow: 0 0 0 1px #22c55e inset; }
.asset-vis-btn {
min-width: 34px;
height: 28px;
padding: 2px 4px;
border: 1px solid #4b5563;
background: #111827;
color: #d1d5db;
border-radius: 6px;
font-size: 14px;
cursor: pointer;
font-family:'ArkPixel', monospace;
}
.asset-vis-btn:hover { border-color:#22c55e; color:#ecfccb; }
.asset-thumb { width:56px; height:56px; object-fit: contain; background:#0b1220; border:1px solid #374151; border-radius:6px; }
.asset-meta { line-height: 1.45; }
.asset-path { color:#d1fae5; word-break: break-all; }
@ -295,9 +308,20 @@
.asset-preview-box { border:1px solid #374151; border-radius:8px; padding:6px; background:#0b1220; margin-bottom:8px; }
.asset-preview-title { color:#9ca3af; font-size:11px; margin-bottom:4px; }
.asset-preview-img { width:100%; height:92px; object-fit:contain; background:#111827; border:1px solid #1f2937; border-radius:6px; }
.home-fav-list { display:flex; gap:8px; overflow-x:auto; padding-bottom:4px; }
.home-fav-item { min-width:126px; max-width:126px; border:1px solid #334155; border-radius:8px; background:#111827; padding:6px; }
.home-fav-item img { width:100%; height:70px; object-fit:cover; border:1px solid #1f2937; border-radius:6px; image-rendering:pixelated; }
.home-fav-meta { color:#9ca3af; font-size:10px; margin-top:4px; line-height:1.3; min-height:24px; }
.home-fav-item button { width:100%; margin-top:4px; border:1px solid #4b5563; background:#1f2937; color:#fff; border-radius:6px; padding:4px 6px; font-family:'ArkPixel', monospace; cursor:pointer; }
.home-fav-item button:hover { border-color:#22c55e; }
#gemini-api-doc-link { color:#86efac; text-decoration: underline; text-underline-offset: 2px; }
#gemini-api-doc-link:hover { color:#bbf7d0; }
#asset-move-panel { border:1px solid #334155; background:#0b1220; border-radius:10px; padding:10px; margin-bottom:10px; }
#asset-home-actions-panel { border:1px solid #334155; background:#0b1220; border-radius:10px; padding:10px; }
#asset-home-actions-panel .asset-toolbar { display:grid; grid-template-columns: 1fr 1fr; gap:8px; }
#asset-home-actions-panel .asset-toolbar > button { width:100%; margin:0; }
#asset-move-row { justify-content: center; gap:12px; margin-bottom:0; }
#asset-move-row .btn-move,
#asset-move-row .btn-home,
@ -1032,7 +1056,7 @@
<aside id="asset-drawer">
<div id="asset-drawer-header">
<span>装修房间 · 资产侧边栏</span>
<button onclick="toggleAssetDrawer(false)">关闭</button>
<button id="btn-close-drawer" onclick="toggleAssetDrawer(false)">关闭</button>
</div>
<div id="asset-drawer-body">
<div id="asset-auth-gate" class="asset-preview-box">
@ -1067,10 +1091,11 @@
<summary id="gemini-panel-summary" style="cursor:pointer; color:#cbd5e1;">🔐 API 设置(可折叠)</summary>
<div id="asset-gemini-config" style="display:block; margin-top:6px;">
<div id="gemini-config-hint" class="asset-sub" style="margin-bottom:4px;">可选:填写你的生图 API Key留空不影响基础功能</div>
<div class="asset-sub" style="margin-bottom:6px;"><a id="gemini-api-doc-link" href="https://ai.google.dev/gemini-api/docs/api-key?hl=zh-cn" target="_blank" rel="noopener noreferrer">📘 如何申请 Google API Key</a></div>
<div id="gemini-mask-status" class="asset-sub" style="margin-bottom:6px; color:#a7f3d0;"></div>
<div class="asset-toolbar" style="gap:6px; flex-wrap:wrap;">
<input id="gemini-api-key-input" type="password" placeholder="粘贴 GEMINI_API_KEY不会回显" style="min-width:220px; flex:1;" autocomplete="new-password" />
<button onclick="saveGeminiConfigFromUI()">保存 Key</button>
<button id="btn-save-gemini-key" onclick="saveGeminiConfigFromUI()">保存 Key</button>
</div>
<div id="gemini-config-msg" class="asset-sub" style="margin-top:4px;"></div>
</div>
@ -1080,6 +1105,18 @@
</div>
</div>
</div>
<div id="asset-home-actions-panel" class="asset-preview-box" style="margin-bottom:10px;">
<div class="asset-toolbar" style="margin-bottom:6px; gap:8px;">
<button id="btn-back-last-bg" class="btn-home" onclick="restoreLastGeneratedBackground()">↩️ 回上一个家</button>
<button id="btn-favorite-home" class="btn-home" onclick="saveCurrentHomeFavorite()">⭐ 收藏这个家</button>
</div>
<div id="asset-home-favorites" class="asset-preview-box" style="margin:0;">
<div id="asset-home-favorites-title" class="asset-preview-title">🏠 收藏的家</div>
<div id="asset-home-favorites-list" class="home-fav-list"></div>
</div>
</div>
<div id="asset-manual-panel">
<div class="asset-toolbar">
<input id="asset-search" placeholder="搜索资产名(如 desk / sofa / star" oninput="renderAssetDrawerList()" />
@ -1091,6 +1128,10 @@
<button id="asset-choose-btn" onclick="openInlineAssetUploader()">上传替换素材</button>
<button id="asset-commit-refresh-btn" onclick="commitAndRefresh()" disabled style="opacity:.55;">确认并刷新</button>
</div>
<div class="asset-toolbar" style="margin-top:0; margin-bottom:6px; gap:8px;">
<button id="asset-reset-default-btn" onclick="resetSelectedAssetToDefault()" disabled style="opacity:.55;">重置为默认资产</button>
<button id="asset-restore-prev-btn" onclick="restoreSelectedAssetPrev()" disabled style="opacity:.55;">用上一版</button>
</div>
<div id="asset-upload-result" class="asset-sub"></div>
</div>
</div>
@ -1122,7 +1163,12 @@
btnIdle: '待命', btnWork: '工作', btnSync: '同步', btnError: '报警', btnDecor: '装修房间',
drawerTitle: '装修房间 · 资产侧边栏', drawerClose: '关闭',
authTitle: '请输入装修验证码', authPlaceholder: '输入验证码', authVerify: '验证', authDefaultPassHint: '默认密码1234可随时让我帮你改建议改成强密码',
btnMove: '📦 搬新家', btnHome: '🐚 回老家', btnBroker: '🤝 找中介', btnDIY: '🪚 自己装', btnBrokerGo: '听中介的',
drawerVisibilityTip: '可见性:点击条目右侧眼睛按钮切换该资产显示',
hideDrawer: '👁 隐藏侧边栏', showDrawer: '👁 显示侧边栏',
assetHide: '隐藏', assetShow: '显示',
resetToDefault: '重置为默认资产', restorePrevAsset: '用上一版',
btnMove: '📦 搬新家', btnHome: '🐚 回老家', btnHomeLast: '↩️ 回上一个家', btnHomeFavorite: '⭐ 收藏这个家', btnBroker: '🤝 找中介', btnDIY: '🪚 自己装', btnBrokerGo: '听中介的',
homeFavTitle: '🏠 收藏的家', homeFavEmpty: '还没有收藏,先点“⭐ 收藏这个家”', homeFavApply: '替换到当前地图', homeFavSaved: '✅ 已收藏当前地图', homeFavApplied: '✅ 已替换为收藏地图',
brokerHint: '你会给龙虾推荐什么样的房子',
brokerPromptPh: '例如:故宫主题、莫奈风格、地牢主题、兵马俑主题……',
brokerNeedPrompt: '请先输入中介方案描述',
@ -1130,7 +1176,7 @@
brokerDone: '✅ 已按中介方案生成并替换底图,正在刷新房间...',
moveSuccess: '✅ 搬家成功!',
brokerMissingKey: '❌ 生图失败:缺少 GEMINI API Key请在下方填写并保存后重试',
geminiPanelTitle: '🔐 API 设置(可折叠)', geminiHint: '可选:填写你的生图 API Key留空不影响基础功能', geminiMaskNoKey: '当前状态:未配置 Key', geminiMaskHasKey: '当前已配置:',
geminiPanelTitle: '🔐 API 设置(可折叠)', geminiHint: '可选:填写你的生图 API Key留空不影响基础功能', geminiApiDoc: '📘 如何申请 Google API Key', geminiInputPh: '粘贴 GEMINI_API_KEY不会回显', geminiSaveKey: '保存 Key', geminiMaskNoKey: '当前状态:未配置 Key', geminiMaskHasKey: '当前已配置:',
speedModeLabel: '生成模式', speedFast: '🍌2', speedQuality: '🍌Pro',
searchPlaceholder: '搜索资产名(如 desk / sofa / star', loaded: '已加载', allAssets: '全部资产',
chooseImage: '上传替换素材', confirmUpload: '确认并刷新', uploadPending: '待上传', uploadTarget: '目标',
@ -1145,7 +1191,12 @@
btnIdle: 'Idle', btnWork: 'Work', btnSync: 'Sync', btnError: 'Alert', btnDecor: 'Decorate Room',
drawerTitle: 'Decorate Room · Asset Sidebar', drawerClose: 'Close',
authTitle: 'Enter Decor Passcode', authPlaceholder: 'Enter passcode', authVerify: 'Verify', authDefaultPassHint: 'Default passcode: 1234 (ask me anytime to change it; stronger passcode recommended)',
btnMove: '📦 New Home', btnHome: '🐚 Go Home', btnBroker: '🤝 Broker', btnDIY: '🪚 DIY', btnBrokerGo: 'Follow Broker',
drawerVisibilityTip: 'Visibility: use the eye button on each row to hide/show that asset',
hideDrawer: '👁 Hide Drawer', showDrawer: '👁 Show Drawer',
assetHide: 'Hide', assetShow: 'Show',
resetToDefault: 'Reset to Default', restorePrevAsset: 'Use Previous',
btnMove: '📦 New Home', btnHome: '🐚 Go Home', btnHomeLast: '↩️ Last One', btnHomeFavorite: '⭐ Save This Home', btnBroker: '🤝 Broker', btnDIY: '🪚 DIY', btnBrokerGo: 'Follow Broker',
homeFavTitle: '🏠 Saved Homes', homeFavEmpty: 'No saved homes yet. Tap “⭐ Save This Home” first.', homeFavApply: 'Apply to Current Map', homeFavSaved: '✅ Current map saved', homeFavApplied: '✅ Applied saved home',
brokerHint: 'What kind of house would you recommend for Lobster?',
brokerPromptPh: 'e.g. Forbidden City theme, Monet style, dungeon theme, Terracotta Warriors theme...',
brokerNeedPrompt: 'Please enter broker style prompt first',
@ -1153,7 +1204,7 @@
brokerDone: '✅ Broker plan applied and background replaced, refreshing room...',
moveSuccess: '✅ Move successful!',
brokerMissingKey: '❌ Generation failed: missing GEMINI API key. Fill it below and retry.',
geminiPanelTitle: '🔐 API Settings (collapsible)', geminiHint: 'Optional: set your image API key (base features work without it)', geminiMaskNoKey: 'Current: no key configured', geminiMaskHasKey: 'Configured key:',
geminiPanelTitle: '🔐 API Settings (collapsible)', geminiHint: 'Optional: set your image API key (base features work without it)', geminiApiDoc: '📘 How to get a Google API Key', geminiInputPh: 'Paste GEMINI_API_KEY (input hidden)', geminiSaveKey: 'Save Key', geminiMaskNoKey: 'Current: no key configured', geminiMaskHasKey: 'Configured key:',
speedModeLabel: 'Render Mode', speedFast: '🍌2', speedQuality: '🍌Pro',
searchPlaceholder: 'Search assets (desk / sofa / star)', loaded: 'Loaded', allAssets: 'All Assets',
chooseImage: 'Upload Replacement Asset', confirmUpload: 'Confirm & Refresh', uploadPending: 'Pending Upload', uploadTarget: 'Target',
@ -1168,7 +1219,12 @@
btnIdle: '待機', btnWork: '作業', btnSync: '同期', btnError: '警報', btnDecor: '部屋を編集',
drawerTitle: '部屋編集・アセットサイドバー', drawerClose: '閉じる',
authTitle: '編集パスコードを入力', authPlaceholder: 'パスコード入力', authVerify: '認証', authDefaultPassHint: '初期パスコード1234いつでも変更を相談可。強固なパス推奨',
btnMove: '📦 引っ越し', btnHome: '🐚 実家に戻る', btnBroker: '🤝 仲介', btnDIY: '🪚 自分で装飾', btnBrokerGo: '仲介に任せる',
drawerVisibilityTip: '表示切替:各行右側の目ボタンで資産を表示/非表示',
hideDrawer: '👁 サイドバーを隠す', showDrawer: '👁 サイドバーを表示',
assetHide: '非表示', assetShow: '表示',
resetToDefault: 'デフォルトへ戻す', restorePrevAsset: '前の版へ戻す',
btnMove: '📦 引っ越し', btnHome: '🐚 実家に戻る', btnHomeLast: '↩️ ひとつ前へ', btnHomeFavorite: '⭐ この家を保存', btnBroker: '🤝 仲介', btnDIY: '🪚 自分で装飾', btnBrokerGo: '仲介に任せる',
homeFavTitle: '🏠 保存した家', homeFavEmpty: 'まだ保存がありません。先に「⭐ この家を保存」を押してください。', homeFavApply: '現在のマップに適用', homeFavSaved: '✅ 現在のマップを保存しました', homeFavApplied: '✅ 保存した家を適用しました',
brokerHint: 'ロブスターにはどんな家をおすすめしますか',
brokerPromptPh: '例:故宮テーマ、モネ風、ダンジョン風、兵馬俑テーマ…',
brokerNeedPrompt: '先に仲介プランの説明を入力してください',
@ -1176,7 +1232,7 @@
brokerDone: '✅ 仲介プランを適用して背景を更新しました。部屋を更新中...',
moveSuccess: '✅ 引っ越し成功!',
brokerMissingKey: '❌ 生成失敗GEMINI APIキーが未設定です。下で入力して保存してください。',
geminiPanelTitle: '🔐 API設定折りたたみ', geminiHint: '任意画像生成APIキーを設定未設定でも基本機能は利用可', geminiMaskNoKey: '現在:キー未設定', geminiMaskHasKey: '設定済みキー:',
geminiPanelTitle: '🔐 API設定折りたたみ', geminiHint: '任意画像生成APIキーを設定未設定でも基本機能は利用可', geminiApiDoc: '📘 Google API Keyの取得方法', geminiInputPh: 'GEMINI_API_KEY を貼り付け(入力は非表示)', geminiSaveKey: 'Keyを保存', geminiMaskNoKey: '現在:キー未設定', geminiMaskHasKey: '設定済みキー:',
speedModeLabel: '生成モード', speedFast: '🍌2', speedQuality: '🍌Pro',
searchPlaceholder: 'アセット検索desk / sofa / star', loaded: '読み込み済み', allAssets: '全アセット',
chooseImage: '差し替え素材をアップロード', confirmUpload: '確定して更新', uploadPending: 'アップロード待ち', uploadTarget: '対象',
@ -1230,7 +1286,7 @@
const drawerTitle = document.querySelector('#asset-drawer-header span');
if (drawerTitle) drawerTitle.textContent = t('drawerTitle');
const drawerClose = document.querySelector('#asset-drawer-header button');
const drawerClose = document.getElementById('btn-close-drawer');
if (drawerClose) drawerClose.textContent = t('drawerClose');
const authTitle = document.querySelector('#asset-auth-gate .asset-preview-title');
@ -1243,6 +1299,9 @@
setText('btn-back-home', 'btnHome');
const brokerBtn = document.querySelector('#asset-broker-row .btn-broker'); if (brokerBtn) brokerBtn.textContent = t('btnBroker');
const diyBtn = document.querySelector('#asset-broker-row .btn-diy'); if (diyBtn) diyBtn.textContent = t('btnDIY');
const backLastBtn = document.getElementById('btn-back-last-bg'); if (backLastBtn) backLastBtn.textContent = t('btnHomeLast');
const favHomeBtn = document.getElementById('btn-favorite-home'); if (favHomeBtn) favHomeBtn.textContent = t('btnHomeFavorite');
const favTitle = document.getElementById('asset-home-favorites-title'); if (favTitle) favTitle.textContent = t('homeFavTitle');
const brokerHint = document.querySelector('#asset-broker-panel .asset-sub'); if (brokerHint) brokerHint.textContent = t('brokerHint');
const brokerPrompt = document.getElementById('asset-broker-prompt'); if (brokerPrompt) brokerPrompt.placeholder = t('brokerPromptPh');
const brokerGoBtn = document.querySelector('#asset-broker-actions button'); if (brokerGoBtn) brokerGoBtn.textContent = t('btnBrokerGo');
@ -1251,6 +1310,9 @@
const speedQualityBtn = document.getElementById('speed-quality-btn'); if (speedQualityBtn) speedQualityBtn.textContent = t('speedQuality');
const geminiPanelSummary = document.getElementById('gemini-panel-summary'); if (geminiPanelSummary) geminiPanelSummary.textContent = t('geminiPanelTitle');
const geminiHint = document.getElementById('gemini-config-hint'); if (geminiHint) geminiHint.textContent = t('geminiHint');
const geminiDocLink = document.getElementById('gemini-api-doc-link'); if (geminiDocLink) geminiDocLink.textContent = t('geminiApiDoc');
const geminiInput = document.getElementById('gemini-api-key-input'); if (geminiInput) geminiInput.placeholder = t('geminiInputPh');
const geminiSaveBtn = document.getElementById('btn-save-gemini-key'); if (geminiSaveBtn) geminiSaveBtn.textContent = t('geminiSaveKey');
setPh('asset-search', 'searchPlaceholder');
@ -1457,7 +1519,10 @@
let assetListData = [];
let sceneAssetItems = [];
let selectedAssetInfo = null;
let hiddenAssetPaths = new Set();
let assetThumbTimers = [];
let homeFavoritesCache = [];
let homeFavoritesLoadedAt = 0;
// 坐标以服务端为准;清理历史本地缓存,避免把素材挪飞
let assetPositionOverrides = {};
@ -1878,6 +1943,9 @@
}
function mapAssetPathToSprite(path) {
// 背景做特殊映射:即使纹理 key 已变成 office_bg_live_xxx也能稳定定位到背景对象
if ((path || '').includes('office_bg_small.webp') && officeBgSprite) return officeBgSprite;
const item = sceneAssetItems.find(x => x.path === path && x.ref && x.ref.getBounds);
if (item) return item.ref;
const cands = pathToTextureCandidates(path);
@ -2026,13 +2094,16 @@
function updateAssetConfirmButtonState() {
const btn = document.getElementById('asset-commit-refresh-btn');
const btnReset = document.getElementById('asset-reset-default-btn');
const btnPrev = document.getElementById('asset-restore-prev-btn');
const panel = document.getElementById('asset-upload-panel');
const can = !!(selectedAssetInfo && selectedAssetInfo.path);
if (panel) panel.classList.toggle('active', can);
if (btn) {
btn.disabled = !can;
btn.style.opacity = can ? '1' : '.55';
}
[btn, btnReset, btnPrev].forEach((b) => {
if (!b) return;
b.disabled = !can;
b.style.opacity = can ? '1' : '.55';
});
}
function selectAssetInDrawer(path) {
@ -2160,6 +2231,37 @@
img.src = `/static/${item.path}?t=${Date.now()}`;
}
function isAssetHidden(path) {
return hiddenAssetPaths.has(path || '');
}
function setAssetVisible(path, visible) {
const p = (path || '').trim();
if (!p) return;
if (visible) hiddenAssetPaths.delete(p);
else hiddenAssetPaths.add(p);
const sp = mapAssetPathToSprite(p);
if (sp && sp.setVisible) {
sp.setVisible(!!visible);
}
}
function toggleAssetVisibility(path, ev) {
if (ev && ev.stopPropagation) ev.stopPropagation();
const p = (path || '').trim();
if (!p) return;
const nextVisible = isAssetHidden(p);
setAssetVisible(p, nextVisible);
renderAssetDrawerList();
const out = document.getElementById('asset-upload-result');
if (out) out.textContent = nextVisible ? `✅ 已显示:${p}` : `🙈 已隐藏:${p}`;
if (selectedAssetInfo && selectedAssetInfo.path === p) {
if (!nextVisible) clearAssetSelectionUI();
else applyScenePreview(p);
}
}
function renderAssetDrawerList() {
const q = (document.getElementById('asset-search')?.value || '').trim().toLowerCase();
const list = document.getElementById('asset-list');
@ -2178,7 +2280,11 @@
const p = (path || '').toLowerCase();
const idx = statePriority.findIndex(x => p.endsWith(x));
if (idx >= 0) return idx; // 0~3: 四个主状态最前
if (p.includes('guest_anim_')) return 999; // guest 动画放最后
// 按钮素材最不重要:统一沉到列表末尾
if (p.includes('/btn-') || p.includes('btn-') || p.includes('button')) return 1000;
if (p.includes('guest_anim_')) return 999; // guest 动画靠后
return 100;
};
const rows = baseRows
@ -2201,12 +2307,15 @@
const reso = (it.width && it.height) ? `${it.width}×${it.height}` : '-';
const displayName = getAssetDisplayName(it.path || '');
const thumbId = `asset-thumb-canvas-${(it.path || '').replace(/[^a-zA-Z0-9]/g, '_')}`;
const hidden = isAssetHidden(it.path);
const visEmoji = hidden ? '🙈' : '👀';
return `<div class="asset-item ${isActive ? 'active' : ''}" data-path="${it.path}" onclick="selectAssetInDrawer('${(it.path || '').replace(/'/g, "\\'")}')">
<canvas id="${thumbId}" class="asset-thumb" width="56" height="56"></canvas>
<div class="asset-meta">
<div class="asset-path">${it.path}</div>
<div class="asset-sub">${displayName} ${reso}</div>
<div class="asset-sub">${displayName} ${reso}${hidden ? ' 已隐藏' : ''}</div>
</div>
<button class="asset-vis-btn" onclick="toggleAssetVisibility('${(it.path || '').replace(/'/g, "\\'")}', event)">${visEmoji}</button>
</div>`;
}).join('');
@ -2601,7 +2710,7 @@ function toggleBrokerPanel() {
if (data && data.ok) {
window.geminiConfig = {
hasKey: !!data.has_api_key,
model: data.gemini_model || 'gemini-3.1-flash-image-preview'
model: data.gemini_model || 'nanobanana-pro'
};
const box = document.getElementById('asset-gemini-config');
if (box) box.style.display = 'block';
@ -2627,7 +2736,7 @@ function toggleBrokerPanel() {
const res = await fetch('/config/gemini', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ api_key: key, model: 'gemini-3.1-flash-image-preview' })
body: JSON.stringify({ api_key: key, model: 'nanobanana-pro' })
});
const data = await res.json();
if (!data.ok) {
@ -2754,8 +2863,15 @@ function toggleBrokerPanel() {
async function restoreHomeBackground() {
const homeBtn = document.getElementById('btn-back-home');
flashButtonActive(homeBtn);
setWorkingStatus('正在回老家');
const out = document.getElementById('asset-move-result') || document.getElementById('asset-upload-result');
const confirmMsg = '⚠️ 回老家会覆盖当前自定义房间背景(可从 bg-history 恢复历史图)。\n确定继续吗';
if (!window.confirm(confirmMsg)) {
out.textContent = '已取消回老家';
return;
}
setWorkingStatus('正在回老家');
// 点击即刻显示遮罩,先于任何网络调用
showRoomLoadingOverlay();
out.textContent = '🏡 正在回老家(恢复初始底图)...';
@ -2780,12 +2896,196 @@ function toggleBrokerPanel() {
}
}
async function restoreLastGeneratedBackground() {
const btn = document.getElementById('btn-back-last-bg');
flashButtonActive(btn);
const out = document.getElementById('asset-move-result') || document.getElementById('asset-upload-result');
const confirmMsg = '⚠️ 将回退到最近一次生成的房间背景,确定继续吗?';
if (!window.confirm(confirmMsg)) {
out.textContent = '已取消回退';
return;
}
setWorkingStatus('正在回退到上一次背景');
showRoomLoadingOverlay();
out.textContent = '↩️ 正在回退到最近一次生成底图...';
try {
const res = await fetch('/assets/restore-last-generated-background', { method: 'POST' });
const data = await res.json();
if (!data.ok) {
out.textContent = `❌ 回退失败:${data.msg || res.status}`;
return;
}
const ok = await refreshOfficeBackgroundOnly();
if (ok) {
out.textContent = '✅ 已回退到上一次背景';
} else {
out.textContent = '✅ 已回退到上一次背景(局部刷新失败,可手动刷新页面)';
}
try { setState('idle', '已回退到上一次背景'); } catch (e) {}
} catch (e) {
out.textContent = `❌ 回退失败:${e}`;
} finally {
hideRoomLoadingOverlay();
}
}
async function fetchJsonSafe(url, options = {}) {
const res = await fetch(url, options);
const ct = (res.headers.get('content-type') || '').toLowerCase();
if (!ct.includes('application/json')) {
const txt = await res.text();
const brief = (txt || '').replace(/\s+/g, ' ').slice(0, 120);
throw new Error(`接口未返回 JSON${res.status}: ${brief || 'empty response'}`);
}
return await res.json();
}
async function renderHomeFavorites(force = false) {
const box = document.getElementById('asset-home-favorites-list');
if (!box) return;
const now = Date.now();
if (!force && homeFavoritesCache.length > 0 && (now - homeFavoritesLoadedAt) < 30000) {
// 使用缓存,避免频繁请求
} else {
try {
const data = await fetchJsonSafe('/assets/home-favorites/list', { cache: 'no-store' });
if (data && data.ok && Array.isArray(data.items)) {
homeFavoritesCache = data.items;
homeFavoritesLoadedAt = now;
}
} catch (e) {
const out = document.getElementById('asset-move-result') || document.getElementById('asset-upload-result');
if (out) out.textContent = `❌ 收藏列表加载失败:${e.message || e}`;
}
}
if (!homeFavoritesCache.length) {
box.innerHTML = `<div class="asset-sub" style="padding:4px 2px;">${t('homeFavEmpty')}</div>`;
return;
}
box.innerHTML = homeFavoritesCache.map((it) => {
const id = (it.id || '').replace(/'/g, "\\'");
const thumb = it.thumb_url || it.url || '';
const time = it.created_at || '';
return `<div class="home-fav-item">
<img src="${thumb}" loading="lazy" alt="favorite-home" />
<div class="home-fav-meta">${time}</div>
<button onclick="applyHomeFavorite('${id}')">${t('homeFavApply')}</button>
</div>`;
}).join('');
}
async function saveCurrentHomeFavorite() {
const btn = document.getElementById('btn-favorite-home');
flashButtonActive(btn);
const out = document.getElementById('asset-move-result') || document.getElementById('asset-upload-result');
try {
const data = await fetchJsonSafe('/assets/home-favorites/save-current', { method: 'POST' });
if (!data.ok) {
out.textContent = `❌ 收藏失败:${data.msg || 'unknown error'}`;
return;
}
out.textContent = t('homeFavSaved');
await renderHomeFavorites(true);
} catch (e) {
out.textContent = `❌ 收藏失败:${e.message || e}`;
}
}
async function applyHomeFavorite(id) {
const out = document.getElementById('asset-move-result') || document.getElementById('asset-upload-result');
if (!id) return;
showRoomLoadingOverlay();
setWorkingStatus('正在替换收藏地图');
try {
const data = await fetchJsonSafe('/assets/home-favorites/apply', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id })
});
if (!data.ok) {
out.textContent = `❌ 替换失败:${data.msg || 'unknown error'}`;
return;
}
const ok = await refreshOfficeBackgroundOnly();
out.textContent = ok ? t('homeFavApplied') : `${t('homeFavApplied')}(局部刷新失败,可手动刷新页面)`;
try { setState('idle', '已应用收藏地图'); } catch (e) {}
} catch (e) {
out.textContent = `❌ 替换失败:${e.message || e}`;
} finally {
hideRoomLoadingOverlay();
}
}
async function resetSelectedAssetToDefault() {
const out = document.getElementById('asset-upload-result');
const path = selectedAssetInfo && selectedAssetInfo.path;
if (!path) {
if (out) out.textContent = '请先选择一个资产';
return;
}
if (!window.confirm(`⚠️ 确定将 ${path} 重置为默认资产吗?`)) return;
try {
const res = await fetch('/assets/restore-default', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path })
});
const data = await res.json();
if (!data.ok) {
if (out) out.textContent = `❌ 重置失败:${data.msg || res.status}`;
return;
}
await refreshSceneObjectByAssetPath(path);
if (out) out.textContent = `✅ 已重置为默认资产:${path}`;
} catch (e) {
if (out) out.textContent = `❌ 重置失败:${e}`;
}
}
async function restoreSelectedAssetPrev() {
const out = document.getElementById('asset-upload-result');
const path = selectedAssetInfo && selectedAssetInfo.path;
if (!path) {
if (out) out.textContent = '请先选择一个资产';
return;
}
if (!window.confirm(`⚠️ 确定将 ${path} 回退到上一版吗?`)) return;
try {
const res = await fetch('/assets/restore-prev', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path })
});
const data = await res.json();
if (!data.ok) {
if (out) out.textContent = `❌ 回退失败:${data.msg || res.status}`;
return;
}
await refreshSceneObjectByAssetPath(path);
if (out) out.textContent = `✅ 已回退到上一版:${path}`;
} catch (e) {
if (out) out.textContent = `❌ 回退失败:${e}`;
}
}
async function toggleAssetDrawer(force) {
const drawer = document.getElementById('asset-drawer');
const next = (typeof force === 'boolean') ? force : !assetDrawerOpen;
assetDrawerOpen = next;
drawer.classList.toggle('open', next);
document.body.classList.toggle('drawer-open', next);
const openBtn = document.getElementById('btn-open-drawer');
if (openBtn) {
openBtn.classList.toggle('is-active', next);
openBtn.textContent = t('btnDecor');
}
const closeBtn = document.getElementById('btn-close-drawer');
if (closeBtn) closeBtn.textContent = t('drawerClose');
if (next) {
assetManualPanelOpen = false;
updateAssetAuthUI();
@ -2793,6 +3093,7 @@ function toggleBrokerPanel() {
await ensureGeminiConfigLoaded();
if (assetDrawerAuthed) {
await refreshAssetDrawerList();
await renderHomeFavorites(false);
bindDrawerFileMeta();
} else {
const msg = document.getElementById('asset-auth-msg');