Files
“yuanmengy” a225272d1e data&device ready
2026-06-05 17:11:56 +08:00

1256 lines
52 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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 => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
}[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&#10;星球智能 #001,TQ_F0E1D2C3B4A59687&#10;星球智能 #002,TQ_9A8B7C6D5E4F3210&#10;也可以只填写 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();
};
});