1256 lines
52 KiB
JavaScript
1256 lines
52 KiB
JavaScript
/**
|
||
* TalkingQ Platform - Device Management Page
|
||
* 按公司分类的设备管理
|
||
*/
|
||
|
||
const DEVICE_BASE_DEVICES = [
|
||
// 智慧星科技 (company_a)
|
||
{ id: 'c2b00f75-e7e7-581e-90f1-390d97d0dee6', name: '智慧星 #A001', companyId: 'company_a', companyName: '智慧星科技', sn: 'TQ_07DF6F77F19C0A55', fw: 'v2.3.1', status: 'online', battery: 85, lastSeen: '刚刚', region: '广东广州' },
|
||
{ id: 'f13a62b9-5c2e-5bb9-9f7b-4d9a3a7c2d01', name: '智慧星 #A002', companyId: 'company_a', companyName: '智慧星科技', sn: 'TQ_0B8A6C21E94F3D10', fw: 'v2.3.0', status: 'online', battery: 62, lastSeen: '1分钟前', region: '广东深圳' },
|
||
{ id: '9d7a4c26-8f13-5c2d-a764-7f90c5b118a2', name: '智慧星 #A003', companyId: 'company_a', companyName: '智慧星科技', sn: 'TQ_14C9F02A77D85B61', fw: 'v2.2.5', status: 'offline', battery: 0, lastSeen: '3天前', region: '广东广州' },
|
||
{ id: 'ad0f462a-2166-5148-9af3-64e711c3df78', name: '智慧星 #A004', companyId: 'company_a', companyName: '智慧星科技', sn: 'TQ_6F91A2C8B0D44E73', fw: 'v2.3.1', status: 'error', battery: 45, lastSeen: '10分钟前', region: '广东佛山' },
|
||
{ id: 'e51f9c0b-7d1a-58d4-8c03-1ac9f6b2e945', name: '智慧星 #A005', companyId: 'company_a', companyName: '智慧星科技', sn: 'TQ_A03C5E79B1D2468F', fw: 'v2.3.1', status: 'online', battery: 33, lastSeen: '5分钟前', region: '广东东莞' },
|
||
|
||
// 未来玩具厂 (company_b)
|
||
{ id: 'b0d5e7c2-6f3a-5a19-9c87-21b64f9e0a33', name: '未来玩具 #B001', companyId: 'company_b', companyName: '未来玩具厂', sn: 'TQ_3E6A9B12C8D074F5', fw: 'v2.0.2', status: 'online', battery: 90, lastSeen: '刚刚', region: '浙江杭州' },
|
||
{ id: '0a91d4e5-2c6f-53b8-8e14-97d23c5f61a0', name: '未来玩具 #B002', companyId: 'company_b', companyName: '未来玩具厂', sn: 'TQ_D4F0219A6B8C35E7', fw: 'v2.0.0', status: 'online', battery: 55, lastSeen: '10分钟前', region: '浙江宁波' },
|
||
{ id: '6cc1b8a3-9a2d-5f71-8d42-f0a7b3c9d5e2', name: '未来玩具 #B003', companyId: 'company_b', companyName: '未来玩具厂', sn: 'TQ_82A4F6D13C9E0B75', fw: 'v1.8.2', status: 'offline', battery: 0, lastSeen: '2天前', region: '浙江温州' },
|
||
{ id: '4f82a7c9-b1d3-5e06-9a54-3c1d8f7b02a6', name: '未来玩具 #B004', companyId: 'company_b', companyName: '未来玩具厂', sn: 'TQ_59C0E7A31D8B246F', fw: 'v2.0.2', status: 'online', battery: 78, lastSeen: '1分钟前', region: '浙江义乌' },
|
||
|
||
// 童趣电子 (company_c)
|
||
{ id: '7b31f9e2-08d6-5a44-9b2c-6e1a0f93c58d', name: '童趣电子 #C001', companyId: 'company_c', companyName: '童趣电子', sn: 'TQ_B7D04A6E2C9138F5', fw: 'v1.8.2', status: 'online', battery: 72, lastSeen: '刚刚', region: '江苏苏州' },
|
||
{ id: '2c9e6a41-d0b7-53f8-8a15-e6c37b9402df', name: '童趣电子 #C002', companyId: 'company_c', companyName: '童趣电子', sn: 'TQ_1F8D3B60A7C9E452', fw: 'v1.8.2', status: 'offline', battery: 0, lastSeen: '5天前', region: '江苏南京' },
|
||
{ id: 'd6a0c7e8-39f4-50b2-8d91-4a6e27c5f0b3', name: '童趣电子 #C003', companyId: 'company_c', companyName: '童趣电子', sn: 'TQ_E2B8C14F09A673D5', fw: 'v1.8.2', status: 'error', battery: 15, lastSeen: '1小时前', region: '江苏无锡' },
|
||
];
|
||
|
||
try {
|
||
[
|
||
'talkingq_added_devices_v1',
|
||
'talkingq_removed_device_ids_v1',
|
||
'talkingq_device_company_overrides_v1',
|
||
].forEach(key => localStorage.removeItem(key));
|
||
} catch (error) {
|
||
console.warn('Failed to clear legacy device cache', error);
|
||
}
|
||
|
||
function saveStoredDevices() {
|
||
// Device changes are intentionally kept in memory for the demo.
|
||
}
|
||
|
||
const DEVICE_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||
const SERIAL_NUMBER_PATTERN = /^TQ_[0-9A-F]{16}$/i;
|
||
|
||
function normalizeSerialNumber(value) {
|
||
return String(value || '').trim().toUpperCase();
|
||
}
|
||
|
||
function normalizeDeviceRecord(device) {
|
||
const deviceId = String(device.device_id || device.id || '').trim();
|
||
const serialNumber = normalizeSerialNumber(device.serial_number || device.sn);
|
||
return {
|
||
...device,
|
||
id: deviceId,
|
||
device_id: deviceId,
|
||
sn: serialNumber,
|
||
serial_number: serialNumber,
|
||
};
|
||
}
|
||
|
||
function generateDeviceId(companyId, allDevices) {
|
||
const usedIds = new Set((allDevices || []).map(device => String(device.id || device.device_id || '').toLowerCase()));
|
||
if (window.crypto?.randomUUID) {
|
||
let nextId = window.crypto.randomUUID();
|
||
while (usedIds.has(nextId.toLowerCase())) {
|
||
nextId = window.crypto.randomUUID();
|
||
}
|
||
return nextId;
|
||
}
|
||
|
||
let nextId = '';
|
||
do {
|
||
nextId = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, char => {
|
||
const value = Math.floor(Math.random() * 16);
|
||
return (char === 'x' ? value : (value & 0x3) | 0x8).toString(16);
|
||
});
|
||
} while (usedIds.has(nextId.toLowerCase()));
|
||
return nextId;
|
||
}
|
||
|
||
function generateSerialNumber(allDevices) {
|
||
const usedSerials = new Set((allDevices || []).map(device => normalizeSerialNumber(device.sn || device.serial_number)));
|
||
let serialNumber = '';
|
||
do {
|
||
const hex = Array.from({ length: 16 }, () => Math.floor(Math.random() * 16).toString(16)).join('').toUpperCase();
|
||
serialNumber = `TQ_${hex}`;
|
||
} while (usedSerials.has(serialNumber));
|
||
return serialNumber;
|
||
}
|
||
|
||
window.__deviceBaseDevices = window.__deviceBaseDevices || DEVICE_BASE_DEVICES.map(device => normalizeDeviceRecord(device));
|
||
window.__deviceAddedDevices = window.__deviceAddedDevices || [];
|
||
window.__deviceRemovedIds = window.__deviceRemovedIds || [];
|
||
window.__deviceCompanyOverrides = window.__deviceCompanyOverrides || {};
|
||
window.__saveDeviceState = window.__saveDeviceState || saveStoredDevices;
|
||
|
||
function applyDeviceCompanyOverride(device) {
|
||
const override = window.__deviceCompanyOverrides?.[device.id];
|
||
return normalizeDeviceRecord(override ? { ...device, ...override } : device);
|
||
}
|
||
|
||
function getEffectiveDeviceCompanyId(device) {
|
||
return window.__deviceCompanyOverrides?.[device.id]?.companyId || device.companyId;
|
||
}
|
||
|
||
window.__getAllDevices = window.__getAllDevices || function() {
|
||
const removedSet = new Set(window.__deviceRemovedIds || []);
|
||
return (window.__deviceBaseDevices || [])
|
||
.concat(window.__deviceAddedDevices || [])
|
||
.filter(device => !removedSet.has(device.id))
|
||
.map(device => applyDeviceCompanyOverride(device));
|
||
};
|
||
|
||
window.__reassignCompanyDevices = window.__reassignCompanyDevices || function(sourceCompanyId, targetCompanyId) {
|
||
if (!sourceCompanyId || !targetCompanyId || sourceCompanyId === targetCompanyId) return 0;
|
||
const targetCompanyName = window.__getCompanyNameById ? window.__getCompanyNameById(targetCompanyId) : targetCompanyId;
|
||
let movedCount = 0;
|
||
const addedIds = new Set((window.__deviceAddedDevices || []).map(device => device.id));
|
||
const updateList = list => {
|
||
list.forEach(device => {
|
||
if (getEffectiveDeviceCompanyId(device) === sourceCompanyId) {
|
||
if (addedIds.has(device.id)) {
|
||
delete window.__deviceCompanyOverrides[device.id];
|
||
} else {
|
||
window.__deviceCompanyOverrides[device.id] = {
|
||
companyId: targetCompanyId,
|
||
companyName: targetCompanyName,
|
||
};
|
||
}
|
||
device.companyId = targetCompanyId;
|
||
device.companyName = targetCompanyName;
|
||
movedCount += 1;
|
||
}
|
||
});
|
||
};
|
||
updateList(window.__deviceBaseDevices || []);
|
||
updateList(window.__deviceAddedDevices || []);
|
||
window.__saveDeviceState?.();
|
||
return movedCount;
|
||
};
|
||
|
||
window.__resetDeviceMockData = window.__resetDeviceMockData || function() {
|
||
window.__deviceBaseDevices = DEVICE_BASE_DEVICES.map(device => normalizeDeviceRecord(device));
|
||
window.__deviceAddedDevices = [];
|
||
window.__deviceRemovedIds = [];
|
||
window.__deviceCompanyOverrides = {};
|
||
window.__saveDeviceState?.();
|
||
window.dispatchEvent(new CustomEvent('devicesChanged', {
|
||
detail: { companyId: 'all', reset: true },
|
||
}));
|
||
};
|
||
|
||
registerPage('device', function(container) {
|
||
// 获取当前公司
|
||
const currentCompanyId = window.__getCurrentCompanyId ? window.__getCurrentCompanyId() : (window.currentCompany || 'all');
|
||
const currentCompany = window.getCurrentCompany ? window.getCurrentCompany() : { id: 'all', name: '全部公司' };
|
||
const currentRole = window.__getUserRole ? window.__getUserRole() : 'admin';
|
||
const isClient = currentRole === 'client';
|
||
const baseDevices = window.__deviceBaseDevices || [];
|
||
window.__deviceAddedDevices = window.__deviceAddedDevices || [];
|
||
window.__deviceRemovedIds = window.__deviceRemovedIds || [];
|
||
window.__deviceCompanyOverrides = window.__deviceCompanyOverrides || {};
|
||
|
||
function getCompanyInfo(companyId) {
|
||
const company = window.__getCompanyById ? window.__getCompanyById(companyId) : null;
|
||
return company || { id: companyId, name: companyId, latestVersion: 'v1.0.0' };
|
||
}
|
||
|
||
function getCompanyName(companyId) {
|
||
return window.__getCompanyNameById ? window.__getCompanyNameById(companyId) : getCompanyInfo(companyId).name;
|
||
}
|
||
|
||
function getLatestFirmware(companyId) {
|
||
return getCompanyInfo(companyId).latestVersion || 'v1.0.0';
|
||
}
|
||
|
||
function escapeHtml(value) {
|
||
return String(value ?? '').replace(/[&<>"']/g, char => ({
|
||
'&': '&',
|
||
'<': '<',
|
||
'>': '>',
|
||
'"': '"',
|
||
"'": ''',
|
||
}[char]));
|
||
}
|
||
|
||
function escapeInlineValue(value) {
|
||
return String(value ?? '')
|
||
.replace(/\\/g, '\\\\')
|
||
.replace(/'/g, "\\'")
|
||
.replace(/\r?\n/g, ' ');
|
||
}
|
||
|
||
function adjustCompanyDeviceCount(companyId, delta) {
|
||
const company = window.companies?.find(item => item.id === companyId);
|
||
if (!company) return;
|
||
company.deviceCount = Math.max(0, (company.deviceCount || 0) + delta);
|
||
}
|
||
|
||
function getStatusLabel(status) {
|
||
return { online: '在线', offline: '离线', error: '异常' }[status] || '未知';
|
||
}
|
||
|
||
function getLiveDevices() {
|
||
const removedSet = new Set(window.__deviceRemovedIds || []);
|
||
return (window.__deviceBaseDevices || [])
|
||
.concat(window.__deviceAddedDevices || [])
|
||
.filter(device => !removedSet.has(device.id))
|
||
.map(device => applyDeviceCompanyOverride(device));
|
||
}
|
||
|
||
function getDefaultDeviceCompanyId(selectableCompanies) {
|
||
if (currentCompanyId !== 'all') return currentCompanyId;
|
||
if (window.currentCompany && window.currentCompany !== 'all') return window.currentCompany;
|
||
return selectableCompanies[0]?.id || 'company_a';
|
||
}
|
||
|
||
function buildDeviceCompanyField(fieldId, selectedCompanyId) {
|
||
const fallbackCompanies = [
|
||
{ id: 'company_a', name: '智慧星科技' },
|
||
{ id: 'company_b', name: '未来玩具厂' },
|
||
{ id: 'company_c', name: '童趣电子' },
|
||
];
|
||
const selectableCompanies = (window.companies?.length ? window.companies : fallbackCompanies)
|
||
.filter(company => company.id !== 'all');
|
||
const safeSelectedId = selectableCompanies.some(company => company.id === selectedCompanyId)
|
||
? selectedCompanyId
|
||
: getDefaultDeviceCompanyId(selectableCompanies);
|
||
const selectedCompany = getCompanyInfo(safeSelectedId);
|
||
|
||
if (isClient) {
|
||
return `
|
||
<input type="text" class="form-control" value="${escapeHtml(selectedCompany.name)}" readonly>
|
||
<input type="hidden" id="${fieldId}" value="${escapeHtml(selectedCompany.id)}">
|
||
`;
|
||
}
|
||
|
||
return `
|
||
<select class="form-control" id="${fieldId}">
|
||
${selectableCompanies.map(company => `
|
||
<option value="${escapeHtml(company.id)}" ${company.id === safeSelectedId ? 'selected' : ''}>${escapeHtml(company.name)}</option>
|
||
`).join('')}
|
||
</select>
|
||
`;
|
||
}
|
||
|
||
function createDeviceRecord({ name, sn, companyId }, allDevicesSnapshot) {
|
||
const companyInfo = getCompanyInfo(companyId);
|
||
const nextId = generateDeviceId(companyId, allDevicesSnapshot);
|
||
const serialNumber = normalizeSerialNumber(sn) || generateSerialNumber(allDevicesSnapshot);
|
||
const displayName = name || `${companyInfo.name} #${serialNumber.slice(-4)}`;
|
||
const record = normalizeDeviceRecord({
|
||
id: nextId,
|
||
device_id: nextId,
|
||
name: displayName,
|
||
companyId,
|
||
companyName: companyInfo.name,
|
||
sn: serialNumber,
|
||
serial_number: serialNumber,
|
||
fw: getLatestFirmware(companyId),
|
||
status: 'online',
|
||
battery: 100,
|
||
lastSeen: '刚刚',
|
||
region: '-',
|
||
});
|
||
allDevicesSnapshot.push(record);
|
||
return record;
|
||
}
|
||
|
||
function syncDeviceMutation(companyId, addedCount = 0) {
|
||
window.__saveDeviceState?.();
|
||
window.__syncCompanyDeviceCounts?.();
|
||
window.__refreshCompanyNavigation?.();
|
||
window.dispatchEvent(new CustomEvent('devicesChanged', {
|
||
detail: { companyId, addedCount },
|
||
}));
|
||
}
|
||
|
||
function parseBatchDeviceInput(rawText) {
|
||
const lines = String(rawText || '')
|
||
.split(/\r?\n/)
|
||
.map(line => line.trim())
|
||
.filter(Boolean);
|
||
|
||
return lines
|
||
.filter((line, index) => !(index === 0 && /设备名称|sn\s*码|SN\s*码|serial|serial_number|device_id/i.test(line)))
|
||
.map((line, index) => {
|
||
const parts = line.split(/[,,\t]/).map(part => part.trim()).filter(Boolean);
|
||
if (parts.length === 1) {
|
||
return { lineNo: index + 1, name: '', sn: parts[0] };
|
||
}
|
||
return { lineNo: index + 1, name: parts[0] || '', sn: parts[1] || '' };
|
||
});
|
||
}
|
||
|
||
const devices = getLiveDevices();
|
||
|
||
// 根据当前公司过滤设备
|
||
const filteredDevices = currentCompanyId === 'all'
|
||
? devices
|
||
: devices.filter(d => d.companyId === currentCompanyId);
|
||
|
||
// 统计信息
|
||
const stats = {
|
||
total: filteredDevices.length,
|
||
online: filteredDevices.filter(d => d.status === 'online').length,
|
||
offline: filteredDevices.filter(d => d.status === 'offline').length,
|
||
error: filteredDevices.filter(d => d.status === 'error').length,
|
||
};
|
||
|
||
container.innerHTML = `
|
||
<div class="page-header">
|
||
<div>
|
||
<div class="page-title">设备管理</div>
|
||
${currentCompanyId === 'all'
|
||
? '<div class="page-desc">管理所有客户的设备,支持实时监控和远程升级</div>'
|
||
: `<div class="page-desc">管理 ${currentCompany.name} 的设备</div>`
|
||
}
|
||
</div>
|
||
<div class="page-actions">
|
||
<button class="btn btn-outline" onclick="exportDevices()">
|
||
<i data-lucide="download"></i> 导出
|
||
</button>
|
||
${!isClient ? `
|
||
<button class="btn btn-outline" onclick="openBatchAddDeviceModal()">
|
||
<i data-lucide="upload"></i> 批量添加
|
||
</button>
|
||
<button class="btn btn-primary" onclick="openAddDeviceModal()">
|
||
<i data-lucide="plus"></i> 添加设备
|
||
</button>
|
||
` : ''}
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 公司信息提示 -->
|
||
${currentCompanyId === 'all'
|
||
? `<div class="all-companies-banner scope-banner">
|
||
<div class="scope-banner-main">
|
||
<div class="scope-banner-icon">
|
||
<i data-lucide="building-2"></i>
|
||
</div>
|
||
<div>
|
||
<div class="scope-banner-eyebrow">设备范围</div>
|
||
<div class="scope-banner-title">全部公司设备</div>
|
||
<div class="scope-banner-desc">汇总展示所有客户公司的设备状态,支持按公司下钻管理。</div>
|
||
</div>
|
||
</div>
|
||
<div class="scope-banner-meta">
|
||
<span class="scope-chip"><span>设备总数</span><strong>${stats.total}</strong></span>
|
||
<span class="scope-chip"><span>在线率</span><strong>${stats.total ? Math.round((stats.online / stats.total) * 100) : 0}%</strong></span>
|
||
</div>
|
||
</div>`
|
||
: `<div class="current-company-banner scope-banner">
|
||
<div class="scope-banner-main">
|
||
<div class="scope-banner-icon">
|
||
<i data-lucide="building-2"></i>
|
||
</div>
|
||
<div>
|
||
<div class="scope-banner-eyebrow">当前公司</div>
|
||
<div class="scope-banner-title">${escapeHtml(currentCompany.name)}</div>
|
||
<div class="scope-banner-desc">当前仅展示该公司的设备数据,新增设备会默认绑定到该公司。</div>
|
||
</div>
|
||
</div>
|
||
<div class="scope-banner-meta">
|
||
<span class="scope-chip"><span>最新固件</span><strong>${escapeHtml(getLatestFirmware(currentCompanyId))}</strong></span>
|
||
<span class="scope-chip"><span>在线率</span><strong>${stats.total ? Math.round((stats.online / stats.total) * 100) : 0}%</strong></span>
|
||
</div>
|
||
</div>`
|
||
}
|
||
|
||
<!-- 统计卡片 -->
|
||
<div class="stats-grid stats-grid-device" style="margin-bottom:20px">
|
||
<div class="stat-card">
|
||
<div class="stat-icon" style="background:#e6f7ff">
|
||
<i data-lucide="tablet-smartphone" style="color:#1890ff"></i>
|
||
</div>
|
||
<div class="stat-content">
|
||
<div class="stat-value">${stats.total}</div>
|
||
<div class="stat-label">设备总数</div>
|
||
</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="stat-icon" style="background:#f6ffed">
|
||
<i data-lucide="wifi" style="color:#52c41a"></i>
|
||
</div>
|
||
<div class="stat-content">
|
||
<div class="stat-value" style="color:#52c41a">${stats.online}</div>
|
||
<div class="stat-label">在线数量</div>
|
||
</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="stat-icon" style="background:#fff7e6">
|
||
<i data-lucide="wifi-off" style="color:#fa8c16"></i>
|
||
</div>
|
||
<div class="stat-content">
|
||
<div class="stat-value" style="color:#fa8c16">${stats.offline}</div>
|
||
<div class="stat-label">离线数量</div>
|
||
</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="stat-icon" style="background:#fff1f0">
|
||
<i data-lucide="alert-circle" style="color:#ff4d4f"></i>
|
||
</div>
|
||
<div class="stat-content">
|
||
<div class="stat-value" style="color:#ff4d4f">${stats.error}</div>
|
||
<div class="stat-label">异常数量</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 筛选和搜索 -->
|
||
<div class="card" style="margin-bottom:16px">
|
||
<div style="display:flex;gap:12px;align-items:center;flex-wrap:wrap">
|
||
<div style="display:flex;align-items:center;gap:8px">
|
||
<i data-lucide="search" style="color:var(--text-muted);width:16px;height:16px"></i>
|
||
<input type="text" class="form-control" id="deviceSearchInput" placeholder="搜索设备名称、device_id、serial_number..." style="width:280px" oninput="filterDevices()">
|
||
</div>
|
||
<select class="form-control" id="statusFilter" style="width:120px" onchange="filterDevices()">
|
||
<option value="">全部状态</option>
|
||
<option value="online">在线</option>
|
||
<option value="offline">离线</option>
|
||
<option value="error">异常</option>
|
||
</select>
|
||
<select class="form-control" id="versionFilter" style="width:140px" onchange="filterDevices()">
|
||
<option value="">全部版本</option>
|
||
<option value="latest">已是最新</option>
|
||
<option value="outdated">需要升级</option>
|
||
</select>
|
||
<button class="btn btn-outline btn-sm" onclick="resetDeviceFilters()">
|
||
<i data-lucide="rotate-ccw" style="width:14px;height:14px"></i> 重置
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 设备列表 -->
|
||
<div class="card" style="padding:0;overflow:hidden">
|
||
<div class="table-wrapper">
|
||
<table class="data-table" id="deviceTable">
|
||
<thead>
|
||
<tr>
|
||
<th>设备名称</th>
|
||
<th>device_id</th>
|
||
<th>serial_number</th>
|
||
${currentCompanyId === 'all' ? '<th>所属公司</th>' : ''}
|
||
<th>固件版本</th>
|
||
<th>状态</th>
|
||
<th>电量</th>
|
||
<th>最后活跃</th>
|
||
<th>操作</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="deviceTableBody">
|
||
${renderDeviceRows(filteredDevices, currentCompanyId)}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
<div style="padding:12px 16px;border-top:1px solid var(--border);display:flex;justify-content:space-between;align-items:center;font-size:13px;color:var(--text-muted)">
|
||
<span>共 <strong id="deviceCount">${filteredDevices.length}</strong> 台设备</span>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
lucide.createIcons({ elements: [container] });
|
||
|
||
// 渲染设备行
|
||
function renderDeviceRows(deviceList, companyId) {
|
||
if (deviceList.length === 0) {
|
||
return `<tr><td colspan="${companyId === 'all' ? 9 : 8}" style="text-align:center;padding:40px;color:var(--text-muted)">暂无设备数据</td></tr>`;
|
||
}
|
||
|
||
return deviceList.map(d => {
|
||
const safeActionId = escapeHtml(escapeInlineValue(d.id));
|
||
const safeName = escapeHtml(d.name);
|
||
const safeDeviceId = escapeHtml(d.device_id || d.id);
|
||
const safeSn = escapeHtml(d.sn);
|
||
const safeCompanyName = escapeHtml(getCompanyName(d.companyId));
|
||
const isLatest = d.fw === 'v2.3.1' || d.fw === 'v2.0.2' || d.fw === 'v1.8.2';
|
||
const statusMap = {
|
||
online: { label: '在线', class: 'badge-success' },
|
||
offline: { label: '离线', class: 'badge-warning' },
|
||
error: { label: '异常', class: 'badge-danger' },
|
||
};
|
||
const status = statusMap[d.status] || statusMap.offline;
|
||
|
||
return `
|
||
<tr class="device-data-row" data-device-id="${escapeHtml(d.id)}" data-status="${escapeHtml(d.status)}" data-version="${escapeHtml(d.fw)}" data-name="${escapeHtml(d.name.toLowerCase())}" data-sn="${escapeHtml(d.sn.toLowerCase())}" data-device-uid="${escapeHtml(String(d.device_id || d.id).toLowerCase())}">
|
||
<td>
|
||
<div style="display:flex;align-items:center;gap:8px">
|
||
<span class="device-icon"><i data-lucide="tablet-smartphone" style="width:16px;height:16px"></i></span>
|
||
<span style="font-weight:500">${safeName}</span>
|
||
</div>
|
||
</td>
|
||
<td style="font-family:monospace;font-size:12px;color:var(--text-muted);max-width:210px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis" title="${safeDeviceId}">${safeDeviceId}</td>
|
||
<td style="font-family:monospace;font-size:12px;color:var(--text-muted)">${safeSn}</td>
|
||
${companyId === 'all' ? `<td><span class="badge badge-default">${safeCompanyName}</span></td>` : ''}
|
||
<td>
|
||
<span class="badge ${isLatest ? 'badge-success' : 'badge-warning'}">
|
||
${escapeHtml(d.fw)} ${isLatest ? '' : '(可升级)'}
|
||
</span>
|
||
</td>
|
||
<td><span class="badge ${status.class}">${status.label}</span></td>
|
||
<td>
|
||
<div style="display:flex;align-items:center;gap:6px">
|
||
<div class="progress-bar" style="width:40px">
|
||
<div class="progress-fill" style="width:${d.battery}%;background:${d.battery < 20 ? 'var(--danger)' : d.battery < 50 ? 'var(--warning)' : 'var(--success)'}"></div>
|
||
</div>
|
||
<span style="font-size:12px;color:var(--text-muted)">${d.battery}%</span>
|
||
</div>
|
||
</td>
|
||
<td style="font-size:12px;color:var(--text-muted)">${escapeHtml(d.lastSeen)}</td>
|
||
<td>
|
||
<div class="action-links">
|
||
${!isClient ? `
|
||
<button class="action-link warning" onclick="openSingleOTA('${safeActionId}')">升级</button>
|
||
<span class="action-divider">|</span>
|
||
` : ''}
|
||
<button class="action-link" onclick="viewDeviceDetail('${safeActionId}')">详情</button>
|
||
${!isClient ? `
|
||
<span class="action-divider">|</span>
|
||
<button class="action-link danger" onclick="deleteDevice('${safeActionId}')">删除</button>
|
||
` : ''}
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
`;
|
||
}).join('');
|
||
}
|
||
|
||
// 筛选设备
|
||
window.filterDevices = function() {
|
||
const searchText = document.getElementById('deviceSearchInput')?.value.toLowerCase() || '';
|
||
const statusFilter = document.getElementById('statusFilter')?.value || '';
|
||
const versionFilter = document.getElementById('versionFilter')?.value || '';
|
||
|
||
const tableBody = document.getElementById('deviceTableBody');
|
||
document.getElementById('deviceFilterEmptyRow')?.remove();
|
||
const rows = document.querySelectorAll('#deviceTableBody tr.device-data-row');
|
||
let visibleCount = 0;
|
||
|
||
rows.forEach(row => {
|
||
const name = row.dataset.name || '';
|
||
const sn = row.dataset.sn || '';
|
||
const deviceUid = row.dataset.deviceUid || '';
|
||
const status = row.dataset.status || '';
|
||
const version = row.dataset.version || '';
|
||
|
||
const matchSearch = !searchText || name.includes(searchText) || sn.includes(searchText) || deviceUid.includes(searchText);
|
||
const matchStatus = !statusFilter || status === statusFilter;
|
||
const matchVersion = !versionFilter ||
|
||
(versionFilter === 'latest' && (version === 'v2.3.1' || version === 'v2.0.2' || version === 'v1.8.2')) ||
|
||
(versionFilter === 'outdated' && version !== 'v2.3.1' && version !== 'v2.0.2' && version !== 'v1.8.2');
|
||
|
||
const visible = matchSearch && matchStatus && matchVersion;
|
||
row.style.display = visible ? '' : 'none';
|
||
if (visible) visibleCount++;
|
||
});
|
||
|
||
if (visibleCount === 0 && rows.length > 0 && tableBody) {
|
||
const emptyRow = document.createElement('tr');
|
||
emptyRow.id = 'deviceFilterEmptyRow';
|
||
emptyRow.innerHTML = `<td colspan="${currentCompanyId === 'all' ? 9 : 8}" style="text-align:center;padding:32px;color:var(--text-muted)">没有匹配的设备,请调整筛选条件</td>`;
|
||
tableBody.appendChild(emptyRow);
|
||
}
|
||
|
||
document.getElementById('deviceCount').textContent = visibleCount;
|
||
};
|
||
|
||
// 重置筛选
|
||
window.resetDeviceFilters = function() {
|
||
const searchInput = document.getElementById('deviceSearchInput');
|
||
const statusSelect = document.getElementById('statusFilter');
|
||
const versionSelect = document.getElementById('versionFilter');
|
||
if (searchInput) searchInput.value = '';
|
||
if (statusSelect) statusSelect.value = '';
|
||
if (versionSelect) versionSelect.value = '';
|
||
filterDevices();
|
||
};
|
||
|
||
// 导出设备
|
||
window.exportDevices = function() {
|
||
const currentScopeLabel = currentCompanyId === 'all' ? '全部公司' : currentCompany.name;
|
||
const exportScopeOptions = getDeviceExportScopeOptions();
|
||
const savePickerTip = window.showSaveFilePicker
|
||
? '当前浏览器支持选择保存位置'
|
||
: '当前浏览器不支持选择保存位置,将自动改为浏览器下载';
|
||
|
||
const modal = createModal({
|
||
title: '导出设备',
|
||
size: 'modal-lg',
|
||
content: `
|
||
<div class="form-group">
|
||
<label class="form-label">导出范围</label>
|
||
<div class="radio-group">
|
||
${exportScopeOptions.map((option, index) => `
|
||
<label class="radio-item">
|
||
<input type="radio" name="deviceExportScope" value="${escapeHtml(option.value)}" ${index === 0 ? 'checked' : ''}>
|
||
${escapeHtml(option.label)}(${option.count})
|
||
</label>
|
||
`).join('')}
|
||
</div>
|
||
<div style="font-size:12px;color:var(--text-muted);margin-top:8px">
|
||
当前公司范围:${escapeHtml(currentScopeLabel)}。导出分类不受页面搜索和表格筛选影响。
|
||
</div>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">文件格式</label>
|
||
<div class="radio-group">
|
||
<label class="radio-item">
|
||
<input type="radio" name="deviceExportFormat" value="xls" checked>
|
||
Excel
|
||
</label>
|
||
<label class="radio-item">
|
||
<input type="radio" name="deviceExportFormat" value="csv">
|
||
CSV
|
||
</label>
|
||
<label class="radio-item">
|
||
<input type="radio" name="deviceExportFormat" value="json">
|
||
JSON
|
||
</label>
|
||
<label class="radio-item">
|
||
<input type="radio" name="deviceExportFormat" value="pdf">
|
||
PDF 报告
|
||
</label>
|
||
</div>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">导出到</label>
|
||
<div class="radio-group">
|
||
<label class="radio-item">
|
||
<input type="radio" name="deviceExportDestination" value="download" checked>
|
||
浏览器下载
|
||
</label>
|
||
<label class="radio-item">
|
||
<input type="radio" name="deviceExportDestination" value="save-as">
|
||
选择保存位置
|
||
</label>
|
||
<label class="radio-item">
|
||
<input type="radio" name="deviceExportDestination" value="email">
|
||
发送到邮箱
|
||
</label>
|
||
</div>
|
||
<div id="deviceExportHint" style="margin-top:8px;font-size:12px;color:var(--text-muted)">${savePickerTip}</div>
|
||
</div>
|
||
<div class="form-group" id="deviceExportEmailWrap" style="display:none">
|
||
<label class="form-label">接收邮箱 <span class="required">*</span></label>
|
||
<input type="email" class="form-control" id="deviceExportEmail" placeholder="name@company.com">
|
||
</div>
|
||
<div style="padding:12px;border:1px solid var(--border);border-radius:8px;background:var(--bg-base);font-size:13px;color:var(--text-secondary)">
|
||
<div style="display:flex;justify-content:space-between;gap:12px;margin-bottom:6px">
|
||
<span>预计导出数量</span>
|
||
<strong id="deviceExportPreviewCount">${exportScopeOptions[0].count} 台</strong>
|
||
</div>
|
||
<div style="display:flex;justify-content:space-between;gap:12px">
|
||
<span>导出字段</span>
|
||
<span>device_id、设备名称、serial_number、公司、固件、状态、电量、最后活跃</span>
|
||
</div>
|
||
</div>
|
||
`,
|
||
onConfirm: () => runDeviceExport(),
|
||
confirmText: '开始导出',
|
||
});
|
||
|
||
const syncExportModal = () => {
|
||
const destination = modal.overlay.querySelector('input[name="deviceExportDestination"]:checked')?.value || 'download';
|
||
const format = modal.overlay.querySelector('input[name="deviceExportFormat"]:checked')?.value || 'xls';
|
||
const scope = modal.overlay.querySelector('input[name="deviceExportScope"]:checked')?.value || 'all';
|
||
const emailWrap = modal.overlay.querySelector('#deviceExportEmailWrap');
|
||
const hint = modal.overlay.querySelector('#deviceExportHint');
|
||
const previewCount = modal.overlay.querySelector('#deviceExportPreviewCount');
|
||
const selectedCount = getDevicesForExportScope(scope).length;
|
||
|
||
if (emailWrap) emailWrap.style.display = destination === 'email' ? '' : 'none';
|
||
if (previewCount) previewCount.textContent = `${selectedCount} 台`;
|
||
if (hint) {
|
||
hint.textContent = format === 'pdf' && destination !== 'email'
|
||
? 'PDF 会打开打印预览,可在系统打印窗口中保存为 PDF'
|
||
: destination === 'save-as'
|
||
? savePickerTip
|
||
: destination === 'email'
|
||
? '邮箱发送会走后端导出任务,当前前端先模拟任务提交'
|
||
: '文件会保存到浏览器默认下载位置';
|
||
}
|
||
};
|
||
|
||
modal.overlay.querySelectorAll('input[name="deviceExportDestination"], input[name="deviceExportScope"], input[name="deviceExportFormat"]').forEach(input => {
|
||
input.addEventListener('change', syncExportModal);
|
||
});
|
||
syncExportModal();
|
||
lucide.createIcons({ elements: [modal.overlay] });
|
||
};
|
||
|
||
function getDeviceExportScopeOptions() {
|
||
return [
|
||
{ value: 'all', label: '全部设备', count: filteredDevices.length },
|
||
{ value: 'online', label: '正常设备', count: filteredDevices.filter(device => device.status === 'online').length },
|
||
{ value: 'offline', label: '离线设备', count: filteredDevices.filter(device => device.status === 'offline').length },
|
||
{ value: 'error', label: '异常设备', count: filteredDevices.filter(device => device.status === 'error').length },
|
||
];
|
||
}
|
||
|
||
function getDevicesForExportScope(scope) {
|
||
if (scope === 'all') {
|
||
return filteredDevices;
|
||
}
|
||
|
||
return filteredDevices.filter(device => device.status === scope);
|
||
}
|
||
|
||
function getExportRows(deviceList) {
|
||
return deviceList.map(device => ({
|
||
'device_id': device.device_id || device.id,
|
||
'设备名称': device.name,
|
||
'serial_number': device.serial_number || device.sn,
|
||
'所属公司': getCompanyName(device.companyId),
|
||
'固件版本': device.fw,
|
||
'状态': getStatusLabel(device.status),
|
||
'电量': `${device.battery}%`,
|
||
'最后活跃': device.lastSeen,
|
||
}));
|
||
}
|
||
|
||
function csvCell(value) {
|
||
const text = String(value ?? '');
|
||
return /[",\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
|
||
}
|
||
|
||
function buildExportFile(format, deviceList, scopeLabel) {
|
||
const rows = getExportRows(deviceList);
|
||
const columns = Object.keys(rows[0] || {
|
||
'device_id': '',
|
||
'设备名称': '',
|
||
'serial_number': '',
|
||
'所属公司': '',
|
||
'固件版本': '',
|
||
'状态': '',
|
||
'电量': '',
|
||
'最后活跃': '',
|
||
});
|
||
const timestamp = new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-');
|
||
const baseName = `TalkingQ设备导出_${scopeLabel}_${timestamp}`.replace(/[\\/:*?"<>|]/g, '-');
|
||
|
||
if (format === 'json') {
|
||
return {
|
||
filename: `${baseName}.json`,
|
||
mime: 'application/json;charset=utf-8',
|
||
content: JSON.stringify({
|
||
exportedAt: new Date().toISOString(),
|
||
scope: scopeLabel,
|
||
total: deviceList.length,
|
||
devices: rows,
|
||
}, null, 2),
|
||
};
|
||
}
|
||
|
||
if (format === 'csv') {
|
||
const csv = [
|
||
columns.map(csvCell).join(','),
|
||
...rows.map(row => columns.map(column => csvCell(row[column])).join(',')),
|
||
].join('\n');
|
||
return {
|
||
filename: `${baseName}.csv`,
|
||
mime: 'text/csv;charset=utf-8',
|
||
content: `\ufeff${csv}`,
|
||
};
|
||
}
|
||
|
||
if (format === 'pdf') {
|
||
return {
|
||
filename: `${baseName}.html`,
|
||
mime: 'text/html;charset=utf-8',
|
||
content: buildPrintableReport(rows, columns, scopeLabel),
|
||
printOnly: true,
|
||
};
|
||
}
|
||
|
||
const tableRows = rows.map(row => `
|
||
<tr>${columns.map(column => `<td>${escapeHtml(row[column])}</td>`).join('')}</tr>
|
||
`).join('');
|
||
|
||
return {
|
||
filename: `${baseName}.xls`,
|
||
mime: 'application/vnd.ms-excel;charset=utf-8',
|
||
content: `
|
||
<html>
|
||
<head><meta charset="utf-8"></head>
|
||
<body>
|
||
<table border="1">
|
||
<thead><tr>${columns.map(column => `<th>${escapeHtml(column)}</th>`).join('')}</tr></thead>
|
||
<tbody>${tableRows}</tbody>
|
||
</table>
|
||
</body>
|
||
</html>
|
||
`,
|
||
};
|
||
}
|
||
|
||
function buildPrintableReport(rows, columns, scopeLabel) {
|
||
const tableRows = rows.map(row => `
|
||
<tr>${columns.map(column => `<td>${escapeHtml(row[column])}</td>`).join('')}</tr>
|
||
`).join('');
|
||
|
||
return `
|
||
<!doctype html>
|
||
<html>
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<title>TalkingQ 设备导出报告</title>
|
||
<style>
|
||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; padding: 24px; color: #1f2937; }
|
||
h1 { font-size: 20px; margin: 0 0 8px; }
|
||
.meta { color: #6b7280; font-size: 12px; margin-bottom: 18px; }
|
||
table { width: 100%; border-collapse: collapse; font-size: 12px; }
|
||
th, td { border: 1px solid #d1d5db; padding: 7px 8px; text-align: left; }
|
||
th { background: #f3f4f6; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<h1>TalkingQ 设备导出报告</h1>
|
||
<div class="meta">范围:${escapeHtml(scopeLabel)} · 数量:${rows.length} 台 · 时间:${new Date().toLocaleString()}</div>
|
||
<table>
|
||
<thead><tr>${columns.map(column => `<th>${escapeHtml(column)}</th>`).join('')}</tr></thead>
|
||
<tbody>${tableRows}</tbody>
|
||
</table>
|
||
</body>
|
||
</html>
|
||
`;
|
||
}
|
||
|
||
function downloadExportFile(file) {
|
||
const blob = new Blob([file.content], { type: file.mime });
|
||
const url = URL.createObjectURL(blob);
|
||
const link = document.createElement('a');
|
||
link.href = url;
|
||
link.download = file.filename;
|
||
document.body.appendChild(link);
|
||
link.click();
|
||
link.remove();
|
||
setTimeout(() => URL.revokeObjectURL(url), 500);
|
||
}
|
||
|
||
async function saveExportFile(file) {
|
||
if (!window.showSaveFilePicker) {
|
||
Toast.warning('当前浏览器不支持选择保存位置,已改为浏览器下载');
|
||
downloadExportFile(file);
|
||
return;
|
||
}
|
||
|
||
const handle = await window.showSaveFilePicker({
|
||
suggestedName: file.filename,
|
||
types: [{
|
||
description: 'TalkingQ export',
|
||
accept: { [file.mime.split(';')[0]]: [`.${file.filename.split('.').pop()}`] },
|
||
}],
|
||
});
|
||
const writable = await handle.createWritable();
|
||
await writable.write(new Blob([file.content], { type: file.mime }));
|
||
await writable.close();
|
||
}
|
||
|
||
function openPrintReport(file, printWindow = window.open('', '_blank')) {
|
||
if (!printWindow) {
|
||
Toast.warning('浏览器阻止了打印窗口,请允许弹窗后重试');
|
||
return false;
|
||
}
|
||
printWindow.document.open();
|
||
printWindow.document.write(file.content);
|
||
printWindow.document.close();
|
||
setTimeout(() => printWindow.print(), 300);
|
||
return true;
|
||
}
|
||
|
||
async function runDeviceExport() {
|
||
const scope = document.querySelector('input[name="deviceExportScope"]:checked')?.value || 'all';
|
||
const format = document.querySelector('input[name="deviceExportFormat"]:checked')?.value || 'xls';
|
||
const destination = document.querySelector('input[name="deviceExportDestination"]:checked')?.value || 'download';
|
||
const email = document.getElementById('deviceExportEmail')?.value.trim() || '';
|
||
const exportList = getDevicesForExportScope(scope);
|
||
const currentScopeLabel = currentCompanyId === 'all' ? '全部公司' : currentCompany.name;
|
||
const scopeLabelMap = {
|
||
all: `${currentScopeLabel}-全部设备`,
|
||
online: `${currentScopeLabel}-正常设备`,
|
||
offline: `${currentScopeLabel}-离线设备`,
|
||
error: `${currentScopeLabel}-异常设备`,
|
||
};
|
||
const scopeLabel = scopeLabelMap[scope] || '设备列表';
|
||
|
||
if (exportList.length === 0) {
|
||
Toast.warning('当前范围没有可导出的设备');
|
||
return false;
|
||
}
|
||
|
||
if (destination === 'email' && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||
Toast.warning('请输入有效的接收邮箱');
|
||
return false;
|
||
}
|
||
|
||
const file = destination !== 'email' ? buildExportFile(format, exportList, scopeLabel) : null;
|
||
const printWindow = file?.printOnly ? window.open('', '_blank') : null;
|
||
if (file?.printOnly && !printWindow) {
|
||
Toast.warning('浏览器阻止了打印窗口,请允许弹窗后重试');
|
||
return false;
|
||
}
|
||
|
||
try {
|
||
await API.devices.export({
|
||
...(window.__getTenantScopeParams ? window.__getTenantScopeParams() : { companyId: currentCompanyId }),
|
||
scope,
|
||
format,
|
||
destination,
|
||
email: destination === 'email' ? email : undefined,
|
||
status: scope === 'all' ? undefined : scope,
|
||
count: exportList.length,
|
||
});
|
||
} catch (error) {
|
||
if (printWindow && !printWindow.closed) printWindow.close();
|
||
Toast.error('设备列表导出失败,请稍后重试');
|
||
return false;
|
||
}
|
||
|
||
if (destination === 'email') {
|
||
Toast.success(`导出任务已提交,完成后发送到 ${escapeHtml(email)}`);
|
||
return true;
|
||
}
|
||
|
||
try {
|
||
if (file.printOnly) {
|
||
const opened = openPrintReport(file, printWindow);
|
||
if (!opened) return false;
|
||
Toast.success('已打开 PDF 打印预览,可选择保存为 PDF');
|
||
return true;
|
||
}
|
||
|
||
if (destination === 'save-as') {
|
||
await saveExportFile(file);
|
||
} else {
|
||
downloadExportFile(file);
|
||
}
|
||
Toast.success(`已导出 ${exportList.length} 台设备`);
|
||
} catch (error) {
|
||
if (error?.name === 'AbortError') {
|
||
Toast.info('已取消导出');
|
||
} else {
|
||
Toast.error('文件保存失败,请稍后重试');
|
||
}
|
||
return false;
|
||
}
|
||
};
|
||
|
||
// 添加设备弹窗
|
||
window.openAddDeviceModal = function() {
|
||
if (isClient) {
|
||
Toast.warning('当前角色无权添加设备');
|
||
return;
|
||
}
|
||
const defaultCompanyId = getDefaultDeviceCompanyId((window.companies || []).filter(company => company.id !== 'all'));
|
||
const companyField = buildDeviceCompanyField('newDeviceCompany', defaultCompanyId);
|
||
|
||
createModal({
|
||
title: '添加设备',
|
||
content: `
|
||
<div class="form-row">
|
||
<div class="form-group">
|
||
<label class="form-label">设备名称 <span class="required">*</span></label>
|
||
<input type="text" class="form-control" id="newDeviceName" placeholder="例如:智慧星 #A001">
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">serial_number <span class="required">*</span></label>
|
||
<input type="text" class="form-control" id="newDeviceSN" placeholder="例如:TQ_F0E1D2C3B4A59687">
|
||
</div>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">所属公司 <span class="required">*</span></label>
|
||
${companyField}
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">备注</label>
|
||
<textarea class="form-control" id="newDeviceRemark" rows="2" placeholder="可选备注信息"></textarea>
|
||
</div>
|
||
`,
|
||
onConfirm: async () => {
|
||
const name = document.getElementById('newDeviceName')?.value.trim();
|
||
const sn = normalizeSerialNumber(document.getElementById('newDeviceSN')?.value);
|
||
const company = document.getElementById('newDeviceCompany')?.value;
|
||
const remark = document.getElementById('newDeviceRemark')?.value.trim() || '';
|
||
|
||
if (!name || !sn || !company) {
|
||
Toast.warning('请填写必填项');
|
||
return false;
|
||
}
|
||
|
||
if (!SERIAL_NUMBER_PATTERN.test(sn)) {
|
||
Toast.warning('serial_number 格式应为 TQ_ 加 16 位十六进制字符');
|
||
return false;
|
||
}
|
||
|
||
if (devices.some(device => normalizeSerialNumber(device.sn || device.serial_number) === sn)) {
|
||
Toast.warning('serial_number 已存在,请检查后再添加');
|
||
return false;
|
||
}
|
||
|
||
const allDevicesSnapshot = getLiveDevices().slice();
|
||
const newDevice = createDeviceRecord({ name, sn, companyId: company }, allDevicesSnapshot);
|
||
|
||
try {
|
||
await API.devices.create({ ...newDevice, remark });
|
||
} catch (error) {
|
||
Toast.error('设备添加失败,请稍后重试');
|
||
return false;
|
||
}
|
||
|
||
window.__deviceAddedDevices.push(newDevice);
|
||
window.__deviceRemovedIds = window.__deviceRemovedIds.filter(id => id !== newDevice.id);
|
||
Toast.success(`设备 ${escapeHtml(name)} 添加成功`);
|
||
if (currentCompanyId !== 'all' && company !== currentCompanyId && window.switchCompany) {
|
||
syncDeviceMutation(company, 1);
|
||
window.switchCompany(company);
|
||
} else {
|
||
if (currentCompanyId !== 'all') window.currentCompany = company;
|
||
syncDeviceMutation(company, 1);
|
||
}
|
||
},
|
||
confirmText: '添加',
|
||
});
|
||
lucide.createIcons();
|
||
};
|
||
|
||
window.openBatchAddDeviceModal = function() {
|
||
if (isClient) {
|
||
Toast.warning('当前角色无权批量添加设备');
|
||
return;
|
||
}
|
||
const defaultCompanyId = getDefaultDeviceCompanyId((window.companies || []).filter(company => company.id !== 'all'));
|
||
const companyField = buildDeviceCompanyField('batchDeviceCompany', defaultCompanyId);
|
||
|
||
createModal({
|
||
title: '批量添加设备',
|
||
size: 'modal-lg',
|
||
content: `
|
||
<div class="form-group">
|
||
<label class="form-label">所属公司 <span class="required">*</span></label>
|
||
${companyField}
|
||
<div style="font-size:12px;color:var(--text-muted);margin-top:6px">
|
||
批量导入的设备会统一绑定到该公司,新公司首次添加设备后会自动出现在设备统计与数据仪表盘中。
|
||
</div>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">设备列表 <span class="required">*</span></label>
|
||
<textarea class="form-control" id="batchDeviceText" rows="9" placeholder="每行一台设备,支持:设备名称,serial_number 星球智能 #001,TQ_F0E1D2C3B4A59687 星球智能 #002,TQ_9A8B7C6D5E4F3210 也可以只填写 serial_number,设备名称会自动生成"></textarea>
|
||
<div style="font-size:12px;color:var(--text-muted);margin-top:6px">
|
||
支持逗号、中文逗号或 Tab 分隔;serial_number 格式为 TQ_ 加 16 位十六进制字符。
|
||
</div>
|
||
</div>
|
||
<div style="padding:12px;border:1px solid var(--border);border-radius:8px;background:var(--bg-base);font-size:13px;color:var(--text-secondary)">
|
||
后端接入时建议先调用批量校验接口检查 serial_number 唯一性,再写入设备表并按 companyId 建立归属关系。
|
||
</div>
|
||
`,
|
||
confirmText: '批量添加',
|
||
onConfirm: async () => {
|
||
const company = document.getElementById('batchDeviceCompany')?.value;
|
||
const rows = parseBatchDeviceInput(document.getElementById('batchDeviceText')?.value || '');
|
||
|
||
if (!company) {
|
||
Toast.warning('请选择所属公司');
|
||
return false;
|
||
}
|
||
if (rows.length === 0) {
|
||
Toast.warning('请填写至少一台设备');
|
||
return false;
|
||
}
|
||
if (rows.length > 500) {
|
||
Toast.warning('单次最多批量添加 500 台设备');
|
||
return false;
|
||
}
|
||
|
||
const existingSnSet = new Set(getLiveDevices().map(device => normalizeSerialNumber(device.sn || device.serial_number)));
|
||
const batchSnSet = new Set();
|
||
const invalidRows = [];
|
||
rows.forEach(row => {
|
||
const sn = normalizeSerialNumber(row.sn);
|
||
const snKey = sn;
|
||
if (!sn || !SERIAL_NUMBER_PATTERN.test(sn)) {
|
||
invalidRows.push(`第 ${row.lineNo} 行 serial_number 格式不正确`);
|
||
} else if (existingSnSet.has(snKey)) {
|
||
invalidRows.push(`第 ${row.lineNo} 行 serial_number 已存在`);
|
||
} else if (batchSnSet.has(snKey)) {
|
||
invalidRows.push(`第 ${row.lineNo} 行 serial_number 重复`);
|
||
}
|
||
batchSnSet.add(snKey);
|
||
});
|
||
|
||
if (invalidRows.length) {
|
||
Toast.warning(invalidRows.slice(0, 3).join(';') + (invalidRows.length > 3 ? '…' : ''));
|
||
return false;
|
||
}
|
||
|
||
const allDevicesSnapshot = getLiveDevices().slice();
|
||
const newDevices = rows.map(row => createDeviceRecord({
|
||
name: row.name.trim(),
|
||
sn: normalizeSerialNumber(row.sn),
|
||
companyId: company,
|
||
}, allDevicesSnapshot));
|
||
|
||
try {
|
||
if (API.devices.batchCreate) {
|
||
await API.devices.batchCreate({
|
||
companyId: company,
|
||
devices: newDevices,
|
||
tenantScope: window.__getTenantScopeParams ? window.__getTenantScopeParams() : {},
|
||
});
|
||
} else {
|
||
await Promise.all(newDevices.map(device => API.devices.create(device)));
|
||
}
|
||
} catch (error) {
|
||
Toast.error('设备批量添加失败,请稍后重试');
|
||
return false;
|
||
}
|
||
|
||
window.__deviceAddedDevices.push(...newDevices);
|
||
newDevices.forEach(device => {
|
||
window.__deviceRemovedIds = window.__deviceRemovedIds.filter(id => id !== device.id);
|
||
});
|
||
Toast.success(`已批量添加 ${newDevices.length} 台设备`);
|
||
if (currentCompanyId !== 'all' && company !== currentCompanyId && window.switchCompany) {
|
||
syncDeviceMutation(company, newDevices.length);
|
||
window.switchCompany(company);
|
||
} else {
|
||
if (currentCompanyId !== 'all') window.currentCompany = company;
|
||
syncDeviceMutation(company, newDevices.length);
|
||
}
|
||
},
|
||
});
|
||
lucide.createIcons();
|
||
};
|
||
|
||
// 单设备OTA升级
|
||
window.openSingleOTA = function(deviceId) {
|
||
if ((window.__getUserRole ? window.__getUserRole() : 'admin') === 'client') {
|
||
Toast.warning('当前角色无权访问 OTA 升级');
|
||
return;
|
||
}
|
||
const device = devices.find(d => d.id === deviceId);
|
||
if (!device) {
|
||
Toast.warning('未找到该设备');
|
||
return;
|
||
}
|
||
if (device?.companyId) {
|
||
window.currentCompany = device.companyId;
|
||
}
|
||
// 跳转到OTA页面进行升级
|
||
Toast.info(`准备升级 ${escapeHtml(device.name)},当前版本: ${escapeHtml(device.fw)}`);
|
||
navigateTo('ota');
|
||
};
|
||
|
||
// 查看设备详情
|
||
window.viewDeviceDetail = function(deviceId) {
|
||
const device = devices.find(d => d.id === deviceId);
|
||
if (!device) {
|
||
Toast.warning('未找到该设备');
|
||
return;
|
||
}
|
||
const safeDevice = {
|
||
id: escapeHtml(device.id),
|
||
name: escapeHtml(device.name),
|
||
deviceId: escapeHtml(device.device_id || device.id),
|
||
serialNumber: escapeHtml(device.serial_number || device.sn),
|
||
companyName: escapeHtml(getCompanyName(device.companyId)),
|
||
fw: escapeHtml(device.fw),
|
||
lastSeen: escapeHtml(device.lastSeen),
|
||
};
|
||
|
||
createModal({
|
||
title: `设备详情 - ${safeDevice.name}`,
|
||
content: `
|
||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px">
|
||
<div class="form-group">
|
||
<label class="form-label">device_id</label>
|
||
<div style="font-family:monospace;word-break:break-all">${safeDevice.deviceId}</div>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">设备名称</label>
|
||
<div>${safeDevice.name}</div>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">serial_number</label>
|
||
<div style="font-family:monospace">${safeDevice.serialNumber}</div>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">所属公司</label>
|
||
<div>${safeDevice.companyName}</div>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">固件版本</label>
|
||
<div><span class="badge badge-info">${safeDevice.fw}</span></div>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">设备状态</label>
|
||
<div>
|
||
<span class="badge ${device.status === 'online' ? 'badge-success' : device.status === 'error' ? 'badge-danger' : 'badge-warning'}">
|
||
${device.status === 'online' ? '在线' : device.status === 'error' ? '异常' : '离线'}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">电池电量</label>
|
||
<div style="display:flex;align-items:center;gap:8px">
|
||
<div class="progress-bar" style="width:80px">
|
||
<div class="progress-fill" style="width:${device.battery}%;background:${device.battery < 20 ? 'var(--danger)' : device.battery < 50 ? 'var(--warning)' : 'var(--success)'}"></div>
|
||
</div>
|
||
<span>${device.battery}%</span>
|
||
</div>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">最后活跃</label>
|
||
<div>${safeDevice.lastSeen}</div>
|
||
</div>
|
||
</div>
|
||
`,
|
||
confirmText: '关闭',
|
||
showCancel: false,
|
||
});
|
||
};
|
||
|
||
// 删除设备
|
||
window.deleteDevice = function(deviceId) {
|
||
const device = devices.find(d => d.id === deviceId);
|
||
if (!device) return;
|
||
const safeName = escapeHtml(device.name);
|
||
|
||
createModal({
|
||
title: '确认删除',
|
||
confirmClass: 'btn-danger',
|
||
content: `
|
||
<div style="text-align:center;padding:20px 0">
|
||
<i data-lucide="alert-triangle" style="width:48px;height:48px;color:var(--warning);margin-bottom:16px"></i>
|
||
<p style="font-size:15px;font-weight:600;margin-bottom:8px">确认删除设备 ${safeName}?</p>
|
||
<p style="font-size:13px;color:var(--text-muted);line-height:1.7">设备将从当前列表移除,并无法再接收 OTA 升级。</p>
|
||
</div>
|
||
`,
|
||
onConfirm: async () => {
|
||
try {
|
||
await API.devices.delete(deviceId);
|
||
} catch (error) {
|
||
Toast.error('设备删除失败,请稍后重试');
|
||
return false;
|
||
}
|
||
if (!window.__deviceRemovedIds.includes(deviceId)) {
|
||
window.__deviceRemovedIds.push(deviceId);
|
||
adjustCompanyDeviceCount(device.companyId, -1);
|
||
}
|
||
syncDeviceMutation(device.companyId, -1);
|
||
Toast.success(`设备 ${safeName} 已删除`);
|
||
},
|
||
confirmText: '确认删除',
|
||
});
|
||
lucide.createIcons();
|
||
};
|
||
});
|