imetting/frontend/src/pages/admin/PromptManagement.jsx

634 lines
22 KiB
React
Raw Normal View History

import React, { useState, useEffect, useRef } from 'react';
import apiClient from '../../utils/apiClient';
import { buildApiUrl, API_ENDPOINTS } from '../../config/api';
import { Plus, ChevronLeft, ChevronRight, Trash2, BookText, FileText, Star, Save, Check, Edit2, X, MessageSquare, Library } from 'lucide-react';
import './PromptManagement.css';
import ConfirmDialog from '../../components/ConfirmDialog';
import FormModal from '../../components/FormModal';
import Toast from '../../components/Toast';
import MarkdownEditor from '../../components/MarkdownEditor';
import Breadcrumb from '../../components/Breadcrumb';
import PageLoading from '../../components/PageLoading';
const TASK_TYPES = {
MEETING_TASK: { label: '会议任务', icon: <MessageSquare size={18} /> },
KNOWLEDGE_TASK: { label: '知识库任务', icon: <Library size={18} /> }
};
const PromptManagement = () => {
const [prompts, setPrompts] = useState([]);
const [selectedPrompt, setSelectedPrompt] = useState(null);
const [editingPrompt, setEditingPrompt] = useState(null); // 正在编辑的提示词
const [editingTitle, setEditingTitle] = useState(false); // 是否正在编辑标题
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [showCreateModal, setShowCreateModal] = useState(false);
const [newPromptData, setNewPromptData] = useState({ name: '', task_type: 'MEETING_TASK' });
const [deleteConfirmInfo, setDeleteConfirmInfo] = useState(null);
const [toasts, setToasts] = useState([]);
const [isSaving, setIsSaving] = useState(false);
// Toast helper functions
const showToast = (message, type = 'info') => {
const id = Date.now();
setToasts(prev => [...prev, { id, message, type }]);
};
const removeToast = (id) => {
setToasts(prev => prev.filter(toast => toast.id !== id));
};
useEffect(() => {
fetchPrompts();
}, []);
const fetchPrompts = async () => {
setLoading(true);
try {
const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.PROMPTS.LIST));
const promptList = response.data.prompts;
setPrompts(promptList);
// 如果有提示词且没有选中,默认选中第一个
if (promptList.length > 0 && !selectedPrompt) {
const firstPrompt = promptList[0];
setSelectedPrompt(firstPrompt);
setEditingPrompt({ ...firstPrompt });
}
} catch (err) {
setError(err.response?.data?.message || '无法加载提示词列表');
} finally {
setLoading(false);
}
};
const handleOpenCreateModal = () => {
setNewPromptData({ name: '', task_type: 'MEETING_TASK' });
setShowCreateModal(true);
};
const handleCloseCreateModal = () => {
setShowCreateModal(false);
setNewPromptData({ name: '', task_type: 'MEETING_TASK' });
};
const handleCreatePrompt = async () => {
if (!newPromptData.name.trim()) {
showToast('请输入提示词名称', 'warning');
return;
}
try {
const response = await apiClient.post(buildApiUrl(API_ENDPOINTS.PROMPTS.CREATE), {
name: newPromptData.name,
task_type: newPromptData.task_type,
content: '',
is_default: false,
is_active: true
});
showToast('提示词创建成功', 'success');
handleCloseCreateModal();
await fetchPrompts();
// 自动选中新创建的提示词
const newPrompt = response.data;
if (newPrompt && newPrompt.id) {
const createdPrompt = prompts.find(p => p.id === newPrompt.id) || newPrompt;
setSelectedPrompt(createdPrompt);
setEditingPrompt({ ...createdPrompt });
}
} catch (err) {
showToast(err.response?.data?.message || '创建失败', 'error');
}
};
const handlePromptSelect = (prompt) => {
setSelectedPrompt(prompt);
setEditingPrompt({ ...prompt });
setEditingTitle(false); // 切换提示词时关闭标题编辑
};
const handleEditChange = (field, value) => {
setEditingPrompt(prev => ({ ...prev, [field]: value }));
};
// 保存标题
const handleSaveTitle = async () => {
if (!editingPrompt || !editingPrompt.id || !editingPrompt.name.trim()) {
showToast('标题不能为空', 'warning');
return;
}
setIsSaving(true);
try {
const dataToSend = {
name: editingPrompt.name,
task_type: editingPrompt.task_type,
content: editingPrompt.content || '',
is_default: Boolean(editingPrompt.is_default),
is_active: editingPrompt.is_active !== false
};
await apiClient.put(
buildApiUrl(API_ENDPOINTS.PROMPTS.UPDATE(editingPrompt.id)),
dataToSend
);
showToast('标题更新成功', 'success');
// 先计算完整更新后的对象(在回调外部)
const fullUpdatedPrompt = {
...prompts.find(p => p.id === editingPrompt.id),
name: editingPrompt.name
};
// 更新本地状态
setPrompts(prevPrompts =>
prevPrompts.map(p => p.id === editingPrompt.id ? fullUpdatedPrompt : p)
);
// 使用完整数据更新状态
setSelectedPrompt(fullUpdatedPrompt);
setEditingPrompt(fullUpdatedPrompt);
setEditingTitle(false);
} catch (err) {
console.error('handleSaveTitle error:', err);
showToast(err.response?.data?.message || '更新失败', 'error');
} finally {
setIsSaving(false);
}
};
// 直接保存字段更新(用于 is_default 和 is_active
const handleDirectSave = async (field, value) => {
if (!editingPrompt || !editingPrompt.id) {
showToast('数据异常,请重新选择提示词', 'error');
console.error('handleDirectSave: editingPrompt is invalid', editingPrompt);
return;
}
// 如果是默认模版,不允许设置为无效
if (field === 'is_active' && !value && editingPrompt.is_default) {
showToast('默认模版不能设置为无效,请先取消默认状态', 'warning');
return;
}
const updatedPrompt = { ...editingPrompt, [field]: value };
setIsSaving(true);
try {
const dataToSend = {
name: updatedPrompt.name,
task_type: updatedPrompt.task_type,
content: updatedPrompt.content || '',
is_default: Boolean(updatedPrompt.is_default),
is_active: updatedPrompt.is_active !== false
};
await apiClient.put(
buildApiUrl(API_ENDPOINTS.PROMPTS.UPDATE(editingPrompt.id)),
dataToSend
);
showToast('更新成功', 'success');
// 先计算完整更新后的对象(在回调外部)
const fullUpdatedPrompt = {
...prompts.find(p => p.id === editingPrompt.id),
[field]: value
};
// 更新本地状态
setPrompts(prevPrompts =>
prevPrompts.map(p => p.id === editingPrompt.id ? fullUpdatedPrompt : p)
);
// 使用完整数据更新状态
setSelectedPrompt(fullUpdatedPrompt);
setEditingPrompt(fullUpdatedPrompt);
} catch (err) {
console.error('handleDirectSave error:', err);
showToast(err.response?.data?.message || '更新失败', 'error');
// 恢复原值
setEditingPrompt({ ...selectedPrompt });
} finally {
setIsSaving(false);
}
};
const handleSave = async () => {
if (!editingPrompt || !editingPrompt.id) {
showToast('数据异常,请重新选择提示词', 'error');
console.error('handleSave: editingPrompt is invalid', editingPrompt);
return;
}
// 验证必需字段
if (!editingPrompt.name || !editingPrompt.task_type || editingPrompt.content === undefined) {
showToast('数据不完整,请刷新页面重试', 'error');
console.error('handleSave: missing required fields', editingPrompt);
return;
}
console.log('handleSave: Saving prompt', {
id: editingPrompt.id,
name: editingPrompt.name,
task_type: editingPrompt.task_type,
content_length: editingPrompt.content?.length,
is_default: editingPrompt.is_default,
is_active: editingPrompt.is_active
});
setIsSaving(true);
try {
const dataToSend = {
name: editingPrompt.name,
task_type: editingPrompt.task_type,
content: editingPrompt.content || '',
is_default: Boolean(editingPrompt.is_default),
is_active: editingPrompt.is_active !== false
};
const url = buildApiUrl(API_ENDPOINTS.PROMPTS.UPDATE(editingPrompt.id));
console.log('handleSave: Request URL:', url);
console.log('handleSave: Sending data', dataToSend);
console.log('handleSave: editingPrompt.id type:', typeof editingPrompt.id, 'value:', editingPrompt.id);
const response = await apiClient.put(url, dataToSend);
console.log('handleSave: Success response:', response);
showToast('保存成功', 'success');
// 先从当前列表中找到原始记录
const originalPrompt = prompts.find(p => p.id === editingPrompt.id);
if (!originalPrompt) {
console.error('handleSave: Cannot find prompt in list', editingPrompt.id);
console.error('handleSave: Current prompts list:', prompts.map(p => ({ id: p.id, name: p.name })));
// 如果找不到,重新加载列表
await fetchPrompts();
return;
}
// 计算完整更新后的对象(在回调外部)
const fullUpdatedPrompt = {
...originalPrompt,
...editingPrompt
};
console.log('handleSave: fullUpdatedPrompt', fullUpdatedPrompt);
// 更新本地状态
setPrompts(prevPrompts =>
prevPrompts.map(p => p.id === editingPrompt.id ? fullUpdatedPrompt : p)
);
// 使用完整数据更新状态
setSelectedPrompt(fullUpdatedPrompt);
setEditingPrompt(fullUpdatedPrompt);
} catch (err) {
console.error('handleSave error:', err);
console.error('handleSave error response:', err.response?.data);
console.error('handleSave error config:', err.config);
showToast(err.response?.data?.message || '保存失败', 'error');
} finally {
setIsSaving(false);
}
};
const handleDelete = async (prompt) => {
// 检查是否为默认模版
if (prompt.is_default) {
showToast('默认模版不允许删除,请先取消默认状态', 'warning');
return;
}
setDeleteConfirmInfo({
id: prompt.id,
name: prompt.name
});
};
const handleConfirmDelete = async () => {
try {
await apiClient.delete(buildApiUrl(API_ENDPOINTS.PROMPTS.DELETE(deleteConfirmInfo.id)));
if (selectedPrompt && selectedPrompt.id === deleteConfirmInfo.id) {
setSelectedPrompt(null);
setEditingPrompt(null);
}
setDeleteConfirmInfo(null);
await fetchPrompts();
showToast('提示词删除成功', 'success');
} catch (err) {
showToast(err.response?.data?.message || '删除失败', 'error');
setDeleteConfirmInfo(null);
}
};
// 按任务类型分组
const groupedPrompts = {
MEETING_TASK: prompts.filter(p => p.task_type === 'MEETING_TASK'),
KNOWLEDGE_TASK: prompts.filter(p => p.task_type === 'KNOWLEDGE_TASK')
};
if (loading) {
return <PageLoading message="加载中..." />;
}
return (
<div className="prompt-management">
<Breadcrumb currentPage="提示词管理" icon={BookText} />
<div className="prompt-layout">
{/* 左侧提示词列表 */}
<div className={`prompt-sidebar ${sidebarCollapsed ? 'collapsed' : ''}`}>
<div className="sidebar-header">
{!sidebarCollapsed && (
<>
<h3>提示词列表</h3>
<button
className="btn-new-prompt"
onClick={handleOpenCreateModal}
title="新增提示词"
>
<Plus size={18} />
</button>
</>
)}
<button
className="btn-toggle-sidebar"
onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
title={sidebarCollapsed ? "展开" : "收起"}
>
{sidebarCollapsed ? <ChevronRight size={18} /> : <ChevronLeft size={18} />}
</button>
</div>
{!sidebarCollapsed && (
<div className="prompt-list-sidebar">
{prompts.length === 0 ? (
<div className="empty-state">
<p>暂无提示词</p>
</div>
) : (
<>
{/* 会议任务 */}
{groupedPrompts.MEETING_TASK.length > 0 && (
<div className="prompt-group">
<div className="group-header">
<span>{TASK_TYPES.MEETING_TASK.icon}</span>
<span>{TASK_TYPES.MEETING_TASK.label}</span>
</div>
{groupedPrompts.MEETING_TASK.map(prompt => (
<div
key={prompt.id}
className={`prompt-list-item ${selectedPrompt?.id === prompt.id ? 'active' : ''}`}
onClick={() => handlePromptSelect(prompt)}
>
<div className="prompt-item-info">
{prompt.is_default ? (
<span className="badge badge-default">
<Star size={12} fill="currentColor" />
</span>
) : null}
<h4 title={prompt.name}>{prompt.name}</h4>
{!prompt.is_active ? (
<span className="badge badge-inactive">未启用</span>
) : null}
</div>
</div>
))}
</div>
)}
{/* 知识库任务 */}
{groupedPrompts.KNOWLEDGE_TASK.length > 0 && (
<div className="prompt-group">
<div className="group-header">
<span>{TASK_TYPES.KNOWLEDGE_TASK.icon}</span>
<span>{TASK_TYPES.KNOWLEDGE_TASK.label}</span>
</div>
{groupedPrompts.KNOWLEDGE_TASK.map(prompt => (
<div
key={prompt.id}
className={`prompt-list-item ${selectedPrompt?.id === prompt.id ? 'active' : ''}`}
onClick={() => handlePromptSelect(prompt)}
>
<div className="prompt-item-info">
{prompt.is_default ? (
<span className="badge badge-default">
<Star size={12} fill="currentColor" />
</span>
) : null}
<h4 title={prompt.name}>{prompt.name}</h4>
{!prompt.is_active ? (
<span className="badge badge-inactive">未启用</span>
) : null}
</div>
</div>
))}
</div>
)}
</>
)}
</div>
)}
</div>
{/* 右侧编辑区 */}
<div className="prompt-detail-area">
{selectedPrompt && editingPrompt ? (
<>
{/* 第一行:标题 + 操作按钮 */}
<div className="prompt-title-row">
<div className="title-edit-container">
{editingTitle ? (
<>
<input
type="text"
className="title-input"
value={editingPrompt.name}
onChange={(e) => handleEditChange('name', e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleSaveTitle();
} else if (e.key === 'Escape') {
setEditingPrompt({ ...selectedPrompt });
setEditingTitle(false);
}
}}
autoFocus
/>
<button
className="icon-btn-small confirm-btn"
onClick={handleSaveTitle}
disabled={isSaving}
title="确认"
>
<Check size={16} />
</button>
<button
className="icon-btn-small cancel-btn"
onClick={() => {
setEditingPrompt({ ...selectedPrompt });
setEditingTitle(false);
}}
title="取消"
>
<X size={16} />
</button>
</>
) : (
<>
<h1>{selectedPrompt.name}</h1>
<button
className="icon-btn-small edit-btn"
onClick={() => setEditingTitle(true)}
title="编辑标题"
>
<Edit2 size={16} />
</button>
</>
)}
</div>
<div className="title-actions">
<button
className="icon-btn save-btn"
onClick={handleSave}
disabled={isSaving}
title="保存"
>
<Save size={18} />
</button>
<button
className="icon-btn delete-btn"
onClick={() => handleDelete(selectedPrompt)}
title="删除"
>
<Trash2 size={18} />
</button>
</div>
</div>
{/* 第二行:任务类型 + 设为默认 + 启用开关 */}
<div className="prompt-controls-row">
<span className="task-type-badge">
{TASK_TYPES[selectedPrompt.task_type]?.icon} {TASK_TYPES[selectedPrompt.task_type]?.label}
</span>
<div className="prompt-actions-right">
<button
className={`btn-set-default ${editingPrompt.is_default ? 'active' : ''}`}
onClick={() => handleDirectSave('is_default', !editingPrompt.is_default)}
disabled={isSaving}
>
<Star size={14} />
<span>{editingPrompt.is_default ? '默认模版' : '设为默认'}</span>
</button>
<label className="switch-label">
<span>启用</span>
<div className="switch-wrapper">
<input
type="checkbox"
checked={editingPrompt.is_active}
onChange={(e) => handleDirectSave('is_active', e.target.checked)}
className="switch-input"
disabled={isSaving}
/>
<span className="switch-slider"></span>
</div>
</label>
</div>
</div>
{/* Markdown 编辑器 */}
<div className="prompt-editor-container">
<MarkdownEditor
value={editingPrompt.content || ''}
onChange={(value) => handleEditChange('content', value)}
placeholder="请输入提示词内容(支持 Markdown 格式)..."
height={500}
showImageUpload={false}
/>
</div>
</>
) : (
<div className="prompt-empty-placeholder">
<FileText size={64} />
<p>请从左侧选择一个提示词进行编辑</p>
</div>
)}
</div>
</div>
{/* 新增提示词表单弹窗(简化版) */}
<FormModal
isOpen={showCreateModal}
onClose={handleCloseCreateModal}
title="新增提示词"
size="small"
actions={
<>
<button type="button" className="btn btn-secondary" onClick={handleCloseCreateModal}>
取消
</button>
<button type="button" className="btn btn-primary" onClick={handleCreatePrompt}>
创建
</button>
</>
}
>
<div className="form-group">
<label><FileText size={16} /> 名称</label>
<input
type="text"
value={newPromptData.name}
onChange={(e) => setNewPromptData(prev => ({ ...prev, name: e.target.value }))}
placeholder="请输入提示词名称"
autoFocus
/>
</div>
<div className="form-group">
<label><BookText size={16} /> 任务类型</label>
<select
value={newPromptData.task_type}
onChange={(e) => setNewPromptData(prev => ({ ...prev, task_type: e.target.value }))}
>
<option value="MEETING_TASK">会议任务</option>
<option value="KNOWLEDGE_TASK">知识库任务</option>
</select>
</div>
</FormModal>
{/* 删除提示词确认对话框 */}
<ConfirmDialog
isOpen={!!deleteConfirmInfo}
onClose={() => setDeleteConfirmInfo(null)}
onConfirm={handleConfirmDelete}
title="删除提示词"
message={`确定要删除提示词"${deleteConfirmInfo?.name}"吗?此操作无法撤销。`}
confirmText="删除"
cancelText="取消"
type="danger"
/>
{/* Toast notifications */}
{toasts.map(toast => (
<Toast
key={toast.id}
message={toast.message}
type={toast.type}
onClose={() => removeToast(toast.id)}
/>
))}
</div>
);
};
export default PromptManagement;