From 1a4d97f1e95bb0124a086638e53f5254766d78d3 Mon Sep 17 00:00:00 2001 From: "mula.liu" Date: Fri, 16 Jan 2026 10:08:11 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E4=BA=86=E5=A4=96=E9=83=A8?= =?UTF-8?q?=E5=BA=94=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/App.jsx | 4 + src/config/api.js | 9 + src/pages/AccountSettings.css | 236 +++++++ src/pages/AccountSettings.jsx | 299 +++++++++ src/pages/AdminDashboard.css | 49 ++ src/pages/AdminDashboard.jsx | 21 +- src/pages/AdminManagement.jsx | 8 +- src/pages/Dashboard.css | 2 + src/pages/Dashboard.jsx | 99 +-- src/pages/admin/ExternalAppManagement.css | 539 ++++++++++++++++ src/pages/admin/ExternalAppManagement.jsx | 728 ++++++++++++++++++++++ src/pages/admin/PromptManagement.jsx | 6 +- 12 files changed, 1921 insertions(+), 79 deletions(-) create mode 100644 src/pages/AccountSettings.css create mode 100644 src/pages/AccountSettings.jsx create mode 100644 src/pages/admin/ExternalAppManagement.css create mode 100644 src/pages/admin/ExternalAppManagement.jsx diff --git a/src/App.jsx b/src/App.jsx index 4522369..66b856d 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -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() { : } /> + : + } /> } /> } /> diff --git a/src/config/api.js b/src/config/api.js index 71c83e7..6be55dd 100644 --- a/src/config/api.js +++ b/src/config/api.js @@ -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}`, diff --git a/src/pages/AccountSettings.css b/src/pages/AccountSettings.css new file mode 100644 index 0000000..f697875 --- /dev/null +++ b/src/pages/AccountSettings.css @@ -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; +} diff --git a/src/pages/AccountSettings.jsx b/src/pages/AccountSettings.jsx new file mode 100644 index 0000000..480a7cb --- /dev/null +++ b/src/pages/AccountSettings.jsx @@ -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 ( +
+ + +
+
+
+ + +
+ +
+ {activeTab === 'profile' && ( +
+
+

基本信息

+ +
+
+ {(previewAvatar || profileData.avatar_url) ? ( + Avatar + ) : ( +
+ +
+ )} + +
+
+

支持 JPG, PNG, GIF

+
+
+ +
+ + +
+ +
+ + setProfileData({ ...profileData, caption: e.target.value })} + required + /> +
+ +
+ + setProfileData({ ...profileData, email: e.target.value })} + required + /> +
+ +
+ +
+
+
+ )} + + {activeTab === 'security' && ( +
+
+

修改密码

+ +
+ + setPasswordData({ ...passwordData, old_password: e.target.value })} + required + /> +
+ +
+ + setPasswordData({ ...passwordData, new_password: e.target.value })} + required + minLength={6} + /> +
+ +
+ + setPasswordData({ ...passwordData, confirm_password: e.target.value })} + required + minLength={6} + /> +
+ +
+ +
+
+
+ )} +
+
+
+ +
+ {toasts.map(toast => ( + removeToast(toast.id)} + /> + ))} +
+
+ ); +}; + +export default AccountSettings; diff --git a/src/pages/AdminDashboard.css b/src/pages/AdminDashboard.css index bf269d4..57ca550 100644 --- a/src/pages/AdminDashboard.css +++ b/src/pages/AdminDashboard.css @@ -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; +} \ No newline at end of file diff --git a/src/pages/AdminDashboard.jsx b/src/pages/AdminDashboard.jsx index 1576a7b..6bf14e0 100644 --- a/src/pages/AdminDashboard.jsx +++ b/src/pages/AdminDashboard.jsx @@ -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': , + 'account_settings': , 'prompt_management': , 'platform_admin': , 'logout': @@ -248,7 +249,21 @@ const AdminDashboard = ({ user, onLogout }) => { - + {user.avatar_url ? ( + {user.caption} + ) : ( + + )} {user.caption} diff --git a/src/pages/AdminManagement.jsx b/src/pages/AdminManagement.jsx index 3de1130..0546ea8 100644 --- a/src/pages/AdminManagement.jsx +++ b/src/pages/AdminManagement.jsx @@ -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: 客户端管理, children: , }, + { + key: 'externalAppManagement', + label: 外部应用管理, + children: , + }, ]; return ( diff --git a/src/pages/Dashboard.css b/src/pages/Dashboard.css index d1b27dc..3e005b5 100644 --- a/src/pages/Dashboard.css +++ b/src/pages/Dashboard.css @@ -184,6 +184,8 @@ justify-content: center; color: white; flex-shrink: 0; + overflow: hidden; + border: 2px solid #e2e8f0; } .user-details { diff --git a/src/pages/Dashboard.jsx b/src/pages/Dashboard.jsx index 67a0a24..b6ad2e0 100644 --- a/src/pages/Dashboard.jsx +++ b/src/pages/Dashboard.jsx @@ -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': , + 'account_settings': , 'prompt_management': , 'platform_admin': , 'logout': }; 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 }) => { - 欢迎,{userInfo?.caption} + {user.avatar_url ? ( + {user.caption} + ) : null} + 欢迎,{user.caption} } @@ -449,7 +424,15 @@ const Dashboard = ({ user, onLogout }) => {
- + {userInfo?.avatar_url ? ( + {userInfo.caption} + ) : ( + + )}
@@ -597,34 +580,6 @@ const Dashboard = ({ user, onLogout }) => {
- {showChangePasswordModal && ( -
-
-
-

修改密码

- {passwordChangeError &&

{passwordChangeError}

} - {passwordChangeSuccess &&

{passwordChangeSuccess}

} -
- - setOldPassword(e.target.value)} required /> -
-
- - setNewPassword(e.target.value)} required /> -
-
- - setConfirmPassword(e.target.value)} required /> -
-
- - -
-
-
-
- )} - {/* 声纹采集模态框 */} { + 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 ( +
+
加载中...
+
+ ); + } + + return ( +
+
+
+

外部应用管理

+

管理Android原生应用和Web应用

+
+ +
+ + {/* 筛选和搜索 */} +
+
+ + +
+ +
+ + setSearchQuery(e.target.value)} + /> + {searchQuery && ( + + )} +
+
+ + {/* 应用列表 */} +
+ + + + + + + + + + + + + + + + {filteredApps.length === 0 ? ( + + + + ) : ( + filteredApps.map(app => { + const appInfo = getAppInfo(app); + return ( + + + + + + + + + + + + ); + }) + )} + +
应用名称类型版本详细信息描述排序状态创建时间操作
+ 暂无数据 +
+
+ {app.icon_url && ( + + )} + {app.app_name} +
+
+
+ {app.app_type === 'native' ? ( + <> + + 原生应用 + + ) : ( + <> + + Web应用 + + )} +
+
{appInfo.version_name || '-'} + {app.app_type === 'native' ? ( +
+
包名: {appInfo.package_name || '-'}
+ {appInfo.apk_url && ( + + + 下载APK + + )} +
+ ) : ( +
+ {appInfo.web_url && ( + + + {appInfo.web_url} + + )} +
+ )} +
+
+ {app.description || '-'} +
+
{app.sort_order} + + {app.is_active ? '启用' : '禁用'} + + {new Date(app.created_at).toLocaleDateString()} +
+ + +
+
+
+ + {/* 添加/编辑应用模态框 */} + {showAppModal && ( +
setShowAppModal(false)}> +
e.stopPropagation()}> +
+

{isEditing ? '编辑应用' : '添加应用'}

+ +
+
+
+ {/* 应用类型 */} +
+ + +
+ + {/* 原生应用 - APK上传 */} + {formData.app_type === 'native' && ( +
+ +
+ + + 上传后自动解析包名、版本等信息 +
+
+ )} + + {/* 应用名称 */} +
+ + handleFormChange('app_name', e.target.value)} + placeholder="请输入应用名称" + required + /> +
+ + {/* 原生应用字段 */} + {formData.app_type === 'native' && ( + <> +
+ + handleFormChange('app_info.version_name', e.target.value)} + placeholder="例如: 1.0.0" + required + /> +
+
+ + handleFormChange('app_info.package_name', e.target.value)} + placeholder="例如: com.example.app" + required + /> +
+
+ + handleFormChange('app_info.apk_url', e.target.value)} + placeholder="APK文件的下载URL" + required + /> +
+ + )} + + {/* Web应用字段 */} + {formData.app_type === 'web' && ( + <> +
+ + handleFormChange('app_info.version_name', e.target.value)} + placeholder="例如: 1.0(可选)" + /> +
+
+ + handleFormChange('app_info.web_url', e.target.value)} + placeholder="https://example.com" + required + /> +
+ + )} + + {/* 应用图标 */} +
+ +
+ + + 支持JPG、PNG、GIF、WEBP格式 +
+ {formData.icon_url && ( +
+ 应用图标预览 +
+ )} +
+ + {/* 应用描述 */} +
+ +