增加了外部应用

main
mula.liu 2026-01-16 10:08:11 +08:00
parent 9d85301a50
commit 1a4d97f1e9
12 changed files with 1921 additions and 79 deletions

View File

@ -14,6 +14,7 @@ import PromptManagementPage from './pages/PromptManagementPage';
import KnowledgeBasePage from './pages/KnowledgeBasePage';
import EditKnowledgeBase from './pages/EditKnowledgeBase';
import ClientDownloadPage from './pages/ClientDownloadPage';
import AccountSettings from './pages/AccountSettings';
import './App.css';
function App() {
@ -100,6 +101,9 @@ function App() {
<Route path="/knowledge-base/edit/:kb_id" element={
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="/meetings/preview/:meeting_id" element={<MeetingPreview />} />
</Routes>

View File

@ -82,6 +82,15 @@ const API_CONFIG = {
DELETE: (id) => `/api/clients/${id}`,
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: {
TYPES: '/api/dict/types',
BY_TYPE: (dictType) => `/api/dict/${dictType}`,

View File

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

View File

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

View File

@ -472,3 +472,52 @@
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;
}

View File

@ -1,5 +1,5 @@
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 { buildApiUrl, API_ENDPOINTS } from '../config/api';
import Dropdown from '../components/Dropdown';
@ -33,7 +33,7 @@ const getTaskTypeText = (type) => TASK_TYPE_TEXT_MAP[type] || type;
//
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: 'platform_admin', menu_name: '平台管理', menu_type: 'link', menu_url: '/admin' },
{ menu_code: 'logout', menu_name: '退出登录', menu_type: 'action' }
@ -64,6 +64,7 @@ const AdminDashboard = ({ user, onLogout }) => {
const getMenuItemConfig = (menu) => {
const iconMap = {
'change_password': <KeyRound size={16} />,
'account_settings': <UserCog size={16} />,
'prompt_management': <BookText size={16} />,
'platform_admin': <Shield size={16} />,
'logout': <LogOut size={16} />
@ -248,7 +249,21 @@ const AdminDashboard = ({ user, onLogout }) => {
<Dropdown
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>
<ChevronDown size={16} />
</div>

View File

@ -1,8 +1,9 @@
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 UserManagement from './admin/UserManagement';
import ClientManagement from './ClientManagement';
import ExternalAppManagement from './admin/ExternalAppManagement';
import PermissionManagement from './admin/PermissionManagement';
import DictManagement from './admin/DictManagement';
import HotWordManagement from './admin/HotWordManagement';
@ -38,6 +39,11 @@ const AdminManagement = () => {
label: <span><Smartphone size={16} /> 客户端管理</span>,
children: <ClientManagement />,
},
{
key: 'externalAppManagement',
label: <span><Package size={16} /> 外部应用管理</span>,
children: <ExternalAppManagement />,
},
];
return (

View File

@ -184,6 +184,8 @@
justify-content: center;
color: white;
flex-shrink: 0;
overflow: hidden;
border: 2px solid #e2e8f0;
}
.user-details {

View File

@ -1,5 +1,5 @@
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 { Link } from 'react-router-dom';
import { buildApiUrl, API_ENDPOINTS } from '../config/api';
@ -26,12 +26,6 @@ const Dashboard = ({ user, onLogout }) => {
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
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);
@ -126,7 +120,7 @@ const Dashboard = ({ user, onLogout }) => {
// fallback
const getDefaultMenus = () => {
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: 'logout', menu_name: '退出登录', menu_type: 'action', sort_order: 99 }
];
@ -150,13 +144,13 @@ const Dashboard = ({ user, onLogout }) => {
const getMenuItemConfig = (menu) => {
const iconMap = {
'change_password': <KeyRound size={16} />,
'account_settings': <UserCog 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
@ -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) => {
try {
await apiClient.post(
@ -430,7 +391,21 @@ const Dashboard = ({ user, onLogout }) => {
<Dropdown
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} />
</div>
}
@ -449,7 +424,15 @@ const Dashboard = ({ user, onLogout }) => {
<div className="left-column">
<div className="user-card">
<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 className="user-details">
<div className="user-name-row">
@ -597,34 +580,6 @@ const Dashboard = ({ user, onLogout }) => {
</section>
</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
isOpen={showVoiceprintModal}

View File

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

View File

@ -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">支持JPGPNGGIFWEBP格式</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;

View File

@ -1,7 +1,7 @@
import React, { useState, useEffect, useRef } from 'react';
import apiClient from '../../utils/apiClient';
import { buildApiUrl, API_ENDPOINTS } from '../../config/api';
import { Plus, ChevronLeft, ChevronRight, Trash2, BookText, FileText, Star, Save, Check, Edit2, X } from 'lucide-react';
import { Plus, ChevronLeft, ChevronRight, Trash2, BookText, FileText, Star, Save, Check, Edit2, X, MessageSquare, Library } from 'lucide-react';
import './PromptManagement.css';
import ConfirmDialog from '../../components/ConfirmDialog';
import FormModal from '../../components/FormModal';
@ -10,8 +10,8 @@ import MarkdownEditor from '../../components/MarkdownEditor';
import Breadcrumb from '../../components/Breadcrumb';
const TASK_TYPES = {
MEETING_TASK: { label: '会议任务', icon: '📝' },
KNOWLEDGE_TASK: { label: '知识库任务', icon: '📚' }
MEETING_TASK: { label: '会议任务', icon: <MessageSquare size={18} /> },
KNOWLEDGE_TASK: { label: '知识库任务', icon: <Library size={18} /> }
};
const PromptManagement = () => {