增加了权限系统
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 HomePage from './pages/HomePage';
|
||||
import Dashboard from './pages/Dashboard';
|
||||
import AdminDashboard from './pages/AdminDashboard';
|
||||
import MeetingDetails from './pages/MeetingDetails';
|
||||
import MeetingPreview from './pages/MeetingPreview';
|
||||
import CreateMeeting from './pages/CreateMeeting';
|
||||
|
|
@ -72,7 +73,11 @@ function App() {
|
|||
user ? <Navigate to="/dashboard" /> : <HomePage onLogin={handleLogin} />
|
||||
} />
|
||||
<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={
|
||||
user ? <MeetingDetails user={user} /> : <Navigate to="/" />
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
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 { buildApiUrl, API_ENDPOINTS } from '../config/api';
|
||||
import './ClientDownloads.css';
|
||||
|
|
@ -7,7 +7,8 @@ import './ClientDownloads.css';
|
|||
const ClientDownloads = () => {
|
||||
const [clients, setClients] = useState({
|
||||
mobile: [],
|
||||
desktop: []
|
||||
desktop: [],
|
||||
terminal: []
|
||||
});
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
|
|
@ -20,7 +21,7 @@ const ClientDownloads = () => {
|
|||
try {
|
||||
const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.CLIENT_DOWNLOADS.LATEST));
|
||||
console.log('Latest clients response:', response);
|
||||
setClients(response.data || { mobile: [], desktop: [] });
|
||||
setClients(response.data || { mobile: [], desktop: [], terminal: [] });
|
||||
} catch (error) {
|
||||
console.error('获取客户端下载失败:', error);
|
||||
} finally {
|
||||
|
|
@ -37,12 +38,24 @@ const ClientDownloads = () => {
|
|||
case 'mac_intel':
|
||||
case 'mac_m':
|
||||
return <Apple size={32} />;
|
||||
case 'mcu':
|
||||
return <Cpu size={32} />;
|
||||
default:
|
||||
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 = {
|
||||
ios: 'iOS',
|
||||
android: 'Android',
|
||||
|
|
@ -99,7 +112,7 @@ const ClientDownloads = () => {
|
|||
{getPlatformIcon(client.platform_name)}
|
||||
</div>
|
||||
<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 && (
|
||||
|
|
@ -139,7 +152,47 @@ const ClientDownloads = () => {
|
|||
{getPlatformIcon(client.platform_name)}
|
||||
</div>
|
||||
<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">
|
||||
<span className="version">v{client.version}</span>
|
||||
{client.file_size && (
|
||||
|
|
@ -160,7 +213,7 @@ const ClientDownloads = () => {
|
|||
)}
|
||||
</div>
|
||||
|
||||
{!clients.mobile?.length && !clients.desktop?.length && (
|
||||
{!clients.mobile?.length && !clients.desktop?.length && !clients.terminal?.length && (
|
||||
<div className="empty-message">暂无可用的客户端下载</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -35,7 +35,12 @@ const API_CONFIG = {
|
|||
NAVIGATION: (meetingId) => `/api/meetings/${meetingId}/navigation`
|
||||
},
|
||||
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: {
|
||||
LIST: '/api/tags'
|
||||
|
|
@ -48,7 +53,8 @@ const API_CONFIG = {
|
|||
LIST: '/api/prompts',
|
||||
CREATE: '/api/prompts',
|
||||
UPDATE: (promptId) => `/api/prompts/${promptId}`,
|
||||
DELETE: (promptId) => `/api/prompts/${promptId}`
|
||||
DELETE: (promptId) => `/api/prompts/${promptId}`,
|
||||
ACTIVE: (taskType) => `/api/prompts/active/${taskType}` // 获取指定任务类型的启用模版
|
||||
},
|
||||
KNOWLEDGE_BASE: {
|
||||
LIST: '/api/knowledge-bases',
|
||||
|
|
@ -59,13 +65,13 @@ const API_CONFIG = {
|
|||
TASK_STATUS: (taskId) => `/api/knowledge-bases/tasks/${taskId}`
|
||||
},
|
||||
CLIENT_DOWNLOADS: {
|
||||
LIST: '/api/clients/downloads',
|
||||
LATEST: '/api/clients/downloads/latest',
|
||||
LATEST_BY_PLATFORM: (platformName) => `/api/clients/downloads/${platformName}/latest`,
|
||||
DETAIL: (id) => `/api/clients/downloads/${id}`,
|
||||
CREATE: '/api/clients/downloads',
|
||||
UPDATE: (id) => `/api/clients/downloads/${id}`,
|
||||
DELETE: (id) => `/api/clients/downloads/${id}`
|
||||
LIST: '/api/clients',
|
||||
LATEST: '/api/clients/latest',
|
||||
LATEST_BY_PLATFORM: '/api/clients/latest/by-platform',
|
||||
DETAIL: (id) => `/api/clients/${id}`,
|
||||
CREATE: '/api/clients',
|
||||
UPDATE: (id) => `/api/clients/${id}`,
|
||||
DELETE: (id) => `/api/clients/${id}`
|
||||
},
|
||||
VOICEPRINT: {
|
||||
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 { MessageSquare, Settings, Users, Smartphone } from 'lucide-react';
|
||||
import { MessageSquare, Settings, Users, Smartphone, Shield } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Tabs } from 'antd';
|
||||
import UserManagement from './admin/UserManagement';
|
||||
import SystemConfiguration from './admin/SystemConfiguration';
|
||||
import ClientManagement from './ClientManagement';
|
||||
import PermissionManagement from './admin/PermissionManagement';
|
||||
import './AdminManagement.css';
|
||||
|
||||
const { TabPane } = Tabs;
|
||||
|
|
@ -36,6 +37,12 @@ const AdminManagement = () => {
|
|||
>
|
||||
<UserManagement />
|
||||
</TabPane>
|
||||
<TabPane
|
||||
tab={<span><Shield size={16} /> 权限管理</span>}
|
||||
key="permissionManagement"
|
||||
>
|
||||
<PermissionManagement />
|
||||
</TabPane>
|
||||
<TabPane
|
||||
tab={<span><Settings size={16} /> 系统配置</span>}
|
||||
key="systemConfiguration"
|
||||
|
|
|
|||
|
|
@ -15,7 +15,8 @@ import {
|
|||
Hash,
|
||||
Link,
|
||||
FileText,
|
||||
HardDrive
|
||||
HardDrive,
|
||||
Cpu
|
||||
} from 'lucide-react';
|
||||
import apiClient from '../utils/apiClient';
|
||||
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_m', label: 'Mac (M系列)', icon: <Apple 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 allOptions = [...platformOptions.mobile, ...platformOptions.desktop];
|
||||
const allOptions = [...platformOptions.mobile, ...platformOptions.desktop, ...platformOptions.terminal];
|
||||
const option = allOptions.find(opt => opt.value === platformName);
|
||||
return option ? option.label : platformName;
|
||||
};
|
||||
|
|
@ -299,7 +304,8 @@ const ClientManagement = ({ user }) => {
|
|||
|
||||
const groupedClients = {
|
||||
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) {
|
||||
|
|
@ -353,21 +359,34 @@ const ClientManagement = ({ user }) => {
|
|||
<Monitor size={16} />
|
||||
<span>桌面端</span>
|
||||
</button>
|
||||
<button
|
||||
className={`filter-btn ${filterPlatformType === 'terminal' ? 'active' : ''}`}
|
||||
onClick={() => setFilterPlatformType('terminal')}
|
||||
>
|
||||
<Cpu size={16} />
|
||||
<span>专用终端</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="clients-sections">
|
||||
{['mobile', 'desktop'].map(type => {
|
||||
{['mobile', 'desktop', 'terminal'].map(type => {
|
||||
const typeClients = groupedClients[type];
|
||||
if (typeClients.length === 0 && filterPlatformType && filterPlatformType !== type) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const typeConfig = {
|
||||
mobile: { icon: <Smartphone size={20} />, label: '移动端' },
|
||||
desktop: { icon: <Monitor size={20} />, label: '桌面端' },
|
||||
terminal: { icon: <Cpu size={20} />, label: '专用终端' }
|
||||
};
|
||||
|
||||
return (
|
||||
<div key={type} className="client-section">
|
||||
<h2 className="section-title">
|
||||
{type === 'mobile' ? <Smartphone size={20} /> : <Monitor size={20} />}
|
||||
<span>{type === 'mobile' ? '移动端' : '桌面端'}</span>
|
||||
{typeConfig[type].icon}
|
||||
<span>{typeConfig[type].label}</span>
|
||||
<span className="count">({typeClients.length})</span>
|
||||
</h2>
|
||||
|
||||
|
|
@ -484,6 +503,7 @@ const ClientManagement = ({ user }) => {
|
|||
>
|
||||
<option value="mobile">移动端</option>
|
||||
<option value="desktop">桌面端</option>
|
||||
<option value="terminal">专用终端</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import PageLoading from '../components/PageLoading';
|
|||
import ScrollToTop from '../components/ScrollToTop';
|
||||
import Dropdown from '../components/Dropdown';
|
||||
import meetingCacheService from '../services/meetingCacheService';
|
||||
import menuService from '../services/menuService';
|
||||
import './Dashboard.css';
|
||||
|
||||
const Dashboard = ({ user, onLogout }) => {
|
||||
|
|
@ -39,10 +40,14 @@ const Dashboard = ({ user, onLogout }) => {
|
|||
const [voiceprintLoading, setVoiceprintLoading] = useState(true);
|
||||
const [showDeleteVoiceprintDialog, setShowDeleteVoiceprintDialog] = useState(false);
|
||||
|
||||
// 菜单权限相关状态
|
||||
const [userMenus, setUserMenus] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchUserData();
|
||||
fetchMeetingsStats();
|
||||
fetchVoiceprintData();
|
||||
fetchUserMenus();
|
||||
|
||||
// 开发环境下,在控制台添加缓存调试命令
|
||||
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(() => {
|
||||
fetchMeetings(1, false);
|
||||
|
|
@ -359,28 +434,7 @@ const Dashboard = ({ user, onLogout }) => {
|
|||
<ChevronDown size={20} />
|
||||
</div>
|
||||
}
|
||||
items={[
|
||||
{
|
||||
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
|
||||
}
|
||||
]}
|
||||
items={userMenus.map(menu => getMenuItemConfig(menu))}
|
||||
align="right"
|
||||
className="user-menu-dropdown"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -661,6 +661,40 @@
|
|||
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 {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
|
|
|
|||
|
|
@ -40,6 +40,8 @@ const KnowledgeBasePage = ({ user }) => {
|
|||
const [createStep, setCreateStep] = useState(1); // 1: 选择会议, 2: 输入提示词
|
||||
const [meetingsPagination, setMeetingsPagination] = useState({ page: 1, total: 0, has_more: false });
|
||||
const [loadingMeetings, setLoadingMeetings] = useState(false);
|
||||
const [availablePrompts, setAvailablePrompts] = useState([]); // 可用的提示词模版列表
|
||||
const [selectedPromptId, setSelectedPromptId] = useState(null); // 选中的提示词模版ID
|
||||
|
||||
// Toast helper functions
|
||||
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) => {
|
||||
try {
|
||||
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), {
|
||||
user_prompt: userPrompt,
|
||||
source_meeting_ids: selectedMeetings.join(','),
|
||||
is_shared: false
|
||||
is_shared: false,
|
||||
prompt_id: selectedPromptId // 传递选中的模版ID
|
||||
});
|
||||
setTaskId(response.data.task_id);
|
||||
} catch (error) {
|
||||
|
|
@ -247,7 +268,10 @@ const KnowledgeBasePage = ({ user }) => {
|
|||
setUserPrompt('');
|
||||
setSearchQuery('');
|
||||
setSelectedTags([]);
|
||||
setSelectedPromptId(null);
|
||||
setShowCreateForm(true);
|
||||
// 获取可用的提示词模版
|
||||
fetchAvailablePrompts();
|
||||
};
|
||||
|
||||
const handleCloseCreateModal = () => {
|
||||
|
|
@ -800,8 +824,27 @@ const KnowledgeBasePage = ({ user }) => {
|
|||
<span className="summary-value">{selectedMeetings.length} 个</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 模版选择 */}
|
||||
{availablePrompts.length > 0 && (
|
||||
<div className="form-group">
|
||||
<label>用户提示词(可选)</label>
|
||||
<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">
|
||||
<label>* 用户提示词(可选)</label>
|
||||
<p className="field-hint">您可以添加额外的要求来定制知识库生成内容,例如重点关注某个主题、提取特定信息等。如不填写,系统将使用默认提示词。</p>
|
||||
<textarea
|
||||
placeholder="例如:请重点关注会议中的决策事项和待办任务..."
|
||||
|
|
|
|||
|
|
@ -116,9 +116,9 @@ const MeetingPreview = () => {
|
|||
</h1>
|
||||
|
||||
<div className="meeting-info-section">
|
||||
<h2 className="section-title">📋 会议信息</h2>
|
||||
<h2 className="section-title">📋 {meetingData.prompt_name || '会议'} 概览</h2>
|
||||
<div className="info-item">
|
||||
<strong>会议时间:</strong>
|
||||
<strong>时间:</strong>
|
||||
{formatDateTime(meetingData.meeting_time)}
|
||||
</div>
|
||||
<div className="info-item">
|
||||
|
|
@ -136,7 +136,7 @@ const MeetingPreview = () => {
|
|||
</div>
|
||||
|
||||
<div className="summary-section">
|
||||
<h2 className="section-title">📝 会议内容</h2>
|
||||
<h2 className="section-title">📝 {meetingData.prompt_name || '会议'} 总结</h2>
|
||||
<Tabs defaultActiveKey="summary" className="preview-tabs">
|
||||
<TabPane tab="摘要" key="summary">
|
||||
<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 {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 左右分栏布局 */
|
||||
.prompt-layout {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
/* 左侧提示词列表 */
|
||||
.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;
|
||||
margin-bottom: 1.5rem;
|
||||
padding: 1rem 1.25rem;
|
||||
border-bottom: 1px solid #e9ecef;
|
||||
background: #f8f9fa;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.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 {
|
||||
.sidebar-header h3 {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
margin: 0;
|
||||
line-height: 1.3;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
.btn-new-prompt {
|
||||
background: #667eea;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
padding: 0.5rem;
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
position: relative; /* For dropdown positioning */
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.dropdown-trigger {
|
||||
.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: #6c757d;
|
||||
color: #64748b;
|
||||
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;
|
||||
transition: all 0.2s ease;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.dropdown-item {
|
||||
.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;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
color: #475569;
|
||||
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;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
background: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.dropdown-item:hover {
|
||||
background: #f3f4f6;
|
||||
color: #111827;
|
||||
.prompt-list-item:hover {
|
||||
background: #f8fafc;
|
||||
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;
|
||||
}
|
||||
|
||||
.dropdown-item.delete-item:hover {
|
||||
background: #fef2f2;
|
||||
color: #b91c1c;
|
||||
.icon-btn-small:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.card-actions button {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: #6c757d;
|
||||
padding: 0.25rem;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.card-actions button:hover {
|
||||
background-color: #f1f3f5;
|
||||
color: #212529;
|
||||
}
|
||||
|
||||
.card-tags {
|
||||
margin-bottom: 1rem;
|
||||
.title-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.tag-chip {
|
||||
background-color: #e9ecef;
|
||||
color: #f2f0f7;
|
||||
padding: 0.25rem 0.6rem;
|
||||
border-radius: 12px;
|
||||
font-size: 0.75rem;
|
||||
.icon-btn {
|
||||
width: 36px;
|
||||
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-content {
|
||||
font-size: 0.9rem;
|
||||
color: #495057;
|
||||
line-height: 1.5;
|
||||
flex-grow: 1;
|
||||
.icon-btn svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Form group styles - 配合 FormModal 使用 */
|
||||
.icon-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.icon-btn.save-btn {
|
||||
background: #667eea;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.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 {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
|
@ -176,7 +532,8 @@
|
|||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.form-group input[type="text"] {
|
||||
.form-group input[type="text"],
|
||||
.form-group select {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid #e2e8f0;
|
||||
|
|
@ -185,44 +542,14 @@
|
|||
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;
|
||||
border-color: #667eea;
|
||||
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 {
|
||||
padding: 0.65rem 1.25rem;
|
||||
border: none;
|
||||
|
|
@ -236,12 +563,17 @@
|
|||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: #667eea;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background-color: #5568d3;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3);
|
||||
|
|
@ -258,29 +590,54 @@
|
|||
border-color: #cbd5e1;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 2rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid #e9ecef;
|
||||
.btn-danger {
|
||||
background-color: #fee2e2;
|
||||
color: #dc2626;
|
||||
border: 1px solid #fecaca;
|
||||
}
|
||||
|
||||
.pagination button {
|
||||
padding: 0.5rem 1rem;
|
||||
border: 1px solid #ced4da;
|
||||
background-color: #fff;
|
||||
.btn-danger:hover {
|
||||
background-color: #fca5a5;
|
||||
color: #991b1b;
|
||||
border-color: #f87171;
|
||||
}
|
||||
|
||||
/* 错误信息 */
|
||||
.error-message {
|
||||
background-color: #fee2e2;
|
||||
color: #dc2626;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
border-left: 3px solid #ef4444;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.pagination button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 1024px) {
|
||||
.prompt-sidebar {
|
||||
width: 280px;
|
||||
}
|
||||
}
|
||||
|
||||
.pagination span {
|
||||
font-size: 0.9rem;
|
||||
color: #495057;
|
||||
@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 apiClient from '../../utils/apiClient';
|
||||
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 TagEditor from '../../components/TagEditor';
|
||||
import ConfirmDialog from '../../components/ConfirmDialog';
|
||||
import FormModal from '../../components/FormModal';
|
||||
import Toast from '../../components/Toast';
|
||||
import MarkdownEditor from '../../components/MarkdownEditor';
|
||||
|
||||
const TASK_TYPES = {
|
||||
MEETING_TASK: { label: '会议任务', icon: '📝' },
|
||||
KNOWLEDGE_TASK: { label: '知识库任务', icon: '📚' }
|
||||
};
|
||||
|
||||
const PromptManagement = () => {
|
||||
const [prompts, setPrompts] = useState([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(12);
|
||||
const [selectedPrompt, setSelectedPrompt] = useState(null);
|
||||
const [editingPrompt, setEditingPrompt] = useState(null); // 正在编辑的提示词
|
||||
const [editingTitle, setEditingTitle] = useState(false); // 是否正在编辑标题
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [currentPrompt, setCurrentPrompt] = useState(null);
|
||||
const [activeMenu, setActiveMenu] = useState(null);
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [newPromptData, setNewPromptData] = useState({ name: '', task_type: 'MEETING_TASK' });
|
||||
const [deleteConfirmInfo, setDeleteConfirmInfo] = useState(null);
|
||||
const [toasts, setToasts] = useState([]);
|
||||
const menuRef = useRef(null);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
// Toast helper functions
|
||||
const showToast = (message, type = 'info') => {
|
||||
|
|
@ -35,27 +39,21 @@ const PromptManagement = () => {
|
|||
|
||||
useEffect(() => {
|
||||
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 () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await apiClient.get(buildApiUrl(`${API_ENDPOINTS.PROMPTS.LIST}?page=${page}&size=${pageSize}`));
|
||||
setPrompts(response.data.prompts);
|
||||
setTotal(response.data.total);
|
||||
const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.PROMPTS.LIST));
|
||||
const promptList = response.data.prompts;
|
||||
setPrompts(promptList);
|
||||
|
||||
// 如果有提示词且没有选中,默认选中第一个
|
||||
if (promptList.length > 0 && !selectedPrompt) {
|
||||
const firstPrompt = promptList[0];
|
||||
setSelectedPrompt(firstPrompt);
|
||||
setEditingPrompt({ ...firstPrompt });
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.message || '无法加载提示词列表');
|
||||
} finally {
|
||||
|
|
@ -63,42 +61,247 @@ const PromptManagement = () => {
|
|||
}
|
||||
};
|
||||
|
||||
const handleOpenModal = (prompt = null) => {
|
||||
if (prompt) {
|
||||
setIsEditing(true);
|
||||
setCurrentPrompt({ ...prompt });
|
||||
} else {
|
||||
setIsEditing(false);
|
||||
setCurrentPrompt({ name: '', tags: '', content: '' });
|
||||
}
|
||||
setError('');
|
||||
setShowModal(true);
|
||||
setActiveMenu(null); // Close menu when modal opens
|
||||
const handleOpenCreateModal = () => {
|
||||
setNewPromptData({ name: '', task_type: 'MEETING_TASK' });
|
||||
setShowCreateModal(true);
|
||||
};
|
||||
|
||||
const handleCloseModal = () => {
|
||||
setShowModal(false);
|
||||
setCurrentPrompt(null);
|
||||
const handleCloseCreateModal = () => {
|
||||
setShowCreateModal(false);
|
||||
setNewPromptData({ name: '', task_type: 'MEETING_TASK' });
|
||||
};
|
||||
|
||||
const handleCreatePrompt = async () => {
|
||||
if (!newPromptData.name.trim()) {
|
||||
showToast('请输入提示词名称', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await apiClient.post(buildApiUrl(API_ENDPOINTS.PROMPTS.CREATE), {
|
||||
name: newPromptData.name,
|
||||
task_type: newPromptData.task_type,
|
||||
content: '',
|
||||
is_default: false,
|
||||
is_active: true
|
||||
});
|
||||
|
||||
showToast('提示词创建成功', 'success');
|
||||
handleCloseCreateModal();
|
||||
await fetchPrompts();
|
||||
|
||||
// 自动选中新创建的提示词
|
||||
const newPrompt = response.data;
|
||||
if (newPrompt && newPrompt.id) {
|
||||
const createdPrompt = prompts.find(p => p.id === newPrompt.id) || newPrompt;
|
||||
setSelectedPrompt(createdPrompt);
|
||||
setEditingPrompt({ ...createdPrompt });
|
||||
}
|
||||
} catch (err) {
|
||||
showToast(err.response?.data?.message || '创建失败', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const handlePromptSelect = (prompt) => {
|
||||
setSelectedPrompt(prompt);
|
||||
setEditingPrompt({ ...prompt });
|
||||
setEditingTitle(false); // 切换提示词时关闭标题编辑
|
||||
};
|
||||
|
||||
const handleEditChange = (field, value) => {
|
||||
setEditingPrompt(prev => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
// 保存标题
|
||||
const handleSaveTitle = async () => {
|
||||
if (!editingPrompt || !editingPrompt.id || !editingPrompt.name.trim()) {
|
||||
showToast('标题不能为空', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const dataToSend = {
|
||||
name: editingPrompt.name,
|
||||
task_type: editingPrompt.task_type,
|
||||
content: editingPrompt.content || '',
|
||||
is_default: Boolean(editingPrompt.is_default),
|
||||
is_active: editingPrompt.is_active !== false
|
||||
};
|
||||
|
||||
await apiClient.put(
|
||||
buildApiUrl(API_ENDPOINTS.PROMPTS.UPDATE(editingPrompt.id)),
|
||||
dataToSend
|
||||
);
|
||||
|
||||
showToast('标题更新成功', 'success');
|
||||
|
||||
// 先计算完整更新后的对象(在回调外部)
|
||||
const fullUpdatedPrompt = {
|
||||
...prompts.find(p => p.id === editingPrompt.id),
|
||||
name: editingPrompt.name
|
||||
};
|
||||
|
||||
// 更新本地状态
|
||||
setPrompts(prevPrompts =>
|
||||
prevPrompts.map(p => p.id === editingPrompt.id ? fullUpdatedPrompt : p)
|
||||
);
|
||||
|
||||
// 使用完整数据更新状态
|
||||
setSelectedPrompt(fullUpdatedPrompt);
|
||||
setEditingPrompt(fullUpdatedPrompt);
|
||||
setEditingTitle(false);
|
||||
} catch (err) {
|
||||
console.error('handleSaveTitle error:', err);
|
||||
showToast(err.response?.data?.message || '更新失败', 'error');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 直接保存字段更新(用于 is_default 和 is_active)
|
||||
const handleDirectSave = async (field, value) => {
|
||||
if (!editingPrompt || !editingPrompt.id) {
|
||||
showToast('数据异常,请重新选择提示词', 'error');
|
||||
console.error('handleDirectSave: editingPrompt is invalid', editingPrompt);
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果是默认模版,不允许设置为无效
|
||||
if (field === 'is_active' && !value && editingPrompt.is_default) {
|
||||
showToast('默认模版不能设置为无效,请先取消默认状态', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedPrompt = { ...editingPrompt, [field]: value };
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const dataToSend = {
|
||||
name: updatedPrompt.name,
|
||||
task_type: updatedPrompt.task_type,
|
||||
content: updatedPrompt.content || '',
|
||||
is_default: Boolean(updatedPrompt.is_default),
|
||||
is_active: updatedPrompt.is_active !== false
|
||||
};
|
||||
|
||||
await apiClient.put(
|
||||
buildApiUrl(API_ENDPOINTS.PROMPTS.UPDATE(editingPrompt.id)),
|
||||
dataToSend
|
||||
);
|
||||
|
||||
showToast('更新成功', 'success');
|
||||
|
||||
// 先计算完整更新后的对象(在回调外部)
|
||||
const fullUpdatedPrompt = {
|
||||
...prompts.find(p => p.id === editingPrompt.id),
|
||||
[field]: value
|
||||
};
|
||||
|
||||
// 更新本地状态
|
||||
setPrompts(prevPrompts =>
|
||||
prevPrompts.map(p => p.id === editingPrompt.id ? fullUpdatedPrompt : p)
|
||||
);
|
||||
|
||||
// 使用完整数据更新状态
|
||||
setSelectedPrompt(fullUpdatedPrompt);
|
||||
setEditingPrompt(fullUpdatedPrompt);
|
||||
} catch (err) {
|
||||
console.error('handleDirectSave error:', err);
|
||||
showToast(err.response?.data?.message || '更新失败', 'error');
|
||||
// 恢复原值
|
||||
setEditingPrompt({ ...selectedPrompt });
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
if (isEditing) {
|
||||
await apiClient.put(buildApiUrl(API_ENDPOINTS.PROMPTS.UPDATE(currentPrompt.id)), currentPrompt);
|
||||
showToast('提示词更新成功', 'success');
|
||||
} else {
|
||||
await apiClient.post(buildApiUrl(API_ENDPOINTS.PROMPTS.CREATE), currentPrompt);
|
||||
showToast('提示词创建成功', 'success');
|
||||
if (!editingPrompt || !editingPrompt.id) {
|
||||
showToast('数据异常,请重新选择提示词', 'error');
|
||||
console.error('handleSave: editingPrompt is invalid', editingPrompt);
|
||||
return;
|
||||
}
|
||||
handleCloseModal();
|
||||
fetchPrompts();
|
||||
|
||||
// 验证必需字段
|
||||
if (!editingPrompt.name || !editingPrompt.task_type || editingPrompt.content === undefined) {
|
||||
showToast('数据不完整,请刷新页面重试', 'error');
|
||||
console.error('handleSave: missing required fields', editingPrompt);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('handleSave: Saving prompt', {
|
||||
id: editingPrompt.id,
|
||||
name: editingPrompt.name,
|
||||
task_type: editingPrompt.task_type,
|
||||
content_length: editingPrompt.content?.length,
|
||||
is_default: editingPrompt.is_default,
|
||||
is_active: editingPrompt.is_active
|
||||
});
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const dataToSend = {
|
||||
name: editingPrompt.name,
|
||||
task_type: editingPrompt.task_type,
|
||||
content: editingPrompt.content || '',
|
||||
is_default: Boolean(editingPrompt.is_default),
|
||||
is_active: editingPrompt.is_active !== false
|
||||
};
|
||||
|
||||
const url = buildApiUrl(API_ENDPOINTS.PROMPTS.UPDATE(editingPrompt.id));
|
||||
console.log('handleSave: Request URL:', url);
|
||||
console.log('handleSave: Sending data', dataToSend);
|
||||
console.log('handleSave: editingPrompt.id type:', typeof editingPrompt.id, 'value:', editingPrompt.id);
|
||||
|
||||
const response = await apiClient.put(url, dataToSend);
|
||||
|
||||
console.log('handleSave: Success response:', response);
|
||||
showToast('保存成功', 'success');
|
||||
|
||||
// 先从当前列表中找到原始记录
|
||||
const originalPrompt = prompts.find(p => p.id === editingPrompt.id);
|
||||
if (!originalPrompt) {
|
||||
console.error('handleSave: Cannot find prompt in list', editingPrompt.id);
|
||||
console.error('handleSave: Current prompts list:', prompts.map(p => ({ id: p.id, name: p.name })));
|
||||
// 如果找不到,重新加载列表
|
||||
await fetchPrompts();
|
||||
return;
|
||||
}
|
||||
|
||||
// 计算完整更新后的对象(在回调外部)
|
||||
const fullUpdatedPrompt = {
|
||||
...originalPrompt,
|
||||
...editingPrompt
|
||||
};
|
||||
|
||||
console.log('handleSave: fullUpdatedPrompt', fullUpdatedPrompt);
|
||||
|
||||
// 更新本地状态
|
||||
setPrompts(prevPrompts =>
|
||||
prevPrompts.map(p => p.id === editingPrompt.id ? fullUpdatedPrompt : p)
|
||||
);
|
||||
|
||||
// 使用完整数据更新状态
|
||||
setSelectedPrompt(fullUpdatedPrompt);
|
||||
setEditingPrompt(fullUpdatedPrompt);
|
||||
} catch (err) {
|
||||
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) => {
|
||||
setActiveMenu(null); // Close menu
|
||||
// 检查是否为默认模版
|
||||
if (prompt.is_default) {
|
||||
showToast('默认模版不允许删除,请先取消默认状态', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
setDeleteConfirmInfo({
|
||||
id: prompt.id,
|
||||
name: prompt.name
|
||||
|
|
@ -108,115 +311,294 @@ const PromptManagement = () => {
|
|||
const handleConfirmDelete = async () => {
|
||||
try {
|
||||
await apiClient.delete(buildApiUrl(API_ENDPOINTS.PROMPTS.DELETE(deleteConfirmInfo.id)));
|
||||
|
||||
if (selectedPrompt && selectedPrompt.id === deleteConfirmInfo.id) {
|
||||
setSelectedPrompt(null);
|
||||
setEditingPrompt(null);
|
||||
}
|
||||
|
||||
setDeleteConfirmInfo(null);
|
||||
fetchPrompts();
|
||||
await fetchPrompts();
|
||||
showToast('提示词删除成功', 'success');
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.message || '删除失败');
|
||||
showToast('删除失败,请重试', 'error');
|
||||
showToast(err.response?.data?.message || '删除失败', 'error');
|
||||
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) => {
|
||||
setActiveMenu(activeMenu === promptId ? null : promptId);
|
||||
};
|
||||
if (loading) {
|
||||
return <div className="prompt-management"><p>加载中...</p></div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="prompt-management">
|
||||
<div className="toolbar">
|
||||
<h2>提示词仓库</h2>
|
||||
<button className="btn btn-primary" onClick={() => handleOpenModal()}><Plus size={16} /> 新增提示词</button>
|
||||
</div>
|
||||
{loading && <p>加载中...</p>}
|
||||
{error && !showModal && <p className="error-message">{error}</p>}
|
||||
{!loading && (
|
||||
<div className="prompt-layout">
|
||||
{/* 左侧提示词列表 */}
|
||||
<div className={`prompt-sidebar ${sidebarCollapsed ? 'collapsed' : ''}`}>
|
||||
<div className="sidebar-header">
|
||||
{!sidebarCollapsed && (
|
||||
<>
|
||||
<div className="prompt-grid">
|
||||
{prompts.map(prompt => (
|
||||
<div key={prompt.id} className="prompt-card">
|
||||
<div className="card-header">
|
||||
<h3>{prompt.name}</h3>
|
||||
<div className="card-actions" ref={activeMenu === prompt.id ? menuRef : null}>
|
||||
<button className="dropdown-trigger" onClick={() => toggleMenu(prompt.id)}><MoreVertical size={18} /></button>
|
||||
{activeMenu === prompt.id && (
|
||||
<div className="dropdown-menu">
|
||||
<button className="dropdown-item" onClick={() => handleOpenModal(prompt)}><Edit size={16}/> 编辑</button>
|
||||
<button className="dropdown-item delete-item" onClick={() => handleDelete(prompt)}><Trash2 size={16}/> 删除</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<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 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>
|
||||
<h3>提示词列表</h3>
|
||||
<button
|
||||
className="btn-new-prompt"
|
||||
onClick={handleOpenCreateModal}
|
||||
title="新增提示词"
|
||||
>
|
||||
<Plus size={18} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
className="btn-toggle-sidebar"
|
||||
onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
|
||||
title={sidebarCollapsed ? "展开" : "收起"}
|
||||
>
|
||||
{sidebarCollapsed ? <ChevronRight size={18} /> : <ChevronLeft size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!sidebarCollapsed && (
|
||||
<div className="prompt-list-sidebar">
|
||||
{prompts.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<p>暂无提示词</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* 会议任务 */}
|
||||
{groupedPrompts.MEETING_TASK.length > 0 && (
|
||||
<div className="prompt-group">
|
||||
<div className="group-header">
|
||||
<span>{TASK_TYPES.MEETING_TASK.icon}</span>
|
||||
<span>{TASK_TYPES.MEETING_TASK.label}</span>
|
||||
</div>
|
||||
{groupedPrompts.MEETING_TASK.map(prompt => (
|
||||
<div
|
||||
key={prompt.id}
|
||||
className={`prompt-list-item ${selectedPrompt?.id === prompt.id ? 'active' : ''}`}
|
||||
onClick={() => handlePromptSelect(prompt)}
|
||||
>
|
||||
<div className="prompt-item-info">
|
||||
{prompt.is_default ? (
|
||||
<span className="badge badge-default">
|
||||
<Star size={12} fill="currentColor" />
|
||||
</span>
|
||||
) : null}
|
||||
<h4 title={prompt.name}>{prompt.name}</h4>
|
||||
{!prompt.is_active ? (
|
||||
<span className="badge badge-inactive">未启用</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 知识库任务 */}
|
||||
{groupedPrompts.KNOWLEDGE_TASK.length > 0 && (
|
||||
<div className="prompt-group">
|
||||
<div className="group-header">
|
||||
<span>{TASK_TYPES.KNOWLEDGE_TASK.icon}</span>
|
||||
<span>{TASK_TYPES.KNOWLEDGE_TASK.label}</span>
|
||||
</div>
|
||||
{groupedPrompts.KNOWLEDGE_TASK.map(prompt => (
|
||||
<div
|
||||
key={prompt.id}
|
||||
className={`prompt-list-item ${selectedPrompt?.id === prompt.id ? 'active' : ''}`}
|
||||
onClick={() => handlePromptSelect(prompt)}
|
||||
>
|
||||
<div className="prompt-item-info">
|
||||
{prompt.is_default ? (
|
||||
<span className="badge badge-default">
|
||||
<Star size={12} fill="currentColor" />
|
||||
</span>
|
||||
) : null}
|
||||
<h4 title={prompt.name}>{prompt.name}</h4>
|
||||
{!prompt.is_active ? (
|
||||
<span className="badge badge-inactive">未启用</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 右侧编辑区 */}
|
||||
<div className="prompt-detail-area">
|
||||
{selectedPrompt && editingPrompt ? (
|
||||
<>
|
||||
{/* 第一行:标题 + 操作按钮 */}
|
||||
<div className="prompt-title-row">
|
||||
<div className="title-edit-container">
|
||||
{editingTitle ? (
|
||||
<>
|
||||
<input
|
||||
type="text"
|
||||
className="title-input"
|
||||
value={editingPrompt.name}
|
||||
onChange={(e) => handleEditChange('name', e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleSaveTitle();
|
||||
} else if (e.key === 'Escape') {
|
||||
setEditingPrompt({ ...selectedPrompt });
|
||||
setEditingTitle(false);
|
||||
}
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
className="icon-btn-small confirm-btn"
|
||||
onClick={handleSaveTitle}
|
||||
disabled={isSaving}
|
||||
title="确认"
|
||||
>
|
||||
<Check size={16} />
|
||||
</button>
|
||||
<button
|
||||
className="icon-btn-small cancel-btn"
|
||||
onClick={() => {
|
||||
setEditingPrompt({ ...selectedPrompt });
|
||||
setEditingTitle(false);
|
||||
}}
|
||||
title="取消"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<h1>{selectedPrompt.name}</h1>
|
||||
<button
|
||||
className="icon-btn-small edit-btn"
|
||||
onClick={() => setEditingTitle(true)}
|
||||
title="编辑标题"
|
||||
>
|
||||
<Edit2 size={16} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="title-actions">
|
||||
<button
|
||||
className="icon-btn save-btn"
|
||||
onClick={handleSave}
|
||||
disabled={isSaving}
|
||||
title="保存"
|
||||
>
|
||||
<Save size={18} />
|
||||
</button>
|
||||
<button
|
||||
className="icon-btn delete-btn"
|
||||
onClick={() => handleDelete(selectedPrompt)}
|
||||
title="删除"
|
||||
>
|
||||
<Trash2 size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 第二行:任务类型 + 设为默认 + 启用开关 */}
|
||||
<div className="prompt-controls-row">
|
||||
<span className="task-type-badge">
|
||||
{TASK_TYPES[selectedPrompt.task_type]?.icon} {TASK_TYPES[selectedPrompt.task_type]?.label}
|
||||
</span>
|
||||
|
||||
<div className="prompt-actions-right">
|
||||
<button
|
||||
className={`btn-set-default ${editingPrompt.is_default ? 'active' : ''}`}
|
||||
onClick={() => handleDirectSave('is_default', !editingPrompt.is_default)}
|
||||
disabled={isSaving}
|
||||
>
|
||||
<Star size={14} />
|
||||
<span>{editingPrompt.is_default ? '默认模版' : '设为默认'}</span>
|
||||
</button>
|
||||
|
||||
<label className="switch-label">
|
||||
<span>启用</span>
|
||||
<div className="switch-wrapper">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editingPrompt.is_active}
|
||||
onChange={(e) => handleDirectSave('is_active', e.target.checked)}
|
||||
className="switch-input"
|
||||
disabled={isSaving}
|
||||
/>
|
||||
<span className="switch-slider"></span>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Markdown 编辑器 */}
|
||||
<div className="prompt-editor-container">
|
||||
<MarkdownEditor
|
||||
value={editingPrompt.content || ''}
|
||||
onChange={(value) => handleEditChange('content', value)}
|
||||
placeholder="请输入提示词内容(支持 Markdown 格式)..."
|
||||
height={500}
|
||||
showImageUpload={false}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="prompt-empty-placeholder">
|
||||
<FileText size={64} />
|
||||
<p>请从左侧选择一个提示词进行编辑</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 新增提示词表单弹窗(简化版) */}
|
||||
<FormModal
|
||||
isOpen={showModal}
|
||||
onClose={handleCloseModal}
|
||||
title={isEditing ? '编辑提示词' : '新增提示词'}
|
||||
size="medium"
|
||||
isOpen={showCreateModal}
|
||||
onClose={handleCloseCreateModal}
|
||||
title="新增提示词"
|
||||
size="small"
|
||||
actions={
|
||||
<>
|
||||
<button type="button" className="btn btn-secondary" onClick={handleCloseModal}>
|
||||
<button type="button" className="btn btn-secondary" onClick={handleCloseCreateModal}>
|
||||
取消
|
||||
</button>
|
||||
<button type="button" className="btn btn-primary" onClick={handleSave}>
|
||||
保存
|
||||
<button type="button" className="btn btn-primary" onClick={handleCreatePrompt}>
|
||||
创建
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{error && <div className="error-message">{error}</div>}
|
||||
{currentPrompt && (
|
||||
<>
|
||||
<div className="form-group">
|
||||
<label><FileText size={16} /> 名称</label>
|
||||
<input
|
||||
type="text"
|
||||
value={currentPrompt.name}
|
||||
onChange={(e) => handleInputChange('name', e.target.value)}
|
||||
value={newPromptData.name}
|
||||
onChange={(e) => setNewPromptData(prev => ({ ...prev, name: e.target.value }))}
|
||||
placeholder="请输入提示词名称"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label><Tag size={16} /> 标签</label>
|
||||
<TagEditor
|
||||
value={currentPrompt.tags}
|
||||
onChange={(value) => handleInputChange('tags', value)}
|
||||
/>
|
||||
<label><BookText size={16} /> 任务类型</label>
|
||||
<select
|
||||
value={newPromptData.task_type}
|
||||
onChange={(e) => setNewPromptData(prev => ({ ...prev, task_type: e.target.value }))}
|
||||
>
|
||||
<option value="MEETING_TASK">会议任务</option>
|
||||
<option value="KNOWLEDGE_TASK">知识库任务</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label><BookText size={16} /> 内容</label>
|
||||
<textarea
|
||||
rows="10"
|
||||
value={currentPrompt.content}
|
||||
onChange={(e) => handleInputChange('content', e.target.value)}
|
||||
placeholder="请输入提示词内容"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</FormModal>
|
||||
|
||||
{/* 删除提示词确认对话框 */}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,31 @@
|
|||
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 {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ const UserManagement = () => {
|
|||
const [resetConfirmInfo, setResetConfirmInfo] = useState(null);
|
||||
const [roles, setRoles] = useState([]);
|
||||
const [toasts, setToasts] = useState([]);
|
||||
const [searchText, setSearchText] = useState(''); // 搜索文本
|
||||
|
||||
// Toast helper functions
|
||||
const showToast = (message, type = 'info') => {
|
||||
|
|
@ -35,12 +36,16 @@ const UserManagement = () => {
|
|||
useEffect(() => {
|
||||
fetchUsers();
|
||||
fetchRoles();
|
||||
}, [page, pageSize]);
|
||||
}, [page, pageSize, searchText]);
|
||||
|
||||
const fetchUsers = async () => {
|
||||
setLoading(true);
|
||||
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);
|
||||
setTotal(response.data.total);
|
||||
} catch (err) {
|
||||
|
|
@ -169,8 +174,20 @@ const UserManagement = () => {
|
|||
<div className="user-management">
|
||||
<div className="toolbar">
|
||||
<h2>用户列表</h2>
|
||||
<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>
|
||||
{loading && <p>加载中...</p>}
|
||||
{error && <p className="error-message">{error}</p>}
|
||||
{!loading && !error && (
|
||||
|
|
|
|||
|
|
@ -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