refactor: 统一代码风格并迁移至 ESLint 新配置

style(backend): 格式化模型文件代码
style(frontend): 调整组件代码格式
chore: 删除旧 ESLint 配置并添加新配置
refactor(backend): 重构模型定义语法
style: 统一箭头函数和对象属性简写
This commit is contained in:
zhang1106
2026-03-27 19:12:16 +08:00
parent 6a8d4144ff
commit 63f0cb570e
166 changed files with 15483 additions and 11170 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+31 -6
View File
@@ -209,12 +209,21 @@ function CategoryManagement() {
allowClear
prefix={<SearchOutlined />}
/>
<Select value={status} onChange={setStatus} style={{ ...selectStyles.base, width: 120 }}>
<Select
value={status}
onChange={setStatus}
style={{ ...selectStyles.base, width: 120 }}
>
<Option value="all">所有状态</Option>
<Option value="active">启用</Option>
<Option value="inactive">停用</Option>
</Select>
<Button onClick={() => fetchCategories()} style={{ height: '40px', borderRadius: '10px' }}>刷新</Button>
<Button
onClick={() => fetchCategories()}
style={{ height: '40px', borderRadius: '10px' }}
>
刷新
</Button>
</Space>
</Card>
@@ -249,12 +258,22 @@ function CategoryManagement() {
<Input placeholder="请输入分类名称" style={inputStyles.form} />
</Form.Item>
<Form.Item name="description" label="描述">
<Input.TextArea rows={3} placeholder={inputPlaceholders.description} maxLength={200} showCount style={textAreaStyles.base} />
<Input.TextArea
rows={3}
placeholder={inputPlaceholders.description}
maxLength={200}
showCount
style={textAreaStyles.base}
/>
</Form.Item>
<Form.Item name="sortOrder" label="排序">
<InputNumber min={0} placeholder="数值越小越靠前" style={inputNumberStyles.base} />
</Form.Item>
<Form.Item name="status" label="状态" rules={[inputValidationRules.required('请选择状态')]}>
<Form.Item
name="status"
label="状态"
rules={[inputValidationRules.required('请选择状态')]}
>
<Select style={selectStyles.base}>
<Option value="active">启用</Option>
<Option value="inactive">停用</Option>
@@ -262,10 +281,16 @@ function CategoryManagement() {
</Form.Item>
<Form.Item>
<Space>
<Button type="primary" htmlType="submit" style={{ height: '40px', borderRadius: '10px' }}>
<Button
type="primary"
htmlType="submit"
style={{ height: '40px', borderRadius: '10px' }}
>
{editingCategory ? '更新' : '创建'}
</Button>
<Button onClick={handleCancel} style={{ height: '40px', borderRadius: '10px' }}>取消</Button>
<Button onClick={handleCancel} style={{ height: '40px', borderRadius: '10px' }}>
取消
</Button>
</Space>
</Form.Item>
</Form>
+99 -32
View File
@@ -84,7 +84,11 @@ function ConsumableLogs() {
setLoading(true);
const params = { page, pageSize };
if (currentFilters.operationType && currentFilters.operationType !== 'all' && currentFilters.operationType.length > 0) {
if (
currentFilters.operationType &&
currentFilters.operationType !== 'all' &&
currentFilters.operationType.length > 0
) {
params.operationType = currentFilters.operationType.join(',');
}
if (currentFilters.consumableId) {
@@ -169,7 +173,9 @@ function ConsumableLogs() {
<Space direction="vertical" size={0}>
<span>{value}</span>
{record.isConsumableDeleted && (
<Tag color="red" size="small">已删除</Tag>
<Tag color="red" size="small">
已删除
</Tag>
)}
</Space>
</Tooltip>
@@ -288,13 +294,15 @@ function ConsumableLogs() {
width: 200,
render: value => (
<Tooltip title={value || '-'}>
<span style={{
display: 'inline-block',
maxWidth: '180px',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
}}>
<span
style={{
display: 'inline-block',
maxWidth: '180px',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{value || '-'}
</span>
</Tooltip>
@@ -648,7 +656,11 @@ function ConsumableLogs() {
导出 <DownOutlined />
</Button>
</Dropdown>
<Button icon={<UploadOutlined />} onClick={() => setImportModalVisible(true)} style={{ height: '40px', borderRadius: '10px' }}>
<Button
icon={<UploadOutlined />}
onClick={() => setImportModalVisible(true)}
style={{ height: '40px', borderRadius: '10px' }}
>
导入
</Button>
</Space>
@@ -735,17 +747,29 @@ function ConsumableLogs() {
>
<Form form={form} layout="vertical" onFinish={handleEditSubmit}>
<Form.Item label="操作原因" name="reason">
<Input.TextArea rows={2} placeholder={inputPlaceholders.reason} style={textAreaStyles.base} />
<Input.TextArea
rows={2}
placeholder={inputPlaceholders.reason}
style={textAreaStyles.base}
/>
</Form.Item>
<Form.Item label="备注" name="notes">
<Input.TextArea rows={3} placeholder={inputPlaceholders.notes} style={textAreaStyles.base} />
<Input.TextArea
rows={3}
placeholder={inputPlaceholders.notes}
style={textAreaStyles.base}
/>
</Form.Item>
<Form.Item
label="修改原因"
name="modificationReason"
rules={[inputValidationRules.required('请输入修改原因')]}
>
<Input.TextArea rows={2} placeholder="请输入修改原因(必填)" style={textAreaStyles.base} />
<Input.TextArea
rows={2}
placeholder="请输入修改原因(必填)"
style={textAreaStyles.base}
/>
</Form.Item>
<Form.Item label="修改人" name="operator">
<Input placeholder={inputPlaceholders.operator} style={inputStyles.form} />
@@ -788,7 +812,11 @@ function ConsumableLogs() {
<div style={{ fontSize: 12, color: '#666' }}>
<p>
<strong>耗材:</strong> {item.consumableName} ({item.consumableId})
{item.isConsumableDeleted && <Tag color="red" style={{ marginLeft: 8 }}>已删除</Tag>}
{item.isConsumableDeleted && (
<Tag color="red" style={{ marginLeft: 8 }}>
已删除
</Tag>
)}
</p>
<p>
<strong>操作人:</strong> {item.operator}
@@ -806,8 +834,8 @@ function ConsumableLogs() {
{item.consumableSnapshot && (
<p>
<strong>快照信息:</strong> 分类:{item.consumableSnapshot.category || '-'} |
单位:{item.consumableSnapshot.unit || '-'} |
单价:{item.consumableSnapshot.unitPrice || '-'}
单位:{item.consumableSnapshot.unit || '-'} | 单价:
{item.consumableSnapshot.unitPrice || '-'}
</p>
)}
{item.modifiedBy && (
@@ -854,12 +882,25 @@ function ConsumableLogs() {
) : (
<div>
<Card size="small" title="基本信息" style={{ marginBottom: 16 }}>
<p><strong>归档ID:</strong> <code>{currentArchive.archiveId}</code></p>
<p><strong>耗材ID:</strong> <code>{currentArchive.consumableId}</code></p>
<p><strong>耗材名称:</strong> {currentArchive.consumableName}</p>
<p><strong>删除人:</strong> {currentArchive.deletedBy}</p>
<p><strong>删除时间:</strong> {dayjs(currentArchive.deletedAt).format('YYYY-MM-DD HH:mm:ss')}</p>
<p><strong>删除原因:</strong> {currentArchive.deleteReason || '-'}</p>
<p>
<strong>归档ID:</strong> <code>{currentArchive.archiveId}</code>
</p>
<p>
<strong>耗材ID:</strong> <code>{currentArchive.consumableId}</code>
</p>
<p>
<strong>耗材名称:</strong> {currentArchive.consumableName}
</p>
<p>
<strong>删除人:</strong> {currentArchive.deletedBy}
</p>
<p>
<strong>删除时间:</strong>{' '}
{dayjs(currentArchive.deletedAt).format('YYYY-MM-DD HH:mm:ss')}
</p>
<p>
<strong>删除原因:</strong> {currentArchive.deleteReason || '-'}
</p>
</Card>
<Card size="small" title="操作统计" style={{ marginBottom: 16 }}>
@@ -875,20 +916,46 @@ function ConsumableLogs() {
</Col>
</Row>
<Divider style={{ margin: '12px 0' }} />
<p><strong>首次操作:</strong> {currentArchive.firstOperationAt ? dayjs(currentArchive.firstOperationAt).format('YYYY-MM-DD HH:mm:ss') : '-'}</p>
<p><strong>最后操作:</strong> {currentArchive.lastOperationAt ? dayjs(currentArchive.lastOperationAt).format('YYYY-MM-DD HH:mm:ss') : '-'}</p>
<p><strong>删除时库存:</strong> {currentArchive.finalStock}</p>
<p>
<strong>首次操作:</strong>{' '}
{currentArchive.firstOperationAt
? dayjs(currentArchive.firstOperationAt).format('YYYY-MM-DD HH:mm:ss')
: '-'}
</p>
<p>
<strong>最后操作:</strong>{' '}
{currentArchive.lastOperationAt
? dayjs(currentArchive.lastOperationAt).format('YYYY-MM-DD HH:mm:ss')
: '-'}
</p>
<p>
<strong>删除时库存:</strong> {currentArchive.finalStock}
</p>
</Card>
{currentArchive.consumableSnapshot && (
<Card size="small" title="耗材快照">
<p><strong>分类:</strong> {currentArchive.consumableSnapshot.category || '-'}</p>
<p><strong>单位:</strong> {currentArchive.consumableSnapshot.unit || '-'}</p>
<p><strong>单价:</strong> {currentArchive.consumableSnapshot.unitPrice || '-'}</p>
<p><strong>供应商:</strong> {currentArchive.consumableSnapshot.supplier || '-'}</p>
<p><strong>:</strong> {currentArchive.consumableSnapshot.location || '-'}</p>
<p><strong>最小库存:</strong> {currentArchive.consumableSnapshot.minStock || '-'}</p>
<p><strong>最大库存:</strong> {currentArchive.consumableSnapshot.maxStock || '-'}</p>
<p>
<strong>分类:</strong> {currentArchive.consumableSnapshot.category || '-'}
</p>
<p>
<strong>:</strong> {currentArchive.consumableSnapshot.unit || '-'}
</p>
<p>
<strong>单价:</strong> {currentArchive.consumableSnapshot.unitPrice || '-'}
</p>
<p>
<strong>供应商:</strong> {currentArchive.consumableSnapshot.supplier || '-'}
</p>
<p>
<strong>位置:</strong> {currentArchive.consumableSnapshot.location || '-'}
</p>
<p>
<strong>最小库存:</strong> {currentArchive.consumableSnapshot.minStock || '-'}
</p>
<p>
<strong>最大库存:</strong> {currentArchive.consumableSnapshot.maxStock || '-'}
</p>
</Card>
)}
</div>
File diff suppressed because it is too large Load Diff
+204 -114
View File
@@ -49,10 +49,7 @@ import dayjs from 'dayjs';
import api from '../api';
import { consumableRecordAPI, consumableCategoryAPI, consumableAPI } from '../api/cache';
import { message } from 'antd';
import {
selectStyles,
datePickerStyles,
} from '../styles/deviceManagementStyles';
import { selectStyles, datePickerStyles } from '../styles/deviceManagementStyles';
import { designTokens } from '../config/theme';
const { Title, Text } = Typography;
@@ -145,14 +142,16 @@ const QuickFilterBtn = styled(Button)`
padding: 0 16px;
font-size: 13px;
font-weight: 500;
border: 1px solid ${props => props.$active ? designTokens.colors.primary.main : designTokens.colors.border};
background: ${props => props.$active ? designTokens.colors.primary.main : 'transparent'};
color: ${props => props.$active ? 'white' : designTokens.colors.text.secondary};
border: 1px solid
${props => (props.$active ? designTokens.colors.primary.main : designTokens.colors.border)};
background: ${props => (props.$active ? designTokens.colors.primary.main : 'transparent')};
color: ${props => (props.$active ? 'white' : designTokens.colors.text.secondary)};
&:hover {
border-color: ${designTokens.colors.primary.main};
color: ${props => props.$active ? 'white' : designTokens.colors.primary.main};
background: ${props => props.$active ? designTokens.colors.primary.main : 'rgba(99, 102, 241, 0.05)'};
color: ${props => (props.$active ? 'white' : designTokens.colors.primary.main)};
background: ${props =>
props.$active ? designTokens.colors.primary.main : 'rgba(99, 102, 241, 0.05)'};
}
`;
@@ -304,17 +303,17 @@ const StatsCard = styled(motion.div)`
font-weight: 600;
padding: 4px 10px;
border-radius: 20px;
&.up {
color: ${designTokens.colors.success.main};
background: rgba(16, 185, 129, 0.1);
}
&.down {
color: ${designTokens.colors.error.main};
background: rgba(239, 68, 68, 0.1);
}
&.neutral {
color: ${designTokens.colors.text.secondary};
background: rgba(107, 114, 128, 0.1);
@@ -478,7 +477,7 @@ const ComparisonItem = styled.div`
align-items: center;
justify-content: center;
gap: 4px;
strong {
color: ${props => props.$color};
font-weight: 600;
@@ -519,7 +518,7 @@ const CategoryCard = styled(motion.div)`
transform: translateY(-4px);
box-shadow: 0 8px 24px ${props => props.$color}20;
border-color: ${props => props.$color}40;
&::before {
opacity: 1;
}
@@ -567,7 +566,7 @@ const CategoryCard = styled(motion.div)`
color: ${designTokens.colors.text.secondary};
padding-top: 8px;
border-top: 1px solid ${designTokens.colors.border}40;
strong {
color: ${designTokens.colors.text.primary};
font-weight: 600;
@@ -731,7 +730,7 @@ const ConsumableStatistics = () => {
const statsResponse = await consumableRecordAPI.statistics(params);
console.log('[统计] 返回:', statsResponse);
console.log('[统计] 最近记录:', statsResponse?.recentRecords);
setStats({
inCount: statsResponse?.inCount || 0,
outCount: statsResponse?.outCount || 0,
@@ -742,7 +741,7 @@ const ConsumableStatistics = () => {
const summaryResponse = await consumableAPI.getStatistics();
console.log('[汇总] 返回:', summaryResponse);
setSummary({
total: summaryResponse?.total || 0,
lowStock: summaryResponse?.lowStock || 0,
@@ -808,7 +807,7 @@ const ConsumableStatistics = () => {
};
}, [realTimeRefresh]);
const handleQuickFilter = (key) => {
const handleQuickFilter = key => {
setQuickFilter(key);
const filter = quickFilters.find(f => f.key === key);
if (filter) {
@@ -823,10 +822,7 @@ const ConsumableStatistics = () => {
const handleRefresh = () => {
setLoading(true);
setIsAutoRefreshing(false);
Promise.all([
loadStatistics(false),
loadLowStockItems(false)
]).finally(() => {
Promise.all([loadStatistics(false), loadLowStockItems(false)]).finally(() => {
setLoading(false);
if (!realTimeRefresh) {
message.success('数据已手动刷新');
@@ -838,22 +834,30 @@ const ConsumableStatistics = () => {
message.info('导出功能开发中...');
};
const getCategoryColor = (category) => {
const getCategoryColor = category => {
const predefinedColors = [
'#6366f1', '#10b981', '#f59e0b', '#ec4899', '#8b5cf6',
'#06b6d4', '#f97316', '#14b8a6', '#ef4444', '#3b82f6'
'#6366f1',
'#10b981',
'#f59e0b',
'#ec4899',
'#8b5cf6',
'#06b6d4',
'#f97316',
'#14b8a6',
'#ef4444',
'#3b82f6',
];
const colorMap = {
'网络设备': '#6366f1',
'线缆': '#10b981',
'配件': '#f59e0b',
'工具': '#ec4899',
'其他': '#6b7280',
网络设备: '#6366f1',
线缆: '#10b981',
配件: '#f59e0b',
工具: '#ec4899',
其他: '#6b7280',
};
if (colorMap[category]) return colorMap[category];
let hash = 0;
for (let i = 0; i < category.length; i++) {
hash = category.charCodeAt(i) + ((hash << 5) - hash);
@@ -880,23 +884,27 @@ const ConsumableStatistics = () => {
<WarningOutlined />
</Avatar>
<div style={{ minWidth: 0 }}>
<div style={{
fontWeight: 600,
color: designTokens.colors.text.primary,
fontSize: '13px',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}>
<div
style={{
fontWeight: 600,
color: designTokens.colors.text.primary,
fontSize: '13px',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{text}
</div>
<div style={{
fontSize: '11px',
color: designTokens.colors.text.secondary,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}>
<div
style={{
fontSize: '11px',
color: designTokens.colors.text.secondary,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{record.specification || record.category || '-'}
</div>
</div>
@@ -913,12 +921,16 @@ const ConsumableStatistics = () => {
const min = record.minStock || 0;
const isLow = current < min;
return (
<div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'center', gap: 4 }}>
<span style={{
fontWeight: 700,
fontSize: '14px',
color: isLow ? designTokens.colors.error.main : designTokens.colors.text.primary,
}}>
<div
style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'center', gap: 4 }}
>
<span
style={{
fontWeight: 700,
fontSize: '14px',
color: isLow ? designTokens.colors.error.main : designTokens.colors.text.primary,
}}
>
{current}
</span>
<span style={{ color: designTokens.colors.text.secondary, fontSize: '11px' }}>/</span>
@@ -942,13 +954,20 @@ const ConsumableStatistics = () => {
const currentStock = record.currentStock || 0;
if (minStock <= 0) {
return <Text type="secondary" style={{ fontSize: '11px' }}>未设置</Text>;
return (
<Text type="secondary" style={{ fontSize: '11px' }}>
未设置
</Text>
);
}
const rate = Math.min(100, Math.round((currentStock / minStock) * 100));
const color = rate < 30 ? designTokens.colors.error.main :
rate < 60 ? designTokens.colors.warning.main :
designTokens.colors.success.main;
const color =
rate < 30
? designTokens.colors.error.main
: rate < 60
? designTokens.colors.warning.main
: designTokens.colors.success.main;
return (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8 }}>
@@ -959,13 +978,15 @@ const ConsumableStatistics = () => {
showInfo={false}
style={{ width: 60 }}
/>
<span style={{
fontWeight: 600,
fontSize: '12px',
color,
minWidth: 32,
textAlign: 'right',
}}>
<span
style={{
fontWeight: 600,
fontSize: '12px',
color,
minWidth: 32,
textAlign: 'right',
}}
>
{rate}%
</span>
</div>
@@ -980,7 +1001,7 @@ const ConsumableStatistics = () => {
dataIndex: 'type',
key: 'type',
width: 80,
render: (type) => (
render: type => (
<Tag
icon={type === 'in' ? <ArrowDownOutlined /> : <ArrowUpOutlined />}
color={type === 'in' ? 'success' : 'processing'}
@@ -1000,14 +1021,18 @@ const ConsumableStatistics = () => {
<Avatar
size={30}
style={{
background: record.category ? getCategoryColor(record.category) : designTokens.colors.info.main,
background: record.category
? getCategoryColor(record.category)
: designTokens.colors.info.main,
fontSize: '12px',
}}
>
{record.category?.charAt(0) || '耗'}
</Avatar>
<div>
<div style={{ fontWeight: 600, fontSize: '14px', color: designTokens.colors.text.primary }}>
<div
style={{ fontWeight: 600, fontSize: '14px', color: designTokens.colors.text.primary }}
>
{text || '-'}
</div>
{record.category && (
@@ -1030,10 +1055,14 @@ const ConsumableStatistics = () => {
strong
style={{
fontSize: '15px',
color: record.type === 'in' ? designTokens.colors.success.main : designTokens.colors.error.main,
color:
record.type === 'in'
? designTokens.colors.success.main
: designTokens.colors.error.main,
}}
>
{record.type === 'in' ? '+' : '-'}{quantity} {record.unit || '个'}
{record.type === 'in' ? '+' : '-'}
{quantity} {record.unit || '个'}
</Text>
),
},
@@ -1042,7 +1071,7 @@ const ConsumableStatistics = () => {
dataIndex: 'operator',
key: 'operator',
width: 100,
render: (operator) => (
render: operator => (
<Space size={6}>
<Avatar size={24} style={{ background: designTokens.colors.info.main, fontSize: '12px' }}>
{operator?.charAt(0) || '?'}
@@ -1056,7 +1085,7 @@ const ConsumableStatistics = () => {
dataIndex: 'createdAt',
key: 'createdAt',
width: 140,
render: (date) => (
render: date => (
<Text type="secondary" style={{ fontSize: '12px' }}>
{dayjs(date).format('MM-DD HH:mm')}
</Text>
@@ -1086,13 +1115,19 @@ const ConsumableStatistics = () => {
</TitleSection>
<Space>
{lastUpdateTime && (
<div style={{ fontSize: 12, color: designTokens.colors.text.secondary, display: 'flex', alignItems: 'center', gap: 4 }}>
{isAutoRefreshing ? (
<Spin size="small" />
) : (
<span style={{ fontSize: 10 }}></span>
)}
{isAutoRefreshing ? '刷新中...' : `更新于 ${dayjs(lastUpdateTime).format('HH:mm:ss')}`}
<div
style={{
fontSize: 12,
color: designTokens.colors.text.secondary,
display: 'flex',
alignItems: 'center',
gap: 4,
}}
>
{isAutoRefreshing ? <Spin size="small" /> : <span style={{ fontSize: 10 }}></span>}
{isAutoRefreshing
? '刷新中...'
: `更新于 ${dayjs(lastUpdateTime).format('HH:mm:ss')}`}
</div>
)}
<Tooltip title={realTimeRefresh ? '已开启30秒自动刷新' : '已关闭自动刷新'}>
@@ -1154,7 +1189,7 @@ const ConsumableStatistics = () => {
<span className="filter-label">时间范围</span>
<RangePicker
value={dateRange}
onChange={(dates) => {
onChange={dates => {
setDateRange(dates);
setQuickFilter(null);
}}
@@ -1174,7 +1209,9 @@ const ConsumableStatistics = () => {
>
<Option value="all">全部类别</Option>
{categories.map(cat => (
<Option key={cat.id} value={cat.name}>{cat.name}</Option>
<Option key={cat.id} value={cat.name}>
{cat.name}
</Option>
))}
</Select>
</FilterItem>
@@ -1227,7 +1264,9 @@ const ConsumableStatistics = () => {
>
<div className="card-content">
<div className="card-header">
<div className="card-icon"><DatabaseOutlined /></div>
<div className="card-icon">
<DatabaseOutlined />
</div>
<div className="card-trend neutral">
<AppstoreOutlined /> 总览
</div>
@@ -1250,7 +1289,9 @@ const ConsumableStatistics = () => {
>
<div className="card-content">
<div className="card-header">
<div className="card-icon"><WarningOutlined /></div>
<div className="card-icon">
<WarningOutlined />
</div>
{summary?.lowStock > 0 && (
<div className="card-trend down">
<ExclamationCircleOutlined /> 需关注
@@ -1277,7 +1318,9 @@ const ConsumableStatistics = () => {
>
<div className="card-content">
<div className="card-header">
<div className="card-icon"><ArrowDownOutlined /></div>
<div className="card-icon">
<ArrowDownOutlined />
</div>
<div className="card-trend up">
<RiseOutlined /> 入库
</div>
@@ -1302,7 +1345,9 @@ const ConsumableStatistics = () => {
>
<div className="card-content">
<div className="card-header">
<div className="card-icon"><ArrowUpOutlined /></div>
<div className="card-icon">
<ArrowUpOutlined />
</div>
<div className="card-trend neutral">
<FallOutlined /> 出库
</div>
@@ -1318,10 +1363,16 @@ const ConsumableStatistics = () => {
</StatsGrid>
<BentoGrid variants={containerVariants} initial="hidden" animate="visible">
<BentoCard variants={itemVariants} $col="span 6" $iconBg={designTokens.colors.info.gradient}>
<BentoCard
variants={itemVariants}
$col="span 6"
$iconBg={designTokens.colors.info.gradient}
>
<div className="card-header">
<div className="header-left">
<div className="header-icon"><ShoppingCartOutlined /></div>
<div className="header-icon">
<ShoppingCartOutlined />
</div>
<span className="header-title">出入库统计</span>
</div>
<div className="header-extra">
@@ -1334,28 +1385,36 @@ const ConsumableStatistics = () => {
$bg="rgba(16, 185, 129, 0.04)"
$color={designTokens.colors.success.main}
>
<div className="item-icon"><ArrowDownOutlined /></div>
<div className="item-icon">
<ArrowDownOutlined />
</div>
<div className="item-value">{stats?.inCount || 0}</div>
<div className="item-label">入库次数</div>
<div className="item-sub"> <strong>{stats?.inQuantity || 0}</strong> </div>
<div className="item-sub">
<strong>{stats?.inQuantity || 0}</strong>
</div>
</ComparisonItem>
<ComparisonItem
$bg="rgba(99, 102, 241, 0.04)"
$color={designTokens.colors.primary.main}
>
<div className="item-icon"><ArrowUpOutlined /></div>
<div className="item-icon">
<ArrowUpOutlined />
</div>
<div className="item-value">{stats?.outCount || 0}</div>
<div className="item-label">出库次数</div>
<div className="item-sub"> <strong>{stats?.outQuantity || 0}</strong> </div>
<div className="item-sub">
<strong>{stats?.outQuantity || 0}</strong>
</div>
</ComparisonItem>
</InOutComparison>
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
style={{
marginTop: 20,
padding: '18px 24px',
style={{
marginTop: 20,
padding: '18px 24px',
background: `linear-gradient(135deg, ${netQuantity >= 0 ? 'rgba(16, 185, 129, 0.08)' : 'rgba(239, 68, 68, 0.08)'} 0%, ${netQuantity >= 0 ? 'rgba(5, 150, 105, 0.04)' : 'rgba(185, 28, 28, 0.04)'} 100%)`,
borderRadius: 14,
display: 'flex',
@@ -1368,14 +1427,22 @@ const ConsumableStatistics = () => {
{netQuantity >= 0 ? (
<>
<RiseOutlined style={{ color: designTokens.colors.success.main, fontSize: 20 }} />
<Text style={{ color: designTokens.colors.success.main, fontWeight: 600, fontSize: 15 }}>
<Text
style={{
color: designTokens.colors.success.main,
fontWeight: 600,
fontSize: 15,
}}
>
净入库 +{netQuantity}
</Text>
</>
) : (
<>
<FallOutlined style={{ color: designTokens.colors.error.main, fontSize: 20 }} />
<Text style={{ color: designTokens.colors.error.main, fontWeight: 600, fontSize: 15 }}>
<Text
style={{ color: designTokens.colors.error.main, fontWeight: 600, fontSize: 15 }}
>
净出库 {Math.abs(netQuantity)}
</Text>
</>
@@ -1384,10 +1451,16 @@ const ConsumableStatistics = () => {
</div>
</BentoCard>
<BentoCard variants={itemVariants} $col="span 6" $iconBg={designTokens.colors.secondary.gradient}>
<BentoCard
variants={itemVariants}
$col="span 6"
$iconBg={designTokens.colors.secondary.gradient}
>
<div className="card-header">
<div className="header-left">
<div className="header-icon"><PieChartOutlined /></div>
<div className="header-icon">
<PieChartOutlined />
</div>
<span className="header-title">类别分布</span>
</div>
</div>
@@ -1426,10 +1499,16 @@ const ConsumableStatistics = () => {
</BentoGrid>
<BentoGrid variants={containerVariants} initial="hidden" animate="visible">
<BentoCard variants={itemVariants} $col="span 6" $iconBg={designTokens.colors.warning.gradient}>
<BentoCard
variants={itemVariants}
$col="span 6"
$iconBg={designTokens.colors.warning.gradient}
>
<div className="card-header">
<div className="header-left">
<div className="header-icon"><ExclamationCircleOutlined /></div>
<div className="header-icon">
<ExclamationCircleOutlined />
</div>
<span className="header-title">库存预警</span>
</div>
{lowStockItems.length > 0 ? (
@@ -1438,14 +1517,16 @@ const ConsumableStatistics = () => {
style={{ backgroundColor: designTokens.colors.warning.main }}
/>
) : (
<span style={{
fontSize: 12,
color: designTokens.colors.success.main,
fontWeight: 500,
padding: '4px 10px',
background: 'rgba(16, 185, 129, 0.1)',
borderRadius: 8
}}>
<span
style={{
fontSize: 12,
color: designTokens.colors.success.main,
fontWeight: 500,
padding: '4px 10px',
background: 'rgba(16, 185, 129, 0.1)',
borderRadius: 8,
}}
>
全部充足
</span>
)}
@@ -1461,15 +1542,18 @@ const ConsumableStatistics = () => {
total: lowStockItems.length,
showSizeChanger: false,
showQuickJumper: false,
showTotal: (total) => `${total}`,
onChange: (page) => setLowStockPagination(prev => ({ ...prev, current: page })),
showTotal: total => `${total}`,
onChange: page => setLowStockPagination(prev => ({ ...prev, current: page })),
}}
size="small"
scroll={{ x: 'max-content', y: 300 }}
locale={{
emptyText: (
<EmptyState>
<BoxPlotOutlined className="empty-icon" style={{ color: designTokens.colors.success.main }} />
<BoxPlotOutlined
className="empty-icon"
style={{ color: designTokens.colors.success.main }}
/>
<div className="empty-text">库存充足</div>
<div className="empty-subtext">所有耗材均在安全范围内</div>
</EmptyState>
@@ -1479,10 +1563,16 @@ const ConsumableStatistics = () => {
</div>
</BentoCard>
<BentoCard variants={itemVariants} $col="span 6" $iconBg={designTokens.colors.success.gradient}>
<BentoCard
variants={itemVariants}
$col="span 6"
$iconBg={designTokens.colors.success.gradient}
>
<div className="card-header">
<div className="header-left">
<div className="header-icon"><HistoryOutlined /></div>
<div className="header-icon">
<HistoryOutlined />
</div>
<span className="header-title">最近记录</span>
</div>
<div className="header-extra">最近10条</div>
+41 -21
View File
@@ -1,6 +1,14 @@
import React, { useState, useCallback, useMemo } from 'react';
import { Card, Row, Col, Typography, message } from 'antd';
import { DatabaseOutlined, CloudServerOutlined, WarningOutlined, HomeOutlined, TeamOutlined, BarChartOutlined, DashboardOutlined } from '@ant-design/icons';
import {
DatabaseOutlined,
CloudServerOutlined,
WarningOutlined,
HomeOutlined,
TeamOutlined,
BarChartOutlined,
DashboardOutlined,
} from '@ant-design/icons';
import api from '../api';
import { designTokens } from '../config/theme';
import { useFetch } from '../hooks/useSWR';
@@ -185,22 +193,33 @@ function Dashboard() {
let currentAngle = 0;
if (runningDeg > 0) {
gradientParts.push(`${PIE_CHART_COLORS.success} ${currentAngle}deg ${currentAngle + runningDeg}deg`);
gradientParts.push(
`${PIE_CHART_COLORS.success} ${currentAngle}deg ${currentAngle + runningDeg}deg`
);
currentAngle += runningDeg;
}
if (maintenanceDeg > 0) {
gradientParts.push(`${PIE_CHART_COLORS.warning} ${currentAngle}deg ${currentAngle + maintenanceDeg}deg`);
gradientParts.push(
`${PIE_CHART_COLORS.warning} ${currentAngle}deg ${currentAngle + maintenanceDeg}deg`
);
currentAngle += maintenanceDeg;
}
if (faultDeg > 0) {
gradientParts.push(`${PIE_CHART_COLORS.error} ${currentAngle}deg ${currentAngle + faultDeg}deg`);
gradientParts.push(
`${PIE_CHART_COLORS.error} ${currentAngle}deg ${currentAngle + faultDeg}deg`
);
currentAngle += faultDeg;
}
if (offlineDeg > 0) {
gradientParts.push(`${PIE_CHART_COLORS.primary} ${currentAngle}deg ${currentAngle + offlineDeg}deg`);
gradientParts.push(
`${PIE_CHART_COLORS.primary} ${currentAngle}deg ${currentAngle + offlineDeg}deg`
);
}
const conicGradient = gradientParts.length > 0 ? `conic-gradient(${gradientParts.join(', ')})` : 'conic-gradient(#e5e7eb 0deg 360deg)';
const conicGradient =
gradientParts.length > 0
? `conic-gradient(${gradientParts.join(', ')})`
: 'conic-gradient(#e5e7eb 0deg 360deg)';
return {
width: '180px',
@@ -215,11 +234,11 @@ function Dashboard() {
}, [deviceStatusPercentages]);
const handleRefresh = useCallback(async () => {
setAnimatedKey((prev) => prev + 1);
setAnimatedKey(prev => prev + 1);
await mutateStats();
}, [mutateStats]);
const handleHover = useCallback((key) => {
const handleHover = useCallback(key => {
setHoveredCard(key);
}, []);
@@ -325,17 +344,14 @@ function Dashboard() {
[stats.deviceGrowth, stats.faultTrend, stats.userGrowth, stats.ticketTrend]
);
const deviceTrendData = useMemo(
() => {
const rawData = statsData?.deviceTrendData || [];
return rawData.map((item) => ({
label: item.label,
value: item.value,
color: designTokens.colors.primary.main
}));
},
[statsData?.deviceTrendData]
);
const deviceTrendData = useMemo(() => {
const rawData = statsData?.deviceTrendData || [];
return rawData.map(item => ({
label: item.label,
value: item.value,
color: designTokens.colors.primary.main,
}));
}, [statsData?.deviceTrendData]);
const styles = `
@keyframes fadeInDown {
@@ -375,7 +391,7 @@ function Dashboard() {
</div>
<Row gutter={[24, 24]} style={{ marginBottom: '32px' }}>
{statCards.map((config) => (
{statCards.map(config => (
<StatCard
key={config.statKey}
config={config}
@@ -544,7 +560,11 @@ function Dashboard() {
</p>
</div>
<QuickStats onlineRate={stats.onlineRate} powerUsage={stats.powerUsage} totalMaxPower={stats.totalMaxPower} />
<QuickStats
onlineRate={stats.onlineRate}
powerUsage={stats.powerUsage}
totalMaxPower={stats.totalMaxPower}
/>
</Col>
<Col xs={24} md={8}>
+70 -60
View File
@@ -45,51 +45,51 @@ const OptionsEditor = ({ value = [], onChange }) => {
};
const handleUpdate = (index, field, fieldValue) => {
const newOptions = value.map((opt, i) =>
i === index ? { ...opt, [field]: fieldValue } : opt
);
const newOptions = value.map((opt, i) => (i === index ? { ...opt, [field]: fieldValue } : opt));
onChange(newOptions);
};
return (
<div style={{
border: '1px solid #e8e8e8',
borderRadius: '12px',
padding: '20px',
background: 'linear-gradient(135deg, #fafbfc 0%, #f5f7fa 100%)',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.04)',
}}>
<div style={{
display: 'flex',
alignItems: 'center',
marginBottom: '16px',
gap: '8px',
}}>
<div style={{
width: '4px',
height: '16px',
background: 'linear-gradient(180deg, #667eea 0%, #764ba2 100%)',
borderRadius: '2px',
}}/>
<span style={{ color: '#333', fontSize: '14px', fontWeight: '600' }}>
选项配置
</span>
<span style={{ color: '#999', fontSize: '12px' }}>
值用于提交标签用于显示
</span>
<div
style={{
border: '1px solid #e8e8e8',
borderRadius: '12px',
padding: '20px',
background: 'linear-gradient(135deg, #fafbfc 0%, #f5f7fa 100%)',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.04)',
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
marginBottom: '16px',
gap: '8px',
}}
>
<div
style={{
width: '4px',
height: '16px',
background: 'linear-gradient(180deg, #667eea 0%, #764ba2 100%)',
borderRadius: '2px',
}}
/>
<span style={{ color: '#333', fontSize: '14px', fontWeight: '600' }}>选项配置</span>
<span style={{ color: '#999', fontSize: '12px' }}>值用于提交标签用于显示</span>
</div>
{value.length === 0 ? (
<div style={{
textAlign: 'center',
padding: '24px',
background: '#fff',
borderRadius: '8px',
border: '1px dashed #d9d9d9',
}}>
<div style={{ color: '#bbb', fontSize: '14px', marginBottom: '12px' }}>
暂无选项
</div>
<div
style={{
textAlign: 'center',
padding: '24px',
background: '#fff',
borderRadius: '8px',
border: '1px dashed #d9d9d9',
}}
>
<div style={{ color: '#bbb', fontSize: '14px', marginBottom: '12px' }}>暂无选项</div>
<Button
type="primary"
icon={<PlusCircleOutlined />}
@@ -105,15 +105,23 @@ const OptionsEditor = ({ value = [], onChange }) => {
</Button>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px', marginBottom: '16px' }}>
<div style={{
display: 'flex',
gap: '12px',
padding: '0 4px',
marginBottom: '4px',
}}>
<span style={{ width: '160px', color: '#666', fontSize: '12px', fontWeight: '500' }}>value</span>
<span style={{ width: '160px', color: '#666', fontSize: '12px', fontWeight: '500' }}>标签label</span>
<div
style={{ display: 'flex', flexDirection: 'column', gap: '10px', marginBottom: '16px' }}
>
<div
style={{
display: 'flex',
gap: '12px',
padding: '0 4px',
marginBottom: '4px',
}}
>
<span style={{ width: '160px', color: '#666', fontSize: '12px', fontWeight: '500' }}>
value
</span>
<span style={{ width: '160px', color: '#666', fontSize: '12px', fontWeight: '500' }}>
标签label
</span>
</div>
{value.map((opt, index) => (
<div
@@ -129,19 +137,21 @@ const OptionsEditor = ({ value = [], onChange }) => {
transition: 'all 0.2s ease',
}}
>
<div style={{
width: '24px',
height: '24px',
borderRadius: '50%',
background: 'linear-gradient(135deg, #667eea20 0%, #764ba220 100%)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#667eea',
fontSize: '12px',
fontWeight: '600',
flexShrink: 0,
}}>
<div
style={{
width: '24px',
height: '24px',
borderRadius: '50%',
background: 'linear-gradient(135deg, #667eea20 0%, #764ba220 100%)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#667eea',
fontSize: '12px',
fontWeight: '600',
flexShrink: 0,
}}
>
{index + 1}
</div>
<Input
+149 -82
View File
@@ -69,11 +69,7 @@ import {
BatchStatusModal,
} from '../components/device';
import { useDebounce } from '../hooks/useDebounce';
import {
getDeviceTypeIcon,
getStatusConfig,
processDeviceData,
} from '../utils/deviceUtils.jsx';
import { getDeviceTypeIcon, getStatusConfig, processDeviceData } from '../utils/deviceUtils.jsx';
const { Option } = Select;
@@ -165,7 +161,7 @@ function DeviceManagement() {
} else {
setAllDevices(processedDevices);
}
setPagination((prev) => ({ ...prev, current: page, pageSize, total }));
setPagination(prev => ({ ...prev, current: page, pageSize, total }));
hasMoreRef.current = page * pageSize < total;
} catch (error) {
message.error('获取设备列表失败');
@@ -183,7 +179,7 @@ function DeviceManagement() {
setLoadingFields(true);
const response = await axios.get('/api/deviceFields');
let fields = response.data.sort((a, b) => a.order - b.order);
// 补充缺失的 options(状态和设备类型)
fields = fields.map(field => {
if (field.fieldName === 'type' && !field.options) {
@@ -191,12 +187,14 @@ function DeviceManagement() {
return { ...field, options: defaultTypeField?.options || [] };
}
if (field.fieldName === 'status' && !field.options) {
const defaultStatusField = DEFAULT_DEVICE_FIELDS_LOCAL.find(f => f.fieldName === 'status');
const defaultStatusField = DEFAULT_DEVICE_FIELDS_LOCAL.find(
f => f.fieldName === 'status'
);
return { ...field, options: defaultStatusField?.options || [] };
}
return field;
});
setDeviceFields(fields);
} catch (error) {
message.error('获取字段配置失败');
@@ -275,7 +273,13 @@ function DeviceManagement() {
const handleLoadMoreDevices = useCallback(() => {
if (!hasMoreRef.current || isLoadingRef.current || deviceLoadingMore) return;
if (debouncedKeyword || status !== 'all' || type !== 'all' || roomId !== 'all' || rackId !== 'all') {
if (
debouncedKeyword ||
status !== 'all' ||
type !== 'all' ||
roomId !== 'all' ||
rackId !== 'all'
) {
return;
}
isLoadingRef.current = true;
@@ -283,7 +287,17 @@ function DeviceManagement() {
fetchDevices(nextPage, pagination.pageSize, true).then(() => {
isLoadingRef.current = false;
});
}, [pagination.current, pagination.pageSize, debouncedKeyword, status, type, roomId, rackId, deviceLoadingMore, fetchDevices]);
}, [
pagination.current,
pagination.pageSize,
debouncedKeyword,
status,
type,
roomId,
rackId,
deviceLoadingMore,
fetchDevices,
]);
useEffect(() => {
if (!loadMoreRef.current) return;
@@ -291,7 +305,13 @@ function DeviceManagement() {
const observer = new IntersectionObserver(
entries => {
if (entries[0].isIntersecting && hasMoreRef.current && !deviceLoadingMore) {
if (!debouncedKeyword && status === 'all' && type === 'all' && roomId === 'all' && rackId === 'all') {
if (
!debouncedKeyword &&
status === 'all' &&
type === 'all' &&
roomId === 'all' &&
rackId === 'all'
) {
handleLoadMoreDevices();
}
}
@@ -316,7 +336,7 @@ function DeviceManagement() {
setEditingDevice(null);
};
const handleSubmit = async (deviceData) => {
const handleSubmit = async deviceData => {
try {
if (editingDevice) {
await axios.put(`/api/devices/${editingDevice.deviceId}`, deviceData);
@@ -336,7 +356,7 @@ function DeviceManagement() {
}
};
const handleSearch = (values) => {
const handleSearch = values => {
setSearching(true);
setKeyword(values.keyword || '');
@@ -345,7 +365,7 @@ function DeviceManagement() {
setRoomId(values.roomId || 'all');
setRackId(values.rackId || 'all');
setPagination((prev) => ({ ...prev, current: 1 }));
setPagination(prev => ({ ...prev, current: 1 }));
setTimeout(() => setSearching(false), 300);
};
@@ -363,7 +383,7 @@ function DeviceManagement() {
setTimeout(() => setSearching(false), 300);
};
const handleTableChange = (newPagination) => {
const handleTableChange = newPagination => {
setPagination(newPagination);
const start = (newPagination.current - 1) * newPagination.pageSize;
@@ -453,7 +473,7 @@ function DeviceManagement() {
});
};
const handleDelete = async (deviceId) => {
const handleDelete = async deviceId => {
Modal.confirm({
title: '确认删除',
content: '确定要删除这个设备吗?',
@@ -473,18 +493,18 @@ function DeviceManagement() {
});
};
const handleShowDetail = (device) => {
const handleShowDetail = device => {
setSelectedDevice(device);
setDetailModalVisible(true);
};
const handleViewDeviceTickets = (device) => {
const handleViewDeviceTickets = device => {
navigate(
`/tickets?deviceId=${device.deviceId}&deviceName=${encodeURIComponent(device.name)}&serialNumber=${encodeURIComponent(device.serialNumber || '')}&view=true`
);
};
const handleCreateTicketForDevice = (device) => {
const handleCreateTicketForDevice = device => {
navigate(
`/tickets?deviceId=${device.deviceId}&deviceName=${encodeURIComponent(device.name)}&serialNumber=${encodeURIComponent(device.serialNumber || '')}&create=true`
);
@@ -498,7 +518,7 @@ function DeviceManagement() {
setBatchStatusModalVisible(true);
};
const handleBatchStatusChange = async (newStatus) => {
const handleBatchStatusChange = async newStatus => {
setBatchStatusLoading(true);
try {
const response = await axios.put('/api/devices/batch-status', {
@@ -532,9 +552,9 @@ function DeviceManagement() {
if (scope === 'selected') {
deviceIds = selectedDevices;
} else if (scope === 'currentPage') {
deviceIds = allDevices.map((device) => device.deviceId);
deviceIds = allDevices.map(device => device.deviceId);
} else if (scope === 'all') {
deviceIds = allDevices.map((device) => device.deviceId);
deviceIds = allDevices.map(device => device.deviceId);
}
if (deviceIds.length === 0) {
@@ -543,7 +563,7 @@ function DeviceManagement() {
}
const params = new URLSearchParams();
deviceIds.forEach((id) => params.append('deviceIds', id));
deviceIds.forEach(id => params.append('deviceIds', id));
params.append('format', format);
const response = await axios.get(`/api/devices/enhanced-export?${params.toString()}`, {
@@ -575,7 +595,7 @@ function DeviceManagement() {
headers: {
'Content-Type': 'multipart/form-data',
},
onUploadProgress: (progressEvent) => {
onUploadProgress: progressEvent => {
const progress = Math.round((progressEvent.loaded * 50) / progressEvent.total);
callbacks.onProgress(Math.min(progress, 50), '正在上传文件...');
},
@@ -617,7 +637,7 @@ function DeviceManagement() {
Array.isArray(data.errors) &&
data.errors.length > 0
) {
errorDetails = data.errors.map((err) => ({
errorDetails = data.errors.map(err => ({
row: err.row || 0,
error: err.error || err.message || '服务器内部错误',
}));
@@ -645,7 +665,7 @@ function DeviceManagement() {
}
};
const handleSaveFieldConfig = async (updatedFields) => {
const handleSaveFieldConfig = async updatedFields => {
try {
const response = await axios.post('/api/deviceFields/config', updatedFields);
setDeviceFields(response.data);
@@ -657,26 +677,26 @@ function DeviceManagement() {
}
};
const handleResetFieldConfig = (defaultFields) => {
const handleResetFieldConfig = defaultFields => {
setDeviceFields(defaultFields);
};
const handleSelectionChange = (selectedRowKeys) => {
const handleSelectionChange = selectedRowKeys => {
setSelectedDevices(selectedRowKeys);
setSelectAll(selectedRowKeys.length === allDevices.length && allDevices.length > 0);
};
const handleHeaderCellResize = (key) => (column) => ({
const handleHeaderCellResize = key => column => ({
width: column.width,
onResize: (width) => {
setColumnWidths((prev) => ({ ...prev, [key]: width }));
onResize: width => {
setColumnWidths(prev => ({ ...prev, [key]: width }));
},
});
const columns = useMemo(() => {
const generatedColumns = [];
deviceFields.forEach((field) => {
deviceFields.forEach(field => {
if (!field.visible) return;
if (field.fieldName === 'rackId') {
@@ -701,7 +721,7 @@ function DeviceManagement() {
key: field.fieldName,
width: columnWidths[field.fieldName] || 100,
onHeaderCell: handleHeaderCellResize(field.fieldName),
render: (type) => (
render: type => (
<Space>
{getDeviceTypeIcon(type)}
<span>{TYPE_MAP[type]}</span>
@@ -715,7 +735,7 @@ function DeviceManagement() {
key: field.fieldName,
width: columnWidths[field.fieldName] || 90,
onHeaderCell: handleHeaderCellResize(field.fieldName),
render: (status) => {
render: status => {
const config = STATUS_MAP[status] || { text: status, color: 'default' };
const statusStyles = {
running: { bg: '#f6ffed', border: '#52c41a', text: '#389e0d' },
@@ -724,7 +744,11 @@ function DeviceManagement() {
fault: { bg: '#fff2f0', border: '#ff4d4f', text: '#cf1322' },
idle: { bg: '#E6FFFA', border: '#36cfc9', text: '#08979d' },
};
const style = statusStyles[status] || { bg: '#fafafa', border: '#d9d9d9', text: '#595959' };
const style = statusStyles[status] || {
bg: '#fafafa',
border: '#d9d9d9',
text: '#595959',
};
return (
<Tag
style={{
@@ -752,7 +776,7 @@ function DeviceManagement() {
key: field.fieldName,
width: columnWidths[field.fieldName] || 120,
onHeaderCell: handleHeaderCellResize(field.fieldName),
render: (date) => {
render: date => {
if (!date) return '';
const dateObj = new Date(date);
@@ -809,8 +833,8 @@ function DeviceManagement() {
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
onMouseEnter={(e) => (e.target.style.textDecoration = 'underline')}
onMouseLeave={(e) => (e.target.style.textDecoration = 'none')}
onMouseEnter={e => (e.target.style.textDecoration = 'underline')}
onMouseLeave={e => (e.target.style.textDecoration = 'none')}
>
{value || '-'}
</a>
@@ -993,17 +1017,38 @@ function DeviceManagement() {
style={{ width: '100%' }}
className="filter-form"
>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: designTokens.spacing.md, alignItems: 'center', width: '100%' }}>
<div style={{
<div
style={{
display: 'flex',
flexWrap: 'wrap',
gap: designTokens.spacing.md,
alignItems: 'center',
gap: designTokens.spacing.sm,
padding: '0 12px',
borderRight: `1px solid ${designTokens.colors.border.light}`,
marginRight: 4,
}}>
<FilterOutlined style={{ color: designTokens.colors.primary.main, fontSize: '16px' }} />
<span style={{ fontSize: '13px', fontWeight: 500, color: designTokens.colors.text.secondary, whiteSpace: 'nowrap' }}>筛选</span>
width: '100%',
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: designTokens.spacing.sm,
padding: '0 12px',
borderRight: `1px solid ${designTokens.colors.border.light}`,
marginRight: 4,
}}
>
<FilterOutlined
style={{ color: designTokens.colors.primary.main, fontSize: '16px' }}
/>
<span
style={{
fontSize: '13px',
fontWeight: 500,
color: designTokens.colors.text.secondary,
whiteSpace: 'nowrap',
}}
>
筛选
</span>
</div>
<Form.Item name="keyword" style={{ margin: 0 }}>
@@ -1017,7 +1062,7 @@ function DeviceManagement() {
transition: `all ${designTokens.transitions.fast}`,
}}
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
onChange={e => setKeyword(e.target.value)}
allowClear
/>
</Form.Item>
@@ -1064,10 +1109,14 @@ function DeviceManagement() {
display: 'flex',
alignItems: 'center',
gap: '6px',
color: advancedSearchVisible ? designTokens.colors.primary.main : designTokens.colors.text.secondary,
color: advancedSearchVisible
? designTokens.colors.primary.main
: designTokens.colors.text.secondary,
fontWeight: 500,
borderRadius: designTokens.borderRadius.medium,
background: advancedSearchVisible ? `${designTokens.colors.primary.main}10` : 'transparent',
background: advancedSearchVisible
? `${designTokens.colors.primary.main}10`
: 'transparent',
transition: `all ${designTokens.transitions.fast}`,
}}
>
@@ -1117,30 +1166,43 @@ function DeviceManagement() {
</div>
{advancedSearchVisible && (
<div style={{
display: 'flex',
flexWrap: 'wrap',
gap: designTokens.spacing.md,
padding: `${designTokens.spacing.md}px 0`,
borderTop: `1px dashed ${designTokens.colors.border.light}`,
marginTop: designTokens.spacing.sm,
}}>
<div style={{
<div
style={{
display: 'flex',
alignItems: 'center',
gap: designTokens.spacing.sm,
padding: '0 12px',
borderRight: `1px solid ${designTokens.colors.border.light}`,
marginRight: 4,
}}>
flexWrap: 'wrap',
gap: designTokens.spacing.md,
padding: `${designTokens.spacing.md}px 0`,
borderTop: `1px dashed ${designTokens.colors.border.light}`,
marginTop: designTokens.spacing.sm,
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: designTokens.spacing.sm,
padding: '0 12px',
borderRight: `1px solid ${designTokens.colors.border.light}`,
marginRight: 4,
}}
>
<EnvironmentOutlined style={{ color: '#3b82f6', fontSize: '16px' }} />
<span style={{ fontSize: '13px', fontWeight: 500, color: designTokens.colors.text.secondary, whiteSpace: 'nowrap' }}>位置筛选</span>
<span
style={{
fontSize: '13px',
fontWeight: 500,
color: designTokens.colors.text.secondary,
whiteSpace: 'nowrap',
}}
>
位置筛选
</span>
</div>
<Form.Item name="roomId" style={{ margin: 0 }}>
<Select
value={roomId}
onChange={(value) => {
onChange={value => {
setRoomId(value);
setRackId('all');
}}
@@ -1149,7 +1211,7 @@ function DeviceManagement() {
suffixIcon={<EnvironmentOutlined style={{ color: '#3b82f6' }} />}
>
<Option value="all">所有机房</Option>
{rooms.map((room) => (
{rooms.map(room => (
<Option key={room.roomId} value={room.roomId}>
{room.name}
</Option>
@@ -1169,8 +1231,8 @@ function DeviceManagement() {
>
<Option value="all">所有机柜</Option>
{racks
.filter((rack) => roomId === 'all' || rack.roomId === roomId)
.map((rack) => (
.filter(rack => roomId === 'all' || rack.roomId === roomId)
.map(rack => (
<Option key={rack.rackId} value={rack.rackId}>
{rack.name}
</Option>
@@ -1217,7 +1279,7 @@ function DeviceManagement() {
showSizeChanger: true,
showQuickJumper: true,
pageSizeOptions: ['10', '20', '30', '50', '100'],
showTotal: (total) => `${total} 条记录`,
showTotal: total => `${total} 条记录`,
style: { marginTop: '16px' },
}}
onChange={handleTableChange}
@@ -1243,7 +1305,7 @@ function DeviceManagement() {
key: 'all',
text: '全选',
onSelect: () => {
const allIds = allDevices.map((device) => device.deviceId);
const allIds = allDevices.map(device => device.deviceId);
setSelectedDevices(allIds);
setSelectAll(true);
},
@@ -1252,8 +1314,8 @@ function DeviceManagement() {
key: 'invert',
text: '反选',
onSelect: () => {
const visibleIds = allDevices.map((device) => device.deviceId);
const newSelected = visibleIds.filter((id) => !selectedDevices.includes(id));
const visibleIds = allDevices.map(device => device.deviceId);
const newSelected = visibleIds.filter(id => !selectedDevices.includes(id));
setSelectedDevices(newSelected);
setSelectAll(newSelected.length === allDevices.length);
},
@@ -1268,11 +1330,11 @@ function DeviceManagement() {
},
],
}}
onRow={(record) => ({
onRow={record => ({
onClick: () =>
handleSelectionChange(
selectedDevices.includes(record.deviceId)
? selectedDevices.filter((id) => id !== record.deviceId)
? selectedDevices.filter(id => id !== record.deviceId)
: [...selectedDevices, record.deviceId]
),
})}
@@ -1283,11 +1345,16 @@ function DeviceManagement() {
return index % 2 === 0 ? 'ant-table-row-even' : 'ant-table-row-odd';
}}
/>
{hasMoreRef.current && !debouncedKeyword && status === 'all' && type === 'all' && roomId === 'all' && rackId === 'all' && (
<div ref={loadMoreRef} style={{ textAlign: 'center', padding: '20px' }}>
{deviceLoadingMore && <Spin tip="加载更多设备..." />}
</div>
)}
{hasMoreRef.current &&
!debouncedKeyword &&
status === 'all' &&
type === 'all' &&
roomId === 'all' &&
rackId === 'all' && (
<div ref={loadMoreRef} style={{ textAlign: 'center', padding: '20px' }}>
{deviceLoadingMore && <Spin tip="加载更多设备..." />}
</div>
)}
{!hasMoreRef.current && allDevices.length > 0 && (
<div style={{ textAlign: 'center', padding: '16px', color: '#999' }}>
已加载全部 {pagination.total} 个设备
+8 -17
View File
@@ -38,9 +38,7 @@ function ErrorBoundaryTest() {
color: '#fff',
}}
>
<h1 style={{ fontSize: '24px', marginBottom: '8px' }}>
🧪 错误边界测试页面
</h1>
<h1 style={{ fontSize: '24px', marginBottom: '8px' }}>🧪 错误边界测试页面</h1>
<p style={{ opacity: 0.9 }}>
用于测试 React 错误边界Error Boundary功能确保单个组件错误不会导致整个页面崩溃
</p>
@@ -62,11 +60,7 @@ function ErrorBoundaryTest() {
<Card title="测试 2:页面级别错误边界" size="small">
<p>这个测试会触发页面级别的错误边界</p>
<Button
type="primary"
danger
onClick={() => setShowError(true)}
>
<Button type="primary" danger onClick={() => setShowError(true)}>
触发页面错误
</Button>
{showError && <BrokenComponent />}
@@ -95,9 +89,7 @@ function ErrorBoundaryTest() {
textAlign: 'center',
}}
>
<h3 style={{ color: '#ff4d4f', margin: '0 0 8px 0' }}>
自定义错误提示
</h3>
<h3 style={{ color: '#ff4d4f', margin: '0 0 8px 0' }}> 自定义错误提示</h3>
<p style={{ margin: 0, color: '#666' }}>
这是自定义的 fallback UI当组件出错时会显示这个界面
</p>
@@ -131,16 +123,15 @@ function ErrorBoundaryTest() {
<Card title="使用说明" size="small">
<ul style={{ lineHeight: '2' }}>
<li>
<strong>错误边界Error Boundary</strong>
React 提供的错误处理机制可以捕获子组件树中的 JavaScript 错误
<strong>错误边界Error Boundary</strong> React
提供的错误处理机制可以捕获子组件树中的 JavaScript 错误
</li>
<li>
<strong>作用</strong>
防止单个组件的错误导致整个应用崩溃提供友好的错误提示界面
<strong>作用</strong> 防止单个组件的错误导致整个应用崩溃提供友好的错误提示界面
</li>
<li>
<strong>实现方式</strong>
使用 React.Component componentDidCatch getDerivedStateFromError 生命周期方法
<strong>实现方式</strong> 使用 React.Component componentDidCatch
getDerivedStateFromError 生命周期方法
</li>
<li>
<strong>注意事项</strong>
+363 -188
View File
@@ -68,7 +68,7 @@ const IdleDeviceManagement = () => {
};
const response = await axios.get('/api/idle-devices', { params });
setIdleDevices(response.data.idleDevices || []);
setPagination((prev) => ({
setPagination(prev => ({
...prev,
total: response.data.total || 0,
}));
@@ -113,7 +113,7 @@ const IdleDeviceManagement = () => {
setIsModalVisible(true);
};
const handleEdit = (record) => {
const handleEdit = record => {
setEditingDevice(record);
let roomId = null;
if (record.rackId && record.Rack) {
@@ -136,7 +136,7 @@ const IdleDeviceManagement = () => {
setIsModalVisible(true);
};
const handleDelete = async (deviceId) => {
const handleDelete = async deviceId => {
try {
await axios.delete(`/api/idle-devices/${deviceId}`);
message.success('删除成功');
@@ -146,7 +146,7 @@ const IdleDeviceManagement = () => {
}
};
const handleShelve = (record) => {
const handleShelve = record => {
setShelvingDevice(record);
setShelvePositionConflict(null);
setShelveSelectedRackId(null);
@@ -195,7 +195,7 @@ const IdleDeviceManagement = () => {
}
};
const handleShelveRackChange = (value) => {
const handleShelveRackChange = value => {
setShelveSelectedRackId(value);
const position = shelveForm.getFieldValue('position');
const height = shelveForm.getFieldValue('height');
@@ -206,7 +206,7 @@ const IdleDeviceManagement = () => {
}
};
const handleShelvePositionChange = (e) => {
const handleShelvePositionChange = e => {
const value = e.target.value ? parseInt(e.target.value) : null;
const height = shelveForm.getFieldValue('height');
if (shelveSelectedRackId && value) {
@@ -216,7 +216,7 @@ const IdleDeviceManagement = () => {
}
};
const handleShelveHeightChange = (e) => {
const handleShelveHeightChange = e => {
const value = e.target.value ? parseInt(e.target.value) : null;
const position = shelveForm.getFieldValue('position');
if (shelveSelectedRackId && position) {
@@ -279,7 +279,7 @@ const IdleDeviceManagement = () => {
}
};
const getIdleDays = (idleDate) => {
const getIdleDays = idleDate => {
if (!idleDate) return 0;
const diff = new Date() - new Date(idleDate);
return Math.floor(diff / (1000 * 60 * 60 * 24));
@@ -292,7 +292,10 @@ const IdleDeviceManagement = () => {
width: 60,
align: 'center',
render: (_, __, index) => (
<Badge count={index + 1 + (pagination.current - 1) * pagination.pageSize} style={{ backgroundColor: '#f59e0b' }} />
<Badge
count={index + 1 + (pagination.current - 1) * pagination.pageSize}
style={{ backgroundColor: '#f59e0b' }}
/>
),
},
{
@@ -302,16 +305,36 @@ const IdleDeviceManagement = () => {
render: (_, record) => (
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<Avatar
style={{ backgroundColor: record.type === 'server' ? '#3b82f6' : record.type === 'switch' ? '#8b5cf6' : '#64748b' }}
style={{
backgroundColor:
record.type === 'server'
? '#3b82f6'
: record.type === 'switch'
? '#8b5cf6'
: '#64748b',
}}
icon={<InboxOutlined />}
/>
<div>
<Text strong style={{ fontSize: '14px', display: 'block' }}>{record.name || '-'}</Text>
<Text strong style={{ fontSize: '14px', display: 'block' }}>
{record.name || '-'}
</Text>
<Space size={4}>
<Tag color={record.type === 'server' ? 'blue' : record.type === 'switch' ? 'purple' : 'default'} style={{ marginRight: 0 }}>
<Tag
color={
record.type === 'server'
? 'blue'
: record.type === 'switch'
? 'purple'
: 'default'
}
style={{ marginRight: 0 }}
>
{record.type === 'server' ? '服务器' : record.type === 'switch' ? '交换机' : '其他'}
</Tag>
<Text type="secondary" style={{ fontSize: '12px' }}>{record.model || '-'}</Text>
<Text type="secondary" style={{ fontSize: '12px' }}>
{record.model || '-'}
</Text>
</Space>
</div>
</div>
@@ -322,8 +345,10 @@ const IdleDeviceManagement = () => {
dataIndex: 'deviceId',
key: 'deviceId',
width: 100,
render: (text) => (
<Text code style={{ fontSize: '12px', padding: '2px 6px' }}>{text}</Text>
render: text => (
<Text code style={{ fontSize: '12px', padding: '2px 6px' }}>
{text}
</Text>
),
},
{
@@ -340,7 +365,13 @@ const IdleDeviceManagement = () => {
);
}
if (record.sourceType === 'rack' && record.Rack) {
const location = [record.Rack.Room?.name, record.Rack.name, record.position ? `U${record.position}` : null].filter(Boolean).join(' / ');
const location = [
record.Rack.Room?.name,
record.Rack.name,
record.position ? `U${record.position}` : null,
]
.filter(Boolean)
.join(' / ');
return (
<Space>
<InboxOutlined style={{ color: '#3b82f6' }} />
@@ -363,7 +394,9 @@ const IdleDeviceManagement = () => {
<div style={{ textAlign: 'center' }}>
<Text style={{ color, fontWeight: 600, fontSize: '16px' }}>{days}</Text>
<br />
<Text type="secondary" style={{ fontSize: '11px' }}></Text>
<Text type="secondary" style={{ fontSize: '11px' }}>
</Text>
</div>
);
},
@@ -374,9 +407,11 @@ const IdleDeviceManagement = () => {
key: 'idleReason',
width: 140,
ellipsis: true,
render: (text) => (
render: text => (
<Tooltip title={text || '-'}>
<Text type="secondary" ellipsis>{text || '-'}</Text>
<Text type="secondary" ellipsis>
{text || '-'}
</Text>
</Tooltip>
),
},
@@ -386,7 +421,7 @@ const IdleDeviceManagement = () => {
key: 'sourceType',
width: 80,
align: 'center',
render: (type) => (
render: type => (
<Tag color={type === 'warehouse' ? 'green' : 'blue'}>
{type === 'warehouse' ? '库房' : '机架'}
</Tag>
@@ -409,11 +444,27 @@ const IdleDeviceManagement = () => {
/>
</Tooltip>
<Tooltip title="编辑">
<Button type="text" icon={<EditOutlined />} onClick={() => handleEdit(record)} style={{ borderRadius: '6px', color: '#3b82f6' }} />
<Button
type="text"
icon={<EditOutlined />}
onClick={() => handleEdit(record)}
style={{ borderRadius: '6px', color: '#3b82f6' }}
/>
</Tooltip>
<Popconfirm title="确认删除?" onConfirm={() => handleDelete(record.deviceId)} okText="确认" cancelText="取消" okButtonProps={{ danger: true }}>
<Popconfirm
title="确认删除?"
onConfirm={() => handleDelete(record.deviceId)}
okText="确认"
cancelText="取消"
okButtonProps={{ danger: true }}
>
<Tooltip title="删除">
<Button type="text" danger icon={<DeleteOutlined />} style={{ borderRadius: '6px' }} />
<Button
type="text"
danger
icon={<DeleteOutlined />}
style={{ borderRadius: '6px' }}
/>
</Tooltip>
</Popconfirm>
</Space>
@@ -452,7 +503,9 @@ const IdleDeviceManagement = () => {
return (
<div style={{ minHeight: '100vh', background: '#f8fafc', padding: '24px' }}>
<div style={{ marginBottom: '24px' }}>
<Title level={4} style={{ marginBottom: '4px', color: '#1e293b' }}>空闲设备管理</Title>
<Title level={4} style={{ marginBottom: '4px', color: '#1e293b' }}>
空闲设备管理
</Title>
<Text type="secondary">管理已下线或空闲的设备支持恢复领用到设备管理</Text>
</div>
@@ -468,24 +521,38 @@ const IdleDeviceManagement = () => {
}}
bodyStyle={{ padding: '20px' }}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div
style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}
>
<div>
<Text type="secondary" style={{ fontSize: '13px' }}>{stat.title}</Text>
<div style={{ fontSize: '28px', fontWeight: 700, color: stat.color, lineHeight: 1.2, marginTop: '4px' }}>
<Text type="secondary" style={{ fontSize: '13px' }}>
{stat.title}
</Text>
<div
style={{
fontSize: '28px',
fontWeight: 700,
color: stat.color,
lineHeight: 1.2,
marginTop: '4px',
}}
>
{stat.value}
</div>
</div>
<div style={{
width: '48px',
height: '48px',
borderRadius: '12px',
background: 'rgba(255,255,255,0.7)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '20px',
color: stat.color
}}>
<div
style={{
width: '48px',
height: '48px',
borderRadius: '12px',
background: 'rgba(255,255,255,0.7)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '20px',
color: stat.color,
}}
>
{stat.icon}
</div>
</div>
@@ -502,12 +569,14 @@ const IdleDeviceManagement = () => {
}}
bodyStyle={{ padding: 0 }}
>
<div style={{
padding: '20px 24px',
borderBottom: '1px solid #f1f5f9',
background: 'linear-gradient(135deg, #fefefe 0%, #f8fafc 100%)',
borderRadius: '16px 16px 0 0',
}}>
<div
style={{
padding: '20px 24px',
borderBottom: '1px solid #f1f5f9',
background: 'linear-gradient(135deg, #fefefe 0%, #f8fafc 100%)',
borderRadius: '16px 16px 0 0',
}}
>
<Row gutter={16} align="middle">
<Col flex="auto">
<Space size="middle" wrap>
@@ -516,7 +585,7 @@ const IdleDeviceManagement = () => {
prefix={<SearchOutlined style={{ color: '#94a3b8' }} />}
style={{ borderRadius: '10px', width: '260px', height: '40px' }}
value={searchKeyword}
onChange={(e) => setSearchKeyword(e.target.value)}
onChange={e => setSearchKeyword(e.target.value)}
allowClear
/>
<Select
@@ -528,7 +597,11 @@ const IdleDeviceManagement = () => {
<Option value="rack">机架</Option>
<Option value="warehouse">库房</Option>
</Select>
<Button icon={<ReloadOutlined />} onClick={fetchIdleDevices} style={{ height: 40, borderRadius: '10px' }}>
<Button
icon={<ReloadOutlined />}
onClick={fetchIdleDevices}
style={{ height: 40, borderRadius: '10px' }}
>
刷新
</Button>
</Space>
@@ -561,12 +634,14 @@ const IdleDeviceManagement = () => {
loading={loading}
pagination={false}
scroll={{ x: 1100 }}
rowClassName={(record, index) => index % 2 === 0 ? 'table-row-even' : 'table-row-odd'}
rowClassName={(record, index) => (index % 2 === 0 ? 'table-row-even' : 'table-row-odd')}
style={{ borderRadius: '0 0 16px 16px' }}
/>
{pagination.total > 0 && (
<div style={{ padding: '16px 24px', borderTop: '1px solid #f1f5f9', background: '#fafafa' }}>
<div
style={{ padding: '16px 24px', borderTop: '1px solid #f1f5f9', background: '#fafafa' }}
>
<Row justify="space-between" align="middle">
<Col>
<Text type="secondary">
@@ -579,11 +654,11 @@ const IdleDeviceManagement = () => {
pageSize={pagination.pageSize}
total={pagination.total}
onChange={(page, pageSize) =>
setPagination((prev) => ({ ...prev, current: page, pageSize }))
setPagination(prev => ({ ...prev, current: page, pageSize }))
}
showSizeChanger
showQuickJumper
showTotal={(total) => `${total}`}
showTotal={total => `${total}`}
size="small"
/>
</Col>
@@ -595,12 +670,14 @@ const IdleDeviceManagement = () => {
<Modal
title={
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<div style={{
width: '8px',
height: '8px',
borderRadius: '50%',
background: editingDevice ? '#3b82f6' : '#f59e0b'
}} />
<div
style={{
width: '8px',
height: '8px',
borderRadius: '50%',
background: editingDevice ? '#3b82f6' : '#f59e0b',
}}
/>
{editingDevice ? '编辑空闲设备' : '添加空闲设备'}
</div>
}
@@ -615,28 +692,38 @@ const IdleDeviceManagement = () => {
style={{ top: 100 }}
>
<Form form={form} layout="vertical" size="middle">
<div style={{
background: 'linear-gradient(135deg, #fefefe 0%, #f8fafc 100%)',
borderRadius: '12px',
padding: '20px',
marginBottom: '16px',
border: '1px solid #e2e8f0'
}}>
<div style={{
fontSize: '14px',
fontWeight: 600,
color: '#334155',
<div
style={{
background: 'linear-gradient(135deg, #fefefe 0%, #f8fafc 100%)',
borderRadius: '12px',
padding: '20px',
marginBottom: '16px',
display: 'flex',
alignItems: 'center',
gap: '8px'
}}>
<div style={{ width: '4px', height: '16px', background: '#3b82f6', borderRadius: '2px' }} />
border: '1px solid #e2e8f0',
}}
>
<div
style={{
fontSize: '14px',
fontWeight: 600,
color: '#334155',
marginBottom: '16px',
display: 'flex',
alignItems: 'center',
gap: '8px',
}}
>
<div
style={{ width: '4px', height: '16px', background: '#3b82f6', borderRadius: '2px' }}
/>
设备基本信息
</div>
<Row gutter={16}>
<Col span={12}>
<Form.Item name="name" label="设备名称" rules={[{ required: true, message: '请输入设备名称' }]}>
<Form.Item
name="name"
label="设备名称"
rules={[{ required: true, message: '请输入设备名称' }]}
>
<Input placeholder="请输入设备名称" style={{ borderRadius: '8px' }} />
</Form.Item>
</Col>
@@ -667,36 +754,51 @@ const IdleDeviceManagement = () => {
<Row gutter={16}>
<Col span={12}>
<Form.Item name="powerConsumption" label="功耗(W)">
<Input type="number" placeholder="请输入功耗" min={0} style={{ borderRadius: '8px' }} />
<Input
type="number"
placeholder="请输入功耗"
min={0}
style={{ borderRadius: '8px' }}
/>
</Form.Item>
</Col>
{editingDevice && (
<Col span={12}>
<Form.Item label="设备ID">
<Input value={editingDevice.deviceId} disabled style={{ borderRadius: '8px' }} />
<Input
value={editingDevice.deviceId}
disabled
style={{ borderRadius: '8px' }}
/>
</Form.Item>
</Col>
)}
</Row>
</div>
<div style={{
background: 'linear-gradient(135deg, #fefefe 0%, #f8fafc 100%)',
borderRadius: '12px',
padding: '20px',
marginBottom: '16px',
border: '1px solid #e2e8f0'
}}>
<div style={{
fontSize: '14px',
fontWeight: 600,
color: '#334155',
<div
style={{
background: 'linear-gradient(135deg, #fefefe 0%, #f8fafc 100%)',
borderRadius: '12px',
padding: '20px',
marginBottom: '16px',
display: 'flex',
alignItems: 'center',
gap: '8px'
}}>
<div style={{ width: '4px', height: '16px', background: '#22c55e', borderRadius: '2px' }} />
border: '1px solid #e2e8f0',
}}
>
<div
style={{
fontSize: '14px',
fontWeight: 600,
color: '#334155',
marginBottom: '16px',
display: 'flex',
alignItems: 'center',
gap: '8px',
}}
>
<div
style={{ width: '4px', height: '16px', background: '#22c55e', borderRadius: '2px' }}
/>
位置信息
</div>
<Row gutter={16}>
@@ -705,7 +807,7 @@ const IdleDeviceManagement = () => {
<Select
placeholder="请选择机房"
allowClear
onChange={(value) => {
onChange={value => {
setSelectedRoomId(value);
form.setFieldsValue({ rackId: null, position: null });
if (value) {
@@ -714,7 +816,7 @@ const IdleDeviceManagement = () => {
}}
style={{ borderRadius: '8px' }}
>
{rooms.map((room) => (
{rooms.map(room => (
<Option key={room.roomId} value={room.roomId}>
{room.name}
</Option>
@@ -725,14 +827,14 @@ const IdleDeviceManagement = () => {
<Col span={8}>
<Form.Item name="rackId" label="机柜">
<Select
placeholder={selectedRoomId ? "请选择机柜" : "请先选择机房"}
placeholder={selectedRoomId ? '请选择机柜' : '请先选择机房'}
allowClear
disabled={!selectedRoomId}
style={{ borderRadius: '8px' }}
>
{racks
.filter((rack) => rack.roomId === selectedRoomId)
.map((rack) => (
.filter(rack => rack.roomId === selectedRoomId)
.map(rack => (
<Option key={rack.rackId} value={rack.rackId}>
{rack.name}
</Option>
@@ -742,11 +844,19 @@ const IdleDeviceManagement = () => {
</Col>
<Col span={8}>
<Form.Item name="position" label="U位">
<Input type="number" placeholder="请输入U位" min={1} disabled={!selectedRoomId} style={{ borderRadius: '8px' }} />
<Input
type="number"
placeholder="请输入U位"
min={1}
disabled={!selectedRoomId}
style={{ borderRadius: '8px' }}
/>
</Form.Item>
</Col>
</Row>
<div style={{ textAlign: 'center', color: '#94a3b8', fontSize: '12px', margin: '8px 0' }}>
<div
style={{ textAlign: 'center', color: '#94a3b8', fontSize: '12px', margin: '8px 0' }}
>
</div>
<Row gutter={16}>
@@ -769,28 +879,37 @@ const IdleDeviceManagement = () => {
</Row>
</div>
<div style={{
background: 'linear-gradient(135deg, #fefefe 0%, #f8fafc 100%)',
borderRadius: '12px',
padding: '20px',
border: '1px solid #e2e8f0'
}}>
<div style={{
fontSize: '14px',
fontWeight: 600,
color: '#334155',
marginBottom: '16px',
display: 'flex',
alignItems: 'center',
gap: '8px'
}}>
<div style={{ width: '4px', height: '16px', background: '#64748b', borderRadius: '2px' }} />
<div
style={{
background: 'linear-gradient(135deg, #fefefe 0%, #f8fafc 100%)',
borderRadius: '12px',
padding: '20px',
border: '1px solid #e2e8f0',
}}
>
<div
style={{
fontSize: '14px',
fontWeight: 600,
color: '#334155',
marginBottom: '16px',
display: 'flex',
alignItems: 'center',
gap: '8px',
}}
>
<div
style={{ width: '4px', height: '16px', background: '#64748b', borderRadius: '2px' }}
/>
附加信息
</div>
<Row gutter={16}>
<Col span={24}>
<Form.Item name="idleReason" label="空闲原因">
<Input placeholder="请输入空闲原因,如:设备下线、备件库存等" style={{ borderRadius: '8px' }} />
<Input
placeholder="请输入空闲原因,如:设备下线、备件库存等"
style={{ borderRadius: '8px' }}
/>
</Form.Item>
</Col>
</Row>
@@ -808,7 +927,9 @@ const IdleDeviceManagement = () => {
<Modal
title={
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<div style={{ width: '8px', height: '8px', borderRadius: '50%', background: '#22c55e' }} />
<div
style={{ width: '8px', height: '8px', borderRadius: '50%', background: '#22c55e' }}
/>
设备上架
</div>
}
@@ -822,33 +943,47 @@ const IdleDeviceManagement = () => {
bodyStyle={{ padding: '24px' }}
>
<Form form={shelveForm} layout="vertical" size="middle">
<div style={{
background: 'linear-gradient(135deg, #fefefe 0%, #f8fafc 100%)',
borderRadius: '12px',
padding: '20px',
marginBottom: '16px',
border: '1px solid #e2e8f0'
}}>
<div style={{
fontSize: '14px',
fontWeight: 600,
color: '#334155',
<div
style={{
background: 'linear-gradient(135deg, #fefefe 0%, #f8fafc 100%)',
borderRadius: '12px',
padding: '20px',
marginBottom: '16px',
display: 'flex',
alignItems: 'center',
gap: '8px'
}}>
<div style={{ width: '4px', height: '16px', background: '#3b82f6', borderRadius: '2px' }} />
border: '1px solid #e2e8f0',
}}
>
<div
style={{
fontSize: '14px',
fontWeight: 600,
color: '#334155',
marginBottom: '16px',
display: 'flex',
alignItems: 'center',
gap: '8px',
}}
>
<div
style={{ width: '4px', height: '16px', background: '#3b82f6', borderRadius: '2px' }}
/>
设备基本信息
</div>
<Row gutter={16}>
<Col span={12}>
<Form.Item name="name" label="设备名称" rules={[{ required: true, message: '请输入设备名称' }]}>
<Form.Item
name="name"
label="设备名称"
rules={[{ required: true, message: '请输入设备名称' }]}
>
<Input placeholder="请输入设备名称" style={{ borderRadius: '8px' }} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="type" label="设备类型" rules={[{ required: true, message: '请选择设备类型' }]}>
<Form.Item
name="type"
label="设备类型"
rules={[{ required: true, message: '请选择设备类型' }]}
>
<Select placeholder="请选择设备类型" style={{ borderRadius: '8px' }}>
<Option value="server">服务器</Option>
<Option value="switch">交换机</Option>
@@ -866,7 +1001,11 @@ const IdleDeviceManagement = () => {
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="serialNumber" label="序列号" rules={[{ required: true, message: '请输入序列号' }]}>
<Form.Item
name="serialNumber"
label="序列号"
rules={[{ required: true, message: '请输入序列号' }]}
>
<Input placeholder="请输入序列号" style={{ borderRadius: '8px' }} />
</Form.Item>
</Col>
@@ -874,34 +1013,50 @@ const IdleDeviceManagement = () => {
<Row gutter={16}>
<Col span={12}>
<Form.Item name="height" label="高度(U)">
<Input type="number" placeholder="请输入高度" min={1} style={{ borderRadius: '8px' }} />
<Input
type="number"
placeholder="请输入高度"
min={1}
style={{ borderRadius: '8px' }}
/>
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="powerConsumption" label="功率(W)">
<Input type="number" placeholder="请输入功率" min={0} style={{ borderRadius: '8px' }} />
<Input
type="number"
placeholder="请输入功率"
min={0}
style={{ borderRadius: '8px' }}
/>
</Form.Item>
</Col>
</Row>
</div>
<div style={{
background: 'linear-gradient(135deg, #fefefe 0%, #f8fafc 100%)',
borderRadius: '12px',
padding: '20px',
marginBottom: '16px',
border: '1px solid #e2e8f0'
}}>
<div style={{
fontSize: '14px',
fontWeight: 600,
color: '#334155',
<div
style={{
background: 'linear-gradient(135deg, #fefefe 0%, #f8fafc 100%)',
borderRadius: '12px',
padding: '20px',
marginBottom: '16px',
display: 'flex',
alignItems: 'center',
gap: '8px'
}}>
<div style={{ width: '4px', height: '16px', background: '#22c55e', borderRadius: '2px' }} />
border: '1px solid #e2e8f0',
}}
>
<div
style={{
fontSize: '14px',
fontWeight: 600,
color: '#334155',
marginBottom: '16px',
display: 'flex',
alignItems: 'center',
gap: '8px',
}}
>
<div
style={{ width: '4px', height: '16px', background: '#22c55e', borderRadius: '2px' }}
/>
上架位置
</div>
<Row gutter={16}>
@@ -909,13 +1064,13 @@ const IdleDeviceManagement = () => {
<Form.Item name="roomId" label="机房">
<Select
placeholder="请选择机房"
onChange={(value) => {
onChange={value => {
setSelectedShelveRoomId(value);
shelveForm.setFieldsValue({ rackId: null });
}}
style={{ borderRadius: '8px' }}
>
{rooms.map((room) => (
{rooms.map(room => (
<Option key={room.roomId} value={room.roomId}>
{room.name}
</Option>
@@ -926,14 +1081,14 @@ const IdleDeviceManagement = () => {
<Col span={8}>
<Form.Item name="rackId" label="机柜">
<Select
placeholder={selectedShelveRoomId ? "请选择机柜" : "请先选择机房"}
placeholder={selectedShelveRoomId ? '请选择机柜' : '请先选择机房'}
disabled={!selectedShelveRoomId}
style={{ borderRadius: '8px' }}
onChange={handleShelveRackChange}
>
{racks
.filter((rack) => rack.roomId === selectedShelveRoomId)
.map((rack) => (
.filter(rack => rack.roomId === selectedShelveRoomId)
.map(rack => (
<Option key={rack.rackId} value={rack.rackId}>
{rack.name}
</Option>
@@ -943,47 +1098,67 @@ const IdleDeviceManagement = () => {
</Col>
<Col span={8}>
<Form.Item name="position" label="U位">
<Input type="number" placeholder="请输入U位" min={1} style={{ borderRadius: '8px' }} onChange={handleShelvePositionChange} />
<Input
type="number"
placeholder="请输入U位"
min={1}
style={{ borderRadius: '8px' }}
onChange={handleShelvePositionChange}
/>
</Form.Item>
</Col>
</Row>
{shelvePositionConflict && (
<div style={{ marginTop: '12px' }}>
<div style={{
background: '#fef2f2',
border: '1px solid #fecaca',
borderRadius: '8px',
padding: '12px 16px',
display: 'flex',
alignItems: 'flex-start',
gap: '10px'
}}>
<ExclamationCircleOutlined style={{ color: '#ef4444', fontSize: '18px', marginTop: '2px' }} />
<div
style={{
background: '#fef2f2',
border: '1px solid #fecaca',
borderRadius: '8px',
padding: '12px 16px',
display: 'flex',
alignItems: 'flex-start',
gap: '10px',
}}
>
<ExclamationCircleOutlined
style={{ color: '#ef4444', fontSize: '18px', marginTop: '2px' }}
/>
<div>
<div style={{ color: '#dc2626', fontWeight: 600, marginBottom: '4px' }}>U位冲突</div>
<div style={{ color: '#991b1b', fontSize: '13px' }}>{shelvePositionConflict}</div>
<div style={{ color: '#dc2626', fontWeight: 600, marginBottom: '4px' }}>
U位冲突
</div>
<div style={{ color: '#991b1b', fontSize: '13px' }}>
{shelvePositionConflict}
</div>
</div>
</div>
</div>
)}
</div>
<div style={{
background: 'linear-gradient(135deg, #fefefe 0%, #f8fafc 100%)',
borderRadius: '12px',
padding: '20px',
border: '1px solid #e2e8f0'
}}>
<div style={{
fontSize: '14px',
fontWeight: 600,
color: '#334155',
marginBottom: '16px',
display: 'flex',
alignItems: 'center',
gap: '8px'
}}>
<div style={{ width: '4px', height: '16px', background: '#64748b', borderRadius: '2px' }} />
<div
style={{
background: 'linear-gradient(135deg, #fefefe 0%, #f8fafc 100%)',
borderRadius: '12px',
padding: '20px',
border: '1px solid #e2e8f0',
}}
>
<div
style={{
fontSize: '14px',
fontWeight: 600,
color: '#334155',
marginBottom: '16px',
display: 'flex',
alignItems: 'center',
gap: '8px',
}}
>
<div
style={{ width: '4px', height: '16px', background: '#64748b', borderRadius: '2px' }}
/>
备注信息
</div>
<Row gutter={16}>
@@ -1022,4 +1197,4 @@ const IdleDeviceManagement = () => {
);
};
export default IdleDeviceManagement;
export default IdleDeviceManagement;
+106 -106
View File
@@ -48,7 +48,7 @@ const api = axios.create({
baseURL: '/api',
});
api.interceptors.request.use((config) => {
api.interceptors.request.use(config => {
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
@@ -92,7 +92,7 @@ const InventoryManagement = () => {
};
const res = await api.get('/inventory/plans', { params });
setPlans(res.data.plans || []);
setPagination((prev) => ({
setPagination(prev => ({
...prev,
total: res.data.total || 0,
}));
@@ -119,7 +119,7 @@ const InventoryManagement = () => {
const fetchRooms = async () => {
try {
const res = await api.get('/rooms', { params: { pageSize: 1000 } });
setRooms(Array.isArray(res.data) ? res.data : (res.data.rooms || []));
setRooms(Array.isArray(res.data) ? res.data : res.data.rooms || []);
} catch (error) {
console.error('获取机房失败', error);
}
@@ -128,7 +128,7 @@ const InventoryManagement = () => {
const fetchRacks = async () => {
try {
const res = await api.get('/racks', { params: { pageSize: 1000 } });
const allRacks = Array.isArray(res.data) ? res.data : (res.data.racks || []);
const allRacks = Array.isArray(res.data) ? res.data : res.data.racks || [];
setRacks(allRacks);
setFilteredRacks(allRacks);
} catch (error) {
@@ -157,20 +157,21 @@ const InventoryManagement = () => {
setModalVisible(true);
};
const handleEdit = (record) => {
const handleEdit = record => {
setEditingPlan(record);
const targetRooms = record.targetRooms || [];
setSelectedRooms(targetRooms);
if (targetRooms.length > 0) {
const filtered = racks.filter(rack =>
targetRooms.includes(rack.roomId) || (rack.Room && targetRooms.includes(rack.Room.roomId))
const filtered = racks.filter(
rack =>
targetRooms.includes(rack.roomId) || (rack.Room && targetRooms.includes(rack.Room.roomId))
);
setFilteredRacks(filtered);
} else {
setFilteredRacks(racks);
}
form.setFieldsValue({
name: record.name,
type: record.type,
@@ -182,7 +183,7 @@ const InventoryManagement = () => {
setModalVisible(true);
};
const handleDelete = async (planId) => {
const handleDelete = async planId => {
Modal.confirm({
title: '确认删除',
content: '确定要删除这个盘点计划吗?此操作不可恢复!',
@@ -202,7 +203,7 @@ const InventoryManagement = () => {
});
};
const handleSubmit = async (values) => {
const handleSubmit = async values => {
try {
const data = {
...values,
@@ -226,7 +227,7 @@ const InventoryManagement = () => {
}
};
const handleStart = async (plan) => {
const handleStart = async plan => {
try {
await api.post(`/inventory/plans/${plan.planId}/start`);
message.success('盘点任务已启动');
@@ -237,7 +238,7 @@ const InventoryManagement = () => {
}
};
const handleComplete = async (plan) => {
const handleComplete = async plan => {
try {
await api.post(`/inventory/plans/${plan.planId}/complete`);
message.success('盘点已完成');
@@ -248,23 +249,23 @@ const InventoryManagement = () => {
}
};
const handleViewTasks = (plan) => {
const handleViewTasks = plan => {
navigate(`/inventory/execution?planId=${plan.planId}`);
};
const handleRoomsChange = (roomIds) => {
const handleRoomsChange = roomIds => {
setSelectedRooms(roomIds || []);
if (!roomIds || roomIds.length === 0) {
setFilteredRacks(racks);
} else {
const filtered = racks.filter(rack =>
roomIds.includes(rack.roomId) || (rack.Room && roomIds.includes(rack.Room.roomId))
const filtered = racks.filter(
rack => roomIds.includes(rack.roomId) || (rack.Room && roomIds.includes(rack.Room.roomId))
);
setFilteredRacks(filtered);
}
};
const getStatusTag = (status) => {
const getStatusTag = status => {
const statusMap = {
draft: { color: 'default', text: '草稿', icon: <FileSearchOutlined /> },
pending: { color: 'orange', text: '待执行', icon: <ClockCircleOutlined /> },
@@ -274,17 +275,13 @@ const InventoryManagement = () => {
};
const config = statusMap[status] || statusMap.draft;
return (
<Tag
color={config.color}
icon={config.icon}
style={{ borderRadius: 6, padding: '2px 8px' }}
>
<Tag color={config.color} icon={config.icon} style={{ borderRadius: 6, padding: '2px 8px' }}>
{config.text}
</Tag>
);
};
const getTypeTag = (type) => {
const getTypeTag = type => {
const typeMap = {
full: { color: 'blue', text: '全面盘点' },
partial: { color: 'cyan', text: '局部盘点' },
@@ -294,7 +291,7 @@ const InventoryManagement = () => {
return <Tag color={config.color}>{config.text}</Tag>;
};
const getProgressPercent = (plan) => {
const getProgressPercent = plan => {
if (!plan.totalDevices || plan.totalDevices === 0) return 0;
return Math.round((plan.checkedDevices / plan.totalDevices) * 100);
};
@@ -334,14 +331,14 @@ const InventoryManagement = () => {
dataIndex: 'type',
key: 'type',
width: 100,
render: (type) => getTypeTag(type),
render: type => getTypeTag(type),
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 120,
render: (status) => getStatusTag(status),
render: status => getStatusTag(status),
},
{
title: '盘点进度',
@@ -357,8 +354,8 @@ const InventoryManagement = () => {
{getProgressPercent(record)}%
</span>
</div>
<Progress
percent={getProgressPercent(record)}
<Progress
percent={getProgressPercent(record)}
size="small"
strokeColor={{
'0%': '#108ee9',
@@ -372,25 +369,26 @@ const InventoryManagement = () => {
title: '异常设备',
key: 'abnormal',
width: 100,
render: (_, record) => (
record.abnormalDevices > 0 ?
<Tag color="error">{record.abnormalDevices} 异常</Tag> :
render: (_, record) =>
record.abnormalDevices > 0 ? (
<Tag color="error">{record.abnormalDevices} 异常</Tag>
) : (
<span style={{ color: '#8c8c8c' }}>-</span>
),
),
},
{
title: '创建人',
dataIndex: ['Creator', 'realName'],
key: 'creator',
width: 100,
render: (name) => name || '-',
render: name => name || '-',
},
{
title: '创建时间',
dataIndex: 'createdAt',
key: 'createdAt',
width: 160,
render: (date) => (date ? dayjs(date).format('YYYY-MM-DD HH:mm') : '-'),
render: date => (date ? dayjs(date).format('YYYY-MM-DD HH:mm') : '-'),
},
{
title: '操作',
@@ -482,21 +480,29 @@ const InventoryManagement = () => {
];
return (
<div style={{ padding: 24, background: designTokens.colors.background.secondary, minHeight: '100vh' }}>
<div
style={{
padding: 24,
background: designTokens.colors.background.secondary,
minHeight: '100vh',
}}
>
<div style={{ marginBottom: 24 }}>
<Row gutter={[16, 16]}>
{statCards.map((stat, index) => (
<Col xs={24} sm={8} key={index}>
<Card
bordered={false}
style={{
<Card
bordered={false}
style={{
borderRadius: 16,
boxShadow: '0 2px 8px rgba(0,0,0,0.06)',
background: stat.gradient,
}}
bodyStyle={{ padding: 20 }}
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div
style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}
>
<div>
<div style={{ color: 'rgba(255,255,255,0.9)', fontSize: 14, marginBottom: 8 }}>
{stat.title}
@@ -505,17 +511,19 @@ const InventoryManagement = () => {
{stat.value || 0}
</div>
</div>
<div style={{
width: 56,
height: 56,
borderRadius: 12,
background: 'rgba(255,255,255,0.2)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 24,
color: '#fff'
}}>
<div
style={{
width: 56,
height: 56,
borderRadius: 12,
background: 'rgba(255,255,255,0.2)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 24,
color: '#fff',
}}
>
{stat.icon}
</div>
</div>
@@ -525,25 +533,27 @@ const InventoryManagement = () => {
</Row>
</div>
<Card
bordered={false}
style={{ borderRadius: 16, boxShadow: '0 2px 8px rgba(0,0,0,0.06)' }}
>
<Tabs
activeKey={activeTab}
onChange={setActiveTab}
style={{ marginBottom: 16 }}
>
<Card bordered={false} style={{ borderRadius: 16, boxShadow: '0 2px 8px rgba(0,0,0,0.06)' }}>
<Tabs activeKey={activeTab} onChange={setActiveTab} style={{ marginBottom: 16 }}>
<TabPane tab="盘点计划列表" key="list">
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 12 }}>
<div
style={{
marginBottom: 16,
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
flexWrap: 'wrap',
gap: 12,
}}
>
<div style={{ display: 'flex', gap: 12, alignItems: 'center', flexWrap: 'wrap' }}>
<Input
placeholder="搜索计划名称"
prefix={<SearchOutlined />}
style={{ width: 240, borderRadius: 8 }}
onChange={(e) => setSearchParamsObj({ keyword: e.target.value })}
onChange={e => setSearchParamsObj({ keyword: e.target.value })}
onPressEnter={() => {
setPagination((prev) => ({ ...prev, current: 1 }));
setPagination(prev => ({ ...prev, current: 1 }));
fetchPlans();
}}
/>
@@ -551,9 +561,9 @@ const InventoryManagement = () => {
placeholder="选择状态"
style={{ width: 140, borderRadius: 8 }}
allowClear
onChange={(value) => {
setSearchParamsObj((prev) => ({ ...prev, status: value }));
setPagination((prev) => ({ ...prev, current: 1 }));
onChange={value => {
setSearchParamsObj(prev => ({ ...prev, status: value }));
setPagination(prev => ({ ...prev, current: 1 }));
}}
>
<Select.Option value="draft">草稿</Select.Option>
@@ -561,17 +571,21 @@ const InventoryManagement = () => {
<Select.Option value="in_progress">进行中</Select.Option>
<Select.Option value="completed">已完成</Select.Option>
</Select>
<Button
icon={<ReloadOutlined />}
onClick={() => { setSearchParamsObj({}); fetchPlans(); fetchStats(); }}
<Button
icon={<ReloadOutlined />}
onClick={() => {
setSearchParamsObj({});
fetchPlans();
fetchStats();
}}
style={{ borderRadius: 8 }}
>
刷新
</Button>
</div>
<Button
type="primary"
icon={<PlusOutlined />}
<Button
type="primary"
icon={<PlusOutlined />}
onClick={handleAdd}
style={{ borderRadius: 8, height: 40 }}
>
@@ -588,14 +602,16 @@ const InventoryManagement = () => {
...pagination,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (total) => `${total} 条记录`,
showTotal: total => `${total} 条记录`,
onChange: (page, pageSize) => {
setPagination((prev) => ({ ...prev, current: page, pageSize }));
setPagination(prev => ({ ...prev, current: page, pageSize }));
},
}}
scroll={{ x: 1200 }}
locale={{
emptyText: <Empty description="暂无盘点计划" image={Empty.PRESENTED_IMAGE_SIMPLE} />,
emptyText: (
<Empty description="暂无盘点计划" image={Empty.PRESENTED_IMAGE_SIMPLE} />
),
}}
/>
</TabPane>
@@ -615,17 +631,13 @@ const InventoryManagement = () => {
footer={null}
width={640}
centered
styles={{
styles={{
header: { borderBottom: '1px solid #f0f0f0', padding: '16px 24px' },
body: { padding: 24 },
footer: { borderTop: '1px solid #f0f0f0', padding: '12px 24px' }
footer: { borderTop: '1px solid #f0f0f0', padding: '12px 24px' },
}}
>
<Form
form={form}
layout="vertical"
onFinish={handleSubmit}
>
<Form form={form} layout="vertical" onFinish={handleSubmit}>
<Row gutter={16}>
<Col span={16}>
<Form.Item
@@ -652,35 +664,26 @@ const InventoryManagement = () => {
</Col>
</Row>
<Form.Item
name="description"
label="描述"
>
<Form.Item name="description" label="描述">
<Input.TextArea rows={2} placeholder="请输入描述" style={{ borderRadius: 8 }} />
</Form.Item>
<Row gutter={16}>
<Col span={12}>
<Form.Item
name="scheduledDate"
label="计划日期"
>
<Form.Item name="scheduledDate" label="计划日期">
<DatePicker style={{ width: '100%', borderRadius: 8 }} placeholder="选择计划日期" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
name="targetRooms"
label="目标机房"
>
<Select
mode="multiple"
placeholder="选择目标机房(不选则为全部)"
<Form.Item name="targetRooms" label="目标机房">
<Select
mode="multiple"
placeholder="选择目标机房(不选则为全部)"
allowClear
onChange={handleRoomsChange}
style={{ borderRadius: 8 }}
>
{rooms.map((room) => (
{rooms.map(room => (
<Select.Option key={room.roomId} value={room.roomId}>
{room.name}
</Select.Option>
@@ -690,17 +693,14 @@ const InventoryManagement = () => {
</Col>
</Row>
<Form.Item
name="targetRacks"
label="目标机柜"
>
<Select
mode="multiple"
placeholder="选择目标机柜(不选则为全部)"
<Form.Item name="targetRacks" label="目标机柜">
<Select
mode="multiple"
placeholder="选择目标机柜(不选则为全部)"
allowClear
style={{ borderRadius: 8 }}
>
{filteredRacks.map((rack) => (
{filteredRacks.map(rack => (
<Select.Option key={rack.rackId} value={rack.rackId}>
{rack.name} ({rack.Room?.name || ''})
</Select.Option>
File diff suppressed because it is too large Load Diff
+6 -4
View File
@@ -374,9 +374,7 @@ const Login = () => {
>
{headerContent.title}
</Title>
<Text style={{ fontSize: '15px', color: '#64748b' }}>
{headerContent.subtitle}
</Text>
<Text style={{ fontSize: '15px', color: '#64748b' }}>{headerContent.subtitle}</Text>
</div>
{/* 首次使用提示 */}
@@ -436,7 +434,11 @@ const Login = () => {
rules={[{ required: true, message: '请输入真实姓名' }]}
>
<Input
prefix={<SafetyCertificateOutlined style={{ color: '#94a3b8', fontSize: '18px' }} />}
prefix={
<SafetyCertificateOutlined
style={{ color: '#94a3b8', fontSize: '18px' }}
/>
}
placeholder="请输入真实姓名"
style={{ borderRadius: '12px', height: '48px' }}
/>
+59 -27
View File
@@ -27,7 +27,11 @@ import {
import api from '../api';
import CloseButton from '../components/CloseButton';
import dayjs from 'dayjs';
import { selectStyles, filterInputStyles, inputPlaceholders } from '../styles/deviceManagementStyles';
import {
selectStyles,
filterInputStyles,
inputPlaceholders,
} from '../styles/deviceManagementStyles';
const { RangePicker } = DatePicker;
const { Option } = Select;
@@ -98,7 +102,7 @@ function OperationLogs() {
...prev,
current: page,
pageSize,
total: response.data.total
total: response.data.total,
}));
}
} catch (error) {
@@ -135,7 +139,7 @@ function OperationLogs() {
fetchLogs(1, pagination.pageSize, clearedFilters);
};
const handleViewDetail = async (record) => {
const handleViewDetail = async record => {
setDetailLoading(true);
setDetailVisible(true);
try {
@@ -151,7 +155,7 @@ function OperationLogs() {
}
};
const getModuleTag = (module) => {
const getModuleTag = module => {
const colors = {
device: 'blue',
user: 'green',
@@ -175,7 +179,7 @@ function OperationLogs() {
return <Tag color={colors[module] || 'default'}>{names[module] || module}</Tag>;
};
const getOperationTag = (type) => {
const getOperationTag = type => {
const colors = {
create: 'green',
update: 'blue',
@@ -203,10 +207,8 @@ function OperationLogs() {
return <Tag color={colors[type] || 'default'}>{names[type] || type}</Tag>;
};
const getResultTag = (result) => {
return result === 'success'
? <Tag color="success">成功</Tag>
: <Tag color="error">失败</Tag>;
const getResultTag = result => {
return result === 'success' ? <Tag color="success">成功</Tag> : <Tag color="error">失败</Tag>;
};
const columns = [
@@ -272,18 +274,19 @@ function OperationLogs() {
width: 80,
fixed: 'right',
render: (_, record) => (
<Button
type="link"
icon={<EyeOutlined />}
onClick={() => handleViewDetail(record)}
>
<Button type="link" icon={<EyeOutlined />} onClick={() => handleViewDetail(record)}>
详情
</Button>
),
},
];
const hasFilters = filters.module || filters.operationType || filters.keyword || filters.dateRange || filters.result;
const hasFilters =
filters.module ||
filters.operationType ||
filters.keyword ||
filters.dateRange ||
filters.result;
return (
<div style={{ padding: '24px' }}>
@@ -311,7 +314,9 @@ function OperationLogs() {
onChange={value => handleFilterChange('module', value)}
>
{MODULE_OPTIONS.map(opt => (
<Option key={opt.value} value={opt.value}>{opt.label}</Option>
<Option key={opt.value} value={opt.value}>
{opt.label}
</Option>
))}
</Select>
</Col>
@@ -324,7 +329,9 @@ function OperationLogs() {
onChange={value => handleFilterChange('operationType', value)}
>
{OPERATION_TYPE_OPTIONS.map(opt => (
<Option key={opt.value} value={opt.value}>{opt.label}</Option>
<Option key={opt.value} value={opt.value}>
{opt.label}
</Option>
))}
</Select>
</Col>
@@ -337,7 +344,9 @@ function OperationLogs() {
onChange={value => handleFilterChange('result', value)}
>
{RESULT_OPTIONS.map(opt => (
<Option key={opt.value} value={opt.value}>{opt.label}</Option>
<Option key={opt.value} value={opt.value}>
{opt.label}
</Option>
))}
</Select>
</Col>
@@ -368,10 +377,7 @@ function OperationLogs() {
筛选
</Button>
{hasFilters && (
<Button
icon={<ClearOutlined />}
onClick={handleClearFilters}
>
<Button icon={<ClearOutlined />} onClick={handleClearFilters}>
清除
</Button>
)}
@@ -391,7 +397,7 @@ function OperationLogs() {
total: pagination.total,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (total) => `${total}`,
showTotal: total => `${total}`,
pageSizeOptions: ['10', '20', '50', '100'],
}}
onChange={handleTableChange}
@@ -428,7 +434,9 @@ function OperationLogs() {
</Descriptions.Item>
<Descriptions.Item label="模块">{currentLog.module}</Descriptions.Item>
<Descriptions.Item label="操作类型">{currentLog.operationType}</Descriptions.Item>
<Descriptions.Item label="操作描述">{currentLog.operationDescription}</Descriptions.Item>
<Descriptions.Item label="操作描述">
{currentLog.operationDescription}
</Descriptions.Item>
<Descriptions.Item label="目标ID">{currentLog.targetId || '-'}</Descriptions.Item>
<Descriptions.Item label="目标名称">{currentLog.targetName || '-'}</Descriptions.Item>
<Descriptions.Item label="操作人ID">{currentLog.operatorId}</Descriptions.Item>
@@ -450,7 +458,15 @@ function OperationLogs() {
{currentLog.beforeState && (
<>
<Text strong>变更前</Text>
<pre style={{ background: '#f5f5f5', padding: 12, borderRadius: 4, overflow: 'auto', maxHeight: 200 }}>
<pre
style={{
background: '#f5f5f5',
padding: 12,
borderRadius: 4,
overflow: 'auto',
maxHeight: 200,
}}
>
{JSON.stringify(currentLog.beforeState, null, 2)}
</pre>
</>
@@ -458,7 +474,15 @@ function OperationLogs() {
{currentLog.afterState && (
<>
<Text strong>变更后</Text>
<pre style={{ background: '#f0f0f0', padding: 12, borderRadius: 4, overflow: 'auto', maxHeight: 200 }}>
<pre
style={{
background: '#f0f0f0',
padding: 12,
borderRadius: 4,
overflow: 'auto',
maxHeight: 200,
}}
>
{JSON.stringify(currentLog.afterState, null, 2)}
</pre>
</>
@@ -469,7 +493,15 @@ function OperationLogs() {
{currentLog && currentLog.metadata && Object.keys(currentLog.metadata).length > 0 && (
<>
<h4 style={{ marginTop: 16 }}>扩展信息</h4>
<pre style={{ background: '#f5f5f5', padding: 12, borderRadius: 4, overflow: 'auto', maxHeight: 200 }}>
<pre
style={{
background: '#f5f5f5',
padding: 12,
borderRadius: 4,
overflow: 'auto',
maxHeight: 200,
}}
>
{JSON.stringify(currentLog.metadata, null, 2)}
</pre>
</>
+252 -130
View File
@@ -42,7 +42,7 @@ const api = axios.create({
baseURL: '/api',
});
api.interceptors.request.use((config) => {
api.interceptors.request.use(config => {
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
@@ -64,13 +64,13 @@ const PendingDeviceManagement = () => {
const [currentDevice, setCurrentDevice] = useState(null);
const [form] = Form.useForm();
const [selectedRoomId, setSelectedRoomId] = useState(null);
const [pagination, setPagination] = useState({
current: 1,
pageSize: 20,
total: 0,
});
const [filters, setFilters] = useState({
status: null,
planId: null,
@@ -79,27 +79,133 @@ const PendingDeviceManagement = () => {
});
const defaultDeviceFields = [
{ fieldName: 'deviceName', displayName: '设备名称', fieldType: 'string', required: true, visible: true, order: 1 },
{ fieldName: 'deviceType', displayName: '设备类型', fieldType: 'select', required: true, visible: true, order: 2, options: [
{ value: 'server', label: '服务器' },
{ value: 'switch', label: '交换机' },
{ value: 'router', label: '路由器' },
{ value: 'storage', label: '存储设备' },
{ value: 'other', label: '其他' },
]},
{ fieldName: 'model', displayName: '型号', fieldType: 'string', required: false, visible: true, order: 3 },
{ fieldName: 'serialNumber', displayName: '序列号', fieldType: 'string', required: true, visible: true, order: 4 },
{ fieldName: 'roomId', displayName: '所属机房', fieldType: 'select', required: false, visible: true, order: 5 },
{ fieldName: 'rackId', displayName: '所属机柜', fieldType: 'select', required: false, visible: true, order: 6 },
{ fieldName: 'position', displayName: '位置(U)', fieldType: 'number', required: false, visible: true, order: 7 },
{ fieldName: 'height', displayName: '高度(U)', fieldType: 'number', required: false, visible: true, order: 8 },
{ fieldName: 'powerConsumption', displayName: '功率(W)', fieldType: 'number', required: false, visible: true, order: 9 },
{ fieldName: 'purchaseDate', displayName: '购买日期', fieldType: 'date', required: false, visible: true, order: 10 },
{ fieldName: 'warrantyExpiry', displayName: '保修到期', fieldType: 'date', required: false, visible: true, order: 11 },
{ fieldName: 'ipAddress', displayName: 'IP地址', fieldType: 'string', required: false, visible: true, order: 12 },
{ fieldName: 'brand', displayName: '品牌', fieldType: 'string', required: false, visible: true, order: 13 },
{ fieldName: 'description', displayName: '描述', fieldType: 'textarea', required: false, visible: true, order: 14 },
{ fieldName: 'remark', displayName: '备注', fieldType: 'textarea', required: false, visible: true, order: 15 },
{
fieldName: 'deviceName',
displayName: '设备名称',
fieldType: 'string',
required: true,
visible: true,
order: 1,
},
{
fieldName: 'deviceType',
displayName: '设备类型',
fieldType: 'select',
required: true,
visible: true,
order: 2,
options: [
{ value: 'server', label: '服务器' },
{ value: 'switch', label: '交换机' },
{ value: 'router', label: '路由器' },
{ value: 'storage', label: '存储设备' },
{ value: 'other', label: '其他' },
],
},
{
fieldName: 'model',
displayName: '型号',
fieldType: 'string',
required: false,
visible: true,
order: 3,
},
{
fieldName: 'serialNumber',
displayName: '序列号',
fieldType: 'string',
required: true,
visible: true,
order: 4,
},
{
fieldName: 'roomId',
displayName: '所属机房',
fieldType: 'select',
required: false,
visible: true,
order: 5,
},
{
fieldName: 'rackId',
displayName: '所属机柜',
fieldType: 'select',
required: false,
visible: true,
order: 6,
},
{
fieldName: 'position',
displayName: '位置(U)',
fieldType: 'number',
required: false,
visible: true,
order: 7,
},
{
fieldName: 'height',
displayName: '高度(U)',
fieldType: 'number',
required: false,
visible: true,
order: 8,
},
{
fieldName: 'powerConsumption',
displayName: '功率(W)',
fieldType: 'number',
required: false,
visible: true,
order: 9,
},
{
fieldName: 'purchaseDate',
displayName: '购买日期',
fieldType: 'date',
required: false,
visible: true,
order: 10,
},
{
fieldName: 'warrantyExpiry',
displayName: '保修到期',
fieldType: 'date',
required: false,
visible: true,
order: 11,
},
{
fieldName: 'ipAddress',
displayName: 'IP地址',
fieldType: 'string',
required: false,
visible: true,
order: 12,
},
{
fieldName: 'brand',
displayName: '品牌',
fieldType: 'string',
required: false,
visible: true,
order: 13,
},
{
fieldName: 'description',
displayName: '描述',
fieldType: 'textarea',
required: false,
visible: true,
order: 14,
},
{
fieldName: 'remark',
displayName: '备注',
fieldType: 'textarea',
required: false,
visible: true,
order: 15,
},
];
const fetchDeviceFields = async () => {
@@ -185,11 +291,11 @@ const PendingDeviceManagement = () => {
fetchDeviceFields();
}, []);
const filteredRacks = selectedRoomId
const filteredRacks = selectedRoomId
? racks.filter(rack => rack.roomId === selectedRoomId)
: racks;
const handleSync = async (pendingId) => {
const handleSync = async pendingId => {
try {
const res = await api.post(`/inventory/pending-devices/${pendingId}/sync`);
message.success(res.data.message);
@@ -205,7 +311,7 @@ const PendingDeviceManagement = () => {
message.warning('请先选择要同步的设备');
return;
}
try {
const res = await api.post('/inventory/pending-devices/batch-sync', {
pendingIds: selectedRowKeys,
@@ -219,27 +325,27 @@ const PendingDeviceManagement = () => {
}
};
const handleEdit = (record) => {
const handleEdit = record => {
setCurrentDevice(record);
setSelectedRoomId(record.roomId);
// 字段名映射:数据库字段名 -> PendingDevice 模型字段名
const fieldMapping = {
'name': 'deviceName',
'type': 'deviceType',
'SN': 'serialNumber',
name: 'deviceName',
type: 'deviceType',
SN: 'serialNumber',
};
// 确保使用 deviceFields 中定义的字段名来设置表单值
const formValues = {
serialNumber: record.serialNumber,
roomId: record.roomId,
rackId: record.rackId,
};
// 使用已加载的字段配置或默认配置
const fields = deviceFields || defaultDeviceFields;
// 遍历字段,将 record 中对应的值设置到表单
fields.forEach(field => {
const fieldName = field.fieldName;
@@ -249,7 +355,7 @@ const PendingDeviceManagement = () => {
formValues[fieldName] = record[recordFieldName];
}
});
// 处理自定义字段
if (record.customFields) {
Object.entries(record.customFields).forEach(([key, value]) => {
@@ -258,21 +364,21 @@ const PendingDeviceManagement = () => {
}
});
}
form.setFieldsValue(formValues);
setEditModalVisible(true);
};
const handleEditSubmit = async (values) => {
const handleEditSubmit = async values => {
try {
const { purchaseDate, warrantyExpiry, ...otherValues } = values;
const payload = {
...otherValues,
purchaseDate: purchaseDate ? dayjs(purchaseDate).toISOString() : null,
warrantyExpiry: warrantyExpiry ? dayjs(warrantyExpiry).toISOString() : null
warrantyExpiry: warrantyExpiry ? dayjs(warrantyExpiry).toISOString() : null,
};
await api.put(`/inventory/pending-devices/${currentDevice.pendingId}`, payload);
message.success('更新成功');
setEditModalVisible(false);
@@ -283,7 +389,7 @@ const PendingDeviceManagement = () => {
}
};
const handleDelete = async (pendingId) => {
const handleDelete = async pendingId => {
Modal.confirm({
title: '确认删除',
content: '确定要删除这个待同步设备吗?此操作不可恢复!',
@@ -303,16 +409,20 @@ const PendingDeviceManagement = () => {
});
};
const getStatusTag = (status) => {
const getStatusTag = status => {
const statusMap = {
pending: { color: 'processing', text: '待同步', icon: <SyncOutlined spin /> },
synced: { color: 'success', text: '已同步', icon: <CheckCircleOutlined /> },
};
const config = statusMap[status] || statusMap.pending;
return <Tag color={config.color} icon={config.icon}>{config.text}</Tag>;
return (
<Tag color={config.color} icon={config.icon}>
{config.text}
</Tag>
);
};
const getTypeLabel = (type) => {
const getTypeLabel = type => {
const typeMap = {
server: '服务器',
switch: '交换机',
@@ -323,7 +433,7 @@ const PendingDeviceManagement = () => {
return typeMap[type] || type;
};
const renderFormField = (field) => {
const renderFormField = field => {
const { fieldName, displayName, fieldType, required, options } = field;
if (fieldName === 'roomId') {
@@ -334,12 +444,12 @@ const PendingDeviceManagement = () => {
label={displayName}
rules={required ? [{ required: true, message: `请选择${displayName}` }] : []}
>
<Select
placeholder={`请选择${displayName}`}
allowClear
showSearch
<Select
placeholder={`请选择${displayName}`}
allowClear
showSearch
optionFilterProp="children"
onChange={(value) => {
onChange={value => {
setSelectedRoomId(value);
form.setFieldsValue({ rackId: undefined });
}}
@@ -351,7 +461,9 @@ const PendingDeviceManagement = () => {
</Select.Option>
))
) : (
<Select.Option value="" disabled>暂无机房数据</Select.Option>
<Select.Option value="" disabled>
暂无机房数据
</Select.Option>
)}
</Select>
</Form.Item>
@@ -367,10 +479,10 @@ const PendingDeviceManagement = () => {
label={displayName}
rules={required ? [{ required: true, message: `请选择${displayName}` }] : []}
>
<Select
placeholder={selectedRoomId ? `请选择${displayName}` : "请先选择机房"}
allowClear
showSearch
<Select
placeholder={selectedRoomId ? `请选择${displayName}` : '请先选择机房'}
allowClear
showSearch
optionFilterProp="children"
disabled={!selectedRoomId}
>
@@ -382,10 +494,14 @@ const PendingDeviceManagement = () => {
</Select.Option>
))
) : (
<Select.Option value="" disabled>该机房下无机柜</Select.Option>
<Select.Option value="" disabled>
该机房下无机柜
</Select.Option>
)
) : (
<Select.Option value="" disabled>请先选择机房</Select.Option>
<Select.Option value="" disabled>
请先选择机房
</Select.Option>
)}
</Select>
</Form.Item>
@@ -401,13 +517,16 @@ const PendingDeviceManagement = () => {
rules={required ? [{ required: true, message: `请选择${displayName}` }] : []}
>
<Select placeholder={`请选择${displayName}`}>
{(Array.isArray(options) ? options : [
{ value: 'server', label: '服务器' },
{ value: 'switch', label: '交换机' },
{ value: 'router', label: '路由器' },
{ value: 'storage', label: '存储设备' },
{ value: 'other', label: '其他' },
]).map(opt => (
{(Array.isArray(options)
? options
: [
{ value: 'server', label: '服务器' },
{ value: 'switch', label: '交换机' },
{ value: 'router', label: '路由器' },
{ value: 'storage', label: '存储设备' },
{ value: 'other', label: '其他' },
]
).map(opt => (
<Select.Option key={opt.value} value={opt.value}>
{opt.label}
</Select.Option>
@@ -490,11 +609,11 @@ const PendingDeviceManagement = () => {
const renderFormFields = () => {
// 如果字段配置未加载,使用默认配置
const fields = deviceFields || defaultDeviceFields;
// 排除机房和机柜字段(已在Modal中单独渲染)
const textFields = []; // 描述、备注等全文本字段
const otherFields = []; // 其他字段
fields.forEach(field => {
// 排除 roomId 和 rackId
if (field.fieldName === 'roomId' || field.fieldName === 'rackId') {
@@ -514,15 +633,11 @@ const PendingDeviceManagement = () => {
{/* 其他字段两列布局 */}
{otherFields.length > 0 && (
<Row gutter={16}>
<Col span={12}>
{otherFields.filter((_, i) => i % 2 === 0).map(renderFormField)}
</Col>
<Col span={12}>
{otherFields.filter((_, i) => i % 2 === 1).map(renderFormField)}
</Col>
<Col span={12}>{otherFields.filter((_, i) => i % 2 === 0).map(renderFormField)}</Col>
<Col span={12}>{otherFields.filter((_, i) => i % 2 === 1).map(renderFormField)}</Col>
</Row>
)}
{/* 文本字段全文本显示 */}
{textFields.map(renderFormField)}
</>
@@ -548,7 +663,7 @@ const PendingDeviceManagement = () => {
dataIndex: 'deviceType',
key: 'deviceType',
width: 100,
render: (type) => getTypeLabel(type),
render: type => getTypeLabel(type),
},
{
title: '位置',
@@ -556,8 +671,8 @@ const PendingDeviceManagement = () => {
width: 180,
render: (_, record) => (
<span>
{record.Room?.name || '-'}
{record.Rack ? ` / ${record.Rack.name}` : ''}
{record.Room?.name || '-'}
{record.Rack ? ` / ${record.Rack.name}` : ''}
{record.position ? ` / U${record.position}` : ''}
</span>
),
@@ -567,35 +682,35 @@ const PendingDeviceManagement = () => {
dataIndex: ['Plan', 'name'],
key: 'planName',
width: 150,
render: (name) => name || '-',
render: name => name || '-',
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 100,
render: (status) => getStatusTag(status),
render: status => getStatusTag(status),
},
{
title: '创建人',
dataIndex: ['Creator', 'realName'],
key: 'creator',
width: 100,
render: (name) => name || '-',
render: name => name || '-',
},
{
title: '创建时间',
dataIndex: 'createdAt',
key: 'createdAt',
width: 140,
render: (date) => date ? dayjs(date).format('YYYY-MM-DD HH:mm') : '-',
render: date => (date ? dayjs(date).format('YYYY-MM-DD HH:mm') : '-'),
},
{
title: '同步时间',
dataIndex: 'syncedAt',
key: 'syncedAt',
width: 140,
render: (date) => date ? dayjs(date).format('YYYY-MM-DD HH:mm') : '-',
render: date => (date ? dayjs(date).format('YYYY-MM-DD HH:mm') : '-'),
},
{
title: '操作',
@@ -641,11 +756,7 @@ const PendingDeviceManagement = () => {
cancelText="取消"
>
<Tooltip title="删除">
<Button
type="text"
icon={<DeleteOutlined />}
style={{ color: '#ff4d4f' }}
/>
<Button type="text" icon={<DeleteOutlined />} style={{ color: '#ff4d4f' }} />
</Tooltip>
</Popconfirm>
</Space>
@@ -654,15 +765,17 @@ const PendingDeviceManagement = () => {
];
return (
<div style={{ padding: 24, background: designTokens.colors.background.secondary, minHeight: '100vh' }}>
<div
style={{
padding: 24,
background: designTokens.colors.background.secondary,
minHeight: '100vh',
}}
>
<Row gutter={16} style={{ marginBottom: 24 }}>
<Col span={6}>
<Card bordered={false} style={{ borderRadius: 12, height: '100%', minHeight: 100 }}>
<Statistic
title="暂存设备总数"
value={stats.total}
prefix={<InboxOutlined />}
/>
<Statistic title="暂存设备总数" value={stats.total} prefix={<InboxOutlined />} />
</Card>
</Col>
<Col span={6}>
@@ -692,8 +805,8 @@ const PendingDeviceManagement = () => {
value={stats.total > 0 ? Math.round((stats.synced / stats.total) * 100) : 0}
suffix="%"
/>
<Progress
percent={stats.total > 0 ? Math.round((stats.synced / stats.total) * 100) : 0}
<Progress
percent={stats.total > 0 ? Math.round((stats.synced / stats.total) * 100) : 0}
showInfo={false}
strokeColor="#52c41a"
/>
@@ -702,14 +815,23 @@ const PendingDeviceManagement = () => {
</Row>
<Card bordered={false} style={{ borderRadius: 12 }}>
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 12 }}>
<div
style={{
marginBottom: 16,
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
flexWrap: 'wrap',
gap: 12,
}}
>
<div style={{ display: 'flex', gap: 12, alignItems: 'center', flexWrap: 'wrap' }}>
<Input
placeholder="搜索序列号/设备名称"
prefix={<SearchOutlined />}
style={{ width: 200 }}
value={filters.keyword}
onChange={(e) => setFilters(prev => ({ ...prev, keyword: e.target.value }))}
onChange={e => setFilters(prev => ({ ...prev, keyword: e.target.value }))}
onPressEnter={() => setPagination(prev => ({ ...prev, current: 1 }))}
/>
<Select
@@ -717,7 +839,7 @@ const PendingDeviceManagement = () => {
style={{ width: 120 }}
allowClear
value={filters.status}
onChange={(value) => setFilters(prev => ({ ...prev, status: value }))}
onChange={value => setFilters(prev => ({ ...prev, status: value }))}
>
<Select.Option value="pending">待同步</Select.Option>
<Select.Option value="synced">已同步</Select.Option>
@@ -729,7 +851,7 @@ const PendingDeviceManagement = () => {
showSearch
optionFilterProp="children"
value={filters.planId}
onChange={(value) => setFilters(prev => ({ ...prev, planId: value }))}
onChange={value => setFilters(prev => ({ ...prev, planId: value }))}
>
{plans.map(plan => (
<Select.Option key={plan.planId} value={plan.planId}>
@@ -762,7 +884,7 @@ const PendingDeviceManagement = () => {
rowSelection={{
selectedRowKeys,
onChange: setSelectedRowKeys,
getCheckboxProps: (record) => ({
getCheckboxProps: record => ({
disabled: record.status !== 'pending',
}),
}}
@@ -774,7 +896,7 @@ const PendingDeviceManagement = () => {
...pagination,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (total) => `${total}`,
showTotal: total => `${total}`,
onChange: (page, pageSize) => {
setPagination(prev => ({ ...prev, current: page, pageSize }));
},
@@ -797,11 +919,7 @@ const PendingDeviceManagement = () => {
footer={null}
width={600}
>
<Form
form={form}
layout="vertical"
onFinish={handleEditSubmit}
>
<Form form={form} layout="vertical" onFinish={handleEditSubmit}>
<Descriptions bordered size="small" column={2} style={{ marginBottom: 16 }}>
<Descriptions.Item label="序列号">
<code>{currentDevice?.serialNumber}</code>
@@ -814,16 +932,13 @@ const PendingDeviceManagement = () => {
{/* 机房机柜选择 - 始终显示 */}
<Row gutter={16}>
<Col span={12}>
<Form.Item
name="roomId"
label="所属机房"
>
<Select
placeholder="请选择机房"
allowClear
showSearch
<Form.Item name="roomId" label="所属机房">
<Select
placeholder="请选择机房"
allowClear
showSearch
optionFilterProp="children"
onChange={(value) => {
onChange={value => {
setSelectedRoomId(value);
form.setFieldsValue({ rackId: undefined });
}}
@@ -835,20 +950,19 @@ const PendingDeviceManagement = () => {
</Select.Option>
))
) : (
<Select.Option value="" disabled>暂无机房数据</Select.Option>
<Select.Option value="" disabled>
暂无机房数据
</Select.Option>
)}
</Select>
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
name="rackId"
label="所属机柜"
>
<Select
placeholder={selectedRoomId ? "请选择机柜" : "请先选择机房"}
allowClear
showSearch
<Form.Item name="rackId" label="所属机柜">
<Select
placeholder={selectedRoomId ? '请选择机柜' : '请先选择机房'}
allowClear
showSearch
optionFilterProp="children"
disabled={!selectedRoomId}
>
@@ -860,10 +974,14 @@ const PendingDeviceManagement = () => {
</Select.Option>
))
) : (
<Select.Option value="" disabled>该机房下无机柜</Select.Option>
<Select.Option value="" disabled>
该机房下无机柜
</Select.Option>
)
) : (
<Select.Option value="" disabled>请先选择机房</Select.Option>
<Select.Option value="" disabled>
请先选择机房
</Select.Option>
)}
</Select>
</Form.Item>
@@ -874,10 +992,14 @@ const PendingDeviceManagement = () => {
<Form.Item style={{ marginBottom: 0, textAlign: 'right', marginTop: 16 }}>
<Space>
<Button onClick={() => {
setEditModalVisible(false);
setSelectedRoomId(null);
}}>取消</Button>
<Button
onClick={() => {
setEditModalVisible(false);
setSelectedRoomId(null);
}}
>
取消
</Button>
<Button type="primary" htmlType="submit">
保存
</Button>
File diff suppressed because it is too large Load Diff
+137 -50
View File
@@ -371,7 +371,7 @@ const Rack3DVisualization = () => {
// U
const usedU = devices.reduce((sum, device) => sum + (device.height || 1), 0);
//
const totalPower = devices.reduce((sum, device) => {
const power = parseFloat(device.powerConsumption) || 0;
@@ -380,7 +380,7 @@ const Rack3DVisualization = () => {
//
const totalHeight = selectedRack.height || 45;
// U
const availableU = totalHeight - usedU;
@@ -568,7 +568,8 @@ const Rack3DVisualization = () => {
position: 'absolute',
top: 88,
right: 24,
background: 'linear-gradient(135deg, rgba(15, 23, 42, 0.95) 0%, rgba(30, 41, 59, 0.9) 100%)',
background:
'linear-gradient(135deg, rgba(15, 23, 42, 0.95) 0%, rgba(30, 41, 59, 0.9) 100%)',
backdropFilter: 'blur(16px)',
padding: isRackInfoCollapsed ? '14px 18px' : '20px',
borderRadius: '20px',
@@ -615,10 +616,13 @@ const Rack3DVisualization = () => {
left: 0,
right: 0,
height: '50%',
background: 'linear-gradient(180deg, rgba(255,255,255,0.15) 0%, transparent 100%)',
background:
'linear-gradient(180deg, rgba(255,255,255,0.15) 0%, transparent 100%)',
}}
/>
<CloudServerOutlined style={{ color: 'white', fontSize: 22, position: 'relative', zIndex: 1 }} />
<CloudServerOutlined
style={{ color: 'white', fontSize: 22, position: 'relative', zIndex: 1 }}
/>
</div>
<div
style={{
@@ -640,13 +644,15 @@ const Rack3DVisualization = () => {
>
{selectedRack.name}
</div>
<div style={{
fontSize: '12px',
color: 'rgba(148, 163, 184, 0.8)',
marginTop: 4,
fontFamily: 'monospace',
letterSpacing: '0.5px',
}}>
<div
style={{
fontSize: '12px',
color: 'rgba(148, 163, 184, 0.8)',
marginTop: 4,
fontFamily: 'monospace',
letterSpacing: '0.5px',
}}
>
#{selectedRack.rackId}
</div>
</div>
@@ -660,7 +666,7 @@ const Rack3DVisualization = () => {
e.stopPropagation();
setIsRackInfoCollapsed(!isRackInfoCollapsed);
}}
style={{
style={{
color: 'rgba(148, 163, 184, 0.7)',
marginLeft: isRackInfoCollapsed ? 0 : 8,
width: isRackInfoCollapsed ? 44 : 32,
@@ -701,10 +707,24 @@ const Rack3DVisualization = () => {
textAlign: 'center',
}}
>
<div style={{ fontSize: '22px', fontWeight: 700, color: '#60a5fa', lineHeight: 1 }}>
<div
style={{
fontSize: '22px',
fontWeight: 700,
color: '#60a5fa',
lineHeight: 1,
}}
>
{selectedRack.height}
</div>
<div style={{ fontSize: '10px', color: 'rgba(148, 163, 184, 0.7)', marginTop: 4, fontWeight: 500 }}>
<div
style={{
fontSize: '10px',
color: 'rgba(148, 163, 184, 0.7)',
marginTop: 4,
fontWeight: 500,
}}
>
总高度
</div>
</div>
@@ -717,10 +737,24 @@ const Rack3DVisualization = () => {
textAlign: 'center',
}}
>
<div style={{ fontSize: '22px', fontWeight: 700, color: '#34d399', lineHeight: 1 }}>
<div
style={{
fontSize: '22px',
fontWeight: 700,
color: '#34d399',
lineHeight: 1,
}}
>
{devices.length}
</div>
<div style={{ fontSize: '10px', color: 'rgba(148, 163, 184, 0.7)', marginTop: 4, fontWeight: 500 }}>
<div
style={{
fontSize: '10px',
color: 'rgba(148, 163, 184, 0.7)',
marginTop: 4,
fontWeight: 500,
}}
>
设备数
</div>
</div>
@@ -733,10 +767,24 @@ const Rack3DVisualization = () => {
textAlign: 'center',
}}
>
<div style={{ fontSize: '22px', fontWeight: 700, color: '#fbbf24', lineHeight: 1 }}>
<div
style={{
fontSize: '22px',
fontWeight: 700,
color: '#fbbf24',
lineHeight: 1,
}}
>
{Math.round((devices.length / selectedRack.height) * 100)}%
</div>
<div style={{ fontSize: '10px', color: 'rgba(148, 163, 184, 0.7)', marginTop: 4, fontWeight: 500 }}>
<div
style={{
fontSize: '10px',
color: 'rgba(148, 163, 184, 0.7)',
marginTop: 4,
fontWeight: 500,
}}
>
负载率
</div>
</div>
@@ -752,49 +800,86 @@ const Rack3DVisualization = () => {
marginBottom: 16,
}}
>
<div style={{ fontSize: '11px', color: 'rgba(148, 163, 184, 0.6)', marginBottom: 12, fontWeight: 600, letterSpacing: '0.5px', textTransform: 'uppercase' }}>
<div
style={{
fontSize: '11px',
color: 'rgba(148, 163, 184, 0.6)',
marginBottom: 12,
fontWeight: 600,
letterSpacing: '0.5px',
textTransform: 'uppercase',
}}
>
资源监控
</div>
{[
{
label: 'U位使用',
value: `${rackStats.usedU}U / ${selectedRack.height}U`,
status: rackStats.usagePercent > 80 ? 'warning' : 'normal',
{
label: 'U位使用',
value: `${rackStats.usedU}U / ${selectedRack.height}U`,
status: rackStats.usagePercent > 80 ? 'warning' : 'normal',
icon: '📊',
subValue: `剩余 ${rackStats.availableU}U`,
},
{
label: '总功率',
value: `${rackStats.totalPower.toFixed(1)}kW`,
status: 'normal',
{
label: '总功率',
value: `${rackStats.totalPower.toFixed(1)}kW`,
status: 'normal',
icon: '⚡',
subValue: '当前负载',
},
{
label: '设备数',
value: `${devices.length}`,
status: 'normal',
{
label: '设备数',
value: `${devices.length}`,
status: 'normal',
icon: '🖥️',
subValue: `负载率 ${rackStats.usagePercent}%`,
},
].map((item, i) => (
<div key={i} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: i < 2 ? 12 : 0 }}>
<div
key={i}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: i < 2 ? 12 : 0,
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<span style={{ fontSize: 16 }}>{item.icon}</span>
<div>
<div style={{ fontSize: '13px', color: 'rgba(226, 232, 240, 0.9)', fontWeight: 500 }}>{item.label}</div>
<div style={{ fontSize: '11px', color: 'rgba(148, 163, 184, 0.6)', marginTop: 2 }}>{item.subValue}</div>
<div
style={{
fontSize: '13px',
color: 'rgba(226, 232, 240, 0.9)',
fontWeight: 500,
}}
>
{item.label}
</div>
<div
style={{
fontSize: '11px',
color: 'rgba(148, 163, 184, 0.6)',
marginTop: 2,
}}
>
{item.subValue}
</div>
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<span style={{ fontSize: '14px', fontWeight: 700, color: '#f8fafc' }}>{item.value}</span>
<div style={{
width: 8,
height: 8,
borderRadius: '50%',
background: item.status === 'normal' ? '#10b981' : '#f59e0b',
boxShadow: `0 0 8px ${item.status === 'normal' ? 'rgba(16, 185, 129, 0.6)' : 'rgba(245, 158, 11, 0.6)'}`,
}} />
<span style={{ fontSize: '14px', fontWeight: 700, color: '#f8fafc' }}>
{item.value}
</span>
<div
style={{
width: 8,
height: 8,
borderRadius: '50%',
background: item.status === 'normal' ? '#10b981' : '#f59e0b',
boxShadow: `0 0 8px ${item.status === 'normal' ? 'rgba(16, 185, 129, 0.6)' : 'rgba(245, 158, 11, 0.6)'}`,
}}
/>
</div>
</div>
))}
@@ -845,13 +930,15 @@ const Rack3DVisualization = () => {
>
<span style={{ fontSize: 14 }}>{item.icon}</span>
<span style={{ flex: 1 }}>{item.text}</span>
<div style={{
width: 4,
height: 4,
borderRadius: '50%',
background: item.color,
boxShadow: `0 0 6px ${item.color}`,
}} />
<div
style={{
width: 4,
height: 4,
borderRadius: '50%',
background: item.color,
boxShadow: `0 0 6px ${item.color}`,
}}
/>
</div>
))}
</div>
+33 -23
View File
@@ -439,7 +439,9 @@ function RackManagement() {
cancelText: '取消',
onOk: async () => {
try {
const results = await Promise.allSettled(selectedRackIds.map(id => axios.delete(`/api/racks/${id}`)));
const results = await Promise.allSettled(
selectedRackIds.map(id => axios.delete(`/api/racks/${id}`))
);
const succeeded = results.filter(r => r.status === 'fulfilled').length;
const failed = results.filter(r => r.status === 'rejected');
if (succeeded > 0) {
@@ -537,14 +539,14 @@ function RackManagement() {
failedCount: 0,
errors: [],
createdRacks: resData.createdRacks || [],
skippedRacks: resData.skippedRacks || []
skippedRacks: resData.skippedRacks || [],
};
if (resData.details && Array.isArray(resData.details)) {
importResult.failedCount = resData.details.length;
importResult.errors = resData.details.map(item => ({
row: item.row,
error: item.errors.join('')
error: item.errors.join(''),
}));
}
@@ -571,8 +573,8 @@ function RackManagement() {
failedCount: errorData.details.length,
errors: errorData.details.map(item => ({
row: item.row,
error: item.errors.join('')
}))
error: item.errors.join(''),
})),
};
setImportResult(importResult);
setImportPhase('导入失败');
@@ -654,7 +656,9 @@ function RackManagement() {
</Text>
);
},
sorter: (a, b) => (a.Devices?.reduce((sum, d) => sum + (d.height || 1), 0) || 0) - (b.Devices?.reduce((sum, d) => sum + (d.height || 1), 0) || 0),
sorter: (a, b) =>
(a.Devices?.reduce((sum, d) => sum + (d.height || 1), 0) || 0) -
(b.Devices?.reduce((sum, d) => sum + (d.height || 1), 0) || 0),
},
{
title: '功率使用',
@@ -1437,14 +1441,16 @@ function RackManagement() {
<p style={{ fontWeight: '600', marginBottom: '8px', color: '#52c41a' }}>
本次新增机柜{importResult.createdRacks.length}
</p>
<div style={{
maxHeight: '150px',
overflow: 'auto',
background: '#f6ffed',
border: '1px solid #b7eb8f',
borderRadius: '8px',
padding: '12px'
}}>
<div
style={{
maxHeight: '150px',
overflow: 'auto',
background: '#f6ffed',
border: '1px solid #b7eb8f',
borderRadius: '8px',
padding: '12px',
}}
>
{importResult.createdRacks.map((rack, idx) => (
<div key={idx} style={{ fontSize: '13px', marginBottom: '4px' }}>
{rack.rackId} - {rack.name}
@@ -1459,14 +1465,16 @@ function RackManagement() {
<p style={{ fontWeight: '600', marginBottom: '8px', color: '#faad14' }}>
已跳过机柜{importResult.skippedRacks.length}
</p>
<div style={{
maxHeight: '150px',
overflow: 'auto',
background: '#fffbe6',
border: '1px solid #ffe58f',
borderRadius: '8px',
padding: '12px'
}}>
<div
style={{
maxHeight: '150px',
overflow: 'auto',
background: '#fffbe6',
border: '1px solid #ffe58f',
borderRadius: '8px',
padding: '12px',
}}
>
{importResult.skippedRacks.map((rack, idx) => (
<div key={idx} style={{ fontSize: '13px', marginBottom: '4px' }}>
{rack.rackId} - {rack.name}
@@ -1478,7 +1486,9 @@ function RackManagement() {
{importResult.errors && importResult.errors.length > 0 && (
<div style={{ marginBottom: '20px' }}>
<p style={{ fontWeight: '600', marginBottom: '8px', color: '#ff4d4f' }}>错误详情</p>
<p style={{ fontWeight: '600', marginBottom: '8px', color: '#ff4d4f' }}>
错误详情
</p>
{importResult.errors.slice(0, 5).map((err, idx) => (
<div
key={idx}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+77 -63
View File
@@ -12,7 +12,13 @@ import {
InputNumber,
Switch,
} from 'antd';
import { PlusOutlined, EditOutlined, DeleteOutlined, PlusCircleOutlined, MinusCircleOutlined } from '@ant-design/icons';
import {
PlusOutlined,
EditOutlined,
DeleteOutlined,
PlusCircleOutlined,
MinusCircleOutlined,
} from '@ant-design/icons';
import axios from 'axios';
import CloseButton from '../components/CloseButton';
@@ -37,44 +43,46 @@ const OptionsEditor = ({ value, onChange }) => {
};
return (
<div style={{
border: '1px solid #e8e8e8',
borderRadius: '12px',
padding: '20px',
background: 'linear-gradient(135deg, #fafbfc 0%, #f5f7fa 100%)',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.04)',
}}>
<div style={{
display: 'flex',
alignItems: 'center',
marginBottom: '16px',
gap: '8px',
}}>
<div style={{
width: '4px',
height: '16px',
background: 'linear-gradient(180deg, #667eea 0%, #764ba2 100%)',
borderRadius: '2px',
}}/>
<span style={{ color: '#333', fontSize: '14px', fontWeight: '600' }}>
选项配置
</span>
<span style={{ color: '#999', fontSize: '12px' }}>
值用于提交标签用于显示
</span>
<div
style={{
border: '1px solid #e8e8e8',
borderRadius: '12px',
padding: '20px',
background: 'linear-gradient(135deg, #fafbfc 0%, #f5f7fa 100%)',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.04)',
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
marginBottom: '16px',
gap: '8px',
}}
>
<div
style={{
width: '4px',
height: '16px',
background: 'linear-gradient(180deg, #667eea 0%, #764ba2 100%)',
borderRadius: '2px',
}}
/>
<span style={{ color: '#333', fontSize: '14px', fontWeight: '600' }}>选项配置</span>
<span style={{ color: '#999', fontSize: '12px' }}>值用于提交标签用于显示</span>
</div>
{options.length === 0 ? (
<div style={{
textAlign: 'center',
padding: '24px',
background: '#fff',
borderRadius: '8px',
border: '1px dashed #d9d9d9',
}}>
<div style={{ color: '#bbb', fontSize: '14px', marginBottom: '12px' }}>
暂无选项
</div>
<div
style={{
textAlign: 'center',
padding: '24px',
background: '#fff',
borderRadius: '8px',
border: '1px dashed #d9d9d9',
}}
>
<div style={{ color: '#bbb', fontSize: '14px', marginBottom: '12px' }}>暂无选项</div>
<Button
type="primary"
icon={<PlusCircleOutlined />}
@@ -90,15 +98,23 @@ const OptionsEditor = ({ value, onChange }) => {
</Button>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px', marginBottom: '16px' }}>
<div style={{
display: 'flex',
gap: '12px',
padding: '0 4px',
marginBottom: '4px',
}}>
<span style={{ width: '160px', color: '#666', fontSize: '12px', fontWeight: '500' }}>value</span>
<span style={{ width: '160px', color: '#666', fontSize: '12px', fontWeight: '500' }}>标签label</span>
<div
style={{ display: 'flex', flexDirection: 'column', gap: '10px', marginBottom: '16px' }}
>
<div
style={{
display: 'flex',
gap: '12px',
padding: '0 4px',
marginBottom: '4px',
}}
>
<span style={{ width: '160px', color: '#666', fontSize: '12px', fontWeight: '500' }}>
value
</span>
<span style={{ width: '160px', color: '#666', fontSize: '12px', fontWeight: '500' }}>
标签label
</span>
</div>
{value.map((opt, index) => (
<div
@@ -114,19 +130,21 @@ const OptionsEditor = ({ value, onChange }) => {
transition: 'all 0.2s ease',
}}
>
<div style={{
width: '24px',
height: '24px',
borderRadius: '50%',
background: 'linear-gradient(135deg, #667eea20 0%, #764ba220 100%)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#667eea',
fontSize: '12px',
fontWeight: '600',
flexShrink: 0,
}}>
<div
style={{
width: '24px',
height: '24px',
borderRadius: '50%',
background: 'linear-gradient(135deg, #667eea20 0%, #764ba220 100%)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#667eea',
fontSize: '12px',
fontWeight: '600',
flexShrink: 0,
}}
>
{index + 1}
</div>
<Input
@@ -432,11 +450,7 @@ function TicketFieldManagement() {
<OptionsEditor />
</Form.Item>
) : (
<Form.Item
name="options"
label="选项配置"
tooltip="仅下拉选择类型需要配置选项"
>
<Form.Item name="options" label="选项配置" tooltip="仅下拉选择类型需要配置选项">
<Input.TextArea
rows={2}
placeholder="仅下拉选择类型需要配置,此处不可编辑"
+203 -133
View File
@@ -360,11 +360,13 @@ function TicketManagement() {
const response = await axios.get('/api/ticket-fields');
const dbFields = response.data.sort((a, b) => a.order - b.order);
const isValidOptions = (opts) => {
const isValidOptions = opts => {
if (!opts) return false;
if (!Array.isArray(opts)) return false;
if (opts.length === 0) return false;
return opts.some(opt => opt && opt.value !== undefined && opt.value !== null && opt.value !== '');
return opts.some(
opt => opt && opt.value !== undefined && opt.value !== null && opt.value !== ''
);
};
const mergedFields = DEFAULT_TICKET_FIELDS.map(defaultField => {
@@ -468,7 +470,11 @@ function TicketManagement() {
break;
case 'textarea':
formItem = (
<Input.TextArea rows={3} placeholder={placeholder || `请输入${displayName}`} showCount />
<Input.TextArea
rows={3}
placeholder={placeholder || `请输入${displayName}`}
showCount
/>
);
break;
case 'boolean':
@@ -1131,17 +1137,19 @@ function TicketManagement() {
<Modal
title={
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<div style={{
width: 32,
height: 32,
borderRadius: 8,
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#fff',
fontSize: 16,
}}>
<div
style={{
width: 32,
height: 32,
borderRadius: 8,
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#fff',
fontSize: 16,
}}
>
<PlusOutlined />
</div>
<span style={{ fontWeight: 600, fontSize: 18 }}>
@@ -1166,32 +1174,38 @@ function TicketManagement() {
style={{ marginBottom: 16 }}
>
{!editingTicket && (
<div style={{
background: 'linear-gradient(135deg, #f0f4ff 0%, #fafbff 100%)',
border: '1px solid #e8eaff',
borderRadius: 12,
padding: '12px 16px',
marginBottom: 20,
display: 'flex',
alignItems: 'center',
gap: 12,
}}>
<div style={{
width: 40,
height: 40,
borderRadius: 8,
background: '#667eea',
<div
style={{
background: 'linear-gradient(135deg, #f0f4ff 0%, #fafbff 100%)',
border: '1px solid #e8eaff',
borderRadius: 12,
padding: '12px 16px',
marginBottom: 20,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#fff',
fontSize: 14,
fontWeight: 600,
}}>
gap: 12,
}}
>
<div
style={{
width: 40,
height: 40,
borderRadius: 8,
background: '#667eea',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#fff',
fontSize: 14,
fontWeight: 600,
}}
>
TKT
</div>
<div>
<div style={{ fontSize: 12, color: '#666', marginBottom: 2 }}>工单编号自动生成</div>
<div style={{ fontSize: 12, color: '#666', marginBottom: 2 }}>
工单编号自动生成
</div>
<Form.Item name="ticketId" noStyle>
<Input
disabled
@@ -1209,28 +1223,34 @@ function TicketManagement() {
</div>
)}
<div style={{
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: '0 24px',
}}>
<div
style={{
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: '0 24px',
}}
>
<div style={{ gridColumn: '1 / -1', marginBottom: 8 }}>
<div style={{
fontSize: 13,
fontWeight: 600,
color: '#333',
marginBottom: 12,
display: 'flex',
alignItems: 'center',
gap: 8,
}}>
<span style={{
width: 4,
height: 16,
background: 'linear-gradient(180deg, #667eea 0%, #764ba2 100%)',
borderRadius: 2,
display: 'inline-block',
}} />
<div
style={{
fontSize: 13,
fontWeight: 600,
color: '#333',
marginBottom: 12,
display: 'flex',
alignItems: 'center',
gap: 8,
}}
>
<span
style={{
width: 4,
height: 16,
background: 'linear-gradient(180deg, #667eea 0%, #764ba2 100%)',
borderRadius: 2,
display: 'inline-block',
}}
/>
设备信息
</div>
</div>
@@ -1264,7 +1284,11 @@ function TicketManagement() {
<div>
<Form.Item
name="serialNumber"
label={<span style={{ fontWeight: 500 }}>设备序列号 <span style={{ color: '#ff4d4f' }}>*</span></span>}
label={
<span style={{ fontWeight: 500 }}>
设备序列号 <span style={{ color: '#ff4d4f' }}>*</span>
</span>
}
rules={[{ required: true, message: '请输入设备序列号' }]}
>
<Input placeholder="请输入设备序列号" size="large" />
@@ -1283,7 +1307,11 @@ function TicketManagement() {
<div style={{ gridColumn: '1 / -1' }}>
<Form.Item
name="deviceId"
label={<span style={{ fontWeight: 500 }}>关联设备 <span style={{ color: '#ff4d4f' }}>*</span></span>}
label={
<span style={{ fontWeight: 500 }}>
关联设备 <span style={{ color: '#ff4d4f' }}>*</span>
</span>
}
rules={[{ required: true, message: '请选择关联设备' }]}
>
<Select
@@ -1317,11 +1345,7 @@ function TicketManagement() {
return renderDeviceSection();
} else if (hasDeviceNameField) {
if (!ticketFields.some(f => f.fieldName === 'deviceId')) {
return (
<div style={{ gridColumn: '1 / -1' }}>
{renderDeviceSection()}
</div>
);
return <div style={{ gridColumn: '1 / -1' }}>{renderDeviceSection()}</div>;
}
} else {
return renderDeviceSection();
@@ -1330,38 +1354,56 @@ function TicketManagement() {
})()}
<div style={{ gridColumn: '1 / -1', marginBottom: 8, marginTop: 8 }}>
<div style={{
fontSize: 13,
fontWeight: 600,
color: '#333',
marginBottom: 12,
display: 'flex',
alignItems: 'center',
gap: 8,
}}>
<span style={{
width: 4,
height: 16,
background: 'linear-gradient(180deg, #667eea 0%, #764ba2 100%)',
borderRadius: 2,
display: 'inline-block',
}} />
<div
style={{
fontSize: 13,
fontWeight: 600,
color: '#333',
marginBottom: 12,
display: 'flex',
alignItems: 'center',
gap: 8,
}}
>
<span
style={{
width: 4,
height: 16,
background: 'linear-gradient(180deg, #667eea 0%, #764ba2 100%)',
borderRadius: 2,
display: 'inline-block',
}}
/>
工单信息
</div>
</div>
{ticketFields.filter(f =>
!['ticketId', 'deviceId', 'deviceName', 'serialNumber', 'expectedCompletionDate', 'resolution', 'notes', 'description'].includes(f.fieldName)
).map(field => (
<div key={field.fieldName}>
{renderFormItem(field)}
</div>
))}
{ticketFields
.filter(
f =>
![
'ticketId',
'deviceId',
'deviceName',
'serialNumber',
'expectedCompletionDate',
'resolution',
'notes',
'description',
].includes(f.fieldName)
)
.map(field => (
<div key={field.fieldName}>{renderFormItem(field)}</div>
))}
<div style={{ gridColumn: '1 / -1' }}>
<Form.Item
name="title"
label={<span style={{ fontWeight: 500 }}>工单标题 <span style={{ color: '#ff4d4f' }}>*</span></span>}
label={
<span style={{ fontWeight: 500 }}>
工单标题 <span style={{ color: '#ff4d4f' }}>*</span>
</span>
}
rules={[{ required: true, message: '请输入工单标题' }]}
>
<Input placeholder="请输入工单标题" size="large" />
@@ -1371,7 +1413,11 @@ function TicketManagement() {
<div>
<Form.Item
name="faultCategory"
label={<span style={{ fontWeight: 500 }}>故障分类 <span style={{ color: '#ff4d4f' }}>*</span></span>}
label={
<span style={{ fontWeight: 500 }}>
故障分类 <span style={{ color: '#ff4d4f' }}>*</span>
</span>
}
rules={[{ required: true, message: '请选择故障分类' }]}
>
<Select placeholder="请选择故障分类" size="large">
@@ -1387,22 +1433,34 @@ function TicketManagement() {
<div>
<Form.Item
name="priority"
label={<span style={{ fontWeight: 500 }}>优先级 <span style={{ color: '#ff4d4f' }}>*</span></span>}
label={
<span style={{ fontWeight: 500 }}>
优先级 <span style={{ color: '#ff4d4f' }}>*</span>
</span>
}
rules={[{ required: true, message: '请选择优先级' }]}
initialValue="medium"
>
<Select placeholder="请选择优先级" size="large">
<Option value="low">
<Tag color="green" style={{ margin: 0 }}></Tag>
<Tag color="green" style={{ margin: 0 }}>
</Tag>
</Option>
<Option value="medium">
<Tag color="orange" style={{ margin: 0 }}></Tag>
<Tag color="orange" style={{ margin: 0 }}>
</Tag>
</Option>
<Option value="high">
<Tag color="red" style={{ margin: 0 }}></Tag>
<Tag color="red" style={{ margin: 0 }}>
</Tag>
</Option>
<Option value="urgent">
<Tag color="magenta" style={{ margin: 0 }}>紧急</Tag>
<Tag color="magenta" style={{ margin: 0 }}>
紧急
</Tag>
</Option>
</Select>
</Form.Item>
@@ -1426,7 +1484,11 @@ function TicketManagement() {
<div style={{ gridColumn: '1 / -1' }}>
<Form.Item
name="description"
label={<span style={{ fontWeight: 500 }}>故障描述 <span style={{ color: '#ff4d4f' }}>*</span></span>}
label={
<span style={{ fontWeight: 500 }}>
故障描述 <span style={{ color: '#ff4d4f' }}>*</span>
</span>
}
rules={[{ required: true, message: '请输入故障描述' }]}
>
<TextArea
@@ -1439,10 +1501,7 @@ function TicketManagement() {
</div>
<div style={{ gridColumn: '1 / -1', marginTop: 8 }}>
<Form.Item
name="notes"
label={<span style={{ fontWeight: 500 }}>备注信息</span>}
>
<Form.Item name="notes" label={<span style={{ fontWeight: 500 }}>备注信息</span>}>
<TextArea
rows={2}
placeholder="补充说明或其他相关信息(选填)"
@@ -1453,14 +1512,16 @@ function TicketManagement() {
</div>
</div>
<div style={{
borderTop: '1px solid #f0f0f0',
marginTop: 24,
paddingTop: 20,
display: 'flex',
justifyContent: 'flex-end',
gap: 12,
}}>
<div
style={{
borderTop: '1px solid #f0f0f0',
marginTop: 24,
paddingTop: 20,
display: 'flex',
justifyContent: 'flex-end',
gap: 12,
}}
>
<Button onClick={handleCancel} size="large" style={{ minWidth: 100 }}>
取消
</Button>
@@ -1706,15 +1767,19 @@ function TicketManagement() {
<Row gutter={[24, 16]}>
<Col span={12}>
<div style={{ color: '#666', fontSize: 12, marginBottom: 4 }}>购买日期</div>
<div>{selectedTicket.Device.purchaseDate
? dayjs(selectedTicket.Device.purchaseDate).format('YYYY-MM-DD')
: '-'}</div>
<div>
{selectedTicket.Device.purchaseDate
? dayjs(selectedTicket.Device.purchaseDate).format('YYYY-MM-DD')
: '-'}
</div>
</Col>
<Col span={12}>
<div style={{ color: '#666', fontSize: 12, marginBottom: 4 }}>保修到期</div>
<div>{selectedTicket.Device.warrantyExpiry
? dayjs(selectedTicket.Device.warrantyExpiry).format('YYYY-MM-DD')
: '-'}</div>
<div>
{selectedTicket.Device.warrantyExpiry
? dayjs(selectedTicket.Device.warrantyExpiry).format('YYYY-MM-DD')
: '-'}
</div>
</Col>
</Row>
</Card>
@@ -1722,28 +1787,35 @@ function TicketManagement() {
{/* 描述信息 */}
{selectedTicket.Device.description && (
<Card title="描述" style={{ marginBottom: 16 }} size="small">
<div style={{ whiteSpace: 'pre-wrap' }}>{selectedTicket.Device.description}</div>
<div style={{ whiteSpace: 'pre-wrap' }}>
{selectedTicket.Device.description}
</div>
</Card>
)}
{/* 自定义字段卡片 */}
{selectedTicket.Device.customFields && Object.keys(selectedTicket.Device.customFields).length > 0 && (
<Card title="自定义字段" size="small">
<Row gutter={[24, 16]}>
{Object.entries(selectedTicket.Device.customFields).map(([key, value]) => {
// deviceFields
const fieldConfig = deviceFields.find(f => f.fieldName === key);
const displayName = fieldConfig?.displayName || key;
return (
<Col span={8} key={key}>
<div style={{ color: '#666', fontSize: 12, marginBottom: 4 }}>{displayName}</div>
<div style={{ fontWeight: 500 }}>{String(value)}</div>
</Col>
);
})}
</Row>
</Card>
)}
{selectedTicket.Device.customFields &&
Object.keys(selectedTicket.Device.customFields).length > 0 && (
<Card title="自定义字段" size="small">
<Row gutter={[24, 16]}>
{Object.entries(selectedTicket.Device.customFields).map(
([key, value]) => {
// deviceFields
const fieldConfig = deviceFields.find(f => f.fieldName === key);
const displayName = fieldConfig?.displayName || key;
return (
<Col span={8} key={key}>
<div style={{ color: '#666', fontSize: 12, marginBottom: 4 }}>
{displayName}
</div>
<div style={{ fontWeight: 500 }}>{String(value)}</div>
</Col>
);
}
)}
</Row>
</Card>
)}
</div>
</TabPane>
)}
@@ -1851,9 +1923,7 @@ function TicketManagement() {
</div>
<div style={{ marginBottom: '4px' }}>
<span style={{ color: '#666' }}>操作人</span>
<span style={{ fontWeight: 500 }}>
{record.operatorName || '-'}
</span>
<span style={{ fontWeight: 500 }}>{record.operatorName || '-'}</span>
</div>
{record.operationDescription && (
<div
+44 -21
View File
@@ -1,5 +1,19 @@
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { Card, Row, Col, Statistic, Table, DatePicker, Select, Space, Tag, message, Button, Switch, Tooltip } from 'antd';
import {
Card,
Row,
Col,
Statistic,
Table,
DatePicker,
Select,
Space,
Tag,
message,
Button,
Switch,
Tooltip,
} from 'antd';
import {
BarChartOutlined,
PieChartOutlined,
@@ -82,26 +96,29 @@ function TicketStatistics() {
const timerRef = useRef(null);
const fetchStatistics = useCallback(async (isManual = false) => {
try {
if (isManual) {
setLoading(true);
}
const params = {
startDate: dateRange[0].format('YYYY-MM-DD'),
endDate: dateRange[1].format('YYYY-MM-DD'),
};
const fetchStatistics = useCallback(
async (isManual = false) => {
try {
if (isManual) {
setLoading(true);
}
const params = {
startDate: dateRange[0].format('YYYY-MM-DD'),
endDate: dateRange[1].format('YYYY-MM-DD'),
};
const response = await axios.get('/api/tickets/stats', { params });
setStatistics(response.data);
setLastUpdateTime(new Date());
} catch (error) {
message.error('获取统计数据失败');
console.error('获取统计数据失败:', error);
} finally {
setLoading(false);
}
}, [dateRange]);
const response = await axios.get('/api/tickets/stats', { params });
setStatistics(response.data);
setLastUpdateTime(new Date());
} catch (error) {
message.error('获取统计数据失败');
console.error('获取统计数据失败:', error);
} finally {
setLoading(false);
}
},
[dateRange]
);
useEffect(() => {
fetchStatistics();
@@ -329,7 +346,13 @@ function TicketStatistics() {
title="工单统计报表"
extra={
<Space>
<Tooltip title={lastUpdateTime ? `最后更新: ${dayjs(lastUpdateTime).format('HH:mm:ss')}` : '尚未更新'}>
<Tooltip
title={
lastUpdateTime
? `最后更新: ${dayjs(lastUpdateTime).format('HH:mm:ss')}`
: '尚未更新'
}
>
<span style={{ fontSize: 12, color: '#888', marginRight: 8 }}>
{lastUpdateTime && `更新于 ${dayjs(lastUpdateTime).format('HH:mm:ss')}`}
</span>