增加了权限系统

main
mula.liu 2025-12-11 16:47:46 +08:00
parent ed628738d2
commit 39eb9e8812
21 changed files with 2932 additions and 395 deletions

BIN
.DS_Store vendored

Binary file not shown.

View File

@ -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
## 总结
知识库新增页面的模版选择功能已完全实现,用户现在可以在创建知识库时灵活选择不同的生成模版,大大提升了知识库生成的灵活性和个性化程度。

BIN
dist.zip

Binary file not shown.

View File

@ -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="/" />

View File

@ -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>

View File

@ -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}`,

View File

@ -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;
}
}

View File

@ -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;

View File

@ -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"

View File

@ -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>

View File

@ -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"
/>

View File

@ -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;

View File

@ -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>
<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>
<label>* 用户提示词可选</label>
<p className="field-hint">您可以添加额外的要求来定制知识库生成内容例如重点关注某个主题提取特定信息等如不填写系统将使用默认提示词</p>
<textarea
placeholder="例如:请重点关注会议中的决策事项和待办任务..."

View File

@ -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">

View File

@ -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;
}
}

View File

@ -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;

View File

@ -1,163 +1,519 @@
/* 提示词管理页面 - 左右分栏布局 */
.prompt-management {
padding: 1rem;
}
.toolbar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
.toolbar h2 {
font-size: 1.5rem;
font-weight: 600;
}
.prompt-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 1.5rem;
}
.prompt-card {
background: #fff;
border: 1px solid #e9ecef;
border-radius: 8px;
padding: 1.5rem;
display: flex;
flex-direction: column;
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
transition: box-shadow 0.3s ease, transform 0.3s ease;
}
.prompt-card:hover {
transform: translateY(-5px);
box-shadow: 0 8px 16px rgba(0,0,0,0.1);
}
.card-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 0.75rem;
}
.card-header h3 {
font-size: 1.1rem;
font-weight: 600;
margin: 0;
line-height: 1.3;
}
.card-actions {
display: flex;
gap: 0.5rem;
position: relative; /* For dropdown positioning */
}
.dropdown-trigger {
background: none;
border: none;
cursor: pointer;
color: #6c757d;
padding: 0.25rem;
border-radius: 4px;
transition: background-color 0.2s ease;
}
.dropdown-trigger:hover {
background-color: #f1f3f5;
color: #212529;
}
.dropdown-menu {
position: absolute;
top: 100%;
right: 0;
background: white;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
border: 1px solid #e2e8f0;
padding: 0.5rem;
margin-top: 0.5rem;
min-width: 120px;
z-index: 10;
height: 100vh;
display: flex;
flex-direction: column;
gap: 2px;
overflow: hidden;
}
.dropdown-item {
/* 左右分栏布局 */
.prompt-layout {
display: flex;
gap: 1.5rem;
flex: 1;
overflow: hidden;
padding: 1rem;
}
/* 左侧提示词列表 */
.prompt-sidebar {
width: 320px;
background: #fff;
border: 1px solid #e9ecef;
border-radius: 12px;
display: flex;
flex-direction: column;
overflow: hidden;
transition: width 0.3s ease;
}
.prompt-sidebar.collapsed {
width: 60px;
}
.sidebar-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 1.25rem;
border-bottom: 1px solid #e9ecef;
background: #f8f9fa;
gap: 0.75rem;
}
.sidebar-header h3 {
font-size: 1.1rem;
font-weight: 600;
color: #1e293b;
margin: 0;
flex: 1;
}
.btn-new-prompt {
background: #667eea;
border: none;
border-radius: 6px;
color: white;
cursor: pointer;
padding: 0.5rem;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
}
.btn-new-prompt:hover {
background: #5568d3;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3);
}
.btn-toggle-sidebar {
background: none;
border: none;
cursor: pointer;
color: #64748b;
padding: 0.25rem;
border-radius: 4px;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
}
.btn-toggle-sidebar:hover {
background: #e2e8f0;
color: #1e293b;
}
.prompt-list-sidebar {
flex: 1;
overflow-y: auto;
padding: 0.5rem;
}
/* 分组样式 */
.prompt-group {
margin-bottom: 1.5rem;
}
.group-header {
display: flex;
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;
.title-actions {
display: flex;
gap: 0.5rem;
}
.card-actions button:hover {
background-color: #f1f3f5;
color: #212529;
.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-tags {
margin-bottom: 1rem;
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
.icon-btn svg {
flex-shrink: 0;
}
.tag-chip {
background-color: #e9ecef;
color: #f2f0f7;
padding: 0.25rem 0.6rem;
border-radius: 12px;
font-size: 0.75rem;
.icon-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.card-content {
font-size: 0.9rem;
color: #495057;
line-height: 1.5;
flex-grow: 1;
.icon-btn.save-btn {
background: #667eea;
color: white;
}
/* Form group styles - 配合 FormModal 使用 */
.icon-btn.save-btn svg {
color: white;
stroke: white;
}
.icon-btn.save-btn:hover:not(:disabled) {
background: #5568d3;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3);
}
.icon-btn.delete-btn {
background: #fee2e2;
color: #dc2626;
}
.icon-btn.delete-btn svg {
color: #dc2626;
stroke: #dc2626;
}
.icon-btn.delete-btn:hover {
background: #fca5a5;
color: #991b1b;
}
.icon-btn.delete-btn:hover svg {
color: #991b1b;
stroke: #991b1b;
}
/* 第二行:控制区 */
.prompt-controls-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1.5rem;
padding: 1rem 2rem;
border-bottom: 1px solid #e9ecef;
background: #fff;
}
.prompt-actions-right {
display: flex;
align-items: center;
gap: 1rem;
}
.btn-set-default {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
border: 1.5px solid #e2e8f0;
border-radius: 8px;
background: white;
color: #64748b;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
}
.btn-set-default:hover:not(:disabled) {
border-color: #f59e0b;
color: #f59e0b;
background: #fffbeb;
}
.btn-set-default.active {
border-color: #f59e0b;
background: linear-gradient(135deg, #fbbf24 0%, #f59e0b 100%);
color: white;
box-shadow: 0 2px 8px rgba(245, 158, 11, 0.3);
}
.btn-set-default.active svg {
fill: white;
}
.btn-set-default:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.task-type-badge {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.75rem;
border-radius: 6px;
font-size: 0.875rem;
font-weight: 500;
background: #dbeafe;
color: #1e40af;
}
/* Switch 开关样式 */
.switch-label {
display: flex;
align-items: center;
gap: 0.75rem;
font-size: 0.875rem;
font-weight: 500;
color: #475569;
cursor: pointer;
user-select: none;
}
.switch-wrapper {
position: relative;
width: 44px;
height: 24px;
}
.switch-input {
opacity: 0;
width: 0;
height: 0;
position: absolute;
}
.switch-slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #cbd5e1;
transition: 0.3s;
border-radius: 24px;
}
.switch-slider:before {
position: absolute;
content: "";
height: 18px;
width: 18px;
left: 3px;
bottom: 3px;
background-color: white;
transition: 0.3s;
border-radius: 50%;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
}
.switch-input:checked + .switch-slider {
background-color: #10b981;
}
.switch-input:checked + .switch-slider:before {
transform: translateX(20px);
}
.switch-input:focus + .switch-slider {
box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.2);
}
/* Markdown 编辑器容器 */
.prompt-editor-container {
flex: 1;
padding: 2rem;
overflow: auto;
background: #fafbfc;
}
/* 空状态 */
.prompt-empty-placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: #94a3b8;
text-align: center;
}
.prompt-empty-placeholder p {
margin-top: 1rem;
font-size: 1rem;
}
.empty-state {
padding: 2rem;
text-align: center;
color: #94a3b8;
}
/* 表单样式 */
.form-group {
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,102 +542,102 @@
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;
border-radius: 8px;
cursor: pointer;
font-weight: 500;
font-size: 0.95rem;
transition: all 0.2s ease;
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.65rem 1.25rem;
border: none;
border-radius: 8px;
cursor: pointer;
font-weight: 500;
font-size: 0.95rem;
transition: all 0.2s ease;
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-primary {
background-color: #667eea;
color: white;
background-color: #667eea;
color: white;
}
.btn-primary:hover {
background-color: #5568d3;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3);
.btn-primary:hover:not(:disabled) {
background-color: #5568d3;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3);
}
.btn-secondary {
background-color: white;
color: #475569;
border: 1px solid #e2e8f0;
background-color: white;
color: #475569;
border: 1px solid #e2e8f0;
}
.btn-secondary:hover {
background-color: #f8fafc;
border-color: #cbd5e1;
background-color: #f8fafc;
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;
border-radius: 6px;
cursor: pointer;
.btn-danger:hover {
background-color: #fca5a5;
color: #991b1b;
border-color: #f87171;
}
.pagination button:disabled {
opacity: 0.5;
cursor: not-allowed;
/* 错误信息 */
.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;
}
.pagination span {
font-size: 0.9rem;
color: #495057;
/* 响应式设计 */
@media (max-width: 1024px) {
.prompt-sidebar {
width: 280px;
}
}
@media (max-width: 768px) {
.prompt-layout {
flex-direction: column;
}
.prompt-sidebar {
width: 100%;
height: 300px;
}
.prompt-sidebar.collapsed {
height: 60px;
width: 100%;
}
.detail-controls {
flex-direction: column;
align-items: flex-start;
}
}

View File

@ -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 () => {
if (!editingPrompt || !editingPrompt.id) {
showToast('数据异常,请重新选择提示词', 'error');
console.error('handleSave: editingPrompt is invalid', editingPrompt);
return;
}
//
if (!editingPrompt.name || !editingPrompt.task_type || editingPrompt.content === undefined) {
showToast('数据不完整,请刷新页面重试', 'error');
console.error('handleSave: missing required fields', editingPrompt);
return;
}
console.log('handleSave: Saving prompt', {
id: editingPrompt.id,
name: editingPrompt.name,
task_type: editingPrompt.task_type,
content_length: editingPrompt.content?.length,
is_default: editingPrompt.is_default,
is_active: editingPrompt.is_active
});
setIsSaving(true);
try {
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');
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;
}
handleCloseModal();
fetchPrompts();
//
const fullUpdatedPrompt = {
...originalPrompt,
...editingPrompt
};
console.log('handleSave: fullUpdatedPrompt', fullUpdatedPrompt);
//
setPrompts(prevPrompts =>
prevPrompts.map(p => p.id === editingPrompt.id ? fullUpdatedPrompt : p)
);
// 使
setSelectedPrompt(fullUpdatedPrompt);
setEditingPrompt(fullUpdatedPrompt);
} catch (err) {
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-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 className="prompt-layout">
{/* 左侧提示词列表 */}
<div className={`prompt-sidebar ${sidebarCollapsed ? 'collapsed' : ''}`}>
<div className="sidebar-header">
{!sidebarCollapsed && (
<>
<h3>提示词列表</h3>
<button
className="btn-new-prompt"
onClick={handleOpenCreateModal}
title="新增提示词"
>
<Plus size={18} />
</button>
</>
)}
<button
className="btn-toggle-sidebar"
onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
title={sidebarCollapsed ? "展开" : "收起"}
>
{sidebarCollapsed ? <ChevronRight size={18} /> : <ChevronLeft size={18} />}
</button>
</div>
<div className="pagination">
<span>总计 {total} </span>
<div>
<button onClick={() => setPage(p => Math.max(1, p - 1))} disabled={page === 1}>上一页</button>
<span> {page} / {Math.ceil(total / pageSize)}</span>
<button onClick={() => setPage(p => p + 1)} disabled={page * pageSize >= total}>下一页</button>
</div>
</div>
</>
)}
{!sidebarCollapsed && (
<div className="prompt-list-sidebar">
{prompts.length === 0 ? (
<div className="empty-state">
<p>暂无提示词</p>
</div>
) : (
<>
{/* 会议任务 */}
{groupedPrompts.MEETING_TASK.length > 0 && (
<div className="prompt-group">
<div className="group-header">
<span>{TASK_TYPES.MEETING_TASK.icon}</span>
<span>{TASK_TYPES.MEETING_TASK.label}</span>
</div>
{groupedPrompts.MEETING_TASK.map(prompt => (
<div
key={prompt.id}
className={`prompt-list-item ${selectedPrompt?.id === prompt.id ? 'active' : ''}`}
onClick={() => handlePromptSelect(prompt)}
>
<div className="prompt-item-info">
{prompt.is_default ? (
<span className="badge badge-default">
<Star size={12} fill="currentColor" />
</span>
) : null}
<h4 title={prompt.name}>{prompt.name}</h4>
{!prompt.is_active ? (
<span className="badge badge-inactive">未启用</span>
) : null}
</div>
</div>
))}
</div>
)}
{/* 知识库任务 */}
{groupedPrompts.KNOWLEDGE_TASK.length > 0 && (
<div className="prompt-group">
<div className="group-header">
<span>{TASK_TYPES.KNOWLEDGE_TASK.icon}</span>
<span>{TASK_TYPES.KNOWLEDGE_TASK.label}</span>
</div>
{groupedPrompts.KNOWLEDGE_TASK.map(prompt => (
<div
key={prompt.id}
className={`prompt-list-item ${selectedPrompt?.id === prompt.id ? 'active' : ''}`}
onClick={() => handlePromptSelect(prompt)}
>
<div className="prompt-item-info">
{prompt.is_default ? (
<span className="badge badge-default">
<Star size={12} fill="currentColor" />
</span>
) : null}
<h4 title={prompt.name}>{prompt.name}</h4>
{!prompt.is_active ? (
<span className="badge badge-inactive">未启用</span>
) : null}
</div>
</div>
))}
</div>
)}
</>
)}
</div>
)}
</div>
{/* 右侧编辑区 */}
<div className="prompt-detail-area">
{selectedPrompt && editingPrompt ? (
<>
{/* 第一行:标题 + 操作按钮 */}
<div className="prompt-title-row">
<div className="title-edit-container">
{editingTitle ? (
<>
<input
type="text"
className="title-input"
value={editingPrompt.name}
onChange={(e) => handleEditChange('name', e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleSaveTitle();
} else if (e.key === 'Escape') {
setEditingPrompt({ ...selectedPrompt });
setEditingTitle(false);
}
}}
autoFocus
/>
<button
className="icon-btn-small confirm-btn"
onClick={handleSaveTitle}
disabled={isSaving}
title="确认"
>
<Check size={16} />
</button>
<button
className="icon-btn-small cancel-btn"
onClick={() => {
setEditingPrompt({ ...selectedPrompt });
setEditingTitle(false);
}}
title="取消"
>
<X size={16} />
</button>
</>
) : (
<>
<h1>{selectedPrompt.name}</h1>
<button
className="icon-btn-small edit-btn"
onClick={() => setEditingTitle(true)}
title="编辑标题"
>
<Edit2 size={16} />
</button>
</>
)}
</div>
<div className="title-actions">
<button
className="icon-btn save-btn"
onClick={handleSave}
disabled={isSaving}
title="保存"
>
<Save size={18} />
</button>
<button
className="icon-btn delete-btn"
onClick={() => handleDelete(selectedPrompt)}
title="删除"
>
<Trash2 size={18} />
</button>
</div>
</div>
{/* 第二行:任务类型 + 设为默认 + 启用开关 */}
<div className="prompt-controls-row">
<span className="task-type-badge">
{TASK_TYPES[selectedPrompt.task_type]?.icon} {TASK_TYPES[selectedPrompt.task_type]?.label}
</span>
<div className="prompt-actions-right">
<button
className={`btn-set-default ${editingPrompt.is_default ? 'active' : ''}`}
onClick={() => handleDirectSave('is_default', !editingPrompt.is_default)}
disabled={isSaving}
>
<Star size={14} />
<span>{editingPrompt.is_default ? '默认模版' : '设为默认'}</span>
</button>
<label className="switch-label">
<span>启用</span>
<div className="switch-wrapper">
<input
type="checkbox"
checked={editingPrompt.is_active}
onChange={(e) => handleDirectSave('is_active', e.target.checked)}
className="switch-input"
disabled={isSaving}
/>
<span className="switch-slider"></span>
</div>
</label>
</div>
</div>
{/* Markdown 编辑器 */}
<div className="prompt-editor-container">
<MarkdownEditor
value={editingPrompt.content || ''}
onChange={(value) => handleEditChange('content', value)}
placeholder="请输入提示词内容(支持 Markdown 格式)..."
height={500}
showImageUpload={false}
/>
</div>
</>
) : (
<div className="prompt-empty-placeholder">
<FileText size={64} />
<p>请从左侧选择一个提示词进行编辑</p>
</div>
)}
</div>
</div>
{/* 新增提示词表单弹窗(简化版) */}
<FormModal
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)}
placeholder="请输入提示词名称"
/>
</div>
<div className="form-group">
<label><Tag size={16} /> 标签</label>
<TagEditor
value={currentPrompt.tags}
onChange={(value) => handleInputChange('tags', value)}
/>
</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>
</>
)}
<div className="form-group">
<label><FileText size={16} /> 名称</label>
<input
type="text"
value={newPromptData.name}
onChange={(e) => setNewPromptData(prev => ({ ...prev, name: e.target.value }))}
placeholder="请输入提示词名称"
autoFocus
/>
</div>
<div className="form-group">
<label><BookText size={16} /> 任务类型</label>
<select
value={newPromptData.task_type}
onChange={(e) => setNewPromptData(prev => ({ ...prev, task_type: e.target.value }))}
>
<option value="MEETING_TASK">会议任务</option>
<option value="KNOWLEDGE_TASK">知识库任务</option>
</select>
</div>
</FormModal>
{/* 删除提示词确认对话框 */}
@ -244,4 +626,4 @@ const PromptManagement = () => {
);
};
export default PromptManagement;
export default PromptManagement;

View File

@ -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;

View File

@ -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,7 +174,19 @@ const UserManagement = () => {
<div className="user-management">
<div className="toolbar">
<h2>用户列表</h2>
<button className="btn btn-primary" onClick={() => handleOpenModal()}><Plus size={16} /> 新增用户</button>
<div className="toolbar-actions">
<input
type="text"
placeholder="搜索用户名或姓名..."
value={searchText}
onChange={(e) => {
setSearchText(e.target.value);
setPage(1); //
}}
className="search-input"
/>
<button className="btn btn-primary" onClick={() => handleOpenModal()}><Plus size={16} /> 新增用户</button>
</div>
</div>
{loading && <p>加载中...</p>}
{error && <p className="error-message">{error}</p>}

View File

@ -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;