1214 lines
52 KiB
JavaScript
1214 lines
52 KiB
JavaScript
/**
|
||
* 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,每行一个,或用逗号分隔 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,每行一个,或用逗号分隔 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 => ({
|
||
'&': '&',
|
||
'<': '<',
|
||
'>': '>',
|
||
'"': '"',
|
||
"'": ''',
|
||
}[char]));
|
||
}
|
||
|
||
function escapeInlineValue(value) {
|
||
return escapeHtml(String(value ?? '')
|
||
.replace(/\\/g, '\\\\')
|
||
.replace(/'/g, "\\'")
|
||
.replace(/\r?\n/g, ' '));
|
||
}
|
||
});
|