From d2645589da6cea2514a3d02a3d6f149d09835281 Mon Sep 17 00:00:00 2001 From: sagit <36596628+Sagit-chu@users.noreply.github.com> Date: Fri, 27 Feb 2026 11:50:03 +0800 Subject: [PATCH] feat(frontend): add customizable favicon and corner logo with live preview (#218) * feat(frontend): add customizable favicon and corner logo with live preview - Add app_logo and app_favicon config fields in config page - Implement BrandLogo component with URL fallback to SVG logo - Integrate BrandLogo into all layouts (admin, h5, h5-simple) and navbar - Add real-time preview for favicon and logo in config page with error state - Support both relative paths and full image URLs for branding assets - Sync branding changes (app_name/app_logo/app_favicon) to site config on save * fix(frontend): preserve tunnel node selection order * fix(select): use useMemo for option label map to improve performance --- vite-frontend/src/App.tsx | 22 +-- vite-frontend/src/components/brand-logo.tsx | 79 ++++++++ vite-frontend/src/components/navbar.tsx | 4 +- vite-frontend/src/config/site.ts | 51 +++++- vite-frontend/src/layouts/admin.tsx | 4 +- vite-frontend/src/layouts/h5-simple.tsx | 4 +- vite-frontend/src/layouts/h5.tsx | 4 +- vite-frontend/src/pages/config.tsx | 140 +++++++++++++-- vite-frontend/src/pages/tunnel.tsx | 170 ++++++++---------- .../src/shadcn-bridge/heroui/select.tsx | 13 +- 10 files changed, 345 insertions(+), 146 deletions(-) create mode 100644 vite-frontend/src/components/brand-logo.tsx diff --git a/vite-frontend/src/App.tsx b/vite-frontend/src/App.tsx index a882f13..75d28c4 100644 --- a/vite-frontend/src/App.tsx +++ b/vite-frontend/src/App.tsx @@ -18,7 +18,7 @@ import AdminLayout from "@/layouts/admin"; import H5Layout from "@/layouts/h5"; import H5SimpleLayout from "@/layouts/h5-simple"; import { isLoggedIn } from "@/utils/auth"; -import { siteConfig } from "@/config/site"; +import { siteConfig, updateSiteConfig } from "@/config/site"; import { useH5Mode } from "@/hooks/useH5Mode"; // 简化的路由保护组件 - 使用 React Router 导航避免循环 @@ -90,23 +90,17 @@ function App() { useEffect(() => { document.title = siteConfig.name; - // 异步检查是否有配置更新 - const checkTitleUpdate = async () => { - try { - // 引入必要的函数 - const { getCachedConfig } = await import("@/config/site"); - const cachedAppName = await getCachedConfig("app_name"); + void updateSiteConfig(); - if (cachedAppName && cachedAppName !== document.title) { - document.title = cachedAppName; - } - } catch {} + const handleConfigUpdate = () => { + void updateSiteConfig(); }; - // 延迟检查,避免阻塞初始渲染 - const timer = setTimeout(checkTitleUpdate, 100); + window.addEventListener("configUpdated", handleConfigUpdate); - return () => clearTimeout(timer); + return () => { + window.removeEventListener("configUpdated", handleConfigUpdate); + }; }, []); return ( diff --git a/vite-frontend/src/components/brand-logo.tsx b/vite-frontend/src/components/brand-logo.tsx new file mode 100644 index 0000000..3adc7bb --- /dev/null +++ b/vite-frontend/src/components/brand-logo.tsx @@ -0,0 +1,79 @@ +import { useEffect, useState } from "react"; + +import { siteConfig, getCachedConfigs } from "@/config/site"; +import { Logo } from "@/components/icons"; + +type BrandLogoProps = { + size?: number; + className?: string; + alt?: string; +}; + +export const BrandLogo = ({ + size = 24, + className, + alt = "brand logo", +}: BrandLogoProps) => { + const [logoUrl, setLogoUrl] = useState(siteConfig.app_logo || ""); + const [imageFailed, setImageFailed] = useState(false); + + useEffect(() => { + let mounted = true; + + const syncLogo = async () => { + try { + const configMap = await getCachedConfigs(); + const nextLogo = ( + configMap.app_logo || + siteConfig.app_logo || + "" + ).trim(); + + if (!mounted) { + return; + } + + siteConfig.app_logo = nextLogo; + setLogoUrl(nextLogo); + setImageFailed(false); + } catch { + if (!mounted) { + return; + } + setLogoUrl((siteConfig.app_logo || "").trim()); + } + }; + + void syncLogo(); + + const handleConfigUpdate = () => { + const nextLogo = (siteConfig.app_logo || "").trim(); + + setLogoUrl(nextLogo); + setImageFailed(false); + void syncLogo(); + }; + + window.addEventListener("configUpdated", handleConfigUpdate); + + return () => { + mounted = false; + window.removeEventListener("configUpdated", handleConfigUpdate); + }; + }, []); + + if (!logoUrl || imageFailed) { + return ; + } + + return ( + {alt} setImageFailed(true)} + /> + ); +}; diff --git a/vite-frontend/src/components/navbar.tsx b/vite-frontend/src/components/navbar.tsx index 04a0a61..a3e51b6 100644 --- a/vite-frontend/src/components/navbar.tsx +++ b/vite-frontend/src/components/navbar.tsx @@ -7,7 +7,7 @@ import { NavbarBrand, NavbarContent, } from "@/shadcn-bridge/heroui/navbar"; -import { Logo } from "@/components/icons"; +import { BrandLogo } from "@/components/brand-logo"; import { siteConfig, getCachedConfig } from "@/config/site"; import { useWebViewMode } from "@/hooks/useWebViewMode"; @@ -69,7 +69,7 @@ export const Navbar = () => { color="foreground" href="/" > - +

{appName}

diff --git a/vite-frontend/src/config/site.ts b/vite-frontend/src/config/site.ts index 46f87f9..5cc2eaa 100644 --- a/vite-frontend/src/config/site.ts +++ b/vite-frontend/src/config/site.ts @@ -16,10 +16,15 @@ const getInitialConfig = () => { version: VERSION, app_version: APP_VERSION, github_repo: GITHUB_REPO, + app_logo: "", + app_favicon: "", }; } const cachedAppName = localStorage.getItem(CACHE_PREFIX + "app_name"); + const cachedAppLogo = localStorage.getItem(CACHE_PREFIX + "app_logo") || ""; + const cachedAppFavicon = + localStorage.getItem(CACHE_PREFIX + "app_favicon") || ""; if (cachedAppName) { return { @@ -27,6 +32,8 @@ const getInitialConfig = () => { version: VERSION, app_version: APP_VERSION, github_repo: GITHUB_REPO, + app_logo: cachedAppLogo, + app_favicon: cachedAppFavicon, }; } @@ -34,6 +41,9 @@ const getInitialConfig = () => { name: "FLVX", version: VERSION, app_version: APP_VERSION, + github_repo: GITHUB_REPO, + app_logo: cachedAppLogo, + app_favicon: cachedAppFavicon, }; }; @@ -99,7 +109,7 @@ export const getCachedConfig = async (key: string): Promise => { // 获取所有配置(优先从缓存) export const getCachedConfigs = async (): Promise> => { // 尝试从缓存获取所有配置 - const configKeys = ["app_name"]; + const configKeys = ["app_name", "app_logo", "app_favicon"]; const cachedConfigs: Record = {}; let hasCachedData = false; @@ -136,15 +146,48 @@ export const getCachedConfigs = async (): Promise> => { return {}; }; +const updateDocumentFavicon = (faviconUrl: string) => { + if (typeof document === "undefined") { + return; + } + + const normalized = faviconUrl.trim() || "/favicon.ico"; + const iconLinks = Array.from( + document.head.querySelectorAll( + 'link[rel="icon"], link[rel="shortcut icon"]', + ), + ); + + if (iconLinks.length === 0) { + const link = document.createElement("link"); + + link.rel = "icon"; + link.href = normalized; + document.head.appendChild(link); + + return; + } + + iconLinks.forEach((link) => { + link.href = normalized; + }); +}; + // 动态更新网站配置 export const updateSiteConfig = async () => { - const appName = await getCachedConfig("app_name"); + const configMap = await getCachedConfigs(); + const appName = (configMap.app_name || "").trim(); + const appLogo = (configMap.app_logo || "").trim(); + const appFavicon = (configMap.app_favicon || "").trim(); if (appName && appName !== siteConfig.name) { siteConfig.name = appName; - // 更新页面标题 - document.title = appName; } + + siteConfig.app_logo = appLogo; + siteConfig.app_favicon = appFavicon; + document.title = siteConfig.name; + updateDocumentFavicon(siteConfig.app_favicon); }; // 清除配置缓存的工具函数 diff --git a/vite-frontend/src/layouts/admin.tsx b/vite-frontend/src/layouts/admin.tsx index 85ca556..71cf98e 100644 --- a/vite-frontend/src/layouts/admin.tsx +++ b/vite-frontend/src/layouts/admin.tsx @@ -19,7 +19,7 @@ import { useDisclosure, } from "@/shadcn-bridge/heroui/modal"; import { Input } from "@/shadcn-bridge/heroui/input"; -import { Logo } from "@/components/icons"; +import { BrandLogo } from "@/components/brand-logo"; import { VersionFooter } from "@/components/version-footer"; import { updatePassword } from "@/api"; import { safeLogout } from "@/utils/logout"; @@ -330,7 +330,7 @@ export default function AdminLayout({ {/* Logo 区域 */}
- +
- +

{siteConfig.name}

diff --git a/vite-frontend/src/layouts/h5.tsx b/vite-frontend/src/layouts/h5.tsx index d7ab83c..4f3bcaa 100644 --- a/vite-frontend/src/layouts/h5.tsx +++ b/vite-frontend/src/layouts/h5.tsx @@ -1,7 +1,7 @@ import React, { useState, useEffect } from "react"; import { useNavigate, useLocation } from "react-router-dom"; -import { Logo } from "@/components/icons"; +import { BrandLogo } from "@/components/brand-logo"; import { siteConfig } from "@/config/site"; import { getAdminFlag } from "@/utils/session"; import { useScrollTopOnPathChange } from "@/hooks/useScrollTopOnPathChange"; @@ -102,7 +102,7 @@ export default function H5Layout({ children }: { children: React.ReactNode }) { {/* 顶部导航栏 */}
- +

{siteConfig.name}

diff --git a/vite-frontend/src/pages/config.tsx b/vite-frontend/src/pages/config.tsx index 2be307a..2d734d1 100644 --- a/vite-frontend/src/pages/config.tsx +++ b/vite-frontend/src/pages/config.tsx @@ -67,6 +67,13 @@ interface ConfigItem { dependsValue?: string; // 依赖的配置项值 } +const BRAND_PREVIEW_KEYS = ["app_logo", "app_favicon"] as const; + +type BrandPreviewKey = (typeof BRAND_PREVIEW_KEYS)[number]; + +const isBrandPreviewKey = (key: string): key is BrandPreviewKey => + BRAND_PREVIEW_KEYS.includes(key as BrandPreviewKey); + // 网站配置项定义 const CONFIG_ITEMS: ConfigItem[] = [ { @@ -91,6 +98,22 @@ const CONFIG_ITEMS: ConfigItem[] = [ description: "在浏览器标签页和导航栏显示的应用名称", type: "input", }, + { + key: "app_logo", + label: "网页角标 Logo", + placeholder: "请输入 Logo 图片 URL", + description: + "用于页面左上角导航角标,支持站内路径(如 /logo.png)或完整图片 URL", + type: "input", + }, + { + key: "app_favicon", + label: "浏览器缩略图标", + placeholder: "请输入 Favicon 图片 URL", + description: + "用于浏览器标签页图标,支持站内路径(如 /favicon.ico)或完整图片 URL", + type: "input", + }, { key: "captcha_enabled", label: "启用验证码", @@ -143,6 +166,8 @@ const getInitialConfigs = (): Record => { "cloudflare_secret_key", "ip", "panel_domain", + "app_logo", + "app_favicon", ]; const initialConfigs: Record = {}; @@ -190,6 +215,9 @@ export default function ConfigPage() { const [updateChannel, setUpdateChannel] = useState( getUpdateReleaseChannel(), ); + const [previewLoadFailed, setPreviewLoadFailed] = useState< + Partial> + >({}); // 权限检查 useEffect(() => { @@ -288,6 +316,10 @@ export default function ConfigPage() { setConfigs(newConfigs); + if (isBrandPreviewKey(key)) { + setPreviewLoadFailed((prev) => ({ ...prev, [key]: false })); + } + const hasChangesNow = Object.keys(newConfigs).some( (k) => newConfigs[k] !== originalConfigs[k], @@ -319,8 +351,11 @@ export default function ConfigPage() { setOriginalConfigs({ ...configs }); setHasChanges(false); - // 如果应用名称发生变化,立即更新网站配置 - if (changedKeys.includes("app_name")) { + if ( + changedKeys.some((key) => + ["app_name", "app_logo", "app_favicon"].includes(key), + ) + ) { await updateSiteConfig(); } @@ -349,6 +384,78 @@ export default function ConfigPage() { return configs[item.dependsOn] === item.dependsValue; }; + const renderBrandPreview = (key: BrandPreviewKey) => { + const previewUrl = (configs[key] || "").trim(); + const appNamePreview = (configs.app_name || "").trim() || "应用名称"; + const failed = previewLoadFailed[key] === true; + const showImage = previewUrl.length > 0 && !failed; + + return ( +
+

实时预览

+
+ {key === "app_logo" ? ( +
+ {showImage ? ( + logo preview + setPreviewLoadFailed((prev) => ({ ...prev, [key]: true })) + } + onLoad={() => + setPreviewLoadFailed((prev) => ({ ...prev, [key]: false })) + } + /> + ) : ( +
+ LOGO +
+ )} + + {appNamePreview} + +
+ ) : ( +
+ {showImage ? ( + favicon preview + setPreviewLoadFailed((prev) => ({ ...prev, [key]: true })) + } + onLoad={() => + setPreviewLoadFailed((prev) => ({ ...prev, [key]: false })) + } + /> + ) : ( +
+ )} + + {appNamePreview} + +
+ )} +
+ + {previewUrl.length === 0 ? ( +

+ 输入图片 URL 后会实时显示预览 +

+ ) : null} + + {previewUrl.length > 0 && failed ? ( +

+ 图片加载失败,请检查图片地址是否可访问 +

+ ) : null} +
+ ); + }; + // 渲染不同类型的配置项 const renderConfigItem = (item: ConfigItem) => { const isChanged = @@ -357,19 +464,22 @@ export default function ConfigPage() { switch (item.type) { case "input": return ( - handleConfigChange(item.key, e.target.value)} - /> + <> + handleConfigChange(item.key, e.target.value)} + /> + {isBrandPreviewKey(item.key) ? renderBrandPreview(item.key) : null} + ); case "switch": diff --git a/vite-frontend/src/pages/tunnel.tsx b/vite-frontend/src/pages/tunnel.tsx index 95fbec8..237f4c2 100644 --- a/vite-frontend/src/pages/tunnel.tsx +++ b/vite-frontend/src/pages/tunnel.tsx @@ -293,37 +293,10 @@ export default function TunnelPage() { })); }; - // 添加节点到指定的转发链跳数 - const addNodeToChain = (groupIndex: number, nodeId: number) => { - setForm((prev) => { - const chainNodes = [...(prev.chainNodes || [])]; - const group = chainNodes[groupIndex] || []; - - // 获取当前组的策略和协议 - const strategy = group.length > 0 ? group[0].strategy : "round"; - const protocol = group.length > 0 ? group[0].protocol : "tls"; - - // 添加节点到该组 - chainNodes[groupIndex] = [ - ...group, - { nodeId, chainType: 2, protocol, strategy }, - ]; - - return { ...prev, chainNodes }; - }); - }; - - // 从某一跳删除指定节点 - const removeNodeFromChain = (groupIndex: number, nodeId: number) => { - setForm((prev) => { - const chainNodes = [...(prev.chainNodes || [])]; - - chainNodes[groupIndex] = (chainNodes[groupIndex] || []).filter( - (node) => node.nodeId !== nodeId, - ); - - return { ...prev, chainNodes }; - }); + const toSelectedNodeIds = (keys: Iterable): number[] => { + return Array.from(keys) + .map((key) => Number.parseInt(String(key), 10)) + .filter((nodeId) => Number.isFinite(nodeId)); }; // 更新某一跳的所有节点的协议 @@ -381,6 +354,36 @@ export default function TunnelPage() { return [...kept, ...added]; }; + const syncChainGroupNodes = ( + groupIndex: number, + selectedNodeIds: number[], + ) => { + setForm((prev) => { + const chainNodes = [...(prev.chainNodes || [])]; + const currentGroup = chainNodes[groupIndex] || []; + const protocol = currentGroup[0]?.protocol || "tls"; + const strategy = currentGroup[0]?.strategy || "round"; + const realNodes = currentGroup.filter((node) => node.nodeId !== -1); + const mergedNodes = mergeOrderedNodes( + realNodes, + selectedNodeIds, + (nodeId) => ({ + nodeId, + chainType: 2, + protocol, + strategy, + }), + ); + + chainNodes[groupIndex] = + mergedNodes.length > 0 + ? mergedNodes + : [{ nodeId: -1, chainType: 2, protocol, strategy }]; + + return { ...prev, chainNodes }; + }); + }; + // 提交表单 const handleSubmit = async () => { if (!validateForm()) return; @@ -1253,16 +1256,16 @@ export default function TunnelPage() { selectionMode="multiple" variant="bordered" onSelectionChange={(keys) => { - const selectedIds = Array.from(keys).map((key) => - parseInt(key as string), - ); - const newInNodeId = mergeOrderedNodes( - form.inNodeId, - selectedIds, - (nodeId) => ({ nodeId, chainType: 1 }), - ); + const selectedIds = toSelectedNodeIds(keys); - setForm((prev) => ({ ...prev, inNodeId: newInNodeId })); + setForm((prev) => ({ + ...prev, + inNodeId: mergeOrderedNodes( + prev.inNodeId, + selectedIds, + (nodeId) => ({ nodeId, chainType: 1 }), + ), + })); }} > {nodes.map((node) => ( @@ -1431,37 +1434,10 @@ export default function TunnelPage() { size="sm" variant="bordered" onSelectionChange={(keys) => { - const selectedIds = Array.from( - keys, - ).map((key) => parseInt(key as string)); - const currentNodes = groupNodes.filter( - (ct) => ct.nodeId !== -1, + syncChainGroupNodes( + groupIndex, + toSelectedNodeIds(keys), ); - - // 找出新增的节点 - const currentNodeIds = currentNodes.map( - (ct) => ct.nodeId, - ); - const addedIds = selectedIds.filter( - (id) => !currentNodeIds.includes(id), - ); - const removedIds = - currentNodeIds.filter( - (id) => !selectedIds.includes(id), - ); - - // 添加新节点 - addedIds.forEach((nodeId) => { - addNodeToChain(groupIndex, nodeId); - }); - - // 删除取消选择的节点 - removedIds.forEach((nodeId) => { - removeNodeFromChain( - groupIndex, - nodeId, - ); - }); }} > {nodes.map((node) => ( @@ -1652,38 +1628,32 @@ export default function TunnelPage() { selectionMode="multiple" variant="bordered" onSelectionChange={(keys) => { - const selectedIds = Array.from(keys).map((key) => - parseInt(key as string), - ); - const currentOutNodes = form.outNodeId || []; + const selectedIds = toSelectedNodeIds(keys); - let protocol = "tls"; - let strategy = "round"; + setForm((prev) => { + const currentOutNodes = prev.outNodeId || []; + const protocol = + currentOutNodes[0]?.protocol || "tls"; + const strategy = + currentOutNodes[0]?.strategy || "round"; + const realNodes = currentOutNodes.filter( + (ct) => ct.nodeId !== -1, + ); - if (currentOutNodes.length > 0) { - protocol = currentOutNodes[0].protocol || "tls"; - strategy = - currentOutNodes[0].strategy || "round"; - } - - const realNodes = currentOutNodes.filter( - (ct) => ct.nodeId !== -1, - ); - const newOutNodeId = mergeOrderedNodes( - realNodes, - selectedIds, - (nodeId) => ({ - nodeId, - chainType: 3, - protocol, - strategy, - }), - ); - - setForm((prev) => ({ - ...prev, - outNodeId: newOutNodeId, - })); + return { + ...prev, + outNodeId: mergeOrderedNodes( + realNodes, + selectedIds, + (nodeId) => ({ + nodeId, + chainType: 3, + protocol, + strategy, + }), + ), + }; + }); }} > {nodes.map((node) => ( diff --git a/vite-frontend/src/shadcn-bridge/heroui/select.tsx b/vite-frontend/src/shadcn-bridge/heroui/select.tsx index 49e0b46..4f5a7d5 100644 --- a/vite-frontend/src/shadcn-bridge/heroui/select.tsx +++ b/vite-frontend/src/shadcn-bridge/heroui/select.tsx @@ -228,11 +228,14 @@ export function Select({ const selectedArray = Array.from(selected); const singleValue = selectedArray[0] ?? ""; - const selectedLabels = options - .filter((option) => selected.has(option.key)) - .map((option) => option.label); - const resolvedSelectedValues = - selectedLabels.length > 0 ? selectedLabels : selectedArray; + const optionLabelMap = React.useMemo(() => { + return new Map(options.map((option) => [option.key, option.label])); + }, [options]); + const resolvedSelectedValues = selectedArray.map((key) => { + const keyText = String(key); + + return optionLabelMap.get(keyText) ?? keyText; + }); const selectedFullText = resolvedSelectedValues.join("、"); const selectedText = selectedArray.length > 0 ? selectedFullText : (placeholder ?? "请选择");