增加了权限系统
parent
ed628738d2
commit
39eb9e8812
|
|
@ -0,0 +1,246 @@
|
||||||
|
# 知识库新增页面 - 模版选择功能实现总结
|
||||||
|
|
||||||
|
## 功能概述
|
||||||
|
在知识库新增流程的第二步"自定义提示词"中添加了模版选择功能,用户可以选择预设的模版来生成知识库内容。
|
||||||
|
|
||||||
|
## 实现的功能点
|
||||||
|
|
||||||
|
### 1. 添加状态管理 ✅
|
||||||
|
**文件**: `frontend/src/pages/KnowledgeBasePage.jsx`
|
||||||
|
|
||||||
|
添加了两个新的 state:
|
||||||
|
```javascript
|
||||||
|
const [availablePrompts, setAvailablePrompts] = useState([]); // 可用的提示词模版列表
|
||||||
|
const [selectedPromptId, setSelectedPromptId] = useState(null); // 选中的提示词模版ID
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 获取模版列表 ✅
|
||||||
|
**文件**: `frontend/src/pages/KnowledgeBasePage.jsx`
|
||||||
|
|
||||||
|
新增方法 `fetchAvailablePrompts()`:
|
||||||
|
```javascript
|
||||||
|
const fetchAvailablePrompts = async () => {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.PROMPTS.ACTIVE('KNOWLEDGE_TASK')));
|
||||||
|
const promptsList = response.data.prompts || [];
|
||||||
|
setAvailablePrompts(promptsList);
|
||||||
|
|
||||||
|
// 自动选中默认模版
|
||||||
|
const defaultPrompt = promptsList.find(p => p.is_default);
|
||||||
|
if (defaultPrompt) {
|
||||||
|
setSelectedPromptId(defaultPrompt.id);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching available prompts:", error);
|
||||||
|
setAvailablePrompts([]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
**调用时机**:
|
||||||
|
- 在 `handleOpenCreateModal()` 中调用,当用户打开新增知识库弹窗时获取模版列表
|
||||||
|
- 自动选中默认模版,无需用户手动选择
|
||||||
|
|
||||||
|
### 3. UI 实现 ✅
|
||||||
|
**文件**: `frontend/src/pages/KnowledgeBasePage.jsx`
|
||||||
|
|
||||||
|
在第二步(`createStep === 2`)中添加模版选择下拉框:
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
{/* 模版选择 */}
|
||||||
|
{availablePrompts.length > 0 && (
|
||||||
|
<div className="form-group">
|
||||||
|
<label>选择生成模版(可选)</label>
|
||||||
|
<p className="field-hint">选择一个预设的模版来生成知识库内容,或使用默认模版。</p>
|
||||||
|
<select
|
||||||
|
value={selectedPromptId || ''}
|
||||||
|
onChange={(e) => setSelectedPromptId(e.target.value ? parseInt(e.target.value) : null)}
|
||||||
|
className="template-select"
|
||||||
|
>
|
||||||
|
{availablePrompts.map(prompt => (
|
||||||
|
<option key={prompt.id} value={prompt.id}>
|
||||||
|
{prompt.name} {prompt.is_default ? '(默认)' : ''}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
**UI 特点**:
|
||||||
|
- 只在有可用模版时显示
|
||||||
|
- 第一个选项为"使用默认模版"(value为空)
|
||||||
|
- 每个模版选项显示名称,默认模版标注"(默认)"
|
||||||
|
- 支持清空选择(返回默认模版)
|
||||||
|
|
||||||
|
### 4. 数据传递 ✅
|
||||||
|
**文件**: `frontend/src/pages/KnowledgeBasePage.jsx`
|
||||||
|
|
||||||
|
修改 `handleGenerate()` 方法,将 `prompt_id` 传递给后端:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const handleGenerate = async () => {
|
||||||
|
if (!selectedMeetings || selectedMeetings.length === 0) {
|
||||||
|
showToast('请至少选择一个会议', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setGenerating(true);
|
||||||
|
try {
|
||||||
|
const response = await apiClient.post(buildApiUrl(API_ENDPOINTS.KNOWLEDGE_BASE.CREATE), {
|
||||||
|
user_prompt: userPrompt,
|
||||||
|
source_meeting_ids: selectedMeetings.join(','),
|
||||||
|
is_shared: false,
|
||||||
|
prompt_id: selectedPromptId // 传递选中的模版ID
|
||||||
|
});
|
||||||
|
setTaskId(response.data.task_id);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error creating knowledge base:", error);
|
||||||
|
setGenerating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. API 配置 ✅
|
||||||
|
**文件**: `frontend/src/config/api.js`
|
||||||
|
|
||||||
|
添加新的 API 端点配置:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
PROMPTS: {
|
||||||
|
LIST: '/api/prompts',
|
||||||
|
CREATE: '/api/prompts',
|
||||||
|
UPDATE: (promptId) => `/api/prompts/${promptId}`,
|
||||||
|
DELETE: (promptId) => `/api/prompts/${promptId}`,
|
||||||
|
ACTIVE: (taskType) => `/api/prompts/active/${taskType}` // 获取指定任务类型的启用模版
|
||||||
|
},
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. CSS 样式 ✅
|
||||||
|
**文件**: `frontend/src/pages/KnowledgeBasePage.css`
|
||||||
|
|
||||||
|
添加模版选择器的样式:
|
||||||
|
|
||||||
|
```css
|
||||||
|
.field-hint {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #64748b;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
background-color: white;
|
||||||
|
color: #1e293b;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-select:hover {
|
||||||
|
border-color: #cbd5e1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #667eea;
|
||||||
|
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-select option {
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 用户流程
|
||||||
|
|
||||||
|
1. **打开新增知识库弹窗**
|
||||||
|
- 点击"新增知识条目"按钮
|
||||||
|
- 自动获取 KNOWLEDGE_TASK 类型的启用模版列表
|
||||||
|
|
||||||
|
2. **第一步:选择会议**
|
||||||
|
- 选择一个或多个会议作为数据源
|
||||||
|
- 点击"下一步"
|
||||||
|
|
||||||
|
3. **第二步:自定义提示词**
|
||||||
|
- 【新增】选择生成模版(可选)
|
||||||
|
- 下拉框显示所有启用的知识库模版
|
||||||
|
- 默认选中标记为"默认"的模版
|
||||||
|
- 可以选择其他模版或清空选择
|
||||||
|
- 输入用户提示词(可选)
|
||||||
|
- 点击"生成知识库"
|
||||||
|
|
||||||
|
4. **生成知识库**
|
||||||
|
- 系统将 `prompt_id` 和 `user_prompt` 一起发送到后端
|
||||||
|
- 后端根据选择的模版生成知识库内容
|
||||||
|
|
||||||
|
## 数据流
|
||||||
|
|
||||||
|
```
|
||||||
|
用户打开弹窗
|
||||||
|
↓
|
||||||
|
fetchAvailablePrompts()
|
||||||
|
↓
|
||||||
|
GET /api/prompts/active/KNOWLEDGE_TASK
|
||||||
|
↓
|
||||||
|
获取启用的模版列表
|
||||||
|
↓
|
||||||
|
自动选中默认模版
|
||||||
|
↓
|
||||||
|
用户可以选择其他模版
|
||||||
|
↓
|
||||||
|
用户点击"生成知识库"
|
||||||
|
↓
|
||||||
|
POST /api/knowledge-bases
|
||||||
|
{
|
||||||
|
user_prompt: "...",
|
||||||
|
source_meeting_ids: "1,2,3",
|
||||||
|
is_shared: false,
|
||||||
|
prompt_id: 13 // 选中的模版ID
|
||||||
|
}
|
||||||
|
↓
|
||||||
|
后端使用指定模版生成知识库
|
||||||
|
```
|
||||||
|
|
||||||
|
## 兼容性
|
||||||
|
|
||||||
|
- **向后兼容**:prompt_id 为可选参数,不选择时传 null,后端使用默认模版
|
||||||
|
- **容错处理**:如果获取模版列表失败,不影响正常流程,用户仍可生成知识库
|
||||||
|
- **智能默认**:自动选中默认模版,减少用户操作
|
||||||
|
|
||||||
|
## 特点
|
||||||
|
|
||||||
|
1. **用户友好**
|
||||||
|
- 自动选中默认模版,无需手动选择
|
||||||
|
- 清晰标注哪个是默认模版
|
||||||
|
- 提供"使用默认模版"选项方便重置
|
||||||
|
|
||||||
|
2. **界面一致**
|
||||||
|
- 与会议总结的模版选择保持一致的设计风格
|
||||||
|
- 使用相同的 UI 组件和样式
|
||||||
|
|
||||||
|
3. **灵活性**
|
||||||
|
- 支持选择任何启用的 KNOWLEDGE_TASK 模版
|
||||||
|
- 可以与用户提示词组合使用
|
||||||
|
- 可以随时切换模版
|
||||||
|
|
||||||
|
## 文件变更列表
|
||||||
|
|
||||||
|
1. `frontend/src/pages/KnowledgeBasePage.jsx` - 主要逻辑实现
|
||||||
|
2. `frontend/src/pages/KnowledgeBasePage.css` - 样式文件
|
||||||
|
3. `frontend/src/config/api.js` - API 配置
|
||||||
|
|
||||||
|
## 后续优化建议
|
||||||
|
|
||||||
|
1. 可以考虑在模版选择器中显示模版的描述或预览
|
||||||
|
2. 可以添加"查看模版详情"的功能
|
||||||
|
3. 可以记住用户上次选择的模版(localStorage)
|
||||||
|
|
||||||
|
## 总结
|
||||||
|
|
||||||
|
知识库新增页面的模版选择功能已完全实现,用户现在可以在创建知识库时灵活选择不同的生成模版,大大提升了知识库生成的灵活性和个性化程度。
|
||||||
|
|
@ -4,6 +4,7 @@ import apiClient from './utils/apiClient';
|
||||||
import { buildApiUrl, API_ENDPOINTS } from './config/api';
|
import { buildApiUrl, API_ENDPOINTS } from './config/api';
|
||||||
import HomePage from './pages/HomePage';
|
import HomePage from './pages/HomePage';
|
||||||
import Dashboard from './pages/Dashboard';
|
import Dashboard from './pages/Dashboard';
|
||||||
|
import AdminDashboard from './pages/AdminDashboard';
|
||||||
import MeetingDetails from './pages/MeetingDetails';
|
import MeetingDetails from './pages/MeetingDetails';
|
||||||
import MeetingPreview from './pages/MeetingPreview';
|
import MeetingPreview from './pages/MeetingPreview';
|
||||||
import CreateMeeting from './pages/CreateMeeting';
|
import CreateMeeting from './pages/CreateMeeting';
|
||||||
|
|
@ -72,7 +73,11 @@ function App() {
|
||||||
user ? <Navigate to="/dashboard" /> : <HomePage onLogin={handleLogin} />
|
user ? <Navigate to="/dashboard" /> : <HomePage onLogin={handleLogin} />
|
||||||
} />
|
} />
|
||||||
<Route path="/dashboard" element={
|
<Route path="/dashboard" element={
|
||||||
user ? <Dashboard user={user} onLogout={handleLogout} /> : <Navigate to="/" />
|
user ? (
|
||||||
|
user.role_id === 1
|
||||||
|
? <AdminDashboard user={user} onLogout={handleLogout} />
|
||||||
|
: <Dashboard user={user} onLogout={handleLogout} />
|
||||||
|
) : <Navigate to="/" />
|
||||||
} />
|
} />
|
||||||
<Route path="/meetings/:meeting_id" element={
|
<Route path="/meetings/:meeting_id" element={
|
||||||
user ? <MeetingDetails user={user} /> : <Navigate to="/" />
|
user ? <MeetingDetails user={user} /> : <Navigate to="/" />
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { Download, Smartphone, Monitor, Apple, ChevronRight } from 'lucide-react';
|
import { Download, Smartphone, Monitor, Apple, ChevronRight, Cpu } from 'lucide-react';
|
||||||
import apiClient from '../utils/apiClient';
|
import apiClient from '../utils/apiClient';
|
||||||
import { buildApiUrl, API_ENDPOINTS } from '../config/api';
|
import { buildApiUrl, API_ENDPOINTS } from '../config/api';
|
||||||
import './ClientDownloads.css';
|
import './ClientDownloads.css';
|
||||||
|
|
@ -7,7 +7,8 @@ import './ClientDownloads.css';
|
||||||
const ClientDownloads = () => {
|
const ClientDownloads = () => {
|
||||||
const [clients, setClients] = useState({
|
const [clients, setClients] = useState({
|
||||||
mobile: [],
|
mobile: [],
|
||||||
desktop: []
|
desktop: [],
|
||||||
|
terminal: []
|
||||||
});
|
});
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
|
@ -20,7 +21,7 @@ const ClientDownloads = () => {
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.CLIENT_DOWNLOADS.LATEST));
|
const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.CLIENT_DOWNLOADS.LATEST));
|
||||||
console.log('Latest clients response:', response);
|
console.log('Latest clients response:', response);
|
||||||
setClients(response.data || { mobile: [], desktop: [] });
|
setClients(response.data || { mobile: [], desktop: [], terminal: [] });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('获取客户端下载失败:', error);
|
console.error('获取客户端下载失败:', error);
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -37,12 +38,24 @@ const ClientDownloads = () => {
|
||||||
case 'mac_intel':
|
case 'mac_intel':
|
||||||
case 'mac_m':
|
case 'mac_m':
|
||||||
return <Apple size={32} />;
|
return <Apple size={32} />;
|
||||||
|
case 'mcu':
|
||||||
|
return <Cpu size={32} />;
|
||||||
default:
|
default:
|
||||||
return <Monitor size={32} />;
|
return <Monitor size={32} />;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const getPlatformLabel = (platformName) => {
|
const getPlatformLabel = (client) => {
|
||||||
|
const platformName = client.platform_name;
|
||||||
|
const platformType = client.platform_type;
|
||||||
|
|
||||||
|
// 根据平台类型和平台名称组合判断
|
||||||
|
if (platformType === 'terminal') {
|
||||||
|
if (platformName === 'android') return 'Android终端';
|
||||||
|
if (platformName === 'mcu') return '单片机';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 默认标签
|
||||||
const labels = {
|
const labels = {
|
||||||
ios: 'iOS',
|
ios: 'iOS',
|
||||||
android: 'Android',
|
android: 'Android',
|
||||||
|
|
@ -99,7 +112,7 @@ const ClientDownloads = () => {
|
||||||
{getPlatformIcon(client.platform_name)}
|
{getPlatformIcon(client.platform_name)}
|
||||||
</div>
|
</div>
|
||||||
<div className="card-info">
|
<div className="card-info">
|
||||||
<h4>{getPlatformLabel(client.platform_name)}</h4>
|
<h4>{getPlatformLabel(client)}</h4>
|
||||||
<div className="version-info">
|
<div className="version-info">
|
||||||
<span className="version">v{client.version}</span>
|
<span className="version">v{client.version}</span>
|
||||||
{client.file_size && (
|
{client.file_size && (
|
||||||
|
|
@ -139,7 +152,47 @@ const ClientDownloads = () => {
|
||||||
{getPlatformIcon(client.platform_name)}
|
{getPlatformIcon(client.platform_name)}
|
||||||
</div>
|
</div>
|
||||||
<div className="card-info">
|
<div className="card-info">
|
||||||
<h4>{getPlatformLabel(client.platform_name)}</h4>
|
<h4>{getPlatformLabel(client)}</h4>
|
||||||
|
<div className="version-info">
|
||||||
|
<span className="version">v{client.version}</span>
|
||||||
|
{client.file_size && (
|
||||||
|
<span className="file-size">{formatFileSize(client.file_size)}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{client.min_system_version && (
|
||||||
|
<p className="system-req">需要 {client.min_system_version} 或更高版本</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="download-icon">
|
||||||
|
<Download size={20} />
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 专用终端 */}
|
||||||
|
{clients.terminal && clients.terminal.length > 0 && (
|
||||||
|
<div className="platform-group">
|
||||||
|
<div className="group-header">
|
||||||
|
<Cpu size={24} />
|
||||||
|
<h3>专用终端</h3>
|
||||||
|
</div>
|
||||||
|
<div className="clients-list">
|
||||||
|
{clients.terminal.map(client => (
|
||||||
|
<a
|
||||||
|
key={client.id}
|
||||||
|
href={client.download_url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="client-download-card"
|
||||||
|
>
|
||||||
|
<div className="card-icon">
|
||||||
|
{getPlatformIcon(client.platform_name)}
|
||||||
|
</div>
|
||||||
|
<div className="card-info">
|
||||||
|
<h4>{getPlatformLabel(client)}</h4>
|
||||||
<div className="version-info">
|
<div className="version-info">
|
||||||
<span className="version">v{client.version}</span>
|
<span className="version">v{client.version}</span>
|
||||||
{client.file_size && (
|
{client.file_size && (
|
||||||
|
|
@ -160,7 +213,7 @@ const ClientDownloads = () => {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!clients.mobile?.length && !clients.desktop?.length && (
|
{!clients.mobile?.length && !clients.desktop?.length && !clients.terminal?.length && (
|
||||||
<div className="empty-message">暂无可用的客户端下载</div>
|
<div className="empty-message">暂无可用的客户端下载</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,12 @@ const API_CONFIG = {
|
||||||
NAVIGATION: (meetingId) => `/api/meetings/${meetingId}/navigation`
|
NAVIGATION: (meetingId) => `/api/meetings/${meetingId}/navigation`
|
||||||
},
|
},
|
||||||
ADMIN: {
|
ADMIN: {
|
||||||
SYSTEM_CONFIG: '/api/admin/system-config'
|
SYSTEM_CONFIG: '/api/admin/system-config',
|
||||||
|
DASHBOARD_STATS: '/api/admin/dashboard/stats',
|
||||||
|
ONLINE_USERS: '/api/admin/online-users',
|
||||||
|
KICK_USER: (userId) => `/api/admin/kick-user/${userId}`,
|
||||||
|
TASKS_MONITOR: '/api/admin/tasks/monitor',
|
||||||
|
SYSTEM_RESOURCES: '/api/admin/system/resources'
|
||||||
},
|
},
|
||||||
TAGS: {
|
TAGS: {
|
||||||
LIST: '/api/tags'
|
LIST: '/api/tags'
|
||||||
|
|
@ -48,7 +53,8 @@ const API_CONFIG = {
|
||||||
LIST: '/api/prompts',
|
LIST: '/api/prompts',
|
||||||
CREATE: '/api/prompts',
|
CREATE: '/api/prompts',
|
||||||
UPDATE: (promptId) => `/api/prompts/${promptId}`,
|
UPDATE: (promptId) => `/api/prompts/${promptId}`,
|
||||||
DELETE: (promptId) => `/api/prompts/${promptId}`
|
DELETE: (promptId) => `/api/prompts/${promptId}`,
|
||||||
|
ACTIVE: (taskType) => `/api/prompts/active/${taskType}` // 获取指定任务类型的启用模版
|
||||||
},
|
},
|
||||||
KNOWLEDGE_BASE: {
|
KNOWLEDGE_BASE: {
|
||||||
LIST: '/api/knowledge-bases',
|
LIST: '/api/knowledge-bases',
|
||||||
|
|
@ -59,13 +65,13 @@ const API_CONFIG = {
|
||||||
TASK_STATUS: (taskId) => `/api/knowledge-bases/tasks/${taskId}`
|
TASK_STATUS: (taskId) => `/api/knowledge-bases/tasks/${taskId}`
|
||||||
},
|
},
|
||||||
CLIENT_DOWNLOADS: {
|
CLIENT_DOWNLOADS: {
|
||||||
LIST: '/api/clients/downloads',
|
LIST: '/api/clients',
|
||||||
LATEST: '/api/clients/downloads/latest',
|
LATEST: '/api/clients/latest',
|
||||||
LATEST_BY_PLATFORM: (platformName) => `/api/clients/downloads/${platformName}/latest`,
|
LATEST_BY_PLATFORM: '/api/clients/latest/by-platform',
|
||||||
DETAIL: (id) => `/api/clients/downloads/${id}`,
|
DETAIL: (id) => `/api/clients/${id}`,
|
||||||
CREATE: '/api/clients/downloads',
|
CREATE: '/api/clients',
|
||||||
UPDATE: (id) => `/api/clients/downloads/${id}`,
|
UPDATE: (id) => `/api/clients/${id}`,
|
||||||
DELETE: (id) => `/api/clients/downloads/${id}`
|
DELETE: (id) => `/api/clients/${id}`
|
||||||
},
|
},
|
||||||
VOICEPRINT: {
|
VOICEPRINT: {
|
||||||
STATUS: (userId) => `/api/voiceprint/${userId}`,
|
STATUS: (userId) => `/api/voiceprint/${userId}`,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,434 @@
|
||||||
|
.admin-dashboard {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 使用与普通Dashboard一致的Header样式 */
|
||||||
|
.admin-dashboard .dashboard-header {
|
||||||
|
background: white;
|
||||||
|
border-bottom: 1px solid #e2e8f0;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-dashboard .header-content {
|
||||||
|
max-width: 1400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 1rem 2rem;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-dashboard .logo {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #667eea;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-dashboard .logo-icon {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-dashboard .user-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auto-refresh-toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
background: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auto-refresh-toggle:hover {
|
||||||
|
border-color: #667eea;
|
||||||
|
color: #667eea;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auto-refresh-toggle.active {
|
||||||
|
background: #667eea;
|
||||||
|
color: white;
|
||||||
|
border-color: #667eea;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-dashboard .user-menu-trigger {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
background: #f8fafc;
|
||||||
|
border-radius: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-dashboard .user-menu-trigger:hover {
|
||||||
|
background: #f1f5f9;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-content {
|
||||||
|
max-width: 1400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||||
|
gap: 1.5rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
background: white;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card:hover {
|
||||||
|
transform: translateY(-4px);
|
||||||
|
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-icon {
|
||||||
|
width: 60px;
|
||||||
|
height: 60px;
|
||||||
|
border-radius: 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: white;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-content {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-content h3 {
|
||||||
|
margin: 0 0 0.5rem 0;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #64748b;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-number {
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1e293b;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-details {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resource-bars {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resource-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resource-bar {
|
||||||
|
flex: 1;
|
||||||
|
height: 8px;
|
||||||
|
background: #f1f5f9;
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resource-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(90deg, #667eea 0%, #764ba2 100%);
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tasks-overview {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tasks-overview h2 {
|
||||||
|
color: #1e293b;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-stats-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-stat-card {
|
||||||
|
background: white;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-stat-card h3 {
|
||||||
|
margin: 0 0 1rem 0;
|
||||||
|
font-size: 1.125rem;
|
||||||
|
color: #1e293b;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-stat-numbers {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-stat-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-stat-label {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-stat-value {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-stat-value.running {
|
||||||
|
color: #3b82f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-stat-value.completed {
|
||||||
|
color: #10b981;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-stat-value.failed {
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-content-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-panel {
|
||||||
|
background: white;
|
||||||
|
border-radius: 16px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-header {
|
||||||
|
padding: 1.5rem;
|
||||||
|
border-bottom: 1px solid #e2e8f0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
color: #1e293b;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-filters {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-filters select {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
background: white;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-filters select:hover {
|
||||||
|
border-color: #667eea;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-filters select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #667eea;
|
||||||
|
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-content {
|
||||||
|
padding: 1.5rem;
|
||||||
|
max-height: 500px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 3rem;
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.online-users-table, .tasks-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.online-users-table th, .tasks-table th {
|
||||||
|
text-align: left;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: #f8fafc;
|
||||||
|
color: #475569;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
border-bottom: 2px solid #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.online-users-table td, .tasks-table td {
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-bottom: 1px solid #f1f5f9;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #1e293b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.online-users-table tbody tr:hover, .tasks-table tbody tr:hover {
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kick-btn {
|
||||||
|
padding: 0.375rem;
|
||||||
|
border: none;
|
||||||
|
background: #fef2f2;
|
||||||
|
color: #ef4444;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kick-btn:hover {
|
||||||
|
background: #fee2e2;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.25rem 0.75rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge.status-pending {
|
||||||
|
background: #fef3c7;
|
||||||
|
color: #92400e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge.status-processing {
|
||||||
|
background: #dbeafe;
|
||||||
|
color: #1e40af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge.status-completed {
|
||||||
|
background: #d1fae5;
|
||||||
|
color: #065f46;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge.status-failed {
|
||||||
|
background: #fee2e2;
|
||||||
|
color: #991b1b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 100vh;
|
||||||
|
color: white;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-dashboard .error-message {
|
||||||
|
background: #fee2e2;
|
||||||
|
color: #991b1b;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 滚动条样式 */
|
||||||
|
.panel-content::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-content::-webkit-scrollbar-track {
|
||||||
|
background: #f1f5f9;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-content::-webkit-scrollbar-thumb {
|
||||||
|
background: #cbd5e1;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-content::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 响应式设计 */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.stats-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-stats-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-navbar {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-navbar-right {
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,508 @@
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { LogOut, User, Users, Activity, Server, HardDrive, Cpu, MemoryStick, RefreshCw, UserX, ChevronDown, KeyRound, Shield, BookText, Waves } from 'lucide-react';
|
||||||
|
import apiClient from '../utils/apiClient';
|
||||||
|
import { buildApiUrl, API_ENDPOINTS } from '../config/api';
|
||||||
|
import Dropdown from '../components/Dropdown';
|
||||||
|
import menuService from '../services/menuService';
|
||||||
|
import './AdminDashboard.css';
|
||||||
|
|
||||||
|
// 常量定义
|
||||||
|
const AUTO_REFRESH_INTERVAL = 30; // 自动刷新间隔(秒)
|
||||||
|
const STATUS_BADGE_MAP = {
|
||||||
|
'pending': 'status-badge status-pending',
|
||||||
|
'processing': 'status-badge status-processing',
|
||||||
|
'completed': 'status-badge status-completed',
|
||||||
|
'failed': 'status-badge status-failed'
|
||||||
|
};
|
||||||
|
const STATUS_TEXT_MAP = {
|
||||||
|
'pending': '待处理',
|
||||||
|
'processing': '处理中',
|
||||||
|
'completed': '已完成',
|
||||||
|
'failed': '失败'
|
||||||
|
};
|
||||||
|
const TASK_TYPE_TEXT_MAP = {
|
||||||
|
'transcription': '转录',
|
||||||
|
'summary': '总结',
|
||||||
|
'knowledge_base': '知识库'
|
||||||
|
};
|
||||||
|
|
||||||
|
// 辅助函数
|
||||||
|
const getStatusBadgeClass = (status) => STATUS_BADGE_MAP[status] || 'status-badge';
|
||||||
|
const getStatusText = (status) => STATUS_TEXT_MAP[status] || status;
|
||||||
|
const getTaskTypeText = (type) => TASK_TYPE_TEXT_MAP[type] || type;
|
||||||
|
|
||||||
|
// 默认管理员菜单
|
||||||
|
const getDefaultMenus = () => [
|
||||||
|
{ menu_code: 'change_password', menu_name: '修改密码', menu_type: 'action' },
|
||||||
|
{ menu_code: 'prompt_management', menu_name: '提示词仓库', menu_type: 'link', menu_url: '/prompts' },
|
||||||
|
{ menu_code: 'platform_admin', menu_name: '平台管理', menu_type: 'link', menu_url: '/admin' },
|
||||||
|
{ menu_code: 'logout', menu_name: '退出登录', menu_type: 'action' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const AdminDashboard = ({ user, onLogout }) => {
|
||||||
|
// 统计数据
|
||||||
|
const [stats, setStats] = useState(null);
|
||||||
|
const [onlineUsers, setOnlineUsers] = useState([]);
|
||||||
|
const [tasks, setTasks] = useState([]);
|
||||||
|
const [resources, setResources] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
// 菜单权限相关状态
|
||||||
|
const [userMenus, setUserMenus] = useState([]);
|
||||||
|
|
||||||
|
// 任务筛选
|
||||||
|
const [taskType, setTaskType] = useState('all');
|
||||||
|
const [taskStatus, setTaskStatus] = useState('all');
|
||||||
|
|
||||||
|
// 自动刷新定时器
|
||||||
|
const [autoRefresh, setAutoRefresh] = useState(true);
|
||||||
|
const [countdown, setCountdown] = useState(AUTO_REFRESH_INTERVAL);
|
||||||
|
|
||||||
|
// 获取菜单配置
|
||||||
|
const getMenuItemConfig = (menu) => {
|
||||||
|
const iconMap = {
|
||||||
|
'change_password': <KeyRound size={16} />,
|
||||||
|
'prompt_management': <BookText size={16} />,
|
||||||
|
'platform_admin': <Shield size={16} />,
|
||||||
|
'logout': <LogOut size={16} />
|
||||||
|
};
|
||||||
|
|
||||||
|
const actionMap = {
|
||||||
|
'logout': onLogout
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
icon: iconMap[menu.menu_code],
|
||||||
|
label: menu.menu_name,
|
||||||
|
onClick: menu.menu_type === 'link' && menu.menu_url
|
||||||
|
? () => window.location.href = menu.menu_url
|
||||||
|
: actionMap[menu.menu_code]
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取用户菜单权限
|
||||||
|
const fetchUserMenus = async () => {
|
||||||
|
try {
|
||||||
|
const response = await menuService.getUserMenus();
|
||||||
|
if (response.code === '200') {
|
||||||
|
setUserMenus(response.data.menus || []);
|
||||||
|
} else {
|
||||||
|
setUserMenus(getDefaultMenus());
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching user menus:', error);
|
||||||
|
setUserMenus(getDefaultMenus());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 初始化和自动刷新
|
||||||
|
useEffect(() => {
|
||||||
|
fetchAllData();
|
||||||
|
fetchUserMenus();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (autoRefresh) {
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
setCountdown(prev => {
|
||||||
|
if (prev <= 1) {
|
||||||
|
fetchAllData();
|
||||||
|
return AUTO_REFRESH_INTERVAL;
|
||||||
|
}
|
||||||
|
return prev - 1;
|
||||||
|
});
|
||||||
|
}, 1000);
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}
|
||||||
|
}, [autoRefresh]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchTasks();
|
||||||
|
}, [taskType, taskStatus]);
|
||||||
|
|
||||||
|
const fetchAllData = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
await Promise.all([
|
||||||
|
fetchStats(),
|
||||||
|
fetchOnlineUsers(),
|
||||||
|
fetchTasks(),
|
||||||
|
fetchResources()
|
||||||
|
]);
|
||||||
|
setError('');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('获取数据失败:', err);
|
||||||
|
setError('加载数据失败,请稍后重试');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchStats = async () => {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.ADMIN.DASHBOARD_STATS));
|
||||||
|
if (response.code === '200') {
|
||||||
|
setStats(response.data);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('获取统计数据失败:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchOnlineUsers = async () => {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.ADMIN.ONLINE_USERS));
|
||||||
|
if (response.code === '200') {
|
||||||
|
setOnlineUsers(response.data.users || []);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('获取在线用户失败:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchTasks = async () => {
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (taskType !== 'all') params.append('task_type', taskType);
|
||||||
|
if (taskStatus !== 'all') params.append('status', taskStatus);
|
||||||
|
params.append('limit', '20');
|
||||||
|
|
||||||
|
const url = `${API_ENDPOINTS.ADMIN.TASKS_MONITOR}?${params.toString()}`;
|
||||||
|
const response = await apiClient.get(buildApiUrl(url));
|
||||||
|
if (response.code === '200') {
|
||||||
|
setTasks(response.data.tasks || []);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('获取任务列表失败:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchResources = async () => {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.ADMIN.SYSTEM_RESOURCES));
|
||||||
|
if (response.code === '200') {
|
||||||
|
setResources(response.data);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('获取系统资源失败:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKickUser = async (userId) => {
|
||||||
|
if (!confirm('确定要踢出该用户吗?')) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await apiClient.post(buildApiUrl(API_ENDPOINTS.ADMIN.KICK_USER(userId)));
|
||||||
|
if (response.code === '200') {
|
||||||
|
alert('用户已被踢出');
|
||||||
|
fetchOnlineUsers();
|
||||||
|
} else {
|
||||||
|
alert('踢出失败: ' + response.message);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('踢出用户失败:', err);
|
||||||
|
alert('踢出用户失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading && !stats) {
|
||||||
|
return (
|
||||||
|
<div className="admin-dashboard">
|
||||||
|
<div className="loading-container">加载中...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="admin-dashboard">
|
||||||
|
{/* 顶部导航栏 - 使用与普通Dashboard一致的样式 */}
|
||||||
|
<div className="dashboard-header">
|
||||||
|
<div className="header-content">
|
||||||
|
<div className="logo">
|
||||||
|
<Waves size={32} className="logo-icon" />
|
||||||
|
<span>iMeeting 管理后台</span>
|
||||||
|
</div>
|
||||||
|
<div className="user-actions">
|
||||||
|
<button
|
||||||
|
className={`auto-refresh-toggle ${autoRefresh ? 'active' : ''}`}
|
||||||
|
onClick={() => setAutoRefresh(!autoRefresh)}
|
||||||
|
title={autoRefresh ? '关闭自动刷新' : '开启自动刷新'}
|
||||||
|
>
|
||||||
|
<RefreshCw size={16} />
|
||||||
|
{autoRefresh ? '自动刷新' : '手动刷新'}
|
||||||
|
</button>
|
||||||
|
<Dropdown
|
||||||
|
trigger={
|
||||||
|
<div className="user-menu-trigger">
|
||||||
|
<User size={20} />
|
||||||
|
<span>{user.caption}</span>
|
||||||
|
<ChevronDown size={16} />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
items={userMenus.map(menu => getMenuItemConfig(menu))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Dashboard Content */}
|
||||||
|
<div className="dashboard-content">
|
||||||
|
{error && <div className="error-message">{error}</div>}
|
||||||
|
|
||||||
|
{/* 统计卡片 */}
|
||||||
|
{stats && (
|
||||||
|
<div className="stats-grid">
|
||||||
|
{/* 用户统计 */}
|
||||||
|
<div className="stat-card">
|
||||||
|
<div className="stat-icon" style={{ background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)' }}>
|
||||||
|
<Users size={24} />
|
||||||
|
</div>
|
||||||
|
<div className="stat-content">
|
||||||
|
<h3>用户统计</h3>
|
||||||
|
<div className="stat-number">{stats.users.total}</div>
|
||||||
|
<div className="stat-details">
|
||||||
|
<span>今日新增: {stats.users.today_new}</span>
|
||||||
|
<span>在线: {stats.users.online}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 会议统计 */}
|
||||||
|
<div className="stat-card">
|
||||||
|
<div className="stat-icon" style={{ background: 'linear-gradient(135deg, #f093fb 0%, #f5576c 100%)' }}>
|
||||||
|
<Activity size={24} />
|
||||||
|
</div>
|
||||||
|
<div className="stat-content">
|
||||||
|
<h3>会议统计</h3>
|
||||||
|
<div className="stat-number">{stats.meetings.total}</div>
|
||||||
|
<div className="stat-details">
|
||||||
|
<span>今日新增: {stats.meetings.today_new}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 存储统计 */}
|
||||||
|
<div className="stat-card">
|
||||||
|
<div className="stat-icon" style={{ background: 'linear-gradient(135deg, #4facfe 0%, #00f2fe 100%)' }}>
|
||||||
|
<HardDrive size={24} />
|
||||||
|
</div>
|
||||||
|
<div className="stat-content">
|
||||||
|
<h3>存储统计</h3>
|
||||||
|
<div className="stat-number">{stats.storage.audio_total_size_gb} GB</div>
|
||||||
|
<div className="stat-details">
|
||||||
|
<span>音频文件: {stats.storage.audio_files_count} 个</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 服务器资源 */}
|
||||||
|
{resources && (
|
||||||
|
<div className="stat-card">
|
||||||
|
<div className="stat-icon" style={{ background: 'linear-gradient(135deg, #fa709a 0%, #fee140 100%)' }}>
|
||||||
|
<Server size={24} />
|
||||||
|
</div>
|
||||||
|
<div className="stat-content">
|
||||||
|
<h3>服务器资源</h3>
|
||||||
|
<div className="resource-bars">
|
||||||
|
<div className="resource-item">
|
||||||
|
<Cpu size={14} />
|
||||||
|
<div className="resource-bar">
|
||||||
|
<div className="resource-fill" style={{ width: `${resources.cpu.percent}%` }}></div>
|
||||||
|
</div>
|
||||||
|
<span>{resources.cpu.percent}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="resource-item">
|
||||||
|
<MemoryStick size={14} />
|
||||||
|
<div className="resource-bar">
|
||||||
|
<div className="resource-fill" style={{ width: `${resources.memory.percent}%` }}></div>
|
||||||
|
</div>
|
||||||
|
<span>{resources.memory.percent}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="resource-item">
|
||||||
|
<HardDrive size={14} />
|
||||||
|
<div className="resource-bar">
|
||||||
|
<div className="resource-fill" style={{ width: `${resources.disk.percent}%` }}></div>
|
||||||
|
</div>
|
||||||
|
<span>{resources.disk.percent}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 任务统计 */}
|
||||||
|
{stats && (
|
||||||
|
<div className="tasks-overview">
|
||||||
|
<h2>任务概览</h2>
|
||||||
|
<div className="task-stats-grid">
|
||||||
|
<div className="task-stat-card">
|
||||||
|
<h3>转录任务</h3>
|
||||||
|
<div className="task-stat-numbers">
|
||||||
|
<div className="task-stat-item">
|
||||||
|
<span className="task-stat-label">进行中</span>
|
||||||
|
<span className="task-stat-value running">{stats.tasks.transcription.running}</span>
|
||||||
|
</div>
|
||||||
|
<div className="task-stat-item">
|
||||||
|
<span className="task-stat-label">已完成</span>
|
||||||
|
<span className="task-stat-value completed">{stats.tasks.transcription.completed}</span>
|
||||||
|
</div>
|
||||||
|
<div className="task-stat-item">
|
||||||
|
<span className="task-stat-label">失败</span>
|
||||||
|
<span className="task-stat-value failed">{stats.tasks.transcription.failed}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="task-stat-card">
|
||||||
|
<h3>总结任务</h3>
|
||||||
|
<div className="task-stat-numbers">
|
||||||
|
<div className="task-stat-item">
|
||||||
|
<span className="task-stat-label">进行中</span>
|
||||||
|
<span className="task-stat-value running">{stats.tasks.summary.running}</span>
|
||||||
|
</div>
|
||||||
|
<div className="task-stat-item">
|
||||||
|
<span className="task-stat-label">已完成</span>
|
||||||
|
<span className="task-stat-value completed">{stats.tasks.summary.completed}</span>
|
||||||
|
</div>
|
||||||
|
<div className="task-stat-item">
|
||||||
|
<span className="task-stat-label">失败</span>
|
||||||
|
<span className="task-stat-value failed">{stats.tasks.summary.failed}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="task-stat-card">
|
||||||
|
<h3>知识库任务</h3>
|
||||||
|
<div className="task-stat-numbers">
|
||||||
|
<div className="task-stat-item">
|
||||||
|
<span className="task-stat-label">进行中</span>
|
||||||
|
<span className="task-stat-value running">{stats.tasks.knowledge_base.running}</span>
|
||||||
|
</div>
|
||||||
|
<div className="task-stat-item">
|
||||||
|
<span className="task-stat-label">已完成</span>
|
||||||
|
<span className="task-stat-value completed">{stats.tasks.knowledge_base.completed}</span>
|
||||||
|
</div>
|
||||||
|
<div className="task-stat-item">
|
||||||
|
<span className="task-stat-label">失败</span>
|
||||||
|
<span className="task-stat-value failed">{stats.tasks.knowledge_base.failed}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="admin-content-grid">
|
||||||
|
{/* 在线用户列表 */}
|
||||||
|
<div className="admin-panel">
|
||||||
|
<div className="panel-header">
|
||||||
|
<h2>在线用户 ({onlineUsers.length})</h2>
|
||||||
|
</div>
|
||||||
|
<div className="panel-content">
|
||||||
|
{onlineUsers.length === 0 ? (
|
||||||
|
<div className="empty-state">暂无在线用户</div>
|
||||||
|
) : (
|
||||||
|
<table className="online-users-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>用户名</th>
|
||||||
|
<th>姓名</th>
|
||||||
|
<th>会话数</th>
|
||||||
|
<th>剩余时间</th>
|
||||||
|
<th>操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{onlineUsers.map(u => (
|
||||||
|
<tr key={u.user_id}>
|
||||||
|
<td>{u.user_id}</td>
|
||||||
|
<td>{u.username}</td>
|
||||||
|
<td>{u.caption}</td>
|
||||||
|
<td>{u.token_count}</td>
|
||||||
|
<td>{u.ttl_hours}h</td>
|
||||||
|
<td>
|
||||||
|
<button
|
||||||
|
className="kick-btn"
|
||||||
|
onClick={() => handleKickUser(u.user_id)}
|
||||||
|
title="踢出用户"
|
||||||
|
>
|
||||||
|
<UserX size={16} />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 任务监控 */}
|
||||||
|
<div className="admin-panel">
|
||||||
|
<div className="panel-header">
|
||||||
|
<h2>任务监控</h2>
|
||||||
|
<div className="panel-filters">
|
||||||
|
<select value={taskType} onChange={(e) => setTaskType(e.target.value)}>
|
||||||
|
<option value="all">全部类型</option>
|
||||||
|
<option value="transcription">转录</option>
|
||||||
|
<option value="summary">总结</option>
|
||||||
|
<option value="knowledge_base">知识库</option>
|
||||||
|
</select>
|
||||||
|
<select value={taskStatus} onChange={(e) => setTaskStatus(e.target.value)}>
|
||||||
|
<option value="all">全部状态</option>
|
||||||
|
<option value="running">进行中</option>
|
||||||
|
<option value="completed">已完成</option>
|
||||||
|
<option value="failed">失败</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="panel-content">
|
||||||
|
{tasks.length === 0 ? (
|
||||||
|
<div className="empty-state">暂无任务</div>
|
||||||
|
) : (
|
||||||
|
<table className="tasks-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>任务ID</th>
|
||||||
|
<th>类型</th>
|
||||||
|
<th>关联对象</th>
|
||||||
|
<th>创建者</th>
|
||||||
|
<th>状态</th>
|
||||||
|
<th>进度</th>
|
||||||
|
<th>创建时间</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{tasks.map(task => (
|
||||||
|
<tr key={`${task.task_type}-${task.task_id}`}>
|
||||||
|
<td>{task.task_id}</td>
|
||||||
|
<td>{getTaskTypeText(task.task_type)}</td>
|
||||||
|
<td>{task.meeting_title || '-'}</td>
|
||||||
|
<td>{task.creator_name || '-'}</td>
|
||||||
|
<td>
|
||||||
|
<span className={getStatusBadgeClass(task.status)}>
|
||||||
|
{getStatusText(task.status)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{task.progress !== null ? `${task.progress}%` : '-'}
|
||||||
|
</td>
|
||||||
|
<td>{new Date(task.created_at).toLocaleString()}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AdminDashboard;
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { MessageSquare, Settings, Users, Smartphone } from 'lucide-react';
|
import { MessageSquare, Settings, Users, Smartphone, Shield } from 'lucide-react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { Tabs } from 'antd';
|
import { Tabs } from 'antd';
|
||||||
import UserManagement from './admin/UserManagement';
|
import UserManagement from './admin/UserManagement';
|
||||||
import SystemConfiguration from './admin/SystemConfiguration';
|
import SystemConfiguration from './admin/SystemConfiguration';
|
||||||
import ClientManagement from './ClientManagement';
|
import ClientManagement from './ClientManagement';
|
||||||
|
import PermissionManagement from './admin/PermissionManagement';
|
||||||
import './AdminManagement.css';
|
import './AdminManagement.css';
|
||||||
|
|
||||||
const { TabPane } = Tabs;
|
const { TabPane } = Tabs;
|
||||||
|
|
@ -36,6 +37,12 @@ const AdminManagement = () => {
|
||||||
>
|
>
|
||||||
<UserManagement />
|
<UserManagement />
|
||||||
</TabPane>
|
</TabPane>
|
||||||
|
<TabPane
|
||||||
|
tab={<span><Shield size={16} /> 权限管理</span>}
|
||||||
|
key="permissionManagement"
|
||||||
|
>
|
||||||
|
<PermissionManagement />
|
||||||
|
</TabPane>
|
||||||
<TabPane
|
<TabPane
|
||||||
tab={<span><Settings size={16} /> 系统配置</span>}
|
tab={<span><Settings size={16} /> 系统配置</span>}
|
||||||
key="systemConfiguration"
|
key="systemConfiguration"
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,8 @@ import {
|
||||||
Hash,
|
Hash,
|
||||||
Link,
|
Link,
|
||||||
FileText,
|
FileText,
|
||||||
HardDrive
|
HardDrive,
|
||||||
|
Cpu
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import apiClient from '../utils/apiClient';
|
import apiClient from '../utils/apiClient';
|
||||||
import { buildApiUrl, API_ENDPOINTS } from '../config/api';
|
import { buildApiUrl, API_ENDPOINTS } from '../config/api';
|
||||||
|
|
@ -59,6 +60,10 @@ const ClientManagement = ({ user }) => {
|
||||||
{ value: 'mac_intel', label: 'Mac (Intel)', icon: <Apple size={16} /> },
|
{ value: 'mac_intel', label: 'Mac (Intel)', icon: <Apple size={16} /> },
|
||||||
{ value: 'mac_m', label: 'Mac (M系列)', icon: <Apple size={16} /> },
|
{ value: 'mac_m', label: 'Mac (M系列)', icon: <Apple size={16} /> },
|
||||||
{ value: 'linux', label: 'Linux', icon: <Monitor size={16} /> }
|
{ value: 'linux', label: 'Linux', icon: <Monitor size={16} /> }
|
||||||
|
],
|
||||||
|
terminal: [
|
||||||
|
{ value: 'android', label: 'Android终端', icon: <Smartphone size={16} /> },
|
||||||
|
{ value: 'mcu', label: '单片机', icon: <Cpu size={16} /> }
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -264,7 +269,7 @@ const ClientManagement = ({ user }) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const getPlatformLabel = (platformName) => {
|
const getPlatformLabel = (platformName) => {
|
||||||
const allOptions = [...platformOptions.mobile, ...platformOptions.desktop];
|
const allOptions = [...platformOptions.mobile, ...platformOptions.desktop, ...platformOptions.terminal];
|
||||||
const option = allOptions.find(opt => opt.value === platformName);
|
const option = allOptions.find(opt => opt.value === platformName);
|
||||||
return option ? option.label : platformName;
|
return option ? option.label : platformName;
|
||||||
};
|
};
|
||||||
|
|
@ -299,7 +304,8 @@ const ClientManagement = ({ user }) => {
|
||||||
|
|
||||||
const groupedClients = {
|
const groupedClients = {
|
||||||
mobile: filteredClients.filter(c => c.platform_type === 'mobile'),
|
mobile: filteredClients.filter(c => c.platform_type === 'mobile'),
|
||||||
desktop: filteredClients.filter(c => c.platform_type === 'desktop')
|
desktop: filteredClients.filter(c => c.platform_type === 'desktop'),
|
||||||
|
terminal: filteredClients.filter(c => c.platform_type === 'terminal')
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
|
|
@ -353,21 +359,34 @@ const ClientManagement = ({ user }) => {
|
||||||
<Monitor size={16} />
|
<Monitor size={16} />
|
||||||
<span>桌面端</span>
|
<span>桌面端</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
className={`filter-btn ${filterPlatformType === 'terminal' ? 'active' : ''}`}
|
||||||
|
onClick={() => setFilterPlatformType('terminal')}
|
||||||
|
>
|
||||||
|
<Cpu size={16} />
|
||||||
|
<span>专用终端</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="clients-sections">
|
<div className="clients-sections">
|
||||||
{['mobile', 'desktop'].map(type => {
|
{['mobile', 'desktop', 'terminal'].map(type => {
|
||||||
const typeClients = groupedClients[type];
|
const typeClients = groupedClients[type];
|
||||||
if (typeClients.length === 0 && filterPlatformType && filterPlatformType !== type) {
|
if (typeClients.length === 0 && filterPlatformType && filterPlatformType !== type) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const typeConfig = {
|
||||||
|
mobile: { icon: <Smartphone size={20} />, label: '移动端' },
|
||||||
|
desktop: { icon: <Monitor size={20} />, label: '桌面端' },
|
||||||
|
terminal: { icon: <Cpu size={20} />, label: '专用终端' }
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={type} className="client-section">
|
<div key={type} className="client-section">
|
||||||
<h2 className="section-title">
|
<h2 className="section-title">
|
||||||
{type === 'mobile' ? <Smartphone size={20} /> : <Monitor size={20} />}
|
{typeConfig[type].icon}
|
||||||
<span>{type === 'mobile' ? '移动端' : '桌面端'}</span>
|
<span>{typeConfig[type].label}</span>
|
||||||
<span className="count">({typeClients.length})</span>
|
<span className="count">({typeClients.length})</span>
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
|
|
@ -484,6 +503,7 @@ const ClientManagement = ({ user }) => {
|
||||||
>
|
>
|
||||||
<option value="mobile">移动端</option>
|
<option value="mobile">移动端</option>
|
||||||
<option value="desktop">桌面端</option>
|
<option value="desktop">桌面端</option>
|
||||||
|
<option value="terminal">专用终端</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import PageLoading from '../components/PageLoading';
|
||||||
import ScrollToTop from '../components/ScrollToTop';
|
import ScrollToTop from '../components/ScrollToTop';
|
||||||
import Dropdown from '../components/Dropdown';
|
import Dropdown from '../components/Dropdown';
|
||||||
import meetingCacheService from '../services/meetingCacheService';
|
import meetingCacheService from '../services/meetingCacheService';
|
||||||
|
import menuService from '../services/menuService';
|
||||||
import './Dashboard.css';
|
import './Dashboard.css';
|
||||||
|
|
||||||
const Dashboard = ({ user, onLogout }) => {
|
const Dashboard = ({ user, onLogout }) => {
|
||||||
|
|
@ -39,10 +40,14 @@ const Dashboard = ({ user, onLogout }) => {
|
||||||
const [voiceprintLoading, setVoiceprintLoading] = useState(true);
|
const [voiceprintLoading, setVoiceprintLoading] = useState(true);
|
||||||
const [showDeleteVoiceprintDialog, setShowDeleteVoiceprintDialog] = useState(false);
|
const [showDeleteVoiceprintDialog, setShowDeleteVoiceprintDialog] = useState(false);
|
||||||
|
|
||||||
|
// 菜单权限相关状态
|
||||||
|
const [userMenus, setUserMenus] = useState([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchUserData();
|
fetchUserData();
|
||||||
fetchMeetingsStats();
|
fetchMeetingsStats();
|
||||||
fetchVoiceprintData();
|
fetchVoiceprintData();
|
||||||
|
fetchUserMenus();
|
||||||
|
|
||||||
// 开发环境下,在控制台添加缓存调试命令
|
// 开发环境下,在控制台添加缓存调试命令
|
||||||
if (process.env.NODE_ENV === 'development') {
|
if (process.env.NODE_ENV === 'development') {
|
||||||
|
|
@ -96,6 +101,76 @@ const Dashboard = ({ user, onLogout }) => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const fetchUserMenus = async () => {
|
||||||
|
try {
|
||||||
|
console.log('[Dashboard] 开始获取用户菜单...');
|
||||||
|
const response = await menuService.getUserMenus();
|
||||||
|
console.log('[Dashboard] 菜单API响应:', response);
|
||||||
|
|
||||||
|
if (response.code === '200') {
|
||||||
|
const menus = response.data.menus || [];
|
||||||
|
console.log('[Dashboard] 用户菜单获取成功,菜单数量:', menus.length, '菜单内容:', menus);
|
||||||
|
setUserMenus(menus);
|
||||||
|
} else {
|
||||||
|
console.error('[Dashboard] 获取用户菜单失败:', response.message);
|
||||||
|
// 使用默认菜单作为fallback
|
||||||
|
setUserMenus(getDefaultMenus());
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Dashboard] 获取用户菜单异常:', err);
|
||||||
|
// 使用默认菜单作为fallback
|
||||||
|
setUserMenus(getDefaultMenus());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取默认菜单(fallback)
|
||||||
|
const getDefaultMenus = () => {
|
||||||
|
const defaultMenus = [
|
||||||
|
{ menu_code: 'change_password', menu_name: '修改密码', menu_type: 'action', sort_order: 1 },
|
||||||
|
{ menu_code: 'prompt_management', menu_name: '提示词仓库', menu_type: 'link', menu_url: '/prompt-management', sort_order: 2 },
|
||||||
|
{ menu_code: 'logout', menu_name: '退出登录', menu_type: 'action', sort_order: 99 }
|
||||||
|
];
|
||||||
|
|
||||||
|
// 如果是管理员,添加平台管理菜单
|
||||||
|
if (user.role_id === 1) {
|
||||||
|
defaultMenus.splice(2, 0, {
|
||||||
|
menu_code: 'platform_admin',
|
||||||
|
menu_name: '平台管理',
|
||||||
|
menu_type: 'link',
|
||||||
|
menu_url: '/admin/management',
|
||||||
|
sort_order: 3
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[Dashboard] 使用默认菜单:', defaultMenus);
|
||||||
|
return defaultMenus;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 将菜单code映射到图标和行为
|
||||||
|
const getMenuItemConfig = (menu) => {
|
||||||
|
const iconMap = {
|
||||||
|
'change_password': <KeyRound size={16} />,
|
||||||
|
'prompt_management': <BookText size={16} />,
|
||||||
|
'platform_admin': <Shield size={16} />,
|
||||||
|
'logout': <LogOut size={16} />
|
||||||
|
};
|
||||||
|
|
||||||
|
const actionMap = {
|
||||||
|
'change_password': () => setShowChangePasswordModal(true),
|
||||||
|
'prompt_management': () => window.location.href = '/prompt-management',
|
||||||
|
'platform_admin': () => window.location.href = '/admin/management',
|
||||||
|
'logout': onLogout
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
icon: iconMap[menu.menu_code] || null,
|
||||||
|
label: menu.menu_name,
|
||||||
|
onClick: menu.menu_type === 'link' && menu.menu_url
|
||||||
|
? () => window.location.href = menu.menu_url
|
||||||
|
: actionMap[menu.menu_code] || (() => {})
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
// 过滤会议
|
// 过滤会议
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchMeetings(1, false);
|
fetchMeetings(1, false);
|
||||||
|
|
@ -359,28 +434,7 @@ const Dashboard = ({ user, onLogout }) => {
|
||||||
<ChevronDown size={20} />
|
<ChevronDown size={20} />
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
items={[
|
items={userMenus.map(menu => getMenuItemConfig(menu))}
|
||||||
{
|
|
||||||
icon: <KeyRound size={16} />,
|
|
||||||
label: '修改密码',
|
|
||||||
onClick: () => setShowChangePasswordModal(true)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: <BookText size={16} />,
|
|
||||||
label: '提示词仓库',
|
|
||||||
onClick: () => window.location.href = '/prompt-management'
|
|
||||||
},
|
|
||||||
...(user.role_id === 1 ? [{
|
|
||||||
icon: <Shield size={16} />,
|
|
||||||
label: '平台管理',
|
|
||||||
onClick: () => window.location.href = '/admin/management'
|
|
||||||
}] : []),
|
|
||||||
{
|
|
||||||
icon: <LogOut size={16} />,
|
|
||||||
label: '退出登录',
|
|
||||||
onClick: onLogout
|
|
||||||
}
|
|
||||||
]}
|
|
||||||
align="right"
|
align="right"
|
||||||
className="user-menu-dropdown"
|
className="user-menu-dropdown"
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -661,6 +661,40 @@
|
||||||
color: #1e293b;
|
color: #1e293b;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.field-hint {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #64748b;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
background-color: white;
|
||||||
|
color: #1e293b;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-select:hover {
|
||||||
|
border-color: #cbd5e1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #667eea;
|
||||||
|
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-select option {
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
.kb-prompt-input {
|
.kb-prompt-input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,8 @@ const KnowledgeBasePage = ({ user }) => {
|
||||||
const [createStep, setCreateStep] = useState(1); // 1: 选择会议, 2: 输入提示词
|
const [createStep, setCreateStep] = useState(1); // 1: 选择会议, 2: 输入提示词
|
||||||
const [meetingsPagination, setMeetingsPagination] = useState({ page: 1, total: 0, has_more: false });
|
const [meetingsPagination, setMeetingsPagination] = useState({ page: 1, total: 0, has_more: false });
|
||||||
const [loadingMeetings, setLoadingMeetings] = useState(false);
|
const [loadingMeetings, setLoadingMeetings] = useState(false);
|
||||||
|
const [availablePrompts, setAvailablePrompts] = useState([]); // 可用的提示词模版列表
|
||||||
|
const [selectedPromptId, setSelectedPromptId] = useState(null); // 选中的提示词模版ID
|
||||||
|
|
||||||
// Toast helper functions
|
// Toast helper functions
|
||||||
const showToast = (message, type = 'info') => {
|
const showToast = (message, type = 'info') => {
|
||||||
|
|
@ -183,6 +185,24 @@ const KnowledgeBasePage = ({ user }) => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 获取知识库任务的启用提示词模版列表
|
||||||
|
const fetchAvailablePrompts = async () => {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.PROMPTS.ACTIVE('KNOWLEDGE_TASK')));
|
||||||
|
const promptsList = response.data.prompts || [];
|
||||||
|
setAvailablePrompts(promptsList);
|
||||||
|
|
||||||
|
// 自动选中默认模版
|
||||||
|
const defaultPrompt = promptsList.find(p => p.is_default);
|
||||||
|
if (defaultPrompt) {
|
||||||
|
setSelectedPromptId(defaultPrompt.id);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching available prompts:", error);
|
||||||
|
setAvailablePrompts([]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const loadKbDetail = async (kbId) => {
|
const loadKbDetail = async (kbId) => {
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.KNOWLEDGE_BASE.DETAIL(kbId)));
|
const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.KNOWLEDGE_BASE.DETAIL(kbId)));
|
||||||
|
|
@ -207,7 +227,8 @@ const KnowledgeBasePage = ({ user }) => {
|
||||||
const response = await apiClient.post(buildApiUrl(API_ENDPOINTS.KNOWLEDGE_BASE.CREATE), {
|
const response = await apiClient.post(buildApiUrl(API_ENDPOINTS.KNOWLEDGE_BASE.CREATE), {
|
||||||
user_prompt: userPrompt,
|
user_prompt: userPrompt,
|
||||||
source_meeting_ids: selectedMeetings.join(','),
|
source_meeting_ids: selectedMeetings.join(','),
|
||||||
is_shared: false
|
is_shared: false,
|
||||||
|
prompt_id: selectedPromptId // 传递选中的模版ID
|
||||||
});
|
});
|
||||||
setTaskId(response.data.task_id);
|
setTaskId(response.data.task_id);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -247,7 +268,10 @@ const KnowledgeBasePage = ({ user }) => {
|
||||||
setUserPrompt('');
|
setUserPrompt('');
|
||||||
setSearchQuery('');
|
setSearchQuery('');
|
||||||
setSelectedTags([]);
|
setSelectedTags([]);
|
||||||
|
setSelectedPromptId(null);
|
||||||
setShowCreateForm(true);
|
setShowCreateForm(true);
|
||||||
|
// 获取可用的提示词模版
|
||||||
|
fetchAvailablePrompts();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCloseCreateModal = () => {
|
const handleCloseCreateModal = () => {
|
||||||
|
|
@ -800,8 +824,27 @@ const KnowledgeBasePage = ({ user }) => {
|
||||||
<span className="summary-value">{selectedMeetings.length} 个</span>
|
<span className="summary-value">{selectedMeetings.length} 个</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 模版选择 */}
|
||||||
|
{availablePrompts.length > 0 && (
|
||||||
|
<div className="form-group">
|
||||||
|
<label>* 选择生成模版(可选)</label>
|
||||||
|
<select
|
||||||
|
value={selectedPromptId || ''}
|
||||||
|
onChange={(e) => setSelectedPromptId(e.target.value ? parseInt(e.target.value) : null)}
|
||||||
|
className="template-select"
|
||||||
|
>
|
||||||
|
{availablePrompts.map(prompt => (
|
||||||
|
<option key={prompt.id} value={prompt.id}>
|
||||||
|
{prompt.name} {prompt.is_default ? '(默认)' : ''}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label>用户提示词(可选)</label>
|
<label>* 用户提示词(可选)</label>
|
||||||
<p className="field-hint">您可以添加额外的要求来定制知识库生成内容,例如重点关注某个主题、提取特定信息等。如不填写,系统将使用默认提示词。</p>
|
<p className="field-hint">您可以添加额外的要求来定制知识库生成内容,例如重点关注某个主题、提取特定信息等。如不填写,系统将使用默认提示词。</p>
|
||||||
<textarea
|
<textarea
|
||||||
placeholder="例如:请重点关注会议中的决策事项和待办任务..."
|
placeholder="例如:请重点关注会议中的决策事项和待办任务..."
|
||||||
|
|
|
||||||
|
|
@ -116,9 +116,9 @@ const MeetingPreview = () => {
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<div className="meeting-info-section">
|
<div className="meeting-info-section">
|
||||||
<h2 className="section-title">📋 会议信息</h2>
|
<h2 className="section-title">📋 {meetingData.prompt_name || '会议'} 概览</h2>
|
||||||
<div className="info-item">
|
<div className="info-item">
|
||||||
<strong>会议时间:</strong>
|
<strong>时间:</strong>
|
||||||
{formatDateTime(meetingData.meeting_time)}
|
{formatDateTime(meetingData.meeting_time)}
|
||||||
</div>
|
</div>
|
||||||
<div className="info-item">
|
<div className="info-item">
|
||||||
|
|
@ -136,7 +136,7 @@ const MeetingPreview = () => {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="summary-section">
|
<div className="summary-section">
|
||||||
<h2 className="section-title">📝 会议内容</h2>
|
<h2 className="section-title">📝 {meetingData.prompt_name || '会议'} 总结</h2>
|
||||||
<Tabs defaultActiveKey="summary" className="preview-tabs">
|
<Tabs defaultActiveKey="summary" className="preview-tabs">
|
||||||
<TabPane tab="摘要" key="summary">
|
<TabPane tab="摘要" key="summary">
|
||||||
<div className="summary-content">
|
<div className="summary-content">
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,121 @@
|
||||||
|
.permission-management {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pm-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
padding-bottom: 16px;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pm-header-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pm-header-left h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1e293b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pm-card {
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pm-description {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: #f8fafc;
|
||||||
|
border-radius: 6px;
|
||||||
|
border-left: 3px solid #667eea;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pm-description p {
|
||||||
|
margin: 0;
|
||||||
|
color: #475569;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 表格样式 */
|
||||||
|
.permission-table .ant-table-thead > tr > th {
|
||||||
|
background: #f8fafc;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #475569;
|
||||||
|
text-align: center;
|
||||||
|
padding: 16px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.permission-table .ant-table-tbody > tr > td {
|
||||||
|
padding: 16px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.permission-table .ant-table-tbody > tr:hover > td {
|
||||||
|
background: #fafbfc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.permission-table .ant-table-cell-fix-left {
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.permission-table .ant-table-tbody > tr:hover > .ant-table-cell-fix-left {
|
||||||
|
background: #fafbfc;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 保存按钮样式 */
|
||||||
|
.save-btn {
|
||||||
|
background: #667eea;
|
||||||
|
border-color: #667eea;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-btn:hover {
|
||||||
|
background: #5568d3;
|
||||||
|
border-color: #5568d3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-btn:focus {
|
||||||
|
background: #5568d3;
|
||||||
|
border-color: #5568d3;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Checkbox样式 */
|
||||||
|
.permission-table .ant-checkbox-wrapper {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.permission-table .ant-checkbox-checked .ant-checkbox-inner {
|
||||||
|
background-color: #667eea;
|
||||||
|
border-color: #667eea;
|
||||||
|
}
|
||||||
|
|
||||||
|
.permission-table .ant-checkbox-wrapper:hover .ant-checkbox-inner,
|
||||||
|
.permission-table .ant-checkbox:hover .ant-checkbox-inner,
|
||||||
|
.permission-table .ant-checkbox-input:focus + .ant-checkbox-inner {
|
||||||
|
border-color: #667eea;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 响应式优化 */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.pm-header {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-btn {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,200 @@
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { Table, Button, Checkbox, message, Spin, Card } from 'antd';
|
||||||
|
import { Shield, Save } from 'lucide-react';
|
||||||
|
import axios from 'axios';
|
||||||
|
import './PermissionManagement.css';
|
||||||
|
|
||||||
|
const PermissionManagement = () => {
|
||||||
|
const [roles, setRoles] = useState([]);
|
||||||
|
const [menus, setMenus] = useState([]);
|
||||||
|
const [permissions, setPermissions] = useState({}); // {roleId: [menuId1, menuId2, ...]}
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
// 获取所有数据
|
||||||
|
const fetchData = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
// 获取token - 与apiClient保持一致
|
||||||
|
const savedUser = localStorage.getItem('iMeetingUser');
|
||||||
|
if (!savedUser) {
|
||||||
|
message.error('未找到登录信息,请重新登录');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const user = JSON.parse(savedUser);
|
||||||
|
const token = user.token;
|
||||||
|
|
||||||
|
// 并行获取角色、菜单数据
|
||||||
|
const [rolesRes, menusRes] = await Promise.all([
|
||||||
|
axios.get('/api/admin/roles', {
|
||||||
|
headers: { Authorization: `Bearer ${token}` }
|
||||||
|
}),
|
||||||
|
axios.get('/api/admin/menus', {
|
||||||
|
headers: { Authorization: `Bearer ${token}` }
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (rolesRes.data.code === '200' && menusRes.data.code === '200') {
|
||||||
|
const rolesList = rolesRes.data.data.roles;
|
||||||
|
const menusList = menusRes.data.data.menus;
|
||||||
|
|
||||||
|
setRoles(rolesList);
|
||||||
|
setMenus(menusList);
|
||||||
|
|
||||||
|
// 获取每个角色的权限
|
||||||
|
const permsMap = {};
|
||||||
|
for (const role of rolesList) {
|
||||||
|
const permRes = await axios.get(
|
||||||
|
`/api/admin/roles/${role.role_id}/permissions`,
|
||||||
|
{ headers: { Authorization: `Bearer ${token}` } }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (permRes.data.code === '200') {
|
||||||
|
permsMap[role.role_id] = permRes.data.data.menu_ids;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setPermissions(permsMap);
|
||||||
|
} else {
|
||||||
|
message.error('获取数据失败: ' + (rolesRes.data.message || menusRes.data.message));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
message.error('获取数据失败: ' + (error.response?.data?.message || error.message));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 切换权限
|
||||||
|
const togglePermission = (roleId, menuId) => {
|
||||||
|
setPermissions(prev => {
|
||||||
|
const rolePerms = prev[roleId] || [];
|
||||||
|
const hasPermission = rolePerms.includes(menuId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
[roleId]: hasPermission
|
||||||
|
? rolePerms.filter(id => id !== menuId)
|
||||||
|
: [...rolePerms, menuId]
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 保存所有权限
|
||||||
|
const saveAllPermissions = async () => {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
// 获取token - 与apiClient保持一致
|
||||||
|
const savedUser = localStorage.getItem('iMeetingUser');
|
||||||
|
if (!savedUser) {
|
||||||
|
message.error('未找到登录信息,请重新登录');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const user = JSON.parse(savedUser);
|
||||||
|
const token = user.token;
|
||||||
|
|
||||||
|
// 保存每个角色的权限
|
||||||
|
for (const role of roles) {
|
||||||
|
await axios.put(
|
||||||
|
`/api/admin/roles/${role.role_id}/permissions`,
|
||||||
|
{ menu_ids: permissions[role.role_id] || [] },
|
||||||
|
{ headers: { Authorization: `Bearer ${token}` } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
message.success('权限保存成功');
|
||||||
|
fetchData(); // 重新加载数据
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving permissions:', error);
|
||||||
|
message.error('保存权限失败');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 生成表格列
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
title: '菜单功能',
|
||||||
|
dataIndex: 'menu_name',
|
||||||
|
key: 'menu_name',
|
||||||
|
fixed: 'left',
|
||||||
|
width: 200,
|
||||||
|
render: (text, record) => (
|
||||||
|
<div>
|
||||||
|
<div style={{ fontWeight: 500, color: '#1e293b' }}>{text}</div>
|
||||||
|
<div style={{ fontSize: '12px', color: '#64748b' }}>({record.menu_code})</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
...roles.map(role => ({
|
||||||
|
title: (
|
||||||
|
<div style={{ textAlign: 'center' }}>
|
||||||
|
<div style={{ fontWeight: 600, marginBottom: '4px' }}>{role.role_name}</div>
|
||||||
|
<div style={{ fontSize: '11px', color: '#64748b' }}>
|
||||||
|
{(permissions[role.role_id] || []).length}/{menus.length}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
key: `role_${role.role_id}`,
|
||||||
|
width: 150,
|
||||||
|
align: 'center',
|
||||||
|
render: (_, menu) => {
|
||||||
|
const rolePerms = permissions[role.role_id] || [];
|
||||||
|
const isChecked = rolePerms.includes(menu.menu_id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Checkbox
|
||||||
|
checked={isChecked}
|
||||||
|
onChange={() => togglePermission(role.role_id, menu.menu_id)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="permission-management">
|
||||||
|
<div className="pm-header">
|
||||||
|
<div className="pm-header-left">
|
||||||
|
<Shield size={24} color="#667eea" />
|
||||||
|
<h2>角色权限管理</h2>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<Save size={16} />}
|
||||||
|
onClick={saveAllPermissions}
|
||||||
|
loading={saving}
|
||||||
|
size="large"
|
||||||
|
className="save-btn"
|
||||||
|
>
|
||||||
|
保存所有权限
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="pm-card">
|
||||||
|
<div className="pm-description">
|
||||||
|
<p>在下表中勾选相应的复选框,为不同角色分配菜单功能权限。修改后点击右上角"保存所有权限"按钮生效。</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Spin spinning={loading}>
|
||||||
|
<Table
|
||||||
|
columns={columns}
|
||||||
|
dataSource={menus}
|
||||||
|
rowKey="menu_id"
|
||||||
|
pagination={false}
|
||||||
|
scroll={{ x: 'max-content' }}
|
||||||
|
className="permission-table"
|
||||||
|
bordered
|
||||||
|
/>
|
||||||
|
</Spin>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PermissionManagement;
|
||||||
|
|
@ -1,163 +1,519 @@
|
||||||
|
/* 提示词管理页面 - 左右分栏布局 */
|
||||||
.prompt-management {
|
.prompt-management {
|
||||||
padding: 1rem;
|
height: 100vh;
|
||||||
}
|
|
||||||
|
|
||||||
.toolbar {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toolbar h2 {
|
|
||||||
font-size: 1.5rem;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.prompt-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
|
||||||
gap: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.prompt-card {
|
|
||||||
background: #fff;
|
|
||||||
border: 1px solid #e9ecef;
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 1.5rem;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
|
|
||||||
transition: box-shadow 0.3s ease, transform 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.prompt-card:hover {
|
|
||||||
transform: translateY(-5px);
|
|
||||||
box-shadow: 0 8px 16px rgba(0,0,0,0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: flex-start;
|
|
||||||
margin-bottom: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-header h3 {
|
|
||||||
font-size: 1.1rem;
|
|
||||||
font-weight: 600;
|
|
||||||
margin: 0;
|
|
||||||
line-height: 1.3;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.5rem;
|
|
||||||
position: relative; /* For dropdown positioning */
|
|
||||||
}
|
|
||||||
|
|
||||||
.dropdown-trigger {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
color: #6c757d;
|
|
||||||
padding: 0.25rem;
|
|
||||||
border-radius: 4px;
|
|
||||||
transition: background-color 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dropdown-trigger:hover {
|
|
||||||
background-color: #f1f3f5;
|
|
||||||
color: #212529;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dropdown-menu {
|
|
||||||
position: absolute;
|
|
||||||
top: 100%;
|
|
||||||
right: 0;
|
|
||||||
background: white;
|
|
||||||
border-radius: 8px;
|
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
|
||||||
border: 1px solid #e2e8f0;
|
|
||||||
padding: 0.5rem;
|
|
||||||
margin-top: 0.5rem;
|
|
||||||
min-width: 120px;
|
|
||||||
z-index: 10;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 2px;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dropdown-item {
|
/* 左右分栏布局 */
|
||||||
|
.prompt-layout {
|
||||||
|
display: flex;
|
||||||
|
gap: 1.5rem;
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 左侧提示词列表 */
|
||||||
|
.prompt-sidebar {
|
||||||
|
width: 320px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e9ecef;
|
||||||
|
border-radius: 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prompt-sidebar.collapsed {
|
||||||
|
width: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
border-bottom: 1px solid #e9ecef;
|
||||||
|
background: #f8f9fa;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-header h3 {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1e293b;
|
||||||
|
margin: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-new-prompt {
|
||||||
|
background: #667eea;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: white;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.5rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-new-prompt:hover {
|
||||||
|
background: #5568d3;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-toggle-sidebar {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #64748b;
|
||||||
|
padding: 0.25rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-toggle-sidebar:hover {
|
||||||
|
background: #e2e8f0;
|
||||||
|
color: #1e293b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prompt-list-sidebar {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 分组样式 */
|
||||||
|
.prompt-group {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
padding: 0.5rem 0.75rem;
|
padding: 0.5rem 0.75rem;
|
||||||
border: none;
|
|
||||||
background: none;
|
|
||||||
cursor: pointer;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
font-weight: 500;
|
font-weight: 600;
|
||||||
color: #374151;
|
color: #475569;
|
||||||
text-decoration: none;
|
background: #f1f5f9;
|
||||||
|
border-radius: 6px;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 提示词列表项 */
|
||||||
|
.prompt-list-item {
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
width: 100%;
|
background: #fff;
|
||||||
text-align: left;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dropdown-item:hover {
|
.prompt-list-item:hover {
|
||||||
background: #f3f4f6;
|
background: #f8fafc;
|
||||||
color: #111827;
|
border-color: #cbd5e1;
|
||||||
|
transform: translateX(4px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dropdown-item.delete-item {
|
.prompt-list-item.active {
|
||||||
|
background: #eff6ff;
|
||||||
|
border-color: #3b82f6;
|
||||||
|
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.prompt-item-info {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prompt-item-info h4 {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1e293b;
|
||||||
|
margin: 0;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
padding: 0.125rem 0.5rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.5;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-default {
|
||||||
|
background: linear-gradient(135deg, #fbbf24 0%, #f59e0b 100%);
|
||||||
|
color: white;
|
||||||
|
box-shadow: 0 2px 4px rgba(245, 158, 11, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-inactive {
|
||||||
|
background: #f1f5f9;
|
||||||
|
color: #64748b;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 右侧编辑区 */
|
||||||
|
.prompt-detail-area {
|
||||||
|
flex: 1;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e9ecef;
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 第一行:标题 + 操作按钮 */
|
||||||
|
.prompt-title-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1.5rem 2rem;
|
||||||
|
border-bottom: 1px solid #e9ecef;
|
||||||
|
background: #fafbfc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title-edit-container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title-edit-container h1 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1e293b;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title-input {
|
||||||
|
flex: 0.8;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1e293b;
|
||||||
|
border: 2px solid #667eea;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
background: white;
|
||||||
|
outline: none;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title-input:focus {
|
||||||
|
border-color: #5568d3;
|
||||||
|
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn-small {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn-small svg {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn-small.edit-btn {
|
||||||
|
background: #f1f5f9;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn-small.edit-btn:hover {
|
||||||
|
background: #e2e8f0;
|
||||||
|
color: #1e293b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn-small.confirm-btn {
|
||||||
|
background: #10b981;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn-small.confirm-btn svg {
|
||||||
|
color: white;
|
||||||
|
stroke: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn-small.confirm-btn:hover:not(:disabled) {
|
||||||
|
background: #059669;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 12px rgba(16, 185, 129, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn-small.cancel-btn {
|
||||||
|
background: #f1f5f9;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn-small.cancel-btn:hover {
|
||||||
|
background: #fee2e2;
|
||||||
color: #dc2626;
|
color: #dc2626;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dropdown-item.delete-item:hover {
|
.icon-btn-small:disabled {
|
||||||
background: #fef2f2;
|
opacity: 0.5;
|
||||||
color: #b91c1c;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-actions button {
|
.title-actions {
|
||||||
background: none;
|
display: flex;
|
||||||
border: none;
|
gap: 0.5rem;
|
||||||
cursor: pointer;
|
|
||||||
color: #6c757d;
|
|
||||||
padding: 0.25rem;
|
|
||||||
border-radius: 4px;
|
|
||||||
transition: background-color 0.2s ease;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-actions button:hover {
|
.icon-btn {
|
||||||
background-color: #f1f3f5;
|
width: 36px;
|
||||||
color: #212529;
|
height: 36px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-tags {
|
.icon-btn svg {
|
||||||
margin-bottom: 1rem;
|
flex-shrink: 0;
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.tag-chip {
|
.icon-btn:disabled {
|
||||||
background-color: #e9ecef;
|
opacity: 0.5;
|
||||||
color: #f2f0f7;
|
cursor: not-allowed;
|
||||||
padding: 0.25rem 0.6rem;
|
|
||||||
border-radius: 12px;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-content {
|
.icon-btn.save-btn {
|
||||||
font-size: 0.9rem;
|
background: #667eea;
|
||||||
color: #495057;
|
color: white;
|
||||||
line-height: 1.5;
|
|
||||||
flex-grow: 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Form group styles - 配合 FormModal 使用 */
|
.icon-btn.save-btn svg {
|
||||||
|
color: white;
|
||||||
|
stroke: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn.save-btn:hover:not(:disabled) {
|
||||||
|
background: #5568d3;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn.delete-btn {
|
||||||
|
background: #fee2e2;
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn.delete-btn svg {
|
||||||
|
color: #dc2626;
|
||||||
|
stroke: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn.delete-btn:hover {
|
||||||
|
background: #fca5a5;
|
||||||
|
color: #991b1b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn.delete-btn:hover svg {
|
||||||
|
color: #991b1b;
|
||||||
|
stroke: #991b1b;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 第二行:控制区 */
|
||||||
|
.prompt-controls-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1.5rem;
|
||||||
|
padding: 1rem 2rem;
|
||||||
|
border-bottom: 1px solid #e9ecef;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prompt-actions-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-set-default {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border: 1.5px solid #e2e8f0;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: white;
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-set-default:hover:not(:disabled) {
|
||||||
|
border-color: #f59e0b;
|
||||||
|
color: #f59e0b;
|
||||||
|
background: #fffbeb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-set-default.active {
|
||||||
|
border-color: #f59e0b;
|
||||||
|
background: linear-gradient(135deg, #fbbf24 0%, #f59e0b 100%);
|
||||||
|
color: white;
|
||||||
|
box-shadow: 0 2px 8px rgba(245, 158, 11, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-set-default.active svg {
|
||||||
|
fill: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-set-default:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-type-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.375rem;
|
||||||
|
padding: 0.375rem 0.75rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
background: #dbeafe;
|
||||||
|
color: #1e40af;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Switch 开关样式 */
|
||||||
|
.switch-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #475569;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch-wrapper {
|
||||||
|
position: relative;
|
||||||
|
width: 44px;
|
||||||
|
height: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch-input {
|
||||||
|
opacity: 0;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch-slider {
|
||||||
|
position: absolute;
|
||||||
|
cursor: pointer;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background-color: #cbd5e1;
|
||||||
|
transition: 0.3s;
|
||||||
|
border-radius: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch-slider:before {
|
||||||
|
position: absolute;
|
||||||
|
content: "";
|
||||||
|
height: 18px;
|
||||||
|
width: 18px;
|
||||||
|
left: 3px;
|
||||||
|
bottom: 3px;
|
||||||
|
background-color: white;
|
||||||
|
transition: 0.3s;
|
||||||
|
border-radius: 50%;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch-input:checked + .switch-slider {
|
||||||
|
background-color: #10b981;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch-input:checked + .switch-slider:before {
|
||||||
|
transform: translateX(20px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch-input:focus + .switch-slider {
|
||||||
|
box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Markdown 编辑器容器 */
|
||||||
|
.prompt-editor-container {
|
||||||
|
flex: 1;
|
||||||
|
padding: 2rem;
|
||||||
|
overflow: auto;
|
||||||
|
background: #fafbfc;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 空状态 */
|
||||||
|
.prompt-empty-placeholder {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
color: #94a3b8;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prompt-empty-placeholder p {
|
||||||
|
margin-top: 1rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
padding: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 表单样式 */
|
||||||
.form-group {
|
.form-group {
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
@ -176,7 +532,8 @@
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-group input[type="text"] {
|
.form-group input[type="text"],
|
||||||
|
.form-group select {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
border: 1px solid #e2e8f0;
|
border: 1px solid #e2e8f0;
|
||||||
|
|
@ -185,102 +542,102 @@
|
||||||
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-group input[type="text"]:focus {
|
.form-group input[type="text"]:focus,
|
||||||
|
.form-group select:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: #667eea;
|
border-color: #667eea;
|
||||||
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
|
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-group textarea {
|
/* 按钮样式 */
|
||||||
width: 100%;
|
|
||||||
padding: 0.75rem;
|
|
||||||
border: 1px solid #e2e8f0;
|
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 1rem;
|
|
||||||
font-family: inherit;
|
|
||||||
resize: vertical;
|
|
||||||
min-height: 200px;
|
|
||||||
max-height: 400px;
|
|
||||||
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group textarea:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: #667eea;
|
|
||||||
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Error message styling */
|
|
||||||
.error-message {
|
|
||||||
background-color: #fee2e2;
|
|
||||||
color: #dc2626;
|
|
||||||
padding: 0.75rem 1rem;
|
|
||||||
border-radius: 6px;
|
|
||||||
border-left: 3px solid #ef4444;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Button styles */
|
|
||||||
.btn {
|
.btn {
|
||||||
padding: 0.65rem 1.25rem;
|
padding: 0.65rem 1.25rem;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary {
|
.btn-primary {
|
||||||
background-color: #667eea;
|
background-color: #667eea;
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary:hover {
|
.btn-primary:hover:not(:disabled) {
|
||||||
background-color: #5568d3;
|
background-color: #5568d3;
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3);
|
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-secondary {
|
.btn-secondary {
|
||||||
background-color: white;
|
background-color: white;
|
||||||
color: #475569;
|
color: #475569;
|
||||||
border: 1px solid #e2e8f0;
|
border: 1px solid #e2e8f0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-secondary:hover {
|
.btn-secondary:hover {
|
||||||
background-color: #f8fafc;
|
background-color: #f8fafc;
|
||||||
border-color: #cbd5e1;
|
border-color: #cbd5e1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pagination {
|
.btn-danger {
|
||||||
display: flex;
|
background-color: #fee2e2;
|
||||||
justify-content: space-between;
|
color: #dc2626;
|
||||||
align-items: center;
|
border: 1px solid #fecaca;
|
||||||
margin-top: 2rem;
|
|
||||||
padding-top: 1rem;
|
|
||||||
border-top: 1px solid #e9ecef;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.pagination button {
|
.btn-danger:hover {
|
||||||
padding: 0.5rem 1rem;
|
background-color: #fca5a5;
|
||||||
border: 1px solid #ced4da;
|
color: #991b1b;
|
||||||
background-color: #fff;
|
border-color: #f87171;
|
||||||
border-radius: 6px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.pagination button:disabled {
|
/* 错误信息 */
|
||||||
opacity: 0.5;
|
.error-message {
|
||||||
cursor: not-allowed;
|
background-color: #fee2e2;
|
||||||
|
color: #dc2626;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
border-left: 3px solid #ef4444;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pagination span {
|
/* 响应式设计 */
|
||||||
font-size: 0.9rem;
|
@media (max-width: 1024px) {
|
||||||
color: #495057;
|
.prompt-sidebar {
|
||||||
|
width: 280px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.prompt-layout {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prompt-sidebar {
|
||||||
|
width: 100%;
|
||||||
|
height: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prompt-sidebar.collapsed {
|
||||||
|
height: 60px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-controls {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,27 +1,31 @@
|
||||||
import React, { useState, useEffect, useRef } from 'react';
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
import apiClient from '../../utils/apiClient';
|
import apiClient from '../../utils/apiClient';
|
||||||
import { buildApiUrl, API_ENDPOINTS } from '../../config/api';
|
import { buildApiUrl, API_ENDPOINTS } from '../../config/api';
|
||||||
import { Plus, MoreVertical, Edit, Trash2, BookText, Tag, FileText } from 'lucide-react';
|
import { Plus, ChevronLeft, ChevronRight, Trash2, BookText, FileText, Star, Save, Check, Edit2, X } from 'lucide-react';
|
||||||
import './PromptManagement.css';
|
import './PromptManagement.css';
|
||||||
import TagEditor from '../../components/TagEditor';
|
|
||||||
import ConfirmDialog from '../../components/ConfirmDialog';
|
import ConfirmDialog from '../../components/ConfirmDialog';
|
||||||
import FormModal from '../../components/FormModal';
|
import FormModal from '../../components/FormModal';
|
||||||
import Toast from '../../components/Toast';
|
import Toast from '../../components/Toast';
|
||||||
|
import MarkdownEditor from '../../components/MarkdownEditor';
|
||||||
|
|
||||||
|
const TASK_TYPES = {
|
||||||
|
MEETING_TASK: { label: '会议任务', icon: '📝' },
|
||||||
|
KNOWLEDGE_TASK: { label: '知识库任务', icon: '📚' }
|
||||||
|
};
|
||||||
|
|
||||||
const PromptManagement = () => {
|
const PromptManagement = () => {
|
||||||
const [prompts, setPrompts] = useState([]);
|
const [prompts, setPrompts] = useState([]);
|
||||||
const [total, setTotal] = useState(0);
|
const [selectedPrompt, setSelectedPrompt] = useState(null);
|
||||||
const [page, setPage] = useState(1);
|
const [editingPrompt, setEditingPrompt] = useState(null); // 正在编辑的提示词
|
||||||
const [pageSize, setPageSize] = useState(12);
|
const [editingTitle, setEditingTitle] = useState(false); // 是否正在编辑标题
|
||||||
|
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [showModal, setShowModal] = useState(false);
|
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||||
const [isEditing, setIsEditing] = useState(false);
|
const [newPromptData, setNewPromptData] = useState({ name: '', task_type: 'MEETING_TASK' });
|
||||||
const [currentPrompt, setCurrentPrompt] = useState(null);
|
|
||||||
const [activeMenu, setActiveMenu] = useState(null);
|
|
||||||
const [deleteConfirmInfo, setDeleteConfirmInfo] = useState(null);
|
const [deleteConfirmInfo, setDeleteConfirmInfo] = useState(null);
|
||||||
const [toasts, setToasts] = useState([]);
|
const [toasts, setToasts] = useState([]);
|
||||||
const menuRef = useRef(null);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
|
||||||
// Toast helper functions
|
// Toast helper functions
|
||||||
const showToast = (message, type = 'info') => {
|
const showToast = (message, type = 'info') => {
|
||||||
|
|
@ -35,27 +39,21 @@ const PromptManagement = () => {
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchPrompts();
|
fetchPrompts();
|
||||||
}, [page, pageSize]);
|
|
||||||
|
|
||||||
// Close dropdown when clicking outside
|
|
||||||
useEffect(() => {
|
|
||||||
const handleClickOutside = (event) => {
|
|
||||||
if (menuRef.current && !menuRef.current.contains(event.target)) {
|
|
||||||
setActiveMenu(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
document.addEventListener("mousedown", handleClickOutside);
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener("mousedown", handleClickOutside);
|
|
||||||
};
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const fetchPrompts = async () => {
|
const fetchPrompts = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.get(buildApiUrl(`${API_ENDPOINTS.PROMPTS.LIST}?page=${page}&size=${pageSize}`));
|
const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.PROMPTS.LIST));
|
||||||
setPrompts(response.data.prompts);
|
const promptList = response.data.prompts;
|
||||||
setTotal(response.data.total);
|
setPrompts(promptList);
|
||||||
|
|
||||||
|
// 如果有提示词且没有选中,默认选中第一个
|
||||||
|
if (promptList.length > 0 && !selectedPrompt) {
|
||||||
|
const firstPrompt = promptList[0];
|
||||||
|
setSelectedPrompt(firstPrompt);
|
||||||
|
setEditingPrompt({ ...firstPrompt });
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.response?.data?.message || '无法加载提示词列表');
|
setError(err.response?.data?.message || '无法加载提示词列表');
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -63,42 +61,247 @@ const PromptManagement = () => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleOpenModal = (prompt = null) => {
|
const handleOpenCreateModal = () => {
|
||||||
if (prompt) {
|
setNewPromptData({ name: '', task_type: 'MEETING_TASK' });
|
||||||
setIsEditing(true);
|
setShowCreateModal(true);
|
||||||
setCurrentPrompt({ ...prompt });
|
|
||||||
} else {
|
|
||||||
setIsEditing(false);
|
|
||||||
setCurrentPrompt({ name: '', tags: '', content: '' });
|
|
||||||
}
|
|
||||||
setError('');
|
|
||||||
setShowModal(true);
|
|
||||||
setActiveMenu(null); // Close menu when modal opens
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCloseModal = () => {
|
const handleCloseCreateModal = () => {
|
||||||
setShowModal(false);
|
setShowCreateModal(false);
|
||||||
setCurrentPrompt(null);
|
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 () => {
|
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 {
|
try {
|
||||||
if (isEditing) {
|
const dataToSend = {
|
||||||
await apiClient.put(buildApiUrl(API_ENDPOINTS.PROMPTS.UPDATE(currentPrompt.id)), currentPrompt);
|
name: editingPrompt.name,
|
||||||
showToast('提示词更新成功', 'success');
|
task_type: editingPrompt.task_type,
|
||||||
} else {
|
content: editingPrompt.content || '',
|
||||||
await apiClient.post(buildApiUrl(API_ENDPOINTS.PROMPTS.CREATE), currentPrompt);
|
is_default: Boolean(editingPrompt.is_default),
|
||||||
showToast('提示词创建成功', 'success');
|
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;
|
||||||
}
|
}
|
||||||
handleCloseModal();
|
|
||||||
fetchPrompts();
|
// 计算完整更新后的对象(在回调外部)
|
||||||
|
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) {
|
} catch (err) {
|
||||||
setError(err.response?.data?.message || '保存失败');
|
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) => {
|
const handleDelete = async (prompt) => {
|
||||||
setActiveMenu(null); // Close menu
|
// 检查是否为默认模版
|
||||||
|
if (prompt.is_default) {
|
||||||
|
showToast('默认模版不允许删除,请先取消默认状态', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setDeleteConfirmInfo({
|
setDeleteConfirmInfo({
|
||||||
id: prompt.id,
|
id: prompt.id,
|
||||||
name: prompt.name
|
name: prompt.name
|
||||||
|
|
@ -108,115 +311,294 @@ const PromptManagement = () => {
|
||||||
const handleConfirmDelete = async () => {
|
const handleConfirmDelete = async () => {
|
||||||
try {
|
try {
|
||||||
await apiClient.delete(buildApiUrl(API_ENDPOINTS.PROMPTS.DELETE(deleteConfirmInfo.id)));
|
await apiClient.delete(buildApiUrl(API_ENDPOINTS.PROMPTS.DELETE(deleteConfirmInfo.id)));
|
||||||
|
|
||||||
|
if (selectedPrompt && selectedPrompt.id === deleteConfirmInfo.id) {
|
||||||
|
setSelectedPrompt(null);
|
||||||
|
setEditingPrompt(null);
|
||||||
|
}
|
||||||
|
|
||||||
setDeleteConfirmInfo(null);
|
setDeleteConfirmInfo(null);
|
||||||
fetchPrompts();
|
await fetchPrompts();
|
||||||
showToast('提示词删除成功', 'success');
|
showToast('提示词删除成功', 'success');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.response?.data?.message || '删除失败');
|
showToast(err.response?.data?.message || '删除失败', 'error');
|
||||||
showToast('删除失败,请重试', 'error');
|
|
||||||
setDeleteConfirmInfo(null);
|
setDeleteConfirmInfo(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleInputChange = (field, value) => {
|
// 按任务类型分组
|
||||||
setCurrentPrompt(prev => ({ ...prev, [field]: value }));
|
const groupedPrompts = {
|
||||||
|
MEETING_TASK: prompts.filter(p => p.task_type === 'MEETING_TASK'),
|
||||||
|
KNOWLEDGE_TASK: prompts.filter(p => p.task_type === 'KNOWLEDGE_TASK')
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleMenu = (promptId) => {
|
if (loading) {
|
||||||
setActiveMenu(activeMenu === promptId ? null : promptId);
|
return <div className="prompt-management"><p>加载中...</p></div>;
|
||||||
};
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="prompt-management">
|
<div className="prompt-management">
|
||||||
<div className="toolbar">
|
<div className="prompt-layout">
|
||||||
<h2>提示词仓库</h2>
|
{/* 左侧提示词列表 */}
|
||||||
<button className="btn btn-primary" onClick={() => handleOpenModal()}><Plus size={16} /> 新增提示词</button>
|
<div className={`prompt-sidebar ${sidebarCollapsed ? 'collapsed' : ''}`}>
|
||||||
</div>
|
<div className="sidebar-header">
|
||||||
{loading && <p>加载中...</p>}
|
{!sidebarCollapsed && (
|
||||||
{error && !showModal && <p className="error-message">{error}</p>}
|
<>
|
||||||
{!loading && (
|
<h3>提示词列表</h3>
|
||||||
<>
|
<button
|
||||||
<div className="prompt-grid">
|
className="btn-new-prompt"
|
||||||
{prompts.map(prompt => (
|
onClick={handleOpenCreateModal}
|
||||||
<div key={prompt.id} className="prompt-card">
|
title="新增提示词"
|
||||||
<div className="card-header">
|
>
|
||||||
<h3>{prompt.name}</h3>
|
<Plus size={18} />
|
||||||
<div className="card-actions" ref={activeMenu === prompt.id ? menuRef : null}>
|
</button>
|
||||||
<button className="dropdown-trigger" onClick={() => toggleMenu(prompt.id)}><MoreVertical size={18} /></button>
|
</>
|
||||||
{activeMenu === prompt.id && (
|
)}
|
||||||
<div className="dropdown-menu">
|
<button
|
||||||
<button className="dropdown-item" onClick={() => handleOpenModal(prompt)}><Edit size={16}/> 编辑</button>
|
className="btn-toggle-sidebar"
|
||||||
<button className="dropdown-item delete-item" onClick={() => handleDelete(prompt)}><Trash2 size={16}/> 删除</button>
|
onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
|
||||||
</div>
|
title={sidebarCollapsed ? "展开" : "收起"}
|
||||||
)}
|
>
|
||||||
</div>
|
{sidebarCollapsed ? <ChevronRight size={18} /> : <ChevronLeft size={18} />}
|
||||||
</div>
|
</button>
|
||||||
<div className="card-tags">
|
|
||||||
{prompt.tags && prompt.tags.split(',').map(t => t.trim()).filter(t => t).map((tag, i) => (
|
|
||||||
<span key={i} className="tag-chip">{tag}</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<p className="card-content">{prompt.content.substring(0, 100)}{prompt.content.length > 100 && '...'}</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="pagination">
|
|
||||||
<span>总计 {total} 条</span>
|
|
||||||
<div>
|
|
||||||
<button onClick={() => setPage(p => Math.max(1, p - 1))} disabled={page === 1}>上一页</button>
|
|
||||||
<span>第 {page} 页 / {Math.ceil(total / pageSize)}</span>
|
|
||||||
<button onClick={() => setPage(p => p + 1)} disabled={page * pageSize >= total}>下一页</button>
|
|
||||||
</div>
|
|
||||||
</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
|
<FormModal
|
||||||
isOpen={showModal}
|
isOpen={showCreateModal}
|
||||||
onClose={handleCloseModal}
|
onClose={handleCloseCreateModal}
|
||||||
title={isEditing ? '编辑提示词' : '新增提示词'}
|
title="新增提示词"
|
||||||
size="medium"
|
size="small"
|
||||||
actions={
|
actions={
|
||||||
<>
|
<>
|
||||||
<button type="button" className="btn btn-secondary" onClick={handleCloseModal}>
|
<button type="button" className="btn btn-secondary" onClick={handleCloseCreateModal}>
|
||||||
取消
|
取消
|
||||||
</button>
|
</button>
|
||||||
<button type="button" className="btn btn-primary" onClick={handleSave}>
|
<button type="button" className="btn btn-primary" onClick={handleCreatePrompt}>
|
||||||
保存
|
创建
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{error && <div className="error-message">{error}</div>}
|
<div className="form-group">
|
||||||
{currentPrompt && (
|
<label><FileText size={16} /> 名称</label>
|
||||||
<>
|
<input
|
||||||
<div className="form-group">
|
type="text"
|
||||||
<label><FileText size={16} /> 名称</label>
|
value={newPromptData.name}
|
||||||
<input
|
onChange={(e) => setNewPromptData(prev => ({ ...prev, name: e.target.value }))}
|
||||||
type="text"
|
placeholder="请输入提示词名称"
|
||||||
value={currentPrompt.name}
|
autoFocus
|
||||||
onChange={(e) => handleInputChange('name', e.target.value)}
|
/>
|
||||||
placeholder="请输入提示词名称"
|
</div>
|
||||||
/>
|
|
||||||
</div>
|
<div className="form-group">
|
||||||
<div className="form-group">
|
<label><BookText size={16} /> 任务类型</label>
|
||||||
<label><Tag size={16} /> 标签</label>
|
<select
|
||||||
<TagEditor
|
value={newPromptData.task_type}
|
||||||
value={currentPrompt.tags}
|
onChange={(e) => setNewPromptData(prev => ({ ...prev, task_type: e.target.value }))}
|
||||||
onChange={(value) => handleInputChange('tags', value)}
|
>
|
||||||
/>
|
<option value="MEETING_TASK">会议任务</option>
|
||||||
</div>
|
<option value="KNOWLEDGE_TASK">知识库任务</option>
|
||||||
<div className="form-group">
|
</select>
|
||||||
<label><BookText size={16} /> 内容</label>
|
</div>
|
||||||
<textarea
|
|
||||||
rows="10"
|
|
||||||
value={currentPrompt.content}
|
|
||||||
onChange={(e) => handleInputChange('content', e.target.value)}
|
|
||||||
placeholder="请输入提示词内容"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</FormModal>
|
</FormModal>
|
||||||
|
|
||||||
{/* 删除提示词确认对话框 */}
|
{/* 删除提示词确认对话框 */}
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,31 @@
|
||||||
color: #1e293b;
|
color: #1e293b;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.user-management .toolbar-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-management .search-input {
|
||||||
|
padding: 8px 16px;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
width: 240px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-management .search-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #667eea;
|
||||||
|
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-management .search-input::placeholder {
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
.users-table {
|
.users-table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ const UserManagement = () => {
|
||||||
const [resetConfirmInfo, setResetConfirmInfo] = useState(null);
|
const [resetConfirmInfo, setResetConfirmInfo] = useState(null);
|
||||||
const [roles, setRoles] = useState([]);
|
const [roles, setRoles] = useState([]);
|
||||||
const [toasts, setToasts] = useState([]);
|
const [toasts, setToasts] = useState([]);
|
||||||
|
const [searchText, setSearchText] = useState(''); // 搜索文本
|
||||||
|
|
||||||
// Toast helper functions
|
// Toast helper functions
|
||||||
const showToast = (message, type = 'info') => {
|
const showToast = (message, type = 'info') => {
|
||||||
|
|
@ -35,12 +36,16 @@ const UserManagement = () => {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchUsers();
|
fetchUsers();
|
||||||
fetchRoles();
|
fetchRoles();
|
||||||
}, [page, pageSize]);
|
}, [page, pageSize, searchText]);
|
||||||
|
|
||||||
const fetchUsers = async () => {
|
const fetchUsers = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.get(buildApiUrl(`${API_ENDPOINTS.USERS.LIST}?page=${page}&size=${pageSize}`));
|
let url = `${API_ENDPOINTS.USERS.LIST}?page=${page}&size=${pageSize}`;
|
||||||
|
if (searchText) {
|
||||||
|
url += `&search=${encodeURIComponent(searchText)}`;
|
||||||
|
}
|
||||||
|
const response = await apiClient.get(buildApiUrl(url));
|
||||||
setUsers(response.data.users);
|
setUsers(response.data.users);
|
||||||
setTotal(response.data.total);
|
setTotal(response.data.total);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -169,7 +174,19 @@ const UserManagement = () => {
|
||||||
<div className="user-management">
|
<div className="user-management">
|
||||||
<div className="toolbar">
|
<div className="toolbar">
|
||||||
<h2>用户列表</h2>
|
<h2>用户列表</h2>
|
||||||
<button className="btn btn-primary" onClick={() => handleOpenModal()}><Plus size={16} /> 新增用户</button>
|
<div className="toolbar-actions">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="搜索用户名或姓名..."
|
||||||
|
value={searchText}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSearchText(e.target.value);
|
||||||
|
setPage(1); // 重置到第一页
|
||||||
|
}}
|
||||||
|
className="search-input"
|
||||||
|
/>
|
||||||
|
<button className="btn btn-primary" onClick={() => handleOpenModal()}><Plus size={16} /> 新增用户</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{loading && <p>加载中...</p>}
|
{loading && <p>加载中...</p>}
|
||||||
{error && <p className="error-message">{error}</p>}
|
{error && <p className="error-message">{error}</p>}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
/**
|
||||||
|
* 菜单服务
|
||||||
|
* 提供用户菜单相关的API调用
|
||||||
|
*/
|
||||||
|
import apiClient from '../utils/apiClient';
|
||||||
|
import { buildApiUrl } from '../config/api';
|
||||||
|
|
||||||
|
const menuService = {
|
||||||
|
/**
|
||||||
|
* 获取当前用户的菜单列表
|
||||||
|
* @returns {Promise} API响应
|
||||||
|
*/
|
||||||
|
async getUserMenus() {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get(buildApiUrl('/api/menus/user'));
|
||||||
|
// apiClient已经处理了响应,返回的是 {code, message, data}
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取用户菜单失败:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default menuService;
|
||||||
Loading…
Reference in New Issue