458 lines
14 KiB
React
458 lines
14 KiB
React
|
|
import React, { useState, useEffect } from 'react';
|
||
|
|
import {
|
||
|
|
Plus,
|
||
|
|
Edit,
|
||
|
|
Trash2,
|
||
|
|
Search,
|
||
|
|
X,
|
||
|
|
Monitor,
|
||
|
|
Smartphone,
|
||
|
|
Tablet,
|
||
|
|
Power,
|
||
|
|
RefreshCw
|
||
|
|
} from 'lucide-react';
|
||
|
|
import apiClient from '../../utils/apiClient';
|
||
|
|
import { buildApiUrl, API_ENDPOINTS } from '../../config/api';
|
||
|
|
import ConfirmDialog from '../../components/ConfirmDialog';
|
||
|
|
import Toast from '../../components/Toast';
|
||
|
|
import PageLoading from '../../components/PageLoading';
|
||
|
|
import FormModal from '../../components/FormModal';
|
||
|
|
import './TerminalManagement.css';
|
||
|
|
|
||
|
|
const TerminalManagement = () => {
|
||
|
|
const [terminals, setTerminals] = useState([]); // All terminals
|
||
|
|
const [terminalTypes, setTerminalTypes] = useState([]);
|
||
|
|
const [loading, setLoading] = useState(true);
|
||
|
|
|
||
|
|
// Local state for filtering/pagination
|
||
|
|
const [keyword, setKeyword] = useState('');
|
||
|
|
const [filterType, setFilterType] = useState('');
|
||
|
|
const [filterStatus, setFilterStatus] = useState('');
|
||
|
|
const [page, setPage] = useState(1);
|
||
|
|
const [pageSize, setPageSize] = useState(20);
|
||
|
|
|
||
|
|
const [showModal, setShowModal] = useState(false);
|
||
|
|
const [isEditing, setIsEditing] = useState(false);
|
||
|
|
const [selectedTerminal, setSelectedTerminal] = useState(null);
|
||
|
|
const [deleteConfirmInfo, setDeleteConfirmInfo] = useState(null);
|
||
|
|
const [toasts, setToasts] = useState([]);
|
||
|
|
|
||
|
|
const [formData, setFormData] = useState({
|
||
|
|
imei: '',
|
||
|
|
terminal_name: '',
|
||
|
|
terminal_type: '',
|
||
|
|
description: '',
|
||
|
|
status: 1
|
||
|
|
});
|
||
|
|
|
||
|
|
// Toast helper
|
||
|
|
const showToast = (message, type = 'info') => {
|
||
|
|
const id = Date.now();
|
||
|
|
setToasts(prev => [...prev, { id, message, type }]);
|
||
|
|
};
|
||
|
|
|
||
|
|
const removeToast = (id) => {
|
||
|
|
setToasts(prev => prev.filter(toast => toast.id !== id));
|
||
|
|
};
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
fetchTerminalTypes();
|
||
|
|
fetchTerminals();
|
||
|
|
}, []);
|
||
|
|
|
||
|
|
const fetchTerminalTypes = async () => {
|
||
|
|
try {
|
||
|
|
const response = await apiClient.get(
|
||
|
|
buildApiUrl(API_ENDPOINTS.DICT_DATA.BY_TYPE('client_platform')),
|
||
|
|
{ params: { parent_code: 'TERMINAL' } }
|
||
|
|
);
|
||
|
|
if (response.code === '200') {
|
||
|
|
setTerminalTypes(response.data.items || []);
|
||
|
|
}
|
||
|
|
} catch (error) {
|
||
|
|
console.error('Failed to fetch terminal types:', error);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
const fetchTerminals = async () => {
|
||
|
|
setLoading(true);
|
||
|
|
try {
|
||
|
|
// Fetch all terminals (using a large size)
|
||
|
|
const params = {
|
||
|
|
page: 1,
|
||
|
|
size: 10000
|
||
|
|
};
|
||
|
|
|
||
|
|
const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.TERMINALS.LIST), { params });
|
||
|
|
if (response.code === '200') {
|
||
|
|
setTerminals(response.data.items);
|
||
|
|
}
|
||
|
|
} catch (error) {
|
||
|
|
console.error('Failed to fetch terminals:', error);
|
||
|
|
showToast('获取终端列表失败', 'error');
|
||
|
|
} finally {
|
||
|
|
setLoading(false);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
// Local Filtering
|
||
|
|
const filteredTerminals = terminals.filter(terminal => {
|
||
|
|
const matchesKeyword = !keyword ||
|
||
|
|
terminal.imei.toLowerCase().includes(keyword.toLowerCase()) ||
|
||
|
|
(terminal.terminal_name && terminal.terminal_name.toLowerCase().includes(keyword.toLowerCase()));
|
||
|
|
|
||
|
|
const matchesType = !filterType || terminal.terminal_type === filterType;
|
||
|
|
|
||
|
|
const matchesStatus = filterStatus === '' || terminal.status === parseInt(filterStatus);
|
||
|
|
|
||
|
|
return matchesKeyword && matchesType && matchesStatus;
|
||
|
|
});
|
||
|
|
|
||
|
|
// Local Pagination
|
||
|
|
const total = filteredTerminals.length;
|
||
|
|
const paginatedTerminals = filteredTerminals.slice((page - 1) * pageSize, page * pageSize);
|
||
|
|
|
||
|
|
const handleReset = () => {
|
||
|
|
setKeyword('');
|
||
|
|
setFilterType('');
|
||
|
|
setFilterStatus('');
|
||
|
|
setPage(1);
|
||
|
|
};
|
||
|
|
|
||
|
|
const handleOpenCreate = () => {
|
||
|
|
setIsEditing(false);
|
||
|
|
setSelectedTerminal(null);
|
||
|
|
setFormData({
|
||
|
|
imei: '',
|
||
|
|
terminal_name: '',
|
||
|
|
terminal_type: terminalTypes.length > 0 ? terminalTypes[0].dict_code : '',
|
||
|
|
description: '',
|
||
|
|
status: 1
|
||
|
|
});
|
||
|
|
setShowModal(true);
|
||
|
|
};
|
||
|
|
|
||
|
|
const handleOpenEdit = (terminal) => {
|
||
|
|
setIsEditing(true);
|
||
|
|
setSelectedTerminal(terminal);
|
||
|
|
setFormData({
|
||
|
|
imei: terminal.imei,
|
||
|
|
terminal_name: terminal.terminal_name || '',
|
||
|
|
terminal_type: terminal.terminal_type,
|
||
|
|
description: terminal.description || '',
|
||
|
|
status: terminal.status
|
||
|
|
});
|
||
|
|
setShowModal(true);
|
||
|
|
};
|
||
|
|
|
||
|
|
const handleSubmit = async (e) => {
|
||
|
|
e.preventDefault();
|
||
|
|
|
||
|
|
if (!formData.imei) {
|
||
|
|
showToast('请输入IMEI号', 'error');
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
if (!formData.terminal_type) {
|
||
|
|
showToast('请选择终端类型', 'error');
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
try {
|
||
|
|
if (isEditing) {
|
||
|
|
await apiClient.put(
|
||
|
|
buildApiUrl(API_ENDPOINTS.TERMINALS.UPDATE(selectedTerminal.id)),
|
||
|
|
formData
|
||
|
|
);
|
||
|
|
showToast('更新成功', 'success');
|
||
|
|
} else {
|
||
|
|
await apiClient.post(buildApiUrl(API_ENDPOINTS.TERMINALS.CREATE), formData);
|
||
|
|
showToast('创建成功', 'success');
|
||
|
|
}
|
||
|
|
setShowModal(false);
|
||
|
|
fetchTerminals();
|
||
|
|
} catch (error) {
|
||
|
|
const msg = error.response?.data?.message || '操作失败';
|
||
|
|
showToast(msg, 'error');
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
const handleDelete = async () => {
|
||
|
|
if (!deleteConfirmInfo) return;
|
||
|
|
try {
|
||
|
|
await apiClient.delete(buildApiUrl(API_ENDPOINTS.TERMINALS.DELETE(deleteConfirmInfo.id)));
|
||
|
|
showToast('删除成功', 'success');
|
||
|
|
fetchTerminals();
|
||
|
|
} catch (error) {
|
||
|
|
const msg = error.response?.data?.message || '删除失败';
|
||
|
|
showToast(msg, 'error');
|
||
|
|
} finally {
|
||
|
|
setDeleteConfirmInfo(null);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
const handleToggleStatus = async (terminal) => {
|
||
|
|
try {
|
||
|
|
const newStatus = terminal.status === 1 ? 0 : 1;
|
||
|
|
await apiClient.post(
|
||
|
|
buildApiUrl(API_ENDPOINTS.TERMINALS.STATUS(terminal.id)),
|
||
|
|
null,
|
||
|
|
{ params: { status: newStatus } }
|
||
|
|
);
|
||
|
|
showToast(`已${newStatus === 1 ? '启用' : '停用'}终端`, 'success');
|
||
|
|
// Update local state directly for better UX
|
||
|
|
setTerminals(prev => prev.map(t =>
|
||
|
|
t.id === terminal.id ? { ...t, status: newStatus } : t
|
||
|
|
));
|
||
|
|
} catch (error) {
|
||
|
|
showToast('状态更新失败', 'error');
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
const getTerminalTypeLabel = (code) => {
|
||
|
|
const type = terminalTypes.find(t => t.dict_code === code);
|
||
|
|
return type ? type.label_cn : code;
|
||
|
|
};
|
||
|
|
|
||
|
|
if (loading && terminals.length === 0) {
|
||
|
|
return <PageLoading message="加载终端数据..." />;
|
||
|
|
}
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="terminal-management">
|
||
|
|
<div className="page-header">
|
||
|
|
<div className="header-left">
|
||
|
|
<h1>终端管理</h1>
|
||
|
|
<p className="subtitle">管理专用终端设备的接入与状态</p>
|
||
|
|
</div>
|
||
|
|
<button className="btn-primary" onClick={handleOpenCreate}>
|
||
|
|
<Plus size={20} />
|
||
|
|
添加终端
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="filter-bar">
|
||
|
|
<div className="search-group">
|
||
|
|
<div className="search-box">
|
||
|
|
<Search size={18} />
|
||
|
|
<input
|
||
|
|
type="text"
|
||
|
|
placeholder="搜索IMEI或终端名称"
|
||
|
|
value={keyword}
|
||
|
|
onChange={(e) => setKeyword(e.target.value)}
|
||
|
|
/>
|
||
|
|
{keyword && (
|
||
|
|
<button className="clear-search" onClick={() => setKeyword('')}>
|
||
|
|
<X size={16} />
|
||
|
|
</button>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="filter-group">
|
||
|
|
<select
|
||
|
|
value={filterType}
|
||
|
|
onChange={(e) => { setFilterType(e.target.value); setPage(1); }}
|
||
|
|
className="filter-select"
|
||
|
|
>
|
||
|
|
<option value="">所有类型</option>
|
||
|
|
{terminalTypes.map(t => (
|
||
|
|
<option key={t.dict_code} value={t.dict_code}>{t.label_cn}</option>
|
||
|
|
))}
|
||
|
|
</select>
|
||
|
|
|
||
|
|
<select
|
||
|
|
value={filterStatus}
|
||
|
|
onChange={(e) => { setFilterStatus(e.target.value); setPage(1); }}
|
||
|
|
className="filter-select"
|
||
|
|
>
|
||
|
|
<option value="">所有状态</option>
|
||
|
|
<option value="1">启用</option>
|
||
|
|
<option value="0">停用</option>
|
||
|
|
</select>
|
||
|
|
|
||
|
|
<button className="btn-icon" onClick={handleReset} title="重置筛选">
|
||
|
|
<RefreshCw size={18} />
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="table-container">
|
||
|
|
<table className="data-table">
|
||
|
|
<thead>
|
||
|
|
<tr>
|
||
|
|
<th>IMEI</th>
|
||
|
|
<th>终端名称</th>
|
||
|
|
<th>类型</th>
|
||
|
|
<th>状态</th>
|
||
|
|
<th>激活状态</th>
|
||
|
|
<th>最后在线</th>
|
||
|
|
<th>创建时间</th>
|
||
|
|
<th>操作</th>
|
||
|
|
</tr>
|
||
|
|
</thead>
|
||
|
|
<tbody>
|
||
|
|
{paginatedTerminals.length === 0 ? (
|
||
|
|
<tr>
|
||
|
|
<td colSpan="8" className="empty-state">暂无数据</td>
|
||
|
|
</tr>
|
||
|
|
) : (
|
||
|
|
paginatedTerminals.map(terminal => (
|
||
|
|
<tr key={terminal.id}>
|
||
|
|
<td className="font-mono">{terminal.imei}</td>
|
||
|
|
<td>{terminal.terminal_name || '-'}</td>
|
||
|
|
<td>
|
||
|
|
<span className="type-badge">
|
||
|
|
{getTerminalTypeLabel(terminal.terminal_type)}
|
||
|
|
</span>
|
||
|
|
</td>
|
||
|
|
<td>
|
||
|
|
<div className="status-toggle" onClick={() => handleToggleStatus(terminal)}>
|
||
|
|
<div className={`toggle-switch ${terminal.status === 1 ? 'on' : 'off'}`}>
|
||
|
|
<div className="toggle-slider"></div>
|
||
|
|
</div>
|
||
|
|
<span className="status-text">{terminal.status === 1 ? '启用' : '停用'}</span>
|
||
|
|
</div>
|
||
|
|
</td>
|
||
|
|
<td>
|
||
|
|
<span className={`status-dot ${terminal.is_activated === 1 ? 'active' : 'inactive'}`}></span>
|
||
|
|
{terminal.is_activated === 1 ? '已激活' : '未激活'}
|
||
|
|
</td>
|
||
|
|
<td>{terminal.last_online_at ? new Date(terminal.last_online_at).toLocaleString() : '-'}</td>
|
||
|
|
<td>{new Date(terminal.created_at).toLocaleDateString()}</td>
|
||
|
|
<td>
|
||
|
|
<div className="action-buttons">
|
||
|
|
<button
|
||
|
|
className="btn-icon btn-edit"
|
||
|
|
onClick={() => handleOpenEdit(terminal)}
|
||
|
|
title="编辑"
|
||
|
|
>
|
||
|
|
<Edit size={16} />
|
||
|
|
</button>
|
||
|
|
<button
|
||
|
|
className="btn-icon btn-delete"
|
||
|
|
onClick={() => setDeleteConfirmInfo({ id: terminal.id, name: terminal.imei })}
|
||
|
|
title="删除"
|
||
|
|
>
|
||
|
|
<Trash2 size={16} />
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
</td>
|
||
|
|
</tr>
|
||
|
|
))
|
||
|
|
)}
|
||
|
|
</tbody>
|
||
|
|
</table>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* 分页 */}
|
||
|
|
{total > 0 && (
|
||
|
|
<div className="pagination">
|
||
|
|
<div className="pagination-info">
|
||
|
|
共 {total} 条记录
|
||
|
|
</div>
|
||
|
|
<div className="pagination-controls">
|
||
|
|
<button
|
||
|
|
disabled={page === 1}
|
||
|
|
onClick={() => setPage(p => p - 1)}
|
||
|
|
>
|
||
|
|
上一页
|
||
|
|
</button>
|
||
|
|
<span className="page-number">{page}</span>
|
||
|
|
<button
|
||
|
|
disabled={page * pageSize >= total}
|
||
|
|
onClick={() => setPage(p => p + 1)}
|
||
|
|
>
|
||
|
|
下一页
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{/* 弹窗 */}
|
||
|
|
<FormModal
|
||
|
|
isOpen={showModal}
|
||
|
|
onClose={() => setShowModal(false)}
|
||
|
|
title={isEditing ? '编辑终端' : '添加终端'}
|
||
|
|
actions={
|
||
|
|
<>
|
||
|
|
<button type="button" className="btn-secondary" onClick={() => setShowModal(false)}>取消</button>
|
||
|
|
<button type="button" className="btn-primary" onClick={handleSubmit}>保存</button>
|
||
|
|
</>
|
||
|
|
}
|
||
|
|
>
|
||
|
|
<div className="form-group">
|
||
|
|
<label>IMEI *</label>
|
||
|
|
<input
|
||
|
|
type="text"
|
||
|
|
value={formData.imei}
|
||
|
|
onChange={e => setFormData({...formData, imei: e.target.value})}
|
||
|
|
placeholder="请输入设备IMEI号"
|
||
|
|
disabled={isEditing} // IMEI通常不可修改
|
||
|
|
required
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
<div className="form-group">
|
||
|
|
<label>终端名称</label>
|
||
|
|
<input
|
||
|
|
type="text"
|
||
|
|
value={formData.terminal_name}
|
||
|
|
onChange={e => setFormData({...formData, terminal_name: e.target.value})}
|
||
|
|
placeholder="给设备起个名字"
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
<div className="form-group">
|
||
|
|
<label>终端类型 *</label>
|
||
|
|
<select
|
||
|
|
value={formData.terminal_type}
|
||
|
|
onChange={e => setFormData({...formData, terminal_type: e.target.value})}
|
||
|
|
required
|
||
|
|
>
|
||
|
|
<option value="" disabled>请选择类型</option>
|
||
|
|
{terminalTypes.map(t => (
|
||
|
|
<option key={t.dict_code} value={t.dict_code}>{t.label_cn}</option>
|
||
|
|
))}
|
||
|
|
</select>
|
||
|
|
</div>
|
||
|
|
<div className="form-group">
|
||
|
|
<label>备注</label>
|
||
|
|
<textarea
|
||
|
|
value={formData.description}
|
||
|
|
onChange={e => setFormData({...formData, description: e.target.value})}
|
||
|
|
placeholder="设备说明..."
|
||
|
|
rows={3}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
<div className="form-group checkbox-group">
|
||
|
|
<label>
|
||
|
|
<input
|
||
|
|
type="checkbox"
|
||
|
|
checked={formData.status === 1}
|
||
|
|
onChange={e => setFormData({...formData, status: e.target.checked ? 1 : 0})}
|
||
|
|
/>
|
||
|
|
立即启用
|
||
|
|
</label>
|
||
|
|
</div>
|
||
|
|
</FormModal>
|
||
|
|
|
||
|
|
{/* 删除确认 */}
|
||
|
|
{deleteConfirmInfo && (
|
||
|
|
<ConfirmDialog
|
||
|
|
isOpen={true}
|
||
|
|
title="删除终端"
|
||
|
|
message={`确定要删除IMEI为 ${deleteConfirmInfo.name} 的终端吗?`}
|
||
|
|
onConfirm={handleDelete}
|
||
|
|
onClose={() => setDeleteConfirmInfo(null)}
|
||
|
|
type="danger"
|
||
|
|
/>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{/* Toasts */}
|
||
|
|
{toasts.map(t => (
|
||
|
|
<Toast key={t.id} message={t.message} type={t.type} onClose={() => removeToast(t.id)} />
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
};
|
||
|
|
|
||
|
|
export default TerminalManagement;
|