imetting/frontend/src/pages/admin/ExternalAppManagement.jsx

743 lines
22 KiB
React
Raw Normal View History

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 FormModal from '../../components/FormModal';
import ToggleSwitch from '../../components/ToggleSwitch';
import ListTable from '../../components/ListTable';
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 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');
}
};
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 || []);
} 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 () => {
// 验证必填字段
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 || {};
};
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>
)
}
];
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>
{/* 应用列表 */}
<ListTable
columns={columns}
data={filteredApps}
loading={loading}
rowKey="id"
emptyMessage="暂无外部应用"
showPagination={false}
/>
{/* 添加/编辑应用模态框 */}
<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>
{/* 原生应用 - 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>
</FormModal>
{/* 删除确认对话框 */}
<ConfirmDialog
isOpen={!!deleteConfirmInfo}
onClose={() => setDeleteConfirmInfo(null)}
onConfirm={handleDeleteApp}
title="确认删除"
message={`确定要删除应用 "${deleteConfirmInfo?.name}" 吗?此操作无法撤销。`}
confirmText="删除"
cancelText="取消"
type="danger"
/>
{/* Toast 通知 */}
{toasts.map(toast => (
<Toast
key={toast.id}
message={toast.message}
type={toast.type}
onClose={() => removeToast(toast.id)}
/>
))}
</div>
);
};
export default ExternalAppManagement;