refactor: 移除调试日志并优化代码结构

feat(components): 新增设备管理相关组件和仪表盘组件
feat(hooks): 添加自定义hooks用于API调用和数据管理
style: 优化滚动条样式和模态框布局
chore: 清理无用脚本和调试文件
docs: 更新组件导出文件
This commit is contained in:
zhang1106
2026-03-10 14:34:39 +08:00
parent 98f705de8b
commit 196dc4ae41
43 changed files with 3082 additions and 3736 deletions
@@ -0,0 +1,110 @@
import React from 'react';
import { Modal, Form, Select, Button } from 'antd';
import { ReloadOutlined } from '@ant-design/icons';
import { designTokens } from '../../config/theme';
const { Option } = Select;
const modalHeaderStyle = {
display: 'flex',
alignItems: 'center',
gap: '8px',
fontSize: '18px',
fontWeight: 600,
};
const secondaryActionStyle = {
height: '40px',
borderRadius: designTokens.borderRadius.small,
border: `1px solid ${designTokens.colors.border.light}`,
fontWeight: '500',
};
const BatchStatusModal = ({
visible,
selectedCount,
loading,
onSubmit,
onCancel,
}) => {
const [form] = Form.useForm();
const handleSubmit = async () => {
try {
const values = await form.validateFields();
await onSubmit(values.status);
} catch (error) {
if (!error.errorFields) {
console.error('批量状态变更失败:', error);
}
}
};
return (
<Modal
title={
<div style={{ ...modalHeaderStyle, paddingRight: '32px' }}>
<ReloadOutlined style={{ color: '#52c41a' }} />
批量状态变更
</div>
}
open={visible}
onCancel={onCancel}
footer={[
<Button key="cancel" onClick={onCancel} style={secondaryActionStyle}>
取消
</Button>,
<Button
key="submit"
type="primary"
loading={loading}
onClick={handleSubmit}
style={{
height: '40px',
borderRadius: designTokens.borderRadius.small,
background: designTokens.colors.primary.gradient,
border: 'none',
color: '#ffffff',
boxShadow: designTokens.shadows.small,
fontWeight: '500',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
确定
</Button>,
]}
destroyOnHidden
styles={{
header: {
borderBottom: '1px solid #f0f0f0',
padding: '16px 24px',
position: 'relative',
},
body: { padding: '24px' },
}}
>
<Form form={form} layout="vertical">
<Form.Item
name="status"
label="选择新状态"
rules={[{ required: true, message: '请选择设备状态' }]}
>
<Select placeholder="请选择设备状态" style={{ width: '100%' }}>
<Option value="running">运行中</Option>
<Option value="maintenance">维护中</Option>
<Option value="offline">离线</Option>
<Option value="fault">故障</Option>
</Select>
</Form.Item>
<div style={{ color: '#666', fontSize: '13px' }}>
已选择{' '}
<span style={{ color: '#1890ff', fontWeight: 600 }}>{selectedCount}</span> 个设备
</div>
</Form>
</Modal>
);
};
export default React.memo(BatchStatusModal);
@@ -0,0 +1,256 @@
import React from 'react';
import { Modal, Button, Card, Row, Col, Tag } from 'antd';
import { AppstoreOutlined } from '@ant-design/icons';
import { designTokens } from '../../config/theme';
import { getStatusConfig, getTypeLabel, getDeviceTypeIcon } from '../../utils/deviceUtils.jsx';
const modalHeaderStyle = {
display: 'flex',
alignItems: 'center',
gap: '8px',
fontSize: '18px',
fontWeight: 600,
};
const secondaryActionStyle = {
height: '40px',
borderRadius: designTokens.borderRadius.small,
border: `1px solid ${designTokens.colors.border.light}`,
fontWeight: '500',
};
const DeviceDetailModal = ({
visible,
device,
deviceFields,
onClose,
onEdit,
onViewTickets,
onCreateTicket,
}) => {
if (!device) return null;
return (
<Modal
title={
<div style={{ ...modalHeaderStyle, paddingRight: '32px' }}>
<AppstoreOutlined style={{ color: '#667eea' }} />
设备详情
</div>
}
open={visible}
onCancel={onClose}
footer={[
<Button key="close" onClick={onClose} style={secondaryActionStyle}>
关闭
</Button>,
<Button key="viewTickets" onClick={() => onViewTickets(device)} style={secondaryActionStyle}>
查看工单
</Button>,
<Button key="createTicket" onClick={() => onCreateTicket(device)} style={secondaryActionStyle}>
创建工单
</Button>,
<Button
key="edit"
type="primary"
onClick={() => {
onClose();
onEdit(device);
}}
style={{
height: '40px',
borderRadius: designTokens.borderRadius.small,
background: designTokens.colors.primary.gradient,
border: 'none',
color: '#ffffff',
boxShadow: designTokens.shadows.small,
fontWeight: '500',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
编辑
</Button>,
]}
width={700}
destroyOnHidden
styles={{
header: {
borderBottom: '1px solid #f0f0f0',
padding: '16px 24px',
position: 'relative',
},
body: { padding: '0', overflow: 'auto' },
}}
>
<div>
<div
style={{
padding: '24px',
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
color: '#fff',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
<div
style={{
width: '64px',
height: '64px',
borderRadius: '12px',
backgroundColor: 'rgba(255,255,255,0.2)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
{getDeviceTypeIcon(device.type)}
</div>
<div style={{ flex: 1 }}>
<div style={{ fontSize: '24px', fontWeight: 600, marginBottom: '8px' }}>
{device.name}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '16px', opacity: 0.9 }}>
<span>{getTypeLabel(device.type)}</span>
<span>|</span>
<span>{device.deviceId}</span>
<span>|</span>
<Tag
color={device.status ? getStatusConfig(device.status).badgeColor : 'default'}
style={{ margin: 0 }}
>
{device.status ? getStatusConfig(device.status).text : '-'}
</Tag>
</div>
</div>
</div>
</div>
<div style={{ padding: '20px 24px' }}>
<Card
size="small"
title={<span style={{ fontWeight: 600 }}>基本信息</span>}
style={{ marginBottom: '16px', borderRadius: '8px' }}
>
<Row gutter={[24, 16]}>
<Col span={8}>
<div style={{ color: '#666', fontSize: '12px', marginBottom: '4px' }}>设备型号</div>
<div style={{ fontWeight: 500 }}>{device.model || '-'}</div>
</Col>
<Col span={8}>
<div style={{ color: '#666', fontSize: '12px', marginBottom: '4px' }}>序列号</div>
<div style={{ fontWeight: 500 }}>{device.serialNumber || '-'}</div>
</Col>
<Col span={8}>
<div style={{ color: '#666', fontSize: '12px', marginBottom: '4px' }}>IP地址</div>
<div style={{ fontWeight: 500 }}>{device.ipAddress || '-'}</div>
</Col>
<Col span={8}>
<div style={{ color: '#666', fontSize: '12px', marginBottom: '4px' }}>所在机房</div>
<div style={{ fontWeight: 500 }}>{device.Rack?.Room?.name || '-'}</div>
</Col>
<Col span={8}>
<div style={{ color: '#666', fontSize: '12px', marginBottom: '4px' }}>所在机柜</div>
<div style={{ fontWeight: 500 }}>{device.Rack?.name || '-'}</div>
</Col>
<Col span={8}>
<div style={{ color: '#666', fontSize: '12px', marginBottom: '4px' }}>位置(U)</div>
<div style={{ fontWeight: 500 }}>U{device.position || '-'}</div>
</Col>
<Col span={8}>
<div style={{ color: '#666', fontSize: '12px', marginBottom: '4px' }}>高度</div>
<div style={{ fontWeight: 500 }}>{device.height ? `${device.height}U` : '-'}</div>
</Col>
<Col span={8}>
<div style={{ color: '#666', fontSize: '12px', marginBottom: '4px' }}>功率</div>
<div style={{ fontWeight: 500 }}>{device.power ? `${device.power}W` : '-'}</div>
</Col>
<Col span={8}>
<div style={{ color: '#666', fontSize: '12px', marginBottom: '4px' }}>状态</div>
<div
style={{
fontWeight: 500,
color: device.status ? getStatusConfig(device.status).color : '#666',
}}
>
{device.status ? getStatusConfig(device.status).text : '-'}
</div>
</Col>
</Row>
</Card>
<Card
size="small"
title={<span style={{ fontWeight: 600 }}>维保信息</span>}
style={{ marginBottom: '16px', borderRadius: '8px' }}
>
<Row gutter={[24, 16]}>
<Col span={12}>
<div style={{ color: '#666', fontSize: '12px', marginBottom: '4px' }}>购买日期</div>
<div style={{ fontWeight: 500 }}>
{device.purchaseDate
? new Date(device.purchaseDate).toLocaleDateString('zh-CN')
: '-'}
</div>
</Col>
<Col span={12}>
<div style={{ color: '#666', fontSize: '12px', marginBottom: '4px' }}>保修到期</div>
<div
style={{
fontWeight:
device.warrantyExpiry && new Date(device.warrantyExpiry) < new Date()
? 600
: 500,
color:
device.warrantyExpiry && new Date(device.warrantyExpiry) < new Date()
? '#d93025'
: '#333',
}}
>
{device.warrantyExpiry
? new Date(device.warrantyExpiry).toLocaleDateString('zh-CN')
: '-'}
</div>
</Col>
</Row>
</Card>
{device.description && (
<Card
size="small"
title={<span style={{ fontWeight: 600 }}>描述</span>}
style={{ marginBottom: '16px', borderRadius: '8px' }}
>
<div style={{ whiteSpace: 'pre-wrap', lineHeight: 1.6 }}>{device.description}</div>
</Card>
)}
{device.customFields && Object.keys(device.customFields).length > 0 && (
<Card
size="small"
title={<span style={{ fontWeight: 600 }}>自定义字段</span>}
style={{ borderRadius: '8px' }}
>
<Row gutter={[24, 16]}>
{Object.entries(device.customFields).map(([key, value]) => {
const fieldConfig = deviceFields.find((f) => f.fieldName === key);
const displayName = fieldConfig?.displayName || key;
return (
<Col span={8} key={key}>
<div style={{ color: '#666', fontSize: '12px', marginBottom: '4px' }}>
{displayName}
</div>
<div style={{ fontWeight: 500 }}>{String(value)}</div>
</Col>
);
})}
</Row>
</Card>
)}
</div>
</div>
</Modal>
);
};
export default React.memo(DeviceDetailModal);
@@ -0,0 +1,361 @@
import React, { useState, useEffect } from 'react';
import { Modal, Form, Input, Select, InputNumber, DatePicker, Switch, Row, Col, Button, Space } from 'antd';
import { PlusOutlined, EditOutlined, DatabaseOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
import { designTokens } from '../../config/theme';
import { getFormInitialValues, prepareDeviceFormData } from '../../utils/deviceUtils.jsx';
const { Option } = Select;
const modalHeaderStyle = {
display: 'flex',
alignItems: 'center',
gap: '8px',
fontSize: '18px',
fontWeight: 600,
};
const inputStyle = {
borderRadius: '8px',
transition: 'all 0.3s ease',
};
const DeviceFormModal = ({
visible,
editingDevice,
deviceFields,
racks,
rooms,
onCancel,
onSubmit,
}) => {
const [form] = Form.useForm();
const [selectedRoomId, setSelectedRoomId] = useState(null);
useEffect(() => {
if (visible) {
if (editingDevice) {
const initialValues = getFormInitialValues(editingDevice, racks);
if (initialValues.purchaseDate) {
initialValues.purchaseDate = dayjs(initialValues.purchaseDate);
}
if (initialValues.warrantyExpiry) {
initialValues.warrantyExpiry = dayjs(initialValues.warrantyExpiry);
}
form.setFieldsValue(initialValues);
if (editingDevice.rackId) {
const rack = racks.find((r) => r.rackId === editingDevice.rackId);
if (rack) {
setSelectedRoomId(rack.roomId);
}
}
} else {
form.resetFields();
setSelectedRoomId(null);
}
}
}, [visible, editingDevice, racks, form]);
const handleSubmit = (values) => {
const deviceData = prepareDeviceFormData(values, !!editingDevice);
onSubmit(deviceData);
};
const handleRoomChange = (value) => {
setSelectedRoomId(value);
form.setFieldValue('rackId', undefined);
};
const renderFieldControl = (field) => {
switch (field.fieldType) {
case 'number':
return (
<InputNumber
placeholder={`请输入${field.displayName}`}
min={0}
style={{ width: '100%', ...inputStyle }}
className="form-input-enhanced"
/>
);
case 'boolean':
return <Switch />;
case 'date':
return (
<DatePicker
style={{ width: '100%', ...inputStyle }}
placeholder={`请选择${field.displayName}`}
className="form-input-enhanced"
/>
);
case 'textarea':
return (
<Input.TextArea
placeholder={`请输入${field.displayName}`}
rows={3}
style={inputStyle}
className="form-input-enhanced"
/>
);
case 'select':
return (
<Select
placeholder={`请选择${field.displayName}`}
style={inputStyle}
className="form-input-enhanced"
>
{field.options &&
field.options.map((option) => (
<Option key={option.value} value={option.value}>
{option.label}
</Option>
))}
</Select>
);
default:
return (
<Input
placeholder={`请输入${field.displayName}`}
style={inputStyle}
className="form-input-enhanced"
/>
);
}
};
const filteredFields = deviceFields.filter(
(field) => field.fieldName !== 'deviceId' && field.fieldName !== 'rackId'
);
const formItems = [];
filteredFields.forEach((field) => {
if (field.fieldName === 'serialNumber') {
formItems.push(
<React.Fragment key={field.fieldName}>
<Col span={12} key={`${field.fieldName}-col`}>
<Form.Item
name={field.fieldName}
label={
<span>
{field.displayName}
{field.required && (
<span style={{ color: '#ff4d4f', marginLeft: '4px' }}>*</span>
)}
</span>
}
rules={
field.required ? [{ required: true, message: `请输入${field.displayName}` }] : []
}
>
{renderFieldControl(field)}
</Form.Item>
</Col>
<Col span={24} key="room-rack-section">
<div
style={{
background: 'linear-gradient(135deg, #f0f5ff 0%, #e6f7ff 100%)',
borderRadius: '12px',
padding: '20px',
marginBottom: '16px',
border: '2px solid #d6e4ff',
boxShadow: '0 2px 8px rgba(24, 144, 255, 0.1)',
}}
>
<div
style={{
fontSize: '14px',
fontWeight: '600',
color: '#1890ff',
marginBottom: '16px',
display: 'flex',
alignItems: 'center',
}}
>
<DatabaseOutlined style={{ marginRight: '8px' }} />
设备位置选择
</div>
<Row gutter={16}>
<Col span={12}>
<Form.Item
name="roomId"
label={
<span>
机房
<span style={{ color: '#ff4d4f', marginLeft: '4px' }}>*</span>
</span>
}
rules={[{ required: true, message: '请选择机房' }]}
style={{ marginBottom: '0' }}
>
<Select
placeholder="请选择机房"
style={{ borderRadius: '8px' }}
showSearch
optionFilterProp="children"
onChange={handleRoomChange}
>
{rooms.map((room) => (
<Option key={room.roomId} value={room.roomId}>
{room.name}
</Option>
))}
</Select>
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
name="rackId"
label={
<span>
机柜
<span style={{ color: '#ff4d4f', marginLeft: '4px' }}>*</span>
</span>
}
rules={[{ required: true, message: '请选择机柜' }]}
style={{ marginBottom: '0' }}
>
<Select
placeholder={selectedRoomId ? '请选择机柜' : '请先选择机房'}
style={{ borderRadius: '8px' }}
disabled={!selectedRoomId}
showSearch
optionFilterProp="children"
>
{(selectedRoomId ? racks.filter((rack) => rack.roomId === selectedRoomId) : []).map(
(rack) => (
<Option key={rack.rackId} value={rack.rackId}>
{rack.name} ({rack.rackId})
</Option>
)
)}
</Select>
</Form.Item>
</Col>
</Row>
</div>
</Col>
</React.Fragment>
);
} else if (field.fieldType === 'textarea') {
formItems.push(
<Col span={24} key={field.fieldName}>
<Form.Item
name={field.fieldName}
label={
<span>
{field.displayName}
{field.required && (
<span style={{ color: '#ff4d4f', marginLeft: '4px' }}>*</span>
)}
</span>
}
rules={
field.required ? [{ required: true, message: `请输入${field.displayName}` }] : []
}
>
{renderFieldControl(field)}
</Form.Item>
</Col>
);
} else {
formItems.push(
<Col span={12} key={field.fieldName}>
<Form.Item
name={field.fieldName}
label={
<span>
{field.displayName}
{field.required && (
<span style={{ color: '#ff4d4f', marginLeft: '4px' }}>*</span>
)}
</span>
}
rules={
field.required ? [{ required: true, message: `请输入${field.displayName}` }] : []
}
>
{renderFieldControl(field)}
</Form.Item>
</Col>
);
}
});
return (
<Modal
title={
<div style={{ ...modalHeaderStyle, paddingRight: '32px' }}>
{editingDevice ? (
<EditOutlined style={{ color: '#667eea' }} />
) : (
<PlusOutlined style={{ color: '#667eea' }} />
)}
{editingDevice ? '编辑设备' : '添加设备'}
</div>
}
open={visible}
onCancel={onCancel}
footer={null}
width={700}
style={{ borderRadius: '16px' }}
styles={{
header: {
borderBottom: '1px solid #f0f0f0',
padding: '16px 24px',
position: 'relative',
},
body: { padding: '24px' },
}}
className="device-modal"
>
<Form form={form} layout="vertical" onFinish={handleSubmit}>
<Row gutter={16}>{formItems}</Row>
<div
style={{
display: 'flex',
justifyContent: 'flex-end',
gap: '12px',
marginTop: '32px',
paddingTop: '24px',
borderTop: '1px solid #f0f0f0',
}}
>
<Button
onClick={onCancel}
style={{
height: '40px',
borderRadius: '8px',
padding: '0 24px',
fontWeight: '500',
transition: 'all 0.3s ease',
}}
>
取消
</Button>
<Button
type="primary"
htmlType="submit"
style={{
height: '40px',
borderRadius: '8px',
background: designTokens.colors.primary.gradient,
border: 'none',
color: '#ffffff',
boxShadow: designTokens.shadows.small,
fontWeight: '500',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
padding: '0 32px',
transition: 'all 0.3s ease',
}}
>
确定
</Button>
</div>
</Form>
</Modal>
);
};
export default React.memo(DeviceFormModal);
@@ -0,0 +1,179 @@
import React, { useState, useMemo } from 'react';
import { Modal, Form, Select, Checkbox, Button, message } from 'antd';
import { ExportOutlined } from '@ant-design/icons';
import { designTokens } from '../../config/theme';
const { Option } = Select;
const modalHeaderStyle = {
display: 'flex',
alignItems: 'center',
gap: '8px',
fontSize: '18px',
fontWeight: 600,
};
const ExportModal = ({
visible,
deviceFields,
selectedDevices,
currentPageDevices,
allDevices,
onExport,
onCancel,
}) => {
const [exportFormat, setExportFormat] = useState('csv');
const [exportScope, setExportScope] = useState('selected');
const [exportFields, setExportFields] = useState([]);
const [exportLoading, setExportLoading] = useState(false);
const visibleFields = useMemo(() => {
return deviceFields.filter((f) => f.visible && f.fieldName !== 'rackId');
}, [deviceFields]);
React.useEffect(() => {
if (visible) {
setExportFields(visibleFields.map((f) => f.fieldName));
}
}, [visible, visibleFields]);
const handleExport = async () => {
if (exportFields.length === 0) {
message.warning('请至少选择一个导出字段');
return;
}
setExportLoading(true);
try {
await onExport({
format: exportFormat,
scope: exportScope,
fields: exportFields,
});
onCancel();
} finally {
setExportLoading(false);
}
};
const getScopeLabel = () => {
switch (exportScope) {
case 'selected':
return `选择的行 (${selectedDevices.length} 个)`;
case 'currentPage':
return `当前页 (${currentPageDevices.length} 个)`;
case 'all':
return `全部设备 (${allDevices.length} 个)`;
default:
return '';
}
};
return (
<Modal
title={
<div style={{ ...modalHeaderStyle, paddingRight: '32px' }}>
<ExportOutlined style={{ color: '#fa8c16' }} />
导出设备数据
</div>
}
open={visible}
onCancel={onCancel}
footer={[
<Button
key="cancel"
onClick={onCancel}
style={{
height: '40px',
borderRadius: designTokens.borderRadius.small,
border: `1px solid ${designTokens.colors.border.light}`,
}}
>
取消
</Button>,
<Button
key="submit"
type="primary"
loading={exportLoading}
onClick={handleExport}
style={{
height: '40px',
borderRadius: designTokens.borderRadius.small,
background: designTokens.colors.primary.gradient,
border: 'none',
color: '#ffffff',
boxShadow: designTokens.shadows.small,
fontWeight: '500',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
导出
</Button>,
]}
destroyOnHidden
styles={{
header: {
borderBottom: '1px solid #f0f0f0',
padding: '16px 24px',
position: 'relative',
},
body: { padding: '24px' },
}}
width={600}
>
<Form layout="vertical">
<Form.Item label="导出格式">
<Select value={exportFormat} onChange={setExportFormat} style={{ width: '100%' }}>
<Option value="csv">CSV 格式</Option>
<Option value="json">JSON 格式</Option>
</Select>
</Form.Item>
<Form.Item label="导出范围">
<Select value={exportScope} onChange={setExportScope} style={{ width: '100%' }}>
<Option value="selected">{getScopeLabel()}</Option>
<Option value="currentPage">当前页 ({currentPageDevices.length} )</Option>
<Option value="all">全部设备 ({allDevices.length} )</Option>
</Select>
</Form.Item>
<Form.Item label="选择导出字段">
<div
style={{
maxHeight: '300px',
overflow: 'auto',
border: '1px solid #f0f0f0',
borderRadius: '8px',
padding: '12px',
}}
>
{visibleFields.map((field) => (
<div key={field.fieldName} style={{ marginBottom: '8px' }}>
<Checkbox
checked={exportFields.includes(field.fieldName)}
onChange={(e) => {
if (e.target.checked) {
setExportFields([...exportFields, field.fieldName]);
} else {
setExportFields(exportFields.filter((f) => f !== field.fieldName));
}
}}
>
{field.displayName}
</Checkbox>
</div>
))}
</div>
</Form.Item>
<div style={{ color: '#666', fontSize: '13px' }}>
已选择{' '}
<span style={{ color: '#1890ff', fontWeight: 600 }}>{selectedDevices.length}</span> 个设备
将导出{' '}
<span style={{ color: '#52c41a', fontWeight: 600 }}>{exportFields.length}</span> 个字段
</div>
</Form>
</Modal>
);
};
export default React.memo(ExportModal);
@@ -0,0 +1,150 @@
import React from 'react';
import { Modal, Form, Switch, Button, Space, message } from 'antd';
import { SettingOutlined } from '@ant-design/icons';
import { designTokens } from '../../config/theme';
const modalHeaderStyle = {
display: 'flex',
alignItems: 'center',
gap: '8px',
fontSize: '18px',
fontWeight: 600,
};
const secondaryActionStyle = {
height: '40px',
borderRadius: designTokens.borderRadius.small,
border: `1px solid ${designTokens.colors.border.light}`,
fontWeight: '500',
};
const FieldConfigModal = ({
visible,
deviceFields,
defaultDeviceFields,
onSave,
onReset,
onCancel,
}) => {
const [form] = Form.useForm();
const getInitialValues = () => {
return deviceFields.reduce(
(acc, field) => ({
...acc,
[`visible_${field.fieldName}`]: field.visible,
[`required_${field.fieldName}`]: field.required,
}),
{}
);
};
const handleSubmit = async (values) => {
const updatedFields = deviceFields.map((field) => ({
fieldId: field.fieldId,
fieldName: field.fieldName,
displayName: field.displayName,
visible: values[`visible_${field.fieldName}`] ?? field.visible,
required: values[`required_${field.fieldName}`] ?? field.required,
}));
await onSave(updatedFields);
};
const handleReset = () => {
onReset(defaultDeviceFields);
message.success('字段配置已重置为默认值');
};
const filteredFields = deviceFields.filter((field) => field.fieldName !== 'deviceId');
return (
<Modal
title={
<div style={{ ...modalHeaderStyle, paddingRight: '32px' }}>
<SettingOutlined style={{ color: '#667eea' }} />
字段配置
</div>
}
open={visible}
onCancel={onCancel}
footer={null}
width={600}
styles={{
header: {
borderBottom: '1px solid #f0f0f0',
padding: '16px 24px',
position: 'relative',
},
body: { padding: '24px' },
}}
>
<Form
form={form}
layout="vertical"
onFinish={handleSubmit}
initialValues={getInitialValues()}
>
<div style={{ maxHeight: 400, overflowY: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ borderBottom: '1px solid #f0f0f0' }}>
<th style={{ padding: '8px', textAlign: 'left', width: '40%' }}>字段名称</th>
<th style={{ padding: '8px', textAlign: 'center', width: '30%' }}>可见</th>
<th style={{ padding: '8px', textAlign: 'center', width: '30%' }}>必填</th>
</tr>
</thead>
<tbody>
{filteredFields.map((field) => (
<tr key={field.fieldName} style={{ borderBottom: '1px solid #f0f0f0' }}>
<td style={{ padding: '8px' }}>{field.displayName}</td>
<td style={{ padding: '8px', textAlign: 'center' }}>
<Form.Item name={`visible_${field.fieldName}`} valuePropName="checked" noStyle>
<Switch size="small" />
</Form.Item>
</td>
<td style={{ padding: '8px', textAlign: 'center' }}>
<Form.Item name={`required_${field.fieldName}`} valuePropName="checked" noStyle>
<Switch size="small" />
</Form.Item>
</td>
</tr>
))}
</tbody>
</table>
</div>
<Form.Item style={{ textAlign: 'right', marginTop: '20px' }}>
<Space>
<Button onClick={onCancel} style={secondaryActionStyle}>
取消
</Button>
<Button onClick={handleReset} style={secondaryActionStyle}>
重置默认
</Button>
<Button
type="primary"
htmlType="submit"
style={{
height: '40px',
borderRadius: designTokens.borderRadius.small,
background: designTokens.colors.primary.gradient,
border: 'none',
color: '#ffffff',
boxShadow: designTokens.shadows.small,
fontWeight: '500',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
保存
</Button>
</Space>
</Form.Item>
</Form>
</Modal>
);
};
export default React.memo(FieldConfigModal);
@@ -0,0 +1,385 @@
import React, { useState } from 'react';
import { Modal, Upload, Button, Progress, message } from 'antd';
import { UploadOutlined, DownloadOutlined } from '@ant-design/icons';
import { designTokens } from '../../config/theme';
const modalHeaderStyle = {
display: 'flex',
alignItems: 'center',
gap: '8px',
fontSize: '18px',
fontWeight: 600,
};
const ImportModal = ({
visible,
deviceFields,
onImport,
onCancel,
}) => {
const [isImporting, setIsImporting] = useState(false);
const [importProgress, setImportProgress] = useState(0);
const [importPhase, setImportPhase] = useState('');
const [importResult, setImportResult] = useState(null);
const handleImport = async (file) => {
try {
setIsImporting(true);
setImportProgress(0);
setImportPhase('正在上传文件...');
setImportResult(null);
await onImport(file, {
onProgress: (progress, phase) => {
setImportProgress(progress);
setImportPhase(phase);
},
onSuccess: (result) => {
setImportResult(result);
setImportProgress(100);
setImportPhase('导入完成');
setIsImporting(false);
},
onError: (error) => {
setImportResult({
success: false,
statistics: {
total: 0,
success: 0,
failed: 1,
errors: [{ row: 0, error: error.message || '导入失败' }],
},
});
setIsImporting(false);
},
});
} catch (error) {
setIsImporting(false);
setImportProgress(0);
message.error('导入失败');
}
return false;
};
const handleClose = () => {
setImportProgress(0);
setImportPhase('');
setImportResult(null);
setIsImporting(false);
onCancel();
};
const requiredFields = deviceFields.filter((f) => f.visible && f.required);
const optionalFields = deviceFields.filter((f) => f.visible && !f.required);
return (
<Modal
title={
<div style={{ ...modalHeaderStyle, paddingRight: '32px' }}>
<UploadOutlined style={{ color: '#667eea' }} />
导入设备
</div>
}
open={visible}
onCancel={handleClose}
footer={null}
width={650}
destroyOnHidden
styles={{
header: {
borderBottom: '1px solid #f0f0f0',
padding: '16px 24px',
position: 'relative',
},
body: { padding: '24px' },
}}
>
{!isImporting && !importResult ? (
<div>
<p style={{ color: '#666', marginBottom: '8px' }}>请上传CSV格式的设备数据文件</p>
<p style={{ color: '#999', fontSize: '12px', marginBottom: '20px' }}>
支持的编码格式GBK
</p>
<div
style={{
marginBottom: '20px',
padding: '16px',
background: 'linear-gradient(180deg, #fafafa 0%, #ffffff 100%)',
borderRadius: '12px',
border: '1px solid #f0f0f0',
}}
>
<p style={{ fontWeight: '600', marginBottom: '8px', color: '#333' }}>
CSV文件格式要求
</p>
<div style={{ maxHeight: '200px', overflowY: 'auto' }}>
{requiredFields.length > 0 && (
<div style={{ marginBottom: '8px' }}>
<span style={{ color: '#d93025', fontWeight: '500' }}>必填字段</span>
<span style={{ color: '#666', fontSize: '13px' }}>
{requiredFields.map((f) => f.displayName).join('、')}
</span>
</div>
)}
{optionalFields.length > 0 && (
<div style={{ marginBottom: '8px' }}>
<span style={{ color: '#666', fontWeight: '500' }}>可选字段</span>
<span style={{ color: '#666', fontSize: '13px' }}>
{optionalFields.map((f) => f.displayName).join('、')}
</span>
</div>
)}
<ul
style={{
paddingLeft: '20px',
marginBottom: '10px',
color: '#666',
fontSize: '13px',
marginTop: '12px',
}}
>
<li>
设备类型server(服务器)switch(交换机)router(路由器)storage(存储设备)other(其他)
</li>
<li>状态值running(运行中)maintenance(维护中)offline(离线)fault(故障)</li>
<li>日期格式YYYY-MM-DD (例如2023-01-01)</li>
</ul>
</div>
</div>
<div style={{ marginBottom: '20px' }}>
<a href="/api/devices/import-template" download="设备导入模板.csv">
<Button
icon={<DownloadOutlined />}
style={{
height: '36px',
borderRadius: designTokens.borderRadius.small,
border: `1px solid ${designTokens.colors.border.light}`,
}}
>
下载导入模板
</Button>
</a>
<span style={{ color: '#999', fontSize: '12px', marginLeft: '10px' }}>
包含示例数据的CSV模板文件根据当前字段配置生成
</span>
</div>
<Upload
name="csvFile"
accept=".csv"
showUploadList={false}
beforeUpload={handleImport}
maxCount={1}
>
<Button
type="primary"
icon={<UploadOutlined />}
block
style={{
height: '40px',
borderRadius: designTokens.borderRadius.small,
background: designTokens.colors.primary.gradient,
border: 'none',
color: '#ffffff',
boxShadow: designTokens.shadows.small,
fontWeight: '500',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
选择CSV文件
</Button>
</Upload>
</div>
) : isImporting ? (
<div>
<div style={{ display: 'flex', alignItems: 'center', marginBottom: '16px' }}>
<div
style={{
width: '48px',
height: '48px',
borderRadius: '50%',
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
marginRight: '16px',
color: '#fff',
fontSize: '20px',
}}
>
<UploadOutlined spin />
</div>
<div>
<p
style={{
margin: '0 0 4px 0',
fontWeight: '600',
color: '#333',
fontSize: '16px',
}}
>
正在导入设备数据
</p>
<p style={{ margin: 0, color: '#667eea', fontSize: '14px' }}>{importPhase}</p>
</div>
</div>
<Progress
percent={importProgress}
status="active"
strokeColor={{ '0%': '#667eea', '100%': '#764ba2' }}
format={() => `${importProgress}%`}
/>
</div>
) : importResult?.statistics ? (
<div>
<p style={{ marginBottom: '10px', fontWeight: '600' }}>导入完成</p>
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(3, 1fr)',
gap: '12px',
marginBottom: '16px',
}}
>
<div
style={{
padding: '12px',
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
borderRadius: '8px',
color: '#fff',
textAlign: 'center',
}}
>
<div style={{ fontSize: '24px', fontWeight: '700' }}>
{importResult.statistics.total || 0}
</div>
<div style={{ fontSize: '12px', opacity: 0.9 }}>总记录数</div>
</div>
<div
style={{
padding: '12px',
background: 'linear-gradient(135deg, #52c41a 0%, #389e0d 100%)',
borderRadius: '8px',
color: '#fff',
textAlign: 'center',
}}
>
<div style={{ fontSize: '24px', fontWeight: '700' }}>
{importResult.statistics.success || 0}
</div>
<div style={{ fontSize: '12px', opacity: 0.9 }}>成功</div>
</div>
<div
style={{
padding: '12px',
background: 'linear-gradient(135deg, #ff4d4f 0%, #cf1322 100%)',
borderRadius: '8px',
color: '#fff',
textAlign: 'center',
}}
>
<div style={{ fontSize: '24px', fontWeight: '700' }}>
{importResult.statistics.failed || 0}
</div>
<div style={{ fontSize: '12px', opacity: 0.9 }}>失败</div>
</div>
</div>
{importResult.statistics?.errors?.length > 0 && (
<div
style={{
marginTop: '20px',
maxHeight: 400,
overflowY: 'auto',
border: '1px solid #ffcccc',
borderRadius: '8px',
padding: '12px',
backgroundColor: '#fff7f7',
}}
>
<h4 style={{ color: '#d93025', marginBottom: '12px', fontWeight: '600' }}>
失败记录详情
</h4>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '13px' }}>
<thead>
<tr style={{ backgroundColor: '#ffeeee' }}>
<th
style={{
border: '1px solid #ffcccc',
padding: '8px',
textAlign: 'left',
width: '80px',
}}
>
行号
</th>
<th
style={{
border: '1px solid #ffcccc',
padding: '8px',
textAlign: 'left',
}}
>
失败原因
</th>
</tr>
</thead>
<tbody>
{importResult.statistics.errors.map((item, index) => (
<tr key={index} style={{ borderBottom: '1px solid #ffcccc' }}>
<td
style={{
border: '1px solid #ffcccc',
padding: '8px',
fontWeight: 'bold',
}}
>
{item.row || index + 1}
</td>
<td
style={{
border: '1px solid #ffcccc',
padding: '8px',
color: '#d93025',
}}
>
{item.error || '未知错误'}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<Button
type="primary"
onClick={handleClose}
style={{
marginTop: '20px',
height: '40px',
borderRadius: designTokens.borderRadius.small,
background: designTokens.colors.primary.gradient,
border: 'none',
color: '#ffffff',
boxShadow: designTokens.shadows.small,
fontWeight: '500',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
确定
</Button>
</div>
) : null}
</Modal>
);
};
export default React.memo(ImportModal);
@@ -0,0 +1,72 @@
import React from 'react';
const resizableTitleStyles = {
container: {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
width: '100%',
paddingRight: '8px',
},
text: {
flex: 1,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
},
resizeHandle: {
width: '10px',
height: '100%',
cursor: 'col-resize',
position: 'absolute',
right: 0,
top: 0,
bottom: 0,
backgroundColor: 'transparent',
transition: 'background-color 0.2s',
},
};
const ResizableTitle = (props) => {
const { children, onResize, width, ...restProps } = props;
const handleMouseDown = (e) => {
if (!onResize) return;
e.preventDefault();
e.stopPropagation();
const th = e.currentTarget.closest('th');
if (!th) return;
const startWidth = th.offsetWidth;
const startX = e.clientX;
const handleMouseMove = (moveEvent) => {
const diff = moveEvent.clientX - startX;
const newWidth = Math.max(50, startWidth + diff);
onResize(newWidth);
};
const handleMouseUp = () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
};
return (
<th {...restProps} style={{ position: 'relative' }}>
<div style={resizableTitleStyles.container}>
<span style={resizableTitleStyles.text}>{children}</span>
{onResize && (
<div onMouseDown={handleMouseDown} style={resizableTitleStyles.resizeHandle} />
)}
</div>
</th>
);
};
export default React.memo(ResizableTitle);
+7
View File
@@ -0,0 +1,7 @@
export { default as ResizableTitle } from './ResizableTitle';
export { default as DeviceDetailModal } from './DeviceDetailModal';
export { default as DeviceFormModal } from './DeviceFormModal';
export { default as ImportModal } from './ImportModal';
export { default as ExportModal } from './ExportModal';
export { default as FieldConfigModal } from './FieldConfigModal';
export { default as BatchStatusModal } from './BatchStatusModal';