mixvideo-v2/apps/desktop/src/pages/AiClassificationSettings.tsx

516 lines
19 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.

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;