diff --git a/backend/app.py b/backend/app.py index a5540df..ac2803e 100644 --- a/backend/app.py +++ b/backend/app.py @@ -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/", 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) diff --git a/backend/requirements.txt b/backend/requirements.txt index 2e7aeea..dda7e89 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1 +1,2 @@ flask==3.0.2 +pillow==10.4.0 diff --git a/frontend/index.html b/frontend/index.html index dd3099e..5818511 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -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 @@