2026-01-19 11:03:08 +00:00
|
|
|
|
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';
|
2026-02-12 07:34:12 +00:00
|
|
|
|
import FormModal from '../../components/FormModal';
|
|
|
|
|
|
import ToggleSwitch from '../../components/ToggleSwitch';
|
|
|
|
|
|
import ListTable from '../../components/ListTable';
|
2026-01-19 11:03:08 +00:00
|
|
|
|
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();
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
2026-02-12 07:34:12 +00:00
|
|
|
|
const handleToggleAppStatus = async (app) => {
|
|
|
|
|
|
const newActive = app.is_active ? false : true;
|
|
|
|
|
|
try {
|
|
|
|
|
|
await apiClient.put(
|
|
|
|
|
|
buildApiUrl(API_ENDPOINTS.EXTERNAL_APPS.UPDATE(app.id)),
|
|
|
|
|
|
{ is_active: newActive }
|
|
|
|
|
|
);
|
|
|
|
|
|
setApps(prev => prev.map(a =>
|
|
|
|
|
|
a.id === app.id ? { ...a, is_active: newActive } : a
|
|
|
|
|
|
));
|
|
|
|
|
|
showToast(`已${newActive ? '启用' : '禁用'}应用`, 'success');
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
showToast('状态更新失败', 'error');
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-01-19 11:03:08 +00:00
|
|
|
|
const fetchApps = async () => {
|
|
|
|
|
|
setLoading(true);
|
|
|
|
|
|
try {
|
|
|
|
|
|
const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.EXTERNAL_APPS.LIST));
|
2026-01-19 13:25:14 +00:00
|
|
|
|
setApps(response.data || []);
|
2026-01-19 11:03:08 +00:00
|
|
|
|
} 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
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-02-12 07:34:12 +00:00
|
|
|
|
const handleSubmit = async () => {
|
2026-01-19 11:03:08 +00:00
|
|
|
|
// 验证必填字段
|
|
|
|
|
|
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 || {};
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-02-12 07:34:12 +00:00
|
|
|
|
const columns = [
|
|
|
|
|
|
{
|
|
|
|
|
|
title: '应用名称',
|
|
|
|
|
|
key: 'app_name',
|
|
|
|
|
|
render: (app) => (
|
|
|
|
|
|
<div className="app-name-cell">
|
|
|
|
|
|
{app.icon_url && (
|
|
|
|
|
|
<img src={app.icon_url} alt="" className="app-icon-small" />
|
|
|
|
|
|
)}
|
|
|
|
|
|
<span>{app.app_name}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
title: '类型',
|
|
|
|
|
|
key: 'app_type',
|
|
|
|
|
|
render: (app) => (
|
|
|
|
|
|
<div className="app-type-badge">
|
|
|
|
|
|
{app.app_type === 'native' ? (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<Smartphone size={14} />
|
|
|
|
|
|
<span>原生应用</span>
|
|
|
|
|
|
</>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<Globe size={14} />
|
|
|
|
|
|
<span>Web应用</span>
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
title: '版本',
|
|
|
|
|
|
key: 'version',
|
|
|
|
|
|
render: (app) => getAppInfo(app).version_name || '-'
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
title: '详细信息',
|
|
|
|
|
|
key: 'info',
|
|
|
|
|
|
render: (app) => {
|
|
|
|
|
|
const appInfo = getAppInfo(app);
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div 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>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
title: '描述',
|
|
|
|
|
|
dataIndex: 'description',
|
|
|
|
|
|
key: 'description',
|
|
|
|
|
|
render: (item) => <div className="description-cell">{item.description || '-'}</div>
|
|
|
|
|
|
},
|
|
|
|
|
|
{ title: '排序', dataIndex: 'sort_order', key: 'sort_order' },
|
|
|
|
|
|
{
|
|
|
|
|
|
title: '状态',
|
|
|
|
|
|
key: 'status',
|
|
|
|
|
|
render: (app) => (
|
|
|
|
|
|
<ToggleSwitch
|
|
|
|
|
|
checked={app.is_active}
|
|
|
|
|
|
onChange={() => handleToggleAppStatus(app)}
|
|
|
|
|
|
size="medium"
|
|
|
|
|
|
/>
|
|
|
|
|
|
)
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
title: '创建时间',
|
|
|
|
|
|
dataIndex: 'created_at',
|
|
|
|
|
|
key: 'created_at',
|
|
|
|
|
|
render: (item) => new Date(item.created_at).toLocaleDateString()
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
title: '操作',
|
|
|
|
|
|
key: 'action',
|
|
|
|
|
|
fixed: 'right',
|
|
|
|
|
|
width: '110px',
|
|
|
|
|
|
render: (app) => (
|
|
|
|
|
|
<div className="action-buttons" style={{ display: 'flex', gap: '8px', flexWrap: 'nowrap' }}>
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={() => handleEditApp(app)}
|
|
|
|
|
|
title="编辑"
|
|
|
|
|
|
style={{ width: '32px', height: '32px', padding: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', border: '1px solid #e2e8f0', borderRadius: '6px', cursor: 'pointer', background: 'white', color: '#3b82f6' }}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Edit size={16} />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={() => setDeleteConfirmInfo({ id: app.id, name: app.app_name })}
|
|
|
|
|
|
title="删除"
|
|
|
|
|
|
style={{ width: '32px', height: '32px', padding: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', border: '1px solid #e2e8f0', borderRadius: '6px', cursor: 'pointer', background: 'white', color: '#ef4444' }}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Trash2 size={16} />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
];
|
2026-01-19 11:03:08 +00:00
|
|
|
|
|
|
|
|
|
|
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>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 应用列表 */}
|
2026-02-12 07:34:12 +00:00
|
|
|
|
<ListTable
|
|
|
|
|
|
columns={columns}
|
|
|
|
|
|
data={filteredApps}
|
|
|
|
|
|
loading={loading}
|
|
|
|
|
|
rowKey="id"
|
|
|
|
|
|
emptyMessage="暂无外部应用"
|
|
|
|
|
|
showPagination={false}
|
|
|
|
|
|
/>
|
2026-01-19 11:03:08 +00:00
|
|
|
|
|
|
|
|
|
|
{/* 添加/编辑应用模态框 */}
|
2026-02-12 07:34:12 +00:00
|
|
|
|
<FormModal
|
|
|
|
|
|
isOpen={showAppModal}
|
|
|
|
|
|
onClose={() => setShowAppModal(false)}
|
|
|
|
|
|
title={isEditing ? '编辑应用' : '添加应用'}
|
|
|
|
|
|
size="medium"
|
|
|
|
|
|
actions={
|
|
|
|
|
|
<>
|
|
|
|
|
|
<button type="button" className="btn btn-secondary" onClick={() => setShowAppModal(false)}>
|
|
|
|
|
|
取消
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button type="button" className="btn btn-primary" onClick={handleSubmit}>
|
|
|
|
|
|
{isEditing ? '更新' : '创建'}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</>
|
|
|
|
|
|
}
|
|
|
|
|
|
>
|
|
|
|
|
|
{/* 应用类型 */}
|
|
|
|
|
|
<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>
|
2026-01-19 11:03:08 +00:00
|
|
|
|
|
2026-02-12 07:34:12 +00:00
|
|
|
|
{/* 原生应用 - 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>
|
|
|
|
|
|
)}
|
2026-01-19 11:03:08 +00:00
|
|
|
|
|
2026-02-12 07:34:12 +00:00
|
|
|
|
{/* 应用名称 */}
|
|
|
|
|
|
<div className="form-group">
|
|
|
|
|
|
<label>应用名称 *</label>
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="text"
|
|
|
|
|
|
value={formData.app_name}
|
|
|
|
|
|
onChange={(e) => handleFormChange('app_name', e.target.value)}
|
|
|
|
|
|
placeholder="请输入应用名称"
|
|
|
|
|
|
required
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
2026-01-19 11:03:08 +00:00
|
|
|
|
|
2026-02-12 07:34:12 +00:00
|
|
|
|
{/* 原生应用字段 */}
|
|
|
|
|
|
{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>
|
2026-01-19 11:03:08 +00:00
|
|
|
|
</div>
|
2026-02-12 07:34:12 +00:00
|
|
|
|
{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>
|
|
|
|
|
|
)}
|
2026-01-19 11:03:08 +00:00
|
|
|
|
</div>
|
2026-02-12 07:34:12 +00:00
|
|
|
|
|
|
|
|
|
|
{/* 应用描述 */}
|
|
|
|
|
|
<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>
|
|
|
|
|
|
</FormModal>
|
2026-01-19 11:03:08 +00:00
|
|
|
|
|
|
|
|
|
|
{/* 删除确认对话框 */}
|
2026-02-12 07:34:12 +00:00
|
|
|
|
<ConfirmDialog
|
|
|
|
|
|
isOpen={!!deleteConfirmInfo}
|
|
|
|
|
|
onClose={() => setDeleteConfirmInfo(null)}
|
|
|
|
|
|
onConfirm={handleDeleteApp}
|
|
|
|
|
|
title="确认删除"
|
|
|
|
|
|
message={`确定要删除应用 "${deleteConfirmInfo?.name}" 吗?此操作无法撤销。`}
|
|
|
|
|
|
confirmText="删除"
|
|
|
|
|
|
cancelText="取消"
|
|
|
|
|
|
type="danger"
|
|
|
|
|
|
/>
|
2026-01-19 11:03:08 +00:00
|
|
|
|
|
|
|
|
|
|
{/* Toast 通知 */}
|
2026-02-12 07:34:12 +00:00
|
|
|
|
{toasts.map(toast => (
|
|
|
|
|
|
<Toast
|
|
|
|
|
|
key={toast.id}
|
|
|
|
|
|
message={toast.message}
|
|
|
|
|
|
type={toast.type}
|
|
|
|
|
|
onClose={() => removeToast(toast.id)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
))}
|
2026-01-19 11:03:08 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
export default ExternalAppManagement;
|