Browse Source
把工作区里积压的多轮改动一次性入库。主要几块: 客户端 - 恒玄 OTA 打通:新增 local_plugins/bes_ota_manager(自包含 BES SDK, Android Java + iOS ObjC,SPP OTA 2.0),路由切到 BesOtaUpgradeView / BesOtaUpgradeController,配套下载服务;杰理那套文件保留但已不挂路由。 - 通话翻译新增阿里 3.5 档(alibaba35_language_config.dart, qwen3.5-livetranslate-flash-realtime + 实时声音复刻)。 - 实名认证接入、游客登录改走服务端 api_sgin 的 Tourists 分支、 功能闸门由 GuestGuard 改为 IdVerifyGuard。 - 41 个语种文案同步(OTA 新增 28 个 key 等)。 - 默认主题改为浅色,不跟随系统。 服务端 - 发版类配置拆表:app_release(版本控制 / 游客显隐,一应用一行)与 channel_app(渠道级下载地址 / 上架状态 / 支付渠道)分家, 新增 comm/apprelease.go 与 console/api_apprelease.go。 - 授权码字符串整体移除:删除 utils/license 包,新增 utils/devcode (MAC 规范化与 PID 反解),配套迁移 SQL 在 docs/migrations/。 - console 新增芯片厂商、代工厂、用户管理接口与对应 admin 页面。 - 身份证实名核验(sys/idverify)与第三方服务的服务端专用类别。 其它 - .gitignore 补挡 apps/services/lego/sys/gin/log-*.log:gin 子系统跑起来 会按时间戳滚动生成日志(多为 0 字节),与 apps/services/comm/log 同一 性质,不进版本管理。已跟踪的 log-2026-08-20 那个仍在库里,未动。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>main
255 changed files with 21833 additions and 2345 deletions
@ -0,0 +1,254 @@ |
|||
<template> |
|||
<div> |
|||
<!-- 顶部 --> |
|||
<div class="flex items-center justify-between mb-5 flex-wrap gap-3"> |
|||
<div> |
|||
<h2 class="text-lg font-semibold text-slate-800">版本控制</h2> |
|||
<p class="text-xs text-slate-400 mt-1"> |
|||
按应用配置线上版本、强制更新与送审版本。<b>整个应用一套,不分渠道</b>——各商店的下载地址可在「渠道分发」里单独覆盖。 |
|||
</p> |
|||
</div> |
|||
<div class="flex items-center gap-3"> |
|||
<span class="text-sm text-slate-500">应用</span> |
|||
<select v-model="scopeApp" class="select select-bordered select-sm" @change="load"> |
|||
<option value="">全局默认(所有应用兜底)</option> |
|||
<option v-for="a in scopeApps" :key="a" :value="a">{{ a }}</option> |
|||
</select> |
|||
</div> |
|||
</div> |
|||
|
|||
<div v-if="!scopeApp" class="scope-tip"> |
|||
当前是<b>全局默认</b>作用域:这里配的对所有应用生效;某个应用单独配了,则以应用的为准。 |
|||
</div> |
|||
|
|||
<div class="ver-wrap"> |
|||
<!-- 左:表单 --> |
|||
<div class="card"> |
|||
<section class="ed-section"> |
|||
<div class="ed-section-title">线上版本</div> |
|||
<div class="grid grid-cols-2 gap-3"> |
|||
<div> |
|||
<span class="ed-label">当前线上版本</span> |
|||
<input v-model="form.version" class="input input-bordered input-sm w-full" placeholder="如 1.0.1" /> |
|||
<p class="ed-hint">高于客户端本机版本时提示「有新版本」。填客户端上报的净版本号,<b>不含构建号</b>(<code>1.0.1</code> 而不是 <code>1.0.1+5</code>)。</p> |
|||
</div> |
|||
<div> |
|||
<span class="ed-label">送审版本号</span> |
|||
<input v-model="form.review_version" class="input input-bordered input-sm w-full" placeholder="过审后清空" /> |
|||
<p class="ed-hint">客户端上报的版本正好等于它时,会隐藏未过审的功能。</p> |
|||
</div> |
|||
</div> |
|||
</section> |
|||
|
|||
<section class="ed-section"> |
|||
<div class="ed-section-title">强制更新</div> |
|||
<label class="flex items-center gap-2 text-sm cursor-pointer"> |
|||
<input v-model="form.force_update" type="checkbox" class="checkbox checkbox-sm checkbox-primary" /> |
|||
<span>开启强制更新</span> |
|||
</label> |
|||
<div v-if="form.force_update" class="pl-7 mt-2"> |
|||
<span class="ed-label">最低支持版本(必填)</span> |
|||
<input v-model="form.min_version" class="input input-bordered input-sm w-full" placeholder="如 1.0.0" /> |
|||
<p class="ed-hint"> |
|||
低于这个版本的客户端会弹出<b>不可关闭</b>的升级框,无法继续使用。 |
|||
不填等于开了强更却挡不住任何人。 |
|||
</p> |
|||
</div> |
|||
<p v-if="versionWarn" class="ed-warn">⚠ {{ versionWarn }}</p> |
|||
</section> |
|||
|
|||
<section class="ed-section"> |
|||
<div class="ed-section-title">下载与说明</div> |
|||
<span class="ed-label">默认下载地址</span> |
|||
<input v-model="form.address" class="input input-bordered input-sm w-full" placeholder="应用商店链接或 apk 直链" /> |
|||
<p class="ed-hint"> |
|||
<b>各商店链接不一样</b>,这里填的是兜底值;华为/小米/App Store 等请在 |
|||
<NuxtLink class="link" to="/channels">渠道分发</NuxtLink> |
|||
里给对应渠道填「本渠道下载地址」,渠道填了就以渠道的为准。 |
|||
</p> |
|||
<div class="mt-3"> |
|||
<span class="ed-label">更新日志</span> |
|||
<textarea v-model="form.changelog" class="textarea textarea-bordered w-full text-xs" rows="4" placeholder="本次更新内容,会下发给客户端在升级框里展示" /> |
|||
</div> |
|||
</section> |
|||
|
|||
<div class="ed-footer"> |
|||
<button class="btn btn-ghost btn-sm" :disabled="submitting" @click="load">重置</button> |
|||
<button class="btn btn-primary btn-sm text-white" :disabled="submitting" @click="save">保存</button> |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- 右:判定预览。版本比较是这块最容易出错的地方,直接把结论摆出来 --> |
|||
<div class="card preview"> |
|||
<div class="ed-section-title">判定预览</div> |
|||
<p class="text-xs text-slate-400 mb-3">输入一个客户端版本号,看它会被判成什么。与服务端 comm.CompareVersion 同一套逐段整数比较。</p> |
|||
<input v-model="probe" class="input input-bordered input-sm w-full" placeholder="如 1.0.1" /> |
|||
<div class="probe-list"> |
|||
<div class="probe-row"> |
|||
<span>有更新可用</span> |
|||
<span class="badge" :class="probeResult.need ? 'on' : 'off'">{{ probeResult.need ? '是' : '否' }}</span> |
|||
</div> |
|||
<div class="probe-row"> |
|||
<span>必须更新(阻断使用)</span> |
|||
<span class="badge" :class="probeResult.force ? 'warn' : 'off'">{{ probeResult.force ? '是' : '否' }}</span> |
|||
</div> |
|||
<div class="probe-row"> |
|||
<span>处于审核期</span> |
|||
<span class="badge" :class="probeResult.reviewing ? 'on' : 'off'">{{ probeResult.reviewing ? '是' : '否' }}</span> |
|||
</div> |
|||
</div> |
|||
<p v-if="!probe.trim()" class="ed-hint mt-3"> |
|||
客户端没上报版本号时三项<b>一律为否</b>——拿不到版本就判强更会把所有老客户端挡在门外。 |
|||
</p> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</template> |
|||
|
|||
<script setup lang="ts"> |
|||
import { ref, reactive, computed, onMounted } from 'vue' |
|||
import { useApi } from '~/composables/useApi' |
|||
import { useToast } from '~/composables/useToast' |
|||
|
|||
definePageMeta({ title: '版本控制' }) |
|||
|
|||
interface AppRelease { |
|||
version: string |
|||
min_version: string |
|||
force_update: boolean |
|||
changelog: string |
|||
address: string |
|||
review_version: string |
|||
} |
|||
|
|||
const { webApi, consoleApi } = useApi() |
|||
const { success, error } = useToast() |
|||
|
|||
const scopeApp = ref('') |
|||
const scopeApps = ref<string[]>([]) |
|||
const submitting = ref(false) |
|||
const probe = ref('') |
|||
|
|||
function emptyForm(): AppRelease { |
|||
return { version: '', min_version: '', force_update: false, changelog: '', address: '', review_version: '' } |
|||
} |
|||
const form = reactive<AppRelease>(emptyForm()) |
|||
|
|||
// 与后端 comm.CompareVersion 同语义:切掉构建号、逐段按整数比、短的补 0、段内取前导数字。 |
|||
// 后台这份只用于即时提示,真正的判定在服务端——两边不一致时以服务端为准。 |
|||
function cmpVer(a: string, b: string): number { |
|||
const as = a.split('+')[0]!.trim().split('.') |
|||
const bs = b.split('+')[0]!.trim().split('.') |
|||
const n = Math.max(as.length, bs.length) |
|||
for (let i = 0; i < n; i++) { |
|||
const x = parseInt(as[i] ?? '0') || 0 |
|||
const y = parseInt(bs[i] ?? '0') || 0 |
|||
if (x !== y) return x < y ? -1 : 1 |
|||
} |
|||
return 0 |
|||
} |
|||
|
|||
const versionWarn = computed(() => { |
|||
if (form.force_update && !form.min_version.trim()) { |
|||
return '开启强制更新时必须填「最低支持版本」,否则挡不住任何人' |
|||
} |
|||
if (form.min_version.trim() && form.version.trim() && cmpVer(form.min_version, form.version) > 0) { |
|||
return '「最低支持版本」高于「当前线上版本」,会把所有线上用户判定为需强更却无更高版本可更' |
|||
} |
|||
return '' |
|||
}) |
|||
|
|||
// 与服务端 evalChannelUpgrade 逐条对齐(含「强更必然意味着有更新」这条兜底) |
|||
const probeResult = computed(() => { |
|||
const v = probe.value.trim() |
|||
if (!v) return { need: false, force: false, reviewing: false } |
|||
const need = !!form.version.trim() && cmpVer(v, form.version) < 0 |
|||
const force = form.force_update && !!form.min_version.trim() && cmpVer(v, form.min_version) < 0 |
|||
const reviewing = !!form.review_version.trim() && cmpVer(v, form.review_version) === 0 |
|||
return { need: need || force, force, reviewing } |
|||
}) |
|||
|
|||
// 应用名来源:应用中心的应用表 + 部署注册表(兜底老数据);无权读这两张表时改走 api_getmyapps。 |
|||
async function loadScopeApps() { |
|||
const names = new Set<string>() |
|||
const [products, deploys] = await Promise.all([ |
|||
consoleApi<Array<{ name: string }>>('products/list', {}).catch(() => [] as Array<{ name: string }>), |
|||
consoleApi<Array<{ app_name?: string; name: string }>>('apps/list', {}).catch(() => [] as Array<{ app_name?: string; name: string }>), |
|||
]) |
|||
;(products ?? []).forEach((p) => p.name && names.add(p.name)) |
|||
;(deploys ?? []).forEach((d) => (d.app_name || d.name) && names.add(d.app_name || d.name)) |
|||
if (!names.size) { |
|||
try { |
|||
const d = await webApi<{ apps: Array<{ name: string; app_name?: string }> }>('getmyapps', {}) |
|||
;(d?.apps ?? []).forEach((a) => { |
|||
const n = a.app_name || a.name |
|||
if (n) names.add(n) |
|||
}) |
|||
} catch { |
|||
// 拿不到就只剩「全局默认」,不阻断页面 |
|||
} |
|||
} |
|||
scopeApps.value = [...names].sort() |
|||
} |
|||
|
|||
async function load() { |
|||
try { |
|||
const d = await webApi<{ item: AppRelease }>('getapprelease', { app_name: scopeApp.value }) |
|||
Object.assign(form, emptyForm(), d?.item ?? {}) |
|||
} catch (e: any) { |
|||
error(e?.message ?? '加载失败') |
|||
Object.assign(form, emptyForm()) |
|||
} |
|||
} |
|||
|
|||
async function save() { |
|||
if (versionWarn.value) { error(versionWarn.value); return } |
|||
submitting.value = true |
|||
try { |
|||
// 只提交版本这一组字段:游客登录配置和它同一行,整行提交会把对方清空。 |
|||
await webApi('saveappversion', { |
|||
app_name: scopeApp.value, |
|||
version: form.version, |
|||
min_version: form.min_version, |
|||
force_update: form.force_update, |
|||
changelog: form.changelog, |
|||
address: form.address, |
|||
review_version: form.review_version, |
|||
}) |
|||
success('保存成功') |
|||
await load() |
|||
} catch (e: any) { |
|||
error(e?.message ?? '保存失败') |
|||
} finally { |
|||
submitting.value = false |
|||
} |
|||
} |
|||
|
|||
onMounted(async () => { |
|||
await loadScopeApps() |
|||
await load() |
|||
}) |
|||
</script> |
|||
|
|||
<style scoped> |
|||
.scope-tip { background: #f8fafc; border: 1px solid #e2e8f0; color: #475569; font-size: 12px; padding: 8px 12px; border-radius: 10px; margin-bottom: 16px; } |
|||
.ver-wrap { display: grid; gap: 16px; grid-template-columns: minmax(0, 2fr) minmax(260px, 1fr); align-items: start; } |
|||
@media (max-width: 900px) { .ver-wrap { grid-template-columns: 1fr; } } |
|||
.card { background: #fff; border: 1px solid #eef2f7; border-radius: 14px; padding: 18px 20px; } |
|||
.ed-section { padding-bottom: 16px; margin-bottom: 16px; border-bottom: 1px solid #f1f5f9; } |
|||
.ed-section:last-of-type { border-bottom: 0; margin-bottom: 0; } |
|||
.ed-section-title { font-size: 13px; font-weight: 700; color: #334155; margin-bottom: 10px; } |
|||
.ed-label { display: block; font-size: 12px; color: #64748b; margin-bottom: 4px; } |
|||
.ed-hint { font-size: 11px; color: #94a3b8; margin-top: 4px; line-height: 1.6; } |
|||
.ed-warn { font-size: 12px; color: #b45309; background: #fffbeb; border: 1px solid #fde68a; border-radius: 8px; padding: 6px 10px; margin-top: 10px; } |
|||
.ed-footer { display: flex; justify-content: flex-end; gap: 8px; padding-top: 14px; border-top: 1px solid #f1f5f9; } |
|||
.preview { position: sticky; top: 16px; } |
|||
.probe-list { margin-top: 12px; display: flex; flex-direction: column; gap: 8px; } |
|||
.probe-row { display: flex; align-items: center; justify-content: space-between; font-size: 12px; color: #475569; } |
|||
.badge { font-size: 11px; font-weight: 700; padding: 2px 10px; border-radius: 8px; } |
|||
.badge.on { background: #dcfce7; color: #059669; } |
|||
.badge.warn { background: #ffe4e6; color: #be123c; } |
|||
.badge.off { background: #f1f5f9; color: #94a3b8; } |
|||
.link { color: #4f46e5; text-decoration: underline; } |
|||
code { background: #f1f5f9; padding: 0 4px; border-radius: 4px; } |
|||
</style> |
|||
@ -0,0 +1,297 @@ |
|||
<template> |
|||
<div> |
|||
<!-- 列表视图 --> |
|||
<div v-show="view === 'list'"> |
|||
<div class="flex items-center justify-between mb-5"> |
|||
<h2 class="text-lg font-semibold text-slate-800">芯片厂商列表</h2> |
|||
<button class="btn btn-primary btn-sm text-white" @click="showForm()">+ 新增芯片厂商</button> |
|||
</div> |
|||
|
|||
<div class="bg-white rounded-xl shadow-sm border border-slate-100 p-4 mb-4"> |
|||
<div class="flex flex-wrap items-end gap-3"> |
|||
<div class="form-control"> |
|||
<label class="label py-1"><span class="label-text text-xs text-slate-500">状态</span></label> |
|||
<select v-model.number="filterEnabled" class="select select-bordered select-sm w-32"> |
|||
<option :value="-1">全部</option> |
|||
<option :value="1">启用中</option> |
|||
<option :value="0">已停用</option> |
|||
</select> |
|||
</div> |
|||
<div class="form-control flex-1 min-w-[220px]"> |
|||
<label class="label py-1"><span class="label-text text-xs text-slate-500">关键字</span></label> |
|||
<input v-model="keyword" type="text" class="input input-bordered input-sm w-full" placeholder="厂商名称 / CID" /> |
|||
</div> |
|||
<button class="btn btn-ghost btn-sm" @click="resetFilter">重置</button> |
|||
<span class="text-xs text-slate-400 ml-auto">共 {{ viewRows.length }} / {{ rows.length }} 条</span> |
|||
</div> |
|||
</div> |
|||
|
|||
<div class="bg-white rounded-xl shadow-sm border border-slate-100"> |
|||
<div class="overflow-x-auto"> |
|||
<table class="table table-zebra"> |
|||
<thead> |
|||
<tr> |
|||
<th class="cursor-pointer select-none" @click="toggleSort('id')">CID{{ sortMark('id') }}</th> |
|||
<th class="cursor-pointer select-none" @click="toggleSort('name')">厂商名称{{ sortMark('name') }}</th> |
|||
<th class="cursor-pointer select-none" @click="toggleSort('sort')">排序{{ sortMark('sort') }}</th> |
|||
<th>方案商数</th> |
|||
<th>状态</th> |
|||
<th>操作</th> |
|||
</tr> |
|||
</thead> |
|||
<tbody> |
|||
<tr v-if="loading"> |
|||
<td colspan="6" class="text-center py-8"><span class="loading loading-spinner loading-sm"></span></td> |
|||
</tr> |
|||
<tr v-else-if="!viewRows.length"> |
|||
<td colspan="6" class="text-center text-slate-400 py-8">暂无数据</td> |
|||
</tr> |
|||
<tr v-for="v in viewRows" :key="v.id"> |
|||
<td class="font-mono text-sm"> |
|||
<span class="badge badge-primary badge-outline badge-sm">0x{{ hexCid(v.id) }}</span> |
|||
</td> |
|||
<td class="font-medium">{{ v.name }}</td> |
|||
<td class="text-sm text-slate-500">{{ v.sort || 0 }}</td> |
|||
<td class="text-sm"> |
|||
<span class="badge badge-sm" :class="providerCount(v.id) ? 'badge-primary badge-outline' : 'badge-ghost'"> |
|||
{{ providerCount(v.id) }} |
|||
</span> |
|||
</td> |
|||
<td> |
|||
<span class="badge badge-sm" :class="v.enabled === false ? 'badge-ghost' : 'badge-success badge-outline'"> |
|||
{{ v.enabled === false ? '已停用' : '启用中' }} |
|||
</span> |
|||
</td> |
|||
<td> |
|||
<div class="flex gap-1"> |
|||
<button class="btn btn-ghost btn-xs" @click="showForm(v)">编辑</button> |
|||
<button class="btn btn-ghost btn-xs text-red-500" @click="delVendor(v)">删除</button> |
|||
</div> |
|||
</td> |
|||
</tr> |
|||
</tbody> |
|||
</table> |
|||
</div> |
|||
</div> |
|||
|
|||
<p class="text-xs text-slate-400 mt-3"> |
|||
芯片厂商用于给「方案商」归组:同一家芯片厂商下往往有多套方案,每套方案在 |
|||
<NuxtLink to="/provider" class="link link-primary">方案商管理</NuxtLink> 里单独建一条记录。 |
|||
名下还有方案商的厂商不能删除,需要下线时改成「停用」——停用后不再出现在新建方案商的下拉里, |
|||
但存量方案商仍能正常显示它的名字。 |
|||
</p> |
|||
</div> |
|||
|
|||
<!-- 表单视图 --> |
|||
<div v-show="view === 'form'"> |
|||
<div class="flex items-center gap-3 mb-5"> |
|||
<button class="btn btn-ghost btn-sm" @click="showList()">← 返回列表</button> |
|||
<h2 class="text-lg font-semibold text-slate-800">{{ isEditing ? '编辑芯片厂商' : '新增芯片厂商' }}</h2> |
|||
</div> |
|||
<div class="bg-white rounded-xl shadow-sm border border-slate-100 p-6 max-w-2xl"> |
|||
<form @submit.prevent="saveVendor"> |
|||
<!-- CID 就是这条记录的主键,手工指定的十六进制编码,可改(改号会级联更新方案商引用) --> |
|||
<div class="form-control mb-4"> |
|||
<label class="label"><span class="label-text font-medium">CID(十六进制)*</span></label> |
|||
<div class="flex items-stretch"> |
|||
<span class="bg-slate-100 text-slate-400 font-mono px-3 flex items-center rounded-l-lg border border-r-0 border-slate-200">0x</span> |
|||
<input |
|||
v-model="form.cidHex" |
|||
type="text" |
|||
class="input input-bordered w-full font-mono uppercase rounded-l-none" |
|||
placeholder="ABF4" |
|||
maxlength="8" |
|||
required |
|||
/> |
|||
</div> |
|||
<span class="label-text-alt text-slate-400 mt-1"> |
|||
CID 就是芯片厂商的唯一标识,全后台不可重复。<b>可以随时改</b>——改号时挂在它下面的方案商会一起跟过去。 |
|||
</span> |
|||
</div> |
|||
|
|||
<div class="form-control mb-4"> |
|||
<label class="label"><span class="label-text font-medium">厂商名称 *</span></label> |
|||
<input v-model="form.name" type="text" class="input input-bordered w-full" placeholder="如:杰里 / 恒玄 / 博通" required /> |
|||
</div> |
|||
|
|||
<div class="grid grid-cols-2 gap-4 mb-4"> |
|||
<div class="form-control"> |
|||
<label class="label"><span class="label-text font-medium">排序</span></label> |
|||
<input v-model.number="form.sort" type="number" class="input input-bordered w-full" /> |
|||
<span class="label-text-alt text-slate-400 mt-1">小的排前面,相同则按 ID</span> |
|||
</div> |
|||
<div class="form-control"> |
|||
<label class="label"><span class="label-text font-medium">状态</span></label> |
|||
<label class="label cursor-pointer justify-start gap-3"> |
|||
<input v-model="form.enabled" type="checkbox" class="toggle toggle-primary" /> |
|||
<span class="label-text">{{ form.enabled ? '启用中' : '已停用' }}</span> |
|||
</label> |
|||
<span class="label-text-alt text-slate-400 mt-1">停用后不再出现在新建方案商的下拉里</span> |
|||
</div> |
|||
</div> |
|||
|
|||
<div class="flex gap-2 pt-2"> |
|||
<button type="submit" class="btn btn-primary text-white" :disabled="submitting"> |
|||
<span v-if="submitting" class="loading loading-spinner loading-xs"></span> |
|||
{{ isEditing ? '保存' : '创建' }} |
|||
</button> |
|||
<button type="button" class="btn btn-ghost" @click="showList()">取消</button> |
|||
</div> |
|||
</form> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</template> |
|||
|
|||
<script setup lang="ts"> |
|||
import { ref, reactive, computed, onMounted } from 'vue' |
|||
import { useApi } from '~/composables/useApi' |
|||
import { useToast } from '~/composables/useToast' |
|||
import { useTableSort } from '~/composables/useTableSort' |
|||
import { invalidateChipVendors, type ChipVendor } from '~/utils/chipvendors' |
|||
|
|||
definePageMeta({ title: '芯片管理' }) |
|||
|
|||
interface Provider { |
|||
id: number |
|||
vendor: number |
|||
} |
|||
|
|||
const { webApi } = useApi() |
|||
const { success, error, warn } = useToast() |
|||
|
|||
const view = ref<'list' | 'form'>('list') |
|||
const rows = ref<ChipVendor[]>([]) |
|||
// 方案商列表只用来在「方案商数」列上做引用计数,让运营删之前一眼看出有没有人挂着 |
|||
const providers = ref<Provider[]>([]) |
|||
const loading = ref(false) |
|||
const submitting = ref(false) |
|||
const isEditing = ref(false) |
|||
const filterEnabled = ref(-1) // -1 全部;1 启用中;0 已停用 |
|||
const keyword = ref('') |
|||
|
|||
const form = reactive({ id: 0, name: '', cidHex: '', sort: 0, enabled: true }) |
|||
|
|||
// CID 统一按十六进制大写展示;0 视为未设置,由模板另行处理,这里不会被调到 |
|||
function hexCid(v?: number) { |
|||
return Number(v || 0).toString(16).toUpperCase() |
|||
} |
|||
function providerCount(vendorId: number) { |
|||
return providers.value.filter((p) => Number(p.vendor || 0) === vendorId).length |
|||
} |
|||
|
|||
const { toggleSort, sortMark, applySort } = useTableSort<ChipVendor>({ |
|||
id: (a, b) => Number(a.id) - Number(b.id), |
|||
name: (a, b) => String(a.name || '').localeCompare(String(b.name || ''), 'zh') || Number(a.id) - Number(b.id), |
|||
sort: (a, b) => Number(a.sort || 0) - Number(b.sort || 0) || Number(a.id) - Number(b.id), |
|||
}, 'sort') |
|||
|
|||
const viewRows = computed(() => { |
|||
const kw = keyword.value.trim().toLowerCase() |
|||
const list = rows.value.filter((v) => { |
|||
if (filterEnabled.value >= 0 && (v.enabled === false ? 0 : 1) !== filterEnabled.value) return false |
|||
if (!kw) return true |
|||
return (v.name || '').toLowerCase().includes(kw) || hexCid(v.id).toLowerCase().includes(kw) |
|||
}) |
|||
return applySort(list) |
|||
}) |
|||
function resetFilter() { |
|||
filterEnabled.value = -1 |
|||
keyword.value = '' |
|||
} |
|||
|
|||
async function loadData() { |
|||
loading.value = true |
|||
try { |
|||
const d = await webApi<{ vendors: ChipVendor[] }>('getchipvendors', {}) |
|||
rows.value = d?.vendors ?? [] |
|||
} catch (e: any) { |
|||
error(e?.message ?? '获取芯片厂商列表失败') |
|||
rows.value = [] |
|||
} finally { |
|||
loading.value = false |
|||
} |
|||
// 引用计数用,拉不到不影响主流程(列显示 0) |
|||
try { |
|||
const d = await webApi<{ providers: Provider[] }>('getsolutionproviders', {}) |
|||
providers.value = d?.providers ?? [] |
|||
} catch { |
|||
providers.value = [] |
|||
} |
|||
} |
|||
|
|||
function showList() { |
|||
view.value = 'list' |
|||
} |
|||
|
|||
function showForm(v?: ChipVendor) { |
|||
if (v) { |
|||
isEditing.value = true |
|||
form.id = Number(v.id) |
|||
form.name = v.name || '' |
|||
form.cidHex = hexCid(v.id) |
|||
form.sort = Number(v.sort || 0) |
|||
form.enabled = v.enabled !== false |
|||
} else { |
|||
isEditing.value = false |
|||
form.id = 0 |
|||
form.name = '' |
|||
form.cidHex = '' |
|||
form.sort = 0 |
|||
form.enabled = true |
|||
} |
|||
view.value = 'form' |
|||
} |
|||
|
|||
async function saveVendor() { |
|||
if (!form.name.trim()) { warn('请填写厂商名称'); return } |
|||
// CID 即主键,必填且必须是合法十六进制 |
|||
const hex = form.cidHex.trim().replace(/^0x/i, '') |
|||
if (!/^[0-9a-fA-F]{1,8}$/.test(hex)) { warn('CID 需填十六进制,如 ABF4'); return } |
|||
const cid = Number.parseInt(hex, 16) |
|||
if (!cid) { warn('CID 不能为 0'); return } |
|||
// 占用校验排除自己,否则编辑时不改号也会被自己挡住 |
|||
if (rows.value.some((v) => Number(v.id) === cid && Number(v.id) !== form.id)) { warn(`CID ${hex.toUpperCase()} 已被占用`); return } |
|||
submitting.value = true |
|||
try { |
|||
const payload: { vendor: Record<string, unknown>; oldid?: number } = { |
|||
vendor: { |
|||
id: cid, |
|||
name: form.name.trim(), |
|||
sort: Number(form.sort || 0), |
|||
enabled: form.enabled, |
|||
}, |
|||
} |
|||
// 编辑时带原 CID:服务端拿它定位记录,再决定要不要改主键 |
|||
if (isEditing.value) payload.oldid = form.id |
|||
await webApi(isEditing.value ? 'updatechipvendor' : 'addchipvendor', payload) |
|||
success(isEditing.value ? '已保存' : '已创建') |
|||
invalidateChipVendors() // 让方案商页下次进入时拿到最新的厂商列表 |
|||
showList() |
|||
await loadData() |
|||
} catch (e: any) { |
|||
error(e?.message ?? '保存失败') |
|||
} finally { |
|||
submitting.value = false |
|||
} |
|||
} |
|||
|
|||
function delVendor(v: ChipVendor) { |
|||
const n = providerCount(v.id) |
|||
if (n > 0) { |
|||
// 前端先挡一道,省一次必然失败的请求;后端 delChipVendor 也有同样的拦截,两边都不能少 |
|||
warn(`「${v.name}」下还有 ${n} 个方案商,请先把它们改到别的厂商下,或把本厂商改成「停用」`) |
|||
return |
|||
} |
|||
if (!window.confirm(`确认删除芯片厂商「${v.name}」?`)) return |
|||
webApi('delchipvendor', { id: v.id }) |
|||
.then(async () => { |
|||
success('已删除') |
|||
invalidateChipVendors() |
|||
await loadData() |
|||
}) |
|||
.catch((e: any) => error(e?.message ?? '删除失败')) |
|||
} |
|||
|
|||
onMounted(loadData) |
|||
</script> |
|||
@ -0,0 +1,244 @@ |
|||
<template> |
|||
<div> |
|||
<!-- 顶部 --> |
|||
<div class="flex items-center justify-between mb-5 flex-wrap gap-3"> |
|||
<div> |
|||
<h2 class="text-lg font-semibold text-slate-800">游客显隐控制</h2> |
|||
<p class="text-xs text-slate-400 mt-1"> |
|||
控制登录页的「游客登录」入口对哪一版客户端可见。<b>整个应用一套,不分渠道。</b> |
|||
</p> |
|||
</div> |
|||
<div class="flex items-center gap-3"> |
|||
<span class="text-sm text-slate-500">应用</span> |
|||
<select v-model="scopeApp" class="select select-bordered select-sm" @change="load"> |
|||
<option value="">全局默认(所有应用兜底)</option> |
|||
<option v-for="a in scopeApps" :key="a" :value="a">{{ a }}</option> |
|||
</select> |
|||
</div> |
|||
</div> |
|||
|
|||
<div v-if="!scopeApp" class="scope-tip"> |
|||
当前是<b>全局默认</b>作用域:这里配的对所有应用生效;某个应用单独配了,则以应用的为准。 |
|||
</div> |
|||
|
|||
<div class="guest-wrap"> |
|||
<div class="card"> |
|||
<section class="ed-section"> |
|||
<div class="ed-section-title">游客登录入口</div> |
|||
<label class="flex items-center gap-2 text-sm cursor-pointer"> |
|||
<input v-model="form.tourists" type="checkbox" class="checkbox checkbox-sm checkbox-primary" /> |
|||
<span>允许游客登录</span> |
|||
</label> |
|||
<div v-if="form.tourists" class="pl-7 mt-3"> |
|||
<span class="ed-label">生效版本(必填)</span> |
|||
<input v-model="form.touristsversion" class="input input-bordered input-sm w-full" placeholder="如 1.0.1" /> |
|||
<p class="ed-hint"> |
|||
填客户端上报的净版本号,<b>不含构建号</b>(<code>1.0.1</code> 而不是 <code>1.0.1+5</code>)。 |
|||
</p> |
|||
</div> |
|||
<p v-if="warn" class="ed-warn">⚠ {{ warn }}</p> |
|||
</section> |
|||
|
|||
<section class="ed-section"> |
|||
<div class="ed-section-title">当前效果</div> |
|||
<div class="effect" :class="effect.tone"> |
|||
<span class="effect-icon">{{ effect.icon }}</span> |
|||
<div> |
|||
<div class="effect-title">{{ effect.title }}</div> |
|||
<div class="effect-desc">{{ effect.desc }}</div> |
|||
</div> |
|||
</div> |
|||
</section> |
|||
|
|||
<div class="ed-footer"> |
|||
<button class="btn btn-ghost btn-sm" :disabled="submitting" @click="load">重置</button> |
|||
<button class="btn btn-primary btn-sm text-white" :disabled="submitting" @click="save">保存</button> |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- 语义说明单独立一块:这是白名单不是黑名单,配反了直接影响过审 --> |
|||
<div class="card note"> |
|||
<div class="ed-section-title">这个开关怎么判定</div> |
|||
<p class="note-p"> |
|||
<b>白名单语义:只有填在「生效版本」里的那一版能看到入口,其余版本一律隐藏。</b> |
|||
</p> |
|||
<table class="note-table"> |
|||
<thead> |
|||
<tr><th>开关</th><th>生效版本</th><th>效果</th></tr> |
|||
</thead> |
|||
<tbody> |
|||
<tr><td>关</td><td>任意</td><td>全部版本隐藏</td></tr> |
|||
<tr><td>开</td><td>空</td><td>全部版本隐藏(保存时会被拦下)</td></tr> |
|||
<tr><td>开</td><td><code>1.0.1</code></td><td><b>只有 1.0.1 可见</b>,其余隐藏</td></tr> |
|||
</tbody> |
|||
</table> |
|||
<p class="note-p"> |
|||
典型用法是送审:只给正在过审的那一版开游客登录,线上其它版本不受影响。 |
|||
</p> |
|||
<p class="note-p muted"> |
|||
判定在服务端做(客户端只拿结论,不拿规则)。留空按「全隐藏」而非「全放行」处理是刻意选的失败方向—— |
|||
判宽会让不该露入口的版本露出去(过审风险、白嫖开户礼),判严只是入口少显示。 |
|||
<b>要全部隐藏请直接取消勾选</b>,不要靠留空版本号表达。 |
|||
</p> |
|||
<p class="note-p muted"> |
|||
客户端拿不到配置(无网/接口失败)时同样按隐藏处理,所以断网启动后短时间内入口不出现是预期行为。 |
|||
</p> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</template> |
|||
|
|||
<script setup lang="ts"> |
|||
import { ref, reactive, computed, onMounted } from 'vue' |
|||
import { useApi } from '~/composables/useApi' |
|||
import { useToast } from '~/composables/useToast' |
|||
|
|||
definePageMeta({ title: '游客显隐控制' }) |
|||
|
|||
interface GuestForm { |
|||
tourists: boolean |
|||
touristsversion: string |
|||
} |
|||
|
|||
const { webApi, consoleApi } = useApi() |
|||
const { success, error } = useToast() |
|||
|
|||
const scopeApp = ref('') |
|||
const scopeApps = ref<string[]>([]) |
|||
const submitting = ref(false) |
|||
// 线上版本号只读,用来提示「生效版本填得比线上还高 = 目前没人看得到」 |
|||
const onlineVersion = ref('') |
|||
|
|||
function emptyForm(): GuestForm { |
|||
return { tourists: false, touristsversion: '' } |
|||
} |
|||
const form = reactive<GuestForm>(emptyForm()) |
|||
|
|||
// 与后端 comm.CompareVersion 同语义:切掉构建号、逐段按整数比、短的补 0 |
|||
function cmpVer(a: string, b: string): number { |
|||
const as = a.split('+')[0]!.trim().split('.') |
|||
const bs = b.split('+')[0]!.trim().split('.') |
|||
const n = Math.max(as.length, bs.length) |
|||
for (let i = 0; i < n; i++) { |
|||
const x = parseInt(as[i] ?? '0') || 0 |
|||
const y = parseInt(bs[i] ?? '0') || 0 |
|||
if (x !== y) return x < y ? -1 : 1 |
|||
} |
|||
return 0 |
|||
} |
|||
|
|||
// 这里只提示,真正的拦截在后端 saveAppTourists(前端提示挡不住直接调接口) |
|||
const warn = computed(() => { |
|||
if (!form.tourists) return '' |
|||
const v = form.touristsversion.trim() |
|||
if (!v) return '开启游客登录必须填生效版本,留空等于所有版本都看不到入口' |
|||
if (onlineVersion.value.trim() && cmpVer(v, onlineVersion.value) > 0) { |
|||
return `生效版本高于「当前线上版本 ${onlineVersion.value}」,目前线上没有任何一版客户端能看到入口` |
|||
} |
|||
return '' |
|||
}) |
|||
|
|||
const effect = computed(() => { |
|||
if (!form.tourists) { |
|||
return { tone: 'off', icon: '🚫', title: '全部版本隐藏', desc: '所有客户端的登录页都不显示游客登录入口。' } |
|||
} |
|||
const v = form.touristsversion.trim() |
|||
if (!v) { |
|||
return { tone: 'warn', icon: '⚠️', title: '等于没开', desc: '勾了开关却没点名版本,所有版本仍然看不到入口。保存时会被拦下。' } |
|||
} |
|||
return { tone: 'on', icon: '✅', title: `只有 ${v} 可见`, desc: `上报版本号等于 ${v} 的客户端会显示游客登录入口,其余版本一律隐藏。` } |
|||
}) |
|||
|
|||
// 应用名来源:应用中心的应用表 + 部署注册表(兜底老数据);无权读这两张表时改走 api_getmyapps。 |
|||
async function loadScopeApps() { |
|||
const names = new Set<string>() |
|||
const [products, deploys] = await Promise.all([ |
|||
consoleApi<Array<{ name: string }>>('products/list', {}).catch(() => [] as Array<{ name: string }>), |
|||
consoleApi<Array<{ app_name?: string; name: string }>>('apps/list', {}).catch(() => [] as Array<{ app_name?: string; name: string }>), |
|||
]) |
|||
;(products ?? []).forEach((p) => p.name && names.add(p.name)) |
|||
;(deploys ?? []).forEach((d) => (d.app_name || d.name) && names.add(d.app_name || d.name)) |
|||
if (!names.size) { |
|||
try { |
|||
const d = await webApi<{ apps: Array<{ name: string; app_name?: string }> }>('getmyapps', {}) |
|||
;(d?.apps ?? []).forEach((a) => { |
|||
const n = a.app_name || a.name |
|||
if (n) names.add(n) |
|||
}) |
|||
} catch { |
|||
// 拿不到就只剩「全局默认」,不阻断页面 |
|||
} |
|||
} |
|||
scopeApps.value = [...names].sort() |
|||
} |
|||
|
|||
async function load() { |
|||
try { |
|||
const d = await webApi<{ item: GuestForm & { version?: string } }>('getapprelease', { app_name: scopeApp.value }) |
|||
Object.assign(form, emptyForm(), { |
|||
tourists: d?.item?.tourists ?? false, |
|||
touristsversion: d?.item?.touristsversion ?? '', |
|||
}) |
|||
onlineVersion.value = d?.item?.version ?? '' |
|||
} catch (e: any) { |
|||
error(e?.message ?? '加载失败') |
|||
Object.assign(form, emptyForm()) |
|||
onlineVersion.value = '' |
|||
} |
|||
} |
|||
|
|||
async function save() { |
|||
// 关掉开关时顺手清空生效版本:输入框此时是隐藏的,留着旧值会被后端的 |
|||
// 「填了版本必须勾选开关」校验挡下,用户看不到是哪一项有问题。 |
|||
if (!form.tourists) form.touristsversion = '' |
|||
if (warn.value) { error(warn.value); return } |
|||
submitting.value = true |
|||
try { |
|||
// 只提交游客这一组字段:版本控制和它同一行,整行提交会把对方清空。 |
|||
await webApi('saveapptourists', { |
|||
app_name: scopeApp.value, |
|||
tourists: form.tourists, |
|||
touristsversion: form.touristsversion, |
|||
}) |
|||
success('保存成功') |
|||
await load() |
|||
} catch (e: any) { |
|||
error(e?.message ?? '保存失败') |
|||
} finally { |
|||
submitting.value = false |
|||
} |
|||
} |
|||
|
|||
onMounted(async () => { |
|||
await loadScopeApps() |
|||
await load() |
|||
}) |
|||
</script> |
|||
|
|||
<style scoped> |
|||
.scope-tip { background: #f8fafc; border: 1px solid #e2e8f0; color: #475569; font-size: 12px; padding: 8px 12px; border-radius: 10px; margin-bottom: 16px; } |
|||
.guest-wrap { display: grid; gap: 16px; grid-template-columns: minmax(0, 1fr) minmax(300px, 1fr); align-items: start; } |
|||
@media (max-width: 900px) { .guest-wrap { grid-template-columns: 1fr; } } |
|||
.card { background: #fff; border: 1px solid #eef2f7; border-radius: 14px; padding: 18px 20px; } |
|||
.ed-section { padding-bottom: 16px; margin-bottom: 16px; border-bottom: 1px solid #f1f5f9; } |
|||
.ed-section:last-of-type { border-bottom: 0; margin-bottom: 0; } |
|||
.ed-section-title { font-size: 13px; font-weight: 700; color: #334155; margin-bottom: 10px; } |
|||
.ed-label { display: block; font-size: 12px; color: #64748b; margin-bottom: 4px; } |
|||
.ed-hint { font-size: 11px; color: #94a3b8; margin-top: 4px; line-height: 1.6; } |
|||
.ed-warn { font-size: 12px; color: #b45309; background: #fffbeb; border: 1px solid #fde68a; border-radius: 8px; padding: 6px 10px; margin-top: 10px; } |
|||
.ed-footer { display: flex; justify-content: flex-end; gap: 8px; padding-top: 14px; border-top: 1px solid #f1f5f9; } |
|||
.effect { display: flex; gap: 10px; align-items: flex-start; border-radius: 10px; padding: 10px 12px; } |
|||
.effect.on { background: #dcfce7; } |
|||
.effect.off { background: #f1f5f9; } |
|||
.effect.warn { background: #fffbeb; } |
|||
.effect-icon { font-size: 16px; line-height: 1.3; } |
|||
.effect-title { font-size: 13px; font-weight: 700; color: #334155; } |
|||
.effect-desc { font-size: 11px; color: #64748b; margin-top: 2px; line-height: 1.6; } |
|||
.note { background: #fcfcfd; } |
|||
.note-p { font-size: 12px; color: #475569; line-height: 1.8; margin-bottom: 10px; } |
|||
.note-p.muted { color: #94a3b8; font-size: 11px; } |
|||
.note-table { width: 100%; font-size: 11px; border-collapse: collapse; margin-bottom: 12px; } |
|||
.note-table th, .note-table td { border: 1px solid #e2e8f0; padding: 5px 8px; text-align: left; color: #475569; } |
|||
.note-table th { background: #f8fafc; color: #64748b; font-weight: 600; } |
|||
code { background: #f1f5f9; padding: 0 4px; border-radius: 4px; } |
|||
</style> |
|||
@ -0,0 +1,322 @@ |
|||
<template> |
|||
<div> |
|||
<!-- 列表视图 --> |
|||
<div v-show="view === 'list'"> |
|||
<div class="flex items-center justify-between mb-5"> |
|||
<h2 class="text-lg font-semibold text-slate-800">工厂列表</h2> |
|||
<button class="btn btn-primary btn-sm text-white" @click="showForm()">+ 新增工厂</button> |
|||
</div> |
|||
|
|||
<div class="bg-white rounded-xl shadow-sm border border-slate-100 p-4 mb-4"> |
|||
<div class="flex flex-wrap items-end gap-3"> |
|||
<div class="form-control"> |
|||
<label class="label py-1"><span class="label-text text-xs text-slate-500">状态</span></label> |
|||
<select v-model.number="filterEnabled" class="select select-bordered select-sm w-32"> |
|||
<option :value="-1">全部</option> |
|||
<option :value="1">合作中</option> |
|||
<option :value="0">已停用</option> |
|||
</select> |
|||
</div> |
|||
<div class="form-control flex-1 min-w-[220px]"> |
|||
<label class="label py-1"><span class="label-text text-xs text-slate-500">关键字</span></label> |
|||
<input v-model="keyword" type="text" class="input input-bordered input-sm w-full" placeholder="工厂名称 / 联系人 / 地址 / 说明" /> |
|||
</div> |
|||
<button class="btn btn-ghost btn-sm" @click="resetFilter">重置</button> |
|||
<span class="text-xs text-slate-400 ml-auto">共 {{ viewRows.length }} / {{ rows.length }} 条</span> |
|||
</div> |
|||
</div> |
|||
|
|||
<div class="bg-white rounded-xl shadow-sm border border-slate-100"> |
|||
<div class="overflow-x-auto"> |
|||
<table class="table table-zebra"> |
|||
<thead> |
|||
<tr> |
|||
<th class="cursor-pointer select-none" @click="toggleSort('id')">ID{{ sortMark('id') }}</th> |
|||
<th class="cursor-pointer select-none" @click="toggleSort('name')">工厂名称{{ sortMark('name') }}</th> |
|||
<th>联系人</th> |
|||
<th>联系电话</th> |
|||
<th>地址</th> |
|||
<th>说明</th> |
|||
<th class="cursor-pointer select-none" @click="toggleSort('sort')">排序{{ sortMark('sort') }}</th> |
|||
<th>状态</th> |
|||
<th>操作</th> |
|||
</tr> |
|||
</thead> |
|||
<tbody> |
|||
<tr v-if="loading"> |
|||
<td colspan="9" class="text-center py-8"><span class="loading loading-spinner loading-sm"></span></td> |
|||
</tr> |
|||
<tr v-else-if="!viewRows.length"> |
|||
<td colspan="9" class="text-center text-slate-400 py-8">暂无数据</td> |
|||
</tr> |
|||
<tr v-for="f in viewRows" :key="f.id"> |
|||
<td class="font-mono text-sm">{{ f.id }}</td> |
|||
<td class="font-medium">{{ f.name }}</td> |
|||
<td class="text-sm">{{ f.contact || '-' }}</td> |
|||
<td class="text-sm">{{ f.phone || '-' }}</td> |
|||
<td class="text-sm text-slate-500 max-w-[180px] truncate" :title="f.address">{{ f.address || '-' }}</td> |
|||
<td class="text-sm text-slate-500 max-w-xs truncate" :title="f.description">{{ f.description || '' }}</td> |
|||
<td class="text-sm text-slate-500">{{ f.sort || 0 }}</td> |
|||
<td> |
|||
<span class="badge badge-sm" :class="f.enabled === false ? 'badge-ghost' : 'badge-success badge-outline'"> |
|||
{{ f.enabled === false ? '已停用' : '合作中' }} |
|||
</span> |
|||
</td> |
|||
<td> |
|||
<div class="flex gap-1"> |
|||
<button class="btn btn-ghost btn-xs" @click="showForm(f)">编辑</button> |
|||
<button class="btn btn-ghost btn-xs text-red-500" @click="delFactory(f)">删除</button> |
|||
</div> |
|||
</td> |
|||
</tr> |
|||
</tbody> |
|||
</table> |
|||
</div> |
|||
</div> |
|||
|
|||
<p class="text-xs text-slate-400 mt-3"> |
|||
工厂是「这批货由哪家厂生产组装」,与 |
|||
品牌商(这货挂谁的牌子卖)是两回事: |
|||
同一品牌可以换厂、同一家厂也可以给多个品牌代工,所以分开维护。 |
|||
</p> |
|||
</div> |
|||
|
|||
<!-- 表单视图 --> |
|||
<div v-show="view === 'form'"> |
|||
<div class="flex items-center gap-3 mb-5"> |
|||
<button class="btn btn-ghost btn-sm" @click="showList()">← 返回列表</button> |
|||
<h2 class="text-lg font-semibold text-slate-800">{{ isEditing ? '编辑工厂' : '新增工厂' }}</h2> |
|||
</div> |
|||
<div class="bg-white rounded-xl shadow-sm border border-slate-100 p-6 max-w-2xl"> |
|||
<form @submit.prevent="saveFactory"> |
|||
<div v-if="isEditing" class="form-control mb-4"> |
|||
<label class="label"><span class="label-text font-medium">ID</span></label> |
|||
<input :value="form.id" type="text" class="input input-bordered w-full bg-slate-50" disabled /> |
|||
</div> |
|||
|
|||
<div class="form-control mb-4"> |
|||
<label class="label"><span class="label-text font-medium">工厂名称 *</span></label> |
|||
<input v-model="form.name" type="text" class="input input-bordered w-full" placeholder="如:东莞市XX电子有限公司" required /> |
|||
</div> |
|||
|
|||
<div class="grid grid-cols-2 gap-4 mb-4"> |
|||
<div class="form-control"> |
|||
<label class="label"><span class="label-text font-medium">联系人</span></label> |
|||
<input v-model="form.contact" type="text" class="input input-bordered w-full" placeholder="如:张工" /> |
|||
</div> |
|||
<div class="form-control"> |
|||
<label class="label"><span class="label-text font-medium">联系电话</span></label> |
|||
<input v-model="form.phone" type="text" class="input input-bordered w-full" placeholder="如:13800000000" /> |
|||
</div> |
|||
</div> |
|||
|
|||
<div class="form-control mb-4"> |
|||
<label class="label"><span class="label-text font-medium">工厂地址</span></label> |
|||
<input v-model="form.address" type="text" class="input input-bordered w-full" placeholder="如:广东省东莞市XX镇XX路X号" /> |
|||
</div> |
|||
|
|||
<div class="form-control mb-4"> |
|||
<label class="label"><span class="label-text font-medium">说明</span></label> |
|||
<textarea v-model="form.description" class="textarea textarea-bordered w-full" rows="3" placeholder="产能、擅长品类、合作情况等"></textarea> |
|||
</div> |
|||
|
|||
<div class="form-control mb-4"> |
|||
<label class="label"><span class="label-text font-medium">联系邮箱</span></label> |
|||
<div class="flex gap-2 mb-2"> |
|||
<input v-model="newEmail" type="email" class="input input-bordered input-sm flex-1" placeholder="输入邮箱后点添加" @keydown.enter.prevent="addEmail" /> |
|||
<button type="button" class="btn btn-sm" @click="addEmail">添加</button> |
|||
</div> |
|||
<div v-if="emails.length" class="flex flex-wrap gap-2"> |
|||
<span v-for="(e, i) in emails" :key="i" class="badge badge-ghost gap-1"> |
|||
{{ e }} |
|||
<button type="button" class="text-red-500" @click="emails.splice(i, 1)">×</button> |
|||
</span> |
|||
</div> |
|||
</div> |
|||
|
|||
<div class="grid grid-cols-2 gap-4 mb-4"> |
|||
<div class="form-control"> |
|||
<label class="label"><span class="label-text font-medium">排序</span></label> |
|||
<input v-model.number="form.sort" type="number" class="input input-bordered w-full" /> |
|||
<span class="label-text-alt text-slate-400 mt-1">小的排前面,相同则按 ID</span> |
|||
</div> |
|||
<div class="form-control"> |
|||
<label class="label"><span class="label-text font-medium">状态</span></label> |
|||
<label class="label cursor-pointer justify-start gap-3"> |
|||
<input v-model="form.enabled" type="checkbox" class="toggle toggle-primary" /> |
|||
<span class="label-text">{{ form.enabled ? '合作中' : '已停用' }}</span> |
|||
</label> |
|||
</div> |
|||
</div> |
|||
|
|||
<div class="flex gap-2 pt-2"> |
|||
<button type="submit" class="btn btn-primary text-white" :disabled="submitting"> |
|||
<span v-if="submitting" class="loading loading-spinner loading-xs"></span> |
|||
{{ isEditing ? '保存' : '创建' }} |
|||
</button> |
|||
<button type="button" class="btn btn-ghost" @click="showList()">取消</button> |
|||
</div> |
|||
</form> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</template> |
|||
|
|||
<script setup lang="ts"> |
|||
import { ref, reactive, computed, onMounted } from 'vue' |
|||
import { useApi } from '~/composables/useApi' |
|||
import { useToast } from '~/composables/useToast' |
|||
import { useTableSort } from '~/composables/useTableSort' |
|||
|
|||
definePageMeta({ title: '工厂管理' }) |
|||
|
|||
interface OemFactory { |
|||
id: number |
|||
name: string |
|||
contact?: string |
|||
phone?: string |
|||
contactmails?: string |
|||
address?: string |
|||
description?: string |
|||
enabled?: boolean |
|||
sort?: number |
|||
createtime?: number |
|||
updatetime?: number |
|||
} |
|||
|
|||
const { webApi } = useApi() |
|||
const { success, error, warn } = useToast() |
|||
|
|||
const view = ref<'list' | 'form'>('list') |
|||
const rows = ref<OemFactory[]>([]) |
|||
const loading = ref(false) |
|||
const submitting = ref(false) |
|||
const isEditing = ref(false) |
|||
const emails = ref<string[]>([]) |
|||
const newEmail = ref('') |
|||
|
|||
const filterEnabled = ref(-1) // -1 全部;1 合作中;0 已停用 |
|||
const keyword = ref('') |
|||
|
|||
const form = reactive({ id: 0, name: '', contact: '', phone: '', address: '', description: '', sort: 0, enabled: true }) |
|||
|
|||
function mailList(s?: string) { |
|||
return (s || '').split(',').map((e) => e.trim()).filter(Boolean) |
|||
} |
|||
|
|||
const { toggleSort, sortMark, applySort } = useTableSort<OemFactory>({ |
|||
id: (a, b) => Number(a.id) - Number(b.id), |
|||
name: (a, b) => String(a.name || '').localeCompare(String(b.name || ''), 'zh') || Number(a.id) - Number(b.id), |
|||
sort: (a, b) => Number(a.sort || 0) - Number(b.sort || 0) || Number(a.id) - Number(b.id), |
|||
}, 'sort') |
|||
|
|||
const viewRows = computed(() => { |
|||
const kw = keyword.value.trim().toLowerCase() |
|||
const list = rows.value.filter((f) => { |
|||
if (filterEnabled.value >= 0 && (f.enabled === false ? 0 : 1) !== filterEnabled.value) return false |
|||
if (!kw) return true |
|||
return (f.name || '').toLowerCase().includes(kw) || |
|||
(f.contact || '').toLowerCase().includes(kw) || |
|||
(f.address || '').toLowerCase().includes(kw) || |
|||
(f.description || '').toLowerCase().includes(kw) |
|||
}) |
|||
return applySort(list) |
|||
}) |
|||
function resetFilter() { |
|||
filterEnabled.value = -1 |
|||
keyword.value = '' |
|||
} |
|||
|
|||
async function loadData() { |
|||
loading.value = true |
|||
try { |
|||
const d = await webApi<{ factories: OemFactory[] }>('getoemfactories', {}) |
|||
rows.value = d?.factories ?? [] |
|||
} catch (e: any) { |
|||
error(e?.message ?? '获取工厂列表失败') |
|||
rows.value = [] |
|||
} finally { |
|||
loading.value = false |
|||
} |
|||
} |
|||
|
|||
function showList() { |
|||
view.value = 'list' |
|||
} |
|||
|
|||
function showForm(f?: OemFactory) { |
|||
if (f) { |
|||
isEditing.value = true |
|||
form.id = Number(f.id) |
|||
form.name = f.name || '' |
|||
form.contact = f.contact || '' |
|||
form.phone = f.phone || '' |
|||
form.address = f.address || '' |
|||
form.description = f.description || '' |
|||
form.sort = Number(f.sort || 0) |
|||
form.enabled = f.enabled !== false |
|||
emails.value = mailList(f.contactmails) |
|||
} else { |
|||
isEditing.value = false |
|||
form.id = 0 |
|||
form.name = '' |
|||
form.contact = '' |
|||
form.phone = '' |
|||
form.address = '' |
|||
form.description = '' |
|||
form.sort = 0 |
|||
form.enabled = true |
|||
emails.value = [] |
|||
} |
|||
newEmail.value = '' |
|||
view.value = 'form' |
|||
} |
|||
|
|||
function addEmail() { |
|||
const e = newEmail.value.trim() |
|||
if (!e) return |
|||
if (emails.value.includes(e)) { warn('该邮箱已添加'); return } |
|||
emails.value.push(e) |
|||
newEmail.value = '' |
|||
} |
|||
|
|||
async function saveFactory() { |
|||
if (!form.name.trim()) { warn('请填写工厂名称'); return } |
|||
submitting.value = true |
|||
try { |
|||
const payload = { |
|||
factory: { |
|||
id: form.id, |
|||
name: form.name.trim(), |
|||
contact: form.contact, |
|||
phone: form.phone, |
|||
contactmails: emails.value.join(','), |
|||
address: form.address, |
|||
description: form.description, |
|||
sort: Number(form.sort || 0), |
|||
enabled: form.enabled, |
|||
}, |
|||
} |
|||
await webApi(isEditing.value ? 'updateoemfactory' : 'addoemfactory', payload) |
|||
success(isEditing.value ? '已保存' : '已创建') |
|||
showList() |
|||
await loadData() |
|||
} catch (e: any) { |
|||
error(e?.message ?? '保存失败') |
|||
} finally { |
|||
submitting.value = false |
|||
} |
|||
} |
|||
|
|||
function delFactory(f: OemFactory) { |
|||
if (!window.confirm(`确认删除工厂「${f.name}」?`)) return |
|||
webApi('deloemfactory', { id: f.id }) |
|||
.then(async () => { |
|||
success('已删除') |
|||
await loadData() |
|||
}) |
|||
.catch((e: any) => error(e?.message ?? '删除失败')) |
|||
} |
|||
|
|||
onMounted(loadData) |
|||
</script> |
|||
@ -1,23 +1,79 @@ |
|||
// 芯片方案厂商分组,对应后端 DBSolutionProvider.vendor(proto 的 SolutionVendor 枚举)。
|
|||
// 一个芯片厂商下可能有多套方案,每套方案是一条方案商记录,靠这个分组归拢。
|
|||
// 新增芯片厂商要三处同步补同一个数值:
|
|||
// apps/proto/db.proto 的 SolutionVendor
|
|||
// apps/services/modules/console/api_device.go 的 chipVendorNames
|
|||
// 本文件的 CHIP_VENDORS
|
|||
// 芯片厂商,对应后端 chip_vendor 表(原 proto 的 SolutionVendor 枚举,2026-09-01 转表)。
|
|||
// 一个芯片厂商下可能有多套方案,每套方案是一条方案商记录,靠 DBSolutionProvider.vendor 归拢。
|
|||
//
|
|||
// 以前这里是写死的四条,加一家厂商要同步改三处代码(db.proto 枚举 + 后端 chipVendorNames +
|
|||
// 本文件)再发版;现在改成拉「芯片管理」页维护的表,代码不用动。
|
|||
//
|
|||
// 缓存是模块级单例:方案商页、芯片管理页共用同一份,避免每个页面各拉一次。
|
|||
// 增删改后调用 invalidateChipVendors() 让下次 load 重新拉。
|
|||
import { ref } from 'vue' |
|||
|
|||
export interface ChipVendor { |
|||
value: number |
|||
label: string |
|||
id: number // 即 CID:手工指定的十六进制编码(如 0xABF4=恒玄),也是 solution_provider.vendor 指向的值
|
|||
name: string |
|||
enabled?: boolean |
|||
sort?: number |
|||
createtime?: number |
|||
updatetime?: number |
|||
} |
|||
|
|||
/** 全部芯片厂商(含已停用的)。停用项仍要留在这里,否则存量方案商的分组会显示成「厂商#N」。 */ |
|||
export const chipVendors = ref<ChipVendor[]>([]) |
|||
|
|||
let loaded = false |
|||
let inflight: Promise<void> | null = null |
|||
|
|||
/** |
|||
* 拉取芯片厂商列表(模块级缓存,重复调用只发一次请求)。 |
|||
* webApi 由调用方传入——util 不是组件上下文,直接 useApi() 在某些场景下取不到 Nuxt 实例。 |
|||
*/ |
|||
export async function loadChipVendors( |
|||
webApi: <T>(method: string, data?: unknown) => Promise<T>, |
|||
force = false, |
|||
): Promise<void> { |
|||
if (loaded && !force) return |
|||
// 并发去重:同一帧里多个组件同时 onMounted 拉取,只发一个请求。
|
|||
if (inflight && !force) return inflight |
|||
inflight = (async () => { |
|||
try { |
|||
const d = await webApi<{ vendors: ChipVendor[] }>('getchipvendors', {}) |
|||
chipVendors.value = d?.vendors ?? [] |
|||
loaded = true |
|||
} catch { |
|||
// 拉不到就保持空列表:下拉为空比显示一份可能过期的硬编码值更诚实,
|
|||
// 也避免运营在后台删了某厂商后前端还能选到它。
|
|||
chipVendors.value = [] |
|||
} finally { |
|||
inflight = null |
|||
} |
|||
})() |
|||
return inflight |
|||
} |
|||
|
|||
/** 增删改之后调用,让下次 loadChipVendors 重新拉。 */ |
|||
export function invalidateChipVendors() { |
|||
loaded = false |
|||
} |
|||
|
|||
export const CHIP_VENDORS: ChipVendor[] = [ |
|||
{ value: 1, label: '杰里' }, |
|||
{ value: 2, label: '蓝汛' }, |
|||
{ value: 3, label: '恒玄' }, |
|||
{ value: 4, label: '博通' }, |
|||
] |
|||
/** 可选项:只列启用中的,用于「新建/编辑方案商」的下拉。 */ |
|||
export function enabledChipVendors(): ChipVendor[] { |
|||
return chipVendors.value.filter((v) => v.enabled !== false) |
|||
} |
|||
|
|||
/** |
|||
* id → 厂商名。查不到时回退成「厂商#N」而不是空白——存量方案商指着一个已删除的厂商时, |
|||
* 显示成 `厂商#2` 至少能看出是数据问题,空白会让人以为没填。 |
|||
*/ |
|||
export function chipVendorName(v?: number | null): string { |
|||
const n = Number(v || 0) |
|||
if (!n) return '未分组' |
|||
return CHIP_VENDORS.find((x) => x.value === n)?.label ?? ('厂商#' + n) |
|||
return chipVendors.value.find((x) => x.id === n)?.name ?? ('厂商#' + n) |
|||
} |
|||
|
|||
/** 「名称 (0xCID)」,下拉里用——同名难辨时靠 CID 区分,也让人一眼看到要烧进固件的那个值。 */ |
|||
export function chipVendorLabel(v?: number | null): string { |
|||
const n = Number(v || 0) |
|||
if (!n) return '未分组' |
|||
const hit = chipVendors.value.find((x) => x.id === n) |
|||
return hit ? `${hit.name} (0x${n.toString(16).toUpperCase()})` : ('厂商#' + n) |
|||
} |
|||
|
|||
@ -0,0 +1,395 @@ |
|||
# 恒玄耳机连接链路:两份改动方案 |
|||
|
|||
背景与问题定位见本文「附:为什么分两份」。两份**互相独立**,可以分别上、分别验证。 |
|||
|
|||
- **方案一**:修「换个耳机就连不上」。纯 bug,无策略取舍,改动小。 |
|||
- **方案二**:自动回连提速 + 确权失败的处置口径。**含策略决定,需要先定口径再动手。** |
|||
|
|||
--- |
|||
|
|||
# 方案一:修「换个耳机就连不上」 |
|||
|
|||
## 根因 |
|||
|
|||
[bes_scan_view.dart:169-181](lib/modules/devices/views/bes_scan_view.dart#L169-L181) 把**用户点的那一台排在候选列表最后**: |
|||
|
|||
```dart |
|||
candidates.add(liveId); // ① 原生当前连着的 ← 旧耳机 A |
|||
candidates.add(last); // ② lastDeviceAddress ← 旧耳机 A |
|||
candidates.add(id); // ③ 用户点的那一台 ← 新耳机 B |
|||
``` |
|||
|
|||
这个顺序是为 Android **TWS 双地址**写的(同一只耳机:配对列表地址 ≠ 跑 SPP 的地址), |
|||
但它分不清「同一台耳机的另一个地址」和「另一台耳机」。 |
|||
|
|||
叠加 Android 原生的静默丢弃 [BluetoothManager.kt:628-640](local_plugins/bluetooth_manager/android/src/main/kotlin/com/example/bluetooth_manager/BluetoothManager.kt#L628-L640): |
|||
|
|||
```kotlin |
|||
fun connect(deviceId: String, ...) { |
|||
cancelReconnect() // ← 会 closeSocketQuietly() |
|||
if (!isConnecting.compareAndSet(false, true)) return // ← 静默 return,不报错不改状态 |
|||
``` |
|||
|
|||
`cancelReconnect()` **不复位 `isConnecting`**(只 interrupt 线程 + 关 socket),它只在连接线程的 |
|||
`finally`(第 638 行)里清。而 CAS 紧接着同步执行,上一次的线程还没被调度到 `finally`—— |
|||
**这个竞态新调用几乎必输**。 |
|||
|
|||
### 失败时间线(A 已配对但不在身边) |
|||
|
|||
| 时刻 | 发生什么 | |
|||
|---|---| |
|||
| t=0 | 连 A → `isConnecting=true` → `sock.connect()` 阻塞(配对但不在场的经典蓝牙设备要等系统页超时) | |
|||
| t=8s | Dart `_waitConnected` 超时 → 循环到 B → `connect(B)`:`cancelReconnect()` **关掉 A 的 socket**,随后 CAS 失败 → **静默丢弃** | |
|||
| t=8s+ε | A 的线程抛 IOException → `publishBluetoothStatus(0)` → `isConnecting=false`。**此时没有任何设备在连** | |
|||
| t=16s | Dart 再次超时 → 弹「连接失败:请确认 Echo-one 已开机、在手机附近」 | |
|||
|
|||
重试无效:`lastDeviceAddress` 仍是 A(`_finish` 没跑过)。**永久卡死。** |
|||
|
|||
**触发条件**:第一个候选在 8 秒内既没连上也没失败。配对但不在范围内的经典蓝牙设备正是这个行为。 |
|||
(若 A 快速失败——比如已关机且系统立即返回——则 B 能连上,所以现场表现会有偶发性。) |
|||
|
|||
### A 在身边时的另一种失败 |
|||
|
|||
候选①立刻成功 → [第 64 行](lib/modules/devices/views/bes_scan_view.dart#L64) 的 `_statusWorker` |
|||
抢在候选循环中途开火 → `_finish()` → `savePairedDevice({address: A, name: B的名字})` → |
|||
弹「连接成功」跳主页。**用户以为换成 B 了,其实还在 A,且配对列表被写脏。** |
|||
|
|||
## 改动清单 |
|||
|
|||
### 1.1 候选 = 点击的那台 + 同名设备遍历,删掉两个 fallback(`bes_scan_view.dart`) |
|||
|
|||
#### 先说为什么现有的 fallback 必须删,而不是重排 |
|||
|
|||
Android 插件里**没有任何扫描/发现路径**(`startDiscovery` / `ACTION_FOUND` 全项目零命中), |
|||
地址的唯一来源是 `adapter.bondedDevices`([getBondedDevicesList:774](local_plugins/bluetooth_manager/android/src/main/kotlin/com/example/bluetooth_manager/BluetoothManager.kt#L774))。 |
|||
而两个 fallback 的来源是闭环的: |
|||
|
|||
``` |
|||
adapter.bondedDevices ──► 扫描页列表 ──► 用户点击 ──► connectToDevice(addr) |
|||
└─► 原生 lastDeviceId = addr |
|||
├─► currentConnectedDevice() ─┐ |
|||
└─► savePairedDevice │ |
|||
└─► lastDeviceAddress ──┤ |
|||
只能吐回 App 自己拨过的地址 ◄─────────────────────────┘ |
|||
``` |
|||
|
|||
**`currentConnectedDevice()` 与 `lastDeviceAddress` 永远产生不了一个「新地址」。** |
|||
所以注释里说的 TWS 双地址(「配对列表是 …C1:C4,SPP 实际走 …BE:F8」)这条链根本够不着—— |
|||
…BE:F8 没有任何入口能进入系统。原作者瞄准的问题是对的,但接错了源: |
|||
这两个 fallback 唯一的实际效果就是**把上一台耳机塞进候选列表**。 |
|||
|
|||
反过来,那个 SPP 地址若真的存在于系统配对列表里,它就是**同名的另一行**—— |
|||
正好落在「同名设备遍历」的范围内。两种情况下遍历同名行都不比现状差。 |
|||
|
|||
#### 改法 |
|||
|
|||
```dart |
|||
// 身份由连上之后的 MAC 校验来定,不由拨哪个地址来定(见 1.4 + BesDeviceAuth)。 |
|||
// 所以连接阶段只要「拨通一条链路」即可,不需要在地址层面追求精确。 |
|||
final tapped = (d['address'] as String? ?? ''); |
|||
final tappedName = (d['name'] as String? ?? '').trim().toUpperCase(); |
|||
|
|||
final candidates = <String>[tapped]; |
|||
// 同名的其余行:可能是同一只耳机的另一个 BR/EDR 地址(TWS), |
|||
// 也可能是另一台同款耳机——两种都值得试,连上哪台由 MAC 校验兜底认领。 |
|||
for (final row in _devices) { |
|||
final a = (row['address'] as String? ?? ''); |
|||
final n = (row['name'] as String? ?? '').trim().toUpperCase(); |
|||
if (a.isEmpty || n != tappedName) continue; |
|||
if (candidates.any((c) => c.toUpperCase() == a.toUpperCase())) continue; |
|||
candidates.add(a); |
|||
} |
|||
// ⚠️ 不要再把 currentConnectedDevice() / lastDeviceAddress 加进来(理由见上) |
|||
``` |
|||
|
|||
**这一条单独就能消掉绝大部分现象**,且不需要动原生。 |
|||
|
|||
#### 配套:`_waitConnected` 监听 1→0 提前失败 |
|||
|
|||
`connectBlocking` 失败时会 `publishBluetoothStatus(0)` |
|||
([第 684 行](local_plugins/bluetooth_manager/android/src/main/kotlin/com/example/bluetooth_manager/BluetoothManager.kt#L684)), |
|||
所以不必每个候选都干等满 8 秒: |
|||
|
|||
```dart |
|||
Future<bool> _waitConnected({Duration timeout = const Duration(seconds: 8)}) async { |
|||
final end = DateTime.now().add(timeout); |
|||
bool sawConnecting = false; |
|||
while (DateTime.now().isBefore(end)) { |
|||
final s = _service.deviceStatus.value; |
|||
if (s == 2) return true; |
|||
if (s == 1) sawConnecting = true; |
|||
// 已经进过「连接中」又掉回 0 = 原生明确失败了,不用等满超时 |
|||
if (sawConnecting && s == 0) return false; |
|||
await Future.delayed(const Duration(milliseconds: 300)); |
|||
} |
|||
return _service.deviceStatus.value == 2; |
|||
} |
|||
``` |
|||
|
|||
遍历 N 个同名设备的总耗时从「N × 8s」降到接近真实失败时间。 |
|||
⚠️ 这一条**依赖 1.3**:原生现在静默丢弃时不发任何状态,`sawConnecting && s == 0` |
|||
在丢弃场景下不会触发(状态停在 Dart 自己写的 1),仍要靠超时兜底。 |
|||
|
|||
⚠️ 副作用(可接受):用户点 B、B 不在范围内时,会回落到同名的 A 并连上它。 |
|||
配合 1.4,落盘与提示显示的是**真实连上的那台**,数据不会脏; |
|||
代价是用户可能拿到不是自己点的那只。若要杜绝,可在回落成功后提示 |
|||
「已连接 <真实名称/地址>」而不是沿用点击行的名字。 |
|||
|
|||
### 1.2 候选循环期间不让 `_statusWorker` 抢跑(`bes_scan_view.dart:64`) |
|||
|
|||
```dart |
|||
_statusWorker = ever<int>(_service.deviceStatus, (v) { |
|||
if (v == 2 && mounted && !_finished && !_connecting) _finish(); |
|||
}); |
|||
``` |
|||
|
|||
`_connecting` 期间由 `_connect` 自己在循环结束后决定收尾,避免第一个候选一连上就跳走。 |
|||
|
|||
### 1.3 Android 原生:`connect()` 不再静默丢弃(`BluetoothManager.kt:628`) |
|||
|
|||
```kotlin |
|||
fun connect(deviceId: String, serviceUuid: String = "aeaf5241-...") { |
|||
cancelReconnect() // 已关掉在飞的 socket,阻塞的 sock.connect() 会立刻抛异常 |
|||
Thread { |
|||
// 等上一次的 finally 复位 isConnecting。不等就 CAS 几乎必然失败, |
|||
// 这次 connect 会被静默丢弃——「换个耳机连不上」的直接原因。 |
|||
val deadline = System.currentTimeMillis() + 2000 |
|||
while (isConnecting.get() && System.currentTimeMillis() < deadline) Thread.sleep(20) |
|||
if (!isConnecting.compareAndSet(false, true)) { |
|||
publishBluetoothStatus(0) // 至少让 Dart 知道这次没成,别干等 8 秒 |
|||
return@Thread |
|||
} |
|||
lastDeviceId = deviceId |
|||
lastServiceUuid = serviceUuid |
|||
isUserInitiatedDisconnect = false |
|||
try { connectBlocking(deviceId, serviceUuid) } finally { isConnecting.set(false) } |
|||
}.start() |
|||
} |
|||
``` |
|||
|
|||
要点: |
|||
- 等待放在**工作线程**里,不能在平台线程 `Thread.sleep`(`connect` 由 MethodChannel 在主线程调用)。 |
|||
- `lastDeviceId` 的赋值跟着挪进线程。已核对:唯一的读者 `getConnectedDeviceInfo()` |
|||
([第 762 行](local_plugins/bluetooth_manager/android/src/main/kotlin/com/example/bluetooth_manager/BluetoothManager.kt#L762)) |
|||
先判 `isConnected`,不依赖它被同步写入。 |
|||
- **iOS 不需要改**:`BluetoothManager.swift:138` 的 `connect()` 没有 `isConnecting` 门禁, |
|||
每次都走 `attachAndConnect` → `resetConnectionState()`,本来就不会丢弃。 |
|||
|
|||
### 1.4 连接后回填真实设备身份(`bes_bluetooth_service.dart:654`) |
|||
|
|||
`connectToDevice` 在调原生**之前**就乐观写了 `connectedDeviceId = 目标地址`: |
|||
|
|||
```dart |
|||
deviceStatus.value = 1; |
|||
connectedDeviceId.value = deviceId; // ← 原生若丢弃/连到别的,这个值就是错的 |
|||
``` |
|||
|
|||
而 `_listen` 的 `event == 2` 分支只在 `connectedDeviceId.value.isEmpty` 时才去问原生真实设备。 |
|||
于是「状态里是 B、链路上是 A」会一路带到确权:拿 B 的 MAC 去 `binddevice`(绑一台没连着的耳机), |
|||
被拒时 `_reject` 又按 `connectedDeviceId == B` 判 `isCurrent=true`,把实际连着的 A 断掉。 |
|||
|
|||
改法:`event == 2` 时**无条件**用原生 `getConnectedDevice()` 回填,不再以「本地值为空」为条件。 |
|||
|
|||
### 1.5 `deviceMac` 按 device id 建缓存,而不是断开就清 |
|||
|
|||
问题:`event == 0` 分支清了 7 个字段,唯独没清 `deviceMac`。注释的理由是「它是这台硬件的属性」—— |
|||
但**换耳机之后它就是上一台的属性了**。而 iOS 的 `resolveDeviceMac()` 第一行就是: |
|||
|
|||
```dart |
|||
if (deviceMac.value.isNotEmpty) return deviceMac.value; // ← 直接返回旧耳机的 MAC |
|||
``` |
|||
|
|||
后果:iOS 换耳机后确权用旧 MAC → 命中旧白名单 → B 冒用 A 的确权通过。 |
|||
|
|||
#### ⚠️ 不能简单地「断开就清」——那会拖慢 iOS 自动回连 |
|||
|
|||
`resolveDeviceMac()` 在 iOS 上拿不到缓存时要 `queryBattery()` 再轮询**最多 3 秒**。 |
|||
而它排在 `verifyConnected()` → 握手链路的最前面。于是「断开就清」会让 |
|||
**每一次 iOS 自动回连都多等最多 3 秒**;在固件 0.0.3 上(`AA 09` 已不回 `BB 0A`) |
|||
更是每次都必然等满 3 秒才返回 null。这与「回连要快」的目标直接冲突。 |
|||
|
|||
#### 改法:把 MAC 缓存成 `deviceId → mac` 的映射并持久化 |
|||
|
|||
```dart |
|||
static const String _macCacheKey = 'bes_device_mac_cache'; // {deviceId: MAC} |
|||
|
|||
// event == 2、用 1.4 回填出真实 connectedDeviceId 之后: |
|||
final cached = (_storage.read<Map>(_macCacheKey) ?? {})[connectedDeviceId.value]; |
|||
deviceMac.value = (cached as String?) ?? ''; // 换了设备就是空,不会串用上一台的 |
|||
|
|||
// _handleRawFrame 解析出 BB 0A 属性 7 时,除了写 deviceMac, |
|||
// 再按当前 connectedDeviceId 落一条映射 |
|||
``` |
|||
|
|||
- iOS 上 `connectedDeviceId` 是 peripheral UUID,对「同一台手机 + 同一只耳机」是稳定的, |
|||
所以这个映射可长期复用 → **回连仍然是同步命中,零等待**。 |
|||
- 换耳机时 id 不同 → 查不到 → `deviceMac` 为空 → 老老实实重新查,**不会串用旧 MAC**。 |
|||
- Android 不受影响:`resolveDeviceMac()` 在 Android 上直接返回 `connectedDeviceId`,不走这条。 |
|||
|
|||
⚠️ 这一条**依赖 1.4**:必须先拿到真实的 `connectedDeviceId` 再查映射, |
|||
否则用 Dart 乐观写入的那个错值做键,等于换了个方式串号。 |
|||
|
|||
## 对自动回连的影响:不影响,且修正了「回连锁错设备」 |
|||
|
|||
三条自动回连路径与本方案的关系: |
|||
|
|||
| 路径 | 触发条件 | 方案一的影响 | |
|||
|---|---|---| |
|||
| **Android ACL 广播**(耳机开机自动回连的主路径) | `ACTION_ACL_CONNECTED` 且 `btDevice.address == lastDeviceId` 且 `!isUserInitiatedDisconnect` 且 `deviceStatus == 0` → `reconnectWithRetry` | 不改这条;**1.1/1.3 修正了 `lastDeviceId` 锁错设备**(见下) | |
|||
| **iOS `.peerConnected`** | 外设在 `authorizedDeviceIds` 里 → `attachAndConnect` | 不改;1.5 改后回连仍是同步命中 | |
|||
| **冷启动 `tryAutoConnect()`** | `deviceStatus == 0` + `BackgroundLaunchConsent.granted` + `getPairedDevices().first` | 不改(它直接调 `connectToDevice`,不经过扫描页的 `_connect`) | |
|||
|
|||
**耳机关机再开机属于链路掉线,不是用户主动断开**——`isUserInitiatedDisconnect` 保持 false、 |
|||
`lastDeviceId` 保留,所以 ACL 回连正常。这条不变。 |
|||
|
|||
**1.1/1.3 反而修好了一个回连 bug**:现状下用户点 B 却连上 A 时,原生 |
|||
`lastDeviceId = A`,于是此后**耳机开机自动回连的永远是 A**。1.1 让拨号顺序正确、 |
|||
1.3 让被丢弃的 connect 不再污染 `lastDeviceId`(赋值挪到抢到 `isConnecting` 之后), |
|||
回连目标才会跟着用户的选择走。 |
|||
|
|||
### 1.2 会不会挡掉「停在扫描页时的 ACL 回连」 |
|||
|
|||
不会。`_statusWorker` 存在的目的正是这个场景,加 `!_connecting` 后: |
|||
- 回连发生在候选循环**期间** → `_waitConnected` 会看到 `deviceStatus == 2` 并 `break`, |
|||
由 `_connect` 自己走 `_finish()`; |
|||
- 回连发生在循环**结束后** → `finally` 已把 `_connecting` 置回 false,worker 照常开火。 |
|||
|
|||
两条路都有人收尾,不存在空档。 |
|||
|
|||
### 与本方案无关、但会关掉自动回连的既有条件(供排查参考) |
|||
|
|||
`disconnect()` 会同时 `lastDeviceId = null` 且 `isUserInitiatedDisconnect = true`, |
|||
**自动回连从此关闭,直到下一次显式 `connect()`**。Dart 侧会走到这里的有 5 处: |
|||
设置页手动断开、`bes_test` 调试页、OTA 让路给插件(升级后会重连)、 |
|||
`BesDeviceAuth._reject`(确权拒绝)、`removePairedDevice` 命中当前设备(解绑)。 |
|||
其中确权拒绝那条正是方案二 2.3 要处理的。 |
|||
|
|||
## 验证方式 |
|||
|
|||
必须真机,且需要**两台**恒玄耳机(或一台 + 一台改过名的其它经典蓝牙设备凑数)。 |
|||
|
|||
| # | 步骤 | 期望 | |
|||
|---|---|---| |
|||
| 1 | 绑定 A → 关闭 A 电源 → 进扫描页点 B | B 在数秒内连上;**不再**出现「连接失败」 | |
|||
| 2 | A 开机在旁 → 进扫描页点 B | 连上的是 B(不是 A),配对列表首项是 B | |
|||
| 2b | A 开机在旁 → 点 B,但 B 拒连/不在范围 | 回落连上 A,且提示与落盘显示的是 **A** 的身份(验 1.4) | |
|||
| 3 | 只有一台耳机时进扫描页 | 自动连接行为不变(回归) | |
|||
| 4 | 杀进程重启 | `tryAutoConnect` 回连的是最近一次连的那台 | |
|||
| 5 | Android 看 logcat | `connect(B)` 之后有 `Bluetooth Connection` 日志,不再静默无输出 | |
|||
| 6 | **连上 B → 关机 B → 再开机 B** | ACL 自动回连的是 **B**(现状会回连 A,因为 `lastDeviceId` 被写成了 A) | |
|||
| 7 | **iOS:已确权耳机断开后重连,掐表** | 握手开始时间与改动前一致,**不引入额外 3 秒**(验 1.5 的映射缓存生效) | |
|||
| 8 | iOS:换成另一台耳机 | 确权用的是**新耳机**的 MAC,不再命中旧白名单 | |
|||
|
|||
⚠️ 1.3 改原生,需重新出包;1.1/1.2/1.4/1.5 纯 Dart,可先单独上一版验证前四条。 |
|||
|
|||
--- |
|||
|
|||
# 方案二:自动回连提速 + 确权失败的处置口径 |
|||
|
|||
> **这份含策略取舍,动手前需要先拍板下面「待定口径」两条。** |
|||
|
|||
## 前提澄清(这两件事已经是现状,不需要改) |
|||
|
|||
1. **确权本来就是「先连上再校验」**:`verifyConnected()` 由 `event == 2`(链路已通)触发, |
|||
不是连接的前置条件。 |
|||
2. **MAC 缓存在本地就不打接口**:[bes_device_auth.dart:149](lib/data/services/bes_device_auth.dart#L149) |
|||
的白名单短路在任何网络调用之前,同步返回。 |
|||
|
|||
所以确权**不是**「换耳机连不上」的原因——那条失败路径上 `deviceStatus` 从来没到过 2, |
|||
`verifyConnected` 一次都没被调用。方案二解决的是另外三件事。 |
|||
|
|||
## 2.1 只有 `authorized` 才写缓存,`unknown` 每次回连都重来 |
|||
|
|||
`_remember()` 只在 [180](lib/data/services/bes_device_auth.dart#L180) 与 |
|||
[229](lib/data/services/bes_device_auth.dart#L229) 两个 authorized 分支调用。 |
|||
判不出结论时什么都不缓存,于是**每次自动回连都重跑一遍**: |
|||
|
|||
- 断网 → `Api.binddevice` 被 `.timeout(10s)` 卡满 → 每次回连白等 10 秒才开始查固件版本/电量 |
|||
- iOS 固件 0.0.3(`AA 09` 已不回 `BB 0A`,`_listen` 里有 TODO)→ `resolveDeviceMac` 每次空等 3 秒 |
|||
|
|||
**改法**:让确权脱离握手链路——白名单命中仍同步放行;没命中则**后台跑**,握手立即继续。 |
|||
这正是「校验通过就无感,不通过再提醒」。 |
|||
|
|||
```dart |
|||
final auth = BesDeviceAuth.tryFastPath(); // 白名单命中 → 同步 authorized |
|||
if (auth == null) { |
|||
unawaited(BesDeviceAuth.verifyInBackground()); // 慢路径:拿到 denied 再断开+提示 |
|||
} |
|||
// 握手不再等待,立即继续 |
|||
``` |
|||
|
|||
**待定口径 ①**:确权改后台后,未授权设备会拿到**数秒的完整功能**才被切断。 |
|||
当前注释写的是「必须排在握手和 AI 编排之前——未确权的设备不该被当成一台可用耳机对待」。 |
|||
这道闸门的定位是**防非授权硬件蹭服务**,不是防滥用,我倾向于可以接受; |
|||
但如果口径是「一秒都不能用」,那 2.1 只能改成「缩短超时」而不是「移出链路」。 |
|||
|
|||
## 2.2 `_reported` 是死字段 |
|||
|
|||
[第 80/87/115/123/181/230 行](lib/data/services/bes_device_auth.dart#L80) 全是 |
|||
`add`/`remove`/`clear`,**没有任何一处读它**。它本来的职责(进程内去重,别每次回连都打接口) |
|||
完全没生效——真正在去重的是持久化白名单 `isAuthorizedLocally`。 |
|||
|
|||
**改法**:让它接管 `unknown` 的进程内去重。断网时一个进程周期内只白等一次 10 秒, |
|||
而不是每次回连都等。这是给自动回连提速最便宜的一刀,且无策略风险。 |
|||
|
|||
## 2.3 一次 denied 会把自动回连永久关掉 |
|||
|
|||
`_reject()` 一次做了三件事:移出 `pairedDevices`、清 `_lastDeviceKey`、 |
|||
`disconnectDevice()`(原生 `disconnect()` 里 `lastDeviceId = null`; |
|||
iOS 侧 `_syncAuthorizedDevices` 也会把它移出 `authorizedDeviceIds`)。 |
|||
**Dart 的 `tryAutoConnect` 和原生 ACL/`.peerConnected` 两条回连路径同时被切断**, |
|||
只能靠用户再进一次扫描页手动点。 |
|||
|
|||
而且有一条**误杀正品**的路径:`_resolveProductId` 匹配不上产品名时 |
|||
`return headsets.first.id` 无条件兜底 → 后台若有多个经典蓝牙耳机产品, |
|||
就会拿**错的 pid** 去 `getFactoryDeviceformac` → 查不到 → `AuthorizeNoCanUse` → denied。 |
|||
而设备名来源 `connectedDeviceName` 正是方案一 1.4 会写错的那个值; |
|||
再叠加 Android `getBondedDevices` 返回系统缓存的旧配对名(代码自己注释了 |
|||
`DEEPVOICE` → `Echo-one` 改名问题),匹配不上的概率不低。 |
|||
|
|||
**改法(三条)**: |
|||
|
|||
- **`_resolveProductId` 去掉 `headsets.first` 兜底**:匹配不到就返回 null → `unknown` → 放行。 |
|||
拿错 pid 去查 license 表,结论必然是「查不到」,等于把「不知道」伪装成「不合法」。 |
|||
- **denied 不再清 `pairedDevices` / `lastDeviceId`**,改为本地黑名单 + 冷却期 |
|||
(`bes_denied_macs: {mac: deniedAtMs}`,24h 内不自动回连、也不重打接口; |
|||
用户在扫描页**手动点**视为明确意图,清掉冷却强制重查)。 |
|||
这样既不永久失联,也不会掉进「连上→弹窗→断开→连上」的死循环—— |
|||
那个循环正是当初移出 `pairedDevices` 要防的。 |
|||
- **提示文案可自救**:`deviceConnectionRestricted` 现在是「设备连接受限,该设备未获授权」, |
|||
用户不知道该做什么。应说明这是设备未在后台登记,需联系客服/经销商。 |
|||
|
|||
**待定口径 ②**:denied 时**要不要还断链**? |
|||
|
|||
| | 判严(现在:断链 + 关闭自动回连) | 中间(断链,但保留自动回连 + 冷却) | 判宽(不断链,仅降级需授权的功能) | |
|||
|---|---|---|---| |
|||
| 误判代价 | 用户永久失去自动回连,且无法自救 | 24h 后自动重试一次 | 几乎无 | |
|||
| 闸门强度 | 最强 | 与判严等价(联网后仍会拦) | 弱,靠功能降级兜底 | |
|||
|
|||
我建议**中间档**:与现有 `unknown` 一律放行的口径一致(类注释:「判严的代价是合法用户一断网 |
|||
就用不了自己的耳机」),denied 分支目前没跟上这个口径。 |
|||
|
|||
## 验证方式 |
|||
|
|||
| # | 场景 | 期望 | |
|||
|---|---|---| |
|||
| 1 | 已确权耳机 + 断网,重启 App 回连 | 握手不再被 10s 超时拖住(白名单命中,本来就不该打接口——回归验证) | |
|||
| 2 | **未**确权耳机 + 断网,反复回连 | 只在第一次白等 10 秒,之后走 `_reported` 去重 | |
|||
| 3 | 后台把某 MAC 从 license 表移除后连接 | 提示可自救;**设备仍留在配对列表**,24h 内不再打接口 | |
|||
| 4 | 后台配置两个经典蓝牙耳机产品,连一台名字对不上的 | 判为 `unknown` 放行,**不再**误判 denied | |
|||
| 5 | 正常首连一台已登记的新耳机 | 无感,握手不被确权拖慢 | |
|||
|
|||
--- |
|||
|
|||
# 附:为什么分两份 |
|||
|
|||
两组问题在时间轴上根本不重叠: |
|||
|
|||
``` |
|||
用户点 B ──► 候选循环(A→B) ──► 原生连接 ──► event==2 ──► 确权 ──► 握手 |
|||
└──────── 方案一 ────────┘ └──── 方案二 ────┘ |
|||
「连不上」死在这里 这里从来没被执行到 |
|||
``` |
|||
|
|||
**确权层怎么调都救不了「用户点 B、代码去连 A」。** 方案一是纯 bug 修复、无口径争议, |
|||
建议先上;方案二牵涉「闸门该多严」的产品决定,定了口径再动。 |
|||
@ -0,0 +1,286 @@ |
|||
# apps/client 全量体检(2026-09-03) |
|||
|
|||
扫描范围:428 个 dart 文件 / 187,759 行 / 41 个语种。 |
|||
工具:`dart analyze` 全量 + 括号配平的自定义扫描脚本 + 逐条 grep 反查。 |
|||
**未做**:真机运行、动态链路验证、`local_plugins/` 下 25 个原生插件的内部审查。 |
|||
|
|||
统计:analyzer 1,222 项(1,049 info + 173 warning)。 |
|||
|
|||
--- |
|||
|
|||
## 必须先修 |
|||
|
|||
### 01. ~~32~~ 27 个翻译 key 在全部 41 个语言文件里都不存在 —— 用户可见 ✅ 已修(2026-09-03) |
|||
|
|||
> **修复记录**:26 个 key 已补入全部 41 个语言文件(1,066 条);第 27 个 `tips` 改为复用 |
|||
> 项目里已有的 `tip`(30 处在用),不新增同义重复键。 |
|||
> 复查结果:代码里生效的 1,151 个 `.tr` key **全部有译文**,各语言文件无重复键, |
|||
> `dart analyze` 全量 1,222 → 1,222 项、0 error(无新增问题)。 |
|||
> |
|||
> **修正初版报告**:初版列的 32 个里有 5 个只出现在**被注释掉的代码**里,界面上并不会显示, |
|||
> 已从清单剔除:`codeSend`、`clearInput`、`ttsInterruptToggle`、`unsupportedMode`、 |
|||
> `checkUpdateTooFrequent`。真正生效的是 27 个。 |
|||
> (`pleaseEnterVerificationCode` 在 `:477` 是生效的,未受此修正影响。) |
|||
|
|||
<details> |
|||
<summary>原始问题描述</summary> |
|||
|
|||
GetX 的 `.tr` 找不到 key 时**原样返回 key 本身**,不报错、不回退中文。这 32 处在 |
|||
**包括简体中文在内的每一种语言**下,界面上直接显示英文标识符。 |
|||
|
|||
命中主流程: |
|||
|
|||
| key | 位置 | 表现 | |
|||
|---|---|---| |
|||
| `pleaseEnterVerificationCode` | `login_controller.dart:477` | 登录页显示 `pleaseEnterVerificationCode` | |
|||
| `codeSend` | `login_controller.dart:331` | 发送验证码按钮 | |
|||
| `userDataParseFailed` | `login_controller.dart:718` | 登录失败提示 | |
|||
| `notCharging` | `home_controller.dart:513` | 首页设备卡片充电状态 | |
|||
| `finishRecording` / `pauseRecording` / `markFavorite` / `unmarkFavorite` / `enterFileName` | `meeting_record_view.dart` | 会议录音页控制按钮 | |
|||
| `monthlyTranscriptionDuration` / `industrySpecificSummaryTemplate` / `distinguishDifferentSpeakers` / `importAudioFromAppWeb` / `findMore` / `bindImmediately` / `autopilotTemplateSummary` | `member_bottom_sheet.dart` | 会员权益弹窗 6 条文案 | |
|||
| `clearInput` | `text_translation_view.dart:84` | 文本翻译清空按钮 | |
|||
| `ttsInterruptToggle` / `tips` | `agent_view.dart:319`, `agent_controller.dart:1773` | 智能体页 | |
|||
| `notBound` | `goodsvip_view.dart:363` | VIP 商品页 | |
|||
| `unsupportedMode` | `translation_controller.dart:343` | 通话翻译 | |
|||
| `storageBytes` / `storageUploads` | `meeting_stats_view.dart:226,232` | 用量统计 | |
|||
| `checkUpdateTooFrequent` | `settings_controller.dart:319` | 设置页 | |
|||
| `scanIdle` / `scanning` / `startScan` / `stopScan` / `discoveredDevices` / `noDevicesFound` / `unnamedDevice` / `disconnect` | `devices_view.dart` | 杰理扫描页(本项目不走,优先级低) | |
|||
|
|||
**修法**:先补 `zh_cn` / `en_us`,再批量灌其余 39 语。 |
|||
⚠️ 语言文件里单双引号两种写法都存在,补之前两种都要查,否则 const map 会在 build 阶段撞重复键。 |
|||
|
|||
</details> |
|||
|
|||
**遗留**:防回归的 CI 检查还没加(见第 04 条),现在仍然可能再漏进新的缺失 key。 |
|||
|
|||
### 02. 微信 appSecret 硬编码 + 无条件打印进设备日志 |
|||
|
|||
``` |
|||
weixinLogin.dart:148 print("_getTokenUrl 数据获取${_appId},${_appSecret}"); |
|||
weixinLogin.dart:160 print("_getRefreshTokenUrl 数据获取${_appId},${_appSecret}"); |
|||
login_service.dart:9 appSecret: 'c792a0387c339f7b5c85d439466c67b7' |
|||
pay_service.dart:41 appSecret: '9ceb39df312b3ba95a229b2aa0d15496' |
|||
``` |
|||
|
|||
裸 `print()` **不受 `kReleaseMode` 约束**,正式包照打。(同项目 `appleLogin.dart` 的 print |
|||
都套了 `kDebugMode`,这两行漏了。)拿到 appSecret 就能以应用身份换 access_token。 |
|||
|
|||
**修法**:先删这两行 print(零成本止血)。appSecret 本身应挪到服务端(客户端只发 code); |
|||
一时改不动至少改成后端 `AppConfig.env()` 下发。**已进过 git 的 secret 必须视为已泄漏,去微信开放平台重置。** |
|||
|
|||
### 03. 支付宝已签名订单串被打进日志 |
|||
|
|||
`pay_service.dart` 有 **68 处裸 `print()`,全文件零 `kDebugMode` 门禁**。 |
|||
|
|||
``` |
|||
pay_service.dart:732 print('支付宝支付订单: $orderInfo'); ← 含服务端签好的 sign |
|||
pay_service.dart:610 print('创建支付订单: $response'); |
|||
pay_service.dart:836 print('创建支付宝订单: $response'); |
|||
``` |
|||
|
|||
**修法**:整文件 print 换成项目已有的 `Logger`(自带 `kReleaseMode ? warning : debug` 分级); |
|||
涉及订单串/响应体的直接删,不要降级。 |
|||
|
|||
--- |
|||
|
|||
## 高 |
|||
|
|||
### 04. 30 个语种各缺 160+ 个正在使用的 key |
|||
|
|||
全项目 key 并集 1,599 个: |
|||
|
|||
| 语种 | key 数 | 覆盖率 | |
|||
|---|---:|---:| |
|||
| `zh_cn` `en_us`(2 个) | 1,555 | 97% | |
|||
| `de` `es` `fr` `ja` `ko` `pt` `ru` `zh_hk` `zh_tw`(9 个) | 1,541 | 96% | |
|||
| `ar` `bg` `cs` `da` `el` … (28 个) | 1,322 | 83% | |
|||
| `tr_tr` | 1,320 | 83% | |
|||
| `nl_nl` | 1,213 | 76% | |
|||
|
|||
荷兰语/土耳其语等用户会在自动回连授权弹窗、蓝牙扫描页、设备绑定页看到 |
|||
`autoConnectConsentTitle` 这样的标识符。 |
|||
|
|||
**修法**:把「以 en_us 为基准做 key 差集」做成 CI 检查,仓库已有的 `tools/i18n_audit.py` 加一条覆盖率断言。 |
|||
|
|||
### 05. `AppConfig.getproduct()` 的地雷仍有 6 处调用 ✅ 已修(2026-09-03) |
|||
|
|||
> **修复记录**:`appconfig.dart` 的 `getproduct()` 与 `agentsystemPrompt()` 都改成了 |
|||
> 显式循环 + 返回 null(不引入 `collection` 依赖——项目 pubspec 里没有它)。 |
|||
> 同时补了两道原本不成立的前置条件: |
|||
> - **未初始化时返回 null**:`config` 是 `static late`,`user_getappconfig` 还没回来时 |
|||
> 原实现会抛 `LateInitializationError`,null 契约照样不成立。 |
|||
> - **空设备名返回 null**:`devicename.contains('')` 恒为 true,原实现会把**产品表里的 |
|||
> 第一个产品**当成匹配结果返回。调用点本来就都紧接着判 `deviceName != ''`,无一依赖此行为。 |
|||
> |
|||
> 6 个调用点的代码**没动**——它们写的 `if (product != null)` 现在才真正开始工作。 |
|||
> 另外把三处「不要用 getproduct,它会抛」的注释改写成了真实理由(那三个本地 |
|||
> `_findProduct` 有更宽的双向包含匹配规则,所以保留,见第 10 条)。 |
|||
> `dart analyze` 对改动文件 0 新增告警。 |
|||
|
|||
内部 `firstWhere` **没有 orElse**,查不到抛 `StateError` 而不是返回 null。 |
|||
代码里已有三处注释写着「不要用这个函数」,但调用点一个没换。 |
|||
|
|||
调用方全写成 `DBProduct? p = getproduct(...); if (p != null) {...}`——那个 null 分支 |
|||
**永远等不到**,异常被外层 try 吞掉后直接走兜底,全程不报错: |
|||
|
|||
``` |
|||
home_controller.dart:526 getCurrentDeviceType() → 设备类型退化成 unknown |
|||
home_controller.dart:609 |
|||
translation_controller.dart:1490 broadcastPeer 恒为 false |
|||
translation_controller.dart:1584 |
|||
agent_controller.dart:368 TTS 打断偏好读不到 |
|||
ble_manager.dart:1055 |
|||
``` |
|||
|
|||
同类:`appconfig.dart:35` 的 `agentsystemPrompt()` 也是无 orElse 的 firstWhere。 |
|||
|
|||
**修法**:改成 `firstWhereOrNull`(collection 包已在依赖里)。改完那 6 个 `if (p != null)` 才真正开始工作。 |
|||
|
|||
### 06. 耳机配对页整页硬编码中文 |
|||
|
|||
`bes_scan_view.dart` 是恒玄耳机唯一配对入口、新用户必经流程,**一句都没走 `.tr`**: |
|||
|
|||
``` |
|||
:223 Text('添加 AI 耳机($_targetName)') |
|||
:274 Text('没有找到 $_targetName 耳机') |
|||
:282 '请先在系统「设置 → 蓝牙」里连接 …' |
|||
:86 Get.snackbar('连接成功', …) |
|||
:190 Get.snackbar('连接失败', …) |
|||
``` |
|||
|
|||
### 07. 会议转写编辑框每次重建都被重置 |
|||
|
|||
`speech_tab.dart:142` 在 `Obx` 回调里 `new TextEditingController(text: item['content'])`。 |
|||
`Obx` 每次重建都造一个新的:**用户正在改的发言人文字会被打回原文**,旧 controller 一个都没 dispose。 |
|||
|
|||
同类(仅泄漏、不影响功能):`profile_view.dart:148`、`profile_meeting_view.dart:12`、 |
|||
`translation_list_view.dart:347`。 |
|||
|
|||
**修法**:`speech_tab` 改 StatefulWidget,controller 按 `item['id']` 缓存在 State 里、dispose 时统一释放。 |
|||
|
|||
### 08. 两端都全局放开了明文 HTTP |
|||
|
|||
``` |
|||
ios/Runner/Info.plist NSAllowsArbitraryLoads = true |
|||
android/…/AndroidManifest.xml:73 usesCleartextTraffic="true" |
|||
android/…/network_security_config.xml base-config 全放开 + certificates src="user" |
|||
``` |
|||
|
|||
**全站可被降级到明文**(业务后端、支付回调、会议音频上传都在口子里),而实际需要明文的 |
|||
只有讯飞声音复刻一家:`timbre_controller.dart:107,128,155,200,241` |
|||
(`opentrain.xfyousheng.com` / `avatar-hci.xfyousheng.com`)。 |
|||
Apple 对全局 `NSAllowsArbitraryLoads` 会要求提交理由说明。 |
|||
|
|||
**修法**:iOS 用 `NSExceptionDomains` 只放 `xfyousheng.com`;Android `base-config` 改 |
|||
`cleartextTrafficPermitted="false"` 再加 `domain-config`。顺带去掉 `certificates src="user"` |
|||
(当前配置默认接受用户证书 = 默认接受抓包代理)。 |
|||
|
|||
--- |
|||
|
|||
## 中 |
|||
|
|||
### 09. 付费页在卖一个已经关掉的功能 |
|||
|
|||
``` |
|||
goodsai_view.dart:324 'helpCallAndSMS'.tr ← AI 商品购买页卖点 |
|||
assistant_directive_service.dart:48 'SEND_message': '本版已移除短信与联系人能力' |
|||
``` |
|||
|
|||
用户付费后说「给爸爸发短信」,什么都不会发生。 |
|||
**修法**:摘掉这张 feature card,或去百炼后台把这两个 tool 也摘掉并恢复端侧实现。两边同时留着是最差组合。 |
|||
|
|||
### 10. 「按设备名找产品」有 5 套各写各的实现 |
|||
|
|||
``` |
|||
appconfig.dart:53 getproduct() ← 会抛异常那个 |
|||
home_controller.dart:89 _findProductByName() |
|||
bes_ota_upgrade_controller.dart:208 _findProduct() |
|||
ota_core_contropller.dart:429 findProduct() |
|||
bes_device_auth.dart:269 内联实现 |
|||
``` |
|||
|
|||
匹配规则互不相同,改一次规则要改 5 处。 |
|||
**修法**:收敛成 `AppConfig.findProduct(name)`,规则用 OTA 那版最完整的 |
|||
(名字互相包含 → 唯一 `devicetype==1` → null)。和第 05 条一起做。 |
|||
|
|||
### 11. eq_settings 是 1,911 行完全不可达的死代码 |
|||
|
|||
`app_routes.dart:22` 声明了 `/eq_settings` 常量,但 `app_pages.dart` **没有对应 GetPage**, |
|||
全项目**没有任何一处导航过去**。模块内部 UI 标签也全是硬编码中文(音效/均衡器/默认/流行/摇滚…)。 |
|||
|
|||
### 12. 4 个调试页面挂在正式路由上,无任何门禁 |
|||
|
|||
`bes_test`、`opus_test`、`speech_test`、`log_export` 都在 `app_pages.dart` 注册为正常 GetPage, |
|||
正式包可达。`bes_test` 尤其敏感——能对耳机下发任意私有协议命令,而 `0x5x/0x6x` 段会切换耳机工作模式。 |
|||
|
|||
### 13. 60 处空 catch 把失败原因全吞了 |
|||
|
|||
分布在 34 个文件,top:`volcano_tts_api_service.dart`(6)、`translation_controller.dart`(3)、 |
|||
`translation_detail_controller.dart`(3)、`agent_controller.dart`(3)、`ble_manager.dart`(3)。 |
|||
|
|||
**修法**:不必全改,先给**网络与蓝牙**相关的补一行 `Logger.w`——这两类线上最常出问题、最难复现。 |
|||
|
|||
### 14. 核心流程里还有一批中文硬编码 |
|||
|
|||
``` |
|||
call_recording_controller.dart:470,763 「已标记」「录音已存入语音记录助手」 |
|||
picture_translation_controller.dart:214,221 |
|||
agent_controller.dart:1773,2446,2728 导航失败 / 播放提示 |
|||
settings_controller.dart:278,279,301-306 清除配对设备确认框 |
|||
calendar_controller.dart:345,528+ ${month}月${day}日 + 中国节假日表 |
|||
``` |
|||
|
|||
### 15. 微信登录里残留着教程示例代码 |
|||
|
|||
`weixinLogin.dart:193` 的 `postData()` 往 `jsonplaceholder.typicode.com`(公开 API 教程站)发 POST。 |
|||
全项目无调用。同文件 `fetchData()` 也只剩注释里的调用。删掉即可,顺带解决 `:177/:208` 打印接口响应体的 print。 |
|||
|
|||
### 16. 网络图片零缓存 |
|||
|
|||
8 处 `Image.network`,未引 `cached_network_image`。产品图/头像每次重建、滚动都重新走网络。 |
|||
|
|||
--- |
|||
|
|||
## 清理项 |
|||
|
|||
### 17. 1,222 项 analyzer 告警 |
|||
|
|||
``` |
|||
358 deprecated_member_use ← 升 SDK 时变硬错误,值得先看 |
|||
198 prefer_const_constructors ← 直接影响重建开销 |
|||
191 avoid_print ← 即第 02/03 条 |
|||
53 unused_import |
|||
34 unused_element / 23 unused_field / 17 unused_local_variable |
|||
14 dead_null_aware_expression(goods_base_controller 占 10 处) |
|||
2 must_call_super |
|||
``` |
|||
|
|||
`dart fix --apply` 能自动清掉约 400 项。deprecated_member_use 建议升 Flutter 前单独排一次。 |
|||
|
|||
### 18. 两处 `onClose` 没调 `super.onClose()` |
|||
|
|||
`record_timer_controller.dart:88`、`translation_history_controller.dart:373`。 |
|||
GetX 的 `ListNotifierMixin.onClose` 标了 `@mustCallSuper`,不调则该 controller 的 worker 不回收。 |
|||
|
|||
### 19. 本地闹钟记录读回时不校验时间格式 |
|||
|
|||
`fromDirective` 走 `_normalizeTime`、格式不对返回 null,这条路径是稳的。 |
|||
但 `assistant_directive.dart:376` 的 `fromJson` 直接 `j['time'] ?? '00:00'` 不校验, |
|||
而 `:354/:355` 的 `hour`/`minute` 是裸 `int.parse(time.split(':')[…])`—— |
|||
本地存储里有一条格式不对的旧记录,代办页整页渲染就抛。 |
|||
|
|||
### 20. CLAUDE.md 与代码已经对不上的几处 |
|||
|
|||
| 位置 | 文档说 | 代码实际 | |
|||
|---|---|---| |
|||
| 微信 Universal Link | `https://ymaikj.com/app/` | `https://yimai.games/app/`。代码与 `Runner.entitlements` 的 `applinks:yimai.games` **一致**,不是 bug;但第三条腿(微信开放平台登记 + `yimai.games/.well-known/apple-app-site-association`)需确认已同步,否则微信登录与支付静默失败 | |
|||
| `version_update_service` | 失败会把游客入口锁死 10 分钟 | 已修:`:156` 成功后才写 `_lastCheckTime`,失败分支显式不写 | |
|||
| `bes_bluetooth_service.dart:296` | — | 注释写「`AA 20` 耳机状态」,而实现和下方长注释都明确是 `AA 09`。`AA 20` 恰是实测过、完全无回包的那个错误命令 | |
|||
| Google Play 权限 | — | 已核对:`SEND_SMS` 与 `MANAGE_EXTERNAL_STORAGE` 确实是 XML 注释状态,`GOOGLE_PLAY_RELEASE_CHECKLIST.md` 判断正确。仍生效的受限权限是 `QUERY_ALL_PACKAGES` 和已废弃的 `GET_TASKS`,checklist §3 已覆盖 | |
|||
|
|||
--- |
|||
|
|||
## 扫描过程中修正过的判断 |
|||
|
|||
- 一度把 `SEND_SMS` / `MANAGE_EXTERNAL_STORAGE` 记成生效权限,逐行核对后确认是 XML 注释,已从清单移除。 |
|||
- `appleLogin.dart` 的 31 处 print 确认套了 `kDebugMode`,未列入第 02 条。 |
|||
|
After Width: | Height: | Size: 164 KiB |
|
After Width: | Height: | Size: 25 KiB |
@ -0,0 +1,73 @@ |
|||
/// 阿里通义 **qwen3.5-livetranslate-flash-realtime**(新版,带实时声音复刻) |
|||
/// 的通话翻译语言表。 |
|||
/// |
|||
/// 与旧版 [getAlibabaTranslationLanguageSpecs] 的关系见 |
|||
/// LanguageManager.findBestMatchingProvider 的路由说明: |
|||
/// 3.5 优先 → 旧版兜底(只剩 yue/el)→ 微软 → 讯飞。 |
|||
/// |
|||
/// ⚠️ **这里只列 3.5 的「音频+文本输出」29 语**,不是它宣传的 60 语。 |
|||
/// 另外 31 语(yue、el、uk、ro、hu…)是**仅文本输出**——通话翻译要把 TTS |
|||
/// PCM 回灌进耳机,没有音频等于功能不成立,所以那 31 语不能走 3.5, |
|||
/// 必须落到下一档。这条是本表存在的全部意义,加语言前先确认它在音频档里。 |
|||
/// 语种档位来源:阿里云百炼「通过 WebSocket 实现实时音视频翻译」文档。 |
|||
/// |
|||
/// 音色:3.5 这一档开了 `enable_voice_clone`,握手时 `voice` 被原生强制写成 |
|||
/// `"default"`(阿里要求 frequency=once/always 时 voice 必须是 default), |
|||
/// 所以下面的 `ttsVoiceName` 实际不生效,只是为了让 map 形状统一、 |
|||
/// 避免 ttsCode 为空时退到 Azure 音色名(对阿里是非法值)。 |
|||
library; |
|||
|
|||
/// 3.5 支持「音频 + 文本」输出的 29 个语种(= 声音复刻可用的范围) |
|||
const Set<String> kAlibaba35AudioShortCodes = { |
|||
'zh', 'en', 'ar', 'de', 'fr', 'es', 'pt', 'id', 'it', 'ko', |
|||
'ru', 'th', 'vi', 'ja', 'tr', 'hi', 'ms', 'nl', 'ur', 'nb', |
|||
'sv', 'da', 'he', 'fi', 'pl', 'is', 'cs', 'fil', 'fa', |
|||
}; |
|||
|
|||
/// 语种展示名,只用于日志与界面回显 |
|||
const Map<String, List<String>> _names = { |
|||
'zh': ['中文', 'Chinese'], |
|||
'en': ['英语', 'English'], |
|||
'ar': ['阿拉伯语', 'Arabic'], |
|||
'de': ['德语', 'German'], |
|||
'fr': ['法语', 'French'], |
|||
'es': ['西班牙语', 'Spanish'], |
|||
'pt': ['葡萄牙语', 'Portuguese'], |
|||
'id': ['印尼语', 'Indonesian'], |
|||
'it': ['意大利语', 'Italian'], |
|||
'ko': ['韩语', 'Korean'], |
|||
'ru': ['俄语', 'Russian'], |
|||
'th': ['泰语', 'Thai'], |
|||
'vi': ['越南语', 'Vietnamese'], |
|||
'ja': ['日语', 'Japanese'], |
|||
'tr': ['土耳其语', 'Turkish'], |
|||
'hi': ['印地语', 'Hindi'], |
|||
'ms': ['马来语', 'Malay'], |
|||
'nl': ['荷兰语', 'Dutch'], |
|||
'ur': ['乌尔都语', 'Urdu'], |
|||
'nb': ['挪威语', 'Norwegian'], |
|||
'sv': ['瑞典语', 'Swedish'], |
|||
'da': ['丹麦语', 'Danish'], |
|||
'he': ['希伯来语', 'Hebrew'], |
|||
'fi': ['芬兰语', 'Finnish'], |
|||
'pl': ['波兰语', 'Polish'], |
|||
'is': ['冰岛语', 'Icelandic'], |
|||
'cs': ['捷克语', 'Czech'], |
|||
'fil': ['菲律宾语', 'Filipino'], |
|||
'fa': ['波斯语', 'Persian'], |
|||
}; |
|||
|
|||
/// 返回 3.5 的翻译语言配置表。`translationCode` 就是短码本身。 |
|||
List<Map<String, String>> getAlibaba35TranslationLanguageSpecs() { |
|||
return kAlibaba35AudioShortCodes.map((code) { |
|||
final n = _names[code] ?? [code, code]; |
|||
return <String, String>{ |
|||
'shortCode': code, |
|||
'chineseName': n[0], |
|||
'englishName': n[1], |
|||
'translationCode': code, |
|||
// 见文件头注释:开了声音复刻后这个值不生效,占位而已 |
|||
'ttsVoiceName': 'default', |
|||
}; |
|||
}).toList(); |
|||
} |
|||
@ -1,9 +1,11 @@ |
|||
import 'package:get/get.dart'; |
|||
import '../controllers/ota_upgrade_controller.dart'; |
|||
import '../controllers/bes_ota_upgrade_controller.dart'; |
|||
|
|||
class OtaUpgradeBinding extends Bindings { |
|||
@override |
|||
void dependencies() { |
|||
Get.lazyPut<OtaUpgradeController>(() => OtaUpgradeController()); |
|||
// 本项目只有恒玄耳机,固件升级走 BES 链路。杰理那版 OtaUpgradeController |
|||
// 依赖 BleManager/Jielihome,已不再由路由装配。 |
|||
Get.lazyPut<BesOtaUpgradeController>(() => BesOtaUpgradeController()); |
|||
} |
|||
} |
|||
|
|||
@ -0,0 +1,153 @@ |
|||
import 'dart:io'; |
|||
|
|||
import 'package:dio/dio.dart'; |
|||
import 'package:dio/io.dart'; |
|||
import 'package:path/path.dart' as p; |
|||
import 'package:get/get.dart'; |
|||
import 'package:path_provider/path_provider.dart'; |
|||
|
|||
import '../../../core/utils/logger.dart'; |
|||
|
|||
/// 恒玄(BES) OTA 固件下载服务 |
|||
/// |
|||
/// 用 Dio 把固件包下载到应用支持目录,提供进度/状态回调,并做"同名复用 + 清旧文件"。 |
|||
class BesOtaDownloadService { |
|||
static const _tag = 'BesOtaDownloadService'; |
|||
static const _dirName = 'ota_upgrades'; |
|||
|
|||
/// 接收空闲超时:超过该时长未收到任何字节即判定断流并抛错(非总时长, |
|||
/// 持续下载不受影响)。兜底"服务器握手后不发数据"导致永久挂起。 |
|||
static const Duration _receiveTimeout = Duration(seconds: 60); |
|||
|
|||
/// 允许跳过 TLS 证书校验的主机:运行时设为"当前固件下载地址的主机"。 |
|||
/// 固件地址由后端产品配置下发,换 CDN(如腾讯云→阿里云)只需后端改地址、 |
|||
/// app 无需改代码发版;且仅对后端指定的这一个下载源放行,不放宽其它请求。 |
|||
/// 为空时(默认)所有 https 都走系统正常证书校验。 |
|||
String? _allowedInsecureHost; |
|||
|
|||
late final Dio _dio; |
|||
|
|||
BesOtaDownloadService() { |
|||
_dio = Dio( |
|||
BaseOptions( |
|||
connectTimeout: const Duration(seconds: 30), |
|||
receiveTimeout: _receiveTimeout, |
|||
sendTimeout: const Duration(seconds: 30), |
|||
headers: {'Accept-Encoding': 'gzip'}, |
|||
), |
|||
); |
|||
|
|||
(_dio.httpClientAdapter as IOHttpClientAdapter).createHttpClient = () { |
|||
final client = HttpClient(); |
|||
client.badCertificateCallback = (cert, host, port) { |
|||
final allowed = |
|||
_allowedInsecureHost != null && host == _allowedInsecureHost; |
|||
if (allowed) { |
|||
Logger.w(_tag, 'SSL 证书校验跳过(固件下载主机): $host:$port'); |
|||
} |
|||
return allowed; // 仅当前固件下载主机放行,其它一律正常校验 |
|||
}; |
|||
return client; |
|||
}; |
|||
} |
|||
|
|||
/// 下载固件包。 |
|||
/// |
|||
/// - [downloadUrl] 固件下载地址。 |
|||
/// - [onProgress] 下载进度回调(0.0~1.0)。 |
|||
/// - [onStatusUpdate] 文本状态回调。 |
|||
/// |
|||
/// 返回下载文件的本地绝对路径;下载失败抛异常。 |
|||
Future<String> downloadFirmware({ |
|||
required String downloadUrl, |
|||
required void Function(double) onProgress, |
|||
required void Function(String) onStatusUpdate, |
|||
CancelToken? cancelToken, |
|||
}) async { |
|||
onStatusUpdate('otaDownloadPreparing'.tr); |
|||
|
|||
// 仅对本次固件下载地址的主机放行证书校验(地址来自后端,换 CDN 自动适配)。 |
|||
_allowedInsecureHost = Uri.tryParse(downloadUrl)?.host; |
|||
|
|||
final fileName = _fileNameFromUrl(downloadUrl); |
|||
final supportDir = await getApplicationSupportDirectory(); |
|||
final targetDir = Directory(p.join(supportDir.path, _dirName)); |
|||
if (!await targetDir.exists()) { |
|||
await targetDir.create(recursive: true); |
|||
} |
|||
|
|||
final savePath = p.join(targetDir.path, fileName); |
|||
final tmpPath = '$savePath.part'; |
|||
|
|||
// 正式文件存在 = 上次已完整下载并 rename(原子化保证),可安全复用。 |
|||
if (await File(savePath).exists()) { |
|||
onStatusUpdate('otaFirmwareCached'.tr); |
|||
return savePath; |
|||
} |
|||
|
|||
// 清理旧固件与可能残留的 .part(上次中断留下的半截文件),避免占用空间/误用。 |
|||
await _cleanOldFiles(targetDir); |
|||
|
|||
onStatusUpdate('otaDownloadStarted'.tr); |
|||
|
|||
// 先下到临时 .part 文件,完整下载成功后再 rename 为正式名—— |
|||
// 避免下载中断/被杀留下的半截文件被下次"同名复用"当成完整固件刷机。 |
|||
final tmpFile = File(tmpPath); |
|||
if (await tmpFile.exists()) await tmpFile.delete(); |
|||
|
|||
await _dio.download( |
|||
downloadUrl, |
|||
tmpPath, |
|||
cancelToken: cancelToken, |
|||
options: Options( |
|||
receiveTimeout: _receiveTimeout, |
|||
followRedirects: true, |
|||
maxRedirects: 5, |
|||
), |
|||
onReceiveProgress: (received, total) { |
|||
if (total > 0) { |
|||
final progress = received / total; |
|||
onProgress(progress); |
|||
onStatusUpdate( |
|||
'otaDownloadingPercent'.trParams({ |
|||
'percent': (progress * 100).toStringAsFixed(1), |
|||
}), |
|||
); |
|||
} |
|||
}, |
|||
); |
|||
|
|||
if (!await tmpFile.exists()) { |
|||
throw FileSystemException('otaDownloadFailedCheckNetwork'.tr); |
|||
} |
|||
// 原子 rename:此后 savePath 存在即代表一个完整固件包。 |
|||
await tmpFile.rename(savePath); |
|||
|
|||
onStatusUpdate('otaDownloadDone'.tr); |
|||
return savePath; |
|||
} |
|||
|
|||
/// 从 URL 末段取文件名;无扩展名时补 `.bin`。 |
|||
String _fileNameFromUrl(String url) { |
|||
var name = url.substring(url.lastIndexOf('/') + 1); |
|||
// 去掉可能的查询参数。 |
|||
final q = name.indexOf('?'); |
|||
if (q != -1) name = name.substring(0, q); |
|||
if (name.isEmpty) name = 'firmware.bin'; |
|||
if (!name.contains('.')) name = '$name.bin'; |
|||
return name; |
|||
} |
|||
|
|||
Future<void> _cleanOldFiles(Directory dir) async { |
|||
try { |
|||
final entries = await dir.list().toList(); |
|||
for (final e in entries) { |
|||
if (await FileSystemEntity.isFile(e.path)) { |
|||
await e.delete(); |
|||
} |
|||
} |
|||
} catch (e) { |
|||
Logger.w(_tag, '清理旧固件失败: $e'); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,495 @@ |
|||
import 'dart:async'; |
|||
|
|||
import 'package:bes_ota_manager/bes_ota_manager.dart'; |
|||
import 'package:dio/dio.dart'; |
|||
import 'package:flutter/material.dart'; |
|||
import 'package:get/get.dart'; |
|||
import 'package:wakelock_plus/wakelock_plus.dart'; |
|||
|
|||
import '../../../core/utils/logger.dart'; |
|||
import '../../../data/models/appconfig.dart'; |
|||
import '../../../data/models/appconfig_model.dart'; |
|||
import '../../../data/services/bes_bluetooth_service.dart'; |
|||
import 'bes_ota_download_service.dart'; |
|||
|
|||
/// OTA 升级阶段(驱动页面 UI)。 |
|||
class OtaPhase { |
|||
static const idle = 0; // 待升级 |
|||
static const downloading = 1; // 下载固件中 |
|||
static const upgrading = 2; // 蓝牙推送中 |
|||
static const success = 3; // 升级成功 |
|||
static const failed = 4; // 升级失败 |
|||
} |
|||
|
|||
/// 恒玄(BES)耳机固件 OTA 升级控制器。 |
|||
/// |
|||
/// 与杰理那条链路([OtaCoreService] / `ota_upgrade_controller.dart`)完全独立: |
|||
/// 不碰 BleManager / Jielihome,只依赖 [BesBluetoothService] 与 `bes_ota_manager` 插件。 |
|||
/// 本项目只有恒玄耳机,路由 `Routes.OTA_UPGRADE` 已指向这里。 |
|||
/// |
|||
/// 流程:读产品配置 → 断开 app 层 SPP → 下载固件 → 插件自管连接推送 → 设备重启。 |
|||
class BesOtaUpgradeController extends GetxController { |
|||
static const _tag = 'BesOtaUpgradeController'; |
|||
|
|||
/// 看门狗超时:升级阶段超过该时长无任何进度或状态推进,即判定卡死失败。 |
|||
/// 兜底原生侧「既不推进也不上报 onError」导致 UI 永久停在升级中的情况。 |
|||
static const Duration _watchdogTimeout = Duration(seconds: 90); |
|||
|
|||
/// 固件下载地址:来自后端 `user_getappconfig` 下发的产品配置,按设备名匹配。 |
|||
String _downloadUrl = ''; |
|||
|
|||
/// 页面已销毁标志:onClose 置位,用于让正在进行的下载/升级异步链提前退出, |
|||
/// 避免在已销毁的控制器上发起 startOta(僵尸会话)。 |
|||
bool _disposed = false; |
|||
|
|||
/// 当前下载的取消令牌:cancel()/onClose 时取消,立即中止下载、不再续跑。 |
|||
CancelToken? _downloadCancelToken; |
|||
|
|||
/// 升级看门狗定时器:每次收到进度/状态事件即重置;超时未重置则判失败。 |
|||
Timer? _watchdog; |
|||
|
|||
final BesBluetoothService _bes = BesBluetoothService.to; |
|||
final BesOtaManager _besOta = BesOtaManager(); |
|||
final BesOtaDownloadService _downloadService = BesOtaDownloadService(); |
|||
|
|||
// ---- 响应式状态 ---- |
|||
final RxString currentVersion = ''.obs; |
|||
final RxString latestVersion = ''.obs; |
|||
final RxString updateDescription = ''.obs; |
|||
final RxBool isUpdateAvailable = false.obs; |
|||
final RxBool isConnected = true.obs; |
|||
|
|||
/// 设备名快照:开始升级时会主动断开 app 层连接,[BesBluetoothService] 的字段被 |
|||
/// 清空,页面展示用控制器自己的快照,不跟着变空。 |
|||
final RxString deviceName = ''.obs; |
|||
|
|||
/// 设备地址快照,服务字段被清空后(如失败重试时)回退使用。 |
|||
String _address = ''; |
|||
|
|||
/// 是否为本次 OTA 主动断开过 app 层连接。主动断开不会自动回连, |
|||
/// 退出页面时据此决定是否恢复连接(没点过「开始升级」则无需处理)。 |
|||
bool _appConnectionReleased = false; |
|||
|
|||
final RxInt phase = OtaPhase.idle.obs; |
|||
final RxDouble downloadProgress = 0.0.obs; // 0~1 |
|||
final RxDouble otaProgress = 0.0.obs; // 0~1 |
|||
final RxString detailedStatus = ''.obs; |
|||
|
|||
StreamSubscription<BesOtaEvent>? _otaSub; |
|||
Worker? _versionWorker; |
|||
Worker? _statusWorker; |
|||
Worker? _deviceWorker; |
|||
Worker? _nameWorker; |
|||
|
|||
bool get isBusy => |
|||
phase.value == OtaPhase.downloading || phase.value == OtaPhase.upgrading; |
|||
|
|||
@override |
|||
void onInit() { |
|||
super.onInit(); |
|||
|
|||
isConnected.value = _bes.isConnected; |
|||
currentVersion.value = _bes.deviceVersion.value; |
|||
deviceName.value = _bes.connectedDeviceName.value; |
|||
_address = _bes.connectedDeviceId.value; |
|||
_loadProductConfig(); |
|||
|
|||
// 设备版本回包(`AA 06` → `BB 86`)后同步刷新;开始升级时主动断开会把 |
|||
// deviceVersion 清空,空值忽略,保留页面上已知的版本显示。 |
|||
_versionWorker = ever(_bes.deviceVersion, (String v) { |
|||
if (v.isEmpty) return; |
|||
currentVersion.value = v; |
|||
_refreshUpdateAvailable(); |
|||
}); |
|||
|
|||
// 跟踪 app 层连接状态(仅用于展示与按钮可用性)。 |
|||
// 注意:OTA 连接由插件自管、掉线会经 onError 上报;开始升级时 app 层连接 |
|||
// 是我们主动断开的,不能拿它的断开去中止 OTA。 |
|||
_statusWorker = ever(_bes.deviceStatus, (int s) { |
|||
isConnected.value = s == 2; |
|||
}); |
|||
|
|||
// (重)连接后重新捕获设备地址,失败重试时要用。 |
|||
_deviceWorker = ever(_bes.connectedDeviceId, (String id) { |
|||
if (id.isEmpty) return; |
|||
_address = id; |
|||
}); |
|||
|
|||
// 设备名就绪/变化时刷新快照并重载产品配置。注意:重连时服务里 id 先于 |
|||
// name 赋值且 worker 同步触发,挂在 id 上拿不到新名字;页面初始化恰逢 |
|||
// 重连中名字为空时,也靠这里在名字就绪后补加载,否则「最新版本」会一直为空。 |
|||
_nameWorker = ever(_bes.connectedDeviceName, (String n) { |
|||
if (n.isEmpty) return; |
|||
deviceName.value = n; |
|||
_loadProductConfig(); |
|||
}); |
|||
|
|||
_otaSub = _besOta.events.listen( |
|||
_handleOtaEvent, |
|||
// 事件通道本身异常(原生 sink 出错/通道断开)也要落到失败终态,避免无声卡死。 |
|||
onError: (Object err) { |
|||
if (phase.value == OtaPhase.success) return; |
|||
_onUpgradeError(null, err.toString()); |
|||
}, |
|||
); |
|||
} |
|||
|
|||
@override |
|||
void onClose() { |
|||
_disposed = true; |
|||
_downloadCancelToken?.cancel('page closed'); // 中止可能仍在跑的下载 |
|||
_cancelWatchdog(); |
|||
_setKeepScreenOn(false); // 退出页面兜底关闭常亮 |
|||
_otaSub?.cancel(); |
|||
_versionWorker?.dispose(); |
|||
_statusWorker?.dispose(); |
|||
_deviceWorker?.dispose(); |
|||
_nameWorker?.dispose(); |
|||
|
|||
// 主动断开不会自动回连:若本次为 OTA 断开过 app 层连接,退出页面时恢复。 |
|||
// 升级成功时设备正在重启,延时几秒再连。 |
|||
final needReconnect = _appConnectionReleased && _address.isNotEmpty; |
|||
final addr = _address; |
|||
final name = deviceName.value; |
|||
// SDK 自己 OTA 成功后的内部重连等了 15s(MSG_OTA_OVER_RECONNECT),设备重启到 |
|||
// 蓝牙就绪需要数秒;即使这次连不上,耳机开机也会主动回连手机兜底。 |
|||
final delay = phase.value == OtaPhase.success |
|||
? const Duration(seconds: 5) // 设备正在重启 |
|||
: const Duration(seconds: 2); // 等耳机拆完 OTA 链路再回连(与开始升级方向一致) |
|||
// 先停掉 OTA 会话释放插件占用的 SPP 连接,再回连 app 层,避免两条连接打架。 |
|||
_besOta.stopOta().whenComplete(() { |
|||
_besOta.dispose(); |
|||
if (!needReconnect) return; |
|||
Future.delayed(delay, () { |
|||
// 期间可能已(自动/手动)连上,避免重复发起。 |
|||
if (_bes.deviceStatus.value != 2) { |
|||
_bes.connectToDevice(addr, name); |
|||
} |
|||
}); |
|||
}); |
|||
super.onClose(); |
|||
} |
|||
|
|||
/// 从后端产品配置取最新固件版本与下载地址(按设备名匹配产品)。 |
|||
void _loadProductConfig() { |
|||
final product = _findProduct(deviceName.value); |
|||
if (product == null) { |
|||
Logger.w(_tag, '未找到设备对应的产品配置: ${deviceName.value}'); |
|||
latestVersion.value = ''; |
|||
updateDescription.value = ''; |
|||
_downloadUrl = ''; |
|||
} else { |
|||
latestVersion.value = product.version?.split('+').first.trim() ?? ''; |
|||
updateDescription.value = product.updatedescription ?? ''; |
|||
_downloadUrl = product.updatepackageaddress ?? ''; |
|||
// 「拉到的版本不对」这类问题以前只能靠猜:失败有日志、成功没有, |
|||
// 分不清是没匹配上产品、下发的值就是旧的、还是设备当前版本读错了。 |
|||
Logger.i( |
|||
_tag, |
|||
'产品配置: name=${product.devicename} pid=${product.id} ' |
|||
'服务端最新=${product.version} → 解析后=${latestVersion.value} ' |
|||
'设备当前=${currentVersion.value} ' |
|||
'产品表共${AppConfig.config.products.length}个'); |
|||
} |
|||
_refreshUpdateAvailable(); |
|||
} |
|||
|
|||
/// 后台是否允许「版本不一致即可升级」。取不到产品配置时按 false(严格)。 |
|||
bool get _allowDowngrade => |
|||
_findProduct(deviceName.value)?.updateonmismatch ?? false; |
|||
|
|||
/// 按设备名找产品。 |
|||
/// |
|||
/// ⚠️ 不要用 [AppConfig.getproduct]——它内部 `firstWhere` 没有 orElse, |
|||
/// 查不到直接抛 StateError,而调用点写的都是 `if (product == null)`, |
|||
/// 那个 null 判断永远等不到,异常会穿出去、在固件页表现成「加载失败」。 |
|||
/// |
|||
/// 顺序:设备名互相包含 → 唯一的经典蓝牙耳机产品 → null。 |
|||
DBProduct? _findProduct(String name) { |
|||
if (!AppConfig.isInitialized()) return null; |
|||
final products = AppConfig.config.products; |
|||
if (products.isEmpty) return null; |
|||
final n = name.trim().toUpperCase(); |
|||
if (n.isNotEmpty && n != 'UNKNOWNEQUIPMENT') { |
|||
for (final p in products) { |
|||
final pn = p.devicename.trim().toUpperCase(); |
|||
if (pn.isEmpty) continue; |
|||
if (pn.contains(n) || n.contains(pn)) return p; |
|||
} |
|||
} |
|||
// 本项目只有恒玄(经典蓝牙耳机)一种设备,名字对不上时退化到它是安全的 |
|||
final headsets = products.where((p) => p.devicetype == 1).toList(); |
|||
if (headsets.length == 1) return headsets.first; |
|||
return null; |
|||
} |
|||
|
|||
void _refreshUpdateAvailable() { |
|||
final cur = currentVersion.value.trim(); |
|||
final latest = latestVersion.value.trim(); |
|||
// 没有产品配置(无最新版本)不可升级 |
|||
if (latest.isEmpty) { |
|||
isUpdateAvailable.value = false; |
|||
return; |
|||
} |
|||
// 读不到当前版本时允许升级(未知 → 允许) |
|||
if (cur.isEmpty) { |
|||
isUpdateAvailable.value = true; |
|||
return; |
|||
} |
|||
// 判定口径由**后台产品配置**决定,不看包类型: |
|||
// updateonmismatch=true → 版本不一致就允许升(开发/测试,可刷回旧固件) |
|||
// updateonmismatch=false → 只有服务端版本更高才允许(正式,默认) |
|||
// ⚠️ 之前用 kDebugMode 分界是错的:测试同事拿到的往往是 release 包, |
|||
// 在开发环境照样点不动升级按钮。开关放后台才能按产品、按环境自由控制。 |
|||
// ⚠️ 字段可空(老服务端不下发),null 一律按 false 走严格判定—— |
|||
// 判宽的后果是线上用户被降级,判严只是开发时少一个便利。 |
|||
isUpdateAvailable.value = _allowDowngrade |
|||
? latest != cur |
|||
: _isNewer(latest, cur); |
|||
} |
|||
|
|||
/// 入口:开始升级(下载 → 交接连接 → OTA 推送)。 |
|||
Future<void> startUpdate() async { |
|||
if (isBusy) return; |
|||
if (!_bes.isConnected) { |
|||
_snack('otaError'.tr, 'otaNeedConnect'.tr); |
|||
return; |
|||
} |
|||
|
|||
// 关键:在断开现有连接前先捕获设备标识(disconnect 会清空); |
|||
// 服务字段已为空时(失败后重试)回退到控制器快照。 |
|||
final serviceId = _bes.connectedDeviceId.value; |
|||
final address = serviceId.isNotEmpty ? serviceId : _address; |
|||
final serviceName = _bes.connectedDeviceName.value; |
|||
final name = serviceName.isNotEmpty ? serviceName : deviceName.value; |
|||
if (address.isEmpty) { |
|||
_snack('otaError'.tr, 'otaNeedConnect'.tr); |
|||
return; |
|||
} |
|||
_address = address; |
|||
|
|||
// 产品配置缺失(AppConfig 晚于页面初始化等)时兜底重取一次。 |
|||
if (_downloadUrl.isEmpty) { |
|||
_loadProductConfig(); |
|||
if (_downloadUrl.isEmpty) { |
|||
_snack('otaError'.tr, 'otaVersionInfoFailed'.tr); |
|||
return; |
|||
} |
|||
} |
|||
|
|||
final cancelToken = CancelToken(); |
|||
_downloadCancelToken = cancelToken; |
|||
|
|||
try { |
|||
// 升级期间保持屏幕常亮(成功/失败/退出页面时关闭)。 |
|||
_setKeepScreenOn(true); |
|||
phase.value = OtaPhase.downloading; |
|||
downloadProgress.value = 0; |
|||
otaProgress.value = 0; |
|||
detailedStatus.value = 'otaPreparing'.tr; |
|||
|
|||
// 1) 释放 app 现有 SPP 连接,让插件自管 OTA 连接。 |
|||
await _bes.disconnectDevice(); |
|||
_appConnectionReleased = true; |
|||
// disconnect 返回只代表本端 socket 已关,耳机侧链路拆除还要在空中传播; |
|||
// 固件已缓存时下载不耗时、衔接近乎瞬时,立刻连 OTA 可能被拒 |
|||
//(多数固件同时只接受一条 SPP 链路),留 2s 缓冲等耳机拆完旧链路。 |
|||
// 曾出现过快速衔接导致 OTA 连接失败的案例,SDK 内部还有 3 次快速重连兜底。 |
|||
await Future.delayed(const Duration(seconds: 2)); |
|||
if (_disposed) return; // 期间已退出页面,不再继续 |
|||
// 2) 初始化原生 OTA 环境。 |
|||
await _besOta.init(); |
|||
|
|||
// 3) 下载固件。 |
|||
final filePath = await _downloadService.downloadFirmware( |
|||
downloadUrl: _downloadUrl, |
|||
cancelToken: cancelToken, |
|||
onProgress: (p) => downloadProgress.value = p, |
|||
onStatusUpdate: (s) => detailedStatus.value = s, |
|||
); |
|||
|
|||
// 已退出/已取消则不再发起原生 OTA,避免在已销毁控制器上建立僵尸会话。 |
|||
if (_disposed || cancelToken.isCancelled) return; |
|||
|
|||
// 4) 启动 SPP OTA 2.0。 |
|||
phase.value = OtaPhase.upgrading; |
|||
detailedStatus.value = 'otaConnectingDevice'.tr; |
|||
// 看门狗从这里起覆盖「连接+握手+推送」全程:原生握手会持续发 onStatus、 |
|||
// 传输会持续发 onProgress,任一推进即重置;长时间静默(如原生卡住且不报错)兜底失败。 |
|||
// 下载阶段的卡死另由 Dio 超时与 CancelToken 处理,不纳入看门狗。 |
|||
_armWatchdog(); |
|||
await _besOta.startOta( |
|||
address: address, |
|||
name: name, |
|||
filePath: filePath, |
|||
protocol: 'SPP', |
|||
userFlag: 1, |
|||
upgradeType: 1, |
|||
user: 1, |
|||
); |
|||
} catch (e, st) { |
|||
// 用户取消下载 / 页面已退出:静默收尾(控制器可能已销毁)。 |
|||
if (_disposed || (e is DioException && CancelToken.isCancel(e))) { |
|||
return; |
|||
} |
|||
Logger.e(_tag, '升级流程异常', e, st); |
|||
_onUpgradeError(null, e.toString()); // 统一走失败终态(含去重/看门狗关闭) |
|||
} |
|||
} |
|||
|
|||
/// 取消升级。 |
|||
void cancel() { |
|||
_downloadCancelToken?.cancel('user cancelled'); // 中止下载,阻断后续 startOta |
|||
_cancelWatchdog(); |
|||
_besOta.stopOta(); |
|||
_setKeepScreenOn(false); |
|||
phase.value = OtaPhase.idle; |
|||
detailedStatus.value = ''; |
|||
downloadProgress.value = 0; |
|||
otaProgress.value = 0; |
|||
} |
|||
|
|||
// ---- 看门狗:覆盖升级(连接/握手/推送)阶段,超时未推进则判失败 ---- |
|||
void _armWatchdog() { |
|||
if (_disposed) return; |
|||
_watchdog?.cancel(); |
|||
_watchdog = Timer(_watchdogTimeout, _onWatchdogTimeout); |
|||
} |
|||
|
|||
void _cancelWatchdog() { |
|||
_watchdog?.cancel(); |
|||
_watchdog = null; |
|||
} |
|||
|
|||
void _onWatchdogTimeout() { |
|||
if (_disposed) return; |
|||
if (phase.value == OtaPhase.upgrading) { |
|||
Logger.w(_tag, 'OTA 看门狗超时:${_watchdogTimeout.inSeconds}s 无进度推进'); |
|||
_onUpgradeError(null, 'otaTimeout'.tr); |
|||
} |
|||
} |
|||
|
|||
/// 屏幕常亮开关。失败不致命,吞掉异常。 |
|||
void _setKeepScreenOn(bool on) { |
|||
try { |
|||
on ? WakelockPlus.enable() : WakelockPlus.disable(); |
|||
} catch (e) { |
|||
Logger.w(_tag, '设置屏幕常亮失败: $e'); |
|||
} |
|||
} |
|||
|
|||
void _handleOtaEvent(BesOtaEvent e) { |
|||
// 进入终态(成功/失败)即锁定:stopOta 清理、设备重启断链、看门狗超时后迟到的 |
|||
// 进度/Canceled/Error 一律忽略,避免成功被覆盖成失败、或失败被迟到进度翻回升级中。 |
|||
// 新会话由 startUpdate 重置 phase。 |
|||
if (phase.value == OtaPhase.success || phase.value == OtaPhase.failed) { |
|||
return; |
|||
} |
|||
// 任何进度/状态推进都重置看门狗(成功/失败在各自处理里关闭看门狗)。 |
|||
if (e.event == 'onProgress' || e.event == 'onStatus') { |
|||
_armWatchdog(); |
|||
} |
|||
switch (e.event) { |
|||
case 'onProgress': |
|||
phase.value = OtaPhase.upgrading; |
|||
_updateProgress(e.progress ?? 0); |
|||
break; |
|||
case 'onStatus': |
|||
// SDK 的 OTA_SEND_DATA_PROGRESS 状态消息带原始浮点百分比(如 99.94765…), |
|||
// 当作进度更新并入单一写入口;其余状态文本只记日志不上屏—— |
|||
// 曾直接展示,但与进度文案高频交替会闪烁出看不清的内容。 |
|||
final numeric = double.tryParse(e.message ?? ''); |
|||
if (numeric != null && numeric > 0) { |
|||
phase.value = OtaPhase.upgrading; |
|||
_updateProgress((numeric / 100).clamp(0.0, 1.0)); |
|||
} |
|||
break; |
|||
case 'onSuccess': |
|||
_onUpgradeSuccess(); |
|||
break; |
|||
case 'onError': |
|||
_onUpgradeError(e.code, e.message); |
|||
break; |
|||
} |
|||
} |
|||
|
|||
/// 进度的单一写入口:onProgress(Android 为整数百分比)与数字型 onStatus |
|||
/// 消息(浮点百分比)都汇到这里,只在单调递增时刷新,避免两个来源 |
|||
/// 高频交替导致数字来回跳动。 |
|||
void _updateProgress(double p) { |
|||
if (p <= otaProgress.value) return; |
|||
otaProgress.value = p; |
|||
detailedStatus.value = 'otaUpgrading'.trParams({ |
|||
'percent': (p * 100).toStringAsFixed(2), |
|||
}); |
|||
} |
|||
|
|||
void _onUpgradeSuccess() { |
|||
_cancelWatchdog(); |
|||
_setKeepScreenOn(false); |
|||
phase.value = OtaPhase.success; |
|||
otaProgress.value = 1.0; |
|||
detailedStatus.value = 'otaSuccessReboot'.tr; |
|||
currentVersion.value = latestVersion.value; |
|||
isUpdateAvailable.value = false; |
|||
_besOta.stopOta(); |
|||
} |
|||
|
|||
void _onUpgradeError(int? code, String? message) { |
|||
if (phase.value == OtaPhase.failed) return; // 已是失败终态,避免重复弹窗 |
|||
_cancelWatchdog(); |
|||
_setKeepScreenOn(false); |
|||
phase.value = OtaPhase.failed; |
|||
// 进入失败终态即释放原生 OTA 会话与 SPP 链路(与 _onUpgradeSuccess/cancel 对齐)。 |
|||
// 否则看门狗超时/连接失败后 native 会话仍在跑,占用唯一 SPP,且因终态已锁, |
|||
// 迟到的 onSuccess 会被吞——会卡死在「失败」直到用户退页面才由 onClose 清理。 |
|||
_besOta.stopOta(); |
|||
detailedStatus.value = 'otaFailed'.tr; |
|||
_snack( |
|||
'otaFailed'.tr, |
|||
message?.isNotEmpty == true |
|||
? message! |
|||
: 'otaFailedWithCode'.trParams({'code': '${code ?? -1}'}), |
|||
); |
|||
} |
|||
|
|||
void _snack(String title, String msg) { |
|||
Get.snackbar( |
|||
title, |
|||
msg, |
|||
snackPosition: SnackPosition.TOP, |
|||
backgroundColor: Colors.red[400], |
|||
colorText: Colors.white, |
|||
margin: const EdgeInsets.all(12), |
|||
duration: const Duration(seconds: 3), |
|||
); |
|||
} |
|||
|
|||
/// [a] 是否比 [b] 新。 |
|||
/// |
|||
/// 逐段取前导数字比较,与服务端 `comm.CompareVersion` 同口径—— |
|||
/// `1.0.0-beta` / `1.2.3+4` 这类版本串不会抛异常(用 int.parse 会抛, |
|||
/// 被外层 catch 吞掉后表现为「明明有新版本却说已是最新」)。 |
|||
bool _isNewer(String a, String b) { |
|||
final pa = _splitVersion(a); |
|||
final pb = _splitVersion(b); |
|||
if (pa.isEmpty || pb.isEmpty) return a.trim() != b.trim(); |
|||
final len = pa.length > pb.length ? pa.length : pb.length; |
|||
for (var i = 0; i < len; i++) { |
|||
final x = i < pa.length ? pa[i] : 0; |
|||
final y = i < pb.length ? pb[i] : 0; |
|||
if (x != y) return x > y; |
|||
} |
|||
return false; |
|||
} |
|||
|
|||
/// 把版本串按 `.` 切开,每段只取前导数字(`3+4` → 3,`0-beta` → 0)。 |
|||
List<int> _splitVersion(String v) { |
|||
final s = v.trim(); |
|||
if (s.isEmpty) return const []; |
|||
return s.split('.').map((seg) { |
|||
final m = RegExp(r'^\d+').firstMatch(seg.trim()); |
|||
return m == null ? 0 : int.parse(m.group(0)!); |
|||
}).toList(); |
|||
} |
|||
} |
|||
@ -0,0 +1,443 @@ |
|||
import 'package:flutter/material.dart'; |
|||
import 'package:flutter_screenutil/flutter_screenutil.dart'; |
|||
import 'package:get/get.dart'; |
|||
|
|||
import '../../../data/models/device_category.dart'; |
|||
import '../controllers/bes_ota_upgrade_controller.dart'; |
|||
|
|||
class BesOtaUpgradeView extends GetView<BesOtaUpgradeController> { |
|||
const BesOtaUpgradeView({super.key}); |
|||
|
|||
static const _accent = Color(0xFF009688); |
|||
static const _accentBg = Color(0xFFE0F2F1); |
|||
|
|||
@override |
|||
Widget build(BuildContext context) { |
|||
return PopScope( |
|||
// 升级中禁止直接返回,先二次确认。 |
|||
canPop: false, |
|||
onPopInvokedWithResult: (didPop, _) { |
|||
if (didPop) return; |
|||
_handleBack(); |
|||
}, |
|||
child: Scaffold( |
|||
backgroundColor: Colors.grey[50], |
|||
body: SafeArea( |
|||
child: Column( |
|||
children: [ |
|||
_buildTopBar(), |
|||
Expanded( |
|||
child: Obx(() { |
|||
// 仅"待升级"且未连接时显示"请先连接设备"占位页。 |
|||
// 升级期间会主动断开 app 层连接(isBleConnected=false),成功/失败等 |
|||
// 终态也要展示对应结果卡片,故只在 idle 态才让占位页接管。 |
|||
if (!controller.isConnected.value && |
|||
controller.phase.value == OtaPhase.idle) { |
|||
return _buildNotConnected(); |
|||
} |
|||
return SingleChildScrollView( |
|||
padding: EdgeInsets.symmetric( |
|||
horizontal: 16.w, |
|||
vertical: 12.h, |
|||
), |
|||
child: Column( |
|||
children: [ |
|||
_buildDeviceCard(), |
|||
16.verticalSpace, |
|||
_buildVersionCard(), |
|||
16.verticalSpace, |
|||
_buildProgressSection(), |
|||
], |
|||
), |
|||
); |
|||
}), |
|||
), |
|||
_buildBottomBar(), |
|||
], |
|||
), |
|||
), |
|||
), |
|||
); |
|||
} |
|||
|
|||
Widget _buildTopBar() { |
|||
return Padding( |
|||
padding: EdgeInsets.only(right: 16.w), |
|||
child: Row( |
|||
children: [ |
|||
// 使用 IconButton 提供 48dp 最小点击热区与点击反馈 |
|||
IconButton( |
|||
onPressed: _handleBack, |
|||
icon: Icon( |
|||
Icons.arrow_back_ios, |
|||
size: 20.w, |
|||
color: Colors.grey[800], |
|||
), |
|||
), |
|||
Expanded( |
|||
child: Center( |
|||
child: Text( |
|||
'firmwareUpgrade'.tr, |
|||
style: TextStyle( |
|||
fontSize: 18.sp, |
|||
fontWeight: FontWeight.bold, |
|||
color: Colors.grey[800], |
|||
), |
|||
), |
|||
), |
|||
), |
|||
SizedBox(width: 20.w), |
|||
], |
|||
), |
|||
); |
|||
} |
|||
|
|||
Widget _buildDeviceCard() { |
|||
return _card( |
|||
child: Row( |
|||
children: [ |
|||
// 耳机实拍图(小尺寸版),比通用耳机图标更能对上用户手里那台设备。 |
|||
// 用 earphoneIconImage 而不是主图 earphoneImage——后者 892×1152/1.1MB, |
|||
// 解到 44px 纯属浪费。 |
|||
Container( |
|||
width: 44.w, |
|||
height: 44.w, |
|||
padding: EdgeInsets.all(4.w), |
|||
decoration: BoxDecoration( |
|||
color: _accentBg, |
|||
borderRadius: BorderRadius.circular(12.r), |
|||
), |
|||
child: Image.asset( |
|||
DeviceCategories.earphoneIconImage, |
|||
fit: BoxFit.contain, |
|||
// 图缺失时退回通用图标,不让整张卡片塌成红色报错块 |
|||
errorBuilder: (_, __, ___) => |
|||
Icon(Icons.headphones, color: _accent, size: 24.w), |
|||
), |
|||
), |
|||
12.horizontalSpace, |
|||
Expanded( |
|||
child: Obx(() { |
|||
final name = controller.deviceName.value; |
|||
return Column( |
|||
crossAxisAlignment: CrossAxisAlignment.start, |
|||
children: [ |
|||
Text( |
|||
name.isEmpty ? 'otaUnknownDevice'.tr : name, |
|||
style: TextStyle( |
|||
fontSize: 15.sp, |
|||
fontWeight: FontWeight.w600, |
|||
color: Colors.grey[850], |
|||
), |
|||
), |
|||
4.verticalSpace, |
|||
Text( |
|||
'${'otaCurrentVersion'.tr}: ' |
|||
'${controller.currentVersion.value.isEmpty ? '--' : controller.currentVersion.value}', |
|||
style: TextStyle(fontSize: 12.sp, color: Colors.grey[500]), |
|||
), |
|||
], |
|||
); |
|||
}), |
|||
), |
|||
], |
|||
), |
|||
); |
|||
} |
|||
|
|||
Widget _buildVersionCard() { |
|||
return _card( |
|||
child: Column( |
|||
children: [ |
|||
_versionRow( |
|||
'otaCurrentVersion'.tr, |
|||
() => controller.currentVersion.value.isEmpty |
|||
? '--' |
|||
: controller.currentVersion.value, |
|||
), |
|||
Divider(height: 20.h, color: Colors.grey[200]), |
|||
_versionRow( |
|||
'otaLatestVersion'.tr, |
|||
() => controller.latestVersion.value.isEmpty |
|||
? '--' |
|||
: controller.latestVersion.value, |
|||
), |
|||
12.verticalSpace, |
|||
Obx(() { |
|||
final available = controller.isUpdateAvailable.value; |
|||
return Container( |
|||
width: double.infinity, |
|||
padding: EdgeInsets.symmetric(vertical: 8.h, horizontal: 12.w), |
|||
decoration: BoxDecoration( |
|||
color: available ? _accentBg : Colors.grey[100], |
|||
borderRadius: BorderRadius.circular(10.r), |
|||
), |
|||
child: Row( |
|||
mainAxisAlignment: MainAxisAlignment.center, |
|||
children: [ |
|||
Icon( |
|||
available ? Icons.system_update : Icons.check_circle, |
|||
size: 16.w, |
|||
color: available ? _accent : Colors.grey[500], |
|||
), |
|||
6.horizontalSpace, |
|||
Text( |
|||
available ? 'otaUpdateAvailable'.tr : 'otaAlreadyLatest'.tr, |
|||
style: TextStyle( |
|||
fontSize: 13.sp, |
|||
color: available ? _accent : Colors.grey[600], |
|||
fontWeight: FontWeight.w500, |
|||
), |
|||
), |
|||
], |
|||
), |
|||
); |
|||
}), |
|||
], |
|||
), |
|||
); |
|||
} |
|||
|
|||
Widget _versionRow(String label, String Function() value) { |
|||
return Row( |
|||
mainAxisAlignment: MainAxisAlignment.spaceBetween, |
|||
children: [ |
|||
Text(label, style: TextStyle(fontSize: 14.sp, color: Colors.grey[600])), |
|||
Obx( |
|||
() => Text( |
|||
value(), |
|||
style: TextStyle( |
|||
fontSize: 14.sp, |
|||
fontWeight: FontWeight.w600, |
|||
color: Colors.grey[850], |
|||
), |
|||
), |
|||
), |
|||
], |
|||
); |
|||
} |
|||
|
|||
Widget _buildProgressSection() { |
|||
return Obx(() { |
|||
final phase = controller.phase.value; |
|||
if (phase == OtaPhase.idle) return const SizedBox.shrink(); |
|||
|
|||
if (phase == OtaPhase.success) { |
|||
return _resultCard( |
|||
icon: Icons.check_circle, |
|||
color: _accent, |
|||
text: 'otaSuccessReboot'.tr, |
|||
); |
|||
} |
|||
if (phase == OtaPhase.failed) { |
|||
return _resultCard( |
|||
icon: Icons.error, |
|||
color: Colors.red[400]!, |
|||
text: controller.detailedStatus.value.isEmpty |
|||
? 'otaFailed'.tr |
|||
: controller.detailedStatus.value, |
|||
); |
|||
} |
|||
// downloading / upgrading |
|||
final isDownloading = phase == OtaPhase.downloading; |
|||
final value = isDownloading |
|||
? controller.downloadProgress.value |
|||
: controller.otaProgress.value; |
|||
return _card( |
|||
child: Column( |
|||
children: [ |
|||
SizedBox( |
|||
width: 96.w, |
|||
height: 96.w, |
|||
child: Stack( |
|||
alignment: Alignment.center, |
|||
children: [ |
|||
SizedBox( |
|||
width: 96.w, |
|||
height: 96.w, |
|||
child: CircularProgressIndicator( |
|||
value: value == 0 ? null : value, |
|||
strokeWidth: 6.w, |
|||
backgroundColor: Colors.grey[200], |
|||
valueColor: const AlwaysStoppedAnimation(_accent), |
|||
), |
|||
), |
|||
Text( |
|||
'${(value * 100).toStringAsFixed(0)}%', |
|||
style: TextStyle( |
|||
fontSize: 20.sp, |
|||
fontWeight: FontWeight.bold, |
|||
color: _accent, |
|||
), |
|||
), |
|||
], |
|||
), |
|||
), |
|||
16.verticalSpace, |
|||
Text( |
|||
controller.detailedStatus.value.isEmpty |
|||
? (isDownloading ? 'otaDownloading'.tr : 'otaUpgradingShort'.tr) |
|||
: controller.detailedStatus.value, |
|||
textAlign: TextAlign.center, |
|||
style: TextStyle(fontSize: 13.sp, color: Colors.grey[600]), |
|||
), |
|||
12.verticalSpace, |
|||
Row( |
|||
children: [ |
|||
Icon(Icons.info_outline, size: 14.w, color: Colors.orange[600]), |
|||
6.horizontalSpace, |
|||
Expanded( |
|||
child: Text( |
|||
'otaKeepConnectedWarning'.tr, |
|||
style: TextStyle(fontSize: 11.sp, color: Colors.orange[700]), |
|||
), |
|||
), |
|||
], |
|||
), |
|||
], |
|||
), |
|||
); |
|||
}); |
|||
} |
|||
|
|||
Widget _buildBottomBar() { |
|||
return Obx(() { |
|||
final phase = controller.phase.value; |
|||
// 升级中只显示进度;失败/断开态不提供重试按钮(从顶栏返回退出页面, |
|||
// 退出时自动回连,重新进入即可再次升级)。 |
|||
if (phase == OtaPhase.downloading || |
|||
phase == OtaPhase.upgrading || |
|||
phase == OtaPhase.failed) { |
|||
return const SizedBox.shrink(); |
|||
} |
|||
|
|||
final isSuccess = phase == OtaPhase.success; |
|||
final canUpgrade = controller.isUpdateAvailable.value && |
|||
controller.isConnected.value; |
|||
|
|||
String label; |
|||
VoidCallback? onTap; |
|||
if (isSuccess) { |
|||
label = 'otaDone'.tr; |
|||
onTap = () => Get.back(); |
|||
} else { |
|||
label = 'otaStartUpgrade'.tr; |
|||
onTap = canUpgrade ? () => controller.startUpdate() : null; |
|||
} |
|||
|
|||
return Padding( |
|||
padding: EdgeInsets.fromLTRB(16.w, 8.h, 16.w, 16.h), |
|||
child: SizedBox( |
|||
width: double.infinity, |
|||
height: 50.h, |
|||
child: ElevatedButton( |
|||
onPressed: onTap, |
|||
style: ElevatedButton.styleFrom( |
|||
backgroundColor: _accent, |
|||
disabledBackgroundColor: Colors.grey[300], |
|||
foregroundColor: Colors.white, |
|||
elevation: 0, |
|||
shape: RoundedRectangleBorder( |
|||
borderRadius: BorderRadius.circular(14.r), |
|||
), |
|||
), |
|||
child: Text( |
|||
label, |
|||
style: TextStyle(fontSize: 16.sp, fontWeight: FontWeight.w600), |
|||
), |
|||
), |
|||
), |
|||
); |
|||
}); |
|||
} |
|||
|
|||
Widget _buildNotConnected() { |
|||
return Center( |
|||
child: Padding( |
|||
padding: EdgeInsets.all(32.w), |
|||
child: Column( |
|||
mainAxisSize: MainAxisSize.min, |
|||
children: [ |
|||
Icon(Icons.bluetooth_disabled, size: 56.w, color: Colors.grey[400]), |
|||
16.verticalSpace, |
|||
Text( |
|||
'otaNotConnectedTip'.tr, |
|||
textAlign: TextAlign.center, |
|||
style: TextStyle(fontSize: 14.sp, color: Colors.grey[600]), |
|||
), |
|||
], |
|||
), |
|||
), |
|||
); |
|||
} |
|||
|
|||
Widget _resultCard({ |
|||
required IconData icon, |
|||
required Color color, |
|||
required String text, |
|||
}) { |
|||
return _card( |
|||
child: Column( |
|||
children: [ |
|||
Icon(icon, size: 56.w, color: color), |
|||
12.verticalSpace, |
|||
Text( |
|||
text, |
|||
textAlign: TextAlign.center, |
|||
style: TextStyle( |
|||
fontSize: 15.sp, |
|||
fontWeight: FontWeight.w600, |
|||
color: Colors.grey[800], |
|||
), |
|||
), |
|||
], |
|||
), |
|||
); |
|||
} |
|||
|
|||
Widget _card({required Widget child}) { |
|||
return Container( |
|||
width: double.infinity, |
|||
padding: EdgeInsets.all(16.w), |
|||
decoration: BoxDecoration( |
|||
color: Colors.white, |
|||
borderRadius: BorderRadius.circular(18.r), |
|||
boxShadow: [ |
|||
BoxShadow( |
|||
color: Colors.black.withValues(alpha: 0.04), |
|||
blurRadius: 10, |
|||
offset: const Offset(0, 2), |
|||
), |
|||
], |
|||
), |
|||
child: child, |
|||
); |
|||
} |
|||
|
|||
void _handleBack() { |
|||
if (controller.isBusy) { |
|||
Get.dialog( |
|||
AlertDialog( |
|||
title: Text('otaExitTitle'.tr), |
|||
content: Text('otaExitConfirm'.tr), |
|||
actions: [ |
|||
TextButton( |
|||
onPressed: () => Get.back(), |
|||
child: Text('cancel'.tr), |
|||
), |
|||
TextButton( |
|||
onPressed: () { |
|||
controller.cancel(); |
|||
Get.back(); // 关弹窗 |
|||
Get.back(); // 退页面 |
|||
}, |
|||
child: Text('confirm'.tr), |
|||
), |
|||
], |
|||
), |
|||
); |
|||
return; |
|||
} |
|||
Get.back(); |
|||
} |
|||
} |
|||
Some files were not shown because too many files changed in this diff
Loading…
Reference in new issue