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

563 lines
18 KiB
React
Raw Normal View History

2026-04-08 09:29:06 +00:00
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import {
2026-03-26 06:55:12 +00:00
Table,
Button,
Input,
Space,
2026-03-26 09:32:31 +00:00
Drawer,
2026-03-26 06:55:12 +00:00
Form,
Select,
App,
Tooltip,
Switch,
InputNumber,
Upload,
2026-03-26 06:55:12 +00:00
Tag,
Avatar,
Row,
Col,
Typography,
} from 'antd';
import {
PlusOutlined,
2026-03-26 09:32:31 +00:00
SaveOutlined,
2026-03-26 06:55:12 +00:00
DeleteOutlined,
EditOutlined,
SearchOutlined,
GlobalOutlined,
RobotOutlined,
UploadOutlined,
LinkOutlined,
AppstoreOutlined,
ReloadOutlined,
ExportOutlined,
PictureOutlined,
CheckCircleOutlined,
BlockOutlined,
} from '@ant-design/icons';
2026-04-08 11:19:33 +00:00
import httpService from '../../services/httpService';
import { buildApiUrl, API_ENDPOINTS } from '../../config/api';
2026-03-26 06:55:12 +00:00
import AdminModuleShell from '../../components/AdminModuleShell';
2026-04-03 16:25:53 +00:00
import ActionButton from '../../components/ActionButton';
2026-04-08 09:29:06 +00:00
import useSystemPageSize from '../../hooks/useSystemPageSize';
2026-03-26 06:55:12 +00:00
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();
2026-04-08 09:29:06 +00:00
const pageSize = useSystemPageSize(10);
2026-03-26 06:55:12 +00:00
const [apps, setApps] = useState([]);
const [loading, setLoading] = useState(true);
2026-03-26 09:32:31 +00:00
const [showDrawer, setShowDrawer] = useState(false);
const [isEditing, setIsEditing] = useState(false);
const [selectedApp, setSelectedApp] = useState(null);
2026-03-26 06:55:12 +00:00
const [filterAppType, setFilterAppType] = useState('all');
const [filterStatus, setFilterStatus] = useState('all');
const [searchQuery, setSearchQuery] = useState('');
2026-03-26 06:55:12 +00:00
const [uploading, setUploading] = useState(false);
2026-04-08 09:29:06 +00:00
const fetchApps = useCallback(async () => {
setLoading(true);
try {
2026-04-08 11:19:33 +00:00
const response = await httpService.get(buildApiUrl(API_ENDPOINTS.EXTERNAL_APPS.LIST));
2026-03-26 06:55:12 +00:00
if (response.code === '200') {
setApps(response.data || []);
}
} catch {
message.error('获取外部应用列表失败');
} finally {
setLoading(false);
}
2026-04-08 09:29:06 +00:00
}, [message]);
useEffect(() => {
fetchApps();
}, [fetchApps]);
2026-03-26 06:55:12 +00:00
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: {},
});
}
2026-03-26 09:32:31 +00:00
setShowDrawer(true);
};
2026-03-26 06:55:12 +00:00
const handleSave = async () => {
try {
2026-03-26 06:55:12 +00:00
const values = await form.validateFields();
const payload = {
...values,
app_info: JSON.stringify(values.app_info || {}),
is_active: values.is_active ? 1 : 0,
};
if (isEditing) {
2026-04-08 11:19:33 +00:00
await httpService.put(buildApiUrl(API_ENDPOINTS.EXTERNAL_APPS.UPDATE(selectedApp.id)), payload);
2026-03-26 06:55:12 +00:00
message.success('应用更新成功');
} else {
2026-04-08 11:19:33 +00:00
await httpService.post(buildApiUrl(API_ENDPOINTS.EXTERNAL_APPS.CREATE), payload);
2026-03-26 06:55:12 +00:00
message.success('应用创建成功');
}
2026-03-26 09:32:31 +00:00
setShowDrawer(false);
fetchApps();
} catch (error) {
2026-03-26 06:55:12 +00:00
if (!error.errorFields) {
message.error('保存失败');
}
}
};
2026-03-26 06:55:12 +00:00
const handleDelete = (item) => {
modal.confirm({
title: '删除外部应用',
content: `确定要删除应用“${item.app_name}”吗?此操作不可恢复。`,
okText: '确定',
okType: 'danger',
onOk: async () => {
try {
2026-04-08 11:19:33 +00:00
await httpService.delete(buildApiUrl(API_ENDPOINTS.EXTERNAL_APPS.DELETE(item.id)));
2026-03-26 06:55:12 +00:00
message.success('删除成功');
fetchApps();
} catch {
message.error('删除失败');
}
2026-03-26 06:55:12 +00:00
},
});
};
2026-03-26 06:55:12 +00:00
const handleFileUpload = async (options, type) => {
const { file, onSuccess, onError } = options;
setUploading(true);
2026-03-26 06:55:12 +00:00
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 {
2026-04-08 11:19:33 +00:00
const response = await httpService.post(buildApiUrl(endpoint), uploadFormData, {
2026-03-26 06:55:12 +00:00
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('应用图标上传成功');
}
2026-03-26 06:55:12 +00:00
onSuccess(response.data);
}
} catch (error) {
2026-03-26 06:55:12 +00:00
message.error('上传失败');
onError(error);
} finally {
2026-03-26 06:55:12 +00:00
setUploading(false);
}
};
2026-03-26 06:55:12 +00:00
const handleToggleStatus = async (item, checked) => {
try {
2026-03-26 06:55:12 +00:00
const newActive = checked ? 1 : 0;
2026-04-08 11:19:33 +00:00
await httpService.put(buildApiUrl(API_ENDPOINTS.EXTERNAL_APPS.UPDATE(item.id)), { is_active: newActive });
2026-03-26 06:55:12 +00:00
setApps((prev) => prev.map((app) => (
app.id === item.id ? { ...app, is_active: newActive } : app
)));
message.success(`${newActive ? '启用' : '禁用'}应用`);
} catch {
message.error('状态更新失败');
}
};
2026-03-26 06:55:12 +00:00
const filteredApps = useMemo(() => apps.filter((app) => {
if (filterAppType !== 'all' && app.app_type !== filterAppType) {
return false;
}
2026-03-26 06:55:12 +00:00
const enabled = app.is_active === 1 || app.is_active === true;
if (filterStatus === 'active' && !enabled) {
return false;
}
2026-03-26 06:55:12 +00:00
if (filterStatus === 'inactive' && enabled) {
return false;
}
2026-03-26 06:55:12 +00:00
if (!searchQuery) {
return true;
}
2026-03-26 06:55:12 +00:00
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 = [
{
2026-03-26 06:55:12 +00:00
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: '类型',
2026-03-26 06:55:12 +00:00
dataIndex: 'app_type',
key: 'app_type',
2026-03-26 06:55:12 +00:00
width: 120,
render: (type) => (
type === 'native'
? <Tag icon={<RobotOutlined />} color="green">原生应用</Tag>
: <Tag icon={<GlobalOutlined />} color="blue">Web 应用</Tag>
),
},
{
2026-03-26 06:55:12 +00:00
title: '入口信息',
key: 'info',
2026-03-26 06:55:12 +00:00
render: (_, record) => {
const info = parseAppInfo(record.app_info);
return (
2026-03-26 06:55:12 +00:00
<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>
) : (
2026-03-26 06:55:12 +00:00
<Text type="secondary" ellipsis style={{ maxWidth: 240 }}>
地址{info.web_url || '-'}
</Text>
)}
2026-03-26 06:55:12 +00:00
</Space>
);
2026-03-26 06:55:12 +00:00
},
},
{
title: '状态',
key: 'status',
2026-03-26 09:32:31 +00:00
width: 110,
2026-03-26 06:55:12 +00:00
render: (_, record) => (
2026-03-26 09:32:31 +00:00
<Switch
size="small"
checked={record.is_active === 1 || record.is_active === true}
onChange={(checked) => handleToggleStatus(record, checked)}
/>
2026-03-26 06:55:12 +00:00
),
},
{
2026-03-26 06:55:12 +00:00
title: '排序',
dataIndex: 'sort_order',
key: 'sort_order',
align: 'center',
width: 80,
},
{
title: '操作',
key: 'action',
fixed: 'right',
2026-03-26 06:55:12 +00:00
width: 140,
render: (_, record) => (
2026-04-03 16:25:53 +00:00
<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)} />
2026-03-26 06:55:12 +00:00
</Space>
),
},
];
return (
2026-03-26 06:55:12 +00:00
<div className="external-app-management">
<AdminModuleShell
icon={<AppstoreOutlined style={{ fontSize: 18, color: '#1d4ed8' }} />}
title="外部应用管理"
subtitle="统一维护原生应用与 Web 应用入口,支持 APK 自动解析、图标上传和状态治理。"
rightActions={(
<Space>
2026-03-26 09:32:31 +00:00
<Button icon={<ReloadOutlined />} className="btn-soft-blue" onClick={fetchApps} loading={loading}>刷新</Button>
2026-03-26 06:55:12 +00:00
<Button type="primary" icon={<PlusOutlined />} onClick={() => handleOpenModal()}>添加应用</Button>
</Space>
)}
2026-03-26 06:55:12 +00:00
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
/>
2026-03-26 06:55:12 +00:00
</Space>
)}
>
<div className="console-table">
<Table
columns={columns}
dataSource={filteredApps}
loading={loading}
rowKey="id"
scroll={{ x: 980 }}
2026-04-08 09:29:06 +00:00
pagination={{ pageSize, showTotal: (count) => `${count} 条记录` }}
/>
</div>
2026-03-26 06:55:12 +00:00
</AdminModuleShell>
2026-03-26 09:32:31 +00:00
<Drawer
open={showDrawer}
2026-03-26 06:55:12 +00:00
title={isEditing ? '编辑外部应用' : '添加外部应用'}
2026-03-26 09:32:31 +00:00
placement="right"
2026-03-26 06:55:12 +00:00
width={680}
2026-03-26 09:32:31 +00:00
onClose={() => setShowDrawer(false)}
destroyOnClose
extra={(
<Space>
<Button type="primary" icon={<SaveOutlined />} onClick={() => form.submit()}>
{isEditing ? '保存修改' : '创建应用'}
</Button>
</Space>
)}
2026-03-26 06:55:12 +00:00
>
<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">
2026-03-26 09:32:31 +00:00
<Switch />
2026-03-26 06:55:12 +00:00
</Form.Item>
</Col>
</Row>
</Form>
2026-03-26 09:32:31 +00:00
</Drawer>
</div>
);
};
export default ExternalAppManagement;