增加了外部应用
parent
9d85301a50
commit
1a4d97f1e9
|
|
@ -14,6 +14,7 @@ import PromptManagementPage from './pages/PromptManagementPage';
|
||||||
import KnowledgeBasePage from './pages/KnowledgeBasePage';
|
import KnowledgeBasePage from './pages/KnowledgeBasePage';
|
||||||
import EditKnowledgeBase from './pages/EditKnowledgeBase';
|
import EditKnowledgeBase from './pages/EditKnowledgeBase';
|
||||||
import ClientDownloadPage from './pages/ClientDownloadPage';
|
import ClientDownloadPage from './pages/ClientDownloadPage';
|
||||||
|
import AccountSettings from './pages/AccountSettings';
|
||||||
import './App.css';
|
import './App.css';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
|
|
@ -100,6 +101,9 @@ function App() {
|
||||||
<Route path="/knowledge-base/edit/:kb_id" element={
|
<Route path="/knowledge-base/edit/:kb_id" element={
|
||||||
user ? <EditKnowledgeBase user={user} /> : <Navigate to="/" />
|
user ? <EditKnowledgeBase user={user} /> : <Navigate to="/" />
|
||||||
} />
|
} />
|
||||||
|
<Route path="/account-settings" element={
|
||||||
|
user ? <AccountSettings user={user} onUpdateUser={handleLogin} /> : <Navigate to="/" />
|
||||||
|
} />
|
||||||
<Route path="/downloads" element={<ClientDownloadPage />} />
|
<Route path="/downloads" element={<ClientDownloadPage />} />
|
||||||
<Route path="/meetings/preview/:meeting_id" element={<MeetingPreview />} />
|
<Route path="/meetings/preview/:meeting_id" element={<MeetingPreview />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|
|
||||||
|
|
@ -82,6 +82,15 @@ const API_CONFIG = {
|
||||||
DELETE: (id) => `/api/clients/${id}`,
|
DELETE: (id) => `/api/clients/${id}`,
|
||||||
UPLOAD: '/api/clients/upload'
|
UPLOAD: '/api/clients/upload'
|
||||||
},
|
},
|
||||||
|
EXTERNAL_APPS: {
|
||||||
|
LIST: '/api/external-apps',
|
||||||
|
ACTIVE: '/api/external-apps/active',
|
||||||
|
CREATE: '/api/external-apps',
|
||||||
|
UPDATE: (id) => `/api/external-apps/${id}`,
|
||||||
|
DELETE: (id) => `/api/external-apps/${id}`,
|
||||||
|
UPLOAD_APK: '/api/external-apps/upload-apk',
|
||||||
|
UPLOAD_ICON: '/api/external-apps/upload-icon'
|
||||||
|
},
|
||||||
DICT_DATA: {
|
DICT_DATA: {
|
||||||
TYPES: '/api/dict/types',
|
TYPES: '/api/dict/types',
|
||||||
BY_TYPE: (dictType) => `/api/dict/${dictType}`,
|
BY_TYPE: (dictType) => `/api/dict/${dictType}`,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,236 @@
|
||||||
|
.account-settings-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: #f8fafc;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-container {
|
||||||
|
max-width: 1000px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #64748b;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-btn:hover {
|
||||||
|
color: #1e293b;
|
||||||
|
border-color: #cbd5e1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-header h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
color: #1e293b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-content {
|
||||||
|
display: flex;
|
||||||
|
gap: 2rem;
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid #e9ecef;
|
||||||
|
overflow: hidden;
|
||||||
|
min-height: 600px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-sidebar {
|
||||||
|
width: 250px;
|
||||||
|
background: #f8fafc;
|
||||||
|
padding: 2rem 0;
|
||||||
|
border-right: 1px solid #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-item {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 1rem 2rem;
|
||||||
|
border: none ;
|
||||||
|
border-radius: 0px;
|
||||||
|
background: transparent;
|
||||||
|
color: #64748b;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
text-align: left;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-item:hover {
|
||||||
|
background: #f1f5f9;
|
||||||
|
color: #1e293b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-item.active {
|
||||||
|
background: white;
|
||||||
|
color: #667eea;
|
||||||
|
border-left: 3px solid #667eea;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-main {
|
||||||
|
flex: 1;
|
||||||
|
padding: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-form {
|
||||||
|
max-width: 500px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-section h3 {
|
||||||
|
margin: 0 0 2rem 0;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
color: #1e293b;
|
||||||
|
padding-bottom: 1rem;
|
||||||
|
border-bottom: 1px solid #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 1rem;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #667eea;
|
||||||
|
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.disabled-input {
|
||||||
|
background: #f1f5f9;
|
||||||
|
color: #94a3b8;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-upload {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-preview {
|
||||||
|
position: relative;
|
||||||
|
width: 100px;
|
||||||
|
height: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-preview img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 50%;
|
||||||
|
object-fit: cover;
|
||||||
|
border: 3px solid #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-placeholder {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #f1f5f9;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #94a3b8;
|
||||||
|
border: 3px solid #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-avatar {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
right: 0;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
background: #667eea;
|
||||||
|
color: white;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
|
transition: all 0.2s;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-avatar:hover {
|
||||||
|
background: #5a67d8;
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-avatar svg {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-info p {
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
margin-top: 2rem;
|
||||||
|
padding-top: 2rem;
|
||||||
|
border-top: 1px solid #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 6px rgba(102, 126, 234, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:disabled {
|
||||||
|
opacity: 0.7;
|
||||||
|
cursor: not-allowed;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,299 @@
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { User, Lock, Mail, Camera, Upload, Save, ArrowLeft, UserCog } from 'lucide-react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import apiClient from '../utils/apiClient';
|
||||||
|
import { buildApiUrl, API_ENDPOINTS } from '../config/api';
|
||||||
|
import Toast from '../components/Toast';
|
||||||
|
import Breadcrumb from '../components/Breadcrumb';
|
||||||
|
import './AccountSettings.css';
|
||||||
|
|
||||||
|
const AccountSettings = ({ user, onUpdateUser }) => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [activeTab, setActiveTab] = useState('profile');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [toasts, setToasts] = useState([]);
|
||||||
|
|
||||||
|
// Profile State
|
||||||
|
const [profileData, setProfileData] = useState({
|
||||||
|
caption: user?.caption || '',
|
||||||
|
email: user?.email || '',
|
||||||
|
username: user?.username || '',
|
||||||
|
avatar_url: user?.avatar_url || ''
|
||||||
|
});
|
||||||
|
const [previewAvatar, setPreviewAvatar] = useState(null);
|
||||||
|
const [avatarFile, setAvatarFile] = useState(null);
|
||||||
|
|
||||||
|
// Password State
|
||||||
|
const [passwordData, setPasswordData] = useState({
|
||||||
|
old_password: '',
|
||||||
|
new_password: '',
|
||||||
|
confirm_password: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Refresh user data on mount
|
||||||
|
fetchUserData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchUserData = async () => {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.USERS.DETAIL(user.user_id)));
|
||||||
|
if (response.code === '200') {
|
||||||
|
const userData = response.data;
|
||||||
|
setProfileData({
|
||||||
|
caption: userData.caption,
|
||||||
|
email: userData.email,
|
||||||
|
username: userData.username,
|
||||||
|
avatar_url: userData.avatar_url
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch user data:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const showToast = (message, type = 'info') => {
|
||||||
|
const id = Date.now();
|
||||||
|
setToasts(prev => [...prev, { id, message, type }]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeToast = (id) => {
|
||||||
|
setToasts(prev => prev.filter(toast => toast.id !== id));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAvatarChange = (e) => {
|
||||||
|
const file = e.target.files[0];
|
||||||
|
if (file) {
|
||||||
|
setAvatarFile(file);
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onloadend = () => {
|
||||||
|
setPreviewAvatar(reader.result);
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleProfileUpdate = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Upload avatar if changed
|
||||||
|
let newAvatarUrl = profileData.avatar_url;
|
||||||
|
if (avatarFile) {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', avatarFile);
|
||||||
|
const uploadRes = await apiClient.post(
|
||||||
|
buildApiUrl(`/api/users/${user.user_id}/avatar`),
|
||||||
|
formData
|
||||||
|
);
|
||||||
|
if (uploadRes.code === '200') {
|
||||||
|
newAvatarUrl = uploadRes.data.avatar_url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Update profile info
|
||||||
|
const updateRes = await apiClient.put(
|
||||||
|
buildApiUrl(API_ENDPOINTS.USERS.UPDATE(user.user_id)),
|
||||||
|
{
|
||||||
|
caption: profileData.caption,
|
||||||
|
email: profileData.email,
|
||||||
|
avatar_url: newAvatarUrl
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (updateRes.code === '200') {
|
||||||
|
showToast('个人信息更新成功', 'success');
|
||||||
|
setProfileData(prev => ({ ...prev, avatar_url: newAvatarUrl }));
|
||||||
|
setAvatarFile(null);
|
||||||
|
setPreviewAvatar(null);
|
||||||
|
|
||||||
|
// Update local storage user info
|
||||||
|
const updatedUser = { ...user, ...updateRes.data };
|
||||||
|
localStorage.setItem('iMeetingUser', JSON.stringify(updatedUser));
|
||||||
|
if (onUpdateUser) onUpdateUser(updatedUser);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Update failed:', error);
|
||||||
|
showToast(error.response?.data?.message || '更新失败', 'error');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePasswordUpdate = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (passwordData.new_password !== passwordData.confirm_password) {
|
||||||
|
showToast('两次输入的密码不一致', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await apiClient.put(
|
||||||
|
buildApiUrl(API_ENDPOINTS.USERS.UPDATE_PASSWORD(user.user_id)),
|
||||||
|
{
|
||||||
|
old_password: passwordData.old_password,
|
||||||
|
new_password: passwordData.new_password
|
||||||
|
}
|
||||||
|
);
|
||||||
|
showToast('密码修改成功', 'success');
|
||||||
|
setPasswordData({ old_password: '', new_password: '', confirm_password: '' });
|
||||||
|
} catch (error) {
|
||||||
|
showToast(error.response?.data?.message || '密码修改失败', 'error');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="account-settings-page">
|
||||||
|
<Breadcrumb currentPage="账户设置" icon={UserCog} />
|
||||||
|
|
||||||
|
<div className="settings-container">
|
||||||
|
<div className="settings-content">
|
||||||
|
<div className="settings-sidebar">
|
||||||
|
<button
|
||||||
|
className={`sidebar-item ${activeTab === 'profile' ? 'active' : ''}`}
|
||||||
|
onClick={() => setActiveTab('profile')}
|
||||||
|
>
|
||||||
|
<User size={20} />
|
||||||
|
<span>个人资料</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`sidebar-item ${activeTab === 'security' ? 'active' : ''}`}
|
||||||
|
onClick={() => setActiveTab('security')}
|
||||||
|
>
|
||||||
|
<Lock size={20} />
|
||||||
|
<span>安全设置</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="settings-main">
|
||||||
|
{activeTab === 'profile' && (
|
||||||
|
<form onSubmit={handleProfileUpdate} className="settings-form">
|
||||||
|
<div className="form-section">
|
||||||
|
<h3>基本信息</h3>
|
||||||
|
|
||||||
|
<div className="avatar-upload">
|
||||||
|
<div className="avatar-preview">
|
||||||
|
{(previewAvatar || profileData.avatar_url) ? (
|
||||||
|
<img
|
||||||
|
src={previewAvatar || (profileData.avatar_url.startsWith('http') ? profileData.avatar_url : `${apiClient.defaults.baseURL || ''}${profileData.avatar_url}`)}
|
||||||
|
alt="Avatar"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="avatar-placeholder">
|
||||||
|
<User size={40} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<label htmlFor="avatar-input" className="upload-avatar">
|
||||||
|
<Camera size={16} />
|
||||||
|
<input id="avatar-input" type="file" accept="image/*" onChange={handleAvatarChange} hidden />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="avatar-info">
|
||||||
|
<p>支持 JPG, PNG, GIF</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label>用户名 (不可修改)</label>
|
||||||
|
<input type="text" value={profileData.username} disabled className="disabled-input" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label>显示名称</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={profileData.caption}
|
||||||
|
onChange={(e) => setProfileData({ ...profileData, caption: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label>邮箱地址</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={profileData.email}
|
||||||
|
onChange={(e) => setProfileData({ ...profileData, email: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-actions">
|
||||||
|
<button type="submit" className="btn-primary" disabled={loading}>
|
||||||
|
<Save size={18} />
|
||||||
|
{loading ? '保存中...' : '保存修改'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'security' && (
|
||||||
|
<form onSubmit={handlePasswordUpdate} className="settings-form">
|
||||||
|
<div className="form-section">
|
||||||
|
<h3>修改密码</h3>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label>当前密码</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={passwordData.old_password}
|
||||||
|
onChange={(e) => setPasswordData({ ...passwordData, old_password: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label>新密码</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={passwordData.new_password}
|
||||||
|
onChange={(e) => setPasswordData({ ...passwordData, new_password: e.target.value })}
|
||||||
|
required
|
||||||
|
minLength={6}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label>确认新密码</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={passwordData.confirm_password}
|
||||||
|
onChange={(e) => setPasswordData({ ...passwordData, confirm_password: e.target.value })}
|
||||||
|
required
|
||||||
|
minLength={6}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-actions">
|
||||||
|
<button type="submit" className="btn-primary" disabled={loading}>
|
||||||
|
<Save size={18} />
|
||||||
|
{loading ? '修改中...' : '修改密码'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="toast-container">
|
||||||
|
{toasts.map(toast => (
|
||||||
|
<Toast
|
||||||
|
key={toast.id}
|
||||||
|
message={toast.message}
|
||||||
|
type={toast.type}
|
||||||
|
onClose={() => removeToast(toast.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AccountSettings;
|
||||||
|
|
@ -472,3 +472,52 @@
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Modal Styles */
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.7);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
background: white;
|
||||||
|
padding: 2rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
width: 90%;
|
||||||
|
max-width: 500px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content h2 {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content .form-group {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content .form-group label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content .form-group input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-top: 2rem;
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import React, { useState, useEffect } from 'react';
|
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 { LogOut, User, Users, Activity, Server, HardDrive, Cpu, MemoryStick, RefreshCw, UserX, ChevronDown, KeyRound, Shield, BookText, Waves, UserCog } from 'lucide-react';
|
||||||
import apiClient from '../utils/apiClient';
|
import apiClient from '../utils/apiClient';
|
||||||
import { buildApiUrl, API_ENDPOINTS } from '../config/api';
|
import { buildApiUrl, API_ENDPOINTS } from '../config/api';
|
||||||
import Dropdown from '../components/Dropdown';
|
import Dropdown from '../components/Dropdown';
|
||||||
|
|
@ -33,7 +33,7 @@ const getTaskTypeText = (type) => TASK_TYPE_TEXT_MAP[type] || type;
|
||||||
|
|
||||||
// 默认管理员菜单
|
// 默认管理员菜单
|
||||||
const getDefaultMenus = () => [
|
const getDefaultMenus = () => [
|
||||||
{ menu_code: 'change_password', menu_name: '修改密码', menu_type: 'action' },
|
{ menu_code: 'account_settings', menu_name: '账户设置', menu_type: 'link', menu_url: '/account-settings' },
|
||||||
{ menu_code: 'prompt_management', menu_name: '提示词仓库', menu_type: 'link', menu_url: '/prompts' },
|
{ 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: 'platform_admin', menu_name: '平台管理', menu_type: 'link', menu_url: '/admin' },
|
||||||
{ menu_code: 'logout', menu_name: '退出登录', menu_type: 'action' }
|
{ menu_code: 'logout', menu_name: '退出登录', menu_type: 'action' }
|
||||||
|
|
@ -64,6 +64,7 @@ const AdminDashboard = ({ user, onLogout }) => {
|
||||||
const getMenuItemConfig = (menu) => {
|
const getMenuItemConfig = (menu) => {
|
||||||
const iconMap = {
|
const iconMap = {
|
||||||
'change_password': <KeyRound size={16} />,
|
'change_password': <KeyRound size={16} />,
|
||||||
|
'account_settings': <UserCog size={16} />,
|
||||||
'prompt_management': <BookText size={16} />,
|
'prompt_management': <BookText size={16} />,
|
||||||
'platform_admin': <Shield size={16} />,
|
'platform_admin': <Shield size={16} />,
|
||||||
'logout': <LogOut size={16} />
|
'logout': <LogOut size={16} />
|
||||||
|
|
@ -248,7 +249,21 @@ const AdminDashboard = ({ user, onLogout }) => {
|
||||||
<Dropdown
|
<Dropdown
|
||||||
trigger={
|
trigger={
|
||||||
<div className="user-menu-trigger">
|
<div className="user-menu-trigger">
|
||||||
<User size={20} />
|
{user.avatar_url ? (
|
||||||
|
<img
|
||||||
|
src={user.avatar_url.startsWith('http') ? user.avatar_url : `${apiClient.defaults.baseURL || ''}${user.avatar_url}`}
|
||||||
|
alt={user.caption}
|
||||||
|
style={{
|
||||||
|
width: '24px',
|
||||||
|
height: '24px',
|
||||||
|
borderRadius: '50%',
|
||||||
|
objectFit: 'cover',
|
||||||
|
border: '1px solid #e2e8f0'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<User size={20} />
|
||||||
|
)}
|
||||||
<span>{user.caption}</span>
|
<span>{user.caption}</span>
|
||||||
<ChevronDown size={16} />
|
<ChevronDown size={16} />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Settings, Users, Smartphone, Shield, BookText, Type } from 'lucide-react';
|
import { Settings, Users, Smartphone, Shield, BookText, Type, Package } from 'lucide-react';
|
||||||
import { Tabs } from 'antd';
|
import { Tabs } from 'antd';
|
||||||
import UserManagement from './admin/UserManagement';
|
import UserManagement from './admin/UserManagement';
|
||||||
import ClientManagement from './ClientManagement';
|
import ClientManagement from './ClientManagement';
|
||||||
|
import ExternalAppManagement from './admin/ExternalAppManagement';
|
||||||
import PermissionManagement from './admin/PermissionManagement';
|
import PermissionManagement from './admin/PermissionManagement';
|
||||||
import DictManagement from './admin/DictManagement';
|
import DictManagement from './admin/DictManagement';
|
||||||
import HotWordManagement from './admin/HotWordManagement';
|
import HotWordManagement from './admin/HotWordManagement';
|
||||||
|
|
@ -38,6 +39,11 @@ const AdminManagement = () => {
|
||||||
label: <span><Smartphone size={16} /> 客户端管理</span>,
|
label: <span><Smartphone size={16} /> 客户端管理</span>,
|
||||||
children: <ClientManagement />,
|
children: <ClientManagement />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'externalAppManagement',
|
||||||
|
label: <span><Package size={16} /> 外部应用管理</span>,
|
||||||
|
children: <ExternalAppManagement />,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -184,6 +184,8 @@
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
color: white;
|
color: white;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 2px solid #e2e8f0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-details {
|
.user-details {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import React, { useState, useEffect, useRef } from 'react';
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
import { LogOut, User, Calendar, Users, TrendingUp, Clock, MessageSquare, Plus, ChevronDown, KeyRound, Shield, Filter, X, Library, BookText, Waves } from 'lucide-react';
|
import { LogOut, User, Calendar, Users, TrendingUp, Clock, MessageSquare, Plus, ChevronDown, KeyRound, Shield, Filter, X, Library, BookText, Waves, UserCog } from 'lucide-react';
|
||||||
import apiClient from '../utils/apiClient';
|
import apiClient from '../utils/apiClient';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { buildApiUrl, API_ENDPOINTS } from '../config/api';
|
import { buildApiUrl, API_ENDPOINTS } from '../config/api';
|
||||||
|
|
@ -26,12 +26,6 @@ const Dashboard = ({ user, onLogout }) => {
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [loadingMore, setLoadingMore] = useState(false);
|
const [loadingMore, setLoadingMore] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [showChangePasswordModal, setShowChangePasswordModal] = useState(false);
|
|
||||||
const [oldPassword, setOldPassword] = useState('');
|
|
||||||
const [newPassword, setNewPassword] = useState('');
|
|
||||||
const [confirmPassword, setConfirmPassword] = useState('');
|
|
||||||
const [passwordChangeError, setPasswordChangeError] = useState('');
|
|
||||||
const [passwordChangeSuccess, setPasswordChangeSuccess] = useState('');
|
|
||||||
|
|
||||||
// 声纹相关状态
|
// 声纹相关状态
|
||||||
const [voiceprintStatus, setVoiceprintStatus] = useState(null);
|
const [voiceprintStatus, setVoiceprintStatus] = useState(null);
|
||||||
|
|
@ -126,7 +120,7 @@ const Dashboard = ({ user, onLogout }) => {
|
||||||
// 获取默认菜单(fallback)
|
// 获取默认菜单(fallback)
|
||||||
const getDefaultMenus = () => {
|
const getDefaultMenus = () => {
|
||||||
const defaultMenus = [
|
const defaultMenus = [
|
||||||
{ menu_code: 'change_password', menu_name: '修改密码', menu_type: 'action', sort_order: 1 },
|
{ menu_code: 'account_settings', menu_name: '账户设置', menu_type: 'link', menu_url: '/account-settings', sort_order: 1 },
|
||||||
{ menu_code: 'prompt_management', menu_name: '提示词仓库', menu_type: 'link', menu_url: '/prompt-management', sort_order: 2 },
|
{ 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 }
|
{ menu_code: 'logout', menu_name: '退出登录', menu_type: 'action', sort_order: 99 }
|
||||||
];
|
];
|
||||||
|
|
@ -150,13 +144,13 @@ const Dashboard = ({ user, onLogout }) => {
|
||||||
const getMenuItemConfig = (menu) => {
|
const getMenuItemConfig = (menu) => {
|
||||||
const iconMap = {
|
const iconMap = {
|
||||||
'change_password': <KeyRound size={16} />,
|
'change_password': <KeyRound size={16} />,
|
||||||
|
'account_settings': <UserCog size={16} />,
|
||||||
'prompt_management': <BookText size={16} />,
|
'prompt_management': <BookText size={16} />,
|
||||||
'platform_admin': <Shield size={16} />,
|
'platform_admin': <Shield size={16} />,
|
||||||
'logout': <LogOut size={16} />
|
'logout': <LogOut size={16} />
|
||||||
};
|
};
|
||||||
|
|
||||||
const actionMap = {
|
const actionMap = {
|
||||||
'change_password': () => setShowChangePasswordModal(true),
|
|
||||||
'prompt_management': () => window.location.href = '/prompt-management',
|
'prompt_management': () => window.location.href = '/prompt-management',
|
||||||
'platform_admin': () => window.location.href = '/admin/management',
|
'platform_admin': () => window.location.href = '/admin/management',
|
||||||
'logout': onLogout
|
'logout': onLogout
|
||||||
|
|
@ -312,39 +306,6 @@ const Dashboard = ({ user, onLogout }) => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePasswordChange = async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
if (newPassword !== confirmPassword) {
|
|
||||||
setPasswordChangeError('新密码不匹配');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (newPassword.length < 6) {
|
|
||||||
setPasswordChangeError('新密码长度不能少于6位');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setPasswordChangeError('');
|
|
||||||
setPasswordChangeSuccess('');
|
|
||||||
|
|
||||||
try {
|
|
||||||
await apiClient.put(buildApiUrl(API_ENDPOINTS.USERS.UPDATE_PASSWORD(user.user_id)), {
|
|
||||||
old_password: oldPassword,
|
|
||||||
new_password: newPassword,
|
|
||||||
});
|
|
||||||
setPasswordChangeSuccess('密码修改成功!');
|
|
||||||
// 清空输入框并准备关闭模态框
|
|
||||||
setOldPassword('');
|
|
||||||
setNewPassword('');
|
|
||||||
setConfirmPassword('');
|
|
||||||
setTimeout(() => {
|
|
||||||
setShowChangePasswordModal(false);
|
|
||||||
setPasswordChangeSuccess('');
|
|
||||||
}, 2000);
|
|
||||||
} catch (err) {
|
|
||||||
setPasswordChangeError(err.response?.data?.message || '密码修改失败');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleVoiceprintUpload = async (formData) => {
|
const handleVoiceprintUpload = async (formData) => {
|
||||||
try {
|
try {
|
||||||
await apiClient.post(
|
await apiClient.post(
|
||||||
|
|
@ -430,7 +391,21 @@ const Dashboard = ({ user, onLogout }) => {
|
||||||
<Dropdown
|
<Dropdown
|
||||||
trigger={
|
trigger={
|
||||||
<div className="user-menu-trigger">
|
<div className="user-menu-trigger">
|
||||||
<span className="welcome-text">欢迎,{userInfo?.caption}</span>
|
{user.avatar_url ? (
|
||||||
|
<img
|
||||||
|
src={user.avatar_url.startsWith('http') ? user.avatar_url : `${apiClient.defaults.baseURL || ''}${user.avatar_url}`}
|
||||||
|
alt={user.caption}
|
||||||
|
style={{
|
||||||
|
width: '24px',
|
||||||
|
height: '24px',
|
||||||
|
borderRadius: '50%',
|
||||||
|
objectFit: 'cover',
|
||||||
|
marginRight: '8px',
|
||||||
|
border: '1px solid #e2e8f0'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<span className="welcome-text">欢迎,{user.caption}</span>
|
||||||
<ChevronDown size={20} />
|
<ChevronDown size={20} />
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
@ -449,7 +424,15 @@ const Dashboard = ({ user, onLogout }) => {
|
||||||
<div className="left-column">
|
<div className="left-column">
|
||||||
<div className="user-card">
|
<div className="user-card">
|
||||||
<div className="user-avatar">
|
<div className="user-avatar">
|
||||||
<User size={28} />
|
{userInfo?.avatar_url ? (
|
||||||
|
<img
|
||||||
|
src={userInfo.avatar_url.startsWith('http') ? userInfo.avatar_url : `${apiClient.defaults.baseURL || ''}${userInfo.avatar_url}`}
|
||||||
|
alt={userInfo.caption}
|
||||||
|
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<User size={28} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="user-details">
|
<div className="user-details">
|
||||||
<div className="user-name-row">
|
<div className="user-name-row">
|
||||||
|
|
@ -597,34 +580,6 @@ const Dashboard = ({ user, onLogout }) => {
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showChangePasswordModal && (
|
|
||||||
<div className="modal-overlay">
|
|
||||||
<div className="modal-content">
|
|
||||||
<form onSubmit={handlePasswordChange}>
|
|
||||||
<h2>修改密码</h2>
|
|
||||||
{passwordChangeError && <p className="error-message">{passwordChangeError}</p>}
|
|
||||||
{passwordChangeSuccess && <p className="success-message">{passwordChangeSuccess}</p>}
|
|
||||||
<div className="form-group">
|
|
||||||
<label>旧密码</label>
|
|
||||||
<input type="password" value={oldPassword} onChange={(e) => setOldPassword(e.target.value)} required />
|
|
||||||
</div>
|
|
||||||
<div className="form-group">
|
|
||||||
<label>新密码</label>
|
|
||||||
<input type="password" value={newPassword} onChange={(e) => setNewPassword(e.target.value)} required />
|
|
||||||
</div>
|
|
||||||
<div className="form-group">
|
|
||||||
<label>确认新密码</label>
|
|
||||||
<input type="password" value={confirmPassword} onChange={(e) => setConfirmPassword(e.target.value)} required />
|
|
||||||
</div>
|
|
||||||
<div className="modal-actions">
|
|
||||||
<button type="button" className="btn btn-secondary" onClick={() => setShowChangePasswordModal(false)}>取消</button>
|
|
||||||
<button type="submit" className="btn btn-primary">确认修改</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 声纹采集模态框 */}
|
{/* 声纹采集模态框 */}
|
||||||
<VoiceprintCollectionModal
|
<VoiceprintCollectionModal
|
||||||
isOpen={showVoiceprintModal}
|
isOpen={showVoiceprintModal}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,539 @@
|
||||||
|
/* 外部应用管理样式 */
|
||||||
|
|
||||||
|
/* 主容器 */
|
||||||
|
.client-management {
|
||||||
|
padding: 2rem;
|
||||||
|
background: #f8fafc;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 页面头部 */
|
||||||
|
.page-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-left h1 {
|
||||||
|
font-size: 1.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1e293b;
|
||||||
|
margin: 0 0 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-left .subtitle {
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
background: #667eea;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background: #5568d3;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 过滤栏 */
|
||||||
|
.filter-bar {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-group label {
|
||||||
|
color: #64748b;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-select {
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: white;
|
||||||
|
color: #1e293b;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #667eea;
|
||||||
|
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-box {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 300px;
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-box svg:first-child {
|
||||||
|
color: #94a3b8;
|
||||||
|
margin-right: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-box input {
|
||||||
|
flex: 1;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clear-search {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #94a3b8;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.25rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
transition: color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clear-search:hover {
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 表格容器 */
|
||||||
|
.apps-table-container {
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 表格样式 */
|
||||||
|
.apps-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.apps-table thead {
|
||||||
|
background: #f8fafc;
|
||||||
|
border-bottom: 2px solid #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.apps-table th {
|
||||||
|
padding: 1rem;
|
||||||
|
text-align: left;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #475569;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.apps-table tbody tr {
|
||||||
|
border-bottom: 1px solid #e2e8f0;
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.apps-table tbody tr:hover {
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.apps-table tbody tr:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.apps-table td {
|
||||||
|
padding: 1rem;
|
||||||
|
color: #1e293b;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 3rem;
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 应用名称单元格 */
|
||||||
|
.app-name-cell {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-icon-small {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 6px;
|
||||||
|
object-fit: cover;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 应用类型徽章 */
|
||||||
|
.app-type-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
background-color: #f1f5f9;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-type-badge svg {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 详细信息单元格 */
|
||||||
|
.info-cell {
|
||||||
|
max-width: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.apk-link,
|
||||||
|
.web-link {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
color: #667eea;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
transition: color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.apk-link:hover,
|
||||||
|
.web-link:hover {
|
||||||
|
color: #5568d3;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.web-link {
|
||||||
|
max-width: 280px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 描述单元格 */
|
||||||
|
.description-cell {
|
||||||
|
max-width: 250px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 状态徽章 */
|
||||||
|
.status-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.375rem 0.75rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge.active {
|
||||||
|
background: #d1fae5;
|
||||||
|
color: #065f46;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge.inactive {
|
||||||
|
background: #fee2e2;
|
||||||
|
color: #991b1b;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 操作按钮 */
|
||||||
|
.action-buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
min-width: 36px;
|
||||||
|
min-height: 36px;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon svg {
|
||||||
|
display: block;
|
||||||
|
width: 16px !important;
|
||||||
|
height: 16px !important;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-edit {
|
||||||
|
background: #dbeafe;
|
||||||
|
color: #3b82f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-edit:hover {
|
||||||
|
background: #3b82f6;
|
||||||
|
color: white;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-delete {
|
||||||
|
background: #fee2e2;
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-delete:hover {
|
||||||
|
background: #ef4444;
|
||||||
|
color: white;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 加载状态 */
|
||||||
|
.loading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 400px;
|
||||||
|
font-size: 1.125rem;
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 模态框样式 */
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
backdrop-filter: blur(2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1);
|
||||||
|
width: 90%;
|
||||||
|
max-width: 700px;
|
||||||
|
max-height: 90vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1.5rem 2rem;
|
||||||
|
border-bottom: 1px solid #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1e293b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn {
|
||||||
|
padding: 0.5rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #f1f5f9;
|
||||||
|
color: #64748b;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn:hover {
|
||||||
|
background: #e2e8f0;
|
||||||
|
color: #1e293b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-body {
|
||||||
|
padding: 2rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-footer {
|
||||||
|
padding: 1rem 2rem;
|
||||||
|
border-top: 1px solid #e2e8f0;
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 表单样式 */
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #1e293b;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input,
|
||||||
|
.form-group select,
|
||||||
|
.form-group textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
transition: border-color 0.2s ease;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input:focus,
|
||||||
|
.form-group select:focus,
|
||||||
|
.form-group textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #667eea;
|
||||||
|
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group textarea {
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-group label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-group input[type="checkbox"] {
|
||||||
|
width: auto;
|
||||||
|
margin: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 按钮样式 */
|
||||||
|
.btn-secondary {
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: none;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
background: #f1f5f9;
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* APK上传区域 */
|
||||||
|
.upload-apk-section {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-upload {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.75rem 1.25rem;
|
||||||
|
background-color: #667eea;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-upload:hover {
|
||||||
|
background-color: #5568d3;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-upload:disabled {
|
||||||
|
background-color: #94a3b8;
|
||||||
|
cursor: not-allowed;
|
||||||
|
transform: none;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-hint {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Toast容器 */
|
||||||
|
.toast-container {
|
||||||
|
position: fixed;
|
||||||
|
top: 1rem;
|
||||||
|
right: 1rem;
|
||||||
|
z-index: 9999;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,728 @@
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
Plus,
|
||||||
|
Edit,
|
||||||
|
Trash2,
|
||||||
|
Search,
|
||||||
|
X,
|
||||||
|
Package,
|
||||||
|
Globe,
|
||||||
|
Upload,
|
||||||
|
ExternalLink,
|
||||||
|
Smartphone
|
||||||
|
} from 'lucide-react';
|
||||||
|
import apiClient from '../../utils/apiClient';
|
||||||
|
import { buildApiUrl, API_ENDPOINTS } from '../../config/api';
|
||||||
|
import ConfirmDialog from '../../components/ConfirmDialog';
|
||||||
|
import Toast from '../../components/Toast';
|
||||||
|
import './ExternalAppManagement.css';
|
||||||
|
|
||||||
|
const ExternalAppManagement = ({ user }) => {
|
||||||
|
const [apps, setApps] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [showAppModal, setShowAppModal] = useState(false);
|
||||||
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
|
const [deleteConfirmInfo, setDeleteConfirmInfo] = useState(null);
|
||||||
|
const [selectedApp, setSelectedApp] = useState(null);
|
||||||
|
const [filterAppType, setFilterAppType] = useState('');
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [toasts, setToasts] = useState([]);
|
||||||
|
const [uploadingApk, setUploadingApk] = useState(false);
|
||||||
|
const [uploadingIcon, setUploadingIcon] = useState(false);
|
||||||
|
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
app_name: '',
|
||||||
|
app_type: 'native',
|
||||||
|
app_info: {
|
||||||
|
version_name: '',
|
||||||
|
package_name: '',
|
||||||
|
apk_url: '',
|
||||||
|
web_url: ''
|
||||||
|
},
|
||||||
|
icon_url: '',
|
||||||
|
description: '',
|
||||||
|
sort_order: 0,
|
||||||
|
is_active: true
|
||||||
|
});
|
||||||
|
|
||||||
|
// Toast helper functions
|
||||||
|
const showToast = (message, type = 'info') => {
|
||||||
|
const id = Date.now();
|
||||||
|
setToasts(prev => [...prev, { id, message, type }]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeToast = (id) => {
|
||||||
|
setToasts(prev => prev.filter(toast => toast.id !== id));
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchApps();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchApps = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.EXTERNAL_APPS.LIST));
|
||||||
|
setApps(response.data.apps || []);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取外部应用列表失败:', error);
|
||||||
|
showToast('获取外部应用列表失败', 'error');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddApp = () => {
|
||||||
|
setIsEditing(false);
|
||||||
|
setSelectedApp(null);
|
||||||
|
setFormData({
|
||||||
|
app_name: '',
|
||||||
|
app_type: 'native',
|
||||||
|
app_info: {
|
||||||
|
version_name: '',
|
||||||
|
package_name: '',
|
||||||
|
apk_url: '',
|
||||||
|
web_url: ''
|
||||||
|
},
|
||||||
|
icon_url: '',
|
||||||
|
description: '',
|
||||||
|
sort_order: 0,
|
||||||
|
is_active: true
|
||||||
|
});
|
||||||
|
setShowAppModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditApp = (app) => {
|
||||||
|
setIsEditing(true);
|
||||||
|
setSelectedApp(app);
|
||||||
|
|
||||||
|
// 解析 app_info
|
||||||
|
let appInfo = {
|
||||||
|
version_name: '',
|
||||||
|
package_name: '',
|
||||||
|
apk_url: '',
|
||||||
|
web_url: ''
|
||||||
|
};
|
||||||
|
|
||||||
|
if (typeof app.app_info === 'string') {
|
||||||
|
try {
|
||||||
|
appInfo = { ...appInfo, ...JSON.parse(app.app_info) };
|
||||||
|
} catch (e) {
|
||||||
|
console.error('解析 app_info 失败:', e);
|
||||||
|
}
|
||||||
|
} else if (typeof app.app_info === 'object') {
|
||||||
|
appInfo = { ...appInfo, ...app.app_info };
|
||||||
|
}
|
||||||
|
|
||||||
|
setFormData({
|
||||||
|
app_name: app.app_name || '',
|
||||||
|
app_type: app.app_type || 'native',
|
||||||
|
app_info: appInfo,
|
||||||
|
icon_url: app.icon_url || '',
|
||||||
|
description: app.description || '',
|
||||||
|
sort_order: app.sort_order || 0,
|
||||||
|
is_active: app.is_active === 1 || app.is_active === true
|
||||||
|
});
|
||||||
|
setShowAppModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteApp = async () => {
|
||||||
|
if (!deleteConfirmInfo) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await apiClient.delete(buildApiUrl(API_ENDPOINTS.EXTERNAL_APPS.DELETE(deleteConfirmInfo.id)));
|
||||||
|
showToast('删除成功', 'success');
|
||||||
|
fetchApps();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('删除失败:', error);
|
||||||
|
const errorMsg = error.response?.data?.message || error.message || '删除失败';
|
||||||
|
showToast(errorMsg, 'error');
|
||||||
|
} finally {
|
||||||
|
setDeleteConfirmInfo(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFormChange = (field, value) => {
|
||||||
|
if (field.startsWith('app_info.')) {
|
||||||
|
const infoField = field.split('.')[1];
|
||||||
|
setFormData(prev => ({
|
||||||
|
...prev,
|
||||||
|
app_info: {
|
||||||
|
...prev.app_info,
|
||||||
|
[infoField]: value
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
setFormData(prev => ({ ...prev, [field]: value }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleApkUpload = async (e) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
if (!file.name.endsWith('.apk')) {
|
||||||
|
showToast('请选择APK文件', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setUploadingApk(true);
|
||||||
|
try {
|
||||||
|
const formDataObj = new FormData();
|
||||||
|
formDataObj.append('apk_file', file);
|
||||||
|
|
||||||
|
const response = await apiClient.post(
|
||||||
|
buildApiUrl(API_ENDPOINTS.EXTERNAL_APPS.UPLOAD_APK),
|
||||||
|
formDataObj,
|
||||||
|
{
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' }
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const apkData = response.data;
|
||||||
|
|
||||||
|
// 自动填充表单
|
||||||
|
setFormData(prev => ({
|
||||||
|
...prev,
|
||||||
|
app_name: apkData.app_name || prev.app_name,
|
||||||
|
app_info: {
|
||||||
|
...prev.app_info,
|
||||||
|
version_name: apkData.version_name || '',
|
||||||
|
package_name: apkData.package_name || '',
|
||||||
|
apk_url: apkData.apk_url || ''
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
showToast('APK上传并解析成功', 'success');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('上传APK失败:', error);
|
||||||
|
const errorMsg = error.response?.data?.message || error.message || '上传失败';
|
||||||
|
showToast(errorMsg, 'error');
|
||||||
|
} finally {
|
||||||
|
setUploadingApk(false);
|
||||||
|
e.target.value = ''; // 重置input
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleIconUpload = async (e) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
// 验证文件类型
|
||||||
|
const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp'];
|
||||||
|
if (!allowedTypes.includes(file.type)) {
|
||||||
|
showToast('请选择图片文件(JPG、PNG、GIF、WEBP)', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setUploadingIcon(true);
|
||||||
|
try {
|
||||||
|
const formDataObj = new FormData();
|
||||||
|
formDataObj.append('icon_file', file);
|
||||||
|
|
||||||
|
const response = await apiClient.post(
|
||||||
|
buildApiUrl(API_ENDPOINTS.EXTERNAL_APPS.UPLOAD_ICON),
|
||||||
|
formDataObj,
|
||||||
|
{
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' }
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const iconData = response.data;
|
||||||
|
|
||||||
|
// 更新表单中的图标URL
|
||||||
|
setFormData(prev => ({
|
||||||
|
...prev,
|
||||||
|
icon_url: iconData.icon_url || ''
|
||||||
|
}));
|
||||||
|
|
||||||
|
showToast('图标上传成功', 'success');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('上传图标失败:', error);
|
||||||
|
const errorMsg = error.response?.data?.message || error.message || '上传失败';
|
||||||
|
showToast(errorMsg, 'error');
|
||||||
|
} finally {
|
||||||
|
setUploadingIcon(false);
|
||||||
|
e.target.value = ''; // 重置input
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
// 验证必填字段
|
||||||
|
if (!formData.app_name) {
|
||||||
|
showToast('请输入应用名称', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据应用类型构建 app_info
|
||||||
|
let appInfoObj = {};
|
||||||
|
if (formData.app_type === 'native') {
|
||||||
|
if (!formData.app_info.version_name || !formData.app_info.package_name || !formData.app_info.apk_url) {
|
||||||
|
showToast('请填写完整的原生应用信息', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
appInfoObj = {
|
||||||
|
version_name: formData.app_info.version_name,
|
||||||
|
package_name: formData.app_info.package_name,
|
||||||
|
apk_url: formData.app_info.apk_url
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
if (!formData.app_info.web_url) {
|
||||||
|
showToast('请输入Web应用URL', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
appInfoObj = {
|
||||||
|
version_name: formData.app_info.version_name || '1.0',
|
||||||
|
web_url: formData.app_info.web_url
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitData = {
|
||||||
|
app_name: formData.app_name,
|
||||||
|
app_type: formData.app_type,
|
||||||
|
app_info: JSON.stringify(appInfoObj),
|
||||||
|
icon_url: formData.icon_url,
|
||||||
|
description: formData.description,
|
||||||
|
sort_order: parseInt(formData.sort_order) || 0,
|
||||||
|
is_active: formData.is_active
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (isEditing && selectedApp) {
|
||||||
|
await apiClient.put(
|
||||||
|
buildApiUrl(API_ENDPOINTS.EXTERNAL_APPS.UPDATE(selectedApp.id)),
|
||||||
|
submitData
|
||||||
|
);
|
||||||
|
showToast('更新成功', 'success');
|
||||||
|
} else {
|
||||||
|
await apiClient.post(buildApiUrl(API_ENDPOINTS.EXTERNAL_APPS.CREATE), submitData);
|
||||||
|
showToast('创建成功', 'success');
|
||||||
|
}
|
||||||
|
setShowAppModal(false);
|
||||||
|
fetchApps();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('操作失败:', error);
|
||||||
|
const errorMsg = error.response?.data?.message || error.message || '操作失败';
|
||||||
|
showToast(errorMsg, 'error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 过滤应用列表
|
||||||
|
const filteredApps = apps.filter(app => {
|
||||||
|
const matchesType = !filterAppType || app.app_type === filterAppType;
|
||||||
|
const matchesSearch = !searchQuery ||
|
||||||
|
app.app_name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
|
(app.description && app.description.toLowerCase().includes(searchQuery.toLowerCase()));
|
||||||
|
return matchesType && matchesSearch;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 获取应用信息
|
||||||
|
const getAppInfo = (app) => {
|
||||||
|
if (typeof app.app_info === 'string') {
|
||||||
|
try {
|
||||||
|
return JSON.parse(app.app_info);
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return app.app_info || {};
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="client-management">
|
||||||
|
<div className="loading">加载中...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="client-management">
|
||||||
|
<div className="page-header">
|
||||||
|
<div className="header-left">
|
||||||
|
<h1>外部应用管理</h1>
|
||||||
|
<p className="subtitle">管理Android原生应用和Web应用</p>
|
||||||
|
</div>
|
||||||
|
<button className="btn-primary" onClick={handleAddApp}>
|
||||||
|
<Plus size={20} />
|
||||||
|
添加应用
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 筛选和搜索 */}
|
||||||
|
<div className="filter-bar">
|
||||||
|
<div className="filter-group">
|
||||||
|
<label>应用类型:</label>
|
||||||
|
<select
|
||||||
|
value={filterAppType}
|
||||||
|
onChange={(e) => setFilterAppType(e.target.value)}
|
||||||
|
className="filter-select"
|
||||||
|
>
|
||||||
|
<option value="">全部</option>
|
||||||
|
<option value="native">原生应用</option>
|
||||||
|
<option value="web">Web应用</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="search-box">
|
||||||
|
<Search size={18} />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="搜索应用名称或描述..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
/>
|
||||||
|
{searchQuery && (
|
||||||
|
<button className="clear-search" onClick={() => setSearchQuery('')}>
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 应用列表 */}
|
||||||
|
<div className="apps-table-container">
|
||||||
|
<table className="apps-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>应用名称</th>
|
||||||
|
<th>类型</th>
|
||||||
|
<th>版本</th>
|
||||||
|
<th>详细信息</th>
|
||||||
|
<th>描述</th>
|
||||||
|
<th>排序</th>
|
||||||
|
<th>状态</th>
|
||||||
|
<th>创建时间</th>
|
||||||
|
<th>操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{filteredApps.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan="9" className="empty-state">
|
||||||
|
暂无数据
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
filteredApps.map(app => {
|
||||||
|
const appInfo = getAppInfo(app);
|
||||||
|
return (
|
||||||
|
<tr key={app.id}>
|
||||||
|
<td>
|
||||||
|
<div className="app-name-cell">
|
||||||
|
{app.icon_url && (
|
||||||
|
<img src={app.icon_url} alt="" className="app-icon-small" />
|
||||||
|
)}
|
||||||
|
<span>{app.app_name}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div className="app-type-badge">
|
||||||
|
{app.app_type === 'native' ? (
|
||||||
|
<>
|
||||||
|
<Smartphone size={14} />
|
||||||
|
<span>原生应用</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Globe size={14} />
|
||||||
|
<span>Web应用</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>{appInfo.version_name || '-'}</td>
|
||||||
|
<td className="info-cell">
|
||||||
|
{app.app_type === 'native' ? (
|
||||||
|
<div className="info-content">
|
||||||
|
<div>包名: {appInfo.package_name || '-'}</div>
|
||||||
|
{appInfo.apk_url && (
|
||||||
|
<a href={appInfo.apk_url} target="_blank" rel="noopener noreferrer" className="apk-link">
|
||||||
|
<ExternalLink size={12} />
|
||||||
|
下载APK
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="info-content">
|
||||||
|
{appInfo.web_url && (
|
||||||
|
<a href={appInfo.web_url} target="_blank" rel="noopener noreferrer" className="web-link">
|
||||||
|
<ExternalLink size={12} />
|
||||||
|
{appInfo.web_url}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div className="description-cell">
|
||||||
|
{app.description || '-'}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>{app.sort_order}</td>
|
||||||
|
<td>
|
||||||
|
<span className={`status-badge ${app.is_active ? 'active' : 'inactive'}`}>
|
||||||
|
{app.is_active ? '启用' : '禁用'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>{new Date(app.created_at).toLocaleDateString()}</td>
|
||||||
|
<td>
|
||||||
|
<div className="action-buttons">
|
||||||
|
<button
|
||||||
|
className="btn-icon btn-edit"
|
||||||
|
onClick={() => handleEditApp(app)}
|
||||||
|
title="编辑"
|
||||||
|
>
|
||||||
|
<Edit size={16} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn-icon btn-delete"
|
||||||
|
onClick={() => setDeleteConfirmInfo({ id: app.id, name: app.app_name })}
|
||||||
|
title="删除"
|
||||||
|
>
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 添加/编辑应用模态框 */}
|
||||||
|
{showAppModal && (
|
||||||
|
<div className="modal-overlay" onClick={() => setShowAppModal(false)}>
|
||||||
|
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="modal-header">
|
||||||
|
<h2>{isEditing ? '编辑应用' : '添加应用'}</h2>
|
||||||
|
<button className="close-btn" onClick={() => setShowAppModal(false)}>
|
||||||
|
<X size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div className="modal-body">
|
||||||
|
{/* 应用类型 */}
|
||||||
|
<div className="form-group">
|
||||||
|
<label>应用类型 *</label>
|
||||||
|
<select
|
||||||
|
value={formData.app_type}
|
||||||
|
onChange={(e) => handleFormChange('app_type', e.target.value)}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<option value="native">原生应用(Android APK)</option>
|
||||||
|
<option value="web">Web应用</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 原生应用 - APK上传 */}
|
||||||
|
{formData.app_type === 'native' && (
|
||||||
|
<div className="form-group">
|
||||||
|
<label>上传APK文件</label>
|
||||||
|
<div className="upload-apk-section">
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept=".apk"
|
||||||
|
onChange={handleApkUpload}
|
||||||
|
disabled={uploadingApk}
|
||||||
|
id="apk-upload"
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
/>
|
||||||
|
<label htmlFor="apk-upload" className="btn-upload">
|
||||||
|
<Upload size={16} />
|
||||||
|
{uploadingApk ? '解析中...' : '选择APK文件'}
|
||||||
|
</label>
|
||||||
|
<span className="upload-hint">上传后自动解析包名、版本等信息</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 应用名称 */}
|
||||||
|
<div className="form-group">
|
||||||
|
<label>应用名称 *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.app_name}
|
||||||
|
onChange={(e) => handleFormChange('app_name', e.target.value)}
|
||||||
|
placeholder="请输入应用名称"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 原生应用字段 */}
|
||||||
|
{formData.app_type === 'native' && (
|
||||||
|
<>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>版本名称 *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.app_info.version_name}
|
||||||
|
onChange={(e) => handleFormChange('app_info.version_name', e.target.value)}
|
||||||
|
placeholder="例如: 1.0.0"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>包名 *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.app_info.package_name}
|
||||||
|
onChange={(e) => handleFormChange('app_info.package_name', e.target.value)}
|
||||||
|
placeholder="例如: com.example.app"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>APK下载链接 *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.app_info.apk_url}
|
||||||
|
onChange={(e) => handleFormChange('app_info.apk_url', e.target.value)}
|
||||||
|
placeholder="APK文件的下载URL"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Web应用字段 */}
|
||||||
|
{formData.app_type === 'web' && (
|
||||||
|
<>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>版本名称</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.app_info.version_name}
|
||||||
|
onChange={(e) => handleFormChange('app_info.version_name', e.target.value)}
|
||||||
|
placeholder="例如: 1.0(可选)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Web应用URL *</label>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
value={formData.app_info.web_url}
|
||||||
|
onChange={(e) => handleFormChange('app_info.web_url', e.target.value)}
|
||||||
|
placeholder="https://example.com"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 应用图标 */}
|
||||||
|
<div className="form-group">
|
||||||
|
<label>应用图标</label>
|
||||||
|
<div className="upload-apk-section">
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/jpeg,image/jpg,image/png,image/gif,image/webp"
|
||||||
|
onChange={handleIconUpload}
|
||||||
|
disabled={uploadingIcon}
|
||||||
|
id="icon-upload"
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
/>
|
||||||
|
<label htmlFor="icon-upload" className="btn-upload">
|
||||||
|
<Upload size={16} />
|
||||||
|
{uploadingIcon ? '上传中...' : '选择图标'}
|
||||||
|
</label>
|
||||||
|
<span className="upload-hint">支持JPG、PNG、GIF、WEBP格式</span>
|
||||||
|
</div>
|
||||||
|
{formData.icon_url && (
|
||||||
|
<div style={{ marginTop: '0.75rem' }}>
|
||||||
|
<img
|
||||||
|
src={formData.icon_url}
|
||||||
|
alt="应用图标预览"
|
||||||
|
style={{
|
||||||
|
width: '64px',
|
||||||
|
height: '64px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
objectFit: 'cover',
|
||||||
|
border: '1px solid #e2e8f0'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 应用描述 */}
|
||||||
|
<div className="form-group">
|
||||||
|
<label>应用描述</label>
|
||||||
|
<textarea
|
||||||
|
value={formData.description}
|
||||||
|
onChange={(e) => handleFormChange('description', e.target.value)}
|
||||||
|
placeholder="请输入应用描述"
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 排序 */}
|
||||||
|
<div className="form-group">
|
||||||
|
<label>排序顺序</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={formData.sort_order}
|
||||||
|
onChange={(e) => handleFormChange('sort_order', e.target.value)}
|
||||||
|
placeholder="数字越小越靠前"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 启用状态 */}
|
||||||
|
<div className="form-group checkbox-group">
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={formData.is_active}
|
||||||
|
onChange={(e) => handleFormChange('is_active', e.target.checked)}
|
||||||
|
/>
|
||||||
|
启用此应用
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="modal-footer">
|
||||||
|
<button type="button" className="btn-secondary" onClick={() => setShowAppModal(false)}>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button type="submit" className="btn-primary">
|
||||||
|
{isEditing ? '更新' : '创建'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 删除确认对话框 */}
|
||||||
|
{deleteConfirmInfo && (
|
||||||
|
<ConfirmDialog
|
||||||
|
isOpen={true}
|
||||||
|
title="确认删除"
|
||||||
|
message={`确定要删除应用 "${deleteConfirmInfo.name}" 吗?此操作无法撤销。`}
|
||||||
|
onConfirm={handleDeleteApp}
|
||||||
|
onClose={() => setDeleteConfirmInfo(null)}
|
||||||
|
confirmText="删除"
|
||||||
|
cancelText="取消"
|
||||||
|
type="danger"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Toast 通知 */}
|
||||||
|
<div className="toast-container">
|
||||||
|
{toasts.map(toast => (
|
||||||
|
<Toast
|
||||||
|
key={toast.id}
|
||||||
|
message={toast.message}
|
||||||
|
type={toast.type}
|
||||||
|
onClose={() => removeToast(toast.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ExternalAppManagement;
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import React, { useState, useEffect, useRef } from 'react';
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
import apiClient from '../../utils/apiClient';
|
import apiClient from '../../utils/apiClient';
|
||||||
import { buildApiUrl, API_ENDPOINTS } from '../../config/api';
|
import { buildApiUrl, API_ENDPOINTS } from '../../config/api';
|
||||||
import { Plus, ChevronLeft, ChevronRight, Trash2, BookText, FileText, Star, Save, Check, Edit2, X } from 'lucide-react';
|
import { Plus, ChevronLeft, ChevronRight, Trash2, BookText, FileText, Star, Save, Check, Edit2, X, MessageSquare, Library } from 'lucide-react';
|
||||||
import './PromptManagement.css';
|
import './PromptManagement.css';
|
||||||
import ConfirmDialog from '../../components/ConfirmDialog';
|
import ConfirmDialog from '../../components/ConfirmDialog';
|
||||||
import FormModal from '../../components/FormModal';
|
import FormModal from '../../components/FormModal';
|
||||||
|
|
@ -10,8 +10,8 @@ import MarkdownEditor from '../../components/MarkdownEditor';
|
||||||
import Breadcrumb from '../../components/Breadcrumb';
|
import Breadcrumb from '../../components/Breadcrumb';
|
||||||
|
|
||||||
const TASK_TYPES = {
|
const TASK_TYPES = {
|
||||||
MEETING_TASK: { label: '会议任务', icon: '📝' },
|
MEETING_TASK: { label: '会议任务', icon: <MessageSquare size={18} /> },
|
||||||
KNOWLEDGE_TASK: { label: '知识库任务', icon: '📚' }
|
KNOWLEDGE_TASK: { label: '知识库任务', icon: <Library size={18} /> }
|
||||||
};
|
};
|
||||||
|
|
||||||
const PromptManagement = () => {
|
const PromptManagement = () => {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue