feat(ota): 添加GD32 OTA转发进度追踪和重发机制
- 升级固件版本从0.2.10到0.2.11 - 新增全局变量g_relay_seq和g_relay_total_packets用于跟踪转发进度 - 实现OTA转发过程中的包重发逻辑,解决GD32 Flash缓冲区满时的问题 - 添加重试计数器,超过3次重发限制时标记为GD32错误 - 在system_status_handler中暴露转发序列号和总包数到API接口 - 优化前端UI显示,添加分阶段进度指示器(EPS32 OTA、GD32上传、GD32转发) - 实现详细的转发进度百分比计算和状态反馈 - 移除未使用的AES解密函数以减少代码体积 - 修复注释中的标点符号错误(->替换为→) - 更新CSS样式以支持新的进度显示需求
This commit is contained in:
@@ -10,7 +10,7 @@
|
||||
// ============================================================
|
||||
// 固件版本
|
||||
// ============================================================
|
||||
inline constexpr const char *FW_VERSION = "0.2.10";
|
||||
inline constexpr const char *FW_VERSION = "0.2.11";
|
||||
|
||||
// ============================================================
|
||||
// WiFi 工作模式选择
|
||||
|
||||
+31
-26
@@ -1,4 +1,4 @@
|
||||
/*
|
||||
/*
|
||||
* ota_relay.c - OTA 中继核心状态机实现
|
||||
* T2.3, T2.4, T3.1 - T3.7
|
||||
*/
|
||||
@@ -21,6 +21,8 @@
|
||||
|
||||
/** @brief 当前 OTA 中继状态机状态。 */
|
||||
relay_state_t g_relay_state = RELAY_IDLE;
|
||||
uint32_t g_relay_seq = 0;
|
||||
uint32_t g_relay_total_packets = 0;
|
||||
volatile bool g_gd32_app_alive = false;
|
||||
|
||||
static const esp_partition_t *g_gd32_partition = NULL;
|
||||
@@ -69,7 +71,6 @@ static ota_result_t relay_packets_from_flash(void);
|
||||
*/
|
||||
static ota_result_t finish_ota(void);
|
||||
static int aes128_ecb_encrypt_block_local(const uint8_t *key, const uint8_t *in, uint8_t *out);
|
||||
static int aes128_ecb_decrypt_block_local(const uint8_t *key, const uint8_t *in, uint8_t *out);
|
||||
static void log_resp_payload(const char *stage, uint8_t resp_type, const uint8_t *payload, size_t len);
|
||||
static esp_err_t wait_until_not_wait(const char *stage, uint8_t *resp_type,
|
||||
uint8_t *resp_payload, size_t resp_max,
|
||||
@@ -333,24 +334,6 @@ static int aes128_ecb_encrypt_block_local(const uint8_t *key, const uint8_t *in,
|
||||
return ret;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 使用 AES-128 ECB 解密单个 16 字节分组。
|
||||
* @param key 16 字节密钥。
|
||||
* @param in 输入密文分组(16 字节)。
|
||||
* @param out 输出明文分组(16 字节)。
|
||||
* @return 0 表示成功,非 0 表示失败。
|
||||
*/
|
||||
static int aes128_ecb_decrypt_block_local(const uint8_t *key, const uint8_t *in, uint8_t *out)
|
||||
{
|
||||
esp_aes_context aes;
|
||||
esp_aes_init(&aes);
|
||||
int ret = esp_aes_setkey(&aes, key, 128);
|
||||
if (ret == 0) {
|
||||
ret = esp_aes_crypt_ecb(&aes, ESP_AES_DECRYPT, in, out);
|
||||
}
|
||||
esp_aes_free(&aes);
|
||||
return ret;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 按 3 个分组对 48 字节数据执行 AES-128 ECB 加密。
|
||||
@@ -825,6 +808,8 @@ static ota_result_t relay_packets_from_flash(void)
|
||||
DEBUG_INFO("OTA", "总包数: %u, 每包 %u 字节", total_packets, (unsigned)GD32_OTA_PACKET_SIZE);
|
||||
DEBUG_INFO("OTA", "状态: RELAY_RELAY_ACTIVE");
|
||||
g_relay_state = RELAY_RELAY_ACTIVE;
|
||||
g_relay_total_packets = total_packets;
|
||||
g_relay_seq = 0;
|
||||
|
||||
for (uint32_t seq = 0; seq < total_packets; seq++) {
|
||||
uint32_t retry_count = 0;
|
||||
@@ -873,6 +858,7 @@ static ota_result_t relay_packets_from_flash(void)
|
||||
|
||||
g_relay_state = RELAY_PACKET_SENT;
|
||||
|
||||
resend_packet:
|
||||
while (1) {
|
||||
resp_len = 0;
|
||||
err = spi_ota_transfer(SPI_OTA_CMD_DATA, tx_buf, sizeof(tx_buf),
|
||||
@@ -910,17 +896,28 @@ static ota_result_t relay_packets_from_flash(void)
|
||||
}
|
||||
}
|
||||
|
||||
/* 非期望 ACK,也非错误帧 → 可能是残留 ACK,循环 poll 直到拿到正确的 */
|
||||
/* 非期望 ACK,也非错误帧 -> 可能是残留 ACK,循环 poll 直到拿到正确的。
|
||||
* 超时/非ACK 退出时需要重发当前包(GD32 Flash 缓冲满时会发 WAIT 后排空,
|
||||
* ESP32 必须重发才能让 GD32 重新处理)。 */
|
||||
bool got_expected = false;
|
||||
for (uint32_t retry = 0; retry < 5; ++retry) {
|
||||
DEBUG_WARN("OTA", "包 %u 收到非目标ACK(seq不匹配),继续轮询(%u/5)...", seq, retry + 1);
|
||||
err = spi_ota_poll_response(&resp_type, resp_payload, sizeof(resp_payload), &resp_len, 2000);
|
||||
if (err == ESP_OK && resp_type == SPI_OTA_RSP_WAIT) {
|
||||
err = wait_until_not_wait("DATA_PACKET", &resp_type, resp_payload, sizeof(resp_payload), &resp_len, 1000, 10);
|
||||
err = wait_until_not_wait("DATA_PACKET", &resp_type, resp_payload, sizeof(resp_payload), &resp_len, 100, 5);
|
||||
}
|
||||
if (err != ESP_OK || resp_type != SPI_OTA_RSP_ACK) {
|
||||
/* poll 超时或收到非 ACK(WAIT/ERROR)-> GD32 Flash 缓冲可能已满,
|
||||
* 正在排空。重发当前包让 GD32 重新入队处理。 */
|
||||
DEBUG_WARN("OTA", "包 %u poll 超时/非ACK,重发 (retry=%u/3)", seq, retry_count + 1);
|
||||
g_relay_state = RELAY_PACKET_ACK_RETRY;
|
||||
if (++retry_count >= 3U) {
|
||||
DEBUG_ERR("OTA", "包 %u 重发次数超限", seq);
|
||||
result = OTA_RESULT_ERR_GD32;
|
||||
break;
|
||||
}
|
||||
goto resend_packet;
|
||||
}
|
||||
if (is_structured_ack_with_seq(resp_payload, resp_len, expected_ack)) {
|
||||
got_expected = true;
|
||||
break;
|
||||
@@ -949,10 +946,16 @@ static ota_result_t relay_packets_from_flash(void)
|
||||
continue; /* 重发本包 */
|
||||
}
|
||||
|
||||
DEBUG_ERR("OTA", "包 %u ACK/ERROR 不匹配,len=%zu", seq, resp_len);
|
||||
/* 轮询耗尽仍未拿到期望 ACK -> 重发当前包 */
|
||||
DEBUG_WARN("OTA", "包 %u 轮询耗尽未匹配,重发 (retry=%u/3)", seq, retry_count + 1);
|
||||
g_relay_state = RELAY_PACKET_ACK_RETRY;
|
||||
if (++retry_count >= 3U) {
|
||||
DEBUG_ERR("OTA", "包 %u 重发次数超限", seq);
|
||||
result = OTA_RESULT_ERR_GD32;
|
||||
break;
|
||||
}
|
||||
goto resend_packet;
|
||||
}
|
||||
|
||||
/* 匹配成功 */
|
||||
flash_offset += (uint32_t)chunk_size;
|
||||
@@ -964,12 +967,12 @@ static ota_result_t relay_packets_from_flash(void)
|
||||
break;
|
||||
}
|
||||
|
||||
g_relay_seq = seq + 1;
|
||||
if (seq % (total_packets / 10 + 1) == 0) {
|
||||
uint32_t progress = (seq * 100U) / total_packets;
|
||||
DEBUG_INFO("OTA", "转发进度: %u/%u 包 (%u%%)", seq, total_packets, progress);
|
||||
uint32_t progress = (g_relay_seq * 100U) / total_packets;
|
||||
DEBUG_INFO("OTA", "转发进度: %u/%u 包 (%u%%)", g_relay_seq, total_packets, progress);
|
||||
}
|
||||
}
|
||||
|
||||
g_relay_state = RELAY_RECEIVE_DONE;
|
||||
DEBUG_DBG("OTA", "状态: RELAY_RECEIVE_DONE");
|
||||
return result;
|
||||
@@ -1146,6 +1149,8 @@ void ota_relay_reset(void)
|
||||
{
|
||||
/** @brief 当前 OTA 中继状态机状态。 */
|
||||
g_relay_state = RELAY_IDLE;
|
||||
g_relay_seq = 0;
|
||||
g_relay_total_packets = 0;
|
||||
session_key_clear(&g_session_ctx);
|
||||
memset(&g_session_ctx, 0, sizeof(session_ctx_t));
|
||||
memset(g_k_session, 0, sizeof(g_k_session));
|
||||
|
||||
@@ -152,5 +152,7 @@ extern volatile bool g_gd32_app_alive;
|
||||
|
||||
// 当前中继状态(供健康监控外部访问)
|
||||
extern relay_state_t g_relay_state;
|
||||
extern uint32_t g_relay_seq;
|
||||
extern uint32_t g_relay_total_packets;
|
||||
|
||||
#endif // OTA_RELAY_H
|
||||
@@ -1201,9 +1201,9 @@ static esp_err_t system_status_handler(httpd_req_t *req)
|
||||
memset(&meta, 0, sizeof(meta));
|
||||
meta_load(&meta);
|
||||
|
||||
char resp[700];
|
||||
char resp[800];
|
||||
int len = snprintf(resp, sizeof(resp),
|
||||
"{\"version\": \"%s\", \"health\": %d, \"free_heap\": %lu, \"min_free_heap\": %lu, \"temperature\": %.1f, \"uptime_sec\": %lu, \"wifi\": {\"mode\": \"%s\", \"ip\": \"%s\", \"rssi\": %d, \"connected\": %s}, \"ota\": {\"total_size\": %lu, \"stored_size\": %lu, \"storage_done\": %s, \"relay_state\": %d}}",
|
||||
"{\"version\": \"%s\", \"health\": %d, \"free_heap\": %lu, \"min_free_heap\": %lu, \"temperature\": %.1f, \"uptime_sec\": %lu, \"wifi\": {\"mode\": \"%s\", \"ip\": \"%s\", \"rssi\": %d, \"connected\": %s}, \"ota\": {\"total_size\": %lu, \"stored_size\": %lu, \"storage_done\": %s, \"relay_state\": %d, \"relay_seq\": %lu, \"relay_total_packets\": %lu}}",
|
||||
FW_VERSION,health_get_current(),
|
||||
(unsigned long)esp_get_free_heap_size(),
|
||||
(unsigned long)esp_get_minimum_free_heap_size(),
|
||||
@@ -1216,7 +1216,9 @@ static esp_err_t system_status_handler(httpd_req_t *req)
|
||||
(unsigned long)meta.total_size,
|
||||
(unsigned long)meta.stored_size,
|
||||
s_gd32_storage_done ? "true" : "false",
|
||||
g_relay_state);
|
||||
g_relay_state,
|
||||
(unsigned long)g_relay_seq,
|
||||
(unsigned long)g_relay_total_packets);
|
||||
|
||||
httpd_resp_set_type(req, "application/json");
|
||||
httpd_resp_send(req, resp, len);
|
||||
|
||||
+79
-9
@@ -13,6 +13,53 @@ let logSocket = null;
|
||||
let latestSystemStatus = null;
|
||||
let isEsp32Rebooting = false;
|
||||
let rebootStartTime = 0;
|
||||
// ==================== 刷写进度 ====================
|
||||
var activePhase = null; // 'esp32Ota' | 'gd32Upload' | 'gd32Relay' | null
|
||||
|
||||
function setActivePhase(phase) {
|
||||
activePhase = phase;
|
||||
var stepMap = { esp32Ota: 'ppEsp32', gd32Upload: 'ppGd32Up', gd32Relay: 'ppGd32Relay' };
|
||||
Object.keys(stepMap).forEach(function(p) {
|
||||
var el = document.getElementById(stepMap[p]);
|
||||
if (!el) return;
|
||||
el.classList.remove('active', 'done');
|
||||
if (p === phase) el.classList.add('active');
|
||||
});
|
||||
// Reset progress fill color
|
||||
var fill = document.getElementById('progressFill');
|
||||
if (fill) fill.classList.remove('done');
|
||||
}
|
||||
|
||||
function markPhaseDone(phase) {
|
||||
var stepMap = { esp32Ota: 'ppEsp32', gd32Upload: 'ppGd32Up', gd32Relay: 'ppGd32Relay' };
|
||||
var el = document.getElementById(stepMap[phase]);
|
||||
if (el) { el.classList.remove('active'); el.classList.add('done'); }
|
||||
// All done? turn progress bar green
|
||||
if (phase === 'gd32Relay') {
|
||||
var fill = document.getElementById('progressFill');
|
||||
if (fill) fill.classList.add('done');
|
||||
}
|
||||
}
|
||||
|
||||
function setProgress(pct, text) {
|
||||
var fillEl = document.getElementById('progressFill');
|
||||
var txtEl = document.getElementById('progressTxt');
|
||||
var pctEl = document.getElementById('progressPct');
|
||||
if (fillEl) fillEl.style.width = pct + '%';
|
||||
if (txtEl) txtEl.textContent = text;
|
||||
if (pctEl) pctEl.textContent = pct + '%';
|
||||
}
|
||||
|
||||
function resetProgress() {
|
||||
setProgress(0, '等待操作...');
|
||||
activePhase = null;
|
||||
['ppEsp32', 'ppGd32Up', 'ppGd32Relay'].forEach(function(id) {
|
||||
var el = document.getElementById(id);
|
||||
if (el) el.classList.remove('active', 'done');
|
||||
});
|
||||
var fill = document.getElementById('progressFill');
|
||||
if (fill) fill.classList.remove('done');
|
||||
}
|
||||
|
||||
// ==================== 工具函数 ====================
|
||||
function formatBytes(bytes) {
|
||||
@@ -183,6 +230,7 @@ function applyDisconnectedRuntimeState() {
|
||||
document.getElementById('mRSSI').textContent = '--';
|
||||
document.getElementById('bOTAProgress').textContent = '--';
|
||||
document.getElementById('bOTAProgress').className = 'v err';
|
||||
resetProgress();
|
||||
resetStageFlow();
|
||||
}
|
||||
|
||||
@@ -362,13 +410,32 @@ function updateSystemStatusUi(data) {
|
||||
else if (rs === 17) pr.className = 'pill online';
|
||||
else pr.className = 'pill idle';
|
||||
|
||||
// OTA progress
|
||||
// OTA cache progress (GD32 upload to ESP32 flash)
|
||||
var total = ota.total_size || 0;
|
||||
var stored = ota.stored_size || 0;
|
||||
var progress = total > 0 ? Math.min(100, Math.round(stored * 100 / total)) : 0;
|
||||
var storageProgress = total > 0 ? Math.min(100, Math.round(stored * 100 / total)) : 0;
|
||||
var bp = document.getElementById('bOTAProgress');
|
||||
bp.textContent = progress + '%';
|
||||
bp.className = 'v ' + (progress === 100 ? 'ok' : progress > 0 ? 'warn' : '');
|
||||
bp.textContent = storageProgress + '%';
|
||||
bp.className = 'v ' + (storageProgress === 100 ? 'ok' : storageProgress > 0 ? 'warn' : '');
|
||||
|
||||
// GD32 relay forwarding progress
|
||||
var relaySeq = ota.relay_seq || 0;
|
||||
var relayTotal = ota.relay_total_packets || 0;
|
||||
var relayProgress = relayTotal > 0 ? Math.min(100, Math.round(relaySeq * 100 / relayTotal)) : 0;
|
||||
|
||||
if (rs > 0 && rs < 17) {
|
||||
// Relay is active — show relay progress
|
||||
setActivePhase('gd32Relay');
|
||||
setProgress(relayProgress, '已转发 ' + relaySeq + ' / ' + relayTotal + ' 包');
|
||||
} else if (total > 0 && stored < total && !isUploading) {
|
||||
// GD32 upload to cache in progress (handled by upload progress if uploading)
|
||||
setActivePhase('gd32Upload');
|
||||
setProgress(storageProgress, '已缓存 ' + formatBytes(stored) + ' / ' + formatBytes(total));
|
||||
} else if (rs === 17) {
|
||||
// Relay done
|
||||
markPhaseDone('gd32Relay');
|
||||
setProgress(100, 'GD32 刷写完成');
|
||||
}
|
||||
|
||||
// stage flow from real state
|
||||
updateStagesFromRelayState(rs);
|
||||
@@ -814,7 +881,7 @@ async function startUpload(chip) {
|
||||
var origText = uploadBtn.textContent;
|
||||
uploadBtn.disabled = true;
|
||||
uploadBtn.textContent = '上传中...';
|
||||
document.getElementById('progressWrap').classList.add('show');
|
||||
setActivePhase(chip === 'esp32' ? 'esp32Ota' : 'gd32Upload');
|
||||
addLog('I', '上传', '开始上传: ' + file.name + ' \u2192 ' + chip.toUpperCase());
|
||||
showToast('开始上传固件到 ' + chip.toUpperCase(), 'success');
|
||||
|
||||
@@ -841,10 +908,7 @@ async function startUpload(chip) {
|
||||
if (e.lengthComputable) {
|
||||
var pct = Math.round((e.loaded / e.total) * 100);
|
||||
uploadProgress = pct;
|
||||
document.getElementById('progressFill').style.width = pct + '%';
|
||||
document.getElementById('progressTxt').textContent =
|
||||
'已上传 ' + formatBytes(e.loaded) + ' / ' + formatBytes(e.total);
|
||||
document.getElementById('progressPct').textContent = pct + '%';
|
||||
setProgress(pct, '已上传 ' + formatBytes(e.loaded) + ' / ' + formatBytes(e.total));
|
||||
document.getElementById('bOTAProgress').textContent = pct + '%';
|
||||
}
|
||||
});
|
||||
@@ -857,7 +921,9 @@ async function startUpload(chip) {
|
||||
if (xhr.status === 200) {
|
||||
addLog('I', '上传', '固件上传完成!');
|
||||
showToast('固件上传成功', 'success');
|
||||
setProgress(100, '上传完成');
|
||||
if (chip === 'esp32') {
|
||||
markPhaseDone('esp32Ota');
|
||||
var countdown = 3;
|
||||
addLog('I', 'OTA', countdown + ' 秒后自动调用 POST /ota/update 完成升级并重启...');
|
||||
var timer = setInterval(function () {
|
||||
@@ -869,6 +935,10 @@ async function startUpload(chip) {
|
||||
triggerEsp32Update();
|
||||
}
|
||||
}, 1000);
|
||||
} else {
|
||||
markPhaseDone('gd32Upload');
|
||||
setActivePhase('gd32Relay');
|
||||
setProgress(0, '等待触发转发...');
|
||||
}
|
||||
} else {
|
||||
var detail = formatHttpErrorDetail(xhr.responseText);
|
||||
|
||||
+15
-6
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>ESP32 OTA 中继控制台</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
<link rel="stylesheet" href="style.css?v=20260530b">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
@@ -104,23 +104,32 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 4. Common -->
|
||||
<!-- 4. Flash Progress -->
|
||||
<div class="card">
|
||||
<div class="card-head"><span class="ico">⚡</span> 通用操作</div>
|
||||
<div class="card-head"><span class="ico">⚡</span> 刷写进度</div>
|
||||
<div class="card-body">
|
||||
<div class="progress-wrap" id="progressWrap">
|
||||
<!-- Phase step indicators -->
|
||||
<div class="progress-phase-row">
|
||||
<div class="pp-step active" id="ppEsp32"><span class="pp-ico">📦</span><span class="pp-txt">ESP32 OTA</span></div>
|
||||
<div class="pp-step" id="ppGd32Up"><span class="pp-ico">📤</span><span class="pp-txt">GD32 上传</span></div>
|
||||
<div class="pp-step" id="ppGd32Relay"><span class="pp-ico">🔀</span><span class="pp-txt">GD32 转发</span></div>
|
||||
</div>
|
||||
<!-- Unified progress bar -->
|
||||
<div class="progress-wrap show" id="progressWrap">
|
||||
<div class="progress-track"><div class="progress-fill" id="progressFill"></div></div>
|
||||
<div class="progress-text">
|
||||
<span id="progressTxt">准备上传...</span>
|
||||
<span id="progressTxt">等待操作...</span>
|
||||
<span id="progressPct">0%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-top:12px;">
|
||||
<button class="btn btn-outline btn-block" onclick="restartEsp32()">重启 ESP32</button>
|
||||
<a class="btn btn-outline btn-block" href="ble_ota.html?v=20260509-0046" style="margin-top:10px;text-decoration:none;">
|
||||
切换到 BLE 蓝牙刷写
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -258,6 +267,6 @@
|
||||
<span id="toastMsg">操作成功</span>
|
||||
</div>
|
||||
|
||||
<script src="app.js?v=20260529"></script>
|
||||
<script src="app.js?v=20260530f"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+56
-8
@@ -1,4 +1,4 @@
|
||||
/* ===== ESP32 OTA 控制台 — 工业调试台风格 ===== */
|
||||
/* ===== ESP32 OTA 控制台 — 工业调试台风格 ===== */
|
||||
|
||||
:root {
|
||||
--bg-root: #090e15;
|
||||
@@ -229,25 +229,72 @@ select option { background: var(--bg-panel); color: var(--text); }
|
||||
.file-meta-item b { font-weight: 600; }
|
||||
|
||||
/* === PROGRESS === */
|
||||
.progress-wrap { margin-top: 14px; display: none; }
|
||||
.progress-phase-row {
|
||||
display: flex; align-items: center; gap: 0; margin-bottom: 14px;
|
||||
flex-wrap: nowrap;
|
||||
background: var(--bg-input);
|
||||
border-radius: 20px;
|
||||
padding: 4px;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.pp-step {
|
||||
flex: 1;
|
||||
display: flex; align-items: center; justify-content: center; gap: 5px;
|
||||
padding: 7px 10px; border-radius: 16px;
|
||||
font-size: 12px; font-weight: 600; color: var(--text-dim);
|
||||
background: transparent;
|
||||
transition: all .25s ease; white-space: nowrap;
|
||||
cursor: default;
|
||||
}
|
||||
.pp-ico { font-size: 13px; opacity: .45; transition: all .25s ease; }
|
||||
.pp-txt { transition: color .25s ease; }
|
||||
.pp-sep { display: none; }
|
||||
|
||||
/* Active step — highlighted capsule */
|
||||
.pp-step.active {
|
||||
color: #07121c;
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 10px var(--accent-dim);
|
||||
}
|
||||
.pp-step.active .pp-ico { opacity: 1; }
|
||||
.pp-step.active .pp-txt { color: #07121c; }
|
||||
|
||||
/* Done step — green outlined capsule */
|
||||
.pp-step.done {
|
||||
color: var(--green);
|
||||
background: rgba(34,197,94,.12);
|
||||
}
|
||||
.pp-step.done .pp-ico { opacity: 1; }
|
||||
.pp-step.done .pp-txt::after {
|
||||
content: ' ✓'; font-size: 10px;
|
||||
}
|
||||
|
||||
.progress-wrap { margin-top: 0; display: none; }
|
||||
.progress-wrap.show { display: block; }
|
||||
.progress-track {
|
||||
width: 100%; height: 8px; border-radius: 20px;
|
||||
width: 100%; height: 10px; border-radius: 20px;
|
||||
background: var(--bg-input); overflow: hidden;
|
||||
box-shadow: inset 0 1px 2px rgba(0,0,0,.3);
|
||||
}
|
||||
.progress-fill {
|
||||
height: 100%; border-radius: 20px; background: var(--accent);
|
||||
width: 0%; transition: width .25s ease;
|
||||
height: 100%; border-radius: 20px;
|
||||
background: linear-gradient(90deg, #22d3ee, #06b6d4);
|
||||
width: 0%; transition: width .3s ease;
|
||||
position: relative;
|
||||
box-shadow: 0 0 8px rgba(34,211,238,.3);
|
||||
}
|
||||
.progress-fill::after {
|
||||
content: ""; position: absolute; inset: 0;
|
||||
background: linear-gradient(90deg, transparent 0%, rgba(255,255,255,.25) 50%, transparent 100%);
|
||||
animation: sweep 1.8s linear infinite;
|
||||
background: linear-gradient(90deg, transparent 0%, rgba(255,255,255,.3) 50%, transparent 100%);
|
||||
animation: sweep 1.6s linear infinite;
|
||||
}
|
||||
.progress-fill.done {
|
||||
background: linear-gradient(90deg, #22c55e, #16a34a);
|
||||
box-shadow: 0 0 8px rgba(34,197,94,.3);
|
||||
}
|
||||
@keyframes sweep { 0% { transform: translateX(-100%); } 100% { transform: translateX(100%); } }
|
||||
.progress-text {
|
||||
display: flex; justify-content: space-between; margin-top: 6px;
|
||||
display: flex; justify-content: space-between; margin-top: 7px;
|
||||
font-size: 12px; color: var(--text-dim);
|
||||
}
|
||||
|
||||
@@ -413,3 +460,4 @@ select option { background: var(--bg-panel); color: var(--text); }
|
||||
.can-grid { grid-template-columns: 1fr 1fr; }
|
||||
.btn-row { flex-direction: column; }
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user