feat(品牌): 支持 _local 外部优先 Logo 与图标覆盖

新增 /admin/brand-assets 静态挂载,并在前端与桌面端统一采用外部优先、内置兜底的品牌资源解析;同时补充 BRANDING 文档与 README 入口说明。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-05-07 10:12:16 +08:00
parent b15ff4c31c
commit acf1266e90
7 changed files with 106 additions and 13 deletions

View file

@ -47,6 +47,7 @@ powershell -ExecutionPolicy Bypass -File .\scripts\start_all.ps1 -Background
- **8787 端口占用 / 重启不生效**:`powershell -ExecutionPolicy Bypass -File .\scripts\stop_gateway.ps1 -Force` 后再启动。 - **8787 端口占用 / 重启不生效**:`powershell -ExecutionPolicy Bypass -File .\scripts\stop_gateway.ps1 -Force` 后再启动。
- **更细的从零教程**:`docs/RUNBOOK.md` →「开源快速安装(从零到跑起来)」。 - **更细的从零教程**:`docs/RUNBOOK.md` →「开源快速安装(从零到跑起来)」。
- **更换品牌 Logo(外部优先)**:见 `docs/BRANDING.md`(将资源放到 `_local/branding/`)。
### 可选:运维专家(network_ops)与 netx ### 可选:运维专家(network_ops)与 netx
@ -110,6 +111,7 @@ Notes:
Full runbook (recommended): see `docs/RUNBOOK.md` → “开源快速安装(从零到跑起来)”. Full runbook (recommended): see `docs/RUNBOOK.md` → “开源快速安装(从零到跑起来)”.
Minimal onboarding guide: `docs/OPEN_SOURCE_QUICKSTART.md`. Minimal onboarding guide: `docs/OPEN_SOURCE_QUICKSTART.md`.
Branding guide (external-first assets under `_local/branding`): `docs/BRANDING.md`.
Chinese zero-to-running checklist: see **开箱即用(从零跑起来)** at the top of this README. Chinese zero-to-running checklist: see **开箱即用(从零跑起来)** at the top of this README.

View file

@ -9,7 +9,20 @@ const DEFAULT_HOST = "127.0.0.1";
const DEFAULT_PORT = 8787; const DEFAULT_PORT = 8787;
const APP_DISPLAY_NAME = "oclaw"; const APP_DISPLAY_NAME = "oclaw";
const APP_ROOT = path.resolve(__dirname, "..", ".."); const APP_ROOT = path.resolve(__dirname, "..", "..");
const APP_ICON_PATH = path.join(APP_ROOT, "src", "admin", "static", "oliver.svg"); const APP_ICON_PATH = (() => {
const candidates = [
path.join(APP_ROOT, "_local", "branding", "desktop.ico"),
path.join(APP_ROOT, "_local", "branding", "logo.png"),
path.join(APP_ROOT, "_local", "branding", "logo.svg"),
path.join(APP_ROOT, "interfaces", "admin", "static", "oliver.svg"),
];
for (const p of candidates) {
try {
if (fs.existsSync(p)) return p;
} catch (_) {}
}
return candidates[candidates.length - 1];
})();
const DATA_ROOT = path.join(app.getPath("userData"), "runtime-data"); const DATA_ROOT = path.join(app.getPath("userData"), "runtime-data");
const LOG_ROOT = path.join(app.getPath("userData"), "logs"); const LOG_ROOT = path.join(app.getPath("userData"), "logs");
const BACKEND_LOG_FILE = path.join(LOG_ROOT, "backend.log"); const BACKEND_LOG_FILE = path.join(LOG_ROOT, "backend.log");

View file

