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

1214 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 - OTA Upgrade Page
* 按公司分类的OTA升级管理
*/
const OTA_INITIAL_FIRMWARES = {
company_a: {
latestVersion: 'v2.3.1',
versions: [
{ version: 'v2.3.1', date: '2026-05-10', size: '24.5 MB', changelog: '性能优化,修复已知问题', uploaded: false },
{ version: 'v2.3.0', date: '2026-04-28', size: '23.8 MB', changelog: '新增语音识别功能', uploaded: false },
{ version: 'v2.2.5', date: '2026-03-15', size: '22.1 MB', changelog: '稳定性修复', uploaded: false },
],
},
company_b: {
latestVersion: 'v2.0.2',
versions: [
{ version: 'v2.0.2', date: '2026-05-12', size: '20.2 MB', changelog: 'Bug修复版本', uploaded: false },
{ version: 'v2.0.0', date: '2026-04-01', size: '19.8 MB', changelog: '全新界面设计', uploaded: false },
{ version: 'v1.8.2', date: '2026-02-20', size: '18.5 MB', changelog: '基础功能版本', uploaded: false },
],
},
company_c: {
latestVersion: 'v1.8.2',
versions: [
{ version: 'v1.8.2', date: '2026-04-15', size: '18.5 MB', changelog: '安全补丁', uploaded: false },
{ version: 'v1.8.0', date: '2026-03-01', size: '18.2 MB', changelog: '功能更新', uploaded: false },
{ version: 'v1.7.5', date: '2026-01-10', size: '17.8 MB', changelog: '初始版本', uploaded: false },
],
},
};
const OTA_INITIAL_TASKS = [
{ id: 'OTA-001', name: '智慧星-批量升级', companyId: 'company_a', companyName: '智慧星科技', targetVersion: 'v2.3.1', strategy: 'all', status: 'in_progress', progress: 68, total: 3200, success: 2176, failed: 12, createdAt: '2026-05-12 10:30', note: '全量灰度升级' },
{ id: 'OTA-002', name: '智慧星-指定版本推送', companyId: 'company_a', companyName: '智慧星科技', targetVersion: 'v2.3.0', strategy: 'version', strategyValue: 'v2.2.5', status: 'completed', progress: 100, total: 150, success: 148, failed: 2, createdAt: '2026-05-10 14:20', note: '修复旧版本语音识别问题' },
{ id: 'OTA-003', name: '未来玩具-全量升级', companyId: 'company_b', companyName: '未来玩具厂', targetVersion: 'v2.0.2', strategy: 'all', status: 'in_progress', progress: 35, total: 1500, success: 525, failed: 3, createdAt: '2026-05-12 09:00', note: '新版 UI 推送' },
{ id: 'OTA-004', name: '童趣-安全补丁', companyId: 'company_c', companyName: '童趣电子', targetVersion: 'v1.8.2', strategy: 'outdated', status: 'completed', progress: 100, total: 800, success: 795, failed: 5, createdAt: '2026-05-11 16:00', note: '安全补丁' },
{ id: 'OTA-005', name: '未来玩具-异常设备补发', companyId: 'company_b', companyName: '未来玩具厂', targetVersion: 'v2.0.2', strategy: 'specific', status: 'failed', progress: 42, total: 80, success: 33, failed: 47, createdAt: '2026-05-13 11:10', note: '部分设备下载固件失败,需要重试' },
];
registerPage('ota', function(container) {
const currentCompanyId = window.__getCurrentCompanyId ? window.__getCurrentCompanyId() : (window.currentCompany || 'all');
const currentCompany = window.getCurrentCompany ? window.getCurrentCompany() : { id: 'all', name: '全部公司' };
const companies = (window.companies?.length ? window.companies : [
{ id: 'company_a', name: '智慧星科技', deviceCount: 3200, latestVersion: 'v2.3.1' },
{ id: 'company_b', name: '未来玩具厂', deviceCount: 1500, latestVersion: 'v2.0.2' },
{ id: 'company_c', name: '童趣电子', deviceCount: 800, latestVersion: 'v1.8.2' },
]).filter(company => company.id !== 'all');
window.__otaFirmwareStore = window.__otaFirmwareStore || JSON.parse(JSON.stringify(OTA_INITIAL_FIRMWARES));
window.__otaTaskStore = window.__otaTaskStore || JSON.parse(JSON.stringify(OTA_INITIAL_TASKS));
const firmwareStore = window.__otaFirmwareStore;
const otaTasks = window.__otaTaskStore;
const currentFirmware = currentCompanyId === 'all' ? null : firmwareStore[currentCompanyId];
const filteredTasks = currentCompanyId === 'all'
? otaTasks
: otaTasks.filter(task => task.companyId === currentCompanyId);
function getDisplayCompanyName(companyId, fallbackName) {
return window.__getCompanyNameById ? window.__getCompanyNameById(companyId) : fallbackName;
}
const stats = {
total: filteredTasks.length,
inProgress: filteredTasks.filter(task => task.status === 'in_progress').length,
completed: filteredTasks.filter(task => task.status === 'completed').length,
failed: filteredTasks.filter(task => task.status === 'failed').length,
stopped: filteredTasks.filter(task => task.status === 'stopped').length,
};
container.innerHTML = `
<div class="page-header">
<div>
<div class="page-title">OTA 升级</div>
${currentCompanyId === 'all'
? '<div class="page-desc">管理所有设备的固件升级,支持批量升级和指定设备升级</div>'
: `<div class="page-desc">管理 ${escapeHtml(currentCompany.name)} 的固件升级</div>`
}
</div>
<div class="page-actions">
<button class="btn btn-primary" onclick="openCreateOTATask()">
<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="radio-tower"></i>
</div>
<div>
<div class="scope-banner-eyebrow">OTA 范围</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>
</div>
</div>`
: `<div class="current-company-banner scope-banner">
<div class="scope-banner-main">
<div class="scope-banner-icon">
<i data-lucide="radio-tower"></i>
</div>
<div>
<div class="scope-banner-eyebrow">当前公司 OTA</div>
<div class="scope-banner-title">${escapeHtml(currentCompany.name)}</div>
<div class="scope-banner-desc">升级任务会作用于该公司设备范围,当前最新固件为 ${escapeHtml(currentFirmware?.latestVersion || '-')}。</div>
</div>
</div>
<div class="scope-banner-meta">
<span class="scope-chip"><span>最新固件</span><strong>${escapeHtml(currentFirmware?.latestVersion || '-')}</strong></span>
</div>
</div>`
}
<div class="stats-grid stats-grid-ota" style="margin-bottom:20px">
<div class="stat-card">
<div class="stat-icon" style="background:#e6f7ff">
<i data-lucide="layers" 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:#fff7e6">
<i data-lucide="loader" style="color:#fa8c16"></i>
</div>
<div class="stat-content">
<div class="stat-value" style="color:#fa8c16">${stats.inProgress}</div>
<div class="stat-label">进行中</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon" style="background:#f6ffed">
<i data-lucide="check-circle" style="color:#52c41a"></i>
</div>
<div class="stat-content">
<div class="stat-value" style="color:#52c41a">${stats.completed}</div>
<div class="stat-label">已完成</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon" style="background:#fff1f0">
<i data-lucide="x-circle" style="color:#ff4d4f"></i>
</div>
<div class="stat-content">
<div class="stat-value" style="color:#ff4d4f">${stats.failed}</div>
<div class="stat-label">失败</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon" style="background:#f5f5f5">
<i data-lucide="pause-circle" style="color:#8c8c8c"></i>
</div>
<div class="stat-content">
<div class="stat-value" style="color:#8c8c8c">${stats.stopped}</div>
<div class="stat-label">已停止</div>
</div>
</div>
</div>
${currentFirmware ? renderFirmwarePanel(currentCompanyId, currentCompany.name, currentFirmware) : renderFirmwareOverview()}
${renderTaskTable(filteredTasks)}
`;
lucide.createIcons({ elements: [container] });
function renderFirmwareOverview() {
return `
<div class="card" style="margin-bottom:20px">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px">
<span style="font-weight:600;font-size:15px">固件概览</span>
<span style="font-size:12px;color:var(--text-muted)">选择公司可查看全部可用固件版本</span>
</div>
<div style="display:grid;grid-template-columns:repeat(auto-fill, minmax(220px, 1fr));gap:12px">
${companies.map(company => {
const firmware = firmwareStore[company.id];
return `
<div style="padding:12px;border:1px solid var(--border);border-radius:var(--radius-sm);background:#fafafa">
<div style="display:flex;justify-content:space-between;gap:8px;align-items:center;margin-bottom:8px">
<strong>${escapeHtml(company.name)}</strong>
<button class="action-link" onclick="switchOTACompany('${escapeInlineValue(company.id)}')">查看</button>
</div>
<div style="font-size:12px;color:var(--text-muted);margin-bottom:6px">最新版本</div>
<span class="badge badge-success">${escapeHtml(firmware?.latestVersion || '-')}</span>
</div>
`;
}).join('')}
</div>
</div>
`;
}
function renderFirmwarePanel(companyId, companyName, firmware) {
const versions = sortFirmwareVersions(firmware.versions, firmware.latestVersion);
return `
<div class="card" style="margin-bottom:20px">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px">
<div>
<span style="font-weight:600;font-size:15px">可用固件版本</span>
<span class="badge badge-success" style="margin-left:12px">最新: ${escapeHtml(firmware.latestVersion)}</span>
<div style="font-size:12px;color:var(--text-muted);margin-top:6px">设为最新版本默认只更新版本基线;如需同步升级设备,可在确认弹窗中选择升级范围。</div>
</div>
<button class="btn btn-outline btn-sm" onclick="openUploadFirmware('${escapeInlineValue(companyId)}')">
<i data-lucide="upload" style="width:14px;height:14px"></i> 上传新固件
</button>
</div>
<div style="display:grid;grid-template-columns:repeat(auto-fill, minmax(280px, 1fr));gap:12px">
${versions.map(version => {
const isLatest = version.version === firmware.latestVersion;
return `
<div style="padding:12px;border:1px solid var(--border);border-radius:var(--radius-sm);background:#fafafa">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px;gap:8px">
<span class="badge ${isLatest ? 'badge-success' : 'badge-default'}">
${escapeHtml(version.version)}${isLatest ? '(最新)' : ''}
</span>
<span style="font-size:12px;color:var(--text-muted)">${escapeHtml(version.size)}</span>
</div>
<div style="font-size:12px;color:var(--text-muted);margin-bottom:4px">发布日期:${escapeHtml(version.date)}</div>
<div style="font-size:12px;color:var(--text-secondary);min-height:34px">${escapeHtml(version.changelog || '-')}</div>
<div style="display:flex;justify-content:space-between;align-items:center;margin-top:10px">
<span style="font-size:12px;color:var(--text-muted)">${version.uploaded ? `文件:${escapeHtml(version.filename || '-')}` : `${escapeHtml(companyName)} 固件`}</span>
<div class="action-links">
${!isLatest ? `<button class="action-link" onclick="setLatestFirmware('${escapeInlineValue(companyId)}','${escapeInlineValue(version.version)}')">设为最新版本</button>` : ''}
${version.uploaded ? `${!isLatest ? '<span class="action-divider">|</span>' : ''}<button class="action-link danger" onclick="deleteFirmware('${escapeInlineValue(companyId)}','${escapeInlineValue(version.version)}')">删除</button>` : ''}
</div>
</div>
</div>
`;
}).join('')}
</div>
</div>
`;
}
function renderTaskTable(taskList) {
return `
<div class="card" style="padding:0;overflow:hidden">
<div style="padding:16px;border-bottom:1px solid var(--border);display:flex;justify-content:space-between;align-items:center">
<span style="font-weight:600">升级任务列表</span>
<span style="font-size:12px;color:var(--text-muted)">共 ${taskList.length} 个任务</span>
</div>
<div class="table-wrapper">
<table class="data-table">
<thead>
<tr>
<th>任务名称</th>
${currentCompanyId === 'all' ? '<th>所属公司</th>' : ''}
<th>目标版本</th>
<th>升级策略</th>
<th>状态</th>
<th>进度</th>
<th>结果</th>
<th>创建时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
${taskList.length === 0 ? `
<tr><td colspan="${currentCompanyId === 'all' ? 9 : 8}" style="text-align:center;padding:40px;color:var(--text-muted)">暂无升级任务</td></tr>
` : taskList.map(renderTaskRow).join('')}
</tbody>
</table>
</div>
</div>
`;
}
function renderTaskRow(task) {
const status = getStatusMeta(task.status);
return `
<tr>
<td>
<div style="font-weight:500">${escapeHtml(task.name)}</div>
<div style="font-size:12px;color:var(--text-muted)">ID: ${escapeHtml(task.id)}</div>
</td>
${currentCompanyId === 'all' ? `<td><span class="badge badge-default">${escapeHtml(getDisplayCompanyName(task.companyId, task.companyName))}</span></td>` : ''}
<td><span class="badge badge-info">${escapeHtml(task.targetVersion)}</span></td>
<td style="font-size:13px">${escapeHtml(formatStrategy(task))}</td>
<td><span class="badge ${status.className}">${status.label}</span></td>
<td style="min-width:120px">
<div style="display:flex;align-items:center;gap:8px">
<div class="progress-bar" style="width:60px">
<div class="progress-fill ${task.status === 'failed' ? 'bg-danger' : ''}" style="width:${task.progress}%"></div>
</div>
<span style="font-size:12px;color:var(--text-muted)">${task.progress}%</span>
</div>
</td>
<td style="font-size:12px">
<span style="color:#52c41a">成功 ${task.success}</span>
${task.failed > 0 ? ` / <span style="color:#ff4d4f">失败 ${task.failed}</span>` : ''}
</td>
<td style="font-size:12px;color:var(--text-muted)">${escapeHtml(task.createdAt)}</td>
<td>
${renderTaskActions(task)}
</td>
</tr>
`;
}
function renderTaskActions(task) {
const safeId = escapeInlineValue(task.id);
const actions = [`<button class="action-link" onclick="viewOTATask('${safeId}')">详情</button>`];
if (task.status === 'in_progress') {
actions.push(`<button class="action-link" onclick="refreshOTATask('${safeId}')">刷新</button>`);
actions.push(`<button class="action-link danger" onclick="stopOTATask('${safeId}')">停止</button>`);
}
if (task.status === 'failed') {
actions.push(`<button class="action-link warning" onclick="retryOTATask('${safeId}', 'failed')">重试</button>`);
}
if (task.status === 'stopped') {
actions.push(`<button class="action-link warning" onclick="retryOTATask('${safeId}', 'resume')">继续</button>`);
}
if (task.status === 'completed' && task.failed > 0) {
actions.push(`<button class="action-link warning" onclick="retryOTATask('${safeId}', 'failed-devices')">重试失败设备</button>`);
}
return `<div class="action-links">${actions.join('<span class="action-divider">|</span>')}</div>`;
}
function formatStrategy(task) {
const strategyMap = {
all: '全部设备',
version: `版本 ${task.strategyValue || '-'}`,
outdated: '过期版本',
specific: '指定设备',
};
if (task.strategy === 'specific' && task.deviceIds?.length) {
return `指定设备(${task.deviceIds.length} 台)`;
}
return strategyMap[task.strategy] || task.strategy;
}
window.switchOTACompany = function(companyId) {
if (window.switchCompany) {
window.switchCompany(companyId);
return;
}
window.currentCompany = companyId;
navigateTo('ota');
};
window.openCreateOTATask = function() {
const defaultCompanyId = currentCompanyId === 'all' ? companies[0]?.id : currentCompanyId;
const companyField = currentCompanyId === 'all'
? `
<select class="form-control" id="otaCompanySelect">
${companies.map(company => `<option value="${escapeHtml(company.id)}" ${company.id === defaultCompanyId ? 'selected' : ''}>${escapeHtml(company.name)}</option>`).join('')}
</select>
`
: `
<input type="text" class="form-control" value="${escapeHtml(currentCompany.name)}" readonly>
<input type="hidden" id="otaCompanySelect" value="${escapeHtml(currentCompanyId)}">
`;
const modal = createModal({
title: '创建升级任务',
size: 'modal-lg',
content: `
<div class="form-row">
<div class="form-group">
<label class="form-label">所属公司 <span class="required">*</span></label>
${companyField}
</div>
<div class="form-group">
<label class="form-label">任务名称 <span class="required">*</span></label>
<input type="text" class="form-control" id="otaTaskName" placeholder="例如:批量升级到最新版本">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">目标固件版本 <span class="required">*</span></label>
<select class="form-control" id="otaTargetVersion"></select>
<div style="font-size:12px;color:var(--text-muted);margin-top:4px">当前最新版本会优先展示;设为最新版本不会自动升级设备。</div>
</div>
<div class="form-group">
<label class="form-label">升级策略 <span class="required">*</span></label>
<select class="form-control" id="otaStrategy">
<option value="all">全部设备</option>
<option value="outdated">仅过期版本设备</option>
<option value="specific">指定设备</option>
</select>
</div>
</div>
<div class="form-group" id="deviceIdGroup" style="display:none">
<label class="form-label">device_id <span class="required">*</span></label>
<textarea class="form-control" id="deviceIds" rows="3" placeholder="请输入 device_id每行一个或用逗号分隔&#10;c2b00f75-e7e7-581e-90f1-390d97d0dee6"></textarea>
<div style="font-size:12px;color:var(--text-muted);margin-top:4px">支持多个 UUID 格式的 device_id 批量输入</div>
</div>
<div class="form-group">
<label class="form-label">升级说明</label>
<textarea class="form-control" id="otaTaskNote" rows="2" placeholder="可选,描述本次升级的内容"></textarea>
</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">
<span>预计目标设备</span>
<strong id="otaTargetPreview">-</strong>
</div>
</div>
<div class="api-note">
<i data-lucide="info"></i>
<span>接口:<code>POST /api/v1/ota/tasks</code></span>
</div>
`,
onConfirm: async () => createOTATaskFromModal(),
confirmText: '创建任务',
});
const companySelect = modal.overlay.querySelector('#otaCompanySelect');
const strategySelect = modal.overlay.querySelector('#otaStrategy');
const deviceIds = modal.overlay.querySelector('#deviceIds');
const refreshForm = () => {
const companyId = companySelect?.value || defaultCompanyId;
const strategy = strategySelect?.value || 'all';
const firmware = firmwareStore[companyId];
const targetSelect = modal.overlay.querySelector('#otaTargetVersion');
const deviceGroup = modal.overlay.querySelector('#deviceIdGroup');
const preview = modal.overlay.querySelector('#otaTargetPreview');
if (targetSelect) {
targetSelect.innerHTML = firmware?.versions?.length
? sortFirmwareVersions(firmware.versions, firmware.latestVersion).map(item => `
<option value="${escapeHtml(item.version)}">${escapeHtml(item.version)}${item.version === firmware.latestVersion ? '(最新)' : ''}</option>
`).join('')
: '<option value="">该公司暂无固件</option>';
}
if (deviceGroup) deviceGroup.style.display = strategy === 'specific' ? '' : 'none';
if (preview) {
const ids = parseDeviceIds(deviceIds?.value || '');
preview.textContent = `${estimateTargetCount(companyId, strategy, ids)}`;
}
};
companySelect?.addEventListener('change', refreshForm);
strategySelect?.addEventListener('change', refreshForm);
deviceIds?.addEventListener('input', refreshForm);
refreshForm();
lucide.createIcons({ elements: [modal.overlay] });
};
async function createOTATaskFromModal() {
const name = document.getElementById('otaTaskName')?.value.trim();
const companyId = document.getElementById('otaCompanySelect')?.value;
const version = document.getElementById('otaTargetVersion')?.value;
const strategy = document.getElementById('otaStrategy')?.value || 'all';
const deviceIds = parseDeviceIds(document.getElementById('deviceIds')?.value || '');
const note = document.getElementById('otaTaskNote')?.value.trim() || '';
const company = getCompanyInfo(companyId);
const targetCount = estimateTargetCount(companyId, strategy, deviceIds);
if (!name || !companyId || !version) {
Toast.warning('请填写必填项');
return false;
}
if (strategy === 'specific' && deviceIds.length === 0) {
Toast.warning('请输入 device_id');
return false;
}
if (strategy === 'specific' && deviceIds.some(id => !isDeviceId(id))) {
Toast.warning('device_id 需为 UUID 格式');
return false;
}
if (!firmwareStore[companyId]?.versions?.some(item => item.version === version)) {
Toast.warning('目标固件版本不存在');
return false;
}
const newTask = {
id: generateTaskId(),
name,
companyId,
companyName: company.name,
targetVersion: version,
strategy,
status: 'in_progress',
progress: 0,
total: targetCount,
success: 0,
failed: 0,
createdAt: formatDateTime(new Date()),
deviceIds,
note,
};
try {
await API.ota.create(newTask);
} catch (error) {
Toast.error('升级任务创建失败,请稍后重试');
return false;
}
otaTasks.unshift(newTask);
Toast.success('升级任务创建成功');
navigateTo('ota');
}
window.openUploadFirmware = function(preselectedCompanyId) {
const defaultCompanyId = preselectedCompanyId || (currentCompanyId === 'all' ? companies[0]?.id : currentCompanyId);
const companyField = currentCompanyId === 'all' && !preselectedCompanyId
? `
<select class="form-control" id="fwCompany">
${companies.map(company => `<option value="${escapeHtml(company.id)}" ${company.id === defaultCompanyId ? 'selected' : ''}>${escapeHtml(company.name)}</option>`).join('')}
</select>
`
: `
<input type="text" class="form-control" value="${escapeHtml(getCompanyInfo(defaultCompanyId).name)}" readonly>
<input type="hidden" id="fwCompany" value="${escapeHtml(defaultCompanyId)}">
`;
const modal = createModal({
title: '上传新固件',
size: 'modal-lg',
content: `
<div class="form-row">
<div class="form-group">
<label class="form-label">所属公司 <span class="required">*</span></label>
${companyField}
</div>
<div class="form-group">
<label class="form-label">固件版本 <span class="required">*</span></label>
<input type="text" class="form-control" id="fwVersion" placeholder="例如v2.3.2">
</div>
</div>
<div class="form-group">
<label class="form-label">固件文件 <span class="required">*</span></label>
<div id="fwDropzone" style="border:2px dashed var(--border);border-radius:var(--radius);padding:24px;text-align:center;cursor:pointer">
<i data-lucide="upload-cloud" style="width:32px;height:32px;color:var(--text-muted);margin-bottom:8px"></i>
<div style="font-size:13px;color:var(--text-muted)">点击选择文件或拖拽到此处</div>
<div style="font-size:12px;color:var(--text-placeholder);margin-top:4px">支持 .bin, .hex, .zip 格式,最大 100MB</div>
<div id="fwFileName" style="font-size:12px;color:var(--primary);margin-top:8px"></div>
</div>
<input type="file" id="fwFile" style="display:none" accept=".bin,.hex,.zip">
</div>
<div class="form-group">
<label class="form-label">版本说明</label>
<textarea class="form-control" id="fwChangelog" rows="3" placeholder="描述此版本的新功能和修复内容"></textarea>
</div>
<div class="api-note">
<i data-lucide="info"></i>
<span>接口:<code>POST /api/v1/ota/firmwares</code></span>
</div>
`,
onConfirm: async () => uploadFirmwareFromModal(),
confirmText: '上传',
});
const fileInput = modal.overlay.querySelector('#fwFile');
const dropzone = modal.overlay.querySelector('#fwDropzone');
const fileName = modal.overlay.querySelector('#fwFileName');
const showFile = () => {
const file = fileInput?.files?.[0];
if (fileName) fileName.textContent = file ? `已选择:${file.name}${formatFileSize(file.size)}` : '';
};
dropzone?.addEventListener('click', () => fileInput?.click());
fileInput?.addEventListener('change', showFile);
dropzone?.addEventListener('dragover', event => {
event.preventDefault();
dropzone.style.borderColor = 'var(--primary)';
});
dropzone?.addEventListener('dragleave', () => {
dropzone.style.borderColor = 'var(--border)';
});
dropzone?.addEventListener('drop', event => {
event.preventDefault();
dropzone.style.borderColor = 'var(--border)';
if (event.dataTransfer?.files?.[0] && fileInput) {
fileInput.files = event.dataTransfer.files;
showFile();
}
});
lucide.createIcons({ elements: [modal.overlay] });
};
async function uploadFirmwareFromModal() {
const companyId = document.getElementById('fwCompany')?.value;
const version = document.getElementById('fwVersion')?.value.trim();
const file = document.getElementById('fwFile')?.files?.[0];
const changelog = document.getElementById('fwChangelog')?.value.trim() || '新固件版本';
if (!companyId || !version) {
Toast.warning('请填写固件版本和所属公司');
return false;
}
if (!/^v?\d+\.\d+\.\d+([-.][A-Za-z0-9]+)?$/.test(version)) {
Toast.warning('固件版本格式应类似 v2.3.2');
return false;
}
const normalizedVersion = version.startsWith('v') ? version : `v${version}`;
if (!file) {
Toast.warning('请选择固件文件');
return false;
}
if (!/\.(bin|hex|zip)$/i.test(file.name)) {
Toast.warning('固件文件仅支持 .bin、.hex、.zip 格式');
return false;
}
if (file.size > 100 * 1024 * 1024) {
Toast.warning('固件文件不能超过 100MB');
return false;
}
if (firmwareStore[companyId]?.versions?.some(item => item.version.toLowerCase() === normalizedVersion.toLowerCase())) {
Toast.warning('该固件版本已存在');
return false;
}
const newFirmware = {
version: normalizedVersion,
date: formatDateOnly(new Date()),
size: formatFileSize(file.size),
changelog,
uploaded: true,
filename: file.name,
};
try {
await API.ota.uploadFirmware(file, { companyId, version: normalizedVersion, changelog });
} catch (error) {
Toast.error('固件上传失败,请稍后重试');
return false;
}
firmwareStore[companyId] = firmwareStore[companyId] || { latestVersion: normalizedVersion, versions: [] };
firmwareStore[companyId].versions.unshift(newFirmware);
firmwareStore[companyId].latestVersion = normalizedVersion;
updateCompanyLatestVersion(companyId, normalizedVersion);
Toast.success(`固件 ${escapeHtml(normalizedVersion)} 上传成功`);
if (currentCompanyId === 'all' && window.switchCompany) {
window.switchCompany(companyId);
} else {
if (currentCompanyId === 'all') window.currentCompany = companyId;
navigateTo('ota');
}
}
window.setLatestFirmware = function(companyId, version) {
const firmware = firmwareStore[companyId];
const target = firmware?.versions?.find(item => item.version === version);
if (!target) {
Toast.warning('固件版本不存在');
return;
}
if (firmware.latestVersion === version) {
Toast.info(`${escapeHtml(version)} 已经是最新版本`);
return;
}
const company = getCompanyInfo(companyId);
const currentLatest = firmware.latestVersion || '-';
const defaultTaskName = `${company.name}-${version}-版本升级`;
const modal = createModal({
title: '设为最新版本',
size: 'modal-lg',
content: `
<div class="ota-latest-flow">
<div class="ota-latest-hero">
<div class="ota-latest-icon">
<i data-lucide="check-circle"></i>
</div>
<div>
<div class="ota-latest-title">将 ${escapeHtml(version)} 设为 ${escapeHtml(company.name)} 的最新版本</div>
<div class="ota-latest-desc">此操作会更新该公司的最新固件基线;是否同时升级设备由下方升级范围决定。</div>
</div>
</div>
<div class="ota-version-compare">
<div class="ota-version-box">
<span>当前最新</span>
<strong>${escapeHtml(currentLatest)}</strong>
</div>
<div class="ota-version-arrow">
<i data-lucide="arrow-right"></i>
</div>
<div class="ota-version-box target">
<span>调整为</span>
<strong>${escapeHtml(version)}</strong>
</div>
</div>
<div class="ota-latest-section">
<div class="ota-latest-section-title">是否同时更新设备</div>
<div class="ota-upgrade-options">
<label class="ota-upgrade-option active" data-upgrade-option="none">
<input type="radio" name="latestUpgradeMode" value="none" checked>
<span>
<strong>仅设为最新版本</strong>
<small>不立即下发升级任务,后续可手动创建 OTA 任务。</small>
</span>
</label>
<label class="ota-upgrade-option" data-upgrade-option="outdated">
<input type="radio" name="latestUpgradeMode" value="outdated">
<span>
<strong>更新过期版本设备</strong>
<small>只升级未处于该目标版本的设备,适合常规发布。</small>
</span>
</label>
<label class="ota-upgrade-option" data-upgrade-option="all">
<input type="radio" name="latestUpgradeMode" value="all">
<span>
<strong>更新全部设备</strong>
<small>向该公司全部设备下发目标版本升级任务。</small>
</span>
</label>
<label class="ota-upgrade-option" data-upgrade-option="specific">
<input type="radio" name="latestUpgradeMode" value="specific">
<span>
<strong>更新指定设备</strong>
<small>输入 device_id只向指定设备下发升级任务。</small>
</span>
</label>
</div>
</div>
<div class="ota-latest-task-config" id="latestUpgradeConfig" style="display:none">
<div class="form-row">
<div class="form-group">
<label class="form-label">升级任务名称 <span class="required">*</span></label>
<input type="text" class="form-control" id="latestUpgradeTaskName" value="${escapeHtml(defaultTaskName)}">
</div>
<div class="form-group">
<label class="form-label">预计目标设备</label>
<div class="ota-upgrade-preview" id="latestUpgradePreview">不创建升级任务</div>
</div>
</div>
<div class="form-group" id="latestSpecificDevicesWrap" style="display:none">
<label class="form-label">指定设备 device_id <span class="required">*</span></label>
<textarea class="form-control" id="latestSpecificDeviceIds" rows="3" placeholder="请输入 device_id每行一个或用逗号分隔&#10;c2b00f75-e7e7-581e-90f1-390d97d0dee6"></textarea>
<div style="font-size:12px;color:var(--text-muted);margin-top:4px">支持多个 UUID 格式的 device_id 批量输入。</div>
</div>
<div class="form-group">
<label class="form-label">升级说明</label>
<textarea class="form-control" id="latestUpgradeNote" rows="2" placeholder="可选,说明本次从 ${escapeHtml(currentLatest)} 调整到 ${escapeHtml(version)} 的原因"></textarea>
</div>
</div>
<div class="api-note">
<i data-lucide="info"></i>
<span>接口:仅设为最新 <code>POST /api/v1/ota/firmwares/{id}/set-latest</code>;同时升级 <code>POST /api/v1/ota/firmwares/{id}/set-latest-and-upgrade</code></span>
</div>
</div>
`,
confirmText: '确认操作',
onConfirm: async () => {
const mode = modal.overlay.querySelector('input[name="latestUpgradeMode"]:checked')?.value || 'none';
const shouldCreateTask = mode !== 'none';
const deviceIds = parseDeviceIds(modal.overlay.querySelector('#latestSpecificDeviceIds')?.value || '');
const taskName = modal.overlay.querySelector('#latestUpgradeTaskName')?.value.trim() || defaultTaskName;
const note = modal.overlay.querySelector('#latestUpgradeNote')?.value.trim()
|| `由设为最新版本操作创建,原最新版本 ${currentLatest}`;
const targetCount = estimateTargetCount(companyId, mode, deviceIds);
if (mode === 'specific' && deviceIds.length === 0) {
Toast.warning('请输入需要升级的 device_id');
return false;
}
if (mode === 'specific' && deviceIds.some(id => !isDeviceId(id))) {
Toast.warning('device_id 需为 UUID 格式');
return false;
}
if (shouldCreateTask && !taskName) {
Toast.warning('请填写升级任务名称');
return false;
}
const upgradeTask = shouldCreateTask ? {
id: generateTaskId(),
name: taskName,
companyId,
companyName: company.name,
targetVersion: version,
strategy: mode,
status: 'in_progress',
progress: 0,
total: targetCount,
success: 0,
failed: 0,
createdAt: formatDateTime(new Date()),
deviceIds,
note,
source: 'set_latest_version',
previousLatestVersion: currentLatest,
} : null;
try {
if (shouldCreateTask) {
await API.ota.setLatestAndCreateTask(`${companyId}:${version}`, {
companyId,
version,
previousLatestVersion: currentLatest,
upgradeTask,
});
} else {
await API.ota.setLatestFirmware(`${companyId}:${version}`, {
companyId,
version,
previousLatestVersion: currentLatest,
});
}
} catch (error) {
Toast.error(shouldCreateTask ? '最新版本设置或升级任务创建失败,请稍后重试' : '最新版本设置失败,请稍后重试');
return false;
}
firmware.latestVersion = version;
updateCompanyLatestVersion(companyId, version);
if (upgradeTask) {
otaTasks.unshift(upgradeTask);
Toast.success(`${escapeHtml(version)} 已设为最新版本,并已创建升级任务`);
} else {
Toast.success(`${escapeHtml(version)} 已设为最新版本`);
}
navigateTo('ota');
},
});
const refreshLatestUpgradeForm = () => {
const mode = modal.overlay.querySelector('input[name="latestUpgradeMode"]:checked')?.value || 'none';
const shouldCreateTask = mode !== 'none';
const config = modal.overlay.querySelector('#latestUpgradeConfig');
const specificWrap = modal.overlay.querySelector('#latestSpecificDevicesWrap');
const preview = modal.overlay.querySelector('#latestUpgradePreview');
const ids = parseDeviceIds(modal.overlay.querySelector('#latestSpecificDeviceIds')?.value || '');
modal.overlay.querySelectorAll('.ota-upgrade-option').forEach(option => {
option.classList.toggle('active', option.dataset.upgradeOption === mode);
});
if (config) config.style.display = shouldCreateTask ? '' : 'none';
if (specificWrap) specificWrap.style.display = mode === 'specific' ? '' : 'none';
if (preview) {
preview.textContent = shouldCreateTask
? `${estimateTargetCount(companyId, mode, ids)} 台设备`
: '不创建升级任务';
}
};
modal.overlay.querySelectorAll('input[name="latestUpgradeMode"]').forEach(input => {
input.addEventListener('change', refreshLatestUpgradeForm);
});
modal.overlay.querySelector('#latestSpecificDeviceIds')?.addEventListener('input', refreshLatestUpgradeForm);
refreshLatestUpgradeForm();
lucide.createIcons({ elements: [modal.overlay] });
};
window.deleteFirmware = function(companyId, version) {
const firmware = firmwareStore[companyId];
if (!firmware) return;
const target = firmware.versions.find(item => item.version === version);
if (!target) return;
if (firmware.latestVersion === version) {
Toast.warning('最新固件不能删除,请先设置其他版本为最新');
return;
}
createModal({
title: '确认删除固件',
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">确认删除固件 ${escapeHtml(version)}</p>
<p style="font-size:13px;color:var(--text-muted)">删除后将不能再创建指向该版本的新升级任务</p>
</div>
`,
onConfirm: async () => {
try {
await API.ota.deleteFirmware(`${companyId}:${version}`);
} catch (error) {
Toast.error('固件删除失败,请稍后重试');
return false;
}
firmware.versions = firmware.versions.filter(item => item.version !== version);
Toast.success(`固件 ${escapeHtml(version)} 已删除`);
navigateTo('ota');
},
confirmText: '确认删除',
});
lucide.createIcons();
};
window.refreshOTATask = async function(taskId) {
const task = otaTasks.find(item => item.id === taskId);
if (!task) {
Toast.warning('未找到该任务');
return;
}
if (task.status !== 'in_progress') {
Toast.info('该任务已结束');
return;
}
try {
await API.ota.getProgress(taskId);
} catch (error) {
Toast.error('进度刷新失败,请稍后重试');
return;
}
const nextProgress = Math.min(100, task.progress + Math.max(8, Math.round((100 - task.progress) * 0.22)));
const processed = Math.round(task.total * nextProgress / 100);
const newFailures = nextProgress >= 100 ? 0 : Math.floor(Math.random() * 3);
task.progress = nextProgress;
task.failed = Math.min((task.failed || 0) + newFailures, processed);
task.success = Math.max(0, Math.min(task.total, processed - task.failed));
if (nextProgress >= 100) {
task.status = 'completed';
task.success = Math.max(0, task.total - task.failed);
}
Toast.success(task.status === 'completed' ? '升级任务已完成' : '任务进度已刷新');
navigateTo('ota');
};
window.retryOTATask = async function(taskId, mode) {
const task = otaTasks.find(item => item.id === taskId);
if (!task) {
Toast.warning('未找到该任务');
return;
}
if (mode === 'failed-devices') {
if (task.failed <= 0) {
Toast.info('该任务没有失败设备');
return;
}
const retryTask = {
id: generateTaskId(),
name: `${task.name}-失败设备重试`,
companyId: task.companyId,
companyName: getDisplayCompanyName(task.companyId, task.companyName),
targetVersion: task.targetVersion,
strategy: 'specific',
status: 'in_progress',
progress: 0,
total: task.failed,
success: 0,
failed: 0,
createdAt: formatDateTime(new Date()),
deviceIds: [],
note: `来自 ${task.id} 的失败设备重试`,
retryOf: task.id,
};
try {
await API.ota.create(retryTask);
} catch (error) {
Toast.error('失败设备重试任务创建失败,请稍后重试');
return;
}
otaTasks.unshift(retryTask);
Toast.success('失败设备重试任务已创建');
navigateTo('ota');
return;
}
try {
if (mode === 'resume' && API.ota.resume) {
await API.ota.resume(taskId);
} else {
await API.ota.create({ retryOf: taskId, mode });
}
} catch (error) {
Toast.error('任务重试失败,请稍后重试');
return;
}
task.status = 'in_progress';
if (mode === 'failed') {
task.progress = 0;
task.success = 0;
task.failed = 0;
task.createdAt = formatDateTime(new Date());
task.note = `${task.note || ''} 已重新发起失败任务重试`.trim();
}
Toast.success(mode === 'resume' ? '升级任务已继续' : '升级任务已重新发起');
navigateTo('ota');
};
window.viewOTATask = function(taskId) {
const task = otaTasks.find(item => item.id === taskId);
if (!task) {
Toast.warning('未找到该任务');
return;
}
const status = getStatusMeta(task.status);
createModal({
title: `任务详情 - ${escapeHtml(task.name)}`,
size: 'modal-lg',
content: `
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-bottom:20px">
<div class="form-group">
<label class="form-label">任务ID</label>
<div style="font-family:monospace">${escapeHtml(task.id)}</div>
</div>
<div class="form-group">
<label class="form-label">所属公司</label>
<div>${escapeHtml(getDisplayCompanyName(task.companyId, task.companyName))}</div>
</div>
<div class="form-group">
<label class="form-label">目标版本</label>
<div><span class="badge badge-info">${escapeHtml(task.targetVersion)}</span></div>
</div>
<div class="form-group">
<label class="form-label">升级策略</label>
<div>${escapeHtml(formatStrategy(task))}</div>
</div>
<div class="form-group">
<label class="form-label">创建时间</label>
<div>${escapeHtml(task.createdAt)}</div>
</div>
<div class="form-group">
<label class="form-label">任务状态</label>
<div><span class="badge ${status.className}">${status.label}</span></div>
</div>
</div>
<div style="margin-bottom:16px">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px">
<span style="font-weight:600">升级进度</span>
<span>${task.progress}%</span>
</div>
<div class="progress-bar" style="height:8px">
<div class="progress-fill ${task.status === 'failed' ? 'bg-danger' : ''}" style="width:${task.progress}%"></div>
</div>
</div>
<div style="display:grid;grid-template-columns:repeat(3, 1fr);gap:12px;text-align:center;margin-bottom:16px">
<div style="padding:12px;background:#f6ffed;border-radius:var(--radius-sm)">
<div style="font-size:24px;font-weight:700;color:#52c41a">${task.total}</div>
<div style="font-size:12px;color:var(--text-muted)">目标设备</div>
</div>
<div style="padding:12px;background:#e6f7ff;border-radius:var(--radius-sm)">
<div style="font-size:24px;font-weight:700;color:#1890ff">${task.success}</div>
<div style="font-size:12px;color:var(--text-muted)">升级成功</div>
</div>
<div style="padding:12px;background:#fff1f0;border-radius:var(--radius-sm)">
<div style="font-size:24px;font-weight:700;color:#ff4d4f">${task.failed}</div>
<div style="font-size:12px;color:var(--text-muted)">升级失败</div>
</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)">
<div style="font-weight:600;margin-bottom:8px">任务日志</div>
${renderTaskLogs(task)}
</div>
`,
confirmText: '关闭',
showCancel: false,
});
lucide.createIcons();
};
window.stopOTATask = function(taskId) {
const task = otaTasks.find(item => item.id === taskId);
if (!task) {
Toast.warning('未找到该任务');
return;
}
createModal({
title: '确认停止',
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">确认停止升级任务 ${escapeHtml(task.name)}</p>
<p style="font-size:13px;color:var(--text-muted)">已升级的设备不受影响,未升级的设备将不再升级</p>
</div>
`,
onConfirm: async () => {
try {
await API.ota.cancel(taskId);
} catch (error) {
Toast.error('停止任务失败,请稍后重试');
return false;
}
task.status = 'stopped';
task.progress = Math.max(task.progress, Math.round(task.success / Math.max(task.total, 1) * 100));
Toast.success('升级任务已停止');
navigateTo('ota');
},
confirmText: '确认停止',
});
lucide.createIcons();
};
function renderTaskLogs(task) {
const rows = [
`${escapeHtml(task.createdAt)} 创建任务,目标版本 ${escapeHtml(task.targetVersion)}`,
`${escapeHtml(task.createdAt)} 下发升级策略:${escapeHtml(formatStrategy(task))}`,
];
if (task.source === 'set_latest_version') {
rows.push(`任务来源:设为最新版本操作${task.previousLatestVersion ? `,原最新版本 ${escapeHtml(task.previousLatestVersion)}` : ''}`);
}
if (task.note) rows.push(`升级说明:${escapeHtml(task.note)}`);
if (task.progress > 0) rows.push(`当前已处理 ${task.success + task.failed}/${task.total} 台设备`);
if (task.status === 'completed') rows.push('任务已完成,等待设备心跳确认最终版本');
if (task.status === 'stopped') rows.push('任务已停止,未升级设备不会继续推送');
if (task.failed > 0) rows.push(`发现 ${task.failed} 台设备升级失败,可在后端接入后查看失败明细`);
return rows.map(row => `<div style="margin-bottom:6px">${row}</div>`).join('');
}
function getStatusMeta(status) {
const map = {
in_progress: { label: '进行中', className: 'badge-warning' },
completed: { label: '已完成', className: 'badge-success' },
failed: { label: '失败', className: 'badge-danger' },
stopped: { label: '已停止', className: 'badge-default' },
};
return map[status] || { label: status || '未知', className: 'badge-default' };
}
function sortFirmwareVersions(versions, latestVersion) {
return [...(versions || [])].sort((a, b) => {
if (a.version === latestVersion) return -1;
if (b.version === latestVersion) return 1;
return new Date(b.date) - new Date(a.date);
});
}
function parseDeviceIds(value) {
return value
.split(/[\n,\s]+/)
.map(item => item.trim().toLowerCase())
.filter(Boolean);
}
function isDeviceId(value) {
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(String(value || '').trim());
}
function estimateTargetCount(companyId, strategy, deviceIds) {
const deviceCount = getCompanyInfo(companyId).deviceCount || 100;
if (strategy === 'specific') return deviceIds.length;
if (strategy === 'outdated') return Math.max(1, Math.round(deviceCount * 0.28));
return deviceCount;
}
function getCompanyInfo(companyId) {
return companies.find(company => company.id === companyId) || { id: companyId, name: companyId || '未知公司', deviceCount: 100 };
}
function updateCompanyLatestVersion(companyId, version) {
const company = window.companies?.find(item => item.id === companyId);
if (company) company.latestVersion = version;
}
function generateTaskId() {
const max = otaTasks
.map(task => parseInt(String(task.id).replace('OTA-', ''), 10))
.filter(Number.isFinite)
.reduce((value, next) => Math.max(value, next), 0);
return `OTA-${String(max + 1).padStart(3, '0')}`;
}
function formatDateOnly(date) {
const pad = value => String(value).padStart(2, '0');
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
}
function formatDateTime(date) {
const pad = value => String(value).padStart(2, '0');
return `${formatDateOnly(date)} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
}
function formatFileSize(bytes) {
if (!Number.isFinite(bytes) || bytes <= 0) return '0 KB';
if (bytes >= 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
return `${Math.ceil(bytes / 1024)} KB`;
}
function escapeHtml(value) {
return String(value ?? '').replace(/[&<>"']/g, char => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
}[char]));
}
function escapeInlineValue(value) {
return escapeHtml(String(value ?? '')
.replace(/\\/g, '\\\\')
.replace(/'/g, "\\'")
.replace(/\r?\n/g, ' '));
}
});