lovart3/app/(admin)/settings/admin-settings-client.tsx
2025-12-18 11:08:14 +08:00

2550 lines
106 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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

"use client";
import { useState, useEffect } from "react";
import useSWR from "swr";
import { toast } from "sonner";
import {
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,
} 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;
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 AuthSettings = {
dingTalkAppKey: string | null;
dingTalkAppSecret: string | null;
isDingTalkEnabled: boolean;
isRegistrationEnabled?: boolean;
};
type GeneratedImageRecord = {
id: string;
userId: string;
userName: string | null;
userEmail: string;
thumbnailUrl: string;
originalUrl: string;
imageSize: string | null;
prompt: string | null;
modelId: 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 [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: 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<AuthSettings>("/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 } = 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]);
// 导航链接状态
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,
});
useEffect(() => {
if (authSettingsData) {
setAuthSettings({
dingTalkAppKey: authSettingsData.dingTalkAppKey || "",
dingTalkAppSecret: authSettingsData.dingTalkAppSecret || "",
isDingTalkEnabled: authSettingsData.isDingTalkEnabled || false,
isRegistrationEnabled: authSettingsData.isRegistrationEnabled ?? true,
});
}
}, [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,
});
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 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("保存网站设置失败");
}
};
// --- 导航链接逻辑 ---
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,
});
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-10">
<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>
</TabsList>
{/* 用户管理 */}
<TabsContent value="users" className="mt-6">
<Card>
<CardContent className="pt-6">
{/* 搜索框 */}
<div className="mb-4">
<Input
type="text"
placeholder="搜索用户(邮箱或用户名)..."
value={userSearch}
onChange={(e) => handleUserSearch(e.target.value)}
className="max-w-md"
/>
</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">
角色: {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">
<img
src={img.thumbnailUrl}
alt={img.prompt || "AI生成的图片"}
className="w-full h-full object-cover"
loading="lazy"
/>
</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>图片详情</DialogTitle>
</DialogHeader>
{selectedImage && (
<div className="space-y-4">
<div className="flex justify-center">
<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>{new Date(selectedImage.createdAt).toLocaleString("zh-CN")}</p>
</div>
<div>
<Label className="text-muted-foreground">图片尺寸</Label>
<p>{selectedImage.imageSize || "未知"}</p>
</div>
<div>
<Label className="text-muted-foreground">模型</Label>
<p>{selectedImage.modelId || "未知"}</p>
</div>
<div className="col-span-2">
<Label className="text-muted-foreground">提示词</Label>
<p className="mt-1">{selectedImage.prompt || "无"}</p>
</div>
</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>
<div className="space-y-2">
<Label htmlFor="dingTalkAppKey">AppKey</Label>
<Input
id="dingTalkAppKey"
value={authSettings.dingTalkAppKey || ""}
onChange={(e) => setAuthSettings({ ...authSettings, dingTalkAppKey: e.target.value })}
type="password"
/>
</div>
<div className="space-y-2">
<Label htmlFor="dingTalkAppSecret">AppSecret</Label>
<Input
id="dingTalkAppSecret"
value={authSettings.dingTalkAppSecret || ""}
onChange={(e) => setAuthSettings({ ...authSettings, dingTalkAppSecret: e.target.value })}
type="password"
/>
</div>
<div className="flex items-center gap-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-96 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>
<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,
});
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>
</Tabs>
</div>
);
}