- BluetoothManager: per-characteristic isolated channels (per-UUID read queue/timeout/dispatch, no cross-char write blocking), 10007 delayed retry for read/write, 300ms link settle after discovery, full tx/rx hex logging - McmCli/McmInfo/McmOta: command/response, TLV parse and OTA frame logs - home.vue: Auto refresh switch (polling off by default, no GATT interference with OTA), MTU-then-load sequencing - ota.vue: SAF file picker via plus.android (ACTION_GET_CONTENT) with Scanner whole-stream read + magic self-check + per-byte fallback; default path moved to _doc (Android 10+ scoped storage) - docs: change logs for 2026-09-04 protocol adaptation and 2026-09-05 field debugging; AGENTS.md updated
604 lines
18 KiB
Vue
604 lines
18 KiB
Vue
<template>
|
||
<!-- #ifdef APP -->
|
||
<scroll-view style="flex:1">
|
||
<!-- #endif -->
|
||
<view class="page">
|
||
|
||
<view class="card" v-if="!connected">
|
||
<text class="tipText">Not connected. Please connect a CAIIC-MCM device on the Device tab first</text>
|
||
</view>
|
||
|
||
<template v-if="connected">
|
||
<!-- 当前设备信息 -->
|
||
<view class="card">
|
||
<view class="infoRow">
|
||
<text class="infoLabel">Current Firmware</text>
|
||
<text class="infoValue">{{ curVersion }}</text>
|
||
</view>
|
||
<view class="infoRow">
|
||
<text class="infoLabel">Bank</text>
|
||
<text class="infoValue">{{ bankInfo }}</text>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 升级包(mothercup_ble_ota.bin 单文件,含双 bank 载荷) -->
|
||
<view class="card">
|
||
<text class="sectionTitle">OTA package (mothercup_ble_ota.bin)</text>
|
||
<input class="input" v-model="filePath" placeholder="_doc/mothercup_ble_ota.bin" :disabled="upgrading" />
|
||
<view class="btnRow">
|
||
<button class="btn" size="mini" :disabled="upgrading" @tap="onChooseFile">Choose File</button>
|
||
<button class="btn" size="mini" :disabled="upgrading" @tap="onLoadFile">Load Package</button>
|
||
</view>
|
||
<text class="hintText">Android 10+ scoped storage: /sdcard/Download is not readable directly - use Choose File, or copy the package into the app private dir (_doc = Android/data/<package>/files)</text>
|
||
<view class="infoRow" v-if="pkgLoaded">
|
||
<text class="infoLabel">Package Version</text>
|
||
<text class="infoValue">{{ pkgVersion }}</text>
|
||
</view>
|
||
<view class="infoRow" v-if="pkgLoaded">
|
||
<text class="infoLabel">Payloads</text>
|
||
<text class="infoValue">bank1 {{ pkgBank1Size }}B / bank2 {{ pkgBank2Size }}B</text>
|
||
</view>
|
||
<view class="infoRow" v-if="pkgLoaded">
|
||
<text class="infoLabel">Target Bank</text>
|
||
<text class="infoValue">APP{{ targetBank }} (opposite of current)</text>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 升级控制 -->
|
||
<view class="card">
|
||
<view class="progressBox">
|
||
<view class="progressBar" :style="'width:' + progress + '%'"></view>
|
||
</view>
|
||
<text class="progressText">{{ progressText }}</text>
|
||
<view class="btnRow">
|
||
<button class="btn" size="mini" v-if="!upgrading" :disabled="!pkgLoaded" @tap="onStartOta">Start Upgrade</button>
|
||
<button class="btn btnWarn" size="mini" v-if="upgrading" @tap="onAbortOta">Abort</button>
|
||
</view>
|
||
<text class="warnText" v-if="upgrading">Do not disconnect or exit during upgrade</text>
|
||
</view>
|
||
|
||
<!-- 日志 -->
|
||
<view class="card" v-if="logs.length > 0">
|
||
<text class="sectionTitle">Upgrade Log</text>
|
||
<text class="logText" v-for="(log, index) in logs" :key="index">{{ log }}</text>
|
||
</view>
|
||
</template>
|
||
|
||
</view>
|
||
<!-- #ifdef APP -->
|
||
</scroll-view>
|
||
<!-- #endif -->
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref } from 'vue'
|
||
import { onLoad, onShow, onHide, onUnload } from '@dcloudio/uni-app'
|
||
import * as Ble from "../../src/BluetoothManager"
|
||
import * as McmCmd from "../../src/mcm/McmCommands"
|
||
import * as McmOta from "../../src/mcm/McmOta"
|
||
|
||
const connected = ref<boolean>(false)
|
||
const curVersion = ref<string>("--")
|
||
const bankInfo = ref<string>("--")
|
||
const filePath = ref<string>("_doc/mothercup_ble_ota.bin")
|
||
const pkgLoaded = ref<boolean>(false)
|
||
const pkgVersion = ref<string>("")
|
||
const pkgBank1Size = ref<number>(0)
|
||
const pkgBank2Size = ref<number>(0)
|
||
const targetBank = ref<number>(0)
|
||
const upgrading = ref<boolean>(false)
|
||
const progress = ref<number>(0)
|
||
const progressText = ref<string>("0%")
|
||
const logs = ref<string[]>([])
|
||
|
||
let pkg: McmOta.ComboPackage | null = null
|
||
let otaMac = "" // 升级开始时记录设备地址,供复位后回连复核
|
||
let needVerifyOnConnect = false // 复位后回连成功时做升级结果复核
|
||
let verifyRetries = 0
|
||
|
||
function addLog(msg: string) {
|
||
logs.value.push(msg)
|
||
console.log("[OTA-PAGE] " + msg)
|
||
}
|
||
|
||
function delay(ms: number): Promise<void> {
|
||
return new Promise((resolve) => { setTimeout(() => { resolve() }, ms) })
|
||
}
|
||
|
||
// ================= 设备信息 =================
|
||
|
||
async function loadDeviceInfo() {
|
||
const info = await McmCmd.getDevInfo()
|
||
curVersion.value = info.fwVersion != null ? McmCmd.formatVersion(info.fwVersion) : "--"
|
||
bankInfo.value = info.curBank != null ? ("APP" + info.curBank) : "--"
|
||
}
|
||
|
||
// ================= 升级包读取(App 本地文件,plus.io) =================
|
||
|
||
function readFileBytes(path: string, cb: (bytes: number[] | null) => void) {
|
||
// #ifdef APP-PLUS
|
||
// App 端 vue3 无 window 对象,plus 挂在 globalThis 上
|
||
const plusIo = (globalThis as any).plus ? (globalThis as any).plus.io : null
|
||
if (plusIo == null) {
|
||
console.log("readFileBytes: plus.io not available")
|
||
cb(null)
|
||
return
|
||
}
|
||
plusIo.resolveLocalFileSystemURL(path, (entry: any) => {
|
||
entry.file((file: any) => {
|
||
const reader = new plusIo.FileReader()
|
||
reader.onloadend = (e: any) => {
|
||
try {
|
||
const dataUrl = e.target.result as string
|
||
const base64 = dataUrl.substring(dataUrl.indexOf(",") + 1)
|
||
const u8 = new Uint8Array(uni.base64ToArrayBuffer(base64))
|
||
const out = [] as number[]
|
||
for (let i = 0; i < u8.length; i++) {
|
||
out.push(u8[i])
|
||
}
|
||
cb(out)
|
||
} catch (err) {
|
||
console.log("readFileBytes parse error", err)
|
||
cb(null)
|
||
}
|
||
}
|
||
reader.onerror = (e: any) => {
|
||
console.log("readFileBytes read error", e)
|
||
cb(null)
|
||
}
|
||
reader.readAsDataURL(file)
|
||
}, (err: any) => {
|
||
console.log("readFileBytes file error", err)
|
||
cb(null)
|
||
})
|
||
}, (err: any) => {
|
||
console.log("readFileBytes resolve error", err)
|
||
cb(null)
|
||
})
|
||
// #endif
|
||
// #ifndef APP-PLUS
|
||
cb(null)
|
||
// #endif
|
||
}
|
||
|
||
// 系统 SAF 文件选择器选包(标准基座无 uni.chooseFile,用 plus.android 起
|
||
// ACTION_GET_CONTENT;选中后 ContentResolver 流式读取,绕开分区存储限制)
|
||
function onChooseFile() {
|
||
// #ifdef APP-PLUS
|
||
const plusAny = (globalThis as any).plus
|
||
if (plusAny == null || plusAny.android == null) {
|
||
uni.showToast({ title: 'Only supported on App', icon: 'none' })
|
||
return
|
||
}
|
||
try {
|
||
const main = plusAny.android.runtimeMainActivity()
|
||
const Intent = plusAny.android.importClass("android.content.Intent")
|
||
const intent = new Intent(Intent.ACTION_GET_CONTENT)
|
||
intent.setType("*/*")
|
||
intent.addCategory(Intent.CATEGORY_OPENABLE)
|
||
main.onActivityResult = (requestCode: number, resultCode: number, data: any) => {
|
||
console.log("[OTA-PAGE] onActivityResult req=" + requestCode + " result=" + resultCode)
|
||
if (requestCode != 9001) return
|
||
if (resultCode != -1 || data == null) { // RESULT_OK = -1;取消则静默
|
||
return
|
||
}
|
||
const r = readContentUri(plusAny.android.invoke(data, "getData"))
|
||
if (r.bytes == null) {
|
||
addLog("SAF read failed: " + r.err)
|
||
uni.showToast({ title: 'Failed to read the chosen file', icon: 'error' })
|
||
return
|
||
}
|
||
addLog("File chosen via SAF, " + r.bytes.length + "B")
|
||
processPackageBytes(r.bytes)
|
||
}
|
||
main.startActivityForResult(intent, 9001)
|
||
console.log("[OTA-PAGE] SAF picker started")
|
||
} catch (e: any) {
|
||
const msg = (e && e.message) ? e.message : "" + e
|
||
console.log("[OTA-PAGE] start SAF picker error: " + msg)
|
||
addLog("FAIL: cannot open file picker: " + msg)
|
||
uni.showToast({ title: 'Cannot open file picker', icon: 'error' })
|
||
}
|
||
// #endif
|
||
// #ifndef APP-PLUS
|
||
uni.showToast({ title: 'Only supported on App', icon: 'none' })
|
||
// #endif
|
||
}
|
||
|
||
// 升级包 magic(LE:11 BA 10 CA),读回内容先自验,不对自动换读取方式
|
||
const PKG_HEAD_MAGIC = [0x11, 0xBA, 0x10, 0xCA]
|
||
|
||
function headMatches(out: number[]): boolean {
|
||
if (out.length < PKG_HEAD_MAGIC.length) return false
|
||
for (let i = 0; i < PKG_HEAD_MAGIC.length; i++) {
|
||
if (out[i] != PKG_HEAD_MAGIC[i]) return false
|
||
}
|
||
return true
|
||
}
|
||
|
||
// 读 content:// Uri 内容为字节数组。首选 Scanner 整流读成 String(快),
|
||
// 自验 magic 失败自动重开流逐字节直读(int 返回值过桥,二进制精确);失败时 err 带原因
|
||
function readContentUri(uri: any): { bytes: number[] | null, err: string } {
|
||
// #ifdef APP-PLUS
|
||
try {
|
||
const plusAny = (globalThis as any).plus
|
||
const main = plusAny.android.runtimeMainActivity()
|
||
const resolver = plusAny.android.invoke(main, "getContentResolver")
|
||
console.log("[OTA-PAGE] SAF uri: " + plusAny.android.invoke(uri, "toString"))
|
||
|
||
// 方式一:Scanner 按 ISO-8859-1 把整流读成 Java String(字节 1:1 映射字符),
|
||
// 字符串作为返回值过桥——byte[] 参数是按值拷贝的,read(buf) 写不回 JS 侧
|
||
let out = [] as number[]
|
||
let scannerOk = false
|
||
try {
|
||
const is1 = plusAny.android.invoke(resolver, "openInputStream", uri)
|
||
if (is1 != null) {
|
||
const Scanner = plusAny.android.importClass("java.util.Scanner")
|
||
const sc = new Scanner(is1, "ISO-8859-1")
|
||
plusAny.android.invoke(sc, "useDelimiter", "\\A") // \A = 输入起始,即整流一个 token
|
||
const r = plusAny.android.invoke(sc, "next")
|
||
plusAny.android.invoke(sc, "close") // 会连带关闭底层流
|
||
if (typeof r == "string" && (r as string).length > 0) {
|
||
const str = r as string
|
||
for (let i = 0; i < str.length; i++) {
|
||
out.push(str.charCodeAt(i) & 0xFF)
|
||
}
|
||
scannerOk = headMatches(out)
|
||
}
|
||
}
|
||
} catch (e2: any) {
|
||
console.log("[OTA-PAGE] scanner mode failed: " + ((e2 && e2.message) ? e2.message : "" + e2))
|
||
}
|
||
if (scannerOk) {
|
||
console.log("[OTA-PAGE] SAF read mode: scanner whole-stream, magic ok")
|
||
} else {
|
||
// 方式二(兜底/自校验失败):重开流逐字节 read(),二进制绝对精确
|
||
if (out.length > 0) {
|
||
console.log("[OTA-PAGE] scanner result failed magic check, fallback to per-byte")
|
||
}
|
||
console.log("[OTA-PAGE] SAF read mode: per-byte (slow, binary-exact)")
|
||
out = [] as number[]
|
||
const is2 = plusAny.android.invoke(resolver, "openInputStream", uri)
|
||
if (is2 == null) return { bytes: null, err: "openInputStream returned null (per-byte)" }
|
||
for (let guard = 0; guard < 4194304; guard++) { // 4MB 上限
|
||
const b = plusAny.android.invoke(is2, "read")
|
||
if (typeof b != "number" || b < 0) break
|
||
out.push(b & 0xFF)
|
||
if (out.length % 16384 == 0) {
|
||
console.log("[OTA-PAGE] per-byte reading ... " + out.length + "B")
|
||
}
|
||
}
|
||
plusAny.android.invoke(is2, "close")
|
||
}
|
||
if (out.length == 0) return { bytes: null, err: "stream read 0 bytes" }
|
||
// 自检日志:长度 + 文件头 hex(合法包应以 11 BA 10 CA 开头)
|
||
let head = ""
|
||
for (let i = 0; i < Math.min(out.length, 16); i++) {
|
||
head += ("0" + out[i].toString(16)).slice(-2).toUpperCase() + " "
|
||
}
|
||
console.log("[OTA-PAGE] SAF read " + out.length + "B, head: " + head +
|
||
(headMatches(out) ? " [magic ok]" : " [MAGIC MISMATCH]"))
|
||
return { bytes: out, err: "" }
|
||
} catch (e: any) {
|
||
const msg = (e && e.message) ? e.message : "" + e
|
||
console.log("[OTA-PAGE] readContentUri error: " + msg)
|
||
return { bytes: null, err: msg }
|
||
}
|
||
// #endif
|
||
// #ifndef APP-PLUS
|
||
return { bytes: null, err: "not on App" }
|
||
// #endif
|
||
}
|
||
|
||
async function onLoadFile() {
|
||
readFileBytes(filePath.value.trim(), (bytes) => {
|
||
processPackageBytes(bytes)
|
||
})
|
||
}
|
||
|
||
async function processPackageBytes(bytes: number[] | null) {
|
||
if (bytes == null) {
|
||
pkgLoaded.value = false
|
||
addLog("FAIL: could not read package file (path=" + filePath.value.trim() + ")")
|
||
uni.showToast({ title: 'Failed to read file, check the path', icon: 'error' })
|
||
return
|
||
}
|
||
try {
|
||
pkg = McmOta.parseCombo(bytes)
|
||
} catch (e: any) {
|
||
pkgLoaded.value = false
|
||
// 诊断:打出实际读到的长度和文件头,区分"读文件错"与"格式错"
|
||
let head = ""
|
||
for (let i = 0; i < Math.min(bytes.length, 16); i++) {
|
||
head += ("0" + bytes[i].toString(16)).slice(-2).toUpperCase() + " "
|
||
}
|
||
addLog("Package rejected: " + ((e && e.message) ? e.message : "parse fail") +
|
||
" (got " + bytes.length + "B, head: " + head + ")")
|
||
uni.showToast({ title: 'Invalid OTA package', icon: 'error' })
|
||
return
|
||
}
|
||
// 目标 = 当前运行 bank 的对侧(CUR_BANK 来自参数特征 TLV 0x07)
|
||
const cur = await McmCmd.getCurBank()
|
||
if (cur == null) {
|
||
pkgLoaded.value = false
|
||
addLog("Could not read current bank from device")
|
||
uni.showToast({ title: 'Read device bank failed', icon: 'error' })
|
||
return
|
||
}
|
||
const target = 3 - cur
|
||
const blob = target == 1 ? pkg.bank1 : pkg.bank2
|
||
// 传输前预检:载荷向量表 Reset PC 必须落在目标 bank 内
|
||
const err = McmOta.checkBlobBank(blob, target)
|
||
if (err != null) {
|
||
pkgLoaded.value = false
|
||
addLog("Package rejected: " + err)
|
||
uni.showToast({ title: 'Wrong bank payload', icon: 'error' })
|
||
return
|
||
}
|
||
targetBank.value = target
|
||
pkgVersion.value = McmOta.formatVersion(pkg.version)
|
||
pkgBank1Size.value = pkg.bank1.length
|
||
pkgBank2Size.value = pkg.bank2.length
|
||
pkgLoaded.value = true
|
||
addLog("Package loaded: version " + pkgVersion.value +
|
||
", target APP" + target + " (" + blob.length + " bytes)")
|
||
}
|
||
|
||
// ================= OTA =================
|
||
|
||
function onProgress(p: McmOta.OtaProgress) {
|
||
progress.value = Math.floor(100 * p.sent / p.total)
|
||
progressText.value = progress.value + "% (" + p.sent + "/" + p.total + " B, rtt " +
|
||
p.rttMs + " ms" + (p.resent > 0 ? ", " + p.resent + " resent" : "") + ")"
|
||
}
|
||
|
||
async function onStartOta() {
|
||
if (pkg == null || upgrading.value) return
|
||
const blob = targetBank.value == 1 ? pkg.bank1 : pkg.bank2
|
||
upgrading.value = true
|
||
progress.value = 0
|
||
progressText.value = "0%"
|
||
otaMac = Ble.getConnectMac()
|
||
addLog("--- OTA start: target APP" + targetBank.value + " ---")
|
||
try {
|
||
await McmOta.runOta(blob, pkg.version, targetBank.value, {
|
||
onProgress: onProgress,
|
||
onLog: addLog
|
||
})
|
||
} catch (e: any) {
|
||
upgrading.value = false
|
||
addLog("FAIL: " + ((e && e.message) ? e.message : "unknown error"))
|
||
uni.showToast({ title: 'Upgrade failed', icon: 'error' })
|
||
return
|
||
}
|
||
// END 成功:设备应答后自动复位进新 bank,等待断连→回连→复核
|
||
addLog("Waiting for the device to reboot ...")
|
||
waitRebootAndVerify()
|
||
}
|
||
|
||
function onAbortOta() {
|
||
McmOta.abortOta()
|
||
addLog("Abort requested ...")
|
||
}
|
||
|
||
// 等设备复位导致链路断开,然后回连(bleState 0 时复核,见 onBleStatueListener)
|
||
async function waitRebootAndVerify() {
|
||
for (let i = 0; i < 20 && Ble.isConnected(); i++) {
|
||
await delay(500)
|
||
}
|
||
if (Ble.isConnected()) {
|
||
// 断连事件一直没来:主动断开再走回连
|
||
Ble.disConnect()
|
||
await delay(1000)
|
||
}
|
||
addLog("Reconnecting to verify ...")
|
||
verifyRetries = 0
|
||
needVerifyOnConnect = true
|
||
;(getApp().globalData as any).ConnectBle(otaMac, false)
|
||
}
|
||
|
||
async function verifyAfterReboot() {
|
||
if (pkg == null) return
|
||
const info = await McmCmd.getDevInfo()
|
||
const okBank = info.curBank == targetBank.value
|
||
const okVer = info.fwVersion == pkg.version
|
||
if (okBank && okVer) {
|
||
addLog("PASS: now running APP" + info.curBank + ", firmware " +
|
||
McmCmd.formatVersion(info.fwVersion as number))
|
||
uni.showToast({ title: 'Upgrade OK', icon: 'success' })
|
||
} else {
|
||
addLog("FAIL: after reboot running APP" + (info.curBank ?? 0) + ", firmware " +
|
||
(info.fwVersion != null ? McmCmd.formatVersion(info.fwVersion) : "?") +
|
||
" (expected APP" + targetBank.value + ", " + McmOta.formatVersion(pkg.version) + ")")
|
||
uni.showToast({ title: 'Verify failed', icon: 'error' })
|
||
}
|
||
upgrading.value = false
|
||
loadDeviceInfo()
|
||
}
|
||
|
||
// ================= 蓝牙状态监听 =================
|
||
|
||
function onBleStatueListener(state: number) {
|
||
if (state == 0) {
|
||
connected.value = true
|
||
if (needVerifyOnConnect) {
|
||
needVerifyOnConnect = false
|
||
// 回连就绪:协商 MTU 后复核升级结果
|
||
Ble.setMtu(247, () => {
|
||
verifyAfterReboot()
|
||
})
|
||
} else {
|
||
loadDeviceInfo()
|
||
}
|
||
} else if (state == 10000) {
|
||
connected.value = false
|
||
if (needVerifyOnConnect && verifyRetries < 5) {
|
||
// 复位后回连失败:重试
|
||
verifyRetries++
|
||
setTimeout(() => {
|
||
if (needVerifyOnConnect) {
|
||
(getApp().globalData as any).ConnectBle(otaMac, false)
|
||
}
|
||
}, 2000)
|
||
} else if (needVerifyOnConnect) {
|
||
needVerifyOnConnect = false
|
||
upgrading.value = false
|
||
addLog("FAIL: reconnect to the device failed after reboot")
|
||
}
|
||
} else if (state == 10001) {
|
||
connected.value = false
|
||
}
|
||
}
|
||
|
||
let subscribed = false
|
||
function subscribe() {
|
||
if (subscribed) return
|
||
uni.$on("bleState", onBleStatueListener)
|
||
subscribed = true
|
||
}
|
||
|
||
function unsubscribe() {
|
||
if (!subscribed) return
|
||
uni.$off("bleState", onBleStatueListener)
|
||
subscribed = false
|
||
}
|
||
|
||
// ================= 生命周期 =================
|
||
|
||
onLoad((options: any) => {
|
||
subscribe()
|
||
})
|
||
|
||
onShow(() => {
|
||
subscribe()
|
||
connected.value = Ble.isConnected()
|
||
if (connected.value) {
|
||
loadDeviceInfo()
|
||
}
|
||
})
|
||
|
||
onHide(() => {
|
||
unsubscribe()
|
||
})
|
||
|
||
onUnload(() => {
|
||
unsubscribe()
|
||
})
|
||
</script>
|
||
|
||
<style scoped>
|
||
.page{
|
||
display:flex;
|
||
flex-direction:column;
|
||
padding: 0 20rpx;
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.card {
|
||
border-radius: 36rpx;
|
||
background-color: #ffffff;
|
||
padding: 24rpx;
|
||
margin-top: 20rpx;
|
||
box-shadow: 0 6rpx 20rpx rgba(0, 0, 0, 0.08);
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.tipText {
|
||
font-size: 26rpx;
|
||
color: #999999;
|
||
text-align: center;
|
||
padding: 20rpx 0;
|
||
}
|
||
|
||
.hintText {
|
||
font-size: 22rpx;
|
||
color: #999999;
|
||
line-height: 1.5;
|
||
margin-top: 8rpx;
|
||
}
|
||
|
||
.sectionTitle {
|
||
font-size: 26rpx;
|
||
color: #888888;
|
||
margin-bottom: 12rpx;
|
||
}
|
||
|
||
.infoRow {
|
||
display: flex;
|
||
flex-direction: row;
|
||
justify-content: space-between;
|
||
padding: 8rpx 0;
|
||
}
|
||
|
||
.infoLabel {
|
||
font-size: 26rpx;
|
||
color: #888888;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.infoValue {
|
||
font-size: 24rpx;
|
||
color: #333333;
|
||
flex: 1;
|
||
text-align: right;
|
||
margin-left: 20rpx;
|
||
}
|
||
|
||
.input {
|
||
border: 1px solid #dddddd;
|
||
border-radius: 12rpx;
|
||
padding: 12rpx 16rpx;
|
||
font-size: 24rpx;
|
||
margin-bottom: 16rpx;
|
||
}
|
||
|
||
.btnRow {
|
||
display: flex;
|
||
flex-direction: row;
|
||
justify-content: flex-end;
|
||
margin-top: 8rpx;
|
||
}
|
||
|
||
.btn {
|
||
background-color: #3A6FA1;
|
||
color: #ffffff;
|
||
}
|
||
.btnWarn {
|
||
background-color: #dd524d;
|
||
}
|
||
|
||
.progressBox {
|
||
height: 16rpx;
|
||
background-color: #eeeeee;
|
||
border-radius: 8rpx;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.progressBar {
|
||
height: 16rpx;
|
||
background-color: #3A6FA1;
|
||
}
|
||
|
||
.progressText {
|
||
font-size: 24rpx;
|
||
color: #888888;
|
||
text-align: center;
|
||
margin-top: 8rpx;
|
||
}
|
||
|
||
.warnText {
|
||
font-size: 24rpx;
|
||
color: #dd524d;
|
||
margin-top: 12rpx;
|
||
line-height: 1.5;
|
||
}
|
||
|
||
.logText {
|
||
font-size: 22rpx;
|
||
color: #333333;
|
||
font-family: monospace;
|
||
line-height: 1.5;
|
||
}
|
||
</style>
|