mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-10-10 19:36:36 +08:00
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
This commit is contained in:
@@ -18,7 +18,7 @@ import AdminLayout from "@/layouts/admin";
|
|||||||
import H5Layout from "@/layouts/h5";
|
import H5Layout from "@/layouts/h5";
|
||||||
import H5SimpleLayout from "@/layouts/h5-simple";
|
import H5SimpleLayout from "@/layouts/h5-simple";
|
||||||
import { isLoggedIn } from "@/utils/auth";
|
import { isLoggedIn } from "@/utils/auth";
|
||||||
import { siteConfig } from "@/config/site";
|
import { siteConfig, updateSiteConfig } from "@/config/site";
|
||||||
import { useH5Mode } from "@/hooks/useH5Mode";
|
import { useH5Mode } from "@/hooks/useH5Mode";
|
||||||
|
|
||||||
// 简化的路由保护组件 - 使用 React Router 导航避免循环
|
// 简化的路由保护组件 - 使用 React Router 导航避免循环
|
||||||
@@ -90,23 +90,17 @@ function App() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.title = siteConfig.name;
|
document.title = siteConfig.name;
|
||||||
|
|
||||||
// 异步检查是否有配置更新
|
void updateSiteConfig();
|
||||||
const checkTitleUpdate = async () => {
|
|
||||||
try {
|
|
||||||
// 引入必要的函数
|
|
||||||
const { getCachedConfig } = await import("@/config/site");
|
|
||||||
const cachedAppName = await getCachedConfig("app_name");
|
|
||||||
|
|
||||||
if (cachedAppName && cachedAppName !== document.title) {
|
const handleConfigUpdate = () => {
|
||||||
document.title = cachedAppName;
|
void updateSiteConfig();
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 延迟检查,避免阻塞初始渲染
|
window.addEventListener("configUpdated", handleConfigUpdate);
|
||||||
const timer = setTimeout(checkTitleUpdate, 100);
|
|
||||||
|
|
||||||
return () => clearTimeout(timer);
|
return () => {
|
||||||
|
window.removeEventListener("configUpdated", handleConfigUpdate);
|
||||||
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -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<string>(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 <Logo className={className} size={size} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
alt={alt}
|
||||||
|
className={className}
|
||||||
|
height={size}
|
||||||
|
src={logoUrl}
|
||||||
|
width={size}
|
||||||
|
onError={() => setImageFailed(true)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
NavbarBrand,
|
NavbarBrand,
|
||||||
NavbarContent,
|
NavbarContent,
|
||||||
} from "@/shadcn-bridge/heroui/navbar";
|
} from "@/shadcn-bridge/heroui/navbar";
|
||||||
import { Logo } from "@/components/icons";
|
import { BrandLogo } from "@/components/brand-logo";
|
||||||
import { siteConfig, getCachedConfig } from "@/config/site";
|
import { siteConfig, getCachedConfig } from "@/config/site";
|
||||||
import { useWebViewMode } from "@/hooks/useWebViewMode";
|
import { useWebViewMode } from "@/hooks/useWebViewMode";
|
||||||
|
|
||||||
@@ -69,7 +69,7 @@ export const Navbar = () => {
|
|||||||
color="foreground"
|
color="foreground"
|
||||||
href="/"
|
href="/"
|
||||||
>
|
>
|
||||||
<Logo size={24} />
|
<BrandLogo size={24} />
|
||||||
<p className="font-bold text-inherit truncate">{appName}</p>
|
<p className="font-bold text-inherit truncate">{appName}</p>
|
||||||
</Link>
|
</Link>
|
||||||
</NavbarBrand>
|
</NavbarBrand>
|
||||||
|
|||||||
@@ -16,10 +16,15 @@ const getInitialConfig = () => {
|
|||||||
version: VERSION,
|
version: VERSION,
|
||||||
app_version: APP_VERSION,
|
app_version: APP_VERSION,
|
||||||
github_repo: GITHUB_REPO,
|
github_repo: GITHUB_REPO,
|
||||||
|
app_logo: "",
|
||||||
|
app_favicon: "",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const cachedAppName = localStorage.getItem(CACHE_PREFIX + "app_name");
|
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) {
|
if (cachedAppName) {
|
||||||
return {
|
return {
|
||||||
@@ -27,6 +32,8 @@ const getInitialConfig = () => {
|
|||||||
version: VERSION,
|
version: VERSION,
|
||||||
app_version: APP_VERSION,
|
app_version: APP_VERSION,
|
||||||
github_repo: GITHUB_REPO,
|
github_repo: GITHUB_REPO,
|
||||||
|
app_logo: cachedAppLogo,
|
||||||
|
app_favicon: cachedAppFavicon,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,6 +41,9 @@ const getInitialConfig = () => {
|
|||||||
name: "FLVX",
|
name: "FLVX",
|
||||||
version: VERSION,
|
version: VERSION,
|
||||||
app_version: APP_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<string | null> => {
|
|||||||
// 获取所有配置(优先从缓存)
|
// 获取所有配置(优先从缓存)
|
||||||
export const getCachedConfigs = async (): Promise<Record<string, string>> => {
|
export const getCachedConfigs = async (): Promise<Record<string, string>> => {
|
||||||
// 尝试从缓存获取所有配置
|
// 尝试从缓存获取所有配置
|
||||||
const configKeys = ["app_name"];
|
const configKeys = ["app_name", "app_logo", "app_favicon"];
|
||||||
const cachedConfigs: Record<string, string> = {};
|
const cachedConfigs: Record<string, string> = {};
|
||||||
let hasCachedData = false;
|
let hasCachedData = false;
|
||||||
|
|
||||||
@@ -136,15 +146,48 @@ export const getCachedConfigs = async (): Promise<Record<string, string>> => {
|
|||||||
return {};
|
return {};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const updateDocumentFavicon = (faviconUrl: string) => {
|
||||||
|
if (typeof document === "undefined") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = faviconUrl.trim() || "/favicon.ico";
|
||||||
|
const iconLinks = Array.from(
|
||||||
|
document.head.querySelectorAll<HTMLLinkElement>(
|
||||||
|
'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 () => {
|
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) {
|
if (appName && appName !== siteConfig.name) {
|
||||||
siteConfig.name = appName;
|
siteConfig.name = appName;
|
||||||
// 更新页面标题
|
|
||||||
document.title = appName;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
siteConfig.app_logo = appLogo;
|
||||||
|
siteConfig.app_favicon = appFavicon;
|
||||||
|
document.title = siteConfig.name;
|
||||||
|
updateDocumentFavicon(siteConfig.app_favicon);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 清除配置缓存的工具函数
|
// 清除配置缓存的工具函数
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import {
|
|||||||
useDisclosure,
|
useDisclosure,
|
||||||
} from "@/shadcn-bridge/heroui/modal";
|
} from "@/shadcn-bridge/heroui/modal";
|
||||||
import { Input } from "@/shadcn-bridge/heroui/input";
|
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 { VersionFooter } from "@/components/version-footer";
|
||||||
import { updatePassword } from "@/api";
|
import { updatePassword } from "@/api";
|
||||||
import { safeLogout } from "@/utils/logout";
|
import { safeLogout } from "@/utils/logout";
|
||||||
@@ -330,7 +330,7 @@ export default function AdminLayout({
|
|||||||
{/* Logo 区域 */}
|
{/* Logo 区域 */}
|
||||||
<div className="px-5 h-14 flex items-center overflow-hidden whitespace-nowrap box-border">
|
<div className="px-5 h-14 flex items-center overflow-hidden whitespace-nowrap box-border">
|
||||||
<div className="flex-shrink-0 flex items-center justify-center w-10">
|
<div className="flex-shrink-0 flex items-center justify-center w-10">
|
||||||
<Logo size={28} />
|
<BrandLogo size={28} />
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className={`transition-all duration-300 overflow-hidden ${isCollapsed ? "max-w-0 opacity-0 ml-0" : "max-w-[180px] opacity-100 ml-2"}`}
|
className={`transition-all duration-300 overflow-hidden ${isCollapsed ? "max-w-0 opacity-0 ml-0" : "max-w-[180px] opacity-100 ml-2"}`}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import React from "react";
|
|||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
import { Button } from "@/shadcn-bridge/heroui/button";
|
import { Button } from "@/shadcn-bridge/heroui/button";
|
||||||
import { Logo } from "@/components/icons";
|
import { BrandLogo } from "@/components/brand-logo";
|
||||||
import { siteConfig } from "@/config/site";
|
import { siteConfig } from "@/config/site";
|
||||||
import { useScrollTopOnPathChange } from "@/hooks/useScrollTopOnPathChange";
|
import { useScrollTopOnPathChange } from "@/hooks/useScrollTopOnPathChange";
|
||||||
|
|
||||||
@@ -33,7 +33,7 @@ export default function H5SimpleLayout({
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</Button>
|
</Button>
|
||||||
<Logo size={20} />
|
<BrandLogo size={20} />
|
||||||
<h1 className="text-sm font-bold text-foreground">
|
<h1 className="text-sm font-bold text-foreground">
|
||||||
{siteConfig.name}
|
{siteConfig.name}
|
||||||
</h1>
|
</h1>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
import { useNavigate, useLocation } from "react-router-dom";
|
import { useNavigate, useLocation } from "react-router-dom";
|
||||||
|
|
||||||
import { Logo } from "@/components/icons";
|
import { BrandLogo } from "@/components/brand-logo";
|
||||||
import { siteConfig } from "@/config/site";
|
import { siteConfig } from "@/config/site";
|
||||||
import { getAdminFlag } from "@/utils/session";
|
import { getAdminFlag } from "@/utils/session";
|
||||||
import { useScrollTopOnPathChange } from "@/hooks/useScrollTopOnPathChange";
|
import { useScrollTopOnPathChange } from "@/hooks/useScrollTopOnPathChange";
|
||||||
@@ -102,7 +102,7 @@ export default function H5Layout({ children }: { children: React.ReactNode }) {
|
|||||||
{/* 顶部导航栏 */}
|
{/* 顶部导航栏 */}
|
||||||
<header className="bg-white dark:bg-black shadow-sm border-b border-gray-200 dark:border-gray-600 h-14 safe-top flex-shrink-0 flex items-center justify-between px-4 relative z-10">
|
<header className="bg-white dark:bg-black shadow-sm border-b border-gray-200 dark:border-gray-600 h-14 safe-top flex-shrink-0 flex items-center justify-between px-4 relative z-10">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Logo size={20} />
|
<BrandLogo size={20} />
|
||||||
<h1 className="text-sm font-bold text-foreground">
|
<h1 className="text-sm font-bold text-foreground">
|
||||||
{siteConfig.name}
|
{siteConfig.name}
|
||||||
</h1>
|
</h1>
|
||||||
|
|||||||
@@ -67,6 +67,13 @@ interface ConfigItem {
|
|||||||
dependsValue?: string; // 依赖的配置项值
|
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[] = [
|
const CONFIG_ITEMS: ConfigItem[] = [
|
||||||
{
|
{
|
||||||
@@ -91,6 +98,22 @@ const CONFIG_ITEMS: ConfigItem[] = [
|
|||||||
description: "在浏览器标签页和导航栏显示的应用名称",
|
description: "在浏览器标签页和导航栏显示的应用名称",
|
||||||
type: "input",
|
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",
|
key: "captcha_enabled",
|
||||||
label: "启用验证码",
|
label: "启用验证码",
|
||||||
@@ -143,6 +166,8 @@ const getInitialConfigs = (): Record<string, string> => {
|
|||||||
"cloudflare_secret_key",
|
"cloudflare_secret_key",
|
||||||
"ip",
|
"ip",
|
||||||
"panel_domain",
|
"panel_domain",
|
||||||
|
"app_logo",
|
||||||
|
"app_favicon",
|
||||||
];
|
];
|
||||||
const initialConfigs: Record<string, string> = {};
|
const initialConfigs: Record<string, string> = {};
|
||||||
|
|
||||||
@@ -190,6 +215,9 @@ export default function ConfigPage() {
|
|||||||
const [updateChannel, setUpdateChannel] = useState<UpdateReleaseChannel>(
|
const [updateChannel, setUpdateChannel] = useState<UpdateReleaseChannel>(
|
||||||
getUpdateReleaseChannel(),
|
getUpdateReleaseChannel(),
|
||||||
);
|
);
|
||||||
|
const [previewLoadFailed, setPreviewLoadFailed] = useState<
|
||||||
|
Partial<Record<BrandPreviewKey, boolean>>
|
||||||
|
>({});
|
||||||
|
|
||||||
// 权限检查
|
// 权限检查
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -288,6 +316,10 @@ export default function ConfigPage() {
|
|||||||
|
|
||||||
setConfigs(newConfigs);
|
setConfigs(newConfigs);
|
||||||
|
|
||||||
|
if (isBrandPreviewKey(key)) {
|
||||||
|
setPreviewLoadFailed((prev) => ({ ...prev, [key]: false }));
|
||||||
|
}
|
||||||
|
|
||||||
const hasChangesNow =
|
const hasChangesNow =
|
||||||
Object.keys(newConfigs).some(
|
Object.keys(newConfigs).some(
|
||||||
(k) => newConfigs[k] !== originalConfigs[k],
|
(k) => newConfigs[k] !== originalConfigs[k],
|
||||||
@@ -319,8 +351,11 @@ export default function ConfigPage() {
|
|||||||
setOriginalConfigs({ ...configs });
|
setOriginalConfigs({ ...configs });
|
||||||
setHasChanges(false);
|
setHasChanges(false);
|
||||||
|
|
||||||
// 如果应用名称发生变化,立即更新网站配置
|
if (
|
||||||
if (changedKeys.includes("app_name")) {
|
changedKeys.some((key) =>
|
||||||
|
["app_name", "app_logo", "app_favicon"].includes(key),
|
||||||
|
)
|
||||||
|
) {
|
||||||
await updateSiteConfig();
|
await updateSiteConfig();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -349,6 +384,78 @@ export default function ConfigPage() {
|
|||||||
return configs[item.dependsOn] === item.dependsValue;
|
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 (
|
||||||
|
<div className="mt-3 rounded-lg border border-default-200 dark:border-default-100/30 bg-default-50/60 dark:bg-default-100/10 p-3">
|
||||||
|
<p className="text-xs text-default-500">实时预览</p>
|
||||||
|
<div className="mt-2 rounded-md border border-default-200 dark:border-default-100/30 bg-white dark:bg-black px-3 py-2">
|
||||||
|
{key === "app_logo" ? (
|
||||||
|
<div className="flex h-10 items-center gap-2">
|
||||||
|
{showImage ? (
|
||||||
|
<img
|
||||||
|
alt="logo preview"
|
||||||
|
className="h-7 w-7 rounded-sm border border-default-200 object-cover dark:border-default-100/30"
|
||||||
|
src={previewUrl}
|
||||||
|
onError={() =>
|
||||||
|
setPreviewLoadFailed((prev) => ({ ...prev, [key]: true }))
|
||||||
|
}
|
||||||
|
onLoad={() =>
|
||||||
|
setPreviewLoadFailed((prev) => ({ ...prev, [key]: false }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-7 w-7 items-center justify-center rounded-sm bg-default-200 text-[10px] font-semibold text-default-600 dark:bg-default-700 dark:text-default-200">
|
||||||
|
LOGO
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<span className="truncate text-sm font-semibold text-foreground">
|
||||||
|
{appNamePreview}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-7 max-w-[260px] items-center gap-2 rounded border border-default-200 bg-default-100/70 px-2 dark:border-default-100/30 dark:bg-default-100/20">
|
||||||
|
{showImage ? (
|
||||||
|
<img
|
||||||
|
alt="favicon preview"
|
||||||
|
className="h-4 w-4 rounded-sm object-contain"
|
||||||
|
src={previewUrl}
|
||||||
|
onError={() =>
|
||||||
|
setPreviewLoadFailed((prev) => ({ ...prev, [key]: true }))
|
||||||
|
}
|
||||||
|
onLoad={() =>
|
||||||
|
setPreviewLoadFailed((prev) => ({ ...prev, [key]: false }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="h-4 w-4 rounded-sm bg-default-300 dark:bg-default-600" />
|
||||||
|
)}
|
||||||
|
<span className="truncate text-xs text-default-700 dark:text-default-300">
|
||||||
|
{appNamePreview}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{previewUrl.length === 0 ? (
|
||||||
|
<p className="mt-2 text-xs text-default-500">
|
||||||
|
输入图片 URL 后会实时显示预览
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{previewUrl.length > 0 && failed ? (
|
||||||
|
<p className="mt-2 text-xs text-danger">
|
||||||
|
图片加载失败,请检查图片地址是否可访问
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// 渲染不同类型的配置项
|
// 渲染不同类型的配置项
|
||||||
const renderConfigItem = (item: ConfigItem) => {
|
const renderConfigItem = (item: ConfigItem) => {
|
||||||
const isChanged =
|
const isChanged =
|
||||||
@@ -357,19 +464,22 @@ export default function ConfigPage() {
|
|||||||
switch (item.type) {
|
switch (item.type) {
|
||||||
case "input":
|
case "input":
|
||||||
return (
|
return (
|
||||||
<Input
|
<>
|
||||||
classNames={{
|
<Input
|
||||||
input: "text-sm",
|
classNames={{
|
||||||
inputWrapper: isChanged
|
input: "text-sm",
|
||||||
? "border-warning-300 data-[hover=true]:border-warning-400"
|
inputWrapper: isChanged
|
||||||
: "",
|
? "border-warning-300 data-[hover=true]:border-warning-400"
|
||||||
}}
|
: "",
|
||||||
placeholder={item.placeholder}
|
}}
|
||||||
size="md"
|
placeholder={item.placeholder}
|
||||||
value={configs[item.key] || ""}
|
size="md"
|
||||||
variant="bordered"
|
value={configs[item.key] || ""}
|
||||||
onChange={(e) => handleConfigChange(item.key, e.target.value)}
|
variant="bordered"
|
||||||
/>
|
onChange={(e) => handleConfigChange(item.key, e.target.value)}
|
||||||
|
/>
|
||||||
|
{isBrandPreviewKey(item.key) ? renderBrandPreview(item.key) : null}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
case "switch":
|
case "switch":
|
||||||
|
|||||||
@@ -293,37 +293,10 @@ export default function TunnelPage() {
|
|||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
// 添加节点到指定的转发链跳数
|
const toSelectedNodeIds = (keys: Iterable<unknown>): number[] => {
|
||||||
const addNodeToChain = (groupIndex: number, nodeId: number) => {
|
return Array.from(keys)
|
||||||
setForm((prev) => {
|
.map((key) => Number.parseInt(String(key), 10))
|
||||||
const chainNodes = [...(prev.chainNodes || [])];
|
.filter((nodeId) => Number.isFinite(nodeId));
|
||||||
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 };
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 更新某一跳的所有节点的协议
|
// 更新某一跳的所有节点的协议
|
||||||
@@ -381,6 +354,36 @@ export default function TunnelPage() {
|
|||||||
return [...kept, ...added];
|
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 () => {
|
const handleSubmit = async () => {
|
||||||
if (!validateForm()) return;
|
if (!validateForm()) return;
|
||||||
@@ -1253,16 +1256,16 @@ export default function TunnelPage() {
|
|||||||
selectionMode="multiple"
|
selectionMode="multiple"
|
||||||
variant="bordered"
|
variant="bordered"
|
||||||
onSelectionChange={(keys) => {
|
onSelectionChange={(keys) => {
|
||||||
const selectedIds = Array.from(keys).map((key) =>
|
const selectedIds = toSelectedNodeIds(keys);
|
||||||
parseInt(key as string),
|
|
||||||
);
|
|
||||||
const newInNodeId = mergeOrderedNodes(
|
|
||||||
form.inNodeId,
|
|
||||||
selectedIds,
|
|
||||||
(nodeId) => ({ nodeId, chainType: 1 }),
|
|
||||||
);
|
|
||||||
|
|
||||||
setForm((prev) => ({ ...prev, inNodeId: newInNodeId }));
|
setForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
inNodeId: mergeOrderedNodes(
|
||||||
|
prev.inNodeId,
|
||||||
|
selectedIds,
|
||||||
|
(nodeId) => ({ nodeId, chainType: 1 }),
|
||||||
|
),
|
||||||
|
}));
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{nodes.map((node) => (
|
{nodes.map((node) => (
|
||||||
@@ -1431,37 +1434,10 @@ export default function TunnelPage() {
|
|||||||
size="sm"
|
size="sm"
|
||||||
variant="bordered"
|
variant="bordered"
|
||||||
onSelectionChange={(keys) => {
|
onSelectionChange={(keys) => {
|
||||||
const selectedIds = Array.from(
|
syncChainGroupNodes(
|
||||||
keys,
|
groupIndex,
|
||||||
).map((key) => parseInt(key as string));
|
toSelectedNodeIds(keys),
|
||||||
const currentNodes = groupNodes.filter(
|
|
||||||
(ct) => ct.nodeId !== -1,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// 找出新增的节点
|
|
||||||
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) => (
|
{nodes.map((node) => (
|
||||||
@@ -1652,38 +1628,32 @@ export default function TunnelPage() {
|
|||||||
selectionMode="multiple"
|
selectionMode="multiple"
|
||||||
variant="bordered"
|
variant="bordered"
|
||||||
onSelectionChange={(keys) => {
|
onSelectionChange={(keys) => {
|
||||||
const selectedIds = Array.from(keys).map((key) =>
|
const selectedIds = toSelectedNodeIds(keys);
|
||||||
parseInt(key as string),
|
|
||||||
);
|
|
||||||
const currentOutNodes = form.outNodeId || [];
|
|
||||||
|
|
||||||
let protocol = "tls";
|
setForm((prev) => {
|
||||||
let strategy = "round";
|
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) {
|
return {
|
||||||
protocol = currentOutNodes[0].protocol || "tls";
|
...prev,
|
||||||
strategy =
|
outNodeId: mergeOrderedNodes(
|
||||||
currentOutNodes[0].strategy || "round";
|
realNodes,
|
||||||
}
|
selectedIds,
|
||||||
|
(nodeId) => ({
|
||||||
const realNodes = currentOutNodes.filter(
|
nodeId,
|
||||||
(ct) => ct.nodeId !== -1,
|
chainType: 3,
|
||||||
);
|
protocol,
|
||||||
const newOutNodeId = mergeOrderedNodes(
|
strategy,
|
||||||
realNodes,
|
}),
|
||||||
selectedIds,
|
),
|
||||||
(nodeId) => ({
|
};
|
||||||
nodeId,
|
});
|
||||||
chainType: 3,
|
|
||||||
protocol,
|
|
||||||
strategy,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
setForm((prev) => ({
|
|
||||||
...prev,
|
|
||||||
outNodeId: newOutNodeId,
|
|
||||||
}));
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{nodes.map((node) => (
|
{nodes.map((node) => (
|
||||||
|
|||||||
@@ -228,11 +228,14 @@ export function Select<T>({
|
|||||||
|
|
||||||
const selectedArray = Array.from(selected);
|
const selectedArray = Array.from(selected);
|
||||||
const singleValue = selectedArray[0] ?? "";
|
const singleValue = selectedArray[0] ?? "";
|
||||||
const selectedLabels = options
|
const optionLabelMap = React.useMemo(() => {
|
||||||
.filter((option) => selected.has(option.key))
|
return new Map(options.map((option) => [option.key, option.label]));
|
||||||
.map((option) => option.label);
|
}, [options]);
|
||||||
const resolvedSelectedValues =
|
const resolvedSelectedValues = selectedArray.map((key) => {
|
||||||
selectedLabels.length > 0 ? selectedLabels : selectedArray;
|
const keyText = String(key);
|
||||||
|
|
||||||
|
return optionLabelMap.get(keyText) ?? keyText;
|
||||||
|
});
|
||||||
const selectedFullText = resolvedSelectedValues.join("、");
|
const selectedFullText = resolvedSelectedValues.join("、");
|
||||||
const selectedText =
|
const selectedText =
|
||||||
selectedArray.length > 0 ? selectedFullText : (placeholder ?? "请选择");
|
selectedArray.length > 0 ? selectedFullText : (placeholder ?? "请选择");
|
||||||
|
|||||||
Reference in New Issue
Block a user