diff --git "a/docs/PRD/\345\223\201\347\211\214\345\256\232\345\210\266 Logo \344\270\216\346\226\207\346\241\210\351\205\215\347\275\256 PRD.md" "b/docs/PRD/\345\223\201\347\211\214\345\256\232\345\210\266 Logo \344\270\216\346\226\207\346\241\210\351\205\215\347\275\256 PRD.md" new file mode 100644 index 0000000000..10f98e854a --- /dev/null +++ "b/docs/PRD/\345\223\201\347\211\214\345\256\232\345\210\266 Logo \344\270\216\346\226\207\346\241\210\351\205\215\347\275\256 PRD.md" @@ -0,0 +1,546 @@ +# 品牌定制 Logo 与文案配置 PRD + +## 1. 背景 + +BiSheng 需要在管理后台提供可视化的品牌定制能力,让系统管理员在不重新打包前端代码的情况下,配置系统品牌名称、管理后台与登录页 Logo、浏览器标签图标以及 Loading 图标/动画。 + +同时,既有“系统品牌白标化定制手册”仍然保留:部署前仍可通过替换 `/assets/bisheng/` 下的默认素材和修改 `config.js` 完成基础白标化。本功能是在系统部署后提供可视化配置,不替代部署前静态替换方案。 + +## 2. 产品目标 + +1. 管理员可在系统配置中进入品牌定制页面,完成品牌名称和视觉素材配置。 +2. 配置保存后,通过运行时脚本下发到管理后台和工作台,避免重新构建前端包。 +3. Logo 素材支持默认素材和用户上传素材两类来源。 +4. 上传素材按用途分类存储和选择,下拉选项展示图片预览与文件名。 +5. 支持删除非默认上传素材;如果删除的是正在使用的素材,自动回退到对应默认素材。 +6. 每个可配置项都提供独立预览按钮,预览区展示对应系统界面并高亮生效位置。 +7. 保持工作台“构建 → 日常”已有图标配置逻辑不被品牌定制覆盖。 + +## 3. 非目标与边界 + +1. 不提供“品牌包”能力,不读取 `brand-default.json`。 +2. 不在品牌定制页面配置 `linsightAgentName`。该字段仍按系统品牌白标化定制手册,通过 `config.js` 控制。 +3. 不在品牌定制页面配置工作台左上角图标、欢迎页面图标、对话头像、欢迎语、输入框提示语等工作台日常配置;这些仍由“构建 → 日常”维护。 +4. 不提供图片裁剪、压缩、在线设计、颜色提取等素材编辑能力。 +5. 不支持按租户、部门、用户分别配置品牌;当前品牌定制为实例级配置。 +6. 不替代主题配色能力;主题配色和品牌定制只是同属于“外观设置”下的两个子选项卡。 + +## 4. 入口与信息架构 + +入口: + +`系统 → 外观设置 → 品牌定制` + +外观设置下包含两个子选项卡: + +| 子选项卡 | 说明 | +| --- | --- | +| 主题配色 | 原有颜色配置与组件预览能力 | +| 品牌定制 | 新增品牌文案、Logo、Loading 配置能力 | + +品牌定制页面分为四个区域: + +1. 文案配置 +2. Logo 素材 +3. Loading 配置 +4. 预览 + +## 5. 角色与权限 + +| 角色 | 权限 | +| --- | --- | +| 管理员 | 可查看、上传、删除、保存、重置品牌配置 | +| 普通用户 | 不可进入品牌定制页面,但可看到已生效的品牌展示 | +| 未登录用户 | 可在登录页加载公开运行时品牌配置 | + +后端配置接口需要管理员鉴权;运行时脚本 `/api/v1/brand/runtime.js` 为公开读取接口,用于页面初始化前加载品牌配置。 + +## 6. 功能说明 + +### 6.1 文案配置 + +当前品牌定制页面仅配置一个文案项: + +| 配置项 | 字段 | 中文列 | 英文列 | 说明 | +| --- | --- | --- | --- | --- | +| 系统品牌名称 | `brandName` | 支持 | 支持 | 用于浏览器标题,以及系统中通过品牌名称变量展示的文案 | + +规则: + +1. 中文、英文分别存储。 +2. 单字段最长 20 个字符。 +3. 不允许输入 `<`、`>`,避免 HTML 注入。 +4. 展示时根据当前语言取值: + - 中文环境优先取 `zh`,为空时回退 `en`。 + - 非中文环境优先取 `en`,为空时回退 `zh`。 +5. 品牌定制保存接口不接收、不保存、不下发 `linsightAgentName`。 +6. `linsightAgentName` 继续通过部署目录的 `config.js` 配置,保证原系统品牌白标化定制手册方案不受影响。 + +### 6.2 Logo 素材配置 + +品牌定制负责以下 Logo/图片配置: + +| 配置项 | 字段 | 默认素材 | 建议规格 | 生效位置 | +| --- | --- | --- | --- | --- | +| 浏览器标签图标 | `assets.favicon` | `/assets/bisheng/favicon.ico` | 32 x 32,建议 ico | 管理后台浏览器标签;工作台初始运行时也会加载,之后可能被“构建 → 日常”的对话头像配置覆盖 | +| 登录页左侧大图 | `assets.loginHeroLight` | `/assets/bisheng/login-logo-big.png` | 420 x 704 | 管理后台登录页浅色模式左侧大图 | +| 登录页左侧大图(暗黑) | `assets.loginHeroDark` | `/assets/bisheng/login-logo-dark.png` | 420 x 704 | 管理后台登录页暗黑模式左侧大图 | +| 表单与顶部 Logo | `assets.headerLogoLight` | `/assets/bisheng/login-logo-small.png` | 410 x 120 | 管理后台左上角 Logo、登录页顶部 Logo(浅色) | +| 表单与顶部 Logo(暗黑) | `assets.headerLogoDark` | `/assets/bisheng/logo-small-dark.png` | 410 x 120 | 管理后台左上角 Logo、登录页顶部 Logo(暗黑) | + +不由品牌定制负责的工作台图标: + +| 工作台图标 | 配置入口 | +| --- | --- | +| 工作台左上角图标 | 构建 → 日常 → 左侧边栏图标 | +| 欢迎页面图标与对话头像 | 构建 → 日常 → 欢迎页面图标 & 对话头像 | +| 工作台最终 favicon | 优先跟随“欢迎页面图标 & 对话头像” | + +### 6.3 素材上传、选择与删除 + +上传规则: + +1. 支持格式:`ico`、`png`、`jpg`、`jpeg`、`svg`、`gif`、`webp`。 +2. 单文件不超过 5MB。 +3. SVG 不允许包含 ` - + + <%- aceScriptSrc %> loading... @@ -65,4 +79,4 @@ }) - \ No newline at end of file + diff --git a/src/frontend/platform/public/assets/bisheng/config.js b/src/frontend/platform/public/assets/bisheng/config.js index 4e816abd76..c6a45a54b5 100644 --- a/src/frontend/platform/public/assets/bisheng/config.js +++ b/src/frontend/platform/public/assets/bisheng/config.js @@ -1,30 +1,15 @@ window.BRAND_CONFIG = { - // 1. 系统品牌名称 brandName: { zh: "BISHENG", en: "BISHENG" }, - - // 2. 灵思智能体 linsightAgentName: { zh: "灵思", en: "Linsight" }, - - // 3. 灵思中英文结合展示名 - linsightFullName: { - zh: "灵思Linsight", - en: "Linsight" - }, - dailyFullName: { - zh: "日常模式", - en: "Daily Mode" - }, - - // 4. Loading 图标配置 - // 支持相对路径 (如 /branding/loading.gif) 或 完整的 URL (如 https://cdn.com/icon.png) + URLLoadingIcon: "", loadingIcon: "", - loadingAnimation: "" // animate-spin | animate-ping | animate-pulse | animate-bounce + loadingAnimation: "" }; // Application-wide runtime config (separate from BRAND_CONFIG above). @@ -33,4 +18,4 @@ window.APP_CONFIG = { // Hide Japanese from the language switcher and prevent it from being // auto-selected by browser/locale detection. Set to false to re-enable. disableJa: true -}; \ No newline at end of file +}; diff --git a/src/frontend/platform/public/locales/en-US/bs.json b/src/frontend/platform/public/locales/en-US/bs.json index e6752b9153..9681be4c8f 100644 --- a/src/frontend/platform/public/locales/en-US/bs.json +++ b/src/frontend/platform/public/locales/en-US/bs.json @@ -84,7 +84,59 @@ "radius": "Radius", "warning": "Warning", "warningForeground": "Warning Foreground", - "blackButton": "Black Button" + "blackButton": "Black Button", + "brandCustomization": "Branding", + "brandAssetUploadSuccess": "Uploaded", + "brandUploading": "Uploading", + "brandUpload": "Upload", + "brandDefaultAsset": "Default", + "brandDeleteAsset": "Delete asset", + "brandAssetDeleteSuccess": "Deleted", + "brandSelectAssetPlaceholder": "Select an asset", + "brandAssetUrlPlaceholder": "Asset URL", + "brandPasteUrl": "Paste URL", + "brandUrlDialogTitle": "Add asset URL", + "brandUrlDialogDescription": "Paste an image URL. It will be added to the selectable asset list.", + "brandUrlDialogCancel": "Cancel", + "brandUrlDialogConfirm": "Confirm", + "brandAssetUrlInvalid": "Enter a valid URL that starts with http:// or https://.", + "brandAssetUrlAddSuccess": "URL added", + "brandUrlAssetName": "URL asset", + "brandDefaultLoadingIcon": "Default loading icon", + "brandResetDefault": "Reset", + "brandSave": "Save", + "brandTextSection": "Copy", + "brandAssetsSection": "Logo Assets", + "brandLoadingSection": "Loading", + "brandPreview": "Preview", + "brandPreviewButton": "Preview", + "brandPreviewEmpty": "Click Preview beside a setting to see where it applies", + "brandSystemName": "System brand", + "brandSystemNameDesc": "Used for browser title, login page, and similar surfaces", + "brandLinsightAgentName": "Agent feature name", + "brandLinsightAgentNameDesc": "Used for feature module names across the system", + "brandChineseColumn": "Chinese", + "brandEnglishColumn": "English", + "brandZhPlaceholder": "Chinese", + "brandEnPlaceholder": "English", + "brandSaveSuccess": "Saved", + "brandFavicon": "Favicon", + "brandFaviconSpec": "32 x 32, ico recommended", + "brandLoginHeroLight": "Login hero", + "brandLoginHeroSpec": "420 x 704, light mode", + "brandLoginHeroDark": "Login hero dark", + "brandLoginHeroDarkSpec": "420 x 704, dark mode", + "brandHeaderLogoLight": "Header logo", + "brandHeaderLogoSpec": "410 x 120, login form and header", + "brandHeaderLogoDark": "Header logo dark", + "brandHeaderLogoDarkSpec": "410 x 120, dark mode", + "brandLoadingIcon": "Loading icon", + "brandLoadingIconSpec": "SVG recommended, upload or URL", + "brandLoadingAnimation": "Animation", + "brandAnimationNone": "None", + "brandAnimationSpin": "Spin", + "brandAnimationPulse": "Pulse", + "brandAnimationBounce": "Bounce" }, "login": { "slogen": "Convenient, Flexible, Reliable Enterprise-Level Large Model Application Development Platform", @@ -381,6 +433,7 @@ "primaryMenu": "Primary menu", "viewPermission": "View permission", "themeColor": "Theme color", + "appearanceSettings": "Appearance", "toolAuthorization": "Tool", "createUser": "Create User", "usernamePlaceholder": "This username will be used to log in, and cannot be changed", diff --git a/src/frontend/platform/public/locales/ja/bs.json b/src/frontend/platform/public/locales/ja/bs.json index ae11f8004c..0bd12cba38 100644 --- a/src/frontend/platform/public/locales/ja/bs.json +++ b/src/frontend/platform/public/locales/ja/bs.json @@ -84,7 +84,59 @@ "radius": "角丸半径", "warning": "警告色", "warningForeground": "警告前景色", - "blackButton": "ブラックボタン" + "blackButton": "ブラックボタン", + "brandCustomization": "ブランド設定", + "brandAssetUploadSuccess": "アップロードしました", + "brandUploading": "アップロード中", + "brandUpload": "アップロード", + "brandDefaultAsset": "デフォルト", + "brandDeleteAsset": "素材を削除", + "brandAssetDeleteSuccess": "削除しました", + "brandSelectAssetPlaceholder": "素材を選択", + "brandAssetUrlPlaceholder": "素材 URL", + "brandPasteUrl": "URL を貼り付け", + "brandUrlDialogTitle": "素材 URL を追加", + "brandUrlDialogDescription": "画像 URL を貼り付けると、選択可能な素材リストに追加されます。", + "brandUrlDialogCancel": "キャンセル", + "brandUrlDialogConfirm": "確定", + "brandAssetUrlInvalid": "http:// または https:// で始まる有効な URL を入力してください。", + "brandAssetUrlAddSuccess": "URL を追加しました", + "brandUrlAssetName": "URL 素材", + "brandDefaultLoadingIcon": "内蔵デフォルト Loading アイコン", + "brandResetDefault": "リセット", + "brandSave": "保存", + "brandTextSection": "文言設定", + "brandAssetsSection": "ロゴ素材", + "brandLoadingSection": "Loading 設定", + "brandPreview": "プレビュー", + "brandPreviewButton": "プレビュー", + "brandPreviewEmpty": "各設定のプレビューボタンを押すと対応エリアを確認できます", + "brandSystemName": "システムブランド名", + "brandSystemNameDesc": "ブラウザータイトル、ログインページなどで使用", + "brandLinsightAgentName": "エージェント機能名", + "brandLinsightAgentNameDesc": "システム内の機能モジュール名として使用", + "brandChineseColumn": "中国語", + "brandEnglishColumn": "英語", + "brandZhPlaceholder": "中国語", + "brandEnPlaceholder": "英語", + "brandSaveSuccess": "保存しました", + "brandFavicon": "ブラウザーアイコン", + "brandFaviconSpec": "32 x 32、ico 推奨", + "brandLoginHeroLight": "ログイン大画像", + "brandLoginHeroSpec": "420 x 704、ライトモード", + "brandLoginHeroDark": "ログイン大画像(ダーク)", + "brandLoginHeroDarkSpec": "420 x 704、ダークモード", + "brandHeaderLogoLight": "フォームとヘッダーロゴ", + "brandHeaderLogoSpec": "410 x 120、ログインフォームと左上ヘッダー", + "brandHeaderLogoDark": "フォームとヘッダーロゴ(ダーク)", + "brandHeaderLogoDarkSpec": "410 x 120、ダークモード", + "brandLoadingIcon": "Loading アイコン", + "brandLoadingIconSpec": "SVG 推奨、アップロードまたは URL", + "brandLoadingAnimation": "アニメーション", + "brandAnimationNone": "なし", + "brandAnimationSpin": "回転", + "brandAnimationPulse": "パルス", + "brandAnimationBounce": "バウンス" }, "login": { "slogen": "便利・柔軟・信頼できるエンタープライズ向け大規模モデルアプリ開発プラットフォーム", @@ -377,6 +429,7 @@ "primaryMenu": "第1階層メニュー", "viewPermission": "閲覧権限", "themeColor": "テーマ配色", + "appearanceSettings": "外観設定", "toolAuthorization": "ツール権限付与", "createUser": "ユーザー作成", "usernamePlaceholder": "今後このユーザー名でログインします。ユーザー名は変更できません。", diff --git a/src/frontend/platform/public/locales/zh-Hans/bs.json b/src/frontend/platform/public/locales/zh-Hans/bs.json index f4528caf02..d6658d5837 100644 --- a/src/frontend/platform/public/locales/zh-Hans/bs.json +++ b/src/frontend/platform/public/locales/zh-Hans/bs.json @@ -85,7 +85,59 @@ "radius": "圆角半径", "warning": "警告色", "warningForeground": "警告前景色", - "blackButton": "黑按钮" + "blackButton": "黑按钮", + "brandCustomization": "品牌定制", + "brandAssetUploadSuccess": "上传成功", + "brandUploading": "上传中", + "brandUpload": "上传", + "brandDefaultAsset": "默认", + "brandDeleteAsset": "删除素材", + "brandAssetDeleteSuccess": "删除成功", + "brandSelectAssetPlaceholder": "请选择素材", + "brandAssetUrlPlaceholder": "素材 URL", + "brandPasteUrl": "粘贴 URL", + "brandUrlDialogTitle": "添加素材 URL", + "brandUrlDialogDescription": "粘贴图片 URL,确认后会加入可选择的素材列表。", + "brandUrlDialogCancel": "取消", + "brandUrlDialogConfirm": "确定", + "brandAssetUrlInvalid": "请输入以 http:// 或 https:// 开头的有效 URL。", + "brandAssetUrlAddSuccess": "URL 已添加", + "brandUrlAssetName": "URL 素材", + "brandDefaultLoadingIcon": "内置默认 Loading 图标", + "brandResetDefault": "重置默认", + "brandSave": "保存", + "brandTextSection": "文案配置", + "brandAssetsSection": "Logo 素材", + "brandLoadingSection": "Loading 配置", + "brandPreview": "预览", + "brandPreviewButton": "预览", + "brandPreviewEmpty": "点击配置项右侧的预览按钮查看对应区域", + "brandSystemName": "系统品牌名称", + "brandSystemNameDesc": "用于浏览器标题、登录页等", + "brandLinsightAgentName": "智能体功能名", + "brandLinsightAgentNameDesc": "系统中各处提到的功能模块名称", + "brandChineseColumn": "中文", + "brandEnglishColumn": "英文", + "brandZhPlaceholder": "中文", + "brandEnPlaceholder": "英文", + "brandSaveSuccess": "保存成功", + "brandFavicon": "浏览器标签图标", + "brandFaviconSpec": "32 x 32,建议 ico", + "brandLoginHeroLight": "登录页左侧大图", + "brandLoginHeroSpec": "420 x 704,浅色模式", + "brandLoginHeroDark": "登录页左侧大图(暗黑)", + "brandLoginHeroDarkSpec": "420 x 704,暗黑模式", + "brandHeaderLogoLight": "表单与顶部 Logo", + "brandHeaderLogoSpec": "410 x 120,浅色模式,用于登录页和管理后台左侧边栏", + "brandHeaderLogoDark": "表单与顶部 Logo(暗黑)", + "brandHeaderLogoDarkSpec": "410 x 120,暗黑模式,用于登录页和管理后台左侧边栏", + "brandLoadingIcon": "Loading 图标", + "brandLoadingIconSpec": "建议 SVG,可上传或填写 URL", + "brandLoadingAnimation": "动画", + "brandAnimationNone": "无", + "brandAnimationSpin": "旋转", + "brandAnimationPulse": "呼吸", + "brandAnimationBounce": "弹跳" }, "login": { "slogen": "便捷、灵活、可靠的企业级大模型应用开发平台", @@ -382,6 +434,7 @@ "primaryMenu": "管理后台菜单", "viewPermission": "查看权限", "themeColor": "主题配色", + "appearanceSettings": "外观设置", "toolAuthorization": "工具权限", "createUser": "创建用户", "usernamePlaceholder": "后续使用此用户名进行登录,用户名不可修改", diff --git a/src/frontend/platform/src/components/bs-icons/loading/index.tsx b/src/frontend/platform/src/components/bs-icons/loading/index.tsx index ccc5d03bac..d5a5356668 100644 --- a/src/frontend/platform/src/components/bs-icons/loading/index.tsx +++ b/src/frontend/platform/src/components/bs-icons/loading/index.tsx @@ -2,6 +2,7 @@ import React, { forwardRef } from "react"; import Load from "./Load.svg?react"; import Loading from "./Loading.svg?react"; import { cname } from "../../bs-ui/utils"; +import { getBrandLoadingIconUrl } from "@/utils/brand"; export const LoadIcon = forwardRef< SVGSVGElement & { className: any }, @@ -15,8 +16,9 @@ export const LoadingIcon = forwardRef< SVGSVGElement & { className: any }, React.PropsWithChildren<{ className?: string }> >(({ className, ...props }, ref) => { - if (window.BRAND_CONFIG.loadingIcon) { - return ; + const loadingIcon = getBrandLoadingIconUrl(); + if (loadingIcon) { + return ; } return ; -}); \ No newline at end of file +}); diff --git a/src/frontend/platform/src/controllers/API/index.ts b/src/frontend/platform/src/controllers/API/index.ts index 51702b6d76..ad6959d329 100644 --- a/src/frontend/platform/src/controllers/API/index.ts +++ b/src/frontend/platform/src/controllers/API/index.ts @@ -186,6 +186,74 @@ export async function getAppConfig(): Promise { export async function saveThemeApi(data: string): Promise { return await axios.post(`/api/v1/web/config`, { value: data }); } + +export interface BrandText { + zh: string; + en: string; +} + +export interface BrandAsset { + url: string; + relative_path?: string; + file_name?: string; +} + +export interface BrandAssetOption extends BrandAsset { + is_default?: boolean; +} + +export interface BrandAssets { + favicon: BrandAsset; + loginHeroLight: BrandAsset; + loginHeroDark: BrandAsset; + headerLogoLight: BrandAsset; + headerLogoDark: BrandAsset; +} + +export interface BrandLoading { + icon?: BrandAsset | null; + iconOptions?: BrandAsset[]; + animation: "" | "animate-spin" | "animate-pulse" | "animate-bounce"; +} + +export interface BrandConfig { + brandName: BrandText; + linsightAgentName: BrandText; + assets: BrandAssets; + loading: BrandLoading; + URLLoadingIcon?: string; +} + +export type BrandConfigUpdate = Omit; + +export type BrandAssetCategory = keyof BrandAssets | "loadingIcon"; + +export async function getBrandConfigApi(): Promise { + return await axios.get(`/api/v1/brand/config`); +} + +export async function saveBrandConfigApi(data: BrandConfigUpdate): Promise { + return await axios.put(`/api/v1/brand/config`, data); +} + +export async function getBrandAssetOptionsApi(category: BrandAssetCategory): Promise { + return await axios.get(`/api/v1/brand/assets/options`, { params: { category } }); +} + +export async function uploadBrandAssetApi(data: FormData): Promise { + return await axios.post(`/api/v1/brand/assets`, data, { + headers: { "Content-Type": "multipart/form-data" }, + }); +} + +export async function deleteBrandAssetApi( + category: BrandAssetCategory, + relativePath: string +): Promise { + return await axios.delete(`/api/v1/brand/assets`, { + params: { category, relative_path: relativePath }, + }); +} /** * Reads all templates from the database. * diff --git a/src/frontend/platform/src/i18n.js b/src/frontend/platform/src/i18n.js index 3ec029fd22..13382f9a5e 100644 --- a/src/frontend/platform/src/i18n.js +++ b/src/frontend/platform/src/i18n.js @@ -60,12 +60,12 @@ i18n.use(Backend) defaultVariables: { bisheng: config.brandName?.en, bishengZh: config.brandName?.zh, - linsight: config.linsightAgentName?.en, - linsightZh: config.linsightAgentName?.zh, - linsightFull: config.linsightFullName?.en, - linsightFullZh: config.linsightFullName?.zh, - dailyFullName: config.dailyFullName?.en, - dailyFullNameZh: config.dailyFullName?.zh, + linsight: config.linsightAgentName?.en || 'Linsight', + linsightZh: config.linsightAgentName?.zh || '灵思', + linsightFull: 'Linsight', + linsightFullZh: '灵思 Linsight', + dailyFullName: 'Daily Mode', + dailyFullNameZh: '日常模式', } } }); @@ -73,4 +73,4 @@ i18n.use(Backend) export default i18n; // Dynamically load the namespace -// i18n.loadNamespaces(['bs']); \ No newline at end of file +// i18n.loadNamespaces(['bs']); diff --git a/src/frontend/platform/src/layout/MainLayout.tsx b/src/frontend/platform/src/layout/MainLayout.tsx index a3b6cd4d72..1e20058c89 100755 --- a/src/frontend/platform/src/layout/MainLayout.tsx +++ b/src/frontend/platform/src/layout/MainLayout.tsx @@ -30,6 +30,7 @@ import { userContext } from "../contexts/userContext"; import { logoutApi } from "../controllers/API/user"; import { captureAndAlertRequestErrorHoc } from "../controllers/request"; import { User } from "../types/api/user"; +import { getBrandAssetUrl } from "../utils/brand"; import HeaderMenu from "./HeaderMenu"; export default function MainLayout() { @@ -113,7 +114,8 @@ export default function MainLayout() {
{/* @ts-ignore */} - + +
diff --git a/src/frontend/platform/src/pages/LoginPage/login.tsx b/src/frontend/platform/src/pages/LoginPage/login.tsx index 6a5eaa3c31..058696ee4f 100644 --- a/src/frontend/platform/src/pages/LoginPage/login.tsx +++ b/src/frontend/platform/src/pages/LoginPage/login.tsx @@ -15,6 +15,7 @@ import LoginBridge from './loginBridge'; import { PWD_RULE, handleEncrypt, handleLdapEncrypt } from './utils'; import { locationContext } from '@/contexts/locationContext'; import { ldapLoginApi, getSSOurlApi } from '@/controllers/API/pro'; +import { getBrandAssetUrl } from '@/utils/brand'; import { getWorkspaceClientUrl } from '@/utils/workspaceUrl'; interface LoginPageProps { @@ -245,15 +246,15 @@ export const LoginPage = ({ forceLocal = false }: LoginPageProps) => { return
- logo_picture - logo_picture + logo_picture + logo_picture {/* */}
- - + + {t('login.slogen')}
diff --git a/src/frontend/platform/src/pages/LoginPage/resetPwd.tsx b/src/frontend/platform/src/pages/LoginPage/resetPwd.tsx index 7f39584933..04f16779e3 100644 --- a/src/frontend/platform/src/pages/LoginPage/resetPwd.tsx +++ b/src/frontend/platform/src/pages/LoginPage/resetPwd.tsx @@ -7,6 +7,7 @@ import { Button } from "../../components/bs-ui/button"; import { PasswordInput } from "../../components/bs-ui/input"; import { changePasswordApi, loggedChangePasswordApi } from "../../controllers/API/user"; import { captureAndAlertRequestErrorHoc } from "../../controllers/request"; +import { getBrandAssetUrl } from "@/utils/brand"; import { PWD_RULE, handleEncrypt } from './utils'; export const ResetPwdPage = () => { @@ -84,7 +85,7 @@ export const ResetPwdPage = () => { >}
- small_logo + small_logo {t('resetPassword.slogen')}
diff --git a/src/frontend/platform/src/pages/SystemPage/index.tsx b/src/frontend/platform/src/pages/SystemPage/index.tsx index c3479e1318..675a6fd230 100644 --- a/src/frontend/platform/src/pages/SystemPage/index.tsx +++ b/src/frontend/platform/src/pages/SystemPage/index.tsx @@ -76,7 +76,7 @@ export default function index() { {t("system.systemConfiguration")} )} {canAccessSystemConfig && ( - {t("system.themeColor")} + {t("system.appearanceSettings")} )} {showOrgTab && ( diff --git a/src/frontend/platform/src/pages/SystemPage/theme/BrandAssetUpload.tsx b/src/frontend/platform/src/pages/SystemPage/theme/BrandAssetUpload.tsx new file mode 100644 index 0000000000..96de318da1 --- /dev/null +++ b/src/frontend/platform/src/pages/SystemPage/theme/BrandAssetUpload.tsx @@ -0,0 +1,455 @@ +import { Button } from "@/components/bs-ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/bs-ui/dialog"; +import { Input } from "@/components/bs-ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, +} from "@/components/bs-ui/select"; +import { useToast } from "@/components/bs-ui/toast/use-toast"; +import type { BrandAsset, BrandAssetCategory, BrandAssetOption } from "@/controllers/API"; +import { deleteBrandAssetApi, uploadBrandAssetApi } from "@/controllers/API"; +import { captureAndAlertRequestErrorHoc } from "@/controllers/request"; +import { withBrandBaseUrl } from "@/utils/brand"; +import { Check, Eye, ImageIcon, Link, Loader2, Trash2, Upload } from "lucide-react"; +import { ChangeEvent, MouseEvent, PointerEvent, ReactNode, useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { BRAND_ASSET_ACCEPT } from "./brandTypes"; + +const EMPTY_DEFAULT_VALUE = "__brand_empty_default__"; + +interface BrandAssetUploadProps { + label: string; + spec: string; + value?: BrandAsset | null; + fallbackUrl?: string; + category?: BrandAssetCategory; + options?: BrandAssetOption[]; + allowUrlOption?: boolean; + emptyPreview?: ReactNode; + previewActive?: boolean; + onChange: (asset: BrandAsset) => void; + onPreview?: () => void; + onUploaded?: (asset: BrandAsset) => void; + onDeleted?: (deletedAsset: BrandAssetOption, fallbackAsset: BrandAsset) => void; + onUrlAdded?: (asset: BrandAsset) => void; +} + +const getAssetValue = (asset?: BrandAsset | null) => ( + asset?.relative_path || asset?.url || "" +); + +const getOptionValue = (asset?: BrandAssetOption | BrandAsset | null) => ( + getAssetValue(asset) || EMPTY_DEFAULT_VALUE +); + +const getFileName = (asset?: BrandAsset | null) => { + if (asset?.file_name) return asset.file_name; + const source = asset?.relative_path || asset?.url || ""; + if (!source || source.startsWith("data:")) return ""; + const cleanSource = source.split("?")[0]; + return cleanSource.split("/").pop() || cleanSource; +}; + +const isValidAssetUrl = (value: string) => { + const trimmed = value.trim(); + return /^https?:\/\/[^\s<>]+$/i.test(trimmed); +}; + +function AssetOptionContent({ + option, + label, + selected = false, + deleting = false, + emptyPreview, + onDelete, +}: { + option: BrandAssetOption; + label: string; + selected?: boolean; + deleting?: boolean; + emptyPreview?: ReactNode; + onDelete?: (option: BrandAssetOption) => void; +}) { + const { t } = useTranslation(); + const pointerDeleteTriggeredRef = useRef(false); + const previewUrl = withBrandBaseUrl(option.url); + const canDelete = !option.is_default && !!onDelete && (!!option.relative_path || !!option.url); + + const handleDeletePointerDown = (event: PointerEvent) => { + event.preventDefault(); + event.stopPropagation(); + if (event.button !== 0 || deleting) return; + pointerDeleteTriggeredRef.current = true; + onDelete?.(option); + window.setTimeout(() => { + pointerDeleteTriggeredRef.current = false; + }, 300); + }; + + const handleDeleteMouseDown = (event: MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + }; + + const handleDeleteClick = (event: MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + if (pointerDeleteTriggeredRef.current || deleting) return; + onDelete?.(option); + }; + + return ( +
+
+ {previewUrl ? ( + {label} + ) : emptyPreview ? ( + emptyPreview + ) : ( + + )} +
+
+
+ {getFileName(option)} + {option.is_default && ( + + {t("theme.brandDefaultAsset")} + + )} +
+
+ {selected && } + {canDelete && ( + + )} +
+ ); +} + +export default function BrandAssetUpload({ + label, + spec, + value, + fallbackUrl = "", + category, + options = [], + allowUrlOption = false, + emptyPreview, + previewActive = false, + onChange, + onPreview, + onUploaded, + onDeleted, + onUrlAdded, +}: BrandAssetUploadProps) { + const { t } = useTranslation(); + const { toast } = useToast(); + const inputRef = useRef(null); + const [uploading, setUploading] = useState(false); + const [deletingValue, setDeletingValue] = useState(""); + const [urlDialogOpen, setUrlDialogOpen] = useState(false); + const [urlValue, setUrlValue] = useState(""); + const selectable = !!category; + const rawSelectedValue = getAssetValue(value); + const selectedValue = selectable ? rawSelectedValue || EMPTY_DEFAULT_VALUE : rawSelectedValue; + const effectiveOptions = useMemo(() => { + if (!selectable || !selectedValue || options.some((option) => getOptionValue(option) === selectedValue)) { + return options; + } + + return [ + { + url: value?.url || fallbackUrl, + relative_path: value?.relative_path || "", + file_name: getFileName(value), + }, + ...options, + ]; + }, [fallbackUrl, options, selectable, selectedValue, value]); + const selectedOption = effectiveOptions.find((option) => getOptionValue(option) === selectedValue); + const previewUrl = withBrandBaseUrl(selectedOption?.url || value?.url || fallbackUrl); + + const handlePickFile = () => { + inputRef.current?.click(); + }; + + const handleFileChange = async (event: ChangeEvent) => { + const file = event.target.files?.[0]; + event.target.value = ""; + if (!file) return; + + const formData = new FormData(); + formData.append("file", file); + if (category) { + formData.append("category", category); + } + setUploading(true); + const uploaded = await captureAndAlertRequestErrorHoc(uploadBrandAssetApi(formData)); + setUploading(false); + if (!uploaded) return; + + onChange(uploaded); + onUploaded?.(uploaded); + toast({ + title: t("prompt"), + variant: "success", + description: t("theme.brandAssetUploadSuccess"), + }); + }; + + const handleAssetSelect = (assetValue: string) => { + const selected = effectiveOptions.find((option) => getOptionValue(option) === assetValue); + if (!selected) return; + onChange({ + url: selected.url, + relative_path: selected.relative_path || "", + file_name: selected.file_name || "", + }); + }; + + const getFallbackAsset = (): BrandAsset => { + const defaultOption = effectiveOptions.find((option) => option.is_default); + return { + url: defaultOption?.url || fallbackUrl, + relative_path: defaultOption?.relative_path || "", + file_name: defaultOption?.file_name || getFileName(defaultOption) || getFileName({ url: fallbackUrl }), + }; + }; + + const handleAssetDelete = async (option: BrandAssetOption) => { + if (option.is_default) return; + + const optionValue = getOptionValue(option); + if (!option.relative_path) { + const fallbackAsset = getFallbackAsset(); + if (optionValue === selectedValue) { + onChange(fallbackAsset); + } + onDeleted?.(option, fallbackAsset); + toast({ + title: t("prompt"), + variant: "success", + description: t("theme.brandAssetDeleteSuccess"), + }); + return; + } + + if (!category) return; + setDeletingValue(optionValue); + const defaultAsset = await captureAndAlertRequestErrorHoc( + deleteBrandAssetApi(category, option.relative_path) + ); + setDeletingValue(""); + if (!defaultAsset) return; + + const fallbackAsset: BrandAsset = { + url: defaultAsset.url || fallbackUrl, + relative_path: defaultAsset.relative_path || "", + file_name: defaultAsset.file_name || getFileName(defaultAsset), + }; + + if (optionValue === selectedValue) { + onChange(fallbackAsset); + } + onDeleted?.(option, fallbackAsset); + toast({ + title: t("prompt"), + variant: "success", + description: t("theme.brandAssetDeleteSuccess"), + }); + }; + + const handleOpenUrlDialog = () => { + setUrlValue(""); + setUrlDialogOpen(true); + }; + + const handleUrlConfirm = () => { + const nextUrl = urlValue.trim(); + if (!isValidAssetUrl(nextUrl)) { + toast({ + title: t("prompt"), + variant: "warning", + description: t("theme.brandAssetUrlInvalid"), + }); + return; + } + + const urlAsset: BrandAsset = { + url: nextUrl, + relative_path: "", + file_name: getFileName({ url: nextUrl }) || t("theme.brandUrlAssetName"), + }; + onChange(urlAsset); + onUrlAdded?.(urlAsset); + setUrlDialogOpen(false); + toast({ + title: t("prompt"), + variant: "success", + description: t("theme.brandAssetUrlAddSuccess"), + }); + }; + + const handleUrlChange = (event: ChangeEvent) => { + onChange({ + url: event.target.value, + relative_path: "", + file_name: value?.file_name || "", + }); + }; + + return ( + <> +
+
+ {previewUrl ? ( + {label} + ) : emptyPreview ? ( + emptyPreview + ) : ( + + )} +
+
+
+
+

{label}

+

{spec}

+
+
+ {onPreview && ( + + )} + {allowUrlOption && ( + + )} + +
+
+ {selectable ? ( + + ) : ( + + )} +
+ +
+ + + + {t("theme.brandUrlDialogTitle")} + {t("theme.brandUrlDialogDescription")} + + setUrlValue(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") { + handleUrlConfirm(); + } + }} + /> + + + + + + + + ); +} diff --git a/src/frontend/platform/src/pages/SystemPage/theme/BrandCustomization.tsx b/src/frontend/platform/src/pages/SystemPage/theme/BrandCustomization.tsx new file mode 100644 index 0000000000..50a3dd4752 --- /dev/null +++ b/src/frontend/platform/src/pages/SystemPage/theme/BrandCustomization.tsx @@ -0,0 +1,548 @@ +import { Button, LoadButton } from "@/components/bs-ui/button"; +import { Input } from "@/components/bs-ui/input"; +import { Label } from "@/components/bs-ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/bs-ui/select"; +import { useToast } from "@/components/bs-ui/toast/use-toast"; +import { darkContext } from "@/contexts/darkContext"; +import type { BrandAsset, BrandAssetOption, BrandConfig, BrandText } from "@/controllers/API"; +import { getBrandAssetOptionsApi, getBrandConfigApi, saveBrandConfigApi } from "@/controllers/API"; +import { captureAndAlertRequestErrorHoc } from "@/controllers/request"; +import { withBrandBaseUrl } from "@/utils/brand"; +import { Eye, RefreshCw, Save } from "lucide-react"; +import { ChangeEvent, useContext, useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import BrandAssetUpload from "./BrandAssetUpload"; +import BrandPreviewPanel from "./BrandPreviewPanel"; +import { BrandAssetKey, BrandPreviewTarget, BrandTextKey, buildDefaultAssetOptions, cloneBrandConfig, DEFAULT_BRAND_CONFIG } from "./brandTypes"; + +const ASSET_ROWS: Array<{ + key: BrandAssetKey; + labelKey: string; + specKey: string; +}> = [ + { key: "favicon", labelKey: "theme.brandFavicon", specKey: "theme.brandFaviconSpec" }, + { key: "loginHeroLight", labelKey: "theme.brandLoginHeroLight", specKey: "theme.brandLoginHeroSpec" }, + { key: "loginHeroDark", labelKey: "theme.brandLoginHeroDark", specKey: "theme.brandLoginHeroDarkSpec" }, + { key: "headerLogoLight", labelKey: "theme.brandHeaderLogoLight", specKey: "theme.brandHeaderLogoSpec" }, + { key: "headerLogoDark", labelKey: "theme.brandHeaderLogoDark", specKey: "theme.brandHeaderLogoDarkSpec" }, +]; + +const TEXT_ROWS: Array<{ + key: BrandTextKey; + labelKey: string; + descKey: string; +}> = [ + { + key: "brandName", + labelKey: "theme.brandSystemName", + descKey: "theme.brandSystemNameDesc", + }, +]; + +const getBrandTitle = (brandName?: BrandText) => { + const language = localStorage.getItem("i18nextLng") || navigator.language || "en-US"; + return language.toLowerCase().startsWith("zh") + ? (brandName?.zh || brandName?.en || "") + : (brandName?.en || brandName?.zh || ""); +}; + +const omitLinsightAgentName = (config: BrandConfig) => { + const payload = { ...config } as Partial; + delete payload.linsightAgentName; + return payload as Omit; +}; + +function applyRuntimeBrandConfig(config: BrandConfig) { + const loadingIcon = config.URLLoadingIcon || config.loading?.icon?.url || ""; + window.BRAND_CONFIG = { + ...window.BRAND_CONFIG, + ...omitLinsightAgentName(config), + loadingIcon, + URLLoadingIcon: loadingIcon, + loadingAnimation: config.loading?.animation || "", + }; + const title = getBrandTitle(config.brandName); + if (title) { + document.title = title; + } + const favicon = config.assets?.favicon?.url; + if (favicon) { + const link = document.querySelector("link[rel*='icon']") || document.createElement("link"); + link.setAttribute("rel", "icon"); + link.setAttribute("href", withBrandBaseUrl(favicon)); + document.head.appendChild(link); + } +} + +function normalizeForSave(config: BrandConfig) { + const next = cloneBrandConfig(config); + const loadingIconUrl = next.loading?.icon?.url || next.URLLoadingIcon || ""; + next.URLLoadingIcon = loadingIconUrl; + next.loading.iconOptions = normalizeLoadingUrlOptions( + next.loading?.icon?.url && !next.loading.icon.relative_path + ? upsertAssetOption(next.loading.iconOptions, next.loading.icon) + : next.loading.iconOptions + ); + if (!loadingIconUrl) { + next.loading.icon = null; + } + return omitLinsightAgentName(next); +} + +const getAssetValue = (asset?: BrandAsset | null) => ( + asset?.relative_path || asset?.url || "" +); + +const DEFAULT_OPTION_VALUE = "__default__"; + +const mergeAssetOptions = (options: BrandAssetOption[]) => { + const seen = new Set(); + return options.filter((option) => { + const value = getAssetValue(option) || DEFAULT_OPTION_VALUE; + if (seen.has(value)) return false; + seen.add(value); + return true; + }); +}; + +const upsertAssetOption = (options: BrandAsset[] = [], asset: BrandAsset) => { + const assetValue = getAssetValue(asset); + if (!assetValue) return options; + return [ + { + url: asset.url || "", + relative_path: asset.relative_path || "", + file_name: asset.file_name || "", + }, + ...options.filter((option) => getAssetValue(option) !== assetValue), + ]; +}; + +const normalizeLoadingUrlOptions = (options: BrandAsset[] = []) => { + const seen = new Set(); + return options.filter((option) => { + if (!option.url || option.relative_path) return false; + const value = getAssetValue(option); + if (!value || seen.has(value)) return false; + seen.add(value); + return true; + }).map((option) => ({ + url: option.url, + relative_path: "", + file_name: option.file_name || "", + })); +}; + +function StaticLoadingBarsIcon({ className = "" }: { className?: string }) { + return ( + + ); +} + +function BrandTextField({ + label, + description, + value, + previewActive, + onChange, + onPreview, +}: { + label: string; + description: string; + value: BrandText; + previewActive: boolean; + onChange: (next: BrandText) => void; + onPreview: () => void; +}) { + const { t } = useTranslation(); + + const handleChange = (lang: keyof BrandText) => (event: ChangeEvent) => { + onChange({ + ...value, + [lang]: event.target.value, + }); + }; + + return ( +
+
+ +

{description}

+
+ + + +
+ ); +} + +export default function BrandCustomization() { + const { t } = useTranslation(); + const { toast } = useToast(); + const { dark } = useContext(darkContext); + const [config, setConfig] = useState(() => cloneBrandConfig(DEFAULT_BRAND_CONFIG)); + const [assetOptions, setAssetOptions] = useState>( + () => buildDefaultAssetOptions() + ); + const [loadingIconUploadOptions, setLoadingIconUploadOptions] = useState([]); + const [previewTarget, setPreviewTarget] = useState(null); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + + useEffect(() => { + let mounted = true; + + const loadBrandData = async () => { + const [configData, optionEntries, loadingIconOptions] = await Promise.all([ + captureAndAlertRequestErrorHoc(getBrandConfigApi()), + Promise.all(ASSET_ROWS.map(async ({ key }) => ({ + key, + options: await captureAndAlertRequestErrorHoc(getBrandAssetOptionsApi(key)), + }))), + captureAndAlertRequestErrorHoc(getBrandAssetOptionsApi("loadingIcon")), + ]); + + if (!mounted) return; + + if (configData) { + setConfig(configData); + } + + setAssetOptions((current) => { + const next = { ...current }; + optionEntries.forEach(({ key, options }) => { + if (options) { + next[key] = options; + } + }); + return next; + }); + if (loadingIconOptions) { + setLoadingIconUploadOptions(loadingIconOptions.filter((option) => !option.is_default)); + } + setLoading(false); + }; + + loadBrandData(); + + return () => { + mounted = false; + }; + }, []); + + const handleAssetUploaded = (key: BrandAssetKey, asset: BrandAsset) => { + setAssetOptions((current) => { + const uploadedOption: BrandAssetOption = { ...asset, is_default: false }; + const uploadedValue = getAssetValue(uploadedOption); + return { + ...current, + [key]: [ + uploadedOption, + ...(current[key] || []).filter((option) => getAssetValue(option) !== uploadedValue), + ], + }; + }); + }; + + const handleAssetDeleted = ( + key: BrandAssetKey, + deletedAsset: BrandAssetOption, + fallbackAsset: BrandAsset, + ) => { + const deletedValue = getAssetValue(deletedAsset); + setAssetOptions((current) => ({ + ...current, + [key]: (current[key] || []).filter((option) => getAssetValue(option) !== deletedValue), + })); + setConfig((current) => { + if (getAssetValue(current.assets[key]) !== deletedValue) { + return current; + } + return { + ...current, + assets: { + ...current.assets, + [key]: fallbackAsset, + }, + }; + }); + }; + + const loadingIconAsset = useMemo(() => ( + config.loading.icon || { + url: config.URLLoadingIcon || DEFAULT_BRAND_CONFIG.URLLoadingIcon || "", + relative_path: "", + file_name: config.URLLoadingIcon ? "" : t("theme.brandDefaultLoadingIcon"), + } + ), [config.URLLoadingIcon, config.loading.icon, t]); + const defaultLoadingAsset = useMemo(() => DEFAULT_BRAND_CONFIG.loading.icon || { + url: DEFAULT_BRAND_CONFIG.URLLoadingIcon || "", + relative_path: "", + file_name: "default-loading-icon", + }, []); + const loadingIconOptions = useMemo(() => ( + mergeAssetOptions([ + { + ...defaultLoadingAsset, + file_name: t("theme.brandDefaultLoadingIcon"), + is_default: true, + }, + ...loadingIconUploadOptions.map((option) => ({ ...option, is_default: false })), + ...(config.loading.iconOptions || []).map((option) => ({ ...option, is_default: false })), + ]) + ), [config.loading.iconOptions, defaultLoadingAsset, loadingIconUploadOptions, t]); + const handleTextChange = (key: BrandTextKey, value: BrandText) => { + setConfig((current) => ({ + ...current, + [key]: value, + })); + }; + + const handleAssetChange = (key: BrandAssetKey, asset: BrandAsset) => { + setConfig((current) => ({ + ...current, + assets: { + ...current.assets, + [key]: asset, + }, + })); + }; + + const handleLoadingIconChange = (asset: BrandAsset) => { + setConfig((current) => ({ + ...current, + URLLoadingIcon: asset.url, + loading: { + ...current.loading, + icon: asset.url ? asset : null, + iconOptions: asset.url && !asset.relative_path + ? upsertAssetOption(current.loading.iconOptions, asset) + : current.loading.iconOptions, + }, + })); + }; + + const handleLoadingIconUploaded = (asset: BrandAsset) => { + setLoadingIconUploadOptions((current) => { + const uploadedOption: BrandAssetOption = { ...asset, is_default: false }; + const uploadedValue = getAssetValue(uploadedOption); + return [ + uploadedOption, + ...current.filter((option) => getAssetValue(option) !== uploadedValue), + ]; + }); + }; + + const handleLoadingUrlAdded = (asset: BrandAsset) => { + setConfig((current) => ({ + ...current, + loading: { + ...current.loading, + iconOptions: upsertAssetOption(current.loading.iconOptions, asset), + }, + })); + }; + + const handleLoadingIconDeleted = ( + deletedAsset: BrandAssetOption, + fallbackAsset: BrandAsset, + ) => { + const deletedValue = getAssetValue(deletedAsset); + if (deletedAsset.relative_path) { + setLoadingIconUploadOptions((current) => ( + current.filter((option) => getAssetValue(option) !== deletedValue) + )); + } + + setConfig((current) => { + const currentValue = getAssetValue(current.loading.icon) || current.URLLoadingIcon || ""; + const shouldResetCurrent = currentValue === deletedValue; + return { + ...current, + URLLoadingIcon: shouldResetCurrent ? fallbackAsset.url : current.URLLoadingIcon, + loading: { + ...current.loading, + icon: shouldResetCurrent ? (fallbackAsset.url ? fallbackAsset : null) : current.loading.icon, + iconOptions: (current.loading.iconOptions || []).filter((option) => ( + getAssetValue(option) !== deletedValue + )), + }, + }; + }); + }; + + const handleAnimationChange = (animation: BrandConfig["loading"]["animation"]) => { + setConfig((current) => ({ + ...current, + loading: { + ...current.loading, + animation, + }, + })); + }; + + const handleReset = () => { + setConfig(cloneBrandConfig(DEFAULT_BRAND_CONFIG)); + }; + + const handleSave = async () => { + setSaving(true); + const saved = await captureAndAlertRequestErrorHoc(saveBrandConfigApi(normalizeForSave(config))); + setSaving(false); + if (!saved) return; + + setConfig(saved); + applyRuntimeBrandConfig(saved); + toast({ + title: t("prompt"), + variant: "success", + description: t("theme.brandSaveSuccess"), + }); + }; + + return ( +
+
+
+
+

{t("theme.brandCustomization")}

+
+ + + + {t("theme.brandSave")} + +
+
+ +
+

{t("theme.brandTextSection")}

+
+
+ + {t("theme.brandChineseColumn")} + {t("theme.brandEnglishColumn")} + +
+ {TEXT_ROWS.map(({ key, labelKey, descKey }) => ( + handleTextChange(key, value)} + onPreview={() => setPreviewTarget(key)} + /> + ))} +
+
+ +
+

{t("theme.brandAssetsSection")}

+
+ {ASSET_ROWS.map(({ key, labelKey, specKey }) => ( + handleAssetChange(key, asset)} + onPreview={() => setPreviewTarget(key)} + onUploaded={(asset) => handleAssetUploaded(key, asset)} + onDeleted={(deletedAsset, fallbackAsset) => handleAssetDeleted(key, deletedAsset, fallbackAsset)} + /> + ))} +
+
+ +
+

{t("theme.brandLoadingSection")}

+ } + previewActive={previewTarget === "loadingIcon"} + onChange={handleLoadingIconChange} + onPreview={() => setPreviewTarget("loadingIcon")} + onUploaded={handleLoadingIconUploaded} + onDeleted={handleLoadingIconDeleted} + onUrlAdded={handleLoadingUrlAdded} + /> +
+ + + +
+
+
+ + +
+
+ ); +} diff --git a/src/frontend/platform/src/pages/SystemPage/theme/BrandPreviewPanel.tsx b/src/frontend/platform/src/pages/SystemPage/theme/BrandPreviewPanel.tsx new file mode 100644 index 0000000000..a0660690f7 --- /dev/null +++ b/src/frontend/platform/src/pages/SystemPage/theme/BrandPreviewPanel.tsx @@ -0,0 +1,292 @@ +import DefaultLoadingIcon from "@/components/bs-icons/loading/Loading.svg?react"; +import type { BrandAsset, BrandConfig } from "@/controllers/API"; +import { withBrandBaseUrl } from "@/utils/brand"; +import { Monitor } from "lucide-react"; +import type { ReactNode } from "react"; +import { useTranslation } from "react-i18next"; +import type { BrandAssetKey, BrandPreviewTarget } from "./brandTypes"; +import { DEFAULT_BRAND_CONFIG } from "./brandTypes"; + +interface BrandPreviewPanelProps { + config: BrandConfig; + target: BrandPreviewTarget | null; + dark: boolean; +} + +const PREVIEW_LABEL_KEYS: Record = { + brandName: "theme.brandSystemName", + favicon: "theme.brandFavicon", + loginHeroLight: "theme.brandLoginHeroLight", + loginHeroDark: "theme.brandLoginHeroDark", + headerLogoLight: "theme.brandHeaderLogoLight", + headerLogoDark: "theme.brandHeaderLogoDark", + loadingIcon: "theme.brandLoadingIcon", + loadingAnimation: "theme.brandLoadingAnimation", +}; + +const cx = (...classes: Array) => ( + classes.filter(Boolean).join(" ") +); + +const getImageUrl = (asset: BrandAsset | undefined, fallback: string) => ( + withBrandBaseUrl(asset?.url || fallback) +); + +const getLocalizedText = (text?: { zh?: string; en?: string }) => ( + text?.zh || text?.en || "" +); + +function Highlight({ + active, + children, + className = "", +}: { + active: boolean; + children: ReactNode; + className?: string; +}) { + return ( +
+ {children} +
+ ); +} + +function SkeletonLine({ className = "" }: { className?: string }) { + return
; +} + +function MockBrowserFrame({ + title, + faviconUrl, + target, + children, +}: { + title: string; + faviconUrl: string; + target: BrandPreviewTarget; + children: ReactNode; +}) { + return ( +
+
+ + + + +
+ {title} +
+
+
+ + + +
+
+ {children} +
+ ); +} + +function LoginPreview({ + config, + target, +}: { + config: BrandConfig; + target: BrandPreviewTarget; +}) { + const isDarkHero = target === "loginHeroDark" || target === "headerLogoDark"; + const heroKey: BrandAssetKey = isDarkHero ? "loginHeroDark" : "loginHeroLight"; + const logoKey: BrandAssetKey = target === "headerLogoDark" ? "headerLogoDark" : "headerLogoLight"; + const faviconUrl = getImageUrl(config.assets.favicon, DEFAULT_BRAND_CONFIG.assets.favicon.url); + const title = getLocalizedText(config.brandName) || "BISHENG"; + + return ( + +
+ + + +
+
+ + + + +

{title}

+
+
+ + + +
+
+
+
+
+ + ); +} + +function AdminPreview({ + config, + target, +}: { + config: BrandConfig; + target: BrandPreviewTarget; +}) { + const logoKey: BrandAssetKey = target === "headerLogoDark" ? "headerLogoDark" : "headerLogoLight"; + const faviconUrl = getImageUrl(config.assets.favicon, DEFAULT_BRAND_CONFIG.assets.favicon.url); + const title = getLocalizedText(config.brandName) || "BISHENG"; + + return ( + +
+
+
+ + + +
+ {Array.from({ length: 7 }).map((_, index) => ( +
+ + +
+ ))} +
+
+
+
+ + +
+
+ +
+ + +
+ +
+
+
+
+
+ ); +} + +function LoadingPreview({ + config, + target, +}: { + config: BrandConfig; + target: BrandPreviewTarget; +}) { + const faviconUrl = getImageUrl(config.assets.favicon, DEFAULT_BRAND_CONFIG.assets.favicon.url); + const title = getLocalizedText(config.brandName) || "BISHENG"; + const loadingIconUrl = withBrandBaseUrl(config.URLLoadingIcon || config.loading?.icon?.url || ""); + + return ( + +
+
+ {Array.from({ length: 6 }).map((_, index) => ( + + ))} +
+ + {loadingIconUrl ? ( + + ) : ( + + )} + +
+
+ ); +} + +function EmptyPreview() { + const { t } = useTranslation(); + return ( +
+
+
+
+ +
+
+

{t("theme.brandPreviewEmpty")}

+
+ +
+ +
+ + + +
+
+
+
+
+ ); +} + +export default function BrandPreviewPanel({ config, target, dark }: BrandPreviewPanelProps) { + const { t } = useTranslation(); + const resolvedTarget = target; + + const renderPreview = () => { + if (!resolvedTarget) return ; + if (resolvedTarget === "loadingIcon" || resolvedTarget === "loadingAnimation") { + return ; + } + if (resolvedTarget === "headerLogoLight" || resolvedTarget === "headerLogoDark") { + return ; + } + const loginTargets: BrandPreviewTarget[] = ["brandName", "favicon", "loginHeroLight", "loginHeroDark"]; + if (loginTargets.includes(resolvedTarget)) { + return ; + } + return ; + }; + + return ( + + ); +} diff --git a/src/frontend/platform/src/pages/SystemPage/theme/brandTypes.ts b/src/frontend/platform/src/pages/SystemPage/theme/brandTypes.ts new file mode 100644 index 0000000000..92319a3433 --- /dev/null +++ b/src/frontend/platform/src/pages/SystemPage/theme/brandTypes.ts @@ -0,0 +1,60 @@ +import type { BrandAssetOption, BrandConfig } from "@/controllers/API"; + +export const BRAND_ASSET_ACCEPT = ".ico,.png,.jpg,.jpeg,.svg,.gif,.webp"; + +export type BrandAssetKey = keyof BrandConfig["assets"]; +export type BrandTextKey = "brandName"; +export type BrandPreviewTarget = BrandTextKey | BrandAssetKey | "loadingIcon" | "loadingAnimation"; + +export const DEFAULT_BRAND_CONFIG: BrandConfig = { + brandName: { zh: "BISHENG", en: "BISHENG" }, + linsightAgentName: { zh: "灵思", en: "Linsight" }, + assets: { + favicon: { + url: "/assets/bisheng/favicon.ico", + relative_path: "", + file_name: "", + }, + loginHeroLight: { + url: "/assets/bisheng/login-logo-big.png", + relative_path: "", + file_name: "", + }, + loginHeroDark: { + url: "/assets/bisheng/login-logo-dark.png", + relative_path: "", + file_name: "", + }, + headerLogoLight: { + url: "/assets/bisheng/login-logo-small.png", + relative_path: "", + file_name: "", + }, + headerLogoDark: { + url: "/assets/bisheng/logo-small-dark.png", + relative_path: "", + file_name: "", + }, + }, + loading: { + icon: null, + iconOptions: [], + animation: "", + }, + URLLoadingIcon: "", +}; + +export const cloneBrandConfig = (config: BrandConfig): BrandConfig => ( + JSON.parse(JSON.stringify(config)) +); + +export const buildDefaultAssetOptions = (): Record => ( + Object.entries(DEFAULT_BRAND_CONFIG.assets).reduce((result, [key, asset]) => ({ + ...result, + [key]: [{ + ...asset, + file_name: asset.url.split("/").pop() || asset.url, + is_default: true, + }], + }), {} as Record) +); diff --git a/src/frontend/platform/src/pages/SystemPage/theme/index.tsx b/src/frontend/platform/src/pages/SystemPage/theme/index.tsx index 27a1c910b0..8b63dc7ab8 100644 --- a/src/frontend/platform/src/pages/SystemPage/theme/index.tsx +++ b/src/frontend/platform/src/pages/SystemPage/theme/index.tsx @@ -7,6 +7,8 @@ import HSLitem from "./HSLitem"; import { RadioGroup, RadioGroupItem } from "@/components/bs-ui/radio"; import { Label } from "@/components/bs-ui/label"; import { useTranslation } from "react-i18next"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/bs-ui/tabs"; +import BrandCustomization from "./BrandCustomization"; // Default theme configuration const defaultTheme = { @@ -58,7 +60,7 @@ const themeKeys = { '--black-button': 'theme.blackButton', }; -export default function Theme() { +function ThemeColorSettings() { const [theme, setTheme] = useState(Object.keys(window.ThemeStyle.comp).length ? window.ThemeStyle.comp : { ...defaultTheme }); const [bg, setBg] = useState(window.ThemeStyle.bg || 'logo') const { t } = useTranslation() @@ -132,3 +134,22 @@ export default function Theme() {
}; + +export default function Theme() { + const { t } = useTranslation(); + + return ( + + + {t("system.themeColor")} + {t("theme.brandCustomization")} + + + + + + + + + ); +} diff --git a/src/frontend/platform/src/types/global.d.ts b/src/frontend/platform/src/types/global.d.ts index d4b62f0608..919dde23a5 100644 --- a/src/frontend/platform/src/types/global.d.ts +++ b/src/frontend/platform/src/types/global.d.ts @@ -9,10 +9,21 @@ declare global { BRAND_CONFIG?: { brandName?: { zh?: string; en?: string }; linsightAgentName?: { zh?: string; en?: string }; - linsightFullName?: { zh?: string; en?: string }; - dailyFullName?: { zh?: string; en?: string }; loadingIcon?: string; + URLLoadingIcon?: string; loadingAnimation?: string; + loading?: { + icon?: { url?: string; relative_path?: string; file_name?: string } | null; + iconOptions?: Array<{ url?: string; relative_path?: string; file_name?: string }>; + animation?: string; + }; + assets?: { + favicon?: { url?: string }; + loginHeroLight?: { url?: string }; + loginHeroDark?: { url?: string }; + headerLogoLight?: { url?: string }; + headerLogoDark?: { url?: string }; + }; }; /** Runtime app config injected by public/assets/bisheng/config.js. */ APP_CONFIG?: { diff --git a/src/frontend/platform/src/utils/brand.ts b/src/frontend/platform/src/utils/brand.ts new file mode 100644 index 0000000000..c59a44eb6d --- /dev/null +++ b/src/frontend/platform/src/utils/brand.ts @@ -0,0 +1,29 @@ +type BrandAssetKey = + | "favicon" + | "loginHeroLight" + | "loginHeroDark" + | "headerLogoLight" + | "headerLogoDark"; + +const ABSOLUTE_URL_PATTERN = /^(https?:|data:|blob:)/i; + +export function withBrandBaseUrl(url = "") { + if (!url) return ""; + if (ABSOLUTE_URL_PATTERN.test(url)) return url; + const baseUrl = (__APP_ENV__.BASE_URL || "").replace(/\/$/, ""); + const normalizedUrl = url.startsWith("/") ? url : `/${url}`; + return `${baseUrl}${normalizedUrl}`; +} + +export function getBrandAssetUrl(key: BrandAssetKey, fallback: string) { + const configuredUrl = window.BRAND_CONFIG?.assets?.[key]?.url || ""; + return withBrandBaseUrl(configuredUrl || fallback); +} + +export function getBrandLoadingIconUrl() { + return withBrandBaseUrl( + window.BRAND_CONFIG?.URLLoadingIcon + || window.BRAND_CONFIG?.loadingIcon + || "", + ); +}