mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-10-02 08:56:38 +08:00
feat: add brand asset upload with PNG conversion and improve config validation
- Add file upload support for logo and favicon with automatic PNG conversion - Add backend validation for brand asset data URLs (app_logo, app_favicon) - Change vite_config.value column type from varchar(200) to text for PostgreSQL - Add schema migration v3 for vite_config.value column type conversion - Update frontend to use file picker instead of manual URL input - Add early favicon application in index.html to prevent flash
This commit is contained in:
@@ -6,6 +6,8 @@ export type SiteConfig = typeof siteConfig;
|
||||
const CACHE_PREFIX = "vite_config_";
|
||||
const VERSION = import.meta.env.VITE_APP_VERSION || "dev";
|
||||
const APP_VERSION = "1.0.3";
|
||||
const DEFAULT_FAVICON = "/favicon.ico";
|
||||
const FAVICON_LINK_ID = "app-favicon";
|
||||
const GITHUB_REPO =
|
||||
import.meta.env.VITE_GITHUB_REPO || "https://github.com/Sagit-chu/flux-panel";
|
||||
|
||||
@@ -95,7 +97,11 @@ export const getCachedConfig = async (key: string): Promise<string | null> => {
|
||||
|
||||
const response = await getConfigByName(key);
|
||||
|
||||
if (response.code === 0 && response.data?.value) {
|
||||
if (
|
||||
response.code === 0 &&
|
||||
response.data &&
|
||||
typeof response.data.value === "string"
|
||||
) {
|
||||
const value = response.data.value;
|
||||
|
||||
configCache.set(key, value);
|
||||
@@ -151,34 +157,62 @@ const updateDocumentFavicon = (faviconUrl: string) => {
|
||||
return;
|
||||
}
|
||||
|
||||
const normalized = faviconUrl.trim() || "/favicon.ico";
|
||||
const iconLinks = Array.from(
|
||||
document.head.querySelectorAll<HTMLLinkElement>(
|
||||
'link[rel="icon"], link[rel="shortcut icon"]',
|
||||
),
|
||||
const normalized = faviconUrl.trim() || DEFAULT_FAVICON;
|
||||
|
||||
let iconLink = document.head.querySelector<HTMLLinkElement>(
|
||||
`link#${FAVICON_LINK_ID}`,
|
||||
);
|
||||
|
||||
if (iconLinks.length === 0) {
|
||||
const link = document.createElement("link");
|
||||
|
||||
link.rel = "icon";
|
||||
link.href = normalized;
|
||||
document.head.appendChild(link);
|
||||
|
||||
return;
|
||||
if (!iconLink) {
|
||||
iconLink = document.createElement("link");
|
||||
iconLink.id = FAVICON_LINK_ID;
|
||||
iconLink.rel = "icon";
|
||||
document.head.appendChild(iconLink);
|
||||
}
|
||||
|
||||
iconLinks.forEach((link) => {
|
||||
link.href = normalized;
|
||||
});
|
||||
iconLink.rel = "icon";
|
||||
iconLink.href = normalized;
|
||||
if (normalized.startsWith("data:image/png")) {
|
||||
iconLink.type = "image/png";
|
||||
} else {
|
||||
iconLink.removeAttribute("type");
|
||||
}
|
||||
|
||||
let shortcutIconLink = document.head.querySelector<HTMLLinkElement>(
|
||||
'link[rel="shortcut icon"]',
|
||||
);
|
||||
|
||||
if (!shortcutIconLink) {
|
||||
shortcutIconLink = document.createElement("link");
|
||||
shortcutIconLink.rel = "shortcut icon";
|
||||
document.head.appendChild(shortcutIconLink);
|
||||
}
|
||||
|
||||
shortcutIconLink.href = normalized;
|
||||
if (normalized.startsWith("data:image/png")) {
|
||||
shortcutIconLink.type = "image/png";
|
||||
} else {
|
||||
shortcutIconLink.removeAttribute("type");
|
||||
}
|
||||
|
||||
const duplicatedIcons = Array.from(
|
||||
document.head.querySelectorAll<HTMLLinkElement>('link[rel="icon"]'),
|
||||
).filter((link) => link !== iconLink);
|
||||
|
||||
duplicatedIcons.forEach((link) => link.remove());
|
||||
};
|
||||
|
||||
// 动态更新网站配置
|
||||
export const updateSiteConfig = async () => {
|
||||
const configMap = await getCachedConfigs();
|
||||
const appName = (configMap.app_name || "").trim();
|
||||
const appLogo = (configMap.app_logo || "").trim();
|
||||
const appFavicon = (configMap.app_favicon || "").trim();
|
||||
export const updateSiteConfig = async (configMap?: Record<string, string>) => {
|
||||
const resolvedConfigMap = configMap ?? (await getCachedConfigs());
|
||||
|
||||
Object.entries(resolvedConfigMap).forEach(([key, value]) => {
|
||||
configCache.set(key, String(value));
|
||||
});
|
||||
|
||||
const appName = (resolvedConfigMap.app_name || "").trim();
|
||||
const appLogo = (resolvedConfigMap.app_logo || "").trim();
|
||||
const appFavicon = (resolvedConfigMap.app_favicon || "").trim();
|
||||
|
||||
if (appName && appName !== siteConfig.name) {
|
||||
siteConfig.name = appName;
|
||||
@@ -186,14 +220,13 @@ export const updateSiteConfig = async () => {
|
||||
|
||||
siteConfig.app_logo = appLogo;
|
||||
siteConfig.app_favicon = appFavicon;
|
||||
document.title = siteConfig.name;
|
||||
if (typeof document !== "undefined") {
|
||||
document.title = siteConfig.name;
|
||||
}
|
||||
updateDocumentFavicon(siteConfig.app_favicon);
|
||||
};
|
||||
|
||||
// 清除配置缓存的工具函数
|
||||
// 缓存清除时机:
|
||||
// 1. 配置更新时:调用此函数清除所有缓存
|
||||
// 2. 退出登录时:safeLogout()中的localStorage.clear()会自动清除
|
||||
// 清除配置缓存的工具函数(用于需要强制重拉配置的场景)
|
||||
export const clearConfigCache = (keys?: string[]) => {
|
||||
if (keys && keys.length > 0) {
|
||||
// 删除指定的配置缓存
|
||||
@@ -206,8 +239,13 @@ export const clearConfigCache = (keys?: string[]) => {
|
||||
|
||||
// 在页面加载时异步更新配置(如果有更新的话)
|
||||
if (typeof window !== "undefined") {
|
||||
if (typeof document !== "undefined") {
|
||||
document.title = siteConfig.name;
|
||||
}
|
||||
updateDocumentFavicon(siteConfig.app_favicon);
|
||||
|
||||
// 延迟执行,避免阻塞初始渲染
|
||||
setTimeout(() => {
|
||||
updateSiteConfig();
|
||||
}, 200);
|
||||
void updateSiteConfig();
|
||||
}, 50);
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ import { SettingsIcon } from "@/components/icons";
|
||||
import { isAdmin } from "@/utils/auth";
|
||||
import {
|
||||
getCachedConfigs,
|
||||
clearConfigCache,
|
||||
configCache,
|
||||
updateSiteConfig,
|
||||
} from "@/config/site";
|
||||
import {
|
||||
@@ -38,6 +38,11 @@ import {
|
||||
getUpdateReleaseChannel,
|
||||
setUpdateReleaseChannel,
|
||||
} from "@/utils/version-update";
|
||||
import {
|
||||
convertBrandAssetToPngDataURL,
|
||||
isPngDataURL,
|
||||
type BrandAssetKind,
|
||||
} from "@/utils/brand-asset";
|
||||
|
||||
// 简单的保存图标组件
|
||||
const SaveIcon = ({ className }: { className?: string }) => (
|
||||
@@ -74,6 +79,12 @@ type BrandPreviewKey = (typeof BRAND_PREVIEW_KEYS)[number];
|
||||
const isBrandPreviewKey = (key: string): key is BrandPreviewKey =>
|
||||
BRAND_PREVIEW_KEYS.includes(key as BrandPreviewKey);
|
||||
|
||||
const BRAND_FILE_ACCEPT = "image/png,image/jpeg,image/webp,image/svg+xml";
|
||||
|
||||
const toBrandAssetKind = (key: BrandPreviewKey): BrandAssetKind => {
|
||||
return key === "app_logo" ? "logo" : "favicon";
|
||||
};
|
||||
|
||||
// 网站配置项定义
|
||||
const CONFIG_ITEMS: ConfigItem[] = [
|
||||
{
|
||||
@@ -101,17 +112,15 @@ const CONFIG_ITEMS: ConfigItem[] = [
|
||||
{
|
||||
key: "app_logo",
|
||||
label: "网页角标 Logo",
|
||||
placeholder: "请输入 Logo 图片 URL",
|
||||
description:
|
||||
"用于页面左上角导航角标,支持站内路径(如 /logo.png)或完整图片 URL",
|
||||
"用于页面左上角导航角标,上传后会自动转换为 PNG 并持久化保存",
|
||||
type: "input",
|
||||
},
|
||||
{
|
||||
key: "app_favicon",
|
||||
label: "浏览器缩略图标",
|
||||
placeholder: "请输入 Favicon 图片 URL",
|
||||
description:
|
||||
"用于浏览器标签页图标,支持站内路径(如 /favicon.ico)或完整图片 URL",
|
||||
"用于浏览器标签页图标,上传后会自动转换为 PNG 并持久化保存",
|
||||
type: "input",
|
||||
},
|
||||
{
|
||||
@@ -204,7 +213,9 @@ export default function ConfigPage() {
|
||||
const [exportSelectorOpen, setExportSelectorOpen] = useState(false);
|
||||
const [importSelectorOpen, setImportSelectorOpen] = useState(false);
|
||||
const [importFileName, setImportFileName] = useState("");
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const backupFileInputRef = useRef<HTMLInputElement>(null);
|
||||
const logoFileInputRef = useRef<HTMLInputElement>(null);
|
||||
const faviconFileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [announcement, setAnnouncement] = useState<AnnouncementData>({
|
||||
content: "",
|
||||
@@ -218,6 +229,9 @@ export default function ConfigPage() {
|
||||
const [previewLoadFailed, setPreviewLoadFailed] = useState<
|
||||
Partial<Record<BrandPreviewKey, boolean>>
|
||||
>({});
|
||||
const [brandUploading, setBrandUploading] = useState<
|
||||
Partial<Record<BrandPreviewKey, boolean>>
|
||||
>({});
|
||||
|
||||
// 权限检查
|
||||
useEffect(() => {
|
||||
@@ -335,18 +349,30 @@ export default function ConfigPage() {
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const response = await updateConfigs(configs);
|
||||
const changedKeys = Object.keys(configs).filter(
|
||||
(key) => configs[key] !== originalConfigs[key],
|
||||
);
|
||||
|
||||
if (changedKeys.length === 0) {
|
||||
setHasChanges(false);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const changedPayload: Record<string, string> = {};
|
||||
|
||||
changedKeys.forEach((key) => {
|
||||
changedPayload[key] = configs[key] || "";
|
||||
});
|
||||
|
||||
const response = await updateConfigs(changedPayload);
|
||||
|
||||
if (response.code === 0) {
|
||||
toast.success("配置保存成功");
|
||||
|
||||
// 清除所有配置缓存,强制下次重新获取
|
||||
clearConfigCache();
|
||||
|
||||
// 获取变更的配置项
|
||||
const changedKeys = Object.keys(configs).filter(
|
||||
(key) => configs[key] !== originalConfigs[key],
|
||||
);
|
||||
Object.entries(configs).forEach(([key, value]) => {
|
||||
configCache.set(key, value);
|
||||
});
|
||||
|
||||
setOriginalConfigs({ ...configs });
|
||||
setHasChanges(false);
|
||||
@@ -356,7 +382,7 @@ export default function ConfigPage() {
|
||||
["app_name", "app_logo", "app_favicon"].includes(key),
|
||||
)
|
||||
) {
|
||||
await updateSiteConfig();
|
||||
await updateSiteConfig(configs);
|
||||
}
|
||||
|
||||
// 触发配置更新事件,通知其他组件
|
||||
@@ -384,6 +410,54 @@ export default function ConfigPage() {
|
||||
return configs[item.dependsOn] === item.dependsValue;
|
||||
};
|
||||
|
||||
const getBrandInputRef = (key: BrandPreviewKey) => {
|
||||
return key === "app_logo" ? logoFileInputRef : faviconFileInputRef;
|
||||
};
|
||||
|
||||
const triggerBrandFilePicker = (key: BrandPreviewKey) => {
|
||||
if (brandUploading[key]) {
|
||||
return;
|
||||
}
|
||||
|
||||
getBrandInputRef(key).current?.click();
|
||||
};
|
||||
|
||||
const clearBrandAsset = (key: BrandPreviewKey) => {
|
||||
handleConfigChange(key, "");
|
||||
setPreviewLoadFailed((prev) => ({ ...prev, [key]: false }));
|
||||
};
|
||||
|
||||
const handleBrandFileChange = async (
|
||||
key: BrandPreviewKey,
|
||||
event: React.ChangeEvent<HTMLInputElement>,
|
||||
) => {
|
||||
const file = event.target.files?.[0];
|
||||
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
setBrandUploading((prev) => ({ ...prev, [key]: true }));
|
||||
|
||||
try {
|
||||
const pngDataURL = await convertBrandAssetToPngDataURL(
|
||||
file,
|
||||
toBrandAssetKind(key),
|
||||
);
|
||||
|
||||
handleConfigChange(key, pngDataURL);
|
||||
toast.success(key === "app_logo" ? "Logo 上传成功" : "Favicon 上传成功");
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : "图片处理失败,请重试";
|
||||
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setBrandUploading((prev) => ({ ...prev, [key]: false }));
|
||||
event.target.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
const renderBrandPreview = (key: BrandPreviewKey) => {
|
||||
const previewUrl = (configs[key] || "").trim();
|
||||
const appNamePreview = (configs.app_name || "").trim() || "应用名称";
|
||||
@@ -443,19 +517,84 @@ export default function ConfigPage() {
|
||||
|
||||
{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 className="mt-2 text-xs text-danger">图片加载失败,请重新上传</p>
|
||||
) : null}
|
||||
|
||||
{previewUrl.length > 0 && !isPngDataURL(previewUrl) ? (
|
||||
<p className="mt-2 text-xs text-warning-600 dark:text-warning-400">
|
||||
当前是旧版 URL 配置,建议重新上传图片以启用无闪烁加载
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderBrandAssetUploader = (key: BrandPreviewKey, isChanged: boolean) => {
|
||||
const value = (configs[key] || "").trim();
|
||||
const uploading = brandUploading[key] === true;
|
||||
const isLogo = key === "app_logo";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`rounded-lg border p-3 ${
|
||||
isChanged
|
||||
? "border-warning-300"
|
||||
: "border-default-200 dark:border-default-100/30"
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
accept={BRAND_FILE_ACCEPT}
|
||||
className="hidden"
|
||||
ref={getBrandInputRef(key)}
|
||||
type="file"
|
||||
onChange={(event) => {
|
||||
void handleBrandFileChange(key, event);
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
color="primary"
|
||||
isLoading={uploading}
|
||||
size="sm"
|
||||
variant="flat"
|
||||
onPress={() => triggerBrandFilePicker(key)}
|
||||
>
|
||||
{value.length > 0
|
||||
? isLogo
|
||||
? "替换 Logo"
|
||||
: "替换 Favicon"
|
||||
: isLogo
|
||||
? "上传 Logo"
|
||||
: "上传 Favicon"}
|
||||
</Button>
|
||||
<Button
|
||||
isDisabled={value.length === 0 || uploading}
|
||||
size="sm"
|
||||
variant="light"
|
||||
onPress={() => clearBrandAsset(key)}
|
||||
>
|
||||
清除
|
||||
</Button>
|
||||
<span className="text-xs text-default-500">仅支持图片文件,自动转换为 PNG</span>
|
||||
</div>
|
||||
|
||||
<p className="mt-2 text-xs text-default-500">
|
||||
{isLogo
|
||||
? "建议上传方形图片,系统会统一转换为 96x96 PNG"
|
||||
: "建议上传方形图片,系统会统一转换为 64x64 PNG"}
|
||||
</p>
|
||||
|
||||
{renderBrandPreview(key)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 渲染不同类型的配置项
|
||||
const renderConfigItem = (item: ConfigItem) => {
|
||||
const isChanged =
|
||||
@@ -463,23 +602,24 @@ export default function ConfigPage() {
|
||||
|
||||
switch (item.type) {
|
||||
case "input":
|
||||
if (isBrandPreviewKey(item.key)) {
|
||||
return renderBrandAssetUploader(item.key, isChanged);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Input
|
||||
classNames={{
|
||||
input: "text-sm",
|
||||
inputWrapper: isChanged
|
||||
? "border-warning-300 data-[hover=true]:border-warning-400"
|
||||
: "",
|
||||
}}
|
||||
placeholder={item.placeholder}
|
||||
size="md"
|
||||
value={configs[item.key] || ""}
|
||||
variant="bordered"
|
||||
onChange={(e) => handleConfigChange(item.key, e.target.value)}
|
||||
/>
|
||||
{isBrandPreviewKey(item.key) ? renderBrandPreview(item.key) : null}
|
||||
</>
|
||||
<Input
|
||||
classNames={{
|
||||
input: "text-sm",
|
||||
inputWrapper: isChanged
|
||||
? "border-warning-300 data-[hover=true]:border-warning-400"
|
||||
: "",
|
||||
}}
|
||||
placeholder={item.placeholder}
|
||||
size="md"
|
||||
value={configs[item.key] || ""}
|
||||
variant="bordered"
|
||||
onChange={(e) => handleConfigChange(item.key, e.target.value)}
|
||||
/>
|
||||
);
|
||||
|
||||
case "switch":
|
||||
@@ -560,7 +700,7 @@ export default function ConfigPage() {
|
||||
}
|
||||
|
||||
setImportSelectorOpen(false);
|
||||
requestAnimationFrame(() => fileInputRef.current?.click());
|
||||
requestAnimationFrame(() => backupFileInputRef.current?.click());
|
||||
};
|
||||
|
||||
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
@@ -597,8 +737,8 @@ export default function ConfigPage() {
|
||||
toast.error("导入失败,请检查文件格式");
|
||||
} finally {
|
||||
setImporting(false);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = "";
|
||||
if (backupFileInputRef.current) {
|
||||
backupFileInputRef.current.value = "";
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -940,7 +1080,7 @@ export default function ConfigPage() {
|
||||
</p>
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
ref={backupFileInputRef}
|
||||
accept=".json"
|
||||
className="hidden"
|
||||
type="file"
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
export type BrandAssetKind = "logo" | "favicon";
|
||||
|
||||
const MAX_BRAND_UPLOAD_BYTES = 2 * 1024 * 1024;
|
||||
const PNG_DATA_URL_PREFIX = "data:image/png;base64,";
|
||||
|
||||
const OUTPUT_SIZE: Record<BrandAssetKind, number> = {
|
||||
logo: 96,
|
||||
favicon: 64,
|
||||
};
|
||||
|
||||
const readFileAsDataURL = (file: File): Promise<string> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
|
||||
reader.onload = () => {
|
||||
if (typeof reader.result !== "string") {
|
||||
reject(new Error("读取图片失败"));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
resolve(reader.result);
|
||||
};
|
||||
|
||||
reader.onerror = () => reject(new Error("读取图片失败"));
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
};
|
||||
|
||||
const loadImage = (src: string): Promise<HTMLImageElement> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const image = new Image();
|
||||
|
||||
image.onload = () => resolve(image);
|
||||
image.onerror = () => reject(new Error("图片解析失败"));
|
||||
image.src = src;
|
||||
});
|
||||
};
|
||||
|
||||
const drawContainedPNG = (
|
||||
image: HTMLImageElement,
|
||||
size: number,
|
||||
): string | null => {
|
||||
const canvas = document.createElement("canvas");
|
||||
|
||||
canvas.width = size;
|
||||
canvas.height = size;
|
||||
|
||||
const ctx = canvas.getContext("2d");
|
||||
|
||||
if (!ctx) {
|
||||
return null;
|
||||
}
|
||||
|
||||
ctx.clearRect(0, 0, size, size);
|
||||
|
||||
const sourceWidth = image.naturalWidth || image.width;
|
||||
const sourceHeight = image.naturalHeight || image.height;
|
||||
|
||||
if (sourceWidth <= 0 || sourceHeight <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const scale = Math.min(size / sourceWidth, size / sourceHeight);
|
||||
const drawWidth = sourceWidth * scale;
|
||||
const drawHeight = sourceHeight * scale;
|
||||
const drawX = (size - drawWidth) / 2;
|
||||
const drawY = (size - drawHeight) / 2;
|
||||
|
||||
ctx.imageSmoothingEnabled = true;
|
||||
ctx.imageSmoothingQuality = "high";
|
||||
ctx.drawImage(image, drawX, drawY, drawWidth, drawHeight);
|
||||
|
||||
return canvas.toDataURL("image/png");
|
||||
};
|
||||
|
||||
export const isPngDataURL = (value: string): boolean => {
|
||||
return value.startsWith(PNG_DATA_URL_PREFIX);
|
||||
};
|
||||
|
||||
export const convertBrandAssetToPngDataURL = async (
|
||||
file: File,
|
||||
kind: BrandAssetKind,
|
||||
): Promise<string> => {
|
||||
if (!file.type.startsWith("image/")) {
|
||||
throw new Error("仅支持上传图片文件");
|
||||
}
|
||||
|
||||
if (file.size > MAX_BRAND_UPLOAD_BYTES) {
|
||||
throw new Error("图片过大,请上传 2MB 以内的文件");
|
||||
}
|
||||
|
||||
const sourceDataURL = await readFileAsDataURL(file);
|
||||
const image = await loadImage(sourceDataURL);
|
||||
const output = drawContainedPNG(image, OUTPUT_SIZE[kind]);
|
||||
|
||||
if (!output) {
|
||||
throw new Error("图片处理失败,请重试");
|
||||
}
|
||||
|
||||
return output;
|
||||
};
|
||||
Reference in New Issue
Block a user