516 lines
19 KiB
TypeScript
516 lines
19 KiB
TypeScript
import React, { useState, useEffect, useCallback } from 'react';
|
||
import {
|
||
PlusIcon,
|
||
PencilIcon,
|
||
TrashIcon,
|
||
EyeIcon,
|
||
ArrowUpIcon,
|
||
ArrowDownIcon,
|
||
CheckIcon,
|
||
XMarkIcon,
|
||
CpuChipIcon
|
||
} from '@heroicons/react/24/outline';
|
||
import { AiClassificationService } from '../services/aiClassificationService';
|
||
import {
|
||
AiClassification,
|
||
AiClassificationFormData,
|
||
AiClassificationFormErrors,
|
||
AiClassificationPreview,
|
||
DEFAULT_FORM_DATA,
|
||
validateClassificationForm,
|
||
hasFormErrors,
|
||
classificationToFormData,
|
||
formDataToCreateRequest,
|
||
formDataToUpdateRequest,
|
||
} from '../types/aiClassification';
|
||
import { LoadingSpinner } from '../components/LoadingSpinner';
|
||
import { ErrorMessage } from '../components/ErrorMessage';
|
||
import { EmptyState } from '../components/EmptyState';
|
||
import { AiClassificationFormDialog } from '../components/AiClassificationFormDialog';
|
||
import { AiClassificationPreviewDialog } from '../components/AiClassificationPreviewDialog';
|
||
import { DeleteConfirmDialog } from '../components/DeleteConfirmDialog';
|
||
|
||
/**
|
||
* AI分类设置页面
|
||
* 遵循前端开发规范的组件设计,实现分类的CRUD操作和实时预览
|
||
*/
|
||
const AiClassificationSettings: React.FC = () => {
|
||
// 状态管理
|
||
const [classifications, setClassifications] = useState<AiClassification[]>([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [showCreateDialog, setShowCreateDialog] = useState(false);
|
||
const [showEditDialog, setShowEditDialog] = useState(false);
|
||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||
const [showPreviewDialog, setShowPreviewDialog] = useState(false);
|
||
const [editingClassification, setEditingClassification] = useState<AiClassification | null>(null);
|
||
const [deletingClassificationId, setDeletingClassificationId] = useState<string | null>(null);
|
||
const [preview, setPreview] = useState<AiClassificationPreview | null>(null);
|
||
const [previewLoading, setPreviewLoading] = useState(false);
|
||
|
||
// 表单状态
|
||
const [formData, setFormData] = useState<AiClassificationFormData>(DEFAULT_FORM_DATA);
|
||
const [formErrors, setFormErrors] = useState<AiClassificationFormErrors>({});
|
||
const [submitting, setSubmitting] = useState(false);
|
||
|
||
// 加载分类列表
|
||
const loadClassifications = useCallback(async () => {
|
||
try {
|
||
setLoading(true);
|
||
setError(null);
|
||
const result = await AiClassificationService.getAllClassificationsIncludingInactive();
|
||
setClassifications(result);
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : '加载分类列表失败');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, []);
|
||
|
||
// 生成预览
|
||
const generatePreview = useCallback(async () => {
|
||
try {
|
||
setPreviewLoading(true);
|
||
const result = await AiClassificationService.generatePreview();
|
||
setPreview(result);
|
||
} catch (err) {
|
||
console.error('生成预览失败:', err);
|
||
} finally {
|
||
setPreviewLoading(false);
|
||
}
|
||
}, []);
|
||
|
||
// 初始化
|
||
useEffect(() => {
|
||
loadClassifications();
|
||
}, [loadClassifications]);
|
||
|
||
// 处理创建分类
|
||
const handleCreate = async () => {
|
||
const errors = validateClassificationForm(formData);
|
||
setFormErrors(errors);
|
||
|
||
if (hasFormErrors(errors)) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
setSubmitting(true);
|
||
const request = formDataToCreateRequest(formData);
|
||
await AiClassificationService.createClassification(request);
|
||
|
||
// 重新加载列表
|
||
await loadClassifications();
|
||
|
||
// 关闭对话框并重置表单
|
||
setShowCreateDialog(false);
|
||
setFormData(DEFAULT_FORM_DATA);
|
||
setFormErrors({});
|
||
} catch (err) {
|
||
setFormErrors({ general: err instanceof Error ? err.message : '创建失败' });
|
||
} finally {
|
||
setSubmitting(false);
|
||
}
|
||
};
|
||
|
||
// 处理编辑分类
|
||
const handleEdit = async () => {
|
||
if (!editingClassification) return;
|
||
|
||
const errors = validateClassificationForm(formData);
|
||
setFormErrors(errors);
|
||
|
||
if (hasFormErrors(errors)) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
setSubmitting(true);
|
||
const request = formDataToUpdateRequest(formData);
|
||
await AiClassificationService.updateClassification(editingClassification.id, request);
|
||
|
||
// 重新加载列表
|
||
await loadClassifications();
|
||
|
||
// 关闭对话框并重置状态
|
||
setShowEditDialog(false);
|
||
setEditingClassification(null);
|
||
setFormData(DEFAULT_FORM_DATA);
|
||
setFormErrors({});
|
||
} catch (err) {
|
||
setFormErrors({ general: err instanceof Error ? err.message : '更新失败' });
|
||
} finally {
|
||
setSubmitting(false);
|
||
}
|
||
};
|
||
|
||
// 处理删除分类
|
||
const handleDelete = async () => {
|
||
if (!deletingClassificationId) return;
|
||
|
||
try {
|
||
setSubmitting(true);
|
||
await AiClassificationService.deleteClassification(deletingClassificationId);
|
||
|
||
// 重新加载列表
|
||
await loadClassifications();
|
||
|
||
// 关闭对话框并重置状态
|
||
setShowDeleteDialog(false);
|
||
setDeletingClassificationId(null);
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : '删除失败');
|
||
} finally {
|
||
setSubmitting(false);
|
||
}
|
||
};
|
||
|
||
// 处理切换状态
|
||
const handleToggleStatus = async (id: string) => {
|
||
try {
|
||
await AiClassificationService.toggleClassificationStatus(id);
|
||
await loadClassifications();
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : '切换状态失败');
|
||
}
|
||
};
|
||
|
||
// 处理移动排序
|
||
const handleMoveUp = async (classification: AiClassification) => {
|
||
const currentIndex = classifications.findIndex(c => c.id === classification.id);
|
||
if (currentIndex <= 0) return;
|
||
|
||
// 重新分配所有分类的排序顺序
|
||
const reorderedIds = [...classifications];
|
||
// 交换当前项和上一项的位置
|
||
[reorderedIds[currentIndex], reorderedIds[currentIndex - 1]] =
|
||
[reorderedIds[currentIndex - 1], reorderedIds[currentIndex]];
|
||
|
||
// 生成新的排序更新
|
||
const updates = reorderedIds.map((item, index) => ({
|
||
id: item.id,
|
||
sort_order: index + 1, // 从1开始排序
|
||
}));
|
||
|
||
try {
|
||
await AiClassificationService.updateSortOrders(updates);
|
||
await loadClassifications();
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : '调整排序失败');
|
||
}
|
||
};
|
||
|
||
const handleMoveDown = async (classification: AiClassification) => {
|
||
const currentIndex = classifications.findIndex(c => c.id === classification.id);
|
||
if (currentIndex >= classifications.length - 1) return;
|
||
|
||
// 重新分配所有分类的排序顺序
|
||
const reorderedIds = [...classifications];
|
||
// 交换当前项和下一项的位置
|
||
[reorderedIds[currentIndex], reorderedIds[currentIndex + 1]] =
|
||
[reorderedIds[currentIndex + 1], reorderedIds[currentIndex]];
|
||
|
||
// 生成新的排序更新
|
||
const updates = reorderedIds.map((item, index) => ({
|
||
id: item.id,
|
||
sort_order: index + 1, // 从1开始排序
|
||
}));
|
||
|
||
try {
|
||
await AiClassificationService.updateSortOrders(updates);
|
||
await loadClassifications();
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : '调整排序失败');
|
||
}
|
||
};
|
||
|
||
// 打开创建对话框
|
||
const openCreateDialog = () => {
|
||
// 设置新分类的排序顺序为最大值+1
|
||
const maxSortOrder = classifications.length > 0
|
||
? Math.max(...classifications.map(c => c.sort_order))
|
||
: 0;
|
||
|
||
setFormData({
|
||
...DEFAULT_FORM_DATA,
|
||
sort_order: maxSortOrder + 1,
|
||
});
|
||
setFormErrors({});
|
||
setShowCreateDialog(true);
|
||
};
|
||
|
||
// 打开编辑对话框
|
||
const openEditDialog = (classification: AiClassification) => {
|
||
setEditingClassification(classification);
|
||
setFormData(classificationToFormData(classification));
|
||
setFormErrors({});
|
||
setShowEditDialog(true);
|
||
};
|
||
|
||
// 打开删除确认对话框
|
||
const openDeleteDialog = (id: string) => {
|
||
setDeletingClassificationId(id);
|
||
setShowDeleteDialog(true);
|
||
};
|
||
|
||
// 打开预览对话框
|
||
const openPreviewDialog = async () => {
|
||
setShowPreviewDialog(true);
|
||
await generatePreview();
|
||
};
|
||
|
||
// 渲染加载状态
|
||
if (loading) {
|
||
return (
|
||
<div className="flex items-center justify-center min-h-96">
|
||
<LoadingSpinner size="large" />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// 渲染错误状态
|
||
if (error) {
|
||
return (
|
||
<div className="p-6">
|
||
<ErrorMessage
|
||
message={error}
|
||
onRetry={loadClassifications}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="min-h-screen bg-gray-50/30">
|
||
<div className="max-w-6xl mx-auto px-4 py-6">
|
||
{/* 美观的页面头部 */}
|
||
<div className="mb-6">
|
||
<div className="page-header bg-gradient-to-r from-white via-purple-50/30 to-white rounded-xl shadow-sm border border-gray-200/50 p-6 relative overflow-hidden">
|
||
<div className="absolute top-0 right-0 w-32 h-32 bg-gradient-to-br from-purple-100/30 to-pink-100/30 rounded-full -translate-y-16 translate-x-16 opacity-50"></div>
|
||
|
||
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4 relative z-10">
|
||
<div className="flex items-center gap-4">
|
||
<div className="w-12 h-12 bg-gradient-to-br from-purple-500 to-pink-500 rounded-xl flex items-center justify-center shadow-sm">
|
||
<CpuChipIcon className="h-6 w-6 text-white" />
|
||
</div>
|
||
<div>
|
||
<h1 className="text-2xl font-bold text-gray-900 mb-1">AI分类设置</h1>
|
||
<p className="text-sm text-gray-600">管理视频AI分类规则和提示词</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-3">
|
||
<button
|
||
onClick={openPreviewDialog}
|
||
className="inline-flex items-center gap-2 px-4 py-2.5 text-sm font-medium text-gray-700 bg-white border border-gray-200 rounded-lg hover:bg-gray-50 hover:border-gray-300 transition-all duration-200 hover:scale-105"
|
||
>
|
||
<EyeIcon className="h-4 w-4" />
|
||
预览提示词
|
||
</button>
|
||
<button
|
||
onClick={openCreateDialog}
|
||
className="inline-flex items-center gap-2 px-4 py-2.5 text-sm font-medium text-white bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-700 hover:to-pink-700 rounded-lg transition-all duration-200 hover:scale-105 shadow-sm hover:shadow-md"
|
||
>
|
||
<PlusIcon className="h-4 w-4" />
|
||
添加分类
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 分类列表 - 优化卡片设计和布局 */}
|
||
{classifications.length === 0 ? (
|
||
<EmptyState
|
||
title="暂无AI分类"
|
||
description="开始创建您的第一个AI分类规则"
|
||
actionText="添加分类"
|
||
onAction={openCreateDialog}
|
||
/>
|
||
) : (
|
||
<div className="space-y-3">
|
||
{/* 列表标题 */}
|
||
<div className="flex items-center justify-between">
|
||
<h2 className="text-sm font-medium text-gray-700">
|
||
分类列表 <span className="text-xs text-gray-400">({classifications.length})</span>
|
||
</h2>
|
||
</div>
|
||
|
||
{/* 美观的分类卡片网格 - 优化滚动 */}
|
||
<div className="grid gap-4 max-h-[calc(100vh-16rem)] overflow-y-auto custom-scrollbar">
|
||
{classifications.map((classification, index) => (
|
||
<div
|
||
key={classification.id}
|
||
className="group relative bg-gradient-to-br from-white to-gray-50/30 border border-gray-200/50 rounded-xl p-5 hover:border-purple-200 hover:shadow-md hover:-translate-y-1 transition-all duration-300 overflow-hidden"
|
||
>
|
||
{/* 装饰性背景 */}
|
||
<div className="absolute top-0 right-0 w-20 h-20 bg-gradient-to-br from-purple-100/20 to-pink-100/20 rounded-full -translate-y-10 translate-x-10 opacity-0 group-hover:opacity-100 transition-all duration-300"></div>
|
||
{/* 美观的卡片头部 */}
|
||
<div className="flex items-start justify-between mb-4 relative z-10">
|
||
<div className="flex-1 min-w-0">
|
||
<div className="flex items-center gap-2 mb-2">
|
||
<h3 className="text-base font-semibold text-gray-900 truncate">
|
||
{classification.name}
|
||
</h3>
|
||
<span className={`inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium ${classification.is_active
|
||
? 'bg-gradient-to-r from-emerald-50 to-emerald-100 text-emerald-700 border border-emerald-200'
|
||
: 'bg-gradient-to-r from-gray-50 to-gray-100 text-gray-600 border border-gray-200'
|
||
}`}>
|
||
{classification.is_active ? '激活' : '禁用'}
|
||
</span>
|
||
<span className="text-xs text-purple-600 bg-purple-50 px-2 py-1 rounded-full border border-purple-200">
|
||
#{classification.sort_order}
|
||
</span>
|
||
</div>
|
||
|
||
{/* 提示词预览 */}
|
||
<p className="text-xs text-gray-600 line-clamp-2 leading-relaxed">
|
||
{classification.prompt_text}
|
||
</p>
|
||
|
||
{/* 描述 */}
|
||
{classification.description && (
|
||
<p className="mt-1 text-xs text-gray-500 line-clamp-1">
|
||
{classification.description}
|
||
</p>
|
||
)}
|
||
|
||
{/* 时间信息 */}
|
||
<div className="mt-2 text-xs text-gray-400">
|
||
{new Date(classification.created_at).toLocaleDateString()}
|
||
</div>
|
||
</div>
|
||
{/* 操作按钮组 - 优化为更精致的设计 */}
|
||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity duration-200">
|
||
{/* 排序按钮组 */}
|
||
<div className="flex items-center bg-gray-50 rounded-lg p-0.5">
|
||
<button
|
||
onClick={() => handleMoveUp(classification)}
|
||
disabled={index === 0}
|
||
className="p-1 text-gray-400 hover:text-gray-600 hover:bg-white rounded disabled:opacity-30 disabled:cursor-not-allowed transition-all duration-150"
|
||
title="上移"
|
||
>
|
||
<ArrowUpIcon className="h-3 w-3" />
|
||
</button>
|
||
<button
|
||
onClick={() => handleMoveDown(classification)}
|
||
disabled={index === classifications.length - 1}
|
||
className="p-1 text-gray-400 hover:text-gray-600 hover:bg-white rounded disabled:opacity-30 disabled:cursor-not-allowed transition-all duration-150"
|
||
title="下移"
|
||
>
|
||
<ArrowDownIcon className="h-3 w-3" />
|
||
</button>
|
||
</div>
|
||
|
||
{/* 状态切换按钮 */}
|
||
<button
|
||
onClick={() => handleToggleStatus(classification.id)}
|
||
className={`p-1.5 rounded-lg transition-all duration-150 ${classification.is_active
|
||
? 'text-emerald-600 hover:text-emerald-700 hover:bg-emerald-50'
|
||
: 'text-gray-400 hover:text-gray-600 hover:bg-gray-50'
|
||
}`}
|
||
title={classification.is_active ? '禁用' : '激活'}
|
||
>
|
||
{classification.is_active ? (
|
||
<CheckIcon className="h-3 w-3" />
|
||
) : (
|
||
<XMarkIcon className="h-3 w-3" />
|
||
)}
|
||
</button>
|
||
|
||
{/* 编辑按钮 */}
|
||
<button
|
||
onClick={() => openEditDialog(classification)}
|
||
className="p-1.5 text-blue-600 hover:text-blue-700 hover:bg-blue-50 rounded-lg transition-all duration-150"
|
||
title="编辑"
|
||
>
|
||
<PencilIcon className="h-3 w-3" />
|
||
</button>
|
||
|
||
{/* 删除按钮 */}
|
||
<button
|
||
onClick={() => openDeleteDialog(classification.id)}
|
||
className="p-1.5 text-red-500 hover:text-red-600 hover:bg-red-50 rounded-lg transition-all duration-150"
|
||
title="删除"
|
||
>
|
||
<TrashIcon className="h-3 w-3" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 创建分类对话框 */}
|
||
<AiClassificationFormDialog
|
||
isOpen={showCreateDialog}
|
||
title="添加AI分类"
|
||
formData={formData}
|
||
formErrors={formErrors}
|
||
submitting={submitting}
|
||
existingClassifications={classifications}
|
||
onFormDataChange={setFormData}
|
||
onSubmit={handleCreate}
|
||
onCancel={() => {
|
||
setShowCreateDialog(false);
|
||
setFormData(DEFAULT_FORM_DATA);
|
||
setFormErrors({});
|
||
}}
|
||
/>
|
||
|
||
{/* 编辑分类对话框 */}
|
||
<AiClassificationFormDialog
|
||
isOpen={showEditDialog}
|
||
title="编辑AI分类"
|
||
formData={formData}
|
||
formErrors={formErrors}
|
||
submitting={submitting}
|
||
isEdit={true}
|
||
existingClassifications={classifications}
|
||
editingClassificationId={editingClassification?.id}
|
||
onFormDataChange={setFormData}
|
||
onSubmit={handleEdit}
|
||
onCancel={() => {
|
||
setShowEditDialog(false);
|
||
setEditingClassification(null);
|
||
setFormData(DEFAULT_FORM_DATA);
|
||
setFormErrors({});
|
||
}}
|
||
/>
|
||
|
||
{/* 删除确认对话框 */}
|
||
<DeleteConfirmDialog
|
||
isOpen={showDeleteDialog}
|
||
title="删除AI分类"
|
||
message="您确定要删除这个AI分类吗?"
|
||
itemName={deletingClassificationId ?
|
||
classifications.find(c => c.id === deletingClassificationId)?.name :
|
||
undefined
|
||
}
|
||
deleting={submitting}
|
||
onConfirm={handleDelete}
|
||
onCancel={() => {
|
||
setShowDeleteDialog(false);
|
||
setDeletingClassificationId(null);
|
||
}}
|
||
/>
|
||
|
||
{/* 预览对话框 */}
|
||
<AiClassificationPreviewDialog
|
||
isOpen={showPreviewDialog}
|
||
preview={preview}
|
||
loading={previewLoading}
|
||
onClose={() => {
|
||
setShowPreviewDialog(false);
|
||
setPreview(null);
|
||
}}
|
||
/>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default AiClassificationSettings;
|