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

563 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

import React, { useCallback, useEffect, useMemo, useState } from 'react';
import {
Table,
Button,
Input,
Space,
Drawer,
Form,
Select,
App,
Tooltip,
Switch,
InputNumber,
Upload,
Tag,
Avatar,
Row,
Col,
Typography,
} from 'antd';
import {
PlusOutlined,
SaveOutlined,
DeleteOutlined,
EditOutlined,
SearchOutlined,
GlobalOutlined,
RobotOutlined,
UploadOutlined,
LinkOutlined,
AppstoreOutlined,
ReloadOutlined,
ExportOutlined,
PictureOutlined,
CheckCircleOutlined,
BlockOutlined,
} from '@ant-design/icons';
import httpService from '../../services/httpService';
import { buildApiUrl, API_ENDPOINTS } from '../../config/api';
import AdminModuleShell from '../../components/AdminModuleShell';
import ActionButton from '../../components/ActionButton';
import useSystemPageSize from '../../hooks/useSystemPageSize';
const { Text } = Typography;
const { TextArea } = Input;
const APP_TYPE_OPTIONS = [
{ label: '全部类型', value: 'all' },
{ label: '原生应用', value: 'native' },
{ label: 'Web 应用', value: 'web' },
];
const STATUS_OPTIONS = [
{ label: '全部状态', value: 'all' },
{ label: '已启用', value: 'active' },
{ label: '已停用', value: 'inactive' },
];
const parseAppInfo = (appInfo) => {
if (!appInfo) return {};
if (typeof appInfo === 'object') return appInfo;
try {
return JSON.parse(appInfo);
} catch {
return {};
}
};
const getAppEntryUrl = (app) => {
const info = parseAppInfo(app.app_info);
return app.app_type === 'native' ? info.apk_url : info.web_url;
};
const ExternalAppManagement = () => {
const { message, modal } = App.useApp();
const [form] = Form.useForm();
const pageSize = useSystemPageSize(10);
const [apps, setApps] = useState([]);
const [loading, setLoading] = useState(true);
const [showDrawer, setShowDrawer] = useState(false);
const [isEditing, setIsEditing] = useState(false);
const [selectedApp, setSelectedApp] = useState(null);
const [filterAppType, setFilterAppType] = useState('all');
const [filterStatus, setFilterStatus] = useState('all');
const [searchQuery, setSearchQuery] = useState('');
const [uploading, setUploading] = useState(false);
const fetchApps = useCallback(async () => {
setLoading(true);
try {
const response = await httpService.get(buildApiUrl(API_ENDPOINTS.EXTERNAL_APPS.LIST));
if (response.code === '200') {
setApps(response.data || []);
}
} catch {
message.error('获取外部应用列表失败');
} finally {
setLoading(false);
}
}, [message]);
useEffect(() => {
fetchApps();
}, [fetchApps]);
const handleOpenModal = (app = null) => {
if (app) {
setIsEditing(true);
setSelectedApp(app);
form.setFieldsValue({
...app,
app_info: parseAppInfo(app.app_info),
is_active: app.is_active === 1 || app.is_active === true,
});
} else {
setIsEditing(false);
setSelectedApp(null);
form.resetFields();
form.setFieldsValue({
app_type: 'native',
is_active: true,
sort_order: 0,
app_info: {},
});
}
setShowDrawer(true);
};
const handleSave = async () => {
try {
const values = await form.validateFields();
const payload = {
...values,
app_info: JSON.stringify(values.app_info || {}),
is_active: values.is_active ? 1 : 0,
};
if (isEditing) {
await httpService.put(buildApiUrl(API_ENDPOINTS.EXTERNAL_APPS.UPDATE(selectedApp.id)), payload);
message.success('应用更新成功');
} else {
await httpService.post(buildApiUrl(API_ENDPOINTS.EXTERNAL_APPS.CREATE), payload);
message.success('应用创建成功');
}
setShowDrawer(false);
fetchApps();
} catch (error) {
if (!error.errorFields) {
message.error('保存失败');
}
}
};
const handleDelete = (item) => {
modal.confirm({
title: '删除外部应用',
content: `确定要删除应用“${item.app_name}”吗?此操作不可恢复。`,
okText: '确定',
okType: 'danger',
onOk: async () => {
try {
await httpService.delete(buildApiUrl(API_ENDPOINTS.EXTERNAL_APPS.DELETE(item.id)));
message.success('删除成功');
fetchApps();
} catch {
message.error('删除失败');
}
},
});
};
const handleFileUpload = async (options, type) => {
const { file, onSuccess, onError } = options;
setUploading(true);
const uploadFormData = new FormData();
const endpoint = type === 'apk' ? API_ENDPOINTS.EXTERNAL_APPS.UPLOAD_APK : API_ENDPOINTS.EXTERNAL_APPS.UPLOAD_ICON;
const fieldName = type === 'apk' ? 'apk_file' : 'icon_file';
uploadFormData.append(fieldName, file);
try {
const response = await httpService.post(buildApiUrl(endpoint), uploadFormData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
if (response.code === '200') {
if (type === 'apk') {
const apkData = response.data;
const currentInfo = form.getFieldValue('app_info') || {};
form.setFieldsValue({
app_name: apkData.app_name || form.getFieldValue('app_name'),
app_info: {
...currentInfo,
version_name: apkData.version_name,
package_name: apkData.package_name,
apk_url: apkData.apk_url,
},
});
message.success('APK 上传并解析成功');
} else {
form.setFieldsValue({ icon_url: response.data.icon_url });
message.success('应用图标上传成功');
}
onSuccess(response.data);
}
} catch (error) {
message.error('上传失败');
onError(error);
} finally {
setUploading(false);
}
};
const handleToggleStatus = async (item, checked) => {
try {
const newActive = checked ? 1 : 0;
await httpService.put(buildApiUrl(API_ENDPOINTS.EXTERNAL_APPS.UPDATE(item.id)), { is_active: newActive });
setApps((prev) => prev.map((app) => (
app.id === item.id ? { ...app, is_active: newActive } : app
)));
message.success(`${newActive ? '启用' : '禁用'}应用`);
} catch {
message.error('状态更新失败');
}
};
const filteredApps = useMemo(() => apps.filter((app) => {
if (filterAppType !== 'all' && app.app_type !== filterAppType) {
return false;
}
const enabled = app.is_active === 1 || app.is_active === true;
if (filterStatus === 'active' && !enabled) {
return false;
}
if (filterStatus === 'inactive' && enabled) {
return false;
}
if (!searchQuery) {
return true;
}
const query = searchQuery.toLowerCase();
const info = parseAppInfo(app.app_info);
return [
app.app_name,
app.description,
info.package_name,
info.web_url,
info.apk_url,
info.version_name,
].some((field) => String(field || '').toLowerCase().includes(query));
}), [apps, filterAppType, filterStatus, searchQuery]);
const nativeCount = useMemo(() => apps.filter((app) => app.app_type === 'native').length, [apps]);
const webCount = useMemo(() => apps.filter((app) => app.app_type === 'web').length, [apps]);
const activeCount = useMemo(() => apps.filter((app) => app.is_active === 1 || app.is_active === true).length, [apps]);
const iconMissingCount = useMemo(() => apps.filter((app) => !app.icon_url).length, [apps]);
const columns = [
{
title: '应用',
key: 'app',
width: 240,
render: (_, record) => (
<Space>
<Avatar shape="square" size={42} src={record.icon_url} icon={<AppstoreOutlined />} />
<Space direction="vertical" size={0}>
<Text strong>{record.app_name}</Text>
<Text type="secondary">{record.description || '暂无描述'}</Text>
</Space>
</Space>
),
},
{
title: '类型',
dataIndex: 'app_type',
key: 'app_type',
width: 120,
render: (type) => (
type === 'native'
? <Tag icon={<RobotOutlined />} color="green">原生应用</Tag>
: <Tag icon={<GlobalOutlined />} color="blue">Web 应用</Tag>
),
},
{
title: '入口信息',
key: 'info',
render: (_, record) => {
const info = parseAppInfo(record.app_info);
return (
<Space direction="vertical" size={0}>
<Text type="secondary">版本{info.version_name || '-'}</Text>
{record.app_type === 'native' ? (
<Text type="secondary" ellipsis style={{ maxWidth: 220 }}>
包名{info.package_name || '-'}
</Text>
) : (
<Text type="secondary" ellipsis style={{ maxWidth: 240 }}>
地址{info.web_url || '-'}
</Text>
)}
</Space>
);
},
},
{
title: '状态',
key: 'status',
width: 110,
render: (_, record) => (
<Switch
size="small"
checked={record.is_active === 1 || record.is_active === true}
onChange={(checked) => handleToggleStatus(record, checked)}
/>
),
},
{
title: '排序',
dataIndex: 'sort_order',
key: 'sort_order',
align: 'center',
width: 80,
},
{
title: '操作',
key: 'action',
fixed: 'right',
width: 140,
render: (_, record) => (
<Space size={6}>
<ActionButton tone="view" variant="iconSm" tooltip="打开入口" icon={<ExportOutlined />} href={getAppEntryUrl(record)} target="_blank" disabled={!getAppEntryUrl(record)} />
<ActionButton tone="edit" variant="iconSm" tooltip="编辑" icon={<EditOutlined />} onClick={() => handleOpenModal(record)} />
<ActionButton tone="delete" variant="iconSm" tooltip="删除" icon={<DeleteOutlined />} onClick={() => handleDelete(record)} />
</Space>
),
},
];
return (
<div className="external-app-management">
<AdminModuleShell
icon={<AppstoreOutlined style={{ fontSize: 18, color: '#1d4ed8' }} />}
title="外部应用管理"
subtitle="统一维护原生应用与 Web 应用入口,支持 APK 自动解析、图标上传和状态治理。"
rightActions={(
<Space>
<Button icon={<ReloadOutlined />} className="btn-soft-blue" onClick={fetchApps} loading={loading}>刷新</Button>
<Button type="primary" icon={<PlusOutlined />} onClick={() => handleOpenModal()}>添加应用</Button>
</Space>
)}
stats={[
{
label: '应用总数',
value: apps.length,
icon: <AppstoreOutlined />,
tone: 'blue',
desc: '统一纳管的外部应用入口总量',
},
{
label: '原生应用',
value: nativeCount,
icon: <RobotOutlined />,
tone: 'green',
desc: '通过 APK 分发和安装的原生应用',
},
{
label: 'Web 应用',
value: webCount,
icon: <GlobalOutlined />,
tone: 'cyan',
desc: '通过浏览器访问的外部系统入口',
},
{
label: '缺少图标',
value: iconMissingCount,
icon: <PictureOutlined />,
tone: 'amber',
desc: '建议补齐图标以统一系统视觉入口',
},
{
label: '已启用',
value: activeCount,
icon: activeCount > 0 ? <CheckCircleOutlined /> : <BlockOutlined />,
tone: 'violet',
desc: '当前对用户可见并允许访问的应用数量',
},
]}
toolbar={(
<Space wrap>
<Select value={filterAppType} onChange={setFilterAppType} style={{ width: 140 }} options={APP_TYPE_OPTIONS} />
<Select value={filterStatus} onChange={setFilterStatus} style={{ width: 140 }} options={STATUS_OPTIONS} />
<Input
placeholder="搜索名称、描述、包名或入口..."
prefix={<SearchOutlined />}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
style={{ width: 280 }}
allowClear
/>
</Space>
)}
>
<div className="console-table">
<Table
columns={columns}
dataSource={filteredApps}
loading={loading}
rowKey="id"
scroll={{ x: 980 }}
pagination={{ pageSize, showTotal: (count) => `${count} 条记录` }}
/>
</div>
</AdminModuleShell>
<Drawer
open={showDrawer}
title={isEditing ? '编辑外部应用' : '添加外部应用'}
placement="right"
width={680}
onClose={() => setShowDrawer(false)}
destroyOnClose
extra={(
<Space>
<Button type="primary" icon={<SaveOutlined />} onClick={() => form.submit()}>
{isEditing ? '保存修改' : '创建应用'}
</Button>
</Space>
)}
>
<Form form={form} layout="vertical" style={{ marginTop: 20 }} onFinish={handleSave}>
<Form.Item name="app_type" label="应用类型" rules={[{ required: true, message: '请选择应用类型' }]}>
<Select>
<Select.Option value="native">原生应用Android APK</Select.Option>
<Select.Option value="web">Web 应用</Select.Option>
</Select>
</Form.Item>
<Form.Item noStyle shouldUpdate={(prev, curr) => prev.app_type !== curr.app_type}>
{({ getFieldValue }) => (
getFieldValue('app_type') === 'native' ? (
<div style={{ marginBottom: 16 }}>
<Upload customRequest={(options) => handleFileUpload(options, 'apk')} showUploadList={false}>
<Button icon={<UploadOutlined />} loading={uploading}>上传并自动解析 APK</Button>
</Upload>
</div>
) : null
)}
</Form.Item>
<Space align="start" style={{ width: '100%' }}>
<Form.Item label="应用图标">
<Upload customRequest={(options) => handleFileUpload(options, 'icon')} showUploadList={false}>
<div style={{ cursor: 'pointer' }}>
<Form.Item noStyle shouldUpdate={(prev, curr) => prev.icon_url !== curr.icon_url}>
{({ getFieldValue }) => (
<Avatar
size={64}
shape="square"
src={getFieldValue('icon_url')}
icon={getFieldValue('icon_url') ? <PictureOutlined /> : <PlusOutlined />}
style={{ border: '1px dashed #d9d9d9', backgroundColor: '#fff' }}
/>
)}
</Form.Item>
</div>
</Upload>
</Form.Item>
<Form.Item name="icon_url" hidden>
<Input />
</Form.Item>
<div style={{ flex: 1 }}>
<Form.Item name="app_name" label="应用名称" rules={[{ required: true, message: '请输入应用名称' }]}>
<Input placeholder="请输入应用名称" />
</Form.Item>
</div>
</Space>
<Form.Item noStyle shouldUpdate={(prev, curr) => prev.app_type !== curr.app_type}>
{({ getFieldValue }) => (
getFieldValue('app_type') === 'native' ? (
<>
<Row gutter={16}>
<Col span={8}>
<Form.Item
name={['app_info', 'version_name']}
label="版本名称"
rules={[{ required: true, message: '请输入版本名称' }]}
>
<Input placeholder="例如 1.0.0" />
</Form.Item>
</Col>
<Col span={16}>
<Form.Item
name={['app_info', 'package_name']}
label="应用包名"
rules={[{ required: true, message: '请输入应用包名' }]}
>
<Input placeholder="com.example.app" />
</Form.Item>
</Col>
</Row>
<Form.Item
name={['app_info', 'apk_url']}
label="APK 下载链接"
rules={[{ required: true, message: '请输入 APK 下载链接' }]}
>
<Input prefix={<LinkOutlined />} placeholder="https://..." />
</Form.Item>
</>
) : (
<>
<Row gutter={16}>
<Col span={8}>
<Form.Item name={['app_info', 'version_name']} label="版本名称">
<Input placeholder="可选,例如 1.2.0" />
</Form.Item>
</Col>
<Col span={16}>
<Form.Item
name={['app_info', 'web_url']}
label="Web 应用 URL"
rules={[{ required: true, message: '请输入 URL' }]}
>
<Input prefix={<GlobalOutlined />} placeholder="https://..." />
</Form.Item>
</Col>
</Row>
</>
)
)}
</Form.Item>
<Form.Item name="description" label="应用描述">
<TextArea rows={2} placeholder="简单描述该应用的用途..." />
</Form.Item>
<Row gutter={16}>
<Col span={12}>
<Form.Item name="sort_order" label="排序权重">
<InputNumber style={{ width: '100%' }} min={0} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="is_active" label="状态" valuePropName="checked">
<Switch />
</Form.Item>
</Col>
</Row>
</Form>
</Drawer>
</div>
);
};
export default ExternalAppManagement;