8686d101cd
## 已实现功能 - 题库管理后端API完整实现 - 模板管理页面(Settings-测评模板) - 评估统计页面 - 人才测评页面(AssessmentView) - QuestionBank前端服务层 ## 技术栈 - 后端: Node.js + NestJS + TypeORM - 前端: React + TypeScript - 容器化: Docker Compose ## 已知待完善 - 题库列表页缺少删除按钮 - 题库详情页未实现(题目管理/AI生成/审核)
242 lines
8.5 KiB
TypeScript
242 lines
8.5 KiB
TypeScript
import React, { useState, useEffect } from 'react';
|
|
import { KnowledgeGroup, CreateGroupData, UpdateGroupData } from '../types';
|
|
import { knowledgeGroupService } from '../services/knowledgeGroupService';
|
|
import { useToast } from '../contexts/ToastContext';
|
|
import { useConfirm } from '../contexts/ConfirmContext';
|
|
import { useLanguage } from '../contexts/LanguageContext';
|
|
import { Folder, Plus, Edit2, Trash2, X } from 'lucide-react';
|
|
|
|
interface GroupManagerProps {
|
|
groups: KnowledgeGroup[];
|
|
onGroupsChange: (groups: KnowledgeGroup[]) => void;
|
|
}
|
|
|
|
const DEFAULT_COLORS = [
|
|
'#3B82F6', '#10B981', '#F59E0B', '#EF4444',
|
|
'#8B5CF6', '#06B6D4', '#84CC16', '#F97316'
|
|
];
|
|
|
|
export const GroupManager: React.FC<GroupManagerProps> = ({ groups, onGroupsChange }) => {
|
|
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
|
const [editingGroup, setEditingGroup] = useState<KnowledgeGroup | null>(null);
|
|
const [formData, setFormData] = useState<CreateGroupData>({
|
|
name: '',
|
|
description: '',
|
|
color: DEFAULT_COLORS[0],
|
|
});
|
|
const [loading, setLoading] = useState(false);
|
|
const { showSuccess, showError } = useToast();
|
|
const { confirm } = useConfirm();
|
|
const { t } = useLanguage();
|
|
|
|
const resetForm = () => {
|
|
setFormData({
|
|
name: '',
|
|
description: '',
|
|
color: DEFAULT_COLORS[0],
|
|
});
|
|
};
|
|
|
|
const handleCreate = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
if (!formData.name.trim()) return;
|
|
|
|
setLoading(true);
|
|
try {
|
|
const newGroup = await knowledgeGroupService.createGroup(formData);
|
|
console.log('[GroupManager] Created group:', newGroup);
|
|
console.log('[GroupManager] Current groups:', groups);
|
|
onGroupsChange([...groups, newGroup]);
|
|
setIsCreateModalOpen(false);
|
|
resetForm();
|
|
showSuccess(t('successNoteCreated')); // Note: Should probably have a more specific translation for group
|
|
} catch (error) {
|
|
showError(t('createFailed'));
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleUpdate = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
if (!editingGroup || !formData.name.trim()) return;
|
|
|
|
setLoading(true);
|
|
try {
|
|
const updatedGroup = await knowledgeGroupService.updateGroup(editingGroup.id, formData);
|
|
onGroupsChange(groups.map(g => g.id === editingGroup.id ? updatedGroup : g));
|
|
setEditingGroup(null);
|
|
resetForm();
|
|
showSuccess(t('successNoteUpdated'));
|
|
} catch (error) {
|
|
showError(t('updateFailedRetry'));
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleDelete = async (group: KnowledgeGroup) => {
|
|
if (!(await confirm(t('confirmDeleteGroup').replace('$1', group.name)))) return;
|
|
|
|
try {
|
|
await knowledgeGroupService.deleteGroup(group.id);
|
|
onGroupsChange(groups.filter(g => g.id !== group.id));
|
|
showSuccess(t('successNoteDeleted'));
|
|
} catch (error) {
|
|
showError(t('deleteFailed'));
|
|
}
|
|
};
|
|
|
|
const openEditModal = (group: KnowledgeGroup) => {
|
|
setEditingGroup(group);
|
|
setFormData({
|
|
name: group.name,
|
|
description: group.description || '',
|
|
color: group.color,
|
|
});
|
|
};
|
|
|
|
const closeModal = () => {
|
|
setIsCreateModalOpen(false);
|
|
setEditingGroup(null);
|
|
resetForm();
|
|
};
|
|
|
|
const isModalOpen = isCreateModalOpen || editingGroup !== null;
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
{/* Group list */}
|
|
<div className="space-y-2">
|
|
{groups.map((group) => (
|
|
<div
|
|
key={group.id}
|
|
className="flex items-center justify-between p-3 bg-white rounded-lg border hover:shadow-sm transition-shadow"
|
|
>
|
|
<div className="flex items-center space-x-3">
|
|
<div
|
|
className="w-4 h-4 rounded-full"
|
|
style={{ backgroundColor: group.color }}
|
|
/>
|
|
<div>
|
|
<div className="font-medium text-gray-900">{group.name}</div>
|
|
{group.description && (
|
|
<div className="text-sm text-gray-500">{group.description}</div>
|
|
)}
|
|
<div className="text-xs text-gray-400">
|
|
{group.fileCount} files
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center space-x-2">
|
|
<button
|
|
onClick={() => openEditModal(group)}
|
|
className="p-1 text-gray-400 hover:text-blue-600 transition-colors"
|
|
>
|
|
<Edit2 size={16} />
|
|
</button>
|
|
<button
|
|
onClick={() => handleDelete(group)}
|
|
className="p-1 text-gray-400 hover:text-red-600 transition-colors"
|
|
>
|
|
<Trash2 size={16} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* Create button */}
|
|
<button
|
|
onClick={() => setIsCreateModalOpen(true)}
|
|
className="w-full flex items-center justify-center p-2 border-2 border-dashed border-gray-300 rounded-lg text-gray-500 hover:border-blue-400 hover:text-blue-600 transition-colors"
|
|
title={t('createNotebook')}
|
|
>
|
|
<Plus size={18} />
|
|
</button>
|
|
|
|
{/* Create/Edit modal */}
|
|
{isModalOpen && (
|
|
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50">
|
|
<div className="bg-white rounded-lg p-6 w-full max-w-md">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<h3 className="text-lg font-semibold">
|
|
{editingGroup ? t('editNotebookTitle') : t('createNotebookTitle')}
|
|
</h3>
|
|
<button
|
|
onClick={closeModal}
|
|
className="text-gray-400 hover:text-gray-600"
|
|
>
|
|
<X size={20} />
|
|
</button>
|
|
</div>
|
|
|
|
<form onSubmit={editingGroup ? handleUpdate : handleCreate} className="space-y-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
{t('name')} *
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={formData.name}
|
|
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
|
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
placeholder={t('namePlaceholder')}
|
|
required
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
{t('shortDescription')}
|
|
</label>
|
|
<textarea
|
|
value={formData.description}
|
|
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
|
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
placeholder={t('descPlaceholder')}
|
|
rows={3}
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
Color indicator
|
|
</label>
|
|
<div className="flex space-x-2">
|
|
{DEFAULT_COLORS.map((color) => (
|
|
<button
|
|
key={color}
|
|
type="button"
|
|
onClick={() => setFormData({ ...formData, color })}
|
|
className={`w-8 h-8 rounded-full border-2 ${formData.color === color ? 'border-gray-400' : 'border-gray-200'
|
|
}`}
|
|
style={{ backgroundColor: color }}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex space-x-3 pt-4">
|
|
<button
|
|
type="button"
|
|
onClick={closeModal}
|
|
className="flex-1 px-4 py-2 text-gray-700 bg-gray-100 rounded-md hover:bg-gray-200 transition-colors"
|
|
>
|
|
{t('cancel')}
|
|
</button>
|
|
<button
|
|
type="submit"
|
|
disabled={loading || !formData.name.trim()}
|
|
className="flex-1 px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
|
>
|
|
{loading ? t('saving') : (editingGroup ? t('save') : t('create'))}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}; |