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

660 lines
23 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

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', desc: '' });
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', desc: '' });
setShowCreateModal(true);
};
const handleCloseCreateModal = () => {
setShowCreateModal(false);
setNewPromptData({ name: '', task_type: 'MEETING_TASK', desc: '' });
};
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,
desc: newPromptData.desc || '',
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 || '',
desc: editingPrompt.desc || '',
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,
desc: editingPrompt.desc
};
// 更新本地状态
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 || '',
desc: updatedPrompt.desc || '',
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 || '',
desc: editingPrompt.desc || '',
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-desc-row">
<textarea
className="prompt-desc-input"
value={editingPrompt.desc || ''}
onChange={(e) => handleEditChange('desc', e.target.value)}
placeholder="添加模版描述(描述此模版的使用场景)..."
rows={2}
/>
</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>
<div className="form-group">
<label><MessageSquare size={16} /> 描述可选</label>
<textarea
value={newPromptData.desc}
onChange={(e) => setNewPromptData(prev => ({ ...prev, desc: e.target.value }))}
placeholder="描述此模版的使用场景..."
rows={3}
/>
</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;