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:
sagitchu
2026-02-27 15:54:04 +08:00
parent a628f31859
commit e5ce0501a2
8 changed files with 584 additions and 75 deletions
+56 -4
View File
@@ -3,6 +3,7 @@ package handler
import ( import (
"context" "context"
"database/sql" "database/sql"
"encoding/base64"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
@@ -72,6 +73,11 @@ type flowItem struct {
D int64 `json:"d"` D int64 `json:"d"`
} }
const (
pngDataURLPrefix = "data:image/png;base64,"
maxBrandAssetDataURLBytes = 1024 * 1024
)
func New(repo *repo.Repository, jwtSecret string) *Handler { func New(repo *repo.Repository, jwtSecret string) *Handler {
h := &Handler{ h := &Handler{
repo: repo, repo: repo,
@@ -747,7 +753,14 @@ func (h *Handler) updateConfigs(w http.ResponseWriter, r *http.Request) {
if key == "" { if key == "" {
continue continue
} }
if err := h.repo.UpsertConfig(key, v, now); err != nil {
value, err := normalizeAndValidateConfigValue(key, v)
if err != nil {
response.WriteJSON(w, response.ErrDefault(err.Error()))
return
}
if err := h.repo.UpsertConfig(key, value, now); err != nil {
response.WriteJSON(w, response.Err(-2, err.Error())) response.WriteJSON(w, response.Err(-2, err.Error()))
return return
} }
@@ -767,16 +780,24 @@ func (h *Handler) updateSingleConfig(w http.ResponseWriter, r *http.Request) {
response.WriteJSON(w, response.ErrDefault("配置名称不能为空")) response.WriteJSON(w, response.ErrDefault("配置名称不能为空"))
return return
} }
if strings.TrimSpace(req.Name) == "" { name := strings.TrimSpace(req.Name)
if name == "" {
response.WriteJSON(w, response.ErrDefault("配置名称不能为空")) response.WriteJSON(w, response.ErrDefault("配置名称不能为空"))
return return
} }
if strings.TrimSpace(req.Value) == "" {
value, err := normalizeAndValidateConfigValue(name, req.Value)
if err != nil {
response.WriteJSON(w, response.ErrDefault(err.Error()))
return
}
if value == "" && name != "app_logo" && name != "app_favicon" {
response.WriteJSON(w, response.ErrDefault("配置值不能为空")) response.WriteJSON(w, response.ErrDefault("配置值不能为空"))
return return
} }
if err := h.repo.UpsertConfig(strings.TrimSpace(req.Name), req.Value, time.Now().UnixMilli()); err != nil { if err := h.repo.UpsertConfig(name, value, time.Now().UnixMilli()); err != nil {
response.WriteJSON(w, response.Err(-2, err.Error())) response.WriteJSON(w, response.Err(-2, err.Error()))
return return
} }
@@ -784,6 +805,37 @@ func (h *Handler) updateSingleConfig(w http.ResponseWriter, r *http.Request) {
response.WriteJSON(w, response.OKEmpty()) response.WriteJSON(w, response.OKEmpty())
} }
func normalizeAndValidateConfigValue(key, value string) (string, error) {
switch strings.TrimSpace(key) {
case "app_logo", "app_favicon":
normalized := strings.TrimSpace(value)
if normalized == "" {
return "", nil
}
if !strings.HasPrefix(normalized, pngDataURLPrefix) {
return "", fmt.Errorf("品牌图片必须通过上传生成 PNG 数据")
}
if len(normalized) > maxBrandAssetDataURLBytes {
return "", fmt.Errorf("品牌图片过大,请上传更小图片")
}
payload := strings.TrimSpace(strings.TrimPrefix(normalized, pngDataURLPrefix))
if payload == "" {
return "", fmt.Errorf("品牌图片数据不能为空")
}
if _, err := base64.StdEncoding.DecodeString(payload); err != nil {
return "", fmt.Errorf("品牌图片数据格式无效")
}
return pngDataURLPrefix + payload, nil
default:
return value, nil
}
}
func (h *Handler) userPackage(w http.ResponseWriter, r *http.Request) { func (h *Handler) userPackage(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost { if r.Method != http.MethodPost {
response.WriteJSON(w, response.ErrDefault("请求失败")) response.WriteJSON(w, response.ErrDefault("请求失败"))
+1 -1
View File
@@ -214,7 +214,7 @@ func (GroupPermissionGrant) TableName() string { return "group_permission_grant"
type ViteConfig struct { type ViteConfig struct {
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"` ID int64 `gorm:"primaryKey;autoIncrement" json:"id"`
Name string `gorm:"type:varchar(200);not null;uniqueIndex" json:"name"` Name string `gorm:"type:varchar(200);not null;uniqueIndex" json:"name"`
Value string `gorm:"type:varchar(200);not null" json:"value"` Value string `gorm:"type:text;not null" json:"value"`
Time int64 `gorm:"not null" json:"time"` Time int64 `gorm:"not null" json:"time"`
} }
+47 -1
View File
@@ -2482,9 +2482,10 @@ func (r *Repository) GetUserTunnelByID(id int64) (*model.UserTunnel, error) {
// ─── Migration ─────────────────────────────────────────────────────── // ─── Migration ───────────────────────────────────────────────────────
const currentSchemaVersion = 2 const currentSchemaVersion = 3
var ensurePostgresIDDefaultsFn = ensurePostgresIDDefaults var ensurePostgresIDDefaultsFn = ensurePostgresIDDefaults
var migrateViteConfigValueColumnTypeFn = migrateViteConfigValueColumnType
func getSchemaVersion(db *gorm.DB) int { func getSchemaVersion(db *gorm.DB) int {
var v model.SchemaVersion var v model.SchemaVersion
@@ -2536,10 +2537,55 @@ func migrateSchema(db *gorm.DB) error {
return err return err
} }
if ver < 3 {
if err := migrateViteConfigValueColumnTypeFn(db); err != nil {
return err
}
}
setSchemaVersion(db, currentSchemaVersion) setSchemaVersion(db, currentSchemaVersion)
return nil return nil
} }
func migrateViteConfigValueColumnType(db *gorm.DB) error {
if db == nil {
return errors.New("nil db")
}
if !db.Migrator().HasTable(&model.ViteConfig{}) {
return nil
}
if db.Dialector.Name() != "postgres" {
return nil
}
type columnRow struct {
DataType string `gorm:"column:data_type"`
}
var row columnRow
if err := db.Raw(
`SELECT data_type FROM information_schema.columns
WHERE table_schema = current_schema()
AND table_name = ?
AND column_name = ?`,
"vite_config", "value",
).Scan(&row).Error; err != nil {
return fmt.Errorf("inspect vite_config.value type: %w", err)
}
if strings.EqualFold(row.DataType, "text") {
return nil
}
if err := db.Exec(`ALTER TABLE "vite_config" ALTER COLUMN "value" TYPE TEXT`).Error; err != nil {
return fmt.Errorf("alter vite_config.value to text: %w", err)
}
return nil
}
func ensurePostgresIDDefaults(db *gorm.DB) error { func ensurePostgresIDDefaults(db *gorm.DB) error {
if db.Dialector.Name() != "postgres" { if db.Dialector.Name() != "postgres" {
return nil return nil
@@ -83,3 +83,95 @@ func TestMigrateSchemaReturnsPostgresIDRepairError(t *testing.T) {
t.Fatalf("expected error %v, got %v", wantErr, err) t.Fatalf("expected error %v, got %v", wantErr, err)
} }
} }
func TestMigrateSchemaRunsViteConfigValueMigrationForLegacySchema(t *testing.T) {
db, err := gorm.Open(gsqlite.Open(":memory:"), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
t.Cleanup(func() {
sqlDB, _ := db.DB()
if sqlDB != nil {
_ = sqlDB.Close()
}
})
if err := db.Exec(`CREATE TABLE schema_version (version INTEGER NOT NULL DEFAULT 0)`).Error; err != nil {
t.Fatalf("create schema_version: %v", err)
}
if err := db.Exec(`INSERT INTO schema_version(version) VALUES(?)`, 2).Error; err != nil {
t.Fatalf("seed schema_version: %v", err)
}
originalIDRepair := ensurePostgresIDDefaultsFn
ensurePostgresIDDefaultsFn = func(db *gorm.DB) error {
return nil
}
t.Cleanup(func() {
ensurePostgresIDDefaultsFn = originalIDRepair
})
called := 0
originalMigrate := migrateViteConfigValueColumnTypeFn
migrateViteConfigValueColumnTypeFn = func(db *gorm.DB) error {
called++
return nil
}
t.Cleanup(func() {
migrateViteConfigValueColumnTypeFn = originalMigrate
})
if err := migrateSchema(db); err != nil {
t.Fatalf("migrateSchema: %v", err)
}
if called != 1 {
t.Fatalf("expected vite_config migration to run once, got %d", called)
}
}
func TestMigrateSchemaReturnsViteConfigMigrationError(t *testing.T) {
db, err := gorm.Open(gsqlite.Open(":memory:"), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
t.Cleanup(func() {
sqlDB, _ := db.DB()
if sqlDB != nil {
_ = sqlDB.Close()
}
})
if err := db.Exec(`CREATE TABLE schema_version (version INTEGER NOT NULL DEFAULT 0)`).Error; err != nil {
t.Fatalf("create schema_version: %v", err)
}
if err := db.Exec(`INSERT INTO schema_version(version) VALUES(?)`, 2).Error; err != nil {
t.Fatalf("seed schema_version: %v", err)
}
originalIDRepair := ensurePostgresIDDefaultsFn
ensurePostgresIDDefaultsFn = func(db *gorm.DB) error {
return nil
}
t.Cleanup(func() {
ensurePostgresIDDefaultsFn = originalIDRepair
})
wantErr := errors.New("vite config migration failed")
originalMigrate := migrateViteConfigValueColumnTypeFn
migrateViteConfigValueColumnTypeFn = func(db *gorm.DB) error {
return wantErr
}
t.Cleanup(func() {
migrateViteConfigValueColumnTypeFn = originalMigrate
})
err = migrateSchema(db)
if !errors.Is(err, wantErr) {
t.Fatalf("expected error %v, got %v", wantErr, err)
}
}
+41 -2
View File
@@ -2,7 +2,6 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/png" href="/pwa-192x192.png" />
<meta name="theme-color" content="#2563eb" /> <meta name="theme-color" content="#2563eb" />
<meta name="apple-mobile-web-app-capable" content="yes" /> <meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" /> <meta name="apple-mobile-web-app-status-bar-style" content="default" />
@@ -13,6 +12,47 @@
<script> <script>
// 防止暗黑模式闪烁:在页面加载前立即应用系统主题 // 防止暗黑模式闪烁:在页面加载前立即应用系统主题
(function() { (function() {
// 页面最早阶段应用缓存 favicon,避免默认图标闪烁
const defaultFavicon = '/favicon.ico';
let cachedFavicon = '';
try {
cachedFavicon = localStorage.getItem('vite_config_app_favicon') || '';
} catch (_) {
cachedFavicon = '';
}
const faviconHref = cachedFavicon.trim() || defaultFavicon;
let faviconLink = document.head.querySelector('link#app-favicon');
if (!faviconLink) {
faviconLink = document.createElement('link');
faviconLink.id = 'app-favicon';
faviconLink.rel = 'icon';
document.head.appendChild(faviconLink);
}
faviconLink.href = faviconHref;
if (faviconHref.startsWith('data:image/png')) {
faviconLink.type = 'image/png';
} else {
faviconLink.removeAttribute('type');
}
let shortcutIconLink = document.head.querySelector('link[rel="shortcut icon"]');
if (!shortcutIconLink) {
shortcutIconLink = document.createElement('link');
shortcutIconLink.rel = 'shortcut icon';
document.head.appendChild(shortcutIconLink);
}
shortcutIconLink.href = faviconHref;
if (faviconHref.startsWith('data:image/png')) {
shortcutIconLink.type = 'image/png';
} else {
shortcutIconLink.removeAttribute('type');
}
// 立即检测系统主题并应用 // 立即检测系统主题并应用
const isDarkMode = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches; const isDarkMode = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
@@ -156,7 +196,6 @@
content="viewport-fit=cover, width=device-width, initial-scale=1.0, user-scalable=no" content="viewport-fit=cover, width=device-width, initial-scale=1.0, user-scalable=no"
name="viewport" name="viewport"
/> />
<link href="/favicon.ico" rel="icon" />
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+67 -29
View File
@@ -6,6 +6,8 @@ export type SiteConfig = typeof siteConfig;
const CACHE_PREFIX = "vite_config_"; const CACHE_PREFIX = "vite_config_";
const VERSION = import.meta.env.VITE_APP_VERSION || "dev"; const VERSION = import.meta.env.VITE_APP_VERSION || "dev";
const APP_VERSION = "1.0.3"; const APP_VERSION = "1.0.3";
const DEFAULT_FAVICON = "/favicon.ico";
const FAVICON_LINK_ID = "app-favicon";
const GITHUB_REPO = const GITHUB_REPO =
import.meta.env.VITE_GITHUB_REPO || "https://github.com/Sagit-chu/flux-panel"; 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); 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; const value = response.data.value;
configCache.set(key, value); configCache.set(key, value);
@@ -151,34 +157,62 @@ const updateDocumentFavicon = (faviconUrl: string) => {
return; return;
} }
const normalized = faviconUrl.trim() || "/favicon.ico"; const normalized = faviconUrl.trim() || DEFAULT_FAVICON;
const iconLinks = Array.from(
document.head.querySelectorAll<HTMLLinkElement>( let iconLink = document.head.querySelector<HTMLLinkElement>(
'link[rel="icon"], link[rel="shortcut icon"]', `link#${FAVICON_LINK_ID}`,
),
); );
if (iconLinks.length === 0) { if (!iconLink) {
const link = document.createElement("link"); iconLink = document.createElement("link");
iconLink.id = FAVICON_LINK_ID;
link.rel = "icon"; iconLink.rel = "icon";
link.href = normalized; document.head.appendChild(iconLink);
document.head.appendChild(link);
return;
} }
iconLinks.forEach((link) => { iconLink.rel = "icon";
link.href = normalized; 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 () => { export const updateSiteConfig = async (configMap?: Record<string, string>) => {
const configMap = await getCachedConfigs(); const resolvedConfigMap = configMap ?? (await getCachedConfigs());
const appName = (configMap.app_name || "").trim();
const appLogo = (configMap.app_logo || "").trim(); Object.entries(resolvedConfigMap).forEach(([key, value]) => {
const appFavicon = (configMap.app_favicon || "").trim(); 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) { if (appName && appName !== siteConfig.name) {
siteConfig.name = appName; siteConfig.name = appName;
@@ -186,14 +220,13 @@ export const updateSiteConfig = async () => {
siteConfig.app_logo = appLogo; siteConfig.app_logo = appLogo;
siteConfig.app_favicon = appFavicon; siteConfig.app_favicon = appFavicon;
document.title = siteConfig.name; if (typeof document !== "undefined") {
document.title = siteConfig.name;
}
updateDocumentFavicon(siteConfig.app_favicon); updateDocumentFavicon(siteConfig.app_favicon);
}; };
// 清除配置缓存的工具函数 // 清除配置缓存的工具函数(用于需要强制重拉配置的场景)
// 缓存清除时机:
// 1. 配置更新时:调用此函数清除所有缓存
// 2. 退出登录时:safeLogout()中的localStorage.clear()会自动清除
export const clearConfigCache = (keys?: string[]) => { export const clearConfigCache = (keys?: string[]) => {
if (keys && keys.length > 0) { if (keys && keys.length > 0) {
// 删除指定的配置缓存 // 删除指定的配置缓存
@@ -206,8 +239,13 @@ export const clearConfigCache = (keys?: string[]) => {
// 在页面加载时异步更新配置(如果有更新的话) // 在页面加载时异步更新配置(如果有更新的话)
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
if (typeof document !== "undefined") {
document.title = siteConfig.name;
}
updateDocumentFavicon(siteConfig.app_favicon);
// 延迟执行,避免阻塞初始渲染 // 延迟执行,避免阻塞初始渲染
setTimeout(() => { setTimeout(() => {
updateSiteConfig(); void updateSiteConfig();
}, 200); }, 50);
} }
+178 -38
View File
@@ -30,7 +30,7 @@ import { SettingsIcon } from "@/components/icons";
import { isAdmin } from "@/utils/auth"; import { isAdmin } from "@/utils/auth";
import { import {
getCachedConfigs, getCachedConfigs,
clearConfigCache, configCache,
updateSiteConfig, updateSiteConfig,
} from "@/config/site"; } from "@/config/site";
import { import {
@@ -38,6 +38,11 @@ import {
getUpdateReleaseChannel, getUpdateReleaseChannel,
setUpdateReleaseChannel, setUpdateReleaseChannel,
} from "@/utils/version-update"; } from "@/utils/version-update";
import {
convertBrandAssetToPngDataURL,
isPngDataURL,
type BrandAssetKind,
} from "@/utils/brand-asset";
// 简单的保存图标组件 // 简单的保存图标组件
const SaveIcon = ({ className }: { className?: string }) => ( const SaveIcon = ({ className }: { className?: string }) => (
@@ -74,6 +79,12 @@ type BrandPreviewKey = (typeof BRAND_PREVIEW_KEYS)[number];
const isBrandPreviewKey = (key: string): key is BrandPreviewKey => const isBrandPreviewKey = (key: string): key is BrandPreviewKey =>
BRAND_PREVIEW_KEYS.includes(key as 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[] = [ const CONFIG_ITEMS: ConfigItem[] = [
{ {
@@ -101,17 +112,15 @@ const CONFIG_ITEMS: ConfigItem[] = [
{ {
key: "app_logo", key: "app_logo",
label: "网页角标 Logo", label: "网页角标 Logo",
placeholder: "请输入 Logo 图片 URL",
description: description:
"用于页面左上角导航角标,支持站内路径(如 /logo.png)或完整图片 URL", "用于页面左上角导航角标,上传后会自动转换为 PNG 并持久化保存",
type: "input", type: "input",
}, },
{ {
key: "app_favicon", key: "app_favicon",
label: "浏览器缩略图标", label: "浏览器缩略图标",
placeholder: "请输入 Favicon 图片 URL",
description: description:
"用于浏览器标签页图标,支持站内路径(如 /favicon.ico)或完整图片 URL", "用于浏览器标签页图标,上传后会自动转换为 PNG 并持久化保存",
type: "input", type: "input",
}, },
{ {
@@ -204,7 +213,9 @@ export default function ConfigPage() {
const [exportSelectorOpen, setExportSelectorOpen] = useState(false); const [exportSelectorOpen, setExportSelectorOpen] = useState(false);
const [importSelectorOpen, setImportSelectorOpen] = useState(false); const [importSelectorOpen, setImportSelectorOpen] = useState(false);
const [importFileName, setImportFileName] = useState(""); 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>({ const [announcement, setAnnouncement] = useState<AnnouncementData>({
content: "", content: "",
@@ -218,6 +229,9 @@ export default function ConfigPage() {
const [previewLoadFailed, setPreviewLoadFailed] = useState< const [previewLoadFailed, setPreviewLoadFailed] = useState<
Partial<Record<BrandPreviewKey, boolean>> Partial<Record<BrandPreviewKey, boolean>>
>({}); >({});
const [brandUploading, setBrandUploading] = useState<
Partial<Record<BrandPreviewKey, boolean>>
>({});
// 权限检查 // 权限检查
useEffect(() => { useEffect(() => {
@@ -335,18 +349,30 @@ export default function ConfigPage() {
const handleSave = async () => { const handleSave = async () => {
setSaving(true); setSaving(true);
try { 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) { if (response.code === 0) {
toast.success("配置保存成功"); toast.success("配置保存成功");
// 清除所有配置缓存,强制下次重新获取 Object.entries(configs).forEach(([key, value]) => {
clearConfigCache(); configCache.set(key, value);
});
// 获取变更的配置项
const changedKeys = Object.keys(configs).filter(
(key) => configs[key] !== originalConfigs[key],
);
setOriginalConfigs({ ...configs }); setOriginalConfigs({ ...configs });
setHasChanges(false); setHasChanges(false);
@@ -356,7 +382,7 @@ export default function ConfigPage() {
["app_name", "app_logo", "app_favicon"].includes(key), ["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; 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 renderBrandPreview = (key: BrandPreviewKey) => {
const previewUrl = (configs[key] || "").trim(); const previewUrl = (configs[key] || "").trim();
const appNamePreview = (configs.app_name || "").trim() || "应用名称"; const appNamePreview = (configs.app_name || "").trim() || "应用名称";
@@ -443,19 +517,84 @@ export default function ConfigPage() {
{previewUrl.length === 0 ? ( {previewUrl.length === 0 ? (
<p className="mt-2 text-xs text-default-500"> <p className="mt-2 text-xs text-default-500">
输入图片 URL 后会实时显示预览 上传图片后会实时显示预览
</p> </p>
) : null} ) : null}
{previewUrl.length > 0 && failed ? ( {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> </p>
) : null} ) : null}
</div> </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 renderConfigItem = (item: ConfigItem) => {
const isChanged = const isChanged =
@@ -463,23 +602,24 @@ export default function ConfigPage() {
switch (item.type) { switch (item.type) {
case "input": case "input":
if (isBrandPreviewKey(item.key)) {
return renderBrandAssetUploader(item.key, isChanged);
}
return ( return (
<> <Input
<Input classNames={{
classNames={{ input: "text-sm",
input: "text-sm", inputWrapper: isChanged
inputWrapper: isChanged ? "border-warning-300 data-[hover=true]:border-warning-400"
? "border-warning-300 data-[hover=true]:border-warning-400" : "",
: "", }}
}} placeholder={item.placeholder}
placeholder={item.placeholder} size="md"
size="md" value={configs[item.key] || ""}
value={configs[item.key] || ""} variant="bordered"
variant="bordered" onChange={(e) => handleConfigChange(item.key, e.target.value)}
onChange={(e) => handleConfigChange(item.key, e.target.value)} />
/>
{isBrandPreviewKey(item.key) ? renderBrandPreview(item.key) : null}
</>
); );
case "switch": case "switch":
@@ -560,7 +700,7 @@ export default function ConfigPage() {
} }
setImportSelectorOpen(false); setImportSelectorOpen(false);
requestAnimationFrame(() => fileInputRef.current?.click()); requestAnimationFrame(() => backupFileInputRef.current?.click());
}; };
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => { const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
@@ -597,8 +737,8 @@ export default function ConfigPage() {
toast.error("导入失败,请检查文件格式"); toast.error("导入失败,请检查文件格式");
} finally { } finally {
setImporting(false); setImporting(false);
if (fileInputRef.current) { if (backupFileInputRef.current) {
fileInputRef.current.value = ""; backupFileInputRef.current.value = "";
} }
} }
}; };
@@ -940,7 +1080,7 @@ export default function ConfigPage() {
</p> </p>
<input <input
ref={fileInputRef} ref={backupFileInputRef}
accept=".json" accept=".json"
className="hidden" className="hidden"
type="file" type="file"
+102
View 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;
};