ai-chatbot/app/(admin)/settings/admin-settings-client.tsx
Your Name 817309ade9
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
feat: add sqlserver admin connection settings
2026-03-24 17:01:50 +08:00

10537 lines
460 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import Link from "next/link";
import { useState, useEffect, useMemo } from "react";
import useSWR from "swr";
import { toast } from "sonner";
import { formatBeijingTime } from "@/lib/utils";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
DETAIL_AGENT_LOCAL_MOONSHOT_BASE_URL,
DETAIL_AGENT_LOCAL_MOONSHOT_MODEL_ID,
DETAIL_AGENT_LOCAL_MOONSHOT_OPTION_ID,
DETAIL_AGENT_LOCAL_MOONSHOT_SETTING_KEY,
normalizeDetailAgentLocalMoonshotConfig,
} from "@/lib/ai/detail-agent-model-config";
import {
defaultThirdPartySqlServerConfig,
THIRD_PARTY_SQLSERVER_CONFIG_KEY,
} from "@/lib/sqlserver/config";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Separator } from "@/components/ui/separator";
import {
Trash2,
Save,
User,
Settings,
Wrench,
Bot,
HardDrive,
Plus,
Edit,
Eye,
EyeOff,
Menu,
Key,
MessageSquare,
Bell,
Image as ImageIcon,
BarChart3,
Search,
Calendar,
TrendingUp,
Users,
Clock,
Sparkles,
RefreshCw,
Scissors,
Expand,
Move3D,
Layers,
Upload,
Video,
Loader2,
Check,
Paintbrush,
Database,
} from "lucide-react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Textarea } from "@/components/ui/textarea";
import { Switch } from "@/components/ui/switch";
import {
defaultMembershipConfig,
defaultMembershipUserOverrides,
type MembershipFaqItem,
type MembershipPlanConfig,
normalizeMembershipConfig,
normalizeMembershipUserOverrides,
} from "@/lib/membership/config";
import {
defaultPointsPackages,
defaultPointsSystemConfig,
normalizePointsPackages,
normalizePointsSystemConfig,
type PointsPackage,
type PointsSystemConfig,
} from "@/lib/points/config";
import {
defaultDulupayConfig,
normalizeDulupayConfig,
type DulupayConfig,
} from "@/lib/payment/dulupay-config";
import {
DEFAULT_CANVAS_SKILL_TAGS,
DEFAULT_CANVAS_SKILL_TAGS_TITLE,
normalizeCanvasSkillTags,
type CanvasSkillTag,
} from "@/lib/canvas-skill-tags";
import {
defaultAgentWorkflowUiConfig,
normalizeAgentWorkflowUiConfig,
} from "@/lib/agents/workflow-ui-config";
import {
CANVAS_SKILL_ICON_OPTIONS,
getCanvasSkillIconComponent,
} from "@/components/canvas/canvas-skill-icons";
import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry";
import {
isImageGenerationModelDescriptor,
isVideoModelDescriptor,
} from "@/lib/ai/model-classification";
import type { AgentWorkflowUiConfig } from "@/types/agent";
const fetcher = (url: string) => fetch(url).then((res) => res.json());
const DEFAULT_BFL_REALTIME_MODEL = "flux-2-klein-4b";
const DEFAULT_BFL_BASE_URL = "https://api.bfl.ai";
function createEmptyCanvasSkillTag(): CanvasSkillTag {
return {
id: `tag-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
name: "",
prompt: "",
placeholder: "",
iconName: "message-circle",
iconUrl: "",
};
}
// 类型定义
type User = {
id: string;
email: string;
name: string | null;
department: string | null;
role: "guest" | "regular" | "admin" | "agent_admin";
isHighVip: boolean;
isActive: boolean;
monthlyImageLimit: number | null;
pointsBalance: number;
pointsGiftedTotal: number;
createdAt: string;
};
const ROLE_LABELS: Record<User["role"], string> = {
guest: "访客用户",
regular: "普通用户",
admin: "管理员",
agent_admin: "智能体管理员",
};
type ToolConfig = {
toolName: string;
isEnabled: boolean;
description?: string;
config?: any;
};
type AiProvider = {
id: string;
name: string;
type: "alibaba" | "openai" | "custom" | "local" | "gemini" | "huanke-gemini" | "qianwen-video" | "doubao-video" | "ruxa-video" | "volcano-vision" | "volcano-video" | "alibaba-image" | "bfl-image" | "ruxa-image" | "fal-image" | "openai-image" | "midjourney" | "fal-video" | "zhipu" | "tripo-3d" | "runway" | "volcano-multimodal";
baseUrl: string;
apiKey: string;
isEnabled: boolean;
createdAt: string;
};
// 🆕 图片生成模型特性配置
type ImageGenConfig = {
maxImages?: number; // 最大输出图片数,如阿里 1-6,Gemini 1-2
supportsSize?: boolean; // 是否支持自定义分辨率
supports1K?: boolean; // 是否支持 1K 分辨率
supports2K?: boolean; // 是否支持 2K 分辨率
supports4K?: boolean; // 是否支持 4K 分辨率
supportsPromptExtend?: boolean; // 是否支持提示词智能优化
supportsNegativePrompt?: boolean; // 是否支持反向提示词
supportsWatermark?: boolean; // 是否支持水印开关
supportsSeed?: boolean; // 是否支持随机种子
creditCostPerImage?: number; // 每张图片积分消耗
creditCostPerVideo?: number; // 每条视频积分消耗
enableLoadingPlaceholder?: boolean; // 是否启用加载占位图
loadingColor?: string; // 占位图主题色,如 "purple", "blue", "green"
};
type AiModel = {
id: string;
providerId: string;
modelId: string;
displayName: string;
avatarUrl?: string;
description?: string;
supportsVision: boolean;
supportsImageGen: boolean;
isEnabled: boolean;
isHidden: boolean;
isDefault: boolean;
sortOrder: number;
fallbackModelId1?: string;
fallbackModelId2?: string;
excludeFromFailover?: boolean;
isAgentModel?: boolean;
agentCodingModelId?: string;
isMultimodal?: boolean;
imageGenConfig?: ImageGenConfig; // 🆕 图片生成特性配置
};
type StorageConfig = {
id: string;
name: string;
type: "s3" | "local" | "aliyun-oss";
endpoint?: string;
region?: string;
bucket?: string;
accessKeyId?: string;
secretAccessKey?: string;
publicUrl?: string;
isDefault: boolean;
isEnabled: boolean;
};
type SiteSettings = {
siteName: string;
siteLogo: string | null;
siteLogoDark: string | null;
favicon: string | null;
showAutoModel: boolean;
usageTimeEnabled: boolean;
usageTimeStart: string;
usageTimeEnd: string;
usageTimeMessage: string;
defaultTheme: "light" | "dark" | "system";
bannerEnabled: boolean;
bannerText: string;
bannerLink: string | null;
toolbox1Name: string;
toolbox1Enabled: boolean;
toolbox2Name: string;
toolbox2Enabled: boolean;
tutorialVideoUrl: string | null;
tutorialEnabled: boolean;
rankingEnabled: boolean;
canvasEnabled: boolean;
agentEnabled: boolean;
canvasAgentModeEnabled: boolean;
agentCategoryTreeConfig: {
productCategories: string[];
audienceTags: string[];
usageScenarios: string[];
brands: string[];
};
agentSecondaryTypeConfig: {
image: string[];
video: string[];
text: string[];
};
agentWorkflowUiConfig: AgentWorkflowUiConfig;
realtimeCanvasEnabled: boolean;
aiDetailGenerationEnabled: boolean;
canvasDefaultModelId: string | null;
canvasAutoModeDefaultModelId: string | null;
canvasDetailDefaultModelId: string | null;
canvasDetailAgentModelId: string | null;
lovartComposerPrefix: string;
lovartComposerPreviewTexts: string[];
lovartPromptTagVisibility: LovartPromptTagVisibility;
contactConfig: ContactConfig;
storageFailoverConfig: StorageFailoverConfig;
ossImageMigrationConfig: OssImageMigrationConfig;
pointsSystemConfig: PointsSystemConfig;
pointsPackages: PointsPackage[];
dulupayConfig: DulupayConfig;
lovartPromoText: string;
lovartPromoActionText: string;
lovartHeroTitle: string;
lovartHeroSubtitle: string;
homeVariant: "classic" | "lovart";
surpriseCategories: string[];
membershipConfig: ReturnType<typeof normalizeMembershipConfig>;
membershipUserOverrides: ReturnType<typeof normalizeMembershipUserOverrides>;
};
type LovartPromptTagVisibility = {
nanoBananaPro: boolean;
design: boolean;
branding: boolean;
illustration: boolean;
ecommerce: boolean;
video: boolean;
};
type ContactConfig = {
phone: string;
wechatQrUrl: string;
};
type StorageFailoverConfig = {
enabled: boolean;
backupStorageConfigId: string;
};
type OssImageMigrationConfig = {
enabled: boolean;
preferAliyunForUploads: boolean;
sourceStorageConfigId: string;
targetStorageConfigId: string;
migrateAfterHours: number;
cleanupAfterHours: number;
batchSize: number;
};
const defaultLovartPromptTagVisibility: LovartPromptTagVisibility = {
nanoBananaPro: true,
design: true,
branding: true,
illustration: true,
ecommerce: true,
video: true,
};
const defaultContactConfig: ContactConfig = {
phone: "",
wechatQrUrl: "",
};
const defaultStorageFailoverConfig: StorageFailoverConfig = {
enabled: false,
backupStorageConfigId: "",
};
const defaultOssImageMigrationConfig: OssImageMigrationConfig = {
enabled: true,
preferAliyunForUploads: true,
sourceStorageConfigId: "",
targetStorageConfigId: "",
migrateAfterHours: 48,
cleanupAfterHours: 72,
batchSize: 20,
};
const lovartPromptTagOptions: Array<{
key: keyof LovartPromptTagVisibility;
label: string;
}> = [
{ key: "nanoBananaPro", label: "Nano Banana Pro" },
{ key: "design", label: "Design" },
{ key: "branding", label: "Branding" },
{ key: "illustration", label: "Illustration" },
{ key: "ecommerce", label: "E-Commerce" },
{ key: "video", label: "Video" },
];
type NavLink = {
id: string;
name: string;
url: string;
order: number;
isEnabled: boolean;
toolboxGroup: number; // 1 或 2
};
type TutorialVideo = {
id: string;
title: string;
videoUrl: string;
coverUrl: string | null;
description: string | null;
order: number;
isEnabled: boolean;
};
type DingTalkOrganization = {
id: string;
name: string;
appKey: string;
appSecret: string;
};
type AuthSettings = {
// 保留单一配置用于兼容旧数据(后端会自动同步为第一个组织)
dingTalkAppKey?: string | null;
dingTalkAppSecret?: string | null;
googleClientId?: string | null;
googleClientSecret?: string | null;
authUrl?: string | null;
isDingTalkEnabled: boolean;
isGoogleEnabled: boolean;
isRegistrationEnabled?: boolean;
isEmailVerificationEnabled?: boolean;
emailVerificationExpiresMinutes?: number;
resendApiKey?: string | null;
resendFromEmail?: string | null;
isEmailServiceConfigured?: boolean;
dingTalkOrganizations: DingTalkOrganization[];
};
type GeneratedImageRecord = {
id: string;
sourceMessageId?: string;
userId: string;
userName: string | null;
userEmail: string;
userDepartment: string | null;
thumbnailUrl: string;
originalUrl: string;
videoUrl?: string | null;
recordType?: "image" | "video";
imageSize: string | null;
prompt: string | null;
modelId: string | null;
isFeatured: boolean;
featuredCategory: string | null;
featuredAt: string | null;
createdAt: string;
chatId: string | null;
};
type ImageAnalytics = {
overview: {
totalImages: number;
totalUsers: number;
todayImages: number;
todayUsers: number;
weekImages: number;
weekUsers: number;
monthImages: number;
monthUsers: number;
};
userStats: Array<{
userId: string;
userName: string | null;
userEmail: string;
imageCount: number;
}>;
dailyStats: Array<{ date: string; count: number }>;
sizeStats: Array<{ imageSize: string | null; count: number }>;
modelStats: Array<{ modelId: string | null; count: number }>;
modelDetailStats: Array<{
modelId: string | null;
count: number;
userCount: number;
modelName: string | null;
providerName: string | null;
providerType: string | null;
}>;
providerTypeStats: Array<{
providerType: string | null;
providerName: string | null;
count: number;
}>;
modelTrendStats: Array<{
date: string;
modelId: string | null;
count: number;
}>;
monthlyStats: Array<{ month: string; count: number }>;
hourlyStats: Array<{ hour: number; count: number }>;
};
const AVAILABLE_TOOLS = [
{ name: "getWeather", description: "获取天气信息" },
{ name: "createDocument", description: "创建文档" },
{ name: "updateDocument", description: "更新文档" },
{ name: "requestSuggestions", description: "请求文档建议" },
];
const PROVIDER_TYPES = [
{ value: "alibaba", label: "阿里云通义千问" },
{ value: "alibaba-image", label: "阿里生图模型" }, // 🆕 千问图片生成/编辑
{ value: "bfl-image", label: "BFL FLUX.2 图片编辑" },
{ value: "ruxa-image", label: "RUXA生图" },
{ value: "fal-image", label: "FAL 生图模型" },
{ value: "openai-image", label: "OpenAI 图片兼容" },
{ value: "midjourney", label: "Midjourney" },
{ value: "tripo-3d", label: "Tripo3D 模型生成" },
{ value: "runway", label: "Runway API" },
{ value: "zhipu", label: "智普官方 4.7" },
{ value: "volcano-multimodal", label: "火山引擎多模态" },
{ value: "openai", label: "OpenAI 兼容" },
{ value: "custom", label: "自定义 OpenAI 兼容" },
{ value: "local", label: "本地模型" },
{ value: "gemini", label: "Gemini中转" },
{ value: "huanke-gemini", label: "环科Gemini生图" }, // 🆕 环科 Gemini 原生格式 API
{ value: "qianwen-video", label: "千问视频生成" },
{ value: "doubao-video", label: "豆包AI视频" },
{ value: "ruxa-video", label: "RUXA 视频生成" },
{ value: "volcano-vision", label: "火山引擎视觉" },
{ value: "volcano-video", label: "火山引擎视频" },
{ value: "fal-video", label: "FAL 视频生成" },
];
const PROVIDER_DEFAULT_BASE_URL: Record<string, string> = {
alibaba: "https://dashscope.aliyuncs.com/compatible-mode/v1",
"alibaba-image":
"https://dashscope.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation",
"bfl-image": "https://api.bfl.ai",
"ruxa-image": "https://api.ruxa.ai",
"fal-image": "https://fal.run",
"openai-image": "https://api.openai.com/v1",
midjourney: "https://your-midjourney-api.example.com",
"fal-video": "https://fal.run",
"tripo-3d": "https://api.tripo3d.ai/v2/openapi",
runway: "https://api.dev.runwayml.com",
zhipu: "https://open.bigmodel.cn/api/paas/v4",
"volcano-multimodal": "https://ark.cn-beijing.volces.com/api/v3",
openai: "https://api.openai.com/v1",
"huanke-gemini": "https://api.huandutech.com/v1",
"qianwen-video": "https://dashscope.aliyuncs.com/api/v1/services/aigc/video-generation",
"doubao-video": "https://ark.cn-beijing.volces.com/api/v3",
"ruxa-video": "https://api.ruxa.ai",
"volcano-vision": "https://ark.cn-beijing.volces.com",
"volcano-video": "https://ark.cn-beijing.volces.com/api/v3",
};
const LEMON_OPENAI_BASE_URL = "https://new.lemonapi.site/v1";
const LEMON_PROVIDER_PRESET = {
name: "LemonAPI Gemini",
type: "openai" as const,
baseUrl: LEMON_OPENAI_BASE_URL,
};
const LEMON_MODEL_PRESETS = [
{
modelId: "[L]gemini-3.1-pro-preview",
displayName: "Gemini-3.1-pro-preview",
supportsVision: true,
supportsImageGen: false,
isMultimodal: true,
description: "LemonAPI OpenAI兼容 Gemini 3.1 Pro 预设模型",
},
];
function getProviderDefaultBaseUrl(type?: string) {
if (!type) return "";
return PROVIDER_DEFAULT_BASE_URL[type] || "";
}
function isHttpUrlLike(value: string) {
return /^https?:\/\//i.test(value.trim());
}
function isLemonApiBaseUrl(value?: string | null) {
return String(value || "").trim().replace(/\/+$/, "") === LEMON_OPENAI_BASE_URL;
}
function isMoonshotOfficialBaseUrl(value?: string | null) {
return (
String(value || "").trim().replace(/\/+$/, "") ===
DETAIL_AGENT_LOCAL_MOONSHOT_BASE_URL
);
}
export function AdminSettingsClient() {
const [activeTab, setActiveTab] = useState("users");
// 用户管理状态
const [userSearch, setUserSearch] = useState("");
const [userPage, setUserPage] = useState(1);
const [userLimit] = useState(10);
const [giftPointsDraftByUser, setGiftPointsDraftByUser] = useState<Record<string, string>>({});
const [deductPointsDraftByUser, setDeductPointsDraftByUser] = useState<Record<string, string>>({});
// 批量设置出图限额状态
const [showBatchLimitDialog, setShowBatchLimitDialog] = useState(false);
const [batchLimitType, setBatchLimitType] = useState<"all" | "department" | "no_department">("all");
const [batchLimitValue, setBatchLimitValue] = useState<string>("");
const [batchLimitDepartment, setBatchLimitDepartment] = useState<string>("");
// 🆕 生图记录状态
const [imageSearch, setImageSearch] = useState("");
const [imagePage, setImagePage] = useState(1);
const [imageStartDate, setImageStartDate] = useState("");
const [imageEndDate, setImageEndDate] = useState("");
const [selectedImage, setSelectedImage] = useState<GeneratedImageRecord | null>(null);
// 🆕 数据分析状态
const [selectedMonth, setSelectedMonth] = useState(() => {
const now = new Date();
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
});
const [selectedDate, setSelectedDate] = useState<string | null>(null); // 选中的日期,用于联动高峰时段
const [hoveredBar, setHoveredBar] = useState<{ type: string; index: number; value: number; label: string } | null>(null);
// API 数据获取
const { data: usersData, mutate: mutateUsers } = useSWR<{
users: User[];
total: number;
page: number;
totalPages: number;
}>(
`/api/admin/users?page=${userPage}&limit=${userLimit}&search=${encodeURIComponent(userSearch)}`,
fetcher
);
// 获取部门列表(用于批量设置)
const { data: departmentsData } = useSWR<{ departments: string[] }>(
"/api/admin/users/batch-limit",
fetcher
);
const { data: providersData, mutate: mutateProviders } = useSWR<{ providers: AiProvider[] }>("/api/admin/ai-providers", fetcher);
const { data: modelsData, mutate: mutateModels } = useSWR<{ models: AiModel[] }>("/api/admin/ai-models", fetcher);
const { data: storageData, mutate: mutateStorage } = useSWR<{ configs: StorageConfig[] }>("/api/admin/storage", fetcher);
const { data: siteSettingsData, mutate: mutateSiteSettings } = useSWR<SiteSettings>("/api/site-settings", fetcher);
const { data: navLinksData, mutate: mutateNavLinks } = useSWR<{ links: NavLink[] }>("/api/nav-links", fetcher);
const { data: tutorialVideosData, mutate: mutateTutorialVideos } = useSWR<{ videos: TutorialVideo[] }>("/api/tutorial-videos", fetcher);
const { data: authSettingsData, mutate: mutateAuthSettings } = useSWR<any>(
"/api/auth-settings",
fetcher,
);
const { data: moonshotDetailAgentSettingData, mutate: mutateMoonshotDetailAgentSetting } =
useSWR<{ setting?: { value?: unknown } }>(
`/api/admin/settings?key=${DETAIL_AGENT_LOCAL_MOONSHOT_SETTING_KEY}`,
fetcher,
);
// 🆕 生图记录数据
const imageQueryParams = new URLSearchParams({
page: imagePage.toString(),
pageSize: "200",
...(imageSearch && { search: imageSearch }),
...(imageStartDate && { startDate: imageStartDate }),
...(imageEndDate && { endDate: imageEndDate }),
});
const { data: imagesData, mutate: mutateImages } = useSWR<{
images: GeneratedImageRecord[];
pagination: {
page: number;
pageSize: number;
total: number;
totalPages: number;
totalImages?: number;
totalVideos?: number;
};
}>(`/api/admin/images?${imageQueryParams}`, fetcher);
// 🆕 生图数据分析
const analyticsUrl = activeTab === "analytics"
? `/api/admin/images/analytics?month=${selectedMonth}${selectedDate ? `&date=${selectedDate}` : ""}`
: null;
const { data: analyticsData } = useSWR<ImageAnalytics>(analyticsUrl, fetcher);
const detailAgentModelOptions = useMemo(
() =>
(modelsData?.models || []).filter(
(model) => !model.supportsImageGen && model.isEnabled && !model.isHidden,
),
[modelsData?.models],
);
// 网站设置状态
const [siteSettings, setSiteSettings] = useState<SiteSettings>({
siteName: "AI Chatbot",
siteLogo: "",
siteLogoDark: "",
favicon: "",
showAutoModel: true,
usageTimeEnabled: false,
usageTimeStart: "08:00",
usageTimeEnd: "20:00",
usageTimeMessage: "由壹点累了也是需要休息的",
defaultTheme: "system",
bannerEnabled: true,
bannerText: "升级会员, Nano Banana Pro 免费365天!",
bannerLink: null,
toolbox1Name: "工具箱",
toolbox1Enabled: true,
toolbox2Name: "工具箱2",
toolbox2Enabled: false,
tutorialVideoUrl: null,
tutorialEnabled: false,
rankingEnabled: true,
canvasEnabled: true,
agentEnabled: true,
canvasAgentModeEnabled: true,
agentCategoryTreeConfig: {
productCategories: [],
audienceTags: [],
usageScenarios: [],
brands: [],
},
agentSecondaryTypeConfig: {
image: [],
video: [],
text: [],
},
agentWorkflowUiConfig: defaultAgentWorkflowUiConfig,
realtimeCanvasEnabled: true,
aiDetailGenerationEnabled: false,
canvasDefaultModelId: null,
canvasAutoModeDefaultModelId: null,
canvasDetailDefaultModelId: null,
canvasDetailAgentModelId: null,
lovartComposerPrefix: "让Buyi-ai",
lovartComposerPreviewTexts: [
"打造引人注目的社交媒体视觉",
"帮我把这张产品图做成高级电商海报",
"基于参考图延展 3 套同风格方案",
],
lovartPromptTagVisibility: defaultLovartPromptTagVisibility,
contactConfig: defaultContactConfig,
storageFailoverConfig: defaultStorageFailoverConfig,
ossImageMigrationConfig: defaultOssImageMigrationConfig,
pointsSystemConfig: defaultPointsSystemConfig,
pointsPackages: defaultPointsPackages,
dulupayConfig: defaultDulupayConfig,
lovartPromoText: "现在升级,立享 Kling 3.0 与 Nano Banana Pro,最高 5 折优惠",
lovartPromoActionText: "立即升级",
lovartHeroTitle: "由壹点AI 让设计更简单",
lovartHeroSubtitle: "懂你的设计代理,帮你搞定一切",
homeVariant: "classic",
surpriseCategories: ["全部", "精选"],
membershipConfig: defaultMembershipConfig,
membershipUserOverrides: defaultMembershipUserOverrides,
});
const [newOverrideUserId, setNewOverrideUserId] = useState("");
const [newOverrideEmail, setNewOverrideEmail] = useState("");
const [newOverridePlanId, setNewOverridePlanId] = useState("");
useEffect(() => {
if (siteSettingsData) {
const normalizedMembershipConfig = normalizeMembershipConfig(
(siteSettingsData as any).membershipConfig
);
const normalizedMembershipOverrides = normalizeMembershipUserOverrides(
(siteSettingsData as any).membershipUserOverrides
);
const normalizedPointsSystemConfig = normalizePointsSystemConfig(
(siteSettingsData as any).pointsSystemConfig
);
const normalizedPointsPackages = normalizePointsPackages(
(siteSettingsData as any).pointsPackages
);
const normalizedDulupayConfig = normalizeDulupayConfig(
(siteSettingsData as any).dulupayConfig
);
setSiteSettings({
siteName: siteSettingsData.siteName || "AI Chatbot",
siteLogo: siteSettingsData.siteLogo || "",
siteLogoDark: siteSettingsData.siteLogoDark || "",
favicon: siteSettingsData.favicon || "",
showAutoModel: siteSettingsData.showAutoModel ?? true,
usageTimeEnabled: siteSettingsData.usageTimeEnabled ?? false,
usageTimeStart: siteSettingsData.usageTimeStart || "08:00",
usageTimeEnd: siteSettingsData.usageTimeEnd || "20:00",
usageTimeMessage: siteSettingsData.usageTimeMessage || "由壹点累了也是需要休息的",
defaultTheme: siteSettingsData.defaultTheme || "system",
bannerEnabled: siteSettingsData.bannerEnabled ?? true,
bannerText: siteSettingsData.bannerText || "升级会员, Nano Banana Pro 免费365天!",
bannerLink: siteSettingsData.bannerLink || null,
toolbox1Name: siteSettingsData.toolbox1Name || "工具箱",
toolbox1Enabled: siteSettingsData.toolbox1Enabled ?? true,
toolbox2Name: siteSettingsData.toolbox2Name || "工具箱2",
toolbox2Enabled: siteSettingsData.toolbox2Enabled ?? false,
tutorialVideoUrl: siteSettingsData.tutorialVideoUrl || null,
tutorialEnabled: siteSettingsData.tutorialEnabled ?? false,
rankingEnabled: siteSettingsData.rankingEnabled ?? true,
canvasEnabled: siteSettingsData.canvasEnabled ?? true,
agentEnabled: (siteSettingsData as any).agentEnabled ?? true,
canvasAgentModeEnabled: (siteSettingsData as any).canvasAgentModeEnabled ?? true,
agentCategoryTreeConfig: {
productCategories: Array.isArray((siteSettingsData as any).agentCategoryTreeConfig?.productCategories)
? (siteSettingsData as any).agentCategoryTreeConfig.productCategories
: [],
audienceTags: Array.isArray((siteSettingsData as any).agentCategoryTreeConfig?.audienceTags)
? (siteSettingsData as any).agentCategoryTreeConfig.audienceTags
: [],
usageScenarios: Array.isArray((siteSettingsData as any).agentCategoryTreeConfig?.usageScenarios)
? (siteSettingsData as any).agentCategoryTreeConfig.usageScenarios
: [],
brands: Array.isArray((siteSettingsData as any).agentCategoryTreeConfig?.brands)
? (siteSettingsData as any).agentCategoryTreeConfig.brands
: [],
},
agentSecondaryTypeConfig: {
image: Array.isArray((siteSettingsData as any).agentSecondaryTypeConfig?.image)
? (siteSettingsData as any).agentSecondaryTypeConfig.image
: [],
video: Array.isArray((siteSettingsData as any).agentSecondaryTypeConfig?.video)
? (siteSettingsData as any).agentSecondaryTypeConfig.video
: [],
text: Array.isArray((siteSettingsData as any).agentSecondaryTypeConfig?.text)
? (siteSettingsData as any).agentSecondaryTypeConfig.text
: [],
},
agentWorkflowUiConfig: normalizeAgentWorkflowUiConfig(
(siteSettingsData as any).agentWorkflowUiConfig
),
realtimeCanvasEnabled: (siteSettingsData as any).realtimeCanvasEnabled ?? true,
aiDetailGenerationEnabled: siteSettingsData.aiDetailGenerationEnabled ?? false,
canvasDefaultModelId: siteSettingsData.canvasDefaultModelId || null,
canvasAutoModeDefaultModelId: siteSettingsData.canvasAutoModeDefaultModelId || null,
canvasDetailDefaultModelId: siteSettingsData.canvasDetailDefaultModelId || null,
canvasDetailAgentModelId: (siteSettingsData as any).canvasDetailAgentModelId || null,
lovartComposerPrefix:
String((siteSettingsData as any).lovartComposerPrefix || "").trim() ||
"让Buyi-ai",
lovartComposerPreviewTexts: Array.isArray(siteSettingsData.lovartComposerPreviewTexts)
? siteSettingsData.lovartComposerPreviewTexts
.map((item: any) => String(item).trim())
.filter(Boolean)
: [
"打造引人注目的社交媒体视觉",
"帮我把这张产品图做成高级电商海报",
"基于参考图延展 3 套同风格方案",
],
lovartPromptTagVisibility: {
nanoBananaPro:
(siteSettingsData as any).lovartPromptTagVisibility?.nanoBananaPro !== false,
design: (siteSettingsData as any).lovartPromptTagVisibility?.design !== false,
branding: (siteSettingsData as any).lovartPromptTagVisibility?.branding !== false,
illustration:
(siteSettingsData as any).lovartPromptTagVisibility?.illustration !== false,
ecommerce: (siteSettingsData as any).lovartPromptTagVisibility?.ecommerce !== false,
video: (siteSettingsData as any).lovartPromptTagVisibility?.video !== false,
},
contactConfig: {
phone: String((siteSettingsData as any).contactConfig?.phone || "").trim(),
wechatQrUrl: String((siteSettingsData as any).contactConfig?.wechatQrUrl || "").trim(),
},
storageFailoverConfig: {
enabled: (siteSettingsData as any).storageFailoverConfig?.enabled === true,
backupStorageConfigId: String(
(siteSettingsData as any).storageFailoverConfig?.backupStorageConfigId || ""
).trim(),
},
ossImageMigrationConfig: {
enabled: (siteSettingsData as any).ossImageMigrationConfig?.enabled !== false,
preferAliyunForUploads:
(siteSettingsData as any).ossImageMigrationConfig?.preferAliyunForUploads !== false,
sourceStorageConfigId: String(
(siteSettingsData as any).ossImageMigrationConfig?.sourceStorageConfigId || ""
).trim(),
targetStorageConfigId: String(
(siteSettingsData as any).ossImageMigrationConfig?.targetStorageConfigId || ""
).trim(),
migrateAfterHours: Math.max(
1,
Math.min(
720,
Number((siteSettingsData as any).ossImageMigrationConfig?.migrateAfterHours) ||
defaultOssImageMigrationConfig.migrateAfterHours
)
),
cleanupAfterHours: Math.max(
2,
Math.min(
1440,
Number((siteSettingsData as any).ossImageMigrationConfig?.cleanupAfterHours) ||
defaultOssImageMigrationConfig.cleanupAfterHours
)
),
batchSize: Math.max(
1,
Math.min(
100,
Number((siteSettingsData as any).ossImageMigrationConfig?.batchSize) ||
defaultOssImageMigrationConfig.batchSize
)
),
},
pointsSystemConfig: normalizedPointsSystemConfig,
pointsPackages: normalizedPointsPackages,
dulupayConfig: normalizedDulupayConfig,
lovartPromoText:
siteSettingsData.lovartPromoText ||
"现在升级,立享 Kling 3.0 与 Nano Banana Pro,最高 5 折优惠",
lovartPromoActionText: siteSettingsData.lovartPromoActionText || "立即升级",
lovartHeroTitle: siteSettingsData.lovartHeroTitle || "由壹点AI 让设计更简单",
lovartHeroSubtitle: siteSettingsData.lovartHeroSubtitle || "懂你的设计代理,帮你搞定一切",
homeVariant: siteSettingsData.homeVariant === "lovart" ? "lovart" : "classic",
surpriseCategories: Array.isArray(siteSettingsData.surpriseCategories)
? siteSettingsData.surpriseCategories.map((item: any) => String(item).trim()).filter(Boolean)
: ["全部", "惊喜"],
membershipConfig: normalizedMembershipConfig,
membershipUserOverrides: normalizedMembershipOverrides,
});
}
}, [siteSettingsData]);
useEffect(() => {
const normalized = normalizeDetailAgentLocalMoonshotConfig(
moonshotDetailAgentSettingData?.setting?.value,
);
setMoonshotDetailAgentConfig({
providerId: "",
modelEntryId: "",
providerName: normalized.providerName,
baseUrl: normalized.baseUrl,
apiKey: "",
modelId: normalized.modelId,
displayName: normalized.displayName,
isEnabled: normalized.enabled,
});
}, [moonshotDetailAgentSettingData]);
const featuredCategoryOptions = useMemo(() => {
const values = (siteSettings.surpriseCategories || [])
.map((item) => String(item || "").trim())
.filter((item) => item && item !== "全部");
return Array.from(new Set(values));
}, [siteSettings.surpriseCategories]);
// 抠图API配置状态
const [rmbgConfig, setRmbgConfig] = useState({
provider: "rmbg" as "rmbg" | "photoroom" | "aoscdn", // 抠图服务提供商
apiUrl: "",
apiKey: "",
// PhotoRoom 配置
photoroomApiKey: "",
// AOS 配置
aosApiKey: "",
aosApiBaseUrl: "https://techsz.aoscdn.com",
aosForegroundType: "auto" as "auto" | "person" | "object" | "stamp",
aosCrop: false,
aosFormat: "png" as "png" | "jpg",
});
// 变清晰API配置状态
const [enhanceConfig, setEnhanceConfig] = useState({
apiUrl: "",
apiKey: "",
type: "clean", // "clean" | "face"
scaleFactor: "auto", // "auto" | "1" | "2" | "4"
// 备用API配置 (Real-ESRGAN)
useBackupApi: false, // 是否启用备用API
backupApiUrl: "http://156.225.27.52:5233", // 备用API地址
backupApiKey: "", // 备用API密钥
backupScale: "4", // 备用API放大倍数 "2" | "4" | "8"
backupModel: "RealESRGAN_x4plus", // 备用API模型
});
// 图像拓展API配置状态
const [extendConfig, setExtendConfig] = useState({
apiKey: "",
xScale: "1.5", // X轴放大倍数
yScale: "1.5", // Y轴放大倍数
});
// 去水印API配置状态
const [watermarkRemoveConfig, setWatermarkRemoveConfig] = useState({
apiKey: "", // PicWish API密钥
});
// 角度编辑API配置状态
const [angleEditConfig, setAngleEditConfig] = useState({
enabled: false,
falApiKey: "",
loraScale: 1.0, // 官方默认值
inferenceSteps: 28,
guidanceScale: 4.5, // 官方默认值
maxImageSize: 10,
timeout: 120,
});
const [layerSplitConfig, setLayerSplitConfig] = useState({
enabled: false,
provider: "replicate" as "replicate",
replicateApiToken: "",
replicateModel: "qwen/qwen-image-layered",
layers: 4,
resolution: 640,
trueCfgScale: 4,
numInferenceSteps: 30,
cfgNormalize: true,
useEnPrompt: true,
negativePrompt: "",
timeoutMs: 300000,
});
// 实时画布API配置状态
const [realtimeCanvasConfig, setRealtimeCanvasConfig] = useState({
enabled: true,
providerType: "bfl" as "bfl" | "replicate",
bflApiKey: "",
bflBaseUrl: DEFAULT_BFL_BASE_URL,
bflModel: DEFAULT_BFL_REALTIME_MODEL,
replicateApiToken: "",
replicateModel: "black-forest-labs/flux-2-klein-4b",
fallbackEnabled: false,
fallbackProviderType: "bfl" as "bfl" | "replicate",
fallbackBflApiKey: "",
fallbackBflBaseUrl: DEFAULT_BFL_BASE_URL,
fallbackBflModel: DEFAULT_BFL_REALTIME_MODEL,
fallbackReplicateApiToken: "",
fallbackReplicateModel: "black-forest-labs/flux-2-klein-4b",
renderMode: "balanced" as "strict-structure" | "balanced" | "stylize",
throttleInterval: 100,
maxBuffering: 2,
maxInputResolution: 1024,
jpegQuality: 0.55,
previewMegapixels: 0.4,
detailMegapixels: 1.0,
outputJpegQuality: 62,
steps: 3,
scheduleMu: 2.3,
feedback: 0.95,
imageSize: "square" as "square" | "square_hd",
});
// 批量处理配置状态
const [sceneFusionConfig, setSceneFusionConfig] = useState({
enabled: true,
preRmbgEnabled: true,
defaultPrompt: "将图1的产品替换到图2的场景中,保持图1产品的外观、颜色和细节不变,自然融合到图2的环境中。",
maxScenes: 10,
modelId: "", // 首选生图模型ID(多样性模式下优先尝试)
});
const [tripo3dConfig, setTripo3dConfig] = useState({
providerId: "",
name: "Tripo3D",
baseUrl: getProviderDefaultBaseUrl("tripo-3d"),
apiKey: "",
isEnabled: true,
});
const [moonshotDetailAgentConfig, setMoonshotDetailAgentConfig] = useState({
providerId: "",
modelEntryId: "",
providerName: "Moonshot AI (Official)",
baseUrl: DETAIL_AGENT_LOCAL_MOONSHOT_BASE_URL,
apiKey: "",
modelId: DETAIL_AGENT_LOCAL_MOONSHOT_MODEL_ID,
displayName: "官方 Kimi K2.5",
isEnabled: true,
});
const [thirdPartySqlServerConfig, setThirdPartySqlServerConfig] = useState({
...defaultThirdPartySqlServerConfig,
});
const [isTestingThirdPartySqlServer, setIsTestingThirdPartySqlServer] = useState(false);
// 加载抠图API配置
const { data: rmbgConfigData } = useSWR("/api/admin/settings?key=rmbg_api_config", fetcher);
useEffect(() => {
if (rmbgConfigData?.setting?.value) {
const config = rmbgConfigData.setting.value as {
provider?: "rmbg" | "photoroom" | "aoscdn";
apiUrl?: string;
apiKey?: string;
photoroomApiKey?: string;
aosApiKey?: string;
aosApiBaseUrl?: string;
aosForegroundType?: "" | "person" | "object" | "stamp";
aosCrop?: 0 | 1;
aosFormat?: "png" | "jpg";
};
setRmbgConfig({
provider: config.provider || "rmbg",
apiUrl: config.apiUrl || "",
apiKey: config.apiKey || "",
photoroomApiKey: config.photoroomApiKey || "",
aosApiKey: config.aosApiKey || "",
aosApiBaseUrl: config.aosApiBaseUrl || "https://techsz.aoscdn.com",
aosForegroundType:
config.aosForegroundType === "person" ||
config.aosForegroundType === "object" ||
config.aosForegroundType === "stamp"
? config.aosForegroundType
: "auto",
aosCrop: config.aosCrop === 1,
aosFormat: config.aosFormat === "jpg" ? "jpg" : "png",
});
}
}, [rmbgConfigData]);
// 加载变清晰API配置
const { data: enhanceConfigData } = useSWR("/api/admin/settings?key=enhance_api_config", fetcher);
useEffect(() => {
if (enhanceConfigData?.setting?.value) {
const config = enhanceConfigData.setting.value as {
apiUrl?: string;
apiKey?: string;
type?: string;
scaleFactor?: number;
useBackupApi?: boolean;
backupApiUrl?: string;
backupApiKey?: string;
backupScale?: string;
backupModel?: string;
};
setEnhanceConfig({
apiUrl: config.apiUrl || "",
apiKey: config.apiKey || "",
type: config.type || "clean",
scaleFactor: config.scaleFactor ? String(config.scaleFactor) : "auto",
useBackupApi: config.useBackupApi || false,
backupApiUrl: config.backupApiUrl || "http://156.225.27.52:5233",
backupApiKey: config.backupApiKey || "",
backupScale: config.backupScale || "4",
backupModel: config.backupModel || "RealESRGAN_x4plus",
});
}
}, [enhanceConfigData]);
// 加载图像拓展API配置
const { data: extendConfigData } = useSWR("/api/admin/settings?key=extend_api_config", fetcher);
useEffect(() => {
if (extendConfigData?.setting?.value) {
const config = extendConfigData.setting.value as {
apiKey?: string;
xScale?: number;
yScale?: number;
};
setExtendConfig({
apiKey: config.apiKey || "",
xScale: config.xScale ? String(config.xScale) : "1.5",
yScale: config.yScale ? String(config.yScale) : "1.5",
});
}
}, [extendConfigData]);
// 加载去水印API配置
const { data: watermarkRemoveConfigData } = useSWR("/api/admin/settings?key=watermark_remove_config", fetcher);
useEffect(() => {
if (watermarkRemoveConfigData?.setting?.value) {
const config = watermarkRemoveConfigData.setting.value as {
apiKey?: string;
};
setWatermarkRemoveConfig({
apiKey: config.apiKey || "",
});
}
}, [watermarkRemoveConfigData]);
// 加载角度编辑API配置
const { data: angleEditConfigData } = useSWR("/api/admin/settings?key=angle_edit_config", fetcher);
useEffect(() => {
if (angleEditConfigData?.setting?.value) {
const config = angleEditConfigData.setting.value as {
enabled?: boolean;
falApiKey?: string;
loraScale?: number;
inferenceSteps?: number;
guidanceScale?: number;
maxImageSize?: number;
timeout?: number;
};
setAngleEditConfig({
enabled: config.enabled || false,
falApiKey: config.falApiKey || "",
loraScale: config.loraScale ?? 1.0,
inferenceSteps: config.inferenceSteps ?? 28,
guidanceScale: config.guidanceScale ?? 4.5,
maxImageSize: config.maxImageSize ?? 10,
timeout: config.timeout ?? 120,
});
}
}, [angleEditConfigData]);
const { data: layerSplitConfigData } = useSWR("/api/admin/settings?key=layer_split_config", fetcher);
useEffect(() => {
if (layerSplitConfigData?.setting?.value) {
const config = layerSplitConfigData.setting.value as {
enabled?: boolean;
provider?: "replicate";
replicateApiToken?: string;
replicateModel?: string;
layers?: number;
resolution?: number;
trueCfgScale?: number;
numInferenceSteps?: number;
cfgNormalize?: boolean;
useEnPrompt?: boolean;
negativePrompt?: string;
timeoutMs?: number;
};
setLayerSplitConfig({
enabled: config.enabled === true,
provider: "replicate",
replicateApiToken: config.replicateApiToken || "",
replicateModel: config.replicateModel || "qwen/qwen-image-layered",
layers: Math.max(2, Math.min(12, Number(config.layers) || 4)),
resolution: Math.max(256, Math.min(1536, Number(config.resolution) || 640)),
trueCfgScale: Math.max(1, Math.min(12, Number(config.trueCfgScale) || 4)),
numInferenceSteps: Math.max(1, Math.min(80, Number(config.numInferenceSteps) || 30)),
cfgNormalize: config.cfgNormalize !== false,
useEnPrompt: config.useEnPrompt !== false,
negativePrompt: config.negativePrompt || "",
timeoutMs: Math.max(30000, Math.min(900000, Number(config.timeoutMs) || 300000)),
});
}
}, [layerSplitConfigData]);
const { data: thirdPartySqlServerConfigData } = useSWR(
`/api/admin/settings?key=${THIRD_PARTY_SQLSERVER_CONFIG_KEY}`,
fetcher
);
useEffect(() => {
if (thirdPartySqlServerConfigData?.setting?.value) {
const config = thirdPartySqlServerConfigData.setting.value as {
enabled?: boolean;
host?: string;
port?: number;
database?: string;
username?: string;
password?: string;
encrypt?: boolean;
trustServerCertificate?: boolean;
connectionTimeoutMs?: number;
requestTimeoutMs?: number;
};
setThirdPartySqlServerConfig({
enabled: config.enabled !== false,
host: config.host || "",
port: Math.max(1, Math.min(65535, Number(config.port) || 1433)),
database: config.database || "",
username: config.username || "",
password: config.password || "",
encrypt: config.encrypt === true,
trustServerCertificate: config.trustServerCertificate !== false,
connectionTimeoutMs: Math.max(1000, Math.min(120000, Number(config.connectionTimeoutMs) || 15000)),
requestTimeoutMs: Math.max(1000, Math.min(120000, Number(config.requestTimeoutMs) || 30000)),
});
}
}, [thirdPartySqlServerConfigData]);
// 加载实时画布API配置
const { data: realtimeCanvasConfigData } = useSWR(
"/api/admin/settings?key=realtime_canvas_config",
fetcher
);
useEffect(() => {
if (realtimeCanvasConfigData?.setting?.value) {
const config = realtimeCanvasConfigData.setting.value as {
enabled?: boolean;
providerType?: "bfl" | "replicate";
bflApiKey?: string;
bflBaseUrl?: string;
bflModel?: string;
replicateApiToken?: string;
replicateModel?: string;
fallbackEnabled?: boolean;
fallbackProviderType?: "bfl" | "replicate";
fallbackBflApiKey?: string;
fallbackBflBaseUrl?: string;
fallbackBflModel?: string;
fallbackRunwareApiKey?: string;
fallbackFalApiKey?: string;
fallbackRunwareModel?: string;
fallbackReplicateApiToken?: string;
fallbackReplicateModel?: string;
runwareApiKey?: string;
falApiKey?: string;
runwareModel?: string;
renderMode?: "strict-structure" | "balanced" | "stylize";
throttleInterval?: number;
maxBuffering?: number;
maxInputResolution?: number;
jpegQuality?: number;
previewMegapixels?: number;
detailMegapixels?: number;
outputJpegQuality?: number;
defaults?: {
steps?: number;
scheduleMu?: number;
feedback?: number;
imageSize?: "square" | "square_hd";
};
};
setRealtimeCanvasConfig({
enabled: config.enabled ?? true,
providerType: config.providerType === "replicate" ? "replicate" : "bfl",
bflApiKey: config.bflApiKey || config.runwareApiKey || config.falApiKey || "",
bflBaseUrl: config.bflBaseUrl || DEFAULT_BFL_BASE_URL,
bflModel: config.bflModel || config.runwareModel || DEFAULT_BFL_REALTIME_MODEL,
replicateApiToken: config.replicateApiToken || "",
replicateModel: config.replicateModel || "black-forest-labs/flux-2-klein-4b",
fallbackEnabled: config.fallbackEnabled === true,
fallbackProviderType: config.fallbackProviderType === "replicate" ? "replicate" : "bfl",
fallbackBflApiKey:
config.fallbackBflApiKey ||
config.fallbackRunwareApiKey ||
config.fallbackFalApiKey ||
"",
fallbackBflBaseUrl: config.fallbackBflBaseUrl || DEFAULT_BFL_BASE_URL,
fallbackBflModel:
config.fallbackBflModel || config.fallbackRunwareModel || DEFAULT_BFL_REALTIME_MODEL,
fallbackReplicateApiToken: config.fallbackReplicateApiToken || "",
fallbackReplicateModel:
config.fallbackReplicateModel || "black-forest-labs/flux-2-klein-4b",
renderMode:
config.renderMode === "strict-structure" || config.renderMode === "stylize"
? config.renderMode
: "balanced",
throttleInterval: Number(config.throttleInterval || 100),
maxBuffering: Number(config.maxBuffering || 2),
maxInputResolution: Number(config.maxInputResolution || 1024),
jpegQuality: Number(config.jpegQuality || 0.55),
previewMegapixels: Number(config.previewMegapixels || 0.4),
detailMegapixels: Number(config.detailMegapixels || 1.0),
outputJpegQuality: Number(config.outputJpegQuality || 62),
steps: Number(config.defaults?.steps || 3),
scheduleMu: Number(config.defaults?.scheduleMu || 2.3),
feedback: Number(config.defaults?.feedback || 0.95),
imageSize: config.defaults?.imageSize === "square_hd" ? "square_hd" : "square",
});
}
}, [realtimeCanvasConfigData]);
// 加载批量处理配置
const { data: sceneFusionConfigData } = useSWR("/api/admin/settings?key=scene_fusion_config", fetcher);
useEffect(() => {
if (sceneFusionConfigData?.setting?.value) {
const config = sceneFusionConfigData.setting.value as {
enabled?: boolean;
preRmbgEnabled?: boolean;
defaultPrompt?: string;
maxScenes?: number;
modelId?: string;
};
setSceneFusionConfig({
enabled: config.enabled ?? true,
preRmbgEnabled: config.preRmbgEnabled ?? true,
defaultPrompt: config.defaultPrompt || "将图1的产品替换到图2的场景中,保持图1产品的外观、颜色和细节不变,自然融合到图2的环境中。",
maxScenes: Math.min(config.maxScenes ?? 10, 10),
modelId: config.modelId ?? "",
});
}
}, [sceneFusionConfigData]);
useEffect(() => {
const tripoProvider = (providersData?.providers || []).find(
(provider) => provider.type === "tripo-3d"
);
if (tripoProvider) {
setTripo3dConfig({
providerId: tripoProvider.id,
name: tripoProvider.name || "Tripo3D",
baseUrl: tripoProvider.baseUrl || getProviderDefaultBaseUrl("tripo-3d"),
apiKey: "",
isEnabled: tripoProvider.isEnabled,
});
return;
}
setTripo3dConfig({
providerId: "",
name: "Tripo3D",
baseUrl: getProviderDefaultBaseUrl("tripo-3d"),
apiKey: "",
isEnabled: true,
});
}, [providersData?.providers]);
// 导航链接状态
const [navLinkForm, setNavLinkForm] = useState({
id: "",
name: "",
url: "",
order: 0,
isEnabled: true,
toolboxGroup: 1, // 默认属于工具箱1
});
const [showNavLinkDialog, setShowNavLinkDialog] = useState(false);
const [editingNavLink, setEditingNavLink] = useState<string | null>(null);
// 视频上传状态
const [videoUploading, setVideoUploading] = useState(false);
const [videoUploadProgress, setVideoUploadProgress] = useState("");
const [videoUploadPercent, setVideoUploadPercent] = useState(0);
const [videoUploadStage, setVideoUploadStage] = useState<"idle" | "uploading" | "compressing" | "saving" | "done">("idle");
const [videoQuality, setVideoQuality] = useState<"low" | "medium" | "high">("medium");
// 视频教程管理状态
const [showTutorialVideoDialog, setShowTutorialVideoDialog] = useState(false);
const [editingTutorialVideo, setEditingTutorialVideo] = useState<string | null>(null);
const [tutorialVideoForm, setTutorialVideoForm] = useState({
title: "",
videoUrl: "",
coverUrl: "",
description: "",
order: 0,
isEnabled: true,
});
// 快捷短语状态
const [questions, setQuestions] = useState<string[]>([]);
const { data: questionsData, mutate: mutateQuestions } = useSWR<string[]>("/api/starter-questions", fetcher);
const [canvasSkillTagsTitle, setCanvasSkillTagsTitle] = useState(
DEFAULT_CANVAS_SKILL_TAGS_TITLE
);
const [canvasSkillTagsEnabled, setCanvasSkillTagsEnabled] = useState(true);
const [canvasSkillTags, setCanvasSkillTags] = useState<CanvasSkillTag[]>(
DEFAULT_CANVAS_SKILL_TAGS
);
const [canvasSkillTagUploadingId, setCanvasSkillTagUploadingId] = useState<string | null>(null);
const { data: canvasSkillTagsData, mutate: mutateCanvasSkillTags } = useSWR<{
enabled?: boolean;
title?: string;
tags?: CanvasSkillTag[];
}>("/api/canvas-skill-tags", fetcher);
// 系统通知状态
const [notification, setNotification] = useState<{ enabled: boolean; title: string; content: string }>({
enabled: false,
title: "",
content: "",
});
useEffect(() => {
fetch("/api/admin/settings?key=login_notification")
.then((res) => res.json())
.then((data) => {
if (data.setting?.value) {
setNotification(data.setting.value);
}
});
}, []);
const handleSaveNotification = async () => {
try {
await fetch("/api/admin/settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
key: "login_notification",
value: notification,
}),
});
toast.success("系统通知已保存");
} catch (error) {
toast.error("保存失败");
}
};
useEffect(() => {
if (questionsData) {
setQuestions(questionsData);
}
}, [questionsData]);
useEffect(() => {
if (canvasSkillTagsData) {
setCanvasSkillTagsEnabled(canvasSkillTagsData.enabled !== false);
setCanvasSkillTagsTitle(
String(canvasSkillTagsData.title || "").trim() || DEFAULT_CANVAS_SKILL_TAGS_TITLE
);
setCanvasSkillTags(normalizeCanvasSkillTags(canvasSkillTagsData.tags));
}
}, [canvasSkillTagsData]);
const handleQuestionChange = (index: number, value: string) => {
const newQuestions = [...questions];
newQuestions[index] = value;
setQuestions(newQuestions);
};
const handleAddQuestion = () => {
setQuestions([...questions, ""]);
};
const handleRemoveQuestion = (index: number) => {
const newQuestions = questions.filter((_, i) => i !== index);
setQuestions(newQuestions);
};
const handleSaveQuestions = async () => {
try {
const res = await fetch("/api/starter-questions", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(questions.filter(q => q.trim() !== "")),
});
if (!res.ok) throw new Error("Failed");
toast.success("快捷短语已更新");
mutateQuestions();
} catch (error) {
toast.error("更新失败");
}
};
const handleCanvasSkillTagChange = (
index: number,
field: "name" | "prompt" | "placeholder" | "iconName" | "iconUrl",
value: string
) => {
setCanvasSkillTags((prev) =>
prev.map((item, itemIndex) =>
itemIndex === index ? { ...item, [field]: value } : item
)
);
};
const handleCanvasSkillTagIconUpload = async (index: number, file: File) => {
if (!file.type.startsWith("image/")) {
toast.error("请上传图片文件");
return;
}
const targetTag = canvasSkillTags[index];
if (!targetTag) return;
try {
setCanvasSkillTagUploadingId(targetTag.id);
const formData = new FormData();
formData.append("file", file);
const response = await fetchUploadWithRetry(formData);
const result = await response.json();
if (!response.ok || !result?.url) {
throw new Error(result?.error || "上传失败");
}
setCanvasSkillTags((prev) =>
prev.map((tag, tagIndex) =>
tagIndex === index ? { ...tag, iconUrl: result.url } : tag
)
);
toast.success("标签图标上传成功");
} catch (error: any) {
toast.error(error?.message || "标签图标上传失败");
} finally {
setCanvasSkillTagUploadingId(null);
}
};
const handleAddCanvasSkillTag = () => {
setCanvasSkillTags((prev) => [...prev, createEmptyCanvasSkillTag()]);
};
const handleRemoveCanvasSkillTag = (index: number) => {
setCanvasSkillTags((prev) => prev.filter((_, itemIndex) => itemIndex !== index));
};
const handleSaveCanvasSkillTags = async () => {
try {
const payload = normalizeCanvasSkillTags(canvasSkillTags);
const res = await fetch("/api/canvas-skill-tags", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
enabled: canvasSkillTagsEnabled,
title: canvasSkillTagsTitle,
tags: payload,
}),
});
if (!res.ok) throw new Error("Failed");
const data = await res.json().catch(() => ({}));
setCanvasSkillTagsEnabled(data?.enabled !== false);
setCanvasSkillTagsTitle(
String(data?.title || canvasSkillTagsTitle || "").trim() ||
DEFAULT_CANVAS_SKILL_TAGS_TITLE
);
setCanvasSkillTags(normalizeCanvasSkillTags(data?.tags ?? payload));
toast.success("快捷标签已更新");
mutateCanvasSkillTags();
} catch (error) {
toast.error("快捷标签更新失败");
}
};
// 认证设置状态
const [authSettings, setAuthSettings] = useState<AuthSettings>({
dingTalkAppKey: "",
dingTalkAppSecret: "",
googleClientId: "",
googleClientSecret: "",
authUrl: "",
isDingTalkEnabled: false,
isGoogleEnabled: false,
isRegistrationEnabled: true,
isEmailVerificationEnabled: false,
emailVerificationExpiresMinutes: 10,
resendApiKey: "",
resendFromEmail: "",
isEmailServiceConfigured: false,
dingTalkOrganizations: [],
});
const [isGoogleConfigured, setIsGoogleConfigured] = useState(false);
useEffect(() => {
if (!authSettingsData) return;
const organizations: DingTalkOrganization[] =
(authSettingsData.dingTalkOrganizations as DingTalkOrganization[] | null) ?? [];
// 如果没有多组织配置,但有单一 AppKey/Secret,则自动构造一个默认组织
const normalizedOrgs =
organizations.length > 0 ||
!authSettingsData.dingTalkAppKey
? organizations
: [
{
id: "default",
name: "默认组织",
appKey: authSettingsData.dingTalkAppKey ?? "",
appSecret: authSettingsData.dingTalkAppSecret ?? "",
},
];
setAuthSettings({
dingTalkAppKey: authSettingsData.dingTalkAppKey || "",
dingTalkAppSecret: authSettingsData.dingTalkAppSecret || "",
googleClientId: authSettingsData.googleClientId || "",
googleClientSecret: authSettingsData.googleClientSecret || "",
authUrl: authSettingsData.authUrl || "",
isDingTalkEnabled: authSettingsData.isDingTalkEnabled || false,
isGoogleEnabled: authSettingsData.isGoogleEnabled || false,
isRegistrationEnabled: authSettingsData.isRegistrationEnabled ?? true,
isEmailVerificationEnabled:
authSettingsData.isEmailVerificationEnabled === true,
emailVerificationExpiresMinutes:
Number(authSettingsData.emailVerificationExpiresMinutes) || 10,
resendApiKey: authSettingsData.resendApiKey || "",
resendFromEmail: authSettingsData.resendFromEmail || "",
isEmailServiceConfigured: authSettingsData.isEmailServiceConfigured === true,
dingTalkOrganizations: normalizedOrgs,
});
setIsGoogleConfigured(Boolean(authSettingsData.isGoogleConfigured));
}, [authSettingsData]);
// AI 提供商 & 模型状态
const [providerForm, setProviderForm] = useState({
id: "",
name: "",
type: "openai" as "alibaba" | "openai" | "custom" | "local" | "gemini" | "huanke-gemini" | "qianwen-video" | "doubao-video" | "ruxa-video" | "volcano-vision" | "volcano-video" | "alibaba-image" | "bfl-image" | "ruxa-image" | "fal-image" | "openai-image" | "midjourney" | "fal-video" | "zhipu" | "tripo-3d" | "runway" | "volcano-multimodal",
baseUrl: "",
apiKey: "",
isEnabled: true,
});
const [showProviderDialog, setShowProviderDialog] = useState(false);
const [editingProvider, setEditingProvider] = useState<string | null>(null);
const [showApiKey, setShowApiKey] = useState(false);
const [modelForm, setModelForm] = useState({
id: "",
providerId: "",
modelId: "",
displayName: "",
avatarUrl: "",
description: "",
supportsVision: false,
supportsImageGen: false,
isEnabled: true,
isHidden: false,
isDefault: false,
isAgentModel: false,
agentCodingModelId: "",
isMultimodal: false,
sortOrder: 0,
fallbackModelId1: "", // 备用模型 ID 1
fallbackModelId2: "", // 备用模型 ID 2
excludeFromFailover: false,
// 🆕 图片生成模型特性配置
imageGenConfig: {
maxImages: 2,
supportsSize: false,
supports1K: true,
supports2K: true,
supports4K: true,
supportsPromptExtend: false,
supportsNegativePrompt: false,
supportsWatermark: false,
supportsSeed: false,
creditCostPerImage: 0,
creditCostPerVideo: 0,
enableLoadingPlaceholder: true,
loadingColor: "purple",
} as ImageGenConfig,
});
const [showModelDialog, setShowModelDialog] = useState(false);
const [editingModel, setEditingModel] = useState<string | null>(null);
const [modelAvatarUploading, setModelAvatarUploading] = useState(false);
const [contactQrUploading, setContactQrUploading] = useState(false);
// 🆕 火山引擎视频多模型表单(动态从API加载)
const [volcanoVideoModels, setVolcanoVideoModels] = useState<Array<{
modelId: string;
displayName: string;
description: string;
}>>([]);
// 🆕 当选择火山引擎视频Provider时,从API加载预设模型配置
useEffect(() => {
const selectedProvider = providersData?.providers?.find(p => p.id === modelForm.providerId);
if (selectedProvider?.type === 'volcano-video' && volcanoVideoModels.length === 0) {
// 从API获取预设模型配置(✅ 显示所有模型供管理员自定义)
fetch('/api/admin/volcano-video-models')
.then(res => res.json())
.then(data => {
if (data.presets) {
// ✅ 显示主模型和所有子模型,允许管理员自定义显示名称和描述
const allModels = data.presets.map((preset: any) => ({
modelId: preset.modelId,
displayName: preset.displayName,
description: preset.description,
isSubModel: preset.isSubModel || false, // 标记是否为子模型
}));
setVolcanoVideoModels(allModels);
}
})
.catch(err => {
console.error('[Load Volcano Models] Error:', err);
toast.error('加载预设模型配置失败');
});
}
}, [modelForm.providerId, providersData?.providers, volcanoVideoModels.length]);
const selectedModelProviderType =
providersData?.providers?.find((p) => p.id === modelForm.providerId)?.type || "";
const isVideoProviderForModelConfig = isVideoModelDescriptor({
providerType: selectedModelProviderType,
modelId: modelForm.modelId,
supportsImageGen: modelForm.supportsImageGen,
});
const isImageProviderForModelConfig = isImageGenerationModelDescriptor({
providerType: selectedModelProviderType,
modelId: modelForm.modelId,
supportsImageGen: modelForm.supportsImageGen,
});
const modelKindHint =
selectedModelProviderType === "runway" && !modelForm.modelId.trim()
? "Runway 同时支持图片与视频模型,填写模型 ID 后系统会自动识别标签。"
: isVideoProviderForModelConfig
? "当前识别为视频模型,将使用视频能力、视频积分和视频路由。"
: isImageProviderForModelConfig
? "当前识别为图片模型,将使用生图能力、图片积分和生图路由。"
: "当前识别为对话/分析模型。";
// 存储配置状态
const [storageForm, setStorageForm] = useState({
id: "",
name: "",
type: "s3" as "s3" | "local" | "aliyun-oss",
endpoint: "",
region: "",
bucket: "",
accessKeyId: "",
secretAccessKey: "",
publicUrl: "",
isDefault: false,
isEnabled: true,
});
const [showStorageDialog, setShowStorageDialog] = useState(false);
const [editingStorage, setEditingStorage] = useState<string | null>(null);
// --- 用户管理逻辑 ---
const handleUserSearch = (search: string) => {
setUserSearch(search);
setUserPage(1); // 搜索时重置到第一页
};
const handleUpdateUser = async (userId: string, updates: any) => {
try {
const response = await fetch("/api/admin/users", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id: userId, ...updates }),
});
if (!response.ok) throw new Error("更新失败");
toast.success("用户更新成功");
mutateUsers();
} catch (error) {
toast.error("更新用户失败");
}
};
const handleGiftPoints = async (userId: string) => {
const rawValue = giftPointsDraftByUser[userId] || "";
const points = Number.parseInt(rawValue, 10);
if (!Number.isFinite(points) || points <= 0) {
toast.error("请输入有效的赠送积分(大于 0)");
return;
}
try {
const response = await fetch("/api/admin/users", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id: userId, giftPoints: points }),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data?.message || data?.error || "赠送失败");
}
toast.success(`已赠送 ${points} 积分`);
setGiftPointsDraftByUser((prev) => ({ ...prev, [userId]: "" }));
mutateUsers();
} catch (error) {
toast.error(error instanceof Error ? error.message : "赠送积分失败");
}
};
const handleDeductPoints = async (userId: string) => {
const rawValue = deductPointsDraftByUser[userId] || "";
const points = Number.parseInt(rawValue, 10);
if (!Number.isFinite(points) || points <= 0) {
toast.error("请输入有效的扣减积分(大于 0)");
return;
}
try {
const response = await fetch("/api/admin/users", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id: userId, deductPoints: points }),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data?.message || data?.error || "扣减失败");
}
toast.success(`已扣减 ${points} 积分`);
setDeductPointsDraftByUser((prev) => ({ ...prev, [userId]: "" }));
mutateUsers();
} catch (error) {
toast.error(error instanceof Error ? error.message : "扣减积分失败");
}
};
const handleDeleteUser = async (userId: string) => {
if (!confirm("确定要删除此用户吗?")) return;
try {
const response = await fetch(`/api/admin/users?id=${userId}`, { method: "DELETE" });
if (!response.ok) throw new Error("删除失败");
toast.success("用户删除成功");
mutateUsers();
} catch (error) {
toast.error("删除用户失败");
}
};
// 批量设置出图限额
const handleBatchSetLimit = async () => {
try {
const response = await fetch("/api/admin/users/batch-limit", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
type: batchLimitType,
limit: batchLimitValue === "" ? null : parseInt(batchLimitValue, 10),
department: batchLimitDepartment,
}),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || "批量设置失败");
}
toast.success(data.message);
setShowBatchLimitDialog(false);
setBatchLimitValue("");
setBatchLimitDepartment("");
mutateUsers();
} catch (error) {
toast.error(error instanceof Error ? error.message : "批量设置失败");
}
};
// --- 网站设置逻辑 ---
const handleSaveSiteSettings = async () => {
try {
if (
siteSettings.storageFailoverConfig.enabled &&
!siteSettings.storageFailoverConfig.backupStorageConfigId
) {
toast.error("请先选择备用存储配置");
return;
}
const selectedBackupConfig = (storageData?.configs || []).find(
(config) => config.id === siteSettings.storageFailoverConfig.backupStorageConfigId
);
if (
siteSettings.storageFailoverConfig.enabled &&
selectedBackupConfig?.isDefault
) {
toast.error("备用存储配置不能与默认配置相同");
return;
}
if (siteSettings.ossImageMigrationConfig.enabled) {
if (
siteSettings.ossImageMigrationConfig.cleanupAfterHours <=
siteSettings.ossImageMigrationConfig.migrateAfterHours
) {
toast.error("OSS 清理时间必须晚于转存时间");
return;
}
if (
siteSettings.ossImageMigrationConfig.sourceStorageConfigId &&
siteSettings.ossImageMigrationConfig.targetStorageConfigId &&
siteSettings.ossImageMigrationConfig.sourceStorageConfigId ===
siteSettings.ossImageMigrationConfig.targetStorageConfigId
) {
toast.error("OSS 源桶和目标桶不能相同");
return;
}
const targetConfig = (storageData?.configs || []).find(
(config) => config.id === siteSettings.ossImageMigrationConfig.targetStorageConfigId
);
if (
targetConfig &&
targetConfig.type === "s3" &&
targetConfig.endpoint?.includes("aliyuncs.com")
) {
toast.error("OSS 转存目标桶不能是阿里云 OSS");
return;
}
}
const parsedMembershipConfig = normalizeMembershipConfig(siteSettings.membershipConfig);
const parsedMembershipOverrides = normalizeMembershipUserOverrides(
siteSettings.membershipUserOverrides
);
const parsedPointsPackages = normalizePointsPackages(siteSettings.pointsPackages);
const payload = {
...siteSettings,
membershipConfig: parsedMembershipConfig,
membershipUserOverrides: parsedMembershipOverrides,
pointsPackages: parsedPointsPackages,
};
const response = await fetch("/api/site-settings", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!response.ok) throw new Error("保存失败");
setSiteSettings(payload);
toast.success("网站设置保存成功");
mutateSiteSettings();
} catch (error) {
toast.error("保存网站设置失败");
}
};
const updatePointsPackage = (index: number, updater: (item: PointsPackage) => PointsPackage) => {
setSiteSettings((prev) => {
const list = [...(prev.pointsPackages || [])];
if (!list[index]) return prev;
list[index] = updater(list[index]);
return { ...prev, pointsPackages: list };
});
};
const addPointsPackage = () => {
setSiteSettings((prev) => {
const list = [...(prev.pointsPackages || [])];
const nextIndex = list.length + 1;
list.push({
id: `package-${Date.now()}`,
name: `套餐${nextIndex}`,
points: 1000,
price: 9.9,
currency: "CNY",
isEnabled: true,
});
return { ...prev, pointsPackages: list };
});
};
const removePointsPackage = (index: number) => {
setSiteSettings((prev) => {
const list = [...(prev.pointsPackages || [])];
if (list.length <= 1) {
toast.error("至少保留一个积分套餐");
return prev;
}
list.splice(index, 1);
return { ...prev, pointsPackages: list };
});
};
const updateMembershipConfigState = (
updater: (config: ReturnType<typeof normalizeMembershipConfig>) => ReturnType<typeof normalizeMembershipConfig>
) => {
setSiteSettings((prev) => ({
...prev,
membershipConfig: updater(prev.membershipConfig),
}));
};
const updateMembershipOverridesState = (
updater: (
overrides: ReturnType<typeof normalizeMembershipUserOverrides>
) => ReturnType<typeof normalizeMembershipUserOverrides>
) => {
setSiteSettings((prev) => ({
...prev,
membershipUserOverrides: updater(prev.membershipUserOverrides),
}));
};
const updateMembershipPlan = (
index: number,
updater: (plan: MembershipPlanConfig) => MembershipPlanConfig
) => {
updateMembershipConfigState((config) => {
const plans = [...config.plans];
if (!plans[index]) return config;
plans[index] = updater(plans[index]);
return { ...config, plans };
});
};
const addMembershipPlan = () => {
updateMembershipConfigState((config) => {
const nextIndex = config.plans.length + 1;
const id = `plan-${Date.now()}`;
const plans = [
...config.plans,
{
id,
name: `套餐${nextIndex}`,
monthlyPrice: 0,
annualPrice: 0,
monthlyCredits: 0,
dailyFreeCredits: 0,
features: [],
},
];
return {
...config,
plans,
defaultPlanId: config.defaultPlanId || id,
};
});
};
const removeMembershipPlan = (index: number) => {
updateMembershipConfigState((config) => {
if (config.plans.length <= 1) {
toast.error("至少保留一个会员套餐");
return config;
}
const target = config.plans[index];
if (!target) return config;
const plans = [...config.plans];
plans.splice(index, 1);
const nextDefaultPlanId =
config.defaultPlanId === target.id ? plans[0]?.id || config.defaultPlanId : config.defaultPlanId;
return {
...config,
plans,
defaultPlanId: nextDefaultPlanId,
};
});
};
const updateMembershipFaq = (
index: number,
updater: (faq: MembershipFaqItem) => MembershipFaqItem
) => {
updateMembershipConfigState((config) => {
const faqs = [...config.faqs];
if (!faqs[index]) return config;
faqs[index] = updater(faqs[index]);
return { ...config, faqs };
});
};
const addMembershipFaq = () => {
updateMembershipConfigState((config) => ({
...config,
faqs: [...config.faqs, { question: "", answer: "" }],
}));
};
const removeMembershipFaq = (index: number) => {
updateMembershipConfigState((config) => {
if (config.faqs.length <= 1) {
toast.error("至少保留一条 FAQ");
return config;
}
const faqs = [...config.faqs];
faqs.splice(index, 1);
return { ...config, faqs };
});
};
const addMembershipOverride = (kind: "id" | "email") => {
const key = kind === "id" ? newOverrideUserId.trim() : newOverrideEmail.trim().toLowerCase();
const planId = newOverridePlanId.trim();
if (!key || !planId) {
toast.error("请先填写标识并选择套餐");
return;
}
updateMembershipOverridesState((overrides) => {
if (kind === "id") {
return {
...overrides,
userPlansById: {
...overrides.userPlansById,
[key]: planId,
},
};
}
return {
...overrides,
userPlansByEmail: {
...overrides.userPlansByEmail,
[key]: planId,
},
};
});
if (kind === "id") {
setNewOverrideUserId("");
} else {
setNewOverrideEmail("");
}
};
const removeMembershipOverride = (kind: "id" | "email", key: string) => {
updateMembershipOverridesState((overrides) => {
if (kind === "id") {
const next = { ...overrides.userPlansById };
delete next[key];
return { ...overrides, userPlansById: next };
}
const next = { ...overrides.userPlansByEmail };
delete next[key];
return { ...overrides, userPlansByEmail: next };
});
};
// --- 抠图API配置逻辑 ---
const handleSaveRmbgConfig = async () => {
try {
// 根据选择的提供商验证配置
if (rmbgConfig.provider === "rmbg" && !rmbgConfig.apiUrl.trim()) {
toast.error("请填写RMBG API地址");
return;
}
if (rmbgConfig.provider === "photoroom" && !rmbgConfig.photoroomApiKey.trim()) {
toast.error("请填写PhotoRoom API密钥");
return;
}
if (rmbgConfig.provider === "aoscdn" && !rmbgConfig.aosApiKey.trim()) {
toast.error("请填写AOS API密钥");
return;
}
const response = await fetch("/api/admin/settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
key: "rmbg_api_config",
value: {
provider: rmbgConfig.provider,
apiUrl: rmbgConfig.apiUrl.trim(),
apiKey: rmbgConfig.apiKey.trim() || undefined,
photoroomApiKey: rmbgConfig.photoroomApiKey.trim() || undefined,
aosApiKey: rmbgConfig.aosApiKey.trim() || undefined,
aosApiBaseUrl: rmbgConfig.aosApiBaseUrl.trim() || "https://techsz.aoscdn.com",
aosForegroundType: rmbgConfig.aosForegroundType === "auto" ? "" : rmbgConfig.aosForegroundType,
aosCrop: rmbgConfig.aosCrop ? 1 : 0,
aosFormat: rmbgConfig.aosFormat,
},
}),
});
if (!response.ok) throw new Error("保存失败");
toast.success("抠图API配置保存成功");
} catch (error) {
toast.error("保存抠图API配置失败");
}
};
// --- 变清晰API配置逻辑 ---
const handleSaveEnhanceConfig = async () => {
try {
// 如果启用了备用API,验证备用API配置
if (enhanceConfig.useBackupApi) {
if (!enhanceConfig.backupApiUrl.trim()) {
toast.error("请输入备用API地址");
return;
}
if (!enhanceConfig.backupApiKey.trim()) {
toast.error("请输入备用API密钥");
return;
}
} else {
// 如果使用主API,验证主API配置
if (!enhanceConfig.apiUrl.trim()) {
toast.error("请输入API地址");
return;
}
if (!enhanceConfig.apiKey.trim()) {
toast.error("请输入API密钥");
return;
}
}
const response = await fetch("/api/admin/settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
key: "enhance_api_config",
value: {
apiUrl: enhanceConfig.apiUrl.trim(),
apiKey: enhanceConfig.apiKey.trim(),
type: enhanceConfig.type,
scaleFactor: enhanceConfig.scaleFactor && enhanceConfig.scaleFactor !== "auto" ? Number.parseInt(enhanceConfig.scaleFactor) : undefined,
// 备用API配置
useBackupApi: enhanceConfig.useBackupApi,
backupApiUrl: enhanceConfig.backupApiUrl.trim(),
backupApiKey: enhanceConfig.backupApiKey.trim(),
backupScale: enhanceConfig.backupScale,
backupModel: enhanceConfig.backupModel,
},
}),
});
if (!response.ok) throw new Error("保存失败");
toast.success("变清晰API配置保存成功");
} catch (error) {
toast.error("保存变清晰API配置失败");
}
};
// --- 图像拓展API配置逻辑 ---
const handleSaveExtendConfig = async () => {
try {
if (!extendConfig.apiKey.trim()) {
toast.error("请输入API密钥");
return;
}
const xScale = Number.parseFloat(extendConfig.xScale);
const yScale = Number.parseFloat(extendConfig.yScale);
if (isNaN(xScale) || xScale < 1.0 || xScale > 2.0) {
toast.error("X轴放大倍数必须在1.0-2.0之间");
return;
}
if (isNaN(yScale) || yScale < 1.0 || yScale > 2.0) {
toast.error("Y轴放大倍数必须在1.0-2.0之间");
return;
}
const response = await fetch("/api/admin/settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
key: "extend_api_config",
value: {
apiKey: extendConfig.apiKey.trim(),
xScale: xScale,
yScale: yScale,
},
}),
});
if (!response.ok) throw new Error("保存失败");
toast.success("图像拓展API配置保存成功");
} catch (error) {
toast.error("保存图像拓展API配置失败");
}
};
// --- 去水印API配置逻辑 ---
const handleSaveWatermarkRemoveConfig = async () => {
try {
if (!watermarkRemoveConfig.apiKey.trim()) {
toast.error("请输入PicWish API密钥");
return;
}
const response = await fetch("/api/admin/settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
key: "watermark_remove_config",
value: {
apiKey: watermarkRemoveConfig.apiKey.trim(),
},
}),
});
if (!response.ok) throw new Error("保存失败");
toast.success("去水印API配置保存成功");
} catch (error) {
toast.error("保存去水印API配置失败");
}
};
// --- 角度编辑API配置逻辑 ---
const handleSaveAngleEditConfig = async () => {
try {
if (angleEditConfig.enabled && !angleEditConfig.falApiKey.trim()) {
toast.error("启用角度编辑功能需要配置 fal.ai API Key");
return;
}
const response = await fetch("/api/admin/settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
key: "angle_edit_config",
value: {
enabled: angleEditConfig.enabled,
falApiKey: angleEditConfig.falApiKey.trim(),
loraScale: angleEditConfig.loraScale,
inferenceSteps: angleEditConfig.inferenceSteps,
guidanceScale: angleEditConfig.guidanceScale,
maxImageSize: angleEditConfig.maxImageSize,
timeout: angleEditConfig.timeout,
},
}),
});
if (!response.ok) throw new Error("保存失败");
toast.success("角度编辑API配置保存成功");
} catch (error) {
toast.error("保存角度编辑API配置失败");
}
};
const handleSaveLayerSplitConfig = async () => {
try {
if (layerSplitConfig.enabled) {
if (!layerSplitConfig.replicateApiToken.trim()) {
toast.error("启用图层拆分需要配置 Replicate API Token");
return;
}
if (!layerSplitConfig.replicateModel.trim()) {
toast.error("请填写 Replicate 模型");
return;
}
}
const response = await fetch("/api/admin/settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
key: "layer_split_config",
value: {
enabled: layerSplitConfig.enabled,
provider: "replicate",
replicateApiToken: layerSplitConfig.replicateApiToken.trim(),
replicateModel: layerSplitConfig.replicateModel.trim() || "qwen/qwen-image-layered",
layers: Math.max(2, Math.min(12, Number(layerSplitConfig.layers) || 4)),
resolution: Math.max(256, Math.min(1536, Number(layerSplitConfig.resolution) || 640)),
trueCfgScale: Math.max(1, Math.min(12, Number(layerSplitConfig.trueCfgScale) || 4)),
numInferenceSteps: Math.max(
1,
Math.min(80, Number(layerSplitConfig.numInferenceSteps) || 30)
),
cfgNormalize: layerSplitConfig.cfgNormalize,
useEnPrompt: layerSplitConfig.useEnPrompt,
negativePrompt: layerSplitConfig.negativePrompt.trim(),
timeoutMs: Math.max(30000, Math.min(900000, Number(layerSplitConfig.timeoutMs) || 300000)),
},
}),
});
if (!response.ok) throw new Error("保存失败");
toast.success("图层拆分API配置保存成功");
} catch (error) {
toast.error("保存图层拆分API配置失败");
}
};
const handleSaveThirdPartySqlServerConfig = async () => {
try {
if (thirdPartySqlServerConfig.enabled) {
if (!thirdPartySqlServerConfig.host.trim()) {
toast.error("请填写 SQL Server 主机地址");
return;
}
if (!thirdPartySqlServerConfig.database.trim()) {
toast.error("请填写数据库名称");
return;
}
if (!thirdPartySqlServerConfig.username.trim()) {
toast.error("请填写数据库账号");
return;
}
if (!thirdPartySqlServerConfig.password.trim()) {
toast.error("请填写数据库密码");
return;
}
}
const response = await fetch("/api/admin/settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
key: THIRD_PARTY_SQLSERVER_CONFIG_KEY,
value: {
enabled: thirdPartySqlServerConfig.enabled,
host: thirdPartySqlServerConfig.host.trim(),
port: Math.max(1, Math.min(65535, Number(thirdPartySqlServerConfig.port) || 1433)),
database: thirdPartySqlServerConfig.database.trim(),
username: thirdPartySqlServerConfig.username.trim(),
password: thirdPartySqlServerConfig.password.trim(),
encrypt: thirdPartySqlServerConfig.encrypt,
trustServerCertificate: thirdPartySqlServerConfig.trustServerCertificate,
connectionTimeoutMs: Math.max(
1000,
Math.min(120000, Number(thirdPartySqlServerConfig.connectionTimeoutMs) || 15000)
),
requestTimeoutMs: Math.max(
1000,
Math.min(120000, Number(thirdPartySqlServerConfig.requestTimeoutMs) || 30000)
),
},
}),
});
const payload = await response.json().catch(() => null);
if (!response.ok) {
throw new Error(payload?.cause || payload?.message || payload?.error || "保存失败");
}
toast.success("SQL Server 配置保存成功");
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存 SQL Server 配置失败");
}
};
const handleTestThirdPartySqlServerConfig = async () => {
try {
setIsTestingThirdPartySqlServer(true);
const response = await fetch("/api/admin/sqlserver/test", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
config: {
enabled: thirdPartySqlServerConfig.enabled,
host: thirdPartySqlServerConfig.host.trim(),
port: Math.max(1, Math.min(65535, Number(thirdPartySqlServerConfig.port) || 1433)),
database: thirdPartySqlServerConfig.database.trim(),
username: thirdPartySqlServerConfig.username.trim(),
password: thirdPartySqlServerConfig.password.trim(),
encrypt: thirdPartySqlServerConfig.encrypt,
trustServerCertificate: thirdPartySqlServerConfig.trustServerCertificate,
connectionTimeoutMs: Math.max(
1000,
Math.min(120000, Number(thirdPartySqlServerConfig.connectionTimeoutMs) || 15000)
),
requestTimeoutMs: Math.max(
1000,
Math.min(120000, Number(thirdPartySqlServerConfig.requestTimeoutMs) || 30000)
),
},
}),
});
const payload = await response.json().catch(() => null);
if (!response.ok) {
throw new Error(payload?.cause || payload?.message || payload?.error || "连接测试失败");
}
toast.success(
`连接成功:${payload?.serverName || thirdPartySqlServerConfig.host} / ${payload?.databaseName || thirdPartySqlServerConfig.database}(${payload?.latencyMs || 0}ms)`
);
} catch (error) {
toast.error(error instanceof Error ? error.message : "SQL Server 连接测试失败");
} finally {
setIsTestingThirdPartySqlServer(false);
}
};
// --- 实时画布API配置逻辑 ---
const handleSaveRealtimeCanvasConfig = async () => {
try {
const primaryProviderType = realtimeCanvasConfig.providerType;
const bflApiKey = realtimeCanvasConfig.bflApiKey.trim();
const bflBaseUrl = realtimeCanvasConfig.bflBaseUrl.trim() || DEFAULT_BFL_BASE_URL;
const bflModel = realtimeCanvasConfig.bflModel.trim() || DEFAULT_BFL_REALTIME_MODEL;
const replicateApiToken = realtimeCanvasConfig.replicateApiToken.trim();
const replicateModel =
realtimeCanvasConfig.replicateModel.trim() || "black-forest-labs/flux-2-klein-4b";
const fallbackEnabled = realtimeCanvasConfig.fallbackEnabled;
const fallbackProviderType = realtimeCanvasConfig.fallbackProviderType;
const fallbackBflApiKey = realtimeCanvasConfig.fallbackBflApiKey.trim();
const fallbackBflBaseUrl =
realtimeCanvasConfig.fallbackBflBaseUrl.trim() || DEFAULT_BFL_BASE_URL;
const fallbackBflModel =
realtimeCanvasConfig.fallbackBflModel.trim() || DEFAULT_BFL_REALTIME_MODEL;
const fallbackReplicateApiToken = realtimeCanvasConfig.fallbackReplicateApiToken.trim();
const fallbackReplicateModel =
realtimeCanvasConfig.fallbackReplicateModel.trim() || "black-forest-labs/flux-2-klein-4b";
if (
realtimeCanvasConfig.enabled &&
((primaryProviderType === "bfl" && !bflApiKey) ||
(primaryProviderType === "replicate" && !replicateApiToken))
) {
toast.error(
primaryProviderType === "replicate"
? "启用实时画布功能需要配置 Replicate API Token"
: "启用实时画布功能需要配置 BFL API Key"
);
return;
}
if (
realtimeCanvasConfig.enabled &&
fallbackEnabled &&
((fallbackProviderType === "bfl" && !fallbackBflApiKey) ||
(fallbackProviderType === "replicate" && !fallbackReplicateApiToken))
) {
toast.error(
fallbackProviderType === "replicate"
? "启用备用实时服务需要配置备用 Replicate API Token"
: "启用备用实时服务需要配置备用 BFL API Key"
);
return;
}
if (realtimeCanvasConfig.enabled) {
const probeResponse = await fetch("/api/admin/realtime/model-capability", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
providerType: primaryProviderType,
apiKey: primaryProviderType === "replicate" ? replicateApiToken : bflApiKey,
modelId: primaryProviderType === "replicate" ? replicateModel : bflModel,
baseUrl: primaryProviderType === "replicate" ? undefined : bflBaseUrl,
}),
});
const probeData = await probeResponse.json().catch(() => null);
if (!probeResponse.ok || probeData?.supported === false) {
toast.error(probeData?.message || "模型能力探针失败,已阻止保存");
return;
}
if (fallbackEnabled) {
const fallbackProbeResponse = await fetch("/api/admin/realtime/model-capability", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
providerType: fallbackProviderType,
apiKey:
fallbackProviderType === "replicate"
? fallbackReplicateApiToken
: fallbackBflApiKey,
modelId:
fallbackProviderType === "replicate" ? fallbackReplicateModel : fallbackBflModel,
baseUrl: fallbackProviderType === "replicate" ? undefined : fallbackBflBaseUrl,
}),
});
const fallbackProbeData = await fallbackProbeResponse.json().catch(() => null);
if (!fallbackProbeResponse.ok || fallbackProbeData?.supported === false) {
toast.error(fallbackProbeData?.message || "备用实时服务探针失败,已阻止保存");
return;
}
}
}
const response = await fetch("/api/admin/settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
key: "realtime_canvas_config",
value: {
enabled: realtimeCanvasConfig.enabled,
providerType: primaryProviderType,
bflApiKey,
bflBaseUrl,
bflModel,
replicateApiToken,
replicateModel,
fallbackEnabled,
fallbackProviderType,
fallbackBflApiKey,
fallbackBflBaseUrl,
fallbackBflModel,
fallbackReplicateApiToken,
fallbackReplicateModel,
runwareApiKey: bflApiKey,
falApiKey: bflApiKey,
runwareModel: bflModel,
renderMode: realtimeCanvasConfig.renderMode,
throttleInterval: Math.min(260, Math.max(60, Number(realtimeCanvasConfig.throttleInterval) || 100)),
maxBuffering: Math.min(6, Math.max(1, Number(realtimeCanvasConfig.maxBuffering) || 2)),
maxInputResolution: Math.min(
1536,
Math.max(512, Number(realtimeCanvasConfig.maxInputResolution) || 1024)
),
jpegQuality: Math.min(0.82, Math.max(0.35, Number(realtimeCanvasConfig.jpegQuality) || 0.55)),
previewMegapixels: Math.min(
0.8,
Math.max(0.2, Number(realtimeCanvasConfig.previewMegapixels) || 0.4)
),
detailMegapixels: Math.min(
1.2,
Math.max(0.6, Number(realtimeCanvasConfig.detailMegapixels) || 1.0)
),
outputJpegQuality: Math.min(
90,
Math.max(35, Number(realtimeCanvasConfig.outputJpegQuality) || 62)
),
defaults: {
steps: Math.min(6, Math.max(1, Number(realtimeCanvasConfig.steps) || 3)),
scheduleMu: Math.min(3.5, Math.max(1.5, Number(realtimeCanvasConfig.scheduleMu) || 2.3)),
feedback: Math.min(1, Math.max(0.7, Number(realtimeCanvasConfig.feedback) || 0.95)),
imageSize: realtimeCanvasConfig.imageSize,
},
},
}),
});
if (!response.ok) {
const errorData = await response.json().catch(() => null);
throw new Error(errorData?.message || "保存失败");
}
toast.success("实时画布配置保存成功");
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存实时画布配置失败");
}
};
// --- 批量处理配置逻辑 ---
const handleSaveSceneFusionConfig = async () => {
try {
if (!sceneFusionConfig.defaultPrompt.trim()) {
toast.error("默认提示词不能为空");
return;
}
const response = await fetch("/api/admin/settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
key: "scene_fusion_config",
value: {
enabled: sceneFusionConfig.enabled,
preRmbgEnabled: sceneFusionConfig.preRmbgEnabled,
defaultPrompt: sceneFusionConfig.defaultPrompt.trim(),
maxScenes: Math.max(1, Math.min(10, sceneFusionConfig.maxScenes || 10)),
modelId: sceneFusionConfig.modelId,
},
}),
});
if (!response.ok) throw new Error("保存失败");
toast.success("批量处理配置保存成功");
} catch (error) {
toast.error("保存批量处理配置失败");
}
};
// --- 导航链接逻辑 ---
const resetNavLinkForm = () => {
setNavLinkForm({ id: "", name: "", url: "", order: 0, isEnabled: true, toolboxGroup: 1 });
setEditingNavLink(null);
};
const handleSaveNavLink = async () => {
try {
const method = editingNavLink ? "PUT" : "POST";
const body = editingNavLink ? { ...navLinkForm, id: editingNavLink } : navLinkForm;
const response = await fetch("/api/nav-links", {
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!response.ok) throw new Error("保存失败");
toast.success(editingNavLink ? "链接更新成功" : "链接创建成功");
mutateNavLinks();
setShowNavLinkDialog(false);
resetNavLinkForm();
} catch (error) {
toast.error("保存导航链接失败");
}
};
const handleDeleteNavLink = async (id: string) => {
if (!confirm("确定要删除此链接吗?")) return;
try {
const response = await fetch(`/api/nav-links?id=${id}`, { method: "DELETE" });
if (!response.ok) throw new Error("删除失败");
toast.success("链接删除成功");
mutateNavLinks();
} catch (error) {
toast.error("删除链接失败");
}
};
// --- 视频教程管理逻辑 ---
const resetTutorialVideoForm = () => {
setTutorialVideoForm({
title: "",
videoUrl: "",
coverUrl: "",
description: "",
order: 0,
isEnabled: true,
});
setEditingTutorialVideo(null);
};
const handleSaveTutorialVideo = async () => {
try {
const method = editingTutorialVideo ? "PUT" : "POST";
const body = editingTutorialVideo
? { ...tutorialVideoForm, id: editingTutorialVideo }
: tutorialVideoForm;
const response = await fetch("/api/tutorial-videos", {
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!response.ok) throw new Error("保存失败");
toast.success(editingTutorialVideo ? "视频更新成功" : "视频创建成功");
mutateTutorialVideos();
setShowTutorialVideoDialog(false);
resetTutorialVideoForm();
} catch (error) {
toast.error("保存视频失败");
}
};
const handleDeleteTutorialVideo = async (id: string) => {
if (!confirm("确定要删除此视频吗?")) return;
try {
const response = await fetch(`/api/tutorial-videos?id=${id}`, { method: "DELETE" });
if (!response.ok) throw new Error("删除失败");
toast.success("视频删除成功");
mutateTutorialVideos();
} catch (error) {
toast.error("删除视频失败");
}
};
// --- 认证设置逻辑 ---
const handleSaveAuthSettings = async () => {
try {
const response = await fetch("/api/auth-settings", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(authSettings),
});
if (!response.ok) throw new Error("保存失败");
toast.success("认证设置保存成功");
mutateAuthSettings();
} catch (error) {
toast.error("保存认证设置失败");
}
};
// --- AI 提供商逻辑 ---
const resetProviderForm = () => {
setProviderForm({
id: "",
name: "",
type: "openai",
baseUrl: getProviderDefaultBaseUrl("openai"),
apiKey: "",
isEnabled: true,
});
setEditingProvider(null);
};
const handleSaveProvider = async () => {
try {
const method = editingProvider ? "PUT" : "POST";
const isNewProvider = !editingProvider;
const isVolcanoVideo = providerForm.type === 'volcano-video';
const isLemonProvider =
providerForm.type === "openai" &&
isLemonApiBaseUrl(providerForm.baseUrl || getProviderDefaultBaseUrl(providerForm.type));
const defaultBaseUrl = getProviderDefaultBaseUrl(providerForm.type);
const normalizedBaseUrl = providerForm.baseUrl.trim();
let requestData: any = editingProvider ? { ...providerForm, id: editingProvider } : providerForm;
if (
defaultBaseUrl &&
(!normalizedBaseUrl || !isHttpUrlLike(normalizedBaseUrl) || normalizedBaseUrl.includes("@"))
) {
requestData.baseUrl = defaultBaseUrl;
} else {
requestData.baseUrl = normalizedBaseUrl;
}
if (editingProvider && !providerForm.apiKey) {
const { apiKey, ...restData } = requestData;
requestData = restData;
}
const response = await fetch("/api/admin/ai-providers", {
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(requestData),
});
if (!response.ok) throw new Error("保存失败");
const data = await response.json();
const providerId = data.provider?.id || editingProvider;
toast.success(editingProvider ? "提供商更新成功" : "提供商创建成功");
mutateProviders();
setShowProviderDialog(false);
resetProviderForm();
// 🆕 如果是新创建的火山引擎视频Provider,自动添加预设模型
if (isNewProvider && isVolcanoVideo && providerId) {
toast.info("正在自动添加预设模型...");
try {
const modelsResponse = await fetch("/api/admin/volcano-video-models", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ providerId }),
});
const modelsData = await modelsResponse.json();
if (modelsResponse.ok && modelsData.addedCount > 0) {
toast.success(`✅ 已自动添加 ${modelsData.addedCount} 个预设模型!`);
mutateModels(); // 刷新模型列表
}
} catch (modelError) {
console.error('[Auto Add Models] Error:', modelError);
toast.warning('预设模型添加失败,请手动点击"快速添加预设模型"按钮');
}
}
if (isNewProvider && isLemonProvider && providerId) {
try {
await handleQuickAddLemonModels(providerId);
} catch (modelError) {
console.error("[Auto Add Lemon Models] Error:", modelError);
toast.warning('LemonAPI 预设模型添加失败,请手动点击"快速添加 Gemini 预设模型"按钮');
}
}
} catch (error) {
toast.error("保存提供商失败");
}
};
const handleDeleteProvider = async (id: string) => {
if (!confirm("确定删除?关联模型也会被删除。")) return;
try {
const response = await fetch(`/api/admin/ai-providers?id=${id}`, { method: "DELETE" });
if (!response.ok) throw new Error("删除失败");
toast.success("提供商删除成功");
mutateProviders();
mutateModels();
} catch (error) {
toast.error("删除提供商失败");
}
};
const handleSaveTripo3dConfig = async () => {
try {
const trimmedName = tripo3dConfig.name.trim() || "Tripo3D";
const trimmedBaseUrl =
tripo3dConfig.baseUrl.trim() || getProviderDefaultBaseUrl("tripo-3d");
const hasProvider = Boolean(tripo3dConfig.providerId);
if (!hasProvider && !tripo3dConfig.apiKey.trim()) {
toast.error("请填写 Tripo3D API Key");
return;
}
const requestData: Record<string, any> = hasProvider
? {
id: tripo3dConfig.providerId,
name: trimmedName,
type: "tripo-3d",
baseUrl: trimmedBaseUrl,
isEnabled: tripo3dConfig.isEnabled,
}
: {
name: trimmedName,
type: "tripo-3d",
baseUrl: trimmedBaseUrl,
apiKey: tripo3dConfig.apiKey.trim(),
isEnabled: tripo3dConfig.isEnabled,
};
if (hasProvider && tripo3dConfig.apiKey.trim()) {
requestData.apiKey = tripo3dConfig.apiKey.trim();
}
const response = await fetch("/api/admin/ai-providers", {
method: hasProvider ? "PUT" : "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(requestData),
});
if (!response.ok) {
throw new Error("保存失败");
}
toast.success("Tripo3D 配置保存成功");
setTripo3dConfig((prev) => ({ ...prev, apiKey: "" }));
mutateProviders();
} catch (error) {
toast.error("保存 Tripo3D 配置失败");
}
};
const handleSaveMoonshotDetailAgentConfig = async () => {
try {
const trimmedBaseUrl =
moonshotDetailAgentConfig.baseUrl.trim() ||
DETAIL_AGENT_LOCAL_MOONSHOT_BASE_URL;
const trimmedModelId =
moonshotDetailAgentConfig.modelId.trim() ||
DETAIL_AGENT_LOCAL_MOONSHOT_MODEL_ID;
const trimmedDisplayName =
moonshotDetailAgentConfig.displayName.trim() || "官方 Kimi K2.5";
const trimmedProviderName =
moonshotDetailAgentConfig.providerName.trim() || "Moonshot AI (Official)";
if (!isMoonshotOfficialBaseUrl(trimmedBaseUrl)) {
toast.error("请使用 Moonshot 官方 API 地址:https://api.moonshot.cn/v1");
return;
}
if (!trimmedModelId) {
toast.error("请填写官方 Kimi K2.5 的 API 模型 ID");
return;
}
if (!moonshotDetailAgentConfig.providerId && !moonshotDetailAgentConfig.apiKey.trim()) {
toast.error("请填写 Moonshot 官方 API Key");
return;
}
const settingResponse = await fetch("/api/admin/settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
key: DETAIL_AGENT_LOCAL_MOONSHOT_SETTING_KEY,
value: {
enabled: moonshotDetailAgentConfig.isEnabled,
providerName: trimmedProviderName,
baseUrl: trimmedBaseUrl,
apiKey: moonshotDetailAgentConfig.apiKey.trim(),
modelId: trimmedModelId,
displayName: trimmedDisplayName,
},
}),
});
const settingResult = await settingResponse.json().catch(() => ({}));
if (!settingResponse.ok) {
throw new Error(String(settingResult?.message || "保存 Moonshot 本地AI配置失败"));
}
const settingsResponse = await fetch("/api/site-settings", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
canvasDetailAgentModelId: DETAIL_AGENT_LOCAL_MOONSHOT_OPTION_ID,
}),
});
if (!settingsResponse.ok) {
throw new Error("写入 AI详情 Agent 专用模型设置失败");
}
setSiteSettings((prev) => ({
...prev,
canvasDetailAgentModelId: DETAIL_AGENT_LOCAL_MOONSHOT_OPTION_ID,
}));
setMoonshotDetailAgentConfig((prev) => ({
...prev,
providerName: trimmedProviderName,
baseUrl: trimmedBaseUrl,
modelId: trimmedModelId,
displayName: trimmedDisplayName,
apiKey: "",
}));
mutateMoonshotDetailAgentSetting();
mutateSiteSettings();
toast.success("Moonshot 官方 Kimi K2.5 已绑定为 AI详情 Agent 专用模型");
} catch (error) {
console.error("[Moonshot Detail Agent Config] Error:", error);
toast.error(error instanceof Error ? error.message : "保存 Moonshot Kimi 配置失败");
}
};
// 快速添加火山引擎视频预设模型
const handleQuickAddVolcanoModels = async (providerId: string) => {
if (!confirm("将为此服务商添加4个预设模型:\n- Seedance Pro (全功能)\n- Seedance Pro Fast (快速)\n- Seedance Lite I2V (图生视频)\n- Seedance Lite T2V (文生视频)\n\n继续吗?")) {
return;
}
try {
toast.info("正在添加预设模型...");
const response = await fetch("/api/admin/volcano-video-models", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ providerId }),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || "添加失败");
}
const { addedCount, errors } = data;
if (addedCount > 0) {
toast.success(`成功添加 ${addedCount} 个预设模型!`);
mutateModels(); // 刷新模型列表
}
if (errors && errors.length > 0) {
toast.warning(`${errors.length} 个模型添加失败(可能已存在)`);
}
} catch (error: any) {
console.error('[Quick Add Models] Error:', error);
toast.error(error.message || "添加预设模型失败");
}
};
const handleUseLemonProviderPreset = () => {
setProviderForm({
id: "",
name: LEMON_PROVIDER_PRESET.name,
type: LEMON_PROVIDER_PRESET.type,
baseUrl: LEMON_PROVIDER_PRESET.baseUrl,
apiKey: "",
isEnabled: true,
});
setEditingProvider(null);
setShowProviderDialog(true);
};
const handleQuickAddLemonModels = async (providerId: string) => {
try {
toast.info("正在添加 LemonAPI Gemini 预设模型...");
let addedCount = 0;
let duplicateCount = 0;
for (const preset of LEMON_MODEL_PRESETS) {
const response = await fetch("/api/admin/ai-models", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
providerId,
modelId: preset.modelId,
displayName: preset.displayName,
description: preset.description,
supportsVision: preset.supportsVision,
supportsImageGen: preset.supportsImageGen,
isEnabled: true,
isHidden: false,
isDefault: false,
isAgentModel: false,
agentCodingModelId: "",
isMultimodal: preset.isMultimodal,
sortOrder: 0,
}),
});
if (response.ok) {
addedCount += 1;
continue;
}
duplicateCount += 1;
}
if (addedCount > 0) {
toast.success(`成功添加 ${addedCount} 个 LemonAPI 预设模型`);
mutateModels();
}
if (addedCount === 0 && duplicateCount > 0) {
toast.warning("LemonAPI 预设模型可能已存在");
}
} catch (error) {
console.error("[Quick Add Lemon Models] Error:", error);
toast.error("添加 LemonAPI 预设模型失败");
}
};
// --- AI 模型逻辑 ---
const resetModelForm = () => {
setModelForm({
id: "",
providerId: "",
modelId: "",
displayName: "",
avatarUrl: "",
description: "",
supportsVision: false,
supportsImageGen: false,
isEnabled: true,
isHidden: false,
isDefault: false,
isAgentModel: false,
agentCodingModelId: "",
isMultimodal: false,
sortOrder: 0,
fallbackModelId1: "",
fallbackModelId2: "",
excludeFromFailover: false,
imageGenConfig: {
maxImages: 2,
supportsSize: false,
supportsPromptExtend: false,
supportsNegativePrompt: false,
supportsWatermark: false,
supportsSeed: false,
creditCostPerImage: 0,
creditCostPerVideo: 0,
enableLoadingPlaceholder: true,
loadingColor: "purple",
},
});
setEditingModel(null);
};
const resizeImageToSquare = (file: File, size = 400): Promise<File> =>
new Promise((resolve, reject) => {
const image = new Image();
const objectUrl = URL.createObjectURL(file);
image.onload = () => {
try {
const canvas = document.createElement("canvas");
canvas.width = size;
canvas.height = size;
const context = canvas.getContext("2d");
if (!context) {
URL.revokeObjectURL(objectUrl);
reject(new Error("无法处理图片"));
return;
}
const minSide = Math.min(image.width, image.height);
const sx = (image.width - minSide) / 2;
const sy = (image.height - minSide) / 2;
context.imageSmoothingEnabled = true;
context.imageSmoothingQuality = "high";
context.drawImage(image, sx, sy, minSide, minSide, 0, 0, size, size);
canvas.toBlob(
(blob) => {
URL.revokeObjectURL(objectUrl);
if (!blob) {
reject(new Error("头像压缩失败"));
return;
}
resolve(
new File([blob], `model-avatar-${Date.now()}.png`, {
type: "image/png",
})
);
},
"image/png",
0.95
);
} catch (error) {
URL.revokeObjectURL(objectUrl);
reject(error);
}
};
image.onerror = () => {
URL.revokeObjectURL(objectUrl);
reject(new Error("图片读取失败"));
};
image.src = objectUrl;
});
const handleModelAvatarUpload = async (file: File) => {
if (!file.type.startsWith("image/")) {
toast.error("请上传图片文件");
return;
}
try {
setModelAvatarUploading(true);
const resizedFile = await resizeImageToSquare(file, 400);
const formData = new FormData();
formData.append("file", resizedFile);
const response = await fetchUploadWithRetry(formData);
const result = await response.json();
if (!response.ok || !result?.url) {
throw new Error(result?.error || "上传失败");
}
setModelForm((prev) => ({ ...prev, avatarUrl: result.url }));
toast.success("模型头像上传成功");
} catch (error: any) {
toast.error(error?.message || "模型头像上传失败");
} finally {
setModelAvatarUploading(false);
}
};
const handleContactQrUpload = async (file: File) => {
if (!file.type.startsWith("image/")) {
toast.error("请上传图片文件");
return;
}
try {
setContactQrUploading(true);
const formData = new FormData();
formData.append("file", file);
const response = await fetchUploadWithRetry(formData);
const result = await response.json();
if (!response.ok || !result?.url) {
throw new Error(result?.error || "上传失败");
}
setSiteSettings((prev) => ({
...prev,
contactConfig: {
...prev.contactConfig,
wechatQrUrl: result.url,
},
}));
toast.success("微信二维码上传成功");
} catch (error: any) {
toast.error(error?.message || "微信二维码上传失败");
} finally {
setContactQrUploading(false);
}
};
const handleSaveModel = async () => {
try {
// 🆕 检查是否是火山引擎视频Provider
const selectedProvider = providersData?.providers?.find(p => p.id === modelForm.providerId);
const isVolcanoVideo = selectedProvider?.type === 'volcano-video';
// 如果是火山引擎视频且不是编辑模式,批量创建模型
if (isVolcanoVideo && !editingModel) {
toast.info("正在添加火山引擎视频模型(1个主模型 + 4个子模型)...");
const response = await fetch("/api/admin/volcano-video-models", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
providerId: modelForm.providerId,
// ✅ 传递所有模型配置(管理员可以自定义显示名称和描述)
models: volcanoVideoModels,
}),
});
const data = await response.json();
if (!response.ok) throw new Error(data.error || "批量添加失败");
toast.success(`✅ 成功添加 ${data.addedCount} 个模型(1个主模型 + ${data.addedCount - 1} 个子模型)!`);
mutateModels();
setShowModelDialog(false);
resetModelForm();
return;
}
// 普通模型的单个创建/编辑逻辑
const method = editingModel ? "PUT" : "POST";
const body = editingModel ? { ...modelForm, id: editingModel } : modelForm;
const response = await fetch("/api/admin/ai-models", {
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!response.ok) throw new Error("保存失败");
toast.success(editingModel ? "模型更新成功" : "模型创建成功");
mutateModels();
setShowModelDialog(false);
resetModelForm();
} catch (error: any) {
console.error('[Save Model] Error:', error);
toast.error(error.message || "保存模型失败");
}
};
const handleDeleteModel = async (id: string) => {
if (!confirm("确定删除此模型?")) return;
try {
const response = await fetch(`/api/admin/ai-models?id=${id}`, { method: "DELETE" });
if (!response.ok) throw new Error("删除失败");
toast.success("模型删除成功");
mutateModels();
} catch (error) {
toast.error("删除模型失败");
}
};
// --- 存储配置逻辑 ---
const resetStorageForm = () => {
setStorageForm({
id: "",
name: "",
type: "s3",
endpoint: "",
region: "",
bucket: "",
accessKeyId: "",
secretAccessKey: "",
publicUrl: "",
isDefault: false,
isEnabled: true,
});
setEditingStorage(null);
};
const handleSaveStorage = async () => {
try {
const method = editingStorage ? "PUT" : "POST";
const body: any = editingStorage
? { ...storageForm, id: editingStorage }
: { ...storageForm };
const normalizeUrl = (value?: string) => {
if (!value) return "";
return value.trim().replace(/\/+$/, "");
};
const endpoint = normalizeUrl(body.endpoint);
body.endpoint = /aliyuncs\.com/i.test(endpoint)
? `https://${endpoint.replace(/^https?:\/\//i, "")}`
: endpoint;
body.region = String(body.region || "").trim();
body.bucket = String(body.bucket || "").trim();
body.accessKeyId = String(body.accessKeyId || "").trim();
body.secretAccessKey = String(body.secretAccessKey || "").trim();
body.publicUrl = normalizeUrl(body.publicUrl);
// 兼容处理:如果是阿里云 OSS,转换为 s3 类型提交
if (body.type === "aliyun-oss") {
body.type = "s3";
}
// 编辑状态下,留空密钥代表不修改
if (editingStorage) {
if (!body.accessKeyId) delete body.accessKeyId;
if (!body.secretAccessKey) delete body.secretAccessKey;
}
const response = await fetch("/api/admin/storage", {
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!response.ok) throw new Error("保存失败");
toast.success(editingStorage ? "存储配置更新成功" : "存储配置创建成功");
mutateStorage();
setShowStorageDialog(false);
resetStorageForm();
} catch (error) {
toast.error("保存存储配置失败");
}
};
const handleDeleteStorage = async (id: string) => {
if (!confirm("确定删除此存储配置?")) return;
try {
const response = await fetch(`/api/admin/storage?id=${id}`, { method: "DELETE" });
if (!response.ok) throw new Error("删除失败");
toast.success("存储配置删除成功");
mutateStorage();
} catch (error) {
toast.error("删除存储配置失败");
}
};
return (
<div className="container mx-auto p-6">
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="grid w-full grid-cols-11">
<TabsTrigger value="users">
<User className="mr-2 h-4 w-4" />
用户管理
</TabsTrigger>
<TabsTrigger value="images">
<ImageIcon className="mr-2 h-4 w-4" />
生图/视频记录
</TabsTrigger>
<TabsTrigger value="analytics">
<BarChart3 className="mr-2 h-4 w-4" />
数据分析
</TabsTrigger>
<TabsTrigger value="site">
<Settings className="mr-2 h-4 w-4" />
网站设置
</TabsTrigger>
<TabsTrigger value="nav">
<Menu className="mr-2 h-4 w-4" />
导航设置
</TabsTrigger>
<TabsTrigger value="questions">
<MessageSquare className="mr-2 h-4 w-4" />
快捷短语
</TabsTrigger>
<TabsTrigger value="auth">
<Key className="mr-2 h-4 w-4" />
认证设置
</TabsTrigger>
<TabsTrigger value="notification">
<Bell className="mr-2 h-4 w-4" />
系统通知
</TabsTrigger>
<TabsTrigger value="ai">
<Bot className="mr-2 h-4 w-4" />
AI 模型
</TabsTrigger>
<TabsTrigger value="storage">
<HardDrive className="mr-2 h-4 w-4" />
存储配置
</TabsTrigger>
<TabsTrigger value="rmbg">
<Scissors className="mr-2 h-4 w-4" />
本地AI设置
</TabsTrigger>
</TabsList>
{/* 用户管理 */}
<TabsContent value="users" className="mt-6">
<Card>
<CardHeader>
<CardTitle className="text-xl">用户管理</CardTitle>
<CardDescription>管理所有注册用户及其权限与积分</CardDescription>
</CardHeader>
<CardContent>
{/* 搜索框和同步按钮 */}
<div className="mb-4 flex flex-col sm:flex-row gap-3 sm:items-center sm:justify-between">
<Input
type="text"
placeholder="搜索用户(邮箱或用户名)..."
value={userSearch}
onChange={(e) => handleUserSearch(e.target.value)}
className="max-w-md"
/>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setShowBatchLimitDialog(true)}
className="flex items-center gap-2"
>
<Settings className="h-4 w-4" />
批量设置限额
</Button>
<Button
variant="outline"
size="sm"
onClick={async () => {
try {
toast.info("正在从钉钉同步用户信息,可能需要几分钟...");
const res = await fetch("/api/admin/sync-dingtalk", { method: "POST" });
const data = await res.json();
if (res.ok) {
toast.success(
`同步完成!匹配 ${data.stats.matched}/${data.stats.total} 个用户,` +
`更新 ${data.stats.synced} 个,跳过 ${data.stats.skipped} 个` +
(data.stats.errors > 0 ? `,失败 ${data.stats.errors} 个` : "")
);
mutateUsers(); // 刷新用户列表
} else {
toast.error(data.error || "同步失败");
}
} catch (error) {
toast.error("同步请求失败");
}
}}
className="flex items-center gap-2"
>
<RefreshCw className="h-4 w-4" />
同步钉钉用户
</Button>
</div>
</div>
{/* 用户列表 */}
<div className="space-y-4 mb-4">
{usersData?.users?.map((user) => (
<div key={user.id} className="flex items-center justify-between p-4 border rounded-lg">
<div className="flex-1">
<div className="font-medium">{user.name || user.email}</div>
<div className="text-sm text-muted-foreground">
邮箱: {user.email}
</div>
<div className="text-sm text-muted-foreground">
部门: {user.department || "未设置"}
</div>
<div className="text-sm text-muted-foreground">
角色: {ROLE_LABELS[user.role] ?? user.role} | 状态: {user.isActive ? "已激活" : "未激活"}
</div>
<div className="text-sm text-muted-foreground">
标签: {user.isHighVip ? "高级VIP" : "普通"}
</div>
<div className="text-sm text-muted-foreground">
月度出图限额: {user.monthlyImageLimit ? `${user.monthlyImageLimit} 张/月` : "无限制"}
</div>
<div className="text-sm text-muted-foreground">
剩余积分: {Number(user.pointsBalance || 0).toLocaleString()} | 累计赠送:{" "}
{Number(user.pointsGiftedTotal || 0).toLocaleString()}
</div>
</div>
<div className="flex items-center gap-2">
{/* 配额设置 */}
<div className="flex items-center gap-1">
<Input
type="number"
min="0"
placeholder="无限制"
className="w-20 h-8 text-xs"
defaultValue={user.monthlyImageLimit ?? ""}
onBlur={(e) => {
const value = e.target.value ? parseInt(e.target.value, 10) : null;
if (value !== user.monthlyImageLimit) {
handleUpdateUser(user.id, { monthlyImageLimit: value });
}
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
const value = (e.target as HTMLInputElement).value
? parseInt((e.target as HTMLInputElement).value, 10)
: null;
if (value !== user.monthlyImageLimit) {
handleUpdateUser(user.id, { monthlyImageLimit: value });
}
}
}}
/>
<span className="text-xs text-muted-foreground">张</span>
</div>
<Select
value={user.role}
onValueChange={(value) => handleUpdateUser(user.id, { role: value as any })}
>
<SelectTrigger className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="guest">访客</SelectItem>
<SelectItem value="regular">普通</SelectItem>
<SelectItem value="admin">管理员</SelectItem>
<SelectItem value="agent_admin">智能体管理员</SelectItem>
</SelectContent>
</Select>
<Button
variant={user.isActive ? "outline" : "default"}
size="sm"
onClick={() => handleUpdateUser(user.id, { isActive: !user.isActive })}
>
{user.isActive ? "禁用" : "激活"}
</Button>
<Button
variant={user.isHighVip ? "default" : "outline"}
size="sm"
onClick={() => handleUpdateUser(user.id, { isHighVip: !user.isHighVip })}
>
{user.isHighVip ? "VIP开" : "VIP关"}
</Button>
<div className="flex items-center gap-1">
<Input
type="number"
min="1"
placeholder="赠送积分"
className="h-8 w-24 text-xs"
value={giftPointsDraftByUser[user.id] ?? ""}
onChange={(e) =>
setGiftPointsDraftByUser((prev) => ({
...prev,
[user.id]: e.target.value,
}))
}
onKeyDown={(e) => {
if (e.key === "Enter") {
void handleGiftPoints(user.id);
}
}}
/>
<Button
variant="secondary"
size="sm"
onClick={() => handleGiftPoints(user.id)}
>
赠送
</Button>
</div>
<div className="flex items-center gap-1">
<Input
type="number"
min="1"
placeholder="扣减积分"
className="h-8 w-24 text-xs"
value={deductPointsDraftByUser[user.id] ?? ""}
onChange={(e) =>
setDeductPointsDraftByUser((prev) => ({
...prev,
[user.id]: e.target.value,
}))
}
onKeyDown={(e) => {
if (e.key === "Enter") {
void handleDeductPoints(user.id);
}
}}
/>
<Button
variant="outline"
size="sm"
onClick={() => handleDeductPoints(user.id)}
>
扣减
</Button>
</div>
<Button variant="destructive" size="sm" onClick={() => handleDeleteUser(user.id)}>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
))}
</div>
{/* 分页控件 */}
{usersData && usersData.totalPages > 1 && (
<div className="flex items-center justify-between">
<div className="text-sm text-muted-foreground">
显示 {((userPage - 1) * userLimit) + 1} - {Math.min(userPage * userLimit, usersData.total)} 条,共 {usersData.total} 条
</div>
<div className="flex items-center gap-2">
{/* 首页按钮 */}
<Button
variant="outline"
size="sm"
onClick={() => setUserPage(1)}
disabled={userPage === 1}
title="首页"
>
首页
</Button>
{/* 上一页按钮 */}
<Button
variant="outline"
size="sm"
onClick={() => setUserPage(p => Math.max(1, p - 1))}
disabled={userPage === 1}
>
上一页
</Button>
{/* 页码显示和跳转 */}
<div className="flex items-center gap-2">
<span className="text-sm whitespace-nowrap">第</span>
<Input
type="number"
min={1}
max={usersData.totalPages}
value={userPage}
onChange={(e) => {
const page = Number.parseInt(e.target.value, 10);
if (page >= 1 && page <= usersData.totalPages) {
setUserPage(page);
}
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
const page = Number.parseInt(e.currentTarget.value, 10);
if (page >= 1 && page <= usersData.totalPages) {
setUserPage(page);
} else {
e.currentTarget.value = userPage.toString();
}
}
}}
className="w-16 h-8 text-center"
/>
<span className="text-sm whitespace-nowrap">/ {usersData.totalPages} 页</span>
</div>
{/* 下一页按钮 */}
<Button
variant="outline"
size="sm"
onClick={() => setUserPage(p => Math.min(usersData.totalPages, p + 1))}
disabled={userPage === usersData.totalPages}
>
下一页
</Button>
{/* 末页按钮 */}
<Button
variant="outline"
size="sm"
onClick={() => setUserPage(usersData.totalPages)}
disabled={userPage === usersData.totalPages}
title="末页"
>
末页
</Button>
</div>
</div>
)}
</CardContent>
</Card>
</TabsContent>
{/* 🆕 生图记录 */}
<TabsContent value="images" className="mt-6">
<Card>
<CardHeader>
<CardTitle>所有生图/视频记录</CardTitle>
<CardDescription>查看所有用户的 AI 图片与视频生成历史记录</CardDescription>
</CardHeader>
<CardContent>
{/* 筛选条件 */}
<div className="flex flex-wrap gap-4 mb-6">
<div className="flex-1 min-w-[200px]">
<Label className="text-xs mb-1 block">搜索用户</Label>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="输入用户名或邮箱..."
value={imageSearch}
onChange={(e) => { setImageSearch(e.target.value); setImagePage(1); }}
className="pl-9"
/>
</div>
</div>
<div className="w-[150px]">
<Label className="text-xs mb-1 block">开始日期</Label>
<Input
type="date"
value={imageStartDate}
onChange={(e) => { setImageStartDate(e.target.value); setImagePage(1); }}
/>
</div>
<div className="w-[150px]">
<Label className="text-xs mb-1 block">结束日期</Label>
<Input
type="date"
value={imageEndDate}
onChange={(e) => { setImageEndDate(e.target.value); setImagePage(1); }}
/>
</div>
<div className="flex items-end">
<Button
variant="outline"
size="sm"
onClick={() => {
setImageSearch("");
setImageStartDate("");
setImageEndDate("");
setImagePage(1);
}}
>
重置
</Button>
</div>
</div>
{/* 统计信息 */}
<div className="text-sm text-muted-foreground mb-4">
共 {imagesData?.pagination.total || 0} 条记录
{imagesData?.pagination.totalImages !== undefined ||
imagesData?.pagination.totalVideos !== undefined
? `(图片 ${imagesData?.pagination.totalImages || 0} / 视频 ${imagesData?.pagination.totalVideos || 0})`
: ""}
</div>
{/* 图片网格 */}
<div className="grid grid-cols-[repeat(auto-fill,minmax(80px,1fr))] gap-3">
{imagesData?.images?.map((img) => (
<div
key={img.id}
className="group relative cursor-pointer"
onClick={() => setSelectedImage(img)}
>
<div className="w-[37px] h-[37px] mx-auto rounded border overflow-hidden bg-muted relative">
{img.recordType === "video" ? (
<video
src={img.videoUrl || img.originalUrl}
className="w-full h-full object-cover"
muted
playsInline
preload="metadata"
/>
) : (
<img
src={img.thumbnailUrl}
alt={img.prompt || "AI生成的图片"}
className="w-full h-full object-cover"
loading="lazy"
/>
)}
{img.recordType === "video" ? (
<div className="absolute inset-0 flex items-center justify-center bg-black/25">
<Video className="h-3 w-3 text-white" />
</div>
) : null}
{/* 精选图标 */}
{img.recordType !== "video" && img.isFeatured && (
<div className="absolute -top-1 -right-1 w-4 h-4 bg-gradient-to-r from-yellow-400 to-orange-500 rounded-full flex items-center justify-center shadow">
<Sparkles className="h-2.5 w-2.5 text-white" />
</div>
)}
</div>
<div className="text-[10px] text-center mt-1 truncate text-muted-foreground">
{img.userName || img.userEmail?.split("@")[0]}
</div>
<div className="text-[9px] text-center text-muted-foreground/60">
{formatBeijingTime(img.createdAt, { month: "numeric", day: "numeric" })}
</div>
</div>
))}
</div>
{/* 分页 */}
{imagesData && imagesData.pagination.totalPages > 1 && (
<div className="flex items-center justify-between mt-6">
<div className="text-sm text-muted-foreground">
第 {imagesData.pagination.page} / {imagesData.pagination.totalPages} 页
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setImagePage(1)}
disabled={imagePage === 1}
>
首页
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setImagePage(p => Math.max(1, p - 1))}
disabled={imagePage === 1}
>
上一页
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setImagePage(p => Math.min(imagesData.pagination.totalPages, p + 1))}
disabled={imagePage === imagesData.pagination.totalPages}
>
下一页
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setImagePage(imagesData.pagination.totalPages)}
disabled={imagePage === imagesData.pagination.totalPages}
>
末页
</Button>
</div>
</div>
)}
</CardContent>
</Card>
{/* 图片详情弹窗 */}
<Dialog open={!!selectedImage} onOpenChange={(open) => !open && setSelectedImage(null)}>
<DialogContent className="max-w-3xl max-h-[85vh] overflow-y-auto !bg-white !dark:bg-zinc-950">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
{selectedImage?.recordType === "video" ? "视频详情" : "图片详情"}
{selectedImage?.recordType !== "video" && selectedImage?.isFeatured && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 bg-gradient-to-r from-yellow-400 to-orange-500 text-white text-xs rounded-full">
<Sparkles className="h-3 w-3" />
精选
</span>
)}
</DialogTitle>
</DialogHeader>
{selectedImage && (
<div className="space-y-4">
<div className="flex justify-center relative">
{selectedImage.recordType === "video" ? (
<video
src={selectedImage.videoUrl || selectedImage.originalUrl}
className="max-h-[400px] w-full rounded-lg bg-black object-contain"
controls
preload="metadata"
/>
) : (
<img
src={selectedImage.originalUrl}
alt={selectedImage.prompt || "AI生成的图片"}
className="max-h-[400px] object-contain rounded-lg"
/>
)}
</div>
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<Label className="text-muted-foreground">用户</Label>
<p>{selectedImage.userName || selectedImage.userEmail}</p>
</div>
<div>
<Label className="text-muted-foreground">部门</Label>
<p>{selectedImage.userDepartment || "未设置"}</p>
</div>
<div>
<Label className="text-muted-foreground">生成时间</Label>
<p>{new Date(selectedImage.createdAt).toLocaleString("zh-CN")}</p>
</div>
<div>
<Label className="text-muted-foreground">
{selectedImage.recordType === "video" ? "视频分辨率" : "图片尺寸"}
</Label>
<p>{selectedImage.imageSize || "未知"}</p>
</div>
<div>
<Label className="text-muted-foreground">模型</Label>
<p>
{analyticsData?.modelDetailStats?.find(
(s) => s.modelId === selectedImage.modelId
)?.modelName ||
selectedImage.modelId ||
"未知"}
</p>
</div>
{selectedImage.recordType !== "video" ? (
<div>
<Label className="text-muted-foreground">精选分类</Label>
<p>{selectedImage.featuredCategory || "未设置"}</p>
</div>
) : null}
{selectedImage.recordType !== "video" && selectedImage.isFeatured && selectedImage.featuredAt && (
<div>
<Label className="text-muted-foreground">精选时间</Label>
<p>{new Date(selectedImage.featuredAt).toLocaleString("zh-CN")}</p>
</div>
)}
<div className="col-span-2">
<Label className="text-muted-foreground">提示词</Label>
<p className="mt-1">{selectedImage.prompt || "无"}</p>
</div>
</div>
{selectedImage.recordType !== "video" ? (
<div className="flex flex-wrap items-center justify-between gap-3 border-t pt-2">
<div className="flex items-center gap-2">
<Label className="text-xs text-muted-foreground">分类</Label>
<Select
value={selectedImage.featuredCategory || "__none__"}
onValueChange={(value) =>
setSelectedImage({
...selectedImage,
featuredCategory: value === "__none__" ? null : value,
})
}
>
<SelectTrigger className="h-8 w-[180px]">
<SelectValue placeholder="选择分类" />
</SelectTrigger>
<SelectContent className="!bg-white !dark:bg-zinc-950">
<SelectItem value="__none__">未分类</SelectItem>
{featuredCategoryOptions.map((category) => (
<SelectItem key={category} value={category}>
{category}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
variant="outline"
size="sm"
onClick={async () => {
try {
const res = await fetch("/api/admin/images", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
imageId: selectedImage.id,
featuredCategory: selectedImage.featuredCategory,
}),
});
if (!res.ok) {
throw new Error("更新精选分类失败");
}
toast.success("精选分类已更新");
mutateImages();
} catch (error) {
toast.error(error instanceof Error ? error.message : "更新精选分类失败");
}
}}
>
保存分类
</Button>
</div>
<Button
variant={selectedImage.isFeatured ? "outline" : "default"}
className={
selectedImage.isFeatured
? ""
: "bg-gradient-to-r from-yellow-400 to-orange-500 hover:from-yellow-500 hover:to-orange-600"
}
onClick={async () => {
try {
const res = await fetch("/api/admin/images", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
imageId: selectedImage.id,
isFeatured: !selectedImage.isFeatured,
}),
});
if (res.ok) {
// 更新本地状态
setSelectedImage({
...selectedImage,
isFeatured: !selectedImage.isFeatured,
featuredAt: !selectedImage.isFeatured ? new Date().toISOString() : null,
});
// 刷新列表
mutateImages();
}
} catch (error) {
console.error("Failed to update featured status:", error);
}
}}
>
<Sparkles className="h-4 w-4 mr-2" />
{selectedImage.isFeatured ? "取消精选" : "设为精选"}
</Button>
</div>
) : null}
</div>
)}
</DialogContent>
</Dialog>
</TabsContent>
{/* 🆕 数据分析 */}
<TabsContent value="analytics" className="mt-6 space-y-6">
{/* 概览卡片 */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-3">
<div className="p-2 bg-blue-100 rounded-lg">
<ImageIcon className="h-5 w-5 text-blue-600" />
</div>
<div>
<p className="text-2xl font-bold">{analyticsData?.overview.totalImages || 0}</p>
<p className="text-xs text-muted-foreground">总生图数</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-3">
<div className="p-2 bg-green-100 rounded-lg">
<Users className="h-5 w-5 text-green-600" />
</div>
<div>
<p className="text-2xl font-bold">{analyticsData?.overview.totalUsers || 0}</p>
<p className="text-xs text-muted-foreground">总用户数</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-3">
<div className="p-2 bg-orange-100 rounded-lg">
<TrendingUp className="h-5 w-5 text-orange-600" />
</div>
<div>
<p className="text-2xl font-bold">{analyticsData?.overview.todayImages || 0}</p>
<p className="text-xs text-muted-foreground">今日生图</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="p-2 bg-purple-100 rounded-lg">
<Calendar className="h-5 w-5 text-purple-600" />
</div>
<div>
<p className="text-2xl font-bold">{analyticsData?.overview.monthImages || 0}</p>
<p className="text-xs text-muted-foreground">
{selectedMonth === `${new Date().getFullYear()}-${String(new Date().getMonth() + 1).padStart(2, "0")}`
? "本月生图"
: `${selectedMonth} 生图`}
</p>
</div>
</div>
<Input
type="month"
value={selectedMonth}
onChange={(e) => setSelectedMonth(e.target.value)}
className="w-[140px] h-8 text-xs"
/>
</div>
</CardContent>
</Card>
</div>
<div className="grid md:grid-cols-2 gap-6">
{/* 用户排行 */}
<Card>
<CardHeader>
<CardTitle className="text-base">用户生图排行 (Top 20)</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3 max-h-[400px] overflow-y-auto">
{analyticsData?.userStats?.map((user, index) => (
<div key={user.userId} className="flex items-center justify-between">
<div className="flex items-center gap-3">
<span className={`w-6 h-6 rounded-full flex items-center justify-center text-xs font-medium ${
index < 3 ? "bg-yellow-100 text-yellow-700" : "bg-gray-100 text-gray-600"
}`}>
{index + 1}
</span>
<span className="text-sm truncate max-w-[150px]">
{user.userName || user.userEmail?.split("@")[0]}
</span>
</div>
<span className="font-medium">{user.imageCount}</span>
</div>
))}
</div>
</CardContent>
</Card>
{/* 图片尺寸分布 */}
<Card>
<CardHeader>
<CardTitle className="text-base">图片尺寸分布</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
{analyticsData?.sizeStats?.map((stat) => {
const total = analyticsData.overview.totalImages || 1;
const percentage = ((stat.count / total) * 100).toFixed(1);
return (
<div key={stat.imageSize || "unknown"} className="space-y-1">
<div className="flex justify-between text-sm">
<span>{stat.imageSize || "未知"}</span>
<span className="text-muted-foreground">{stat.count} ({percentage}%)</span>
</div>
<div className="h-2 bg-gray-100 rounded-full overflow-hidden">
<div
className="h-full bg-blue-500 rounded-full"
style={{ width: `${percentage}%` }}
/>
</div>
</div>
);
})}
</div>
</CardContent>
</Card>
{/* 每日趋势 */}
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="text-base">最近30天趋势</CardTitle>
{selectedDate && (
<Button
variant="ghost"
size="sm"
className="text-xs h-6"
onClick={() => setSelectedDate(null)}
>
清除选择 ({selectedDate})
</Button>
)}
</CardHeader>
<CardContent>
<div className="h-[200px] flex items-end gap-1 relative">
{analyticsData?.dailyStats?.map((stat, index) => {
const maxCount = Math.max(...(analyticsData.dailyStats?.map(s => s.count) || [1]));
const height = (stat.count / maxCount) * 100;
const isHovered = hoveredBar?.type === "daily" && hoveredBar?.index === index;
const isSelected = selectedDate === stat.date;
return (
<div
key={stat.date}
className={`flex-1 rounded-t transition-colors cursor-pointer relative ${
isSelected
? "bg-orange-500 hover:bg-orange-600"
: isHovered
? "bg-blue-600"
: "bg-blue-500 hover:bg-blue-600"
}`}
style={{ height: `${Math.max(height, 2)}%` }}
onMouseEnter={() => setHoveredBar({ type: "daily", index, value: stat.count, label: stat.date })}
onMouseLeave={() => setHoveredBar(null)}
onClick={() => setSelectedDate(selectedDate === stat.date ? null : stat.date)}
>
{isHovered && (
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-2 py-1 bg-gray-900 text-white text-xs rounded whitespace-nowrap z-10">
{stat.date}: {stat.count} 张
<div className="text-[10px] text-gray-300">点击查看当天高峰时段</div>
</div>
)}
</div>
);
})}
</div>
<div className="flex justify-between text-xs text-muted-foreground mt-2">
<span>{analyticsData?.dailyStats?.[0]?.date}</span>
<span>{analyticsData?.dailyStats?.at(-1)?.date}</span>
</div>
</CardContent>
</Card>
{/* 高峰时段 */}
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
生图高峰时段
{selectedDate && (
<span className="text-xs font-normal bg-orange-100 text-orange-700 px-2 py-0.5 rounded">
{selectedDate}
</span>
)}
</CardTitle>
{!selectedDate && (
<CardDescription className="text-xs">点击左侧日期柱形查看当天数据</CardDescription>
)}
</CardHeader>
<CardContent>
<div className="h-[200px] flex items-end gap-1 relative">
{Array.from({ length: 24 }, (_, hour) => {
const stat = analyticsData?.hourlyStats?.find(s => s.hour === hour);
const count = stat?.count || 0;
const maxCount = Math.max(...(analyticsData?.hourlyStats?.map(s => s.count) || [1]), 1);
const height = (count / maxCount) * 100;
const isHovered = hoveredBar?.type === "hourly" && hoveredBar?.index === hour;
return (
<div
key={hour}
className={`flex-1 rounded-t transition-colors cursor-pointer relative ${
selectedDate
? (isHovered ? "bg-orange-600" : "bg-orange-500 hover:bg-orange-600")
: (isHovered ? "bg-green-600" : "bg-green-500 hover:bg-green-600")
}`}
style={{ height: `${Math.max(height, 2)}%` }}
onMouseEnter={() => setHoveredBar({ type: "hourly", index: hour, value: count, label: `${hour}:00` })}
onMouseLeave={() => setHoveredBar(null)}
>
{isHovered && (
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-2 py-1 bg-gray-900 text-white text-xs rounded whitespace-nowrap z-10">
{hour}:00-{hour + 1}:00: {count} 张
</div>
)}
</div>
);
})}
</div>
<div className="flex justify-between text-xs text-muted-foreground mt-2">
<span>0:00</span>
<span>12:00</span>
<span>23:00</span>
</div>
</CardContent>
</Card>
{/* 月度统计 */}
<Card className="md:col-span-2">
<CardHeader>
<CardTitle className="text-base">月度统计</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-6 gap-4">
{analyticsData?.monthlyStats?.slice(-6).map((stat) => (
<div key={stat.month} className="text-center p-3 bg-gray-50 rounded-lg">
<p className="text-2xl font-bold text-blue-600">{stat.count}</p>
<p className="text-xs text-muted-foreground">{stat.month}</p>
</div>
))}
</div>
</CardContent>
</Card>
</div>
{/* 🆕 模型使用分析 */}
<div className="mt-6">
<h3 className="text-lg font-semibold mb-4 flex items-center gap-2">
<Bot className="h-5 w-5" />
模型使用分析
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* 模型使用排行 */}
<Card>
<CardHeader>
<CardTitle className="text-base">模型使用排行</CardTitle>
<CardDescription className="text-xs">按生图数量排序</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-3">
{analyticsData?.modelDetailStats?.slice(0, 8).map((stat, index) => {
const maxCount = analyticsData?.modelDetailStats?.[0]?.count || 1;
const percentage = (stat.count / maxCount) * 100;
return (
<div key={stat.modelId || index} className="space-y-1">
<div className="flex items-center justify-between text-sm">
<div className="flex items-center gap-2">
<span className={`w-5 h-5 rounded-full flex items-center justify-center text-xs font-bold ${
index === 0 ? "bg-yellow-500 text-white" :
index === 1 ? "bg-gray-400 text-white" :
index === 2 ? "bg-amber-600 text-white" :
"bg-gray-200 text-gray-600"
}`}>
{index + 1}
</span>
<span className="font-medium truncate max-w-[150px]" title={stat.modelName || stat.modelId || "未知模型"}>
{stat.modelName || stat.modelId || "未知模型"}
</span>
</div>
<div className="text-right">
<span className="font-semibold">{stat.count}</span>
<span className="text-muted-foreground text-xs ml-1">张</span>
</div>
</div>
<div className="w-full h-2 bg-gray-100 rounded-full overflow-hidden">
<div
className={`h-full rounded-full ${
index === 0 ? "bg-yellow-500" :
index === 1 ? "bg-gray-400" :
index === 2 ? "bg-amber-600" :
"bg-blue-400"
}`}
style={{ width: `${percentage}%` }}
/>
</div>
<div className="flex justify-between text-xs text-muted-foreground">
<span>{stat.providerName || "未知提供商"}</span>
<span>{stat.userCount} 用户</span>
</div>
</div>
);
})}
{(!analyticsData?.modelDetailStats || analyticsData.modelDetailStats.length === 0) && (
<div className="text-center text-muted-foreground py-8">暂无数据</div>
)}
</div>
</CardContent>
</Card>
{/* 提供商类型分布 */}
<Card>
<CardHeader>
<CardTitle className="text-base">提供商分布</CardTitle>
<CardDescription className="text-xs">按 AI 提供商统计</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
{(() => {
const providerColors: Record<string, string> = {
"gemini": "bg-purple-500",
"volcano-vision": "bg-orange-500",
"volcano-video": "bg-red-500",
"doubao-video": "bg-red-400",
"fal-video": "bg-indigo-500",
"ruxa-video": "bg-violet-500",
"alibaba": "bg-blue-500",
"alibaba-image": "bg-amber-500",
"bfl-image": "bg-cyan-600",
"ruxa-image": "bg-emerald-500",
"fal-image": "bg-sky-500",
"openai-image": "bg-green-600",
"midjourney": "bg-fuchsia-600",
"tripo-3d": "bg-teal-500",
"openai": "bg-green-500",
"zhipu": "bg-lime-500",
"volcano-multimodal": "bg-fuchsia-500",
"runway": "bg-rose-500",
"qianwen-video": "bg-cyan-500",
"custom": "bg-gray-500",
"local": "bg-yellow-500",
};
const total = analyticsData?.providerTypeStats?.reduce((acc, s) => acc + s.count, 0) || 0;
return analyticsData?.providerTypeStats?.map((stat, index) => {
const percentage = total > 0 ? ((stat.count / total) * 100).toFixed(1) : "0";
const color = providerColors[stat.providerType || ""] || "bg-gray-400";
return (
<div key={stat.providerType || index} className="flex items-center gap-3">
<div className={`w-3 h-3 rounded-full ${color}`} />
<div className="flex-1">
<div className="flex justify-between items-center">
<span className="text-sm font-medium">
{stat.providerName || stat.providerType || "未知"}
</span>
<span className="text-sm">
<span className="font-semibold">{stat.count}</span>
<span className="text-muted-foreground ml-1">({percentage}%)</span>
</span>
</div>
<div className="w-full h-2 bg-gray-100 rounded-full overflow-hidden mt-1">
<div
className={`h-full rounded-full ${color}`}
style={{ width: `${percentage}%` }}
/>
</div>
</div>
</div>
);
});
})()}
{(!analyticsData?.providerTypeStats || analyticsData.providerTypeStats.length === 0) && (
<div className="text-center text-muted-foreground py-8">暂无数据</div>
)}
</div>
</CardContent>
</Card>
{/* 图片尺寸分布 */}
<Card>
<CardHeader>
<CardTitle className="text-base">图片尺寸分布</CardTitle>
<CardDescription className="text-xs">按生成图片尺寸统计</CardDescription>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-3">
{analyticsData?.sizeStats?.map((stat) => {
const total = analyticsData?.sizeStats?.reduce((acc, s) => acc + s.count, 0) || 0;
const percentage = total > 0 ? ((stat.count / total) * 100).toFixed(1) : "0";
return (
<div
key={stat.imageSize || "unknown"}
className="flex-1 min-w-[100px] p-3 bg-gradient-to-br from-indigo-50 to-purple-50 rounded-lg border border-indigo-100"
>
<p className="text-xl font-bold text-indigo-600">{stat.imageSize || "未知"}</p>
<p className="text-sm text-muted-foreground">{stat.count} 张</p>
<p className="text-xs text-indigo-400">{percentage}%</p>
</div>
);
})}
</div>
</CardContent>
</Card>
{/* 模型使用趋势(最近7天) */}
<Card>
<CardHeader>
<CardTitle className="text-base">模型使用趋势</CardTitle>
<CardDescription className="text-xs">最近7天各模型使用量</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-2">
{(() => {
// 按日期分组
const groupedByDate = analyticsData?.modelTrendStats?.reduce((acc, stat) => {
if (!acc[stat.date]) acc[stat.date] = [];
acc[stat.date].push(stat);
return acc;
}, {} as Record<string, typeof analyticsData.modelTrendStats>) || {};
const dates = Object.keys(groupedByDate).sort().slice(-7);
const maxDailyTotal = Math.max(
...dates.map(date =>
groupedByDate[date]?.reduce((sum, s) => sum + s.count, 0) || 0
),
1
);
return dates.map(date => {
const dayStats = groupedByDate[date] || [];
const dayTotal = dayStats.reduce((sum, s) => sum + s.count, 0);
const heightPercent = (dayTotal / maxDailyTotal) * 100;
return (
<div key={date} className="flex items-center gap-2">
<span className="text-xs text-muted-foreground w-20">
{date.slice(5)}
</span>
<div className="flex-1 h-6 bg-gray-100 rounded overflow-hidden flex">
{dayStats.map((stat, idx) => {
const statPercent = dayTotal > 0 ? (stat.count / dayTotal) * 100 : 0;
const colors = ["bg-blue-500", "bg-green-500", "bg-purple-500", "bg-orange-500", "bg-pink-500"];
return (
<div
key={stat.modelId || idx}
className={`h-full ${colors[idx % colors.length]}`}
style={{ width: `${statPercent * (heightPercent / 100)}%` }}
title={`${stat.modelId || "未知"}: ${stat.count}张`}
/>
);
})}
</div>
<span className="text-xs font-medium w-10 text-right">{dayTotal}</span>
</div>
);
});
})()}
{(!analyticsData?.modelTrendStats || analyticsData.modelTrendStats.length === 0) && (
<div className="text-center text-muted-foreground py-8">暂无数据</div>
)}
</div>
</CardContent>
</Card>
</div>
</div>
</TabsContent>
{/* 网站设置 */}
<TabsContent value="site" className="mt-6">
<Card>
<CardHeader>
<CardTitle className="text-xl">网站基本设置</CardTitle>
<CardDescription>配置网站名称、LOGO、功能开关等</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="space-y-2">
<Label htmlFor="siteName">网站名称</Label>
<Input
id="siteName"
value={siteSettings.siteName}
onChange={(e) => setSiteSettings({ ...siteSettings, siteName: e.target.value })}
placeholder="输入网站名称"
/>
</div>
<div className="space-y-2">
<Label htmlFor="siteLogo">网站 LOGO (白天模式/默认)</Label>
<Input
id="siteLogo"
value={siteSettings.siteLogo || ""}
onChange={(e) => setSiteSettings({ ...siteSettings, siteLogo: e.target.value })}
placeholder="https://example.com/logo.png"
/>
</div>
<div className="space-y-2">
<Label htmlFor="siteLogoDark">网站 LOGO (黑夜模式)</Label>
<Input
id="siteLogoDark"
value={siteSettings.siteLogoDark || ""}
onChange={(e) => setSiteSettings({ ...siteSettings, siteLogoDark: e.target.value })}
placeholder="https://example.com/logo-dark.png"
/>
</div>
<div className="space-y-2">
<Label htmlFor="favicon">浏览器图标 (Favicon URL)</Label>
<Input
id="favicon"
value={siteSettings.favicon || ""}
onChange={(e) => setSiteSettings({ ...siteSettings, favicon: e.target.value })}
placeholder="https://example.com/favicon.ico"
/>
</div>
<div className="space-y-2">
<Label htmlFor="defaultTheme">默认主题模式</Label>
<Select
value={siteSettings.defaultTheme}
onValueChange={(value: "light" | "dark" | "system") => setSiteSettings({ ...siteSettings, defaultTheme: value })}
>
<SelectTrigger id="defaultTheme">
<SelectValue placeholder="选择默认主题" />
</SelectTrigger>
<SelectContent>
<SelectItem value="system">跟随系统</SelectItem>
<SelectItem value="light">浅色模式</SelectItem>
<SelectItem value="dark">深色模式</SelectItem>
</SelectContent>
</Select>
<div className="text-xs text-muted-foreground">
设置新用户首次访问时的默认主题模式
</div>
</div>
<div className="space-y-2">
<Label htmlFor="homeVariant">首页展示模式</Label>
<Select
value={siteSettings.homeVariant}
onValueChange={(value: "classic" | "lovart") =>
setSiteSettings({ ...siteSettings, homeVariant: value })
}
>
<SelectTrigger id="homeVariant">
<SelectValue placeholder="选择首页模式" />
</SelectTrigger>
<SelectContent>
<SelectItem value="classic">经典对话首页</SelectItem>
<SelectItem value="lovart">新版项目首页</SelectItem>
</SelectContent>
</Select>
<div className="text-xs text-muted-foreground">
新版项目首页会启用全新的项目入口、文件列表与精选展示。
</div>
</div>
<div className="space-y-2">
<Label htmlFor="surpriseCategories">精选相册分类(逗号分隔)</Label>
<Input
id="surpriseCategories"
value={siteSettings.surpriseCategories.join(", ")}
onChange={(e) => {
const parsed = e.target.value
.split(",")
.map((item) => item.trim())
.filter(Boolean);
const unique = Array.from(new Set(parsed));
setSiteSettings({
...siteSettings,
surpriseCategories: unique.length > 0 ? unique : ["全部", "精选"],
});
}}
placeholder="全部, 精选, 海报, 电商"
/>
<div className="text-xs text-muted-foreground">
新版首页“精选”模块会按这些分类显示筛选标签。
</div>
</div>
<div className="grid gap-4 rounded-lg border border-dashed p-4">
<div className="text-sm font-medium">首页 / 无限画布对话框文案配置</div>
<div className="space-y-2">
<Label htmlFor="lovartComposerPrefix">对话框固定前缀文案(首页与无限画布共用)</Label>
<Input
id="lovartComposerPrefix"
value={siteSettings.lovartComposerPrefix}
onChange={(e) =>
setSiteSettings({
...siteSettings,
lovartComposerPrefix: e.target.value.slice(0, 40),
})
}
placeholder="让Buyi-ai"
/>
<div className="text-xs text-muted-foreground">
该前缀会固定显示在打字文案前面,切换文案时不删除。
</div>
</div>
<div className="space-y-2">
<Label htmlFor="lovartComposerPreviewTexts">
对话框预览文案(首页与无限画布共用,每行一条,按顺序轮播打字显示)
</Label>
<Textarea
id="lovartComposerPreviewTexts"
value={siteSettings.lovartComposerPreviewTexts.join("\n")}
onChange={(e) => {
const values = e.target.value
.split("\n")
.map((item) => item.trim())
.filter(Boolean)
.slice(0, 20);
setSiteSettings({
...siteSettings,
lovartComposerPreviewTexts:
values.length > 0
? values
: [
"打造引人注目的社交媒体视觉",
"帮我把这张产品图做成高级电商海报",
"基于参考图延展 3 套同风格方案",
],
});
}}
rows={5}
placeholder={"打造引人注目的社交媒体视觉\n帮我把这张产品图做成高级电商海报"}
/>
<div className="text-xs text-muted-foreground">
仅配置动态尾文案;前端会固定显示前缀后再进行打字与删字切换。支持最多 20 条。
</div>
</div>
<div className="space-y-3 rounded-lg border p-3">
<div className="text-sm font-medium">对话框下方标签按钮显示</div>
<div className="grid grid-cols-1 gap-2 md:grid-cols-2">
{lovartPromptTagOptions.map((item) => (
<div key={item.key} className="flex items-center justify-between rounded-md border px-3 py-2">
<Label htmlFor={`lovart-tag-${item.key}`}>{item.label}</Label>
<Switch
id={`lovart-tag-${item.key}`}
checked={siteSettings.lovartPromptTagVisibility[item.key] !== false}
onCheckedChange={(checked) =>
setSiteSettings({
...siteSettings,
lovartPromptTagVisibility: {
...siteSettings.lovartPromptTagVisibility,
[item.key]: checked,
},
})
}
/>
</div>
))}
</div>
<div className="text-xs text-muted-foreground">
关闭后对应标签按钮会在新版首页对话框下方隐藏。
</div>
</div>
<div className="space-y-3 rounded-lg border p-3">
<div className="text-sm font-medium">联系我们配置</div>
<div className="space-y-2">
<Label htmlFor="contactPhone">联系电话</Label>
<Input
id="contactPhone"
value={siteSettings.contactConfig.phone}
onChange={(e) =>
setSiteSettings({
...siteSettings,
contactConfig: {
...siteSettings.contactConfig,
phone: e.target.value.slice(0, 64),
},
})
}
placeholder="例如:400-888-8888"
/>
</div>
<div className="space-y-2">
<Label htmlFor="contactWechatQrUrl">微信二维码 URL</Label>
<Input
id="contactWechatQrUrl"
value={siteSettings.contactConfig.wechatQrUrl}
onChange={(e) =>
setSiteSettings({
...siteSettings,
contactConfig: {
...siteSettings.contactConfig,
wechatQrUrl: e.target.value,
},
})
}
placeholder="https://example.com/wechat-qr.png"
/>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
className="h-8"
disabled={contactQrUploading}
onClick={() => document.getElementById("contact-qr-upload")?.click()}
>
{contactQrUploading ? (
<>
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
上传中
</>
) : (
"上传二维码"
)}
</Button>
<input
id="contact-qr-upload"
type="file"
accept="image/*"
className="hidden"
onChange={async (event) => {
const file = event.target.files?.[0];
if (file) {
await handleContactQrUpload(file);
}
event.target.value = "";
}}
/>
</div>
{siteSettings.contactConfig.wechatQrUrl ? (
<div className="rounded-lg border p-2">
<img
src={siteSettings.contactConfig.wechatQrUrl}
alt="微信二维码预览"
className="h-28 w-28 rounded-md object-cover"
/>
</div>
) : null}
</div>
<div className="text-xs text-muted-foreground">
新版首页用户菜单点击“联系我们”后会弹出该二维码和联系电话。
</div>
</div>
<div className="space-y-2">
<Label htmlFor="lovartPromoText">顶部升级提示文案</Label>
<Input
id="lovartPromoText"
value={siteSettings.lovartPromoText}
onChange={(e) =>
setSiteSettings({ ...siteSettings, lovartPromoText: e.target.value })
}
placeholder="现在升级,立享 Kling 3.0 与 Nano Banana Pro,最高 5 折优惠"
/>
</div>
<div className="space-y-2">
<Label htmlFor="lovartPromoActionText">顶部升级按钮文案</Label>
<Input
id="lovartPromoActionText"
value={siteSettings.lovartPromoActionText}
onChange={(e) =>
setSiteSettings({ ...siteSettings, lovartPromoActionText: e.target.value })
}
placeholder="立即升级"
/>
</div>
<div className="space-y-2">
<Label htmlFor="lovartHeroTitle">首页主标题</Label>
<Input
id="lovartHeroTitle"
value={siteSettings.lovartHeroTitle}
onChange={(e) =>
setSiteSettings({ ...siteSettings, lovartHeroTitle: e.target.value })
}
placeholder="由壹点AI 让设计更简单"
/>
</div>
<div className="space-y-2">
<Label htmlFor="lovartHeroSubtitle">首页副标题</Label>
<Input
id="lovartHeroSubtitle"
value={siteSettings.lovartHeroSubtitle}
onChange={(e) =>
setSiteSettings({ ...siteSettings, lovartHeroSubtitle: e.target.value })
}
placeholder="懂你的设计代理,帮你搞定一切"
/>
</div>
</div>
<div className="border rounded-lg p-4 space-y-3">
<div className="flex items-center justify-between">
<div>
<Label className="text-sm font-medium">会员升级系统配置</Label>
<div className="text-xs text-muted-foreground mt-1">
支持配置套餐、FAQ、模型额度、用户套餐覆盖。保存后新版首页“升级”弹窗实时生效。
</div>
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
setSiteSettings({
...siteSettings,
membershipConfig: defaultMembershipConfig,
membershipUserOverrides: defaultMembershipUserOverrides,
});
toast.success("已恢复会员配置默认模板");
}}
>
恢复默认模板
</Button>
</div>
<div className="grid grid-cols-1 gap-3 md:grid-cols-4">
<div className="flex items-center justify-between rounded-lg border p-3">
<div className="space-y-0.5">
<Label htmlFor="membershipEnabled">启用会员系统</Label>
<div className="text-xs text-muted-foreground">控制升级弹窗及会员规则是否生效</div>
</div>
<Switch
id="membershipEnabled"
checked={siteSettings.membershipConfig.enabled !== false}
onCheckedChange={(checked) =>
updateMembershipConfigState((config) => ({
...config,
enabled: checked,
}))
}
/>
</div>
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">货币符号</Label>
<Input
value={siteSettings.membershipConfig.currency}
onChange={(e) =>
updateMembershipConfigState((config) => ({
...config,
currency: e.target.value || "$",
}))
}
placeholder="$"
/>
</div>
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">年付折扣标签</Label>
<Input
value={siteSettings.membershipConfig.annualDiscountLabel}
onChange={(e) =>
updateMembershipConfigState((config) => ({
...config,
annualDiscountLabel: e.target.value,
}))
}
placeholder="限时 5 折优惠"
/>
</div>
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">默认套餐</Label>
<Select
value={siteSettings.membershipConfig.defaultPlanId}
onValueChange={(value) =>
updateMembershipConfigState((config) => ({
...config,
defaultPlanId: value,
}))
}
>
<SelectTrigger>
<SelectValue placeholder="选择默认套餐" />
</SelectTrigger>
<SelectContent>
{siteSettings.membershipConfig.plans.map((plan) => (
<SelectItem key={plan.id} value={plan.id}>
{plan.name || plan.id}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label>会员套餐</Label>
<Button type="button" variant="outline" size="sm" onClick={addMembershipPlan}>
<Plus className="mr-1.5 h-3.5 w-3.5" />
新增套餐
</Button>
</div>
<div className="space-y-3">
{siteSettings.membershipConfig.plans.map((plan, index) => (
<div key={`${plan.id}-${index}`} className="rounded-lg border p-3 space-y-3">
<div className="grid grid-cols-1 gap-3 md:grid-cols-7">
<div className="space-y-1 md:col-span-2">
<Label className="text-xs text-muted-foreground">套餐名称</Label>
<Input
value={plan.name}
onChange={(e) =>
updateMembershipPlan(index, (item) => ({
...item,
name: e.target.value,
}))
}
/>
</div>
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">月付</Label>
<Input
type="number"
min={0}
step="0.01"
value={plan.monthlyPrice}
onChange={(e) =>
updateMembershipPlan(index, (item) => ({
...item,
monthlyPrice: Math.max(0, Number.parseFloat(e.target.value || "0")),
}))
}
/>
</div>
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">年付</Label>
<Input
type="number"
min={0}
step="0.01"
value={plan.annualPrice}
onChange={(e) =>
updateMembershipPlan(index, (item) => ({
...item,
annualPrice: Math.max(0, Number.parseFloat(e.target.value || "0")),
}))
}
/>
</div>
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">月积分</Label>
<Input
type="number"
min={0}
value={plan.monthlyCredits}
onChange={(e) =>
updateMembershipPlan(index, (item) => ({
...item,
monthlyCredits: Math.max(0, Number.parseInt(e.target.value || "0", 10)),
}))
}
/>
</div>
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">每日免费积分</Label>
<Input
type="number"
min={0}
value={plan.dailyFreeCredits}
onChange={(e) =>
updateMembershipPlan(index, (item) => ({
...item,
dailyFreeCredits: Math.max(0, Number.parseInt(e.target.value || "0", 10)),
}))
}
/>
</div>
<div className="flex items-end justify-end">
<Button
type="button"
variant="outline"
size="icon"
onClick={() => removeMembershipPlan(index)}
title="删除套餐"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
<div className="grid grid-cols-1 gap-3 md:grid-cols-3">
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">套餐ID</Label>
<Input
value={plan.id}
onChange={(e) =>
updateMembershipPlan(index, (item) => ({
...item,
id: e.target.value.replace(/\s+/g, "-"),
}))
}
/>
</div>
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">徽章文案</Label>
<Input
value={plan.badge || ""}
onChange={(e) =>
updateMembershipPlan(index, (item) => ({
...item,
badge: e.target.value || undefined,
}))
}
placeholder="如:热门"
/>
</div>
<div className="flex items-end">
<div className="flex items-center gap-2 rounded-md border px-2 py-1.5">
<Switch
checked={plan.highlighted === true}
onCheckedChange={(checked) =>
updateMembershipPlan(index, (item) => ({
...item,
highlighted: checked,
}))
}
/>
<span className="text-xs text-muted-foreground">高亮展示</span>
</div>
</div>
</div>
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">权益(每行一条)</Label>
<Textarea
value={(plan.features || []).join("\n")}
onChange={(e) =>
updateMembershipPlan(index, (item) => ({
...item,
features: e.target.value
.split("\n")
.map((line) => line.trim())
.filter(Boolean),
}))
}
rows={3}
placeholder="访问所有图片模型&#10;优先队列"
/>
</div>
</div>
))}
</div>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label>FAQ</Label>
<Button type="button" variant="outline" size="sm" onClick={addMembershipFaq}>
<Plus className="mr-1.5 h-3.5 w-3.5" />
新增 FAQ
</Button>
</div>
<div className="space-y-2">
{siteSettings.membershipConfig.faqs.map((faq, index) => (
<div key={`faq-${index}`} className="rounded-lg border p-3 space-y-2">
<div className="flex items-center justify-between gap-2">
<Input
value={faq.question}
onChange={(e) =>
updateMembershipFaq(index, (item) => ({
...item,
question: e.target.value,
}))
}
placeholder="问题"
/>
<Button
type="button"
variant="outline"
size="icon"
onClick={() => removeMembershipFaq(index)}
title="删除 FAQ"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
<Textarea
value={faq.answer}
onChange={(e) =>
updateMembershipFaq(index, (item) => ({
...item,
answer: e.target.value,
}))
}
rows={2}
placeholder="回答"
/>
</div>
))}
</div>
</div>
<div className="space-y-2">
<Label>用户套餐覆盖</Label>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
<div className="rounded-lg border p-3 space-y-2">
<div className="text-xs font-medium text-muted-foreground">按用户ID覆盖</div>
<div className="flex gap-2">
<Input
value={newOverrideUserId}
onChange={(e) => setNewOverrideUserId(e.target.value)}
placeholder="用户ID"
/>
<Select value={newOverridePlanId} onValueChange={setNewOverridePlanId}>
<SelectTrigger className="w-[140px]">
<SelectValue placeholder="套餐" />
</SelectTrigger>
<SelectContent>
{siteSettings.membershipConfig.plans.map((plan) => (
<SelectItem key={`id-plan-${plan.id}`} value={plan.id}>
{plan.name || plan.id}
</SelectItem>
))}
</SelectContent>
</Select>
<Button type="button" onClick={() => addMembershipOverride("id")}>
添加
</Button>
</div>
<div className="space-y-1">
{Object.entries(siteSettings.membershipUserOverrides.userPlansById || {}).map(
([key, planId]) => (
<div key={`id-${key}`} className="flex items-center justify-between rounded-md border px-2 py-1.5 text-xs">
<span className="truncate pr-2">{key}</span>
<div className="flex items-center gap-2">
<span className="text-muted-foreground">{planId}</span>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => removeMembershipOverride("id", key)}
className="h-6 w-6"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</div>
)
)}
</div>
</div>
<div className="rounded-lg border p-3 space-y-2">
<div className="text-xs font-medium text-muted-foreground">按邮箱覆盖</div>
<div className="flex gap-2">
<Input
value={newOverrideEmail}
onChange={(e) => setNewOverrideEmail(e.target.value)}
placeholder="用户邮箱"
/>
<Select value={newOverridePlanId} onValueChange={setNewOverridePlanId}>
<SelectTrigger className="w-[140px]">
<SelectValue placeholder="套餐" />
</SelectTrigger>
<SelectContent>
{siteSettings.membershipConfig.plans.map((plan) => (
<SelectItem key={`email-plan-${plan.id}`} value={plan.id}>
{plan.name || plan.id}
</SelectItem>
))}
</SelectContent>
</Select>
<Button type="button" onClick={() => addMembershipOverride("email")}>
添加
</Button>
</div>
<div className="space-y-1">
{Object.entries(siteSettings.membershipUserOverrides.userPlansByEmail || {}).map(
([key, planId]) => (
<div key={`email-${key}`} className="flex items-center justify-between rounded-md border px-2 py-1.5 text-xs">
<span className="truncate pr-2">{key}</span>
<div className="flex items-center gap-2">
<span className="text-muted-foreground">{planId}</span>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => removeMembershipOverride("email", key)}
className="h-6 w-6"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</div>
)
)}
</div>
</div>
</div>
</div>
</div>
<div className="border rounded-lg p-4 space-y-3">
<div className="flex items-center justify-between">
<div>
<Label className="text-sm font-medium">积分系统配置</Label>
<div className="text-xs text-muted-foreground mt-1">
可开启按模型扣积分、配置积分套餐;后续接入支付后可直接用于充值。
</div>
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
setSiteSettings({
...siteSettings,
pointsSystemConfig: defaultPointsSystemConfig,
pointsPackages: defaultPointsPackages,
});
toast.success("已恢复积分配置默认模板");
}}
>
恢复默认模板
</Button>
</div>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
<div className="flex items-center justify-between rounded-lg border p-3">
<div className="space-y-0.5">
<Label htmlFor="pointsEnabled">启用积分消耗</Label>
<div className="text-xs text-muted-foreground">
开启后按模型单价和图片张数扣除积分
</div>
</div>
<Switch
id="pointsEnabled"
checked={siteSettings.pointsSystemConfig?.enabled === true}
onCheckedChange={(checked) =>
setSiteSettings({
...siteSettings,
pointsSystemConfig: {
...siteSettings.pointsSystemConfig,
enabled: checked,
},
})
}
/>
</div>
<div className="flex items-center justify-between rounded-lg border p-3">
<div className="space-y-0.5">
<Label htmlFor="pointsBlockOnInsufficient">余额不足时阻止生图</Label>
<div className="text-xs text-muted-foreground">
开启后积分不足直接返回错误,不执行生图任务
</div>
</div>
<Switch
id="pointsBlockOnInsufficient"
checked={siteSettings.pointsSystemConfig?.blockOnInsufficient !== false}
onCheckedChange={(checked) =>
setSiteSettings({
...siteSettings,
pointsSystemConfig: {
...siteSettings.pointsSystemConfig,
blockOnInsufficient: checked,
},
})
}
/>
</div>
</div>
<div className="rounded-lg border p-3">
<div className="space-y-1">
<Label htmlFor="points3dCost">3D生成单次积分消耗</Label>
<div className="text-xs text-muted-foreground">
作用于 3D 生成功能(文生3D / 图生3D / 多视图3D),每次提交任务扣除一次。
</div>
</div>
<div className="mt-2 w-full max-w-xs">
<Input
id="points3dCost"
min={0}
type="number"
value={siteSettings.pointsSystemConfig?.creditCostPer3D ?? 0}
onChange={(e) =>
setSiteSettings({
...siteSettings,
pointsSystemConfig: {
...siteSettings.pointsSystemConfig,
creditCostPer3D: Math.max(
0,
Number.parseInt(e.target.value || "0", 10) || 0
),
},
})
}
/>
</div>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label>积分套餐</Label>
<Button type="button" variant="outline" size="sm" onClick={addPointsPackage}>
<Plus className="mr-1.5 h-3.5 w-3.5" />
新增套餐
</Button>
</div>
<div className="space-y-3">
{(siteSettings.pointsPackages || []).map((pkg, index) => (
<div key={`${pkg.id}-${index}`} className="rounded-lg border p-3 space-y-3">
<div className="grid grid-cols-1 gap-3 md:grid-cols-6">
<div className="space-y-1 md:col-span-2">
<Label className="text-xs text-muted-foreground">套餐名称</Label>
<Input
value={pkg.name}
onChange={(e) =>
updatePointsPackage(index, (item) => ({
...item,
name: e.target.value,
}))
}
placeholder="如:入门包"
/>
</div>
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">积分数量</Label>
<Input
type="number"
min={1}
value={pkg.points}
onChange={(e) =>
updatePointsPackage(index, (item) => ({
...item,
points: Math.max(1, Number.parseInt(e.target.value || "1", 10)),
}))
}
/>
</div>
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">售价</Label>
<Input
type="number"
min={0}
step="0.01"
value={pkg.price}
onChange={(e) =>
updatePointsPackage(index, (item) => ({
...item,
price: Math.max(0, Number.parseFloat(e.target.value || "0")),
}))
}
/>
</div>
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">货币</Label>
<Input
value={pkg.currency}
onChange={(e) =>
updatePointsPackage(index, (item) => ({
...item,
currency: String(e.target.value || "CNY").toUpperCase(),
}))
}
placeholder="CNY"
/>
</div>
<div className="flex items-end justify-end gap-2">
<div className="flex items-center gap-2 rounded-md border px-2 py-1.5">
<Switch
checked={pkg.isEnabled !== false}
onCheckedChange={(checked) =>
updatePointsPackage(index, (item) => ({
...item,
isEnabled: checked,
}))
}
/>
<span className="text-xs text-muted-foreground">启用</span>
</div>
<Button
type="button"
variant="outline"
size="icon"
onClick={() => removePointsPackage(index)}
title="删除套餐"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">套餐ID(系统内部标识)</Label>
<Input
value={pkg.id}
onChange={(e) =>
updatePointsPackage(index, (item) => ({
...item,
id: e.target.value.replace(/\s+/g, "-"),
}))
}
placeholder="starter-1000"
/>
</div>
</div>
))}
</div>
</div>
</div>
<div className="border rounded-lg p-4 space-y-3">
<div className="flex items-center justify-between">
<div>
<Label className="text-sm font-medium">DuluPay 支付配置</Label>
<div className="text-xs text-muted-foreground mt-1">
用于积分充值支付。支持 V1(MD5)和 V2(SHA256WithRSA)两种签名模式。
</div>
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={() =>
setSiteSettings({
...siteSettings,
dulupayConfig: defaultDulupayConfig,
})
}
>
恢复默认
</Button>
</div>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
<div className="flex items-center justify-between rounded-lg border p-3">
<div className="space-y-0.5">
<Label htmlFor="dulupayEnabled">启用支付</Label>
<div className="text-xs text-muted-foreground">
开启后允许前端发起 DuluPay 充值订单
</div>
</div>
<Switch
id="dulupayEnabled"
checked={siteSettings.dulupayConfig?.enabled === true}
onCheckedChange={(checked) =>
setSiteSettings({
...siteSettings,
dulupayConfig: {
...siteSettings.dulupayConfig,
enabled: checked,
},
})
}
/>
</div>
<div className="space-y-2">
<Label>接口版本</Label>
<Select
value={siteSettings.dulupayConfig?.version || "v1"}
onValueChange={(value: "v1" | "v2") =>
setSiteSettings({
...siteSettings,
dulupayConfig: {
...siteSettings.dulupayConfig,
version: value,
},
})
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="v1">V1 (MD5)</SelectItem>
<SelectItem value="v2">V2 (SHA256WithRSA)</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-1 gap-3 md:grid-cols-3">
<div className="space-y-2 md:col-span-3">
<Label>基础地址</Label>
<Input
value={siteSettings.dulupayConfig?.baseUrl || ""}
onChange={(e) =>
setSiteSettings({
...siteSettings,
dulupayConfig: {
...siteSettings.dulupayConfig,
baseUrl: e.target.value,
},
})
}
placeholder="https://www.dulupay.com"
/>
</div>
<div className="space-y-2">
<Label>异步通知地址</Label>
<Input
value={siteSettings.dulupayConfig?.notifyUrl || ""}
onChange={(e) =>
setSiteSettings({
...siteSettings,
dulupayConfig: {
...siteSettings.dulupayConfig,
notifyUrl: e.target.value,
},
})
}
placeholder="https://your-domain.com/api/payment/dulupay/notify"
/>
</div>
<div className="space-y-2">
<Label>同步返回地址</Label>
<Input
value={siteSettings.dulupayConfig?.returnUrl || ""}
onChange={(e) =>
setSiteSettings({
...siteSettings,
dulupayConfig: {
...siteSettings.dulupayConfig,
returnUrl: e.target.value,
},
})
}
placeholder="https://your-domain.com/payment/success"
/>
</div>
<div className="space-y-2">
<Label>默认支付类型</Label>
<Input
value={siteSettings.dulupayConfig?.defaultType || ""}
onChange={(e) =>
setSiteSettings({
...siteSettings,
dulupayConfig: {
...siteSettings.dulupayConfig,
defaultType: e.target.value,
},
})
}
placeholder="alipay / wxpay"
/>
</div>
</div>
{siteSettings.dulupayConfig?.version === "v1" ? (
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
<div className="space-y-2">
<Label>商户 PID</Label>
<Input
value={siteSettings.dulupayConfig?.pid || ""}
onChange={(e) =>
setSiteSettings({
...siteSettings,
dulupayConfig: {
...siteSettings.dulupayConfig,
pid: e.target.value,
},
})
}
placeholder="商户ID (pid)"
/>
</div>
<div className="space-y-2">
<Label>商户 Key</Label>
<Input
type="password"
value={siteSettings.dulupayConfig?.key || ""}
onChange={(e) =>
setSiteSettings({
...siteSettings,
dulupayConfig: {
...siteSettings.dulupayConfig,
key: e.target.value,
},
})
}
placeholder="V1 签名密钥 key"
/>
</div>
</div>
) : (
<div className="space-y-3">
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
<div className="space-y-2">
<Label>商户号 mch_id</Label>
<Input
value={siteSettings.dulupayConfig?.mchId || ""}
onChange={(e) =>
setSiteSettings({
...siteSettings,
dulupayConfig: {
...siteSettings.dulupayConfig,
mchId: e.target.value,
},
})
}
placeholder="V2 商户号"
/>
</div>
<div className="space-y-2">
<Label>应用 app_id</Label>
<Input
value={siteSettings.dulupayConfig?.appId || ""}
onChange={(e) =>
setSiteSettings({
...siteSettings,
dulupayConfig: {
...siteSettings.dulupayConfig,
appId: e.target.value,
},
})
}
placeholder="V2 应用ID"
/>
</div>
</div>
<div className="space-y-2">
<Label>商户私钥(RSA)</Label>
<Textarea
rows={4}
value={siteSettings.dulupayConfig?.merchantPrivateKey || ""}
onChange={(e) =>
setSiteSettings({
...siteSettings,
dulupayConfig: {
...siteSettings.dulupayConfig,
merchantPrivateKey: e.target.value,
},
})
}
placeholder="-----BEGIN PRIVATE KEY-----"
/>
</div>
<div className="space-y-2">
<Label>平台公钥(RSA)</Label>
<Textarea
rows={4}
value={siteSettings.dulupayConfig?.platformPublicKey || ""}
onChange={(e) =>
setSiteSettings({
...siteSettings,
dulupayConfig: {
...siteSettings.dulupayConfig,
platformPublicKey: e.target.value,
},
})
}
placeholder="-----BEGIN PUBLIC KEY-----"
/>
</div>
</div>
)}
</div>
<div className="flex items-center justify-between space-x-2 rounded-lg border p-4">
<div className="space-y-0.5">
<Label htmlFor="showAutoModel">显示"自动"模型选项</Label>
<div className="text-sm text-muted-foreground">
开启后,用户可以选择"Agent模式"模型,系统将根据场景智能选择最佳模型
</div>
</div>
<Switch
id="showAutoModel"
checked={siteSettings.showAutoModel}
onCheckedChange={(checked) => setSiteSettings({ ...siteSettings, showAutoModel: checked })}
/>
</div>
<Separator />
<div className="space-y-4">
<div className="flex items-center justify-between space-x-2 rounded-lg border p-4">
<div className="space-y-0.5">
<Label htmlFor="usageTimeEnabled">启用使用时间限制</Label>
<div className="text-sm text-muted-foreground">
开启后,仅在指定时间段内允许使用系统,超出时间段输入框将被禁用
</div>
</div>
<Switch
id="usageTimeEnabled"
checked={siteSettings.usageTimeEnabled}
onCheckedChange={(checked) => setSiteSettings({ ...siteSettings, usageTimeEnabled: checked })}
/>
</div>
{siteSettings.usageTimeEnabled && (
<>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="usageTimeStart">开始时间</Label>
<Select
value={siteSettings.usageTimeStart}
onValueChange={(value) => setSiteSettings({ ...siteSettings, usageTimeStart: value })}
>
<SelectTrigger id="usageTimeStart">
<SelectValue placeholder="选择开始时间" />
</SelectTrigger>
<SelectContent className="max-h-[300px] overflow-y-auto">
{Array.from({ length: 24 }, (_, i) => {
const hour = String(i).padStart(2, '0');
return [
<SelectItem key={`${hour}:00`} value={`${hour}:00`}>{hour}:00</SelectItem>,
<SelectItem key={`${hour}:30`} value={`${hour}:30`}>{hour}:30</SelectItem>
];
}).flat()}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="usageTimeEnd">结束时间</Label>
<Select
value={siteSettings.usageTimeEnd}
onValueChange={(value) => setSiteSettings({ ...siteSettings, usageTimeEnd: value })}
>
<SelectTrigger id="usageTimeEnd">
<SelectValue placeholder="选择结束时间" />
</SelectTrigger>
<SelectContent className="max-h-[300px] overflow-y-auto">
{Array.from({ length: 24 }, (_, i) => {
const hour = String(i).padStart(2, '0');
return [
<SelectItem key={`${hour}:00`} value={`${hour}:00`}>{hour}:00</SelectItem>,
<SelectItem key={`${hour}:30`} value={`${hour}:30`}>{hour}:30</SelectItem>
];
}).flat()}
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="usageTimeMessage">超时提示信息</Label>
<Textarea
id="usageTimeMessage"
value={siteSettings.usageTimeMessage}
onChange={(e) => setSiteSettings({ ...siteSettings, usageTimeMessage: e.target.value })}
placeholder="输入超时提示信息"
rows={2}
/>
<div className="text-xs text-muted-foreground">
当前设置:{siteSettings.usageTimeStart} - {siteSettings.usageTimeEnd}
</div>
</div>
</>
)}
{/* 广告横幅设置 */}
<div className="border-t pt-4">
<div className="flex items-center justify-between mb-4">
<div className="space-y-0.5">
<Label htmlFor="bannerEnabled">广告横幅</Label>
<div className="text-sm text-muted-foreground">
在无限画布等页面的聊天面板中显示广告横幅
</div>
</div>
<Switch
id="bannerEnabled"
checked={siteSettings.bannerEnabled}
onCheckedChange={(checked) => setSiteSettings({ ...siteSettings, bannerEnabled: checked })}
/>
</div>
{siteSettings.bannerEnabled && (
<>
<div className="space-y-2">
<Label htmlFor="bannerText">横幅文字</Label>
<Input
id="bannerText"
value={siteSettings.bannerText}
onChange={(e) => setSiteSettings({ ...siteSettings, bannerText: e.target.value })}
placeholder="输入横幅显示的文字"
/>
</div>
<div className="space-y-2 mt-3">
<Label htmlFor="bannerLink">横幅链接(可选)</Label>
<Input
id="bannerLink"
value={siteSettings.bannerLink || ""}
onChange={(e) => setSiteSettings({ ...siteSettings, bannerLink: e.target.value || null })}
placeholder="输入点击横幅后跳转的链接"
/>
<div className="text-xs text-muted-foreground">
如果不填写链接,点击横幅将不会跳转
</div>
</div>
</>
)}
</div>
{/* 无限画布设置 */}
<div className="border-t pt-4">
<div className="flex items-center justify-between mb-4">
<div className="space-y-0.5">
<Label htmlFor="canvasEnabled">无限画布功能</Label>
<div className="text-sm text-muted-foreground">
开启后,用户可以使用无限画布进行图片编辑和生成
</div>
</div>
<Switch
id="canvasEnabled"
checked={siteSettings.canvasEnabled}
onCheckedChange={(checked) => setSiteSettings({ ...siteSettings, canvasEnabled: checked })}
/>
</div>
{siteSettings.canvasEnabled && (
<>
<div className="flex items-center justify-between rounded-lg border p-3">
<div className="space-y-0.5">
<Label htmlFor="agentEnabled">智能体功能</Label>
<div className="text-xs text-muted-foreground">
开启后,首页侧边栏显示“智能体”入口,用户可进入独立的智能体工作台;关闭后隐藏该入口。
</div>
</div>
<Switch
id="agentEnabled"
checked={siteSettings.agentEnabled}
onCheckedChange={(checked) =>
setSiteSettings({ ...siteSettings, agentEnabled: checked })
}
/>
</div>
<div className="flex items-center justify-between rounded-lg border p-3">
<div className="space-y-0.5">
<Label htmlFor="canvasAgentModeEnabled">无限画布 Agent模式</Label>
<div className="text-xs text-muted-foreground">
控制无限画布右侧对话框中的 Agent模式 入口,与上面的智能体工作台入口独立。
</div>
</div>
<Switch
id="canvasAgentModeEnabled"
checked={siteSettings.canvasAgentModeEnabled}
onCheckedChange={(checked) =>
setSiteSettings({ ...siteSettings, canvasAgentModeEnabled: checked })
}
/>
</div>
{siteSettings.agentEnabled && (
<div className="rounded-lg border p-4 space-y-4">
<div>
<div className="font-medium">智能体工作流配置</div>
<div className="text-xs text-muted-foreground mt-1">
智能体页面的功能按钮名称、Prompt 片段和预览图已迁移到独立子页面,避免网站设置页过于臃肿。原先重复的分类和二级类型配置已不再在这里维护。
</div>
</div>
<div className="rounded-xl border bg-muted/20 p-4">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div className="space-y-1">
<div className="text-sm font-medium">独立维护智能体功能卡片</div>
<div className="text-xs text-muted-foreground">
建议在独立页面中维护每个环节的按钮名称、预览图上传、说明文案和 Prompt 片段。
</div>
</div>
<Button asChild>
<Link href="/settings/agent-workflow">前往智能体工作流配置</Link>
</Button>
</div>
</div>
</div>
)}
<div className="flex items-center justify-between rounded-lg border p-3">
<div className="space-y-0.5">
<Label htmlFor="realtimeCanvasEnabled">实时画布功能</Label>
<div className="text-xs text-muted-foreground">
开启后,无限画布左侧工具栏显示“实时画布”按钮;关闭后前端隐藏。
</div>
</div>
<Switch
id="realtimeCanvasEnabled"
checked={siteSettings.realtimeCanvasEnabled}
onCheckedChange={(checked) =>
setSiteSettings({ ...siteSettings, realtimeCanvasEnabled: checked })
}
/>
</div>
<div className="flex items-center justify-between rounded-lg border p-3">
<div className="space-y-0.5">
<Label htmlFor="aiDetailGenerationEnabled">AI详情生成功能</Label>
<div className="text-xs text-muted-foreground">
开启后,无限画布前端会显示“AI详情生成”入口;关闭后前端隐藏该功能。
</div>
</div>
<Switch
id="aiDetailGenerationEnabled"
checked={siteSettings.aiDetailGenerationEnabled}
onCheckedChange={(checked) =>
setSiteSettings({ ...siteSettings, aiDetailGenerationEnabled: checked })
}
/>
</div>
<div className="space-y-2 mt-4">
<Label htmlFor="canvasDetailAgentModelId">
AI详情生成 - Agent专用模型
</Label>
<Select
value={siteSettings.canvasDetailAgentModelId || "agent-default"}
onValueChange={(value) =>
setSiteSettings({
...siteSettings,
canvasDetailAgentModelId: value === "agent-default" ? null : value,
})
}
>
<SelectTrigger id="canvasDetailAgentModelId">
<SelectValue placeholder="选择详情专用Agent模型" />
</SelectTrigger>
<SelectContent>
<SelectItem value="agent-default">沿用Agent对话默认模型</SelectItem>
{moonshotDetailAgentConfig.isEnabled ? (
<SelectItem value={DETAIL_AGENT_LOCAL_MOONSHOT_OPTION_ID}>
{moonshotDetailAgentConfig.displayName} · 本地AI配置
</SelectItem>
) : null}
{detailAgentModelOptions.map((model) => (
<SelectItem key={model.id} value={model.id}>
{model.displayName}
{model.supportsVision ? " · 多模态" : ""}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="text-xs text-muted-foreground">
用于 AI详情生成 的分镜规划与排版复审。未设置时沿用 Agent 对话默认模型;建议选择官方
Kimi K2.5 多模态模型。
</div>
</div>
<div className="space-y-2 mt-4">
<Label htmlFor="canvasDetailDefaultModelId">AI详情生成 - 默认生图模型</Label>
<Select
value={siteSettings.canvasDetailDefaultModelId || "agent-default"}
onValueChange={(value) =>
setSiteSettings({
...siteSettings,
canvasDetailDefaultModelId: value === "agent-default" ? null : value,
})
}
>
<SelectTrigger id="canvasDetailDefaultModelId">
<SelectValue placeholder="选择默认模型" />
</SelectTrigger>
<SelectContent>
<SelectItem value="agent-default">沿用Agent模式默认生图模型</SelectItem>
{modelsData?.models?.filter((m: any) => m.supportsImageGen).map((model: any) => (
<SelectItem key={model.id} value={model.id}>
{model.displayName}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="text-xs text-muted-foreground">
仅用于AI详情生成(含详情图与评价晒图)的默认出图模型;不影响普通生图模式。
</div>
</div>
<div className="space-y-2">
<Label htmlFor="canvasDefaultModelId">画布默认生图模型</Label>
<Select
value={siteSettings.canvasDefaultModelId || "auto"}
onValueChange={(value) => setSiteSettings({
...siteSettings,
canvasDefaultModelId: value === "auto" ? null : value
})}
>
<SelectTrigger id="canvasDefaultModelId">
<SelectValue placeholder="选择默认模型" />
</SelectTrigger>
<SelectContent>
<SelectItem value="auto">Agent模式(推荐)</SelectItem>
{modelsData?.models?.filter((m: any) => m.supportsImageGen).map((model: any) => (
<SelectItem key={model.id} value={model.id}>
{model.displayName}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="text-xs text-muted-foreground">
设置无限画布中用户的默认生图模型。"Agent模式"会根据用户需求自动选择最佳模型。
</div>
</div>
{/* Agent模式下的默认生图模型 */}
<div className="space-y-2 mt-4 pt-4 border-t">
<Label htmlFor="canvasAutoModeDefaultModelId">Agent模式 - 默认生图模型</Label>
<Select
value={siteSettings.canvasAutoModeDefaultModelId || "system"}
onValueChange={(value) => setSiteSettings({
...siteSettings,
canvasAutoModeDefaultModelId: value === "system" ? null : value
})}
>
<SelectTrigger id="canvasAutoModeDefaultModelId">
<SelectValue placeholder="选择默认模型" />
</SelectTrigger>
<SelectContent>
<SelectItem value="system">系统智能选择(根据意图路由)</SelectItem>
{modelsData?.models?.filter((m: any) => m.supportsImageGen).map((model: any) => (
<SelectItem key={model.id} value={model.id}>
{model.displayName}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="text-xs text-muted-foreground">
当用户选择"Agent模式"时,系统实际使用的生图模型。"系统智能选择"会根据用户意图(如抠图、换背景等)自动路由到最佳模型。
</div>
</div>
</>
)}
</div>
</div>
<Button onClick={handleSaveSiteSettings}>
<Save className="mr-2 h-4 w-4" /> 保存设置
</Button>
</CardContent>
</Card>
</TabsContent>
{/* 导航设置 */}
<TabsContent value="nav" className="mt-6 space-y-6">
{/* 工具箱名称设置 */}
<Card>
<CardHeader>
<CardTitle>工具箱设置</CardTitle>
<CardDescription>配置导航栏上的工具箱名称和显示状态</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* 工具箱1 */}
<div className="flex items-center justify-between rounded-lg border p-4">
<div className="flex-1 space-y-2">
<div className="flex items-center gap-4">
<Label className="text-base w-20">工具箱1</Label>
<Input
value={siteSettings.toolbox1Name}
onChange={(e) => setSiteSettings({ ...siteSettings, toolbox1Name: e.target.value })}
placeholder="工具箱名称"
className="max-w-[200px]"
/>
</div>
<div className="text-sm text-muted-foreground">
主工具箱,用于展示常用的外部链接
</div>
</div>
<Switch
checked={siteSettings.toolbox1Enabled}
onCheckedChange={(checked) => setSiteSettings({ ...siteSettings, toolbox1Enabled: checked })}
/>
</div>
{/* 工具箱2 */}
<div className="flex items-center justify-between rounded-lg border p-4">
<div className="flex-1 space-y-2">
<div className="flex items-center gap-4">
<Label className="text-base w-20">工具箱2</Label>
<Input
value={siteSettings.toolbox2Name}
onChange={(e) => setSiteSettings({ ...siteSettings, toolbox2Name: e.target.value })}
placeholder="工具箱2名称"
className="max-w-[200px]"
/>
</div>
<div className="text-sm text-muted-foreground">
第二个工具箱,可用于分类展示不同类型的链接
</div>
</div>
<Switch
checked={siteSettings.toolbox2Enabled}
onCheckedChange={(checked) => setSiteSettings({ ...siteSettings, toolbox2Enabled: checked })}
/>
</div>
{/* 精选榜设置 */}
<div className="flex items-center justify-between rounded-lg border p-4">
<div className="flex-1 space-y-1">
<Label className="text-base">精选榜</Label>
<div className="text-sm text-muted-foreground">
控制是否在导航栏显示精选榜按钮
</div>
</div>
<Switch
checked={siteSettings.rankingEnabled}
onCheckedChange={(checked) => setSiteSettings({ ...siteSettings, rankingEnabled: checked })}
/>
</div>
{/* 功能教程设置 */}
<div className="flex items-center justify-between rounded-lg border p-4">
<div className="flex-1 space-y-1">
<Label className="text-base">功能教程</Label>
<div className="text-sm text-muted-foreground">
启用后在导航栏显示功能教程按钮,点击后下拉显示视频列表
</div>
</div>
<Switch
checked={siteSettings.tutorialEnabled}
onCheckedChange={(checked) => setSiteSettings({ ...siteSettings, tutorialEnabled: checked })}
/>
</div>
<Button onClick={handleSaveSiteSettings}>
<Save className="mr-2 h-4 w-4" /> 保存导航设置
</Button>
</CardContent>
</Card>
{/* 视频教程管理 */}
{siteSettings.tutorialEnabled && (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="flex items-center gap-2">
<Video className="h-5 w-5" />
视频教程管理
</CardTitle>
<CardDescription>添加和管理功能教程视频,用户可以在导航栏查看</CardDescription>
</div>
<Dialog open={showTutorialVideoDialog} onOpenChange={setShowTutorialVideoDialog}>
<DialogTrigger asChild>
<Button onClick={() => { resetTutorialVideoForm(); setShowTutorialVideoDialog(true); }}>
<Plus className="mr-2 h-4 w-4" /> 添加视频
</Button>
</DialogTrigger>
<DialogContent className="max-w-2xl !bg-white !dark:bg-zinc-950">
<DialogHeader>
<DialogTitle>{editingTutorialVideo ? "编辑视频" : "添加视频"}</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label>视频标题 *</Label>
<Input
value={tutorialVideoForm.title}
onChange={(e) => setTutorialVideoForm({ ...tutorialVideoForm, title: e.target.value })}
placeholder="例如:快速入门教程"
/>
</div>
{/* 视频上传或链接 */}
<div className="space-y-3">
<Label>视频文件</Label>
<div className="flex items-center gap-3">
<Select
value={videoQuality}
onValueChange={(v) => setVideoQuality(v as "low" | "medium" | "high")}
>
<SelectTrigger className="w-[140px]">
<SelectValue placeholder="压缩质量" />
</SelectTrigger>
<SelectContent className="!bg-white !dark:bg-zinc-950">
<SelectItem value="low">低质量 (小)</SelectItem>
<SelectItem value="medium">中等 (推荐)</SelectItem>
<SelectItem value="high">高质量 (大)</SelectItem>
</SelectContent>
</Select>
<label className="flex-1">
<input
type="file"
accept="video/*"
className="hidden"
onChange={async (e) => {
const file = e.target.files?.[0];
if (!file) return;
// 文件大小检查
const maxSize = 500 * 1024 * 1024; // 500MB
if (file.size > maxSize) {
toast.error("视频文件不能超过500MB");
e.target.value = "";
return;
}
setVideoUploading(true);
setVideoUploadPercent(0);
setVideoUploadStage("uploading");
setVideoUploadProgress(`上传中 0%`);
try {
const formData = new FormData();
formData.append("file", file);
formData.append("quality", videoQuality);
formData.append("compress", "true");
// 使用 XMLHttpRequest 以获取真实上传进度
const result = await new Promise<{ url: string; compression?: { compressed: boolean; ratio: string; originalSize: string; compressedSize: string } }>((resolve, reject) => {
const xhr = new XMLHttpRequest();
// 上传进度
xhr.upload.onprogress = (event) => {
if (event.lengthComputable) {
const percent = Math.round((event.loaded / event.total) * 100);
setVideoUploadPercent(percent);
setVideoUploadProgress(`上传中 ${percent}%`);
}
};
// 上传完成后,切换到压缩阶段
xhr.upload.onloadend = () => {
setVideoUploadPercent(100);
setVideoUploadStage("compressing");
setVideoUploadProgress("压缩处理中...");
};
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
try {
const response = JSON.parse(xhr.responseText);
if (response.error) {
reject(new Error(response.error));
} else {
setVideoUploadStage("done");
setVideoUploadProgress("完成!");
resolve(response);
}
} catch {
reject(new Error("解析响应失败"));
}
} else {
try {
const response = JSON.parse(xhr.responseText);
reject(new Error(response.error || `上传失败 (${xhr.status})`));
} catch {
reject(new Error(`上传失败 (${xhr.status})`));
}
}
};
xhr.onerror = () => reject(new Error("网络错误,请检查连接"));
xhr.ontimeout = () => reject(new Error("上传超时"));
xhr.open("POST", "/api/video/upload");
xhr.timeout = 600000; // 10分钟超时
xhr.send(formData);
});
setTutorialVideoForm({ ...tutorialVideoForm, videoUrl: result.url });
if (result.compression?.compressed) {
toast.success(
`上传成功!压缩率: ${result.compression.ratio}% (${result.compression.originalSize} → ${result.compression.compressedSize})`,
{ duration: 5000 }
);
} else {
toast.success("视频上传成功!");
}
} catch (error) {
toast.error(error instanceof Error ? error.message : "上传失败");
} finally {
setVideoUploading(false);
setVideoUploadProgress("");
setVideoUploadPercent(0);
setVideoUploadStage("idle");
e.target.value = "";
}
}}
disabled={videoUploading}
/>
<div className="w-full space-y-2">
<Button
type="button"
variant="outline"
className="w-full cursor-pointer"
disabled={videoUploading}
asChild
>
<span>
{videoUploading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{videoUploadStage === "uploading" && `上传中 ${videoUploadPercent}%`}
{videoUploadStage === "compressing" && "压缩处理中..."}
{videoUploadStage === "saving" && "保存中..."}
{videoUploadStage === "done" && "完成!"}
</>
) : (
<>
<Upload className="mr-2 h-4 w-4" />
上传视频
</>
)}
</span>
</Button>
{/* 上传进度条 - 始终在上传过程中显示 */}
{videoUploading && (
<div className="space-y-1.5 p-3 bg-muted/50 rounded-lg border">
<div className="flex items-center justify-between text-xs">
<span className="font-medium">
{videoUploadStage === "uploading" && "正在上传..."}
{videoUploadStage === "compressing" && "正在压缩视频..."}
{videoUploadStage === "saving" && "正在保存..."}
{videoUploadStage === "done" && "处理完成!"}
</span>
<span className="text-muted-foreground">
{videoUploadStage === "uploading" && `${videoUploadPercent}%`}
{videoUploadStage === "compressing" && "请耐心等待"}
</span>
</div>
<div className="h-2.5 w-full bg-gray-200 rounded-full overflow-hidden dark:bg-gray-700">
<div
className={`h-full transition-all duration-300 ${
videoUploadStage === "compressing"
? "bg-yellow-500 animate-pulse"
: videoUploadStage === "done"
? "bg-green-500"
: "bg-blue-500"
}`}
style={{
width: videoUploadStage === "compressing" || videoUploadStage === "saving" || videoUploadStage === "done"
? "100%"
: `${videoUploadPercent}%`
}}
/>
</div>
<p className="text-xs text-muted-foreground">
{videoUploadStage === "uploading" && "正在将视频上传到服务器..."}
{videoUploadStage === "compressing" && "服务器正在压缩视频以优化播放体验,大文件可能需要几分钟..."}
{videoUploadStage === "saving" && "正在保存到存储..."}
{videoUploadStage === "done" && "视频已成功处理!"}
</p>
</div>
)}
</div>
</label>
</div>
</div>
<div className="space-y-2">
<Label>视频链接 *</Label>
<Input
value={tutorialVideoForm.videoUrl}
onChange={(e) => setTutorialVideoForm({ ...tutorialVideoForm, videoUrl: e.target.value })}
placeholder="视频URL(上传后自动填充或手动输入)"
/>
</div>
{/* 封面上传或链接 */}
<div className="space-y-2">
<Label>封面图片</Label>
<div className="flex items-center gap-2">
<Input
value={tutorialVideoForm.coverUrl}
onChange={(e) => setTutorialVideoForm({ ...tutorialVideoForm, coverUrl: e.target.value })}
placeholder="封面图片URL(可选)"
className="flex-1"
/>
<label>
<input
type="file"
accept="image/*"
className="hidden"
onChange={async (e) => {
const file = e.target.files?.[0];
if (!file) return;
try {
const formData = new FormData();
formData.append("file", file);
const response = await fetchUploadWithRetry(formData);
const result = await response.json();
if (result.url) {
setTutorialVideoForm({ ...tutorialVideoForm, coverUrl: result.url });
toast.success("封面上传成功");
}
} catch (error) {
toast.error("封面上传失败");
}
e.target.value = "";
}}
/>
<Button type="button" variant="outline" size="sm" asChild>
<span>
<Upload className="h-4 w-4" />
</span>
</Button>
</label>
</div>
</div>
<div className="space-y-2">
<Label>视频描述</Label>
<Input
value={tutorialVideoForm.description}
onChange={(e) => setTutorialVideoForm({ ...tutorialVideoForm, description: e.target.value })}
placeholder="简短描述(可选)"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>排序</Label>
<Input
type="number"
value={tutorialVideoForm.order}
onChange={(e) => setTutorialVideoForm({ ...tutorialVideoForm, order: parseInt(e.target.value) || 0 })}
/>
</div>
<div className="flex items-center gap-2 pt-6">
<Switch
checked={tutorialVideoForm.isEnabled}
onCheckedChange={(checked) => setTutorialVideoForm({ ...tutorialVideoForm, isEnabled: checked })}
/>
<Label>启用</Label>
</div>
</div>
{/* 预览 */}
{tutorialVideoForm.videoUrl && (
<div className="space-y-2">
<Label>预览</Label>
<div className="relative aspect-video bg-black rounded-lg overflow-hidden max-h-[200px]">
<video
src={tutorialVideoForm.videoUrl}
className="w-full h-full object-contain"
controls
/>
</div>
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowTutorialVideoDialog(false)}>取消</Button>
<Button onClick={handleSaveTutorialVideo} disabled={!tutorialVideoForm.title || !tutorialVideoForm.videoUrl}>
保存
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</CardHeader>
<CardContent>
<div className="space-y-3">
{tutorialVideosData?.videos?.map((video) => (
<div key={video.id} className="flex items-center gap-4 p-3 border rounded-lg">
{/* 封面 */}
<div className="w-24 h-16 bg-muted rounded overflow-hidden flex-shrink-0">
{video.coverUrl ? (
<img src={video.coverUrl} alt={video.title} className="w-full h-full object-cover" />
) : (
<div className="w-full h-full flex items-center justify-center">
<Video className="h-6 w-6 text-muted-foreground" />
</div>
)}
</div>
{/* 信息 */}
<div className="flex-1 min-w-0">
<div className="font-medium flex items-center gap-2">
{video.title}
{!video.isEnabled && <span className="text-xs bg-gray-200 px-2 py-0.5 rounded">已禁用</span>}
</div>
{video.description && (
<div className="text-sm text-muted-foreground truncate">{video.description}</div>
)}
<div className="text-xs text-muted-foreground">排序: {video.order}</div>
</div>
{/* 操作 */}
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => {
setTutorialVideoForm({
title: video.title,
videoUrl: video.videoUrl,
coverUrl: video.coverUrl || "",
description: video.description || "",
order: video.order,
isEnabled: video.isEnabled,
});
setEditingTutorialVideo(video.id);
setShowTutorialVideoDialog(true);
}}
>
<Edit className="h-4 w-4" />
</Button>
<Button variant="destructive" size="sm" onClick={() => handleDeleteTutorialVideo(video.id)}>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
))}
{(!tutorialVideosData?.videos?.length) && (
<div className="text-center text-muted-foreground py-8">
暂无视频教程,点击上方按钮添加
</div>
)}
</div>
</CardContent>
</Card>
)}
{/* 导航链接列表 */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>导航链接</CardTitle>
<CardDescription>添加和管理工具箱中的链接</CardDescription>
</div>
<Dialog open={showNavLinkDialog} onOpenChange={setShowNavLinkDialog}>
<DialogTrigger asChild>
<Button onClick={() => { resetNavLinkForm(); setShowNavLinkDialog(true); }}>
<Plus className="mr-2 h-4 w-4" /> 添加链接
</Button>
</DialogTrigger>
<DialogContent className="!bg-white !dark:bg-zinc-950">
<DialogHeader>
<DialogTitle>{editingNavLink ? "编辑链接" : "添加链接"}</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label>名称</Label>
<Input
value={navLinkForm.name}
onChange={(e) => setNavLinkForm({ ...navLinkForm, name: e.target.value })}
placeholder="链接名称"
/>
</div>
<div className="space-y-2">
<Label>URL</Label>
<Input
value={navLinkForm.url}
onChange={(e) => setNavLinkForm({ ...navLinkForm, url: e.target.value })}
placeholder="https://..."
/>
</div>
<div className="space-y-2">
<Label>所属工具箱</Label>
<div className="flex gap-4">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="toolboxGroup"
value={1}
checked={navLinkForm.toolboxGroup === 1}
onChange={() => setNavLinkForm({ ...navLinkForm, toolboxGroup: 1 })}
className="w-4 h-4"
/>
<span className="text-sm">{siteSettings.toolbox1Name || "工具箱1"}</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="toolboxGroup"
value={2}
checked={navLinkForm.toolboxGroup === 2}
onChange={() => setNavLinkForm({ ...navLinkForm, toolboxGroup: 2 })}
className="w-4 h-4"
/>
<span className="text-sm">{siteSettings.toolbox2Name || "工具箱2"}</span>
</label>
</div>
</div>
<div className="space-y-2">
<Label>排序 (越小越靠前)</Label>
<Input
type="number"
value={navLinkForm.order}
onChange={(e) => setNavLinkForm({ ...navLinkForm, order: parseInt(e.target.value) || 0 })}
/>
</div>
<div className="flex items-center gap-2">
<Switch
checked={navLinkForm.isEnabled}
onCheckedChange={(checked) => setNavLinkForm({ ...navLinkForm, isEnabled: checked })}
/>
<Label>启用</Label>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowNavLinkDialog(false)}>取消</Button>
<Button onClick={handleSaveNavLink}>保存</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</CardHeader>
<CardContent>
{/* 工具箱1 的链接 */}
<div className="space-y-4">
<div className="text-sm font-medium text-muted-foreground border-b pb-2">
{siteSettings.toolbox1Name || "工具箱1"} 的链接
</div>
{navLinksData?.links?.filter(l => (l.toolboxGroup || 1) === 1).map((link) => (
<div key={link.id} className="flex items-center justify-between p-4 border rounded-lg">
<div className="flex-1">
<div className="font-medium flex items-center gap-2">
{link.name}
{!link.isEnabled && <span className="text-xs bg-gray-200 px-2 py-0.5 rounded">已禁用</span>}
</div>
<div className="text-sm text-muted-foreground">{link.url} (排序: {link.order})</div>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => {
setNavLinkForm({
id: link.id,
name: link.name,
url: link.url,
order: link.order,
isEnabled: link.isEnabled,
toolboxGroup: link.toolboxGroup || 1,
});
setEditingNavLink(link.id);
setShowNavLinkDialog(true);
}}
>
<Edit className="h-4 w-4" />
</Button>
<Button variant="destructive" size="sm" onClick={() => handleDeleteNavLink(link.id)}>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
))}
{(!navLinksData?.links?.filter(l => (l.toolboxGroup || 1) === 1).length) && (
<div className="text-center text-muted-foreground py-4 text-sm">暂无链接</div>
)}
</div>
{/* 工具箱2 的链接 */}
<div className="space-y-4 mt-6">
<div className="text-sm font-medium text-muted-foreground border-b pb-2">
{siteSettings.toolbox2Name || "工具箱2"} 的链接
</div>
{navLinksData?.links?.filter(l => l.toolboxGroup === 2).map((link) => (
<div key={link.id} className="flex items-center justify-between p-4 border rounded-lg">
<div className="flex-1">
<div className="font-medium flex items-center gap-2">
{link.name}
{!link.isEnabled && <span className="text-xs bg-gray-200 px-2 py-0.5 rounded">已禁用</span>}
</div>
<div className="text-sm text-muted-foreground">{link.url} (排序: {link.order})</div>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => {
setNavLinkForm({
id: link.id,
name: link.name,
url: link.url,
order: link.order,
isEnabled: link.isEnabled,
toolboxGroup: link.toolboxGroup || 1,
});
setEditingNavLink(link.id);
setShowNavLinkDialog(true);
}}
>
<Edit className="h-4 w-4" />
</Button>
<Button variant="destructive" size="sm" onClick={() => handleDeleteNavLink(link.id)}>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
))}
{(!navLinksData?.links?.filter(l => l.toolboxGroup === 2).length) && (
<div className="text-center text-muted-foreground py-4 text-sm">暂无链接</div>
)}
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="questions" className="mt-6">
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="text-xl">快捷短语</CardTitle>
<CardDescription>配置聊天输入框下方的预设快捷提问</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{questions.map((q, index) => (
<div key={index} className="flex gap-2">
<Input
value={q}
onChange={(e) => handleQuestionChange(index, e.target.value)}
placeholder={`问题 ${index + 1}`}
/>
<Button
variant="ghost"
size="icon"
onClick={() => handleRemoveQuestion(index)}
disabled={questions.length <= 1}
>
<Trash2 className="h-4 w-4 text-muted-foreground hover:text-destructive" />
</Button>
</div>
))}
<Button variant="outline" onClick={handleAddQuestion} className="w-full">
<Plus className="mr-2 h-4 w-4" /> 添加短语
</Button>
<Button onClick={handleSaveQuestions} className="w-full md:w-auto">
<Save className="mr-2 h-4 w-4" /> 保存设置
</Button>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-xl">画布对话快捷标签</CardTitle>
<CardDescription>
每个标签支持「图标 + 标签名 + 输入框提示词 + 预设提示词」。用户点击标签后,输入框会显示对应灰色提示,发送时会自动附带对应预设提示词。
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between rounded-lg border p-3">
<div className="space-y-0.5">
<Label>在画布对话中显示 Skills 标签</Label>
<p className="text-xs text-muted-foreground">
关闭后,对话框将不显示快捷标签区域。
</p>
</div>
<Switch
checked={canvasSkillTagsEnabled}
onCheckedChange={setCanvasSkillTagsEnabled}
/>
</div>
<div className="space-y-2">
<Label>标签区标题</Label>
<Input
value={canvasSkillTagsTitle}
onChange={(event) => setCanvasSkillTagsTitle(event.target.value)}
placeholder={DEFAULT_CANVAS_SKILL_TAGS_TITLE}
/>
</div>
{canvasSkillTags.map((tag, index) => {
const TagIcon = getCanvasSkillIconComponent(tag.iconName);
return (
<div key={tag.id || index} className="space-y-2 rounded-lg border p-3">
<div className="flex items-center gap-2">
<Input
value={tag.name}
onChange={(event) =>
handleCanvasSkillTagChange(index, "name", event.target.value)
}
placeholder={`标签名称 ${index + 1}`}
/>
<Button
variant="ghost"
size="icon"
onClick={() => handleRemoveCanvasSkillTag(index)}
disabled={canvasSkillTags.length <= 1}
>
<Trash2 className="h-4 w-4 text-muted-foreground hover:text-destructive" />
</Button>
</div>
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center overflow-hidden rounded-md border bg-muted/40">
{tag.iconUrl ? (
<img
src={tag.iconUrl}
alt={tag.name || `标签${index + 1}`}
className="h-full w-full object-cover"
/>
) : (
<TagIcon className="h-4 w-4 text-muted-foreground" />
)}
</div>
<div className="flex flex-1 flex-wrap items-center gap-2">
<Select
value={tag.iconName || "message-circle"}
onValueChange={(value) =>
handleCanvasSkillTagChange(index, "iconName", value)
}
>
<SelectTrigger className="h-9 w-[170px]">
<div className="flex items-center gap-2">
<TagIcon className="h-4 w-4 text-muted-foreground" />
<span className="text-sm">开源图标</span>
</div>
</SelectTrigger>
<SelectContent>
{CANVAS_SKILL_ICON_OPTIONS.map((option) => {
const OptionIcon = option.icon;
return (
<SelectItem key={option.value} value={option.value}>
<div className="flex items-center gap-2">
<OptionIcon className="h-4 w-4 text-muted-foreground" />
<span>{option.label}</span>
</div>
</SelectItem>
);
})}
</SelectContent>
</Select>
<Input
value={tag.iconUrl || ""}
onChange={(event) =>
handleCanvasSkillTagChange(index, "iconUrl", event.target.value)
}
placeholder="图标 URL(可选)"
/>
<Button
type="button"
variant="outline"
size="sm"
onClick={() =>
document.getElementById(`canvas-skill-tag-icon-${tag.id}`)?.click()
}
disabled={canvasSkillTagUploadingId === tag.id}
>
{canvasSkillTagUploadingId === tag.id ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
) : null}
上传图标
</Button>
<input
id={`canvas-skill-tag-icon-${tag.id}`}
type="file"
accept="image/*"
className="hidden"
onChange={(event) => {
const file = event.target.files?.[0];
if (file) {
void handleCanvasSkillTagIconUpload(index, file);
}
event.currentTarget.value = "";
}}
/>
</div>
</div>
<Input
value={tag.placeholder || ""}
onChange={(event) =>
handleCanvasSkillTagChange(index, "placeholder", event.target.value)
}
placeholder="输入框灰色提示词(可选)"
/>
<Textarea
value={tag.prompt}
onChange={(event) =>
handleCanvasSkillTagChange(index, "prompt", event.target.value)
}
className="min-h-[84px]"
placeholder="请输入该标签对应的预设提示词"
/>
</div>
);
})}
<Button variant="outline" onClick={handleAddCanvasSkillTag} className="w-full">
<Plus className="mr-2 h-4 w-4" /> 添加标签
</Button>
<Button onClick={handleSaveCanvasSkillTags} className="w-full md:w-auto">
<Save className="mr-2 h-4 w-4" /> 保存标签设置
</Button>
</CardContent>
</Card>
</div>
</TabsContent>
{/* 认证设置 */}
<TabsContent value="auth" className="mt-6">
<Card>
<CardHeader>
<CardTitle className="text-xl">登录认证</CardTitle>
<CardDescription>配置开放注册、钉钉登录与Google登录</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label>开放注册</Label>
<p className="text-sm text-muted-foreground">允许新用户注册账号</p>
</div>
<Switch
checked={authSettings.isRegistrationEnabled}
onCheckedChange={(checked) => setAuthSettings({ ...authSettings, isRegistrationEnabled: checked })}
/>
</div>
<div className="grid grid-cols-1 gap-3 rounded-md border p-3 md:grid-cols-2">
<div className="flex items-center justify-between md:col-span-2">
<div className="space-y-0.5">
<Label>邮箱验证码注册</Label>
<p className="text-sm text-muted-foreground">
开启后,用户注册时必须先收取邮箱验证码。
</p>
</div>
<Switch
checked={authSettings.isEmailVerificationEnabled === true}
onCheckedChange={(checked) =>
setAuthSettings({
...authSettings,
isEmailVerificationEnabled: checked,
})
}
/>
</div>
<div className="space-y-1">
<Label htmlFor="emailVerificationExpiresMinutes">
验证码有效期(分钟)
</Label>
<Input
id="emailVerificationExpiresMinutes"
type="number"
min={3}
max={30}
value={authSettings.emailVerificationExpiresMinutes ?? 10}
onChange={(event) =>
setAuthSettings({
...authSettings,
emailVerificationExpiresMinutes: Math.min(
Math.max(Number(event.target.value) || 10, 3),
30
),
})
}
/>
</div>
<div className="space-y-1">
<Label htmlFor="resendFromEmail">发件邮箱(Resend)</Label>
<Input
id="resendFromEmail"
value={authSettings.resendFromEmail || ""}
onChange={(event) =>
setAuthSettings({
...authSettings,
resendFromEmail: event.target.value,
})
}
placeholder="noreply@your-domain.ai"
/>
</div>
<div className="space-y-1 md:col-span-2">
<Label htmlFor="resendApiKey">Resend API Key</Label>
<Input
id="resendApiKey"
type="password"
value={authSettings.resendApiKey || ""}
onChange={(event) =>
setAuthSettings({
...authSettings,
resendApiKey: event.target.value,
})
}
placeholder="re_xxxxxxxxxxxxxxxxx"
/>
</div>
<p className="text-xs text-muted-foreground md:col-span-2">
当前状态:
{Boolean(
String(authSettings.resendApiKey || "").trim() &&
String(authSettings.resendFromEmail || "").trim()
)
? " 已配置"
: " 未配置"}
</p>
</div>
<Separator />
<div className="space-y-4">
<h3 className="text-lg font-semibold">钉钉登录</h3>
<p className="text-sm text-muted-foreground">
支持配置多个钉钉组织,每个组织包含名称、AppKey、AppSecret。默认使用第一个组织作为登录入口。
</p>
<div className="space-y-3">
{authSettings.dingTalkOrganizations.map((org, index) => (
<div
key={org.id}
className="grid grid-cols-1 gap-3 rounded-md border p-3 md:grid-cols-3"
>
<div className="space-y-1">
<Label htmlFor={`ding-org-name-${index}`}>组织名称</Label>
<Input
id={`ding-org-name-${index}`}
value={org.name}
placeholder={`例如:总部 / 研发中心 ${index + 1}`}
onChange={(e) => {
const value = e.target.value;
setAuthSettings({
...authSettings,
dingTalkOrganizations:
authSettings.dingTalkOrganizations.map(
(item, i) =>
i === index ? { ...item, name: value } : item,
),
});
}}
/>
</div>
<div className="space-y-1">
<Label htmlFor={`ding-org-appkey-${index}`}>AppKey</Label>
<Input
id={`ding-org-appkey-${index}`}
type="password"
value={org.appKey}
onChange={(e) => {
const value = e.target.value;
setAuthSettings({
...authSettings,
dingTalkOrganizations:
authSettings.dingTalkOrganizations.map(
(item, i) =>
i === index ? { ...item, appKey: value } : item,
),
});
}}
/>
</div>
<div className="space-y-1">
<Label htmlFor={`ding-org-appsecret-${index}`}>
AppSecret
</Label>
<div className="flex items-center gap-2">
<Input
id={`ding-org-appsecret-${index}`}
type="password"
value={org.appSecret}
onChange={(e) => {
const value = e.target.value;
setAuthSettings({
...authSettings,
dingTalkOrganizations:
authSettings.dingTalkOrganizations.map(
(item, i) =>
i === index
? { ...item, appSecret: value }
: item,
),
});
}}
/>
<Button
type="button"
variant="ghost"
size="icon"
className="shrink-0"
onClick={() => {
setAuthSettings({
...authSettings,
dingTalkOrganizations:
authSettings.dingTalkOrganizations.filter(
(_, i) => i !== index,
),
});
}}
disabled={authSettings.dingTalkOrganizations.length <= 1}
>
<Trash2 className="h-4 w-4 text-muted-foreground" />
</Button>
</div>
</div>
</div>
))}
<Button
type="button"
variant="outline"
className="w-full md:w-auto"
onClick={() => {
setAuthSettings({
...authSettings,
dingTalkOrganizations: [
...authSettings.dingTalkOrganizations,
{
id: crypto.randomUUID(),
name: `新组织 ${authSettings.dingTalkOrganizations.length + 1}`,
appKey: "",
appSecret: "",
},
],
});
}}
>
<Plus className="mr-2 h-4 w-4" />
新增组织
</Button>
</div>
<div className="flex items-center gap-2 pt-2">
<Switch
id="dingTalkEnabled"
checked={authSettings.isDingTalkEnabled}
onCheckedChange={(checked) => setAuthSettings({ ...authSettings, isDingTalkEnabled: checked })}
/>
<Label htmlFor="dingTalkEnabled">启用钉钉登录</Label>
</div>
</div>
<Separator />
<div className="space-y-3">
<h3 className="text-lg font-semibold">Google 登录</h3>
<p className="text-sm text-muted-foreground">
通过 Google OAuth 登录,可直接在这里配置 Google 凭据与 AUTH_URL。
</p>
<div className="grid grid-cols-1 gap-3 rounded-md border p-3 md:grid-cols-2">
<div className="space-y-1">
<Label htmlFor="googleClientId">Google Client ID</Label>
<Input
id="googleClientId"
value={authSettings.googleClientId || ""}
onChange={(e) =>
setAuthSettings({ ...authSettings, googleClientId: e.target.value })
}
placeholder="Google OAuth Client ID"
/>
</div>
<div className="space-y-1">
<Label htmlFor="googleClientSecret">Google Client Secret</Label>
<Input
id="googleClientSecret"
type="password"
value={authSettings.googleClientSecret || ""}
onChange={(e) =>
setAuthSettings({ ...authSettings, googleClientSecret: e.target.value })
}
placeholder="Google OAuth Client Secret"
/>
</div>
<div className="space-y-1 md:col-span-2">
<Label htmlFor="authUrl">AUTH_URL(站点地址)</Label>
<Input
id="authUrl"
value={authSettings.authUrl || ""}
onChange={(e) =>
setAuthSettings({ ...authSettings, authUrl: e.target.value })
}
placeholder="https://your-domain.com"
/>
</div>
</div>
<div className="flex items-center justify-between rounded-md border p-3">
<div className="space-y-1">
<Label htmlFor="googleEnabled">启用 Google 登录</Label>
<p className="text-xs text-muted-foreground">
当前服务器配置状态:{isGoogleConfigured ? "已配置" : "未配置"}
</p>
</div>
<Switch
id="googleEnabled"
checked={authSettings.isGoogleEnabled}
onCheckedChange={(checked) =>
setAuthSettings({ ...authSettings, isGoogleEnabled: checked })
}
/>
</div>
{!isGoogleConfigured ? (
<p className="text-xs text-amber-600">
未检测到完整 Google 配置(Client ID / Client Secret)。
</p>
) : null}
</div>
<Separator />
<Button onClick={handleSaveAuthSettings}>
<Save className="mr-2 h-4 w-4" /> 保存认证配置
</Button>
</CardContent>
</Card>
</TabsContent>
{/* 系统通知设置 */}
<TabsContent value="notification" className="mt-6">
<Card>
<CardHeader>
<CardTitle className="text-xl">系统通知</CardTitle>
<CardDescription>发布全站公告通知,用户进入系统时可见</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="flex items-center gap-2">
<Switch
id="notification-enabled"
checked={notification.enabled}
onCheckedChange={(checked) => setNotification({ ...notification, enabled: checked })}
/>
<Label htmlFor="notification-enabled">启用通知弹窗</Label>
</div>
<div className="space-y-2">
<Label htmlFor="notification-title">通知标题</Label>
<Input
id="notification-title"
value={notification.title}
onChange={(e) => setNotification({ ...notification, title: e.target.value })}
placeholder="例如:系统维护公告"
/>
</div>
<div className="space-y-2">
<Label htmlFor="notification-content">通知内容</Label>
<Textarea
id="notification-content"
value={notification.content}
onChange={(e) => setNotification({ ...notification, content: e.target.value })}
placeholder="请输入通知详细内容..."
rows={5}
/>
</div>
<Button onClick={handleSaveNotification}>
<Save className="mr-2 h-4 w-4" /> 保存通知设置
</Button>
</CardContent>
</Card>
</TabsContent>
{/* AI 模型配置 */}
<TabsContent value="ai" className="mt-6 space-y-6">
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle>AI 提供商</CardTitle>
<CardDescription>配置 AI 服务提供商</CardDescription>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" onClick={handleUseLemonProviderPreset}>
<Sparkles className="mr-2 h-4 w-4" /> LemonAPI Gemini
</Button>
<Dialog open={showProviderDialog} onOpenChange={setShowProviderDialog}>
<DialogTrigger asChild>
<Button onClick={() => { resetProviderForm(); setShowProviderDialog(true); }}>
<Plus className="mr-2 h-4 w-4" /> 添加提供商
</Button>
</DialogTrigger>
<DialogContent className="!bg-white !dark:bg-zinc-950">
<DialogHeader>
<DialogTitle>{editingProvider ? "编辑提供商" : "添加提供商"}</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label>名称</Label>
<Input
value={providerForm.name}
onChange={(e) => setProviderForm({ ...providerForm, name: e.target.value })}
placeholder="阿里云通义千问"
/>
</div>
<div className="space-y-2">
<Label>类型</Label>
<Select
value={providerForm.type}
onValueChange={(value) =>
setProviderForm((prev) => {
const previousDefaultBaseUrl = getProviderDefaultBaseUrl(prev.type);
const nextDefaultBaseUrl = getProviderDefaultBaseUrl(value);
const currentBaseUrl = prev.baseUrl.trim();
const shouldAutoReplace =
!editingProvider ||
!currentBaseUrl ||
currentBaseUrl === previousDefaultBaseUrl ||
!isHttpUrlLike(currentBaseUrl) ||
currentBaseUrl.includes("@");
return {
...prev,
type: value as any,
baseUrl: shouldAutoReplace
? nextDefaultBaseUrl || currentBaseUrl
: currentBaseUrl,
};
})
}
>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent
className="max-h-72 overflow-y-auto overscroll-contain !bg-white !dark:bg-zinc-950"
onWheelCapture={(event) => event.stopPropagation()}
>
{PROVIDER_TYPES.map((type) => (
<SelectItem key={type.value} value={type.value}>{type.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>API 地址</Label>
<Input
value={providerForm.baseUrl}
onChange={(e) => setProviderForm({ ...providerForm, baseUrl: e.target.value })}
autoComplete="off"
inputMode="url"
placeholder={getProviderDefaultBaseUrl(providerForm.type) || "https://api.openai.com/v1"}
/>
</div>
<div className="space-y-2">
<Label>API Key</Label>
<div className="relative">
<Input
type={showApiKey ? "text" : "password"}
value={providerForm.apiKey}
onChange={(e) => setProviderForm({ ...providerForm, apiKey: e.target.value })}
autoComplete="new-password"
placeholder={
editingProvider
? "留空则不修改"
: providerForm.type === "runway"
? "key_ 后跟 128 位十六进制字符"
: "sk-..."
}
/>
<Button
type="button" variant="ghost" size="sm"
className="absolute right-2 top-1/2 -translate-y-1/2"
onClick={() => setShowApiKey(!showApiKey)}
>
{showApiKey ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</Button>
</div>
{providerForm.type === "runway" && (
<p className="text-xs text-muted-foreground">
Runway Key 必须以 `key_` 开头,后面是 128 位十六进制字符。不要粘贴带“Bearer ”、“API Key:”或中文标点的内容。
</p>
)}
</div>
<div className="flex items-center gap-2">
<Switch
checked={providerForm.isEnabled}
onCheckedChange={(checked) => setProviderForm({ ...providerForm, isEnabled: checked })}
/>
<Label>启用</Label>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowProviderDialog(false)}>取消</Button>
<Button onClick={handleSaveProvider}>保存</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</CardHeader>
<CardContent>
<div className="space-y-4">
{providersData?.providers?.map((provider) => (
<div key={provider.id} className="flex items-center justify-between p-4 border rounded-lg">
<div className="flex-1">
<div className="font-medium flex items-center gap-2">
{provider.name}
{!provider.isEnabled && <span className="text-xs bg-gray-200 px-2 py-0.5 rounded">已禁用</span>}
</div>
<div className="text-sm text-muted-foreground">
类型: {PROVIDER_TYPES.find((t) => t.value === provider.type)?.label} | API: {provider.baseUrl}
</div>
{provider.type === 'volcano-video' && (
<Button
variant="secondary"
size="sm"
className="mt-2"
onClick={() => handleQuickAddVolcanoModels(provider.id)}
>
<Plus className="h-4 w-4 mr-1" />
快速添加预设模型
</Button>
)}
{provider.type === "openai" && isLemonApiBaseUrl(provider.baseUrl) && (
<Button
variant="secondary"
size="sm"
className="mt-2"
onClick={() => handleQuickAddLemonModels(provider.id)}
>
<Sparkles className="h-4 w-4 mr-1" />
快速添加 Gemini 预设模型
</Button>
)}
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => {
setProviderForm({
id: provider.id,
name: provider.name,
type: provider.type,
baseUrl: provider.baseUrl,
apiKey: "",
isEnabled: provider.isEnabled,
});
setEditingProvider(provider.id);
setShowProviderDialog(true);
}}>
<Edit className="h-4 w-4" />
</Button>
<Button variant="destructive" size="sm" onClick={() => handleDeleteProvider(provider.id)}>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
))}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle>AI 模型</CardTitle>
<CardDescription>配置可用的 AI 模型</CardDescription>
</div>
<Dialog open={showModelDialog} onOpenChange={setShowModelDialog}>
<DialogTrigger asChild>
<Button
onClick={() => { resetModelForm(); setShowModelDialog(true); }}
disabled={!providersData?.providers?.length}
>
<Plus className="mr-2 h-4 w-4" /> 添加模型
</Button>
</DialogTrigger>
<DialogContent className="!bg-white !dark:bg-zinc-950">
<DialogHeader>
<DialogTitle>{editingModel ? "编辑模型" : "添加模型"}</DialogTitle>
</DialogHeader>
<div className="space-y-4 max-h-[70vh] overflow-y-auto">
<div className="space-y-2">
<Label>所属提供商</Label>
<Select
value={modelForm.providerId}
onValueChange={(value) => setModelForm({ ...modelForm, providerId: value })}
>
<SelectTrigger><SelectValue placeholder="选择提供商" /></SelectTrigger>
<SelectContent className="!bg-white !dark:bg-zinc-950">
{providersData?.providers?.map((p) => (
<SelectItem key={p.id} value={p.id}>{p.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 🆕 火山引擎视频模型:显示4个模型配置表单 */}
{!editingModel && providersData?.providers?.find(p => p.id === modelForm.providerId)?.type === 'volcano-video' ? (
<div className="space-y-4">
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3 text-sm text-blue-800">
<p className="font-medium">📹 火山引擎视频模型批量配置</p>
<p className="text-xs mt-1">将一次性添加以下4个预设模型,您可以修改模型名称和描述</p>
</div>
{volcanoVideoModels.map((model, index) => (
<div key={index} className="border rounded-lg p-4 space-y-3">
<div className="flex items-center justify-between">
<h4 className="font-medium text-sm">模型 {index + 1}</h4>
<span className="text-xs text-muted-foreground">排序: {(index + 1) * 10}</span>
</div>
<div className="space-y-2">
<Label className="text-xs">模型 ID *</Label>
<Input
value={model.modelId}
onChange={(e) => {
const newModels = [...volcanoVideoModels];
newModels[index].modelId = e.target.value;
setVolcanoVideoModels(newModels);
}}
placeholder="例如: doubao-seedance-1-0-pro-250528"
/>
</div>
<div className="space-y-2">
<Label className="text-xs">显示名称 *</Label>
<Input
value={model.displayName}
onChange={(e) => {
const newModels = [...volcanoVideoModels];
newModels[index].displayName = e.target.value;
setVolcanoVideoModels(newModels);
}}
placeholder="例如: 豆包 Seedance Pro"
/>
</div>
<div className="space-y-2">
<Label className="text-xs">描述</Label>
<Textarea
value={model.description}
onChange={(e) => {
const newModels = [...volcanoVideoModels];
newModels[index].description = e.target.value;
setVolcanoVideoModels(newModels);
}}
placeholder="模型功能描述"
className="resize-none"
rows={2}
/>
</div>
</div>
))}
</div>
) : (
/* 普通模型:单个配置表单 */
<>
<div className="space-y-2">
<Label>模型 ID</Label>
<Input
value={modelForm.modelId}
onChange={(e) => setModelForm({ ...modelForm, modelId: e.target.value })}
placeholder="例如: qwen-turbo"
/>
<p className="text-xs text-muted-foreground">{modelKindHint}</p>
</div>
<div className="space-y-2">
<Label>显示名称</Label>
<Input
value={modelForm.displayName}
onChange={(e) => setModelForm({ ...modelForm, displayName: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label>模型头像(400x400)</Label>
<div className="flex items-center gap-3">
<div className="h-12 w-12 overflow-hidden rounded-full border bg-muted">
{modelForm.avatarUrl ? (
<img
src={modelForm.avatarUrl}
alt="模型头像预览"
className="h-full w-full object-cover"
/>
) : (
<div className="flex h-full w-full items-center justify-center text-[10px] text-muted-foreground">
400x400
</div>
)}
</div>
<div className="flex flex-1 items-center gap-2">
<Input
value={modelForm.avatarUrl}
onChange={(e) => setModelForm({ ...modelForm, avatarUrl: e.target.value })}
placeholder="头像 URL(推荐 400x400)"
/>
<Button
type="button"
variant="outline"
disabled={modelAvatarUploading}
onClick={() => document.getElementById("model-avatar-upload")?.click()}
>
{modelAvatarUploading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Upload className="h-4 w-4" />
)}
</Button>
<input
id="model-avatar-upload"
type="file"
accept="image/*"
className="hidden"
onChange={async (event) => {
const file = event.target.files?.[0];
if (file) {
await handleModelAvatarUpload(file);
}
event.target.value = "";
}}
/>
</div>
</div>
<p className="text-xs text-muted-foreground">上传后会自动裁切并压缩为 400x400 PNG。</p>
</div>
<div className="space-y-2">
<Label>描述</Label>
<Textarea
value={modelForm.description}
onChange={(e) => setModelForm({ ...modelForm, description: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label>排序顺序</Label>
<Input
type="number"
value={modelForm.sortOrder}
onChange={(e) => setModelForm({ ...modelForm, sortOrder: parseInt(e.target.value) || 0 })}
/>
</div>
{/* 🆕 备用模型 ID 配置 */}
<div className="space-y-2 border-t pt-4 mt-4">
<div className="flex items-center gap-2 mb-2">
<Label className="text-sm font-medium">备用模型配置</Label>
<span className="text-xs text-muted-foreground">(主模型请求失败时自动切换)</span>
</div>
<div className="space-y-2">
<Label className="text-xs text-muted-foreground">备用模型 ID 1(优先使用)</Label>
<Input
value={modelForm.fallbackModelId1}
onChange={(e) => setModelForm({ ...modelForm, fallbackModelId1: e.target.value })}
placeholder="例如: nano-banana-pro-backup"
/>
</div>
<div className="space-y-2">
<Label className="text-xs text-muted-foreground">备用模型 ID 2(最后备用)</Label>
<Input
value={modelForm.fallbackModelId2}
onChange={(e) => setModelForm({ ...modelForm, fallbackModelId2: e.target.value })}
placeholder="例如: nano-banana-lite"
/>
</div>
</div>
<div className="flex gap-4">
<div className="flex items-center gap-2">
<Switch
checked={modelForm.supportsVision}
onCheckedChange={(checked) => setModelForm({ ...modelForm, supportsVision: checked })}
/>
<Label className="whitespace-nowrap">支持视觉</Label>
</div>
<div className="flex items-center gap-2">
<Switch
checked={modelForm.supportsImageGen}
onCheckedChange={(checked) => setModelForm({ ...modelForm, supportsImageGen: checked })}
/>
<Label className="whitespace-nowrap">支持绘图</Label>
</div>
</div>
<div className="space-y-3">
<div className="flex gap-6">
<div className="flex items-center gap-2">
<Switch
checked={modelForm.isEnabled}
onCheckedChange={(checked) => setModelForm({ ...modelForm, isEnabled: checked })}
/>
<Label className="whitespace-nowrap">启用</Label>
</div>
<div className="flex items-center gap-2">
<Switch
checked={modelForm.isDefault}
onCheckedChange={(checked) => setModelForm({ ...modelForm, isDefault: checked })}
/>
<Label className="whitespace-nowrap">设为默认</Label>
</div>
</div>
<div className="flex items-center gap-2">
<Switch
checked={modelForm.isHidden}
onCheckedChange={(checked) => setModelForm({ ...modelForm, isHidden: checked })}
/>
<Label>前台隐藏(仅 Agent/故障迁移可用)</Label>
</div>
</div>
<div className="flex items-center gap-2">
<Switch
checked={modelForm.isAgentModel}
onCheckedChange={(checked) => setModelForm({ ...modelForm, isAgentModel: checked })}
/>
<Label>Agent 自动模型(前端隐藏,仅后台 Agent 使用)</Label>
</div>
{modelForm.isAgentModel && (
<div className="space-y-2">
<Label className="text-sm">编程模型 ID(可选)</Label>
<Input
value={modelForm.agentCodingModelId}
onChange={(e) =>
setModelForm({
...modelForm,
agentCodingModelId: e.target.value,
})
}
placeholder="例如: doubao-seed-2-0-code-preview-260215"
/>
<p className="text-xs text-muted-foreground">
Agent 需要结构化规划/编程输出时优先使用该模型 ID,留空则使用当前 Agent 模型。
</p>
</div>
)}
<div className="flex items-center gap-2">
<Switch
checked={modelForm.isMultimodal || false}
onCheckedChange={(checked) => setModelForm({ ...modelForm, isMultimodal: checked })}
/>
<Label>多模态模型(Agent 识图与分析使用同一模型)</Label>
</div>
<div className="flex items-center gap-2">
<Switch
checked={modelForm.excludeFromFailover || false}
onCheckedChange={(checked) =>
setModelForm({ ...modelForm, excludeFromFailover: checked })
}
/>
<Label>禁止参与故障迁移(不作为其他模型的自动备用模型)</Label>
</div>
{/* 🆕 图片生成模型特性配置 */}
{(isImageProviderForModelConfig || isVideoProviderForModelConfig) && (
<div className="border rounded-lg p-4 space-y-4 bg-muted/30">
<div className="font-medium text-sm">生成配置</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>最大输出图片数</Label>
<Input
type="number"
min={1}
max={10}
value={modelForm.imageGenConfig?.maxImages || 2}
onChange={(e) => setModelForm({
...modelForm,
imageGenConfig: {
...modelForm.imageGenConfig,
maxImages: Number.parseInt(e.target.value) || 2,
},
})}
/>
<p className="text-xs text-muted-foreground">Gemini: 1-2, 阿里: 1-6</p>
</div>
<div className="space-y-2">
<Label>每张图片积分消耗</Label>
<Input
type="number"
min={0}
step={1}
value={modelForm.imageGenConfig?.creditCostPerImage ?? 0}
onChange={(e) => setModelForm({
...modelForm,
imageGenConfig: {
...modelForm.imageGenConfig,
creditCostPerImage: Math.max(
0,
Number.parseInt(e.target.value, 10) || 0
),
},
})}
/>
<p className="text-xs text-muted-foreground">
开启积分系统后,将按“单价 × 实际出图张数”扣除积分。
</p>
</div>
<div className="space-y-2">
<Label>每条视频积分消耗</Label>
<Input
type="number"
min={0}
step={1}
value={modelForm.imageGenConfig?.creditCostPerVideo ?? 0}
onChange={(e) => setModelForm({
...modelForm,
imageGenConfig: {
...modelForm.imageGenConfig,
creditCostPerVideo: Math.max(
0,
Number.parseInt(e.target.value, 10) || 0
),
},
})}
/>
<p className="text-xs text-muted-foreground">
视频生成成功后按每条视频扣除积分。未配置时默认使用“每张图片积分”作为兼容值。
</p>
</div>
<div className="space-y-2">
<Label>占位图颜色主题</Label>
<Select
value={modelForm.imageGenConfig?.loadingColor || "purple"}
onValueChange={(value) => setModelForm({
...modelForm,
imageGenConfig: {
...modelForm.imageGenConfig,
loadingColor: value,
},
})}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="purple">紫色 (Gemini)</SelectItem>
<SelectItem value="orange">橙色 (阿里)</SelectItem>
<SelectItem value="blue">蓝色 (火山)</SelectItem>
<SelectItem value="green">绿色</SelectItem>
<SelectItem value="red">红色</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="flex items-center gap-2">
<Switch
checked={modelForm.imageGenConfig?.supportsPromptExtend || false}
onCheckedChange={(checked) => setModelForm({
...modelForm,
imageGenConfig: {
...modelForm.imageGenConfig,
supportsPromptExtend: checked,
},
})}
/>
<Label className="text-sm">支持提示词优化</Label>
</div>
<div className="flex items-center gap-2">
<Switch
checked={modelForm.imageGenConfig?.supportsNegativePrompt || false}
onCheckedChange={(checked) => setModelForm({
...modelForm,
imageGenConfig: {
...modelForm.imageGenConfig,
supportsNegativePrompt: checked,
},
})}
/>
<Label className="text-sm">支持反向提示词</Label>
</div>
<div className="flex items-center gap-2">
<Switch
checked={modelForm.imageGenConfig?.supportsSize || false}
onCheckedChange={(checked) => setModelForm({
...modelForm,
imageGenConfig: {
...modelForm.imageGenConfig,
supportsSize: checked,
},
})}
/>
<Label className="text-sm">支持自定义分辨率</Label>
</div>
<div className="flex items-center gap-2">
<Switch
checked={modelForm.imageGenConfig?.enableLoadingPlaceholder !== false}
onCheckedChange={(checked) => setModelForm({
...modelForm,
imageGenConfig: {
...modelForm.imageGenConfig,
enableLoadingPlaceholder: checked,
},
})}
/>
<Label className="text-sm">启用加载占位图</Label>
</div>
</div>
{/* 分辨率档位开关 */}
<div className="border rounded-md p-3 space-y-2 bg-muted/20">
<div className="text-sm font-medium text-muted-foreground">分辨率档位控制</div>
<p className="text-xs text-muted-foreground">控制前台用户可选的分辨率档位,关闭后用户将无法选择对应分辨率</p>
<div className="grid grid-cols-3 gap-4 mt-2">
<div className="flex items-center gap-2">
<Switch
checked={modelForm.imageGenConfig?.supports1K !== false}
onCheckedChange={(checked) => setModelForm({
...modelForm,
imageGenConfig: {
...modelForm.imageGenConfig,
supports1K: checked,
},
})}
/>
<Label className="text-sm">1K</Label>
</div>
<div className="flex items-center gap-2">
<Switch
checked={modelForm.imageGenConfig?.supports2K !== false}
onCheckedChange={(checked) => setModelForm({
...modelForm,
imageGenConfig: {
...modelForm.imageGenConfig,
supports2K: checked,
},
})}
/>
<Label className="text-sm">2K</Label>
</div>
<div className="flex items-center gap-2">
<Switch
checked={modelForm.imageGenConfig?.supports4K !== false}
onCheckedChange={(checked) => setModelForm({
...modelForm,
imageGenConfig: {
...modelForm.imageGenConfig,
supports4K: checked,
},
})}
/>
<Label className="text-sm">4K</Label>
</div>
</div>
</div>
</div>
)}
</>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowModelDialog(false)}>取消</Button>
<Button onClick={handleSaveModel}>保存</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</CardHeader>
<CardContent>
<div className="space-y-4">
{modelsData?.models?.map((model) => {
const provider = providersData?.providers?.find((p) => p.id === model.providerId);
const isVideoModel = isVideoModelDescriptor({
providerType: provider?.type,
modelId: model.modelId,
capabilities: (model as any).capabilities,
supportsImageGen: model.supportsImageGen,
});
const isImageModel = isImageGenerationModelDescriptor({
providerType: provider?.type,
modelId: model.modelId,
capabilities: (model as any).capabilities,
supportsImageGen: model.supportsImageGen,
});
return (
<div key={model.id} className="flex items-center justify-between p-4 border rounded-lg">
<div className="flex flex-1 items-center gap-3">
<div className="h-10 w-10 overflow-hidden rounded-full border bg-muted">
{model.avatarUrl ? (
<img src={model.avatarUrl} alt={model.displayName} className="h-full w-full object-cover" />
) : (
<div className="flex h-full w-full items-center justify-center text-xs text-muted-foreground">
无图
</div>
)}
</div>
<div className="min-w-0">
<div className="font-medium flex items-center gap-2">
<span className="truncate">{model.displayName}</span>
{model.isDefault && <span className="text-xs bg-blue-100 text-blue-800 px-2 py-0.5 rounded">默认</span>}
{model.isAgentModel && <span className="text-xs bg-violet-100 text-violet-800 px-2 py-0.5 rounded">Agent</span>}
{isImageModel && <span className="text-xs bg-sky-100 text-sky-800 px-2 py-0.5 rounded">图片模型</span>}
{isVideoModel && <span className="text-xs bg-red-100 text-red-800 px-2 py-0.5 rounded">视频模型</span>}
{!isImageModel && !isVideoModel && <span className="text-xs bg-slate-100 text-slate-700 px-2 py-0.5 rounded">对话模型</span>}
{model.isMultimodal && <span className="text-xs bg-emerald-100 text-emerald-800 px-2 py-0.5 rounded">多模态</span>}
{model.isHidden && <span className="text-xs bg-amber-100 text-amber-800 px-2 py-0.5 rounded">前台隐藏</span>}
{(model as any).excludeFromFailover && <span className="text-xs bg-rose-100 text-rose-700 px-2 py-0.5 rounded">不参与故障迁移</span>}
</div>
<div className="text-sm text-muted-foreground truncate">
ID: {model.modelId} | 提供商: {provider?.name ?? "未知"}
</div>
</div>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => {
setModelForm({
id: model.id,
providerId: model.providerId,
modelId: model.modelId,
displayName: model.displayName,
avatarUrl: (model as any).avatarUrl || "",
description: model.description || "",
supportsVision: model.supportsVision,
supportsImageGen: model.supportsImageGen,
isEnabled: model.isEnabled,
isHidden: model.isHidden || false,
isDefault: model.isDefault,
isAgentModel: model.isAgentModel || false,
agentCodingModelId: model.agentCodingModelId || "",
isMultimodal: model.isMultimodal || false,
sortOrder: model.sortOrder || 0,
fallbackModelId1: (model as any).fallbackModelId1 || "",
fallbackModelId2: (model as any).fallbackModelId2 || "",
excludeFromFailover: (model as any).excludeFromFailover || false,
imageGenConfig: (model as any).imageGenConfig || {
maxImages: 2,
supportsSize: false,
supports1K: true,
supports2K: true,
supports4K: true,
supportsPromptExtend: false,
supportsNegativePrompt: false,
supportsWatermark: false,
supportsSeed: false,
creditCostPerImage: 0,
creditCostPerVideo: 0,
enableLoadingPlaceholder: true,
loadingColor: "purple",
},
});
setEditingModel(model.id);
setShowModelDialog(true);
}}>
<Edit className="h-4 w-4" />
</Button>
<Button variant="destructive" size="sm" onClick={() => handleDeleteModel(model.id)}>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
);
})}
</div>
</CardContent>
</Card>
</TabsContent>
{/* 存储配置 */}
<TabsContent value="storage" className="mt-6 space-y-6">
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle>存储配置</CardTitle>
<CardDescription>配置 S3 兼容对象存储</CardDescription>
</div>
<Dialog open={showStorageDialog} onOpenChange={setShowStorageDialog}>
<DialogTrigger asChild>
<Button onClick={() => { resetStorageForm(); setShowStorageDialog(true); }}>
<Plus className="mr-2 h-4 w-4" /> 添加配置
</Button>
</DialogTrigger>
<DialogContent className="!bg-white !dark:bg-zinc-950">
<DialogHeader>
<DialogTitle>{editingStorage ? "编辑配置" : "添加配置"}</DialogTitle>
</DialogHeader>
<div className="space-y-4 max-h-96 overflow-y-auto">
<div className="space-y-2">
<Label>配置名称</Label>
<Input
value={storageForm.name}
onChange={(e) => setStorageForm({ ...storageForm, name: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label>类型</Label>
<Select
value={storageForm.type}
onValueChange={(value) => setStorageForm({ ...storageForm, type: value as any })}
>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent className="!bg-white !dark:bg-zinc-950">
<SelectItem value="s3">S3 兼容</SelectItem>
<SelectItem value="aliyun-oss">阿里云 OSS</SelectItem>
<SelectItem value="local">本地存储</SelectItem>
</SelectContent>
</Select>
</div>
{(storageForm.type === "s3" || storageForm.type === "aliyun-oss") && (
<>
{storageForm.type === "aliyun-oss" && (
<div className="rounded-md bg-blue-50 p-3 mb-4 text-xs text-blue-700 border border-blue-100">
<p className="font-semibold mb-1">阿里云 OSS 配置说明:</p>
<p>• Endpoint 示例:https://oss-cn-shanghai.aliyuncs.com</p>
<p>• 请确保 AccessKey 拥有对应 Bucket 的读写权限</p>
</div>
)}
<div className="space-y-2">
<Label>端点 (Endpoint)</Label>
<Input
value={storageForm.endpoint}
onChange={(e) => setStorageForm({ ...storageForm, endpoint: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label>Bucket</Label>
<Input
value={storageForm.bucket}
onChange={(e) => setStorageForm({ ...storageForm, bucket: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label>Access Key</Label>
<Input
value={storageForm.accessKeyId}
onChange={(e) => setStorageForm({ ...storageForm, accessKeyId: e.target.value })}
placeholder={editingStorage ? "留空则不修改" : ""}
/>
</div>
<div className="space-y-2">
<Label>Secret Key</Label>
<Input
type="password"
value={storageForm.secretAccessKey}
onChange={(e) => setStorageForm({ ...storageForm, secretAccessKey: e.target.value })}
placeholder={editingStorage ? "留空则不修改" : ""}
/>
</div>
<div className="space-y-2">
<Label>Region</Label>
<Input
value={storageForm.region}
onChange={(e) => setStorageForm({ ...storageForm, region: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label>Public URL</Label>
<Input
value={storageForm.publicUrl}
onChange={(e) => setStorageForm({ ...storageForm, publicUrl: e.target.value })}
/>
</div>
</>
)}
<div className="flex gap-4">
<div className="flex items-center gap-2">
<Switch
checked={storageForm.isEnabled}
onCheckedChange={(checked) => setStorageForm({ ...storageForm, isEnabled: checked })}
/>
<Label>启用</Label>
</div>
<div className="flex items-center gap-2">
<Switch
checked={storageForm.isDefault}
onCheckedChange={(checked) => setStorageForm({ ...storageForm, isDefault: checked })}
/>
<Label>默认</Label>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowStorageDialog(false)}>取消</Button>
<Button onClick={handleSaveStorage}>保存</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</CardHeader>
<CardContent>
<div className="space-y-4">
{storageData?.configs?.map((config) => (
<div key={config.id} className="flex items-center justify-between p-4 border rounded-lg">
<div className="flex-1">
<div className="font-medium flex items-center gap-2">
{config.name}
{config.isDefault && <span className="text-xs bg-blue-100 text-blue-800 px-2 py-0.5 rounded">默认</span>}
{!config.isEnabled && <span className="text-xs bg-gray-200 px-2 py-0.5 rounded">已禁用</span>}
</div>
<div className="text-sm text-muted-foreground">
{config.type === "s3"
? (config.endpoint?.includes("aliyuncs.com") ? "阿里云 OSS" : "S3") + ` | Bucket: ${config.bucket}`
: "本地存储"}
</div>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => {
setStorageForm({
id: config.id,
name: config.name,
type: config.type === "s3" && config.endpoint?.includes("aliyuncs.com") ? "aliyun-oss" : config.type as any,
endpoint: config.endpoint || "",
region: config.region || "",
bucket: config.bucket || "",
accessKeyId: "",
secretAccessKey: "",
publicUrl: config.publicUrl || "",
isDefault: config.isDefault,
isEnabled: config.isEnabled,
});
setEditingStorage(config.id);
setShowStorageDialog(true);
}}>
<Edit className="h-4 w-4" />
</Button>
<Button variant="destructive" size="sm" onClick={() => handleDeleteStorage(config.id)}>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
))}
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>存储故障迁移</CardTitle>
<CardDescription>
主存储桶上传失败时,自动切换到备用 MinIO/S3/阿里云 OSS 等 S3 兼容存储桶。
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between rounded-lg border p-4">
<div className="space-y-1">
<Label className="text-sm font-medium">启用备用桶故障迁移</Label>
<p className="text-xs text-muted-foreground">
建议仅在主桶不稳定时开启,避免无效重试。
</p>
</div>
<Switch
checked={siteSettings.storageFailoverConfig.enabled}
onCheckedChange={(checked) =>
setSiteSettings((prev) => ({
...prev,
storageFailoverConfig: {
...prev.storageFailoverConfig,
enabled: checked,
},
}))
}
/>
</div>
<div className="space-y-2">
<Label>备用存储配置</Label>
<Select
value={siteSettings.storageFailoverConfig.backupStorageConfigId || "none"}
onValueChange={(value) =>
setSiteSettings((prev) => ({
...prev,
storageFailoverConfig: {
...prev.storageFailoverConfig,
backupStorageConfigId: value === "none" ? "" : value,
},
}))
}
>
<SelectTrigger>
<SelectValue placeholder="选择备用 S3/MinIO/阿里云 OSS 配置" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">不使用备用配置</SelectItem>
{(storageData?.configs || [])
.filter((config) => config.type === "s3" && config.isEnabled)
.map((config) => (
<SelectItem key={config.id} value={config.id}>
{config.name}
{config.isDefault ? "(当前默认)" : ""}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
仅可选择已启用的 S3 兼容配置(如阿里云 OSS、MinIO 等)作为备用桶。
</p>
</div>
{siteSettings.storageFailoverConfig.enabled &&
siteSettings.storageFailoverConfig.backupStorageConfigId &&
(storageData?.configs || []).some(
(config) =>
config.id === siteSettings.storageFailoverConfig.backupStorageConfigId &&
config.isDefault
) && (
<div className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-700">
备用桶不能与默认桶相同,请选择另一条存储配置。
</div>
)}
<div className="flex justify-end">
<Button onClick={handleSaveSiteSettings}>保存故障迁移设置</Button>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>阿里云 OSS 自动转存</CardTitle>
<CardDescription>
若已配置阿里云 OSS,上传优先进入 OSS;到期后自动转存到目标默认桶并替换数据库 URL。
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between rounded-lg border p-4">
<div className="space-y-1">
<Label className="text-sm font-medium">启用 OSS 自动转存</Label>
<p className="text-xs text-muted-foreground">
开启后执行“先 OSS、后迁移、再清理”的生命周期策略(不依赖 CDN)。
</p>
</div>
<Switch
checked={siteSettings.ossImageMigrationConfig.enabled}
onCheckedChange={(checked) =>
setSiteSettings((prev) => ({
...prev,
ossImageMigrationConfig: {
...prev.ossImageMigrationConfig,
enabled: checked,
},
}))
}
/>
</div>
<div className="flex items-center justify-between rounded-lg border p-4">
<div className="space-y-1">
<Label className="text-sm font-medium">默认使用阿里云 OSS 入口桶</Label>
<p className="text-xs text-muted-foreground">
关闭后新上传图片直接走当前默认存储,不再优先进入阿里云 OSS。
</p>
<p className="text-xs text-muted-foreground">
该开关只影响新图上传入口,不影响下方的转存时间、清理时间、单次批量任务设置。
</p>
</div>
<Switch
checked={siteSettings.ossImageMigrationConfig.preferAliyunForUploads}
onCheckedChange={(checked) =>
setSiteSettings((prev) => ({
...prev,
ossImageMigrationConfig: {
...prev.ossImageMigrationConfig,
preferAliyunForUploads: checked,
},
}))
}
/>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label>OSS 源桶(可选指定)</Label>
<Select
value={siteSettings.ossImageMigrationConfig.sourceStorageConfigId || "auto"}
onValueChange={(value) =>
setSiteSettings((prev) => ({
...prev,
ossImageMigrationConfig: {
...prev.ossImageMigrationConfig,
sourceStorageConfigId: value === "auto" ? "" : value,
},
}))
}
>
<SelectTrigger
disabled={!siteSettings.ossImageMigrationConfig.preferAliyunForUploads}
>
<SelectValue placeholder="自动选择阿里云 OSS(默认优先)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="auto">自动选择阿里云 OSS</SelectItem>
{(storageData?.configs || [])
.filter(
(config) =>
config.type === "s3" &&
config.isEnabled &&
config.endpoint?.includes("aliyuncs.com")
)
.map((config) => (
<SelectItem key={config.id} value={config.id}>
{config.name}
{config.isDefault ? "(默认)" : ""}
</SelectItem>
))}
</SelectContent>
</Select>
{!siteSettings.ossImageMigrationConfig.preferAliyunForUploads ? (
<p className="text-xs text-muted-foreground">
已关闭默认走阿里云 OSS,当前源桶选择不会用于新上传图片。
</p>
) : null}
</div>
<div className="space-y-2">
<Label>目标转存桶(可选指定)</Label>
<Select
value={siteSettings.ossImageMigrationConfig.targetStorageConfigId || "default"}
onValueChange={(value) =>
setSiteSettings((prev) => ({
...prev,
ossImageMigrationConfig: {
...prev.ossImageMigrationConfig,
targetStorageConfigId: value === "default" ? "" : value,
},
}))
}
>
<SelectTrigger>
<SelectValue placeholder="默认存储桶(建议非阿里云)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="default">使用当前默认存储桶</SelectItem>
{(storageData?.configs || [])
.filter((config) => config.type === "s3" && config.isEnabled)
.map((config) => (
<SelectItem key={config.id} value={config.id}>
{config.name}
{config.endpoint?.includes("aliyuncs.com")
? "(阿里云 OSS,不建议)"
: ""}
{config.isDefault ? "(默认)" : ""}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="grid gap-4 md:grid-cols-3">
<div className="space-y-2">
<Label>转存时间(小时)</Label>
<Input
type="number"
min={1}
max={720}
value={siteSettings.ossImageMigrationConfig.migrateAfterHours}
onChange={(e) =>
setSiteSettings((prev) => ({
...prev,
ossImageMigrationConfig: {
...prev.ossImageMigrationConfig,
migrateAfterHours: Math.max(
1,
Math.min(720, Number(e.target.value) || 48)
),
},
}))
}
/>
</div>
<div className="space-y-2">
<Label>清理时间(小时)</Label>
<Input
type="number"
min={2}
max={1440}
value={siteSettings.ossImageMigrationConfig.cleanupAfterHours}
onChange={(e) =>
setSiteSettings((prev) => ({
...prev,
ossImageMigrationConfig: {
...prev.ossImageMigrationConfig,
cleanupAfterHours: Math.max(
2,
Math.min(1440, Number(e.target.value) || 72)
),
},
}))
}
/>
</div>
<div className="space-y-2">
<Label>单次批量任务</Label>
<Input
type="number"
min={1}
max={100}
value={siteSettings.ossImageMigrationConfig.batchSize}
onChange={(e) =>
setSiteSettings((prev) => ({
...prev,
ossImageMigrationConfig: {
...prev.ossImageMigrationConfig,
batchSize: Math.max(
1,
Math.min(100, Number(e.target.value) || 20)
),
},
}))
}
/>
</div>
</div>
<div className="rounded-lg border border-blue-200 bg-blue-50 px-3 py-2 text-xs text-blue-700">
推荐值:转存 48 小时,清理 72 小时。并配置定时调用
<code className="mx-1">/api/jobs/migrate-oss-images</code>
执行迁移任务。
</div>
<div className="flex justify-end">
<Button onClick={handleSaveSiteSettings}>保存 OSS 自动转存设置</Button>
</div>
</CardContent>
</Card>
</TabsContent>
{/* 本地AI设置 */}
<TabsContent value="rmbg" className="mt-6 space-y-6">
<Card>
<CardHeader>
<CardTitle className="text-xl">Moonshot 官方 Kimi K2.5 配置</CardTitle>
<CardDescription>
为 AI详情生成 单独配置 Moonshot 官方 Kimi K2.5 模型。保存后会自动绑定到“AI详情生成 - Agent专用模型”。
</CardDescription>
</CardHeader>
<CardContent className="space-y-6 pt-6">
<div className="space-y-2">
<Label htmlFor="moonshotProviderName">提供商名称</Label>
<Input
id="moonshotProviderName"
value={moonshotDetailAgentConfig.providerName}
onChange={(e) =>
setMoonshotDetailAgentConfig((prev) => ({
...prev,
providerName: e.target.value,
}))
}
placeholder="Moonshot AI (Official)"
/>
</div>
<div className="space-y-2">
<Label htmlFor="moonshotBaseUrl">官方 API 地址</Label>
<Input
id="moonshotBaseUrl"
value={moonshotDetailAgentConfig.baseUrl}
onChange={(e) =>
setMoonshotDetailAgentConfig((prev) => ({
...prev,
baseUrl: e.target.value,
}))
}
placeholder={DETAIL_AGENT_LOCAL_MOONSHOT_BASE_URL}
/>
<div className="text-xs text-muted-foreground">
根据 Moonshot 官方文档,OpenAI 兼容接口地址为 `{DETAIL_AGENT_LOCAL_MOONSHOT_BASE_URL}`
</div>
</div>
<div className="space-y-2">
<Label htmlFor="moonshotApiKey">
API Key {moonshotDetailAgentConfig.providerId ? "(留空则不修改)" : "*"}
</Label>
<Input
id="moonshotApiKey"
type="password"
value={moonshotDetailAgentConfig.apiKey}
onChange={(e) =>
setMoonshotDetailAgentConfig((prev) => ({
...prev,
apiKey: e.target.value,
}))
}
placeholder={
moonshotDetailAgentConfig.providerId
? "留空则不修改当前 Key"
: "请输入 Moonshot 官方 API Key"
}
/>
</div>
<div className="space-y-2">
<Label htmlFor="moonshotModelId">Kimi K2.5 API 模型 ID *</Label>
<Input
id="moonshotModelId"
value={moonshotDetailAgentConfig.modelId}
onChange={(e) =>
setMoonshotDetailAgentConfig((prev) => ({
...prev,
modelId: e.target.value,
}))
}
placeholder={DETAIL_AGENT_LOCAL_MOONSHOT_MODEL_ID}
/>
<div className="text-xs text-muted-foreground">
已按你提供的 Moonshot 官方文档默认填写 `kimi-k2.5`;保存后系统会自动创建/更新对应模型记录。
</div>
</div>
<div className="space-y-2">
<Label htmlFor="moonshotDisplayName">模型显示名称</Label>
<Input
id="moonshotDisplayName"
value={moonshotDetailAgentConfig.displayName}
onChange={(e) =>
setMoonshotDetailAgentConfig((prev) => ({
...prev,
displayName: e.target.value,
}))
}
placeholder="官方 Kimi K2.5"
/>
</div>
<div className="flex items-center justify-between rounded-lg border p-3">
<div className="space-y-0.5">
<Label className="text-sm">启用该详情 Agent 模型</Label>
<div className="text-xs text-muted-foreground">
关闭后仍会保留记录,但不会被 AI详情生成 选作专用 Agent 模型。
</div>
</div>
<div className="flex items-center gap-2">
<Switch
checked={moonshotDetailAgentConfig.isEnabled}
onCheckedChange={(checked) =>
setMoonshotDetailAgentConfig((prev) => ({
...prev,
isEnabled: checked,
}))
}
/>
<span className="text-sm font-medium text-muted-foreground min-w-[3rem]">
{moonshotDetailAgentConfig.isEnabled ? "已开启" : "已关闭"}
</span>
</div>
</div>
<div className="rounded-lg border border-blue-200 bg-blue-50 p-4 text-xs text-blue-800 space-y-1">
<p>• 专用于 AI详情生成 的分镜规划与排版复审,不影响普通 Agent 对话。</p>
<p>• 系统会按多模态 Agent 模型写入:支持视觉、支持结构化规划、支持协议复审。</p>
<p>• 保存成功后会自动写入“AI详情生成 - Agent专用模型”。</p>
</div>
<div className="flex justify-end">
<Button onClick={handleSaveMoonshotDetailAgentConfig}>
<Save className="mr-2 h-4 w-4" />
保存 Moonshot Kimi K2.5 配置
</Button>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-xl">Tripo3D API 配置</CardTitle>
<CardDescription>
配置无限画布 3D 生成功能使用的 Tripo3D 接口,保存后写入数据库 AI 提供商配置。
</CardDescription>
</CardHeader>
<CardContent className="space-y-6 pt-6">
<div className="space-y-2">
<Label htmlFor="tripo3dName">提供商名称</Label>
<Input
id="tripo3dName"
value={tripo3dConfig.name}
onChange={(e) =>
setTripo3dConfig({ ...tripo3dConfig, name: e.target.value })
}
placeholder="Tripo3D"
/>
</div>
<div className="space-y-2">
<Label htmlFor="tripo3dBaseUrl">API 地址</Label>
<Input
id="tripo3dBaseUrl"
value={tripo3dConfig.baseUrl}
onChange={(e) =>
setTripo3dConfig({ ...tripo3dConfig, baseUrl: e.target.value })
}
placeholder={getProviderDefaultBaseUrl("tripo-3d")}
/>
<div className="text-xs text-muted-foreground">
默认:{getProviderDefaultBaseUrl("tripo-3d")}
</div>
</div>
<div className="space-y-2">
<Label htmlFor="tripo3dApiKey">
API Key {tripo3dConfig.providerId ? "(留空则不修改)" : "*"}
</Label>
<Input
id="tripo3dApiKey"
type="password"
value={tripo3dConfig.apiKey}
onChange={(e) =>
setTripo3dConfig({ ...tripo3dConfig, apiKey: e.target.value })
}
placeholder={
tripo3dConfig.providerId ? "留空则不修改当前 Key" : "请输入 Tripo3D API Key"
}
/>
</div>
<div className="flex items-center justify-between rounded-lg border p-3">
<div className="space-y-0.5">
<Label className="text-sm">启用 Tripo3D</Label>
<div className="text-xs text-muted-foreground">
关闭后 3D 生成接口将不可用
</div>
</div>
<div className="flex items-center gap-2">
<Switch
checked={tripo3dConfig.isEnabled}
onCheckedChange={(checked) =>
setTripo3dConfig({ ...tripo3dConfig, isEnabled: checked })
}
/>
<span className="text-sm font-medium text-muted-foreground min-w-[3rem]">
{tripo3dConfig.isEnabled ? "已开启" : "已关闭"}
</span>
</div>
</div>
<Button onClick={handleSaveTripo3dConfig}>
<Save className="mr-2 h-4 w-4" /> 保存 Tripo3D 配置
</Button>
</CardContent>
</Card>
{/* 抠图API配置 */}
<Card>
<CardHeader>
<CardTitle className="text-xl">抠图API配置</CardTitle>
<CardDescription>
配置背景移除API,用于画图标注功能中的抠图处理。支持自部署RMBG、PhotoRoom和AOS云服务。
</CardDescription>
</CardHeader>
<CardContent className="space-y-6 pt-6">
{/* 服务提供商选择 */}
<div className="space-y-3">
<Label>抠图服务提供商</Label>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div
className={`relative flex cursor-pointer items-center gap-3 rounded-lg border p-4 transition-all hover:bg-accent ${
rmbgConfig.provider === "rmbg" ? "border-primary bg-accent ring-1 ring-primary" : ""
}`}
onClick={() => setRmbgConfig({ ...rmbgConfig, provider: "rmbg" })}
>
<div className={`flex h-4 w-4 shrink-0 items-center justify-center rounded-full border border-primary ${
rmbgConfig.provider === "rmbg" ? "bg-primary text-primary-foreground" : "bg-transparent"
}`}>
{rmbgConfig.provider === "rmbg" && <Check className="h-3 w-3" />}
</div>
<div className="space-y-1">
<p className="text-sm font-medium leading-none">RMBG-2.0 (自部署)</p>
</div>
</div>
<div
className={`relative flex cursor-pointer items-center gap-3 rounded-lg border p-4 transition-all hover:bg-accent ${
rmbgConfig.provider === "photoroom" ? "border-primary bg-accent ring-1 ring-primary" : ""
}`}
onClick={() => setRmbgConfig({ ...rmbgConfig, provider: "photoroom" })}
>
<div className={`flex h-4 w-4 shrink-0 items-center justify-center rounded-full border border-primary ${
rmbgConfig.provider === "photoroom" ? "bg-primary text-primary-foreground" : "bg-transparent"
}`}>
{rmbgConfig.provider === "photoroom" && <Check className="h-3 w-3" />}
</div>
<div className="space-y-1">
<p className="text-sm font-medium leading-none">PhotoRoom (云服务)</p>
</div>
</div>
<div
className={`relative flex cursor-pointer items-center gap-3 rounded-lg border p-4 transition-all hover:bg-accent ${
rmbgConfig.provider === "aoscdn" ? "border-primary bg-accent ring-1 ring-primary" : ""
}`}
onClick={() => setRmbgConfig({ ...rmbgConfig, provider: "aoscdn" })}
>
<div className={`flex h-4 w-4 shrink-0 items-center justify-center rounded-full border border-primary ${
rmbgConfig.provider === "aoscdn" ? "bg-primary text-primary-foreground" : "bg-transparent"
}`}>
{rmbgConfig.provider === "aoscdn" && <Check className="h-3 w-3" />}
</div>
<div className="space-y-1">
<p className="text-sm font-medium leading-none">AOS Segmentation</p>
</div>
</div>
</div>
</div>
<Separator />
{/* RMBG 配置 */}
{rmbgConfig.provider === "rmbg" && (
<>
<div className="text-sm font-medium text-muted-foreground">RMBG-2.0 配置</div>
<div className="space-y-2">
<Label htmlFor="rmbgApiUrl">API地址 *</Label>
<Input
id="rmbgApiUrl"
value={rmbgConfig.apiUrl}
onChange={(e) => setRmbgConfig({ ...rmbgConfig, apiUrl: e.target.value })}
placeholder="http://156.225.27.52:8000"
/>
<div className="text-xs text-muted-foreground">
请输入完整的API地址,例如: http://156.225.27.52:8000
</div>
</div>
<div className="space-y-2">
<Label htmlFor="rmbgApiKey">API密钥(可选)</Label>
<Input
id="rmbgApiKey"
type="password"
value={rmbgConfig.apiKey}
onChange={(e) => setRmbgConfig({ ...rmbgConfig, apiKey: e.target.value })}
placeholder="如果API需要密钥,请在此输入"
/>
</div>
<div className="rounded-lg border border-blue-200 bg-blue-50 p-4 space-y-2">
<div className="text-sm font-medium text-blue-900">RMBG-2.0 说明</div>
<div className="text-xs text-blue-800 space-y-1">
<p>• 需要自行部署RMBG-2.0服务</p>
<p>• 系统会自动调用 /rmbg/base64 接口进行抠图处理</p>
<p>• 支持的图片格式:JPG, PNG, JPEG等常见格式</p>
</div>
</div>
</>
)}
{/* PhotoRoom 配置 */}
{rmbgConfig.provider === "photoroom" && (
<>
<div className="text-sm font-medium text-muted-foreground">PhotoRoom 配置</div>
<div className="space-y-2">
<Label htmlFor="photoroomApiKey">API密钥 *</Label>
<Input
id="photoroomApiKey"
type="password"
value={rmbgConfig.photoroomApiKey}
onChange={(e) => setRmbgConfig({ ...rmbgConfig, photoroomApiKey: e.target.value })}
placeholder="输入PhotoRoom API密钥"
/>
<div className="text-xs text-muted-foreground">
在 PhotoRoom 开发者平台获取API密钥
</div>
</div>
<div className="rounded-lg border border-green-200 bg-green-50 p-4 space-y-2">
<div className="text-sm font-medium text-green-900">PhotoRoom 说明</div>
<div className="text-xs text-green-800 space-y-1">
<p>• 使用 PhotoRoom 云服务进行高清抠图</p>
<p>• 支持 HD 高清背景移除,效果更好</p>
<p>• API文档:https://www.photoroom.com/api</p>
<p>• 按调用次数计费,请注意用量</p>
</div>
</div>
</>
)}
{/* AOS 配置 */}
{rmbgConfig.provider === "aoscdn" && (
<>
<div className="text-sm font-medium text-muted-foreground">AOS Segmentation 配置</div>
<div className="space-y-2">
<Label htmlFor="aosApiKey">API密钥 *</Label>
<Input
id="aosApiKey"
type="password"
value={rmbgConfig.aosApiKey}
onChange={(e) => setRmbgConfig({ ...rmbgConfig, aosApiKey: e.target.value })}
placeholder="输入 AOS X-API-KEY"
/>
</div>
<div className="space-y-2">
<Label htmlFor="aosApiBaseUrl">API地址</Label>
<Input
id="aosApiBaseUrl"
value={rmbgConfig.aosApiBaseUrl}
onChange={(e) => setRmbgConfig({ ...rmbgConfig, aosApiBaseUrl: e.target.value })}
placeholder="https://techsz.aoscdn.com"
/>
<div className="text-xs text-muted-foreground">
默认使用官方地址,可按需替换。
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label>前景类型</Label>
<Select
value={rmbgConfig.aosForegroundType}
onValueChange={(value: "auto" | "person" | "object" | "stamp") =>
setRmbgConfig({ ...rmbgConfig, aosForegroundType: value })
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="auto">自动识别</SelectItem>
<SelectItem value="person">人物</SelectItem>
<SelectItem value="object">物体</SelectItem>
<SelectItem value="stamp">图章</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>输出格式</Label>
<Select
value={rmbgConfig.aosFormat}
onValueChange={(value: "png" | "jpg") =>
setRmbgConfig({ ...rmbgConfig, aosFormat: value })
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="png">PNG(透明背景)</SelectItem>
<SelectItem value="jpg">JPG(白底)</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="flex items-center justify-between rounded-lg border p-3">
<div className="space-y-0.5">
<Label className="text-sm">裁剪到主体边缘</Label>
<div className="text-xs text-muted-foreground">
关闭时保持原图尺寸,开启时按主体区域裁剪。
</div>
</div>
<div className="flex items-center gap-2">
<Switch
checked={rmbgConfig.aosCrop}
onCheckedChange={(checked) => setRmbgConfig({ ...rmbgConfig, aosCrop: checked })}
/>
<span className="text-sm font-medium text-muted-foreground min-w-[3rem]">
{rmbgConfig.aosCrop ? "已开启" : "已关闭"}
</span>
</div>
</div>
<div className="rounded-lg border border-indigo-200 bg-indigo-50 p-4 space-y-2">
<div className="text-sm font-medium text-indigo-900">AOS 说明</div>
<div className="text-xs text-indigo-800 space-y-1">
<p>• 使用异步任务模式(1秒轮询,最多30秒)</p>
<p>• 接口返回链接有效期1小时,系统会自动转存到你的存储桶</p>
<p>• 官方限制:图片最大20MB,分辨率最大4096x4096</p>
</div>
</div>
</>
)}
<Button onClick={handleSaveRmbgConfig}>
<Save className="mr-2 h-4 w-4" /> 保存配置
</Button>
</CardContent>
</Card>
{/* 变清晰API配置 */}
<Card>
<CardHeader>
<CardTitle className="text-xl">变清晰API配置</CardTitle>
<CardDescription>
配置图片变清晰API,用于画图标注功能中的图片增强处理。支持主API和备用API切换。
</CardDescription>
</CardHeader>
<CardContent className="space-y-6 pt-6">
{/* API切换开关 */}
<div className="flex items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<Label className="text-base">使用备用API (Real-ESRGAN)</Label>
<div className="text-sm text-muted-foreground">
启用后将使用Real-ESRGAN本地模型进行图片增强,效果更好
</div>
</div>
<div className="flex items-center gap-2">
<Switch
checked={enhanceConfig.useBackupApi}
onCheckedChange={(checked) => setEnhanceConfig({ ...enhanceConfig, useBackupApi: checked })}
/>
<span className="text-sm font-medium text-muted-foreground min-w-[3rem]">
{enhanceConfig.useBackupApi ? "已开启" : "已关闭"}
</span>
</div>
</div>
{/* 主API配置 */}
{!enhanceConfig.useBackupApi && (
<>
<Separator />
<div className="text-sm font-medium text-muted-foreground">主API配置 (第三方云服务)</div>
<div className="space-y-2">
<Label htmlFor="enhanceApiUrl">API地址 *</Label>
<Input
id="enhanceApiUrl"
value={enhanceConfig.apiUrl}
onChange={(e) => setEnhanceConfig({ ...enhanceConfig, apiUrl: e.target.value })}
placeholder="https://techsz.aoscdn.com"
/>
<div className="text-xs text-muted-foreground">
请输入完整的API地址,例如: https://techsz.aoscdn.com
</div>
</div>
<div className="space-y-2">
<Label htmlFor="enhanceApiKey">API密钥 *</Label>
<Input
id="enhanceApiKey"
type="password"
value={enhanceConfig.apiKey}
onChange={(e) => setEnhanceConfig({ ...enhanceConfig, apiKey: e.target.value })}
placeholder="请输入API密钥"
/>
<div className="text-xs text-muted-foreground">
API密钥是必需的,请从服务提供商获取
</div>
</div>
<div className="space-y-2">
<Label htmlFor="enhanceType">修复类型</Label>
<Select
value={enhanceConfig.type}
onValueChange={(value) => setEnhanceConfig({ ...enhanceConfig, type: value })}
>
<SelectTrigger id="enhanceType">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="clean">通用放大清晰化</SelectItem>
<SelectItem value="face">人像放大清晰化</SelectItem>
</SelectContent>
</Select>
<div className="text-xs text-muted-foreground">
选择图片类型:通用或人像(人像模式最多同时处理10个人)
</div>
</div>
<div className="space-y-2">
<Label htmlFor="enhanceScaleFactor">放大倍数(可选)</Label>
<Select
value={enhanceConfig.scaleFactor}
onValueChange={(value) => setEnhanceConfig({ ...enhanceConfig, scaleFactor: value })}
>
<SelectTrigger id="enhanceScaleFactor">
<SelectValue placeholder="自动缩放" />
</SelectTrigger>
<SelectContent>
<SelectItem value="auto">自动缩放</SelectItem>
<SelectItem value="1">不放大</SelectItem>
<SelectItem value="2">2倍放大</SelectItem>
<SelectItem value="4">4倍放大</SelectItem>
</SelectContent>
</Select>
<div className="text-xs text-muted-foreground">
选择放大倍数:自动(根据图片尺寸智能选择)、不放大、2倍或4倍
</div>
</div>
<div className="rounded-lg border border-blue-200 bg-blue-50 p-4 space-y-2">
<div className="text-sm font-medium text-blue-900">主API文档说明</div>
<div className="text-xs text-blue-800 space-y-1">
<p>• API地址应指向服务根地址,例如: https://techsz.aoscdn.com</p>
<p>• 系统会自动调用 /api/tasks/visual/scale 接口进行变清晰处理</p>
<p>• 支持的图片格式:JPG, JPEG, PNG, BMP, WEBP, TIFF等</p>
<p>• 图片大小:不超过50MB,不限制分辨率(建议按业务需求控制尺寸)</p>
<p>• 返回的链接有效期为1小时,系统会自动下载并存储到S3</p>
</div>
</div>
</>
)}
{/* 备用API配置 (Real-ESRGAN) */}
{enhanceConfig.useBackupApi && (
<>
<Separator />
<div className="text-sm font-medium text-muted-foreground">备用API配置 (Real-ESRGAN 本地模型)</div>
<div className="space-y-2">
<Label htmlFor="backupApiUrl">API地址 *</Label>
<Input
id="backupApiUrl"
value={enhanceConfig.backupApiUrl}
onChange={(e) => setEnhanceConfig({ ...enhanceConfig, backupApiUrl: e.target.value })}
placeholder="http://156.225.27.52:5233"
/>
<div className="text-xs text-muted-foreground">
Real-ESRGAN API服务地址,默认: http://156.225.27.52:5233
</div>
</div>
<div className="space-y-2">
<Label htmlFor="backupApiKey">API密钥 *</Label>
<Input
id="backupApiKey"
type="password"
value={enhanceConfig.backupApiKey}
onChange={(e) => setEnhanceConfig({ ...enhanceConfig, backupApiKey: e.target.value })}
placeholder="请输入Real-ESRGAN API密钥"
/>
<div className="text-xs text-muted-foreground">
API密钥用于认证,请从管理员处获取
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="backupScale">放大倍数</Label>
<Select
value={enhanceConfig.backupScale}
onValueChange={(value) => setEnhanceConfig({ ...enhanceConfig, backupScale: value })}
>
<SelectTrigger id="backupScale">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="2">2倍放大</SelectItem>
<SelectItem value="4">4倍放大(推荐)</SelectItem>
<SelectItem value="8">8倍放大</SelectItem>
</SelectContent>
</Select>
<div className="text-xs text-muted-foreground">
推荐使用4倍放大,效果最佳
</div>
</div>
<div className="space-y-2">
<Label htmlFor="backupModel">模型选择</Label>
<Select
value={enhanceConfig.backupModel}
onValueChange={(value) => setEnhanceConfig({ ...enhanceConfig, backupModel: value })}
>
<SelectTrigger id="backupModel">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="RealESRGAN_x2plus">RealESRGAN x2plus (2倍)</SelectItem>
<SelectItem value="RealESRGAN_x4plus">RealESRGAN x4plus (推荐)</SelectItem>
<SelectItem value="RealESRGAN_x8plus">RealESRGAN x8plus (8倍)</SelectItem>
<SelectItem value="RealESRGAN_animevideov3">动漫视频模型 (4倍)</SelectItem>
</SelectContent>
</Select>
<div className="text-xs text-muted-foreground">
不同模型适用于不同场景
</div>
</div>
</div>
<div className="rounded-lg border border-green-200 bg-green-50 p-4 space-y-2">
<div className="text-sm font-medium text-green-900">Real-ESRGAN API说明</div>
<div className="text-xs text-green-800 space-y-1">
<p>• 基于Real-ESRGAN深度学习模型,图像超分辨率效果更好</p>
<p>• 使用URL方式上传图片,处理后自动保存到S3存储</p>
<p>• 支持2x、4x、8x放大倍数</p>
<p>• 处理时间:通常1-10秒,取决于图片大小</p>
<p>• 支持格式:JPG, PNG, JPEG等常见图片格式</p>
<p>• 文件限制:最大50MB</p>
</div>
</div>
</>
)}
<Button onClick={handleSaveEnhanceConfig}>
<Save className="mr-2 h-4 w-4" /> 保存配置
</Button>
</CardContent>
</Card>
{/* 图像拓展API配置 */}
<Card>
<CardHeader>
<CardTitle className="text-xl">图像拓展API配置</CardTitle>
<CardDescription>
配置阿里云Dashscope图像拓展API,用于画图标注功能中的图像智能扩展处理
</CardDescription>
</CardHeader>
<CardContent className="space-y-6 pt-6">
<div className="space-y-2">
<Label htmlFor="extendApiKey">API密钥 *</Label>
<Input
id="extendApiKey"
type="password"
value={extendConfig.apiKey}
onChange={(e) => setExtendConfig({ ...extendConfig, apiKey: e.target.value })}
placeholder="sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
/>
<div className="text-xs text-muted-foreground">
请输入阿里云Dashscope API密钥,例如: sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxx
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="extendXScale">X轴放大倍数 *</Label>
<Input
id="extendXScale"
type="number"
step="0.1"
min="1.0"
max="2.0"
value={extendConfig.xScale}
onChange={(e) => setExtendConfig({ ...extendConfig, xScale: e.target.value })}
placeholder="1.5"
/>
<div className="text-xs text-muted-foreground">
X轴放大倍数(1.0-2.0),推荐1.5
</div>
</div>
<div className="space-y-2">
<Label htmlFor="extendYScale">Y轴放大倍数 *</Label>
<Input
id="extendYScale"
type="number"
step="0.1"
min="1.0"
max="2.0"
value={extendConfig.yScale}
onChange={(e) => setExtendConfig({ ...extendConfig, yScale: e.target.value })}
placeholder="1.5"
/>
<div className="text-xs text-muted-foreground">
Y轴放大倍数(1.0-2.0),推荐1.5
</div>
</div>
</div>
<div className="rounded-lg border border-blue-200 bg-blue-50 p-4 space-y-2">
<div className="text-sm font-medium text-blue-900">API文档说明</div>
<div className="text-xs text-blue-800 space-y-1">
<p>• 使用阿里云Dashscope图像拓展API(image-out-painting模型)</p>
<p>• API端点: https://dashscope.aliyuncs.com/api/v1/services/aigc/image2image/out-painting</p>
<p>• 支持的图片格式:JPG, PNG, WebP等常见格式</p>
<p>• 最大图片大小:4MB</p>
<p>• 处理时间:约5-15秒/张</p>
<p>• 放大倍数范围:1.0-2.0(推荐1.5倍等比例扩展)</p>
</div>
</div>
<Button onClick={handleSaveExtendConfig}>
<Save className="mr-2 h-4 w-4" /> 保存配置
</Button>
</CardContent>
</Card>
{/* 去水印API配置 */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-xl">
<svg className="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 2.69l5.66 5.66a8 8 0 1 1-11.31 0z" />
<line x1="4" y1="4" x2="20" y2="20" />
</svg>
去水印API配置
</CardTitle>
<CardDescription>
配置PicWish去水印API,用于自动去除图片中的水印
</CardDescription>
</CardHeader>
<CardContent className="space-y-6 pt-6">
<div className="space-y-2">
<Label htmlFor="watermarkRemoveApiKey">API密钥 *</Label>
<Input
id="watermarkRemoveApiKey"
type="password"
value={watermarkRemoveConfig.apiKey}
onChange={(e) => setWatermarkRemoveConfig({ ...watermarkRemoveConfig, apiKey: e.target.value })}
placeholder="输入PicWish API密钥"
/>
<div className="text-xs text-muted-foreground">
在 PicWish 开发者平台获取API密钥
</div>
</div>
<div className="rounded-lg border border-purple-200 bg-purple-50 p-4 space-y-2">
<div className="text-sm font-medium text-purple-900">PicWish API 说明</div>
<div className="text-xs text-purple-800 space-y-1">
<p>• 使用 PicWish 云服务进行智能去水印</p>
<p>• 支持各种类型的水印去除(文字、图片水印)</p>
<p>• API文档:https://picwish.com/developer</p>
<p>• 处理时间:约10-30秒/张</p>
</div>
</div>
<Button onClick={handleSaveWatermarkRemoveConfig}>
<Save className="mr-2 h-4 w-4" /> 保存配置
</Button>
</CardContent>
</Card>
{/* 角度编辑API配置 */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-xl">
<Move3D className="h-5 w-5" />
角度编辑API配置
</CardTitle>
<CardDescription>
配置 fal.ai 多角度图像编辑 API,用于对同一图像生成不同视角的变体
</CardDescription>
</CardHeader>
<CardContent className="space-y-6 pt-6">
{/* 启用开关 */}
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label>启用角度编辑功能</Label>
<div className="text-xs text-muted-foreground">
开启后用户可在图片编辑器中使用角度编辑功能
</div>
</div>
<div className="flex items-center gap-2">
<Switch
checked={angleEditConfig.enabled}
onCheckedChange={(checked) => setAngleEditConfig({ ...angleEditConfig, enabled: checked })}
/>
<span className="text-sm font-medium text-muted-foreground min-w-[3rem]">
{angleEditConfig.enabled ? "已开启" : "已关闭"}
</span>
</div>
</div>
<Separator />
{/* API Key */}
<div className="space-y-2">
<Label htmlFor="falApiKey">
fal.ai API Key <span className="text-destructive">*</span>
</Label>
<Input
id="falApiKey"
type="password"
value={angleEditConfig.falApiKey}
onChange={(e) => setAngleEditConfig({ ...angleEditConfig, falApiKey: e.target.value })}
placeholder="fal_xxxxxxxxxxxxx"
disabled={!angleEditConfig.enabled}
/>
<div className="text-xs text-muted-foreground">
从{" "}
<a
href="https://fal.ai/dashboard/keys"
target="_blank"
rel="noopener noreferrer"
className="underline hover:text-primary"
>
fal.ai Dashboard
</a>
{" "}获取 API Key
</div>
</div>
<Separator />
{/* LoRA 强度 */}
<div className="space-y-3">
<div className="flex justify-between">
<Label>默认变换强度</Label>
<span className="text-sm text-muted-foreground">
{(angleEditConfig.loraScale * 100).toFixed(0)}%
</span>
</div>
<Input
type="range"
value={angleEditConfig.loraScale}
onChange={(e) => setAngleEditConfig({ ...angleEditConfig, loraScale: Number.parseFloat(e.target.value) })}
min={0.5}
max={1.0}
step={0.05}
disabled={!angleEditConfig.enabled}
className="w-full"
/>
<div className="text-xs text-muted-foreground">
值越高角度变化越明显,值越低保留原图特征越多(推荐 85%-95%)
</div>
</div>
<Separator />
{/* 高级设置 */}
<div className="space-y-4">
<Label className="text-base font-medium">高级设置</Label>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="inferenceSteps">推理步数</Label>
<Input
id="inferenceSteps"
type="number"
value={angleEditConfig.inferenceSteps}
onChange={(e) => setAngleEditConfig({ ...angleEditConfig, inferenceSteps: Number.parseInt(e.target.value) || 28 })}
min={10}
max={50}
disabled={!angleEditConfig.enabled}
/>
</div>
<div className="space-y-2">
<Label htmlFor="guidanceScale">引导系数</Label>
<Input
id="guidanceScale"
type="number"
value={angleEditConfig.guidanceScale}
onChange={(e) => setAngleEditConfig({ ...angleEditConfig, guidanceScale: Number.parseFloat(e.target.value) || 4.5 })}
min={1}
max={10}
step={0.5}
disabled={!angleEditConfig.enabled}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="maxImageSize">最大图片(MB)</Label>
<Input
id="maxImageSize"
type="number"
value={angleEditConfig.maxImageSize}
onChange={(e) => setAngleEditConfig({ ...angleEditConfig, maxImageSize: Number.parseInt(e.target.value) || 10 })}
min={1}
max={50}
disabled={!angleEditConfig.enabled}
/>
</div>
<div className="space-y-2">
<Label htmlFor="timeout">超时时间(秒)</Label>
<Input
id="timeout"
type="number"
value={angleEditConfig.timeout}
onChange={(e) => setAngleEditConfig({ ...angleEditConfig, timeout: Number.parseInt(e.target.value) || 120 })}
min={30}
max={300}
disabled={!angleEditConfig.enabled}
/>
</div>
</div>
</div>
<div className="rounded-lg border border-blue-200 bg-blue-50 p-4 space-y-2">
<div className="text-sm font-medium text-blue-900">功能说明</div>
<div className="text-xs text-blue-800 space-y-1">
<p>• 使用 Qwen-Image-Edit-2511-Multiple-Angles-LoRA 模型</p>
<p>• 支持 96 种相机姿态(4 仰角 × 8 方位角 × 3 距离)</p>
<p>• 可生成正面、侧面、俯视、仰视等多角度图像</p>
<p>• 处理时间:约 10-30 秒/张</p>
<p>• 费用:约 $0.02-0.05/张(fal.ai 按量计费)</p>
</div>
</div>
<Button onClick={handleSaveAngleEditConfig}>
<Save className="mr-2 h-4 w-4" /> 保存配置
</Button>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-xl">
<Layers className="h-5 w-5" />
图层拆分API配置
</CardTitle>
<CardDescription>
配置 Replicate 上的 Qwen-Image-Layered 模型,用于将单张图片拆分为多个可编辑图层并回写到无限画布。
</CardDescription>
</CardHeader>
<CardContent className="space-y-6 pt-6">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label>启用图层拆分功能</Label>
<div className="text-xs text-muted-foreground">
开启后,用户在无限画布中选中图片可使用“图层拆分”工具。
</div>
</div>
<div className="flex items-center gap-2">
<Switch
checked={layerSplitConfig.enabled}
onCheckedChange={(checked) =>
setLayerSplitConfig({ ...layerSplitConfig, enabled: checked })
}
/>
<span className="text-sm font-medium text-muted-foreground min-w-[3rem]">
{layerSplitConfig.enabled ? "已开启" : "已关闭"}
</span>
</div>
</div>
<Separator />
<div className="space-y-2">
<Label htmlFor="layerSplitReplicateApiToken">
Replicate API Token <span className="text-destructive">*</span>
</Label>
<Input
id="layerSplitReplicateApiToken"
type="password"
value={layerSplitConfig.replicateApiToken}
onChange={(e) =>
setLayerSplitConfig({ ...layerSplitConfig, replicateApiToken: e.target.value })
}
placeholder="r8_xxxxxxxxxxxxx"
disabled={!layerSplitConfig.enabled}
/>
</div>
<div className="space-y-2">
<Label htmlFor="layerSplitReplicateModel">Replicate 模型</Label>
<Input
id="layerSplitReplicateModel"
value={layerSplitConfig.replicateModel}
onChange={(e) =>
setLayerSplitConfig({ ...layerSplitConfig, replicateModel: e.target.value })
}
placeholder="qwen/qwen-image-layered"
disabled={!layerSplitConfig.enabled}
/>
</div>
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
<div className="space-y-2">
<Label htmlFor="layerSplitLayers">默认层数</Label>
<Input
id="layerSplitLayers"
type="number"
min={2}
max={12}
value={layerSplitConfig.layers}
onChange={(e) =>
setLayerSplitConfig({
...layerSplitConfig,
layers: Math.max(2, Math.min(12, Number(e.target.value) || 4)),
})
}
disabled={!layerSplitConfig.enabled}
/>
</div>
<div className="space-y-2">
<Label htmlFor="layerSplitResolution">分辨率</Label>
<Input
id="layerSplitResolution"
type="number"
min={256}
max={1536}
step={64}
value={layerSplitConfig.resolution}
onChange={(e) =>
setLayerSplitConfig({
...layerSplitConfig,
resolution: Math.max(256, Math.min(1536, Number(e.target.value) || 640)),
})
}
disabled={!layerSplitConfig.enabled}
/>
</div>
<div className="space-y-2">
<Label htmlFor="layerSplitCfg">true_cfg_scale</Label>
<Input
id="layerSplitCfg"
type="number"
min={1}
max={12}
step={0.5}
value={layerSplitConfig.trueCfgScale}
onChange={(e) =>
setLayerSplitConfig({
...layerSplitConfig,
trueCfgScale: Math.max(1, Math.min(12, Number(e.target.value) || 4)),
})
}
disabled={!layerSplitConfig.enabled}
/>
</div>
<div className="space-y-2">
<Label htmlFor="layerSplitSteps">推理步数</Label>
<Input
id="layerSplitSteps"
type="number"
min={1}
max={80}
value={layerSplitConfig.numInferenceSteps}
onChange={(e) =>
setLayerSplitConfig({
...layerSplitConfig,
numInferenceSteps: Math.max(1, Math.min(80, Number(e.target.value) || 30)),
})
}
disabled={!layerSplitConfig.enabled}
/>
</div>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
<div className="flex items-center justify-between rounded-lg border p-3">
<div className="space-y-0.5">
<Label className="text-sm">cfg_normalize</Label>
<div className="text-xs text-muted-foreground">保持前景/背景透明边缘更稳定</div>
</div>
<Switch
checked={layerSplitConfig.cfgNormalize}
onCheckedChange={(checked) =>
setLayerSplitConfig({ ...layerSplitConfig, cfgNormalize: checked })
}
disabled={!layerSplitConfig.enabled}
/>
</div>
<div className="flex items-center justify-between rounded-lg border p-3">
<div className="space-y-0.5">
<Label className="text-sm">use_en_prompt</Label>
<div className="text-xs text-muted-foreground">使用英文提示增强兼容性</div>
</div>
<Switch
checked={layerSplitConfig.useEnPrompt}
onCheckedChange={(checked) =>
setLayerSplitConfig({ ...layerSplitConfig, useEnPrompt: checked })
}
disabled={!layerSplitConfig.enabled}
/>
</div>
<div className="space-y-2">
<Label htmlFor="layerSplitTimeout">超时时间(毫秒)</Label>
<Input
id="layerSplitTimeout"
type="number"
min={30000}
max={900000}
step={1000}
value={layerSplitConfig.timeoutMs}
onChange={(e) =>
setLayerSplitConfig({
...layerSplitConfig,
timeoutMs: Math.max(30000, Math.min(900000, Number(e.target.value) || 300000)),
})
}
disabled={!layerSplitConfig.enabled}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="layerSplitNegativePrompt">负向提示词(可选)</Label>
<Textarea
id="layerSplitNegativePrompt"
rows={3}
value={layerSplitConfig.negativePrompt}
onChange={(e) =>
setLayerSplitConfig({ ...layerSplitConfig, negativePrompt: e.target.value })
}
placeholder="例如:text, watermark, logo, artifacts"
disabled={!layerSplitConfig.enabled}
/>
</div>
<div className="rounded-lg border border-blue-200 bg-blue-50 p-4 space-y-2">
<div className="text-sm font-medium text-blue-900">Qwen-Image-Layered 说明</div>
<div className="text-xs text-blue-800 space-y-1">
<p>• 用途:将单张图片拆成多个独立图层(RGBA layer)</p>
<p>• 平台:Replicate 托管推理,无需自建 GPU 服务器</p>
<p>• 默认模型:qwen/qwen-image-layered</p>
<p>• 使用后会将每个图层转存到你的存储桶,并在无限画布中新增图片节点</p>
</div>
</div>
<Button onClick={handleSaveLayerSplitConfig}>
<Save className="mr-2 h-4 w-4" /> 保存图层拆分配置
</Button>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-xl">
<Database className="h-5 w-5" />
第三方 SQL Server 数据库连接
</CardTitle>
<CardDescription>
配置后可供后续业务功能直接访问外部 SQL Server 数据库。当前仅提供配置保存与连通性测试。
</CardDescription>
</CardHeader>
<CardContent className="space-y-6 pt-6">
<div className="flex items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<Label className="text-base">启用第三方数据库连接</Label>
<div className="text-sm text-muted-foreground">
关闭后保留配置但不作为后续功能默认外部数据源。
</div>
</div>
<div className="flex items-center gap-2">
<Switch
checked={thirdPartySqlServerConfig.enabled}
onCheckedChange={(checked) =>
setThirdPartySqlServerConfig((prev) => ({ ...prev, enabled: checked }))
}
/>
<span className="text-sm font-medium text-muted-foreground min-w-[3rem]">
{thirdPartySqlServerConfig.enabled ? "已开启" : "已关闭"}
</span>
</div>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="sqlserverHost">主机地址</Label>
<Input
id="sqlserverHost"
value={thirdPartySqlServerConfig.host}
onChange={(e) =>
setThirdPartySqlServerConfig((prev) => ({ ...prev, host: e.target.value }))
}
placeholder="218.93.191.16"
/>
</div>
<div className="space-y-2">
<Label htmlFor="sqlserverPort">端口</Label>
<Input
id="sqlserverPort"
type="number"
min={1}
max={65535}
value={thirdPartySqlServerConfig.port}
onChange={(e) =>
setThirdPartySqlServerConfig((prev) => ({
...prev,
port: Math.max(1, Math.min(65535, Number(e.target.value) || 1433)),
}))
}
placeholder="1433"
/>
</div>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="sqlserverDatabase">数据库名称</Label>
<Input
id="sqlserverDatabase"
value={thirdPartySqlServerConfig.database}
onChange={(e) =>
setThirdPartySqlServerConfig((prev) => ({ ...prev, database: e.target.value }))
}
placeholder="JSDataMiddlePlatform"
/>
</div>
<div className="space-y-2">
<Label htmlFor="sqlserverUsername">账号</Label>
<Input
id="sqlserverUsername"
value={thirdPartySqlServerConfig.username}
onChange={(e) =>
setThirdPartySqlServerConfig((prev) => ({ ...prev, username: e.target.value }))
}
placeholder="nanting"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="sqlserverPassword">密码</Label>
<Input
id="sqlserverPassword"
type="password"
value={thirdPartySqlServerConfig.password}
onChange={(e) =>
setThirdPartySqlServerConfig((prev) => ({ ...prev, password: e.target.value }))
}
placeholder="请输入 SQL Server 密码"
/>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="sqlserverConnectionTimeout">连接超时(毫秒)</Label>
<Input
id="sqlserverConnectionTimeout"
type="number"
min={1000}
max={120000}
step={1000}
value={thirdPartySqlServerConfig.connectionTimeoutMs}
onChange={(e) =>
setThirdPartySqlServerConfig((prev) => ({
...prev,
connectionTimeoutMs: Math.max(
1000,
Math.min(120000, Number(e.target.value) || 15000)
),
}))
}
/>
</div>
<div className="space-y-2">
<Label htmlFor="sqlserverRequestTimeout">请求超时(毫秒)</Label>
<Input
id="sqlserverRequestTimeout"
type="number"
min={1000}
max={120000}
step={1000}
value={thirdPartySqlServerConfig.requestTimeoutMs}
onChange={(e) =>
setThirdPartySqlServerConfig((prev) => ({
...prev,
requestTimeoutMs: Math.max(
1000,
Math.min(120000, Number(e.target.value) || 30000)
),
}))
}
/>
</div>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="flex items-center justify-between rounded-lg border p-3">
<div className="space-y-0.5">
<Label className="text-sm">启用加密</Label>
<div className="text-xs text-muted-foreground">
现代云数据库通常需要开启;传统内网 SQL Server 可关闭。
</div>
</div>
<Switch
checked={thirdPartySqlServerConfig.encrypt}
onCheckedChange={(checked) =>
setThirdPartySqlServerConfig((prev) => ({ ...prev, encrypt: checked }))
}
/>
</div>
<div className="flex items-center justify-between rounded-lg border p-3">
<div className="space-y-0.5">
<Label className="text-sm">信任服务器证书</Label>
<div className="text-xs text-muted-foreground">
对自签名证书或传统 SQL Server 更友好。
</div>
</div>
<Switch
checked={thirdPartySqlServerConfig.trustServerCertificate}
onCheckedChange={(checked) =>
setThirdPartySqlServerConfig((prev) => ({
...prev,
trustServerCertificate: checked,
}))
}
/>
</div>
</div>
<div className="rounded-lg border border-blue-200 bg-blue-50 p-4 space-y-1 text-xs text-blue-800">
<p>• 当前支持 SQL Server 标准 TCP 连接。</p>
<p>• 配置会保存到系统设置,供后续外部数据查询/同步功能复用。</p>
<p>• 测试连接会尝试建立连接并执行基础查询,不会修改外部数据库数据。</p>
</div>
<div className="flex flex-wrap justify-end gap-3">
<Button variant="outline" onClick={handleTestThirdPartySqlServerConfig} disabled={isTestingThirdPartySqlServer}>
{isTestingThirdPartySqlServer ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Database className="mr-2 h-4 w-4" />
)}
测试连接
</Button>
<Button onClick={handleSaveThirdPartySqlServerConfig}>
<Save className="mr-2 h-4 w-4" /> 保存 SQL Server 配置
</Button>
</div>
</CardContent>
</Card>
{/* 实时画布API配置 */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-xl">
<Paintbrush className="h-5 w-5" />
实时画布配置
</CardTitle>
<CardDescription>
配置实时画布功能的 BFL(FLUX.2)参数、模型与 API Key。保存时会自动探针校验模型可用性。
</CardDescription>
</CardHeader>
<CardContent className="space-y-6 pt-6">
<div className="flex items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<Label className="text-base">启用实时画布服务</Label>
<div className="text-sm text-muted-foreground">
关闭后前端“实时画布”入口仍可显示,但会提示服务未启用。
</div>
</div>
<div className="flex items-center gap-2">
<Switch
checked={realtimeCanvasConfig.enabled}
onCheckedChange={(checked) =>
setRealtimeCanvasConfig({ ...realtimeCanvasConfig, enabled: checked })
}
/>
<span className="text-sm font-medium text-muted-foreground min-w-[3rem]">
{realtimeCanvasConfig.enabled ? "已开启" : "已关闭"}
</span>
</div>
</div>
<div className="space-y-2">
<Label>主服务供应商</Label>
<Select
value={realtimeCanvasConfig.providerType}
onValueChange={(value: "bfl" | "replicate") =>
setRealtimeCanvasConfig({ ...realtimeCanvasConfig, providerType: value })
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="bfl">BFL / 兼容接口</SelectItem>
<SelectItem value="replicate">Replicate</SelectItem>
</SelectContent>
</Select>
</div>
{realtimeCanvasConfig.providerType === "replicate" ? (
<>
<div className="space-y-2">
<Label htmlFor="realtimeReplicateApiToken">
Replicate API Token <span className="text-destructive">*</span>
</Label>
<Input
id="realtimeReplicateApiToken"
type="password"
value={realtimeCanvasConfig.replicateApiToken}
onChange={(e) =>
setRealtimeCanvasConfig({
...realtimeCanvasConfig,
replicateApiToken: e.target.value,
})
}
placeholder="r8_xxxxxxxxxxxxx"
disabled={!realtimeCanvasConfig.enabled}
/>
</div>
<div className="space-y-2">
<Label htmlFor="realtimeReplicateModel">
Replicate 模型 <span className="text-destructive">*</span>
</Label>
<Input
id="realtimeReplicateModel"
value={realtimeCanvasConfig.replicateModel}
onChange={(e) =>
setRealtimeCanvasConfig({
...realtimeCanvasConfig,
replicateModel: e.target.value,
})
}
placeholder="black-forest-labs/flux-2-klein-4b"
disabled={!realtimeCanvasConfig.enabled}
/>
</div>
</>
) : (
<>
<div className="space-y-2">
<Label htmlFor="realtimeBflApiKey">
BFL API Key <span className="text-destructive">*</span>
</Label>
<Input
id="realtimeBflApiKey"
type="password"
value={realtimeCanvasConfig.bflApiKey}
onChange={(e) =>
setRealtimeCanvasConfig({ ...realtimeCanvasConfig, bflApiKey: e.target.value })
}
placeholder="bfl_xxxxxxxxxxxxx"
disabled={!realtimeCanvasConfig.enabled}
/>
</div>
<div className="space-y-2">
<Label htmlFor="realtimeBflBaseUrl">BFL API Base URL</Label>
<Input
id="realtimeBflBaseUrl"
value={realtimeCanvasConfig.bflBaseUrl}
onChange={(e) =>
setRealtimeCanvasConfig({ ...realtimeCanvasConfig, bflBaseUrl: e.target.value })
}
placeholder={DEFAULT_BFL_BASE_URL}
disabled={!realtimeCanvasConfig.enabled}
/>
</div>
<div className="space-y-2">
<Label htmlFor="realtimeBflModel">
FLUX.2 模型 ID <span className="text-destructive">*</span>
</Label>
<Input
id="realtimeBflModel"
value={realtimeCanvasConfig.bflModel}
onChange={(e) =>
setRealtimeCanvasConfig({ ...realtimeCanvasConfig, bflModel: e.target.value })
}
placeholder={DEFAULT_BFL_REALTIME_MODEL}
disabled={!realtimeCanvasConfig.enabled}
/>
</div>
</>
)}
<div className="rounded-lg border border-dashed p-4 space-y-4">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label className="text-base">备用实时服务</Label>
<div className="text-sm text-muted-foreground">
主实时服务失败时自动切换到备用 BFL 服务。
</div>
</div>
<Switch
checked={realtimeCanvasConfig.fallbackEnabled}
onCheckedChange={(checked) =>
setRealtimeCanvasConfig({ ...realtimeCanvasConfig, fallbackEnabled: checked })
}
disabled={!realtimeCanvasConfig.enabled}
/>
</div>
<div className="space-y-2">
<Label>备用供应商</Label>
<Select
value={realtimeCanvasConfig.fallbackProviderType}
onValueChange={(value: "bfl" | "replicate") =>
setRealtimeCanvasConfig({ ...realtimeCanvasConfig, fallbackProviderType: value })
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="bfl">BFL / 兼容接口</SelectItem>
<SelectItem value="replicate">Replicate</SelectItem>
</SelectContent>
</Select>
</div>
{realtimeCanvasConfig.fallbackProviderType === "replicate" ? (
<>
<div className="space-y-2">
<Label htmlFor="realtimeFallbackReplicateApiToken">备用 Replicate API Token</Label>
<Input
id="realtimeFallbackReplicateApiToken"
type="password"
value={realtimeCanvasConfig.fallbackReplicateApiToken}
onChange={(e) =>
setRealtimeCanvasConfig({
...realtimeCanvasConfig,
fallbackReplicateApiToken: e.target.value,
})
}
placeholder="r8_xxxxxxxxxxxxx"
disabled={!realtimeCanvasConfig.enabled || !realtimeCanvasConfig.fallbackEnabled}
/>
</div>
<div className="space-y-2">
<Label htmlFor="realtimeFallbackReplicateModel">备用 Replicate 模型</Label>
<Input
id="realtimeFallbackReplicateModel"
value={realtimeCanvasConfig.fallbackReplicateModel}
onChange={(e) =>
setRealtimeCanvasConfig({
...realtimeCanvasConfig,
fallbackReplicateModel: e.target.value,
})
}
placeholder="black-forest-labs/flux-2-klein-4b"
disabled={!realtimeCanvasConfig.enabled || !realtimeCanvasConfig.fallbackEnabled}
/>
</div>
</>
) : (
<>
<div className="space-y-2">
<Label htmlFor="realtimeFallbackBflApiKey">备用 BFL API Key</Label>
<Input
id="realtimeFallbackBflApiKey"
type="password"
value={realtimeCanvasConfig.fallbackBflApiKey}
onChange={(e) =>
setRealtimeCanvasConfig({
...realtimeCanvasConfig,
fallbackBflApiKey: e.target.value,
})
}
placeholder="bfl_xxxxxxxxxxxxx"
disabled={!realtimeCanvasConfig.enabled || !realtimeCanvasConfig.fallbackEnabled}
/>
</div>
<div className="space-y-2">
<Label htmlFor="realtimeFallbackBflBaseUrl">备用 BFL API Base URL</Label>
<Input
id="realtimeFallbackBflBaseUrl"
value={realtimeCanvasConfig.fallbackBflBaseUrl}
onChange={(e) =>
setRealtimeCanvasConfig({
...realtimeCanvasConfig,
fallbackBflBaseUrl: e.target.value,
})
}
placeholder={DEFAULT_BFL_BASE_URL}
disabled={!realtimeCanvasConfig.enabled || !realtimeCanvasConfig.fallbackEnabled}
/>
</div>
<div className="space-y-2">
<Label htmlFor="realtimeFallbackBflModel">备用 FLUX.2 模型 ID</Label>
<Input
id="realtimeFallbackBflModel"
value={realtimeCanvasConfig.fallbackBflModel}
onChange={(e) =>
setRealtimeCanvasConfig({
...realtimeCanvasConfig,
fallbackBflModel: e.target.value,
})
}
placeholder={DEFAULT_BFL_REALTIME_MODEL}
disabled={!realtimeCanvasConfig.enabled || !realtimeCanvasConfig.fallbackEnabled}
/>
</div>
</>
)}
</div>
<div className="grid grid-cols-1 gap-4">
<div className="space-y-2">
<Label>实时渲染模式</Label>
<Select
value={realtimeCanvasConfig.renderMode}
onValueChange={(
value: "strict-structure" | "balanced" | "stylize"
) =>
setRealtimeCanvasConfig({ ...realtimeCanvasConfig, renderMode: value })
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="strict-structure">结构优先</SelectItem>
<SelectItem value="balanced">平衡模式</SelectItem>
<SelectItem value="stylize">风格优先</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<p className="text-xs text-muted-foreground">
推荐:实时草图模型使用 `flux-2-klein-4b`,高质量可切换 `flux-2-pro-preview`。
</p>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>发送节流(ms)</Label>
<Input
type="number"
min={60}
max={260}
value={realtimeCanvasConfig.throttleInterval}
onChange={(e) =>
setRealtimeCanvasConfig({
...realtimeCanvasConfig,
throttleInterval: Number.parseInt(e.target.value, 10) || 100,
})
}
/>
</div>
<div className="space-y-2">
<Label>最大缓冲帧</Label>
<Input
type="number"
min={1}
max={6}
value={realtimeCanvasConfig.maxBuffering}
onChange={(e) =>
setRealtimeCanvasConfig({
...realtimeCanvasConfig,
maxBuffering: Number.parseInt(e.target.value, 10) || 2,
})
}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>输入分辨率上限(px)</Label>
<Input
type="number"
min={512}
max={1536}
value={realtimeCanvasConfig.maxInputResolution}
onChange={(e) =>
setRealtimeCanvasConfig({
...realtimeCanvasConfig,
maxInputResolution: Number.parseInt(e.target.value, 10) || 1024,
})
}
/>
</div>
<div className="space-y-2">
<Label>JPEG质量</Label>
<Input
type="number"
min={0.35}
max={0.82}
step={0.01}
value={realtimeCanvasConfig.jpegQuality}
onChange={(e) =>
setRealtimeCanvasConfig({
...realtimeCanvasConfig,
jpegQuality: Number.parseFloat(e.target.value) || 0.55,
})
}
/>
</div>
</div>
<div className="grid grid-cols-3 gap-4">
<div className="space-y-2">
<Label>预览分支(MP)</Label>
<Input
type="number"
min={0.2}
max={0.8}
step={0.01}
value={realtimeCanvasConfig.previewMegapixels}
onChange={(e) =>
setRealtimeCanvasConfig({
...realtimeCanvasConfig,
previewMegapixels: Number.parseFloat(e.target.value) || 0.4,
})
}
/>
</div>
<div className="space-y-2">
<Label>细节分支(MP)</Label>
<Input
type="number"
min={0.6}
max={1.2}
step={0.01}
value={realtimeCanvasConfig.detailMegapixels}
onChange={(e) =>
setRealtimeCanvasConfig({
...realtimeCanvasConfig,
detailMegapixels: Number.parseFloat(e.target.value) || 1,
})
}
/>
</div>
<div className="space-y-2">
<Label>返回JPEG质量</Label>
<Input
type="number"
min={35}
max={90}
step={1}
value={realtimeCanvasConfig.outputJpegQuality}
onChange={(e) =>
setRealtimeCanvasConfig({
...realtimeCanvasConfig,
outputJpegQuality: Number.parseInt(e.target.value, 10) || 62,
})
}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>推理步数</Label>
<Input
type="number"
min={1}
max={6}
value={realtimeCanvasConfig.steps}
onChange={(e) =>
setRealtimeCanvasConfig({
...realtimeCanvasConfig,
steps: Number.parseInt(e.target.value, 10) || 3,
})
}
/>
</div>
<div className="space-y-2">
<Label>反馈强度</Label>
<Input
type="number"
min={0.7}
max={1}
step={0.01}
value={realtimeCanvasConfig.feedback}
onChange={(e) =>
setRealtimeCanvasConfig({
...realtimeCanvasConfig,
feedback: Number.parseFloat(e.target.value) || 0.95,
})
}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>schedule_mu</Label>
<Input
type="number"
min={1.5}
max={3.5}
step={0.1}
value={realtimeCanvasConfig.scheduleMu}
onChange={(e) =>
setRealtimeCanvasConfig({
...realtimeCanvasConfig,
scheduleMu: Number.parseFloat(e.target.value) || 2.3,
})
}
/>
</div>
<div className="space-y-2">
<Label>输出尺寸</Label>
<Select
value={realtimeCanvasConfig.imageSize}
onValueChange={(value: "square" | "square_hd") =>
setRealtimeCanvasConfig({ ...realtimeCanvasConfig, imageSize: value })
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="square">square</SelectItem>
<SelectItem value="square_hd">square_hd</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<Button onClick={handleSaveRealtimeCanvasConfig}>
<Save className="mr-2 h-4 w-4" /> 保存配置
</Button>
</CardContent>
</Card>
{/* 批量处理配置 */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-xl">
<Sparkles className="h-5 w-5" />
批量处理配置
</CardTitle>
<CardDescription>
配置批量处理功能的默认提示词和参数。批量处理可将产品图批量融合到多个场景中。
</CardDescription>
</CardHeader>
<CardContent className="space-y-6 pt-6">
{/* 功能开关 */}
<div className="flex items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<Label className="text-base">启用批量处理功能</Label>
<div className="text-sm text-muted-foreground">
开启后用户可在图片编辑器中使用批量处理功能
</div>
</div>
<div className="flex items-center gap-2">
<Switch
checked={sceneFusionConfig.enabled}
onCheckedChange={(checked) => setSceneFusionConfig({ ...sceneFusionConfig, enabled: checked })}
/>
<span className="text-sm font-medium text-muted-foreground min-w-[3rem]">
{sceneFusionConfig.enabled ? "已开启" : "已关闭"}
</span>
</div>
</div>
<div className="flex items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<Label className="text-base">场景裂变前自动抠图</Label>
<div className="text-sm text-muted-foreground">
先对产品图做抠图预处理,再进行裂变融合;抠图失败将自动降级为原图继续生成
</div>
</div>
<div className="flex items-center gap-2">
<Switch
checked={sceneFusionConfig.preRmbgEnabled}
onCheckedChange={(checked) =>
setSceneFusionConfig({ ...sceneFusionConfig, preRmbgEnabled: checked })
}
disabled={!sceneFusionConfig.enabled}
/>
<span className="text-sm font-medium text-muted-foreground min-w-[3rem]">
{sceneFusionConfig.preRmbgEnabled ? "已开启" : "已关闭"}
</span>
</div>
</div>
{/* 默认提示词 */}
<div className="space-y-2">
<Label htmlFor="sceneFusionPrompt">默认提示词 *</Label>
<Textarea
id="sceneFusionPrompt"
value={sceneFusionConfig.defaultPrompt}
onChange={(e) => setSceneFusionConfig({ ...sceneFusionConfig, defaultPrompt: e.target.value })}
placeholder="输入默认的融合提示词..."
rows={4}
disabled={!sceneFusionConfig.enabled}
/>
<div className="text-xs text-muted-foreground">
该提示词将作为批量处理的基础指令,用户可以在此基础上添加修改要求
</div>
</div>
{/* 最大场景数量 */}
<div className="space-y-2">
<Label htmlFor="maxScenes">最大场景数量</Label>
<Input
id="maxScenes"
type="number"
value={sceneFusionConfig.maxScenes}
onChange={(e) =>
setSceneFusionConfig({
...sceneFusionConfig,
maxScenes: Math.max(1, Math.min(10, Number.parseInt(e.target.value) || 10)),
})
}
min={1}
max={10}
disabled={!sceneFusionConfig.enabled}
/>
<div className="text-xs text-muted-foreground">
用户单次裂变可上传的最大场景图数量(1-10张)
</div>
</div>
<div className="rounded-lg border border-blue-200 bg-blue-50 p-3">
<div className="text-sm font-medium text-blue-900">生成模式:多样性模式(固定)</div>
<div className="text-xs text-blue-800 mt-1">
系统会在所有可用生图模型中轮换分配任务,并在单模型失败时自动迁移到其他模型。
</div>
</div>
{/* 生图模型选择 */}
<div className="space-y-2">
<Label htmlFor="sceneFusionModel">首选生图模型(可选)</Label>
<Select
value={sceneFusionConfig.modelId || "auto"}
onValueChange={(value) => setSceneFusionConfig({ ...sceneFusionConfig, modelId: value === "auto" ? "" : value })}
disabled={!sceneFusionConfig.enabled}
>
<SelectTrigger>
<SelectValue placeholder="选择首选模型(不选则随机轮换)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="auto">不指定首选(完全随机轮换)</SelectItem>
{(modelsData?.models || [])
.filter((m: AiModel) => {
const provider = (providersData?.providers || []).find((p: AiProvider) => p.id === m.providerId);
return Boolean(provider && m.supportsImageGen);
})
.map((m: AiModel) => {
const provider = (providersData?.providers || []).find((p: AiProvider) => p.id === m.providerId);
return (
<SelectItem key={m.id} value={m.id}>
{m.displayName} ({provider?.name || provider?.type})
</SelectItem>
);
})
}
{/* 如果没有找到生图模型,显示提示 */}
{(modelsData?.models || []).filter((m: AiModel) => {
const provider = (providersData?.providers || []).find((p: AiProvider) => p.id === m.providerId);
return Boolean(provider && m.supportsImageGen);
}).length === 0 && (
<SelectItem value="no-model" disabled>
暂无可用的生图模型,请先配置生图供应商
</SelectItem>
)}
</SelectContent>
</Select>
<div className="text-xs text-muted-foreground">
仅用于设置“优先尝试”的模型;实际生成会自动在多模型间轮换并故障迁移
</div>
</div>
<div className="rounded-lg border border-blue-200 bg-blue-50 p-4 space-y-2">
<div className="text-sm font-medium text-blue-900">功能说明</div>
<div className="text-xs text-blue-800 space-y-1">
<p>• 用户上传1张产品图和多张场景参考图</p>
<p>• 系统自动将产品图融合到每个场景中</p>
<p>• 固定采用多样性模式:多模型轮换 + 自动故障迁移</p>
<p>• 生成的图片会自动保存到用户相册</p>
</div>
</div>
<Button onClick={handleSaveSceneFusionConfig}>
<Save className="mr-2 h-4 w-4" /> 保存配置
</Button>
</CardContent>
</Card>
</TabsContent>
</Tabs>
{/* 批量设置出图限额对话框 */}
<Dialog open={showBatchLimitDialog} onOpenChange={setShowBatchLimitDialog}>
<DialogContent className="sm:max-w-[500px] !bg-white !dark:bg-zinc-950">
<DialogHeader>
<DialogTitle>批量设置出图限额</DialogTitle>
</DialogHeader>
<div className="space-y-4 pt-4">
{/* 设置类型选择 */}
<div className="space-y-2">
<Label>设置范围</Label>
<Select
value={batchLimitType}
onValueChange={(value: "all" | "department" | "no_department") => {
setBatchLimitType(value);
setBatchLimitDepartment("");
}}
>
<SelectTrigger>
<SelectValue placeholder="选择设置范围" />
</SelectTrigger>
<SelectContent className="!bg-white !dark:bg-zinc-950">
<SelectItem value="all">所有用户</SelectItem>
<SelectItem value="department">按部门设置</SelectItem>
<SelectItem value="no_department">无部门用户</SelectItem>
</SelectContent>
</Select>
</div>
{/* 部门选择(仅在按部门设置时显示) */}
{batchLimitType === "department" && (
<div className="space-y-2">
<Label>选择部门</Label>
<Select
value={batchLimitDepartment}
onValueChange={setBatchLimitDepartment}
>
<SelectTrigger>
<SelectValue placeholder="选择部门" />
</SelectTrigger>
<SelectContent className="max-h-60 overflow-y-auto !bg-white !dark:bg-zinc-950">
{departmentsData?.departments?.map((dept) => (
<SelectItem key={dept} value={dept}>
{dept}
</SelectItem>
))}
</SelectContent>
</Select>
{departmentsData?.departments?.length === 0 && (
<p className="text-sm text-muted-foreground">暂无部门数据,请先同步钉钉用户</p>
)}
</div>
)}
{/* 限额输入 */}
<div className="space-y-2">
<Label>月度出图限额</Label>
<div className="flex items-center gap-2">
<Input
type="number"
min="0"
placeholder="留空表示无限制"
value={batchLimitValue}
onChange={(e) => setBatchLimitValue(e.target.value)}
className="flex-1"
/>
<span className="text-sm text-muted-foreground">张/月</span>
</div>
<p className="text-xs text-muted-foreground">
设置为空或0表示无限制
</p>
</div>
{/* 预览信息 */}
<div className="rounded-md bg-muted p-3 text-sm">
<p className="font-medium mb-1">即将执行:</p>
<p className="text-muted-foreground">
{batchLimitType === "all" && "将为所有用户设置出图限额"}
{batchLimitType === "department" && batchLimitDepartment && `将为部门「${batchLimitDepartment}」的用户设置出图限额`}
{batchLimitType === "department" && !batchLimitDepartment && "请先选择部门"}
{batchLimitType === "no_department" && "将为所有无部门用户设置出图限额"}
{" → "}
{batchLimitValue ? `${batchLimitValue} 张/月` : "无限制"}
</p>
</div>
</div>
<DialogFooter className="mt-4">
<Button variant="outline" onClick={() => setShowBatchLimitDialog(false)}>
取消
</Button>
<Button
onClick={handleBatchSetLimit}
disabled={batchLimitType === "department" && !batchLimitDepartment}
>
确认设置
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}