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