3058 lines
128 KiB
TypeScript
3058 lines
128 KiB
TypeScript
"use client";
|
||
|
||
import { useState, useEffect } from "react";
|
||
import useSWR from "swr";
|
||
import { toast } from "sonner";
|
||
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,
|
||
} from "lucide-react";
|
||
import {
|
||
Dialog,
|
||
DialogContent,
|
||
DialogDescription,
|
||
DialogFooter,
|
||
DialogHeader,
|
||
DialogTitle,
|
||
DialogTrigger,
|
||
} from "@/components/ui/dialog";
|
||
import { Textarea } from "@/components/ui/textarea";
|
||
import { Switch } from "@/components/ui/switch";
|
||
|
||
const fetcher = (url: string) => fetch(url).then((res) => res.json());
|
||
|
||
// 类型定义
|
||
type User = {
|
||
id: string;
|
||
email: string;
|
||
name: string | null;
|
||
department: string | null;
|
||
role: "guest" | "regular" | "admin";
|
||
isActive: boolean;
|
||
monthlyImageLimit: number | null;
|
||
createdAt: string;
|
||
};
|
||
|
||
const ROLE_LABELS: Record<User["role"], string> = {
|
||
guest: "访客用户",
|
||
regular: "普通用户",
|
||
admin: "管理员",
|
||
};
|
||
|
||
type ToolConfig = {
|
||
toolName: string;
|
||
isEnabled: boolean;
|
||
description?: string;
|
||
config?: any;
|
||
};
|
||
|
||
type AiProvider = {
|
||
id: string;
|
||
name: string;
|
||
type: "alibaba" | "openai" | "custom" | "local" | "gemini" | "qianwen-video" | "volcano-vision" | "volcano-video";
|
||
baseUrl: string;
|
||
apiKey: string;
|
||
isEnabled: boolean;
|
||
createdAt: string;
|
||
};
|
||
|
||
type AiModel = {
|
||
id: string;
|
||
providerId: string;
|
||
modelId: string;
|
||
displayName: string;
|
||
description?: string;
|
||
supportsVision: boolean;
|
||
supportsImageGen: boolean;
|
||
isEnabled: boolean;
|
||
isDefault: boolean;
|
||
sortOrder: number;
|
||
};
|
||
|
||
type StorageConfig = {
|
||
id: string;
|
||
name: string;
|
||
type: "s3" | "local";
|
||
endpoint?: string;
|
||
region?: string;
|
||
bucket?: string;
|
||
accessKeyId?: string;
|
||
secretAccessKey?: string;
|
||
publicUrl?: string;
|
||
isDefault: boolean;
|
||
isEnabled: boolean;
|
||
};
|
||
|
||
type SiteSettings = {
|
||
siteName: string;
|
||
siteLogo: string | null;
|
||
siteLogoDark: string | null;
|
||
favicon: string | null;
|
||
showAutoModel: boolean;
|
||
usageTimeEnabled: boolean;
|
||
usageTimeStart: string;
|
||
usageTimeEnd: string;
|
||
usageTimeMessage: string;
|
||
};
|
||
|
||
type NavLink = {
|
||
id: string;
|
||
name: string;
|
||
url: string;
|
||
order: number;
|
||
isEnabled: boolean;
|
||
};
|
||
|
||
type DingTalkOrganization = {
|
||
id: string;
|
||
name: string;
|
||
appKey: string;
|
||
appSecret: string;
|
||
};
|
||
|
||
type AuthSettings = {
|
||
// 保留单一配置用于兼容旧数据(后端会自动同步为第一个组织)
|
||
dingTalkAppKey?: string | null;
|
||
dingTalkAppSecret?: string | null;
|
||
isDingTalkEnabled: boolean;
|
||
isRegistrationEnabled?: boolean;
|
||
dingTalkOrganizations: DingTalkOrganization[];
|
||
};
|
||
|
||
type GeneratedImageRecord = {
|
||
id: string;
|
||
userId: string;
|
||
userName: string | null;
|
||
userEmail: string;
|
||
userDepartment: string | null;
|
||
thumbnailUrl: string;
|
||
originalUrl: string;
|
||
imageSize: string | null;
|
||
prompt: string | null;
|
||
modelId: string | null;
|
||
isFeatured: boolean;
|
||
featuredAt: string | null;
|
||
createdAt: string;
|
||
chatId: string | null;
|
||
};
|
||
|
||
type ImageAnalytics = {
|
||
overview: {
|
||
totalImages: number;
|
||
totalUsers: number;
|
||
todayImages: number;
|
||
todayUsers: number;
|
||
weekImages: number;
|
||
weekUsers: number;
|
||
monthImages: number;
|
||
monthUsers: number;
|
||
};
|
||
userStats: Array<{
|
||
userId: string;
|
||
userName: string | null;
|
||
userEmail: string;
|
||
imageCount: number;
|
||
}>;
|
||
dailyStats: Array<{ date: string; count: number }>;
|
||
sizeStats: Array<{ imageSize: string | null; count: number }>;
|
||
modelStats: Array<{ modelId: string | null; count: number }>;
|
||
modelDetailStats: Array<{
|
||
modelId: string | null;
|
||
count: number;
|
||
userCount: number;
|
||
modelName: string | null;
|
||
providerName: string | null;
|
||
providerType: string | null;
|
||
}>;
|
||
providerTypeStats: Array<{
|
||
providerType: string | null;
|
||
providerName: string | null;
|
||
count: number;
|
||
}>;
|
||
modelTrendStats: Array<{
|
||
date: string;
|
||
modelId: string | null;
|
||
count: number;
|
||
}>;
|
||
monthlyStats: Array<{ month: string; count: number }>;
|
||
hourlyStats: Array<{ hour: number; count: number }>;
|
||
};
|
||
|
||
const AVAILABLE_TOOLS = [
|
||
{ name: "getWeather", description: "获取天气信息" },
|
||
{ name: "createDocument", description: "创建文档" },
|
||
{ name: "updateDocument", description: "更新文档" },
|
||
{ name: "requestSuggestions", description: "请求文档建议" },
|
||
];
|
||
|
||
const PROVIDER_TYPES = [
|
||
{ value: "alibaba", label: "阿里云通义千问" },
|
||
{ value: "openai", label: "OpenAI 兼容" },
|
||
{ value: "custom", label: "自定义 OpenAI 兼容" },
|
||
{ value: "local", label: "本地模型" },
|
||
{ value: "gemini", label: "Gemini中转" },
|
||
{ value: "qianwen-video", label: "千问视频生成" },
|
||
{ value: "volcano-vision", label: "火山引擎视觉" },
|
||
{ value: "volcano-video", label: "火山引擎视频" },
|
||
];
|
||
|
||
export function AdminSettingsClient() {
|
||
const [activeTab, setActiveTab] = useState("users");
|
||
|
||
// 用户管理状态
|
||
const [userSearch, setUserSearch] = useState("");
|
||
const [userPage, setUserPage] = useState(1);
|
||
const [userLimit] = useState(10);
|
||
|
||
// 批量设置出图限额状态
|
||
const [showBatchLimitDialog, setShowBatchLimitDialog] = useState(false);
|
||
const [batchLimitType, setBatchLimitType] = useState<"all" | "department" | "no_department">("all");
|
||
const [batchLimitValue, setBatchLimitValue] = useState<string>("");
|
||
const [batchLimitDepartment, setBatchLimitDepartment] = useState<string>("");
|
||
|
||
// 🆕 生图记录状态
|
||
const [imageSearch, setImageSearch] = useState("");
|
||
const [imagePage, setImagePage] = useState(1);
|
||
const [imageStartDate, setImageStartDate] = useState("");
|
||
const [imageEndDate, setImageEndDate] = useState("");
|
||
const [selectedImage, setSelectedImage] = useState<GeneratedImageRecord | null>(null);
|
||
|
||
// 🆕 数据分析状态
|
||
const [selectedMonth, setSelectedMonth] = useState(() => {
|
||
const now = new Date();
|
||
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
|
||
});
|
||
const [selectedDate, setSelectedDate] = useState<string | null>(null); // 选中的日期,用于联动高峰时段
|
||
const [hoveredBar, setHoveredBar] = useState<{ type: string; index: number; value: number; label: string } | null>(null);
|
||
|
||
// API 数据获取
|
||
const { data: usersData, mutate: mutateUsers } = useSWR<{
|
||
users: User[];
|
||
total: number;
|
||
page: number;
|
||
totalPages: number;
|
||
}>(
|
||
`/api/admin/users?page=${userPage}&limit=${userLimit}&search=${encodeURIComponent(userSearch)}`,
|
||
fetcher
|
||
);
|
||
|
||
// 获取部门列表(用于批量设置)
|
||
const { data: departmentsData } = useSWR<{ departments: string[] }>(
|
||
"/api/admin/users/batch-limit",
|
||
fetcher
|
||
);
|
||
const { data: providersData, mutate: mutateProviders } = useSWR<{ providers: AiProvider[] }>("/api/admin/ai-providers", fetcher);
|
||
const { data: modelsData, mutate: mutateModels } = useSWR<{ models: AiModel[] }>("/api/admin/ai-models", fetcher);
|
||
const { data: storageData, mutate: mutateStorage } = useSWR<{ configs: StorageConfig[] }>("/api/admin/storage", fetcher);
|
||
const { data: siteSettingsData, mutate: mutateSiteSettings } = useSWR<SiteSettings>("/api/site-settings", fetcher);
|
||
const { data: navLinksData, mutate: mutateNavLinks } = useSWR<{ links: NavLink[] }>("/api/nav-links", fetcher);
|
||
const { data: authSettingsData, mutate: mutateAuthSettings } = useSWR<any>(
|
||
"/api/auth-settings",
|
||
fetcher,
|
||
);
|
||
|
||
// 🆕 生图记录数据
|
||
const imageQueryParams = new URLSearchParams({
|
||
page: imagePage.toString(),
|
||
pageSize: "200",
|
||
...(imageSearch && { search: imageSearch }),
|
||
...(imageStartDate && { startDate: imageStartDate }),
|
||
...(imageEndDate && { endDate: imageEndDate }),
|
||
});
|
||
const { data: imagesData, mutate: mutateImages } = useSWR<{
|
||
images: GeneratedImageRecord[];
|
||
pagination: { page: number; pageSize: number; total: number; totalPages: number };
|
||
}>(`/api/admin/images?${imageQueryParams}`, fetcher);
|
||
|
||
// 🆕 生图数据分析
|
||
const analyticsUrl = activeTab === "analytics"
|
||
? `/api/admin/images/analytics?month=${selectedMonth}${selectedDate ? `&date=${selectedDate}` : ""}`
|
||
: null;
|
||
const { data: analyticsData } = useSWR<ImageAnalytics>(analyticsUrl, fetcher);
|
||
|
||
// 网站设置状态
|
||
const [siteSettings, setSiteSettings] = useState<SiteSettings>({
|
||
siteName: "AI Chatbot",
|
||
siteLogo: "",
|
||
siteLogoDark: "",
|
||
favicon: "",
|
||
showAutoModel: true,
|
||
usageTimeEnabled: false,
|
||
usageTimeStart: "08:00",
|
||
usageTimeEnd: "20:00",
|
||
usageTimeMessage: "由壹点累了也是需要休息的",
|
||
});
|
||
|
||
useEffect(() => {
|
||
if (siteSettingsData) {
|
||
setSiteSettings({
|
||
siteName: siteSettingsData.siteName || "AI Chatbot",
|
||
siteLogo: siteSettingsData.siteLogo || "",
|
||
siteLogoDark: siteSettingsData.siteLogoDark || "",
|
||
favicon: siteSettingsData.favicon || "",
|
||
showAutoModel: siteSettingsData.showAutoModel ?? true,
|
||
usageTimeEnabled: siteSettingsData.usageTimeEnabled ?? false,
|
||
usageTimeStart: siteSettingsData.usageTimeStart || "08:00",
|
||
usageTimeEnd: siteSettingsData.usageTimeEnd || "20:00",
|
||
usageTimeMessage: siteSettingsData.usageTimeMessage || "由壹点累了也是需要休息的",
|
||
});
|
||
}
|
||
}, [siteSettingsData]);
|
||
|
||
// 抠图API配置状态
|
||
const [rmbgConfig, setRmbgConfig] = useState({
|
||
apiUrl: "",
|
||
apiKey: "",
|
||
});
|
||
|
||
// 加载抠图API配置
|
||
const { data: rmbgConfigData } = useSWR("/api/admin/settings?key=rmbg_api_config", fetcher);
|
||
useEffect(() => {
|
||
if (rmbgConfigData?.setting?.value) {
|
||
const config = rmbgConfigData.setting.value as { apiUrl?: string; apiKey?: string };
|
||
setRmbgConfig({
|
||
apiUrl: config.apiUrl || "",
|
||
apiKey: config.apiKey || "",
|
||
});
|
||
}
|
||
}, [rmbgConfigData]);
|
||
|
||
// 导航链接状态
|
||
const [navLinkForm, setNavLinkForm] = useState({
|
||
id: "",
|
||
name: "",
|
||
url: "",
|
||
order: 0,
|
||
isEnabled: true,
|
||
});
|
||
const [showNavLinkDialog, setShowNavLinkDialog] = useState(false);
|
||
const [editingNavLink, setEditingNavLink] = useState<string | null>(null);
|
||
|
||
// 快捷短语状态
|
||
const [questions, setQuestions] = useState<string[]>([]);
|
||
const { data: questionsData, mutate: mutateQuestions } = useSWR<string[]>("/api/starter-questions", fetcher);
|
||
|
||
// 系统通知状态
|
||
const [notification, setNotification] = useState<{ enabled: boolean; title: string; content: string }>({
|
||
enabled: false,
|
||
title: "",
|
||
content: "",
|
||
});
|
||
|
||
useEffect(() => {
|
||
fetch("/api/admin/settings?key=login_notification")
|
||
.then((res) => res.json())
|
||
.then((data) => {
|
||
if (data.setting?.value) {
|
||
setNotification(data.setting.value);
|
||
}
|
||
});
|
||
}, []);
|
||
|
||
const handleSaveNotification = async () => {
|
||
try {
|
||
await fetch("/api/admin/settings", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
key: "login_notification",
|
||
value: notification,
|
||
}),
|
||
});
|
||
toast.success("系统通知已保存");
|
||
} catch (error) {
|
||
toast.error("保存失败");
|
||
}
|
||
};
|
||
|
||
useEffect(() => {
|
||
if (questionsData) {
|
||
setQuestions(questionsData);
|
||
}
|
||
}, [questionsData]);
|
||
|
||
const handleQuestionChange = (index: number, value: string) => {
|
||
const newQuestions = [...questions];
|
||
newQuestions[index] = value;
|
||
setQuestions(newQuestions);
|
||
};
|
||
|
||
const handleAddQuestion = () => {
|
||
setQuestions([...questions, ""]);
|
||
};
|
||
|
||
const handleRemoveQuestion = (index: number) => {
|
||
const newQuestions = questions.filter((_, i) => i !== index);
|
||
setQuestions(newQuestions);
|
||
};
|
||
|
||
const handleSaveQuestions = async () => {
|
||
try {
|
||
const res = await fetch("/api/starter-questions", {
|
||
method: "PUT",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(questions.filter(q => q.trim() !== "")),
|
||
});
|
||
if (!res.ok) throw new Error("Failed");
|
||
toast.success("快捷短语已更新");
|
||
mutateQuestions();
|
||
} catch (error) {
|
||
toast.error("更新失败");
|
||
}
|
||
};
|
||
|
||
// 认证设置状态
|
||
const [authSettings, setAuthSettings] = useState<AuthSettings>({
|
||
dingTalkAppKey: "",
|
||
dingTalkAppSecret: "",
|
||
isDingTalkEnabled: false,
|
||
isRegistrationEnabled: true,
|
||
dingTalkOrganizations: [],
|
||
});
|
||
|
||
useEffect(() => {
|
||
if (!authSettingsData) return;
|
||
|
||
const organizations: DingTalkOrganization[] =
|
||
(authSettingsData.dingTalkOrganizations as DingTalkOrganization[] | null) ?? [];
|
||
|
||
// 如果没有多组织配置,但有单一 AppKey/Secret,则自动构造一个默认组织
|
||
const normalizedOrgs =
|
||
organizations.length > 0 ||
|
||
!authSettingsData.dingTalkAppKey
|
||
? organizations
|
||
: [
|
||
{
|
||
id: "default",
|
||
name: "默认组织",
|
||
appKey: authSettingsData.dingTalkAppKey ?? "",
|
||
appSecret: authSettingsData.dingTalkAppSecret ?? "",
|
||
},
|
||
];
|
||
|
||
setAuthSettings({
|
||
dingTalkAppKey: authSettingsData.dingTalkAppKey || "",
|
||
dingTalkAppSecret: authSettingsData.dingTalkAppSecret || "",
|
||
isDingTalkEnabled: authSettingsData.isDingTalkEnabled || false,
|
||
isRegistrationEnabled: authSettingsData.isRegistrationEnabled ?? true,
|
||
dingTalkOrganizations: normalizedOrgs,
|
||
});
|
||
}, [authSettingsData]);
|
||
|
||
// AI 提供商 & 模型状态
|
||
const [providerForm, setProviderForm] = useState({
|
||
id: "",
|
||
name: "",
|
||
type: "openai" as "alibaba" | "openai" | "custom" | "local" | "gemini" | "qianwen-video" | "volcano-vision" | "volcano-video",
|
||
baseUrl: "",
|
||
apiKey: "",
|
||
isEnabled: true,
|
||
});
|
||
const [showProviderDialog, setShowProviderDialog] = useState(false);
|
||
const [editingProvider, setEditingProvider] = useState<string | null>(null);
|
||
const [showApiKey, setShowApiKey] = useState(false);
|
||
|
||
const [modelForm, setModelForm] = useState({
|
||
id: "",
|
||
providerId: "",
|
||
modelId: "",
|
||
displayName: "",
|
||
description: "",
|
||
supportsVision: false,
|
||
supportsImageGen: false,
|
||
isEnabled: true,
|
||
isDefault: false,
|
||
sortOrder: 0,
|
||
fallbackModelId1: "", // 🆕 备用模型 ID 1
|
||
fallbackModelId2: "", // 🆕 备用模型 ID 2
|
||
});
|
||
const [showModelDialog, setShowModelDialog] = useState(false);
|
||
const [editingModel, setEditingModel] = useState<string | null>(null);
|
||
|
||
// 🆕 火山引擎视频多模型表单(动态从API加载)
|
||
const [volcanoVideoModels, setVolcanoVideoModels] = useState<Array<{
|
||
modelId: string;
|
||
displayName: string;
|
||
description: string;
|
||
}>>([]);
|
||
|
||
// 🆕 当选择火山引擎视频Provider时,从API加载预设模型配置
|
||
useEffect(() => {
|
||
const selectedProvider = providersData?.providers?.find(p => p.id === modelForm.providerId);
|
||
if (selectedProvider?.type === 'volcano-video' && volcanoVideoModels.length === 0) {
|
||
// 从API获取预设模型配置(✅ 显示所有模型供管理员自定义)
|
||
fetch('/api/admin/volcano-video-models')
|
||
.then(res => res.json())
|
||
.then(data => {
|
||
if (data.presets) {
|
||
// ✅ 显示主模型和所有子模型,允许管理员自定义显示名称和描述
|
||
const allModels = data.presets.map((preset: any) => ({
|
||
modelId: preset.modelId,
|
||
displayName: preset.displayName,
|
||
description: preset.description,
|
||
isSubModel: preset.isSubModel || false, // 标记是否为子模型
|
||
}));
|
||
setVolcanoVideoModels(allModels);
|
||
}
|
||
})
|
||
.catch(err => {
|
||
console.error('[Load Volcano Models] Error:', err);
|
||
toast.error('加载预设模型配置失败');
|
||
});
|
||
}
|
||
}, [modelForm.providerId, providersData?.providers, volcanoVideoModels.length]);
|
||
|
||
// 存储配置状态
|
||
const [storageForm, setStorageForm] = useState({
|
||
id: "",
|
||
name: "",
|
||
type: "s3" as "s3" | "local",
|
||
endpoint: "",
|
||
region: "",
|
||
bucket: "",
|
||
accessKeyId: "",
|
||
secretAccessKey: "",
|
||
publicUrl: "",
|
||
isDefault: false,
|
||
isEnabled: true,
|
||
});
|
||
const [showStorageDialog, setShowStorageDialog] = useState(false);
|
||
const [editingStorage, setEditingStorage] = useState<string | null>(null);
|
||
|
||
// --- 用户管理逻辑 ---
|
||
const handleUserSearch = (search: string) => {
|
||
setUserSearch(search);
|
||
setUserPage(1); // 搜索时重置到第一页
|
||
};
|
||
|
||
const handleUpdateUser = async (userId: string, updates: any) => {
|
||
try {
|
||
const response = await fetch("/api/admin/users", {
|
||
method: "PATCH",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ id: userId, ...updates }),
|
||
});
|
||
if (!response.ok) throw new Error("更新失败");
|
||
toast.success("用户更新成功");
|
||
mutateUsers();
|
||
} catch (error) {
|
||
toast.error("更新用户失败");
|
||
}
|
||
};
|
||
|
||
const handleDeleteUser = async (userId: string) => {
|
||
if (!confirm("确定要删除此用户吗?")) return;
|
||
try {
|
||
const response = await fetch(`/api/admin/users?id=${userId}`, { method: "DELETE" });
|
||
if (!response.ok) throw new Error("删除失败");
|
||
toast.success("用户删除成功");
|
||
mutateUsers();
|
||
} catch (error) {
|
||
toast.error("删除用户失败");
|
||
}
|
||
};
|
||
|
||
// 批量设置出图限额
|
||
const handleBatchSetLimit = async () => {
|
||
try {
|
||
const response = await fetch("/api/admin/users/batch-limit", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
type: batchLimitType,
|
||
limit: batchLimitValue === "" ? null : parseInt(batchLimitValue, 10),
|
||
department: batchLimitDepartment,
|
||
}),
|
||
});
|
||
|
||
const data = await response.json();
|
||
|
||
if (!response.ok) {
|
||
throw new Error(data.error || "批量设置失败");
|
||
}
|
||
|
||
toast.success(data.message);
|
||
setShowBatchLimitDialog(false);
|
||
setBatchLimitValue("");
|
||
setBatchLimitDepartment("");
|
||
mutateUsers();
|
||
} catch (error) {
|
||
toast.error(error instanceof Error ? error.message : "批量设置失败");
|
||
}
|
||
};
|
||
|
||
// --- 网站设置逻辑 ---
|
||
const handleSaveSiteSettings = async () => {
|
||
try {
|
||
const response = await fetch("/api/site-settings", {
|
||
method: "PUT",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(siteSettings),
|
||
});
|
||
if (!response.ok) throw new Error("保存失败");
|
||
toast.success("网站设置保存成功");
|
||
mutateSiteSettings();
|
||
} catch (error) {
|
||
toast.error("保存网站设置失败");
|
||
}
|
||
};
|
||
|
||
// --- 抠图API配置逻辑 ---
|
||
const handleSaveRmbgConfig = async () => {
|
||
try {
|
||
if (!rmbgConfig.apiUrl.trim()) {
|
||
toast.error("请填写API地址");
|
||
return;
|
||
}
|
||
|
||
const response = await fetch("/api/admin/settings", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
key: "rmbg_api_config",
|
||
value: {
|
||
apiUrl: rmbgConfig.apiUrl.trim(),
|
||
apiKey: rmbgConfig.apiKey.trim() || undefined,
|
||
},
|
||
}),
|
||
});
|
||
|
||
if (!response.ok) throw new Error("保存失败");
|
||
toast.success("抠图API配置保存成功");
|
||
} catch (error) {
|
||
toast.error("保存抠图API配置失败");
|
||
}
|
||
};
|
||
|
||
// --- 导航链接逻辑 ---
|
||
const resetNavLinkForm = () => {
|
||
setNavLinkForm({ id: "", name: "", url: "", order: 0, isEnabled: true });
|
||
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 handleSaveAuthSettings = async () => {
|
||
try {
|
||
const response = await fetch("/api/auth-settings", {
|
||
method: "PUT",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(authSettings),
|
||
});
|
||
if (!response.ok) throw new Error("保存失败");
|
||
toast.success("认证设置保存成功");
|
||
mutateAuthSettings();
|
||
} catch (error) {
|
||
toast.error("保存认证设置失败");
|
||
}
|
||
};
|
||
|
||
// --- AI 提供商逻辑 ---
|
||
const resetProviderForm = () => {
|
||
setProviderForm({
|
||
id: "",
|
||
name: "",
|
||
type: "openai",
|
||
baseUrl: "",
|
||
apiKey: "",
|
||
isEnabled: true,
|
||
});
|
||
setEditingProvider(null);
|
||
};
|
||
|
||
const handleSaveProvider = async () => {
|
||
try {
|
||
const method = editingProvider ? "PUT" : "POST";
|
||
const isNewProvider = !editingProvider;
|
||
const isVolcanoVideo = providerForm.type === 'volcano-video';
|
||
|
||
let requestData: any = editingProvider ? { ...providerForm, id: editingProvider } : providerForm;
|
||
if (editingProvider && !providerForm.apiKey) {
|
||
const { apiKey, ...restData } = requestData;
|
||
requestData = restData;
|
||
}
|
||
|
||
const response = await fetch("/api/admin/ai-providers", {
|
||
method,
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(requestData),
|
||
});
|
||
|
||
if (!response.ok) throw new Error("保存失败");
|
||
|
||
const data = await response.json();
|
||
const providerId = data.provider?.id || editingProvider;
|
||
|
||
toast.success(editingProvider ? "提供商更新成功" : "提供商创建成功");
|
||
mutateProviders();
|
||
setShowProviderDialog(false);
|
||
resetProviderForm();
|
||
|
||
// 🆕 如果是新创建的火山引擎视频Provider,自动添加预设模型
|
||
if (isNewProvider && isVolcanoVideo && providerId) {
|
||
toast.info("正在自动添加预设模型...");
|
||
|
||
try {
|
||
const modelsResponse = await fetch("/api/admin/volcano-video-models", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ providerId }),
|
||
});
|
||
|
||
const modelsData = await modelsResponse.json();
|
||
|
||
if (modelsResponse.ok && modelsData.addedCount > 0) {
|
||
toast.success(`✅ 已自动添加 ${modelsData.addedCount} 个预设模型!`);
|
||
mutateModels(); // 刷新模型列表
|
||
}
|
||
} catch (modelError) {
|
||
console.error('[Auto Add Models] Error:', modelError);
|
||
toast.warning('预设模型添加失败,请手动点击"快速添加预设模型"按钮');
|
||
}
|
||
}
|
||
} catch (error) {
|
||
toast.error("保存提供商失败");
|
||
}
|
||
};
|
||
|
||
const handleDeleteProvider = async (id: string) => {
|
||
if (!confirm("确定删除?关联模型也会被删除。")) return;
|
||
try {
|
||
const response = await fetch(`/api/admin/ai-providers?id=${id}`, { method: "DELETE" });
|
||
if (!response.ok) throw new Error("删除失败");
|
||
toast.success("提供商删除成功");
|
||
mutateProviders();
|
||
mutateModels();
|
||
} catch (error) {
|
||
toast.error("删除提供商失败");
|
||
}
|
||
};
|
||
|
||
// 快速添加火山引擎视频预设模型
|
||
const handleQuickAddVolcanoModels = async (providerId: string) => {
|
||
if (!confirm("将为此服务商添加4个预设模型:\n- Seedance Pro (全功能)\n- Seedance Pro Fast (快速)\n- Seedance Lite I2V (图生视频)\n- Seedance Lite T2V (文生视频)\n\n继续吗?")) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
toast.info("正在添加预设模型...");
|
||
|
||
const response = await fetch("/api/admin/volcano-video-models", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ providerId }),
|
||
});
|
||
|
||
const data = await response.json();
|
||
|
||
if (!response.ok) {
|
||
throw new Error(data.error || "添加失败");
|
||
}
|
||
|
||
const { addedCount, errors } = data;
|
||
|
||
if (addedCount > 0) {
|
||
toast.success(`成功添加 ${addedCount} 个预设模型!`);
|
||
mutateModels(); // 刷新模型列表
|
||
}
|
||
|
||
if (errors && errors.length > 0) {
|
||
toast.warning(`${errors.length} 个模型添加失败(可能已存在)`);
|
||
}
|
||
|
||
} catch (error: any) {
|
||
console.error('[Quick Add Models] Error:', error);
|
||
toast.error(error.message || "添加预设模型失败");
|
||
}
|
||
};
|
||
|
||
// --- AI 模型逻辑 ---
|
||
const resetModelForm = () => {
|
||
setModelForm({
|
||
id: "",
|
||
providerId: "",
|
||
modelId: "",
|
||
displayName: "",
|
||
description: "",
|
||
supportsVision: false,
|
||
supportsImageGen: false,
|
||
isEnabled: true,
|
||
isDefault: false,
|
||
sortOrder: 0,
|
||
fallbackModelId1: "",
|
||
fallbackModelId2: "",
|
||
});
|
||
setEditingModel(null);
|
||
};
|
||
|
||
const handleSaveModel = async () => {
|
||
try {
|
||
// 🆕 检查是否是火山引擎视频Provider
|
||
const selectedProvider = providersData?.providers?.find(p => p.id === modelForm.providerId);
|
||
const isVolcanoVideo = selectedProvider?.type === 'volcano-video';
|
||
|
||
// 如果是火山引擎视频且不是编辑模式,批量创建模型
|
||
if (isVolcanoVideo && !editingModel) {
|
||
toast.info("正在添加火山引擎视频模型(1个主模型 + 4个子模型)...");
|
||
|
||
const response = await fetch("/api/admin/volcano-video-models", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
providerId: modelForm.providerId,
|
||
// ✅ 传递所有模型配置(管理员可以自定义显示名称和描述)
|
||
models: volcanoVideoModels,
|
||
}),
|
||
});
|
||
|
||
const data = await response.json();
|
||
|
||
if (!response.ok) throw new Error(data.error || "批量添加失败");
|
||
|
||
toast.success(`✅ 成功添加 ${data.addedCount} 个模型(1个主模型 + ${data.addedCount - 1} 个子模型)!`);
|
||
mutateModels();
|
||
setShowModelDialog(false);
|
||
resetModelForm();
|
||
return;
|
||
}
|
||
|
||
// 普通模型的单个创建/编辑逻辑
|
||
const method = editingModel ? "PUT" : "POST";
|
||
const body = editingModel ? { ...modelForm, id: editingModel } : modelForm;
|
||
const response = await fetch("/api/admin/ai-models", {
|
||
method,
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(body),
|
||
});
|
||
|
||
if (!response.ok) throw new Error("保存失败");
|
||
toast.success(editingModel ? "模型更新成功" : "模型创建成功");
|
||
mutateModels();
|
||
setShowModelDialog(false);
|
||
resetModelForm();
|
||
} catch (error: any) {
|
||
console.error('[Save Model] Error:', error);
|
||
toast.error(error.message || "保存模型失败");
|
||
}
|
||
};
|
||
|
||
const handleDeleteModel = async (id: string) => {
|
||
if (!confirm("确定删除此模型?")) return;
|
||
try {
|
||
const response = await fetch(`/api/admin/ai-models?id=${id}`, { method: "DELETE" });
|
||
if (!response.ok) throw new Error("删除失败");
|
||
toast.success("模型删除成功");
|
||
mutateModels();
|
||
} catch (error) {
|
||
toast.error("删除模型失败");
|
||
}
|
||
};
|
||
|
||
// --- 存储配置逻辑 ---
|
||
const resetStorageForm = () => {
|
||
setStorageForm({
|
||
id: "",
|
||
name: "",
|
||
type: "s3",
|
||
endpoint: "",
|
||
region: "",
|
||
bucket: "",
|
||
accessKeyId: "",
|
||
secretAccessKey: "",
|
||
publicUrl: "",
|
||
isDefault: false,
|
||
isEnabled: true,
|
||
});
|
||
setEditingStorage(null);
|
||
};
|
||
|
||
const handleSaveStorage = async () => {
|
||
try {
|
||
const method = editingStorage ? "PUT" : "POST";
|
||
const body = editingStorage ? { ...storageForm, id: editingStorage } : storageForm;
|
||
const response = await fetch("/api/admin/storage", {
|
||
method,
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(body),
|
||
});
|
||
|
||
if (!response.ok) throw new Error("保存失败");
|
||
toast.success(editingStorage ? "存储配置更新成功" : "存储配置创建成功");
|
||
mutateStorage();
|
||
setShowStorageDialog(false);
|
||
resetStorageForm();
|
||
} catch (error) {
|
||
toast.error("保存存储配置失败");
|
||
}
|
||
};
|
||
|
||
const handleDeleteStorage = async (id: string) => {
|
||
if (!confirm("确定删除此存储配置?")) return;
|
||
try {
|
||
const response = await fetch(`/api/admin/storage?id=${id}`, { method: "DELETE" });
|
||
if (!response.ok) throw new Error("删除失败");
|
||
toast.success("存储配置删除成功");
|
||
mutateStorage();
|
||
} catch (error) {
|
||
toast.error("删除存储配置失败");
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className="container mx-auto p-6">
|
||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||
<TabsList className="grid w-full grid-cols-11">
|
||
<TabsTrigger value="users">
|
||
<User className="mr-2 h-4 w-4" />
|
||
用户管理
|
||
</TabsTrigger>
|
||
<TabsTrigger value="images">
|
||
<ImageIcon className="mr-2 h-4 w-4" />
|
||
生图记录
|
||
</TabsTrigger>
|
||
<TabsTrigger value="analytics">
|
||
<BarChart3 className="mr-2 h-4 w-4" />
|
||
数据分析
|
||
</TabsTrigger>
|
||
<TabsTrigger value="site">
|
||
<Settings className="mr-2 h-4 w-4" />
|
||
网站设置
|
||
</TabsTrigger>
|
||
<TabsTrigger value="nav">
|
||
<Menu className="mr-2 h-4 w-4" />
|
||
导航设置
|
||
</TabsTrigger>
|
||
<TabsTrigger value="questions">
|
||
<MessageSquare className="mr-2 h-4 w-4" />
|
||
快捷短语
|
||
</TabsTrigger>
|
||
<TabsTrigger value="auth">
|
||
<Key className="mr-2 h-4 w-4" />
|
||
认证设置
|
||
</TabsTrigger>
|
||
<TabsTrigger value="notification">
|
||
<Bell className="mr-2 h-4 w-4" />
|
||
系统通知
|
||
</TabsTrigger>
|
||
<TabsTrigger value="ai">
|
||
<Bot className="mr-2 h-4 w-4" />
|
||
AI 模型
|
||
</TabsTrigger>
|
||
<TabsTrigger value="storage">
|
||
<HardDrive className="mr-2 h-4 w-4" />
|
||
存储配置
|
||
</TabsTrigger>
|
||
<TabsTrigger value="rmbg">
|
||
<Scissors className="mr-2 h-4 w-4" />
|
||
抠图设置
|
||
</TabsTrigger>
|
||
</TabsList>
|
||
|
||
{/* 用户管理 */}
|
||
<TabsContent value="users" className="mt-6">
|
||
<Card>
|
||
<CardContent className="pt-6">
|
||
{/* 搜索框和同步按钮 */}
|
||
<div className="mb-4 flex flex-col sm:flex-row gap-3 sm:items-center sm:justify-between">
|
||
<Input
|
||
type="text"
|
||
placeholder="搜索用户(邮箱或用户名)..."
|
||
value={userSearch}
|
||
onChange={(e) => handleUserSearch(e.target.value)}
|
||
className="max-w-md"
|
||
/>
|
||
<div className="flex items-center gap-2">
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={() => setShowBatchLimitDialog(true)}
|
||
className="flex items-center gap-2"
|
||
>
|
||
<Settings className="h-4 w-4" />
|
||
批量设置限额
|
||
</Button>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={async () => {
|
||
try {
|
||
toast.info("正在从钉钉同步用户信息,可能需要几分钟...");
|
||
const res = await fetch("/api/admin/sync-dingtalk", { method: "POST" });
|
||
const data = await res.json();
|
||
if (res.ok) {
|
||
toast.success(
|
||
`同步完成!匹配 ${data.stats.matched}/${data.stats.total} 个用户,` +
|
||
`更新 ${data.stats.synced} 个,跳过 ${data.stats.skipped} 个` +
|
||
(data.stats.errors > 0 ? `,失败 ${data.stats.errors} 个` : "")
|
||
);
|
||
mutateUsers(); // 刷新用户列表
|
||
} else {
|
||
toast.error(data.error || "同步失败");
|
||
}
|
||
} catch (error) {
|
||
toast.error("同步请求失败");
|
||
}
|
||
}}
|
||
className="flex items-center gap-2"
|
||
>
|
||
<RefreshCw className="h-4 w-4" />
|
||
同步钉钉用户
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 用户列表 */}
|
||
<div className="space-y-4 mb-4">
|
||
{usersData?.users?.map((user) => (
|
||
<div key={user.id} className="flex items-center justify-between p-4 border rounded-lg">
|
||
<div className="flex-1">
|
||
<div className="font-medium">{user.name || user.email}</div>
|
||
<div className="text-sm text-muted-foreground">
|
||
邮箱: {user.email}
|
||
</div>
|
||
<div className="text-sm text-muted-foreground">
|
||
部门: {user.department || "未设置"}
|
||
</div>
|
||
<div className="text-sm text-muted-foreground">
|
||
角色: {ROLE_LABELS[user.role] ?? user.role} | 状态: {user.isActive ? "已激活" : "未激活"}
|
||
</div>
|
||
<div className="text-sm text-muted-foreground">
|
||
月度出图限额: {user.monthlyImageLimit ? `${user.monthlyImageLimit} 张/月` : "无限制"}
|
||
</div>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
{/* 配额设置 */}
|
||
<div className="flex items-center gap-1">
|
||
<Input
|
||
type="number"
|
||
min="0"
|
||
placeholder="无限制"
|
||
className="w-20 h-8 text-xs"
|
||
defaultValue={user.monthlyImageLimit ?? ""}
|
||
onBlur={(e) => {
|
||
const value = e.target.value ? parseInt(e.target.value, 10) : null;
|
||
if (value !== user.monthlyImageLimit) {
|
||
handleUpdateUser(user.id, { monthlyImageLimit: value });
|
||
}
|
||
}}
|
||
onKeyDown={(e) => {
|
||
if (e.key === "Enter") {
|
||
const value = (e.target as HTMLInputElement).value
|
||
? parseInt((e.target as HTMLInputElement).value, 10)
|
||
: null;
|
||
if (value !== user.monthlyImageLimit) {
|
||
handleUpdateUser(user.id, { monthlyImageLimit: value });
|
||
}
|
||
}
|
||
}}
|
||
/>
|
||
<span className="text-xs text-muted-foreground">张</span>
|
||
</div>
|
||
<Select
|
||
value={user.role}
|
||
onValueChange={(value) => handleUpdateUser(user.id, { role: value as any })}
|
||
>
|
||
<SelectTrigger className="w-32">
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="guest">访客</SelectItem>
|
||
<SelectItem value="regular">普通</SelectItem>
|
||
<SelectItem value="admin">管理员</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
<Button
|
||
variant={user.isActive ? "outline" : "default"}
|
||
size="sm"
|
||
onClick={() => handleUpdateUser(user.id, { isActive: !user.isActive })}
|
||
>
|
||
{user.isActive ? "禁用" : "激活"}
|
||
</Button>
|
||
<Button variant="destructive" size="sm" onClick={() => handleDeleteUser(user.id)}>
|
||
<Trash2 className="h-4 w-4" />
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
{/* 分页控件 */}
|
||
{usersData && usersData.totalPages > 1 && (
|
||
<div className="flex items-center justify-between">
|
||
<div className="text-sm text-muted-foreground">
|
||
显示 {((userPage - 1) * userLimit) + 1} - {Math.min(userPage * userLimit, usersData.total)} 条,共 {usersData.total} 条
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
{/* 首页按钮 */}
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={() => setUserPage(1)}
|
||
disabled={userPage === 1}
|
||
title="首页"
|
||
>
|
||
首页
|
||
</Button>
|
||
|
||
{/* 上一页按钮 */}
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={() => setUserPage(p => Math.max(1, p - 1))}
|
||
disabled={userPage === 1}
|
||
>
|
||
上一页
|
||
</Button>
|
||
|
||
{/* 页码显示和跳转 */}
|
||
<div className="flex items-center gap-2">
|
||
<span className="text-sm whitespace-nowrap">第</span>
|
||
<Input
|
||
type="number"
|
||
min={1}
|
||
max={usersData.totalPages}
|
||
value={userPage}
|
||
onChange={(e) => {
|
||
const page = Number.parseInt(e.target.value, 10);
|
||
if (page >= 1 && page <= usersData.totalPages) {
|
||
setUserPage(page);
|
||
}
|
||
}}
|
||
onKeyDown={(e) => {
|
||
if (e.key === 'Enter') {
|
||
const page = Number.parseInt(e.currentTarget.value, 10);
|
||
if (page >= 1 && page <= usersData.totalPages) {
|
||
setUserPage(page);
|
||
} else {
|
||
e.currentTarget.value = userPage.toString();
|
||
}
|
||
}
|
||
}}
|
||
className="w-16 h-8 text-center"
|
||
/>
|
||
<span className="text-sm whitespace-nowrap">/ {usersData.totalPages} 页</span>
|
||
</div>
|
||
|
||
{/* 下一页按钮 */}
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={() => setUserPage(p => Math.min(usersData.totalPages, p + 1))}
|
||
disabled={userPage === usersData.totalPages}
|
||
>
|
||
下一页
|
||
</Button>
|
||
|
||
{/* 末页按钮 */}
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={() => setUserPage(usersData.totalPages)}
|
||
disabled={userPage === usersData.totalPages}
|
||
title="末页"
|
||
>
|
||
末页
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
</TabsContent>
|
||
|
||
{/* 🆕 生图记录 */}
|
||
<TabsContent value="images" className="mt-6">
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>所有生图记录</CardTitle>
|
||
<CardDescription>查看所有用户的 AI 生图历史记录</CardDescription>
|
||
</CardHeader>
|
||
<CardContent>
|
||
{/* 筛选条件 */}
|
||
<div className="flex flex-wrap gap-4 mb-6">
|
||
<div className="flex-1 min-w-[200px]">
|
||
<Label className="text-xs mb-1 block">搜索用户</Label>
|
||
<div className="relative">
|
||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||
<Input
|
||
placeholder="输入用户名或邮箱..."
|
||
value={imageSearch}
|
||
onChange={(e) => { setImageSearch(e.target.value); setImagePage(1); }}
|
||
className="pl-9"
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div className="w-[150px]">
|
||
<Label className="text-xs mb-1 block">开始日期</Label>
|
||
<Input
|
||
type="date"
|
||
value={imageStartDate}
|
||
onChange={(e) => { setImageStartDate(e.target.value); setImagePage(1); }}
|
||
/>
|
||
</div>
|
||
<div className="w-[150px]">
|
||
<Label className="text-xs mb-1 block">结束日期</Label>
|
||
<Input
|
||
type="date"
|
||
value={imageEndDate}
|
||
onChange={(e) => { setImageEndDate(e.target.value); setImagePage(1); }}
|
||
/>
|
||
</div>
|
||
<div className="flex items-end">
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={() => {
|
||
setImageSearch("");
|
||
setImageStartDate("");
|
||
setImageEndDate("");
|
||
setImagePage(1);
|
||
}}
|
||
>
|
||
重置
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 统计信息 */}
|
||
<div className="text-sm text-muted-foreground mb-4">
|
||
共 {imagesData?.pagination.total || 0} 条记录
|
||
</div>
|
||
|
||
{/* 图片网格 */}
|
||
<div className="grid grid-cols-[repeat(auto-fill,minmax(80px,1fr))] gap-3">
|
||
{imagesData?.images?.map((img) => (
|
||
<div
|
||
key={img.id}
|
||
className="group relative cursor-pointer"
|
||
onClick={() => setSelectedImage(img)}
|
||
>
|
||
<div className="w-[37px] h-[37px] mx-auto rounded border overflow-hidden bg-muted relative">
|
||
<img
|
||
src={img.thumbnailUrl}
|
||
alt={img.prompt || "AI生成的图片"}
|
||
className="w-full h-full object-cover"
|
||
loading="lazy"
|
||
/>
|
||
{/* 精选图标 */}
|
||
{img.isFeatured && (
|
||
<div className="absolute -top-1 -right-1 w-4 h-4 bg-gradient-to-r from-yellow-400 to-orange-500 rounded-full flex items-center justify-center shadow">
|
||
<Sparkles className="h-2.5 w-2.5 text-white" />
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div className="text-[10px] text-center mt-1 truncate text-muted-foreground">
|
||
{img.userName || img.userEmail?.split("@")[0]}
|
||
</div>
|
||
<div className="text-[9px] text-center text-muted-foreground/60">
|
||
{new Date(img.createdAt).toLocaleDateString("zh-CN", { month: "numeric", day: "numeric" })}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
{/* 分页 */}
|
||
{imagesData && imagesData.pagination.totalPages > 1 && (
|
||
<div className="flex items-center justify-between mt-6">
|
||
<div className="text-sm text-muted-foreground">
|
||
第 {imagesData.pagination.page} / {imagesData.pagination.totalPages} 页
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={() => setImagePage(1)}
|
||
disabled={imagePage === 1}
|
||
>
|
||
首页
|
||
</Button>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={() => setImagePage(p => Math.max(1, p - 1))}
|
||
disabled={imagePage === 1}
|
||
>
|
||
上一页
|
||
</Button>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={() => setImagePage(p => Math.min(imagesData.pagination.totalPages, p + 1))}
|
||
disabled={imagePage === imagesData.pagination.totalPages}
|
||
>
|
||
下一页
|
||
</Button>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={() => setImagePage(imagesData.pagination.totalPages)}
|
||
disabled={imagePage === imagesData.pagination.totalPages}
|
||
>
|
||
末页
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* 图片详情弹窗 */}
|
||
<Dialog open={!!selectedImage} onOpenChange={(open) => !open && setSelectedImage(null)}>
|
||
<DialogContent className="max-w-2xl">
|
||
<DialogHeader>
|
||
<DialogTitle className="flex items-center gap-2">
|
||
图片详情
|
||
{selectedImage?.isFeatured && (
|
||
<span className="inline-flex items-center gap-1 px-2 py-0.5 bg-gradient-to-r from-yellow-400 to-orange-500 text-white text-xs rounded-full">
|
||
<Sparkles className="h-3 w-3" />
|
||
精选
|
||
</span>
|
||
)}
|
||
</DialogTitle>
|
||
</DialogHeader>
|
||
{selectedImage && (
|
||
<div className="space-y-4">
|
||
<div className="flex justify-center relative">
|
||
<img
|
||
src={selectedImage.originalUrl}
|
||
alt={selectedImage.prompt || "AI生成的图片"}
|
||
className="max-h-[400px] object-contain rounded-lg"
|
||
/>
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||
<div>
|
||
<Label className="text-muted-foreground">用户</Label>
|
||
<p>{selectedImage.userName || selectedImage.userEmail}</p>
|
||
</div>
|
||
<div>
|
||
<Label className="text-muted-foreground">部门</Label>
|
||
<p>{selectedImage.userDepartment || "未设置"}</p>
|
||
</div>
|
||
<div>
|
||
<Label className="text-muted-foreground">生成时间</Label>
|
||
<p>{new Date(selectedImage.createdAt).toLocaleString("zh-CN")}</p>
|
||
</div>
|
||
<div>
|
||
<Label className="text-muted-foreground">图片尺寸</Label>
|
||
<p>{selectedImage.imageSize || "未知"}</p>
|
||
</div>
|
||
<div>
|
||
<Label className="text-muted-foreground">模型</Label>
|
||
<p>{selectedImage.modelId || "未知"}</p>
|
||
</div>
|
||
{selectedImage.isFeatured && selectedImage.featuredAt && (
|
||
<div>
|
||
<Label className="text-muted-foreground">精选时间</Label>
|
||
<p>{new Date(selectedImage.featuredAt).toLocaleString("zh-CN")}</p>
|
||
</div>
|
||
)}
|
||
<div className="col-span-2">
|
||
<Label className="text-muted-foreground">提示词</Label>
|
||
<p className="mt-1">{selectedImage.prompt || "无"}</p>
|
||
</div>
|
||
</div>
|
||
{/* 精选操作按钮 */}
|
||
<div className="flex justify-end pt-2 border-t">
|
||
<Button
|
||
variant={selectedImage.isFeatured ? "outline" : "default"}
|
||
className={selectedImage.isFeatured ? "" : "bg-gradient-to-r from-yellow-400 to-orange-500 hover:from-yellow-500 hover:to-orange-600"}
|
||
onClick={async () => {
|
||
try {
|
||
const res = await fetch("/api/admin/images", {
|
||
method: "PATCH",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
imageId: selectedImage.id,
|
||
isFeatured: !selectedImage.isFeatured,
|
||
}),
|
||
});
|
||
if (res.ok) {
|
||
// 更新本地状态
|
||
setSelectedImage({
|
||
...selectedImage,
|
||
isFeatured: !selectedImage.isFeatured,
|
||
featuredAt: !selectedImage.isFeatured ? new Date().toISOString() : null,
|
||
});
|
||
// 刷新列表
|
||
mutateImages();
|
||
}
|
||
} catch (error) {
|
||
console.error("Failed to update featured status:", error);
|
||
}
|
||
}}
|
||
>
|
||
<Sparkles className="h-4 w-4 mr-2" />
|
||
{selectedImage.isFeatured ? "取消精选" : "设为精选"}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</DialogContent>
|
||
</Dialog>
|
||
</TabsContent>
|
||
|
||
{/* 🆕 数据分析 */}
|
||
<TabsContent value="analytics" className="mt-6 space-y-6">
|
||
{/* 概览卡片 */}
|
||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||
<Card>
|
||
<CardContent className="pt-6">
|
||
<div className="flex items-center gap-3">
|
||
<div className="p-2 bg-blue-100 rounded-lg">
|
||
<ImageIcon className="h-5 w-5 text-blue-600" />
|
||
</div>
|
||
<div>
|
||
<p className="text-2xl font-bold">{analyticsData?.overview.totalImages || 0}</p>
|
||
<p className="text-xs text-muted-foreground">总生图数</p>
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
<Card>
|
||
<CardContent className="pt-6">
|
||
<div className="flex items-center gap-3">
|
||
<div className="p-2 bg-green-100 rounded-lg">
|
||
<Users className="h-5 w-5 text-green-600" />
|
||
</div>
|
||
<div>
|
||
<p className="text-2xl font-bold">{analyticsData?.overview.totalUsers || 0}</p>
|
||
<p className="text-xs text-muted-foreground">总用户数</p>
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
<Card>
|
||
<CardContent className="pt-6">
|
||
<div className="flex items-center gap-3">
|
||
<div className="p-2 bg-orange-100 rounded-lg">
|
||
<TrendingUp className="h-5 w-5 text-orange-600" />
|
||
</div>
|
||
<div>
|
||
<p className="text-2xl font-bold">{analyticsData?.overview.todayImages || 0}</p>
|
||
<p className="text-xs text-muted-foreground">今日生图</p>
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
<Card>
|
||
<CardContent className="pt-6">
|
||
<div className="flex items-center justify-between">
|
||
<div className="flex items-center gap-3">
|
||
<div className="p-2 bg-purple-100 rounded-lg">
|
||
<Calendar className="h-5 w-5 text-purple-600" />
|
||
</div>
|
||
<div>
|
||
<p className="text-2xl font-bold">{analyticsData?.overview.monthImages || 0}</p>
|
||
<p className="text-xs text-muted-foreground">
|
||
{selectedMonth === `${new Date().getFullYear()}-${String(new Date().getMonth() + 1).padStart(2, "0")}`
|
||
? "本月生图"
|
||
: `${selectedMonth} 生图`}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
<Input
|
||
type="month"
|
||
value={selectedMonth}
|
||
onChange={(e) => setSelectedMonth(e.target.value)}
|
||
className="w-[140px] h-8 text-xs"
|
||
/>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
|
||
<div className="grid md:grid-cols-2 gap-6">
|
||
{/* 用户排行 */}
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle className="text-base">用户生图排行 (Top 20)</CardTitle>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<div className="space-y-3 max-h-[400px] overflow-y-auto">
|
||
{analyticsData?.userStats?.map((user, index) => (
|
||
<div key={user.userId} className="flex items-center justify-between">
|
||
<div className="flex items-center gap-3">
|
||
<span className={`w-6 h-6 rounded-full flex items-center justify-center text-xs font-medium ${
|
||
index < 3 ? "bg-yellow-100 text-yellow-700" : "bg-gray-100 text-gray-600"
|
||
}`}>
|
||
{index + 1}
|
||
</span>
|
||
<span className="text-sm truncate max-w-[150px]">
|
||
{user.userName || user.userEmail?.split("@")[0]}
|
||
</span>
|
||
</div>
|
||
<span className="font-medium">{user.imageCount}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* 图片尺寸分布 */}
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle className="text-base">图片尺寸分布</CardTitle>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<div className="space-y-3">
|
||
{analyticsData?.sizeStats?.map((stat) => {
|
||
const total = analyticsData.overview.totalImages || 1;
|
||
const percentage = ((stat.count / total) * 100).toFixed(1);
|
||
return (
|
||
<div key={stat.imageSize || "unknown"} className="space-y-1">
|
||
<div className="flex justify-between text-sm">
|
||
<span>{stat.imageSize || "未知"}</span>
|
||
<span className="text-muted-foreground">{stat.count} ({percentage}%)</span>
|
||
</div>
|
||
<div className="h-2 bg-gray-100 rounded-full overflow-hidden">
|
||
<div
|
||
className="h-full bg-blue-500 rounded-full"
|
||
style={{ width: `${percentage}%` }}
|
||
/>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* 每日趋势 */}
|
||
<Card>
|
||
<CardHeader className="flex flex-row items-center justify-between">
|
||
<CardTitle className="text-base">最近30天趋势</CardTitle>
|
||
{selectedDate && (
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
className="text-xs h-6"
|
||
onClick={() => setSelectedDate(null)}
|
||
>
|
||
清除选择 ({selectedDate})
|
||
</Button>
|
||
)}
|
||
</CardHeader>
|
||
<CardContent>
|
||
<div className="h-[200px] flex items-end gap-1 relative">
|
||
{analyticsData?.dailyStats?.map((stat, index) => {
|
||
const maxCount = Math.max(...(analyticsData.dailyStats?.map(s => s.count) || [1]));
|
||
const height = (stat.count / maxCount) * 100;
|
||
const isHovered = hoveredBar?.type === "daily" && hoveredBar?.index === index;
|
||
const isSelected = selectedDate === stat.date;
|
||
return (
|
||
<div
|
||
key={stat.date}
|
||
className={`flex-1 rounded-t transition-colors cursor-pointer relative ${
|
||
isSelected
|
||
? "bg-orange-500 hover:bg-orange-600"
|
||
: isHovered
|
||
? "bg-blue-600"
|
||
: "bg-blue-500 hover:bg-blue-600"
|
||
}`}
|
||
style={{ height: `${Math.max(height, 2)}%` }}
|
||
onMouseEnter={() => setHoveredBar({ type: "daily", index, value: stat.count, label: stat.date })}
|
||
onMouseLeave={() => setHoveredBar(null)}
|
||
onClick={() => setSelectedDate(selectedDate === stat.date ? null : stat.date)}
|
||
>
|
||
{isHovered && (
|
||
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-2 py-1 bg-gray-900 text-white text-xs rounded whitespace-nowrap z-10">
|
||
{stat.date}: {stat.count} 张
|
||
<div className="text-[10px] text-gray-300">点击查看当天高峰时段</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
<div className="flex justify-between text-xs text-muted-foreground mt-2">
|
||
<span>{analyticsData?.dailyStats?.[0]?.date}</span>
|
||
<span>{analyticsData?.dailyStats?.at(-1)?.date}</span>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* 高峰时段 */}
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle className="text-base flex items-center gap-2">
|
||
生图高峰时段
|
||
{selectedDate && (
|
||
<span className="text-xs font-normal bg-orange-100 text-orange-700 px-2 py-0.5 rounded">
|
||
{selectedDate}
|
||
</span>
|
||
)}
|
||
</CardTitle>
|
||
{!selectedDate && (
|
||
<CardDescription className="text-xs">点击左侧日期柱形查看当天数据</CardDescription>
|
||
)}
|
||
</CardHeader>
|
||
<CardContent>
|
||
<div className="h-[200px] flex items-end gap-1 relative">
|
||
{Array.from({ length: 24 }, (_, hour) => {
|
||
const stat = analyticsData?.hourlyStats?.find(s => s.hour === hour);
|
||
const count = stat?.count || 0;
|
||
const maxCount = Math.max(...(analyticsData?.hourlyStats?.map(s => s.count) || [1]), 1);
|
||
const height = (count / maxCount) * 100;
|
||
const isHovered = hoveredBar?.type === "hourly" && hoveredBar?.index === hour;
|
||
return (
|
||
<div
|
||
key={hour}
|
||
className={`flex-1 rounded-t transition-colors cursor-pointer relative ${
|
||
selectedDate
|
||
? (isHovered ? "bg-orange-600" : "bg-orange-500 hover:bg-orange-600")
|
||
: (isHovered ? "bg-green-600" : "bg-green-500 hover:bg-green-600")
|
||
}`}
|
||
style={{ height: `${Math.max(height, 2)}%` }}
|
||
onMouseEnter={() => setHoveredBar({ type: "hourly", index: hour, value: count, label: `${hour}:00` })}
|
||
onMouseLeave={() => setHoveredBar(null)}
|
||
>
|
||
{isHovered && (
|
||
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-2 py-1 bg-gray-900 text-white text-xs rounded whitespace-nowrap z-10">
|
||
{hour}:00-{hour + 1}:00: {count} 张
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
<div className="flex justify-between text-xs text-muted-foreground mt-2">
|
||
<span>0:00</span>
|
||
<span>12:00</span>
|
||
<span>23:00</span>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* 月度统计 */}
|
||
<Card className="md:col-span-2">
|
||
<CardHeader>
|
||
<CardTitle className="text-base">月度统计</CardTitle>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<div className="grid grid-cols-6 gap-4">
|
||
{analyticsData?.monthlyStats?.slice(-6).map((stat) => (
|
||
<div key={stat.month} className="text-center p-3 bg-gray-50 rounded-lg">
|
||
<p className="text-2xl font-bold text-blue-600">{stat.count}</p>
|
||
<p className="text-xs text-muted-foreground">{stat.month}</p>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
|
||
{/* 🆕 模型使用分析 */}
|
||
<div className="mt-6">
|
||
<h3 className="text-lg font-semibold mb-4 flex items-center gap-2">
|
||
<Bot className="h-5 w-5" />
|
||
模型使用分析
|
||
</h3>
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||
{/* 模型使用排行 */}
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle className="text-base">模型使用排行</CardTitle>
|
||
<CardDescription className="text-xs">按生图数量排序</CardDescription>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<div className="space-y-3">
|
||
{analyticsData?.modelDetailStats?.slice(0, 8).map((stat, index) => {
|
||
const maxCount = analyticsData?.modelDetailStats?.[0]?.count || 1;
|
||
const percentage = (stat.count / maxCount) * 100;
|
||
return (
|
||
<div key={stat.modelId || index} className="space-y-1">
|
||
<div className="flex items-center justify-between text-sm">
|
||
<div className="flex items-center gap-2">
|
||
<span className={`w-5 h-5 rounded-full flex items-center justify-center text-xs font-bold ${
|
||
index === 0 ? "bg-yellow-500 text-white" :
|
||
index === 1 ? "bg-gray-400 text-white" :
|
||
index === 2 ? "bg-amber-600 text-white" :
|
||
"bg-gray-200 text-gray-600"
|
||
}`}>
|
||
{index + 1}
|
||
</span>
|
||
<span className="font-medium truncate max-w-[150px]" title={stat.modelName || stat.modelId || "未知模型"}>
|
||
{stat.modelName || stat.modelId || "未知模型"}
|
||
</span>
|
||
</div>
|
||
<div className="text-right">
|
||
<span className="font-semibold">{stat.count}</span>
|
||
<span className="text-muted-foreground text-xs ml-1">张</span>
|
||
</div>
|
||
</div>
|
||
<div className="w-full h-2 bg-gray-100 rounded-full overflow-hidden">
|
||
<div
|
||
className={`h-full rounded-full ${
|
||
index === 0 ? "bg-yellow-500" :
|
||
index === 1 ? "bg-gray-400" :
|
||
index === 2 ? "bg-amber-600" :
|
||
"bg-blue-400"
|
||
}`}
|
||
style={{ width: `${percentage}%` }}
|
||
/>
|
||
</div>
|
||
<div className="flex justify-between text-xs text-muted-foreground">
|
||
<span>{stat.providerName || "未知提供商"}</span>
|
||
<span>{stat.userCount} 用户</span>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
{(!analyticsData?.modelDetailStats || analyticsData.modelDetailStats.length === 0) && (
|
||
<div className="text-center text-muted-foreground py-8">暂无数据</div>
|
||
)}
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* 提供商类型分布 */}
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle className="text-base">提供商分布</CardTitle>
|
||
<CardDescription className="text-xs">按 AI 提供商统计</CardDescription>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<div className="space-y-4">
|
||
{(() => {
|
||
const providerColors: Record<string, string> = {
|
||
"gemini": "bg-purple-500",
|
||
"volcano-vision": "bg-orange-500",
|
||
"volcano-video": "bg-red-500",
|
||
"alibaba": "bg-blue-500",
|
||
"openai": "bg-green-500",
|
||
"qianwen-video": "bg-cyan-500",
|
||
"custom": "bg-gray-500",
|
||
"local": "bg-yellow-500",
|
||
};
|
||
const total = analyticsData?.providerTypeStats?.reduce((acc, s) => acc + s.count, 0) || 0;
|
||
|
||
return analyticsData?.providerTypeStats?.map((stat, index) => {
|
||
const percentage = total > 0 ? ((stat.count / total) * 100).toFixed(1) : "0";
|
||
const color = providerColors[stat.providerType || ""] || "bg-gray-400";
|
||
return (
|
||
<div key={stat.providerType || index} className="flex items-center gap-3">
|
||
<div className={`w-3 h-3 rounded-full ${color}`} />
|
||
<div className="flex-1">
|
||
<div className="flex justify-between items-center">
|
||
<span className="text-sm font-medium">
|
||
{stat.providerName || stat.providerType || "未知"}
|
||
</span>
|
||
<span className="text-sm">
|
||
<span className="font-semibold">{stat.count}</span>
|
||
<span className="text-muted-foreground ml-1">({percentage}%)</span>
|
||
</span>
|
||
</div>
|
||
<div className="w-full h-2 bg-gray-100 rounded-full overflow-hidden mt-1">
|
||
<div
|
||
className={`h-full rounded-full ${color}`}
|
||
style={{ width: `${percentage}%` }}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
});
|
||
})()}
|
||
{(!analyticsData?.providerTypeStats || analyticsData.providerTypeStats.length === 0) && (
|
||
<div className="text-center text-muted-foreground py-8">暂无数据</div>
|
||
)}
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* 图片尺寸分布 */}
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle className="text-base">图片尺寸分布</CardTitle>
|
||
<CardDescription className="text-xs">按生成图片尺寸统计</CardDescription>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<div className="flex flex-wrap gap-3">
|
||
{analyticsData?.sizeStats?.map((stat) => {
|
||
const total = analyticsData?.sizeStats?.reduce((acc, s) => acc + s.count, 0) || 0;
|
||
const percentage = total > 0 ? ((stat.count / total) * 100).toFixed(1) : "0";
|
||
return (
|
||
<div
|
||
key={stat.imageSize || "unknown"}
|
||
className="flex-1 min-w-[100px] p-3 bg-gradient-to-br from-indigo-50 to-purple-50 rounded-lg border border-indigo-100"
|
||
>
|
||
<p className="text-xl font-bold text-indigo-600">{stat.imageSize || "未知"}</p>
|
||
<p className="text-sm text-muted-foreground">{stat.count} 张</p>
|
||
<p className="text-xs text-indigo-400">{percentage}%</p>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* 模型使用趋势(最近7天) */}
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle className="text-base">模型使用趋势</CardTitle>
|
||
<CardDescription className="text-xs">最近7天各模型使用量</CardDescription>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<div className="space-y-2">
|
||
{(() => {
|
||
// 按日期分组
|
||
const groupedByDate = analyticsData?.modelTrendStats?.reduce((acc, stat) => {
|
||
if (!acc[stat.date]) acc[stat.date] = [];
|
||
acc[stat.date].push(stat);
|
||
return acc;
|
||
}, {} as Record<string, typeof analyticsData.modelTrendStats>) || {};
|
||
|
||
const dates = Object.keys(groupedByDate).sort().slice(-7);
|
||
const maxDailyTotal = Math.max(
|
||
...dates.map(date =>
|
||
groupedByDate[date]?.reduce((sum, s) => sum + s.count, 0) || 0
|
||
),
|
||
1
|
||
);
|
||
|
||
return dates.map(date => {
|
||
const dayStats = groupedByDate[date] || [];
|
||
const dayTotal = dayStats.reduce((sum, s) => sum + s.count, 0);
|
||
const heightPercent = (dayTotal / maxDailyTotal) * 100;
|
||
|
||
return (
|
||
<div key={date} className="flex items-center gap-2">
|
||
<span className="text-xs text-muted-foreground w-20">
|
||
{date.slice(5)}
|
||
</span>
|
||
<div className="flex-1 h-6 bg-gray-100 rounded overflow-hidden flex">
|
||
{dayStats.map((stat, idx) => {
|
||
const statPercent = dayTotal > 0 ? (stat.count / dayTotal) * 100 : 0;
|
||
const colors = ["bg-blue-500", "bg-green-500", "bg-purple-500", "bg-orange-500", "bg-pink-500"];
|
||
return (
|
||
<div
|
||
key={stat.modelId || idx}
|
||
className={`h-full ${colors[idx % colors.length]}`}
|
||
style={{ width: `${statPercent * (heightPercent / 100)}%` }}
|
||
title={`${stat.modelId || "未知"}: ${stat.count}张`}
|
||
/>
|
||
);
|
||
})}
|
||
</div>
|
||
<span className="text-xs font-medium w-10 text-right">{dayTotal}</span>
|
||
</div>
|
||
);
|
||
});
|
||
})()}
|
||
{(!analyticsData?.modelTrendStats || analyticsData.modelTrendStats.length === 0) && (
|
||
<div className="text-center text-muted-foreground py-8">暂无数据</div>
|
||
)}
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
</div>
|
||
</TabsContent>
|
||
|
||
{/* 网站设置 */}
|
||
<TabsContent value="site" className="mt-6">
|
||
<Card>
|
||
<CardContent className="space-y-6 pt-6">
|
||
<div className="space-y-2">
|
||
<Label htmlFor="siteName">网站名称</Label>
|
||
<Input
|
||
id="siteName"
|
||
value={siteSettings.siteName}
|
||
onChange={(e) => setSiteSettings({ ...siteSettings, siteName: e.target.value })}
|
||
placeholder="输入网站名称"
|
||
/>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label htmlFor="siteLogo">网站 LOGO (白天模式/默认)</Label>
|
||
<Input
|
||
id="siteLogo"
|
||
value={siteSettings.siteLogo || ""}
|
||
onChange={(e) => setSiteSettings({ ...siteSettings, siteLogo: e.target.value })}
|
||
placeholder="https://example.com/logo.png"
|
||
/>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label htmlFor="siteLogoDark">网站 LOGO (黑夜模式)</Label>
|
||
<Input
|
||
id="siteLogoDark"
|
||
value={siteSettings.siteLogoDark || ""}
|
||
onChange={(e) => setSiteSettings({ ...siteSettings, siteLogoDark: e.target.value })}
|
||
placeholder="https://example.com/logo-dark.png"
|
||
/>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label htmlFor="favicon">浏览器图标 (Favicon URL)</Label>
|
||
<Input
|
||
id="favicon"
|
||
value={siteSettings.favicon || ""}
|
||
onChange={(e) => setSiteSettings({ ...siteSettings, favicon: e.target.value })}
|
||
placeholder="https://example.com/favicon.ico"
|
||
/>
|
||
</div>
|
||
<div className="flex items-center justify-between space-x-2 rounded-lg border p-4">
|
||
<div className="space-y-0.5">
|
||
<Label htmlFor="showAutoModel">显示"自动"模型选项</Label>
|
||
<div className="text-sm text-muted-foreground">
|
||
开启后,用户可以选择"智能自动"模型,系统将根据场景智能选择最佳模型
|
||
</div>
|
||
</div>
|
||
<Switch
|
||
id="showAutoModel"
|
||
checked={siteSettings.showAutoModel}
|
||
onCheckedChange={(checked) => setSiteSettings({ ...siteSettings, showAutoModel: checked })}
|
||
/>
|
||
</div>
|
||
<Separator />
|
||
<div className="space-y-4">
|
||
<div className="flex items-center justify-between space-x-2 rounded-lg border p-4">
|
||
<div className="space-y-0.5">
|
||
<Label htmlFor="usageTimeEnabled">启用使用时间限制</Label>
|
||
<div className="text-sm text-muted-foreground">
|
||
开启后,仅在指定时间段内允许使用系统,超出时间段输入框将被禁用
|
||
</div>
|
||
</div>
|
||
<Switch
|
||
id="usageTimeEnabled"
|
||
checked={siteSettings.usageTimeEnabled}
|
||
onCheckedChange={(checked) => setSiteSettings({ ...siteSettings, usageTimeEnabled: checked })}
|
||
/>
|
||
</div>
|
||
{siteSettings.usageTimeEnabled && (
|
||
<>
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<div className="space-y-2">
|
||
<Label htmlFor="usageTimeStart">开始时间</Label>
|
||
<Select
|
||
value={siteSettings.usageTimeStart}
|
||
onValueChange={(value) => setSiteSettings({ ...siteSettings, usageTimeStart: value })}
|
||
>
|
||
<SelectTrigger id="usageTimeStart">
|
||
<SelectValue placeholder="选择开始时间" />
|
||
</SelectTrigger>
|
||
<SelectContent className="max-h-[300px] overflow-y-auto">
|
||
{Array.from({ length: 24 }, (_, i) => {
|
||
const hour = String(i).padStart(2, '0');
|
||
return [
|
||
<SelectItem key={`${hour}:00`} value={`${hour}:00`}>{hour}:00</SelectItem>,
|
||
<SelectItem key={`${hour}:30`} value={`${hour}:30`}>{hour}:30</SelectItem>
|
||
];
|
||
}).flat()}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label htmlFor="usageTimeEnd">结束时间</Label>
|
||
<Select
|
||
value={siteSettings.usageTimeEnd}
|
||
onValueChange={(value) => setSiteSettings({ ...siteSettings, usageTimeEnd: value })}
|
||
>
|
||
<SelectTrigger id="usageTimeEnd">
|
||
<SelectValue placeholder="选择结束时间" />
|
||
</SelectTrigger>
|
||
<SelectContent className="max-h-[300px] overflow-y-auto">
|
||
{Array.from({ length: 24 }, (_, i) => {
|
||
const hour = String(i).padStart(2, '0');
|
||
return [
|
||
<SelectItem key={`${hour}:00`} value={`${hour}:00`}>{hour}:00</SelectItem>,
|
||
<SelectItem key={`${hour}:30`} value={`${hour}:30`}>{hour}:30</SelectItem>
|
||
];
|
||
}).flat()}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label htmlFor="usageTimeMessage">超时提示信息</Label>
|
||
<Textarea
|
||
id="usageTimeMessage"
|
||
value={siteSettings.usageTimeMessage}
|
||
onChange={(e) => setSiteSettings({ ...siteSettings, usageTimeMessage: e.target.value })}
|
||
placeholder="输入超时提示信息"
|
||
rows={2}
|
||
/>
|
||
<div className="text-xs text-muted-foreground">
|
||
当前设置:{siteSettings.usageTimeStart} - {siteSettings.usageTimeEnd}
|
||
</div>
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
<Button onClick={handleSaveSiteSettings}>
|
||
<Save className="mr-2 h-4 w-4" /> 保存设置
|
||
</Button>
|
||
</CardContent>
|
||
</Card>
|
||
</TabsContent>
|
||
|
||
{/* 导航设置 */}
|
||
<TabsContent value="nav" className="mt-6">
|
||
<Card>
|
||
<CardContent className="pt-6">
|
||
<div className="flex justify-end mb-4">
|
||
<Dialog open={showNavLinkDialog} onOpenChange={setShowNavLinkDialog}>
|
||
<DialogTrigger asChild>
|
||
<Button onClick={() => { resetNavLinkForm(); setShowNavLinkDialog(true); }}>
|
||
<Plus className="mr-2 h-4 w-4" /> 添加链接
|
||
</Button>
|
||
</DialogTrigger>
|
||
<DialogContent>
|
||
<DialogHeader>
|
||
<DialogTitle>{editingNavLink ? "编辑链接" : "添加链接"}</DialogTitle>
|
||
</DialogHeader>
|
||
<div className="space-y-4">
|
||
<div className="space-y-2">
|
||
<Label>名称</Label>
|
||
<Input
|
||
value={navLinkForm.name}
|
||
onChange={(e) => setNavLinkForm({ ...navLinkForm, name: e.target.value })}
|
||
placeholder="链接名称"
|
||
/>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label>URL</Label>
|
||
<Input
|
||
value={navLinkForm.url}
|
||
onChange={(e) => setNavLinkForm({ ...navLinkForm, url: e.target.value })}
|
||
placeholder="https://..."
|
||
/>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label>排序 (越小越靠前)</Label>
|
||
<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>
|
||
|
||
<div className="space-y-4">
|
||
{navLinksData?.links?.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,
|
||
});
|
||
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?.length) && (
|
||
<div className="text-center text-muted-foreground py-8">暂无导航链接</div>
|
||
)}
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
</TabsContent>
|
||
|
||
<TabsContent value="questions" className="mt-6">
|
||
<Card>
|
||
<CardContent className="space-y-4 pt-6">
|
||
{questions.map((q, index) => (
|
||
<div key={index} className="flex gap-2">
|
||
<Input
|
||
value={q}
|
||
onChange={(e) => handleQuestionChange(index, e.target.value)}
|
||
placeholder={`问题 ${index + 1}`}
|
||
/>
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
onClick={() => handleRemoveQuestion(index)}
|
||
disabled={questions.length <= 1}
|
||
>
|
||
<Trash2 className="h-4 w-4 text-muted-foreground hover:text-destructive" />
|
||
</Button>
|
||
</div>
|
||
))}
|
||
<Button variant="outline" onClick={handleAddQuestion} className="w-full">
|
||
<Plus className="mr-2 h-4 w-4" /> 添加短语
|
||
</Button>
|
||
<Button onClick={handleSaveQuestions} className="w-full md:w-auto">
|
||
<Save className="mr-2 h-4 w-4" /> 保存设置
|
||
</Button>
|
||
</CardContent>
|
||
</Card>
|
||
</TabsContent>
|
||
|
||
{/* 认证设置 */}
|
||
<TabsContent value="auth" className="mt-6">
|
||
<Card>
|
||
<CardContent className="space-y-6 pt-6">
|
||
<div className="flex items-center justify-between">
|
||
<div className="space-y-0.5">
|
||
<Label>开放注册</Label>
|
||
<p className="text-sm text-muted-foreground">允许新用户注册账号</p>
|
||
</div>
|
||
<Switch
|
||
checked={authSettings.isRegistrationEnabled}
|
||
onCheckedChange={(checked) => setAuthSettings({ ...authSettings, isRegistrationEnabled: checked })}
|
||
/>
|
||
</div>
|
||
<Separator />
|
||
<div className="space-y-4">
|
||
<h3 className="text-lg font-semibold">钉钉登录</h3>
|
||
<p className="text-sm text-muted-foreground">
|
||
支持配置多个钉钉组织,每个组织包含名称、AppKey、AppSecret。默认使用第一个组织作为登录入口。
|
||
</p>
|
||
|
||
<div className="space-y-3">
|
||
{authSettings.dingTalkOrganizations.map((org, index) => (
|
||
<div
|
||
key={org.id}
|
||
className="grid grid-cols-1 gap-3 rounded-md border p-3 md:grid-cols-3"
|
||
>
|
||
<div className="space-y-1">
|
||
<Label htmlFor={`ding-org-name-${index}`}>组织名称</Label>
|
||
<Input
|
||
id={`ding-org-name-${index}`}
|
||
value={org.name}
|
||
placeholder={`例如:总部 / 研发中心 ${index + 1}`}
|
||
onChange={(e) => {
|
||
const value = e.target.value;
|
||
setAuthSettings({
|
||
...authSettings,
|
||
dingTalkOrganizations:
|
||
authSettings.dingTalkOrganizations.map(
|
||
(item, i) =>
|
||
i === index ? { ...item, name: value } : item,
|
||
),
|
||
});
|
||
}}
|
||
/>
|
||
</div>
|
||
<div className="space-y-1">
|
||
<Label htmlFor={`ding-org-appkey-${index}`}>AppKey</Label>
|
||
<Input
|
||
id={`ding-org-appkey-${index}`}
|
||
type="password"
|
||
value={org.appKey}
|
||
onChange={(e) => {
|
||
const value = e.target.value;
|
||
setAuthSettings({
|
||
...authSettings,
|
||
dingTalkOrganizations:
|
||
authSettings.dingTalkOrganizations.map(
|
||
(item, i) =>
|
||
i === index ? { ...item, appKey: value } : item,
|
||
),
|
||
});
|
||
}}
|
||
/>
|
||
</div>
|
||
<div className="space-y-1">
|
||
<Label htmlFor={`ding-org-appsecret-${index}`}>
|
||
AppSecret
|
||
</Label>
|
||
<div className="flex items-center gap-2">
|
||
<Input
|
||
id={`ding-org-appsecret-${index}`}
|
||
type="password"
|
||
value={org.appSecret}
|
||
onChange={(e) => {
|
||
const value = e.target.value;
|
||
setAuthSettings({
|
||
...authSettings,
|
||
dingTalkOrganizations:
|
||
authSettings.dingTalkOrganizations.map(
|
||
(item, i) =>
|
||
i === index
|
||
? { ...item, appSecret: value }
|
||
: item,
|
||
),
|
||
});
|
||
}}
|
||
/>
|
||
<Button
|
||
type="button"
|
||
variant="ghost"
|
||
size="icon"
|
||
className="shrink-0"
|
||
onClick={() => {
|
||
setAuthSettings({
|
||
...authSettings,
|
||
dingTalkOrganizations:
|
||
authSettings.dingTalkOrganizations.filter(
|
||
(_, i) => i !== index,
|
||
),
|
||
});
|
||
}}
|
||
disabled={authSettings.dingTalkOrganizations.length <= 1}
|
||
>
|
||
<Trash2 className="h-4 w-4 text-muted-foreground" />
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
className="w-full md:w-auto"
|
||
onClick={() => {
|
||
setAuthSettings({
|
||
...authSettings,
|
||
dingTalkOrganizations: [
|
||
...authSettings.dingTalkOrganizations,
|
||
{
|
||
id: crypto.randomUUID(),
|
||
name: `新组织 ${authSettings.dingTalkOrganizations.length + 1}`,
|
||
appKey: "",
|
||
appSecret: "",
|
||
},
|
||
],
|
||
});
|
||
}}
|
||
>
|
||
<Plus className="mr-2 h-4 w-4" />
|
||
新增组织
|
||
</Button>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-2 pt-2">
|
||
<Switch
|
||
id="dingTalkEnabled"
|
||
checked={authSettings.isDingTalkEnabled}
|
||
onCheckedChange={(checked) => setAuthSettings({ ...authSettings, isDingTalkEnabled: checked })}
|
||
/>
|
||
<Label htmlFor="dingTalkEnabled">启用钉钉登录</Label>
|
||
</div>
|
||
</div>
|
||
<Separator />
|
||
<Button onClick={handleSaveAuthSettings}>
|
||
<Save className="mr-2 h-4 w-4" /> 保存认证配置
|
||
</Button>
|
||
</CardContent>
|
||
</Card>
|
||
</TabsContent>
|
||
|
||
{/* 系统通知设置 */}
|
||
<TabsContent value="notification" className="mt-6">
|
||
<Card>
|
||
<CardContent className="space-y-6 pt-6">
|
||
<div className="flex items-center gap-2">
|
||
<Switch
|
||
id="notification-enabled"
|
||
checked={notification.enabled}
|
||
onCheckedChange={(checked) => setNotification({ ...notification, enabled: checked })}
|
||
/>
|
||
<Label htmlFor="notification-enabled">启用通知弹窗</Label>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label htmlFor="notification-title">通知标题</Label>
|
||
<Input
|
||
id="notification-title"
|
||
value={notification.title}
|
||
onChange={(e) => setNotification({ ...notification, title: e.target.value })}
|
||
placeholder="例如:系统维护公告"
|
||
/>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label htmlFor="notification-content">通知内容</Label>
|
||
<Textarea
|
||
id="notification-content"
|
||
value={notification.content}
|
||
onChange={(e) => setNotification({ ...notification, content: e.target.value })}
|
||
placeholder="请输入通知详细内容..."
|
||
rows={5}
|
||
/>
|
||
</div>
|
||
<Button onClick={handleSaveNotification}>
|
||
<Save className="mr-2 h-4 w-4" /> 保存通知设置
|
||
</Button>
|
||
</CardContent>
|
||
</Card>
|
||
</TabsContent>
|
||
|
||
{/* AI 模型配置 */}
|
||
<TabsContent value="ai" className="mt-6 space-y-6">
|
||
<Card>
|
||
<CardHeader className="flex flex-row items-center justify-between">
|
||
<div>
|
||
<CardTitle>AI 提供商</CardTitle>
|
||
<CardDescription>配置 AI 服务提供商</CardDescription>
|
||
</div>
|
||
<Dialog open={showProviderDialog} onOpenChange={setShowProviderDialog}>
|
||
<DialogTrigger asChild>
|
||
<Button onClick={() => { resetProviderForm(); setShowProviderDialog(true); }}>
|
||
<Plus className="mr-2 h-4 w-4" /> 添加提供商
|
||
</Button>
|
||
</DialogTrigger>
|
||
<DialogContent>
|
||
<DialogHeader>
|
||
<DialogTitle>{editingProvider ? "编辑提供商" : "添加提供商"}</DialogTitle>
|
||
</DialogHeader>
|
||
<div className="space-y-4">
|
||
<div className="space-y-2">
|
||
<Label>名称</Label>
|
||
<Input
|
||
value={providerForm.name}
|
||
onChange={(e) => setProviderForm({ ...providerForm, name: e.target.value })}
|
||
placeholder="阿里云通义千问"
|
||
/>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label>类型</Label>
|
||
<Select
|
||
value={providerForm.type}
|
||
onValueChange={(value) => setProviderForm({ ...providerForm, type: value as any })}
|
||
>
|
||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||
<SelectContent>
|
||
{PROVIDER_TYPES.map((type) => (
|
||
<SelectItem key={type.value} value={type.value}>{type.label}</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label>API 地址</Label>
|
||
<Input
|
||
value={providerForm.baseUrl}
|
||
onChange={(e) => setProviderForm({ ...providerForm, baseUrl: e.target.value })}
|
||
/>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label>API Key</Label>
|
||
<div className="relative">
|
||
<Input
|
||
type={showApiKey ? "text" : "password"}
|
||
value={providerForm.apiKey}
|
||
onChange={(e) => setProviderForm({ ...providerForm, apiKey: e.target.value })}
|
||
placeholder={editingProvider ? "留空则不修改" : "sk-..."}
|
||
/>
|
||
<Button
|
||
type="button" variant="ghost" size="sm"
|
||
className="absolute right-2 top-1/2 -translate-y-1/2"
|
||
onClick={() => setShowApiKey(!showApiKey)}
|
||
>
|
||
{showApiKey ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<Switch
|
||
checked={providerForm.isEnabled}
|
||
onCheckedChange={(checked) => setProviderForm({ ...providerForm, isEnabled: checked })}
|
||
/>
|
||
<Label>启用</Label>
|
||
</div>
|
||
</div>
|
||
<DialogFooter>
|
||
<Button variant="outline" onClick={() => setShowProviderDialog(false)}>取消</Button>
|
||
<Button onClick={handleSaveProvider}>保存</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<div className="space-y-4">
|
||
{providersData?.providers?.map((provider) => (
|
||
<div key={provider.id} className="flex items-center justify-between p-4 border rounded-lg">
|
||
<div className="flex-1">
|
||
<div className="font-medium flex items-center gap-2">
|
||
{provider.name}
|
||
{!provider.isEnabled && <span className="text-xs bg-gray-200 px-2 py-0.5 rounded">已禁用</span>}
|
||
</div>
|
||
<div className="text-sm text-muted-foreground">
|
||
类型: {PROVIDER_TYPES.find((t) => t.value === provider.type)?.label} | API: {provider.baseUrl}
|
||
</div>
|
||
{provider.type === 'volcano-video' && (
|
||
<Button
|
||
variant="secondary"
|
||
size="sm"
|
||
className="mt-2"
|
||
onClick={() => handleQuickAddVolcanoModels(provider.id)}
|
||
>
|
||
<Plus className="h-4 w-4 mr-1" />
|
||
快速添加预设模型
|
||
</Button>
|
||
)}
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<Button variant="outline" size="sm" onClick={() => {
|
||
setProviderForm({
|
||
id: provider.id,
|
||
name: provider.name,
|
||
type: provider.type,
|
||
baseUrl: provider.baseUrl,
|
||
apiKey: "",
|
||
isEnabled: provider.isEnabled,
|
||
});
|
||
setEditingProvider(provider.id);
|
||
setShowProviderDialog(true);
|
||
}}>
|
||
<Edit className="h-4 w-4" />
|
||
</Button>
|
||
<Button variant="destructive" size="sm" onClick={() => handleDeleteProvider(provider.id)}>
|
||
<Trash2 className="h-4 w-4" />
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Card>
|
||
<CardHeader className="flex flex-row items-center justify-between">
|
||
<div>
|
||
<CardTitle>AI 模型</CardTitle>
|
||
<CardDescription>配置可用的 AI 模型</CardDescription>
|
||
</div>
|
||
<Dialog open={showModelDialog} onOpenChange={setShowModelDialog}>
|
||
<DialogTrigger asChild>
|
||
<Button
|
||
onClick={() => { resetModelForm(); setShowModelDialog(true); }}
|
||
disabled={!providersData?.providers?.length}
|
||
>
|
||
<Plus className="mr-2 h-4 w-4" /> 添加模型
|
||
</Button>
|
||
</DialogTrigger>
|
||
<DialogContent>
|
||
<DialogHeader>
|
||
<DialogTitle>{editingModel ? "编辑模型" : "添加模型"}</DialogTitle>
|
||
</DialogHeader>
|
||
<div className="space-y-4 max-h-[70vh] overflow-y-auto">
|
||
<div className="space-y-2">
|
||
<Label>所属提供商</Label>
|
||
<Select
|
||
value={modelForm.providerId}
|
||
onValueChange={(value) => setModelForm({ ...modelForm, providerId: value })}
|
||
>
|
||
<SelectTrigger><SelectValue placeholder="选择提供商" /></SelectTrigger>
|
||
<SelectContent>
|
||
{providersData?.providers?.map((p) => (
|
||
<SelectItem key={p.id} value={p.id}>{p.name}</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
|
||
{/* 🆕 火山引擎视频模型:显示4个模型配置表单 */}
|
||
{!editingModel && providersData?.providers?.find(p => p.id === modelForm.providerId)?.type === 'volcano-video' ? (
|
||
<div className="space-y-4">
|
||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3 text-sm text-blue-800">
|
||
<p className="font-medium">📹 火山引擎视频模型批量配置</p>
|
||
<p className="text-xs mt-1">将一次性添加以下4个预设模型,您可以修改模型名称和描述</p>
|
||
</div>
|
||
|
||
{volcanoVideoModels.map((model, index) => (
|
||
<div key={index} className="border rounded-lg p-4 space-y-3">
|
||
<div className="flex items-center justify-between">
|
||
<h4 className="font-medium text-sm">模型 {index + 1}</h4>
|
||
<span className="text-xs text-muted-foreground">排序: {(index + 1) * 10}</span>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label className="text-xs">模型 ID *</Label>
|
||
<Input
|
||
value={model.modelId}
|
||
onChange={(e) => {
|
||
const newModels = [...volcanoVideoModels];
|
||
newModels[index].modelId = e.target.value;
|
||
setVolcanoVideoModels(newModels);
|
||
}}
|
||
placeholder="例如: doubao-seedance-1-0-pro-250528"
|
||
/>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label className="text-xs">显示名称 *</Label>
|
||
<Input
|
||
value={model.displayName}
|
||
onChange={(e) => {
|
||
const newModels = [...volcanoVideoModels];
|
||
newModels[index].displayName = e.target.value;
|
||
setVolcanoVideoModels(newModels);
|
||
}}
|
||
placeholder="例如: 豆包 Seedance Pro"
|
||
/>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label className="text-xs">描述</Label>
|
||
<Textarea
|
||
value={model.description}
|
||
onChange={(e) => {
|
||
const newModels = [...volcanoVideoModels];
|
||
newModels[index].description = e.target.value;
|
||
setVolcanoVideoModels(newModels);
|
||
}}
|
||
placeholder="模型功能描述"
|
||
className="resize-none"
|
||
rows={2}
|
||
/>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
) : (
|
||
/* 普通模型:单个配置表单 */
|
||
<>
|
||
<div className="space-y-2">
|
||
<Label>模型 ID</Label>
|
||
<Input
|
||
value={modelForm.modelId}
|
||
onChange={(e) => setModelForm({ ...modelForm, modelId: e.target.value })}
|
||
placeholder="例如: qwen-turbo"
|
||
/>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label>显示名称</Label>
|
||
<Input
|
||
value={modelForm.displayName}
|
||
onChange={(e) => setModelForm({ ...modelForm, displayName: e.target.value })}
|
||
/>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label>描述</Label>
|
||
<Textarea
|
||
value={modelForm.description}
|
||
onChange={(e) => setModelForm({ ...modelForm, description: e.target.value })}
|
||
/>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label>排序顺序</Label>
|
||
<Input
|
||
type="number"
|
||
value={modelForm.sortOrder}
|
||
onChange={(e) => setModelForm({ ...modelForm, sortOrder: parseInt(e.target.value) || 0 })}
|
||
/>
|
||
</div>
|
||
|
||
{/* 🆕 备用模型 ID 配置 */}
|
||
<div className="space-y-2 border-t pt-4 mt-4">
|
||
<div className="flex items-center gap-2 mb-2">
|
||
<Label className="text-sm font-medium">备用模型配置</Label>
|
||
<span className="text-xs text-muted-foreground">(主模型请求失败时自动切换)</span>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label className="text-xs text-muted-foreground">备用模型 ID 1(优先使用)</Label>
|
||
<Input
|
||
value={modelForm.fallbackModelId1}
|
||
onChange={(e) => setModelForm({ ...modelForm, fallbackModelId1: e.target.value })}
|
||
placeholder="例如: nano-banana-pro-backup"
|
||
/>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label className="text-xs text-muted-foreground">备用模型 ID 2(最后备用)</Label>
|
||
<Input
|
||
value={modelForm.fallbackModelId2}
|
||
onChange={(e) => setModelForm({ ...modelForm, fallbackModelId2: e.target.value })}
|
||
placeholder="例如: nano-banana-lite"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex gap-4">
|
||
<div className="flex items-center gap-2">
|
||
<Switch
|
||
checked={modelForm.supportsVision}
|
||
onCheckedChange={(checked) => setModelForm({ ...modelForm, supportsVision: checked })}
|
||
/>
|
||
<Label>支持视觉</Label>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<Switch
|
||
checked={modelForm.supportsImageGen}
|
||
onCheckedChange={(checked) => setModelForm({ ...modelForm, supportsImageGen: checked })}
|
||
/>
|
||
<Label>支持绘图</Label>
|
||
</div>
|
||
</div>
|
||
<div className="flex gap-4">
|
||
<div className="flex items-center gap-2">
|
||
<Switch
|
||
checked={modelForm.isEnabled}
|
||
onCheckedChange={(checked) => setModelForm({ ...modelForm, isEnabled: checked })}
|
||
/>
|
||
<Label>启用</Label>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<Switch
|
||
checked={modelForm.isDefault}
|
||
onCheckedChange={(checked) => setModelForm({ ...modelForm, isDefault: checked })}
|
||
/>
|
||
<Label>设为默认</Label>
|
||
</div>
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
<DialogFooter>
|
||
<Button variant="outline" onClick={() => setShowModelDialog(false)}>取消</Button>
|
||
<Button onClick={handleSaveModel}>保存</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<div className="space-y-4">
|
||
{modelsData?.models?.map((model) => {
|
||
const provider = providersData?.providers?.find((p) => p.id === model.providerId);
|
||
return (
|
||
<div key={model.id} className="flex items-center justify-between p-4 border rounded-lg">
|
||
<div className="flex-1">
|
||
<div className="font-medium flex items-center gap-2">
|
||
{model.displayName}
|
||
{model.isDefault && <span className="text-xs bg-blue-100 text-blue-800 px-2 py-0.5 rounded">默认</span>}
|
||
</div>
|
||
<div className="text-sm text-muted-foreground">
|
||
ID: {model.modelId} | 提供商: {provider?.name ?? "未知"}
|
||
</div>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<Button variant="outline" size="sm" onClick={() => {
|
||
setModelForm({
|
||
id: model.id,
|
||
providerId: model.providerId,
|
||
modelId: model.modelId,
|
||
displayName: model.displayName,
|
||
description: model.description || "",
|
||
supportsVision: model.supportsVision,
|
||
supportsImageGen: model.supportsImageGen,
|
||
isEnabled: model.isEnabled,
|
||
isDefault: model.isDefault,
|
||
sortOrder: model.sortOrder || 0,
|
||
fallbackModelId1: model.fallbackModelId1 || "",
|
||
fallbackModelId2: model.fallbackModelId2 || "",
|
||
});
|
||
setEditingModel(model.id);
|
||
setShowModelDialog(true);
|
||
}}>
|
||
<Edit className="h-4 w-4" />
|
||
</Button>
|
||
<Button variant="destructive" size="sm" onClick={() => handleDeleteModel(model.id)}>
|
||
<Trash2 className="h-4 w-4" />
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
</TabsContent>
|
||
|
||
{/* 存储配置 */}
|
||
<TabsContent value="storage" className="mt-6">
|
||
<Card>
|
||
<CardHeader className="flex flex-row items-center justify-between">
|
||
<div>
|
||
<CardTitle>存储配置</CardTitle>
|
||
<CardDescription>配置 S3 兼容对象存储</CardDescription>
|
||
</div>
|
||
<Dialog open={showStorageDialog} onOpenChange={setShowStorageDialog}>
|
||
<DialogTrigger asChild>
|
||
<Button onClick={() => { resetStorageForm(); setShowStorageDialog(true); }}>
|
||
<Plus className="mr-2 h-4 w-4" /> 添加配置
|
||
</Button>
|
||
</DialogTrigger>
|
||
<DialogContent>
|
||
<DialogHeader>
|
||
<DialogTitle>{editingStorage ? "编辑配置" : "添加配置"}</DialogTitle>
|
||
</DialogHeader>
|
||
<div className="space-y-4 max-h-96 overflow-y-auto">
|
||
<div className="space-y-2">
|
||
<Label>配置名称</Label>
|
||
<Input
|
||
value={storageForm.name}
|
||
onChange={(e) => setStorageForm({ ...storageForm, name: e.target.value })}
|
||
/>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label>类型</Label>
|
||
<Select
|
||
value={storageForm.type}
|
||
onValueChange={(value) => setStorageForm({ ...storageForm, type: value as any })}
|
||
>
|
||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="s3">S3 兼容</SelectItem>
|
||
<SelectItem value="local">本地存储</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
{storageForm.type === "s3" && (
|
||
<>
|
||
<div className="space-y-2">
|
||
<Label>端点 (Endpoint)</Label>
|
||
<Input
|
||
value={storageForm.endpoint}
|
||
onChange={(e) => setStorageForm({ ...storageForm, endpoint: e.target.value })}
|
||
/>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label>Bucket</Label>
|
||
<Input
|
||
value={storageForm.bucket}
|
||
onChange={(e) => setStorageForm({ ...storageForm, bucket: e.target.value })}
|
||
/>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label>Access Key</Label>
|
||
<Input
|
||
value={storageForm.accessKeyId}
|
||
onChange={(e) => setStorageForm({ ...storageForm, accessKeyId: e.target.value })}
|
||
placeholder={editingStorage ? "留空则不修改" : ""}
|
||
/>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label>Secret Key</Label>
|
||
<Input
|
||
type="password"
|
||
value={storageForm.secretAccessKey}
|
||
onChange={(e) => setStorageForm({ ...storageForm, secretAccessKey: e.target.value })}
|
||
placeholder={editingStorage ? "留空则不修改" : ""}
|
||
/>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label>Region</Label>
|
||
<Input
|
||
value={storageForm.region}
|
||
onChange={(e) => setStorageForm({ ...storageForm, region: e.target.value })}
|
||
/>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label>Public URL</Label>
|
||
<Input
|
||
value={storageForm.publicUrl}
|
||
onChange={(e) => setStorageForm({ ...storageForm, publicUrl: e.target.value })}
|
||
/>
|
||
</div>
|
||
</>
|
||
)}
|
||
<div className="flex gap-4">
|
||
<div className="flex items-center gap-2">
|
||
<Switch
|
||
checked={storageForm.isEnabled}
|
||
onCheckedChange={(checked) => setStorageForm({ ...storageForm, isEnabled: checked })}
|
||
/>
|
||
<Label>启用</Label>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<Switch
|
||
checked={storageForm.isDefault}
|
||
onCheckedChange={(checked) => setStorageForm({ ...storageForm, isDefault: checked })}
|
||
/>
|
||
<Label>默认</Label>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<DialogFooter>
|
||
<Button variant="outline" onClick={() => setShowStorageDialog(false)}>取消</Button>
|
||
<Button onClick={handleSaveStorage}>保存</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<div className="space-y-4">
|
||
{storageData?.configs?.map((config) => (
|
||
<div key={config.id} className="flex items-center justify-between p-4 border rounded-lg">
|
||
<div className="flex-1">
|
||
<div className="font-medium flex items-center gap-2">
|
||
{config.name}
|
||
{config.isDefault && <span className="text-xs bg-blue-100 text-blue-800 px-2 py-0.5 rounded">默认</span>}
|
||
{!config.isEnabled && <span className="text-xs bg-gray-200 px-2 py-0.5 rounded">已禁用</span>}
|
||
</div>
|
||
<div className="text-sm text-muted-foreground">
|
||
{config.type === "s3" ? `S3 | Bucket: ${config.bucket}` : "本地存储"}
|
||
</div>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<Button variant="outline" size="sm" onClick={() => {
|
||
setStorageForm({
|
||
id: config.id,
|
||
name: config.name,
|
||
type: config.type,
|
||
endpoint: config.endpoint || "",
|
||
region: config.region || "",
|
||
bucket: config.bucket || "",
|
||
accessKeyId: "",
|
||
secretAccessKey: "",
|
||
publicUrl: config.publicUrl || "",
|
||
isDefault: config.isDefault,
|
||
isEnabled: config.isEnabled,
|
||
});
|
||
setEditingStorage(config.id);
|
||
setShowStorageDialog(true);
|
||
}}>
|
||
<Edit className="h-4 w-4" />
|
||
</Button>
|
||
<Button variant="destructive" size="sm" onClick={() => handleDeleteStorage(config.id)}>
|
||
<Trash2 className="h-4 w-4" />
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
</TabsContent>
|
||
|
||
{/* 抠图设置 */}
|
||
<TabsContent value="rmbg" className="mt-6">
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>抠图API配置</CardTitle>
|
||
<CardDescription>
|
||
配置RMBG-2.0背景移除API,用于画图标注功能中的抠图处理
|
||
</CardDescription>
|
||
</CardHeader>
|
||
<CardContent className="space-y-6 pt-6">
|
||
<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 className="text-xs text-muted-foreground">
|
||
如果API需要认证,请填写API密钥(可选)
|
||
</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地址应指向RMBG-2.0服务的根地址</p>
|
||
<p>• 系统会自动调用 /rmbg/base64 接口进行抠图处理</p>
|
||
<p>• 支持的图片格式:JPG, PNG, JPEG等常见格式</p>
|
||
<p>• 输出格式:PNG格式的透明背景图片</p>
|
||
</div>
|
||
</div>
|
||
<Button onClick={handleSaveRmbgConfig}>
|
||
<Save className="mr-2 h-4 w-4" /> 保存配置
|
||
</Button>
|
||
</CardContent>
|
||
</Card>
|
||
</TabsContent>
|
||
|
||
</Tabs>
|
||
|
||
{/* 批量设置出图限额对话框 */}
|
||
<Dialog open={showBatchLimitDialog} onOpenChange={setShowBatchLimitDialog}>
|
||
<DialogContent className="sm:max-w-[500px]">
|
||
<DialogHeader>
|
||
<DialogTitle>批量设置出图限额</DialogTitle>
|
||
</DialogHeader>
|
||
<div className="space-y-4 pt-4">
|
||
{/* 设置类型选择 */}
|
||
<div className="space-y-2">
|
||
<Label>设置范围</Label>
|
||
<Select
|
||
value={batchLimitType}
|
||
onValueChange={(value: "all" | "department" | "no_department") => {
|
||
setBatchLimitType(value);
|
||
setBatchLimitDepartment("");
|
||
}}
|
||
>
|
||
<SelectTrigger>
|
||
<SelectValue placeholder="选择设置范围" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="all">所有用户</SelectItem>
|
||
<SelectItem value="department">按部门设置</SelectItem>
|
||
<SelectItem value="no_department">无部门用户</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
|
||
{/* 部门选择(仅在按部门设置时显示) */}
|
||
{batchLimitType === "department" && (
|
||
<div className="space-y-2">
|
||
<Label>选择部门</Label>
|
||
<Select
|
||
value={batchLimitDepartment}
|
||
onValueChange={setBatchLimitDepartment}
|
||
>
|
||
<SelectTrigger>
|
||
<SelectValue placeholder="选择部门" />
|
||
</SelectTrigger>
|
||
<SelectContent className="max-h-60 overflow-y-auto">
|
||
{departmentsData?.departments?.map((dept) => (
|
||
<SelectItem key={dept} value={dept}>
|
||
{dept}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
{departmentsData?.departments?.length === 0 && (
|
||
<p className="text-sm text-muted-foreground">暂无部门数据,请先同步钉钉用户</p>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* 限额输入 */}
|
||
<div className="space-y-2">
|
||
<Label>月度出图限额</Label>
|
||
<div className="flex items-center gap-2">
|
||
<Input
|
||
type="number"
|
||
min="0"
|
||
placeholder="留空表示无限制"
|
||
value={batchLimitValue}
|
||
onChange={(e) => setBatchLimitValue(e.target.value)}
|
||
className="flex-1"
|
||
/>
|
||
<span className="text-sm text-muted-foreground">张/月</span>
|
||
</div>
|
||
<p className="text-xs text-muted-foreground">
|
||
设置为空或0表示无限制
|
||
</p>
|
||
</div>
|
||
|
||
{/* 预览信息 */}
|
||
<div className="rounded-md bg-muted p-3 text-sm">
|
||
<p className="font-medium mb-1">即将执行:</p>
|
||
<p className="text-muted-foreground">
|
||
{batchLimitType === "all" && "将为所有用户设置出图限额"}
|
||
{batchLimitType === "department" && batchLimitDepartment && `将为部门「${batchLimitDepartment}」的用户设置出图限额`}
|
||
{batchLimitType === "department" && !batchLimitDepartment && "请先选择部门"}
|
||
{batchLimitType === "no_department" && "将为所有无部门用户设置出图限额"}
|
||
{" → "}
|
||
{batchLimitValue ? `${batchLimitValue} 张/月` : "无限制"}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
<DialogFooter className="mt-4">
|
||
<Button variant="outline" onClick={() => setShowBatchLimitDialog(false)}>
|
||
取消
|
||
</Button>
|
||
<Button
|
||
onClick={handleBatchSetLimit}
|
||
disabled={batchLimitType === "department" && !batchLimitDepartment}
|
||
>
|
||
确认设置
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
</div>
|
||
);
|
||
}
|