563 lines
18 KiB
JavaScript
563 lines
18 KiB
JavaScript
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;
|