5471 lines
242 KiB
TypeScript
5471 lines
242 KiB
TypeScript
"use client";
|
||
|
||
import { useState, useEffect, useMemo } from "react";
|
||
import useSWR from "swr";
|
||
import { toast } from "sonner";
|
||
import { formatBeijingTime } from "@/lib/utils";
|
||
import {
|
||
Card,
|
||
CardContent,
|
||
CardDescription,
|
||
CardHeader,
|
||
CardTitle,
|
||
} from "@/components/ui/card";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Input } from "@/components/ui/input";
|
||
import { Label } from "@/components/ui/label";
|
||
import {
|
||
Select,
|
||
SelectContent,
|
||
SelectItem,
|
||
SelectTrigger,
|
||
SelectValue,
|
||
} from "@/components/ui/select";
|
||
import { 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";
|
||
import {
|
||
defaultMembershipConfig,
|
||
defaultMembershipUserOverrides,
|
||
normalizeMembershipConfig,
|
||
normalizeMembershipUserOverrides,
|
||
} from "@/lib/membership/config";
|
||
|
||
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" | "huanke-gemini" | "qianwen-video" | "volcano-vision" | "volcano-video" | "alibaba-image" | "ruxa-image" | "zhipu";
|
||
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;
|
||
isAgentModel?: boolean;
|
||
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;
|
||
lovartComposerPreviewTexts: string[];
|
||
lovartPromoText: string;
|
||
lovartPromoActionText: string;
|
||
lovartHeroTitle: string;
|
||
lovartHeroSubtitle: string;
|
||
homeVariant: "classic" | "lovart";
|
||
surpriseCategories: string[];
|
||
membershipConfig: ReturnType<typeof normalizeMembershipConfig>;
|
||
membershipUserOverrides: ReturnType<typeof normalizeMembershipUserOverrides>;
|
||
};
|
||
|
||
type NavLink = {
|
||
id: string;
|
||
name: string;
|
||
url: string;
|
||
order: number;
|
||
isEnabled: boolean;
|
||
toolboxGroup: number; // 1 或 2
|
||
};
|
||
|
||
type TutorialVideo = {
|
||
id: string;
|
||
title: string;
|
||
videoUrl: string;
|
||
coverUrl: string | null;
|
||
description: string | null;
|
||
order: number;
|
||
isEnabled: boolean;
|
||
};
|
||
|
||
type DingTalkOrganization = {
|
||
id: string;
|
||
name: string;
|
||
appKey: string;
|
||
appSecret: string;
|
||
};
|
||
|
||
type AuthSettings = {
|
||
// 保留单一配置用于兼容旧数据(后端会自动同步为第一个组织)
|
||
dingTalkAppKey?: string | null;
|
||
dingTalkAppSecret?: string | null;
|
||
googleClientId?: string | null;
|
||
googleClientSecret?: string | null;
|
||
authUrl?: string | null;
|
||
isDingTalkEnabled: boolean;
|
||
isGoogleEnabled: boolean;
|
||
isRegistrationEnabled?: boolean;
|
||
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;
|
||
featuredCategory: string | null;
|
||
featuredAt: string | null;
|
||
createdAt: string;
|
||
chatId: string | null;
|
||
};
|
||
|
||
type ImageAnalytics = {
|
||
overview: {
|
||
totalImages: number;
|
||
totalUsers: number;
|
||
todayImages: number;
|
||
todayUsers: number;
|
||
weekImages: number;
|
||
weekUsers: number;
|
||
monthImages: number;
|
||
monthUsers: number;
|
||
};
|
||
userStats: Array<{
|
||
userId: string;
|
||
userName: string | null;
|
||
userEmail: string;
|
||
imageCount: number;
|
||
}>;
|
||
dailyStats: Array<{ date: string; count: number }>;
|
||
sizeStats: Array<{ imageSize: string | null; count: number }>;
|
||
modelStats: Array<{ modelId: string | null; count: number }>;
|
||
modelDetailStats: Array<{
|
||
modelId: string | null;
|
||
count: number;
|
||
userCount: number;
|
||
modelName: string | null;
|
||
providerName: string | null;
|
||
providerType: string | null;
|
||
}>;
|
||
providerTypeStats: Array<{
|
||
providerType: string | null;
|
||
providerName: string | null;
|
||
count: number;
|
||
}>;
|
||
modelTrendStats: Array<{
|
||
date: string;
|
||
modelId: string | null;
|
||
count: number;
|
||
}>;
|
||
monthlyStats: Array<{ month: string; count: number }>;
|
||
hourlyStats: Array<{ hour: number; count: number }>;
|
||
};
|
||
|
||
const AVAILABLE_TOOLS = [
|
||
{ name: "getWeather", description: "获取天气信息" },
|
||
{ name: "createDocument", description: "创建文档" },
|
||
{ name: "updateDocument", description: "更新文档" },
|
||
{ name: "requestSuggestions", description: "请求文档建议" },
|
||
];
|
||
|
||
const PROVIDER_TYPES = [
|
||
{ value: "alibaba", label: "阿里云通义千问" },
|
||
{ value: "alibaba-image", label: "阿里生图模型" }, // 🆕 千问图片生成/编辑
|
||
{ value: "ruxa-image", label: "RUXA 生图模型" },
|
||
{ value: "zhipu", label: "智普官方 4.7" },
|
||
{ 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,
|
||
lovartComposerPreviewTexts: [
|
||
"让 Lovart 打造引人注目的社交媒体视觉",
|
||
"帮我把这张产品图做成高级电商海报",
|
||
"基于参考图延展 3 套同风格方案",
|
||
],
|
||
lovartPromoText: "现在升级,立享 Kling 3.0 与 Nano Banana Pro,最高 5 折优惠",
|
||
lovartPromoActionText: "立即升级",
|
||
lovartHeroTitle: "由壹点AI 让设计更简单",
|
||
lovartHeroSubtitle: "懂你的设计代理,帮你搞定一切",
|
||
homeVariant: "classic",
|
||
surpriseCategories: ["全部", "惊喜"],
|
||
membershipConfig: defaultMembershipConfig,
|
||
membershipUserOverrides: defaultMembershipUserOverrides,
|
||
});
|
||
const [membershipConfigDraft, setMembershipConfigDraft] = useState(
|
||
JSON.stringify(defaultMembershipConfig, null, 2)
|
||
);
|
||
const [membershipOverridesDraft, setMembershipOverridesDraft] = useState(
|
||
JSON.stringify(defaultMembershipUserOverrides, null, 2)
|
||
);
|
||
|
||
useEffect(() => {
|
||
if (siteSettingsData) {
|
||
const normalizedMembershipConfig = normalizeMembershipConfig(
|
||
(siteSettingsData as any).membershipConfig
|
||
);
|
||
const normalizedMembershipOverrides = normalizeMembershipUserOverrides(
|
||
(siteSettingsData as any).membershipUserOverrides
|
||
);
|
||
|
||
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,
|
||
lovartComposerPreviewTexts: Array.isArray(siteSettingsData.lovartComposerPreviewTexts)
|
||
? siteSettingsData.lovartComposerPreviewTexts
|
||
.map((item: any) => String(item).trim())
|
||
.filter(Boolean)
|
||
: [
|
||
"让 Lovart 打造引人注目的社交媒体视觉",
|
||
"帮我把这张产品图做成高级电商海报",
|
||
"基于参考图延展 3 套同风格方案",
|
||
],
|
||
lovartPromoText:
|
||
siteSettingsData.lovartPromoText ||
|
||
"现在升级,立享 Kling 3.0 与 Nano Banana Pro,最高 5 折优惠",
|
||
lovartPromoActionText: siteSettingsData.lovartPromoActionText || "立即升级",
|
||
lovartHeroTitle: siteSettingsData.lovartHeroTitle || "由壹点AI 让设计更简单",
|
||
lovartHeroSubtitle: siteSettingsData.lovartHeroSubtitle || "懂你的设计代理,帮你搞定一切",
|
||
homeVariant: siteSettingsData.homeVariant === "lovart" ? "lovart" : "classic",
|
||
surpriseCategories: Array.isArray(siteSettingsData.surpriseCategories)
|
||
? siteSettingsData.surpriseCategories.map((item: any) => String(item).trim()).filter(Boolean)
|
||
: ["全部", "惊喜"],
|
||
membershipConfig: normalizedMembershipConfig,
|
||
membershipUserOverrides: normalizedMembershipOverrides,
|
||
});
|
||
setMembershipConfigDraft(JSON.stringify(normalizedMembershipConfig, null, 2));
|
||
setMembershipOverridesDraft(JSON.stringify(normalizedMembershipOverrides, null, 2));
|
||
}
|
||
}, [siteSettingsData]);
|
||
|
||
const featuredCategoryOptions = useMemo(() => {
|
||
const values = (siteSettings.surpriseCategories || [])
|
||
.map((item) => String(item || "").trim())
|
||
.filter((item) => item && item !== "全部");
|
||
return Array.from(new Set(values));
|
||
}, [siteSettings.surpriseCategories]);
|
||
|
||
// 抠图API配置状态
|
||
const [rmbgConfig, setRmbgConfig] = useState({
|
||
provider: "rmbg" as "rmbg" | "photoroom", // 抠图服务提供商
|
||
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: "",
|
||
googleClientId: "",
|
||
googleClientSecret: "",
|
||
authUrl: "",
|
||
isDingTalkEnabled: false,
|
||
isGoogleEnabled: false,
|
||
isRegistrationEnabled: true,
|
||
dingTalkOrganizations: [],
|
||
});
|
||
const [isGoogleConfigured, setIsGoogleConfigured] = useState(false);
|
||
|
||
useEffect(() => {
|
||
if (!authSettingsData) return;
|
||
|
||
const organizations: DingTalkOrganization[] =
|
||
(authSettingsData.dingTalkOrganizations as DingTalkOrganization[] | null) ?? [];
|
||
|
||
// 如果没有多组织配置,但有单一 AppKey/Secret,则自动构造一个默认组织
|
||
const normalizedOrgs =
|
||
organizations.length > 0 ||
|
||
!authSettingsData.dingTalkAppKey
|
||
? organizations
|
||
: [
|
||
{
|
||
id: "default",
|
||
name: "默认组织",
|
||
appKey: authSettingsData.dingTalkAppKey ?? "",
|
||
appSecret: authSettingsData.dingTalkAppSecret ?? "",
|
||
},
|
||
];
|
||
|
||
setAuthSettings({
|
||
dingTalkAppKey: authSettingsData.dingTalkAppKey || "",
|
||
dingTalkAppSecret: authSettingsData.dingTalkAppSecret || "",
|
||
googleClientId: authSettingsData.googleClientId || "",
|
||
googleClientSecret: authSettingsData.googleClientSecret || "",
|
||
authUrl: authSettingsData.authUrl || "",
|
||
isDingTalkEnabled: authSettingsData.isDingTalkEnabled || false,
|
||
isGoogleEnabled: authSettingsData.isGoogleEnabled || false,
|
||
isRegistrationEnabled: authSettingsData.isRegistrationEnabled ?? true,
|
||
dingTalkOrganizations: normalizedOrgs,
|
||
});
|
||
setIsGoogleConfigured(Boolean(authSettingsData.isGoogleConfigured));
|
||
}, [authSettingsData]);
|
||
|
||
// AI 提供商 & 模型状态
|
||
const [providerForm, setProviderForm] = useState({
|
||
id: "",
|
||
name: "",
|
||
type: "openai" as "alibaba" | "openai" | "custom" | "local" | "gemini" | "huanke-gemini" | "qianwen-video" | "volcano-vision" | "volcano-video" | "alibaba-image" | "ruxa-image" | "zhipu",
|
||
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,
|
||
isAgentModel: 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 {
|
||
let parsedMembershipConfig: ReturnType<typeof normalizeMembershipConfig>;
|
||
let parsedMembershipOverrides: ReturnType<typeof normalizeMembershipUserOverrides>;
|
||
|
||
try {
|
||
parsedMembershipConfig = normalizeMembershipConfig(
|
||
JSON.parse(membershipConfigDraft)
|
||
);
|
||
} catch {
|
||
toast.error("会员配置 JSON 格式错误,请检查后再保存");
|
||
return;
|
||
}
|
||
|
||
try {
|
||
parsedMembershipOverrides = normalizeMembershipUserOverrides(
|
||
JSON.parse(membershipOverridesDraft)
|
||
);
|
||
} catch {
|
||
toast.error("会员用户覆盖 JSON 格式错误,请检查后再保存");
|
||
return;
|
||
}
|
||
|
||
const payload = {
|
||
...siteSettings,
|
||
membershipConfig: parsedMembershipConfig,
|
||
membershipUserOverrides: parsedMembershipOverrides,
|
||
};
|
||
|
||
const response = await fetch("/api/site-settings", {
|
||
method: "PUT",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(payload),
|
||
});
|
||
if (!response.ok) throw new Error("保存失败");
|
||
setSiteSettings(payload);
|
||
setMembershipConfigDraft(JSON.stringify(parsedMembershipConfig, null, 2));
|
||
setMembershipOverridesDraft(JSON.stringify(parsedMembershipOverrides, null, 2));
|
||
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,
|
||
isAgentModel: 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>
|
||
<div>
|
||
<Label className="text-muted-foreground">精选分类</Label>
|
||
<p>{selectedImage.featuredCategory || "未设置"}</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 flex-wrap items-center justify-between gap-3 border-t pt-2">
|
||
<div className="flex items-center gap-2">
|
||
<Label className="text-xs text-muted-foreground">分类</Label>
|
||
<Select
|
||
value={selectedImage.featuredCategory || "__none__"}
|
||
onValueChange={(value) =>
|
||
setSelectedImage({
|
||
...selectedImage,
|
||
featuredCategory: value === "__none__" ? null : value,
|
||
})
|
||
}
|
||
>
|
||
<SelectTrigger className="h-8 w-[180px]">
|
||
<SelectValue placeholder="选择分类" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="__none__">未分类</SelectItem>
|
||
{featuredCategoryOptions.map((category) => (
|
||
<SelectItem key={category} value={category}>
|
||
{category}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={async () => {
|
||
try {
|
||
const res = await fetch("/api/admin/images", {
|
||
method: "PATCH",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
imageId: selectedImage.id,
|
||
featuredCategory: selectedImage.featuredCategory,
|
||
}),
|
||
});
|
||
if (!res.ok) {
|
||
throw new Error("更新精选分类失败");
|
||
}
|
||
toast.success("精选分类已更新");
|
||
mutateImages();
|
||
} catch (error) {
|
||
toast.error(error instanceof Error ? error.message : "更新精选分类失败");
|
||
}
|
||
}}
|
||
>
|
||
保存分类
|
||
</Button>
|
||
</div>
|
||
<Button
|
||
variant={selectedImage.isFeatured ? "outline" : "default"}
|
||
className={selectedImage.isFeatured ? "" : "bg-gradient-to-r from-yellow-400 to-orange-500 hover:from-yellow-500 hover:to-orange-600"}
|
||
onClick={async () => {
|
||
try {
|
||
const res = await fetch("/api/admin/images", {
|
||
method: "PATCH",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
imageId: selectedImage.id,
|
||
isFeatured: !selectedImage.isFeatured,
|
||
}),
|
||
});
|
||
if (res.ok) {
|
||
// 更新本地状态
|
||
setSelectedImage({
|
||
...selectedImage,
|
||
isFeatured: !selectedImage.isFeatured,
|
||
featuredAt: !selectedImage.isFeatured ? new Date().toISOString() : null,
|
||
});
|
||
// 刷新列表
|
||
mutateImages();
|
||
}
|
||
} catch (error) {
|
||
console.error("Failed to update featured status:", error);
|
||
}
|
||
}}
|
||
>
|
||
<Sparkles className="h-4 w-4 mr-2" />
|
||
{selectedImage.isFeatured ? "取消精选" : "设为精选"}
|
||
</Button>
|
||
</div>
|
||
</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",
|
||
"alibaba-image": "bg-amber-500",
|
||
"ruxa-image": "bg-emerald-500",
|
||
"openai": "bg-green-500",
|
||
"zhipu": "bg-lime-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="space-y-2">
|
||
<Label htmlFor="homeVariant">首页展示模式</Label>
|
||
<Select
|
||
value={siteSettings.homeVariant}
|
||
onValueChange={(value: "classic" | "lovart") =>
|
||
setSiteSettings({ ...siteSettings, homeVariant: value })
|
||
}
|
||
>
|
||
<SelectTrigger id="homeVariant">
|
||
<SelectValue placeholder="选择首页模式" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="classic">经典对话首页</SelectItem>
|
||
<SelectItem value="lovart">新版项目首页</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
<div className="text-xs text-muted-foreground">
|
||
新版项目首页会启用全新的项目入口、文件列表与精选展示。
|
||
</div>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label htmlFor="surpriseCategories">惊喜相册分类(逗号分隔)</Label>
|
||
<Input
|
||
id="surpriseCategories"
|
||
value={siteSettings.surpriseCategories.join(", ")}
|
||
onChange={(e) => {
|
||
const parsed = e.target.value
|
||
.split(",")
|
||
.map((item) => item.trim())
|
||
.filter(Boolean);
|
||
const unique = Array.from(new Set(parsed));
|
||
setSiteSettings({
|
||
...siteSettings,
|
||
surpriseCategories: unique.length > 0 ? unique : ["全部", "惊喜"],
|
||
});
|
||
}}
|
||
placeholder="全部, 惊喜, 海报, 电商"
|
||
/>
|
||
<div className="text-xs text-muted-foreground">
|
||
新版首页“精选”模块会按这些分类显示筛选标签。
|
||
</div>
|
||
</div>
|
||
<div className="grid gap-4 rounded-lg border border-dashed p-4">
|
||
<div className="text-sm font-medium">新版首页文案配置</div>
|
||
<div className="space-y-2">
|
||
<Label htmlFor="lovartComposerPreviewTexts">
|
||
对话框预览文案(每行一条,按顺序轮播打字显示)
|
||
</Label>
|
||
<Textarea
|
||
id="lovartComposerPreviewTexts"
|
||
value={siteSettings.lovartComposerPreviewTexts.join("\n")}
|
||
onChange={(e) => {
|
||
const values = e.target.value
|
||
.split("\n")
|
||
.map((item) => item.trim())
|
||
.filter(Boolean)
|
||
.slice(0, 20);
|
||
|
||
setSiteSettings({
|
||
...siteSettings,
|
||
lovartComposerPreviewTexts:
|
||
values.length > 0
|
||
? values
|
||
: [
|
||
"让 Lovart 打造引人注目的社交媒体视觉",
|
||
"帮我把这张产品图做成高级电商海报",
|
||
"基于参考图延展 3 套同风格方案",
|
||
],
|
||
});
|
||
}}
|
||
rows={5}
|
||
placeholder={"让 Lovart 打造引人注目的社交媒体视觉\n帮我把这张产品图做成高级电商海报"}
|
||
/>
|
||
<div className="text-xs text-muted-foreground">
|
||
前端输入框将按此顺序循环打字显示,支持最多 20 条。
|
||
</div>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label htmlFor="lovartPromoText">顶部升级提示文案</Label>
|
||
<Input
|
||
id="lovartPromoText"
|
||
value={siteSettings.lovartPromoText}
|
||
onChange={(e) =>
|
||
setSiteSettings({ ...siteSettings, lovartPromoText: e.target.value })
|
||
}
|
||
placeholder="现在升级,立享 Kling 3.0 与 Nano Banana Pro,最高 5 折优惠"
|
||
/>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label htmlFor="lovartPromoActionText">顶部升级按钮文案</Label>
|
||
<Input
|
||
id="lovartPromoActionText"
|
||
value={siteSettings.lovartPromoActionText}
|
||
onChange={(e) =>
|
||
setSiteSettings({ ...siteSettings, lovartPromoActionText: e.target.value })
|
||
}
|
||
placeholder="立即升级"
|
||
/>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label htmlFor="lovartHeroTitle">首页主标题</Label>
|
||
<Input
|
||
id="lovartHeroTitle"
|
||
value={siteSettings.lovartHeroTitle}
|
||
onChange={(e) =>
|
||
setSiteSettings({ ...siteSettings, lovartHeroTitle: e.target.value })
|
||
}
|
||
placeholder="由壹点AI 让设计更简单"
|
||
/>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label htmlFor="lovartHeroSubtitle">首页副标题</Label>
|
||
<Input
|
||
id="lovartHeroSubtitle"
|
||
value={siteSettings.lovartHeroSubtitle}
|
||
onChange={(e) =>
|
||
setSiteSettings({ ...siteSettings, lovartHeroSubtitle: e.target.value })
|
||
}
|
||
placeholder="懂你的设计代理,帮你搞定一切"
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div className="border rounded-lg p-4 space-y-3">
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<Label className="text-sm font-medium">会员升级系统配置</Label>
|
||
<div className="text-xs text-muted-foreground mt-1">
|
||
支持配置套餐、FAQ、模型额度、用户套餐覆盖。保存后新版首页“升级”弹窗实时生效。
|
||
</div>
|
||
</div>
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={() => {
|
||
setMembershipConfigDraft(JSON.stringify(defaultMembershipConfig, null, 2));
|
||
setMembershipOverridesDraft(
|
||
JSON.stringify(defaultMembershipUserOverrides, null, 2)
|
||
);
|
||
toast.success("已恢复会员配置默认模板");
|
||
}}
|
||
>
|
||
恢复默认模板
|
||
</Button>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label htmlFor="membershipConfigDraft">会员配置 JSON</Label>
|
||
<Textarea
|
||
id="membershipConfigDraft"
|
||
value={membershipConfigDraft}
|
||
onChange={(e) => setMembershipConfigDraft(e.target.value)}
|
||
rows={12}
|
||
className="font-mono text-xs"
|
||
/>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label htmlFor="membershipOverridesDraft">用户套餐覆盖 JSON</Label>
|
||
<Textarea
|
||
id="membershipOverridesDraft"
|
||
value={membershipOverridesDraft}
|
||
onChange={(e) => setMembershipOverridesDraft(e.target.value)}
|
||
rows={7}
|
||
className="font-mono text-xs"
|
||
/>
|
||
<div className="text-xs text-muted-foreground">
|
||
可用字段:`userPlansById`(用户ID到planId)和 `userPlansByEmail`(邮箱到planId)。
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className="flex items-center justify-between space-x-2 rounded-lg border p-4">
|
||
<div className="space-y-0.5">
|
||
<Label htmlFor="showAutoModel">显示"自动"模型选项</Label>
|
||
<div className="text-sm text-muted-foreground">
|
||
开启后,用户可以选择"Agent模式"模型,系统将根据场景智能选择最佳模型
|
||
</div>
|
||
</div>
|
||
<Switch
|
||
id="showAutoModel"
|
||
checked={siteSettings.showAutoModel}
|
||
onCheckedChange={(checked) => setSiteSettings({ ...siteSettings, showAutoModel: checked })}
|
||
/>
|
||
</div>
|
||
<Separator />
|
||
<div className="space-y-4">
|
||
<div className="flex items-center justify-between space-x-2 rounded-lg border p-4">
|
||
<div className="space-y-0.5">
|
||
<Label htmlFor="usageTimeEnabled">启用使用时间限制</Label>
|
||
<div className="text-sm text-muted-foreground">
|
||
开启后,仅在指定时间段内允许使用系统,超出时间段输入框将被禁用
|
||
</div>
|
||
</div>
|
||
<Switch
|
||
id="usageTimeEnabled"
|
||
checked={siteSettings.usageTimeEnabled}
|
||
onCheckedChange={(checked) => setSiteSettings({ ...siteSettings, usageTimeEnabled: checked })}
|
||
/>
|
||
</div>
|
||
{siteSettings.usageTimeEnabled && (
|
||
<>
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<div className="space-y-2">
|
||
<Label htmlFor="usageTimeStart">开始时间</Label>
|
||
<Select
|
||
value={siteSettings.usageTimeStart}
|
||
onValueChange={(value) => setSiteSettings({ ...siteSettings, usageTimeStart: value })}
|
||
>
|
||
<SelectTrigger id="usageTimeStart">
|
||
<SelectValue placeholder="选择开始时间" />
|
||
</SelectTrigger>
|
||
<SelectContent className="max-h-[300px] overflow-y-auto">
|
||
{Array.from({ length: 24 }, (_, i) => {
|
||
const hour = String(i).padStart(2, '0');
|
||
return [
|
||
<SelectItem key={`${hour}:00`} value={`${hour}:00`}>{hour}:00</SelectItem>,
|
||
<SelectItem key={`${hour}:30`} value={`${hour}:30`}>{hour}:30</SelectItem>
|
||
];
|
||
}).flat()}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label htmlFor="usageTimeEnd">结束时间</Label>
|
||
<Select
|
||
value={siteSettings.usageTimeEnd}
|
||
onValueChange={(value) => setSiteSettings({ ...siteSettings, usageTimeEnd: value })}
|
||
>
|
||
<SelectTrigger id="usageTimeEnd">
|
||
<SelectValue placeholder="选择结束时间" />
|
||
</SelectTrigger>
|
||
<SelectContent className="max-h-[300px] overflow-y-auto">
|
||
{Array.from({ length: 24 }, (_, i) => {
|
||
const hour = String(i).padStart(2, '0');
|
||
return [
|
||
<SelectItem key={`${hour}:00`} value={`${hour}:00`}>{hour}:00</SelectItem>,
|
||
<SelectItem key={`${hour}:30`} value={`${hour}:30`}>{hour}:30</SelectItem>
|
||
];
|
||
}).flat()}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label htmlFor="usageTimeMessage">超时提示信息</Label>
|
||
<Textarea
|
||
id="usageTimeMessage"
|
||
value={siteSettings.usageTimeMessage}
|
||
onChange={(e) => setSiteSettings({ ...siteSettings, usageTimeMessage: e.target.value })}
|
||
placeholder="输入超时提示信息"
|
||
rows={2}
|
||
/>
|
||
<div className="text-xs text-muted-foreground">
|
||
当前设置:{siteSettings.usageTimeStart} - {siteSettings.usageTimeEnd}
|
||
</div>
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
{/* 广告横幅设置 */}
|
||
<div className="border-t pt-4">
|
||
<div className="flex items-center justify-between mb-4">
|
||
<div className="space-y-0.5">
|
||
<Label htmlFor="bannerEnabled">广告横幅</Label>
|
||
<div className="text-sm text-muted-foreground">
|
||
在无限画布等页面的聊天面板中显示广告横幅
|
||
</div>
|
||
</div>
|
||
<Switch
|
||
id="bannerEnabled"
|
||
checked={siteSettings.bannerEnabled}
|
||
onCheckedChange={(checked) => setSiteSettings({ ...siteSettings, bannerEnabled: checked })}
|
||
/>
|
||
</div>
|
||
{siteSettings.bannerEnabled && (
|
||
<>
|
||
<div className="space-y-2">
|
||
<Label htmlFor="bannerText">横幅文字</Label>
|
||
<Input
|
||
id="bannerText"
|
||
value={siteSettings.bannerText}
|
||
onChange={(e) => setSiteSettings({ ...siteSettings, bannerText: e.target.value })}
|
||
placeholder="输入横幅显示的文字"
|
||
/>
|
||
</div>
|
||
<div className="space-y-2 mt-3">
|
||
<Label htmlFor="bannerLink">横幅链接(可选)</Label>
|
||
<Input
|
||
id="bannerLink"
|
||
value={siteSettings.bannerLink || ""}
|
||
onChange={(e) => setSiteSettings({ ...siteSettings, bannerLink: e.target.value || null })}
|
||
placeholder="输入点击横幅后跳转的链接"
|
||
/>
|
||
<div className="text-xs text-muted-foreground">
|
||
如果不填写链接,点击横幅将不会跳转
|
||
</div>
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
|
||
{/* 无限画布设置 */}
|
||
<div className="border-t pt-4">
|
||
<div className="flex items-center justify-between mb-4">
|
||
<div className="space-y-0.5">
|
||
<Label htmlFor="canvasEnabled">无限画布功能</Label>
|
||
<div className="text-sm text-muted-foreground">
|
||
开启后,用户可以使用无限画布进行图片编辑和生成
|
||
</div>
|
||
</div>
|
||
<Switch
|
||
id="canvasEnabled"
|
||
checked={siteSettings.canvasEnabled}
|
||
onCheckedChange={(checked) => setSiteSettings({ ...siteSettings, canvasEnabled: checked })}
|
||
/>
|
||
</div>
|
||
{siteSettings.canvasEnabled && (
|
||
<>
|
||
<div className="space-y-2">
|
||
<Label htmlFor="canvasDefaultModelId">画布默认生图模型</Label>
|
||
<Select
|
||
value={siteSettings.canvasDefaultModelId || "auto"}
|
||
onValueChange={(value) => setSiteSettings({
|
||
...siteSettings,
|
||
canvasDefaultModelId: value === "auto" ? null : value
|
||
})}
|
||
>
|
||
<SelectTrigger id="canvasDefaultModelId">
|
||
<SelectValue placeholder="选择默认模型" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="auto">Agent模式(推荐)</SelectItem>
|
||
{modelsData?.models?.filter((m: any) => m.supportsImageGen).map((model: any) => (
|
||
<SelectItem key={model.id} value={model.id}>
|
||
{model.displayName}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
<div className="text-xs text-muted-foreground">
|
||
设置无限画布中用户的默认生图模型。"Agent模式"会根据用户需求自动选择最佳模型。
|
||
</div>
|
||
</div>
|
||
|
||
{/* Agent模式下的默认生图模型 */}
|
||
<div className="space-y-2 mt-4 pt-4 border-t">
|
||
<Label htmlFor="canvasAutoModeDefaultModelId">Agent模式 - 默认生图模型</Label>
|
||
<Select
|
||
value={siteSettings.canvasAutoModeDefaultModelId || "system"}
|
||
onValueChange={(value) => setSiteSettings({
|
||
...siteSettings,
|
||
canvasAutoModeDefaultModelId: value === "system" ? null : value
|
||
})}
|
||
>
|
||
<SelectTrigger id="canvasAutoModeDefaultModelId">
|
||
<SelectValue placeholder="选择默认模型" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="system">系统智能选择(根据意图路由)</SelectItem>
|
||
{modelsData?.models?.filter((m: any) => m.supportsImageGen).map((model: any) => (
|
||
<SelectItem key={model.id} value={model.id}>
|
||
{model.displayName}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
<div className="text-xs text-muted-foreground">
|
||
当用户选择"Agent模式"时,系统实际使用的生图模型。"系统智能选择"会根据用户意图(如抠图、换背景等)自动路由到最佳模型。
|
||
</div>
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
<Button onClick={handleSaveSiteSettings}>
|
||
<Save className="mr-2 h-4 w-4" /> 保存设置
|
||
</Button>
|
||
</CardContent>
|
||
</Card>
|
||
</TabsContent>
|
||
|
||
{/* 导航设置 */}
|
||
<TabsContent value="nav" className="mt-6 space-y-6">
|
||
{/* 工具箱名称设置 */}
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>工具箱设置</CardTitle>
|
||
<CardDescription>配置导航栏上的工具箱名称和显示状态</CardDescription>
|
||
</CardHeader>
|
||
<CardContent className="space-y-6">
|
||
{/* 工具箱1 */}
|
||
<div className="flex items-center justify-between rounded-lg border p-4">
|
||
<div className="flex-1 space-y-2">
|
||
<div className="flex items-center gap-4">
|
||
<Label className="text-base w-20">工具箱1</Label>
|
||
<Input
|
||
value={siteSettings.toolbox1Name}
|
||
onChange={(e) => setSiteSettings({ ...siteSettings, toolbox1Name: e.target.value })}
|
||
placeholder="工具箱名称"
|
||
className="max-w-[200px]"
|
||
/>
|
||
</div>
|
||
<div className="text-sm text-muted-foreground">
|
||
主工具箱,用于展示常用的外部链接
|
||
</div>
|
||
</div>
|
||
<Switch
|
||
checked={siteSettings.toolbox1Enabled}
|
||
onCheckedChange={(checked) => setSiteSettings({ ...siteSettings, toolbox1Enabled: checked })}
|
||
/>
|
||
</div>
|
||
|
||
{/* 工具箱2 */}
|
||
<div className="flex items-center justify-between rounded-lg border p-4">
|
||
<div className="flex-1 space-y-2">
|
||
<div className="flex items-center gap-4">
|
||
<Label className="text-base w-20">工具箱2</Label>
|
||
<Input
|
||
value={siteSettings.toolbox2Name}
|
||
onChange={(e) => setSiteSettings({ ...siteSettings, toolbox2Name: e.target.value })}
|
||
placeholder="工具箱2名称"
|
||
className="max-w-[200px]"
|
||
/>
|
||
</div>
|
||
<div className="text-sm text-muted-foreground">
|
||
第二个工具箱,可用于分类展示不同类型的链接
|
||
</div>
|
||
</div>
|
||
<Switch
|
||
checked={siteSettings.toolbox2Enabled}
|
||
onCheckedChange={(checked) => setSiteSettings({ ...siteSettings, toolbox2Enabled: checked })}
|
||
/>
|
||
</div>
|
||
|
||
{/* 精选榜设置 */}
|
||
<div className="flex items-center justify-between rounded-lg border p-4">
|
||
<div className="flex-1 space-y-1">
|
||
<Label className="text-base">精选榜</Label>
|
||
<div className="text-sm text-muted-foreground">
|
||
控制是否在导航栏显示精选榜按钮
|
||
</div>
|
||
</div>
|
||
<Switch
|
||
checked={siteSettings.rankingEnabled}
|
||
onCheckedChange={(checked) => setSiteSettings({ ...siteSettings, rankingEnabled: checked })}
|
||
/>
|
||
</div>
|
||
|
||
{/* 功能教程设置 */}
|
||
<div className="flex items-center justify-between rounded-lg border p-4">
|
||
<div className="flex-1 space-y-1">
|
||
<Label className="text-base">功能教程</Label>
|
||
<div className="text-sm text-muted-foreground">
|
||
启用后在导航栏显示功能教程按钮,点击后下拉显示视频列表
|
||
</div>
|
||
</div>
|
||
<Switch
|
||
checked={siteSettings.tutorialEnabled}
|
||
onCheckedChange={(checked) => setSiteSettings({ ...siteSettings, tutorialEnabled: checked })}
|
||
/>
|
||
</div>
|
||
|
||
<Button onClick={handleSaveSiteSettings}>
|
||
<Save className="mr-2 h-4 w-4" /> 保存导航设置
|
||
</Button>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* 视频教程管理 */}
|
||
{siteSettings.tutorialEnabled && (
|
||
<Card>
|
||
<CardHeader>
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<CardTitle className="flex items-center gap-2">
|
||
<Video className="h-5 w-5" />
|
||
视频教程管理
|
||
</CardTitle>
|
||
<CardDescription>添加和管理功能教程视频,用户可以在导航栏查看</CardDescription>
|
||
</div>
|
||
<Dialog open={showTutorialVideoDialog} onOpenChange={setShowTutorialVideoDialog}>
|
||
<DialogTrigger asChild>
|
||
<Button onClick={() => { resetTutorialVideoForm(); setShowTutorialVideoDialog(true); }}>
|
||
<Plus className="mr-2 h-4 w-4" /> 添加视频
|
||
</Button>
|
||
</DialogTrigger>
|
||
<DialogContent className="max-w-2xl">
|
||
<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 />
|
||
<div className="space-y-3">
|
||
<h3 className="text-lg font-semibold">Google 登录</h3>
|
||
<p className="text-sm text-muted-foreground">
|
||
通过 Google OAuth 登录,可直接在这里配置 Google 凭据与 AUTH_URL。
|
||
</p>
|
||
<div className="grid grid-cols-1 gap-3 rounded-md border p-3 md:grid-cols-2">
|
||
<div className="space-y-1">
|
||
<Label htmlFor="googleClientId">Google Client ID</Label>
|
||
<Input
|
||
id="googleClientId"
|
||
value={authSettings.googleClientId || ""}
|
||
onChange={(e) =>
|
||
setAuthSettings({ ...authSettings, googleClientId: e.target.value })
|
||
}
|
||
placeholder="Google OAuth Client ID"
|
||
/>
|
||
</div>
|
||
<div className="space-y-1">
|
||
<Label htmlFor="googleClientSecret">Google Client Secret</Label>
|
||
<Input
|
||
id="googleClientSecret"
|
||
type="password"
|
||
value={authSettings.googleClientSecret || ""}
|
||
onChange={(e) =>
|
||
setAuthSettings({ ...authSettings, googleClientSecret: e.target.value })
|
||
}
|
||
placeholder="Google OAuth Client Secret"
|
||
/>
|
||
</div>
|
||
<div className="space-y-1 md:col-span-2">
|
||
<Label htmlFor="authUrl">AUTH_URL(站点地址)</Label>
|
||
<Input
|
||
id="authUrl"
|
||
value={authSettings.authUrl || ""}
|
||
onChange={(e) =>
|
||
setAuthSettings({ ...authSettings, authUrl: e.target.value })
|
||
}
|
||
placeholder="https://your-domain.com"
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div className="flex items-center justify-between rounded-md border p-3">
|
||
<div className="space-y-1">
|
||
<Label htmlFor="googleEnabled">启用 Google 登录</Label>
|
||
<p className="text-xs text-muted-foreground">
|
||
当前服务器配置状态:{isGoogleConfigured ? "已配置" : "未配置"}
|
||
</p>
|
||
</div>
|
||
<Switch
|
||
id="googleEnabled"
|
||
checked={authSettings.isGoogleEnabled}
|
||
onCheckedChange={(checked) =>
|
||
setAuthSettings({ ...authSettings, isGoogleEnabled: checked })
|
||
}
|
||
/>
|
||
</div>
|
||
{!isGoogleConfigured ? (
|
||
<p className="text-xs text-amber-600">
|
||
未检测到完整 Google 配置(Client ID / Client Secret)。
|
||
</p>
|
||
) : null}
|
||
</div>
|
||
<Separator />
|
||
<Button onClick={handleSaveAuthSettings}>
|
||
<Save className="mr-2 h-4 w-4" /> 保存认证配置
|
||
</Button>
|
||
</CardContent>
|
||
</Card>
|
||
</TabsContent>
|
||
|
||
{/* 系统通知设置 */}
|
||
<TabsContent value="notification" className="mt-6">
|
||
<Card>
|
||
<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((prev) => ({
|
||
...prev,
|
||
type: value as any,
|
||
baseUrl:
|
||
!prev.baseUrl && value === "ruxa-image"
|
||
? "https://api.ruxa.ai"
|
||
: !prev.baseUrl && value === "zhipu"
|
||
? "https://open.bigmodel.cn/api/paas/v4"
|
||
: prev.baseUrl,
|
||
}))
|
||
}
|
||
>
|
||
<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 })}
|
||
placeholder={
|
||
providerForm.type === "ruxa-image"
|
||
? "https://api.ruxa.ai"
|
||
: providerForm.type === "zhipu"
|
||
? "https://open.bigmodel.cn/api/paas/v4"
|
||
: "https://api.openai.com/v1"
|
||
}
|
||
/>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label>API Key</Label>
|
||
<div className="relative">
|
||
<Input
|
||
type={showApiKey ? "text" : "password"}
|
||
value={providerForm.apiKey}
|
||
onChange={(e) => setProviderForm({ ...providerForm, apiKey: e.target.value })}
|
||
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>
|
||
<div className="flex items-center gap-2">
|
||
<Switch
|
||
checked={modelForm.isAgentModel}
|
||
onCheckedChange={(checked) => setModelForm({ ...modelForm, isAgentModel: checked })}
|
||
/>
|
||
<Label>Agent 自动模型(前端隐藏,仅后台 Agent 使用)</Label>
|
||
</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>}
|
||
{model.isAgentModel && <span className="text-xs bg-violet-100 text-violet-800 px-2 py-0.5 rounded">Agent</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,
|
||
isAgentModel: model.isAgentModel || false,
|
||
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>• 图片大小:不超过50MB,不限制分辨率(建议按业务需求控制尺寸)</p>
|
||
<p>• 返回的链接有效期为1小时,系统会自动下载并存储到S3</p>
|
||
</div>
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
{/* 备用API配置 (Real-ESRGAN) */}
|
||
{enhanceConfig.useBackupApi && (
|
||
<>
|
||
<Separator />
|
||
<div className="text-sm font-medium text-muted-foreground">备用API配置 (Real-ESRGAN 本地模型)</div>
|
||
<div className="space-y-2">
|
||
<Label htmlFor="backupApiUrl">API地址 *</Label>
|
||
<Input
|
||
id="backupApiUrl"
|
||
value={enhanceConfig.backupApiUrl}
|
||
onChange={(e) => setEnhanceConfig({ ...enhanceConfig, backupApiUrl: e.target.value })}
|
||
placeholder="http://156.225.27.52:5233"
|
||
/>
|
||
<div className="text-xs text-muted-foreground">
|
||
Real-ESRGAN API服务地址,默认: http://156.225.27.52:5233
|
||
</div>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label htmlFor="backupApiKey">API密钥 *</Label>
|
||
<Input
|
||
id="backupApiKey"
|
||
type="password"
|
||
value={enhanceConfig.backupApiKey}
|
||
onChange={(e) => setEnhanceConfig({ ...enhanceConfig, backupApiKey: e.target.value })}
|
||
placeholder="请输入Real-ESRGAN API密钥"
|
||
/>
|
||
<div className="text-xs text-muted-foreground">
|
||
API密钥用于认证,请从管理员处获取
|
||
</div>
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<div className="space-y-2">
|
||
<Label htmlFor="backupScale">放大倍数</Label>
|
||
<Select
|
||
value={enhanceConfig.backupScale}
|
||
onValueChange={(value) => setEnhanceConfig({ ...enhanceConfig, backupScale: value })}
|
||
>
|
||
<SelectTrigger id="backupScale">
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="2">2倍放大</SelectItem>
|
||
<SelectItem value="4">4倍放大(推荐)</SelectItem>
|
||
<SelectItem value="8">8倍放大</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
<div className="text-xs text-muted-foreground">
|
||
推荐使用4倍放大,效果最佳
|
||
</div>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label htmlFor="backupModel">模型选择</Label>
|
||
<Select
|
||
value={enhanceConfig.backupModel}
|
||
onValueChange={(value) => setEnhanceConfig({ ...enhanceConfig, backupModel: value })}
|
||
>
|
||
<SelectTrigger id="backupModel">
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="RealESRGAN_x2plus">RealESRGAN x2plus (2倍)</SelectItem>
|
||
<SelectItem value="RealESRGAN_x4plus">RealESRGAN x4plus (推荐)</SelectItem>
|
||
<SelectItem value="RealESRGAN_x8plus">RealESRGAN x8plus (8倍)</SelectItem>
|
||
<SelectItem value="RealESRGAN_animevideov3">动漫视频模型 (4倍)</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
<div className="text-xs text-muted-foreground">
|
||
不同模型适用于不同场景
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className="rounded-lg border border-green-200 bg-green-50 p-4 space-y-2">
|
||
<div className="text-sm font-medium text-green-900">Real-ESRGAN API说明</div>
|
||
<div className="text-xs text-green-800 space-y-1">
|
||
<p>• 基于Real-ESRGAN深度学习模型,图像超分辨率效果更好</p>
|
||
<p>• 使用URL方式上传图片,处理后自动保存到S3存储</p>
|
||
<p>• 支持2x、4x、8x放大倍数</p>
|
||
<p>• 处理时间:通常1-10秒,取决于图片大小</p>
|
||
<p>• 支持格式:JPG, PNG, JPEG等常见图片格式</p>
|
||
<p>• 文件限制:最大50MB</p>
|
||
</div>
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
<Button onClick={handleSaveEnhanceConfig}>
|
||
<Save className="mr-2 h-4 w-4" /> 保存配置
|
||
</Button>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* 图像拓展API配置 */}
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>图像拓展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", "ruxa-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", "ruxa-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>
|
||
);
|
||
}
|