mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-09-29 07:56:37 +08:00
feat: add brand asset upload with PNG conversion and improve config validation (#220)
## Summary - Add file upload support for logo and favicon with automatic PNG conversion (96x96 for logo, 64x64 for favicon) - 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 compatibility - 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 ## Changes - Backend: Validate brand asset data URLs, support larger config values - Frontend: File upload with PNG conversion, improved caching - Migration: PostgreSQL vite_config.value column type migration ## Testing - Backend contract tests added for migration v3
This commit is contained in:
@@ -3,6 +3,7 @@ package handler
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -72,6 +73,11 @@ type flowItem struct {
|
||||
D int64 `json:"d"`
|
||||
}
|
||||
|
||||
const (
|
||||
pngDataURLPrefix = "data:image/png;base64,"
|
||||
maxBrandAssetDataURLBytes = 1024 * 1024
|
||||
)
|
||||
|
||||
func New(repo *repo.Repository, jwtSecret string) *Handler {
|
||||
h := &Handler{
|
||||
repo: repo,
|
||||
@@ -747,7 +753,14 @@ func (h *Handler) updateConfigs(w http.ResponseWriter, r *http.Request) {
|
||||
if key == "" {
|
||||
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()))
|
||||
return
|
||||
}
|
||||
@@ -767,16 +780,24 @@ func (h *Handler) updateSingleConfig(w http.ResponseWriter, r *http.Request) {
|
||||
response.WriteJSON(w, response.ErrDefault("配置名称不能为空"))
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.Name) == "" {
|
||||
name := strings.TrimSpace(req.Name)
|
||||
if name == "" {
|
||||
response.WriteJSON(w, response.ErrDefault("配置名称不能为空"))
|
||||
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("配置值不能为空"))
|
||||
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()))
|
||||
return
|
||||
}
|
||||
@@ -784,6 +805,37 @@ func (h *Handler) updateSingleConfig(w http.ResponseWriter, r *http.Request) {
|
||||
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) {
|
||||
if r.Method != http.MethodPost {
|
||||
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||
|
||||
@@ -214,7 +214,7 @@ func (GroupPermissionGrant) TableName() string { return "group_permission_grant"
|
||||
type ViteConfig struct {
|
||||
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
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"`
|
||||
}
|
||||
|
||||
|
||||
@@ -2482,9 +2482,10 @@ func (r *Repository) GetUserTunnelByID(id int64) (*model.UserTunnel, error) {
|
||||
|
||||
// ─── Migration ───────────────────────────────────────────────────────
|
||||
|
||||
const currentSchemaVersion = 2
|
||||
const currentSchemaVersion = 3
|
||||
|
||||
var ensurePostgresIDDefaultsFn = ensurePostgresIDDefaults
|
||||
var migrateViteConfigValueColumnTypeFn = migrateViteConfigValueColumnType
|
||||
|
||||
func getSchemaVersion(db *gorm.DB) int {
|
||||
var v model.SchemaVersion
|
||||
@@ -2536,10 +2537,55 @@ func migrateSchema(db *gorm.DB) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if ver < 3 {
|
||||
if err := migrateViteConfigValueColumnTypeFn(db); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
setSchemaVersion(db, currentSchemaVersion)
|
||||
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 {
|
||||
if db.Dialector.Name() != "postgres" {
|
||||
return nil
|
||||
|
||||
@@ -83,3 +83,95 @@ func TestMigrateSchemaReturnsPostgresIDRepairError(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/png" href="/pwa-192x192.png" />
|
||||
<meta name="theme-color" content="#2563eb" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
@@ -13,6 +12,47 @@
|
||||
<script>
|
||||
// 防止暗黑模式闪烁:在页面加载前立即应用系统主题
|
||||
(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;
|
||||
|
||||
@@ -156,7 +196,6 @@
|
||||
content="viewport-fit=cover, width=device-width, initial-scale=1.0, user-scalable=no"
|
||||
name="viewport"
|
||||
/>
|
||||
<link href="/favicon.ico" rel="icon" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -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