@ -6,7 +6,11 @@ const pngToIco = typeof pngToIcoModule === "function" ? pngToIcoModule : pngToIc
async function main() { async function main() {
const desktopRoot = path.resolve(__dirname, ".."); const desktopRoot = path.resolve(__dirname, "..");
const svgPath = path.resolve(desktopRoot, "..", "src", "admin", "static", "oliver.svg"); const svgCandidates = [
path.resolve(desktopRoot, "..", "_local", "branding", "logo.svg"),
path.resolve(desktopRoot, "..", "interfaces", "admin", "static", "oliver.svg"),
];
const svgPath = svgCandidates.find((p) => fs.existsSync(p)) || svgCandidates[svgCandidates.length - 1];
const assetsDir = path.join(desktopRoot, "assets"); const assetsDir = path.join(desktopRoot, "assets");
const icoPath = path.join(assetsDir, "oclaw.ico"); const icoPath = path.join(assetsDir, "oclaw.ico");

46
docs/BRANDING.md Normal file
View file

@ -0,0 +1,46 @@
# Branding Guide
This project supports **external-first branding**.
Put your branding assets under `_local/branding`, and Oclaw will use them first.
If an external file is missing, Oclaw falls back to built-in defaults.
## Directory
Create this folder in the repository root:
`_local/branding/`
## Supported Files
- `logo.svg`
- Used by admin sidebar logo and chat assistant logo/avatar.
- URL path: `/admin/brand-assets/logo.svg`
- `desktop.ico` (recommended on Windows)
- Preferred desktop window icon for Electron.
- `logo.png` (desktop fallback)
- Used as desktop icon fallback when `desktop.ico` is absent.
## Resolution Order
### Web UI (Admin + Chat)
1. `_local/branding/logo.svg`
2. Built-in fallback: `interfaces/admin/static/oliver.svg`
### Desktop (Electron window icon)
1. `_local/branding/desktop.ico`
2. `_local/branding/logo.png`
3. `_local/branding/logo.svg`
4. Built-in fallback: `interfaces/admin/static/oliver.svg`
## How to Apply
1. Put your files into `_local/branding/`.
2. Restart gateway (and desktop app if using Electron).
3. Hard refresh browser page if needed.
## Notes
- `/admin/brand-assets/*` is served with no-cache headers to reduce stale asset issues.
- `_local/*` is git-ignored by default, so tenant/customer branding stays local.

View file

@ -1934,7 +1934,8 @@ function prependMessageTime(node, tsIso) {
node.insertBefore(el("div", { class: "chat-msg__time", text: txt }), node.firstChild); node.insertBefore(el("div", { class: "chat-msg__time", text: txt }), node.firstChild);
} }
const CHAT_BOT_LOGO_SRC = "/admin/assets/oliver.svg"; const CHAT_BOT_LOGO_SRC = "/admin/brand-assets/logo.svg";
const CHAT_BOT_LOGO_FALLBACK_SRC = "/admin/assets/oliver.svg";
/** 内置默认用户头像(与助手头像同尺寸与底栏样式,SVG) */ /** 内置默认用户头像(与助手头像同尺寸与底栏样式,SVG) */
const DEFAULT_USER_AVATAR_SRC = "/admin/assets/default-user-avatar.svg"; const DEFAULT_USER_AVATAR_SRC = "/admin/assets/default-user-avatar.svg";
@ -1950,12 +1951,22 @@ async function loadMeProfile() {
} }
function buildBotAvatarImg() { function buildBotAvatarImg() {
return el("img", { const img = el("img", {
class: "chat-avatar chat-avatar--bot", class: "chat-avatar chat-avatar--bot",
src: CHAT_BOT_LOGO_SRC, src: CHAT_BOT_LOGO_SRC,
alt: "", alt: "",
loading: "lazy", loading: "lazy",
}); });
img.addEventListener(
"error",
() => {
if (img.dataset.logoFallbackApplied === "1") return;
img.dataset.logoFallbackApplied = "1";
img.src = CHAT_BOT_LOGO_FALLBACK_SRC;
},
{ once: true },
);
return img;
} }
function buildUserAvatarSlot() { function buildUserAvatarSlot() {
@ -2331,13 +2342,21 @@ function mount(node) {
} }
function buildChatBrandLogoNode() { function buildChatBrandLogoNode() {
return el("div", { class: "chat-nav__brandWrap" }, [ const img = el("img", {
el("img", { class: "chat-nav__brandLogo",
class: "chat-nav__brandLogo", src: CHAT_BOT_LOGO_SRC,
src: "/admin/assets/oliver.svg", alt: "site logo",
alt: "oliver logo", });
}), img.addEventListener(
]); "error",
() => {
if (img.dataset.logoFallbackApplied === "1") return;
img.dataset.logoFallbackApplied = "1";
img.src = CHAT_BOT_LOGO_FALLBACK_SRC;
},
{ once: true },
);
return el("div", { class: "chat-nav__brandWrap" }, [img]);
} }
function resolveAdminHashUrl(hashPath, sessionId) { function resolveAdminHashUrl(hashPath, sessionId) {

View file

@ -53,7 +53,12 @@
<aside class="sidebar"> <aside class="sidebar">
<div class="brand"> <div class="brand">
<div class="brand__logoWrap"> <div class="brand__logoWrap">
<img class="brand__logo" src="/admin/assets/oliver.svg" alt="oliver logo" /> <img
class="brand__logo"
src="/admin/brand-assets/logo.svg"
alt="site logo"
onerror="if(!this.dataset.fallback){this.dataset.fallback='1';this.src='/admin/assets/oliver.svg';}"
/>
</div> </div>
</div> </div>
<nav class="nav"> <nav class="nav">

View file

@ -8,6 +8,7 @@ import asyncio
import os import os
import shutil import shutil
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from pathlib import Path
from typing import Any from typing import Any
import threading import threading
@ -230,6 +231,8 @@ async def _lifespan(app: FastAPI): # type: ignore[no-untyped-def]
def create_app() -> FastAPI: def create_app() -> FastAPI:
app = FastAPI(title="ops-gateway", version="0.1", lifespan=_lifespan) app = FastAPI(title="ops-gateway", version="0.1", lifespan=_lifespan)
brand_assets_dir = (Path(PROJECT_ROOT) / "_local" / "branding").resolve()
app.mount("/admin/brand-assets", StaticFiles(directory=str(brand_assets_dir), check_dir=False), name="admin-brand-assets")
app.mount("/admin/assets", StaticFiles(directory=str(admin_static_dir())), name="admin-assets") app.mount("/admin/assets", StaticFiles(directory=str(admin_static_dir())), name="admin-assets")
app.include_router(build_admin_router()) app.include_router(build_admin_router())
app.include_router(weixin_ilink_router) app.include_router(weixin_ilink_router)
@ -239,7 +242,8 @@ def create_app() -> FastAPI:
resp = await call_next(request) resp = await call_next(request)
# Avoid stale JS/CSS after refactors (especially in Electron webview). # Avoid stale JS/CSS after refactors (especially in Electron webview).
# The admin SPA and /chat both load from /admin/assets/... # The admin SPA and /chat both load from /admin/assets/...
if str(request.url.path or "").startswith("/admin/assets/"): req_path = str(request.url.path or "")
if req_path.startswith("/admin/assets/") or req_path.startswith("/admin/brand-assets/"):
resp.headers["Cache-Control"] = "no-store, max-age=0" resp.headers["Cache-Control"] = "no-store, max-age=0"
resp.headers["Pragma"] = "no-cache" resp.headers["Pragma"] = "no-cache"
return resp return resp