lovart3/app/(admin)/settings/admin-settings-client.tsx

5035 lines
221 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 { useState, useEffect } 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 { 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,
Upload,
Video,
Loader2,
Check,
} 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";
const fetcher = (url: string) => fetch(url).then((res) => res.json());
// 类型定义
type User = {
id: string;
email: string;
name: string | null;
department: string | null;
role: "guest" | "regular" | "admin";
isActive: boolean;
monthlyImageLimit: number | null;
createdAt: string;
};
const ROLE_LABELS: Record<User["role"], string> = {
guest: "访客用户",
regular: "普通用户",
admin: "管理员",
};
type ToolConfig = {
toolName: string;
isEnabled: boolean;
description?: string;
config?: any;
};
type AiProvider = {
id: string;
name: string;
type: "alibaba" | "openai" | "custom" | "local" | "gemini" | "qianwen-video" | "volcano-vision" | "volcano-video" | "alibaba-image";
baseUrl: string;
apiKey: string;
isEnabled: boolean;
createdAt: string;
};
// 🆕 图片生成模型特性配置
type ImageGenConfig = {
maxImages?: number; // 最大输出图片数,如阿里 1-6,Gemini 1-2
supportsSize?: boolean; // 是否支持自定义分辨率
supportsPromptExtend?: boolean; // 是否支持提示词智能优化
supportsNegativePrompt?: boolean; // 是否支持反向提示词
supportsWatermark?: boolean; // 是否支持水印开关
supportsSeed?: boolean; // 是否支持随机种子
enableLoadingPlaceholder?: boolean; // 是否启用加载占位图
loadingColor?: string; // 占位图主题色,如 "purple", "blue", "green"
};
type AiModel = {
id: string;
providerId: string;
modelId: string;
displayName: string;
description?: string;
supportsVision: boolean;
supportsImageGen: boolean;
isEnabled: boolean;
isDefault: boolean;
sortOrder: number;
imageGenConfig?: ImageGenConfig; // 🆕 图片生成特性配置
};
type StorageConfig = {
id: string;
name: string;
type: "s3" | "local";
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;
canvasDefaultModelId: string | null;
canvasAutoModeDefaultModelId: string | null;
};
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;
isDingTalkEnabled: boolean;
isRegistrationEnabled?: boolean;
dingTalkOrganizations: DingTalkOrganization[];
};
type GeneratedImageRecord = {
id: string;
userId: string;
userName: string | null;
userEmail: string;
userDepartment: string | null;
thumbnailUrl: string;
originalUrl: string;
imageSize: string | null;
prompt: string | null;
modelId: string | null;
isFeatured: boolean;
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: "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: "volcano-vision", label: "火山引擎视觉" },
{ value: "volcano-video", label: "火山引擎视频" },
];
export function AdminSettingsClient() {
const [activeTab, setActiveTab] = useState("users");
// 用户管理状态
const [userSearch, setUserSearch] = useState("");
const [userPage, setUserPage] = useState(1);
const [userLimit] = useState(10);
// 批量设置出图限额状态
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 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 };
}>(`/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 [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,
canvasDefaultModelId: null,
canvasAutoModeDefaultModelId: null,
});
useEffect(() => {
if (siteSettingsData) {
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,
canvasDefaultModelId: siteSettingsData.canvasDefaultModelId || null,
canvasAutoModeDefaultModelId: siteSettingsData.canvasAutoModeDefaultModelId || null,
});
}
}, [siteSettingsData]);
// 抠图API配置状态
const [rmbgConfig, setRmbgConfig] = useState({
provider: "rmbg" as "rmbg" | "photoroom", // 抠图服务提供商
apiUrl: "",
apiKey: "",
// PhotoRoom 配置
photoroomApiKey: "",
});
// 变清晰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 [sceneFusionConfig, setSceneFusionConfig] = useState({
enabled: true,
defaultPrompt: "将图1的产品替换到图2的场景中,保持图1产品的外观、颜色和细节不变,自然融合到图2的环境中。",
maxScenes: 10,
modelId: "", // 使用的生图模型ID
});
// 加载抠图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";
apiUrl?: string;
apiKey?: string;
photoroomApiKey?: string;
};
setRmbgConfig({
provider: config.provider || "rmbg",
apiUrl: config.apiUrl || "",
apiKey: config.apiKey || "",
photoroomApiKey: config.photoroomApiKey || "",
});
}
}, [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: sceneFusionConfigData } = useSWR("/api/admin/settings?key=scene_fusion_config", fetcher);
useEffect(() => {
if (sceneFusionConfigData?.setting?.value) {
const config = sceneFusionConfigData.setting.value as {
enabled?: boolean;
defaultPrompt?: string;
maxScenes?: number;
modelId?: string;
};
setSceneFusionConfig({
enabled: config.enabled ?? true,
defaultPrompt: config.defaultPrompt || "将图1的产品替换到图2的场景中,保持图1产品的外观、颜色和细节不变,自然融合到图2的环境中。",
maxScenes: config.maxScenes ?? 10,
modelId: config.modelId ?? "",
});
}
}, [sceneFusionConfigData]);
// 导航链接状态
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 [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]);
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 [authSettings, setAuthSettings] = useState<AuthSettings>({
dingTalkAppKey: "",
dingTalkAppSecret: "",
isDingTalkEnabled: false,
isRegistrationEnabled: true,
dingTalkOrganizations: [],
});
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 || "",
isDingTalkEnabled: authSettingsData.isDingTalkEnabled || false,
isRegistrationEnabled: authSettingsData.isRegistrationEnabled ?? true,
dingTalkOrganizations: normalizedOrgs,
});
}, [authSettingsData]);
// AI 提供商 & 模型状态
const [providerForm, setProviderForm] = useState({
id: "",
name: "",
type: "openai" as "alibaba" | "openai" | "custom" | "local" | "gemini" | "qianwen-video" | "volcano-vision" | "volcano-video" | "alibaba-image",
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: "",
description: "",
supportsVision: false,
supportsImageGen: false,
isEnabled: true,
isDefault: false,
sortOrder: 0,
fallbackModelId1: "", // 备用模型 ID 1
fallbackModelId2: "", // 备用模型 ID 2
// 🆕 图片生成模型特性配置
imageGenConfig: {
maxImages: 2,
supportsSize: false,
supportsPromptExtend: false,
supportsNegativePrompt: false,
supportsWatermark: false,
supportsSeed: false,
enableLoadingPlaceholder: true,
loadingColor: "purple",
} as ImageGenConfig,
});
const [showModelDialog, setShowModelDialog] = useState(false);
const [editingModel, setEditingModel] = useState<string | null>(null);
// 🆕 火山引擎视频多模型表单(动态从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 [storageForm, setStorageForm] = useState({
id: "",
name: "",
type: "s3" as "s3" | "local",
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 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 {
const response = await fetch("/api/site-settings", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(siteSettings),
});
if (!response.ok) throw new Error("保存失败");
toast.success("网站设置保存成功");
mutateSiteSettings();
} catch (error) {
toast.error("保存网站设置失败");
}
};
// --- 抠图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;
}
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,
},
}),
});
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 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,
defaultPrompt: sceneFusionConfig.defaultPrompt.trim(),
maxScenes: sceneFusionConfig.maxScenes,
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: "",
apiKey: "",
isEnabled: true,
});
setEditingProvider(null);
};
const handleSaveProvider = async () => {
try {
const method = editingProvider ? "PUT" : "POST";
const isNewProvider = !editingProvider;
const isVolcanoVideo = providerForm.type === 'volcano-video';
let requestData: any = editingProvider ? { ...providerForm, id: editingProvider } : providerForm;
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('预设模型添加失败,请手动点击"快速添加预设模型"按钮');
}
}
} 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 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 || "添加预设模型失败");
}
};
// --- AI 模型逻辑 ---
const resetModelForm = () => {
setModelForm({
id: "",
providerId: "",
modelId: "",
displayName: "",
description: "",
supportsVision: false,
supportsImageGen: false,
isEnabled: true,
isDefault: false,
sortOrder: 0,
fallbackModelId1: "",
fallbackModelId2: "",
imageGenConfig: {
maxImages: 2,
supportsSize: false,
supportsPromptExtend: false,
supportsNegativePrompt: false,
supportsWatermark: false,
supportsSeed: false,
enableLoadingPlaceholder: true,
loadingColor: "purple",
},
});
setEditingModel(null);
};
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 = editingStorage ? { ...storageForm, id: editingStorage } : storageForm;
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>
<CardContent className="pt-6">
{/* 搜索框和同步按钮 */}
<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.monthlyImageLimit ? `${user.monthlyImageLimit} 张/月` : "无限制"}
</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>
</SelectContent>
</Select>
<Button
variant={user.isActive ? "outline" : "default"}
size="sm"
onClick={() => handleUpdateUser(user.id, { isActive: !user.isActive })}
>
{user.isActive ? "禁用" : "激活"}
</Button>
<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} 条记录
</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
src={img.thumbnailUrl}
alt={img.prompt || "AI生成的图片"}
className="w-full h-full object-cover"
loading="lazy"
/>
{/* 精选图标 */}
{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-2xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
图片详情
{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">
<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">图片尺寸</Label>
<p>{selectedImage.imageSize || "未知"}</p>
</div>
<div>
<Label className="text-muted-foreground">模型</Label>
<p>{selectedImage.modelId || "未知"}</p>
</div>
{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>
{/* 精选操作按钮 */}
<div className="flex justify-end pt-2 border-t">
<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>
</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",
"alibaba": "bg-blue-500",
"openai": "bg-green-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>
<CardContent className="space-y-6 pt-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="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">
开启后,用户可以选择"智能自动"模型,系统将根据场景智能选择最佳模型
</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="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">智能自动(推荐)</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">
设置无限画布中用户的默认生图模型。"智能自动"会根据用户需求自动选择最佳模型。
</div>
</div>
{/* 智能自动模式下的默认生图模型 */}
<div className="space-y-2 mt-4 pt-4 border-t">
<Label htmlFor="canvasAutoModeDefaultModelId">智能自动模式 - 默认生图模型</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">
当用户选择"智能自动"模式时,系统实际使用的生图模型。"系统智能选择"会根据用户意图(如抠图、换背景等)自动路由到最佳模型。
</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">
<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>
<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 fetch("/api/files/upload", {
method: "POST",
body: 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>
<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">
<Card>
<CardContent className="space-y-4 pt-6">
{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>
</TabsContent>
{/* 认证设置 */}
<TabsContent value="auth" className="mt-6">
<Card>
<CardContent className="space-y-6 pt-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>
<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 />
<Button onClick={handleSaveAuthSettings}>
<Save className="mr-2 h-4 w-4" /> 保存认证配置
</Button>
</CardContent>
</Card>
</TabsContent>
{/* 系统通知设置 */}
<TabsContent value="notification" className="mt-6">
<Card>
<CardContent className="space-y-6 pt-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>
<Dialog open={showProviderDialog} onOpenChange={setShowProviderDialog}>
<DialogTrigger asChild>
<Button onClick={() => { resetProviderForm(); setShowProviderDialog(true); }}>
<Plus className="mr-2 h-4 w-4" /> 添加提供商
</Button>
</DialogTrigger>
<DialogContent>
<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({ ...providerForm, type: value as any })}
>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{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 })}
/>
</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 })}
placeholder={editingProvider ? "留空则不修改" : "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>
</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>
</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>
)}
</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>
<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>
{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"
/>
</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>描述</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>支持视觉</Label>
</div>
<div className="flex items-center gap-2">
<Switch
checked={modelForm.supportsImageGen}
onCheckedChange={(checked) => setModelForm({ ...modelForm, supportsImageGen: checked })}
/>
<Label>支持绘图</Label>
</div>
</div>
<div className="flex gap-4">
<div className="flex items-center gap-2">
<Switch
checked={modelForm.isEnabled}
onCheckedChange={(checked) => setModelForm({ ...modelForm, isEnabled: checked })}
/>
<Label>启用</Label>
</div>
<div className="flex items-center gap-2">
<Switch
checked={modelForm.isDefault}
onCheckedChange={(checked) => setModelForm({ ...modelForm, isDefault: checked })}
/>
<Label>设为默认</Label>
</div>
</div>
{/* 🆕 图片生成模型特性配置 */}
{modelForm.supportsImageGen && (
<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>
<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>
)}
</>
)}
</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);
return (
<div key={model.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">
{model.displayName}
{model.isDefault && <span className="text-xs bg-blue-100 text-blue-800 px-2 py-0.5 rounded">默认</span>}
</div>
<div className="text-sm text-muted-foreground">
ID: {model.modelId} | 提供商: {provider?.name ?? "未知"}
</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,
description: model.description || "",
supportsVision: model.supportsVision,
supportsImageGen: model.supportsImageGen,
isEnabled: model.isEnabled,
isDefault: model.isDefault,
sortOrder: model.sortOrder || 0,
fallbackModelId1: (model as any).fallbackModelId1 || "",
fallbackModelId2: (model as any).fallbackModelId2 || "",
imageGenConfig: (model as any).imageGenConfig || {
maxImages: 2,
supportsSize: false,
supportsPromptExtend: false,
supportsNegativePrompt: false,
supportsWatermark: false,
supportsSeed: false,
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">
<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>
<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>
<SelectItem value="s3">S3 兼容</SelectItem>
<SelectItem value="local">本地存储</SelectItem>
</SelectContent>
</Select>
</div>
{storageForm.type === "s3" && (
<>
<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" ? `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,
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>
</TabsContent>
{/* 本地AI设置 */}
<TabsContent value="rmbg" className="mt-6 space-y-6">
{/* 抠图API配置 */}
<Card>
<CardHeader>
<CardTitle>抠图API配置</CardTitle>
<CardDescription>
配置背景移除API,用于画图标注功能中的抠图处理。支持自部署RMBG和PhotoRoom云服务。
</CardDescription>
</CardHeader>
<CardContent className="space-y-6 pt-6">
{/* 服务提供商选择 */}
<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="rmbgProvider"
value="rmbg"
checked={rmbgConfig.provider === "rmbg"}
onChange={() => setRmbgConfig({ ...rmbgConfig, provider: "rmbg" })}
className="w-4 h-4"
/>
<span className="text-sm">RMBG-2.0 (自部署)</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="rmbgProvider"
value="photoroom"
checked={rmbgConfig.provider === "photoroom"}
onChange={() => setRmbgConfig({ ...rmbgConfig, provider: "photoroom" })}
className="w-4 h-4"
/>
<span className="text-sm">PhotoRoom (云服务)</span>
</label>
</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>
</>
)}
<Button onClick={handleSaveRmbgConfig}>
<Save className="mr-2 h-4 w-4" /> 保存配置
</Button>
</CardContent>
</Card>
{/* 变清晰API配置 */}
<Card>
<CardHeader>
<CardTitle>变清晰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>
<Switch
checked={enhanceConfig.useBackupApi}
onCheckedChange={(checked) => setEnhanceConfig({ ...enhanceConfig, useBackupApi: checked })}
/>
</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>• 图片大小:不超过20MB,分辨率最大4096x4096</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>图像拓展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">
<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">
<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>
<Switch
checked={angleEditConfig.enabled}
onCheckedChange={(checked) => setAngleEditConfig({ ...angleEditConfig, enabled: checked })}
/>
</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">
<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>
<Switch
checked={sceneFusionConfig.enabled}
onCheckedChange={(checked) => setSceneFusionConfig({ ...sceneFusionConfig, enabled: checked })}
/>
</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: Number.parseInt(e.target.value) || 10 })}
min={1}
max={20}
disabled={!sceneFusionConfig.enabled}
/>
<div className="text-xs text-muted-foreground">
用户单次裂变可上传的最大场景图数量(1-20张)
</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);
if (!provider) return false;
// 支持阿里生图、Gemini、既梦等图片模型
const imageProviderTypes = ["alibaba-image", "gemini-image", "gemini", "volcano-vision"];
return imageProviderTypes.includes(provider.type);
})
.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);
const imageProviderTypes = ["alibaba-image", "gemini-image", "gemini", "volcano-vision"];
return provider && imageProviderTypes.includes(provider.type);
}).length === 0 && (
<SelectItem value="no-model" disabled>
暂无可用的生图模型,请先配置生图供应商
</SelectItem>
)}
</SelectContent>
</Select>
<div className="text-xs text-muted-foreground">
选择用于详情替换的生图模型,支持阿里生图、Gemini、既梦等图片模型
</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>• 支持选择阿里生图或Gemini图片模型</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]">
<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>
<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">
{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>
);
}