Browse Source

收拢积压改动:恒玄 OTA、发版配置拆表、实名认证、芯片厂商/代工厂管理等

把工作区里积压的多轮改动一次性入库。主要几块:

客户端
- 恒玄 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
Rodger-Wang 1 month ago
parent
commit
24e17b3434
  1. 4
      .gitignore
  2. 254
      apps/admin/app/pages/appversion.vue
  3. 158
      apps/admin/app/pages/channels.vue
  4. 297
      apps/admin/app/pages/chipvendor.vue
  5. 184
      apps/admin/app/pages/dealers.vue
  6. 476
      apps/admin/app/pages/device.vue
  7. 244
      apps/admin/app/pages/guestaccess.vue
  8. 322
      apps/admin/app/pages/oemfactory.vue
  9. 360
      apps/admin/app/pages/product.vue
  10. 52
      apps/admin/app/pages/provider.vue
  11. 87
      apps/admin/app/pages/settlements.vue
  12. 524
      apps/admin/app/pages/users.vue
  13. 86
      apps/admin/app/utils/chipvendors.ts
  14. 26
      apps/admin/app/utils/menus.ts
  15. 395
      apps/client/BES_CONNECT_FIX_PLAN.md
  16. 286
      apps/client/CLIENT_AUDIT.md
  17. BIN
      apps/client/assets/images/app_icon.png
  18. BIN
      apps/client/assets/images/devices/earphone_icon.png
  19. 6
      apps/client/ios/Podfile.lock
  20. 16
      apps/client/ios/Runner.xcodeproj/project.pbxproj
  21. 12
      apps/client/lib/core/theme/theme_service.dart
  22. 56
      apps/client/lib/core/translations/language/ar_sa.dart
  23. 56
      apps/client/lib/core/translations/language/bg_bg.dart
  24. 56
      apps/client/lib/core/translations/language/cs_cz.dart
  25. 56
      apps/client/lib/core/translations/language/da_dk.dart
  26. 57
      apps/client/lib/core/translations/language/de_de.dart
  27. 56
      apps/client/lib/core/translations/language/el_gr.dart
  28. 62
      apps/client/lib/core/translations/language/en_us.dart
  29. 57
      apps/client/lib/core/translations/language/es_es.dart
  30. 56
      apps/client/lib/core/translations/language/et_ee.dart
  31. 56
      apps/client/lib/core/translations/language/fa_ir.dart
  32. 56
      apps/client/lib/core/translations/language/fi_fi.dart
  33. 57
      apps/client/lib/core/translations/language/fr_fr.dart
  34. 56
      apps/client/lib/core/translations/language/hi_in.dart
  35. 56
      apps/client/lib/core/translations/language/hr_hr.dart
  36. 56
      apps/client/lib/core/translations/language/hu_hu.dart
  37. 56
      apps/client/lib/core/translations/language/id_id.dart
  38. 56
      apps/client/lib/core/translations/language/is_is.dart
  39. 56
      apps/client/lib/core/translations/language/it_it.dart
  40. 57
      apps/client/lib/core/translations/language/ja_jp.dart
  41. 57
      apps/client/lib/core/translations/language/ko_kr.dart
  42. 56
      apps/client/lib/core/translations/language/lt_lt.dart
  43. 56
      apps/client/lib/core/translations/language/lv_lv.dart
  44. 56
      apps/client/lib/core/translations/language/ms_my.dart
  45. 56
      apps/client/lib/core/translations/language/nb_no.dart
  46. 56
      apps/client/lib/core/translations/language/nl_nl.dart
  47. 56
      apps/client/lib/core/translations/language/pl_pl.dart
  48. 57
      apps/client/lib/core/translations/language/pt_pt.dart
  49. 56
      apps/client/lib/core/translations/language/ro_ro.dart
  50. 57
      apps/client/lib/core/translations/language/ru_ru.dart
  51. 56
      apps/client/lib/core/translations/language/sk_sk.dart
  52. 56
      apps/client/lib/core/translations/language/sl_si.dart
  53. 56
      apps/client/lib/core/translations/language/sr_rs.dart
  54. 56
      apps/client/lib/core/translations/language/sv_se.dart
  55. 56
      apps/client/lib/core/translations/language/th_th.dart
  56. 56
      apps/client/lib/core/translations/language/tl_ph.dart
  57. 56
      apps/client/lib/core/translations/language/tr_tr.dart
  58. 56
      apps/client/lib/core/translations/language/uk_ua.dart
  59. 56
      apps/client/lib/core/translations/language/vi_vn.dart
  60. 59
      apps/client/lib/core/translations/language/zh_cn.dart
  61. 57
      apps/client/lib/core/translations/language/zh_hk.dart
  62. 57
      apps/client/lib/core/translations/language/zh_tw.dart
  63. 18
      apps/client/lib/core/utils/id_verify_guard.dart
  64. 33
      apps/client/lib/data/models/appconfig.dart
  65. 10
      apps/client/lib/data/models/appconfig_model.dart
  66. 2
      apps/client/lib/data/models/appconfig_model.g.dart
  67. 25
      apps/client/lib/data/models/channel_app_model.dart
  68. 8
      apps/client/lib/data/models/channel_app_model.g.dart
  69. 10
      apps/client/lib/data/models/device_category.dart
  70. 5
      apps/client/lib/data/services/account_service.dart
  71. 415
      apps/client/lib/data/services/bes_bluetooth_service.dart
  72. 30
      apps/client/lib/data/services/id_verify_service.dart
  73. 73
      apps/client/lib/data/services/language_configs/alibaba35_language_config.dart
  74. 134
      apps/client/lib/data/services/language_configs/alibaba_language_config.dart
  75. 133
      apps/client/lib/data/services/language_manager.dart
  76. 10
      apps/client/lib/data/services/speech_factory.dart
  77. 41
      apps/client/lib/data/services/speech_impl/azure_ast_service.dart
  78. 257
      apps/client/lib/data/services/version_update_service.dart
  79. 4
      apps/client/lib/modules/agent/controllers/agent_controller.dart
  80. 167
      apps/client/lib/modules/call_recording/views/call_recording_view.dart
  81. 160
      apps/client/lib/modules/devices/views/bes_scan_view.dart
  82. 17
      apps/client/lib/modules/home/controllers/home_controller.dart
  83. 22
      apps/client/lib/modules/login/views/login_view.dart
  84. 130
      apps/client/lib/modules/main_tab/views/tabs/device_tab.dart
  85. 70
      apps/client/lib/modules/main_tab/views/widgets/module_card.dart
  86. 55
      apps/client/lib/modules/my_devices/controllers/my_devices_controller.dart
  87. 58
      apps/client/lib/modules/my_devices/views/my_devices_view.dart
  88. 6
      apps/client/lib/modules/ota_upgrade/bindings/ota_upgrade_binding.dart
  89. 153
      apps/client/lib/modules/ota_upgrade/controllers/bes_ota_download_service.dart
  90. 495
      apps/client/lib/modules/ota_upgrade/controllers/bes_ota_upgrade_controller.dart
  91. 98
      apps/client/lib/modules/ota_upgrade/controllers/ota_core_contropller.dart
  92. 36
      apps/client/lib/modules/ota_upgrade/controllers/ota_upgrade_controller.dart
  93. 443
      apps/client/lib/modules/ota_upgrade/views/bes_ota_upgrade_view.dart
  94. 25
      apps/client/lib/modules/ota_upgrade/views/ota_upgrade_view.dart
  95. 11
      apps/client/lib/modules/profile/controllers/profile_controller.dart
  96. 32
      apps/client/lib/modules/settings/views/settings_view.dart
  97. 4
      apps/client/lib/modules/speech_test/views/speech_test_view.dart
  98. 21
      apps/client/lib/modules/splash/views/splash_view.dart
  99. 6
      apps/client/lib/routes/app_pages.dart
  100. 23
      apps/client/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureSpeechPlugin.kt

4
.gitignore

@ -56,3 +56,7 @@ node_modules/
# 误入库的运行日志:lego 未配置日志路径时会在工作目录写 ./log, # 误入库的运行日志:lego 未配置日志路径时会在工作目录写 ./log,
# 本地跑一次服务或 go test 就被覆写一次,制造无意义 diff。 # 本地跑一次服务或 go test 就被覆写一次,制造无意义 diff。
apps/services/comm/log apps/services/comm/log
# gin 子系统的运行日志:本地跑服务时按时间戳滚动生成(多为 0 字节),
# 与 apps/services/comm/log 同一性质,不进版本管理。
apps/services/lego/sys/gin/log-*.log

254
apps/admin/app/pages/appversion.vue

@ -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>

158
apps/admin/app/pages/channels.vue

@ -5,7 +5,15 @@
<div> <div>
<h2 class="text-lg font-semibold text-slate-800">渠道分发</h2> <h2 class="text-lg font-semibold text-slate-800">渠道分发</h2>
<p class="text-xs text-slate-400 mt-1"> <p class="text-xs text-slate-400 mt-1">
管理各应用在各分发渠道上的版本、强制更新与能力开关。支付渠道在这里控制「开放哪几种支付入口」,商户凭据在应用的业务功能配置里填。 管理各应用在各分发渠道上<b>因渠道而异</b>的东西:本渠道下载地址、上架状态、支付渠道、是否允许跳过设备绑定。
支付渠道在这里控制「开放哪几种支付入口」,商户凭据在应用的业务功能配置里填。
</p>
<p class="text-xs text-slate-400 mt-1">
版本号 / 强制更新 / 送审版本去
<NuxtLink class="link" to="/appversion">服务配置 → 版本控制</NuxtLink>,
游客登录开关去
<NuxtLink class="link" to="/guestaccess">服务配置 → 游客显隐控制</NuxtLink>
—— 那两组是整个应用一套的,不分渠道。
</p> </p>
</div> </div>
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
@ -52,26 +60,7 @@
<span class="status-badge" :style="statusStyle(c.status)">{{ statusLabel(c.status) }}</span> <span class="status-badge" :style="statusStyle(c.status)">{{ statusLabel(c.status) }}</span>
</div> </div>
<div class="ver-row">
<div class="ver-box">
<span class="ver-label">线上版本</span>
<span class="ver-val">{{ c.version || '—' }}</span>
</div>
<div class="ver-box">
<span class="ver-label">最低支持</span>
<span class="ver-val" :class="{ warn: c.force_update }">{{ c.min_version || '—' }}</span>
</div>
<div v-if="c.review_version" class="ver-box">
<span class="ver-label">审核中</span>
<span class="ver-val review">{{ c.review_version }}</span>
</div>
</div>
<div class="flex flex-wrap items-center gap-1.5 mt-2"> <div class="flex flex-wrap items-center gap-1.5 mt-2">
<span v-if="c.force_update" class="flag flag-warn">强制更新</span>
<span v-if="c.tourists" class="flag" :class="{ 'flag-warn': !c.touristsversion }">
{{ c.touristsversion ? `游客登录 仅 ${c.touristsversion}` : '游客登录 未填版本(不生效)' }}
</span>
<span v-if="c.allowskipdevicebinding" class="flag">可跳过绑定</span> <span v-if="c.allowskipdevicebinding" class="flag">可跳过绑定</span>
<span v-if="!c.enable" class="flag flag-off">已停用</span> <span v-if="!c.enable" class="flag flag-off">已停用</span>
</div> </div>
@ -86,7 +75,9 @@
>{{ payLabel(p) }}{{ payMissing(p) ? ' ⚠' : '' }}</span> >{{ payLabel(p) }}{{ payMissing(p) ? ' ⚠' : '' }}</span>
</div> </div>
<div v-if="c.address" class="text-[11px] text-slate-400 mt-2 truncate">{{ c.address }}</div> <div class="text-[11px] mt-2 truncate" :class="c.address ? 'text-slate-400' : 'text-slate-300'">
{{ c.address || '未填本渠道地址 · 用应用默认' }}
</div>
</div> </div>
<!-- 该组里尚未配置的渠道,点一下直接建 --> <!-- 该组里尚未配置的渠道,点一下直接建 -->
@ -139,59 +130,28 @@
</section> </section>
<section class="ed-section"> <section class="ed-section">
<div class="ed-section-title">版本</div> <div class="ed-section-title">本渠道下载地址</div>
<div class="grid grid-cols-3 gap-3"> <input v-model="form.address" class="input input-bordered input-sm w-full" placeholder="本商店的下载链接,留空则用应用默认地址" />
<div> <p class="ed-tip">
<span class="ed-label">当前线上版本</span> 客户端点「立即更新」时直接打开这个地址,所以<b>必须是本渠道自己的商店链接</b>——
<input v-model="form.version" class="input input-bordered input-sm w-full" placeholder="如 1.4.2" /> 华为、小米、App Store 各不相同,填错会把用户引到别家商店。
</div> 留空则回退到
<div> <NuxtLink class="link" to="/appversion">版本控制</NuxtLink>
<span class="ed-label">最低支持版本</span> 里配的「默认下载地址」。
<input v-model="form.min_version" class="input input-bordered input-sm w-full" placeholder="低于它的客户端需更新" /> </p>
</div>
<div>
<span class="ed-label">审核中版本</span>
<input v-model="form.review_version" class="input input-bordered input-sm w-full" placeholder="过审后清空" />
</div>
</div>
<label class="flex items-center gap-2 text-sm cursor-pointer mt-3">
<input v-model="form.force_update" type="checkbox" class="checkbox checkbox-sm checkbox-primary" />
<span>强制更新</span>
<span class="text-xs text-slate-400">低于「最低支持版本」的客户端必须更新才能继续使用</span>
</label>
<p v-if="versionWarn" class="ed-warn">⚠ {{ versionWarn }}</p>
<div class="mt-3">
<span class="ed-label">下载地址</span>
<input v-model="form.address" class="input input-bordered input-sm w-full" placeholder="应用商店链接或 apk 直链" />
</div>
<div class="mt-3">
<span class="ed-label">更新日志</span>
<textarea v-model="form.changelog" class="textarea textarea-bordered w-full text-xs" rows="3" placeholder="本次更新内容,会下发给客户端展示" />
</div>
</section> </section>
<section class="ed-section"> <section class="ed-section">
<div class="ed-section-title">能力开关</div> <div class="ed-section-title">能力开关</div>
<div class="flex flex-col gap-2"> <label class="flex items-center gap-2 text-sm cursor-pointer">
<label class="flex items-center gap-2 text-sm cursor-pointer"> <input v-model="form.allowskipdevicebinding" type="checkbox" class="checkbox checkbox-sm checkbox-primary" />
<input v-model="form.tourists" type="checkbox" class="checkbox checkbox-sm checkbox-primary" /> <span>允许跳过设备绑定</span>
<span>允许游客登录</span> </label>
</label> <p class="ed-tip">
<div v-if="form.tourists" class="pl-7"> 游客登录开关已移到
<span class="ed-label">生效版本(必填)</span> <NuxtLink class="link" to="/guestaccess">服务配置 → 游客显隐控制</NuxtLink>
<input v-model="form.touristsversion" class="input input-bordered input-sm w-full" placeholder="如 1.0.2,只有这一版的客户端能看到游客登录" /> (按应用配,不分渠道)。
<p class="text-xs text-slate-400 mt-1"> </p>
白名单语义:<b>只有填在这里的版本可见,其余版本一律隐藏</b>。
送审场景就是只给正在过审的那一版开。版本号要与客户端上报的一致(不含构建号,如 <code>1.0.2</code>)。
留空等于所有版本都看不到入口——要全部隐藏请直接取消上面的勾选。
</p>
<p v-if="touristsWarn" class="ed-warn">⚠ {{ touristsWarn }}</p>
</div>
<label class="flex items-center gap-2 text-sm cursor-pointer">
<input v-model="form.allowskipdevicebinding" type="checkbox" class="checkbox checkbox-sm checkbox-primary" />
<span>允许跳过设备绑定</span>
</label>
</div>
</section> </section>
<section class="ed-section"> <section class="ed-section">
@ -235,20 +195,17 @@ import { useToast } from '~/composables/useToast'
definePageMeta({ title: '渠道分发' }) definePageMeta({ title: '渠道分发' })
// 与后端 comm.ChannelApp 一一对应。
// ⚠️ 版本号 / 最低支持版本 / 强制更新 / 更新日志 / 送审版本 / 游客登录开关**不在这里**:
// 它们是「整个应用一套」的,已搬到 app_release(服务配置 → 版本控制 / 游客显隐控制)。
// 别再加回来——按渠道各存一份意味着同一个开关有 8 处能改而客户端只读一处。
interface ChannelApp { interface ChannelApp {
row_id: number row_id: number
app_name: string app_name: string
channel: number channel: number
version: string
min_version: string
force_update: boolean
changelog: string
address: string address: string
review_version: string
status: number status: number
description: string description: string
tourists: boolean
touristsversion: string
allowskipdevicebinding: boolean allowskipdevicebinding: boolean
paychannels: string paychannels: string
enable: boolean enable: boolean
@ -310,9 +267,8 @@ const submitting = ref(false)
function emptyForm(): ChannelApp { function emptyForm(): ChannelApp {
return { return {
row_id: 0, app_name: '', channel: 0, version: '', min_version: '', force_update: false, row_id: 0, app_name: '', channel: 0, address: '', status: 1, description: '',
changelog: '', address: '', review_version: '', status: 1, description: '', allowskipdevicebinding: false, paychannels: '', enable: true,
tourists: false, touristsversion: '', allowskipdevicebinding: false, paychannels: '', enable: true,
} }
} }
const form = reactive<ChannelApp>(emptyForm()) const form = reactive<ChannelApp>(emptyForm())
@ -348,36 +304,6 @@ const payWarnings = computed(() => {
return Object.keys(map).map((k) => ({ key: k, label: payLabel(k), channels: map[k]! })) return Object.keys(map).map((k) => ({ key: k, label: payLabel(k), channels: map[k]! }))
}) })
// 游客登录版本限定的提示:这里只提示,真正的拦截在后端 saveChannelApp(前端提示挡不住直接调接口)。
const touristsWarn = computed(() => {
if (!form.tourists) return ''
const v = (form.touristsversion || '').trim()
if (!v) return '开启游客登录必须填生效版本,留空等于所有版本都看不到入口'
if (form.version.trim() && cmpVer(v, form.version) > 0) {
return '生效版本高于「当前线上版本」,目前线上没有任何一版客户端能看到游客登录入口'
}
return ''
})
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 ''
})
// 与后端 comm.CompareVersion 同语义:逐段按整数比,短的补 0
function cmpVer(a: string, b: string): number {
const as = a.trim().split('.'), bs = b.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
}
// ===== 加载 ===== // ===== 加载 =====
async function loadScopeApps() { async function loadScopeApps() {
try { try {
@ -438,11 +364,6 @@ function togglePay(key: string) {
async function save() { async function save() {
if (!form.channel) { error('请选择分发渠道'); return } if (!form.channel) { error('请选择分发渠道'); return }
if (versionWarn.value) { error(versionWarn.value); return }
// 关掉游客登录时顺手清空生效版本:输入框在关掉后是隐藏的,留着旧值会被后端的
// 「填了版本必须勾选开关」校验挡下,用户看不到是哪一项有问题。
if (!form.tourists) form.touristsversion = ''
if (touristsWarn.value) { error(touristsWarn.value); return }
submitting.value = true submitting.value = true
try { try {
await webApi('savechannelapp', { ...form, app_name: scopeApp.value }) await webApi('savechannelapp', { ...form, app_name: scopeApp.value })
@ -484,15 +405,8 @@ onMounted(async () => {
.ch-empty:hover { border-color: #c7d2fe; background: #f8fafc; } .ch-empty:hover { border-color: #c7d2fe; background: #f8fafc; }
.status-badge { font-size: 10px; font-weight: 700; padding: 2px 8px; border-radius: 6px; white-space: nowrap; } .status-badge { font-size: 10px; font-weight: 700; padding: 2px 8px; border-radius: 6px; white-space: nowrap; }
.ver-row { display: flex; gap: 14px; margin-top: 10px; flex-wrap: wrap; }
.ver-box { display: flex; flex-direction: column; }
.ver-label { font-size: 10px; color: #94a3b8; }
.ver-val { font-size: 13px; font-weight: 700; color: #1e293b; font-family: 'SF Mono', Menlo, monospace; }
.ver-val.warn { color: #b45309; }
.ver-val.review { color: #7c3aed; }
.flag { font-size: 10px; font-weight: 600; padding: 2px 7px; border-radius: 5px; background: #f1f5f9; color: #475569; } .flag { font-size: 10px; font-weight: 600; padding: 2px 7px; border-radius: 5px; background: #f1f5f9; color: #475569; }
.flag-warn { background: #fef3c7; color: #b45309; }
.flag-off { background: #fee2e2; color: #b91c1c; } .flag-off { background: #fee2e2; color: #b91c1c; }
.pay-pill { font-size: 10px; font-weight: 600; padding: 2px 8px; border-radius: 6px; background: #eef2ff; color: #4338ca; } .pay-pill { font-size: 10px; font-weight: 600; padding: 2px 8px; border-radius: 6px; background: #eef2ff; color: #4338ca; }
.pay-pill.missing { background: #fef3c7; color: #b45309; } .pay-pill.missing { background: #fef3c7; color: #b45309; }

297
apps/admin/app/pages/chipvendor.vue

@ -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()">&larr; 返回列表</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>

184
apps/admin/app/pages/dealers.vue

@ -10,13 +10,6 @@
<!-- 筛选 --> <!-- 筛选 -->
<div class="bg-white rounded-xl shadow-sm border border-slate-100 p-4 mb-4"> <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="flex flex-wrap items-end gap-3">
<div v-if="!auth.isBrandAccount && !auth.isDealer" class="form-control">
<label class="label py-1"><span class="label-text text-xs text-slate-500">品牌商</span></label>
<select v-model.number="filters.brandid" class="select select-bordered select-sm w-48" @change="loadData">
<option :value="0">全部品牌商</option>
<option v-for="b in brands" :key="b.id" :value="b.id">{{ b.brand }}</option>
</select>
</div>
<div class="form-control"> <div class="form-control">
<label class="label py-1"><span class="label-text text-xs text-slate-500">状态</span></label> <label class="label py-1"><span class="label-text text-xs text-slate-500">状态</span></label>
<select v-model.number="filters.status" class="select select-bordered select-sm w-36" @change="loadData"> <select v-model.number="filters.status" class="select select-bordered select-sm w-36" @change="loadData">
@ -43,16 +36,16 @@
<table class="table table-zebra"> <table class="table table-zebra">
<thead> <thead>
<tr> <tr>
<th>渠道商ID</th><th>名称</th><th>所属品牌商</th><th>分成比例</th> <th>渠道商ID</th><th>名称</th><th>生产批次</th>
<th>联系方式</th><th>状态</th><th>创建时间</th><th>操作</th> <th>联系方式</th><th>状态</th><th>创建时间</th><th>操作</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-if="loading"> <tr v-if="loading">
<td colspan="8" class="text-center py-8"><span class="loading loading-spinner loading-sm"></span></td> <td colspan="7" class="text-center py-8"><span class="loading loading-spinner loading-sm"></span></td>
</tr> </tr>
<tr v-else-if="!rows.length"> <tr v-else-if="!rows.length">
<td colspan="8" class="text-center text-slate-400 py-8">暂无数据</td> <td colspan="7" class="text-center text-slate-400 py-8">暂无数据</td>
</tr> </tr>
<tr v-for="ch in rows" :key="ch.id"> <tr v-for="ch in rows" :key="ch.id">
<td class="font-mono text-sm font-semibold">{{ ch.id }}</td> <td class="font-mono text-sm font-semibold">{{ ch.id }}</td>
@ -60,8 +53,16 @@
{{ ch.name }} {{ ch.name }}
<span v-if="ch.official" class="badge badge-sm badge-primary badge-outline ml-1">官方</span> <span v-if="ch.official" class="badge badge-sm badge-primary badge-outline ml-1">官方</span>
</td> </td>
<td><span class="badge badge-ghost badge-sm">{{ brandName(ch.brandid) }}</span></td> <!-- 生产批次:从生产批次台账按 channelid 汇总,只读。批次归属在「MAC生成管理」里生产时定 -->
<td class="text-sm">{{ ratePct(ch.sharerate) }}</td> <td class="text-sm">
<button
v-if="batchStat(ch.id).batches"
class="btn btn-ghost btn-xs text-indigo-600 font-normal"
@click="showBatches(ch)">
{{ batchStat(ch.id).batches }} 批 / {{ batchStat(ch.id).macs }} 个
</button>
<span v-else class="text-slate-300">-</span>
</td>
<td class="text-sm text-slate-500"> <td class="text-sm text-slate-500">
<div v-if="ch.contact">{{ ch.contact }}</div> <div v-if="ch.contact">{{ ch.contact }}</div>
<div v-if="ch.contactmails" class="text-xs text-slate-400">{{ ch.contactmails }}</div> <div v-if="ch.contactmails" class="text-xs text-slate-400">{{ ch.contactmails }}</div>
@ -92,13 +93,11 @@
</div> </div>
</div> </div>
<p class="text-xs text-slate-400 mt-3"> <p class="text-xs text-slate-400 mt-3">
<template v-if="auth.isBrandAccount"> 渠道商新增后需超管/管理员审核通过(状态=启用)才能用于生成 MAC;已有生产批次的渠道商不能删除,只能停用。
这里只显示贵品牌商名下的渠道商。新增后为「待审核」,需平台审核通过(状态=启用)才能用于生产授权码;分成比例由平台设置。 标「官方」的是品牌自营渠道,随品牌商自动创建、直接启用、不可删除。
</template> <br />
<template v-else> 渠道商是<b>全局</b>实体,不归属某个品牌商;<b>产品归属与分成比例都在【MAC生成管理 → 生成MAC】里按批次定</b>——
渠道商新增后需超管/管理员审核通过(状态=启用)才能用于生产授权码;已有生产批次的渠道商不能删除,只能停用。 同一家渠道商不同批次的比例可以不同。「生产批次」列是从生产台账按渠道商汇总出来的<b>只读</b>数据。
标「官方」的是品牌自营渠道,随品牌商自动创建、直接启用、不可删除。
</template>
</p> </p>
</div> </div>
@ -119,23 +118,6 @@
<label class="label"><span class="label-text font-medium">渠道商名称 *</span></label> <label class="label"><span class="label-text font-medium">渠道商名称 *</span></label>
<input v-model="form.name" type="text" class="input input-bordered w-full" required /> <input v-model="form.name" type="text" class="input input-bordered w-full" required />
</div> </div>
<div v-if="!auth.isBrandAccount" class="form-control mb-4">
<label class="label"><span class="label-text font-medium">所属品牌商 *</span></label>
<select v-model.number="form.brandid" class="select select-bordered w-full" :disabled="isEditing" required>
<option :value="0">请选择品牌商</option>
<option v-for="b in brands" :key="b.id" :value="b.id">{{ b.brand }}</option>
</select>
<span v-if="isEditing" class="label-text-alt text-slate-400 mt-1">归属品牌商创建后不可修改</span>
</div>
<div class="form-control mb-4">
<label class="label"><span class="label-text font-medium">分成比例(%)</span></label>
<input
v-model.number="form.sharepct" type="number" step="0.01" min="0" max="100"
class="input input-bordered w-full" :disabled="!canAudit" />
<span class="label-text-alt text-slate-400 mt-1">
从所属品牌商的分成里再切;仅超管/管理员可设置。
</span>
</div>
<div class="form-control mb-4"> <div class="form-control mb-4">
<label class="label"><span class="label-text font-medium">联系人 / 电话</span></label> <label class="label"><span class="label-text font-medium">联系人 / 电话</span></label>
<input v-model="form.contact" type="text" class="input input-bordered w-full" /> <input v-model="form.contact" type="text" class="input input-bordered w-full" />
@ -193,6 +175,39 @@
</div> </div>
</div> </div>
<!-- 关联的 MAC 生产批次(只读):归属在生产时定下,这里只做汇总与追溯 -->
<dialog ref="batchDlg" class="modal">
<div class="modal-box max-w-3xl">
<h3 class="font-bold text-lg mb-1">{{ batchCtx?.name }} 的生产批次</h3>
<p class="text-xs text-slate-400 mb-4">
批次归属在【MAC生成管理 → 生成MAC】里生产时定下,此处只读。共
{{ batchCtx ? batchStat(batchCtx.id).batches : 0 }} 批 /
{{ batchCtx ? batchStat(batchCtx.id).macs : 0 }} 个 MAC。
</p>
<div class="overflow-x-auto max-h-96">
<table class="table table-sm table-zebra">
<thead><tr><th>批次号</th><th>产品</th><th>数量</th><th>MAC区间</th><th>生产时间</th></tr></thead>
<tbody>
<tr v-for="b in ctxBatches" :key="b.batchno">
<td class="font-mono">{{ b.batchno }}</td>
<td>{{ productName(b.productid) }}</td>
<td>{{ b.devicenum }}</td>
<td class="font-mono text-xs text-slate-500">
<span v-if="b.startmac">{{ b.startmac }} ~ {{ b.endmac }}</span>
<span v-else class="text-slate-300">-</span>
</td>
<td class="text-slate-500">{{ fmtTime(b.ts) }}</td>
</tr>
</tbody>
</table>
</div>
<div class="modal-action">
<button class="btn btn-sm" @click="batchDlg?.close()">关闭</button>
</div>
</div>
<form method="dialog" class="modal-backdrop"><button>close</button></form>
</dialog>
<!-- 新开渠道账号的凭据回执 --> <!-- 新开渠道账号的凭据回执 -->
<AccountCredentialDialog :info="cred" @close="cred = null" /> <AccountCredentialDialog :info="cred" @close="cred = null" />
</div> </div>
@ -209,12 +224,15 @@ definePageMeta({ title: '渠道商管理' })
interface Channel { interface Channel {
id: string id: string
// 后端仍下发这两个字段,但后台已不维护它们,界面上也不显示:
// brandid 0=全局渠道商;非 0 只出现在随品牌商自动创建的「官方渠道」上
// sharerate [遗留]渠道商级分成比例;比例已改为按生产批次设置
brandid: number brandid: number
sharerate: number
name: string name: string
contact: string contact: string
contactmails: string contactmails: string
remark: string remark: string
sharerate: number // 万分比
status: number // 0待审核 1启用 2停用 3驳回 status: number // 0待审核 1启用 2停用 3驳回
applyaccount: string applyaccount: string
auditaccount: string auditaccount: string
@ -224,7 +242,17 @@ interface Channel {
updatetime: number updatetime: number
official: boolean // 品牌官方渠道:随品牌商自动创建,不可删除 official: boolean // 品牌官方渠道:随品牌商自动创建,不可删除
} }
interface Brand { id: number; brand: string } interface Product { id: number; devicename: string }
// 生产批次:渠道商的「关联批次」由它按 channelid 汇总而来,不在渠道商表里另存一份
interface Batch {
batchno: string
channelid: string
productid: number
devicenum: number
startmac: string
endmac: string
ts: number
}
// addchannel 的返回:渠道商本体 + (可选)渠道账号凭据 // addchannel 的返回:渠道商本体 + (可选)渠道账号凭据
interface AddChannelResp { interface AddChannelResp {
account?: string account?: string
@ -237,7 +265,7 @@ const { webApi } = useApi()
const { success, error } = useToast() const { success, error } = useToast()
const auth = useAuthStore() const auth = useAuthStore()
// 审批(含分成比例设置、顺手开渠道账号)仅超管/管理员。 // 审批(顺手开渠道账号)仅超管/管理员。
const canAudit = computed(() => auth.isAdmin) const canAudit = computed(() => auth.isAdmin)
// 新开账号的凭据回执(密码只有这一次能看到) // 新开账号的凭据回执(密码只有这一次能看到)
@ -245,16 +273,20 @@ const cred = ref<AccountCredential | null>(null)
const view = ref<'list' | 'form'>('list') const view = ref<'list' | 'form'>('list')
const rows = ref<Channel[]>([]) const rows = ref<Channel[]>([])
const brands = ref<Brand[]>([]) const products = ref<Product[]>([])
const batches = ref<Batch[]>([])
const batchDlg = ref<HTMLDialogElement | null>(null)
const batchCtx = ref<Channel | null>(null)
const loading = ref(false) const loading = ref(false)
const submitting = ref(false) const submitting = ref(false)
const isEditing = ref(false) const isEditing = ref(false)
// status 用 -1 表示「全部」:0 是合法取值(待审核),不能拿零值当全部。 // status 用 -1 表示「全部」:0 是合法取值(待审核),不能拿零值当全部。
const filters = reactive({ brandid: 0, status: -1, keyword: '' }) const filters = reactive({ status: -1, keyword: '' })
// 表单里的比例用百分比(更直观),提交时换算成后端的万分比整数。 // 表单里的比例用百分比(更直观),提交时换算成后端的万分比整数。
const form = reactive({ const form = reactive({
id: '', name: '', brandid: 0, sharepct: 0, contact: '', contactmails: '', remark: '', id: '', name: '', contact: '', contactmails: '', remark: '',
// 仅新增时用:顺手开一个绑定本渠道商的渠道账号 // 仅新增时用:顺手开一个绑定本渠道商的渠道账号
createAccount: false, createAccount: false,
accountUsername: '', accountUsername: '',
@ -268,11 +300,33 @@ const STATUS_CLASS: Record<number, string> = {
} }
function statusLabel(s: number) { return STATUS_LABELS[s] ?? String(s) } function statusLabel(s: number) { return STATUS_LABELS[s] ?? String(s) }
function statusClass(s: number) { return STATUS_CLASS[s] ?? 'badge-ghost' } function statusClass(s: number) { return STATUS_CLASS[s] ?? 'badge-ghost' }
function ratePct(rate?: number) { return ((Number(rate) || 0) / 100).toFixed(2) + '%' } function productName(id?: number) {
function brandName(id?: number) { const p = products.value.find((x) => x.id === Number(id))
const b = brands.value.find((x) => x.id === id) return p?.devicename || (id ? '产品#' + hex4(Number(id)) : '-')
return b ? b.brand : (id ? '品牌商#' + Number(id).toString(16).toUpperCase() : '-') }
// 产品 id 与后台其它页面一致,按十六进制大写展示(PID)
function hex4(v?: number | null) {
return v ? Number(v).toString(16).padStart(4, '0').toUpperCase() : ''
}
// 某渠道商的批次汇总:批次数 + MAC 总数。数据源是生产批次台账,渠道商表里不另存。
function batchStat(channelid: string) {
let b = 0
let m = 0
for (const x of batches.value) {
if (x.channelid !== channelid) continue
b += 1
m += Number(x.devicenum) || 0
}
return { batches: b, macs: m }
} }
const ctxBatches = computed(() =>
batchCtx.value ? batches.value.filter((b) => b.channelid === batchCtx.value!.id) : [],
)
function showBatches(ch: Channel) {
batchCtx.value = ch
batchDlg.value?.showModal()
}
function fmtTime(ts?: number) { function fmtTime(ts?: number) {
if (!ts) return '-' if (!ts) return '-'
const d = new Date(ts * 1000) const d = new Date(ts * 1000)
@ -280,21 +334,33 @@ function fmtTime(ts?: number) {
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}` return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`
} }
async function loadBrands() { // 产品不按品牌商过滤:产品建档阶段已不再要求填品牌商(brandid 可为 0),
// 按品牌过滤会让新建的产品在这里一个都选不到。
async function loadProducts() {
try {
const d = await webApi<{ products: Product[] }>('getproducts', {})
products.value = d?.products ?? []
} catch {
products.value = []
}
}
// 一次拉全部批次再在前端按 channelid 分组,省掉「每个渠道商一次请求」。
// 拉不到只影响「生产批次」列显示为 -,不挡主流程。
async function loadBatches() {
try { try {
const d = await webApi<{ brands: Brand[] }>('getbrands', {}) const d = await webApi<{ notes: Batch[] }>('getproductionbatches', { limit: 2000 })
brands.value = d?.brands ?? [] batches.value = d?.notes ?? []
} catch { } catch {
brands.value = [] batches.value = []
} }
} }
async function loadData() { async function loadData() {
loading.value = true loading.value = true
try { try {
if (!brands.value.length) await loadBrands() if (!products.value.length) await loadProducts()
const d = await webApi<{ channels: Channel[] }>('getchannels', { const d = await webApi<{ channels: Channel[] }>('getchannels', {
brandid: Number(filters.brandid) || 0,
status: Number(filters.status), status: Number(filters.status),
keyword: filters.keyword.trim(), keyword: filters.keyword.trim(),
}) })
@ -305,10 +371,10 @@ async function loadData() {
} finally { } finally {
loading.value = false loading.value = false
} }
await loadBatches()
} }
function resetFilters() { function resetFilters() {
filters.brandid = 0
filters.status = -1 filters.status = -1
filters.keyword = '' filters.keyword = ''
loadData() loadData()
@ -324,8 +390,6 @@ function showForm(ch?: Channel) {
isEditing.value = true isEditing.value = true
form.id = ch.id form.id = ch.id
form.name = ch.name || '' form.name = ch.name || ''
form.brandid = ch.brandid || 0
form.sharepct = (Number(ch.sharerate) || 0) / 100
form.contact = ch.contact || '' form.contact = ch.contact || ''
form.contactmails = ch.contactmails || '' form.contactmails = ch.contactmails || ''
form.remark = ch.remark || '' form.remark = ch.remark || ''
@ -333,8 +397,6 @@ function showForm(ch?: Channel) {
isEditing.value = false isEditing.value = false
form.id = '' form.id = ''
form.name = '' form.name = ''
form.brandid = Number(filters.brandid) || 0
form.sharepct = 0
form.contact = '' form.contact = ''
form.contactmails = '' form.contactmails = ''
form.remark = '' form.remark = ''
@ -347,15 +409,13 @@ function showForm(ch?: Channel) {
} }
async function saveChannel() { async function saveChannel() {
// 品牌商账号不选品牌商:后端按账号绑定自动补成自己名下。 if (!form.name.trim()) { error('请填写渠道商名称'); return }
if (!form.brandid && !auth.isBrandAccount) { error('请选择所属品牌商'); return }
submitting.value = true submitting.value = true
try { try {
const channel: Record<string, unknown> = { const channel: Record<string, unknown> = {
// 品牌商归属与分成比例都不再由渠道商表单提交:
// 前者已取消(渠道商是全局实体),后者按生产批次设置(见 MAC生成管理 → 生成MAC)。
name: form.name.trim(), name: form.name.trim(),
brandid: Number(form.brandid) || 0,
// 百分比 → 万分比整数,避免浮点比例入库。
sharerate: Math.round((Number(form.sharepct) || 0) * 100),
contact: form.contact, contact: form.contact,
contactmails: form.contactmails, contactmails: form.contactmails,
remark: form.remark, remark: form.remark,

476
apps/admin/app/pages/device.vue

@ -3,16 +3,14 @@
<!-- ========== 页面 Tab ========== --> <!-- ========== 页面 Tab ========== -->
<div class="flex items-center justify-between flex-wrap gap-3"> <div class="flex items-center justify-between flex-wrap gap-3">
<div class="tabs tabs-boxed bg-slate-100 inline-flex"> <div class="tabs tabs-boxed bg-slate-100 inline-flex">
<a class="tab" :class="{ 'tab-active': tab === 'codes' }" @click="switchPageTab('codes')">授权码查询</a> <a class="tab" :class="{ 'tab-active': tab === 'codes' }" @click="switchPageTab('codes')">MAC查询</a>
<a class="tab" :class="{ 'tab-active': tab === 'batches' }" @click="switchPageTab('batches')">生产记录</a> <a class="tab" :class="{ 'tab-active': tab === 'batches' }" @click="switchPageTab('batches')">生产记录</a>
<a v-if="canCreate" class="tab" :class="{ 'tab-active': tab === 'produce' }" @click="switchPageTab('produce')">生产授权码</a> <a v-if="canCreate" class="tab" :class="{ 'tab-active': tab === 'produce' }" @click="switchPageTab('produce')">生成MAC</a>
</div> </div>
<!-- 共享品牌/产品筛选(授权码查询与生产记录共用) --> <!-- 共享渠道商/产品筛选(MAC查询与生产记录共用)。
品牌商筛选已去掉:生产链路不再涉及品牌商,新批次 brandid 一律为 0,
这个下拉筛出来永远是空的。 -->
<div v-if="tab !== 'produce'" class="flex items-end gap-2 flex-wrap"> <div v-if="tab !== 'produce'" class="flex items-end gap-2 flex-wrap">
<select v-model.number="filterBrand" class="select select-bordered select-sm" style="min-width:150px;" @change="onBrandChange">
<option :value="0">全部品牌商</option>
<option v-for="b in brands" :key="b.id" :value="b.id">{{ b.brand || '' }}</option>
</select>
<select v-model="filterChannel" class="select select-bordered select-sm" style="min-width:160px;" @change="onChannelChange"> <select v-model="filterChannel" class="select select-bordered select-sm" style="min-width:160px;" @change="onChannelChange">
<option value="">全部渠道商</option> <option value="">全部渠道商</option>
<option v-for="ch in filterChannelOptions" :key="ch.id" :value="ch.id">{{ ch.name }} ({{ ch.id }})</option> <option v-for="ch in filterChannelOptions" :key="ch.id" :value="ch.id">{{ ch.name }} ({{ ch.id }})</option>
@ -24,10 +22,10 @@
</div> </div>
</div> </div>
<!-- ========== Tab 1: 授权码查询 ========== --> <!-- ========== Tab 1: MAC查询 ========== -->
<div v-show="tab === 'codes'" class="bg-white rounded-xl shadow-sm border border-slate-100"> <div v-show="tab === 'codes'" class="bg-white rounded-xl shadow-sm border border-slate-100">
<div class="flex items-center justify-between px-5 py-3 border-b border-slate-100 flex-wrap gap-2"> <div class="flex items-center justify-between px-5 py-3 border-b border-slate-100 flex-wrap gap-2">
<h3 class="text-sm font-semibold text-slate-700">授权码记录<span class="text-xs text-slate-400 font-normal ml-2">默认按生产时间倒序</span></h3> <h3 class="text-sm font-semibold text-slate-700">MAC记录<span class="text-xs text-slate-400 font-normal ml-2">默认按生产时间倒序</span></h3>
<div class="flex items-end gap-2 flex-wrap"> <div class="flex items-end gap-2 flex-wrap">
<div class="form-control"> <div class="form-control">
<label class="label py-0"><span class="label-text text-xs">状态</span></label> <label class="label py-0"><span class="label-text text-xs">状态</span></label>
@ -50,11 +48,7 @@
<input v-model="codeBatch" type="text" class="input input-bordered input-xs font-mono" placeholder="批次号" style="width:90px;" /> <input v-model="codeBatch" type="text" class="input input-bordered input-xs font-mono" placeholder="批次号" style="width:90px;" />
</div> </div>
<div class="form-control"> <div class="form-control">
<label class="label py-0"><span class="label-text text-xs">授权码</span></label> <label class="label py-0"><span class="label-text text-xs">MAC地址</span></label>
<input v-model="codeCode" type="text" class="input input-bordered input-xs" placeholder="授权码" />
</div>
<div class="form-control">
<label class="label py-0"><span class="label-text text-xs">MAC</span></label>
<input v-model="codeMac" type="text" class="input input-bordered input-xs" placeholder="MAC" /> <input v-model="codeMac" type="text" class="input input-bordered input-xs" placeholder="MAC" />
</div> </div>
<div class="form-control"> <div class="form-control">
@ -88,24 +82,23 @@
<thead> <thead>
<tr> <tr>
<th class="w-8"><input v-model="selectAll" type="checkbox" class="checkbox checkbox-xs" @change="toggleSelectAll" /></th> <th class="w-8"><input v-model="selectAll" type="checkbox" class="checkbox checkbox-xs" @change="toggleSelectAll" /></th>
<th>授权码</th><th>批次</th><th>MAC地址</th><th>用户UID</th><th>激活时间</th><th>状态</th><th>操作</th> <th>MAC地址</th><th>批次</th><th>用户UID</th><th>激活时间</th><th>状态</th><th>操作</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-if="!filterProduct"> <tr v-if="!filterProduct">
<td colspan="8" class="text-center text-slate-400 py-8">请先选择产品</td> <td colspan="7" class="text-center text-slate-400 py-8">请先选择产品</td>
</tr> </tr>
<tr v-else-if="devicesError"> <tr v-else-if="devicesError">
<td colspan="8" class="text-center text-red-400 py-8">查询失败: {{ devicesError }}</td> <td colspan="7" class="text-center text-red-400 py-8">查询失败: {{ devicesError }}</td>
</tr> </tr>
<tr v-else-if="!devices.length"> <tr v-else-if="!devices.length">
<td colspan="8" class="text-center text-slate-400 py-8">暂无数据</td> <td colspan="7" class="text-center text-slate-400 py-8">暂无数据</td>
</tr> </tr>
<tr v-for="(d, i) in devices" v-else :key="i"> <tr v-for="(d, i) in devices" v-else :key="i">
<td><input v-model="selectedCodes" type="checkbox" class="checkbox checkbox-xs" :value="d.code || ''" /></td> <td><input v-model="selectedCodes" type="checkbox" class="checkbox checkbox-xs" :value="d.code || ''" /></td>
<td class="font-mono text-xs">{{ d.code || '' }}</td> <td class="font-mono text-xs font-medium">{{ d.devicemac || '-' }}</td>
<td class="font-mono text-xs">{{ d.batchno || '' }}</td> <td class="font-mono text-xs">{{ d.batchno || '' }}</td>
<td class="font-mono text-xs">{{ d.devicemac || '-' }}</td>
<td class="text-xs">{{ d.uid || '-' }}</td> <td class="text-xs">{{ d.uid || '-' }}</td>
<td class="text-xs text-slate-500">{{ d.usedtime ? fmtTime(d.usedtime) : '-' }}</td> <td class="text-xs text-slate-500">{{ d.usedtime ? fmtTime(d.usedtime) : '-' }}</td>
<td> <td>
@ -142,7 +135,7 @@
<div class="text-2xl font-bold text-slate-800">{{ statBatch }}</div> <div class="text-2xl font-bold text-slate-800">{{ statBatch }}</div>
</div> </div>
<div class="bg-white rounded-xl shadow-sm border border-slate-100 p-5"> <div class="bg-white rounded-xl shadow-sm border border-slate-100 p-5">
<div class="text-xs text-slate-400 mb-1">生产授权码总数</div> <div class="text-xs text-slate-400 mb-1">生成MAC总数</div>
<div class="text-2xl font-bold text-indigo-600">{{ statDevices }}</div> <div class="text-2xl font-bold text-indigo-600">{{ statDevices }}</div>
</div> </div>
<div class="bg-white rounded-xl shadow-sm border border-slate-100 p-5"> <div class="bg-white rounded-xl shadow-sm border border-slate-100 p-5">
@ -152,7 +145,7 @@
</div> </div>
<div class="bg-white rounded-xl shadow-sm border border-slate-100"> <div class="bg-white rounded-xl shadow-sm border border-slate-100">
<div class="flex items-center justify-between px-5 py-3 border-b border-slate-100 flex-wrap gap-2"> <div class="flex items-center justify-between px-5 py-3 border-b border-slate-100 flex-wrap gap-2">
<h3 class="text-sm font-semibold text-slate-700">生产记录(按批次)<span class="text-xs text-slate-400 font-normal ml-2">点击记录查看该批次授权码</span></h3> <h3 class="text-sm font-semibold text-slate-700">生产记录(按批次)<span class="text-xs text-slate-400 font-normal ml-2">点击记录查看该批次MAC</span></h3>
<div class="flex items-end gap-2 flex-wrap"> <div class="flex items-end gap-2 flex-wrap">
<div class="form-control"> <div class="form-control">
<label class="label py-0"><span class="label-text text-xs">起始时间</span></label> <label class="label py-0"><span class="label-text text-xs">起始时间</span></label>
@ -171,11 +164,12 @@
<table class="table table-pin-rows text-sm"> <table class="table table-pin-rows text-sm">
<thead> <thead>
<tr> <tr>
<th>批次号</th>
<th class="w-40">生产时间</th> <th class="w-40">生产时间</th>
<th>品牌商</th>
<th>渠道商</th> <th>渠道商</th>
<th>产品</th> <th>产品</th>
<th>批次号</th> <th>代工厂</th>
<th class="text-right">分成比例</th>
<th>序号区间</th> <th>序号区间</th>
<th>起始MAC</th> <th>起始MAC</th>
<th>结束MAC</th> <th>结束MAC</th>
@ -185,27 +179,29 @@
</thead> </thead>
<tbody> <tbody>
<tr v-if="notesLoading"> <tr v-if="notesLoading">
<td colspan="10" class="text-center text-slate-400 py-8">加载中…</td> <td colspan="11" class="text-center text-slate-400 py-8">加载中…</td>
</tr> </tr>
<tr v-else-if="notesError"> <tr v-else-if="notesError">
<td colspan="10" class="text-center text-red-400 py-8">查询失败: {{ notesError }}</td> <td colspan="11" class="text-center text-red-400 py-8">查询失败: {{ notesError }}</td>
</tr> </tr>
<tr v-else-if="!notes.length"> <tr v-else-if="!notes.length">
<td colspan="10" class="text-center text-slate-400 py-8">暂无数据</td> <td colspan="11" class="text-center text-slate-400 py-8">暂无数据</td>
</tr> </tr>
<tr v-for="(n, i) in notes" v-else :key="i" class="hover cursor-pointer" @click="viewBatchCodes(n.productid, String(n.batchno || ''))"> <tr v-for="(n, i) in notes" v-else :key="i" class="hover cursor-pointer" @click="viewBatchCodes(n.productid, String(n.batchno || ''))">
<td class="font-mono text-xs font-medium text-indigo-600">{{ n.batchno || '' }}</td>
<td class="text-xs text-slate-500 whitespace-nowrap">{{ fmtTime(n.ts) }}</td> <td class="text-xs text-slate-500 whitespace-nowrap">{{ fmtTime(n.ts) }}</td>
<td class="text-xs">{{ nameOfBrand(n.brandid) }}</td>
<td class="text-xs">{{ nameOfChannel(n.channelid) }}</td> <td class="text-xs">{{ nameOfChannel(n.channelid) }}</td>
<td class="text-xs">{{ nameOfProduct(n.productid) }}</td> <td class="text-xs">{{ nameOfProduct(n.productid) }}</td>
<td class="font-mono text-xs font-medium text-indigo-600">{{ n.batchno || '' }}</td> <td class="text-xs">{{ nameOfFactory(n.factoryid) }}</td>
<td class="text-xs text-right">{{ ratePct(n.sharerate) }}</td>
<td class="font-mono text-xs">{{ serialRange(n) }}</td> <td class="font-mono text-xs">{{ serialRange(n) }}</td>
<td class="font-mono text-xs">{{ n.startmac || '' }}</td> <!-- 导入的批次没有 MAC 区间(外部 MAC 不连续),显示「-」;明细点「查看MAC」 -->
<td class="font-mono text-xs">{{ n.endmac || '' }}</td> <td class="font-mono text-xs">{{ n.startmac || '-' }}</td>
<td class="font-mono text-xs">{{ n.endmac || '-' }}</td>
<td class="text-right font-medium">{{ n.devicenum || 0 }}</td> <td class="text-right font-medium">{{ n.devicenum || 0 }}</td>
<td> <td>
<div class="flex gap-1"> <div class="flex gap-1">
<button class="btn btn-ghost btn-xs text-indigo-500" @click.stop="viewBatchCodes(n.productid, String(n.batchno || ''))">查看授权码</button> <button class="btn btn-ghost btn-xs text-indigo-500" @click.stop="viewBatchCodes(n.productid, String(n.batchno || ''))">查看MAC</button>
<button v-if="isSuperAdmin" class="btn btn-ghost btn-xs text-red-500" @click.stop="openRevokeDialog(n.brandid, n.productid, String(n.batchno || ''), n.devicenum || 0)">撤销</button> <button v-if="isSuperAdmin" class="btn btn-ghost btn-xs text-red-500" @click.stop="openRevokeDialog(n.brandid, n.productid, String(n.batchno || ''), n.devicenum || 0)">撤销</button>
</div> </div>
</td> </td>
@ -216,17 +212,19 @@
</div> </div>
</div> </div>
<!-- ========== Tab 3: 生产授权码 ========== --> <!-- ========== Tab 3: 生成MAC ========== -->
<div v-show="tab === 'produce'" class="bg-white rounded-xl shadow-sm border border-slate-100 p-8 max-w-3xl"> <div v-show="tab === 'produce'" class="bg-white rounded-xl shadow-sm border border-slate-100 p-8 max-w-3xl">
<h3 class="text-base font-semibold text-slate-800 mb-2">生产授权码</h3> <h3 class="text-base font-semibold text-slate-800 mb-2">生成MAC</h3>
<p class="text-sm text-slate-500 leading-relaxed mb-1"> <p class="text-sm text-slate-500 leading-relaxed mb-1">
生产流程:填写生产表单 → 系统按产品类型计算最终授权码数量并预分配批次号 → 生产流程:填写生产表单 → 系统按产品类型计算最终数量并预分配批次号 →
<b>二次确认生产方案</b>(品牌商 / 产品ID / 批次号 / 数量 / MAC区间)→ 提交服务端生产 → 发送生产通知邮件。 <b>核对生产方案</b>(产品ID / 批次号 / 数量 / MAC区间 / 赠送)→ 提交服务端生产 → 发送生产通知邮件。
</p> </p>
<ul class="text-xs text-slate-400 list-disc list-inside mb-6 space-y-0.5"> <ul class="text-xs text-slate-400 list-disc list-inside mb-6 space-y-0.5">
<li>经典蓝牙耳机产品生成<b>设备码</b>(每个码绑定 MAC 地址);其余产品生成<b>授权码</b>。</li> <li>生成的就是 <b>MAC 地址</b>,<b>不再有单独的授权码</b>——设备的凭据就是它自己的 MAC,
MAC 前 2 字节即 PID,产品 / 批次 / 渠道都能从它查回来。</li>
<li>产品配置了「每副份数」时,最终生成数量 = 生产数量 × 每副份数。</li> <li>产品配置了「每副份数」时,最终生成数量 = 生产数量 × 每副份数。</li>
<li>导入模式按上传的 MAC 地址列表逐条生成设备码。</li> <li><b>导入MAC</b>:按上传的 MAC 地址列表逐条建档,用于外部已烧录好 MAC 的批次。</li>
<li><b>分成比例按批次填</b>——同一家渠道商不同批次可以谈不同比例,生产时冻结进批次台账。</li>
</ul> </ul>
<button class="btn btn-primary text-white" @click="showCreateForm">+ 开始生产</button> <button class="btn btn-primary text-white" @click="showCreateForm">+ 开始生产</button>
</div> </div>
@ -234,65 +232,104 @@
<!-- 生产表单弹窗 --> <!-- 生产表单弹窗 -->
<dialog ref="createModal" class="modal"> <dialog ref="createModal" class="modal">
<div class="modal-box max-w-2xl"> <div class="modal-box max-w-2xl">
<h3 class="font-bold text-lg mb-4">生产授权码</h3> <h3 class="font-bold text-lg mb-4">生成MAC</h3>
<form @submit.prevent="submitPreview"> <form @submit.prevent="submitPreview">
<div class="grid grid-cols-2 gap-4 mb-4"> <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>
<select v-model.number="cBrand" class="select select-bordered select-sm w-full" required @change="onCreateBrandChange">
<option :value="0">请选择品牌商</option>
<option v-for="b in brands" :key="b.id" :value="b.id">{{ (b.brand || '') + ' (' + hexId(b.id) + ')' }}</option>
</select>
</div>
<div class="form-control"> <div class="form-control">
<label class="label"><span class="label-text font-medium">渠道商 *</span></label> <label class="label"><span class="label-text font-medium">渠道商 *</span></label>
<select v-model="cChannel" class="select select-bordered select-sm w-full" required :disabled="!createChannelOptions.length"> <select v-model="cChannel" class="select select-bordered select-sm w-full" required :disabled="!createChannelOptions.length">
<option value="">{{ cBrand ? (createChannelOptions.length ? '请选择渠道商' : '该品牌商暂无已启用渠道商') : '请先选择品牌商' }}</option> <option value="">{{ createChannelOptions.length ? '请选择渠道商' : '暂无已启用渠道商,请先到【渠道商管理】新增并审核通过' }}</option>
<option v-for="ch in createChannelOptions" :key="ch.id" :value="ch.id">{{ ch.name + ' (' + ch.id + ')' }}</option> <option v-for="ch in createChannelOptions" :key="ch.id" :value="ch.id">{{ ch.name + ' (' + ch.id + ')' }}</option>
</select> </select>
</div> </div>
<div class="form-control"> <div class="form-control">
<label class="label"><span class="label-text font-medium">产品 *</span></label> <label class="label"><span class="label-text font-medium">产品 *</span></label>
<select v-model.number="cProduct" class="select select-bordered select-sm w-full" required :disabled="!createProductOptions.length"> <select v-model.number="cProduct" class="select select-bordered select-sm w-full" required :disabled="!createProductOptions.length">
<option :value="0">{{ cBrand ? '请选择产品' : '请先选择品牌商' }}</option> <option :value="0">{{ createProductOptions.length ? '请选择产品' : '暂无产品,请先到【产品管理】创建' }}</option>
<option v-for="p in createProductOptions" :key="p.id" :value="p.id">{{ (p.devicename || '') + ' (' + hexId(p.id) + ')' }}</option> <option v-for="p in createProductOptions" :key="p.id" :value="p.id">{{ (p.devicename || '') + ' (' + hexId(p.id) + ')' }}</option>
</select> </select>
</div> </div>
<!-- 分成比例按批次定:同一渠道商不同批次可以谈不同比例,挂在渠道商上只能存一个值 -->
<div class="form-control">
<label class="label"><span class="label-text font-medium">渠道分成比例(%)</span></label>
<input v-model.number="cSharepct" type="number" step="0.01" min="0" max="100" class="input input-bordered input-sm w-full" placeholder="0" />
<span class="label-text-alt text-slate-400 mt-1">给上面这家渠道商的分成,<b>生产时冻结进批次</b>,事后改不影响已生成的批次</span>
</div>
</div>
<!-- 产品档案带出来的三项,只读:改它们要回【产品管理】改产品,改在这里等于同一事实两处维护 -->
<div class="rounded-lg border border-slate-200 bg-slate-50 px-4 py-3 mb-4">
<div class="text-xs text-slate-400 mb-2">以下三项随所选产品自动带出,不可在此修改(要改请到【产品管理】改产品档案)</div>
<div class="grid grid-cols-3 gap-4 text-sm">
<div>
<div class="text-xs text-slate-500 mb-0.5">代工厂</div>
<div class="font-medium" :class="cProduct ? 'text-slate-700' : 'text-slate-300'">{{ cProduct ? nameOfFactory(cFactory) : '—' }}</div>
</div>
<div>
<div class="text-xs text-slate-500 mb-0.5">方案公司 (BID)</div>
<div class="font-medium" :class="cProduct ? 'text-slate-700' : 'text-slate-300'">{{ cProduct ? providerLabel : '—' }}</div>
</div>
<div>
<div class="text-xs text-slate-500 mb-0.5">芯片 (CID)</div>
<div class="font-medium" :class="cProduct ? 'text-slate-700' : 'text-slate-300'">{{ cProduct ? vendorLabel : '—' }}</div>
</div>
</div>
</div>
<!-- 绑定赠送按批次配:同款产品不同批次(不同渠道/不同活动)送的东西经常不一样 -->
<div class="rounded-lg border border-slate-200 px-4 py-3 mb-4">
<div class="text-xs text-slate-500 mb-2">
绑定赠送(这一批设备被用户绑定时发放,<b>生产时冻结</b>;三项都填 0 则回退到产品档案上的旧配置)
</div>
<div class="grid grid-cols-3 gap-4">
<div class="form-control">
<label class="label py-1"><span class="label-text text-xs">赠送VIP(天)</span></label>
<input v-model.number="cRewardVip" type="number" min="0" max="3650" class="input input-bordered input-sm w-full" placeholder="0" />
</div>
<div class="form-control">
<label class="label py-1"><span class="label-text text-xs">赠送翻译时长(分钟)</span></label>
<input v-model.number="cRewardTranslate" type="number" min="0" max="525600" class="input input-bordered input-sm w-full" placeholder="0" />
</div>
<div class="form-control">
<label class="label py-1"><span class="label-text text-xs">赠送会议时长(分钟)</span></label>
<input v-model.number="cRewardMeeting" type="number" min="0" max="525600" class="input input-bordered input-sm w-full" placeholder="0" />
</div>
</div>
</div> </div>
<div class="tabs tabs-boxed mb-4 bg-slate-100 inline-flex"> <div class="tabs tabs-boxed mb-4 bg-slate-100 inline-flex">
<a class="tab tab-sm" :class="{ 'tab-active': createMode === 'production' }" @click="createMode = 'production'">生产</a> <a class="tab tab-sm" :class="{ 'tab-active': createMode === 'production' }" @click="createMode = 'production'">生成MAC</a>
<a class="tab tab-sm" :class="{ 'tab-active': createMode === 'import' }" @click="createMode = 'import'">导入</a> <a class="tab tab-sm" :class="{ 'tab-active': createMode === 'import' }" @click="createMode = 'import'">导入MAC</a>
</div> </div>
<!-- 只剩生产数量。原来右边那个「绑定MAC地址」开关已删:MAC 本身就是设备凭据,
经典蓝牙耳机一律生成 MAC,没有「绑不绑」的选择;而且服务端从来没读过这个字段。 -->
<div v-show="createMode === 'production'" class="grid grid-cols-2 gap-4 mb-1"> <div v-show="createMode === 'production'" class="grid grid-cols-2 gap-4 mb-1">
<div class="form-control"> <div class="form-control">
<label class="label"><span class="label-text font-medium">生产数量 *</span></label> <label class="label"><span class="label-text font-medium">生产数量 *</span></label>
<input v-model.number="cNumber" type="number" class="input input-bordered input-sm w-full" min="1" max="10000" placeholder="生产数量(副)" :required="createMode === 'production'" /> <input v-model.number="cNumber" type="number" class="input input-bordered input-sm w-full" min="1" max="10000" placeholder="生产数量(副)" :required="createMode === 'production'" />
</div> </div>
<div class="form-control">
<label class="label"><span class="label-text font-medium">绑定MAC地址</span></label>
<input v-model="cBindmac" type="checkbox" class="toggle toggle-primary" />
</div>
</div> </div>
<div v-show="createMode === 'import'"> <div v-show="createMode === 'import'">
<div class="form-control mb-2"> <div class="form-control mb-2">
<label class="label"><span class="label-text font-medium">上传 CSV 文件</span></label> <label class="label"><span class="label-text font-medium">上传 CSV 文件</span></label>
<input type="file" class="file-input file-input-bordered file-input-sm w-full" accept=".csv" @change="readCSV" /> <input type="file" class="file-input file-input-bordered file-input-sm w-full" accept=".csv" @change="readCSV" />
<p class="text-xs text-slate-400 mt-1">上传 CSV 文件,系统将读取第一列作为 MAC 地址</p> <p class="text-xs text-slate-400 mt-1">只读第一列,每行一个 MAC 地址</p>
</div> </div>
<div class="form-control mb-1"> <div class="form-control mb-1">
<label class="label"><span class="label-text font-medium">MAC 地址列表 *</span></label> <label class="label"><span class="label-text font-medium">MAC 地址列表 *</span></label>
<textarea v-model="cMacaddresses" class="textarea textarea-bordered w-full" rows="4" placeholder="MAC 地址以逗号分隔,可手动输入或通过CSV导入"></textarea> <textarea v-model="cMacaddresses" class="textarea textarea-bordered w-full" rows="4" placeholder="MAC 地址以逗号分隔,可手动输入或通过 CSV 导入"></textarea>
<span class="label-text-alt text-slate-400 mt-1">用于外部已烧录好 MAC 的批次:不按序号推算,直接按这里的地址逐条建档</span>
</div> </div>
</div> </div>
<!-- 按产品类型说明最终产量 --> <!-- 按产品类型说明最终产量 -->
<div v-if="cProduct" class="rounded-lg bg-indigo-50 border border-indigo-100 px-4 py-3 text-sm text-slate-700 mt-3 mb-3"> <div v-if="cProduct" class="rounded-lg bg-indigo-50 border border-indigo-100 px-4 py-3 text-sm text-slate-700 mt-3 mb-3">
<div class="flex flex-wrap gap-x-6 gap-y-1"> <div class="flex flex-wrap gap-x-6 gap-y-1">
<span>产品类型:<b>{{ createProductInfo.isClassicBT ? '经典蓝牙耳机(设备码,绑定MAC)' : '通用产品(授权码)' }}</b></span> <span>产品类型:<b>{{ createProductInfo.isClassicBT ? '经典蓝牙耳机' : '通用产品' }}</b></span>
<span>每副份数:<b>{{ createProductInfo.codeCount }}</b></span> <span>每副份数:<b>{{ createProductInfo.codeCount }}</b></span>
<span v-if="createMode === 'production'">最终生成:<b class="text-indigo-600">{{ createFinalTotal }}</b> 个{{ createProductInfo.isClassicBT ? '设备码' : '授权码' }} <span v-if="createMode === 'production'">最终生成:<b class="text-indigo-600">{{ createFinalTotal }}</b> 个{{ createProductInfo.isClassicBT ? 'MAC' : '码' }}
<template v-if="createProductInfo.codeCount > 1">({{ cNumber || 0 }} 副 × {{ createProductInfo.codeCount }} 份)</template> <template v-if="createProductInfo.codeCount > 1">({{ cNumber || 0 }} 副 × {{ createProductInfo.codeCount }} 份)</template>
</span> </span>
<span v-else>最终生成:<b class="text-indigo-600">{{ importMacCount }}</b> 个设备码(按 MAC 条数)</span> <span v-else>最终生成:<b class="text-indigo-600">{{ importMacCount }}</b> 条(按导入的 MAC 条数)</span>
</div> </div>
</div> </div>
<div class="form-control mb-4"> <div class="form-control mb-4">
@ -302,7 +339,7 @@
<div class="modal-action"> <div class="modal-action">
<button type="button" class="btn btn-ghost btn-sm" @click="closeCreateModal">取消</button> <button type="button" class="btn btn-ghost btn-sm" @click="closeCreateModal">取消</button>
<button type="submit" class="btn btn-primary btn-sm text-white" :disabled="previewLoading"> <button type="submit" class="btn btn-primary btn-sm text-white" :disabled="previewLoading">
<span v-if="previewLoading" class="loading loading-spinner loading-xs"></span>下一步:确认生产方案 <span v-if="previewLoading" class="loading loading-spinner loading-xs"></span>下一步:核对生产方案
</button> </button>
</div> </div>
</form> </form>
@ -310,25 +347,36 @@
<form method="dialog" class="modal-backdrop"><button>close</button></form> <form method="dialog" class="modal-backdrop"><button>close</button></form>
</dialog> </dialog>
<!-- 生产方案二次确认弹窗(按邮件规格展示) --> <!-- 生产方案核对弹窗(按邮件规格展示) -->
<dialog ref="confirmModal" class="modal"> <dialog ref="confirmModal" class="modal">
<div class="modal-box max-w-lg"> <div class="modal-box max-w-lg">
<h3 class="font-bold text-lg mb-3">确认生产方案</h3> <h3 class="font-bold text-lg mb-3">核对生产方案</h3>
<div class="bg-amber-50 border border-amber-200 rounded-lg p-3 text-xs text-amber-700 mb-4"> <div class="bg-amber-50 border border-amber-200 rounded-lg p-3 text-xs text-amber-700 mb-4">
请核对以下生产方案。确认后立即提交服务端生产,批次一经生成只能整批撤销、不可修改。 请核对以下生产方案。确认后立即提交服务端生产,批次一经生成只能整批撤销、不可修改。
</div> </div>
<div v-if="preview" class="rounded-lg border border-slate-200 bg-slate-50 px-4 py-3 font-mono text-sm leading-7 mb-4"> <div v-if="preview" class="rounded-lg border border-slate-200 bg-slate-50 px-4 py-3 font-mono text-sm leading-7 mb-4">
<div>生产批次(BatchNo):<b class="text-indigo-600">{{ preview.batchno || '生产时分配' }}</b></div> <div>生产批次(BatchNo):<b class="text-indigo-600">{{ preview.batchno || '生产时分配' }}</b></div>
<div>品牌商:{{ preview.brandname }}</div> <div>品牌商:{{ preview.brandname || '-' }}<span v-if="!preview.brandid" class="text-xs text-slate-400 ml-2">(无品牌归属,由所选渠道商推出)</span></div>
<div>渠道商:{{ preview.channelname }}({{ preview.channelid }})</div> <div>渠道商:{{ preview.channelname }}({{ preview.channelid }})</div>
<div>服务ID(CID):0x{{ previewCid }}</div> <div>渠道分成比例:<b class="text-indigo-600">{{ (Number(cSharepct) || 0).toFixed(2) }}%</b></div>
<div>品牌ID(BID):0x{{ hexId(preview.brandid).padStart(4, '0') }}</div> <div>代工厂:{{ nameOfFactory(cFactory) }}</div>
<div>芯片ID(CID):0x{{ previewCid }}</div>
<div>方案ID(BID):0x{{ previewBid }}</div>
<div>产品ID(PID):0x{{ hexId(preview.productid).padStart(4, '0') }}</div> <div>产品ID(PID):0x{{ hexId(preview.productid).padStart(4, '0') }}</div>
<div>产品名称:{{ preview.productname }}</div> <div>产品名称:{{ preview.productname }}</div>
<div>授权码类型:{{ preview.codetype }}</div> <div>生成类型:{{ preview.codetype }}</div>
<div>生产数量:<b class="text-indigo-600">{{ preview.total }}</b> 个<template v-if="preview.codecount > 1">(每副 {{ preview.codecount }} 份)</template></div> <div>生产数量:<b class="text-indigo-600">{{ preview.total }}</b> 个<template v-if="preview.codecount > 1">(每副 {{ preview.codecount }} 份)</template></div>
<div>序号区间:{{ preview.serialstart }} - {{ preview.serialend }}</div> <div>序号区间:{{ preview.serialstart }} - {{ preview.serialend }}</div>
<!-- 生产模式:MAC 由 (PID,序号) 算出,首尾就是真实边界,显示区间 -->
<div v-if="preview.startmac">MAC地址区间:{{ preview.startmac }} - {{ preview.endmac }}</div> <div v-if="preview.startmac">MAC地址区间:{{ preview.startmac }} - {{ preview.endmac }}</div>
<!-- 导入模式:MAC 来自外部 CSV、彼此不连续,没有「区间」这回事,只报条数与前几条样例 -->
<template v-else-if="createMode === 'import'">
<div>MAC来源:<b>外部导入</b>(无区间,共 {{ preview.total }} 条)</div>
<div v-if="preview.importsample && preview.importsample.length" class="text-xs text-slate-500 pl-4">
前 {{ preview.importsample.length }} 条:{{ preview.importsample.join('、') }}{{ preview.total > preview.importsample.length ? ' …' : '' }}
</div>
</template>
<div>绑定赠送:{{ rewardSummary }}</div>
</div> </div>
<div class="modal-action"> <div class="modal-action">
<button type="button" class="btn btn-ghost btn-sm" @click="closeConfirmModal">返回修改</button> <button type="button" class="btn btn-ghost btn-sm" @click="closeConfirmModal">返回修改</button>
@ -389,14 +437,13 @@
撤销生产批次 撤销生产批次
</h3> </h3>
<div class="bg-red-50 border border-red-200 rounded-lg p-3 text-sm text-red-700 mb-4"> <div class="bg-red-50 border border-red-200 rounded-lg p-3 text-sm text-red-700 mb-4">
此操作将<strong>删除该批次的全部授权码</strong>以及对应的生产出货记录,<strong>不可恢复</strong>。<br /> 此操作将<strong>删除该批次的全部 MAC</strong>以及对应的生产出货记录,<strong>不可恢复</strong>。<br />
若该批次中已有任意授权码被激活/绑定,撤销会被拒绝。 若该批次中已有任意一条被激活/绑定,撤销会被拒绝。
</div> </div>
<div class="text-sm space-y-1 mb-4"> <div class="text-sm space-y-1 mb-4">
<div><span class="text-slate-500">品牌商:</span><span class="font-medium">{{ revokeCtx ? nameOfBrand(revokeCtx.brandid) : '' }}</span></div>
<div><span class="text-slate-500">产品:</span><span class="font-medium">{{ revokeCtx ? nameOfProduct(revokeCtx.productid) : '' }}</span></div> <div><span class="text-slate-500">产品:</span><span class="font-medium">{{ revokeCtx ? nameOfProduct(revokeCtx.productid) : '' }}</span></div>
<div><span class="text-slate-500">批次号:</span><span class="font-mono font-medium text-red-600">{{ revokeConfirmTarget }}</span></div> <div><span class="text-slate-500">批次号:</span><span class="font-mono font-medium text-red-600">{{ revokeConfirmTarget }}</span></div>
<div><span class="text-slate-500">授权码数量:</span><span class="font-medium">{{ revokeCtx ? revokeCtx.devicenum : '' }}</span></div> <div><span class="text-slate-500">MAC数量:</span><span class="font-medium">{{ revokeCtx ? revokeCtx.devicenum : '' }}</span></div>
</div> </div>
<div class="form-control mb-2"> <div class="form-control mb-2">
<label class="label"><span class="label-text text-xs">请输入批次号 <span class="font-mono text-red-600">{{ revokeConfirmTarget }}</span> 以确认撤销</span></label> <label class="label"><span class="label-text text-xs">请输入批次号 <span class="font-mono text-red-600">{{ revokeConfirmTarget }}</span> 以确认撤销</span></label>
@ -420,8 +467,9 @@ import { ref, reactive, computed, onMounted } from 'vue'
import { useApi } from '~/composables/useApi' import { useApi } from '~/composables/useApi'
import { useToast } from '~/composables/useToast' import { useToast } from '~/composables/useToast'
import { useAuthStore } from '~/stores/auth' import { useAuthStore } from '~/stores/auth'
import { loadChipVendors, chipVendorName } from '~/utils/chipvendors'
definePageMeta({ title: '生产管理' }) definePageMeta({ title: 'MAC生成管理' })
interface Brand { interface Brand {
id: number id: number
@ -432,14 +480,16 @@ interface Product {
id: number id: number
devicename: string devicename: string
brandid: number brandid: number
providerid?: number // 归属方案商 = 服务ID(CID) providerid?: number // 归属方案公司 = BID;CID 再由该方案商的 vendor 推出
oemfactoryid?: number // 默认代工厂(oem_factory.id),生产表单只读带出、批次归属按它落库
devicetype?: number devicetype?: number
devicecodecount?: number devicecodecount?: number
} }
interface Channel { interface Channel {
id: string id: string
brandid: number brandid: number // 0=全局渠道商;非 0 只出现在随品牌商自动创建的「官方渠道」上,生产时批次继承它
name: string name: string
contactmails?: string // 生产通知邮件的收件人回退来源(无品牌归属时用它)
status: number // 0待审核 1启用 2停用 3驳回 status: number // 0待审核 1启用 2停用 3驳回
} }
interface Note { interface Note {
@ -455,6 +505,20 @@ interface Note {
devicenum?: number devicenum?: number
batchno?: number | string batchno?: number | string
remark?: string remark?: string
factoryid?: number // 代工厂 id(oem_factory.id),0/缺失 = 未指定(本功能上线前的存量批次)
sharerate?: number // 本批次渠道分成比例(万分比),0/缺失 = 不分成
}
interface OemFactory {
id: number
name: string
enabled?: boolean
}
// 方案公司:BID 直接取它的 id,CID 取它的 vendor(芯片公司 id)
interface Provider {
id: number
name: string
vendor?: number
contactmails?: string // 生产通知邮件的收件人:货是方案公司在打,通知发给它
} }
interface DeviceRow { interface DeviceRow {
code: string code: string
@ -497,6 +561,7 @@ interface PreviewResult {
serialend?: number | string serialend?: number | string
startmac?: string startmac?: string
endmac?: string endmac?: string
importsample?: string[] // 导入模式的前几条 MAC 样例;生产模式为空
} }
interface RevokeCtx { interface RevokeCtx {
brandid: number brandid: number
@ -510,7 +575,7 @@ const { success, error, warn } = useToast()
const auth = useAuthStore() const auth = useAuthStore()
const isSuperAdmin = computed(() => auth.identity === 1) const isSuperAdmin = computed(() => auth.identity === 1)
// 管理员及以上(identity 1=超管, 2=管理员)才可生产授权码 // 管理员及以上(identity 1=超管, 2=管理员)才可生成 MAC
const canCreate = computed(() => auth.identity > 0 && auth.identity <= 2) const canCreate = computed(() => auth.identity > 0 && auth.identity <= 2)
// ========== 页面 Tab ========== // ========== 页面 Tab ==========
@ -522,6 +587,10 @@ function switchPageTab(t: 'codes' | 'batches' | 'produce') {
// ========== 全局状态 ========== // ========== 全局状态 ==========
const brands = ref<Brand[]>([]) const brands = ref<Brand[]>([])
const channels = ref<Channel[]>([]) // 全量渠道商(含未启用),前端按品牌商过滤 const channels = ref<Channel[]>([]) // 全量渠道商(含未启用),前端按品牌商过滤
// 全量代工厂(含已停用):列表列名要能显示历史批次里已停用的工厂,生产下拉再单独筛 enabled
const factories = ref<OemFactory[]>([])
// 方案公司全量:生产二次确认与通知邮件里的 CID/BID 都从这里推
const providers = ref<Provider[]>([])
const products = ref<Product[]>([]) // 当前账号可访问的产品(已按 productids 过滤) const products = ref<Product[]>([]) // 当前账号可访问的产品(已按 productids 过滤)
const allProducts = ref<Product[]>([]) // 全量产品(用于名称查询) const allProducts = ref<Product[]>([]) // 全量产品(用于名称查询)
const notes = ref<Note[]>([]) const notes = ref<Note[]>([])
@ -531,7 +600,6 @@ const devPage = ref(0)
const devPageSize = ref(200) const devPageSize = ref(200)
// ── 共享筛选 ── // ── 共享筛选 ──
const filterBrand = ref(0)
const filterChannel = ref('') const filterChannel = ref('')
const filterProduct = ref(0) const filterProduct = ref(0)
const filterStart = ref('') const filterStart = ref('')
@ -545,11 +613,18 @@ const notesCountText = ref('')
const notesLoading = ref(false) const notesLoading = ref(false)
const notesError = ref('') const notesError = ref('')
// ── 授权码筛选(默认最近生产:全部状态 + 按生产时间倒序) ── // ── MAC 筛选(默认最近生产:全部状态 + 按生产时间倒序) ──
const codeStatus = ref('0') const codeStatus = ref('0')
const codeSort = ref('createtime') const codeSort = ref('createtime')
const codeBatch = ref('') // 批次号(batchno 短码) const codeBatch = ref('') // 批次号(batchno 短码)
const codeCode = ref('') // 本批次给渠道商的分成比例,表单用百分比(更直观),提交时换算成后端的万分比整数。
// 放在批次而不是渠道商上:同一渠道商不同批次可以谈不同比例。
const cSharepct = ref(0)
// 绑定赠送三项,按批次配:同款产品不同批次(不同渠道/不同活动)送的东西经常不一样。
// 三项都是 0 时服务端会回退到产品档案上的旧配置,所以「全 0」= 不覆盖,而不是「不送」。
const cRewardVip = ref(0)
const cRewardTranslate = ref(0)
const cRewardMeeting = ref(0)
const codeMac = ref('') const codeMac = ref('')
const codeUid = ref('') const codeUid = ref('')
const codePageSizeSel = ref('200') const codePageSizeSel = ref('200')
@ -567,10 +642,8 @@ const devicesTable = ref<HTMLElement | null>(null)
const createModal = ref<HTMLDialogElement | null>(null) const createModal = ref<HTMLDialogElement | null>(null)
const confirmModal = ref<HTMLDialogElement | null>(null) const confirmModal = ref<HTMLDialogElement | null>(null)
const createMode = ref<'production' | 'import'>('production') const createMode = ref<'production' | 'import'>('production')
const cBrand = ref(0)
const cChannel = ref('') const cChannel = ref('')
const cProduct = ref(0) const cProduct = ref(0)
const cBindmac = ref(false)
const cNumber = ref<number | null>(null) const cNumber = ref<number | null>(null)
const cMacaddresses = ref('') const cMacaddresses = ref('')
const cRemark = ref('') const cRemark = ref('')
@ -597,30 +670,48 @@ const revokeConfirmOk = computed(() => !!revokeCtx.value && revokeConfirmInput.v
const totalPages = computed(() => Math.max(1, Math.ceil(devTotal.value / devPageSize.value))) const totalPages = computed(() => Math.max(1, Math.ceil(devTotal.value / devPageSize.value)))
const pgInfoText = computed(() => filterProduct.value ? '第 ' + (devPage.value + 1) + ' / ' + totalPages.value + ' 页' : '-') const pgInfoText = computed(() => filterProduct.value ? '第 ' + (devPage.value + 1) + ' / ' + totalPages.value + ' 页' : '-')
// 顶部产品下拉:按所选品牌商过滤 // 顶部产品/渠道商下拉:都列全部。原来按所选品牌商收窄,品牌商筛选去掉后这层过滤没了意义——
const filterProductOptions = computed(() => { // 而且产品与渠道商现在的 brandid 基本都是 0,按它筛只会筛出空列表。
return filterBrand.value const filterProductOptions = computed(() => products.value)
? products.value.filter((p) => p.brandid === filterBrand.value) const filterChannelOptions = computed(() => channels.value)
: products.value // 生产表单的代工厂/方案公司/芯片都由所选产品带出,只读——同一事实在两处能改就一定会不一致。
// 代工厂取产品档案上的 oemfactoryid,服务端也是这么取的(不收前端传值)。
const cFactory = computed(() => {
const p = allProducts.value.find((x) => x.id === cProduct.value)
return Number(p?.oemfactoryid || 0)
}) })
// 顶部渠道商下拉:按所选品牌商过滤(不限品牌商时列全部) // 「名称 (0xBID)」;产品没绑方案商时显示成「未绑定方案」而不是空白,
const filterChannelOptions = computed(() => { // 否则运营看不出是没选产品还是产品缺配置。
return filterBrand.value const providerLabel = computed(() => {
? channels.value.filter((ch) => ch.brandid === filterBrand.value) const p = allProducts.value.find((x) => x.id === cProduct.value)
: channels.value if (!p?.providerid) return '未绑定方案'
const pv = providers.value.find((x) => x.id === p.providerid)
return `${pv?.name || '方案商#' + p.providerid} (0x${hex4(p.providerid)})`
}) })
// 生产表单渠道商下拉:按所选品牌商过滤,且只列已启用的(待审核/停用/驳回不能用于生产) const vendorLabel = computed(() => {
const createChannelOptions = computed(() => { const p = allProducts.value.find((x) => x.id === cProduct.value)
return cBrand.value if (!p?.providerid) return '未绑定方案'
? channels.value.filter((ch) => ch.brandid === cBrand.value && ch.status === 1) const pv = providers.value.find((x) => x.id === p.providerid)
: [] if (!pv?.vendor) return '方案未选芯片'
return `${chipVendorName(pv.vendor)} (0x${hex4(pv.vendor)})`
}) })
// 生产表单产品下拉:按所选品牌商过滤 // 核对页的一行摘要:三项都为 0 时说清楚会回退到产品配置,别让人以为是「不送」
const createProductOptions = computed(() => { const rewardSummary = computed(() => {
return cBrand.value const parts: string[] = []
? products.value.filter((p) => p.brandid === cBrand.value) if (Number(cRewardVip.value) > 0) parts.push(`VIP ${cRewardVip.value} 天`)
: [] if (Number(cRewardTranslate.value) > 0) parts.push(`翻译 ${cRewardTranslate.value} 分钟`)
if (Number(cRewardMeeting.value) > 0) parts.push(`会议 ${cRewardMeeting.value} 分钟`)
return parts.length ? parts.join(' + ') : '未配置(按产品档案上的旧配置发放)'
}) })
// 生产表单渠道商下拉:只列已启用的(待审核/停用/驳回不能用于生产)。
// ⚠️ 不按品牌商过滤:表单已经没有品牌商这一项了,品牌归属反过来由所选渠道商推出
// (品牌官方渠道 → 继承它的品牌商;全局渠道商 → 无品牌归属)。
const createChannelOptions = computed(() => channels.value.filter((ch) => ch.status === 1))
// 生产表单产品下拉:列全部产品,不按品牌商/渠道商过滤。
// 产品已经不带品牌商了(品牌归属改成生产时按批次定,见产品管理表单),
// 再按 brandid 筛会因为产品的 brandid=0 而永远筛出空列表、一个产品都选不到。
const createProductOptions = computed(() => products.value)
// 生产表单所选产品的类型说明(经典蓝牙=1 → 设备码;每副份数 devicecodecount) // 生产表单所选产品的类型说明(经典蓝牙=1 → 设备码;每副份数 devicecodecount)
const createProductInfo = computed(() => { const createProductInfo = computed(() => {
const p = allProducts.value.find((x) => x.id === cProduct.value) const p = allProducts.value.find((x) => x.id === cProduct.value)
@ -629,15 +720,28 @@ const createProductInfo = computed(() => {
}) })
const createFinalTotal = computed(() => (Number(cNumber.value) || 0) * createProductInfo.value.codeCount) const createFinalTotal = computed(() => (Number(cNumber.value) || 0) * createProductInfo.value.codeCount)
const importMacCount = computed(() => cMacaddresses.value.split(',').map((s) => s.trim()).filter(Boolean).length) const importMacCount = computed(() => cMacaddresses.value.split(',').map((s) => s.trim()).filter(Boolean).length)
// 服务ID(CID) = 产品绑定的方案商 id(挂在产品上,不是品牌商上) // 三个要烧进固件的标识,口径固定为:
const previewCid = computed(() => { // CID = 芯片公司(产品 → 方案商 → solution_provider.vendor,即 chip_vendor.id)
if (!preview.value) return 'N/A' // BID = 方案公司(产品 → providerid,即 solution_provider.id)
return cidOfProduct(preview.value.productid) // PID = 产品(product.id)
}) // 两者都从产品的方案商推出来,所以产品必须绑方案商,否则 CID/BID 都定不了。
function hex4(v?: number | null) {
return v ? Number(v).toString(16).padStart(4, '0').toUpperCase() : 'N/A'
}
// BID:产品绑定的方案公司 id
function bidOfProduct(productId: number) {
const p = allProducts.value.find((x) => x.id === productId)
return hex4(p?.providerid)
}
// CID:该方案公司用的芯片公司 id(方案商的 vendor 列)
function cidOfProduct(productId: number) { function cidOfProduct(productId: number) {
const p = allProducts.value.find((x) => x.id === productId) const p = allProducts.value.find((x) => x.id === productId)
return p && p.providerid ? Number(p.providerid).toString(16).padStart(4, '0').toUpperCase() : 'N/A' if (!p?.providerid) return 'N/A'
const pv = providers.value.find((x) => x.id === p.providerid)
return hex4(pv?.vendor)
} }
const previewCid = computed(() => (preview.value ? cidOfProduct(preview.value.productid) : 'N/A'))
const previewBid = computed(() => (preview.value ? bidOfProduct(preview.value.productid) : 'N/A'))
// ========== 工具函数 ========== // ========== 工具函数 ==========
function fmtTime(ts?: number) { function fmtTime(ts?: number) {
@ -646,15 +750,17 @@ function fmtTime(ts?: number) {
return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0') + return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0') +
' ' + String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0') ' ' + String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0')
} }
// 万分比 → 百分比展示。0 显示成 -,免得整列都是意义不大的 0.00%
function ratePct(rate?: number) {
const n = Number(rate) || 0
return n ? (n / 100).toFixed(2) + '%' : '-'
}
function dateToUnix(s: string, endOfDay: boolean) { function dateToUnix(s: string, endOfDay: boolean) {
if (!s) return 0 if (!s) return 0
const d = new Date(s + (endOfDay ? 'T23:59:59' : 'T00:00:00')) const d = new Date(s + (endOfDay ? 'T23:59:59' : 'T00:00:00'))
return Math.floor(d.getTime() / 1000) return Math.floor(d.getTime() / 1000)
} }
function nameOfBrand(id: number): string {
const b = brands.value.find((x) => x.id === id)
return b ? b.brand : String(id || '')
}
function nameOfChannel(id?: string): string { function nameOfChannel(id?: string): string {
if (!id) return '未分配' if (!id) return '未分配'
const ch = channels.value.find((x) => x.id === id) const ch = channels.value.find((x) => x.id === id)
@ -664,6 +770,20 @@ function nameOfProduct(id: number): string {
const p = allProducts.value.find((x) => x.id === id) const p = allProducts.value.find((x) => x.id === id)
return p ? p.devicename : String(id || '') return p ? p.devicename : String(id || '')
} }
// 代工厂名。0/缺失 = 本功能上线前的存量批次,显示「未指定」而不是空白,
// 免得看着像加载失败;查不到名字(工厂被删)时退化成 工厂#N,至少能看出是数据问题。
// 产品归属的方案公司名。没绑方案时返回「未绑定方案」——生产前置校验已经会拦住这种产品,
// 这里只是让邮件里不出现空白行。
function providerNameOfProduct(productId: number): string {
const p = allProducts.value.find((x) => x.id === productId)
if (!p?.providerid) return '未绑定方案'
return providers.value.find((v) => v.id === p.providerid)?.name || ('方案商#' + p.providerid)
}
function nameOfFactory(id?: number): string {
const n = Number(id || 0)
if (!n) return '未指定'
return factories.value.find((f) => f.id === n)?.name ?? ('工厂#' + n)
}
function hexId(id: number) { function hexId(id: number) {
return id ? Number(id).toString(16).toUpperCase() : '' return id ? Number(id).toString(16).toUpperCase() : ''
} }
@ -673,7 +793,7 @@ function serialRange(n: Note): string {
if (!a && !b) return '-' if (!a && !b) return '-'
return a + ' - ' + b return a + ' - ' + b
} }
// 授权码状态展示(封禁优先;圆点 + 文字的轻量样式) // 记录状态展示(封禁优先;圆点 + 文字的轻量样式)
function codeState(d: DeviceRow): { label: string; dot: string; text: string } { function codeState(d: DeviceRow): { label: string; dot: string; text: string } {
if (d.disabled !== 0) return { label: '已禁用', dot: 'bg-red-500', text: 'text-red-600' } if (d.disabled !== 0) return { label: '已禁用', dot: 'bg-red-500', text: 'text-red-600' }
if (d.status === 1) return { label: '已激活', dot: 'bg-emerald-500', text: 'text-emerald-600' } if (d.status === 1) return { label: '已激活', dot: 'bg-emerald-500', text: 'text-emerald-600' }
@ -684,20 +804,28 @@ function codeState(d: DeviceRow): { label: string; dot: string; text: string } {
// ========== 初始化 ========== // ========== 初始化 ==========
async function init() { async function init() {
try { try {
const [fRes, pRes, cRes] = await Promise.all([ const [fRes, pRes, cRes, facRes, pvRes] = await Promise.all([
webApi<{ brands: Brand[] }>('getbrands', {}), webApi<{ brands: Brand[] }>('getbrands', {}),
webApi<{ products: Product[] }>('getproducts', {}), webApi<{ products: Product[] }>('getproducts', {}),
// status=-1 取全部状态:列表列名要能显示历史批次里已停用的渠道商,生产下拉再单独筛 status=1 // status=-1 取全部状态:列表列名要能显示历史批次里已停用的渠道商,生产下拉再单独筛 status=1
webApi<{ channels: Channel[] }>('getchannels', { status: -1 }), webApi<{ channels: Channel[] }>('getchannels', { status: -1 }),
webApi<{ factories: OemFactory[] }>('getoemfactories', {}),
webApi<{ providers: Provider[] }>('getsolutionproviders', {}),
// 芯片厂商:生产表单要按「方案商.vendor → 芯片名」展示 CID,拿不到只影响那一栏的显示
loadChipVendors(webApi),
]) ])
brands.value = fRes?.brands ?? [] brands.value = fRes?.brands ?? []
allProducts.value = pRes?.products ?? [] allProducts.value = pRes?.products ?? []
channels.value = cRes?.channels ?? [] channels.value = cRes?.channels ?? []
factories.value = facRes?.factories ?? []
providers.value = pvRes?.providers ?? []
} catch (e: any) { } catch (e: any) {
error(e?.message ?? '加载基础数据失败') error(e?.message ?? '加载基础数据失败')
brands.value = [] brands.value = []
allProducts.value = [] allProducts.value = []
channels.value = [] channels.value = []
factories.value = []
providers.value = []
} }
// 按账号绑定的 productids 过滤(identity 1 超管不限) // 按账号绑定的 productids 过滤(identity 1 超管不限)
@ -716,16 +844,6 @@ async function init() {
} }
// ========== 筛选联动 ========== // ========== 筛选联动 ==========
function onBrandChange() {
// 切换品牌商后,若当前所选产品/渠道商不在过滤后的列表,则清空
if (filterProduct.value && !filterProductOptions.value.find((p) => p.id === filterProduct.value)) {
filterProduct.value = 0
}
if (filterChannel.value && !filterChannelOptions.value.find((ch) => ch.id === filterChannel.value)) {
filterChannel.value = ''
}
reloadAll()
}
function onChannelChange() { function onChannelChange() {
devPage.value = 0 devPage.value = 0
reloadAll() reloadAll()
@ -759,7 +877,6 @@ async function loadNotes() {
notesError.value = '' notesError.value = ''
const hasTimeFilter = !!(filterStart.value || filterEnd.value) const hasTimeFilter = !!(filterStart.value || filterEnd.value)
const params: Record<string, unknown> = { limit: hasTimeFilter ? 500 : 10 } const params: Record<string, unknown> = { limit: hasTimeFilter ? 500 : 10 }
if (filterBrand.value) params.brandid = filterBrand.value
if (filterChannel.value) params.channelid = filterChannel.value if (filterChannel.value) params.channelid = filterChannel.value
if (filterProduct.value) params.productid = filterProduct.value if (filterProduct.value) params.productid = filterProduct.value
const s = dateToUnix(filterStart.value, false) const s = dateToUnix(filterStart.value, false)
@ -807,7 +924,7 @@ async function submitRevoke() {
batchno: revokeCtx.value.batchno, batchno: revokeCtx.value.batchno,
}) })
closeRevokeDialog() closeRevokeDialog()
success('已撤销批次,删除 ' + (data?.deleted ?? 0) + ' 个授权码') success('已撤销批次,删除 ' + (data?.deleted ?? 0) + ' 条记录')
reloadAll() reloadAll()
} catch (e: any) { } catch (e: any) {
revokeError.value = e?.message ?? '撤销失败' revokeError.value = e?.message ?? '撤销失败'
@ -816,7 +933,7 @@ async function submitRevoke() {
} }
} }
// 点击生产记录跳转到授权码查询(按批次号过滤) // 点击生产记录跳转到 MAC 查询(按批次号过滤)
function viewBatchCodes(pid: number, batchno: string) { function viewBatchCodes(pid: number, batchno: string) {
filterProduct.value = pid filterProduct.value = pid
codeBatch.value = batchno codeBatch.value = batchno
@ -829,7 +946,7 @@ function viewBatchCodes(pid: number, batchno: string) {
}, 100) }, 100)
} }
// ========== 授权码记录加载 ========== // ========== MAC 记录加载 ==========
function buildDeviceFilterParams(): Record<string, unknown> { function buildDeviceFilterParams(): Record<string, unknown> {
const params: Record<string, unknown> = { const params: Record<string, unknown> = {
productid: filterProduct.value, productid: filterProduct.value,
@ -838,11 +955,9 @@ function buildDeviceFilterParams(): Record<string, unknown> {
} }
const st = parseInt(codeStatus.value) const st = parseInt(codeStatus.value)
if (!isNaN(st)) params.status = st if (!isNaN(st)) params.status = st
if (filterBrand.value) params.brandid = filterBrand.value
if (filterChannel.value) params.channelid = filterChannel.value if (filterChannel.value) params.channelid = filterChannel.value
const batch = codeBatch.value.trim() const batch = codeBatch.value.trim()
if (batch) params.batchno = batch if (batch) params.batchno = batch
const code = codeCode.value.trim(); if (code) params.code = code
const mac = codeMac.value.trim(); if (mac) params.devicemac = mac const mac = codeMac.value.trim(); if (mac) params.devicemac = mac
const uid = codeUid.value.trim(); if (uid) params.uid = uid const uid = codeUid.value.trim(); if (uid) params.uid = uid
return params return params
@ -882,7 +997,9 @@ function changePage(delta: number) {
async function resetDevice(license: string) { async function resetDevice(license: string) {
if (!window.confirm('确定重置该设备授权状态?')) return if (!window.confirm('确定重置该设备授权状态?')) return
try { try {
await webApi('resetlicensestatus', { licenses: license }) // 必须带 productid:设备分表(license_<pid>)按产品切,而 code 就是 MAC,
// 导入进来的 MAC 前两字节是外部厂商的 OUI,服务端反解出来会是别人的产品。
await webApi('resetlicensestatus', { licenses: license, productid: Number(filterProduct.value) || 0 })
success('重置成功') success('重置成功')
loadDevices(false) loadDevices(false)
loadNotes() loadNotes()
@ -904,9 +1021,9 @@ function toggleSelectAll() {
async function batchSetDisabled(disabled: number, codes?: string[]) { async function batchSetDisabled(disabled: number, codes?: string[]) {
if (!filterProduct.value) { warn('请先选择产品'); return } if (!filterProduct.value) { warn('请先选择产品'); return }
const list = codes ?? selectedCodes.value.slice() const list = codes ?? selectedCodes.value.slice()
if (!list.length) { warn('请先勾选授权码'); return } if (!list.length) { warn('请先勾选记录'); return }
const action = disabled === -1 ? '禁用' : '恢复' const action = disabled === -1 ? '禁用' : '恢复'
if (!window.confirm('确定' + action + ' ' + list.length + ' 个授权码?' + (disabled === -1 ? '\n禁用后该授权码将无法绑定。' : ''))) return if (!window.confirm('确定' + action + ' ' + list.length + ' 条记录?' + (disabled === -1 ? '\n禁用后对应设备将无法绑定。' : ''))) return
try { try {
const data = await webApi<{ affected?: number }>('disableauthcodes', { productid: filterProduct.value, codes: list, disabled }) const data = await webApi<{ affected?: number }>('disableauthcodes', { productid: filterProduct.value, codes: list, disabled })
success(action + '成功(' + (data?.affected ?? 0) + ' 条)') success(action + '成功(' + (data?.affected ?? 0) + ' 条)')
@ -918,10 +1035,12 @@ async function batchSetDisabled(disabled: number, codes?: string[]) {
// ========== 生产表单 → 预览 → 二次确认 → 生产 ========== // ========== 生产表单 → 预览 → 二次确认 → 生产 ==========
function showCreateForm() { function showCreateForm() {
cBrand.value = 0
cChannel.value = '' cChannel.value = ''
cProduct.value = 0 cProduct.value = 0
cBindmac.value = false cSharepct.value = 0 // 比例是一批一谈的,沿用上一批的值风险太大
cRewardVip.value = 0 // 赠送同理,且宁可漏配(回退产品配置)也不要多发
cRewardTranslate.value = 0
cRewardMeeting.value = 0
cNumber.value = null cNumber.value = null
cMacaddresses.value = '' cMacaddresses.value = ''
cRemark.value = '' cRemark.value = ''
@ -936,11 +1055,6 @@ function closeConfirmModal() {
confirmModal.value?.close() confirmModal.value?.close()
} }
function onCreateBrandChange() {
cProduct.value = 0
cChannel.value = ''
}
function readCSV(ev: Event) { function readCSV(ev: Event) {
const input = ev.target as HTMLInputElement const input = ev.target as HTMLInputElement
const file = input.files?.[0] const file = input.files?.[0]
@ -961,21 +1075,33 @@ function readCSV(ev: Event) {
// 组装生产请求参数(预览与正式生产共用) // 组装生产请求参数(预览与正式生产共用)
function buildCreateParams(): Record<string, unknown> | null { function buildCreateParams(): Record<string, unknown> | null {
const brandId = Number(cBrand.value)
const productId = Number(cProduct.value) const productId = Number(cProduct.value)
const channelId = cChannel.value.trim() const channelId = cChannel.value.trim()
if (!brandId || !productId) { warn('请选择品牌商和产品'); return null }
if (!channelId) { warn('请选择渠道商'); return null } if (!channelId) { warn('请选择渠道商'); return null }
// 服务ID(CID) 取自产品绑定的方案商,没绑就生产不出可用的设备码 if (!productId) { warn('请选择产品'); return null }
// CID/BID 都从产品的方案商推导(BID=方案公司、CID=该方案用的芯片公司),没绑就发不出可用设备码
if (bidOfProduct(productId) === 'N/A') {
warn('该产品未绑定方案,无法确定方案ID(BID),请先到【产品管理】补上归属方案')
return null
}
if (cidOfProduct(productId) === 'N/A') { if (cidOfProduct(productId) === 'N/A') {
warn('该产品未绑定方案商,无法确定服务ID(CID),请先到【产品管理】补上归属方案商') warn('该产品的方案未选芯片,无法确定芯片ID(CID),请先到【方案商管理】给它选芯片')
return null return null
} }
const params: Record<string, unknown> = { brandid: brandId, channelid: channelId, productid: productId, remark: cRemark.value.trim() } // 不传 brandid:品牌归属由服务端按所选渠道商推出(见 resolveProductionBrandId),
// 前端再传一份只会多出一个对不上的口径。
const params: Record<string, unknown> = { channelid: channelId, productid: productId, remark: cRemark.value.trim() }
// 代工厂不传:服务端直接取产品档案上的 oemfactoryid(表单里那一栏只是只读展示)
// 分成比例:百分比 → 万分比整数,避免浮点比例入库
const pct = Number(cSharepct.value) || 0
if (pct < 0 || pct > 100) { warn('分成比例需在 0~100% 之间'); return null }
params.sharerate = Math.round(pct * 100)
params.rewardvip = Math.max(0, Number(cRewardVip.value) || 0)
params.rewardtranslate = Math.max(0, Number(cRewardTranslate.value) || 0)
params.rewardmeeting = Math.max(0, Number(cRewardMeeting.value) || 0)
if (createMode.value === 'production') { if (createMode.value === 'production') {
params.createtype = 1 params.createtype = 1
params.number = Number(cNumber.value) params.number = Number(cNumber.value)
params.isbindmac = cBindmac.value
if (!params.number || (params.number as number) < 1) { warn('请输入有效数量'); return null } if (!params.number || (params.number as number) < 1) { warn('请输入有效数量'); return null }
} else { } else {
params.createtype = 2 params.createtype = 2
@ -1007,25 +1133,25 @@ async function submitPreview() {
async function submitCreate() { async function submitCreate() {
const params = buildCreateParams() const params = buildCreateParams()
if (!params) return if (!params) return
const brandId = params.brandid as number
const productId = params.productid as number const productId = params.productid as number
if (preview.value?.batchno) params.batchno = preview.value.batchno // 回传预分配批次号 if (preview.value?.batchno) params.batchno = preview.value.batchno // 回传预分配批次号
submitting.value = true submitting.value = true
try { try {
const data = await webApi<CreateResult>('createauthcodes', params) const data = await webApi<CreateResult>('createauthcodes', params)
// 结果中的 devices 可能是授权码字符串数组或对象数组,统一规整为对象 // 结果中的 devices 可能是码字符串数组或对象数组,统一规整为对象
const rawDevices = data?.devices ?? [] const rawDevices = data?.devices ?? []
const devs: DeviceRow[] = rawDevices.map((x: any) => ( const devs: DeviceRow[] = rawDevices.map((x: any) => (
typeof x === 'string' typeof x === 'string'
? ({ code: x, brandid: data?.brandid ?? brandId, productid: data?.productid ?? productId } as DeviceRow) ? ({ code: x, brandid: data?.brandid ?? 0, productid: data?.productid ?? productId } as DeviceRow)
: ({ ...x, brandid: x.brandid ?? data?.brandid ?? brandId, productid: x.productid ?? data?.productid ?? productId } as DeviceRow) : ({ ...x, brandid: x.brandid ?? data?.brandid ?? 0, productid: x.productid ?? data?.productid ?? productId } as DeviceRow)
)) ))
closeConfirmModal() closeConfirmModal()
closeCreateModal() closeCreateModal()
if (devs.length > 0) { if (devs.length > 0) {
success('成功生成 ' + devs.length + ' 个授权码,批次 ' + (data?.batchno || '')) success('成功生成 ' + devs.length + ' 条,批次 ' + (data?.batchno || ''))
buildCSV(devs) buildCSV(devs)
openEmailDialog(data ?? {}, brandId, productId) // 品牌商由服务端推出,收件人取不到品牌邮箱时回退渠道商邮箱
openEmailDialog(data ?? {}, data?.brandid ?? 0, productId)
} else { } else {
success('创建成功') success('创建成功')
tab.value = 'batches' tab.value = 'batches'
@ -1039,17 +1165,18 @@ async function submitCreate() {
} }
// ========== CSV ========== // ========== CSV ==========
// 这份 CSV 是发给方案公司/工厂烧录用的,只有 MAC 一列。
// 2026-09-02 起授权码字符串已整体移除,code 就是 MAC,没有第二个可导的标识。
function buildCSV(devs: DeviceRow[]) { function buildCSV(devs: DeviceRow[]) {
const hasMac = devs.some((d) => d.devicemac) const headers = ['Mac地址']
const headers = hasMac ? ['License', 'Mac地址'] : ['License'] const rows = devs.map((d) => [d.devicemac || d.code || ''])
const rows = devs.map((d) => hasMac ? [d.code || '', d.devicemac || ''] : [d.code || ''])
const content = [headers.join(',')].concat(rows.map((r) => r.join(','))).join('\n') const content = [headers.join(',')].concat(rows.map((r) => r.join(','))).join('\n')
csvBlob.value = new Blob([content], { type: 'text/csv;charset=utf-8;' }) csvBlob.value = new Blob([content], { type: 'text/csv;charset=utf-8;' })
const first = devs[0] const first = devs[0]
csvFileName.value = '设备生产_' + (first.brandid || 'unknown') + '_' + (first.productid || 'unknown') + '_' + new Date().toISOString().split('T')[0] + '.csv' csvFileName.value = '设备生产_' + (first.brandid || 'unknown') + '_' + (first.productid || 'unknown') + '_' + new Date().toISOString().split('T')[0] + '.csv'
} }
// 导出当前筛选条件下的「全部」授权码(分页拉取,而非仅当前页) // 导出当前筛选条件下的「全部」记录(分页拉取,而非仅当前页)
async function exportDevicesCSV() { async function exportDevicesCSV() {
if (!filterProduct.value) { warn('请先选择产品'); return } if (!filterProduct.value) { warn('请先选择产品'); return }
try { try {
@ -1078,10 +1205,11 @@ async function exportDevicesCSV() {
function exportNotesCSV() { function exportNotesCSV() {
if (!notes.value.length) { warn('没有数据可导出'); return } if (!notes.value.length) { warn('没有数据可导出'); return }
const headers = ['生产时间', '品牌商', '渠道商', '产品', '批次号', '序号区间', '起始MAC', '结束MAC', '数量'] // 与页面列顺序保持一致:批次号在前,不再有品牌商(生产链路已不涉及品牌商)
const headers = ['批次号', '生产时间', '渠道商', '产品', '代工厂', '分成比例', '序号区间', '起始MAC', '结束MAC', '数量']
const rows = notes.value.map((n) => [ const rows = notes.value.map((n) => [
fmtTime(n.ts), nameOfBrand(n.brandid), nameOfChannel(n.channelid), nameOfProduct(n.productid), n.batchno || '', fmtTime(n.ts), nameOfChannel(n.channelid), nameOfProduct(n.productid),
n.batchno || '', serialRange(n), n.startmac || '', n.endmac || '', n.devicenum || 0, nameOfFactory(n.factoryid), ratePct(n.sharerate), serialRange(n), n.startmac || '-', n.endmac || '-', n.devicenum || 0,
]) ])
const content = [headers.join(',')].concat(rows.map((r) => r.join(','))).join('\n') const content = [headers.join(',')].concat(rows.map((r) => r.join(','))).join('\n')
const blob = new Blob([content], { type: 'text/csv;charset=utf-8;' }) const blob = new Blob([content], { type: 'text/csv;charset=utf-8;' })
@ -1101,15 +1229,25 @@ function downloadBlob(blob: Blob, name: string) {
} }
// ========== 邮件 ========== // ========== 邮件 ==========
// 生产通知的收件人:**方案公司**(产品归属的方案商)的联系邮箱优先——货是方案公司在打,
// MAC 清单也是给它烧录用的。方案公司没填邮箱时才依次退到品牌商、本次所选渠道商,
// 三个都没有就留空让人手填;没有回退的话表单会经常是空的,运营每次都得自己查邮箱。
function productionMailTo(productId: number, brandMails?: string): string[] {
const p = allProducts.value.find((x) => x.id === productId)
const provider = p?.providerid ? providers.value.find((v) => v.id === p.providerid) : undefined
const channel = channels.value.find((ch) => ch.id === cChannel.value)
const raw = [provider?.contactmails, brandMails, channel?.contactmails]
.map((x) => (x || '').trim())
.find((x) => x !== '')
return (raw || '').split(',').map((e) => e.trim()).filter(Boolean)
}
function openEmailDialog(createResult: CreateResult, brandId: number, productId: number) { function openEmailDialog(createResult: CreateResult, brandId: number, productId: number) {
const brand = brands.value.find((f) => f.id === brandId) const brand = brands.value.find((f) => f.id === brandId)
const product = allProducts.value.find((p) => p.id === productId) const product = allProducts.value.find((p) => p.id === productId)
const productName = product ? product.devicename : '未知产品(ID:' + productId + ')' const productName = product ? product.devicename : '未知产品(ID:' + productId + ')'
let emails: string[] = [] const emails = productionMailTo(productId, brand?.contactmails)
if (brand && brand.contactmails) {
emails = brand.contactmails.split(',').map((e) => e.trim()).filter(Boolean)
}
email.to = emails[0] || '' email.to = emails[0] || ''
email.cc = emails.length > 1 ? emails.slice(1).join(',') : '' email.cc = emails.length > 1 ? emails.slice(1).join(',') : ''
@ -1118,11 +1256,14 @@ function openEmailDialog(createResult: CreateResult, brandId: number, productId:
email.content = email.content =
'尊敬的合作伙伴:\n\n' + '尊敬的合作伙伴:\n\n' +
'我公司向贵司发送产品生产通知函。\n\n' + '我公司向贵司发送产品生产通知函。\n\n' +
'设备地址区间为:' + (createResult.startmac || '') + '-' + (createResult.endmac || '') + '\n' + (createResult.startmac
? '设备地址区间为:' + createResult.startmac + '-' + createResult.endmac + '\n'
: '设备地址:见附件 CSV(本批为导入的 MAC,不连续、无区间)\n') +
'序号区间:' + (createResult.serialstart ?? '') + ' - ' + (createResult.serialend ?? '') + '\n' + '序号区间:' + (createResult.serialstart ?? '') + ' - ' + (createResult.serialend ?? '') + '\n' +
'生产批次(BatchNo):' + (createResult.batchno || '') + '\n' + '生产批次(BatchNo):' + (createResult.batchno || '') + '\n' +
'服务ID(CID): 0x' + cidOfProduct(productId) + '\n' + '芯片ID(CID): 0x' + cidOfProduct(productId) + '\n' +
'品牌ID(BID): 0x' + (brandId != null ? Number(brandId).toString(16).padStart(4, '0').toUpperCase() : 'N/A') + '\n' + '方案ID(BID): 0x' + bidOfProduct(productId) + '\n' +
'方案公司: ' + providerNameOfProduct(productId) + '\n' +
'产品ID(PID): 0x' + (productId != null ? Number(productId).toString(16).padStart(4, '0').toUpperCase() : 'N/A') + '\n' + '产品ID(PID): 0x' + (productId != null ? Number(productId).toString(16).padStart(4, '0').toUpperCase() : 'N/A') + '\n' +
'产品名称: ' + productName + '\n' + '产品名称: ' + productName + '\n' +
'生产数量:' + (createResult.number || 0) + '\n\n' + '生产数量:' + (createResult.number || 0) + '\n\n' +
@ -1139,18 +1280,15 @@ function openEmailForList() {
const product = allProducts.value.find((p) => p.id === first.productid) const product = allProducts.value.find((p) => p.id === first.productid)
const productName = product ? product.devicename : '' const productName = product ? product.devicename : ''
let emails: string[] = [] const emails = productionMailTo(first.productid, brand?.contactmails)
if (brand && brand.contactmails) {
emails = brand.contactmails.split(',').map((e) => e.trim()).filter(Boolean)
}
email.to = emails[0] || '' email.to = emails[0] || ''
email.cc = emails.length > 1 ? emails.slice(1).join(',') : '' email.cc = emails.length > 1 ? emails.slice(1).join(',') : ''
const today = new Date().toLocaleDateString() const today = new Date().toLocaleDateString()
email.subject = '【授权码清单】' + (productName || '设备') + ' - ' + today email.subject = '【MAC清单】' + (productName || '设备') + ' - ' + today
email.content = email.content =
'尊敬的合作伙伴:\n\n' + '尊敬的合作伙伴:\n\n' +
'附件为当前查询的授权码清单,共 ' + devices.value.length + ' 条记录。\n' + '附件为当前查询的 MAC 清单,共 ' + devices.value.length + ' 条记录。\n' +
(productName ? '产品:' + productName + '\n' : '') + (productName ? '产品:' + productName + '\n' : '') +
'\n请查收。\n\n此致\n敬礼!' '\n请查收。\n\n此致\n敬礼!'
emailModal.value?.showModal() emailModal.value?.showModal()

244
apps/admin/app/pages/guestaccess.vue

@ -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>

322
apps/admin/app/pages/oemfactory.vue

@ -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()">&larr; 返回列表</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)">&times;</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>

360
apps/admin/app/pages/product.vue

@ -29,7 +29,7 @@
</div> </div>
<div class="form-control flex-1" style="min-width:200px;"> <div class="form-control flex-1" style="min-width:200px;">
<label class="label py-1"><span class="label-text text-xs text-slate-500">关键字</span></label> <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="产品名称 / 产品ID(十六进制)" /> <input v-model="keyword" type="text" class="input input-bordered input-sm w-full" placeholder="产品名称 / PID(十六进制)" />
</div> </div>
<button class="btn btn-ghost btn-sm" @click="resetFilter">重置</button> <button class="btn btn-ghost btn-sm" @click="resetFilter">重置</button>
<span class="text-xs text-slate-400 ml-auto">共 {{ viewRows.length }} / {{ rows.length }} 条</span> <span class="text-xs text-slate-400 ml-auto">共 {{ viewRows.length }} / {{ rows.length }} 条</span>
@ -41,7 +41,7 @@
<table class="table table-zebra text-sm"> <table class="table table-zebra text-sm">
<thead> <thead>
<tr> <tr>
<th class="cursor-pointer select-none" @click="toggleSort('id')">ID{{ sortMark('id') }}</th> <th class="cursor-pointer select-none" @click="toggleSort('id')">PID{{ sortMark('id') }}</th>
<th>图片</th> <th>图片</th>
<th class="cursor-pointer select-none" @click="toggleSort('devicename')">名称{{ sortMark('devicename') }}</th> <th class="cursor-pointer select-none" @click="toggleSort('devicename')">名称{{ sortMark('devicename') }}</th>
<th class="cursor-pointer select-none" @click="toggleSort('brand')">品牌商{{ sortMark('brand') }}</th> <th class="cursor-pointer select-none" @click="toggleSort('brand')">品牌商{{ sortMark('brand') }}</th>
@ -57,7 +57,7 @@
<tr v-if="loading"><td colspan="11" class="text-center py-8"><span class="loading loading-spinner loading-sm"></span></td></tr> <tr v-if="loading"><td colspan="11" class="text-center py-8"><span class="loading loading-spinner loading-sm"></span></td></tr>
<tr v-else-if="!viewRows.length"><td colspan="11" class="text-center text-slate-400 py-8">暂无数据</td></tr> <tr v-else-if="!viewRows.length"><td colspan="11" class="text-center text-slate-400 py-8">暂无数据</td></tr>
<tr v-for="p in viewRows" :key="p.id"> <tr v-for="p in viewRows" :key="p.id">
<td class="font-mono text-sm">{{ hexId(p.id) }}</td> <td class="font-mono text-sm"><span class="badge badge-primary badge-outline badge-sm">0x{{ hex4(p.id) }}</span></td>
<td> <td>
<img v-if="p.productimage" :src="p.productimage" class="w-10 h-10 rounded object-cover" /> <img v-if="p.productimage" :src="p.productimage" class="w-10 h-10 rounded object-cover" />
<div v-else class="w-10 h-10 rounded bg-slate-100 flex items-center justify-center text-slate-300"> <div v-else class="w-10 h-10 rounded bg-slate-100 flex items-center justify-center text-slate-300">
@ -108,49 +108,93 @@
<div class="bg-white rounded-xl shadow-sm border border-slate-100 p-6 max-w-3xl"> <div class="bg-white rounded-xl shadow-sm border border-slate-100 p-6 max-w-3xl">
<form @submit.prevent="saveProduct"> <form @submit.prevent="saveProduct">
<h3 class="text-sm font-semibold text-slate-500 mb-3">基本信息</h3> <h3 class="text-sm font-semibold text-slate-500 mb-3">基本信息</h3>
<!-- PID:产品主键,手工指定的十六进制编码。建档填错能改,但一旦投产(有批次/已发 MAC)就锁死 -->
<div class="form-control mb-4"> <div class="form-control mb-4">
<label class="label"><span class="label-text font-medium">产品名称 *</span></label> <label class="label"><span class="label-text font-medium">PID(十六进制)*</span></label>
<input v-model="form.devicename" type="text" class="input input-bordered w-full" required /> <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.idHex"
type="text"
class="input input-bordered w-full font-mono uppercase rounded-l-none"
placeholder="B001"
maxlength="8"
required
/>
</div>
<span class="label-text-alt text-slate-400 mt-1">
PID 是产品的唯一标识,全后台不可重复,且<b>就是设备 MAC 的前 2 字节</b>。
<b>未投产时可以改</b>;已生产过 MAC / 有生产批次的产品不能改——设备分表
<span class="font-mono">license_&lt;PID&gt;</span> 与已发出去的 MAC 都按它组织,服务端会拒绝。
</span>
<span v-if="!isEditing" class="label-text-alt text-slate-400 mt-1">现有 PID 段从 B001 起,建议接着往后排。</span>
</div> </div>
<!-- 芯片 → 方案商 联动:先定芯片,方案商下拉只列该芯片下的方案。
芯片本身不存在产品上——它由方案商推导(solution_provider.vendor),
存一份会在方案商换芯片时变成对不上的旧值,而 CID(芯片公司) 是要烧进固件的。 -->
<div class="grid grid-cols-2 gap-4 mb-4"> <div class="grid grid-cols-2 gap-4 mb-4">
<div class="form-control"> <div class="form-control">
<label class="label"><span class="label-text font-medium">所属品牌商</span></label> <label class="label">
<select v-model.number="form.brandid" class="select select-bordered w-full"> <span class="label-text font-medium">芯片</span>
<option :value="0">请选择</option> <span class="label-text-alt text-slate-400">用来筛下面的方案</span>
<option v-for="b in brandOptions" :key="b.id" :value="b.id">{{ b.brand }}</option> </label>
<select v-model.number="form.chipid" class="select select-bordered w-full" @change="onChipChange">
<option :value="0">全部芯片</option>
<option v-for="v in chipVendors" :key="v.id" :value="v.id">{{ v.name }} (0x{{ v.id.toString(16).toUpperCase() }})</option>
</select> </select>
<span v-if="!chipVendors.length" class="label-text-alt text-amber-500 mt-1">还没有芯片厂商,请先到【芯片管理】创建</span>
</div> </div>
<div class="form-control"> <div class="form-control">
<label class="label"><span class="label-text font-medium">设备类型</span></label> <label class="label">
<select v-model.number="form.devicetype" class="select select-bordered w-full"> <span class="label-text font-medium">生产工厂</span>
<option v-for="(label, val) in deviceTypeOptions" :key="val" :value="Number(val)">{{ label }}</option> <span class="label-text-alt text-slate-400">默认由哪家厂生产</span>
</label>
<select v-model.number="form.oemfactoryid" class="select select-bordered w-full">
<option :value="0">{{ factoryOptions.length ? '不指定' : '暂无可用工厂' }}</option>
<option v-for="f in factoryOptions" :key="f.id" :value="f.id">{{ f.name }}</option>
</select> </select>
<span v-if="!factoryOptions.length" class="label-text-alt text-slate-400 mt-1">可先留空,之后到【工厂管理】添加再回来补</span>
</div> </div>
</div> </div>
<div class="form-control mb-4">
<label class="label">
<span class="label-text font-medium">产品说明</span>
<span class="label-text-alt text-slate-400">这款产品是什么、有什么特性(与版本更新说明无关)</span>
</label>
<textarea v-model="form.description" class="textarea textarea-bordered w-full" rows="3" placeholder="如:双麦降噪同传耳机,支持 40 种语言实时翻译、会议转写"></textarea>
</div>
<!-- 归属方案商:生产设备码时的服务ID(CID)取这里;不再由品牌商推导 --> <!-- 归属方案:BID 取它的 id,CID 再由它的芯片推出 -->
<div class="form-control mb-4"> <div class="form-control mb-4">
<label class="label"> <label class="label">
<span class="label-text font-medium">归属方案商 *</span> <span class="label-text font-medium">归属方案 *</span>
<span class="label-text-alt text-slate-400">生产设备码的服务ID(CID)取自这里</span> <span class="label-text-alt text-slate-400">方案ID(BID)取自这里,芯片ID(CID)由它的芯片推出</span>
</label> </label>
<select v-model.number="form.providerid" class="select select-bordered w-full" required> <select v-model.number="form.providerid" class="select select-bordered w-full" required>
<option :value="0" disabled>请选择方案商</option> <option :value="0" disabled>请选择方案</option>
<!-- 按芯片厂商分组:同一家方案商公司可能有多套方案,选项带 ID 才分得清是哪一套 --> <!-- 按芯片分组:同一家方案公司可能有多套方案,选项带 BID 才分得清是哪一套 -->
<optgroup v-for="g in providerGroups" :key="g.vendor" :label="g.label"> <optgroup v-for="g in providerGroups" :key="g.vendor" :label="g.label">
<option v-for="pv in g.items" :key="pv.id" :value="pv.id">{{ pv.name }}({{ hexId(pv.id) }})</option> <option v-for="pv in g.items" :key="pv.id" :value="pv.id">{{ pv.name }}({{ hexId(pv.id) }})</option>
</optgroup> </optgroup>
</select> </select>
<span v-if="!providers.length" class="label-text-alt text-amber-500 mt-1">还没有方案商,请先到【方案商管理】创建</span> <span v-if="!providers.length" class="label-text-alt text-amber-500 mt-1">还没有方案商,请先到【方案商管理】创建</span>
<span v-else-if="form.chipid && !providerGroups.length" class="label-text-alt text-amber-500 mt-1">该芯片下还没有方案,换个芯片或先去【方案商管理】添加</span>
</div> </div>
<div class="form-control mb-4">
<label class="label"><span class="label-text font-medium">产品名称 *</span></label>
<input v-model="form.devicename" type="text" class="input input-bordered w-full" required />
</div>
<!-- 品牌商已从产品上移除:生产阶段不关心产品属于哪个品牌,品牌归属在 MAC生成 时按批次选。 -->
<div class="form-control mb-4">
<label class="label"><span class="label-text font-medium">设备类型</span></label>
<select v-model.number="form.devicetype" class="select select-bordered w-full">
<option v-for="(label, val) in deviceTypeOptions" :key="val" :value="Number(val)">{{ label }}</option>
</select>
</div>
<div class="form-control mb-4">
<label class="label">
<span class="label-text font-medium">产品说明</span>
<span class="label-text-alt text-slate-400">这款产品是什么、有什么特性(与版本更新说明无关)</span>
</label>
<textarea v-model="form.description" class="textarea textarea-bordered w-full" rows="3" placeholder="如:双麦降噪同传耳机,支持 40 种语言实时翻译、会议转写"></textarea>
</div>
<!-- 绑定应用:决定该产品的数据算在哪个应用下,也决定品牌商/渠道商账号能选到哪些应用 --> <!-- 绑定应用:决定该产品的数据算在哪个应用下,也决定品牌商/渠道商账号能选到哪些应用 -->
<div class="form-control mb-4"> <div class="form-control mb-4">
<label class="label"> <label class="label">
@ -182,27 +226,6 @@
</div> </div>
</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>
<select v-model="form.channel" class="select select-bordered w-full">
<option value="official">官方</option>
<option value="mobile">移动</option>
</select>
</div>
</div>
<div v-show="form.channel === 'mobile'" class="mb-4">
<div class="grid grid-cols-3 gap-4">
<div class="form-control"><label class="label"><span class="label-text font-medium">移动平台ID</span></label>
<input v-model="mobileExtra.platform_id" type="text" class="input input-bordered w-full" placeholder="移动平台分配的 ID" /></div>
<div class="form-control"><label class="label"><span class="label-text font-medium">移动平台KEY</span></label>
<input v-model="mobileExtra.platform_key" type="text" class="input input-bordered w-full" placeholder="移动平台分配的 KEY" /></div>
<div class="form-control"><label class="label"><span class="label-text font-medium">TUI(8位)</span></label>
<input v-model="mobileExtra.tui" type="text" maxlength="8" class="input input-bordered w-full" placeholder="CMEI 前缀,如 11113396" /></div>
</div>
</div>
<!-- 产品图片 --> <!-- 产品图片 -->
<div class="form-control mb-4"> <div class="form-control mb-4">
<label class="label"><span class="label-text font-medium">产品图片</span></label> <label class="label"><span class="label-text font-medium">产品图片</span></label>
@ -222,25 +245,6 @@
</div> </div>
</div> </div>
<!-- 绑定奖励 -->
<div class="divider text-sm text-slate-400">绑定奖励设置</div>
<div class="grid grid-cols-3 gap-4 mb-4">
<div class="form-control"><label class="label"><span class="label-text font-medium">VIP时长(天)</span></label>
<input v-model.number="form.bindrewardvpitime" type="number" min="0" class="input input-bordered w-full" /></div>
<div class="form-control"><label class="label"><span class="label-text font-medium">翻译时长(分钟)</span></label>
<input v-model.number="form.bindrewardtranslate" type="number" min="0" class="input input-bordered w-full" /></div>
<div class="form-control"><label class="label"><span class="label-text font-medium">会议时长(分钟)</span></label>
<input v-model.number="form.bindrewardmeeting" type="number" min="0" class="input input-bordered w-full" /></div>
</div>
<!-- 功能开关 -->
<div class="divider text-sm text-slate-400">功能开关</div>
<div class="grid grid-cols-3 gap-3 mb-6">
<label v-for="t in toggleFields" :key="t.key" class="flex items-center gap-2 cursor-pointer">
<input v-model="form.toggles[t.key]" type="checkbox" class="toggle toggle-primary toggle-sm" />
<span class="text-sm">{{ t.label }}</span>
</label>
</div>
<div class="flex gap-3"> <div class="flex gap-3">
<button type="button" class="btn btn-ghost" @click="showList()">取消</button> <button type="button" class="btn btn-ghost" @click="showList()">取消</button>
@ -259,14 +263,17 @@
<div class="bg-slate-50 rounded-lg p-4 text-sm space-y-2 mb-4"> <div class="bg-slate-50 rounded-lg p-4 text-sm space-y-2 mb-4">
<div class="flex"><span class="text-slate-500 w-28">产品名称</span><span class="font-medium">{{ form.devicename || '-' }}</span></div> <div class="flex"><span class="text-slate-500 w-28">产品名称</span><span class="font-medium">{{ form.devicename || '-' }}</span></div>
<div v-if="form.description" class="flex"><span class="text-slate-500 w-28 shrink-0">产品说明</span><span class="whitespace-pre-wrap">{{ form.description }}</span></div> <div v-if="form.description" class="flex"><span class="text-slate-500 w-28 shrink-0">产品说明</span><span class="whitespace-pre-wrap">{{ form.description }}</span></div>
<div class="flex"><span class="text-slate-500 w-28">品牌商(BID)</span> <div class="flex"><span class="text-slate-500 w-28">产品(PID)</span>
<span class="font-medium">{{ brandName(form.brandid) }}<span class="font-mono text-slate-400 ml-2">0x{{ hex4(form.brandid) }}</span></span></div> <span class="font-medium font-mono">0x{{ (parsePid() ?? 0).toString(16).toUpperCase().padStart(4, '0') }}</span></div>
<div class="flex"><span class="text-slate-500 w-28">方案商(CID)</span> <div class="flex"><span class="text-slate-500 w-28">芯片(CID)</span>
<span class="font-medium">{{ chipVendorName(chipOfForm) }}<span class="font-mono text-slate-400 ml-2">0x{{ hex4(chipOfForm) }}</span></span></div>
<div class="flex"><span class="text-slate-500 w-28">方案(BID)</span>
<span class="font-medium">{{ providerLabel(form.providerid) }}<span class="font-mono text-slate-400 ml-2">0x{{ hex4(form.providerid) }}</span></span></div> <span class="font-medium">{{ providerLabel(form.providerid) }}<span class="font-mono text-slate-400 ml-2">0x{{ hex4(form.providerid) }}</span></span></div>
<div class="flex"><span class="text-slate-500 w-28">生产工厂</span><span>{{ factoryName(form.oemfactoryid) }}</span></div>
<div class="flex"><span class="text-slate-500 w-28">设备类型</span><span>{{ deviceTypeOptions[form.devicetype] || form.devicetype }}</span></div> <div class="flex"><span class="text-slate-500 w-28">设备类型</span><span>{{ deviceTypeOptions[form.devicetype] || form.devicetype }}</span></div>
<div class="flex"><span class="text-slate-500 w-28">设备码数量</span><span>{{ form.devicecodecount === 2 ? '双份 (2个/副)' : '单份 (1个/副)' }}</span></div> <div class="flex"><span class="text-slate-500 w-28">设备码数量</span><span>{{ form.devicecodecount === 2 ? '双份 (2个/副)' : '单份 (1个/副)' }}</span></div>
<div class="flex"><span class="text-slate-500 w-28">绑定应用</span><span>{{ form.appnames.length ? form.appnames.join('、') : '未绑定' }}</span></div> <div class="flex"><span class="text-slate-500 w-28">绑定应用</span><span>{{ form.appnames.length ? form.appnames.join('、') : '未绑定' }}</span></div>
<p class="text-xs text-slate-400 pt-1">产品ID(PID) 由系统在创建后分配,会一并写进通知邮件。</p> <p class="text-xs text-slate-400 pt-1">PID 为上面手工指定的值,会一并写进通知邮件。</p>
</div> </div>
<label class="flex items-center gap-2 cursor-pointer mb-3"> <label class="flex items-center gap-2 cursor-pointer mb-3">
@ -343,11 +350,30 @@
<!-- 当前版本信息 --> <!-- 当前版本信息 -->
<div v-if="currentProduct" class="bg-white rounded-xl shadow-sm border border-slate-100 p-4 mb-5"> <div v-if="currentProduct" class="bg-white rounded-xl shadow-sm border border-slate-100 p-4 mb-5">
<div class="flex items-center gap-6 text-sm flex-wrap"> <div class="flex items-center gap-6 text-sm flex-wrap">
<span class="text-slate-400">产品ID:</span><span class="font-mono">{{ hexId(currentProduct.id) }}</span> <span class="text-slate-400">PID:</span><span class="font-mono">0x{{ hex4(currentProduct.id) }}</span>
<span class="text-slate-400">当前版本:</span><span class="badge badge-primary badge-sm">{{ currentProduct.version || '无' }}</span> <span class="text-slate-400">当前版本:</span><span class="badge badge-primary badge-sm">{{ currentProduct.version || '无' }}</span>
<span class="text-slate-400">版本ID:</span><span class="text-xs">{{ currentProduct.versionid || '无' }}</span> <span class="text-slate-400">版本ID:</span><span class="text-xs">{{ currentProduct.versionid || '无' }}</span>
<span class="text-slate-400">更新包:</span><span class="text-xs max-w-md truncate">{{ currentProduct.updatepackageaddress || '无' }}</span> <span class="text-slate-400">更新包:</span><span class="text-xs max-w-md truncate">{{ currentProduct.updatepackageaddress || '无' }}</span>
</div> </div>
<div class="divider my-2"></div>
<label class="flex items-start gap-2 cursor-pointer">
<input
type="checkbox"
class="checkbox checkbox-warning checkbox-sm mt-0.5"
:checked="!!currentProduct.updateonmismatch"
:disabled="mismatchSaving"
@change="toggleUpdateOnMismatch(($event.target as HTMLInputElement).checked)"
/>
<span class="text-sm">
版本不一致即可升级
<span class="loading loading-spinner loading-xs ml-1" v-if="mismatchSaving"></span>
<span class="block text-xs text-slate-400 mt-0.5">
开发/测试用:勾上后设备版本只要和服务器版本<b>不同</b>就能点升级(可刷回旧固件反复验证)。
不勾则只有服务器版本<b>更高</b>时才允许升级。
<b class="text-warning">正式产品请勿开启</b>——挂错一个旧版固件会把全量用户降级。
</span>
</span>
</label>
</div> </div>
<!-- 添加新版本 --> <!-- 添加新版本 -->
<div class="bg-white rounded-xl shadow-sm border border-slate-100 p-5 mb-5"> <div class="bg-white rounded-xl shadow-sm border border-slate-100 p-5 mb-5">
@ -375,7 +401,7 @@
<div class="bg-white rounded-xl shadow-sm border border-slate-100"> <div class="bg-white rounded-xl shadow-sm border border-slate-100">
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<table class="table table-zebra text-sm"> <table class="table table-zebra text-sm">
<thead><tr><th>ID</th><th>版本号</th><th>描述</th><th>更新包地址</th><th>操作</th></tr></thead> <thead><tr><th>版本ID</th><th>版本号</th><th>描述</th><th>更新包地址</th><th>操作</th></tr></thead>
<tbody> <tbody>
<tr v-if="!versions.length"><td colspan="5" class="text-center text-slate-400 py-8">暂无版本</td></tr> <tr v-if="!versions.length"><td colspan="5" class="text-center text-slate-400 py-8">暂无版本</td></tr>
<tr v-for="v in versions" :key="v.id"> <tr v-for="v in versions" :key="v.id">
@ -409,7 +435,7 @@ import { ref, reactive, computed, onMounted } from 'vue'
import { useApi } from '~/composables/useApi' import { useApi } from '~/composables/useApi'
import { useToast } from '~/composables/useToast' import { useToast } from '~/composables/useToast'
import { useAuthStore } from '~/stores/auth' import { useAuthStore } from '~/stores/auth'
import { CHIP_VENDORS, chipVendorName } from '~/utils/chipvendors' import { chipVendors, loadChipVendors, chipVendorName } from '~/utils/chipvendors'
import { useTableSort } from '~/composables/useTableSort' import { useTableSort } from '~/composables/useTableSort'
definePageMeta({ title: '产品管理' }) definePageMeta({ title: '产品管理' })
@ -418,11 +444,13 @@ interface Product {
id: number; devicename: string; brandid: number; providerid?: number; devicetype: number id: number; devicename: string; brandid: number; providerid?: number; devicetype: number
description?: string // 产品说明(特性描述) description?: string // 产品说明(特性描述)
createtime?: number createtime?: number
oemfactoryid?: number // 代工厂 id(oem_factory.id),0/缺失 = 未指定
scanuuid: string; devicecodecount: number; productimage: string scanuuid: string; devicecodecount: number; productimage: string
channel: string; channelextra: string channel: string; channelextra: string
appnames?: string // 绑定应用名 CSV(空=未绑定) appnames?: string // 绑定应用名 CSV(空=未绑定)
bindrewardvpitime: number; bindrewardtranslate: number; bindrewardmeeting: number bindrewardvpitime: number; bindrewardtranslate: number; bindrewardmeeting: number
version?: string; versionid?: number; updatepackageaddress?: string version?: string; versionid?: number; updatepackageaddress?: string
updateonmismatch?: boolean // 版本不一致即可升级(开发用,默认关)
[key: string]: unknown [key: string]: unknown
} }
interface Version { id: number; version: string; description: string; updatepackageaddress: string; productid: number } interface Version { id: number; version: string; description: string; updatepackageaddress: string; productid: number }
@ -436,23 +464,6 @@ const auth = useAuthStore()
const deviceTypes: Record<number, string> = { 0: '未知', 1: '经典蓝牙', 2: 'BLE双端', 3: 'BLE单端', 4: '录音笔', 5: '智能眼镜', 6: '激活码卡片' } const deviceTypes: Record<number, string> = { 0: '未知', 1: '经典蓝牙', 2: 'BLE双端', 3: 'BLE单端', 4: '录音笔', 5: '智能眼镜', 6: '激活码卡片' }
const deviceTypeOptions: Record<number, string> = { 0: '未知', 1: '经典蓝牙耳机', 2: 'BLE双端', 3: 'BLE单端', 4: '智能录音笔', 5: '智能眼镜', 6: '激活码卡片' } const deviceTypeOptions: Record<number, string> = { 0: '未知', 1: '经典蓝牙耳机', 2: 'BLE双端', 3: 'BLE单端', 4: '智能录音笔', 5: '智能眼镜', 6: '激活码卡片' }
const toggleFields = [
{ key: 'iscanbindagain', label: '可重复绑定奖励' },
{ key: 'iscontinuouschat', label: '连续对话' },
{ key: 'eqenable', label: 'EQ 均衡器' },
{ key: 'keyenable', label: '按键控制' },
{ key: 'workmodeenable', label: '工作模式' },
{ key: 'callmodeenable', label: '通话模式' },
{ key: 'ledenable', label: 'LED 灯' },
{ key: 'finddeviceenable', label: '查找设备' },
{ key: 'noisereductionenable', label: '降噪' },
{ key: 'shutdownenable', label: '定时关机' },
{ key: 'showcasebattery', label: '充电盒电量' },
{ key: 'wakeupenable', label: '唤醒控制' },
{ key: 'broadcastpeertranslate', label: '播报对方翻译' },
{ key: 'hideaiassistant', label: '隐藏AI助手' },
]
const view = ref<'list' | 'form' | 'version'>('list') const view = ref<'list' | 'form' | 'version'>('list')
const rows = ref<Product[]>([]) const rows = ref<Product[]>([])
const brands = ref<Record<number, string>>({}) const brands = ref<Record<number, string>>({})
@ -469,19 +480,33 @@ const loading = ref(false)
const submitting = ref(false) const submitting = ref(false)
const isEditing = ref(false) const isEditing = ref(false)
function emptyToggles(): Record<string, boolean> {
const o: Record<string, boolean> = {}
toggleFields.forEach((t) => { o[t.key] = false })
return o
}
const form = reactive({ const form = reactive({
id: 0, devicename: '', description: '', brandid: 0, providerid: 0, devicetype: 0, scanuuid: '', devicecodecount: 1, // id 是当前 PID,idHex 是编辑框里的十六进制值;改 PID 时两者不同,靠 id 做定位、idHex 做新值
id: 0, idHex: '', devicename: '', description: '', brandid: 0, providerid: 0, devicetype: 0, scanuuid: '', devicecodecount: 1,
// chipid 只用于筛下面的方案下拉,不落库——芯片由方案商推导,见模板注释
chipid: 0, oemfactoryid: 0,
productimage: '', channel: 'official', appnames: [] as string[], productimage: '', channel: 'official', appnames: [] as string[],
bindrewardvpitime: 0, bindrewardtranslate: 0, bindrewardmeeting: 0,
toggles: emptyToggles() as Record<string, boolean>,
}) })
const mobileExtra = reactive({ platform_id: '', platform_key: '', tui: '' })
// 代工厂(含停用的:存量产品可能挂在停用工厂上,列表要显示得出名字;下拉另筛)
interface OemFactory { id: number; name: string; enabled?: boolean }
const factories = ref<OemFactory[]>([])
const factoryOptions = computed(() => factories.value.filter((f) => f.enabled !== false))
function factoryName(id?: number) {
const n = Number(id || 0)
if (!n) return '未指定'
return factories.value.find((f) => f.id === n)?.name ?? ('工厂#' + n)
}
// 当前所选方案对应的芯片公司 id —— 即 CID。始终由方案商推出,不单独存,
// 这样方案商改了芯片,产品这边显示的 CID 立刻跟着变,不会留下对不上的旧值。
const chipOfForm = computed(() => Number(providers.value.find((x) => x.id === form.providerid)?.vendor || 0))
// 换芯片后,若当前所选方案不属于该芯片则清空,避免留下一个跟芯片对不上的方案
function onChipChange() {
if (!form.chipid) return
const pv = providers.value.find((x) => x.id === form.providerid)
if (!pv || Number(pv.vendor || 0) !== form.chipid) form.providerid = 0
}
function hexId(id: number) { return id ? Number(id).toString(16).toUpperCase() : '' } function hexId(id: number) { return id ? Number(id).toString(16).toUpperCase() : '' }
function hex4(id: number) { return id ? Number(id).toString(16).padStart(4, '0').toUpperCase() : 'N/A' } function hex4(id: number) { return id ? Number(id).toString(16).padStart(4, '0').toUpperCase() : 'N/A' }
@ -501,9 +526,12 @@ function providerLabel(id?: number) {
} }
// 方案商下拉/筛选按芯片厂商分组(未分组的归到最后一组) // 方案商下拉/筛选按芯片厂商分组(未分组的归到最后一组)
const providerGroups = computed(() => { const providerGroups = computed(() => {
const groups = CHIP_VENDORS.map((v) => ({ vendor: v.value, label: v.label, items: [] as Provider[] })) // 含已停用的厂商:存量方案商可能挂在停用厂商下,漏了它们会被归进「未分组」那一组
const groups = chipVendors.value.map((v) => ({ vendor: v.id, label: v.name, items: [] as Provider[] }))
groups.push({ vendor: 0, label: chipVendorName(0), items: [] as Provider[] }) groups.push({ vendor: 0, label: chipVendorName(0), items: [] as Provider[] })
for (const pv of providers.value) { // 选了芯片就只看该芯片下的方案;选「全部芯片」时不筛
const pool = form.chipid ? providers.value.filter((pv) => Number(pv.vendor || 0) === form.chipid) : providers.value
for (const pv of pool) {
const g = groups.find((x) => x.vendor === Number(pv.vendor || 0)) ?? groups[groups.length - 1]! const g = groups.find((x) => x.vendor === Number(pv.vendor || 0)) ?? groups[groups.length - 1]!
g.items.push(pv) g.items.push(pv)
} }
@ -546,7 +574,14 @@ function resetFilter() {
function appNamesOf(p: Product): string[] { function appNamesOf(p: Product): string[] {
return String(p.appnames || '').split(',').map((s) => s.trim()).filter(Boolean) return String(p.appnames || '').split(',').map((s) => s.trim()).filter(Boolean)
} }
function featureCount(p: Product) { return toggleFields.filter((t) => !!p[t.key]).length } // 功能开关已从产品表单移除(改由别处配置),但字段仍在库里、仍下发给客户端,
// 列表保留一个「已开启 N/14」的只读计数,方便一眼看出这个产品开了哪些能力。
const TOGGLE_KEYS = [
'iscanbindagain', 'iscontinuouschat', 'eqenable', 'keyenable', 'workmodeenable',
'callmodeenable', 'ledenable', 'finddeviceenable', 'noisereductionenable',
'shutdownenable', 'showcasebattery', 'wakeupenable', 'broadcastpeertranslate', 'hideaiassistant',
]
function featureCount(p: Product) { return TOGGLE_KEYS.filter((k) => !!p[k]).length }
// ── 列表 ── // ── 列表 ──
async function loadData() { async function loadData() {
@ -573,7 +608,7 @@ async function loadBrands() {
} catch { brandOptions.value = [] } } catch { brandOptions.value = [] }
} }
// 方案商:产品必须绑一个,生产设备码的服务ID(CID) 取自它 // 方案公司:产品必须绑一个。BID 取它的 id,CID 取它的 vendor(芯片公司)
async function loadProviders() { async function loadProviders() {
try { try {
const d = await webApi<{ providers: Provider[] }>('getsolutionproviders', {}) const d = await webApi<{ providers: Provider[] }>('getsolutionproviders', {})
@ -581,6 +616,14 @@ async function loadProviders() {
} catch { providers.value = [] } } catch { providers.value = [] }
} }
// 代工厂:产品可选绑一个,作为「默认由哪家厂生产」;具体批次在 MAC 生成页还能再选一次
async function loadFactories() {
try {
const d = await webApi<{ factories: OemFactory[] }>('getoemfactories', {})
factories.value = d?.factories ?? []
} catch { factories.value = [] }
}
// 应用候选取自应用中心(console/api/products/list,即 app_product 表),与部署无关,只按应用名绑。 // 应用候选取自应用中心(console/api/products/list,即 app_product 表),与部署无关,只按应用名绑。
async function loadAppOptions() { async function loadAppOptions() {
try { try {
@ -595,83 +638,84 @@ function showForm(p?: Product) {
loadBrands() loadBrands()
loadProviders() loadProviders()
loadAppOptions() loadAppOptions()
loadFactories()
if (p) { if (p) {
isEditing.value = true isEditing.value = true
form.id = p.id form.id = p.id
form.idHex = hexId(p.id)
form.devicename = p.devicename || '' form.devicename = p.devicename || ''
form.description = p.description || '' form.description = p.description || ''
form.brandid = p.brandid || 0
form.providerid = p.providerid || 0 form.providerid = p.providerid || 0
// 芯片不落库,由所选方案商反推,保证展示的芯片永远等于方案商当前归属
form.chipid = Number(providers.value.find((x) => x.id === p.providerid)?.vendor || 0)
form.oemfactoryid = p.oemfactoryid || 0
form.devicetype = p.devicetype || 0 form.devicetype = p.devicetype || 0
form.scanuuid = p.scanuuid || '' form.scanuuid = p.scanuuid || ''
form.devicecodecount = p.devicecodecount === 2 ? 2 : 1 form.devicecodecount = p.devicecodecount === 2 ? 2 : 1
form.productimage = p.productimage || '' form.productimage = p.productimage || ''
form.channel = p.channel || 'official'
form.appnames = appNamesOf(p) form.appnames = appNamesOf(p)
form.bindrewardvpitime = p.bindrewardvpitime || 0
form.bindrewardtranslate = p.bindrewardtranslate || 0
form.bindrewardmeeting = p.bindrewardmeeting || 0
let extra: any = {}
try { extra = p.channelextra ? JSON.parse(p.channelextra) : {} } catch { extra = {} }
mobileExtra.platform_id = extra.platform_id || ''
mobileExtra.platform_key = extra.platform_key || ''
mobileExtra.tui = extra.tui || ''
toggleFields.forEach((t) => { form.toggles[t.key] = !!p[t.key] })
} else { } else {
isEditing.value = false isEditing.value = false
form.id = 0 form.id = 0
form.idHex = ''
form.chipid = 0
form.oemfactoryid = 0
form.devicename = '' form.devicename = ''
form.description = '' form.description = ''
form.brandid = 0
form.providerid = 0 form.providerid = 0
form.devicetype = 0 form.devicetype = 0
form.scanuuid = '' form.scanuuid = ''
form.devicecodecount = 1 form.devicecodecount = 1
form.productimage = '' form.productimage = ''
form.channel = 'official'
form.appnames = [] form.appnames = []
form.bindrewardvpitime = 0
form.bindrewardtranslate = 0
form.bindrewardmeeting = 0
mobileExtra.platform_id = ''
mobileExtra.platform_key = ''
mobileExtra.tui = ''
toggleFields.forEach((t) => { form.toggles[t.key] = false })
} }
view.value = 'form' view.value = 'form'
} }
function buildProductPayload(): Record<string, unknown> { function buildProductPayload(): Record<string, unknown> {
let channelextra = '' // 只下发本表单实际编辑的字段。
if (form.channel === 'mobile') { // 品牌商 / 渠道方(channel,channelextra) / 绑定奖励 / 功能开关 已从表单移除,故不再下发——
channelextra = JSON.stringify({ platform_id: mobileExtra.platform_id || '', platform_key: mobileExtra.platform_key || '', tui: mobileExtra.tui || '' }) // 服务端 updateProduct 会把这几项从旧记录原样保留,编辑产品不会把它们清空。
}
const product: Record<string, unknown> = { const product: Record<string, unknown> = {
devicename: form.devicename, devicename: form.devicename,
description: form.description, description: form.description,
brandid: Number(form.brandid) || 0,
providerid: Number(form.providerid) || 0, providerid: Number(form.providerid) || 0,
devicetype: Number(form.devicetype) || 0, devicetype: Number(form.devicetype) || 0,
scanuuid: form.scanuuid, scanuuid: form.scanuuid,
devicecodecount: Number(form.devicecodecount) || 1, devicecodecount: Number(form.devicecodecount) || 1,
productimage: form.productimage, productimage: form.productimage,
channel: form.channel,
channelextra,
appnames: form.appnames.join(','), appnames: form.appnames.join(','),
bindrewardvpitime: Number(form.bindrewardvpitime) || 0, oemfactoryid: Number(form.oemfactoryid) || 0,
bindrewardtranslate: Number(form.bindrewardtranslate) || 0,
bindrewardmeeting: Number(form.bindrewardmeeting) || 0,
} }
toggleFields.forEach((t) => { product[t.key] = form.toggles[t.key] }) // PID 新增时也手填:一律从输入框解析(编辑改号时由调用方另传 oldid 定位旧记录)
if (isEditing.value) product.id = Number(form.id) product.id = parsePid() ?? 0
return product return product
} }
// 解析 PID 输入框;不合法返回 null(调用方负责提示)。新增/编辑共用一套口径。
function parsePid(): number | null {
const hex = form.idHex.trim().replace(/^0x/i, '')
if (!/^[0-9a-fA-F]{1,8}$/.test(hex)) return null
const n = Number.parseInt(hex, 16)
return n > 0 ? n : null
}
// PID 合法性 + 全后台唯一(排除自己)。返回 true 表示可以提交。
function checkPid(): boolean {
const pid = parsePid()
if (pid === null) { warn('PID 需填十六进制且不为 0,如 B001'); return false }
if (rows.value.some((p) => Number(p.id) === pid && Number(p.id) !== form.id)) {
warn(`PID ${pid.toString(16).toUpperCase()} 已被占用`)
return false
}
return true
}
// 编辑直接保存;新增走二次确认(确认框里同时定好通知邮件的收件人/抄送) // 编辑直接保存;新增走二次确认(确认框里同时定好通知邮件的收件人/抄送)
async function saveProduct() { async function saveProduct() {
if (!form.devicename.trim()) { warn('请输入产品名称'); return } if (!form.devicename.trim()) { warn('请输入产品名称'); return }
if (!form.brandid) { warn('请选择所属品牌商'); return } if (!checkPid()) return
if (!form.providerid) { warn('请选择归属方案商(生产设备码的 CID 取自它)'); return } if (!form.providerid) { warn('请选择归属方案(方案ID(BID) 取自它)'); return }
if (!isEditing.value) { if (!isEditing.value) {
prepareNotify() prepareNotify()
confirmModal.value?.showModal() confirmModal.value?.showModal()
@ -679,7 +723,9 @@ async function saveProduct() {
} }
submitting.value = true submitting.value = true
try { try {
await webApi('updateproduct', { product: buildProductPayload() }) const product = buildProductPayload()
// 带原 PID:服务端拿它定位记录,再决定要不要改主键
await webApi('updateproduct', { product, oldid: Number(form.id) })
success('保存成功') success('保存成功')
showList() showList()
} catch (e: any) { } catch (e: any) {
@ -727,6 +773,7 @@ function closeConfirm() { confirmModal.value?.close() }
async function doCreate() { async function doCreate() {
if (notify.enabled && !notify.to.trim()) { warn('请填写通知邮件的收件人(品牌方邮箱)'); return } if (notify.enabled && !notify.to.trim()) { warn('请填写通知邮件的收件人(品牌方邮箱)'); return }
if (!checkPid()) { closeConfirm(); return }
submitting.value = true submitting.value = true
try { try {
const d = await webApi<{ product: Product }>('addproduct', { product: buildProductPayload() }) const d = await webApi<{ product: Product }>('addproduct', { product: buildProductPayload() })
@ -746,7 +793,8 @@ async function doCreate() {
} }
} }
// 邮件正文给客户录入用:方案商ID(CID) / 品牌商ID(BID) / 产品ID(PID) 三个十六进制标识 // 邮件正文给客户录入用:芯片ID(CID) / 方案ID(BID) / 产品ID(PID) 三个十六进制标识
// 口径固定:CID=芯片公司、BID=方案公司、PID=产品。CID 由所选方案商的 vendor 推出,不单独存。
function buildNotifyMail(pid: number) { function buildNotifyMail(pid: number) {
const b = brandOptions.value.find((x) => x.id === form.brandid) const b = brandOptions.value.find((x) => x.id === form.brandid)
const pv = providers.value.find((x) => x.id === form.providerid) const pv = providers.value.find((x) => x.id === form.providerid)
@ -755,8 +803,8 @@ function buildNotifyMail(pid: number) {
notify.content = notify.content =
'尊敬的合作伙伴:\n\n' + '尊敬的合作伙伴:\n\n' +
'贵司产品「' + form.devicename + '」已在平台创建完成,相关标识如下,请按此录入生产与开发环境:\n\n' + '贵司产品「' + form.devicename + '」已在平台创建完成,相关标识如下,请按此录入生产与开发环境:\n\n' +
'方案商(CID): 0x' + hex4(form.providerid) + ' ' + (pv ? pv.name + '(' + chipVendorName(pv.vendor) + ')' : '') + '\n' + '芯片ID(CID): 0x' + hex4(chipOfForm.value) + ' ' + chipVendorName(chipOfForm.value) + '\n' +
'品牌商(BID): 0x' + hex4(form.brandid) + ' ' + (b ? b.brand : '') + '\n' + '方案ID(BID): 0x' + hex4(form.providerid) + ' ' + (pv ? pv.name : '') + '\n' +
'产品ID(PID): 0x' + hex4(pid) + ' ' + form.devicename + '\n' + '产品ID(PID): 0x' + hex4(pid) + ' ' + form.devicename + '\n' +
'设备类型: ' + (deviceTypeOptions[form.devicetype] || form.devicetype) + '\n' + '设备类型: ' + (deviceTypeOptions[form.devicetype] || form.devicetype) + '\n' +
(form.description ? '产品说明: ' + form.description + '\n' : '') + (form.description ? '产品说明: ' + form.description + '\n' : '') +
@ -877,6 +925,30 @@ async function switchCurrentVersion(v: Version): Promise<boolean> {
} }
} }
const mismatchSaving = ref(false)
// 「版本不一致即可升级」开关。后端 updateproduct 是整条 Save,所以照
// switchCurrentVersion 的做法把整个 product 回传,只改这一个字段。
// ⚠️ 不要只 POST 一个 {id, updateonmismatch}——那会把产品其它字段全抹成零值。
async function toggleUpdateOnMismatch(checked: boolean) {
if (!currentProduct.value) { error('产品信息未加载'); return }
mismatchSaving.value = true
const product = { ...currentProduct.value, updateonmismatch: checked }
try {
await webApi('updateproduct', { product })
const d = await webApi<{ product: Product }>('getproduct', { id: product.id })
currentProduct.value = d?.product ?? product
// 列表里那一行也要跟着变,否则重开抽屉又是旧值
const row = rows.value.find((r) => r.id === product.id)
if (row) row.updateonmismatch = checked
success(checked ? '已开启:版本不一致即可升级' : '已关闭:仅允许升级到更高版本')
} catch (e: any) {
error(e?.message ?? '保存失败')
} finally {
mismatchSaving.value = false
}
}
function useVersion(v: Version) { function useVersion(v: Version) {
if (!window.confirm(`确定将版本 "${v.version}" 设为当前使用版本?`)) return if (!window.confirm(`确定将版本 "${v.version}" 设为当前使用版本?`)) return
switchCurrentVersion(v).then((ok) => { switchCurrentVersion(v).then((ok) => {
@ -891,7 +963,7 @@ async function addVersion() {
if (!otaFile.value) { warn('请选择固件文件'); return } if (!otaFile.value) { warn('请选择固件文件'); return }
versionSubmitting.value = true versionSubmitting.value = true
try { try {
// 固件包:目录传 product/<产品id>/,后端强制置于 yunyan/ 根下 → yunyan/product/<id>/<文件名> // 固件包:目录传 product/<产品id>/,后端强制置于 EAIMAR/ 根下 → EAIMAR/product/<id>/<文件名>
const url = await uploadFile(otaFile.value, `product/${pid}/`) const url = await uploadFile(otaFile.value, `product/${pid}/`)
const d = await webApi<{ version: Version }>('addproductversion', { const d = await webApi<{ version: Version }>('addproductversion', {
version: { productid: pid, version: ver.version.trim(), description: ver.desc, updatepackageaddress: url }, version: { productid: pid, version: ver.version.trim(), description: ver.desc, updatepackageaddress: url },
@ -916,5 +988,9 @@ function delVersion(v: Version) {
.catch((e: any) => error(e?.message ?? '删除失败')) .catch((e: any) => error(e?.message ?? '删除失败'))
} }
onMounted(loadData) onMounted(async () => {
// 芯片厂商已改为后台可维护的表(chip_vendor),方案商分组要按最新的厂商列表来
await loadChipVendors(webApi)
await loadData()
})
</script> </script>

52
apps/admin/app/pages/provider.vue

@ -14,7 +14,7 @@
<label class="label py-1"><span class="label-text text-xs text-slate-500">芯片分组</span></label> <label class="label py-1"><span class="label-text text-xs text-slate-500">芯片分组</span></label>
<select v-model.number="filterVendor" class="select select-bordered select-sm w-40"> <select v-model.number="filterVendor" class="select select-bordered select-sm w-40">
<option :value="-1">全部芯片</option> <option :value="-1">全部芯片</option>
<option v-for="v in CHIP_VENDORS" :key="v.value" :value="v.value">{{ v.label }}</option> <option v-for="v in chipVendors" :key="v.id" :value="v.id">{{ v.name }} (0x{{ v.id.toString(16).toUpperCase() }}){{ v.enabled === false ? ' 已停用' : '' }}</option>
<option :value="0">未分组</option> <option :value="0">未分组</option>
</select> </select>
</div> </div>
@ -32,7 +32,7 @@
<table class="table table-zebra"> <table class="table table-zebra">
<thead> <thead>
<tr> <tr>
<th class="cursor-pointer select-none" @click="toggleSort('id')">ID{{ sortMark('id') }}</th> <th class="cursor-pointer select-none" @click="toggleSort('id')">BID{{ sortMark('id') }}</th>
<th class="cursor-pointer select-none" @click="toggleSort('vendor')">芯片分组{{ sortMark('vendor') }}</th> <th class="cursor-pointer select-none" @click="toggleSort('vendor')">芯片分组{{ sortMark('vendor') }}</th>
<th class="cursor-pointer select-none" @click="toggleSort('name')">方案商公司{{ sortMark('name') }}</th> <th class="cursor-pointer select-none" @click="toggleSort('name')">方案商公司{{ sortMark('name') }}</th>
<th>方案说明</th> <th>方案说明</th>
@ -83,36 +83,35 @@
</div> </div>
<div class="bg-white rounded-xl shadow-sm border border-slate-100 p-6 max-w-2xl"> <div class="bg-white rounded-xl shadow-sm border border-slate-100 p-6 max-w-2xl">
<form @submit.prevent="saveProvider"> <form @submit.prevent="saveProvider">
<!-- 方案商 ID:手工指定的有含义编码,落库后不可改(已烧录进设备) --> <!-- BID:手工指定的有含义编码,就是方案商主键。建档填错能改,但名下有产品后就锁死 -->
<div class="form-control mb-4"> <div class="form-control mb-4">
<label class="label"><span class="label-text font-medium">方案商 ID(十六进制)*</span></label> <label class="label"><span class="label-text font-medium">BID(十六进制)*</span></label>
<div class="flex items-stretch"> <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> <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 <input
v-model="form.idHex" v-model="form.idHex"
type="text" type="text"
class="input input-bordered w-full font-mono uppercase rounded-l-none" class="input input-bordered w-full font-mono uppercase rounded-l-none"
:disabled="isEditing"
placeholder="C101" placeholder="C101"
maxlength="8" maxlength="8"
required required
/> />
</div> </div>
<span class="label-text-alt text-slate-400 mt-1"> <span class="label-text-alt text-slate-400 mt-1">
方案商 ID 不自增,由人工指定成有含义的编码(如 C101 = 杰里 01 方案)。新增一律用 C 开头段(C1xx…); BID 不自增,由人工指定成有含义的编码(如 C101 = 杰里 01 方案)。新增建议用 C 开头段(C1xx…)。
历史 ID(ABEF / ABF0~ABF4 / AB21)已烧录进设备,<b>创建后不可修改</b>。 <b>名下还没有产品时可以改</b>;一旦有产品挂上来,改号会让那些产品指向不存在的方案商,服务端会拒绝。
</span> </span>
<span v-if="!isEditing && usedIdHint" class="label-text-alt text-slate-400 mt-1">{{ usedIdHint }}</span> <span v-if="!isEditing && usedIdHint" class="label-text-alt text-slate-400 mt-1">{{ usedIdHint }}</span>
</div> </div>
<!-- 芯片分组:一个芯片厂商可以有多套方案,每套方案单独一条记录 --> <!-- 芯片分组:一个芯片厂商可以有多套方案,每套方案单独一条记录 -->
<div class="form-control mb-4"> <div class="form-control mb-4">
<label class="label"><span class="label-text font-medium">芯片分组 *</span></label> <label class="label"><span class="label-text font-medium">芯片 *</span></label>
<select v-model.number="form.vendor" class="select select-bordered w-full" required> <select v-model.number="form.vendor" class="select select-bordered w-full" required>
<option :value="0" disabled>请选择芯片厂商</option> <option :value="0" disabled>请选择芯片厂商</option>
<option v-for="v in CHIP_VENDORS" :key="v.value" :value="v.value">{{ v.label }}</option> <option v-for="v in enabledVendors" :key="v.id" :value="v.id">{{ v.name }} (0x{{ v.id.toString(16).toUpperCase() }})</option>
</select> </select>
<span class="label-text-alt text-slate-400 mt-1">同一个芯片厂商可以有多套方案,每套方案单独建一条方案商记录,靠这里归组。</span> <span class="label-text-alt text-slate-400 mt-1">这家方案公司用的是哪家芯片。厂商清单在「芯片管理」里维护,CID 也在那里填。</span>
</div> </div>
<div class="form-control mb-4"> <div class="form-control mb-4">
@ -160,7 +159,7 @@
import { ref, reactive, computed, onMounted } from 'vue' import { ref, reactive, computed, onMounted } from 'vue'
import { useApi } from '~/composables/useApi' import { useApi } from '~/composables/useApi'
import { useToast } from '~/composables/useToast' import { useToast } from '~/composables/useToast'
import { CHIP_VENDORS, chipVendorName } from '~/utils/chipvendors' import { chipVendors, enabledChipVendors, loadChipVendors, chipVendorName } from '~/utils/chipvendors'
import { useTableSort } from '~/composables/useTableSort' import { useTableSort } from '~/composables/useTableSort'
definePageMeta({ title: '方案商管理' }) definePageMeta({ title: '方案商管理' })
@ -191,6 +190,10 @@ const keyword = ref('')
const form = reactive({ id: 0, idHex: '', name: '', description: '', vendor: 0 }) const form = reactive({ id: 0, idHex: '', name: '', description: '', vendor: 0 })
// 新建/编辑时只能选启用中的厂商;列表筛选那边用全量 chipVendors(含停用),
// 否则挂在停用厂商下的存量方案商会筛不出来。
const enabledVendors = computed(() => enabledChipVendors())
function hexId(id: number) { function hexId(id: number) {
return id ? Number(id).toString(16).toUpperCase() : '' return id ? Number(id).toString(16).toUpperCase() : ''
} }
@ -288,18 +291,17 @@ function removeEmail(idx: number) {
} }
async function saveProvider() { async function saveProvider() {
if (!form.vendor) { warn('请选择芯片分组'); return } if (!form.vendor) { warn('请选择芯片'); return }
let id = form.id // BID 现在编辑时也能改,所以新增/编辑一律从输入框重新解析,不再沿用 form.id
if (!isEditing.value) { const hex = form.idHex.trim().replace(/^0x/i, '')
const hex = form.idHex.trim().replace(/^0x/i, '') if (!/^[0-9a-fA-F]{1,8}$/.test(hex)) { warn('BID 需填十六进制,如 C101'); return }
if (!/^[0-9a-fA-F]{1,8}$/.test(hex)) { warn('方案商 ID 需填十六进制,如 C101'); return } const id = Number.parseInt(hex, 16)
id = Number.parseInt(hex, 16) if (!id) { warn('BID 不能为 0'); return }
if (!id) { warn('方案商 ID 不能为 0'); return } // 占用校验要排除自己,否则编辑时不改号也会被自己挡住
if (rows.value.some((p) => Number(p.id) === id)) { warn(`ID ${hex.toUpperCase()} 已被占用`); return } if (rows.value.some((p) => Number(p.id) === id && Number(p.id) !== form.id)) { warn(`BID ${hex.toUpperCase()} 已被占用`); return }
}
submitting.value = true submitting.value = true
try { try {
const payload: { provider: Record<string, unknown> } = { const payload: { provider: Record<string, unknown>; oldid?: number } = {
provider: { provider: {
id, id,
name: form.name, name: form.name,
@ -308,6 +310,8 @@ async function saveProvider() {
vendor: form.vendor, vendor: form.vendor,
}, },
} }
// 编辑时必须带原 BID:服务端拿它定位记录,再决定要不要改主键
if (isEditing.value) payload.oldid = form.id
await webApi(isEditing.value ? 'updatesolutionprovider' : 'addsolutionprovider', payload) await webApi(isEditing.value ? 'updatesolutionprovider' : 'addsolutionprovider', payload)
success('保存成功') success('保存成功')
showList() showList()
@ -325,5 +329,9 @@ function delProvider(p: Provider) {
.catch((e: any) => error(e?.message ?? '删除失败')) .catch((e: any) => error(e?.message ?? '删除失败'))
} }
onMounted(loadData) onMounted(async () => {
// 芯片厂商已改为后台可维护的表(chip_vendor),不再是写死的四条,进页面先拉一次。
await loadChipVendors(webApi)
await loadData()
})
</script> </script>

87
apps/admin/app/pages/settlements.vue

@ -2,7 +2,7 @@
<div> <div>
<div class="flex items-center justify-between mb-5 flex-wrap gap-3"> <div class="flex items-center justify-between mb-5 flex-wrap gap-3">
<h2 class="text-lg font-semibold text-slate-800"> <h2 class="text-lg font-semibold text-slate-800">
月结算<span class="text-xs text-slate-400 font-normal ml-2">按订单实付金额分成,渠道商从品牌商那份里再切</span> 月结算<span class="text-xs text-slate-400 font-normal ml-2">按【渠道商 × 生产批次】出行,比例取自该批次</span>
</h2> </h2>
<div v-if="canManage" class="flex gap-2"> <div v-if="canManage" class="flex gap-2">
<button class="btn btn-primary btn-sm text-white" :disabled="generating" @click="genSettlement"> <button class="btn btn-primary btn-sm text-white" :disabled="generating" @click="genSettlement">
@ -19,13 +19,6 @@
<label class="label py-1"><span class="label-text text-xs text-slate-500">结算月</span></label> <label class="label py-1"><span class="label-text text-xs text-slate-500">结算月</span></label>
<input v-model="filters.month" type="month" class="input input-bordered input-sm w-44" @change="loadData" /> <input v-model="filters.month" type="month" class="input input-bordered input-sm w-44" @change="loadData" />
</div> </div>
<div v-if="!auth.isBrandAccount && !auth.isDealer" class="form-control">
<label class="label py-1"><span class="label-text text-xs text-slate-500">品牌商</span></label>
<select v-model.number="filters.brandid" class="select select-bordered select-sm w-48" @change="loadData">
<option :value="0">全部品牌商</option>
<option v-for="b in brands" :key="b.id" :value="b.id">{{ b.brand }}</option>
</select>
</div>
<div v-if="!auth.isDealer" class="form-control"> <div v-if="!auth.isDealer" class="form-control">
<label class="label py-1"><span class="label-text text-xs text-slate-500">渠道商</span></label> <label class="label py-1"><span class="label-text text-xs text-slate-500">渠道商</span></label>
<select v-model="filters.channelid" class="select select-bordered select-sm w-52" @change="loadData"> <select v-model="filters.channelid" class="select select-bordered select-sm w-52" @change="loadData">
@ -60,27 +53,30 @@
<table class="table table-zebra text-sm"> <table class="table table-zebra text-sm">
<thead> <thead>
<tr> <tr>
<th>结算月</th><th>品牌商</th><th>渠道商</th><th class="text-right">订单数</th> <th>结算月</th><th>渠道商</th><th>批次号</th><th>产品</th><th class="text-right">订单数</th>
<th class="text-right">结算基数</th><th class="text-right">品牌比例</th><th class="text-right">品牌实得</th> <th class="text-right">结算基数</th>
<th class="text-right">渠道比例</th><th class="text-right">渠道分成</th><th class="text-right">平台留存</th> <th class="text-right">渠道比例</th><th class="text-right">渠道分成</th><th class="text-right">平台留存</th>
<th>状态</th><th v-if="canManage">操作</th> <th>状态</th><th v-if="canManage">操作</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-if="loading"> <tr v-if="loading">
<td :colspan="canManage ? 12 : 11" class="text-center py-8"><span class="loading loading-spinner loading-sm"></span></td> <td :colspan="canManage ? 11 : 10" class="text-center py-8"><span class="loading loading-spinner loading-sm"></span></td>
</tr> </tr>
<tr v-else-if="!rows.length"> <tr v-else-if="!rows.length">
<td :colspan="canManage ? 12 : 11" class="text-center text-slate-400 py-8">该月暂无结算数据</td> <td :colspan="canManage ? 11 : 10" class="text-center text-slate-400 py-8">该月暂无结算数据</td>
</tr> </tr>
<tr v-for="(r, i) in rows" v-else :key="i"> <tr v-for="(r, i) in rows" v-else :key="i">
<td class="font-mono">{{ fmtPeriod(r.period) }}</td> <td class="font-mono">{{ fmtPeriod(r.period) }}</td>
<td>{{ brandName(r.brandid) }}</td>
<td>{{ channelName(r.channelid) }}</td> <td>{{ channelName(r.channelid) }}</td>
<!-- 批次为空 = 这些订单归不到任何批次(用户没绑过设备 / 批次已撤销),比例 0、全归平台 -->
<td class="font-mono text-xs">
<span v-if="r.batchno" class="text-indigo-600 font-medium">{{ r.batchno }}</span>
<span v-else class="badge badge-ghost badge-sm">无批次归属</span>
</td>
<td class="text-xs">{{ productName(r.productid) }}</td>
<td class="text-right">{{ r.order_count }}</td> <td class="text-right">{{ r.order_count }}</td>
<td class="text-right font-medium">{{ fmtMoney(r.base_amount) }}</td> <td class="text-right font-medium">{{ fmtMoney(r.base_amount) }}</td>
<td class="text-right text-slate-500">{{ ratePct(r.brand_rate) }}</td>
<td class="text-right text-emerald-600 font-medium">{{ fmtMoney(r.brand_net) }}</td>
<td class="text-right text-slate-500">{{ ratePct(r.channel_rate) }}</td> <td class="text-right text-slate-500">{{ ratePct(r.channel_rate) }}</td>
<td class="text-right text-indigo-600 font-medium">{{ fmtMoney(r.channel_amount) }}</td> <td class="text-right text-indigo-600 font-medium">{{ fmtMoney(r.channel_amount) }}</td>
<td class="text-right text-slate-500">{{ fmtMoney(r.platform_amount) }}</td> <td class="text-right text-slate-500">{{ fmtMoney(r.platform_amount) }}</td>
@ -102,8 +98,10 @@
</div> </div>
<p class="text-xs text-slate-400 mt-3 leading-relaxed"> <p class="text-xs text-slate-400 mt-3 leading-relaxed">
结算基数 = 该月<b>已支付</b>订单实付金额合计,订单按下单时冻结的「来源设备」归属到品牌商与渠道商。<br /> 结算基数 = 该月<b>已支付</b>订单实付金额合计。订单按下单时冻结的「来源设备」先归到渠道商,
品牌商总提成 = 基数 × 品牌比例;渠道分成 = 品牌总提成 × 渠道比例;品牌实得 = 品牌总提成 − 渠道分成;平台留存 = 基数 − 品牌总提成。<br /> 再由设备反查出它属于哪个<b>生产批次</b>——分成比例是一批一谈的,取自该批次。<br />
渠道分成 = 基数 × 渠道比例;平台留存 = 基数 − 渠道分成。
标「无批次归属」的行是查不到来源设备的订单(用户从未绑过设备、或批次已被撤销),比例按 0 处理、全额归平台。<br />
比例在生成结算单时快照冻结,之后调整比例不影响已生成的月份;<b>已确认</b>的行重算时会跳过,需先退回草稿才能重算。 比例在生成结算单时快照冻结,之后调整比例不影响已生成的月份;<b>已确认</b>的行重算时会跳过,需先退回草稿才能重算。
</p> </p>
</div> </div>
@ -119,15 +117,13 @@ definePageMeta({ title: '月结算' })
interface Settlement { interface Settlement {
period: number period: number
brandid: number
channelid: string channelid: string
batchno: string // 生产批次号;空 = 这些订单归不到批次
productid: number
order_count: number order_count: number
base_amount: number base_amount: number
brand_rate: number
brand_gross: number
channel_rate: number channel_rate: number
channel_amount: number channel_amount: number
brand_net: number
platform_amount: number platform_amount: number
status: number status: number
gen_time: number gen_time: number
@ -135,7 +131,6 @@ interface Settlement {
confirm_time: number confirm_time: number
remark: string remark: string
} }
interface Brand { id: number; brand: string }
interface Channel { id: string; brandid: number; name: string } interface Channel { id: string; brandid: number; name: string }
const { webApi } = useApi() const { webApi } = useApi()
@ -146,22 +141,20 @@ const auth = useAuthStore()
const canManage = computed(() => auth.isAdmin) const canManage = computed(() => auth.isAdmin)
const rows = ref<Settlement[]>([]) const rows = ref<Settlement[]>([])
const brands = ref<Brand[]>([])
const channels = ref<Channel[]>([]) const channels = ref<Channel[]>([])
const products = ref<Record<number, string>>({})
const loading = ref(false) const loading = ref(false)
const generating = ref(false) const generating = ref(false)
const totals = reactive({ base: 0, brandNet: 0, channel: 0, platform: 0 }) const totals = reactive({ base: 0, channel: 0, platform: 0 })
// 默认结算上一个自然月(月初看上月账最常见) // 默认结算上一个自然月(月初看上月账最常见)
const filters = reactive({ month: prevMonthStr(), brandid: 0, channelid: '', status: -1 }) const filters = reactive({ month: prevMonthStr(), channelid: '', status: -1 })
const channelOptions = computed(() => // 渠道商已是全局实体(brandid 恒为 0),不再按品牌商收窄
filters.brandid ? channels.value.filter((ch) => ch.brandid === filters.brandid) : channels.value, const channelOptions = computed(() => channels.value)
)
const summaryCards = computed(() => [ const summaryCards = computed(() => [
{ label: '结算基数(订单实付)', value: totals.base, cls: 'text-slate-800' }, { label: '结算基数(订单实付)', value: totals.base, cls: 'text-slate-800' },
{ label: '品牌商实得', value: totals.brandNet, cls: 'text-emerald-600' },
{ label: '渠道商分成', value: totals.channel, cls: 'text-indigo-600' }, { label: '渠道商分成', value: totals.channel, cls: 'text-indigo-600' },
{ label: '平台留存', value: totals.platform, cls: 'text-slate-600' }, { label: '平台留存', value: totals.platform, cls: 'text-slate-600' },
]) ])
@ -189,10 +182,11 @@ function fmtMoney(cents?: number) {
function ratePct(rate?: number) { function ratePct(rate?: number) {
return ((Number(rate) || 0) / 100).toFixed(2) + '%' return ((Number(rate) || 0) / 100).toFixed(2) + '%'
} }
function brandName(id?: number) { // 产品名。结算行上存的是批次所属产品,仅用于展示。
if (!id) return '未归属品牌商' function productName(id?: number) {
const b = brands.value.find((x) => x.id === id) const n = Number(id || 0)
return b ? b.brand : '品牌商#' + Number(id).toString(16).toUpperCase() if (!n) return '-'
return products.value[n] || ('产品#' + n.toString(16).toUpperCase())
} }
function channelName(id?: string) { function channelName(id?: string) {
if (!id) return '未分配渠道' if (!id) return '未分配渠道'
@ -207,33 +201,33 @@ function statusClass(s: number) { return STATUS_CLASS[s] ?? 'badge-ghost' }
async function loadRefs() { async function loadRefs() {
try { try {
const [bd, cd] = await Promise.all([ const [cd, pd] = await Promise.all([
webApi<{ brands: Brand[] }>('getbrands', {}),
webApi<{ channels: Channel[] }>('getchannels', { status: -1 }), webApi<{ channels: Channel[] }>('getchannels', { status: -1 }),
webApi<{ products: { id: number; devicename: string }[] }>('getproducts', {}),
]) ])
brands.value = bd?.brands ?? []
channels.value = cd?.channels ?? [] channels.value = cd?.channels ?? []
const m: Record<number, string> = {}
for (const p of pd?.products ?? []) m[p.id] = p.devicename
products.value = m
} catch { } catch {
brands.value = []
channels.value = [] channels.value = []
products.value = {}
} }
} }
async function loadData() { async function loadData() {
loading.value = true loading.value = true
try { try {
if (!brands.value.length) await loadRefs() if (!channels.value.length) await loadRefs()
const d = await webApi<{ const d = await webApi<{
items: Settlement[]; total_base: number; total_brand_net: number; total_channel: number; total_platform: number items: Settlement[]; total_base: number; total_channel: number; total_platform: number
}>('getsettlements', { }>('getsettlements', {
period: monthToPeriod(filters.month), period: monthToPeriod(filters.month),
brandid: Number(filters.brandid) || 0,
channelid: filters.channelid, channelid: filters.channelid,
status: Number(filters.status), status: Number(filters.status),
}) })
rows.value = d?.items ?? [] rows.value = d?.items ?? []
totals.base = d?.total_base ?? 0 totals.base = d?.total_base ?? 0
totals.brandNet = d?.total_brand_net ?? 0
totals.channel = d?.total_channel ?? 0 totals.channel = d?.total_channel ?? 0
totals.platform = d?.total_platform ?? 0 totals.platform = d?.total_platform ?? 0
} catch (e: any) { } catch (e: any) {
@ -265,7 +259,8 @@ async function confirm(r: Settlement, status: number) {
if (!window.confirm(`确定${actions[status]}这一行结算吗?`)) return if (!window.confirm(`确定${actions[status]}这一行结算吗?`)) return
try { try {
await webApi('confirmsettlement', { await webApi('confirmsettlement', {
period: r.period, brandid: r.brandid, channelid: r.channelid, status, remark: r.remark || '', // batchno 是结算行主键的一部分,不带的话服务端会定位到「无批次归属」那一行
period: r.period, channelid: r.channelid, batchno: r.batchno || '', status, remark: r.remark || '',
}) })
success(actions[status] + '成功') success(actions[status] + '成功')
loadData() loadData()
@ -275,11 +270,11 @@ async function confirm(r: Settlement, status: number) {
} }
function exportCSV() { function exportCSV() {
const headers = ['结算月', '品牌商', '渠道商', '订单数', '结算基数(元)', '品牌比例', '品牌总提成(元)', '渠道比例', '渠道分成(元)', '品牌实得(元)', '平台留存(元)', '状态'] const headers = ['结算月', '渠道商', '批次号', '产品', '订单数', '结算基数(元)', '渠道比例', '渠道分成(元)', '平台留存(元)', '状态']
const lines = rows.value.map((r) => [ const lines = rows.value.map((r) => [
fmtPeriod(r.period), brandName(r.brandid), channelName(r.channelid), r.order_count, fmtPeriod(r.period), channelName(r.channelid), r.batchno || '无批次归属', productName(r.productid), r.order_count,
(r.base_amount / 100).toFixed(2), ratePct(r.brand_rate), (r.brand_gross / 100).toFixed(2), (r.base_amount / 100).toFixed(2),
ratePct(r.channel_rate), (r.channel_amount / 100).toFixed(2), (r.brand_net / 100).toFixed(2), ratePct(r.channel_rate), (r.channel_amount / 100).toFixed(2),
(r.platform_amount / 100).toFixed(2), statusLabel(r.status), (r.platform_amount / 100).toFixed(2), statusLabel(r.status),
]) ])
const content = [headers.join(',')].concat(lines.map((l) => l.join(','))).join('\n') const content = [headers.join(',')].concat(lines.map((l) => l.join(','))).join('\n')

524
apps/admin/app/pages/users.vue

@ -1,59 +1,230 @@
<template> <template>
<div class="space-y-6"> <div class="space-y-5">
<!-- ========== 查询条件:应用 → 区域 → UID ========== --> <!-- ========== 列表视图 ========== -->
<div class="bg-white rounded-xl shadow-sm border border-slate-100 p-4"> <div v-show="view === 'list'" class="space-y-5">
<div class="flex items-end gap-3 flex-wrap"> <!-- 查询条件:应用 → 区域 是硬前提(用户住在所选应用的业务库里),其余是过滤 -->
<div class="form-control"> <div class="bg-white rounded-xl shadow-sm border border-slate-100 p-4">
<label class="label py-1"><span class="label-text text-xs">应用</span></label> <div class="flex items-end gap-3 flex-wrap">
<select v-model="qApp" class="select select-bordered select-sm" style="min-width:180px;" @change="onAppChange"> <div class="form-control">
<option v-if="!appNames.length" value="">无可用应用</option> <label class="label py-1"><span class="label-text text-xs text-slate-500">应用 *</span></label>
<option v-for="n in appNames" :key="n" :value="n">{{ n }}</option> <select v-model="qApp" class="select select-bordered select-sm" style="min-width:180px;" @change="onAppChange">
</select> <option v-if="!appNames.length" value="">无可用应用</option>
<option v-for="n in appNames" :key="n" :value="n">{{ n }}</option>
</select>
</div>
<div class="form-control">
<label class="label py-1"><span class="label-text text-xs text-slate-500">区域 *</span></label>
<select v-model="qRegion" class="select select-bordered select-sm" style="min-width:130px;" @change="reload">
<option v-if="!regionItems.length" value="">无可用区域</option>
<option v-for="a in regionItems" :key="a.id" :value="a.id">{{ regionLabel(a.region) }}</option>
</select>
</div>
<div class="form-control flex-1" style="min-width:220px;">
<label class="label py-1"><span class="label-text text-xs text-slate-500">关键字</span></label>
<input
v-model="q.keyword" type="text" class="input input-bordered input-sm w-full"
placeholder="UID(精确) / 手机号 / 邮箱 / 用户名 / 登录账号 / 设备ID" @keydown.enter.prevent="reload" />
</div>
<div class="form-control">
<label class="label py-1"><span class="label-text text-xs text-slate-500">状态</span></label>
<select v-model.number="q.state" class="select select-bordered select-sm w-28" @change="reload">
<option :value="-1">全部</option>
<option :value="0">正常</option>
<option :value="1">已禁用</option>
<option :value="2">已注销</option>
</select>
</div>
<div class="form-control">
<label class="label py-1"><span class="label-text text-xs text-slate-500">会员</span></label>
<select v-model.number="q.vip" class="select select-bordered select-sm w-28" @change="reload">
<option :value="-1">全部</option>
<option :value="1">会员中</option>
<option :value="0">非会员</option>
</select>
</div>
<div class="form-control">
<label class="label py-1"><span class="label-text text-xs text-slate-500">实名</span></label>
<select v-model.number="q.idverified" class="select select-bordered select-sm w-28" @change="reload">
<option :value="-1">全部</option>
<option :value="1">已实名</option>
<option :value="0">未实名</option>
</select>
</div>
<div class="form-control">
<label class="label py-1"><span class="label-text text-xs text-slate-500">注册起</span></label>
<input v-model="q.startDate" type="date" class="input input-bordered input-sm" @change="reload" />
</div>
<div class="form-control">
<label class="label py-1"><span class="label-text text-xs text-slate-500">注册止</span></label>
<input v-model="q.endDate" type="date" class="input input-bordered input-sm" @change="reload" />
</div>
<button class="btn btn-primary btn-sm text-white" @click="reload">查询</button>
<button class="btn btn-ghost btn-sm" @click="resetFilters">重置</button>
</div>
</div>
<!-- 结果 -->
<div class="bg-white rounded-xl shadow-sm border border-slate-100">
<div class="flex items-center justify-between px-5 py-3 border-b border-slate-100">
<h3 class="text-sm font-semibold text-slate-700">
用户列表<span class="text-xs text-slate-400 font-normal ml-2">按注册时间倒序,点行查看详情</span>
</h3>
<div class="flex items-center gap-2">
<span class="text-xs text-slate-500">共 {{ total }} 人</span>
<select v-model.number="q.pageSize" class="select select-bordered select-xs" @change="reload">
<option :value="20">20/页</option>
<option :value="50">50/页</option>
<option :value="100">100/页</option>
<option :value="200">200/页</option>
</select>
</div>
</div> </div>
<div class="form-control"> <div class="overflow-x-auto">
<label class="label py-1"><span class="label-text text-xs">区域</span></label> <table class="table table-zebra text-sm">
<select v-model="qRegion" class="select select-bordered select-sm" style="min-width:140px;"> <thead>
<option v-if="!regionItems.length" value="">无可用区域</option> <tr>
<option v-for="a in regionItems" :key="a.id" :value="a.id">{{ regionLabel(a.region) }}</option> <th>用户</th><th>联系方式</th><th>状态</th><th>会员</th>
</select> <th class="text-right">翻译</th><th class="text-right">会议</th><th class="text-right">AI</th>
<th>实名</th><th>注册时间</th><th>最后登录</th><th>操作</th>
</tr>
</thead>
<tbody>
<tr v-if="loading"><td colspan="11" class="text-center py-10"><span class="loading loading-spinner loading-sm"></span></td></tr>
<tr v-else-if="!qRegion"><td colspan="11" class="text-center text-slate-400 py-10">请先选择应用与区域</td></tr>
<tr v-else-if="listError"><td colspan="11" class="text-center text-red-400 py-10">{{ listError }}</td></tr>
<tr v-else-if="!rows.length"><td colspan="11" class="text-center text-slate-400 py-10">没有符合条件的用户</td></tr>
<tr v-for="u in rows" v-else :key="u.uid" class="hover cursor-pointer" @click="openDetail(u)">
<td>
<div class="flex items-center gap-2">
<img v-if="u.avatar" :src="u.avatar" class="w-8 h-8 rounded-full object-cover border border-slate-100" />
<div v-else class="w-8 h-8 rounded-full bg-indigo-100 text-indigo-600 flex items-center justify-center text-xs font-bold shrink-0">
{{ initialOf(u) }}
</div>
<div class="min-w-0">
<div class="font-medium truncate max-w-[140px]">{{ u.name || '(未设置)' }}</div>
<div class="text-[11px] text-slate-400 font-mono truncate max-w-[140px]" :title="u.uid">{{ u.uid }}</div>
</div>
</div>
</td>
<td class="text-xs text-slate-500">
<div v-if="u.phone">{{ u.phone }}</div>
<div v-if="u.mail" class="truncate max-w-[160px]" :title="u.mail">{{ u.mail }}</div>
<div v-if="u.account" class="text-slate-400">{{ u.account }}</div>
<span v-if="!u.phone && !u.mail && !u.account" class="text-slate-300">-</span>
</td>
<td><span class="badge badge-sm" :class="stateClass(u.State)">{{ stateLabel(u.State) }}</span></td>
<td class="text-xs">
<span v-if="isVip(u)" class="badge badge-sm badge-primary badge-outline">VIP {{ u.viplv || 0 }}</span>
<span v-else class="text-slate-300">-</span>
<div v-if="u.vipexptime" class="text-[11px] text-slate-400 mt-0.5">{{ fmtDate(u.vipexptime) }}</div>
</td>
<td class="text-right text-xs">{{ fmtMin(u.tradeintegral) }}</td>
<td class="text-right text-xs">{{ fmtMin(u.meetintegral) }}</td>
<td class="text-right text-xs">{{ u.aichatintegral || 0 }}</td>
<td><span class="badge badge-sm" :class="u.idverified ? 'badge-success badge-outline' : 'badge-ghost'">{{ u.idverified ? '已实名' : '未实名' }}</span></td>
<td class="text-xs text-slate-500 whitespace-nowrap">{{ fmtTs(u.createtime) }}</td>
<td class="text-xs text-slate-500 whitespace-nowrap">{{ fmtTs(u.lastsgintime) }}</td>
<td><button class="btn btn-ghost btn-xs text-indigo-600" @click.stop="openDetail(u)">详情</button></td>
</tr>
</tbody>
</table>
</div> </div>
<div class="form-control"> <div class="flex items-center justify-between px-5 py-3 border-t border-slate-100">
<label class="label py-1"><span class="label-text text-xs">用户UID</span></label> <span class="text-xs text-slate-500">{{ pageInfo }}</span>
<input v-model="qUid" type="text" class="input input-bordered input-sm" style="min-width:240px;" placeholder="输入用户 UID" @keydown.enter.prevent="searchUser" /> <div class="flex items-center gap-2">
<button class="btn btn-ghost btn-xs" :disabled="q.page <= 0 || loading" @click="changePage(-1)">上一页</button>
<span class="text-xs text-slate-500">第 {{ q.page + 1 }} / {{ totalPages }} 页</span>
<button class="btn btn-ghost btn-xs" :disabled="q.page + 1 >= totalPages || loading" @click="changePage(1)">下一页</button>
</div>
</div> </div>
<button class="btn btn-primary btn-sm text-white" @click="searchUser">搜索</button>
</div> </div>
<p class="text-xs text-slate-400 mt-2">选择应用与区域后输入 UID,直接查询该部署业务库中的用户信息。</p>
</div> </div>
<!-- ========== 结果 ========== --> <!-- ========== 详情视图 ========== -->
<div v-if="!user" class="bg-white rounded-xl shadow-sm border border-slate-100 p-12 text-center text-slate-400"> <div v-show="view === 'detail'" class="space-y-5">
{{ emptyHint }} <div class="flex items-center gap-3">
</div> <button class="btn btn-ghost btn-sm" @click="view = 'list'">&larr; 返回列表</button>
<div v-else class="space-y-5"> <h2 class="text-lg font-semibold text-slate-800">用户详情</h2>
<!-- 头部 --> </div>
<div class="bg-white rounded-xl shadow-sm border border-slate-100 p-5 flex items-center gap-4">
<img v-if="user.avatar" :src="user.avatar" class="w-14 h-14 rounded-full object-cover border border-slate-100" /> <div v-if="user" class="space-y-5">
<div v-else class="w-14 h-14 rounded-full bg-indigo-100 text-indigo-600 flex items-center justify-center text-xl font-bold"> <!-- 头部 -->
{{ avatarInitial }} <div class="bg-white rounded-xl shadow-sm border border-slate-100 p-5 flex items-center gap-4 flex-wrap">
</div> <img v-if="user.avatar" :src="user.avatar" class="w-14 h-14 rounded-full object-cover border border-slate-100" />
<div> <div v-else class="w-14 h-14 rounded-full bg-indigo-100 text-indigo-600 flex items-center justify-center text-xl font-bold">
<div class="text-lg font-semibold text-slate-800">{{ dispVal(user.name) }}</div> {{ initialOf(user) }}
<div class="text-xs text-slate-400 font-mono mt-0.5">{{ dispVal(user.uid) }}</div> </div>
<div>
<div class="text-lg font-semibold text-slate-800">{{ dispVal(user.name) }}</div>
<div class="text-xs text-slate-400 font-mono mt-0.5">{{ dispVal(user.uid) }}</div>
</div>
<div class="ml-auto flex items-center gap-2 flex-wrap">
<span class="badge badge-sm" :class="stateClass(user.State)">{{ stateLabel(user.State) }}</span>
<span class="badge badge-primary badge-outline">VIP {{ user.viplv || 0 }}</span>
<button v-if="giftAllowed" class="btn btn-success btn-sm text-white" @click="openGift">赠送资源点</button>
</div>
</div> </div>
<div class="ml-auto flex items-center gap-3">
<span class="badge badge-primary badge-outline">VIP {{ user.viplv || 0 }}</span> <!-- 设置参数:仅超管/管理员。代理要发资源走「赠送资源点」,那条路会扣自己的余额 -->
<button v-if="giftAllowed" class="btn btn-success btn-sm text-white" @click="openGift">赠送资源点</button> <div v-if="canEdit" class="bg-white rounded-xl shadow-sm border border-slate-100 overflow-hidden">
<div class="px-5 py-3 border-b border-slate-50 flex items-center justify-between">
<span class="text-sm font-semibold text-slate-700">设置参数</span>
<span class="text-xs text-slate-400">只提交你改过的项;积分填的是<b>余额绝对值</b>,不是增量</span>
</div>
<div class="px-5 py-4">
<div class="grid grid-cols-2 md:grid-cols-3 gap-4">
<div class="form-control">
<label class="label py-1"><span class="label-text text-xs">用户名</span></label>
<input v-model="edit.name" type="text" class="input input-bordered input-sm" />
</div>
<div class="form-control">
<label class="label py-1"><span class="label-text text-xs">账号状态</span></label>
<select v-model.number="edit.state" class="select select-bordered select-sm">
<option :value="0">正常</option>
<option :value="1">禁用</option>
<option :value="2">已注销</option>
</select>
</div>
<div class="form-control">
<label class="label py-1"><span class="label-text text-xs">VIP 等级</span></label>
<input v-model.number="edit.viplv" type="number" min="0" class="input input-bordered input-sm" />
</div>
<div class="form-control">
<label class="label py-1"><span class="label-text text-xs">VIP 到期</span></label>
<input v-model="edit.vipexpDate" type="datetime-local" class="input input-bordered input-sm" />
<span class="label-text-alt text-slate-400 mt-1">清空 = 不设到期(等于没有会员)</span>
</div>
<div class="form-control">
<label class="label py-1"><span class="label-text text-xs">翻译余额(分钟)</span></label>
<input v-model.number="edit.trademin" type="number" min="0" class="input input-bordered input-sm" />
</div>
<div class="form-control">
<label class="label py-1"><span class="label-text text-xs">会议余额(分钟)</span></label>
<input v-model.number="edit.meetmin" type="number" min="0" class="input input-bordered input-sm" />
</div>
<div class="form-control">
<label class="label py-1"><span class="label-text text-xs">AI 余额(次)</span></label>
<input v-model.number="edit.aichat" type="number" min="0" class="input input-bordered input-sm" />
</div>
</div>
<div class="flex items-center gap-2 mt-4">
<button class="btn btn-primary btn-sm text-white" :disabled="saving || !dirtyCount" @click="saveEdit">
<span v-if="saving" class="loading loading-spinner loading-xs"></span>
保存{{ dirtyCount ? `(${dirtyCount} 项改动)` : '' }}
</button>
<button class="btn btn-ghost btn-sm" :disabled="!dirtyCount" @click="resetEdit">撤销改动</button>
</div>
</div>
</div> </div>
</div>
<!-- 分组卡片 --> <!-- 只读分组卡片 -->
<div v-for="s in sections" :key="s.title" class="bg-white rounded-xl shadow-sm border border-slate-100 overflow-hidden"> <div v-for="s in sections" :key="s.title" class="bg-white rounded-xl shadow-sm border border-slate-100 overflow-hidden">
<div class="px-5 py-3 border-b border-slate-50 text-sm font-semibold text-slate-700">{{ s.title }}</div> <div class="px-5 py-3 border-b border-slate-50 text-sm font-semibold text-slate-700">{{ s.title }}</div>
<div class="px-5 py-3 grid grid-cols-1 md:grid-cols-2 gap-x-8"> <div class="px-5 py-3 grid grid-cols-1 md:grid-cols-2 gap-x-8">
<div v-for="r in s.rows" :key="r[0]" class="flex justify-between gap-3 py-1.5 border-b border-slate-50"> <div v-for="r in s.rows" :key="r[0]" class="flex justify-between gap-3 py-1.5 border-b border-slate-50">
<span class="text-xs text-slate-400 shrink-0">{{ r[1] }}</span> <span class="text-xs text-slate-400 shrink-0">{{ r[1] }}</span>
<span class="text-sm text-slate-700 break-all text-right">{{ cellValue(user, r) }}</span> <span class="text-sm text-slate-700 break-all text-right">{{ cellValue(user, r) }}</span>
</div>
</div> </div>
</div> </div>
</div> </div>
@ -63,7 +234,10 @@
<dialog ref="giftModal" class="modal"> <dialog ref="giftModal" class="modal">
<div class="modal-box max-w-md"> <div class="modal-box max-w-md">
<h3 class="font-bold text-lg mb-1 text-slate-800">赠送资源点</h3> <h3 class="font-bold text-lg mb-1 text-slate-800">赠送资源点</h3>
<p class="text-xs text-slate-400 mb-4">目标用户 UID:<span class="font-mono text-slate-600">{{ lastUid }}</span>。代理账号将从自己的资源点余额中扣减,超管/管理员无消耗。</p> <p class="text-xs text-slate-400 mb-4">
目标用户 UID:<span class="font-mono text-slate-600">{{ lastUid }}</span>。
这里是<b>增量赠送</b>(在现有余额上累加);代理账号从自己的资源点余额中扣减,超管/管理员无消耗。
</p>
<div class="grid grid-cols-3 gap-3"> <div class="grid grid-cols-3 gap-3">
<div class="form-control"> <div class="form-control">
<label class="label py-1"><span class="label-text text-sm">VIP天数</span></label> <label class="label py-1"><span class="label-text text-sm">VIP天数</span></label>
@ -104,6 +278,8 @@ interface AppItem {
region: string region: string
enabled?: boolean enabled?: boolean
} }
// 列表行与详情是同一个 pb.DBUser,详情多几个 idverify_* 附加字段,故统一用宽松 map
type UserRow = Record<string, any>
type SectionRow = [string, string, ('ts' | 'bool' | 'gender')?] type SectionRow = [string, string, ('ts' | 'bool' | 'gender')?]
interface Section { interface Section {
@ -115,23 +291,43 @@ const { webApi, consoleApi } = useApi()
const { success, error, warn } = useToast() const { success, error, warn } = useToast()
const auth = useAuthStore() const auth = useAuthStore()
const view = ref<'list' | 'detail'>('list')
// 启用的注册项 [{id,name,region,...}] // ---- 应用/区域(硬前提:用户住在所选应用的业务库里)----
const apps = ref<AppItem[]>([]) const apps = ref<AppItem[]>([])
const qApp = ref('') const qApp = ref('')
const qRegion = ref<number | ''>('') const qRegion = ref<number | ''>('')
const qUid = ref('')
const user = ref<Record<string, any> | null>(null) // ---- 列表 ----
const emptyHint = ref('请输入查询条件后点击「搜索」') const rows = ref<UserRow[]>([])
const total = ref(0)
const loading = ref(false)
const listError = ref('')
// state / vip / idverified 的「不限」必须是 -1:0 是合法取值(正常 / 非会员 / 未实名),
// 拿零值当不限会让默认查询悄悄只返回一部分人。
const q = reactive({ keyword: '', state: -1, vip: -1, idverified: -1, startDate: '', endDate: '', page: 0, pageSize: 20 })
// 最近一次成功查询的应用/用户,赠送时复用 const totalPages = computed(() => Math.max(1, Math.ceil(total.value / q.pageSize)))
const pageInfo = computed(() => {
if (!total.value) return '-'
const from = q.page * q.pageSize + 1
return `第 ${from} - ${Math.min(from + rows.value.length - 1, total.value)} 条 / 共 ${total.value} 条`
})
// ---- 详情 ----
const user = ref<UserRow | null>(null)
const lastAppId = ref<number | ''>('') const lastAppId = ref<number | ''>('')
const lastUid = ref('') const lastUid = ref('')
// 修改用户参数仅超管/管理员(后端同样把关);代理只能走「赠送资源点」那条扣自己余额的路。
const canEdit = computed(() => auth.isAdmin)
// 超管/管理员/代理可赠送(运营不可) // 超管/管理员/代理可赠送(运营不可)
const giftAllowed = computed(() => [1, 2, 3].includes(auth.identity)) const giftAllowed = computed(() => [1, 2, 3].includes(auth.identity))
const saving = ref(false)
const edit = reactive({ name: '', state: 0, viplv: 0, vipexpDate: '', trademin: 0, meetmin: 0, aichat: 0 })
const editBase = reactive({ name: '', state: 0, viplv: 0, vipexpDate: '', trademin: 0, meetmin: 0, aichat: 0 })
const giftModal = ref<HTMLDialogElement | null>(null) const giftModal = ref<HTMLDialogElement | null>(null)
const gift = reactive({ vipday: 0, trademin: 0, meetmin: 0 }) const gift = reactive({ vipday: 0, trademin: 0, meetmin: 0 })
@ -144,13 +340,13 @@ const appNames = computed(() => {
}) })
return names return names
}) })
// 二级:当前应用下的部署(按国内/海外展示) // 二级:当前应用下的部署(按国内/海外展示)
const regionItems = computed(() => apps.value.filter((a) => (a.app_name || a.name) === qApp.value)) const regionItems = computed(() => apps.value.filter((a) => (a.app_name || a.name) === qApp.value))
function onAppChange() { function onAppChange() {
const items = regionItems.value const items = regionItems.value
qRegion.value = items.length ? items[0].id : '' qRegion.value = items.length ? items[0].id : ''
reload()
} }
// ---- 展示工具 ---- // ---- 展示工具 ----
@ -158,31 +354,69 @@ function fmtTs(ts: any) {
if (!ts) return '-' if (!ts) return '-'
return new Date(ts * 1000).toLocaleString() return new Date(ts * 1000).toLocaleString()
} }
function fmtBool(v: any) { function fmtDate(ts: any) {
return v ? '是' : '否' if (!ts) return '-'
const d = new Date(ts * 1000)
const p = (n: number) => ('0' + n).slice(-2)
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`
}
function fmtBool(v: any) { return v ? '是' : '否' }
function fmtGender(g: any) { return g === 1 ? '男' : (g === 2 ? '女' : '未知') }
function dispVal(v: any) { return (v === undefined || v === null || v === '') ? '-' : v }
// 翻译/会议积分按【秒】存储,展示成分钟;AI 积分是次数,不参与换算
function fmtMin(sec: any) {
const n = Number(sec) || 0
return n ? Math.floor(n / 60) + ' 分' : '0'
}
function initialOf(u: UserRow) {
return String(u?.name || u?.uid || '?').charAt(0).toUpperCase()
} }
function fmtGender(g: any) { const STATE_LABELS: Record<number, string> = { 0: '正常', 1: '已禁用', 2: '已注销' }
return g === 1 ? '男' : (g === 2 ? '女' : '未知') const STATE_CLASS: Record<number, string> = { 0: 'badge-success badge-outline', 1: 'badge-error', 2: 'badge-ghost' }
function stateLabel(s: any) { return STATE_LABELS[Number(s) || 0] ?? String(s) }
function stateClass(s: any) { return STATE_CLASS[Number(s) || 0] ?? 'badge-ghost' }
// 会员按到期时间判,不按 viplv:等级过期后并不会被清零,拿它判会把过期用户也算成会员
function isVip(u: UserRow) { return Number(u.vipexptime || 0) * 1000 > Date.now() }
// 日期输入 → unix 秒。结束日按「当天 24:00」算,否则选同一天会查不到当天注册的人
function dateToUnix(s: string, endOfDay: boolean): number {
if (!s) return 0
const d = new Date(s + 'T00:00:00')
if (isNaN(d.getTime())) return 0
return Math.floor(d.getTime() / 1000) + (endOfDay ? 86400 : 0)
} }
function dispVal(v: any) { // <input type="datetime-local"> ↔ unix 秒
return (v === undefined || v === null || v === '') ? '-' : v function unixToLocalInput(ts: any): string {
const n = Number(ts) || 0
if (!n) return ''
const d = new Date(n * 1000)
const p = (x: number) => ('0' + x).slice(-2)
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}T${p(d.getHours())}:${p(d.getMinutes())}`
}
function localInputToUnix(s: string): number {
if (!s) return 0
const d = new Date(s)
return isNaN(d.getTime()) ? 0 : Math.floor(d.getTime() / 1000)
} }
const sections: Section[] = [ const sections: Section[] = [
{ title: '基本信息', rows: [ { title: '基本信息', rows: [
['uid', 'UID'], ['name', '用户名'], ['account', '登录账号'], ['mail', '邮箱'], ['phone', '手机号'], ['uid', 'UID'], ['name', '用户名'], ['account', '登录账号'], ['mail', '邮箱'], ['phone', '手机号'],
['gender', '性别', 'gender'], ['language', '语言'], ['version', '版本'], ['channel', '渠道'], ['State', '状态'], ['gender', '性别', 'gender'], ['language', '语言'], ['version', '版本'], ['channel', '渠道'],
['country_code', '国家码'], ['continent_code', '大洲码'], ['servicetag', '服务区'], ['phonemodel', '手机型号'], ['phonemac', '设备ID'], ['country_code', '国家码'], ['continent_code', '大洲码'], ['servicetag', '服务区'], ['phonemodel', '手机型号'], ['phonemac', '设备ID'],
] }, ] },
{ title: '第三方登录', rows: [ { title: '第三方登录', rows: [
['wxopenid', '微信 openid'], ['googleopenid', 'Google openid'], ['facebookopenid', 'Facebook openid'], ['appleopenid', 'Apple openid'], ['wxopenid', '微信 openid'], ['googleopenid', 'Google openid'], ['facebookopenid', 'Facebook openid'], ['appleopenid', 'Apple openid'],
] }, ] },
{ title: '设备与归属', rows: [
['lastbindproductid', '最后绑定产品ID'], ['lastbindchannelid', '最后绑定渠道商'],
['isactivatecode', '已领公码', 'bool'], ['isgiveaway', '已领过赠送', 'bool'],
] },
{ title: '会员与积分', rows: [ { title: '会员与积分', rows: [
['viplv', 'VIP等级'], ['vipexptime', 'VIP到期', 'ts'], ['viplv', 'VIP等级'], ['vipexptime', 'VIP到期', 'ts'],
['aichatintegral', 'AI积分'], ['aichattotalintegral', 'AI累计积分'], ['aichatintegral', 'AI积分(次)'], ['aichattotalintegral', 'AI累计'],
['tradeintegral', '翻译积分'], ['tradetotalintegral', '翻译累计积分'], ['tradeintegral', '翻译积分(秒)'], ['tradetotalintegral', '翻译累计(秒)'],
['meetintegral', '会议积分'], ['meettotalintegral', '会议累计积分'], ['meetintegral', '会议积分(秒)'], ['meettotalintegral', '会议累计(秒)'],
['lastbindproductid', '最后绑定产品ID'], ['isactivatecode', '已领激活码', 'bool'], ['isgiveaway', '首次绑定赠送', 'bool'],
] }, ] },
{ title: '实名认证', rows: [ { title: '实名认证', rows: [
['idverified', '已实名', 'bool'], ['idverifiedtime', '实名时间', 'ts'], ['idverified', '已实名', 'bool'], ['idverifiedtime', '实名时间', 'ts'],
@ -194,7 +428,7 @@ const sections: Section[] = [
] }, ] },
] ]
function cellValue(u: Record<string, any>, row: SectionRow) { function cellValue(u: UserRow, row: SectionRow) {
const t = row[2] const t = row[2]
const v = u[row[0]] const v = u[row[0]]
if (t === 'ts') return fmtTs(v) if (t === 'ts') return fmtTs(v)
@ -203,47 +437,149 @@ function cellValue(u: Record<string, any>, row: SectionRow) {
return dispVal(v) return dispVal(v)
} }
const avatarInitial = computed(() => { // ---- 列表加载 ----
const u = user.value
if (!u) return '?'
return String(u.name || u.uid || '?').charAt(0).toUpperCase()
})
// 选定本页应用 → 同步到全局 currentAppId,使 webApi 自动带上对应 X-App-Id // 选定本页应用 → 同步到全局 currentAppId,使 webApi 自动带上对应 X-App-Id
async function searchUser() { async function loadUsers() {
if (!qApp.value) { warn('请选择应用'); return } if (!qApp.value || !qRegion.value) {
if (!qRegion.value) { warn('请选择区域'); return } rows.value = []
const uid = qUid.value.trim() total.value = 0
if (!uid) { warn('请输入用户UID'); return } return
}
const appId = Number(qRegion.value) const appId = Number(qRegion.value)
lastAppId.value = appId lastAppId.value = appId
lastUid.value = uid
// 带本页选中的 X-App-Id(webApi 取 auth.currentAppId)
auth.setCurrentApp(appId) auth.setCurrentApp(appId)
loading.value = true
listError.value = ''
try {
const d = await webApi<{ users: UserRow[]; total: number }>('getusers', {
keyword: q.keyword.trim(),
state: Number(q.state),
vip: Number(q.vip),
idverified: Number(q.idverified),
start: dateToUnix(q.startDate, false),
end: dateToUnix(q.endDate, true),
page: q.page,
page_size: q.pageSize,
})
rows.value = d?.users ?? []
total.value = d?.total ?? 0
} catch (e: any) {
listError.value = e?.message || '查询失败'
rows.value = []
total.value = 0
} finally {
loading.value = false
}
}
// 改了任何筛选项都要回到第一页——留在第 5 页上换条件,多半会看到一片空白
function reload() {
q.page = 0
loadUsers()
}
function changePage(d: number) {
const next = q.page + d
if (next < 0 || next >= totalPages.value) return
q.page = next
loadUsers()
}
function resetFilters() {
q.keyword = ''
q.state = -1
q.vip = -1
q.idverified = -1
q.startDate = ''
q.endDate = ''
reload()
}
// ---- 详情 ----
// 列表行已经是整行 user,但详情要多出 idverify_* 那几项(getuserinfo 才附带),所以再查一次。
async function openDetail(row: UserRow) {
lastUid.value = String(row.uid || '')
user.value = row
fillEdit(row)
view.value = 'detail'
if (!lastUid.value) return
try {
auth.setCurrentApp(Number(lastAppId.value))
const d = await webApi<UserRow>('getuserinfo', { uid: lastUid.value })
if (d) {
user.value = d
fillEdit(d)
}
} catch (e: any) {
// 详情拉不到不清空:列表那一行的数据仍然是对的,够看大部分字段
warn(e?.message || '获取实名等附加信息失败,已显示列表数据')
}
}
function fillEdit(u: UserRow) {
const snap = {
name: String(u.name || ''),
state: Number(u.State || 0),
viplv: Number(u.viplv || 0),
vipexpDate: unixToLocalInput(u.vipexptime),
trademin: Math.floor((Number(u.tradeintegral) || 0) / 60),
meetmin: Math.floor((Number(u.meetintegral) || 0) / 60),
aichat: Number(u.aichatintegral || 0),
}
Object.assign(edit, snap)
Object.assign(editBase, snap)
}
function resetEdit() {
Object.assign(edit, editBase)
}
// 只提交改过的项:整包提交会把并发中变化的余额一起覆盖成后台读到的旧值
const dirtyCount = computed(() =>
(Object.keys(editBase) as (keyof typeof editBase)[]).filter((k) => edit[k] !== editBase[k]).length,
)
async function saveEdit() {
if (!lastUid.value || !dirtyCount.value) return
const payload: Record<string, unknown> = { uid: lastUid.value }
if (edit.name !== editBase.name) payload.name = edit.name.trim()
if (edit.state !== editBase.state) payload.state = Number(edit.state)
if (edit.viplv !== editBase.viplv) payload.viplv = Number(edit.viplv)
if (edit.vipexpDate !== editBase.vipexpDate) payload.vipexptime = localInputToUnix(edit.vipexpDate)
// 分钟 → 秒:库里存的是秒,表单填的是分钟
if (edit.trademin !== editBase.trademin) payload.tradeintegral = Math.max(0, Number(edit.trademin) || 0) * 60
if (edit.meetmin !== editBase.meetmin) payload.meetintegral = Math.max(0, Number(edit.meetmin) || 0) * 60
if (edit.aichat !== editBase.aichat) payload.aichatintegral = Math.max(0, Number(edit.aichat) || 0)
saving.value = true
try { try {
const data = await webApi<Record<string, any>>('getuserinfo', { uid }) auth.setCurrentApp(Number(lastAppId.value))
user.value = data || {} await webApi('updateuser', payload)
success('已保存')
await refreshCurrent()
} catch (e: any) { } catch (e: any) {
error(e?.message || '查询失败') error(e?.message || '保存失败')
user.value = null } finally {
emptyHint.value = e?.message || '未找到该用户' saving.value = false
} }
} }
// 保存/赠送之后重新拉详情与列表,避免界面上还是改之前的数
async function refreshCurrent() {
try {
auth.setCurrentApp(Number(lastAppId.value))
const d = await webApi<UserRow>('getuserinfo', { uid: lastUid.value })
if (d) {
user.value = d
fillEdit(d)
}
} catch { /* 详情刷新失败不影响已保存的结果 */ }
loadUsers()
}
// ---- 赠送资源点 ---- // ---- 赠送资源点 ----
function openGift() { function openGift() {
if (!lastUid.value) { warn('请先查询用户'); return } if (!lastUid.value) { warn('请先选择用户'); return }
gift.vipday = 0 gift.vipday = 0
gift.trademin = 0 gift.trademin = 0
gift.meetmin = 0 gift.meetmin = 0
giftModal.value?.showModal() giftModal.value?.showModal()
} }
function closeGift() { giftModal.value?.close() }
function closeGift() {
giftModal.value?.close()
}
async function submitGift() { async function submitGift() {
const vipday = Number(gift.vipday) || 0 const vipday = Number(gift.vipday) || 0
@ -251,14 +587,13 @@ async function submitGift() {
const meetmin = Number(gift.meetmin) || 0 const meetmin = Number(gift.meetmin) || 0
if (vipday < 0 || trademin < 0 || meetmin < 0) { warn('数量不能为负'); return } if (vipday < 0 || trademin < 0 || meetmin < 0) { warn('数量不能为负'); return }
if (!vipday && !trademin && !meetmin) { warn('请至少填写一项'); return } if (!vipday && !trademin && !meetmin) { warn('请至少填写一项'); return }
if (!lastAppId.value || !lastUid.value) { warn('请先查询用户'); return } if (!lastAppId.value || !lastUid.value) { warn('请先选择用户'); return }
auth.setCurrentApp(Number(lastAppId.value)) auth.setCurrentApp(Number(lastAppId.value))
try { try {
await webApi('giftuserresource', { uid: lastUid.value, vipday, trademin, meetmin }) await webApi('giftuserresource', { uid: lastUid.value, vipday, trademin, meetmin })
success('赠送成功') success('赠送成功')
giftModal.value?.close() giftModal.value?.close()
searchUser() // 刷新用户资源显示 await refreshCurrent()
} catch (e: any) { } catch (e: any) {
error(e?.message || '赠送失败') error(e?.message || '赠送失败')
} }
@ -273,7 +608,8 @@ function buildAppSelect() {
return return
} }
qApp.value = names[0] qApp.value = names[0]
onAppChange() const items = regionItems.value
qRegion.value = items.length ? items[0].id : ''
} }
// ---- 初始化:加载启用的注册应用 ---- // ---- 初始化:加载启用的注册应用 ----
@ -291,6 +627,7 @@ async function loadApps() {
})) }))
} }
buildAppSelect() buildAppSelect()
loadUsers()
return return
} }
try { try {
@ -300,6 +637,7 @@ async function loadApps() {
apps.value = [] apps.value = []
} }
buildAppSelect() buildAppSelect()
loadUsers()
} }
onMounted(loadApps) onMounted(loadApps)

86
apps/admin/app/utils/chipvendors.ts

@ -1,23 +1,79 @@
// 芯片方案厂商分组,对应后端 DBSolutionProvider.vendor(proto 的 SolutionVendor 枚举)。 // 芯片厂商,对应后端 chip_vendor 表(原 proto 的 SolutionVendor 枚举,2026-09-01 转表)。
// 一个芯片厂商下可能有多套方案,每套方案是一条方案商记录,靠这个分组归拢。 // 一个芯片厂商下可能有多套方案,每套方案是一条方案商记录,靠 DBSolutionProvider.vendor 归拢。
// 新增芯片厂商要三处同步补同一个数值: //
// apps/proto/db.proto 的 SolutionVendor // 以前这里是写死的四条,加一家厂商要同步改三处代码(db.proto 枚举 + 后端 chipVendorNames +
// apps/services/modules/console/api_device.go 的 chipVendorNames // 本文件)再发版;现在改成拉「芯片管理」页维护的表,代码不用动。
// 本文件的 CHIP_VENDORS //
// 缓存是模块级单例:方案商页、芯片管理页共用同一份,避免每个页面各拉一次。
// 增删改后调用 invalidateChipVendors() 让下次 load 重新拉。
import { ref } from 'vue'
export interface ChipVendor { export interface ChipVendor {
value: number id: number // 即 CID:手工指定的十六进制编码(如 0xABF4=恒玄),也是 solution_provider.vendor 指向的值
label: string 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: '杰里' }, export function enabledChipVendors(): ChipVendor[] {
{ value: 2, label: '蓝汛' }, return chipVendors.value.filter((v) => v.enabled !== false)
{ value: 3, label: '恒玄' }, }
{ value: 4, label: '博通' },
]
/**
* id → 厂商名。查不到时回退成「厂商#N」而不是空白——存量方案商指着一个已删除的厂商时,
* 显示成 `厂商#2` 至少能看出是数据问题,空白会让人以为没填。
*/
export function chipVendorName(v?: number | null): string { export function chipVendorName(v?: number | null): string {
const n = Number(v || 0) const n = Number(v || 0)
if (!n) return '未分组' 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)
} }

26
apps/admin/app/utils/menus.ts

@ -27,12 +27,25 @@ export const MENU_GROUPS: MenuGroup[] = [
{ {
label: '生产管理', label: '生产管理',
items: [ items: [
// 顺序 = 生产链路的上下游:芯片厂商 → 方案商 → 代工厂 → 产品 → 发 MAC → 铺渠道。
//
// ⚠️【2026-09-02 暂时下架】品牌商管理(/factory) 与 公码管理(/code) 已从菜单移除。
// 只是注释掉、页面与接口都还在:
// - 直接敲 URL 仍能进(menuItemByPath 查不到就不拦,见 middleware/access.global.ts);
// - 存量账号 access CSV 里的 'factory'/'code' 不受影响,恢复时把下面两行放开即可;
// - 但「后台账号 → 菜单权限」里这两项的勾选框会跟着消失,新账号勾不到。
// 背景:品牌商已不参与生产链路(生成MAC 表单里已去掉品牌商,归属改由渠道商推导)。
{ id: 'chipvendor', label: '芯片管理', to: '/chipvendor', icon: 'M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z' },
{ id: 'provider', label: '方案商管理', to: '/provider', icon: 'M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z' }, { id: 'provider', label: '方案商管理', to: '/provider', icon: 'M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z' },
{ id: 'factory', label: '品牌商管理', to: '/factory', icon: 'M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4' }, { id: 'oemfactory', label: '工厂管理', to: '/oemfactory', icon: 'M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10' },
{ id: 'channel', label: '渠道商管理', to: '/dealers', icon: 'M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z' },
{ id: 'product', label: '产品管理', to: '/product', icon: 'M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4' }, { id: 'product', label: '产品管理', to: '/product', icon: 'M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4' },
{ id: 'code', label: '公码管理', to: '/code', icon: 'M4 4h6v6H4V4zm10 0h6v6h-6V4zM4 14h6v6H4v-6zm10 2h2m-2 4h6m-4-4v4m4-8h2m-2 0v4' }, // 原「生产管理」,2026-09-01 改名:这个页面实际干的就是按产品发 MAC 段/授权码并记生产批次
{ id: 'device', label: '生产管理', to: '/device', icon: 'M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z' }, // (production_batch 的字段就是 startmac/endmac),叫生产管理太泛。
// ⚠️ 权限键仍是 device,不能跟着改名——它已存进各账号的 access CSV,改了等于把所有人的权限清零。
{ id: 'device', label: 'MAC生成管理', to: '/device', icon: 'M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z' },
{ id: 'channel', label: '渠道商管理', to: '/dealers', icon: 'M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z' },
// { id: 'factory', label: '品牌商管理', to: '/factory', icon: 'M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4' },
// { id: 'code', label: '公码管理', to: '/code', icon: 'M4 4h6v6H4V4zm10 0h6v6h-6V4zM4 14h6v6H4v-6zm10 2h2m-2 4h6m-4-4v4m4-8h2m-2 0v4' },
], ],
}, },
{ {
@ -54,6 +67,11 @@ export const MENU_GROUPS: MenuGroup[] = [
{ id: 'appconfig', label: '应用环境配置', to: '/appconfig', icon: 'M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4' }, { id: 'appconfig', label: '应用环境配置', to: '/appconfig', icon: 'M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4' },
{ id: 'appgoods', label: '应用商品配置', to: '/appgoods', icon: 'M16 11V7a4 4 0 00-8 0v4M5 9h14l1 12H4L5 9z' }, { id: 'appgoods', label: '应用商品配置', to: '/appgoods', icon: 'M16 11V7a4 4 0 00-8 0v4M5 9h14l1 12H4L5 9z' },
{ id: 'meettemplate', label: '会议模板', to: '/meettemplates', icon: 'M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z' }, { id: 'meettemplate', label: '会议模板', to: '/meettemplates', icon: 'M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z' },
// 版本控制 / 游客显隐控制:按应用配、不分渠道(存 app_release 表)。
// 这两组开关原先埋在「SaaS 管理 → 渠道分发」里按渠道各存一份,同一个开关有 8 处能改,
// 客户端只读一处,出过「后台开了游客登录但 App 里没有」的事故,故拆到这里单独管。
{ id: 'appversion', label: '版本控制', to: '/appversion', icon: 'M12 3v12m0 0l-4-4m4 4l4-4M4 17v2a2 2 0 002 2h12a2 2 0 002-2v-2' },
{ id: 'guestaccess', label: '游客显隐控制', to: '/guestaccess', icon: 'M15 12a3 3 0 11-6 0 3 3 0 016 0z M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z' },
{ id: 'svcconfig', label: '第三方服务配置', to: '/serviceconfig', icon: 'M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z M15 12a3 3 0 11-6 0 3 3 0 016 0z' }, { id: 'svcconfig', label: '第三方服务配置', to: '/serviceconfig', icon: 'M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z M15 12a3 3 0 11-6 0 3 3 0 016 0z' },
{ id: 'agents', label: 'Agent 配置', to: '/agents', icon: 'M12 3a2 2 0 012 2v1h3a2 2 0 012 2v3a2 2 0 01-2 2h-1v3a2 2 0 01-2 2H8a2 2 0 01-2-2v-3H5a2 2 0 01-2-2V8a2 2 0 012-2h3V5a2 2 0 012-2zM9 13h.01M15 13h.01' }, { id: 'agents', label: 'Agent 配置', to: '/agents', icon: 'M12 3a2 2 0 012 2v1h3a2 2 0 012 2v3a2 2 0 01-2 2h-1v3a2 2 0 01-2 2H8a2 2 0 01-2-2v-3H5a2 2 0 01-2-2V8a2 2 0 012-2h3V5a2 2 0 012-2zM9 13h.01M15 13h.01' },
{ id: 'meetingsvc', label: '会议记录服务', to: '/meetingsvc', icon: 'M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10' }, { id: 'meetingsvc', label: '会议记录服务', to: '/meetingsvc', icon: 'M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10' },

395
apps/client/BES_CONNECT_FIX_PLAN.md

@ -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 修复、无口径争议,
建议先上;方案二牵涉「闸门该多严」的产品决定,定了口径再动。

286
apps/client/CLIENT_AUDIT.md

@ -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 条。

BIN
apps/client/assets/images/app_icon.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

BIN
apps/client/assets/images/devices/earphone_icon.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

6
apps/client/ios/Podfile.lock

@ -7,6 +7,8 @@ PODS:
- AMapFoundation (>= 1.8.2) - AMapFoundation (>= 1.8.2)
- audio_waveforms (0.0.1): - audio_waveforms (0.0.1):
- Flutter - Flutter
- bes_ota_manager (0.0.1):
- Flutter
- bluetooth_manager (0.0.1): - bluetooth_manager (0.0.1):
- Flutter - Flutter
- device_plugin_interface (0.1.0): - device_plugin_interface (0.1.0):
@ -54,6 +56,7 @@ PODS:
DEPENDENCIES: DEPENDENCIES:
- amap_service (from `.symlinks/plugins/amap_service/ios`) - amap_service (from `.symlinks/plugins/amap_service/ios`)
- audio_waveforms (from `.symlinks/plugins/audio_waveforms/ios`) - audio_waveforms (from `.symlinks/plugins/audio_waveforms/ios`)
- bes_ota_manager (from `.symlinks/plugins/bes_ota_manager/ios`)
- bluetooth_manager (from `.symlinks/plugins/bluetooth_manager/ios`) - bluetooth_manager (from `.symlinks/plugins/bluetooth_manager/ios`)
- device_plugin_interface (from `.symlinks/plugins/device_plugin_interface/ios`) - device_plugin_interface (from `.symlinks/plugins/device_plugin_interface/ios`)
- floating_ui_plugin (from `.symlinks/plugins/floating_ui_plugin/ios`) - floating_ui_plugin (from `.symlinks/plugins/floating_ui_plugin/ios`)
@ -82,6 +85,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/amap_service/ios" :path: ".symlinks/plugins/amap_service/ios"
audio_waveforms: audio_waveforms:
:path: ".symlinks/plugins/audio_waveforms/ios" :path: ".symlinks/plugins/audio_waveforms/ios"
bes_ota_manager:
:path: ".symlinks/plugins/bes_ota_manager/ios"
bluetooth_manager: bluetooth_manager:
:path: ".symlinks/plugins/bluetooth_manager/ios" :path: ".symlinks/plugins/bluetooth_manager/ios"
device_plugin_interface: device_plugin_interface:
@ -114,6 +119,7 @@ SPEC CHECKSUMS:
AMapFoundation: 5f01fcabd1e591967e52178d2d96dac843396285 AMapFoundation: 5f01fcabd1e591967e52178d2d96dac843396285
AMapNavi: fceb4715f9a3b5a25f330facebee2aa2bdba42dd AMapNavi: fceb4715f9a3b5a25f330facebee2aa2bdba42dd
audio_waveforms: cd736909ebc6b6a164eb74701d8c705ce3241e1c audio_waveforms: cd736909ebc6b6a164eb74701d8c705ce3241e1c
bes_ota_manager: 4fcdbe6962548a1ac69421f0c3212837e76c0af4
bluetooth_manager: f09915bb90e61cc30cbf93f1f5b127b86d957e43 bluetooth_manager: f09915bb90e61cc30cbf93f1f5b127b86d957e43
device_plugin_interface: ee316d62288f942bd08a05acdf417ec4671639a7 device_plugin_interface: ee316d62288f942bd08a05acdf417ec4671639a7
floating_ui_plugin: d218b25dcd922d3a66a536dd2190ecd47451e5a8 floating_ui_plugin: d218b25dcd922d3a66a536dd2190ecd47451e5a8

16
apps/client/ios/Runner.xcodeproj/project.pbxproj

@ -3,7 +3,7 @@
archiveVersion = 1; archiveVersion = 1;
classes = { classes = {
}; };
objectVersion = 60; objectVersion = 54;
objects = { objects = {
/* Begin PBXBuildFile section */ /* Begin PBXBuildFile section */
@ -259,7 +259,7 @@
); );
mainGroup = 97C146E51CF9000F007C117D; mainGroup = 97C146E51CF9000F007C117D;
packageReferences = ( packageReferences = (
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */, 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */,
); );
productRefGroup = 97C146EF1CF9000F007C117D /* Products */; productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
projectDirPath = ""; projectDirPath = "";
@ -302,14 +302,10 @@
inputFileListPaths = ( inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist", "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist",
); );
inputPaths = (
);
name = "[CP] Copy Pods Resources"; name = "[CP] Copy Pods Resources";
outputFileListPaths = ( outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist", "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist",
); );
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh; shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n"; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n";
@ -354,14 +350,10 @@
inputFileListPaths = ( inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
); );
inputPaths = (
);
name = "[CP] Embed Pods Frameworks"; name = "[CP] Embed Pods Frameworks";
outputFileListPaths = ( outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
); );
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh; shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
@ -810,7 +802,7 @@
/* End XCConfigurationList section */ /* End XCConfigurationList section */
/* Begin XCLocalSwiftPackageReference section */ /* Begin XCLocalSwiftPackageReference section */
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = { 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */ = {
isa = XCLocalSwiftPackageReference; isa = XCLocalSwiftPackageReference;
relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage;
}; };
@ -819,7 +811,7 @@
/* Begin XCSwiftPackageProductDependency section */ /* Begin XCSwiftPackageProductDependency section */
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = { 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = {
isa = XCSwiftPackageProductDependency; isa = XCSwiftPackageProductDependency;
package = 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */; package = 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */;
productName = FlutterGeneratedPluginSwiftPackage; productName = FlutterGeneratedPluginSwiftPackage;
}; };
/* End XCSwiftPackageProductDependency section */ /* End XCSwiftPackageProductDependency section */

12
apps/client/lib/core/theme/theme_service.dart

@ -35,12 +35,16 @@ class ThemeService {
return isDarkMode ? ThemeMode.dark : ThemeMode.light; return isDarkMode ? ThemeMode.dark : ThemeMode.light;
} }
// 默认系统跟随 // 默认浅色。**刻意不跟随系统**:设置页只有一个「暗黑模式」开关
Logger.info('未发现主题偏好,默认系统跟随'); // (读的是 isSavedDarkMode(),无偏好时为 false),跟随系统会让深色手机上
return ThemeMode.system; // 装完就是暗的、开关却显示「关」,两边对不上。要恢复跟随系统得先把
// 设置页改成三档(浅色/深色/跟随系统)。
Logger.info('未发现主题偏好,默认浅色');
return ThemeMode.light;
} catch (e) { } catch (e) {
// 读存储都失败了,同样落到浅色——与上面的默认保持一致。
Logger.error('读取主题模式失败: ${e.toString()}'); Logger.error('读取主题模式失败: ${e.toString()}');
return ThemeMode.system; return ThemeMode.light;
} }
} }

56
apps/client/lib/core/translations/language/ar_sa.dart

@ -1751,4 +1751,60 @@ const Map<String, String> arSA = {
'idVerifyTooFrequent': 'Too many attempts. Please try again later.', 'idVerifyTooFrequent': 'Too many attempts. Please try again later.',
'idVerifyFailed': 'Verification failed. Please try again later.', 'idVerifyFailed': 'Verification failed. Please try again later.',
'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed', 'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed',
// ---- 恒玄(BES)固件 OTA 升级页 ----
'otaAlreadyLatest': 'لديك أحدث إصدار',
'otaCurrentVersion': 'الإصدار الحالي',
'otaDone': 'تم',
'otaDownloading': 'جارٍ تنزيل البرنامج الثابت...',
'otaError': 'خطأ',
'otaExitConfirm': 'هناك ترقية قيد التنفيذ. هل تريد الخروج فعلاً؟',
'otaExitTitle': 'إنهاء الترقية',
'otaFailed': 'فشلت الترقية',
'otaFailedWithCode': 'فشلت الترقية (الرمز @code)',
'otaKeepConnectedWarning': 'أبقِ الجهاز قريبًا ولا تُغلق التطبيق أثناء الترقية.',
'otaLatestVersion': 'أحدث إصدار',
'otaNeedConnect': 'يرجى توصيل جهاز أولاً',
'otaNotConnectedTip': 'لم يتم العثور على جهاز متصل. يرجى توصيل سماعتك قبل الترقية.',
'otaPreparing': 'جارٍ التحضير...',
'otaStartUpgrade': 'بدء الترقية',
'otaSuccessReboot': 'تمت الترقية بنجاح. يرجى إعادة تشغيل الجهاز.',
'otaTimeout': 'انتهت مهلة الترقية. يرجى المحاولة مرة أخرى.',
'otaUnknownDevice': 'جهاز غير معروف',
'otaUpdateAvailable': 'يتوفر تحديث',
'otaUpgrading': 'جارٍ الترقية... @percent%',
'otaUpgradingShort': 'جارٍ الترقية...',
'otaDownloadPreparing': 'جارٍ التحضير لتنزيل البرنامج الثابت...',
'otaFirmwareCached': 'البرنامج الثابت جاهز، جارٍ التحضير للترقية...',
'otaDownloadStarted': 'بدأ تنزيل البرنامج الثابت...',
'otaDownloadingPercent': 'جارٍ تنزيل البرنامج الثابت... @percent%',
'otaDownloadDone': 'اكتمل التنزيل، جارٍ التحضير للترقية...',
'addDevice': 'إضافة جهاز',
'deviceConnectionRestricted': 'اتصال الجهاز مقيّد، هذا الجهاز غير مصرّح به',
// --- 2026-09-03 补齐:这些 key 代码里在用,但 41 个语言文件里都没有 ---
'autopilotTemplateSummary': 'ملخصات تلقائية بقوالب Autopilot',
'bindImmediately': 'الربط الآن',
'disconnect': 'قطع الاتصال',
'discoveredDevices': 'الأجهزة المكتشفة',
'distinguishDifferentSpeakers': 'التمييز بين المتحدثين',
'enterFileName': 'أدخل اسم الملف',
'findMore': 'اكتشف المزيد',
'finishRecording': 'إنهاء التسجيل',
'importAudioFromAppWeb': 'استورد الصوت من التطبيق أو الويب لتفريغه وتلخيصه',
'industrySpecificSummaryTemplate': 'قوالب ملخصات احترافية مصمّمة لمجالك',
'markFavorite': 'إضافة إلى المفضلة',
'monthlyTranscriptionDuration': '300 دقيقة تفريغ نصي شهريًا',
'noDevicesFound': 'لم يتم العثور على أجهزة',
'notBound': 'غير مرتبط',
'notCharging': 'لا يتم الشحن',
'pauseRecording': 'إيقاف التسجيل مؤقتًا',
'pleaseEnterVerificationCode': 'الرجاء إدخال رمز التحقق',
'scanIdle': 'لا يوجد بحث',
'scanning': 'جارٍ البحث...',
'startScan': 'بدء البحث',
'stopScan': 'إيقاف البحث',
'storageBytes': 'إجمالي التخزين',
'storageUploads': 'عمليات الرفع',
'unmarkFavorite': 'إزالة من المفضلة',
'unnamedDevice': 'جهاز بلا اسم',
'userDataParseFailed': 'تعذّر تحليل بيانات المستخدم',
}; };

56
apps/client/lib/core/translations/language/bg_bg.dart

@ -1814,4 +1814,60 @@ const Map<String, String> bgBG = {
'idVerifyTooFrequent': 'Too many attempts. Please try again later.', 'idVerifyTooFrequent': 'Too many attempts. Please try again later.',
'idVerifyFailed': 'Verification failed. Please try again later.', 'idVerifyFailed': 'Verification failed. Please try again later.',
'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed', 'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed',
// ---- 恒玄(BES)固件 OTA 升级页 ----
'otaAlreadyLatest': 'Вече е актуален',
'otaCurrentVersion': 'Текуща версия',
'otaDone': 'Готово',
'otaDownloading': 'Изтегляне на фърмуера...',
'otaError': 'Грешка',
'otaExitConfirm': 'В момента тече актуализация. Сигурни ли сте, че искате да излезете?',
'otaExitTitle': 'Изход от актуализацията',
'otaFailed': 'Актуализацията е неуспешна',
'otaFailedWithCode': 'Актуализацията е неуспешна (код @code)',
'otaKeepConnectedWarning': 'По време на актуализацията дръжте устройството наблизо и не затваряйте приложението.',
'otaLatestVersion': 'Най-нова версия',
'otaNeedConnect': 'Първо свържете устройство',
'otaNotConnectedTip': 'Не е открито свързано устройство. Свържете слушалките, преди да актуализирате.',
'otaPreparing': 'Подготовка...',
'otaStartUpgrade': 'Стартиране на актуализацията',
'otaSuccessReboot': 'Актуализацията е успешна. Рестартирайте устройството.',
'otaTimeout': 'Времето за актуализация изтече. Опитайте отново.',
'otaUnknownDevice': 'Неизвестно устройство',
'otaUpdateAvailable': 'Налична е актуализация',
'otaUpgrading': 'Актуализиране... @percent%',
'otaUpgradingShort': 'Актуализиране...',
'otaDownloadPreparing': 'Подготовка за изтегляне на фърмуера...',
'otaFirmwareCached': 'Фърмуерът е готов, подготовка за актуализация...',
'otaDownloadStarted': 'Изтеглянето на фърмуера започна...',
'otaDownloadingPercent': 'Изтегляне на фърмуера... @percent%',
'otaDownloadDone': 'Изтеглянето завърши, подготовка за актуализация...',
'addDevice': 'Добавяне на устройство',
'deviceConnectionRestricted': 'Връзката с устройството е ограничена: устройството не е оторизирано',
// --- 2026-09-03 补齐:这些 key 代码里在用,但 41 个语言文件里都没有 ---
'autopilotTemplateSummary': 'Автоматични обобщения с шаблони Autopilot',
'bindImmediately': 'Свържи сега',
'disconnect': 'Прекъсване',
'discoveredDevices': 'Открити устройства',
'distinguishDifferentSpeakers': 'Различаване на говорещите',
'enterFileName': 'Въведете име на файл',
'findMore': 'Открий още',
'finishRecording': 'Край на записа',
'importAudioFromAppWeb': 'Импортирайте аудио от приложението или уеб, за да го транскрибирате и обобщите',
'industrySpecificSummaryTemplate': 'Професионални шаблони за обобщение за вашия бранш',
'markFavorite': 'Добави в любими',
'monthlyTranscriptionDuration': '300 минути транскрипция месечно',
'noDevicesFound': 'Не са открити устройства',
'notBound': 'Не е свързано',
'notCharging': 'Не се зарежда',
'pauseRecording': 'Пауза на записа',
'pleaseEnterVerificationCode': 'Въведете кода за потвърждение',
'scanIdle': 'Не се сканира',
'scanning': 'Сканиране...',
'startScan': 'Започни сканиране',
'stopScan': 'Спри сканирането',
'storageBytes': 'Общо хранилище',
'storageUploads': 'Качвания',
'unmarkFavorite': 'Премахни от любими',
'unnamedDevice': 'Устройство без име',
'userDataParseFailed': 'Данните на потребителя не бяха разчетени',
}; };

56
apps/client/lib/core/translations/language/cs_cz.dart

@ -1782,4 +1782,60 @@ const Map<String, String> csCZ = {
'idVerifyTooFrequent': 'Too many attempts. Please try again later.', 'idVerifyTooFrequent': 'Too many attempts. Please try again later.',
'idVerifyFailed': 'Verification failed. Please try again later.', 'idVerifyFailed': 'Verification failed. Please try again later.',
'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed', 'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed',
// ---- 恒玄(BES)固件 OTA 升级页 ----
'otaAlreadyLatest': 'Již aktuální',
'otaCurrentVersion': 'Aktuální verze',
'otaDone': 'Hotovo',
'otaDownloading': 'Stahování firmwaru...',
'otaError': 'Chyba',
'otaExitConfirm': 'Probíhá aktualizace. Opravdu chcete odejít?',
'otaExitTitle': 'Ukončit aktualizaci',
'otaFailed': 'Aktualizace se nezdařila',
'otaFailedWithCode': 'Aktualizace se nezdařila (kód @code)',
'otaKeepConnectedWarning': 'Během aktualizace mějte zařízení nablízku a nezavírejte aplikaci.',
'otaLatestVersion': 'Nejnovější verze',
'otaNeedConnect': 'Nejprve připojte zařízení',
'otaNotConnectedTip': 'Nebylo nalezeno připojené zařízení. Před aktualizací připojte sluchátka.',
'otaPreparing': 'Příprava...',
'otaStartUpgrade': 'Spustit aktualizaci',
'otaSuccessReboot': 'Aktualizace proběhla úspěšně. Restartujte zařízení.',
'otaTimeout': 'Vypršel časový limit aktualizace. Zkuste to znovu.',
'otaUnknownDevice': 'Neznámé zařízení',
'otaUpdateAvailable': 'Je k dispozici aktualizace',
'otaUpgrading': 'Aktualizace... @percent%',
'otaUpgradingShort': 'Aktualizace...',
'otaDownloadPreparing': 'Příprava stahování firmwaru...',
'otaFirmwareCached': 'Firmware připraven, příprava aktualizace...',
'otaDownloadStarted': 'Stahování firmwaru zahájeno...',
'otaDownloadingPercent': 'Stahování firmwaru... @percent%',
'otaDownloadDone': 'Stahování dokončeno, příprava aktualizace...',
'addDevice': 'Přidat zařízení',
'deviceConnectionRestricted': 'Připojení zařízení je omezeno: toto zařízení není autorizováno',
// --- 2026-09-03 补齐:这些 key 代码里在用,但 41 个语言文件里都没有 ---
'autopilotTemplateSummary': 'Automatická shrnutí ze šablon Autopilot',
'bindImmediately': 'Spárovat nyní',
'disconnect': 'Odpojit',
'discoveredDevices': 'Nalezená zařízení',
'distinguishDifferentSpeakers': 'Rozlišení mluvčích',
'enterFileName': 'Zadejte název souboru',
'findMore': 'Objevit více',
'finishRecording': 'Ukončit nahrávání',
'importAudioFromAppWeb': 'Importujte zvuk z aplikace nebo webu, přepište jej a shrňte',
'industrySpecificSummaryTemplate': 'Profesionální šablony shrnutí pro váš obor',
'markFavorite': 'Přidat k oblíbeným',
'monthlyTranscriptionDuration': '300 minut přepisu měsíčně',
'noDevicesFound': 'Nebyla nalezena žádná zařízení',
'notBound': 'Nespárováno',
'notCharging': 'Nenabíjí se',
'pauseRecording': 'Pozastavit nahrávání',
'pleaseEnterVerificationCode': 'Zadejte ověřovací kód',
'scanIdle': 'Nevyhledává se',
'scanning': 'Vyhledávání...',
'startScan': 'Spustit vyhledávání',
'stopScan': 'Zastavit vyhledávání',
'storageBytes': 'Celkové úložiště',
'storageUploads': 'Nahrání',
'unmarkFavorite': 'Odebrat z oblíbených',
'unnamedDevice': 'Nepojmenované zařízení',
'userDataParseFailed': 'Data uživatele se nepodařilo načíst',
}; };

56
apps/client/lib/core/translations/language/da_dk.dart

@ -1784,4 +1784,60 @@ const Map<String, String> daDK = {
'idVerifyTooFrequent': 'Too many attempts. Please try again later.', 'idVerifyTooFrequent': 'Too many attempts. Please try again later.',
'idVerifyFailed': 'Verification failed. Please try again later.', 'idVerifyFailed': 'Verification failed. Please try again later.',
'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed', 'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed',
// ---- 恒玄(BES)固件 OTA 升级页 ----
'otaAlreadyLatest': 'Allerede opdateret',
'otaCurrentVersion': 'Nuværende version',
'otaDone': 'Færdig',
'otaDownloading': 'Downloader firmware...',
'otaError': 'Fejl',
'otaExitConfirm': 'En opdatering er i gang. Er du sikker på, at du vil afslutte?',
'otaExitTitle': 'Afslut opdatering',
'otaFailed': 'Opdateringen mislykkedes',
'otaFailedWithCode': 'Opdateringen mislykkedes (kode @code)',
'otaKeepConnectedWarning': 'Hold enheden tæt på, og luk ikke appen under opdateringen.',
'otaLatestVersion': 'Nyeste version',
'otaNeedConnect': 'Tilslut først en enhed',
'otaNotConnectedTip': 'Ingen tilsluttet enhed fundet. Tilslut dine øretelefoner før opdatering.',
'otaPreparing': 'Forbereder...',
'otaStartUpgrade': 'Start opdatering',
'otaSuccessReboot': 'Opdateringen lykkedes. Genstart enheden.',
'otaTimeout': 'Opdateringen fik timeout. Prøv igen.',
'otaUnknownDevice': 'Ukendt enhed',
'otaUpdateAvailable': 'Opdatering tilgængelig',
'otaUpgrading': 'Opdaterer... @percent%',
'otaUpgradingShort': 'Opdaterer...',
'otaDownloadPreparing': 'Forbereder download af firmware...',
'otaFirmwareCached': 'Firmware klar, forbereder opdatering...',
'otaDownloadStarted': 'Download af firmware startet...',
'otaDownloadingPercent': 'Downloader firmware... @percent%',
'otaDownloadDone': 'Download fuldført, forbereder opdatering...',
'addDevice': 'Tilføj enhed',
'deviceConnectionRestricted': 'Enhedsforbindelse begrænset: enheden er ikke autoriseret',
// --- 2026-09-03 补齐:这些 key 代码里在用,但 41 个语言文件里都没有 ---
'autopilotTemplateSummary': 'Automatiske opsummeringer med Autopilot-skabeloner',
'bindImmediately': 'Tilknyt nu',
'disconnect': 'Afbryd forbindelsen',
'discoveredDevices': 'Fundne enheder',
'distinguishDifferentSpeakers': 'Skeln mellem talere',
'enterFileName': 'Indtast et filnavn',
'findMore': 'Opdag mere',
'finishRecording': 'Afslut optagelse',
'importAudioFromAppWeb': 'Importér lyd fra appen eller nettet for at transskribere og opsummere',
'industrySpecificSummaryTemplate': 'Professionelle opsummeringsskabeloner til din branche',
'markFavorite': 'Føj til favoritter',
'monthlyTranscriptionDuration': '300 minutters transskription om måneden',
'noDevicesFound': 'Der blev ikke fundet nogen enheder',
'notBound': 'Ikke tilknyttet',
'notCharging': 'Oplader ikke',
'pauseRecording': 'Sæt optagelsen på pause',
'pleaseEnterVerificationCode': 'Indtast bekræftelseskoden',
'scanIdle': 'Søger ikke',
'scanning': 'Søger...',
'startScan': 'Start søgning',
'stopScan': 'Stop søgning',
'storageBytes': 'Samlet lagerplads',
'storageUploads': 'Uploads',
'unmarkFavorite': 'Fjern fra favoritter',
'unnamedDevice': 'Enhed uden navn',
'userDataParseFailed': 'Brugerdata kunne ikke læses',
}; };

57
apps/client/lib/core/translations/language/de_de.dart

@ -1797,7 +1797,7 @@ const Map<String, String> deDE = {
'currentConnection': 'Aktuelle Verbindung', 'currentConnection': 'Aktuelle Verbindung',
'binding': 'Wird verbunden…', 'binding': 'Wird verbunden…',
'tabDevice': 'Geräte', 'tabDevice': 'Geräte',
'tabTodo': 'To-do', 'tabTodo': 'Remin',
'tabPlaza': 'Plaza', 'tabPlaza': 'Plaza',
'sloganHome': 'Technik für ein smarteres Leben', 'sloganHome': 'Technik für ein smarteres Leben',
'sloganAi': 'Grenzenlose Intelligenz', 'sloganAi': 'Grenzenlose Intelligenz',
@ -2071,4 +2071,59 @@ const Map<String, String> deDE = {
'idVerifyTooFrequent': 'Too many attempts. Please try again later.', 'idVerifyTooFrequent': 'Too many attempts. Please try again later.',
'idVerifyFailed': 'Verification failed. Please try again later.', 'idVerifyFailed': 'Verification failed. Please try again later.',
'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed', 'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed',
// ---- 恒玄(BES)固件 OTA 升级页 ----
'otaAlreadyLatest': 'Bereits aktuell',
'otaCurrentVersion': 'Aktuelle Version',
'otaDone': 'Fertig',
'otaDownloading': 'Firmware wird heruntergeladen...',
'otaError': 'Fehler',
'otaExitConfirm': 'Ein Update läuft. Möchten Sie wirklich beenden?',
'otaExitTitle': 'Update beenden',
'otaFailed': 'Update fehlgeschlagen',
'otaFailedWithCode': 'Update fehlgeschlagen (Code @code)',
'otaKeepConnectedWarning': 'Halten Sie das Gerät während des Updates in der Nähe und schließen Sie die App nicht.',
'otaLatestVersion': 'Neueste Version',
'otaNeedConnect': 'Bitte zuerst ein Gerät verbinden',
'otaNotConnectedTip': 'Kein verbundenes Gerät gefunden. Bitte verbinden Sie Ihre Kopfhörer vor dem Update.',
'otaPreparing': 'Wird vorbereitet...',
'otaStartUpgrade': 'Update starten',
'otaSuccessReboot': 'Update erfolgreich. Bitte starten Sie das Gerät neu.',
'otaTimeout': 'Zeitüberschreitung beim Update. Bitte erneut versuchen.',
'otaUnknownDevice': 'Unbekanntes Gerät',
'otaUpdateAvailable': 'Update verfügbar',
'otaUpgrading': 'Update läuft... @percent%',
'otaUpgradingShort': 'Update läuft...',
'otaDownloadPreparing': 'Download der Firmware wird vorbereitet...',
'otaFirmwareCached': 'Firmware bereit, Update wird vorbereitet...',
'otaDownloadStarted': 'Firmware-Download gestartet...',
'otaDownloadingPercent': 'Firmware wird heruntergeladen... @percent%',
'otaDownloadDone': 'Download abgeschlossen, Update wird vorbereitet...',
'deviceConnectionRestricted': 'Geräteverbindung eingeschränkt: Dieses Gerät ist nicht autorisiert',
// --- 2026-09-03 补齐:这些 key 代码里在用,但 41 个语言文件里都没有 ---
'autopilotTemplateSummary': 'Automatische Zusammenfassungen mit Autopilot-Vorlagen',
'bindImmediately': 'Jetzt koppeln',
'disconnect': 'Trennen',
'discoveredDevices': 'Gefundene Geräte',
'distinguishDifferentSpeakers': 'Sprecher unterscheiden',
'enterFileName': 'Dateinamen eingeben',
'findMore': 'Mehr entdecken',
'finishRecording': 'Aufnahme beenden',
'importAudioFromAppWeb': 'Audio aus App oder Web importieren, transkribieren und zusammenfassen',
'industrySpecificSummaryTemplate': 'Professionelle Zusammenfassungsvorlagen für Ihre Branche',
'markFavorite': 'Zu Favoriten hinzufügen',
'monthlyTranscriptionDuration': '300 Minuten Transkription pro Monat',
'noDevicesFound': 'Keine Geräte gefunden',
'notBound': 'Nicht gekoppelt',
'notCharging': 'Lädt nicht',
'pauseRecording': 'Aufnahme pausieren',
'pleaseEnterVerificationCode': 'Bitte Bestätigungscode eingeben',
'scanIdle': 'Sucht nicht',
'scanning': 'Suche läuft ...',
'startScan': 'Suche starten',
'stopScan': 'Suche stoppen',
'storageBytes': 'Gesamter Speicher',
'storageUploads': 'Uploads',
'unmarkFavorite': 'Aus Favoriten entfernen',
'unnamedDevice': 'Unbenanntes Gerät',
'userDataParseFailed': 'Benutzerdaten konnten nicht gelesen werden',
}; };

56
apps/client/lib/core/translations/language/el_gr.dart

@ -1818,4 +1818,60 @@ const Map<String, String> elGR = {
'idVerifyTooFrequent': 'Too many attempts. Please try again later.', 'idVerifyTooFrequent': 'Too many attempts. Please try again later.',
'idVerifyFailed': 'Verification failed. Please try again later.', 'idVerifyFailed': 'Verification failed. Please try again later.',
'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed', 'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed',
// ---- 恒玄(BES)固件 OTA 升级页 ----
'otaAlreadyLatest': 'Ήδη ενημερωμένο',
'otaCurrentVersion': 'Τρέχουσα έκδοση',
'otaDone': 'Τέλος',
'otaDownloading': 'Λήψη υλικολογισμικού...',
'otaError': 'Σφάλμα',
'otaExitConfirm': 'Μια αναβάθμιση βρίσκεται σε εξέλιξη. Θέλετε σίγουρα να βγείτε;',
'otaExitTitle': 'Έξοδος από την αναβάθμιση',
'otaFailed': 'Η αναβάθμιση απέτυχε',
'otaFailedWithCode': 'Η αναβάθμιση απέτυχε (κωδικός @code)',
'otaKeepConnectedWarning': 'Κρατήστε τη συσκευή κοντά και μην κλείσετε την εφαρμογή κατά την αναβάθμιση.',
'otaLatestVersion': 'Τελευταία έκδοση',
'otaNeedConnect': 'Συνδέστε πρώτα μια συσκευή',
'otaNotConnectedTip': 'Δεν εντοπίστηκε συνδεδεμένη συσκευή. Συνδέστε τα ακουστικά σας πριν την αναβάθμιση.',
'otaPreparing': 'Προετοιμασία...',
'otaStartUpgrade': 'Έναρξη αναβάθμισης',
'otaSuccessReboot': 'Η αναβάθμιση ολοκληρώθηκε. Επανεκκινήστε τη συσκευή.',
'otaTimeout': 'Λήξη χρόνου αναβάθμισης. Δοκιμάστε ξανά.',
'otaUnknownDevice': 'Άγνωστη συσκευή',
'otaUpdateAvailable': 'Διαθέσιμη ενημέρωση',
'otaUpgrading': 'Αναβάθμιση... @percent%',
'otaUpgradingShort': 'Αναβάθμιση...',
'otaDownloadPreparing': 'Προετοιμασία λήψης υλικολογισμικού...',
'otaFirmwareCached': 'Το υλικολογισμικό είναι έτοιμο, προετοιμασία αναβάθμισης...',
'otaDownloadStarted': 'Η λήψη υλικολογισμικού ξεκίνησε...',
'otaDownloadingPercent': 'Λήψη υλικολογισμικού... @percent%',
'otaDownloadDone': 'Η λήψη ολοκληρώθηκε, προετοιμασία αναβάθμισης...',
'addDevice': 'Προσθήκη συσκευής',
'deviceConnectionRestricted': 'Περιορισμένη σύνδεση συσκευής: η συσκευή δεν είναι εξουσιοδοτημένη',
// --- 2026-09-03 补齐:这些 key 代码里在用,但 41 个语言文件里都没有 ---
'autopilotTemplateSummary': 'Αυτόματες συνόψεις με πρότυπα Autopilot',
'bindImmediately': 'Σύνδεση τώρα',
'disconnect': 'Αποσύνδεση',
'discoveredDevices': 'Συσκευές που βρέθηκαν',
'distinguishDifferentSpeakers': 'Διάκριση ομιλητών',
'enterFileName': 'Εισαγάγετε όνομα αρχείου',
'findMore': 'Ανακαλύψτε περισσότερα',
'finishRecording': 'Ολοκλήρωση εγγραφής',
'importAudioFromAppWeb': 'Εισαγάγετε ήχο από την εφαρμογή ή το web για απομαγνητοφώνηση και σύνοψη',
'industrySpecificSummaryTemplate': 'Επαγγελματικά πρότυπα σύνοψης για τον κλάδο σας',
'markFavorite': 'Προσθήκη στα αγαπημένα',
'monthlyTranscriptionDuration': '300 λεπτά απομαγνητοφώνησης τον μήνα',
'noDevicesFound': 'Δεν βρέθηκαν συσκευές',
'notBound': 'Μη συνδεδεμένο',
'notCharging': 'Δεν φορτίζει',
'pauseRecording': 'Παύση εγγραφής',
'pleaseEnterVerificationCode': 'Εισαγάγετε τον κωδικό επαλήθευσης',
'scanIdle': 'Δεν γίνεται σάρωση',
'scanning': 'Σάρωση...',
'startScan': 'Έναρξη σάρωσης',
'stopScan': 'Διακοπή σάρωσης',
'storageBytes': 'Συνολικός αποθηκευτικός χώρος',
'storageUploads': 'Μεταφορτώσεις',
'unmarkFavorite': 'Αφαίρεση από τα αγαπημένα',
'unnamedDevice': 'Συσκευή χωρίς όνομα',
'userDataParseFailed': 'Αποτυχία ανάγνωσης δεδομένων χρήστη',
}; };

62
apps/client/lib/core/translations/language/en_us.dart

@ -1554,6 +1554,8 @@ const Map<String, String> enUS = {
'otaUpgradeSuccessComplete': 'Upgrade completed successfully', 'otaUpgradeSuccessComplete': 'Upgrade completed successfully',
'otaUpgradeFailedWithCode': 'Upgrade failed, error code: @code', 'otaUpgradeFailedWithCode': 'Upgrade failed, error code: @code',
'otaUpgradeFailedTitle': 'Upgrade Failed', 'otaUpgradeFailedTitle': 'Upgrade Failed',
'otaBesNotSupported':
'This headset does not yet support in-app firmware upgrade. Please contact support.',
'otaUpgradeFailedDesc': 'otaUpgradeFailedDesc':
'Please check device connection or update package, error code: @code', 'Please check device connection or update package, error code: @code',
'otaDeviceDisconnected': 'Device disconnected', 'otaDeviceDisconnected': 'Device disconnected',
@ -1765,7 +1767,7 @@ const Map<String, String> enUS = {
// ==== 界面文案补齐(自启动授权 / 设备页 / AI页 / 广场 等)==== // ==== 界面文案补齐(自启动授权 / 设备页 / AI页 / 广场 等)====
'tabDevice': 'Devices', 'tabDevice': 'Devices',
'tabTodo': 'To-Do', 'tabTodo': 'Remin',
'tabPlaza': 'Plaza', 'tabPlaza': 'Plaza',
'sloganHome': 'Technology for a smarter life', 'sloganHome': 'Technology for a smarter life',
'sloganAi': 'Limitless intelligence', 'sloganAi': 'Limitless intelligence',
@ -2036,4 +2038,62 @@ const Map<String, String> enUS = {
'idVerifyTooFrequent': 'Too many attempts. Please try again later.', 'idVerifyTooFrequent': 'Too many attempts. Please try again later.',
'idVerifyFailed': 'Verification failed. Please try again later.', 'idVerifyFailed': 'Verification failed. Please try again later.',
'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed', 'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed',
// ---- 恒玄(BES)固件 OTA 升级页 ----
'otaAlreadyLatest': 'Already up to date',
'otaCurrentVersion': 'Current version',
'otaDone': 'Done',
'otaDownloading': 'Downloading firmware...',
'otaError': 'Error',
'otaExitConfirm': 'An upgrade is in progress. Are you sure you want to exit?',
'otaExitTitle': 'Exit Upgrade',
'otaFailed': 'Upgrade failed',
'otaFailedWithCode': 'Upgrade failed (code @code)',
'otaKeepConnectedWarning':
'Keep the device close and do not close the app during the upgrade.',
'otaLatestVersion': 'Latest version',
'otaNeedConnect': 'Please connect a device first',
'otaNotConnectedTip':
'No connected device detected. Please connect your earbuds before upgrading.',
'otaPreparing': 'Preparing...',
'otaStartUpgrade': 'Start Upgrade',
'otaSuccessReboot': 'Upgrade succeeded. Please restart the device.',
'otaTimeout': 'Upgrade timed out. Please try again.',
'otaUnknownDevice': 'Unknown device',
'otaUpdateAvailable': 'Update available',
'otaUpgrading': 'Upgrading... @percent%',
'otaUpgradingShort': 'Upgrading...',
'otaDownloadPreparing': 'Preparing to download firmware...',
'otaFirmwareCached': 'Firmware ready, preparing to upgrade...',
'otaDownloadStarted': 'Downloading firmware...',
'otaDownloadingPercent': 'Downloading firmware... @percent%',
'otaDownloadDone': 'Download complete, preparing to upgrade...',
'deviceConnectionRestricted': 'Device connection restricted: this device is not authorized',
// --- 2026-09-03 补齐:这些 key 代码里在用,但 41 个语言文件里都没有 ---
'autopilotTemplateSummary': 'Autopilot template summaries',
'bindImmediately': 'Bind Now',
'disconnect': 'Disconnect',
'discoveredDevices': 'Discovered Devices',
'distinguishDifferentSpeakers': 'Distinguish different speakers',
'enterFileName': 'Enter a file name',
'findMore': 'Discover More',
'finishRecording': 'Finish Recording',
'importAudioFromAppWeb': 'Import audio from the app or web to transcribe and summarize',
'industrySpecificSummaryTemplate': 'Professional summary templates for your industry',
'markFavorite': 'Add to Favorites',
'monthlyTranscriptionDuration': '300 minutes of transcription per month',
'noDevicesFound': 'No devices found',
'notBound': 'Not bound',
'notCharging': 'Not charging',
'pauseRecording': 'Pause Recording',
'pleaseEnterVerificationCode': 'Please enter the verification code',
'scanIdle': 'Not scanning',
'scanning': 'Scanning...',
'startScan': 'Start Scan',
'stopScan': 'Stop Scan',
'storageBytes': 'Total Storage',
'storageUploads': 'Uploads',
'unmarkFavorite': 'Remove from Favorites',
'unnamedDevice': 'Unnamed Device',
'userDataParseFailed': 'Failed to parse user data',
}; };

57
apps/client/lib/core/translations/language/es_es.dart

@ -1768,7 +1768,7 @@ const Map<String, String> esES = {
// ==== 界面文案补齐(自启动授权 / 设备页 / AI页 / 广场 等)==== // ==== 界面文案补齐(自启动授权 / 设备页 / AI页 / 广场 等)====
'tabDevice': 'Dispositivos', 'tabDevice': 'Dispositivos',
'tabTodo': 'Tareas', 'tabTodo': 'Remin',
'tabPlaza': 'Plaza', 'tabPlaza': 'Plaza',
'sloganHome': 'Tecnología para una vida inteligente', 'sloganHome': 'Tecnología para una vida inteligente',
'sloganAi': 'Inteligencia sin límites', 'sloganAi': 'Inteligencia sin límites',
@ -2065,4 +2065,59 @@ const Map<String, String> esES = {
'idVerifyTooFrequent': 'Too many attempts. Please try again later.', 'idVerifyTooFrequent': 'Too many attempts. Please try again later.',
'idVerifyFailed': 'Verification failed. Please try again later.', 'idVerifyFailed': 'Verification failed. Please try again later.',
'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed', 'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed',
// ---- 恒玄(BES)固件 OTA 升级页 ----
'otaAlreadyLatest': 'Ya está actualizado',
'otaCurrentVersion': 'Versión actual',
'otaDone': 'Listo',
'otaDownloading': 'Descargando firmware...',
'otaError': 'Error',
'otaExitConfirm': 'Hay una actualización en curso. ¿Seguro que quieres salir?',
'otaExitTitle': 'Salir de la actualización',
'otaFailed': 'Error en la actualización',
'otaFailedWithCode': 'Error en la actualización (código @code)',
'otaKeepConnectedWarning': 'Mantén el dispositivo cerca y no cierres la aplicación durante la actualización.',
'otaLatestVersion': 'Última versión',
'otaNeedConnect': 'Conecta un dispositivo primero',
'otaNotConnectedTip': 'No se detectó ningún dispositivo conectado. Conecta tus auriculares antes de actualizar.',
'otaPreparing': 'Preparando...',
'otaStartUpgrade': 'Iniciar actualización',
'otaSuccessReboot': 'Actualización correcta. Reinicia el dispositivo.',
'otaTimeout': 'Se agotó el tiempo de la actualización. Inténtalo de nuevo.',
'otaUnknownDevice': 'Dispositivo desconocido',
'otaUpdateAvailable': 'Actualización disponible',
'otaUpgrading': 'Actualizando... @percent%',
'otaUpgradingShort': 'Actualizando...',
'otaDownloadPreparing': 'Preparando la descarga del firmware...',
'otaFirmwareCached': 'Firmware listo, preparando la actualización...',
'otaDownloadStarted': 'Descarga del firmware iniciada...',
'otaDownloadingPercent': 'Descargando firmware... @percent%',
'otaDownloadDone': 'Descarga completa, preparando la actualización...',
'deviceConnectionRestricted': 'Conexión del dispositivo restringida: este dispositivo no está autorizado',
// --- 2026-09-03 补齐:这些 key 代码里在用,但 41 个语言文件里都没有 ---
'autopilotTemplateSummary': 'Resúmenes automáticos con plantillas Autopilot',
'bindImmediately': 'Vincular ahora',
'disconnect': 'Desconectar',
'discoveredDevices': 'Dispositivos encontrados',
'distinguishDifferentSpeakers': 'Distinguir a los hablantes',
'enterFileName': 'Introduce un nombre de archivo',
'findMore': 'Descubrir más',
'finishRecording': 'Finalizar grabación',
'importAudioFromAppWeb': 'Importa audio desde la app o la web para transcribirlo y resumirlo',
'industrySpecificSummaryTemplate': 'Plantillas de resumen profesionales para tu sector',
'markFavorite': 'Añadir a favoritos',
'monthlyTranscriptionDuration': '300 minutos de transcripción al mes',
'noDevicesFound': 'No se encontraron dispositivos',
'notBound': 'Sin vincular',
'notCharging': 'No está cargando',
'pauseRecording': 'Pausar grabación',
'pleaseEnterVerificationCode': 'Introduce el código de verificación',
'scanIdle': 'Sin buscar',
'scanning': 'Buscando...',
'startScan': 'Iniciar búsqueda',
'stopScan': 'Detener búsqueda',
'storageBytes': 'Almacenamiento total',
'storageUploads': 'Subidas',
'unmarkFavorite': 'Quitar de favoritos',
'unnamedDevice': 'Dispositivo sin nombre',
'userDataParseFailed': 'Error al procesar los datos del usuario',
}; };

56
apps/client/lib/core/translations/language/et_ee.dart

@ -1778,4 +1778,60 @@ const Map<String, String> etEE = {
'idVerifyTooFrequent': 'Too many attempts. Please try again later.', 'idVerifyTooFrequent': 'Too many attempts. Please try again later.',
'idVerifyFailed': 'Verification failed. Please try again later.', 'idVerifyFailed': 'Verification failed. Please try again later.',
'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed', 'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed',
// ---- 恒玄(BES)固件 OTA 升级页 ----
'otaAlreadyLatest': 'Juba ajakohane',
'otaCurrentVersion': 'Praegune versioon',
'otaDone': 'Valmis',
'otaDownloading': 'Püsivara allalaadimine...',
'otaError': 'Viga',
'otaExitConfirm': 'Uuendus on pooleli. Kas soovite kindlasti väljuda?',
'otaExitTitle': 'Välju uuendusest',
'otaFailed': 'Uuendamine ebaõnnestus',
'otaFailedWithCode': 'Uuendamine ebaõnnestus (kood @code)',
'otaKeepConnectedWarning': 'Hoidke seadet uuendamise ajal lähedal ja ärge sulgege rakendust.',
'otaLatestVersion': 'Uusim versioon',
'otaNeedConnect': 'Ühendage esmalt seade',
'otaNotConnectedTip': 'Ühendatud seadet ei leitud. Ühendage enne uuendamist kõrvaklapid.',
'otaPreparing': 'Ettevalmistamine...',
'otaStartUpgrade': 'Alusta uuendamist',
'otaSuccessReboot': 'Uuendamine õnnestus. Taaskäivitage seade.',
'otaTimeout': 'Uuendamise ajalõpp. Proovige uuesti.',
'otaUnknownDevice': 'Tundmatu seade',
'otaUpdateAvailable': 'Saadaval on uuendus',
'otaUpgrading': 'Uuendamine... @percent%',
'otaUpgradingShort': 'Uuendamine...',
'otaDownloadPreparing': 'Püsivara allalaadimise ettevalmistamine...',
'otaFirmwareCached': 'Püsivara on valmis, uuendamise ettevalmistamine...',
'otaDownloadStarted': 'Püsivara allalaadimine algas...',
'otaDownloadingPercent': 'Püsivara allalaadimine... @percent%',
'otaDownloadDone': 'Allalaadimine lõpetatud, uuendamise ettevalmistamine...',
'addDevice': 'Lisa seade',
'deviceConnectionRestricted': 'Seadme ühendus on piiratud: see seade pole autoriseeritud',
// --- 2026-09-03 补齐:这些 key 代码里在用,但 41 个语言文件里都没有 ---
'autopilotTemplateSummary': 'Automaatsed kokkuvõtted Autopiloti mallidega',
'bindImmediately': 'Seo kohe',
'disconnect': 'Katkesta ühendus',
'discoveredDevices': 'Leitud seadmed',
'distinguishDifferentSpeakers': 'Kõnelejate eristamine',
'enterFileName': 'Sisestage faili nimi',
'findMore': 'Avasta rohkem',
'finishRecording': 'Lõpeta salvestus',
'importAudioFromAppWeb': 'Impordi heli rakendusest või veebist, et see transkribeerida ja kokku võtta',
'industrySpecificSummaryTemplate': 'Professionaalsed kokkuvõttemallid teie valdkonna jaoks',
'markFavorite': 'Lisa lemmikutesse',
'monthlyTranscriptionDuration': '300 minutit transkribeerimist kuus',
'noDevicesFound': 'Seadmeid ei leitud',
'notBound': 'Sidumata',
'notCharging': 'Ei lae',
'pauseRecording': 'Peata salvestus',
'pleaseEnterVerificationCode': 'Sisestage kinnituskood',
'scanIdle': 'Otsingut ei toimu',
'scanning': 'Otsin...',
'startScan': 'Alusta otsingut',
'stopScan': 'Peata otsing',
'storageBytes': 'Salvestusruum kokku',
'storageUploads': 'Üleslaadimised',
'unmarkFavorite': 'Eemalda lemmikutest',
'unnamedDevice': 'Nimeta seade',
'userDataParseFailed': 'Kasutajaandmete lugemine ebaõnnestus',
}; };

56
apps/client/lib/core/translations/language/fa_ir.dart

@ -1769,4 +1769,60 @@ const Map<String, String> faIR = {
'idVerifyTooFrequent': 'Too many attempts. Please try again later.', 'idVerifyTooFrequent': 'Too many attempts. Please try again later.',
'idVerifyFailed': 'Verification failed. Please try again later.', 'idVerifyFailed': 'Verification failed. Please try again later.',
'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed', 'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed',
// ---- 恒玄(BES)固件 OTA 升级页 ----
'otaAlreadyLatest': 'هم‌اکنون به‌روز است',
'otaCurrentVersion': 'نسخهٔ فعلی',
'otaDone': 'انجام شد',
'otaDownloading': 'در حال دانلود میان‌افزار...',
'otaError': 'خطا',
'otaExitConfirm': 'ارتقا در حال انجام است. مطمئنید می‌خواهید خارج شوید؟',
'otaExitTitle': 'خروج از ارتقا',
'otaFailed': 'ارتقا ناموفق بود',
'otaFailedWithCode': 'ارتقا ناموفق بود (کد @code)',
'otaKeepConnectedWarning': 'در حین ارتقا دستگاه را نزدیک نگه دارید و برنامه را نبندید.',
'otaLatestVersion': 'آخرین نسخه',
'otaNeedConnect': 'لطفاً ابتدا یک دستگاه وصل کنید',
'otaNotConnectedTip': 'دستگاه متصلی یافت نشد. پیش از ارتقا هدفون خود را وصل کنید.',
'otaPreparing': 'در حال آماده‌سازی...',
'otaStartUpgrade': 'شروع ارتقا',
'otaSuccessReboot': 'ارتقا با موفقیت انجام شد. لطفاً دستگاه را دوباره راه‌اندازی کنید.',
'otaTimeout': 'زمان ارتقا به پایان رسید. لطفاً دوباره تلاش کنید.',
'otaUnknownDevice': 'دستگاه ناشناخته',
'otaUpdateAvailable': 'به‌روزرسانی موجود است',
'otaUpgrading': 'در حال ارتقا... @percent%',
'otaUpgradingShort': 'در حال ارتقا...',
'otaDownloadPreparing': 'در حال آماده‌سازی دانلود میان‌افزار...',
'otaFirmwareCached': 'میان‌افزار آماده است، در حال آماده‌سازی ارتقا...',
'otaDownloadStarted': 'دانلود میان‌افزار آغاز شد...',
'otaDownloadingPercent': 'در حال دانلود میان‌افزار... @percent%',
'otaDownloadDone': 'دانلود کامل شد، در حال آماده‌سازی ارتقا...',
'addDevice': 'افزودن دستگاه',
'deviceConnectionRestricted': 'اتصال دستگاه محدود است: این دستگاه مجاز نیست',
// --- 2026-09-03 补齐:这些 key 代码里在用,但 41 个语言文件里都没有 ---
'autopilotTemplateSummary': 'خلاصه‌سازی خودکار با قالب‌های Autopilot',
'bindImmediately': 'اتصال فوری',
'disconnect': 'قطع اتصال',
'discoveredDevices': 'دستگاه‌های یافت‌شده',
'distinguishDifferentSpeakers': 'تشخیص گویندگان مختلف',
'enterFileName': 'نام فایل را وارد کنید',
'findMore': 'بیشتر ببینید',
'finishRecording': 'پایان ضبط',
'importAudioFromAppWeb': 'صدا را از برنامه یا وب وارد کنید تا پیاده‌سازی و خلاصه شود',
'industrySpecificSummaryTemplate': 'قالب‌های خلاصه حرفه‌ای متناسب با صنعت شما',
'markFavorite': 'افزودن به علاقه‌مندی‌ها',
'monthlyTranscriptionDuration': 'ماهانه ۳۰۰ دقیقه پیاده‌سازی متن',
'noDevicesFound': 'دستگاهی یافت نشد',
'notBound': 'متصل نشده',
'notCharging': 'در حال شارژ نیست',
'pauseRecording': 'توقف موقت ضبط',
'pleaseEnterVerificationCode': 'لطفاً کد تأیید را وارد کنید',
'scanIdle': 'در حال جست‌وجو نیست',
'scanning': 'در حال جست‌وجو...',
'startScan': 'شروع جست‌وجو',
'stopScan': 'توقف جست‌وجو',
'storageBytes': 'کل فضای ذخیره‌سازی',
'storageUploads': 'بارگذاری‌ها',
'unmarkFavorite': 'حذف از علاقه‌مندی‌ها',
'unnamedDevice': 'دستگاه بدون نام',
'userDataParseFailed': 'تحلیل داده‌های کاربر ناموفق بود',
}; };

56
apps/client/lib/core/translations/language/fi_fi.dart

@ -1788,4 +1788,60 @@ const Map<String, String> fiFI = {
'idVerifyTooFrequent': 'Too many attempts. Please try again later.', 'idVerifyTooFrequent': 'Too many attempts. Please try again later.',
'idVerifyFailed': 'Verification failed. Please try again later.', 'idVerifyFailed': 'Verification failed. Please try again later.',
'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed', 'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed',
// ---- 恒玄(BES)固件 OTA 升级页 ----
'otaAlreadyLatest': 'Jo ajan tasalla',
'otaCurrentVersion': 'Nykyinen versio',
'otaDone': 'Valmis',
'otaDownloading': 'Ladataan laiteohjelmistoa...',
'otaError': 'Virhe',
'otaExitConfirm': 'Päivitys on käynnissä. Haluatko varmasti poistua?',
'otaExitTitle': 'Poistu päivityksestä',
'otaFailed': 'Päivitys epäonnistui',
'otaFailedWithCode': 'Päivitys epäonnistui (koodi @code)',
'otaKeepConnectedWarning': 'Pidä laite lähellä äläkä sulje sovellusta päivityksen aikana.',
'otaLatestVersion': 'Uusin versio',
'otaNeedConnect': 'Yhdistä ensin laite',
'otaNotConnectedTip': 'Yhdistettyä laitetta ei löytynyt. Yhdistä kuulokkeet ennen päivitystä.',
'otaPreparing': 'Valmistellaan...',
'otaStartUpgrade': 'Aloita päivitys',
'otaSuccessReboot': 'Päivitys onnistui. Käynnistä laite uudelleen.',
'otaTimeout': 'Päivityksen aikakatkaisu. Yritä uudelleen.',
'otaUnknownDevice': 'Tuntematon laite',
'otaUpdateAvailable': 'Päivitys saatavilla',
'otaUpgrading': 'Päivitetään... @percent%',
'otaUpgradingShort': 'Päivitetään...',
'otaDownloadPreparing': 'Valmistellaan laiteohjelmiston latausta...',
'otaFirmwareCached': 'Laiteohjelmisto valmis, valmistellaan päivitystä...',
'otaDownloadStarted': 'Laiteohjelmiston lataus alkoi...',
'otaDownloadingPercent': 'Ladataan laiteohjelmistoa... @percent%',
'otaDownloadDone': 'Lataus valmis, valmistellaan päivitystä...',
'addDevice': 'Lisää laite',
'deviceConnectionRestricted': 'Laitteen yhteys on rajoitettu: laitetta ei ole valtuutettu',
// --- 2026-09-03 补齐:这些 key 代码里在用,但 41 个语言文件里都没有 ---
'autopilotTemplateSummary': 'Automaattiset koosteet Autopilot-malleilla',
'bindImmediately': 'Yhdistä nyt',
'disconnect': 'Katkaise yhteys',
'discoveredDevices': 'Löydetyt laitteet',
'distinguishDifferentSpeakers': 'Puhujien erottelu',
'enterFileName': 'Anna tiedostonimi',
'findMore': 'Tutustu lisää',
'finishRecording': 'Lopeta tallennus',
'importAudioFromAppWeb': 'Tuo ääntä sovelluksesta tai verkosta litteroitavaksi ja tiivistettäväksi',
'industrySpecificSummaryTemplate': 'Ammattimaiset koostemallit toimialallesi',
'markFavorite': 'Lisää suosikkeihin',
'monthlyTranscriptionDuration': '300 minuuttia litterointia kuukaudessa',
'noDevicesFound': 'Laitteita ei löytynyt',
'notBound': 'Ei yhdistetty',
'notCharging': 'Ei lataudu',
'pauseRecording': 'Keskeytä tallennus',
'pleaseEnterVerificationCode': 'Anna vahvistuskoodi',
'scanIdle': 'Ei haeta',
'scanning': 'Haetaan...',
'startScan': 'Aloita haku',
'stopScan': 'Lopeta haku',
'storageBytes': 'Tallennustila yhteensä',
'storageUploads': 'Lataukset',
'unmarkFavorite': 'Poista suosikeista',
'unnamedDevice': 'Nimetön laite',
'userDataParseFailed': 'Käyttäjätietojen luku epäonnistui',
}; };

57
apps/client/lib/core/translations/language/fr_fr.dart

@ -1807,7 +1807,7 @@ const Map<String, String> frFR = {
'currentConnection': 'Connexion actuelle', 'currentConnection': 'Connexion actuelle',
'binding': 'Association…', 'binding': 'Association…',
'tabDevice': 'Appareils', 'tabDevice': 'Appareils',
'tabTodo': 'À faire', 'tabTodo': 'Remin',
'tabPlaza': 'Plaza', 'tabPlaza': 'Plaza',
'sloganHome': 'La technologie au service du quotidien', 'sloganHome': 'La technologie au service du quotidien',
'sloganAi': 'Une intelligence sans limites', 'sloganAi': 'Une intelligence sans limites',
@ -2084,4 +2084,59 @@ const Map<String, String> frFR = {
'idVerifyTooFrequent': 'Too many attempts. Please try again later.', 'idVerifyTooFrequent': 'Too many attempts. Please try again later.',
'idVerifyFailed': 'Verification failed. Please try again later.', 'idVerifyFailed': 'Verification failed. Please try again later.',
'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed', 'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed',
// ---- 恒玄(BES)固件 OTA 升级页 ----
'otaAlreadyLatest': 'Déjà à jour',
'otaCurrentVersion': 'Version actuelle',
'otaDone': 'Terminé',
'otaDownloading': 'Téléchargement du firmware...',
'otaError': 'Erreur',
'otaExitConfirm': 'Une mise à jour est en cours. Voulez-vous vraiment quitter ?',
'otaExitTitle': 'Quitter la mise à jour',
'otaFailed': 'Échec de la mise à jour',
'otaFailedWithCode': 'Échec de la mise à jour (code @code)',
'otaKeepConnectedWarning': 'Gardez l’appareil à proximité et ne fermez pas l’application pendant la mise à jour.',
'otaLatestVersion': 'Dernière version',
'otaNeedConnect': 'Veuillez d’abord connecter un appareil',
'otaNotConnectedTip': 'Aucun appareil connecté détecté. Connectez vos écouteurs avant la mise à jour.',
'otaPreparing': 'Préparation...',
'otaStartUpgrade': 'Démarrer la mise à jour',
'otaSuccessReboot': 'Mise à jour réussie. Veuillez redémarrer l’appareil.',
'otaTimeout': 'Délai de mise à jour dépassé. Veuillez réessayer.',
'otaUnknownDevice': 'Appareil inconnu',
'otaUpdateAvailable': 'Mise à jour disponible',
'otaUpgrading': 'Mise à jour... @percent%',
'otaUpgradingShort': 'Mise à jour en cours...',
'otaDownloadPreparing': 'Préparation du téléchargement du firmware...',
'otaFirmwareCached': 'Firmware prêt, préparation de la mise à jour...',
'otaDownloadStarted': 'Téléchargement du firmware démarré...',
'otaDownloadingPercent': 'Téléchargement du firmware... @percent%',
'otaDownloadDone': 'Téléchargement terminé, préparation de la mise à jour...',
"deviceConnectionRestricted": "Connexion de l'appareil restreinte : cet appareil n'est pas autorisé",
// --- 2026-09-03 补齐:这些 key 代码里在用,但 41 个语言文件里都没有 ---
'autopilotTemplateSummary': 'Résumés automatiques avec les modèles Autopilot',
'bindImmediately': 'Associer maintenant',
'disconnect': 'Déconnecter',
'discoveredDevices': 'Appareils détectés',
'distinguishDifferentSpeakers': 'Distinguer les intervenants',
'enterFileName': 'Saisissez un nom de fichier',
'findMore': 'En découvrir plus',
'finishRecording': "Terminer l'enregistrement",
'importAudioFromAppWeb': "Importez de l'audio depuis l'app ou le web pour le transcrire et le résumer",
'industrySpecificSummaryTemplate': 'Modèles de résumé professionnels conçus pour votre secteur',
'markFavorite': 'Ajouter aux favoris',
'monthlyTranscriptionDuration': '300 minutes de transcription par mois',
'noDevicesFound': 'Aucun appareil trouvé',
'notBound': 'Non associé',
'notCharging': 'Pas en charge',
'pauseRecording': "Suspendre l'enregistrement",
'pleaseEnterVerificationCode': 'Veuillez saisir le code de vérification',
'scanIdle': 'Aucune recherche',
'scanning': 'Recherche en cours...',
'startScan': 'Lancer la recherche',
'stopScan': 'Arrêter la recherche',
'storageBytes': 'Stockage total',
'storageUploads': 'Envois',
'unmarkFavorite': 'Retirer des favoris',
'unnamedDevice': 'Appareil sans nom',
'userDataParseFailed': "Échec de l'analyse des données utilisateur",
}; };

56
apps/client/lib/core/translations/language/hi_in.dart

@ -1787,4 +1787,60 @@ const Map<String, String> hiIN = {
'idVerifyTooFrequent': 'Too many attempts. Please try again later.', 'idVerifyTooFrequent': 'Too many attempts. Please try again later.',
'idVerifyFailed': 'Verification failed. Please try again later.', 'idVerifyFailed': 'Verification failed. Please try again later.',
'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed', 'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed',
// ---- 恒玄(BES)固件 OTA 升级页 ----
'otaAlreadyLatest': 'पहले से नवीनतम है',
'otaCurrentVersion': 'वर्तमान संस्करण',
'otaDone': 'पूर्ण',
'otaDownloading': 'फ़र्मवेयर डाउनलोड हो रहा है...',
'otaError': 'त्रुटि',
'otaExitConfirm': 'अपग्रेड चल रहा है। क्या आप वाकई बाहर निकलना चाहते हैं?',
'otaExitTitle': 'अपग्रेड से बाहर निकलें',
'otaFailed': 'अपग्रेड विफल',
'otaFailedWithCode': 'अपग्रेड विफल (कोड @code)',
'otaKeepConnectedWarning': 'अपग्रेड के दौरान डिवाइस को पास रखें और ऐप बंद न करें।',
'otaLatestVersion': 'नवीनतम संस्करण',
'otaNeedConnect': 'कृपया पहले कोई डिवाइस कनेक्ट करें',
'otaNotConnectedTip': 'कोई कनेक्टेड डिवाइस नहीं मिला। अपग्रेड से पहले अपने ईयरफ़ोन कनेक्ट करें।',
'otaPreparing': 'तैयार किया जा रहा है...',
'otaStartUpgrade': 'अपग्रेड शुरू करें',
'otaSuccessReboot': 'अपग्रेड सफल। कृपया डिवाइस को पुनः चालू करें।',
'otaTimeout': 'अपग्रेड का समय समाप्त। कृपया पुनः प्रयास करें।',
'otaUnknownDevice': 'अज्ञात डिवाइस',
'otaUpdateAvailable': 'अपडेट उपलब्ध है',
'otaUpgrading': 'अपग्रेड हो रहा है... @percent%',
'otaUpgradingShort': 'अपग्रेड हो रहा है...',
'otaDownloadPreparing': 'फ़र्मवेयर डाउनलोड की तैयारी...',
'otaFirmwareCached': 'फ़र्मवेयर तैयार है, अपग्रेड की तैयारी...',
'otaDownloadStarted': 'फ़र्मवेयर डाउनलोड शुरू...',
'otaDownloadingPercent': 'फ़र्मवेयर डाउनलोड हो रहा है... @percent%',
'otaDownloadDone': 'डाउनलोड पूर्ण, अपग्रेड की तैयारी...',
'addDevice': 'डिवाइस जोड़ें',
'deviceConnectionRestricted': 'डिवाइस कनेक्शन प्रतिबंधित: यह डिवाइस अधिकृत नहीं है',
// --- 2026-09-03 补齐:这些 key 代码里在用,但 41 个语言文件里都没有 ---
'autopilotTemplateSummary': 'Autopilot टेम्पलेट से स्वतः सारांश',
'bindImmediately': 'अभी जोड़ें',
'disconnect': 'डिस्कनेक्ट करें',
'discoveredDevices': 'मिले हुए डिवाइस',
'distinguishDifferentSpeakers': 'अलग-अलग वक्ताओं की पहचान',
'enterFileName': 'फ़ाइल का नाम दर्ज करें',
'findMore': 'और जानें',
'finishRecording': 'रिकॉर्डिंग समाप्त करें',
'importAudioFromAppWeb': 'ऐप या वेब से ऑडियो आयात कर उसका ट्रांसक्रिप्शन और सारांश बनाएँ',
'industrySpecificSummaryTemplate': 'आपके उद्योग के लिए बने पेशेवर सारांश टेम्पलेट',
'markFavorite': 'पसंदीदा में जोड़ें',
'monthlyTranscriptionDuration': 'हर महीने 300 मिनट ट्रांसक्रिप्शन',
'noDevicesFound': 'कोई डिवाइस नहीं मिला',
'notBound': 'जुड़ा नहीं है',
'notCharging': 'चार्ज नहीं हो रहा',
'pauseRecording': 'रिकॉर्डिंग रोकें',
'pleaseEnterVerificationCode': 'कृपया सत्यापन कोड दर्ज करें',
'scanIdle': 'स्कैन नहीं हो रहा',
'scanning': 'स्कैन हो रहा है...',
'startScan': 'स्कैन शुरू करें',
'stopScan': 'स्कैन रोकें',
'storageBytes': 'कुल संग्रहण',
'storageUploads': 'अपलोड',
'unmarkFavorite': 'पसंदीदा से हटाएँ',
'unnamedDevice': 'बिना नाम का डिवाइस',
'userDataParseFailed': 'उपयोगकर्ता डेटा पढ़ने में विफल',
}; };

56
apps/client/lib/core/translations/language/hr_hr.dart

@ -1803,4 +1803,60 @@ const Map<String, String> hrHR = {
'idVerifyTooFrequent': 'Too many attempts. Please try again later.', 'idVerifyTooFrequent': 'Too many attempts. Please try again later.',
'idVerifyFailed': 'Verification failed. Please try again later.', 'idVerifyFailed': 'Verification failed. Please try again later.',
'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed', 'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed',
// ---- 恒玄(BES)固件 OTA 升级页 ----
'otaAlreadyLatest': 'Već je ažurno',
'otaCurrentVersion': 'Trenutačna verzija',
'otaDone': 'Gotovo',
'otaDownloading': 'Preuzimanje firmvera...',
'otaError': 'Pogreška',
'otaExitConfirm': 'Nadogradnja je u tijeku. Želite li stvarno izaći?',
'otaExitTitle': 'Izlaz iz nadogradnje',
'otaFailed': 'Nadogradnja nije uspjela',
'otaFailedWithCode': 'Nadogradnja nije uspjela (kod @code)',
'otaKeepConnectedWarning': 'Tijekom nadogradnje držite uređaj blizu i ne zatvarajte aplikaciju.',
'otaLatestVersion': 'Najnovija verzija',
'otaNeedConnect': 'Najprije povežite uređaj',
'otaNotConnectedTip': 'Nije pronađen povezani uređaj. Povežite slušalice prije nadogradnje.',
'otaPreparing': 'Priprema...',
'otaStartUpgrade': 'Pokreni nadogradnju',
'otaSuccessReboot': 'Nadogradnja je uspjela. Ponovno pokrenite uređaj.',
'otaTimeout': 'Isteklo je vrijeme nadogradnje. Pokušajte ponovno.',
'otaUnknownDevice': 'Nepoznat uređaj',
'otaUpdateAvailable': 'Dostupna je nadogradnja',
'otaUpgrading': 'Nadogradnja... @percent%',
'otaUpgradingShort': 'Nadogradnja...',
'otaDownloadPreparing': 'Priprema preuzimanja firmvera...',
'otaFirmwareCached': 'Firmver je spreman, priprema nadogradnje...',
'otaDownloadStarted': 'Preuzimanje firmvera je započelo...',
'otaDownloadingPercent': 'Preuzimanje firmvera... @percent%',
'otaDownloadDone': 'Preuzimanje je dovršeno, priprema nadogradnje...',
'addDevice': 'Dodaj uređaj',
'deviceConnectionRestricted': 'Povezivanje uređaja ograničeno: uređaj nije autoriziran',
// --- 2026-09-03 补齐:这些 key 代码里在用,但 41 个语言文件里都没有 ---
'autopilotTemplateSummary': 'Automatski sažeci pomoću Autopilot predložaka',
'bindImmediately': 'Poveži odmah',
'disconnect': 'Prekini vezu',
'discoveredDevices': 'Pronađeni uređaji',
'distinguishDifferentSpeakers': 'Razlikovanje govornika',
'enterFileName': 'Unesite naziv datoteke',
'findMore': 'Otkrijte više',
'finishRecording': 'Završi snimanje',
'importAudioFromAppWeb': 'Uvezite zvuk iz aplikacije ili s weba radi transkripcije i sažetka',
'industrySpecificSummaryTemplate': 'Profesionalni predlošci sažetaka za vašu djelatnost',
'markFavorite': 'Dodaj u favorite',
'monthlyTranscriptionDuration': '300 minuta transkripcije mjesečno',
'noDevicesFound': 'Nije pronađen nijedan uređaj',
'notBound': 'Nije povezano',
'notCharging': 'Ne puni se',
'pauseRecording': 'Pauziraj snimanje',
'pleaseEnterVerificationCode': 'Unesite verifikacijski kod',
'scanIdle': 'Ne pretražuje se',
'scanning': 'Pretraživanje...',
'startScan': 'Pokreni pretragu',
'stopScan': 'Zaustavi pretragu',
'storageBytes': 'Ukupna pohrana',
'storageUploads': 'Prijenosi',
'unmarkFavorite': 'Ukloni iz favorita',
'unnamedDevice': 'Uređaj bez naziva',
'userDataParseFailed': 'Podatke korisnika nije moguće učitati',
}; };

56
apps/client/lib/core/translations/language/hu_hu.dart

@ -1806,4 +1806,60 @@ const Map<String, String> huHU = {
'idVerifyTooFrequent': 'Too many attempts. Please try again later.', 'idVerifyTooFrequent': 'Too many attempts. Please try again later.',
'idVerifyFailed': 'Verification failed. Please try again later.', 'idVerifyFailed': 'Verification failed. Please try again later.',
'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed', 'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed',
// ---- 恒玄(BES)固件 OTA 升级页 ----
'otaAlreadyLatest': 'Már naprakész',
'otaCurrentVersion': 'Jelenlegi verzió',
'otaDone': 'Kész',
'otaDownloading': 'Firmware letöltése...',
'otaError': 'Hiba',
'otaExitConfirm': 'Frissítés van folyamatban. Biztosan kilép?',
'otaExitTitle': 'Kilépés a frissítésből',
'otaFailed': 'A frissítés sikertelen',
'otaFailedWithCode': 'A frissítés sikertelen (kód: @code)',
'otaKeepConnectedWarning': 'A frissítés alatt tartsa közel az eszközt, és ne zárja be az alkalmazást.',
'otaLatestVersion': 'Legújabb verzió',
'otaNeedConnect': 'Először csatlakoztasson egy eszközt',
'otaNotConnectedTip': 'Nem található csatlakoztatott eszköz. Frissítés előtt csatlakoztassa a fülhallgatót.',
'otaPreparing': 'Előkészítés...',
'otaStartUpgrade': 'Frissítés indítása',
'otaSuccessReboot': 'A frissítés sikerült. Indítsa újra az eszközt.',
'otaTimeout': 'A frissítés időtúllépés miatt megszakadt. Próbálja újra.',
'otaUnknownDevice': 'Ismeretlen eszköz',
'otaUpdateAvailable': 'Elérhető frissítés',
'otaUpgrading': 'Frissítés... @percent%',
'otaUpgradingShort': 'Frissítés...',
'otaDownloadPreparing': 'Firmware letöltésének előkészítése...',
'otaFirmwareCached': 'A firmware kész, frissítés előkészítése...',
'otaDownloadStarted': 'Firmware letöltése elindult...',
'otaDownloadingPercent': 'Firmware letöltése... @percent%',
'otaDownloadDone': 'Letöltés kész, frissítés előkészítése...',
'addDevice': 'Eszköz hozzáadása',
'deviceConnectionRestricted': 'Az eszköz csatlakozása korlátozott: az eszköz nincs engedélyezve',
// --- 2026-09-03 补齐:这些 key 代码里在用,但 41 个语言文件里都没有 ---
'autopilotTemplateSummary': 'Automatikus összefoglalók Autopilot sablonokkal',
'bindImmediately': 'Párosítás most',
'disconnect': 'Bontás',
'discoveredDevices': 'Megtalált eszközök',
'distinguishDifferentSpeakers': 'Beszélők megkülönböztetése',
'enterFileName': 'Adjon meg egy fájlnevet',
'findMore': 'Fedezzen fel többet',
'finishRecording': 'Felvétel befejezése',
'importAudioFromAppWeb': 'Importáljon hangot az appból vagy a webről leiratozáshoz és összefoglaláshoz',
'industrySpecificSummaryTemplate': 'Az Ön ágazatára szabott profi összefoglaló sablonok',
'markFavorite': 'Hozzáadás a kedvencekhez',
'monthlyTranscriptionDuration': 'Havi 300 perc leiratozás',
'noDevicesFound': 'Nem található eszköz',
'notBound': 'Nincs párosítva',
'notCharging': 'Nem tölt',
'pauseRecording': 'Felvétel szüneteltetése',
'pleaseEnterVerificationCode': 'Adja meg az ellenőrző kódot',
'scanIdle': 'Nincs keresés',
'scanning': 'Keresés...',
'startScan': 'Keresés indítása',
'stopScan': 'Keresés leállítása',
'storageBytes': 'Teljes tárhely',
'storageUploads': 'Feltöltések',
'unmarkFavorite': 'Eltávolítás a kedvencekből',
'unnamedDevice': 'Névtelen eszköz',
'userDataParseFailed': 'A felhasználói adatok feldolgozása sikertelen',
}; };

56
apps/client/lib/core/translations/language/id_id.dart

@ -1787,4 +1787,60 @@ const Map<String, String> idID = {
'idVerifyTooFrequent': 'Too many attempts. Please try again later.', 'idVerifyTooFrequent': 'Too many attempts. Please try again later.',
'idVerifyFailed': 'Verification failed. Please try again later.', 'idVerifyFailed': 'Verification failed. Please try again later.',
'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed', 'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed',
// ---- 恒玄(BES)固件 OTA 升级页 ----
'otaAlreadyLatest': 'Sudah versi terbaru',
'otaCurrentVersion': 'Versi saat ini',
'otaDone': 'Selesai',
'otaDownloading': 'Mengunduh firmware...',
'otaError': 'Kesalahan',
'otaExitConfirm': 'Peningkatan sedang berlangsung. Yakin ingin keluar?',
'otaExitTitle': 'Keluar dari peningkatan',
'otaFailed': 'Peningkatan gagal',
'otaFailedWithCode': 'Peningkatan gagal (kode @code)',
'otaKeepConnectedWarning': 'Selama peningkatan, dekatkan perangkat dan jangan tutup aplikasi.',
'otaLatestVersion': 'Versi terbaru',
'otaNeedConnect': 'Hubungkan perangkat terlebih dahulu',
'otaNotConnectedTip': 'Tidak ada perangkat terhubung. Hubungkan earphone sebelum melakukan peningkatan.',
'otaPreparing': 'Menyiapkan...',
'otaStartUpgrade': 'Mulai peningkatan',
'otaSuccessReboot': 'Peningkatan berhasil. Silakan mulai ulang perangkat.',
'otaTimeout': 'Waktu peningkatan habis. Silakan coba lagi.',
'otaUnknownDevice': 'Perangkat tidak dikenal',
'otaUpdateAvailable': 'Pembaruan tersedia',
'otaUpgrading': 'Meningkatkan... @percent%',
'otaUpgradingShort': 'Meningkatkan...',
'otaDownloadPreparing': 'Menyiapkan unduhan firmware...',
'otaFirmwareCached': 'Firmware siap, menyiapkan peningkatan...',
'otaDownloadStarted': 'Unduhan firmware dimulai...',
'otaDownloadingPercent': 'Mengunduh firmware... @percent%',
'otaDownloadDone': 'Unduhan selesai, menyiapkan peningkatan...',
'addDevice': 'Tambah perangkat',
'deviceConnectionRestricted': 'Koneksi perangkat dibatasi: perangkat ini tidak diotorisasi',
// --- 2026-09-03 补齐:这些 key 代码里在用,但 41 个语言文件里都没有 ---
'autopilotTemplateSummary': 'Ringkasan otomatis dengan templat Autopilot',
'bindImmediately': 'Tautkan Sekarang',
'disconnect': 'Putuskan sambungan',
'discoveredDevices': 'Perangkat ditemukan',
'distinguishDifferentSpeakers': 'Bedakan tiap pembicara',
'enterFileName': 'Masukkan nama berkas',
'findMore': 'Jelajahi Lainnya',
'finishRecording': 'Selesaikan Rekaman',
'importAudioFromAppWeb': 'Impor audio dari aplikasi atau web untuk ditranskripsikan dan diringkas',
'industrySpecificSummaryTemplate': 'Templat ringkasan profesional untuk industri Anda',
'markFavorite': 'Tambahkan ke favorit',
'monthlyTranscriptionDuration': '300 menit transkripsi per bulan',
'noDevicesFound': 'Perangkat tidak ditemukan',
'notBound': 'Belum ditautkan',
'notCharging': 'Tidak mengisi daya',
'pauseRecording': 'Jeda Rekaman',
'pleaseEnterVerificationCode': 'Masukkan kode verifikasi',
'scanIdle': 'Tidak memindai',
'scanning': 'Memindai...',
'startScan': 'Mulai Pindai',
'stopScan': 'Hentikan Pindai',
'storageBytes': 'Total Penyimpanan',
'storageUploads': 'Unggahan',
'unmarkFavorite': 'Hapus dari favorit',
'unnamedDevice': 'Perangkat tanpa nama',
'userDataParseFailed': 'Gagal membaca data pengguna',
}; };

56
apps/client/lib/core/translations/language/is_is.dart

@ -1784,4 +1784,60 @@ const Map<String, String> isIS = {
'idVerifyTooFrequent': 'Too many attempts. Please try again later.', 'idVerifyTooFrequent': 'Too many attempts. Please try again later.',
'idVerifyFailed': 'Verification failed. Please try again later.', 'idVerifyFailed': 'Verification failed. Please try again later.',
'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed', 'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed',
// ---- 恒玄(BES)固件 OTA 升级页 ----
'otaAlreadyLatest': 'Þegar uppfært',
'otaCurrentVersion': 'Núverandi útgáfa',
'otaDone': 'Lokið',
'otaDownloading': 'Sæki fastbúnað...',
'otaError': 'Villa',
'otaExitConfirm': 'Uppfærsla er í gangi. Ertu viss um að þú viljir hætta?',
'otaExitTitle': 'Hætta við uppfærslu',
'otaFailed': 'Uppfærsla mistókst',
'otaFailedWithCode': 'Uppfærsla mistókst (kóði @code)',
'otaKeepConnectedWarning': 'Hafðu tækið nálægt og lokaðu ekki forritinu meðan á uppfærslu stendur.',
'otaLatestVersion': 'Nýjasta útgáfa',
'otaNeedConnect': 'Tengdu fyrst tæki',
'otaNotConnectedTip': 'Ekkert tengt tæki fannst. Tengdu heyrnartólin áður en þú uppfærir.',
'otaPreparing': 'Undirbý...',
'otaStartUpgrade': 'Hefja uppfærslu',
'otaSuccessReboot': 'Uppfærsla tókst. Endurræstu tækið.',
'otaTimeout': 'Uppfærslan rann út á tíma. Reyndu aftur.',
'otaUnknownDevice': 'Óþekkt tæki',
'otaUpdateAvailable': 'Uppfærsla í boði',
'otaUpgrading': 'Uppfæri... @percent%',
'otaUpgradingShort': 'Uppfæri...',
'otaDownloadPreparing': 'Undirbý niðurhal fastbúnaðar...',
'otaFirmwareCached': 'Fastbúnaður tilbúinn, undirbý uppfærslu...',
'otaDownloadStarted': 'Niðurhal fastbúnaðar hafið...',
'otaDownloadingPercent': 'Sæki fastbúnað... @percent%',
'otaDownloadDone': 'Niðurhali lokið, undirbý uppfærslu...',
'addDevice': 'Bæta við tæki',
'deviceConnectionRestricted': 'Tenging tækis takmörkuð: þetta tæki er ekki heimilað',
// --- 2026-09-03 补齐:这些 key 代码里在用,但 41 个语言文件里都没有 ---
'autopilotTemplateSummary': 'Sjálfvirkar samantektir með Autopilot-sniðmátum',
'bindImmediately': 'Tengja núna',
'disconnect': 'Aftengja',
'discoveredDevices': 'Fundin tæki',
'distinguishDifferentSpeakers': 'Greina á milli ræðumanna',
'enterFileName': 'Sláðu inn skráarnafn',
'findMore': 'Uppgötvaðu meira',
'finishRecording': 'Ljúka upptöku',
'importAudioFromAppWeb': 'Fluttu inn hljóð úr appinu eða af vefnum til umritunar og samantektar',
'industrySpecificSummaryTemplate': 'Fagleg samantektarsniðmát fyrir þína atvinnugrein',
'markFavorite': 'Bæta við eftirlæti',
'monthlyTranscriptionDuration': '300 mínútur af umritun á mánuði',
'noDevicesFound': 'Engin tæki fundust',
'notBound': 'Ekki tengt',
'notCharging': 'Hleður ekki',
'pauseRecording': 'Gera hlé á upptöku',
'pleaseEnterVerificationCode': 'Sláðu inn staðfestingarkóðann',
'scanIdle': 'Ekki að leita',
'scanning': 'Leita...',
'startScan': 'Hefja leit',
'stopScan': 'Stöðva leit',
'storageBytes': 'Heildargeymslurými',
'storageUploads': 'Upphleðslur',
'unmarkFavorite': 'Fjarlægja úr eftirlæti',
'unnamedDevice': 'Ónefnt tæki',
'userDataParseFailed': 'Ekki tókst að lesa notandagögn',
}; };

56
apps/client/lib/core/translations/language/it_it.dart

@ -1825,4 +1825,60 @@ const Map<String, String> itIT = {
'idVerifyTooFrequent': 'Too many attempts. Please try again later.', 'idVerifyTooFrequent': 'Too many attempts. Please try again later.',
'idVerifyFailed': 'Verification failed. Please try again later.', 'idVerifyFailed': 'Verification failed. Please try again later.',
'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed', 'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed',
// ---- 恒玄(BES)固件 OTA 升级页 ----
'otaAlreadyLatest': 'Già aggiornato',
'otaCurrentVersion': 'Versione attuale',
'otaDone': 'Fatto',
'otaDownloading': 'Download del firmware...',
'otaError': 'Errore',
'otaExitConfirm': 'È in corso un aggiornamento. Vuoi davvero uscire?',
'otaExitTitle': 'Esci dall’aggiornamento',
'otaFailed': 'Aggiornamento non riuscito',
'otaFailedWithCode': 'Aggiornamento non riuscito (codice @code)',
'otaKeepConnectedWarning': 'Tieni il dispositivo vicino e non chiudere l’app durante l’aggiornamento.',
'otaLatestVersion': 'Ultima versione',
'otaNeedConnect': 'Collega prima un dispositivo',
'otaNotConnectedTip': 'Nessun dispositivo collegato rilevato. Collega gli auricolari prima di aggiornare.',
'otaPreparing': 'Preparazione...',
'otaStartUpgrade': 'Avvia aggiornamento',
'otaSuccessReboot': 'Aggiornamento riuscito. Riavvia il dispositivo.',
'otaTimeout': 'Timeout dell’aggiornamento. Riprova.',
'otaUnknownDevice': 'Dispositivo sconosciuto',
'otaUpdateAvailable': 'Aggiornamento disponibile',
'otaUpgrading': 'Aggiornamento... @percent%',
'otaUpgradingShort': 'Aggiornamento in corso...',
'otaDownloadPreparing': 'Preparazione del download del firmware...',
'otaFirmwareCached': 'Firmware pronto, preparazione dell’aggiornamento...',
'otaDownloadStarted': 'Download del firmware avviato...',
'otaDownloadingPercent': 'Download del firmware... @percent%',
'otaDownloadDone': 'Download completato, preparazione dell’aggiornamento...',
'addDevice': 'Aggiungi dispositivo',
'deviceConnectionRestricted': 'Connessione del dispositivo limitata: questo dispositivo non è autorizzato',
// --- 2026-09-03 补齐:这些 key 代码里在用,但 41 个语言文件里都没有 ---
'autopilotTemplateSummary': 'Riepiloghi automatici con i modelli Autopilot',
'bindImmediately': 'Associa ora',
'disconnect': 'Disconnetti',
'discoveredDevices': 'Dispositivi rilevati',
'distinguishDifferentSpeakers': 'Distingui i diversi interlocutori',
'enterFileName': 'Inserisci un nome file',
'findMore': 'Scopri di più',
'finishRecording': 'Termina registrazione',
'importAudioFromAppWeb': "Importa l'audio dall'app o dal web per trascriverlo e riassumerlo",
'industrySpecificSummaryTemplate': 'Modelli di riepilogo professionali per il tuo settore',
'markFavorite': 'Aggiungi ai preferiti',
'monthlyTranscriptionDuration': '300 minuti di trascrizione al mese',
'noDevicesFound': 'Nessun dispositivo trovato',
'notBound': 'Non associato',
'notCharging': 'Non in carica',
'pauseRecording': 'Metti in pausa la registrazione',
'pleaseEnterVerificationCode': 'Inserisci il codice di verifica',
'scanIdle': 'Nessuna ricerca',
'scanning': 'Ricerca in corso...',
'startScan': 'Avvia ricerca',
'stopScan': 'Interrompi ricerca',
'storageBytes': 'Spazio totale',
'storageUploads': 'Caricamenti',
'unmarkFavorite': 'Rimuovi dai preferiti',
'unnamedDevice': 'Dispositivo senza nome',
'userDataParseFailed': "Impossibile leggere i dati dell'utente",
}; };

57
apps/client/lib/core/translations/language/ja_jp.dart

@ -1593,7 +1593,7 @@ const Map<String, String> jaJP = {
'meetingRecordScreenCapturePermissionDenied': 'meetingRecordScreenCapturePermissionDenied':
'システム音声を録音するには「画面収録」の許可が必要です。許可してから再試行してください', 'システム音声を録音するには「画面収録」の許可が必要です。許可してから再試行してください',
'tabDevice': 'デバイス', 'tabDevice': 'デバイス',
'tabTodo': 'ToDo', 'tabTodo': 'Remin',
'tabPlaza': 'プラザ', 'tabPlaza': 'プラザ',
'sloganHome': 'テクノロジーでスマートな毎日を', 'sloganHome': 'テクノロジーでスマートな毎日を',
'sloganAi': '無限の知性、無限の可能性', 'sloganAi': '無限の知性、無限の可能性',
@ -1866,4 +1866,59 @@ const Map<String, String> jaJP = {
'idVerifyTooFrequent': 'Too many attempts. Please try again later.', 'idVerifyTooFrequent': 'Too many attempts. Please try again later.',
'idVerifyFailed': 'Verification failed. Please try again later.', 'idVerifyFailed': 'Verification failed. Please try again later.',
'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed', 'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed',
// ---- 恒玄(BES)固件 OTA 升级页 ----
'otaAlreadyLatest': '最新の状態です',
'otaCurrentVersion': '現在のバージョン',
'otaDone': '完了',
'otaDownloading': 'ファームウェアをダウンロード中...',
'otaError': 'エラー',
'otaExitConfirm': 'アップグレード中です。終了してもよろしいですか?',
'otaExitTitle': 'アップグレードを終了',
'otaFailed': 'アップグレードに失敗しました',
'otaFailedWithCode': 'アップグレードに失敗しました(コード @code)',
'otaKeepConnectedWarning': 'アップグレード中はデバイスを近くに置き、アプリを終了しないでください',
'otaLatestVersion': '最新バージョン',
'otaNeedConnect': '先にデバイスを接続してください',
'otaNotConnectedTip': '接続されたデバイスが見つかりません。イヤホンを接続してから更新してください',
'otaPreparing': '準備中...',
'otaStartUpgrade': 'アップグレードを開始',
'otaSuccessReboot': 'アップグレードに成功しました。デバイスを再起動してください',
'otaTimeout': 'アップグレードがタイムアウトしました。再試行してください',
'otaUnknownDevice': '不明なデバイス',
'otaUpdateAvailable': '更新があります',
'otaUpgrading': 'アップグレード中... @percent%',
'otaUpgradingShort': 'アップグレード中...',
'otaDownloadPreparing': 'ファームウェアのダウンロードを準備中...',
'otaFirmwareCached': 'ファームウェアの準備が完了しました',
'otaDownloadStarted': 'ファームウェアのダウンロードを開始...',
'otaDownloadingPercent': 'ファームウェアをダウンロード中... @percent%',
'otaDownloadDone': 'ダウンロード完了、アップグレードを準備中...',
'deviceConnectionRestricted': 'デバイスの接続が制限されています。この製品は認証されていません',
// --- 2026-09-03 补齐:这些 key 代码里在用,但 41 个语言文件里都没有 ---
'autopilotTemplateSummary': 'Autopilot テンプレートによる自動要約',
'bindImmediately': '今すぐ連携',
'disconnect': '接続を解除',
'discoveredDevices': '検出されたデバイス',
'distinguishDifferentSpeakers': '話者を区別',
'enterFileName': 'ファイル名を入力してください',
'findMore': 'もっと見る',
'finishRecording': '録音を終了',
'importAudioFromAppWeb': 'アプリや Web から音声を取り込んで文字起こしと要約',
'industrySpecificSummaryTemplate': '業種別に設計されたプロ向け要約テンプレート',
'markFavorite': 'お気に入りに追加',
'monthlyTranscriptionDuration': '毎月 300 分の文字起こし',
'noDevicesFound': 'デバイスが見つかりません',
'notBound': '未連携',
'notCharging': '充電していません',
'pauseRecording': '録音を一時停止',
'pleaseEnterVerificationCode': '認証コードを入力してください',
'scanIdle': 'スキャンしていません',
'scanning': 'スキャン中...',
'startScan': 'スキャン開始',
'stopScan': 'スキャン停止',
'storageBytes': 'ストレージ使用量',
'storageUploads': 'アップロード回数',
'unmarkFavorite': 'お気に入りから削除',
'unnamedDevice': '名称未設定のデバイス',
'userDataParseFailed': 'ユーザーデータの解析に失敗しました',
}; };

57
apps/client/lib/core/translations/language/ko_kr.dart

@ -1598,7 +1598,7 @@ const Map<String, String> koKR = {
'currentConnection': '현재 연결', 'currentConnection': '현재 연결',
'binding': '등록 중…', 'binding': '등록 중…',
'tabDevice': '기기', 'tabDevice': '기기',
'tabTodo': '할 일', 'tabTodo': 'Remin',
'tabPlaza': '플라자', 'tabPlaza': '플라자',
'sloganHome': '기술로 더 스마트한 일상을', 'sloganHome': '기술로 더 스마트한 일상을',
'sloganAi': '무한한 지능, 무한한 가능성', 'sloganAi': '무한한 지능, 무한한 가능성',
@ -1859,4 +1859,59 @@ const Map<String, String> koKR = {
'idVerifyTooFrequent': 'Too many attempts. Please try again later.', 'idVerifyTooFrequent': 'Too many attempts. Please try again later.',
'idVerifyFailed': 'Verification failed. Please try again later.', 'idVerifyFailed': 'Verification failed. Please try again later.',
'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed', 'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed',
// ---- 恒玄(BES)固件 OTA 升级页 ----
'otaAlreadyLatest': '이미 최신 버전입니다',
'otaCurrentVersion': '현재 버전',
'otaDone': '완료',
'otaDownloading': '펌웨어 다운로드 중...',
'otaError': '오류',
'otaExitConfirm': '업그레이드가 진행 중입니다. 종료하시겠습니까?',
'otaExitTitle': '업그레이드 종료',
'otaFailed': '업그레이드 실패',
'otaFailedWithCode': '업그레이드 실패 (코드 @code)',
'otaKeepConnectedWarning': '업그레이드 중에는 기기를 가까이 두고 앱을 종료하지 마세요',
'otaLatestVersion': '최신 버전',
'otaNeedConnect': '먼저 기기를 연결하세요',
'otaNotConnectedTip': '연결된 기기가 없습니다. 이어폰을 연결한 후 업그레이드하세요',
'otaPreparing': '준비 중...',
'otaStartUpgrade': '업그레이드 시작',
'otaSuccessReboot': '업그레이드 성공. 기기를 다시 시작하세요',
'otaTimeout': '업그레이드 시간이 초과되었습니다. 다시 시도하세요',
'otaUnknownDevice': '알 수 없는 기기',
'otaUpdateAvailable': '업데이트 가능',
'otaUpgrading': '업그레이드 중... @percent%',
'otaUpgradingShort': '업그레이드 중...',
'otaDownloadPreparing': '펌웨어 다운로드 준비 중...',
'otaFirmwareCached': '펌웨어 준비 완료',
'otaDownloadStarted': '펌웨어 다운로드 시작...',
'otaDownloadingPercent': '펌웨어 다운로드 중... @percent%',
'otaDownloadDone': '다운로드 완료, 업그레이드 준비 중...',
'deviceConnectionRestricted': '기기 연결이 제한되었습니다. 인증되지 않은 기기입니다',
// --- 2026-09-03 补齐:这些 key 代码里在用,但 41 个语言文件里都没有 ---
'autopilotTemplateSummary': 'Autopilot 템플릿 자동 요약',
'bindImmediately': '지금 연결',
'disconnect': '연결 해제',
'discoveredDevices': '검색된 기기',
'distinguishDifferentSpeakers': '화자 구분',
'enterFileName': '파일 이름을 입력하세요',
'findMore': '더 알아보기',
'finishRecording': '녹음 종료',
'importAudioFromAppWeb': '앱과 웹에서 오디오를 가져와 전사 및 요약',
'industrySpecificSummaryTemplate': '업종별로 설계된 전문 요약 템플릿',
'markFavorite': '즐겨찾기 추가',
'monthlyTranscriptionDuration': '매월 300분 전사 시간',
'noDevicesFound': '기기를 찾을 수 없습니다',
'notBound': '미연결',
'notCharging': '충전 중 아님',
'pauseRecording': '녹음 일시정지',
'pleaseEnterVerificationCode': '인증번호를 입력하세요',
'scanIdle': '검색 대기 중',
'scanning': '검색 중...',
'startScan': '검색 시작',
'stopScan': '검색 중지',
'storageBytes': '총 저장 용량',
'storageUploads': '업로드 횟수',
'unmarkFavorite': '즐겨찾기 해제',
'unnamedDevice': '이름 없는 기기',
'userDataParseFailed': '사용자 데이터 분석 실패',
}; };

56
apps/client/lib/core/translations/language/lt_lt.dart

@ -1802,4 +1802,60 @@ const Map<String, String> ltLT = {
'idVerifyTooFrequent': 'Too many attempts. Please try again later.', 'idVerifyTooFrequent': 'Too many attempts. Please try again later.',
'idVerifyFailed': 'Verification failed. Please try again later.', 'idVerifyFailed': 'Verification failed. Please try again later.',
'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed', 'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed',
// ---- 恒玄(BES)固件 OTA 升级页 ----
'otaAlreadyLatest': 'Jau naujausia versija',
'otaCurrentVersion': 'Dabartinė versija',
'otaDone': 'Atlikta',
'otaDownloading': 'Atsisiunčiama programinė aparatinė įranga...',
'otaError': 'Klaida',
'otaExitConfirm': 'Vyksta naujinimas. Ar tikrai norite išeiti?',
'otaExitTitle': 'Išeiti iš naujinimo',
'otaFailed': 'Naujinimas nepavyko',
'otaFailedWithCode': 'Naujinimas nepavyko (kodas @code)',
'otaKeepConnectedWarning': 'Naujinimo metu laikykite įrenginį šalia ir neuždarykite programos.',
'otaLatestVersion': 'Naujausia versija',
'otaNeedConnect': 'Pirmiausia prijunkite įrenginį',
'otaNotConnectedTip': 'Prijungto įrenginio nerasta. Prieš naujindami prijunkite ausines.',
'otaPreparing': 'Ruošiama...',
'otaStartUpgrade': 'Pradėti naujinimą',
'otaSuccessReboot': 'Naujinimas pavyko. Paleiskite įrenginį iš naujo.',
'otaTimeout': 'Baigėsi naujinimo laikas. Bandykite dar kartą.',
'otaUnknownDevice': 'Nežinomas įrenginys',
'otaUpdateAvailable': 'Yra naujinys',
'otaUpgrading': 'Naujinama... @percent%',
'otaUpgradingShort': 'Naujinama...',
'otaDownloadPreparing': 'Ruošiamas programinės aparatinės įrangos atsisiuntimas...',
'otaFirmwareCached': 'Programinė aparatinė įranga paruošta, ruošiamas naujinimas...',
'otaDownloadStarted': 'Pradėtas programinės aparatinės įrangos atsisiuntimas...',
'otaDownloadingPercent': 'Atsisiunčiama programinė aparatinė įranga... @percent%',
'otaDownloadDone': 'Atsisiuntimas baigtas, ruošiamas naujinimas...',
'addDevice': 'Pridėti įrenginį',
'deviceConnectionRestricted': 'Įrenginio ryšys apribotas: šis įrenginys neautorizuotas',
// --- 2026-09-03 补齐:这些 key 代码里在用,但 41 个语言文件里都没有 ---
'autopilotTemplateSummary': 'Automatinės santraukos pagal „Autopilot“ šablonus',
'bindImmediately': 'Susieti dabar',
'disconnect': 'Atjungti',
'discoveredDevices': 'Rasti įrenginiai',
'distinguishDifferentSpeakers': 'Kalbėtojų atskyrimas',
'enterFileName': 'Įveskite failo pavadinimą',
'findMore': 'Sužinoti daugiau',
'finishRecording': 'Baigti įrašymą',
'importAudioFromAppWeb': 'Importuokite garsą iš programėlės ar žiniatinklio, kad jį transkribuotumėte ir apibendrintumėte',
'industrySpecificSummaryTemplate': 'Profesionalūs santraukų šablonai jūsų sričiai',
'markFavorite': 'Įtraukti į mėgstamus',
'monthlyTranscriptionDuration': '300 transkripcijos minučių per mėnesį',
'noDevicesFound': 'Įrenginių nerasta',
'notBound': 'Nesusieta',
'notCharging': 'Nekraunama',
'pauseRecording': 'Pristabdyti įrašymą',
'pleaseEnterVerificationCode': 'Įveskite patvirtinimo kodą',
'scanIdle': 'Neieškoma',
'scanning': 'Ieškoma...',
'startScan': 'Pradėti paiešką',
'stopScan': 'Stabdyti paiešką',
'storageBytes': 'Bendra saugykla',
'storageUploads': 'Įkėlimai',
'unmarkFavorite': 'Pašalinti iš mėgstamų',
'unnamedDevice': 'Įrenginys be pavadinimo',
'userDataParseFailed': 'Nepavyko nuskaityti naudotojo duomenų',
}; };

56
apps/client/lib/core/translations/language/lv_lv.dart

@ -1807,4 +1807,60 @@ const Map<String, String> lvLV = {
'idVerifyTooFrequent': 'Too many attempts. Please try again later.', 'idVerifyTooFrequent': 'Too many attempts. Please try again later.',
'idVerifyFailed': 'Verification failed. Please try again later.', 'idVerifyFailed': 'Verification failed. Please try again later.',
'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed', 'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed',
// ---- 恒玄(BES)固件 OTA 升级页 ----
'otaAlreadyLatest': 'Jau ir jaunākā versija',
'otaCurrentVersion': 'Pašreizējā versija',
'otaDone': 'Gatavs',
'otaDownloading': 'Notiek programmaparatūras lejupielāde...',
'otaError': 'Kļūda',
'otaExitConfirm': 'Notiek jaunināšana. Vai tiešām vēlaties iziet?',
'otaExitTitle': 'Iziet no jaunināšanas',
'otaFailed': 'Jaunināšana neizdevās',
'otaFailedWithCode': 'Jaunināšana neizdevās (kods @code)',
'otaKeepConnectedWarning': 'Jaunināšanas laikā turiet ierīci tuvumā un neaizveriet lietotni.',
'otaLatestVersion': 'Jaunākā versija',
'otaNeedConnect': 'Vispirms pievienojiet ierīci',
'otaNotConnectedTip': 'Pievienota ierīce netika atrasta. Pirms jaunināšanas pievienojiet austiņas.',
'otaPreparing': 'Notiek sagatavošana...',
'otaStartUpgrade': 'Sākt jaunināšanu',
'otaSuccessReboot': 'Jaunināšana izdevās. Lūdzu, restartējiet ierīci.',
'otaTimeout': 'Jaunināšanas noildze. Lūdzu, mēģiniet vēlreiz.',
'otaUnknownDevice': 'Nezināma ierīce',
'otaUpdateAvailable': 'Pieejams atjauninājums',
'otaUpgrading': 'Notiek jaunināšana... @percent%',
'otaUpgradingShort': 'Notiek jaunināšana...',
'otaDownloadPreparing': 'Tiek sagatavota programmaparatūras lejupielāde...',
'otaFirmwareCached': 'Programmaparatūra ir gatava, tiek sagatavota jaunināšana...',
'otaDownloadStarted': 'Sākta programmaparatūras lejupielāde...',
'otaDownloadingPercent': 'Notiek programmaparatūras lejupielāde... @percent%',
'otaDownloadDone': 'Lejupielāde pabeigta, tiek sagatavota jaunināšana...',
'addDevice': 'Pievienot ierīci',
'deviceConnectionRestricted': 'Ierīces savienojums ierobežots: šī ierīce nav autorizēta',
// --- 2026-09-03 补齐:这些 key 代码里在用,但 41 个语言文件里都没有 ---
'autopilotTemplateSummary': 'Automātiski kopsavilkumi ar Autopilot veidnēm',
'bindImmediately': 'Saistīt tūlīt',
'disconnect': 'Atvienot',
'discoveredDevices': 'Atrastās ierīces',
'distinguishDifferentSpeakers': 'Runātāju atšķiršana',
'enterFileName': 'Ievadiet faila nosaukumu',
'findMore': 'Uzzināt vairāk',
'finishRecording': 'Beigt ierakstu',
'importAudioFromAppWeb': 'Importējiet audio no lietotnes vai tīmekļa, lai to atšifrētu un apkopotu',
'industrySpecificSummaryTemplate': 'Profesionālas kopsavilkumu veidnes jūsu nozarei',
'markFavorite': 'Pievienot izlasei',
'monthlyTranscriptionDuration': '300 atšifrēšanas minūtes mēnesī',
'noDevicesFound': 'Ierīces netika atrastas',
'notBound': 'Nav saistīts',
'notCharging': 'Neuzlādējas',
'pauseRecording': 'Pauzēt ierakstu',
'pleaseEnterVerificationCode': 'Ievadiet verifikācijas kodu',
'scanIdle': 'Netiek meklēts',
'scanning': 'Notiek meklēšana...',
'startScan': 'Sākt meklēšanu',
'stopScan': 'Apturēt meklēšanu',
'storageBytes': 'Kopējā krātuve',
'storageUploads': 'Augšupielādes',
'unmarkFavorite': 'Noņemt no izlases',
'unnamedDevice': 'Ierīce bez nosaukuma',
'userDataParseFailed': 'Neizdevās nolasīt lietotāja datus',
}; };

56
apps/client/lib/core/translations/language/ms_my.dart

@ -1791,4 +1791,60 @@ const Map<String, String> msMY = {
'idVerifyTooFrequent': 'Too many attempts. Please try again later.', 'idVerifyTooFrequent': 'Too many attempts. Please try again later.',
'idVerifyFailed': 'Verification failed. Please try again later.', 'idVerifyFailed': 'Verification failed. Please try again later.',
'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed', 'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed',
// ---- 恒玄(BES)固件 OTA 升级页 ----
'otaAlreadyLatest': 'Sudah versi terkini',
'otaCurrentVersion': 'Versi semasa',
'otaDone': 'Selesai',
'otaDownloading': 'Memuat turun perisian tegar...',
'otaError': 'Ralat',
'otaExitConfirm': 'Naik taraf sedang berjalan. Anda pasti mahu keluar?',
'otaExitTitle': 'Keluar daripada naik taraf',
'otaFailed': 'Naik taraf gagal',
'otaFailedWithCode': 'Naik taraf gagal (kod @code)',
'otaKeepConnectedWarning': 'Semasa naik taraf, dekatkan peranti dan jangan tutup aplikasi.',
'otaLatestVersion': 'Versi terkini',
'otaNeedConnect': 'Sila sambungkan peranti dahulu',
'otaNotConnectedTip': 'Tiada peranti bersambung dikesan. Sambungkan fon telinga sebelum menaik taraf.',
'otaPreparing': 'Menyediakan...',
'otaStartUpgrade': 'Mula naik taraf',
'otaSuccessReboot': 'Naik taraf berjaya. Sila mulakan semula peranti.',
'otaTimeout': 'Naik taraf tamat masa. Sila cuba lagi.',
'otaUnknownDevice': 'Peranti tidak diketahui',
'otaUpdateAvailable': 'Kemas kini tersedia',
'otaUpgrading': 'Menaik taraf... @percent%',
'otaUpgradingShort': 'Menaik taraf...',
'otaDownloadPreparing': 'Menyediakan muat turun perisian tegar...',
'otaFirmwareCached': 'Perisian tegar sedia, menyediakan naik taraf...',
'otaDownloadStarted': 'Muat turun perisian tegar bermula...',
'otaDownloadingPercent': 'Memuat turun perisian tegar... @percent%',
'otaDownloadDone': 'Muat turun selesai, menyediakan naik taraf...',
'addDevice': 'Tambah peranti',
'deviceConnectionRestricted': 'Sambungan peranti dihadkan: peranti ini tidak dibenarkan',
// --- 2026-09-03 补齐:这些 key 代码里在用,但 41 个语言文件里都没有 ---
'autopilotTemplateSummary': 'Ringkasan automatik dengan templat Autopilot',
'bindImmediately': 'Pautkan Sekarang',
'disconnect': 'Putuskan sambungan',
'discoveredDevices': 'Peranti ditemui',
'distinguishDifferentSpeakers': 'Bezakan setiap penutur',
'enterFileName': 'Masukkan nama fail',
'findMore': 'Terokai Lagi',
'finishRecording': 'Tamatkan Rakaman',
'importAudioFromAppWeb': 'Import audio dari apl atau web untuk ditranskripsi dan diringkaskan',
'industrySpecificSummaryTemplate': 'Templat ringkasan profesional untuk industri anda',
'markFavorite': 'Tambah ke kegemaran',
'monthlyTranscriptionDuration': '300 minit transkripsi sebulan',
'noDevicesFound': 'Tiada peranti ditemui',
'notBound': 'Belum dipautkan',
'notCharging': 'Tidak mengecas',
'pauseRecording': 'Jeda Rakaman',
'pleaseEnterVerificationCode': 'Sila masukkan kod pengesahan',
'scanIdle': 'Tidak mengimbas',
'scanning': 'Mengimbas...',
'startScan': 'Mula Imbas',
'stopScan': 'Henti Imbas',
'storageBytes': 'Jumlah Storan',
'storageUploads': 'Muat naik',
'unmarkFavorite': 'Buang dari kegemaran',
'unnamedDevice': 'Peranti tanpa nama',
'userDataParseFailed': 'Gagal membaca data pengguna',
}; };

56
apps/client/lib/core/translations/language/nb_no.dart

@ -1785,4 +1785,60 @@ const Map<String, String> nbNO = {
'idVerifyTooFrequent': 'Too many attempts. Please try again later.', 'idVerifyTooFrequent': 'Too many attempts. Please try again later.',
'idVerifyFailed': 'Verification failed. Please try again later.', 'idVerifyFailed': 'Verification failed. Please try again later.',
'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed', 'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed',
// ---- 恒玄(BES)固件 OTA 升级页 ----
'otaAlreadyLatest': 'Allerede oppdatert',
'otaCurrentVersion': 'Gjeldende versjon',
'otaDone': 'Ferdig',
'otaDownloading': 'Laster ned fastvare...',
'otaError': 'Feil',
'otaExitConfirm': 'En oppdatering pågår. Er du sikker på at du vil avslutte?',
'otaExitTitle': 'Avslutt oppdatering',
'otaFailed': 'Oppdateringen mislyktes',
'otaFailedWithCode': 'Oppdateringen mislyktes (kode @code)',
'otaKeepConnectedWarning': 'Hold enheten i nærheten og ikke lukk appen under oppdateringen.',
'otaLatestVersion': 'Nyeste versjon',
'otaNeedConnect': 'Koble til en enhet først',
'otaNotConnectedTip': 'Fant ingen tilkoblet enhet. Koble til øretelefonene før du oppdaterer.',
'otaPreparing': 'Forbereder...',
'otaStartUpgrade': 'Start oppdatering',
'otaSuccessReboot': 'Oppdateringen er fullført. Start enheten på nytt.',
'otaTimeout': 'Tidsavbrudd for oppdateringen. Prøv igjen.',
'otaUnknownDevice': 'Ukjent enhet',
'otaUpdateAvailable': 'Oppdatering tilgjengelig',
'otaUpgrading': 'Oppdaterer... @percent%',
'otaUpgradingShort': 'Oppdaterer...',
'otaDownloadPreparing': 'Forbereder nedlasting av fastvare...',
'otaFirmwareCached': 'Fastvaren er klar, forbereder oppdatering...',
'otaDownloadStarted': 'Nedlasting av fastvare startet...',
'otaDownloadingPercent': 'Laster ned fastvare... @percent%',
'otaDownloadDone': 'Nedlasting fullført, forbereder oppdatering...',
'addDevice': 'Legg til enhet',
'deviceConnectionRestricted': 'Enhetstilkobling begrenset: denne enheten er ikke autorisert',
// --- 2026-09-03 补齐:这些 key 代码里在用,但 41 个语言文件里都没有 ---
'autopilotTemplateSummary': 'Automatiske sammendrag med Autopilot-maler',
'bindImmediately': 'Koble til nå',
'disconnect': 'Koble fra',
'discoveredDevices': 'Enheter som ble funnet',
'distinguishDifferentSpeakers': 'Skill mellom talere',
'enterFileName': 'Skriv inn et filnavn',
'findMore': 'Oppdag mer',
'finishRecording': 'Avslutt opptak',
'importAudioFromAppWeb': 'Importer lyd fra appen eller nettet for transkribering og sammendrag',
'industrySpecificSummaryTemplate': 'Profesjonelle sammendragsmaler for din bransje',
'markFavorite': 'Legg til i favoritter',
'monthlyTranscriptionDuration': '300 minutter transkribering per måned',
'noDevicesFound': 'Fant ingen enheter',
'notBound': 'Ikke koblet',
'notCharging': 'Lader ikke',
'pauseRecording': 'Sett opptak på pause',
'pleaseEnterVerificationCode': 'Skriv inn bekreftelseskoden',
'scanIdle': 'Søker ikke',
'scanning': 'Søker...',
'startScan': 'Start søk',
'stopScan': 'Stopp søk',
'storageBytes': 'Total lagring',
'storageUploads': 'Opplastinger',
'unmarkFavorite': 'Fjern fra favoritter',
'unnamedDevice': 'Enhet uten navn',
'userDataParseFailed': 'Kunne ikke lese brukerdata',
}; };

56
apps/client/lib/core/translations/language/nl_nl.dart

@ -1684,4 +1684,60 @@ const Map<String, String> nlNL = {
'idVerifyTooFrequent': 'Too many attempts. Please try again later.', 'idVerifyTooFrequent': 'Too many attempts. Please try again later.',
'idVerifyFailed': 'Verification failed. Please try again later.', 'idVerifyFailed': 'Verification failed. Please try again later.',
'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed', 'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed',
// ---- 恒玄(BES)固件 OTA 升级页 ----
'otaAlreadyLatest': 'Al up-to-date',
'otaCurrentVersion': 'Huidige versie',
'otaDone': 'Klaar',
'otaDownloading': 'Firmware downloaden...',
'otaError': 'Fout',
'otaExitConfirm': 'Er loopt een update. Weet je zeker dat je wilt afsluiten?',
'otaExitTitle': 'Update afsluiten',
'otaFailed': 'Update mislukt',
'otaFailedWithCode': 'Update mislukt (code @code)',
'otaKeepConnectedWarning': 'Houd het apparaat dichtbij en sluit de app niet tijdens de update.',
'otaLatestVersion': 'Nieuwste versie',
'otaNeedConnect': 'Verbind eerst een apparaat',
'otaNotConnectedTip': 'Geen verbonden apparaat gevonden. Verbind je oordopjes voordat je bijwerkt.',
'otaPreparing': 'Voorbereiden...',
'otaStartUpgrade': 'Update starten',
'otaSuccessReboot': 'Update geslaagd. Start het apparaat opnieuw op.',
'otaTimeout': 'Time-out bij de update. Probeer het opnieuw.',
'otaUnknownDevice': 'Onbekend apparaat',
'otaUpdateAvailable': 'Update beschikbaar',
'otaUpgrading': 'Bijwerken... @percent%',
'otaUpgradingShort': 'Bijwerken...',
'otaDownloadPreparing': 'Firmware-download voorbereiden...',
'otaFirmwareCached': 'Firmware gereed, update voorbereiden...',
'otaDownloadStarted': 'Firmware-download gestart...',
'otaDownloadingPercent': 'Firmware downloaden... @percent%',
'otaDownloadDone': 'Download voltooid, update voorbereiden...',
'addDevice': 'Apparaat toevoegen',
'deviceConnectionRestricted': 'Apparaatverbinding beperkt: dit apparaat is niet geautoriseerd',
// --- 2026-09-03 补齐:这些 key 代码里在用,但 41 个语言文件里都没有 ---
'autopilotTemplateSummary': 'Automatische samenvattingen met Autopilot-sjablonen',
'bindImmediately': 'Nu koppelen',
'disconnect': 'Verbinding verbreken',
'discoveredDevices': 'Gevonden apparaten',
'distinguishDifferentSpeakers': 'Sprekers onderscheiden',
'enterFileName': 'Voer een bestandsnaam in',
'findMore': 'Meer ontdekken',
'finishRecording': 'Opname beëindigen',
'importAudioFromAppWeb': 'Importeer audio uit de app of het web om te transcriberen en samen te vatten',
'industrySpecificSummaryTemplate': 'Professionele samenvattingssjablonen voor jouw branche',
'markFavorite': 'Aan favorieten toevoegen',
'monthlyTranscriptionDuration': '300 minuten transcriptie per maand',
'noDevicesFound': 'Geen apparaten gevonden',
'notBound': 'Niet gekoppeld',
'notCharging': 'Laadt niet op',
'pauseRecording': 'Opname pauzeren',
'pleaseEnterVerificationCode': 'Voer de verificatiecode in',
'scanIdle': 'Niet aan het scannen',
'scanning': 'Scannen...',
'startScan': 'Scannen starten',
'stopScan': 'Scannen stoppen',
'storageBytes': 'Totale opslag',
'storageUploads': 'Uploads',
'unmarkFavorite': 'Uit favorieten verwijderen',
'unnamedDevice': 'Naamloos apparaat',
'userDataParseFailed': 'Kan gebruikersgegevens niet verwerken',
}; };

56
apps/client/lib/core/translations/language/pl_pl.dart

@ -1813,4 +1813,60 @@ const Map<String, String> plPL = {
'idVerifyTooFrequent': 'Too many attempts. Please try again later.', 'idVerifyTooFrequent': 'Too many attempts. Please try again later.',
'idVerifyFailed': 'Verification failed. Please try again later.', 'idVerifyFailed': 'Verification failed. Please try again later.',
'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed', 'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed',
// ---- 恒玄(BES)固件 OTA 升级页 ----
'otaAlreadyLatest': 'Masz najnowszą wersję',
'otaCurrentVersion': 'Bieżąca wersja',
'otaDone': 'Gotowe',
'otaDownloading': 'Pobieranie oprogramowania...',
'otaError': 'Błąd',
'otaExitConfirm': 'Trwa aktualizacja. Czy na pewno chcesz wyjść?',
'otaExitTitle': 'Zakończ aktualizację',
'otaFailed': 'Aktualizacja nie powiodła się',
'otaFailedWithCode': 'Aktualizacja nie powiodła się (kod @code)',
'otaKeepConnectedWarning': 'Podczas aktualizacji trzymaj urządzenie blisko i nie zamykaj aplikacji.',
'otaLatestVersion': 'Najnowsza wersja',
'otaNeedConnect': 'Najpierw połącz urządzenie',
'otaNotConnectedTip': 'Nie wykryto połączonego urządzenia. Połącz słuchawki przed aktualizacją.',
'otaPreparing': 'Przygotowywanie...',
'otaStartUpgrade': 'Rozpocznij aktualizację',
'otaSuccessReboot': 'Aktualizacja zakończona pomyślnie. Uruchom ponownie urządzenie.',
'otaTimeout': 'Przekroczono limit czasu aktualizacji. Spróbuj ponownie.',
'otaUnknownDevice': 'Nieznane urządzenie',
'otaUpdateAvailable': 'Dostępna aktualizacja',
'otaUpgrading': 'Aktualizowanie... @percent%',
'otaUpgradingShort': 'Aktualizowanie...',
'otaDownloadPreparing': 'Przygotowywanie pobierania oprogramowania...',
'otaFirmwareCached': 'Oprogramowanie gotowe, przygotowywanie aktualizacji...',
'otaDownloadStarted': 'Rozpoczęto pobieranie oprogramowania...',
'otaDownloadingPercent': 'Pobieranie oprogramowania... @percent%',
'otaDownloadDone': 'Pobieranie zakończone, przygotowywanie aktualizacji...',
'addDevice': 'Dodaj urządzenie',
'deviceConnectionRestricted': 'Połączenie urządzenia ograniczone: to urządzenie nie jest autoryzowane',
// --- 2026-09-03 补齐:这些 key 代码里在用,但 41 个语言文件里都没有 ---
'autopilotTemplateSummary': 'Automatyczne podsumowania na szablonach Autopilot',
'bindImmediately': 'Powiąż teraz',
'disconnect': 'Rozłącz',
'discoveredDevices': 'Znalezione urządzenia',
'distinguishDifferentSpeakers': 'Rozróżnianie mówców',
'enterFileName': 'Wpisz nazwę pliku',
'findMore': 'Odkryj więcej',
'finishRecording': 'Zakończ nagrywanie',
'importAudioFromAppWeb': 'Zaimportuj dźwięk z aplikacji lub sieci, aby go przepisać i podsumować',
'industrySpecificSummaryTemplate': 'Profesjonalne szablony podsumowań dla Twojej branży',
'markFavorite': 'Dodaj do ulubionych',
'monthlyTranscriptionDuration': '300 minut transkrypcji miesięcznie',
'noDevicesFound': 'Nie znaleziono urządzeń',
'notBound': 'Niepowiązane',
'notCharging': 'Nie ładuje się',
'pauseRecording': 'Wstrzymaj nagrywanie',
'pleaseEnterVerificationCode': 'Wpisz kod weryfikacyjny',
'scanIdle': 'Brak skanowania',
'scanning': 'Skanowanie...',
'startScan': 'Rozpocznij skanowanie',
'stopScan': 'Zatrzymaj skanowanie',
'storageBytes': 'Łączna pamięć',
'storageUploads': 'Przesłane pliki',
'unmarkFavorite': 'Usuń z ulubionych',
'unnamedDevice': 'Urządzenie bez nazwy',
'userDataParseFailed': 'Nie udało się odczytać danych użytkownika',
}; };

57
apps/client/lib/core/translations/language/pt_pt.dart

@ -1794,7 +1794,7 @@ const Map<String, String> ptPT = {
'currentConnection': 'Ligação atual', 'currentConnection': 'Ligação atual',
'binding': 'A associar…', 'binding': 'A associar…',
'tabDevice': 'Dispositivos', 'tabDevice': 'Dispositivos',
'tabTodo': 'Tarefas', 'tabTodo': 'Remin',
'tabPlaza': 'Plaza', 'tabPlaza': 'Plaza',
'sloganHome': 'Tecnologia para um dia a dia inteligente', 'sloganHome': 'Tecnologia para um dia a dia inteligente',
'sloganAi': 'Inteligência sem limites', 'sloganAi': 'Inteligência sem limites',
@ -2071,4 +2071,59 @@ const Map<String, String> ptPT = {
'idVerifyTooFrequent': 'Too many attempts. Please try again later.', 'idVerifyTooFrequent': 'Too many attempts. Please try again later.',
'idVerifyFailed': 'Verification failed. Please try again later.', 'idVerifyFailed': 'Verification failed. Please try again later.',
'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed', 'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed',
// ---- 恒玄(BES)固件 OTA 升级页 ----
'otaAlreadyLatest': 'Já está atualizado',
'otaCurrentVersion': 'Versão atual',
'otaDone': 'Concluído',
'otaDownloading': 'A transferir firmware...',
'otaError': 'Erro',
'otaExitConfirm': 'Há uma atualização em curso. Tem a certeza de que quer sair?',
'otaExitTitle': 'Sair da atualização',
'otaFailed': 'Falha na atualização',
'otaFailedWithCode': 'Falha na atualização (código @code)',
'otaKeepConnectedWarning': 'Mantenha o dispositivo por perto e não feche a aplicação durante a atualização.',
'otaLatestVersion': 'Versão mais recente',
'otaNeedConnect': 'Ligue primeiro um dispositivo',
'otaNotConnectedTip': 'Nenhum dispositivo ligado detetado. Ligue os auriculares antes de atualizar.',
'otaPreparing': 'A preparar...',
'otaStartUpgrade': 'Iniciar atualização',
'otaSuccessReboot': 'Atualização concluída. Reinicie o dispositivo.',
'otaTimeout': 'Tempo limite da atualização excedido. Tente novamente.',
'otaUnknownDevice': 'Dispositivo desconhecido',
'otaUpdateAvailable': 'Atualização disponível',
'otaUpgrading': 'A atualizar... @percent%',
'otaUpgradingShort': 'A atualizar...',
'otaDownloadPreparing': 'A preparar a transferência do firmware...',
'otaFirmwareCached': 'Firmware pronto, a preparar a atualização...',
'otaDownloadStarted': 'Transferência do firmware iniciada...',
'otaDownloadingPercent': 'A transferir firmware... @percent%',
'otaDownloadDone': 'Transferência concluída, a preparar a atualização...',
'deviceConnectionRestricted': 'Ligação do dispositivo restrita: este dispositivo não está autorizado',
// --- 2026-09-03 补齐:这些 key 代码里在用,但 41 个语言文件里都没有 ---
'autopilotTemplateSummary': 'Resumos automáticos com modelos Autopilot',
'bindImmediately': 'Associar agora',
'disconnect': 'Desligar',
'discoveredDevices': 'Dispositivos encontrados',
'distinguishDifferentSpeakers': 'Distinguir os oradores',
'enterFileName': 'Introduza um nome de ficheiro',
'findMore': 'Descobrir mais',
'finishRecording': 'Terminar gravação',
'importAudioFromAppWeb': 'Importe áudio da app ou da web para transcrever e resumir',
'industrySpecificSummaryTemplate': 'Modelos de resumo profissionais para o seu setor',
'markFavorite': 'Adicionar aos favoritos',
'monthlyTranscriptionDuration': '300 minutos de transcrição por mês',
'noDevicesFound': 'Nenhum dispositivo encontrado',
'notBound': 'Não associado',
'notCharging': 'Não está a carregar',
'pauseRecording': 'Pausar gravação',
'pleaseEnterVerificationCode': 'Introduza o código de verificação',
'scanIdle': 'Sem procurar',
'scanning': 'A procurar...',
'startScan': 'Iniciar procura',
'stopScan': 'Parar procura',
'storageBytes': 'Armazenamento total',
'storageUploads': 'Carregamentos',
'unmarkFavorite': 'Remover dos favoritos',
'unnamedDevice': 'Dispositivo sem nome',
'userDataParseFailed': 'Falha ao processar os dados do utilizador',
}; };

56
apps/client/lib/core/translations/language/ro_ro.dart

@ -1815,4 +1815,60 @@ const Map<String, String> roRO = {
'idVerifyTooFrequent': 'Too many attempts. Please try again later.', 'idVerifyTooFrequent': 'Too many attempts. Please try again later.',
'idVerifyFailed': 'Verification failed. Please try again later.', 'idVerifyFailed': 'Verification failed. Please try again later.',
'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed', 'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed',
// ---- 恒玄(BES)固件 OTA 升级页 ----
'otaAlreadyLatest': 'Deja la zi',
'otaCurrentVersion': 'Versiunea curentă',
'otaDone': 'Gata',
'otaDownloading': 'Se descarcă firmware-ul...',
'otaError': 'Eroare',
'otaExitConfirm': 'O actualizare este în curs. Sigur vrei să ieși?',
'otaExitTitle': 'Ieși din actualizare',
'otaFailed': 'Actualizarea a eșuat',
'otaFailedWithCode': 'Actualizarea a eșuat (cod @code)',
'otaKeepConnectedWarning': 'Ține dispozitivul aproape și nu închide aplicația în timpul actualizării.',
'otaLatestVersion': 'Cea mai recentă versiune',
'otaNeedConnect': 'Conectează mai întâi un dispozitiv',
'otaNotConnectedTip': 'Nu a fost detectat niciun dispozitiv conectat. Conectează căștile înainte de actualizare.',
'otaPreparing': 'Se pregătește...',
'otaStartUpgrade': 'Începe actualizarea',
'otaSuccessReboot': 'Actualizare reușită. Repornește dispozitivul.',
'otaTimeout': 'Actualizarea a expirat. Încearcă din nou.',
'otaUnknownDevice': 'Dispozitiv necunoscut',
'otaUpdateAvailable': 'Actualizare disponibilă',
'otaUpgrading': 'Se actualizează... @percent%',
'otaUpgradingShort': 'Se actualizează...',
'otaDownloadPreparing': 'Se pregătește descărcarea firmware-ului...',
'otaFirmwareCached': 'Firmware pregătit, se pregătește actualizarea...',
'otaDownloadStarted': 'Descărcarea firmware-ului a început...',
'otaDownloadingPercent': 'Se descarcă firmware-ul... @percent%',
'otaDownloadDone': 'Descărcare finalizată, se pregătește actualizarea...',
'addDevice': 'Adaugă dispozitiv',
'deviceConnectionRestricted': 'Conexiunea dispozitivului este restricționată: dispozitivul nu este autorizat',
// --- 2026-09-03 补齐:这些 key 代码里在用,但 41 个语言文件里都没有 ---
'autopilotTemplateSummary': 'Rezumate automate cu șabloane Autopilot',
'bindImmediately': 'Asociază acum',
'disconnect': 'Deconectează',
'discoveredDevices': 'Dispozitive găsite',
'distinguishDifferentSpeakers': 'Diferențierea vorbitorilor',
'enterFileName': 'Introdu un nume de fișier',
'findMore': 'Descoperă mai mult',
'finishRecording': 'Încheie înregistrarea',
'importAudioFromAppWeb': 'Importă audio din aplicație sau din web pentru transcriere și rezumat',
'industrySpecificSummaryTemplate': 'Șabloane de rezumat profesionale pentru domeniul tău',
'markFavorite': 'Adaugă la favorite',
'monthlyTranscriptionDuration': '300 de minute de transcriere pe lună',
'noDevicesFound': 'Nu s-au găsit dispozitive',
'notBound': 'Neasociat',
'notCharging': 'Nu se încarcă',
'pauseRecording': 'Întrerupe înregistrarea',
'pleaseEnterVerificationCode': 'Introdu codul de verificare',
'scanIdle': 'Nu se scanează',
'scanning': 'Se scanează...',
'startScan': 'Pornește scanarea',
'stopScan': 'Oprește scanarea',
'storageBytes': 'Spațiu total',
'storageUploads': 'Încărcări',
'unmarkFavorite': 'Elimină de la favorite',
'unnamedDevice': 'Dispozitiv fără nume',
'userDataParseFailed': 'Datele utilizatorului nu au putut fi citite',
}; };

57
apps/client/lib/core/translations/language/ru_ru.dart

@ -1772,7 +1772,7 @@ const Map<String, String> ruRU = {
'binding': 'Привязка…', 'binding': 'Привязка…',
'microphonePermission': 'Микрофон', 'microphonePermission': 'Микрофон',
'tabDevice': 'Устройства', 'tabDevice': 'Устройства',
'tabTodo': 'Задачи', 'tabTodo': 'Remin',
'tabPlaza': 'Плаза', 'tabPlaza': 'Плаза',
'sloganHome': 'Технологии для умной жизни', 'sloganHome': 'Технологии для умной жизни',
'sloganAi': 'Безграничный интеллект', 'sloganAi': 'Безграничный интеллект',
@ -2046,4 +2046,59 @@ const Map<String, String> ruRU = {
'idVerifyTooFrequent': 'Too many attempts. Please try again later.', 'idVerifyTooFrequent': 'Too many attempts. Please try again later.',
'idVerifyFailed': 'Verification failed. Please try again later.', 'idVerifyFailed': 'Verification failed. Please try again later.',
'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed', 'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed',
// ---- 恒玄(BES)固件 OTA 升级页 ----
'otaAlreadyLatest': 'Установлена последняя версия',
'otaCurrentVersion': 'Текущая версия',
'otaDone': 'Готово',
'otaDownloading': 'Загрузка прошивки...',
'otaError': 'Ошибка',
'otaExitConfirm': 'Идёт обновление. Вы действительно хотите выйти?',
'otaExitTitle': 'Выйти из обновления',
'otaFailed': 'Ошибка обновления',
'otaFailedWithCode': 'Ошибка обновления (код @code)',
'otaKeepConnectedWarning': 'Держите устройство рядом и не закрывайте приложение во время обновления.',
'otaLatestVersion': 'Последняя версия',
'otaNeedConnect': 'Сначала подключите устройство',
'otaNotConnectedTip': 'Подключённое устройство не найдено. Подключите наушники перед обновлением.',
'otaPreparing': 'Подготовка...',
'otaStartUpgrade': 'Начать обновление',
'otaSuccessReboot': 'Обновление выполнено. Перезапустите устройство.',
'otaTimeout': 'Время обновления истекло. Попробуйте ещё раз.',
'otaUnknownDevice': 'Неизвестное устройство',
'otaUpdateAvailable': 'Доступно обновление',
'otaUpgrading': 'Обновление... @percent%',
'otaUpgradingShort': 'Обновление...',
'otaDownloadPreparing': 'Подготовка к загрузке прошивки...',
'otaFirmwareCached': 'Прошивка готова, подготовка к обновлению...',
'otaDownloadStarted': 'Загрузка прошивки начата...',
'otaDownloadingPercent': 'Загрузка прошивки... @percent%',
'otaDownloadDone': 'Загрузка завершена, подготовка к обновлению...',
'deviceConnectionRestricted': 'Подключение устройства ограничено: устройство не авторизовано',
// --- 2026-09-03 补齐:这些 key 代码里在用,但 41 个语言文件里都没有 ---
'autopilotTemplateSummary': 'Автоматические сводки по шаблонам Autopilot',
'bindImmediately': 'Привязать',
'disconnect': 'Отключить',
'discoveredDevices': 'Найденные устройства',
'distinguishDifferentSpeakers': 'Различать говорящих',
'enterFileName': 'Введите имя файла',
'findMore': 'Узнать больше',
'finishRecording': 'Завершить запись',
'importAudioFromAppWeb': 'Импортируйте аудио из приложения или веб-версии для расшифровки и сводки',
'industrySpecificSummaryTemplate': 'Профессиональные шаблоны сводок для вашей отрасли',
'markFavorite': 'В избранное',
'monthlyTranscriptionDuration': '300 минут расшифровки в месяц',
'noDevicesFound': 'Устройства не найдены',
'notBound': 'Не привязано',
'notCharging': 'Не заряжается',
'pauseRecording': 'Приостановить запись',
'pleaseEnterVerificationCode': 'Введите код подтверждения',
'scanIdle': 'Поиск не идёт',
'scanning': 'Поиск...',
'startScan': 'Начать поиск',
'stopScan': 'Остановить поиск',
'storageBytes': 'Всего в хранилище',
'storageUploads': 'Загрузки',
'unmarkFavorite': 'Убрать из избранного',
'unnamedDevice': 'Устройство без имени',
'userDataParseFailed': 'Не удалось обработать данные пользователя',
}; };

56
apps/client/lib/core/translations/language/sk_sk.dart

@ -1796,4 +1796,60 @@ const Map<String, String> skSK = {
'idVerifyTooFrequent': 'Too many attempts. Please try again later.', 'idVerifyTooFrequent': 'Too many attempts. Please try again later.',
'idVerifyFailed': 'Verification failed. Please try again later.', 'idVerifyFailed': 'Verification failed. Please try again later.',
'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed', 'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed',
// ---- 恒玄(BES)固件 OTA 升级页 ----
'otaAlreadyLatest': 'Už je aktuálny',
'otaCurrentVersion': 'Aktuálna verzia',
'otaDone': 'Hotovo',
'otaDownloading': 'Sťahovanie firmvéru...',
'otaError': 'Chyba',
'otaExitConfirm': 'Prebieha aktualizácia. Naozaj chcete odísť?',
'otaExitTitle': 'Ukončiť aktualizáciu',
'otaFailed': 'Aktualizácia zlyhala',
'otaFailedWithCode': 'Aktualizácia zlyhala (kód @code)',
'otaKeepConnectedWarning': 'Počas aktualizácie majte zariadenie nablízku a nezatvárajte aplikáciu.',
'otaLatestVersion': 'Najnovšia verzia',
'otaNeedConnect': 'Najprv pripojte zariadenie',
'otaNotConnectedTip': 'Nenašlo sa pripojené zariadenie. Pred aktualizáciou pripojte slúchadlá.',
'otaPreparing': 'Príprava...',
'otaStartUpgrade': 'Spustiť aktualizáciu',
'otaSuccessReboot': 'Aktualizácia úspešná. Reštartujte zariadenie.',
'otaTimeout': 'Vypršal časový limit aktualizácie. Skúste to znova.',
'otaUnknownDevice': 'Neznáme zariadenie',
'otaUpdateAvailable': 'Dostupná aktualizácia',
'otaUpgrading': 'Aktualizácia... @percent%',
'otaUpgradingShort': 'Aktualizácia...',
'otaDownloadPreparing': 'Príprava sťahovania firmvéru...',
'otaFirmwareCached': 'Firmvér pripravený, príprava aktualizácie...',
'otaDownloadStarted': 'Sťahovanie firmvéru sa začalo...',
'otaDownloadingPercent': 'Sťahovanie firmvéru... @percent%',
'otaDownloadDone': 'Sťahovanie dokončené, príprava aktualizácie...',
'addDevice': 'Pridať zariadenie',
'deviceConnectionRestricted': 'Pripojenie zariadenia je obmedzené: toto zariadenie nie je autorizované',
// --- 2026-09-03 补齐:这些 key 代码里在用,但 41 个语言文件里都没有 ---
'autopilotTemplateSummary': 'Automatické zhrnutia zo šablón Autopilot',
'bindImmediately': 'Spárovať teraz',
'disconnect': 'Odpojiť',
'discoveredDevices': 'Nájdené zariadenia',
'distinguishDifferentSpeakers': 'Rozlíšenie hovoriacich',
'enterFileName': 'Zadajte názov súboru',
'findMore': 'Objaviť viac',
'finishRecording': 'Ukončiť nahrávanie',
'importAudioFromAppWeb': 'Importujte zvuk z aplikácie alebo webu, prepíšte ho a zhrňte',
'industrySpecificSummaryTemplate': 'Profesionálne šablóny zhrnutí pre váš odbor',
'markFavorite': 'Pridať k obľúbeným',
'monthlyTranscriptionDuration': '300 minút prepisu mesačne',
'noDevicesFound': 'Nenašli sa žiadne zariadenia',
'notBound': 'Nespárované',
'notCharging': 'Nenabíja sa',
'pauseRecording': 'Pozastaviť nahrávanie',
'pleaseEnterVerificationCode': 'Zadajte overovací kód',
'scanIdle': 'Nevyhľadáva sa',
'scanning': 'Vyhľadávanie...',
'startScan': 'Spustiť vyhľadávanie',
'stopScan': 'Zastaviť vyhľadávanie',
'storageBytes': 'Celkové úložisko',
'storageUploads': 'Nahratia',
'unmarkFavorite': 'Odstrániť z obľúbených',
'unnamedDevice': 'Nepomenované zariadenie',
'userDataParseFailed': 'Údaje používateľa sa nepodarilo načítať',
}; };

56
apps/client/lib/core/translations/language/sl_si.dart

@ -1799,4 +1799,60 @@ const Map<String, String> slSI = {
'idVerifyTooFrequent': 'Too many attempts. Please try again later.', 'idVerifyTooFrequent': 'Too many attempts. Please try again later.',
'idVerifyFailed': 'Verification failed. Please try again later.', 'idVerifyFailed': 'Verification failed. Please try again later.',
'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed', 'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed',
// ---- 恒玄(BES)固件 OTA 升级页 ----
'otaAlreadyLatest': 'Že posodobljeno',
'otaCurrentVersion': 'Trenutna različica',
'otaDone': 'Končano',
'otaDownloading': 'Prenašanje vdelane programske opreme...',
'otaError': 'Napaka',
'otaExitConfirm': 'Nadgradnja poteka. Ali res želite zapustiti?',
'otaExitTitle': 'Izhod iz nadgradnje',
'otaFailed': 'Nadgradnja ni uspela',
'otaFailedWithCode': 'Nadgradnja ni uspela (koda @code)',
'otaKeepConnectedWarning': 'Med nadgradnjo imejte napravo blizu in ne zapirajte aplikacije.',
'otaLatestVersion': 'Najnovejša različica',
'otaNeedConnect': 'Najprej povežite napravo',
'otaNotConnectedTip': 'Povezane naprave ni bilo mogoče najti. Pred nadgradnjo povežite slušalke.',
'otaPreparing': 'Pripravljanje...',
'otaStartUpgrade': 'Začni nadgradnjo',
'otaSuccessReboot': 'Nadgradnja je uspela. Znova zaženite napravo.',
'otaTimeout': 'Časovna omejitev nadgradnje je potekla. Poskusite znova.',
'otaUnknownDevice': 'Neznana naprava',
'otaUpdateAvailable': 'Na voljo je posodobitev',
'otaUpgrading': 'Nadgrajevanje... @percent%',
'otaUpgradingShort': 'Nadgrajevanje...',
'otaDownloadPreparing': 'Pripravljanje prenosa vdelane programske opreme...',
'otaFirmwareCached': 'Vdelana programska oprema je pripravljena, pripravljanje nadgradnje...',
'otaDownloadStarted': 'Prenos vdelane programske opreme se je začel...',
'otaDownloadingPercent': 'Prenašanje vdelane programske opreme... @percent%',
'otaDownloadDone': 'Prenos je končan, pripravljanje nadgradnje...',
'addDevice': 'Dodaj napravo',
'deviceConnectionRestricted': 'Povezava naprave je omejena: ta naprava ni pooblaščena',
// --- 2026-09-03 补齐:这些 key 代码里在用,但 41 个语言文件里都没有 ---
'autopilotTemplateSummary': 'Samodejni povzetki s predlogami Autopilot',
'bindImmediately': 'Poveži zdaj',
'disconnect': 'Prekini povezavo',
'discoveredDevices': 'Najdene naprave',
'distinguishDifferentSpeakers': 'Razlikovanje govorcev',
'enterFileName': 'Vnesite ime datoteke',
'findMore': 'Odkrijte več',
'finishRecording': 'Končaj snemanje',
'importAudioFromAppWeb': 'Uvozite zvok iz aplikacije ali spleta za prepis in povzetek',
'industrySpecificSummaryTemplate': 'Profesionalne predloge povzetkov za vašo panogo',
'markFavorite': 'Dodaj med priljubljene',
'monthlyTranscriptionDuration': '300 minut prepisa na mesec',
'noDevicesFound': 'Ni najdenih naprav',
'notBound': 'Ni povezano',
'notCharging': 'Se ne polni',
'pauseRecording': 'Začasno ustavi snemanje',
'pleaseEnterVerificationCode': 'Vnesite potrditveno kodo',
'scanIdle': 'Iskanje ne poteka',
'scanning': 'Iskanje...',
'startScan': 'Začni iskanje',
'stopScan': 'Ustavi iskanje',
'storageBytes': 'Skupna shramba',
'storageUploads': 'Nalaganja',
'unmarkFavorite': 'Odstrani iz priljubljenih',
'unnamedDevice': 'Neimenovana naprava',
'userDataParseFailed': 'Uporabniških podatkov ni bilo mogoče prebrati',
}; };

56
apps/client/lib/core/translations/language/sr_rs.dart

@ -1795,4 +1795,60 @@ const Map<String, String> srRS = {
'idVerifyTooFrequent': 'Too many attempts. Please try again later.', 'idVerifyTooFrequent': 'Too many attempts. Please try again later.',
'idVerifyFailed': 'Verification failed. Please try again later.', 'idVerifyFailed': 'Verification failed. Please try again later.',
'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed', 'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed',
// ---- 恒玄(BES)固件 OTA 升级页 ----
'otaAlreadyLatest': 'Već je ažurno',
'otaCurrentVersion': 'Trenutna verzija',
'otaDone': 'Gotovo',
'otaDownloading': 'Preuzimanje firmvera...',
'otaError': 'Greška',
'otaExitConfirm': 'Ažuriranje je u toku. Da li stvarno želite da izađete?',
'otaExitTitle': 'Izlaz iz ažuriranja',
'otaFailed': 'Ažuriranje nije uspelo',
'otaFailedWithCode': 'Ažuriranje nije uspelo (kod @code)',
'otaKeepConnectedWarning': 'Tokom ažuriranja držite uređaj blizu i ne zatvarajte aplikaciju.',
'otaLatestVersion': 'Najnovija verzija',
'otaNeedConnect': 'Prvo povežite uređaj',
'otaNotConnectedTip': 'Nije pronađen povezani uređaj. Povežite slušalice pre ažuriranja.',
'otaPreparing': 'Priprema...',
'otaStartUpgrade': 'Pokreni ažuriranje',
'otaSuccessReboot': 'Ažuriranje je uspelo. Ponovo pokrenite uređaj.',
'otaTimeout': 'Isteklo je vreme ažuriranja. Pokušajte ponovo.',
'otaUnknownDevice': 'Nepoznat uređaj',
'otaUpdateAvailable': 'Dostupno je ažuriranje',
'otaUpgrading': 'Ažuriranje... @percent%',
'otaUpgradingShort': 'Ažuriranje...',
'otaDownloadPreparing': 'Priprema preuzimanja firmvera...',
'otaFirmwareCached': 'Firmver je spreman, priprema ažuriranja...',
'otaDownloadStarted': 'Preuzimanje firmvera je počelo...',
'otaDownloadingPercent': 'Preuzimanje firmvera... @percent%',
'otaDownloadDone': 'Preuzimanje je završeno, priprema ažuriranja...',
'addDevice': 'Dodaj uređaj',
'deviceConnectionRestricted': 'Povezivanje uređaja je ograničeno: uređaj nije autorizovan',
// --- 2026-09-03 补齐:这些 key 代码里在用,但 41 个语言文件里都没有 ---
'autopilotTemplateSummary': 'Аутоматски резимеи помоћу Autopilot шаблона',
'bindImmediately': 'Повежи одмах',
'disconnect': 'Прекини везу',
'discoveredDevices': 'Пронађени уређаји',
'distinguishDifferentSpeakers': 'Разликовање говорника',
'enterFileName': 'Унесите назив датотеке',
'findMore': 'Откријте више',
'finishRecording': 'Заврши снимање',
'importAudioFromAppWeb': 'Увезите звук из апликације или са веба ради транскрипције и резимеа',
'industrySpecificSummaryTemplate': 'Професионални шаблони резимеа за вашу делатност',
'markFavorite': 'Додај у омиљено',
'monthlyTranscriptionDuration': '300 минута транскрипције месечно',
'noDevicesFound': 'Није пронађен ниједан уређај',
'notBound': 'Није повезано',
'notCharging': 'Не пуни се',
'pauseRecording': 'Паузирај снимање',
'pleaseEnterVerificationCode': 'Унесите верификациони код',
'scanIdle': 'Не претражује се',
'scanning': 'Претраживање...',
'startScan': 'Покрени претрагу',
'stopScan': 'Заустави претрагу',
'storageBytes': 'Укупно складиште',
'storageUploads': 'Отпремања',
'unmarkFavorite': 'Уклони из омиљених',
'unnamedDevice': 'Уређај без назива',
'userDataParseFailed': 'Подаци корисника нису могли да се учитају',
}; };

56
apps/client/lib/core/translations/language/sv_se.dart

@ -1791,4 +1791,60 @@ const Map<String, String> svSE = {
'idVerifyTooFrequent': 'Too many attempts. Please try again later.', 'idVerifyTooFrequent': 'Too many attempts. Please try again later.',
'idVerifyFailed': 'Verification failed. Please try again later.', 'idVerifyFailed': 'Verification failed. Please try again later.',
'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed', 'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed',
// ---- 恒玄(BES)固件 OTA 升级页 ----
'otaAlreadyLatest': 'Redan uppdaterad',
'otaCurrentVersion': 'Nuvarande version',
'otaDone': 'Klar',
'otaDownloading': 'Laddar ner fast programvara...',
'otaError': 'Fel',
'otaExitConfirm': 'En uppdatering pågår. Vill du verkligen avsluta?',
'otaExitTitle': 'Avsluta uppdatering',
'otaFailed': 'Uppdateringen misslyckades',
'otaFailedWithCode': 'Uppdateringen misslyckades (kod @code)',
'otaKeepConnectedWarning': 'Håll enheten nära och stäng inte appen under uppdateringen.',
'otaLatestVersion': 'Senaste versionen',
'otaNeedConnect': 'Anslut en enhet först',
'otaNotConnectedTip': 'Ingen ansluten enhet hittades. Anslut dina hörlurar innan du uppdaterar.',
'otaPreparing': 'Förbereder...',
'otaStartUpgrade': 'Starta uppdatering',
'otaSuccessReboot': 'Uppdateringen lyckades. Starta om enheten.',
'otaTimeout': 'Tidsgränsen för uppdateringen överskreds. Försök igen.',
'otaUnknownDevice': 'Okänd enhet',
'otaUpdateAvailable': 'Uppdatering tillgänglig',
'otaUpgrading': 'Uppdaterar... @percent%',
'otaUpgradingShort': 'Uppdaterar...',
'otaDownloadPreparing': 'Förbereder nedladdning av fast programvara...',
'otaFirmwareCached': 'Fast programvara klar, förbereder uppdatering...',
'otaDownloadStarted': 'Nedladdning av fast programvara startad...',
'otaDownloadingPercent': 'Laddar ner fast programvara... @percent%',
'otaDownloadDone': 'Nedladdning klar, förbereder uppdatering...',
'addDevice': 'Lägg till enhet',
'deviceConnectionRestricted': 'Enhetsanslutning begränsad: enheten är inte auktoriserad',
// --- 2026-09-03 补齐:这些 key 代码里在用,但 41 个语言文件里都没有 ---
'autopilotTemplateSummary': 'Automatiska sammanfattningar med Autopilot-mallar',
'bindImmediately': 'Koppla nu',
'disconnect': 'Koppla från',
'discoveredDevices': 'Hittade enheter',
'distinguishDifferentSpeakers': 'Skilj på olika talare',
'enterFileName': 'Ange ett filnamn',
'findMore': 'Upptäck mer',
'finishRecording': 'Avsluta inspelning',
'importAudioFromAppWeb': 'Importera ljud från appen eller webben för transkribering och sammanfattning',
'industrySpecificSummaryTemplate': 'Professionella sammanfattningsmallar för din bransch',
'markFavorite': 'Lägg till i favoriter',
'monthlyTranscriptionDuration': '300 minuters transkribering per månad',
'noDevicesFound': 'Inga enheter hittades',
'notBound': 'Inte kopplad',
'notCharging': 'Laddar inte',
'pauseRecording': 'Pausa inspelning',
'pleaseEnterVerificationCode': 'Ange verifieringskoden',
'scanIdle': 'Söker inte',
'scanning': 'Söker...',
'startScan': 'Starta sökning',
'stopScan': 'Stoppa sökning',
'storageBytes': 'Total lagring',
'storageUploads': 'Uppladdningar',
'unmarkFavorite': 'Ta bort från favoriter',
'unnamedDevice': 'Namnlös enhet',
'userDataParseFailed': 'Det gick inte att läsa användardata',
}; };

56
apps/client/lib/core/translations/language/th_th.dart

@ -1744,4 +1744,60 @@ const Map<String, String> thTH = {
'idVerifyTooFrequent': 'Too many attempts. Please try again later.', 'idVerifyTooFrequent': 'Too many attempts. Please try again later.',
'idVerifyFailed': 'Verification failed. Please try again later.', 'idVerifyFailed': 'Verification failed. Please try again later.',
'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed', 'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed',
// ---- 恒玄(BES)固件 OTA 升级页 ----
'otaAlreadyLatest': 'เป็นเวอร์ชันล่าสุดแล้ว',
'otaCurrentVersion': 'เวอร์ชันปัจจุบัน',
'otaDone': 'เสร็จสิ้น',
'otaDownloading': 'กำลังดาวน์โหลดเฟิร์มแวร์...',
'otaError': 'ข้อผิดพลาด',
'otaExitConfirm': 'กำลังอัปเกรดอยู่ ต้องการออกหรือไม่?',
'otaExitTitle': 'ออกจากการอัปเกรด',
'otaFailed': 'อัปเกรดไม่สำเร็จ',
'otaFailedWithCode': 'อัปเกรดไม่สำเร็จ (รหัส @code)',
'otaKeepConnectedWarning': 'ระหว่างอัปเกรด กรุณาวางอุปกรณ์ไว้ใกล้ ๆ และอย่าปิดแอป',
'otaLatestVersion': 'เวอร์ชันล่าสุด',
'otaNeedConnect': 'กรุณาเชื่อมต่ออุปกรณ์ก่อน',
'otaNotConnectedTip': 'ไม่พบอุปกรณ์ที่เชื่อมต่อ กรุณาเชื่อมต่อหูฟังก่อนอัปเกรด',
'otaPreparing': 'กำลังเตรียม...',
'otaStartUpgrade': 'เริ่มอัปเกรด',
'otaSuccessReboot': 'อัปเกรดสำเร็จ กรุณารีสตาร์ทอุปกรณ์',
'otaTimeout': 'หมดเวลาการอัปเกรด กรุณาลองใหม่',
'otaUnknownDevice': 'อุปกรณ์ที่ไม่รู้จัก',
'otaUpdateAvailable': 'มีการอัปเดต',
'otaUpgrading': 'กำลังอัปเกรด... @percent%',
'otaUpgradingShort': 'กำลังอัปเกรด...',
'otaDownloadPreparing': 'กำลังเตรียมดาวน์โหลดเฟิร์มแวร์...',
'otaFirmwareCached': 'เฟิร์มแวร์พร้อมแล้ว กำลังเตรียมอัปเกรด...',
'otaDownloadStarted': 'เริ่มดาวน์โหลดเฟิร์มแวร์...',
'otaDownloadingPercent': 'กำลังดาวน์โหลดเฟิร์มแวร์... @percent%',
'otaDownloadDone': 'ดาวน์โหลดเสร็จ กำลังเตรียมอัปเกรด...',
'addDevice': 'เพิ่มอุปกรณ์',
'deviceConnectionRestricted': 'การเชื่อมต่ออุปกรณ์ถูกจำกัด: อุปกรณ์นี้ไม่ได้รับอนุญาต',
// --- 2026-09-03 补齐:这些 key 代码里在用,但 41 个语言文件里都没有 ---
'autopilotTemplateSummary': 'สรุปอัตโนมัติด้วยเทมเพลต Autopilot',
'bindImmediately': 'ผูกอุปกรณ์ทันที',
'disconnect': 'ตัดการเชื่อมต่อ',
'discoveredDevices': 'อุปกรณ์ที่พบ',
'distinguishDifferentSpeakers': 'แยกแยะผู้พูดแต่ละคน',
'enterFileName': 'กรอกชื่อไฟล์',
'findMore': 'ดูเพิ่มเติม',
'finishRecording': 'สิ้นสุดการบันทึก',
'importAudioFromAppWeb': 'นำเข้าเสียงจากแอปหรือเว็บเพื่อถอดความและสรุป',
'industrySpecificSummaryTemplate': 'เทมเพลตสรุปมืออาชีพที่ออกแบบมาเพื่ออุตสาหกรรมของคุณ',
'markFavorite': 'เพิ่มในรายการโปรด',
'monthlyTranscriptionDuration': 'ถอดความ 300 นาทีต่อเดือน',
'noDevicesFound': 'ไม่พบอุปกรณ์',
'notBound': 'ยังไม่ได้ผูก',
'notCharging': 'ไม่ได้ชาร์จ',
'pauseRecording': 'หยุดบันทึกชั่วคราว',
'pleaseEnterVerificationCode': 'กรุณากรอกรหัสยืนยัน',
'scanIdle': 'ไม่ได้กำลังค้นหา',
'scanning': 'กำลังค้นหา...',
'startScan': 'เริ่มค้นหา',
'stopScan': 'หยุดค้นหา',
'storageBytes': 'พื้นที่จัดเก็บทั้งหมด',
'storageUploads': 'จำนวนการอัปโหลด',
'unmarkFavorite': 'นำออกจากรายการโปรด',
'unnamedDevice': 'อุปกรณ์ที่ไม่มีชื่อ',
'userDataParseFailed': 'อ่านข้อมูลผู้ใช้ไม่สำเร็จ',
}; };

56
apps/client/lib/core/translations/language/tl_ph.dart

@ -1829,4 +1829,60 @@ const Map<String, String> tlPH = {
'idVerifyTooFrequent': 'Too many attempts. Please try again later.', 'idVerifyTooFrequent': 'Too many attempts. Please try again later.',
'idVerifyFailed': 'Verification failed. Please try again later.', 'idVerifyFailed': 'Verification failed. Please try again later.',
'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed', 'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed',
// ---- 恒玄(BES)固件 OTA 升级页 ----
'otaAlreadyLatest': 'Pinakabago na',
'otaCurrentVersion': 'Kasalukuyang bersyon',
'otaDone': 'Tapos na',
'otaDownloading': 'Dina-download ang firmware...',
'otaError': 'Error',
'otaExitConfirm': 'May kasalukuyang pag-upgrade. Sigurado kang gusto mong lumabas?',
'otaExitTitle': 'Lumabas sa pag-upgrade',
'otaFailed': 'Nabigo ang pag-upgrade',
'otaFailedWithCode': 'Nabigo ang pag-upgrade (code @code)',
'otaKeepConnectedWarning': 'Ilapit ang device at huwag isara ang app habang nag-a-upgrade.',
'otaLatestVersion': 'Pinakabagong bersyon',
'otaNeedConnect': 'Kumonekta muna sa isang device',
'otaNotConnectedTip': 'Walang nakitang nakakonektang device. Ikonekta ang iyong earphone bago mag-upgrade.',
'otaPreparing': 'Naghahanda...',
'otaStartUpgrade': 'Simulan ang pag-upgrade',
'otaSuccessReboot': 'Matagumpay ang pag-upgrade. Pakirestart ang device.',
'otaTimeout': 'Nag-timeout ang pag-upgrade. Subukan ulit.',
'otaUnknownDevice': 'Hindi kilalang device',
'otaUpdateAvailable': 'May available na update',
'otaUpgrading': 'Nag-a-upgrade... @percent%',
'otaUpgradingShort': 'Nag-a-upgrade...',
'otaDownloadPreparing': 'Naghahanda sa pag-download ng firmware...',
'otaFirmwareCached': 'Handa na ang firmware, naghahanda sa pag-upgrade...',
'otaDownloadStarted': 'Nagsimula ang pag-download ng firmware...',
'otaDownloadingPercent': 'Dina-download ang firmware... @percent%',
'otaDownloadDone': 'Tapos na ang download, naghahanda sa pag-upgrade...',
'addDevice': 'Magdagdag ng device',
'deviceConnectionRestricted': 'Pinaghihigpitan ang koneksyon ng device: hindi awtorisado ang device na ito',
// --- 2026-09-03 补齐:这些 key 代码里在用,但 41 个语言文件里都没有 ---
'autopilotTemplateSummary': 'Awtomatikong buod gamit ang mga template ng Autopilot',
'bindImmediately': 'I-bind Ngayon',
'disconnect': 'Idiskonekta',
'discoveredDevices': 'Mga natagpuang device',
'distinguishDifferentSpeakers': 'Kilalanin ang magkakaibang nagsasalita',
'enterFileName': 'Maglagay ng pangalan ng file',
'findMore': 'Tumuklas Pa',
'finishRecording': 'Tapusin ang Pagre-record',
'importAudioFromAppWeb': 'Mag-import ng audio mula sa app o web para i-transcribe at buurin',
'industrySpecificSummaryTemplate': 'Mga propesyonal na template ng buod para sa iyong industriya',
'markFavorite': 'Idagdag sa mga paborito',
'monthlyTranscriptionDuration': '300 minutong transcription bawat buwan',
'noDevicesFound': 'Walang natagpuang device',
'notBound': 'Hindi naka-bind',
'notCharging': 'Hindi nagcha-charge',
'pauseRecording': 'I-pause ang Pagre-record',
'pleaseEnterVerificationCode': 'Pakilagay ang verification code',
'scanIdle': 'Hindi nagsa-scan',
'scanning': 'Nagsa-scan...',
'startScan': 'Simulan ang Scan',
'stopScan': 'Itigil ang Scan',
'storageBytes': 'Kabuuang Storage',
'storageUploads': 'Mga upload',
'unmarkFavorite': 'Alisin sa mga paborito',
'unnamedDevice': 'Device na walang pangalan',
'userDataParseFailed': 'Nabigong basahin ang data ng user',
}; };

56
apps/client/lib/core/translations/language/tr_tr.dart

@ -1783,4 +1783,60 @@ const Map<String, String> trTR = {
'idVerifyTooFrequent': 'Too many attempts. Please try again later.', 'idVerifyTooFrequent': 'Too many attempts. Please try again later.',
'idVerifyFailed': 'Verification failed. Please try again later.', 'idVerifyFailed': 'Verification failed. Please try again later.',
'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed', 'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed',
// ---- 恒玄(BES)固件 OTA 升级页 ----
'otaAlreadyLatest': 'Zaten güncel',
'otaCurrentVersion': 'Geçerli sürüm',
'otaDone': 'Tamam',
'otaDownloading': 'Ürün yazılımı indiriliyor...',
'otaError': 'Hata',
'otaExitConfirm': 'Bir güncelleme sürüyor. Çıkmak istediğinizden emin misiniz?',
'otaExitTitle': 'Güncellemeden çık',
'otaFailed': 'Güncelleme başarısız',
'otaFailedWithCode': 'Güncelleme başarısız (kod @code)',
'otaKeepConnectedWarning': 'Güncelleme sırasında cihazı yakın tutun ve uygulamayı kapatmayın.',
'otaLatestVersion': 'En son sürüm',
'otaNeedConnect': 'Lütfen önce bir cihaz bağlayın',
'otaNotConnectedTip': 'Bağlı cihaz bulunamadı. Güncellemeden önce kulaklığınızı bağlayın.',
'otaPreparing': 'Hazırlanıyor...',
'otaStartUpgrade': 'Güncellemeyi başlat',
'otaSuccessReboot': 'Güncelleme başarılı. Lütfen cihazı yeniden başlatın.',
'otaTimeout': 'Güncelleme zaman aşımına uğradı. Lütfen tekrar deneyin.',
'otaUnknownDevice': 'Bilinmeyen cihaz',
'otaUpdateAvailable': 'Güncelleme mevcut',
'otaUpgrading': 'Güncelleniyor... @percent%',
'otaUpgradingShort': 'Güncelleniyor...',
'otaDownloadPreparing': 'Ürün yazılımı indirmesi hazırlanıyor...',
'otaFirmwareCached': 'Ürün yazılımı hazır, güncelleme hazırlanıyor...',
'otaDownloadStarted': 'Ürün yazılımı indirmesi başladı...',
'otaDownloadingPercent': 'Ürün yazılımı indiriliyor... @percent%',
'otaDownloadDone': 'İndirme tamamlandı, güncelleme hazırlanıyor...',
'addDevice': 'Cihaz ekle',
'deviceConnectionRestricted': 'Cihaz bağlantısı kısıtlandı: bu cihaz yetkilendirilmemiş',
// --- 2026-09-03 补齐:这些 key 代码里在用,但 41 个语言文件里都没有 ---
'autopilotTemplateSummary': 'Autopilot şablonlarıyla otomatik özet',
'bindImmediately': 'Şimdi eşle',
'disconnect': 'Bağlantıyı kes',
'discoveredDevices': 'Bulunan cihazlar',
'distinguishDifferentSpeakers': 'Konuşmacıları ayırt et',
'enterFileName': 'Bir dosya adı girin',
'findMore': 'Daha fazlasını keşfet',
'finishRecording': 'Kaydı bitir',
'importAudioFromAppWeb': 'Uygulamadan veya web’den ses içe aktarıp yazıya dökün ve özetleyin',
'industrySpecificSummaryTemplate': 'Sektörünüz için tasarlanmış profesyonel özet şablonları',
'markFavorite': 'Favorilere ekle',
'monthlyTranscriptionDuration': 'Ayda 300 dakika deşifre süresi',
'noDevicesFound': 'Cihaz bulunamadı',
'notBound': 'Eşlenmedi',
'notCharging': 'Şarj olmuyor',
'pauseRecording': 'Kaydı duraklat',
'pleaseEnterVerificationCode': 'Lütfen doğrulama kodunu girin',
'scanIdle': 'Taranmıyor',
'scanning': 'Taranıyor...',
'startScan': 'Taramayı başlat',
'stopScan': 'Taramayı durdur',
'storageBytes': 'Toplam depolama',
'storageUploads': 'Yüklemeler',
'unmarkFavorite': 'Favorilerden çıkar',
'unnamedDevice': 'Adsız cihaz',
'userDataParseFailed': 'Kullanıcı verileri işlenemedi',
}; };

56
apps/client/lib/core/translations/language/uk_ua.dart

@ -1803,4 +1803,60 @@ const Map<String, String> ukUA = {
'idVerifyTooFrequent': 'Too many attempts. Please try again later.', 'idVerifyTooFrequent': 'Too many attempts. Please try again later.',
'idVerifyFailed': 'Verification failed. Please try again later.', 'idVerifyFailed': 'Verification failed. Please try again later.',
'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed', 'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed',
// ---- 恒玄(BES)固件 OTA 升级页 ----
'otaAlreadyLatest': 'Установлено найновішу версію',
'otaCurrentVersion': 'Поточна версія',
'otaDone': 'Готово',
'otaDownloading': 'Завантаження прошивки...',
'otaError': 'Помилка',
'otaExitConfirm': 'Триває оновлення. Ви справді хочете вийти?',
'otaExitTitle': 'Вийти з оновлення',
'otaFailed': 'Помилка оновлення',
'otaFailedWithCode': 'Помилка оновлення (код @code)',
'otaKeepConnectedWarning': 'Тримайте пристрій поруч і не закривайте застосунок під час оновлення.',
'otaLatestVersion': 'Найновіша версія',
'otaNeedConnect': 'Спершу підключіть пристрій',
'otaNotConnectedTip': 'Підключений пристрій не знайдено. Підключіть навушники перед оновленням.',
'otaPreparing': 'Підготовка...',
'otaStartUpgrade': 'Почати оновлення',
'otaSuccessReboot': 'Оновлення виконано. Перезапустіть пристрій.',
'otaTimeout': 'Час оновлення вичерпано. Спробуйте ще раз.',
'otaUnknownDevice': 'Невідомий пристрій',
'otaUpdateAvailable': 'Доступне оновлення',
'otaUpgrading': 'Оновлення... @percent%',
'otaUpgradingShort': 'Оновлення...',
'otaDownloadPreparing': 'Підготовка до завантаження прошивки...',
'otaFirmwareCached': 'Прошивка готова, підготовка до оновлення...',
'otaDownloadStarted': 'Завантаження прошивки почалося...',
'otaDownloadingPercent': 'Завантаження прошивки... @percent%',
'otaDownloadDone': 'Завантаження завершено, підготовка до оновлення...',
'addDevice': 'Додати пристрій',
'deviceConnectionRestricted': 'Підключення пристрою обмежено: пристрій не авторизовано',
// --- 2026-09-03 补齐:这些 key 代码里在用,但 41 个语言文件里都没有 ---
'autopilotTemplateSummary': 'Автоматичні підсумки за шаблонами Autopilot',
'bindImmediately': "Зв'язати зараз",
'disconnect': 'Відключити',
'discoveredDevices': 'Знайдені пристрої',
'distinguishDifferentSpeakers': 'Розрізнення мовців',
'enterFileName': "Введіть ім'я файлу",
'findMore': 'Дізнатися більше',
'finishRecording': 'Завершити запис',
'importAudioFromAppWeb': 'Імпортуйте аудіо з застосунку або вебу для розшифрування та підсумку',
'industrySpecificSummaryTemplate': 'Професійні шаблони підсумків для вашої галузі',
'markFavorite': 'Додати до обраного',
'monthlyTranscriptionDuration': '300 хвилин розшифрування щомісяця',
'noDevicesFound': 'Пристроїв не знайдено',
'notBound': "Не зв'язано",
'notCharging': 'Не заряджається',
'pauseRecording': 'Призупинити запис',
'pleaseEnterVerificationCode': 'Введіть код підтвердження',
'scanIdle': 'Пошук не виконується',
'scanning': 'Пошук...',
'startScan': 'Почати пошук',
'stopScan': 'Зупинити пошук',
'storageBytes': 'Загальний обсяг сховища',
'storageUploads': 'Завантаження',
'unmarkFavorite': 'Прибрати з обраного',
'unnamedDevice': 'Пристрій без назви',
'userDataParseFailed': 'Не вдалося обробити дані користувача',
}; };

56
apps/client/lib/core/translations/language/vi_vn.dart

@ -1763,4 +1763,60 @@ const Map<String, String> viVN = {
'idVerifyTooFrequent': 'Too many attempts. Please try again later.', 'idVerifyTooFrequent': 'Too many attempts. Please try again later.',
'idVerifyFailed': 'Verification failed. Please try again later.', 'idVerifyFailed': 'Verification failed. Please try again later.',
'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed', 'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed',
// ---- 恒玄(BES)固件 OTA 升级页 ----
'otaAlreadyLatest': 'Đã là phiên bản mới nhất',
'otaCurrentVersion': 'Phiên bản hiện tại',
'otaDone': 'Xong',
'otaDownloading': 'Đang tải phần mềm...',
'otaError': 'Lỗi',
'otaExitConfirm': 'Đang nâng cấp. Bạn có chắc muốn thoát không?',
'otaExitTitle': 'Thoát nâng cấp',
'otaFailed': 'Nâng cấp thất bại',
'otaFailedWithCode': 'Nâng cấp thất bại (mã @code)',
'otaKeepConnectedWarning': 'Giữ thiết bị ở gần và không đóng ứng dụng trong khi nâng cấp.',
'otaLatestVersion': 'Phiên bản mới nhất',
'otaNeedConnect': 'Vui lòng kết nối thiết bị trước',
'otaNotConnectedTip': 'Không phát hiện thiết bị đã kết nối. Hãy kết nối tai nghe trước khi nâng cấp.',
'otaPreparing': 'Đang chuẩn bị...',
'otaStartUpgrade': 'Bắt đầu nâng cấp',
'otaSuccessReboot': 'Nâng cấp thành công. Vui lòng khởi động lại thiết bị.',
'otaTimeout': 'Nâng cấp quá thời gian. Vui lòng thử lại.',
'otaUnknownDevice': 'Thiết bị không xác định',
'otaUpdateAvailable': 'Có bản cập nhật',
'otaUpgrading': 'Đang nâng cấp... @percent%',
'otaUpgradingShort': 'Đang nâng cấp...',
'otaDownloadPreparing': 'Đang chuẩn bị tải phần mềm...',
'otaFirmwareCached': 'Phần mềm đã sẵn sàng, đang chuẩn bị nâng cấp...',
'otaDownloadStarted': 'Bắt đầu tải phần mềm...',
'otaDownloadingPercent': 'Đang tải phần mềm... @percent%',
'otaDownloadDone': 'Tải xong, đang chuẩn bị nâng cấp...',
'addDevice': 'Thêm thiết bị',
'deviceConnectionRestricted': 'Kết nối thiết bị bị hạn chế: thiết bị này chưa được ủy quyền',
// --- 2026-09-03 补齐:这些 key 代码里在用,但 41 个语言文件里都没有 ---
'autopilotTemplateSummary': 'Tự động tóm tắt bằng mẫu Autopilot',
'bindImmediately': 'Liên kết ngay',
'disconnect': 'Ngắt kết nối',
'discoveredDevices': 'Thiết bị đã tìm thấy',
'distinguishDifferentSpeakers': 'Phân biệt người nói',
'enterFileName': 'Nhập tên tệp',
'findMore': 'Khám phá thêm',
'finishRecording': 'Kết thúc ghi âm',
'importAudioFromAppWeb': 'Nhập âm thanh từ ứng dụng hoặc web để chuyển thành văn bản và tóm tắt',
'industrySpecificSummaryTemplate': 'Mẫu tóm tắt chuyên nghiệp cho ngành của bạn',
'markFavorite': 'Thêm vào yêu thích',
'monthlyTranscriptionDuration': '300 phút chuyển lời nói thành văn bản mỗi tháng',
'noDevicesFound': 'Không tìm thấy thiết bị',
'notBound': 'Chưa liên kết',
'notCharging': 'Không sạc',
'pauseRecording': 'Tạm dừng ghi âm',
'pleaseEnterVerificationCode': 'Vui lòng nhập mã xác minh',
'scanIdle': 'Chưa quét',
'scanning': 'Đang quét...',
'startScan': 'Bắt đầu quét',
'stopScan': 'Dừng quét',
'storageBytes': 'Tổng dung lượng',
'storageUploads': 'Lượt tải lên',
'unmarkFavorite': 'Bỏ khỏi yêu thích',
'unnamedDevice': 'Thiết bị chưa đặt tên',
'userDataParseFailed': 'Không đọc được dữ liệu người dùng',
}; };

59
apps/client/lib/core/translations/language/zh_cn.dart

@ -1386,6 +1386,7 @@ const Map<String, String> zhCN = {
'otaUpgradeSuccessComplete': '升级成功完成', 'otaUpgradeSuccessComplete': '升级成功完成',
'otaUpgradeFailedWithCode': '升级失败,错误码:@code', 'otaUpgradeFailedWithCode': '升级失败,错误码:@code',
'otaUpgradeFailedTitle': '升级失败', 'otaUpgradeFailedTitle': '升级失败',
'otaBesNotSupported': '该耳机暂不支持在 App 内升级固件,请联系客服获取升级方式',
'otaUpgradeFailedDesc': '请检查设备连接或升级包,错误码:@code', 'otaUpgradeFailedDesc': '请检查设备连接或升级包,错误码:@code',
'otaDeviceDisconnected': '设备连接断开', 'otaDeviceDisconnected': '设备连接断开',
'otaDeviceConnected': '设备连接成功', 'otaDeviceConnected': '设备连接成功',
@ -1594,7 +1595,7 @@ const Map<String, String> zhCN = {
// ==== 界面文案补齐(自启动授权 / 设备页 / AI页 / 广场 等)==== // ==== 界面文案补齐(自启动授权 / 设备页 / AI页 / 广场 等)====
'tabDevice': '设备', 'tabDevice': '设备',
'tabTodo': '代办', 'tabTodo': '拾忆',
'tabPlaza': '广场', 'tabPlaza': '广场',
'sloganHome': '科技引领 智能人生', 'sloganHome': '科技引领 智能人生',
'sloganAi': '智能无限 无所不能', 'sloganAi': '智能无限 无所不能',
@ -1851,4 +1852,60 @@ const Map<String, String> zhCN = {
'idVerifyTooFrequent': '尝试过于频繁,请稍后再试', 'idVerifyTooFrequent': '尝试过于频繁,请稍后再试',
'idVerifyFailed': '实名认证失败,请稍后再试', 'idVerifyFailed': '实名认证失败,请稍后再试',
'genderLockedByIdVerify': '性别已根据实名认证信息确定,不可修改', 'genderLockedByIdVerify': '性别已根据实名认证信息确定,不可修改',
// ---- 恒玄(BES)固件 OTA 升级页 ----
'otaAlreadyLatest': '已是最新版本',
'otaCurrentVersion': '当前版本',
'otaDone': '完成',
'otaDownloading': '正在下载固件...',
'otaError': '错误',
'otaExitConfirm': '升级正在进行中,确定要退出吗?',
'otaExitTitle': '退出升级',
'otaFailed': '升级失败',
'otaFailedWithCode': '升级失败(错误码 @code)',
'otaKeepConnectedWarning': '升级过程中请保持设备靠近并勿关闭应用',
'otaLatestVersion': '最新版本',
'otaNeedConnect': '请先连接设备',
'otaNotConnectedTip': '未检测到已连接的设备,请先连接耳机后再升级固件',
'otaPreparing': '准备中...',
'otaStartUpgrade': '开始升级',
'otaSuccessReboot': '升级成功,请重启设备',
'otaTimeout': '升级超时,请重试',
'otaUnknownDevice': '未知设备',
'otaUpdateAvailable': '有可用更新',
'otaUpgrading': '升级中... @percent%',
'otaUpgradingShort': '正在升级...',
'otaDownloadPreparing': '准备下载固件包...',
'otaFirmwareCached': '固件已就绪,准备升级...',
'otaDownloadStarted': '开始下载固件包...',
'otaDownloadingPercent': '正在下载固件包... @percent%',
'otaDownloadDone': '下载完成,准备升级...',
'deviceConnectionRestricted': '设备连接受限,该设备未获授权',
// --- 2026-09-03 补齐:这些 key 代码里在用,但 41 个语言文件里都没有 ---
'autopilotTemplateSummary': 'Autopilot 模版自动总结',
'bindImmediately': '立即绑定',
'disconnect': '断开连接',
'discoveredDevices': '发现的设备',
'distinguishDifferentSpeakers': '区分不同说话人',
'enterFileName': '请输入文件名',
'findMore': '发现更多',
'finishRecording': '结束录音',
'importAudioFromAppWeb': '从 APP、WEB 导入音频进行转写和总结',
'industrySpecificSummaryTemplate': '为行业细分场景设计的专业总结模版',
'markFavorite': '标记收藏',
'monthlyTranscriptionDuration': '每月 300 分钟转写时长',
'noDevicesFound': '未找到设备',
'notBound': '未绑定',
'notCharging': '未充电',
'pauseRecording': '暂停录音',
'pleaseEnterVerificationCode': '请输入验证码',
'scanIdle': '未在扫描',
'scanning': '正在扫描...',
'startScan': '开始扫描',
'stopScan': '停止扫描',
'storageBytes': '存储总大小',
'storageUploads': '上传次数',
'unmarkFavorite': '取消收藏',
'unnamedDevice': '未命名设备',
'userDataParseFailed': '用户数据解析失败',
}; };

57
apps/client/lib/core/translations/language/zh_hk.dart

@ -1570,7 +1570,7 @@ const Map<String, String> zhHK = {
// ==== 界面文案补齐(自启动授权 / 设备页 / AI页 / 广场 等)==== // ==== 界面文案补齐(自启动授权 / 设备页 / AI页 / 广场 等)====
'tabDevice': '裝置', 'tabDevice': '裝置',
'tabTodo': '待辦', 'tabTodo': '拾憶',
'tabPlaza': '廣場', 'tabPlaza': '廣場',
'sloganHome': '科技引領 智能生活', 'sloganHome': '科技引領 智能生活',
'sloganAi': '智能無限 無所不能', 'sloganAi': '智能無限 無所不能',
@ -1836,4 +1836,59 @@ const Map<String, String> zhHK = {
'idVerifyTooFrequent': '尝试过于频繁,请稍后再试', 'idVerifyTooFrequent': '尝试过于频繁,请稍后再试',
'idVerifyFailed': '实名认证失败,请稍后再试', 'idVerifyFailed': '实名认证失败,请稍后再试',
'genderLockedByIdVerify': '性别已根据实名认证信息确定,不可修改', 'genderLockedByIdVerify': '性别已根据实名认证信息确定,不可修改',
// ---- 恒玄(BES)固件 OTA 升级页 ----
'otaAlreadyLatest': '已是最新版本',
'otaCurrentVersion': '目前版本',
'otaDone': '完成',
'otaDownloading': '正在下載韌體...',
'otaError': '錯誤',
'otaExitConfirm': '升級正在進行中,確定要離開嗎?',
'otaExitTitle': '離開升級',
'otaFailed': '升級失敗',
'otaFailedWithCode': '升級失敗(錯誤碼 @code)',
'otaKeepConnectedWarning': '升級過程中請保持裝置靠近並勿關閉應用程式',
'otaLatestVersion': '最新版本',
'otaNeedConnect': '請先連接裝置',
'otaNotConnectedTip': '未偵測到已連接的裝置,請先連接耳機後再升級韌體',
'otaPreparing': '準備中...',
'otaStartUpgrade': '開始升級',
'otaSuccessReboot': '升級成功,請重新啟動裝置',
'otaTimeout': '升級逾時,請重試',
'otaUnknownDevice': '未知裝置',
'otaUpdateAvailable': '有可用更新',
'otaUpgrading': '升級中... @percent%',
'otaUpgradingShort': '正在升級...',
'otaDownloadPreparing': '準備下載韌體檔...',
'otaFirmwareCached': '韌體已就緒,準備升級...',
'otaDownloadStarted': '開始下載韌體檔...',
'otaDownloadingPercent': '正在下載韌體檔... @percent%',
'otaDownloadDone': '下載完成,準備升級...',
'deviceConnectionRestricted': '裝置連接受限,此裝置未獲授權',
// --- 2026-09-03 补齐:这些 key 代码里在用,但 41 个语言文件里都没有 ---
'autopilotTemplateSummary': 'Autopilot 範本自動總結',
'bindImmediately': '立即綁定',
'disconnect': '中斷連線',
'discoveredDevices': '發現的裝置',
'distinguishDifferentSpeakers': '區分不同說話人',
'enterFileName': '請輸入檔案名稱',
'findMore': '發現更多',
'finishRecording': '結束錄音',
'importAudioFromAppWeb': '從 APP、WEB 匯入音訊進行轉寫和總結',
'industrySpecificSummaryTemplate': '為產業細分場景設計的專業總結範本',
'markFavorite': '標記收藏',
'monthlyTranscriptionDuration': '每月 300 分鐘轉寫時長',
'noDevicesFound': '未找到裝置',
'notBound': '未綁定',
'notCharging': '未充電',
'pauseRecording': '暫停錄音',
'pleaseEnterVerificationCode': '請輸入驗證碼',
'scanIdle': '未在掃描',
'scanning': '正在掃描...',
'startScan': '開始掃描',
'stopScan': '停止掃描',
'storageBytes': '儲存總大小',
'storageUploads': '上傳次數',
'unmarkFavorite': '取消收藏',
'unnamedDevice': '未命名裝置',
'userDataParseFailed': '使用者資料解析失敗',
}; };

57
apps/client/lib/core/translations/language/zh_tw.dart

@ -1570,7 +1570,7 @@ const Map<String, String> zhTW = {
// ==== 界面文案补齐(自启动授权 / 设备页 / AI页 / 广场 等)==== // ==== 界面文案补齐(自启动授权 / 设备页 / AI页 / 广场 等)====
'tabDevice': '裝置', 'tabDevice': '裝置',
'tabTodo': '待辦', 'tabTodo': '拾憶',
'tabPlaza': '廣場', 'tabPlaza': '廣場',
'sloganHome': '科技引領 智慧生活', 'sloganHome': '科技引領 智慧生活',
'sloganAi': '智慧無限 無所不能', 'sloganAi': '智慧無限 無所不能',
@ -1836,4 +1836,59 @@ const Map<String, String> zhTW = {
'idVerifyTooFrequent': '尝试过于频繁,请稍后再试', 'idVerifyTooFrequent': '尝试过于频繁,请稍后再试',
'idVerifyFailed': '实名认证失败,请稍后再试', 'idVerifyFailed': '实名认证失败,请稍后再试',
'genderLockedByIdVerify': '性别已根据实名认证信息确定,不可修改', 'genderLockedByIdVerify': '性别已根据实名认证信息确定,不可修改',
// ---- 恒玄(BES)固件 OTA 升级页 ----
'otaAlreadyLatest': '已是最新版本',
'otaCurrentVersion': '目前版本',
'otaDone': '完成',
'otaDownloading': '正在下載韌體...',
'otaError': '錯誤',
'otaExitConfirm': '升級正在進行中,確定要離開嗎?',
'otaExitTitle': '離開升級',
'otaFailed': '升級失敗',
'otaFailedWithCode': '升級失敗(錯誤碼 @code)',
'otaKeepConnectedWarning': '升級過程中請保持裝置靠近並勿關閉應用程式',
'otaLatestVersion': '最新版本',
'otaNeedConnect': '請先連接裝置',
'otaNotConnectedTip': '未偵測到已連接的裝置,請先連接耳機後再升級韌體',
'otaPreparing': '準備中...',
'otaStartUpgrade': '開始升級',
'otaSuccessReboot': '升級成功,請重新啟動裝置',
'otaTimeout': '升級逾時,請重試',
'otaUnknownDevice': '未知裝置',
'otaUpdateAvailable': '有可用更新',
'otaUpgrading': '升級中... @percent%',
'otaUpgradingShort': '正在升級...',
'otaDownloadPreparing': '準備下載韌體檔...',
'otaFirmwareCached': '韌體已就緒,準備升級...',
'otaDownloadStarted': '開始下載韌體檔...',
'otaDownloadingPercent': '正在下載韌體檔... @percent%',
'otaDownloadDone': '下載完成,準備升級...',
'deviceConnectionRestricted': '裝置連線受限,此裝置未獲授權',
// --- 2026-09-03 补齐:这些 key 代码里在用,但 41 个语言文件里都没有 ---
'autopilotTemplateSummary': 'Autopilot 範本自動總結',
'bindImmediately': '立即綁定',
'disconnect': '中斷連線',
'discoveredDevices': '發現的裝置',
'distinguishDifferentSpeakers': '區分不同說話人',
'enterFileName': '請輸入檔案名稱',
'findMore': '發現更多',
'finishRecording': '結束錄音',
'importAudioFromAppWeb': '從 APP、WEB 匯入音訊進行轉寫和總結',
'industrySpecificSummaryTemplate': '為產業細分場景設計的專業總結範本',
'markFavorite': '標記收藏',
'monthlyTranscriptionDuration': '每月 300 分鐘轉寫時長',
'noDevicesFound': '未找到裝置',
'notBound': '未綁定',
'notCharging': '未充電',
'pauseRecording': '暫停錄音',
'pleaseEnterVerificationCode': '請輸入驗證碼',
'scanIdle': '未在掃描',
'scanning': '正在掃描...',
'startScan': '開始掃描',
'stopScan': '停止掃描',
'storageBytes': '儲存總大小',
'storageUploads': '上傳次數',
'unmarkFavorite': '取消收藏',
'unnamedDevice': '未命名裝置',
'userDataParseFailed': '使用者資料解析失敗',
}; };

18
apps/client/lib/core/utils/id_verify_guard.dart

@ -1,3 +1,4 @@
import 'package:flutter/widgets.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import '../../data/models/user_Info.dart'; import '../../data/models/user_Info.dart';
@ -25,6 +26,23 @@ class IdVerifyGuard {
return !svc.verified.value; return !svc.verified.value;
} }
/// [required] 的**响应式**版本,给「未认证」角标这类要跟着状态走的 UI 用。
///
/// 直接在 build 里读 [required] 是不会重建的 —— 它读的确实是 Rx,但没被 Obx
/// 收集,于是认证通过后角标要等下次进页面才消失。这里统一封装成 builder:
/// - 无条件先把两个 Rx 读出来再判断,早退会漏收集依赖;
/// - IdVerifyService 尚未注册时**不建** Obx —— Obx 里一个 Rx 都没读到会抛
/// 「improper use of a GetX」。正常情况下它在 InitialBinding 里是 permanent 的。
static Widget builder(Widget Function(bool needVerify) build) {
if (!Get.isRegistered<IdVerifyService>()) return build(false);
return Obx(() {
final svc = IdVerifyService.to;
final loaded = svc.loaded.value;
final verified = svc.verified.value;
return build(User.isLoggedIn() && loaded && !verified);
});
}
/// 拦截入口:需要实名时弹认证窗并返回 true(调用方直接 return)。 /// 拦截入口:需要实名时弹认证窗并返回 true(调用方直接 return)。
/// ///
/// 弹窗里认证通过后不会自动重跑原动作 —— 调用方 return 之后, /// 弹窗里认证通过后不会自动重跑原动作 —— 调用方 return 之后,

33
apps/client/lib/data/models/appconfig.dart

@ -31,9 +31,16 @@ class AppConfig {
} }
//智能体配置 //智能体配置
///
/// 找不到该 agentId 时返回 null。**不要改回 `firstWhere`**——它没有 orElse,
/// 查不到时抛 StateError 而不是返回 null,调用方(ble_manager 的
/// startAgentService)那句 try 会把整个智能体服务的启动一起吞掉。
static String? agentsystemPrompt(String agentId) { static String? agentsystemPrompt(String agentId) {
final foundAgent = config.agents.firstWhere((agent) => agent.id == agentId); if (!_isInitialized) return null;
return foundAgent.systemPrompt; for (final agent in config.agents) {
if (agent.id == agentId) return agent.systemPrompt;
}
return null;
} }
static List<String> allproductNames() { static List<String> allproductNames() {
@ -47,10 +54,24 @@ class AppConfig {
return uuids; return uuids;
} }
//根据产品名称获取产品配置 /// 根据产品名称获取产品配置,查不到返回 null。
///
/// ⚠️ **不要改回 `firstWhere`**:它没有 orElse,查不到时抛 StateError 而不是
/// 返回 null。所有调用点写的都是 `if (product != null)`,那个 null 分支于是
/// 永远等不到,异常被外层 try 吞掉后直接走兜底——设备类型退化成 unknown、
/// 通话翻译的 broadcastpeertranslate 恒为 false,且全程不报任何错。
///
/// 未初始化(`user_getappconfig` 还没回来)时同样返回 null,而不是让
/// `late config` 抛 LateInitializationError——否则 null 契约仍然不成立。
///
/// 空设备名也返回 null:`devicename.contains('')` 恒为 true,原实现会把
/// **产品表里的第一个产品**当成匹配结果返回。调用点本来就都在紧接着判
/// `deviceName != ''`,没有一处依赖这个行为。
static DBProduct? getproduct(String name) { static DBProduct? getproduct(String name) {
final foundProduct = config.products if (!_isInitialized || name.isEmpty) return null;
.firstWhere((product) => product.devicename.contains(name)); for (final product in config.products) {
return foundProduct; if (product.devicename.contains(name)) return product;
}
return null;
} }
} }

10
apps/client/lib/data/models/appconfig_model.dart

@ -82,6 +82,15 @@ class DBProduct {
final String scanuuid; final String scanuuid;
final String? version; final String? version;
bool? isforceupdate; bool? isforceupdate;
/// 版本不一致即可升级(后台「产品 → 版本管理」里的开关,默认关)。
///
/// 开发/测试环境用:测试机固件常常比后台挂的还新,按「只允许升到更高版本」
/// 会永远点不动升级按钮,也没法刷回旧固件反复验证 OTA。
/// ⚠️ 可空且默认按 false 处理——老服务端不下发这个字段时必须退回严格判定,
/// 绝不能反过来(那会让线上用户被降级)。
final bool? updateonmismatch;
final bool? iscontinuouschat; final bool? iscontinuouschat;
final bool? wakeupenable; //该产品是否拥有唤醒开关控制 final bool? wakeupenable; //该产品是否拥有唤醒开关控制
final bool? broadcastpeertranslate; //通话翻译是否本地播报对方翻译内容 final bool? broadcastpeertranslate; //通话翻译是否本地播报对方翻译内容
@ -97,6 +106,7 @@ class DBProduct {
required this.scanuuid, required this.scanuuid,
required this.version, required this.version,
required this.isforceupdate, //该产品固件版本是否强制更新 required this.isforceupdate, //该产品固件版本是否强制更新
this.updateonmismatch, //可选:老服务端不下发这个字段,缺省即 null→按 false
required this.iscontinuouschat, required this.iscontinuouschat,
required this.wakeupenable, required this.wakeupenable,
required this.broadcastpeertranslate, required this.broadcastpeertranslate,

2
apps/client/lib/data/models/appconfig_model.g.dart

@ -82,6 +82,7 @@ DBProduct _$DBProductFromJson(Map<String, dynamic> json) => DBProduct(
scanuuid: json['scanuuid'] as String, scanuuid: json['scanuuid'] as String,
version: json['version'] as String?, version: json['version'] as String?,
isforceupdate: json['isforceupdate'] as bool?, isforceupdate: json['isforceupdate'] as bool?,
updateonmismatch: json['updateonmismatch'] as bool?,
iscontinuouschat: json['iscontinuouschat'] as bool?, iscontinuouschat: json['iscontinuouschat'] as bool?,
wakeupenable: json['wakeupenable'] as bool?, wakeupenable: json['wakeupenable'] as bool?,
broadcastpeertranslate: json['broadcastpeertranslate'] as bool?, broadcastpeertranslate: json['broadcastpeertranslate'] as bool?,
@ -98,6 +99,7 @@ Map<String, dynamic> _$DBProductToJson(DBProduct instance) => <String, dynamic>{
'scanuuid': instance.scanuuid, 'scanuuid': instance.scanuuid,
'version': instance.version, 'version': instance.version,
'isforceupdate': instance.isforceupdate, 'isforceupdate': instance.isforceupdate,
'updateonmismatch': instance.updateonmismatch,
'iscontinuouschat': instance.iscontinuouschat, 'iscontinuouschat': instance.iscontinuouschat,
'wakeupenable': instance.wakeupenable, 'wakeupenable': instance.wakeupenable,
'broadcastpeertranslate': instance.broadcastpeertranslate, 'broadcastpeertranslate': instance.broadcastpeertranslate,

25
apps/client/lib/data/models/channel_app_model.dart

@ -81,10 +81,25 @@ class AppInfo {
@JsonKey(name: 'allowskipdevicebinding') @JsonKey(name: 'allowskipdevicebinding')
final bool allowskipdevicebinding; final bool allowskipdevicebinding;
/// 支付渠道列表 /// 支付渠道列表。
@JsonKey(name: 'Paychannels') ///
/// 服务端的 json tag 是全小写的 `paychannels`(见 db.proto 的 DBChannelApp),
/// 这里两种写法都认一下:早先只认大写 `Paychannels`,实际永远解析不到。
@JsonKey(name: 'paychannels', readValue: _readPaychannels)
final String? paychannels; final String? paychannels;
/// 最低支持版本,低于它必须更新。空 = 后台没配,不参与判定。
@JsonKey(name: 'minversion')
final String? minversion;
/// 强制更新开关,配合 [minversion] 使用。
///
/// **强更判定的唯一依据就是这两个字段**(后台「渠道分发」里配)。
/// 别再拿「主版本号第一位不同」之类的土规则去猜 —— 那和后台配置完全脱节,
/// 运营勾了强更也拦不住人。
@JsonKey(name: 'forceupdate')
final bool forceupdate;
const AppInfo({ const AppInfo({
this.address, this.address,
required this.channel, required this.channel,
@ -94,6 +109,8 @@ class AppInfo {
this.touristsversion, this.touristsversion,
this.allowskipdevicebinding = false, this.allowskipdevicebinding = false,
required this.paychannels, required this.paychannels,
this.minversion,
this.forceupdate = false,
}); });
factory AppInfo.fromJson(Map<String, dynamic> json) => factory AppInfo.fromJson(Map<String, dynamic> json) =>
@ -105,6 +122,10 @@ class AppInfo {
// 辅助函数:将 UserChannel 转换为 JSON // 辅助函数:将 UserChannel 转换为 JSON
int _channelToJson(UserChannel channel) => channel.value; int _channelToJson(UserChannel channel) => channel.value;
// 兼容历史大写键名的读取器:先按服务端真实的小写键取,取不到再退回大写。
Object? _readPaychannels(Map json, String key) =>
json[key] ?? json['Paychannels'];
/// 版本更新信息模型 /// 版本更新信息模型
class VersionUpdateInfo { class VersionUpdateInfo {
/// 是否有新版本 /// 是否有新版本

8
apps/client/lib/data/models/channel_app_model.g.dart

@ -40,7 +40,9 @@ AppInfo _$AppInfoFromJson(Map<String, dynamic> json) => AppInfo(
tourists: json['tourists'] as bool? ?? false, tourists: json['tourists'] as bool? ?? false,
touristsversion: json['touristsversion'] as String?, touristsversion: json['touristsversion'] as String?,
allowskipdevicebinding: json['allowskipdevicebinding'] as bool? ?? false, allowskipdevicebinding: json['allowskipdevicebinding'] as bool? ?? false,
paychannels: json['Paychannels'] as String?, paychannels: _readPaychannels(json, 'paychannels') as String?,
minversion: json['minversion'] as String?,
forceupdate: json['forceupdate'] as bool? ?? false,
); );
Map<String, dynamic> _$AppInfoToJson(AppInfo instance) => <String, dynamic>{ Map<String, dynamic> _$AppInfoToJson(AppInfo instance) => <String, dynamic>{
@ -51,5 +53,7 @@ Map<String, dynamic> _$AppInfoToJson(AppInfo instance) => <String, dynamic>{
'tourists': instance.tourists, 'tourists': instance.tourists,
'touristsversion': instance.touristsversion, 'touristsversion': instance.touristsversion,
'allowskipdevicebinding': instance.allowskipdevicebinding, 'allowskipdevicebinding': instance.allowskipdevicebinding,
'Paychannels': instance.paychannels, 'paychannels': instance.paychannels,
'minversion': instance.minversion,
'forceupdate': instance.forceupdate,
}; };

10
apps/client/lib/data/models/device_category.dart

@ -64,6 +64,12 @@ class DeviceCategories {
/// AI 耳机(恒玄方案)的实拍图 /// AI 耳机(恒玄方案)的实拍图
static const String earphoneImage = 'assets/images/devices/earphone.png'; static const String earphoneImage = 'assets/images/devices/earphone.png';
/// 同一张耳机实拍图的小尺寸版(111×144,25KB),用于列表/卡片里的小图标。
/// [earphoneImage] 是 892×1152 / 1.1MB 的主图,塞进 44px 的位置纯属浪费解码。
/// 换图时两张一起换:`sips -Z 144 earphone.png --out earphone_icon.png`
static const String earphoneIconImage =
'assets/images/devices/earphone_icon.png';
/// AI 手机支架(蓝讯 Echomeet)的实拍图 /// AI 手机支架(蓝讯 Echomeet)的实拍图
static const String phoneHolderImage = static const String phoneHolderImage =
'assets/images/devices/phone_holder.png'; 'assets/images/devices/phone_holder.png';
@ -76,7 +82,9 @@ class DeviceCategories {
if (name == null || name.isEmpty) return null; if (name == null || name.isEmpty) return null;
final n = name.toLowerCase(); final n = name.toLowerCase();
if (n.contains('echomeet')) return phoneHolderImage; if (n.contains('echomeet')) return phoneHolderImage;
if (n.contains('deepvoice') || n.contains('deepsound') || // 'echo-one' 要写全,不能只写 'echo'——上面 echomeet(手机支架)也含 echo
if (n.contains('echo-one') ||
n.contains('deepvoice') || n.contains('deepsound') ||
n.contains('voitrans') || n.contains('voitrans') ||
n.contains('buds') || n.contains('ai80') || n.contains('a80')) { n.contains('buds') || n.contains('ai80') || n.contains('a80')) {
return earphoneImage; return earphoneImage;

5
apps/client/lib/data/services/account_service.dart

@ -7,6 +7,7 @@ import '../services/network/api.dart';
import 'db/database_helper.dart'; import 'db/database_helper.dart';
import '../../data/models/user_Info.dart'; import '../../data/models/user_Info.dart';
import 'meeting/meeting_upload_service.dart'; import 'meeting/meeting_upload_service.dart';
import 'bes_device_auth.dart';
/// 账号管理服务 /// 账号管理服务
/// ///
@ -34,6 +35,9 @@ class AccountService extends GetxService {
await _storage.write("user_info", null); await _storage.write("user_info", null);
await _storage.write("local_avatar", null); await _storage.write("local_avatar", null);
User.clearUserSession(); User.clearUserSession();
// 换账号后同一台耳机要重新登记到新账号名下,
// 不清这个 set 的话「本进程已登记过」会把新账号的绑定吞掉。
BesDeviceAuth.reset();
logger.d('用户数据清理成功'); logger.d('用户数据清理成功');
return true; return true;
} catch (e) { } catch (e) {
@ -47,6 +51,7 @@ class AccountService extends GetxService {
try { try {
await _storage.erase(); await _storage.erase();
User.clearUserSession(); User.clearUserSession();
BesDeviceAuth.reset();
logger.d('所有用户数据清理成功'); logger.d('所有用户数据清理成功');
return true; return true;
} catch (e) { } catch (e) {

415
apps/client/lib/data/services/bes_bluetooth_service.dart

@ -4,12 +4,14 @@ import 'dart:typed_data';
import 'package:bluetooth_manager/bluetooth_manager.dart' as bes; import 'package:bluetooth_manager/bluetooth_manager.dart' as bes;
import 'package:flutter/foundation.dart' show kDebugMode;
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:get_storage/get_storage.dart'; import 'package:get_storage/get_storage.dart';
import '../../core/utils/logger.dart'; import '../../core/utils/logger.dart';
import 'agent_module_defaults.dart'; import 'agent_module_defaults.dart';
import 'background_launch_consent.dart'; import 'background_launch_consent.dart';
import 'bes_device_auth.dart';
/// 恒玄(BES/Bestechnic)芯片经典蓝牙连接服务。 /// 恒玄(BES/Bestechnic)芯片经典蓝牙连接服务。
/// ///
@ -48,9 +50,43 @@ class BesBluetoothService extends GetxService {
final RxInt batteryRight = (-1).obs; final RxInt batteryRight = (-1).obs;
final RxInt batteryCase = (-1).obs; final RxInt batteryCase = (-1).obs;
/// 耳机自报的蓝牙地址(`BB 0A` 的属性 7),格式 `11:11:22:33:33:A4`。
///
/// 与 [connectedDeviceId] 的区别:后者在 Android 上是蓝牙 MAC,在 iOS 上是
/// CoreBluetooth 的 peripheral UUID(每台 iPhone 看同一只耳机都不一样)。
/// 这个值来自耳机自己的上报,两端一致,是 iOS 上唯一能拿到真 MAC 的途径。
///
/// 每次连上时由 [_restoreDeviceMac] 按 [connectedDeviceId] 从 [_macCacheKey]
/// 恢复,查不到就是空。**它属于「当前这台」,不是全局值**——早先的写法是断开
/// 不清、一直留着,换耳机之后就变成上一台的 MAC,新耳机会拿它命中旧耳机的
/// 确权白名单蒙混过关。
final RxString deviceMac = ''.obs;
/// 充电中标记:电量字节的**最高位**就是充电指示位(协议 3.1.10),
/// 所以电量必须 `& 0x7F` 取值,直接用原字节会得到 128~228 的假电量。
final RxBool chargingLeft = false.obs;
final RxBool chargingRight = false.obs;
final RxBool chargingCase = false.obs;
static const String _pairedDevicesKey = 'bes_paired_devices'; static const String _pairedDevicesKey = 'bes_paired_devices';
static const String _lastDeviceKey = 'bes_last_device'; static const String _lastDeviceKey = 'bes_last_device';
/// `{本地设备标识: 耳机自报的 MAC}`,持久化。
///
/// 为什么不是「断开就清 [deviceMac]」:iOS 上 [resolveDeviceMac] 拿不到缓存时
/// 要发 `AA 09` 再轮询**最多 3 秒**,而它排在确权和握手的最前面——断开就清会让
/// **每次自动回连都多等最多 3 秒**;固件 0.0.3 上 `AA 09` 已经不回 `BB 0A`,
/// 那就是每次必然等满 3 秒才拿到 null。
///
/// 但也不能像原先那样「断开不清、全局留着」:换一台耳机之后那个值就是**上一台**
/// 的 MAC 了,iOS 的 [resolveDeviceMac] 第一行直接返回它,于是新耳机会命中旧耳机
/// 的确权白名单蒙混过关。
///
/// 按本地标识建映射同时满足两边:同一台回连是同步命中(零等待),
/// 换设备时标识不同、查不到,老老实实重查。iOS 的 peripheral UUID 对
/// 「同一台手机 + 同一只耳机」是稳定的,可长期复用。
static const String _macCacheKey = 'bes_device_mac_cache';
/// 设备端按键事件(type: startMicRecording / stopMicRecording / startAI …)。 /// 设备端按键事件(type: startMicRecording / stopMicRecording / startAI …)。
/// 只做转发,具体业务由订阅方决定;与支架的 [HolderRecordingService] 完全分开。 /// 只做转发,具体业务由订阅方决定;与支架的 [HolderRecordingService] 完全分开。
final RxString lastCmdType = ''.obs; final RxString lastCmdType = ''.obs;
@ -59,6 +95,12 @@ class BesBluetoothService extends GetxService {
String get lastDeviceName => String get lastDeviceName =>
(_storage.read<Map>(_lastDeviceKey)?['name'] as String?) ?? ''; (_storage.read<Map>(_lastDeviceKey)?['name'] as String?) ?? '';
/// ⚠️ **别拿它(或 [currentConnectedDevice])去当连接候选地址。**
/// 这两个值都只是把 App 自己拨过的地址回吐——插件里没有扫描/发现路径,
/// 地址的唯一来源是系统配对列表;`lastDeviceId` 只在原生 `connect()` 里赋值,
/// 这个 key 又来自 `savePairedDevice` 存的 `connectedDeviceId`。
/// 它们永远产生不了「新地址」,塞进候选只会把**上一台耳机**排到用户的选择前面。
/// 换耳机连不上就是这么来的,详见 bes_scan_view 的 `_connect`。
String get lastDeviceAddress => String get lastDeviceAddress =>
(_storage.read<Map>(_lastDeviceKey)?['address'] as String?) ?? ''; (_storage.read<Map>(_lastDeviceKey)?['address'] as String?) ?? '';
@ -134,19 +176,55 @@ class BesBluetoothService extends GetxService {
deviceStatus.value = event; deviceStatus.value = event;
Logger.i(_tag, '恒玄设备状态变化: $event'); Logger.i(_tag, '恒玄设备状态变化: $event');
if (event == 2) { if (event == 2) {
// 首次连上耳机 → AI 页铺开耳机的默认功能编排
AgentDefaults.onEarphoneConnected();
try { try {
if (connectedDeviceId.value.isEmpty) { // **无条件**向原生要当前真正连着的设备,不再以「本地值为空」为条件。
// 被动连接场景(原生 ACL 广播触发的自动回连)没人调过 connectToDevice, //
// 向原生查询当前已连接外设补齐 id/name。 // connectToDevice 是在调原生**之前**就乐观写了 connectedDeviceId 的,
final info = await _manager.getConnectedDevice(); // 那个值只代表「我们想连谁」。原生若把这次 connect 丢弃、或连上的是
// await 期间可能已经掉线,避免把陈旧信息写回 // 候选列表里的另一个地址,本地值就是错的;只在为空时才查询,会把这个
if (info != null && deviceStatus.value == 2) { // 错值一路带到确权——拿 B 的 MAC 去 binddevice(绑一台没连着的耳机),
connectedDeviceId.value = info['address'] ?? ''; // 被拒时 _reject 又按它判 isCurrent,把实际连着的那台断掉。
connectedDeviceName.value = info['name'] ?? ''; //
// 被动连接场景(ACL 广播 / .peerConnected)本来就靠这一步补齐 id/name,
// 改成无条件只是多一次 IPC,对那条路径没有影响。
final info = await _manager.getConnectedDevice();
// await 期间可能已经掉线,避免把陈旧信息写回
if (info != null && deviceStatus.value == 2) {
final realId = info['address'] ?? '';
if (realId.isNotEmpty) {
if (realId.toUpperCase() != connectedDeviceId.value.toUpperCase()) {
Logger.w(_tag,
'实际连上的设备与请求的不一致: 请求=${connectedDeviceId.value} 实际=$realId');
}
connectedDeviceId.value = realId;
} }
final realName = info['name'] ?? '';
if (realName.isNotEmpty) connectedDeviceName.value = realName;
}
// 身份确定之后再恢复这台设备的 MAC 缓存(见 _restoreDeviceMac)
_restoreDeviceMac();
// 设备确权闸门。必须排在握手和 AI 编排之前——未确权的设备不该被
// 当成一台可用耳机对待。
//
// 第一次连这台耳机才走服务端(按 MAC 校验),之后读本地白名单,
// 命中就是同步返回、不产生任何延迟。所以「首连慢几百毫秒」是
// 唯一的代价,回连和离线都不受影响。
//
// 只有 denied 会中止:unknown(断网/未登录/产品表没下发)一律放行,
// 理由见 BesDeviceAuth 的类注释。
final auth = await BesDeviceAuth.verifyConnected();
if (auth == BesAuthResult.denied) {
// 断开与提示已由 BesDeviceAuth 就地做完(此时 connectedDeviceId
// 已被清空,别再拿它打日志),这里只是不再往下走
Logger.w(_tag, '设备未通过确权,已断开连接');
return;
} }
if (deviceStatus.value != 2) return; // 确权期间掉线了
// 首次连上耳机 → AI 页铺开耳机的默认功能编排
AgentDefaults.onEarphoneConnected();
// 数据通道就绪后再发 0x06 获取固件版本 // 数据通道就绪后再发 0x06 获取固件版本
await Future.delayed(const Duration(milliseconds: 500)); await Future.delayed(const Duration(milliseconds: 500));
if (deviceStatus.value == 2) { if (deviceStatus.value == 2) {
@ -157,6 +235,15 @@ class BesBluetoothService extends GetxService {
await queryCallState(); await queryCallState();
// 命令探测改成手动触发(BesBluetoothService.to.probeCommands()), // 命令探测改成手动触发(BesBluetoothService.to.probeCommands()),
// 不再每次连上都扫——扫一轮要几十秒,日志也吵 // 不再每次连上都扫——扫一轮要几十秒,日志也吵
//
// TODO(临时): 固件 0.0.3 上 `AA 09` 不再回 `BB 0A`(电量帧),
// 之前那张命令表是在 0.0.2 上扫出来的,升级后失效了。
// 重扫 0x01~0x20 这一段——当初就是在这段里扫出 AA 09 的。
// 0x21~0x7F 已确认整段无回包(2026-09-01 扫过 69 个命令),不用再扫。
// **重新定位到电量命令后必须删掉这段**。
if (kDebugMode) {
unawaited(probeCommands(from: 0x01, to: 0x20));
}
} }
} catch (e) { } catch (e) {
Logger.e(_tag, '连接后握手失败(不致命): $e'); Logger.e(_tag, '连接后握手失败(不致命): $e');
@ -170,6 +257,9 @@ class BesBluetoothService extends GetxService {
batteryLeft.value = -1; batteryLeft.value = -1;
batteryRight.value = -1; batteryRight.value = -1;
batteryCase.value = -1; batteryCase.value = -1;
chargingLeft.value = false;
chargingRight.value = false;
chargingCase.value = false;
} }
}); });
@ -201,13 +291,16 @@ class BesBluetoothService extends GetxService {
/// 恒玄这套私有协议的电量命令没有文档,deepvoice 也没实现过,只能实测。 /// 恒玄这套私有协议的电量命令没有文档,deepvoice 也没实现过,只能实测。
/// 跳过已知会切换工作模式的命令(0x5x/0x6x 那批:进通话/录音/AI 模式), /// 跳过已知会切换工作模式的命令(0x5x/0x6x 那批:进通话/录音/AI 模式),
/// 只扫低位查询类命令。仅在 debug 包里调用。 /// 只扫低位查询类命令。仅在 debug 包里调用。
Future<void> probeCommands() async { Future<void> probeCommands({int from = 0x21, int to = 0x7F}) async {
const skip = {0x06, 0x0E, 0x51, 0x52, 0x56, 0x57, 0x58, 0x59, 0x5A, // 0x5x/0x6x 那批会切工作模式(进通话/录音/AI),一律不碰。
// 0x06(固件版本)/0x0E(通话状态) **故意不跳过**:重扫是为了确认新固件上
// 哪些命令还活着,跳过它们就失去了对照组。
const skip = {0x51, 0x52, 0x56, 0x57, 0x58, 0x59, 0x5A,
0x5E, 0x5F, 0x64, 0x65, 0x66, 0x67, 0x69, 0x6A}; 0x5E, 0x5F, 0x64, 0x65, 0x66, 0x67, 0x69, 0x6A};
// 第一轮 0x01~0x20 已扫过(拿到按键配置 0x8C、EQ 0x90 等), Logger.i(_tag,
// 这轮扫 0x21~0x50 和 0x6B~0x7F,跳过会切工作模式的 0x5x/0x6x '[探测] 开始 0x${from.toRadixString(16)}~0x${to.toRadixString(16)}');
for (var cmd = 0x21; cmd <= 0x7F; cmd++) { for (var cmd = from; cmd <= to; cmd++) {
if (cmd >= 0x51 && cmd <= 0x6A) continue; if (cmd >= 0x51 && cmd <= 0x6A) continue; // 会切工作模式,别碰
if (skip.contains(cmd)) continue; if (skip.contains(cmd)) continue;
if (deviceStatus.value != 2) return; if (deviceStatus.value != 2) return;
Logger.i(_tag, '[探测] 发送 AA ${cmd.toRadixString(16).padLeft(2, '0')}'); Logger.i(_tag, '[探测] 发送 AA ${cmd.toRadixString(16).padLeft(2, '0')}');
@ -219,6 +312,21 @@ class BesBluetoothService extends GetxService {
Logger.i(_tag, '[探测] 结束'); Logger.i(_tag, '[探测] 结束');
} }
/// 实测出来的命令号(2026-09-01,Echo-one 固件 0.0.2,`probeCommands` 扫 0x01~0x20 得到):
///
/// | 请求 | 回包 | 含义 |
/// |---|---|---|
/// | `AA 09` | `BB 0A` + `BB 89`(ack) | **耳机状态:左右耳电量 / 位置 / 连接状态 / MAC** |
/// | `AA 06` | `BB 86` | 固件版本 |
/// | `AA 0E` | `BB 8E` | 通话状态 |
/// | `AA 0B` | `BB 8B 04 00` | 未知,只回一个 0 |
/// | `AA 0D` | `BB 8D 07 00 02 01 02` | 未知 |
/// | `AA 0F` | `BB 8F 04 00` | 未知 |
/// | `AA 19` | `BB 99 04 01` | 未知,回 1 |
///
/// `BB 18` 是耳机周期主动上报的另一种状态帧(`02 01 01 02 02 00`,值 0/1),
/// **不是电量**,`AA 18` 也查不出东西。
/// 刷新耳机当前状态。 /// 刷新耳机当前状态。
/// ///
/// deepvoice 的做法:每次进入一个功能都重新拉一遍设备状态,而不是只在连接 /// deepvoice 的做法:每次进入一个功能都重新拉一遍设备状态,而不是只在连接
@ -227,6 +335,7 @@ class BesBluetoothService extends GetxService {
/// - `AA 06` 固件版本(回 0x86) /// - `AA 06` 固件版本(回 0x86)
/// - `AA 0C` 按键配置(回 0x8C) /// - `AA 0C` 按键配置(回 0x8C)
/// - `AA 10` EQ 配置(回 0x90) /// - `AA 10` EQ 配置(回 0x90)
/// - `AA 20` 耳机状态/电量(回 0x0A)
/// 回包各自在 [_handleRawFrame] 里解析并刷新对应的 Rx。 /// 回包各自在 [_handleRawFrame] 里解析并刷新对应的 Rx。
Future<void> refreshDeviceState() async { Future<void> refreshDeviceState() async {
if (!isConnected) return; if (!isConnected) return;
@ -237,6 +346,9 @@ class BesBluetoothService extends GetxService {
await Future.delayed(const Duration(milliseconds: 120)); await Future.delayed(const Duration(milliseconds: 120));
await sendData([0xAA, 0x10]); await sendData([0xAA, 0x10]);
await Future.delayed(const Duration(milliseconds: 120)); await Future.delayed(const Duration(milliseconds: 120));
// 电量会随时间掉,进功能页时重查一次,别只依赖连接那一刻的值
await queryBattery();
await Future.delayed(const Duration(milliseconds: 120));
await queryCallState(); await queryCallState();
Logger.i(_tag, '已刷新耳机状态'); Logger.i(_tag, '已刷新耳机状态');
} catch (e) { } catch (e) {
@ -244,14 +356,75 @@ class BesBluetoothService extends GetxService {
} }
} }
/// 查询电量。 /// 查询耳机状态(电量 / 是否在盒内 / 连接状态 / 蓝牙地址)。
/// ///
/// deepvoice 那边没实现过电量查询,恒玄这套私有协议里电量用哪个 CMD /// 协议 3.1.10 的状态帧:**APP 发 `AA 09`,耳机回 `BB 0A`**
/// 目前没有文档。这里按同族固件常见的 0x0A 试查一次;固件不认的话会静默 /// (状态变化时耳机也会主动推同一帧)。
/// 忽略,不影响其它功能。真实 CMD 一旦确认,改这一处即可。 ///
/// 请求码 `0x09` 是**真机实测**出来的(Echo-one 固件 0.0.2),不是文档给的:
/// 文档正文只写了「响应命令 20」且不带 `0x` 前缀,那个 20 既不是 `0x20`
/// 也不是十进制的 `0x14`——两个都发过,**完全无回包**。别再照文档改这里。
///
/// ⚠️ 请求码和响应码差 1(`09` → `0A`),不是本机其它命令那种
/// 「响应 = 请求 | 0x80」的规律(`AA 06`→`BB 86`、`AA 0E`→`BB 8E`)。
/// 按 `|0x80` 推出来的 `AA 0A` 是错的,早期版本就栽在这上面。
/// `AA 09` 另外还会附带一个 `BB 89 04 00` 的 ack,忽略即可。
/// 取这台耳机的真实蓝牙 MAC,拿不到返回 null。
///
/// Android 直接用 [connectedDeviceId](系统给的就是 MAC);iOS 上那是
/// peripheral UUID,只能靠耳机自报的 [deviceMac],而它要等一帧 `BB 0A`
/// 回来才有值——所以这里会主动发一次查询并等它到达。
///
/// [timeout] 内没等到就返回 null,调用方按「拿不到 MAC」处理,别死等。
Future<String?> resolveDeviceMac(
{Duration timeout = const Duration(seconds: 3)}) async {
if (Platform.isAndroid) {
final id = connectedDeviceId.value;
return id.isEmpty ? null : id.toUpperCase();
}
if (deviceMac.value.isNotEmpty) return deviceMac.value;
if (!isConnected) return null;
await queryBattery();
final deadline = DateTime.now().add(timeout);
while (DateTime.now().isBefore(deadline)) {
if (deviceMac.value.isNotEmpty) return deviceMac.value;
await Future.delayed(const Duration(milliseconds: 100));
if (!isConnected) return null;
}
return null;
}
/// 连上之后按本地设备标识恢复这台耳机的 MAC(见 [_macCacheKey])。
///
/// 必须在 [connectedDeviceId] 已经用原生真实值回填之后调用——用连接前写入的
/// 乐观值做键,等于换个方式串号。
void _restoreDeviceMac() {
final id = connectedDeviceId.value;
if (id.isEmpty) {
deviceMac.value = '';
return;
}
final cache = _storage.read<Map>(_macCacheKey);
final cached = cache?[id.toUpperCase()] as String?;
// 查不到就清空:这次连的是另一台设备,绝不能沿用上一台的 MAC
deviceMac.value = cached ?? '';
}
void _rememberDeviceMac(String mac) {
final id = connectedDeviceId.value;
if (id.isEmpty || mac.isEmpty) return;
final cache = <String, dynamic>{
...?_storage.read<Map>(_macCacheKey)?.cast<String, dynamic>(),
};
final key = id.toUpperCase();
if (cache[key] == mac) return;
cache[key] = mac;
_storage.write(_macCacheKey, cache);
}
Future<void> queryBattery() async { Future<void> queryBattery() async {
try { try {
await sendData([0xAA, 0x0A]); await sendData([0xAA, 0x09]);
} catch (e) { } catch (e) {
Logger.e(_tag, '查询电量失败: $e'); Logger.e(_tag, '查询电量失败: $e');
} }
@ -282,17 +455,24 @@ class BesBluetoothService extends GetxService {
_parseFirmwareVersion(data); _parseFirmwareVersion(data);
break; break;
case 0x0A: case 0x0A:
// 实测这帧是连接后周期性上报的遥测: // TODO(临时/抓帧): 找 iOS 能用的真 MAC。iOS 拿不到蓝牙 MAC(CoreBluetooth
// BB 0A 28 | 02 01 63 | 02 02 60 | 02 03 46 | 02 04 89 | 02 05 6E // 只给 peripheral UUID),设备页显示的「MAC」和设备确权都因此失效;
// | 0D 06 .. <本机MAC> .. <对端MAC> // 这帧的属性 6「预留」里嵌着 MAC,布局没实测过,先原样打出来分析。
// 值每秒都在变(99→97、70→73),还有 >100 的(137/110), // **布局确认后必须删掉这段**。
// 不像电量,更像链路质量/信号强度。先只记日志,不当电量显示, _dumpStatusFrame(data);
// 免得界面上跳一个会乱跳的假电量。固件确认后再决定怎么用。 // 耳机状态帧(协议 3.1.10):既是 `AA 20` 的响应,也会在状态变化时主动上报。
Logger.d(_tag, '设备遥测(0x0A) 原始=$data'); // BB 0A 28 | 02 01 2B | 02 02 18 | 02 03 64 | 02 04 85 | 02 05 6E
break; // | 0D 06 .. | 07 07 <6字节MAC>
case 0x8A: // 响应 0xAA 0x0A 查询电量(当前固件不响应) // 属性 1=左耳电量 2=右耳电量 3=盒电量 4=耳机位置 5=连接状态 6=预留 7=蓝牙地址。
// ⚠️ 早先把这帧判成「链路质量」只记日志,是因为解析偏移和属性映射都错了
//(见 _parseBattery 的注释),解出来的是错位垃圾值,才显得「每秒乱跳、还超过 100」。
// 真机实测(Echo-one 0.0.2)这帧解出 L=69% R=86%,末尾 MAC 与设备地址逐字节吻合。
_parseBattery(data); _parseBattery(data);
break; break;
case 0x89:
// `AA 09` 的 ack(实测 `BB 89 04 00`,00=成功)。真正的数据在 BB 0A 里,
// 这里只吞掉不处理,免得落到 default 分支刷「未识别回包」日志。
break;
case 0x18: case 0x18:
// 连接后周期性上报,结构是 [len,attr,value] × 2(attr 0x01左 / 0x02右)。 // 连接后周期性上报,结构是 [len,attr,value] × 2(attr 0x01左 / 0x02右)。
// 实测值恒为 1,不像百分比;可能是等级或入耳状态。 // 实测值恒为 1,不像百分比;可能是等级或入耳状态。
@ -300,46 +480,130 @@ class BesBluetoothService extends GetxService {
Logger.d(_tag, '设备状态上报(0x18) 原始=$data'); Logger.d(_tag, '设备状态上报(0x18) 原始=$data');
break; break;
default: default:
// 没识别的回包打出来,方便按真实固件补协议(电量 CMD 就靠这个定位) // 没识别的回包打出来,方便按真实固件补协议(电量 CMD 就靠这个定位)。
Logger.d(_tag, '未识别回包 CMD=0x${data[1].toRadixString(16)} 原始=$data'); // 用 INFO 级 + 十六进制:DEBUG 在某些构建里被过滤掉,而十进制的
// `[187, 134, 34, ...]` 对着协议文档看要一个个换算,很容易看错。
Logger.i(
_tag,
'[探测] 未识别回包 CMD=0x${data[1].toRadixString(16).padLeft(2, '0')} '
'原始=${data.map((e) => e.toRadixString(16).padLeft(2, '0').toUpperCase()).join(' ')}');
} }
} }
/// 电量回包:与固件版本同构 —— BB CMD len + 若干块 [len, 属性, 数据…], /// 耳机状态帧(`BB 0A`,协议 3.1.10)里的电量解析。
/// 属性 0x01左 / 0x02右 / 0x04盒,数据首字节为百分比。 ///
/// 收到超出 0~100 的值一律丢弃,宁可不显示也不显示错的。 /// 帧structure:`BB | 0A | CMD Length(1) | 块… | checksum`,
/// 块是 `[len, 属性, 数据…]`,**len 计的是「属性+数据」的字节数**
/// (`07 07 20 FF 36 6B A4 F3` = len 7 → 属性 1 + 6 字节 MAC)。
///
/// 属性:1=左耳电量 2=右耳电量 3=充电盒电量 4=耳机位置 5=连接状态 6=预留 7=蓝牙地址。
///
/// ⚠️ 这里踩过三个坑,都会让电量永远显示不出来,且**不报任何错**:
/// 1. **起点是 3 不是 4**。`_parseFirmwareVersion` 那帧的长度字段占 2 字节,
/// 照抄它的 `p = 4` 用在本帧上会整体错位一格——把属性号当成块长度,
/// 真实样例里第一个块长度读成 0x01 < 2 直接 break,一个值都解不出来。
/// 2. **充电盒是属性 0x03 不是 0x04**。0x04 是「耳机位置」,值经常 >100
/// (样例里 0x85),当电量读会被 0~100 的范围判丢掉。
/// 3. **电量字节最高位是充电指示位**,必须 `& 0x7F` 取百分比。
/// 直接用原字节,充电时会得到 128~228 的假电量。
void _parseBattery(List<int> data) { void _parseBattery(List<int> data) {
var p = 4; var p = 3; // 跳过 SOF + CMD + 1 字节长度
var hit = false; var hit = false;
while (p + 1 < data.length) { while (p + 2 < data.length) {
final blockLen = data[p]; final blockLen = data[p];
if (blockLen < 2 || p + blockLen >= data.length) break; if (blockLen < 2 || p + blockLen >= data.length) break;
final attr = data[p + 1]; final attr = data[p + 1];
final value = data[p + 2]; final raw = data[p + 2];
if (value >= 0 && value <= 100) { final level = raw & 0x7F; // 低 7 位是百分比
hit = true; final charging = (raw & 0x80) != 0; // 最高位是充电指示
if (level <= 100) {
switch (attr) { switch (attr) {
case 0x01: case 0x01:
batteryLeft.value = value; batteryLeft.value = level;
chargingLeft.value = charging;
hit = true;
break; break;
case 0x02: case 0x02:
batteryRight.value = value; batteryRight.value = level;
chargingRight.value = charging;
hit = true;
break; break;
case 0x04: case 0x03:
batteryCase.value = value; batteryCase.value = level;
chargingCase.value = charging;
hit = true;
break; break;
} }
} }
// 属性 7 = 耳机蓝牙地址(6 字节)。这是**耳机自己报上来的**真 MAC,
// 不经过系统蓝牙 API,所以 iOS 上也拿得到——iOS 的 connectedDeviceId
// 是 CoreBluetooth 的 peripheral UUID(每台手机看同一只耳机都不一样),
// 设备页显示 MAC 和设备确权都指望这个值。
// 实测(Echo-one 0.0.3)解出 11:11:22:33:33:A4,与 Android 上
// connectedDeviceId 逐字节一致,也正是后台 license 表登记的那个。
// ⚠️ 别改用属性 6:那 12 字节里虽然也嵌着一个 MAC
//(布局 `00 01 01 | <6字节> | 00 00 00`,实测 70:B3:B1:4D:33:DC),
// 但它既不是耳机主地址也不是手机地址,含义至今未知,拿去确权必然对不上。
if (attr == 0x07 && blockLen >= 7 && p + 7 < data.length) {
final mac = data
.sublist(p + 2, p + 8)
.map((e) => e.toRadixString(16).padLeft(2, '0').toUpperCase())
.join(':');
if (mac != deviceMac.value) {
deviceMac.value = mac;
Logger.i(_tag, '耳机上报蓝牙地址: $mac');
}
// 按本地设备标识记下来,下次连同一台时同步命中,不用再等一帧 BB 0A
_rememberDeviceMac(mac);
}
p += blockLen + 1; p += blockLen + 1;
} }
if (!hit) return; if (!hit) return;
final levels = [batteryLeft.value, batteryRight.value] final levels =
.where((v) => v >= 0) [batteryLeft.value, batteryRight.value].where((v) => v >= 0).toList();
.toList();
if (levels.isNotEmpty) { if (levels.isNotEmpty) {
// 左右耳取较低的一侧——用户关心的是「还能用多久」
batteryLevel.value = levels.reduce((a, b) => a < b ? a : b); batteryLevel.value = levels.reduce((a, b) => a < b ? a : b);
Logger.i(_tag, '恒玄耳机电量: ${batteryLevel.value}%' } else if (batteryCase.value >= 0) {
' (L=${batteryLeft.value} R=${batteryRight.value} 仓=${batteryCase.value})'); // 只有盒子上报时(两只耳机都在盒内)退而显示盒电量
batteryLevel.value = batteryCase.value;
}
Logger.i(
_tag,
'恒玄耳机电量: ${batteryLevel.value}%'
' (L=${batteryLeft.value}${chargingLeft.value ? "⚡" : ""}'
' R=${batteryRight.value}${chargingRight.value ? "⚡" : ""}'
' 仓=${batteryCase.value}${chargingCase.value ? "⚡" : ""})');
}
/// 【调试用/临时】把 `BB 0A` 状态帧逐块打出来,重点是属性 6 那 12 字节。
///
/// 目的:确认属性 6 里嵌的 MAC 到底是谁的(副耳?手机?),能不能拿来当
/// iOS 上的设备 MAC 用。布局定了就删掉这个方法和它的调用点。
void _dumpStatusFrame(List<int> data) {
String hex(Iterable<int> b) =>
b.map((e) => e.toRadixString(16).padLeft(2, '0').toUpperCase()).join(' ');
String mac(Iterable<int> b) =>
b.map((e) => e.toRadixString(16).padLeft(2, '0').toUpperCase()).join(':');
Logger.i(_tag, '[抓帧] BB0A 原始 = ${hex(data)}');
var p = 3; // 与 _parseBattery 同一起点:SOF + CMD + 1 字节长度
while (p + 1 < data.length) {
final blockLen = data[p];
if (blockLen < 2 || p + blockLen >= data.length) break;
final attr = data[p + 1];
final body = data.sublist(p + 2, p + 1 + blockLen);
final note = attr == 6
? ' ← 预留(找MAC) 可能的MAC切片: '
'[0..5]=${body.length >= 6 ? mac(body.sublist(0, 6)) : "-"} '
'[3..8]=${body.length >= 9 ? mac(body.sublist(3, 9)) : "-"} '
'[6..11]=${body.length >= 12 ? mac(body.sublist(6, 12)) : "-"}'
: (attr == 7 ? ' ← 蓝牙地址 = ${mac(body)}' : '');
Logger.i(
_tag,
'[抓帧] attr=$attr len=$blockLen body(${body.length}B)='
'${hex(body)}$note');
p += blockLen + 1;
} }
} }
@ -495,16 +759,65 @@ class BesBluetoothService extends GetxService {
await _syncAuthorizedDevices(devices); await _syncAuthorizedDevices(devices);
} }
/// 把一台设备移出「已配对」列表(自动回连列表),正连着就顺带断开。
///
/// [address] 可以是本地设备标识([connectedDeviceId],即 pairedDevices 里存的
/// 那个串)或服务端记录的真 MAC(`UserDevice.devicemac`)——**两者在 iOS 上是
/// 完全不同的值**(前者是 CoreBluetooth peripheral UUID),所以这里两种都认。
///
/// ⚠️ 只按 [connectedDeviceId] 比较会让 iOS 上的解绑**静默失效**:
/// 传进来的是真 MAC,跟 UUID 永远不相等 → 既不断开连接、也删不掉
/// pairedDevices,表现为「服务端解绑成功了,App 却还连着、还会自动回连」。
/// 耳机自报的 [deviceMac] 是唯一能把「服务端的 MAC」和「本地的 UUID」
/// 对应起来的桥。
///
/// 地址比较一律大小写不敏感——存进去的大小写取决于当时是谁写的。
Future<void> removePairedDevice(String address) async { Future<void> removePairedDevice(String address) async {
final target = address.toUpperCase();
// 要删的是不是「当前连着的这一台」:本地标识对上、或自报 MAC 对上都算
final bool isCurrent = (connectedDeviceId.value.isNotEmpty &&
connectedDeviceId.value.toUpperCase() == target) ||
(deviceMac.value.isNotEmpty && deviceMac.value.toUpperCase() == target);
// 待清理的本地标识:传进来的那个,外加(若是当前设备)它的本地 id——
// iOS 上 pairedDevices 存的是后者,只按前者删会漏。
final ids = <String>{target};
if (isCurrent && connectedDeviceId.value.isNotEmpty) {
ids.add(connectedDeviceId.value.toUpperCase());
}
final devices = getPairedDevices(); final devices = getPairedDevices();
devices.removeWhere((d) => d['address'] == address); devices.removeWhere(
(d) => ids.contains((d['address'] as String? ?? '').toUpperCase()));
await _storage.write(_pairedDevicesKey, devices); await _storage.write(_pairedDevicesKey, devices);
// lastDevice 是 tryAutoConnect 的另一个入口,只清 pairedDevices 不够
if (ids.contains(
(_storage.read<Map>(_lastDeviceKey)?['address'] as String? ?? '')
.toUpperCase())) {
await _storage.remove(_lastDeviceKey);
}
await _syncAuthorizedDevices(devices); await _syncAuthorizedDevices(devices);
if (connectedDeviceId.value == address) {
// 解绑 = 「不要它了」,把这些本地标识对应的 MAC 缓存一并删掉,
// 下次再连上时重新查一遍(与 BesDeviceAuth.forget 清白名单是同一个用意:
// 不清的话会命中缓存跳过重新确权)。
_forgetDeviceMac(ids);
if (isCurrent) {
deviceMac.value = '';
await disconnectDevice(); await disconnectDevice();
} }
} }
void _forgetDeviceMac(Set<String> upperIds) {
final cache = _storage.read<Map>(_macCacheKey);
if (cache == null || cache.isEmpty) return;
final next = <String, dynamic>{...cache.cast<String, dynamic>()}
..removeWhere((k, _) => upperIds.contains(k.toUpperCase()));
if (next.length != cache.length) _storage.write(_macCacheKey, next);
}
Future<void> _syncAuthorizedDevices([List<Map<String, dynamic>>? devices]) async { Future<void> _syncAuthorizedDevices([List<Map<String, dynamic>>? devices]) async {
final ids = (devices ?? getPairedDevices()) final ids = (devices ?? getPairedDevices())
.map((d) => d['address'] as String? ?? '') .map((d) => d['address'] as String? ?? '')

30
apps/client/lib/data/services/id_verify_service.dart

@ -1,4 +1,5 @@
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:get_storage/get_storage.dart';
import '../models/user_Info.dart'; import '../models/user_Info.dart';
import '../../core/utils/logger.dart'; import '../../core/utils/logger.dart';
@ -102,13 +103,7 @@ class IdVerifyService extends GetxService {
// 免得「我的」页还显示着用户当初随手选的那个。 // 免得「我的」页还显示着用户当初随手选的那个。
final g = _asInt(data is Map ? data['gender'] : null); final g = _asInt(data is Map ? data['gender'] : null);
verifiedGender.value = g; verifiedGender.value = g;
if (g > 0) { _applyGender(g);
try {
User.instance.gender = g;
} catch (_) {
// User 尚未初始化(理论上到不了这里,实名要求已登录),忽略即可
}
}
return verified.value ? null : 'idVerifyFailed'.tr; return verified.value ? null : 'idVerifyFailed'.tr;
} catch (e) { } catch (e) {
Logger.w(_tag, '实名核验失败: $e'); Logger.w(_tag, '实名核验失败: $e');
@ -116,6 +111,27 @@ class IdVerifyService extends GetxService {
} }
} }
/// 把核验回来的真实性别写进全局用户对象,并让在场的 UI 立刻跟着变。
///
/// 三件事缺一不可,早先只做了第一件,所以「我的」页要退出去再进来才更新:
/// 1. 改 [User.instance](ProfileController 的 `userInfo` 包的就是这个对象);
/// 2. 回写本地 `user_info`,否则重启后又变回用户自选的那个值
/// (服务端 markUserVerified 已经存了,但本地缓存会盖回来);
/// 3. `refreshTick++` 驱动重建 —— 直接改字段不会触发任何 Rx。
void _applyGender(int gender) {
if (gender <= 0) return;
if (!User.isLoggedIn()) return; // 实名要求已登录,理论上到不了这里
final u = User.instance;
if (u.gender == gender) return;
u.gender = gender;
try {
GetStorage().write('user_info', u.toJson());
} catch (e) {
Logger.w(_tag, '性别写入本地缓存失败: $e');
}
User.refreshTick.value++;
}
void _apply(dynamic data) { void _apply(dynamic data) {
if (data is! Map) return; if (data is! Map) return;
verified.value = data['verified'] == true; verified.value = data['verified'] == true;

73
apps/client/lib/data/services/language_configs/alibaba35_language_config.dart

@ -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();
}

134
apps/client/lib/data/services/language_configs/alibaba_language_config.dart

@ -1,9 +1,55 @@
import '../../models/user_Info.dart'; import '../../models/user_Info.dart';
/// 返回阿里云通义千问的翻译语言配置表(Translation) /// 阿里通义**旧版** `qwen3-livetranslate-flash-realtime` 的通话翻译语言表与音色。
/// ///
/// 数据来源:阿里云通义千问翻译支持语种 /// 路由上它是**第二档**:3.5(带声音复刻)不覆盖的语种才落到这里,
/// 参考: https://bailian.console.aliyun.com/cn-beijing/?spm=5176.12818093_47.overview_recent.2.3be916d0Dc1NWf&tab=doc#/doc/?type=model&url=2983281 /// 见 LanguageManager.findBestMatchingProvider。旧版支持 18 语,
/// 其中 16 语已被 3.5 的「音频输出 29 语」覆盖,所以实际只有
/// **yue(粤语)和 el(希腊语)**会真的走到这一档——这两个在 3.5 里
/// 是「仅文本输出」,出不了 TTS 音频,通话翻译用不了。
/// 表里仍按阿里文档把 18 语列全,别按「反正走不到」删掉。
///
/// 音色(旧版没有声音复刻,只能挑固定音色):
///
/// ⚠️ **模型不会"听出说话人是男是女"再挑音色**。`voice` 是 session 级参数,
/// 在握手的第一条 `session.update` 里定死,之后再发 `voice` 会被服务端忽略
/// (阿里文档明写)。所以对方的性别我们无从得知,也没法中途换音色。
/// (新版 qwen3.5-livetranslate-flash-realtime 支持实时**声音复刻**
/// `enable_voice_clone`,能让译文听起来像本人说外语——那才是根治方案,
/// 但要换模型,不在当前实现范围。)
///
/// 口径与项目既有的 [LanguageManager._getVoiceByGender] 对齐:
/// **只有明确设置为男(gender==1)才用男声,未设置(0) 与 女(2) 一律女声**。
/// 默认女声是刻意选的失败方向——认不出来时给女声比给男声更不出戏。
/// 原实现写的是 `gender == 2 ? Cherry : Nofish`,等于把「未设置」也判成男声,
/// 与全项目口径相反,已修正。
const String kAlibabaVoiceFemale = 'Cherry'; // 芊悦,女声
const String kAlibabaVoiceMale = 'Nofish'; // 不吃鱼,男声
/// 粤语专用女声。目前粤语没在下面的表里(被注释掉了),留着备用:
/// 真要放开 yue,得确认 Cherry 能不能播粤语,不能就换这个。
const String kAlibabaVoiceCantonese = 'Kiki'; // 粤语-阿清,女声
/// 代表「用户本人」的那一路音色:己方说话 → 译成对方语言 → 对方听见的"我"。
/// 用户设了性别就跟着走,没设按女声。
///
/// [targetShortCode] 是**这一路要播出来的语言**(不是源语言):
/// 粤语只有 Kiki 这一个音色,Cherry/Nofish 播不了,所以粤语忽略性别。
String getAlibabaVoiceForUser(String targetShortCode) {
if (targetShortCode == 'yue') return kAlibabaVoiceCantonese;
try {
return User.instance.gender == 1 ? kAlibabaVoiceMale : kAlibabaVoiceFemale;
} catch (_) {
// 用户信息还没初始化(未登录/游客首启)——也别退化成男声
return kAlibabaVoiceFemale;
}
}
/// 代表「对方」的那一路音色:对方说话 → 译成己方语言 → 我听见的"对方"。
/// 对方性别无从得知,固定女声。
String getAlibabaVoiceForPeer(String targetShortCode) =>
targetShortCode == 'yue' ? kAlibabaVoiceCantonese : kAlibabaVoiceFemale;
List<Map<String, String>> getAlibabaTranslationLanguageSpecs() { List<Map<String, String>> getAlibabaTranslationLanguageSpecs() {
return [ return [
// 中文系列 // 中文系列
@ -12,7 +58,7 @@ List<Map<String, String>> getAlibabaTranslationLanguageSpecs() {
'chineseName': '中文', 'chineseName': '中文',
'englishName': 'Chinese', 'englishName': 'Chinese',
'translationCode': 'zh', 'translationCode': 'zh',
'ttsVoiceName': User.instance.gender == 2 ? 'Cherry' : 'Nofish', 'ttsVoiceName': kAlibabaVoiceFemale,
}, },
// 常见通用语种 // 常见通用语种
@ -21,63 +67,125 @@ List<Map<String, String>> getAlibabaTranslationLanguageSpecs() {
'chineseName': '英语', 'chineseName': '英语',
'englishName': 'English', 'englishName': 'English',
'translationCode': 'en', 'translationCode': 'en',
'ttsVoiceName': User.instance.gender == 2 ? 'Cherry' : 'Nofish', 'ttsVoiceName': kAlibabaVoiceFemale,
}, },
{ {
'shortCode': 'ja', 'shortCode': 'ja',
'chineseName': '日语', 'chineseName': '日语',
'englishName': 'Japanese', 'englishName': 'Japanese',
'translationCode': 'ja', 'translationCode': 'ja',
'ttsVoiceName': User.instance.gender == 2 ? 'Cherry' : 'Nofish', 'ttsVoiceName': kAlibabaVoiceFemale,
}, },
{ {
'shortCode': 'ko', 'shortCode': 'ko',
'chineseName': '韩语', 'chineseName': '韩语',
'englishName': 'Korean', 'englishName': 'Korean',
'translationCode': 'ko', 'translationCode': 'ko',
'ttsVoiceName': User.instance.gender == 2 ? 'Cherry' : 'Nofish', 'ttsVoiceName': kAlibabaVoiceFemale,
}, },
{ {
'shortCode': 'fr', 'shortCode': 'fr',
'chineseName': '法语', 'chineseName': '法语',
'englishName': 'French', 'englishName': 'French',
'translationCode': 'fr', 'translationCode': 'fr',
'ttsVoiceName': User.instance.gender == 2 ? 'Cherry' : 'Nofish', 'ttsVoiceName': kAlibabaVoiceFemale,
}, },
{ {
'shortCode': 'de', 'shortCode': 'de',
'chineseName': '德语', 'chineseName': '德语',
'englishName': 'German', 'englishName': 'German',
'translationCode': 'de', 'translationCode': 'de',
'ttsVoiceName': User.instance.gender == 2 ? 'Cherry' : 'Nofish', 'ttsVoiceName': kAlibabaVoiceFemale,
}, },
{ {
'shortCode': 'es', 'shortCode': 'es',
'chineseName': '西班牙语', 'chineseName': '西班牙语',
'englishName': 'Spanish', 'englishName': 'Spanish',
'translationCode': 'es', 'translationCode': 'es',
'ttsVoiceName': User.instance.gender == 2 ? 'Cherry' : 'Nofish', 'ttsVoiceName': kAlibabaVoiceFemale,
}, },
{ {
'shortCode': 'it', 'shortCode': 'it',
'chineseName': '意大利语', 'chineseName': '意大利语',
'englishName': 'Italian', 'englishName': 'Italian',
'translationCode': 'it', 'translationCode': 'it',
'ttsVoiceName': User.instance.gender == 2 ? 'Cherry' : 'Nofish', 'ttsVoiceName': kAlibabaVoiceFemale,
}, },
{ {
'shortCode': 'pt', 'shortCode': 'pt',
'chineseName': '葡萄牙语', 'chineseName': '葡萄牙语',
'englishName': 'Portuguese', 'englishName': 'Portuguese',
'translationCode': 'pt', 'translationCode': 'pt',
'ttsVoiceName': User.instance.gender == 2 ? 'Cherry' : 'Nofish', 'ttsVoiceName': kAlibabaVoiceFemale,
}, },
{ {
'shortCode': 'ru', 'shortCode': 'ru',
'chineseName': '俄语', 'chineseName': '俄语',
'englishName': 'Russian', 'englishName': 'Russian',
'translationCode': 'ru', 'translationCode': 'ru',
'ttsVoiceName': User.instance.gender == 2 ? 'Cherry' : 'Nofish', 'ttsVoiceName': kAlibabaVoiceFemale,
},
// 以下 8 语是旧版模型支持、但 3.5 的**音频档**里没有或用不上的补充。
// 真正只会走到这一档的是 yue 和 el(3.5 里它们只有文本输出)。
{
'shortCode': 'id',
'chineseName': '印尼语',
'englishName': 'Indonesian',
'translationCode': 'id',
'ttsVoiceName': kAlibabaVoiceFemale,
},
{
'shortCode': 'vi',
'chineseName': '越南语',
'englishName': 'Vietnamese',
'translationCode': 'vi',
'ttsVoiceName': kAlibabaVoiceFemale,
},
{
'shortCode': 'th',
'chineseName': '泰语',
'englishName': 'Thai',
'translationCode': 'th',
'ttsVoiceName': kAlibabaVoiceFemale,
},
{
'shortCode': 'ar',
'chineseName': '阿拉伯语',
'englishName': 'Arabic',
'translationCode': 'ar',
'ttsVoiceName': kAlibabaVoiceFemale,
},
{
'shortCode': 'hi',
'chineseName': '印地语',
'englishName': 'Hindi',
'translationCode': 'hi',
'ttsVoiceName': kAlibabaVoiceFemale,
},
{
'shortCode': 'tr',
'chineseName': '土耳其语',
'englishName': 'Turkish',
'translationCode': 'tr',
'ttsVoiceName': kAlibabaVoiceFemale,
},
// ⚠️ 粤语音色只有 Kiki,见 getAlibabaVoiceForUser 里的 yue 分支
{
'shortCode': 'yue',
'chineseName': '粤语',
'englishName': 'Cantonese',
'translationCode': 'yue',
'ttsVoiceName': kAlibabaVoiceCantonese,
},
// ⚠️ 希腊语:旧版文档列了这个语种,但 Cherry/Nofish 能不能播希腊语
// **没有实测过**。真机验证优先测这一对。
{
'shortCode': 'el',
'chineseName': '希腊语',
'englishName': 'Greek',
'translationCode': 'el',
'ttsVoiceName': kAlibabaVoiceFemale,
}, },
// { // {

133
apps/client/lib/data/services/language_manager.dart

@ -7,6 +7,7 @@ import 'language_configs/google_language_config.dart';
import 'language_configs/iflytek_language_config.dart'; import 'language_configs/iflytek_language_config.dart';
import 'language_configs/volcano_language_config.dart'; import 'language_configs/volcano_language_config.dart';
import 'language_configs/alibaba_language_config.dart'; import 'language_configs/alibaba_language_config.dart';
import 'language_configs/alibaba35_language_config.dart';
import 'language_configs/ast_config.dart'; import 'language_configs/ast_config.dart';
/// 语言信息类,包含语言的各种表示形式 /// 语言信息类,包含语言的各种表示形式
@ -612,28 +613,42 @@ class LanguageManager extends GetxService {
.toList(); .toList();
} }
/// 豆包端到端 STS 实际优化良好的语言白名单
/// 只有源和目标都在白名单内,且至少一个是 zh/en 时,才走豆包;
/// 其余情况退到阿里或微软,避免法/德/西/葡/俄等语种走豆包端到端时
/// 出现词被拆碎、ASR 误识别、WebSocket 频繁断开等问题
static const Set<String> _volcanoE2EWhitelist = {'zh', 'en', 'ja', 'ko'};
/// 查找同时支持源语言和目标语言的最佳提供商(通过 shortCode 匹配) /// 查找同时支持源语言和目标语言的最佳提供商(通过 shortCode 匹配)
/// 路由策略: ///
/// 1. 豆包:源/目标都在白名单(zh/en/ja/ko)且至少一个是 zh/en /// ⚠️ **通话翻译不再走豆包(火山)端到端**(2026-08-31 改):
/// 2. 阿里:源/目标都在阿里翻译表内(zh/en/ja/ko/fr/de/es/it/pt/ru) /// 原来 zh/en/ja/ko 这几对语言优先命中豆包,现统一改走阿里
/// 3. 微软:其他所有组合(阿里不覆盖的稀有语种,或两端有任一不在阿里白名单) /// (通义 qwen3-livetranslate 端到端)。豆包覆盖的 {zh,en,ja,ko} 是阿里
/// 4. 讯飞:兜底 /// 翻译表(zh/en/ja/ko/fr/de/es/it/pt/ru)的真子集,所以摘掉豆包不会
/// 留下任何没人接的语言对。**别把 `SpeechServiceType.volcano` 加回
/// providers 列表**——加回去它排在前面就会又把中英通话翻译抢走。
/// (原生侧 DoubaoE2ETranslateHelper 仍在,只是不再被这里选中;
/// 本函数是通话翻译选 provider 的唯一入口,见
/// TranslationController._initializeCallModeTranslationService。)
///
/// 路由策略(2026-09-01 起,四档降级):
/// 1. **阿里 3.5**(qwen3.5-livetranslate-flash-realtime,带实时声音复刻):
/// 源/目标都在它的「音频+文本输出」29 语内。这一档译文用**说话人本人
/// 的声音**,男女音色天然对得上,不需要猜性别。
/// 2. **阿里旧版**(qwen3-livetranslate-flash-realtime):3.5 出不了音频的
/// 语种落这里。旧版 18 语里有 16 语被 3.5 覆盖,所以实际只有
/// **yue(粤语)/ el(希腊语)** 会走到——它俩在 3.5 里是「仅文本输出」。
/// 3. **微软**:上面两档都不覆盖的语种(app 一共开放 80 个语种,
/// 阿里两档合起来 31 个,剩下约 50 个都靠微软)。
/// 4. **讯飞**:兜底。
///
/// ⚠️ 「3.5 支持 60 语」这个宣传数字**不能直接拿来当路由依据**:其中 31 语
/// 是仅文本输出。通话翻译必须拿到 TTS PCM 回灌耳机,没音频等于功能不成立,
/// 所以第 1 档卡的是 29 语的音频档,不是 60。
BestProviderResult? findBestMatchingProvider( BestProviderResult? findBestMatchingProvider(
String sourceShortCode, String targetShortCode) { String sourceShortCode, String targetShortCode) {
final providers = [ final providers = [
SpeechServiceType.volcano, SpeechServiceType.alibaba35,
SpeechServiceType.alibaba, SpeechServiceType.alibaba,
SpeechServiceType.azure, SpeechServiceType.azure,
SpeechServiceType.iflytek, SpeechServiceType.iflytek,
]; ];
Logger.info( Logger.info(
'[TRANS-INIT] 开始匹配提供商: $sourceShortCode -> $targetShortCode, 策略=豆包(zh/en/ja/ko白名单且含zh|en)>阿里(含zh)>微软>讯飞'); '[TRANS-INIT] 开始匹配提供商: $sourceShortCode -> $targetShortCode, 策略=阿里3.5(声音复刻)>阿里旧版>微软>讯飞(豆包已停用)');
for (final provider in providers) { for (final provider in providers) {
final providerName = _providerStringFromType(provider); final providerName = _providerStringFromType(provider);
@ -659,36 +674,12 @@ class LanguageManager extends GetxService {
continue; continue;
} }
// 豆包端到端限制:
// (a) 源和目标都必须在白名单 {zh, en, ja, ko}
// (b) 且至少一个是 zh/en(豆包 STS 以中英为 pivot)
if (provider == SpeechServiceType.volcano) {
final bothInWhitelist =
_volcanoE2EWhitelist.contains(sourceShortCode) &&
_volcanoE2EWhitelist.contains(targetShortCode);
final hasZhOrEn = sourceShortCode == 'zh' ||
sourceShortCode == 'en' ||
targetShortCode == 'zh' ||
targetShortCode == 'en';
if (!bothInWhitelist) {
Logger.info(
'[TRANS-INIT] 跳过 ${providerDisplayName('volcano')}: 端到端仅对 {zh,en,ja,ko} 稳定,当前 $sourceShortCode/$targetShortCode 不全在白名单');
continue;
}
if (!hasZhOrEn) {
Logger.info(
'[TRANS-INIT] 跳过 ${providerDisplayName('volcano')}: 需源或目标至少一个为 zh/en(当前 $sourceShortCode/$targetShortCode)');
continue;
}
}
// 阿里端到端 (qwen3-livetranslate): 覆盖主流 10 语(zh/en/ja/ko/fr/de/es/it/pt/ru), // 阿里端到端 (qwen3-livetranslate): 覆盖主流 10 语(zh/en/ja/ko/fr/de/es/it/pt/ru),
// 两端都在阿里翻译表内即走阿里;稀有语种交给微软兜底。 // 两端都在阿里翻译表内即走阿里;稀有语种交给微软兜底。
// 这里不加额外白名单——上面 transSpecs 的匹配检查已经做了筛选。 // 这里不加额外白名单——上面 transSpecs 的匹配检查已经做了筛选。
// 对于非端到端服务(Azure, Iflytek),还需要检查 ASR 和 TTS 支持 // 对于非端到端服务(Azure, Iflytek),还需要检查 ASR 和 TTS 支持
if (provider != SpeechServiceType.volcano && if (!_isAlibaba(provider)) {
provider != SpeechServiceType.alibaba) {
final asrSpecs = _getAsrSpecs(provider); final asrSpecs = _getAsrSpecs(provider);
final ttsSpecs = _getTtsSpecs(provider); final ttsSpecs = _getTtsSpecs(provider);
@ -711,21 +702,60 @@ class LanguageManager extends GetxService {
_getGenderAwareTtsCode(spec['shortCode']!) ?? spec["ttsVoiceName"]!; _getGenderAwareTtsCode(spec['shortCode']!) ?? spec["ttsVoiceName"]!;
} }
} }
} else if (provider == SpeechServiceType.alibaba) { } else if (_isAlibaba(provider)) {
// 阿里端到端的音色是**按路**定的,两路含义完全不同,别给成同一个值:
//
// A 路 = 己方麦克风 → 译成对方语言 → **对方听见的"我"**
// 原生读 supportedLanguages[5],即这里的 targetSpec['ttsCode']
// B 路 = 对端通话音 → 译成己方语言 → **我听见的"对方"**
// 原生读 supportedLanguages[4],即这里的 sourceSpec['ttsCode']
//
// 映射见 AzureSpeechPlugin(两端一致):
// bailianConfigA.voice = ttsLang1 / bailianConfigB.voice = ttsLang0。
// 改这两行前先回去核一眼,写反了就变成「对方说话用我的性别、
// 我说话用默认女声」,且不会报任何错。
//
// 对方是男是女**无从得知**:qwen3-livetranslate 的 voice 是 session 级
// 参数,握手时定死且中途改不了,模型也不做说话人性别识别。
// 所以 B 路一律女声;A 路代表用户本人,跟随用户设置的性别
// (未设置同样落女声,见 getAlibabaVoiceForUser)。
// 每一路的音色跟的是**这一路要播出来的语言**:
// A 路播 targetShortCode,B 路播 sourceShortCode。
final String userVoice = getAlibabaVoiceForUser(targetShortCode);
final String peerVoice = getAlibabaVoiceForPeer(sourceShortCode);
final ttsSpecs = _getTtsSpecs(provider); final ttsSpecs = _getTtsSpecs(provider);
for (final spec in ttsSpecs) { for (final spec in ttsSpecs) {
// 仍然按 shortCode 命中才填:填不上会退到 translation_controller 里
// getTtsVoiceNameByAsrCode 的 Azure 音色名(zh-CN-XiaoxiaoNeural),
// 那对阿里是非法值。上面的 transSpecs 校验保证了这里一定命中。
if (spec['shortCode'] == sourceShortCode) { if (spec['shortCode'] == sourceShortCode) {
sourceTransSpec["ttsCode"] = spec["ttsVoiceName"]!; sourceTransSpec["ttsCode"] = peerVoice;
} }
if (spec['shortCode'] == targetShortCode) { if (spec['shortCode'] == targetShortCode) {
targetTransSpec["ttsCode"] = spec["ttsVoiceName"]!; targetTransSpec["ttsCode"] = userVoice;
} }
} }
// ⚠️ 日志里别直接读 User.instance:未初始化时它是 throw 而不是返回 null,
// 异常会被上层 _initializeCallModeTranslationService 的 catch 吞成
// 「通话翻译初始化失败」,一句日志把整个功能带塌。
int? genderForLog;
try {
genderForLog = User.instance.gender;
} catch (_) {
genderForLog = null;
}
if (provider == SpeechServiceType.alibaba35) {
Logger.info(
'[TRANS-INIT] 阿里3.5 开启声音复刻:两路音色由原生强制写成 default,'
'译文用说话人本人的声音,不按性别选音色(gender=${genderForLog ?? "未初始化"} 仅记录)');
} else {
Logger.info(
'[TRANS-INIT] 阿里旧版音色: A路(我说话/对方听)=$userVoice, B路(对方说话/我听)=$peerVoice, gender=${genderForLog ?? "未初始化"}');
}
} }
final providerString = _providerStringFromType(provider); final providerString = _providerStringFromType(provider);
final isEndToEnd = provider == SpeechServiceType.volcano || final isEndToEnd = _isAlibaba(provider);
provider == SpeechServiceType.alibaba;
Logger.info( Logger.info(
'[TRANS-INIT] 命中 ${providerDisplayName(providerString)}[$providerString] for $sourceShortCode -> $targetShortCode (端到端=$isEndToEnd), transSrc=${sourceTransSpec["translationCode"]}, transTgt=${targetTransSpec["translationCode"]}, asrSrc=${sourceTransSpec["asrCode"]}, asrTgt=${targetTransSpec["asrCode"]}, ttsSrc=${sourceTransSpec["ttsCode"]}, ttsTgt=${targetTransSpec["ttsCode"]}'); '[TRANS-INIT] 命中 ${providerDisplayName(providerString)}[$providerString] for $sourceShortCode -> $targetShortCode (端到端=$isEndToEnd), transSrc=${sourceTransSpec["translationCode"]}, transTgt=${targetTransSpec["translationCode"]}, asrSrc=${sourceTransSpec["asrCode"]}, asrTgt=${targetTransSpec["asrCode"]}, ttsSrc=${sourceTransSpec["ttsCode"]}, ttsTgt=${targetTransSpec["ttsCode"]}');
return BestProviderResult( return BestProviderResult(
@ -737,6 +767,13 @@ class LanguageManager extends GetxService {
return null; return null;
} }
/// 两档阿里共用同一套端到端流水线(同一个原生 helper、同一套 WS 协议),
/// 差别只在 model 名与是否开声音复刻,所以凡是「是不是端到端阿里」的判断
/// 都走这里,别再逐个写 `== SpeechServiceType.alibaba`——漏掉 3.5 会让它
/// 掉进 Azure 那条「还要查 ASR/TTS 表」的分支里,直接匹配失败。
static bool _isAlibaba(SpeechServiceType t) =>
t == SpeechServiceType.alibaba || t == SpeechServiceType.alibaba35;
String _providerStringFromType(SpeechServiceType type) { String _providerStringFromType(SpeechServiceType type) {
switch (type) { switch (type) {
case SpeechServiceType.iflytek: case SpeechServiceType.iflytek:
@ -747,6 +784,8 @@ class LanguageManager extends GetxService {
return 'google'; return 'google';
case SpeechServiceType.alibaba: case SpeechServiceType.alibaba:
return 'alibaba'; return 'alibaba';
case SpeechServiceType.alibaba35:
return 'alibaba35';
case SpeechServiceType.volcano: case SpeechServiceType.volcano:
return 'volcano'; return 'volcano';
case SpeechServiceType.flutter: case SpeechServiceType.flutter:
@ -760,7 +799,9 @@ class LanguageManager extends GetxService {
case 'volcano': case 'volcano':
return '豆包(火山)'; return '豆包(火山)';
case 'alibaba': case 'alibaba':
return '阿里(通义)'; return '阿里(通义·旧版)';
case 'alibaba35':
return '阿里(通义3.5·声音复刻)';
case 'azure': case 'azure':
return '微软(Azure)'; return '微软(Azure)';
case 'iflytek': case 'iflytek':
@ -801,6 +842,8 @@ class LanguageManager extends GetxService {
return getVolcanoTranslationLanguageSpecs(); return getVolcanoTranslationLanguageSpecs();
case SpeechServiceType.alibaba: case SpeechServiceType.alibaba:
return getAlibabaTranslationLanguageSpecs(); return getAlibabaTranslationLanguageSpecs();
case SpeechServiceType.alibaba35:
return getAlibaba35TranslationLanguageSpecs();
default: default:
return getAzureTranslationLanguageSpecs(); return getAzureTranslationLanguageSpecs();
} }
@ -816,6 +859,8 @@ class LanguageManager extends GetxService {
return getIflytekTTSLanguageSpecs(); return getIflytekTTSLanguageSpecs();
case SpeechServiceType.alibaba: case SpeechServiceType.alibaba:
return getAlibabaTranslationLanguageSpecs(); return getAlibabaTranslationLanguageSpecs();
case SpeechServiceType.alibaba35:
return getAlibaba35TranslationLanguageSpecs();
default: default:
return getAzureTTSLanguageSpecs(); return getAzureTTSLanguageSpecs();
} }

10
apps/client/lib/data/services/speech_factory.dart

@ -17,9 +17,17 @@ enum SpeechServiceType {
/// Google 语音服务 /// Google 语音服务
google, google,
/// 阿里巴巴语音服务 /// 阿里巴巴语音服务(旧版 qwen3-livetranslate-flash-realtime,无声音复刻)
alibaba, alibaba,
/// 阿里巴巴语音服务 3.5(qwen3.5-livetranslate-flash-realtime,带实时声音复刻)
///
/// 与 [alibaba] 是**同一套 WebSocket 协议、同一个原生 helper**,只差
/// `model` 参数和 `enable_voice_clone` 两个开关,所以下发给原生的
/// provider 字符串仍然是 `alibaba`(见 AzureAstService.initialize),
/// 原生不需要为它多一个分支。
alibaba35,
/// 火山引擎(豆包)语音服务 /// 火山引擎(豆包)语音服务
volcano, volcano,

41
apps/client/lib/data/services/speech_impl/azure_ast_service.dart

@ -5,6 +5,21 @@ import '../../../core/utils/logger.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import '../ast_service.dart'; import '../ast_service.dart';
/// 阿里通义实时翻译的两个模型名。
///
/// ⚠️ 3.5 的模型名里是**点号** `qwen3.5-...`,不是文档 URL 里的 `qwen3-5-...`;
/// 写错了 WebSocket 握手会被服务端拒掉。
const String kAlibabaModelLegacy = 'qwen3-livetranslate-flash-realtime';
const String kAlibabaModel35 = 'qwen3.5-livetranslate-flash-realtime';
/// 声音复刻的复刻时机:`never` / `once` / `always`。
///
/// 取 `once`:通话里每一路的说话人是固定的(A 路永远是机主、B 路永远是对端),
/// 开头复刻一次就够;`always` 会持续重复刻,费用和延迟都更高。
/// ⚠️ frequency 为 once/always 时,阿里要求 `voice` 必须是 `"default"`,
/// 这一步由原生 helper 强制写入,Dart 侧算出来的音色在 3.5 档不生效。
const String kAlibabaVoiceCloneFrequency = 'once';
/// Azure 语音识别服务 /// Azure 语音识别服务
/// ///
/// 该服务提供了通过平台通道与原生 Microsoft Speech SDK 交互的接口 /// 该服务提供了通过平台通道与原生 Microsoft Speech SDK 交互的接口
@ -251,7 +266,7 @@ class AzureAstService extends GetxService implements AstService {
_alibabaAppKey = AppConfig.env('ALIBABA_OPENSPEECH_APP_KEY') ?? ''; _alibabaAppKey = AppConfig.env('ALIBABA_OPENSPEECH_APP_KEY') ?? '';
final aliModel = AppConfig.env('ALIBABA_OPENSPEECH_APP_ID') ?? ''; final aliModel = AppConfig.env('ALIBABA_OPENSPEECH_APP_ID') ?? '';
_alibabaAppId = aliModel.trim().isEmpty _alibabaAppId = aliModel.trim().isEmpty
? 'qwen3-livetranslate-flash-realtime' ? kAlibabaModelLegacy
: aliModel; : aliModel;
final aliUrl = AppConfig.env('ALIBABA_OPENSPEECH_APP_URL') ?? ''; final aliUrl = AppConfig.env('ALIBABA_OPENSPEECH_APP_URL') ?? '';
_alibabaAppURL = aliUrl.trim().isEmpty _alibabaAppURL = aliUrl.trim().isEmpty
@ -411,7 +426,21 @@ class AzureAstService extends GetxService implements AstService {
try { try {
final List<String> languages = final List<String> languages =
supportedLanguages ?? _defaultSupportedLanguages; supportedLanguages ?? _defaultSupportedLanguages;
Logger.info('[STS] initialize: provider=$provider, isInitialized=$_isInitialized'); // 两档阿里在原生侧是**同一个 helper、同一套 WS 协议**,只差 model 名和
// 是否开声音复刻,所以下发给原生的 provider 统一成 'alibaba',
// 原生不需要为 3.5 多一个分支(少 6 处 == 比较,少 6 处漏改的机会)。
final String nativeProvider =
provider == 'alibaba35' ? 'alibaba' : provider;
// 3.5 才有实时声音复刻;旧版模型没这个能力,传了会被拒。
final bool enableVoiceClone = provider == 'alibaba35';
// ⚠️ 模型名必须跟着档位走。`.env` 的 ALIBABA_OPENSPEECH_APP_ID 只覆盖
// 旧版那一档;3.5 这一档写死常量,否则运维改了 env 会把 3.5 打回旧版,
// 而现象只是「声音复刻悄悄没了」,不报任何错。
final String alibabaModel =
provider == 'alibaba35' ? kAlibabaModel35 : _alibabaAppId;
Logger.info(
'[STS] initialize: provider=$provider (native=$nativeProvider), '
'alibabaModel=$alibabaModel, voiceClone=$enableVoiceClone, isInitialized=$_isInitialized');
// 底层会初始化前释放 // 底层会初始化前释放
if (_isInitialized) { if (_isInitialized) {
await dispose(); await dispose();
@ -422,7 +451,7 @@ class AzureAstService extends GetxService implements AstService {
Logger.info('[STS] initialize: eventChannel已建立, streamController=${_eventStreamController?.hashCode}'); Logger.info('[STS] initialize: eventChannel已建立, streamController=${_eventStreamController?.hashCode}');
final bool result = await _channel.invokeMethod('initialize', { final bool result = await _channel.invokeMethod('initialize', {
'provider': provider, 'provider': nativeProvider,
'subscriptionKey': _subscriptionKey, 'subscriptionKey': _subscriptionKey,
'region': _serviceRegion, 'region': _serviceRegion,
'supportedLanguages': languages, 'supportedLanguages': languages,
@ -440,8 +469,12 @@ class AzureAstService extends GetxService implements AstService {
'resourceId': _doubaoResourceId, 'resourceId': _doubaoResourceId,
'alibabaAppKey': _alibabaAppKey, 'alibabaAppKey': _alibabaAppKey,
'alibabaAppId': _alibabaAppId, 'alibabaAppId': alibabaModel,
'alibabaAppURL': _alibabaAppURL, 'alibabaAppURL': _alibabaAppURL,
// 原生新增入参(旧原生收不到也不影响:它读不到就按老行为走)
'alibabaModel': alibabaModel,
'alibabaEnableVoiceClone': enableVoiceClone,
'alibabaVoiceCloneFrequency': kAlibabaVoiceCloneFrequency,
}); });
_isInitialized = result; _isInitialized = result;

257
apps/client/lib/data/services/version_update_service.dart

@ -1,5 +1,4 @@
import 'dart:io'; import 'dart:io';
import 'dart:math' as math;
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:package_info_plus/package_info_plus.dart'; import 'package:package_info_plus/package_info_plus.dart';
@ -23,6 +22,7 @@ class VersionUpdateService extends GetxService {
static String? _latestVersion = ""; //最新版本号+构建号 static String? _latestVersion = ""; //最新版本号+构建号
static bool? _hasUpdate = false; //是否有更新 static bool? _hasUpdate = false; //是否有更新
static bool? _isForceUpdate = false; //是否强制更新 static bool? _isForceUpdate = false; //是否强制更新
static bool? _isReviewing = false; //本机版本是否正处于渠道审核期(服务端判定)
static String? _updateUrl = ""; //更新链接 static String? _updateUrl = ""; //更新链接
static AppInfo? _appInfo; //应用信息 static AppInfo? _appInfo; //应用信息
static bool? _isShowTourists = false; //是否显示游客登录 static bool? _isShowTourists = false; //是否显示游客登录
@ -68,6 +68,10 @@ class VersionUpdateService extends GetxService {
/// 获取缓存的更新地址 /// 获取缓存的更新地址
String get cachedUpdateUrl => _updateUrl ?? ''; String get cachedUpdateUrl => _updateUrl ?? '';
/// 本机版本是否正处于渠道审核期(后台「版本控制」填的送审版本号等于本机版本)。
/// 服务端算好的结论,供「审核期隐藏未过审功能」用;目前还没有 UI 消费它。
bool get cachedIsReviewing => _isReviewing ?? false;
/// 获取缓存的应用信息 /// 获取缓存的应用信息
AppInfo? get cachedAppInfo => _appInfo; AppInfo? get cachedAppInfo => _appInfo;
@ -98,7 +102,19 @@ class VersionUpdateService extends GetxService {
// super.onInit(); // super.onInit();
// } // }
/// 检查版本更新 /// 检查版本更新。
///
/// 走**单数**接口 `user_getchannelapp`:只要本渠道这一条,且服务端把
/// `needupdate/forceupdate/reviewing` 三个结论直接算好下发(见 api_getchannelapp.go),
/// 客户端不再自己比版本号。
///
/// 为什么不是复数的 `user_getchannelapps`:
/// - 它取回全部渠道,而客户端只用得上自己这一条 —— 早先那套「环境渠道 + 品牌渠道」
/// 双取的逻辑早已失效(`_channel` 在 getCurrentPlatformInfo 里就被改写成品牌渠道,
/// 两个变量取到的是同一条),留着只是多传一堆用不到的数据。
/// - 更要命的是它**不在网关白名单里**,未登录时返回 `code:18 NoLogin`。而游客登录入口
/// 恰恰只在未登录时才需要显示,于是入口在任何未登录设备上都不可能出现,且不报错。
/// 单数接口本来就在白名单里。
Future<VersionUpdateInfo> checkForUpdate() async { Future<VersionUpdateInfo> checkForUpdate() async {
if (_lastCheckTime != null && if (_lastCheckTime != null &&
DateTime.now().difference(_lastCheckTime!).inMinutes < DateTime.now().difference(_lastCheckTime!).inMinutes <
@ -110,118 +126,118 @@ class VersionUpdateService extends GetxService {
currentVersion: _appVersion ?? '1.0.0', currentVersion: _appVersion ?? '1.0.0',
latestVersion: (_latestVersion ?? '1.0.0').split('+')[0], latestVersion: (_latestVersion ?? '1.0.0').split('+')[0],
appInfo: _appInfo, appInfo: _appInfo,
updateType: _hasUpdate! updateType: (_hasUpdate ?? false)
? (_isForceUpdate! ? UpdateType.force : UpdateType.optional) ? ((_isForceUpdate ?? false) ? UpdateType.force : UpdateType.optional)
: UpdateType.none, : UpdateType.none,
); );
} }
final currentVersion = await getCurrentVersion();
try { try {
final currentVersion = await getCurrentVersion();
final platformInfo = await getCurrentPlatformInfo(); final platformInfo = await getCurrentPlatformInfo();
_platform = platformInfo.platform; _platform = platformInfo.platform;
//_channel = platformInfo.channel;
_lastCheckTime = DateTime.now();
// 获取所有渠道包信息。必须带上本机版本号:游客登录入口是否显示由服务端按版本判定, // 必须带上本机版本号:游客登录入口是否显示、是否需要强更,都由服务端按这个版本判定,
// 不传服务端认不出是哪一版,后台限定了生效版本时会一律隐藏。 // 不传服务端认不出是哪一版,后台限定了生效版本时会一律隐藏。
// 传净版本号(去掉 "+构建号"):后台填的是 1.0.2 这种商店版本号,两边口径必须一致。 // 传净版本号(去掉 "+构建号"):后台填的是 1.0.2 这种商店版本号,两边口径必须一致。
// 直接传 "1.0.1+3" 虽然也能被服务端的 leadingInt 吃掉后缀而碰巧判对,但那是巧合不是约定。 // 直接传 "1.0.1+3" 虽然也能被服务端的 leadingInt 吃掉后缀而碰巧判对,但那是巧合不是约定。
final response = final response = await Api.getChannelApp({
await Api.getChannelApps({'version': currentVersion.split('+')[0]}); 'channel': platformInfo.channel.value,
'version': currentVersion.split('+')[0],
});
_logger.d('lxm--获取渠道包信息(channel=${platformInfo.channel.name}):$response');
_logger.d('lxm--获取所有渠道包信息:$response'); final AppInfo? app = _parseApp(response);
final List<AppInfo> apps = _parseAppsList(response); // ⚠️ 缓存时间戳只在**请求成功之后**才写。早先是在发请求之前写的,于是一次网络抖动
// 就把结果锁死 10 分钟:失败分支把 _isShowTourists 置 false,而缓存命中分支
// 根本不重算它 —— 表现为 splash 那次失败之后,登录页怎么重进游客入口都不出现。
_lastCheckTime = DateTime.now();
if (apps.isEmpty) { if (app == null) {
_logger.w('lxm--获取所有渠道包信息失败或为空'); // 后台还没给这个渠道配任何东西。按「无更新 + 不显示游客入口」处理:
// 游客入口判宽会在送审期把不该露的入口露给审核员,判严只是入口少显示。
_logger.w('lxm--该渠道未配置,按无更新处理');
_appInfo = null;
_isShowTourists = false;
_allowSkipDeviceBinding = false;
_hasUpdate = false;
_isForceUpdate = false;
_isReviewing = false;
return VersionUpdateInfo( return VersionUpdateInfo(
hasUpdate: false, hasUpdate: false,
currentVersion: currentVersion, currentVersion: currentVersion.split('+')[0],
latestVersion: currentVersion, latestVersion: currentVersion.split('+')[0],
updateType: UpdateType.none, updateType: UpdateType.none,
); );
} }
final Map<UserChannel, AppInfo> appIndex = _buildChannelIndex(apps); _appInfo = app;
_latestVersion = app.version;
_updateUrl = app.address;
// 依据设备品牌渠道用于版本更新链接 // 这里的 tourists 已是服务端按本机版本算好的结论,直接用,不要再做版本比较。
final brandChannel = platformInfo.channel; _isShowTourists = app.tourists;
_allowSkipDeviceBinding = app.allowskipdevicebinding;
// 环境变量渠道用于游客登录配置 _logger.d('lxm--从服务器获取游客登录配置(最终): $_isShowTourists');
// final envChannelName = dotenv.env['Channel'] ?? 'OFFICIAL';
final envChannel = _channel;
final AppInfo? envAppInfo = appIndex[envChannel];
// 若不存在品牌渠道信息,降级为环境渠道;仍不存在则返回无更新 // 升级判定同样取服务端结论:版本号逐段比较各端各写一遍必然出现实现不一致,
final AppInfo? brandAppInfo = appIndex[brandChannel] ?? envAppInfo; // 而强更判错的代价是全员无法使用。服务端没回这几个字段时按 false 处理。
_hasUpdate = _asBool(response, 'needupdate');
_isForceUpdate = _asBool(response, 'forceupdate');
_isReviewing = _asBool(response, 'reviewing');
if (brandAppInfo == null) { final UpdateType updateType = _hasUpdate!
_logger.w('lxm--未找到品牌或环境渠道的应用信息,返回无更新'); ? (_isForceUpdate! ? UpdateType.force : UpdateType.optional)
return VersionUpdateInfo( : UpdateType.none;
hasUpdate: false,
currentVersion: currentVersion,
latestVersion: currentVersion,
updateType: UpdateType.none,
);
}
_appInfo = brandAppInfo;
_latestVersion = brandAppInfo.version;
_updateUrl = brandAppInfo.address;
// 设置游客登录显示状态(先用当前渠道结果作为回退,再用.env渠道覆盖以保持一致)。
// 这里的 tourists 已是服务端按本机版本算好的结论,直接用,不要再做版本比较。
_isShowTourists = (envAppInfo ?? brandAppInfo).tourists;
_allowSkipDeviceBinding =
(envAppInfo ?? brandAppInfo).allowskipdevicebinding;
_logger.d('lxm--从服务器获取游客登录配置(最终): ${_isShowTourists}');
// if (channelAppResponse.code != 0 || channelAppResponse.data == null) {
// _logger.w('获取版本信息失败: ${channelAppResponse.msg}');
// return VersionUpdateInfo(
// hasUpdate: false,
// currentVersion: currentVersion,
// latestVersion: currentVersion,
// updateType: UpdateType.none,
// );
// }
// final appInfo = channelAppResponse.data!.app;
_hasUpdate = _compareVersions(currentVersion, brandAppInfo.version);
// 判断更新类型:如果主版本号不同则强制更新
UpdateType updateType = UpdateType.none;
if (_hasUpdate!) {
_isForceUpdate =
_shouldForceUpdate(currentVersion, brandAppInfo.version);
updateType = _isForceUpdate! ? UpdateType.force : UpdateType.optional;
}
//[3]返回版本更新信息
return VersionUpdateInfo( return VersionUpdateInfo(
hasUpdate: _hasUpdate!, hasUpdate: _hasUpdate!,
currentVersion: currentVersion.split('+')[0], currentVersion: currentVersion.split('+')[0],
latestVersion: brandAppInfo.version.split('+')[0], latestVersion: app.version.split('+')[0],
appInfo: brandAppInfo, appInfo: app,
updateType: updateType, updateType: updateType,
); );
} catch (e) { } catch (e) {
_logger.e('检查版本更新失败: $e'); _logger.e('检查版本更新失败: $e');
// 发生错误时,保持默认值 false // 失败方向偏向「不显示游客入口 / 不提示更新」。注意**不写** _lastCheckTime:
// 失败不该占用 10 分钟缓存窗口,下一次调用要能真的重试。
_isShowTourists = false; _isShowTourists = false;
final currentVersion = await getCurrentVersion(); _allowSkipDeviceBinding = false;
_hasUpdate = false;
_isForceUpdate = false;
_isReviewing = false;
return VersionUpdateInfo( return VersionUpdateInfo(
hasUpdate: false, hasUpdate: false,
currentVersion: currentVersion, currentVersion: currentVersion.split('+')[0],
latestVersion: currentVersion, latestVersion: currentVersion.split('+')[0],
updateType: UpdateType.none, updateType: UpdateType.none,
); );
} }
} }
/// 从 `user_getchannelapp` 的响应里取 app 段;该渠道没配置时服务端回的是空对象。
AppInfo? _parseApp(dynamic response) {
try {
final appJson = (response is Map) ? response['app'] : null;
if (appJson is Map<String, dynamic>) return AppInfo.fromJson(appJson);
} catch (e) {
_logger.e('解析渠道包信息失败: $e');
}
return null;
}
/// 取响应里的布尔字段;缺字段或类型不对一律 false(服务端 omitempty 会吃掉 false)。
static bool _asBool(dynamic response, String key) {
if (response is! Map) return false;
final v = response[key];
if (v is bool) return v;
if (v is num) return v != 0;
if (v is String) return v == 'true' || v == '1';
return false;
}
/// 获取当前应用版本信息 /// 获取当前应用版本信息
Future<String> getCurrentVersion() async { Future<String> getCurrentVersion() async {
try { try {
@ -373,103 +389,12 @@ class VersionUpdateService extends GetxService {
} }
} }
/// 判断是否需要强制更新
/// 当主版本号(第一位数字)不同时返回true
bool _shouldForceUpdate(String current, String latest) {
try {
// 拆分版本号与构建号
final currentParts = current.split('+');
final latestParts = latest.split('+');
// 解析版本号 (major.minor.patch)
final currentVersions =
currentParts[0].split('.').map(int.parse).toList();
final latestVersions = latestParts[0].split('.').map(int.parse).toList();
// 比较主版本号(第一位)
if (currentVersions.isNotEmpty && latestVersions.isNotEmpty) {
if (currentVersions[0] != latestVersions[0]) {
return true;
}
}
// 检查是否有两个或更多的 + 号,如果有则强制更新
if (latest.split('+').length > 2) {
return true;
}
return false;
} catch (e) {
_logger.e('判断强制更新失败: $e');
return false;
}
}
/// 比较版本号
/// 返回true表示需要更新(远程版本更高)
bool _compareVersions(String current, String latest) {
// 拆分版本号与构建号
final currentParts = current.split('+')[0];
final latestParts = latest.split('+')[0];
// 解析版本号 (major.minor.patch)
final currentVersions = currentParts.split('.').map(int.parse).toList();
final latestVersions = latestParts.split('.').map(int.parse).toList();
// 使用较小的长度进行比较,避免索引越界
final minLength = math.min(currentVersions.length, latestVersions.length);
// 逐级比较版本号
for (var i = 0; i < minLength; i++) {
if (latestVersions[i] > currentVersions[i]) return true;
if (latestVersions[i] < currentVersions[i]) return false;
}
// 如果前面的版本号都相同,比较长度
// 更长的版本号被认为是更新的版本(如 1.0.1 > 1.0)
return latestVersions.length > currentVersions.length;
}
/// 解析响应数据为AppInfo列表
List<AppInfo> _parseAppsList(dynamic response) {
try {
final appsJson = (response is Map) ? response['apps'] : null;
if (appsJson is List) {
return appsJson
.whereType<Map<String, dynamic>>()
.map((e) => AppInfo.fromJson(e))
.toList();
}
} catch (e) {
_logger.e('解析渠道列表失败: $e');
}
return [];
}
/// 构建渠道索引
Map<UserChannel, AppInfo> _buildChannelIndex(List<AppInfo> apps) {
final index = <UserChannel, AppInfo>{};
for (final app in apps) {
index[app.channel] = app;
}
return index;
}
/// 解析版本号字符串为数字数组
List<int> _parseVersion(String version) {
final parts = version.split('.');
final result = <int>[];
for (int i = 0; i < 3; i++) {
if (i < parts.length) {
result.add(int.tryParse(parts[i]) ?? 0);
} else {
result.add(0);
}
}
return result;
}
/// 执行更新操作 /// 执行更新操作
Future<bool> performUpdate(VersionUpdateInfo updateInfo) async { Future<bool> performUpdate(VersionUpdateInfo updateInfo) async {

4
apps/client/lib/modules/agent/controllers/agent_controller.dart

@ -1770,7 +1770,9 @@ class AgentController extends GetxController with WidgetsBindingObserver {
// 必须已连接杰理耳机;iOS / Android 都走 device_jieli 的 AI 助理通路 // 必须已连接杰理耳机;iOS / Android 都走 device_jieli 的 AI 助理通路
final BleManager bleManager = Get.find<BleManager>(); final BleManager bleManager = Get.find<BleManager>();
if (!bleManager.isConnected) { if (!bleManager.isConnected) {
Get.snackbar('tips'.tr, '请先连接耳机'); // 用已有的 'tip'(全项目 30 处在用),不要写 'tips'——那个 key 一个语言
// 文件里都没有,.tr 会原样显示 "tips"。
Get.snackbar('tip'.tr, '请先连接耳机');
return; return;
} }

167
apps/client/lib/modules/call_recording/views/call_recording_view.dart

@ -8,6 +8,7 @@ import '../controllers/call_recording_controller.dart';
import 'widgets/recorder_waveform.dart'; import 'widgets/recorder_waveform.dart';
import '../../../core/theme/app_palette.dart'; import '../../../core/theme/app_palette.dart';
import '../../../core/utils/background_session_notifier.dart'; import '../../../core/utils/background_session_notifier.dart';
import '../../../core/utils/logger.dart';
/// 录音页退出操作 /// 录音页退出操作
enum _RecordingExitAction { continueInBackground, endAndSave, cancel } enum _RecordingExitAction { continueInBackground, endAndSave, cancel }
@ -20,17 +21,79 @@ enum _RecordingExitAction { continueInBackground, endAndSave, cancel }
/// 没有用 GetView:通话录音 / 现场录音是两个 permanent 单例(按 folder 分开 /// 没有用 GetView:通话录音 / 现场录音是两个 permanent 单例(按 folder 分开
/// tag,见 [CallRecordingBinding]),GetView 默认的 Get.find() 不带 tag,会 /// tag,见 [CallRecordingBinding]),GetView 默认的 Get.find() 不带 tag,会
/// 拿错实例,所以这里自己按 tag 查。 /// 拿错实例,所以这里自己按 tag 查。
class CallRecordingView extends StatelessWidget { ///
/// 是 StatefulWidget 而不是 StatelessWidget,只为了**在 initState 里把 tag 和
/// 路由参数定死**——理由见 [_CallRecordingViewState._tag] 的注释。
class CallRecordingView extends StatefulWidget {
const CallRecordingView({super.key}); const CallRecordingView({super.key});
String get _tag => CallRecordingController.tagFor(Get.arguments); @override
State<CallRecordingView> createState() => _CallRecordingViewState();
}
class _CallRecordingViewState extends State<CallRecordingView> {
static const String _logTag = 'CallRecordingView';
/// 每个 tag 当前有几个页面实例活着。
///
/// 退出时的延时删除([_deleteControllerAfterPop])必须给「刚又进来的页面」
/// 让路:用户退出后 400ms 内再点进来时,binding 看到 controller 还注册着
/// 就直接复用,紧接着旧页面那颗延时炸弹把它删掉——之后这个页面每次 build
/// 和每次按返回都 `Get.find` 失败,返回键彻底失效,人就卡在录音页里出不去。
static final Map<String, int> _liveViews = <String, int>{};
/// 本页对应的 controller tag,**进页面时取一次就不再变**。
///
/// ⚠️ 不能在 build / 回调里现取 `Get.arguments`:它是「全局当前路由」的参数,
/// 任何 dialog / bottomSheet push 进来都会被 GetObserver.didPush 里的
/// `value.args = route.settings.arguments` 冲掉(GetX 4.7 的行为)。
/// 退出确认弹窗一弹,`Get.arguments` 就变成 null,tag 退化成默认的
/// 'call_recordings'——现场录音页当场 find 不到自己的 controller。
late final String _tag;
/// 同理缓存路由参数与路由名,供「后台继续」重新跳回本页用。
late final Map<String, dynamic>? _args;
late final String _route;
/// 返回键重入锁:确认弹窗还开着时再按返回,不要叠第二个弹窗。
bool _popHandling = false;
CallRecordingController? get _controllerOrNull =>
Get.isRegistered<CallRecordingController>(tag: _tag)
? Get.find<CallRecordingController>(tag: _tag)
: null;
CallRecordingController get controller => CallRecordingController get controller =>
Get.find<CallRecordingController>(tag: _tag); Get.find<CallRecordingController>(tag: _tag);
void _deleteControllerAfterPop() { @override
final tag = _tag; void initState() {
super.initState();
_tag = CallRecordingController.tagFor(Get.arguments);
_args = Get.arguments as Map<String, dynamic>?;
_route = Get.currentRoute;
_liveViews.update(_tag, (v) => v + 1, ifAbsent: () => 1);
// 用户从后台通知或其它页面点回来了,之前挂着的"还在后台录音"提示可以撤了。
unawaited(BackgroundSessionNotifier.cancel(
CallRecordingController.notificationIdFor(_tag),
));
}
@override
void dispose() {
final left = (_liveViews[_tag] ?? 1) - 1;
if (left <= 0) {
_liveViews.remove(_tag);
} else {
_liveViews[_tag] = left;
}
super.dispose();
}
static void _deleteControllerAfterPop(String tag) {
Future.delayed(const Duration(milliseconds: 400), () { Future.delayed(const Duration(milliseconds: 400), () {
// 这段时间里用户又进来了(或者压根没退成):controller 还有人用,别删。
if ((_liveViews[tag] ?? 0) > 0) return;
if (Get.isRegistered<CallRecordingController>(tag: tag)) { if (Get.isRegistered<CallRecordingController>(tag: tag)) {
Get.delete<CallRecordingController>(tag: tag, force: true); Get.delete<CallRecordingController>(tag: tag, force: true);
} }
@ -39,7 +102,7 @@ class CallRecordingView extends StatelessWidget {
/// 显示退出确认弹窗:录音中点返回(而不是点结束)时弹, /// 显示退出确认弹窗:录音中点返回(而不是点结束)时弹,
/// 「后台继续」不停录音、也不删这个 permanent controller。 /// 「后台继续」不停录音、也不删这个 permanent controller。
Future<_RecordingExitAction?> _showExitConfirmDialog(bool isDarkMode) { Future<_RecordingExitAction?> _showExitConfirmDialog() {
return Get.dialog<_RecordingExitAction>( return Get.dialog<_RecordingExitAction>(
AlertDialog( AlertDialog(
title: Text('meetingRecordExitTitle'.tr), title: Text('meetingRecordExitTitle'.tr),
@ -67,63 +130,81 @@ class CallRecordingView extends StatelessWidget {
); );
} }
Future<void> _handlePop(bool isDarkMode) async { Future<void> _handlePop() async {
if (!controller.isRecording.value) { if (_popHandling) return;
Get.back(); _popHandling = true;
_deleteControllerAfterPop(); final String tag = _tag;
return; try {
} final c = _controllerOrNull;
if (c == null || !c.isRecording.value) {
final action = await _showExitConfirmDialog(isDarkMode);
switch (action) {
case _RecordingExitAction.endAndSave:
// 弹窗等用户选择期间,录音可能已经被设备端(耳机挂断/按键)停掉了,
// 这里再查一次 isRecording,避免 toggle 把它当成"当前没在录"又启动一次。
if (controller.isRecording.value) {
await controller.toggleRecording();
}
Get.back(); Get.back();
_deleteControllerAfterPop(); _deleteControllerAfterPop(tag);
return;
case _RecordingExitAction.continueInBackground:
final route = Get.currentRoute;
final arguments = Get.arguments as Map<String, dynamic>?;
Get.back();
unawaited(BackgroundSessionNotifier.show(
id: CallRecordingController.notificationIdFor(_tag),
title: controller.pageTitle.tr,
body: 'backgroundRecordingIndicatorBody'.tr,
route: route,
arguments: arguments,
));
return;
case _RecordingExitAction.cancel:
case null:
return; return;
}
final action = await _showExitConfirmDialog();
switch (action) {
case _RecordingExitAction.endAndSave:
// ⚠️ **先关页面,再收尾**,顺序不能反。
// 停止录音这一路是 `停设备指令 → 关文件 → 归档`,归档要发
// `echomeet_addrecord`(Dio 30s connect + 30s receive 超时)。
// 通话中数据网络本来就可能不通,原先 `await toggleRecording()`
// 之后才 Get.back(),这一分钟里页面纹丝不动、点什么都没反应——
// 表现就是"进了通话录音界面出不来"。
// 落盘与归档由全局服务(BesCallRecordingService / HolderRecordingService)
// 或 controller 自己持有的通路完成,跟页面在不在没有关系。
Get.back();
final Future<void> stopping = c.isRecording.value
? c.toggleRecording()
: Future<void>.value();
unawaited(stopping.catchError((Object e) {
Logger.error('$_logTag 结束录音失败: $e');
}).whenComplete(() => _deleteControllerAfterPop(tag)));
return;
case _RecordingExitAction.continueInBackground:
Get.back();
unawaited(BackgroundSessionNotifier.show(
id: CallRecordingController.notificationIdFor(tag),
title: c.pageTitle.tr,
body: 'backgroundRecordingIndicatorBody'.tr,
route: _route,
arguments: _args,
));
return;
case _RecordingExitAction.cancel:
case null:
return;
}
} catch (e, st) {
// PopScope(canPop: false) 之下,这个回调是本页唯一的出口:
// 一旦这里抛异常,返回键就变成永久空操作,用户真的出不去了。
// 所以兜底直接放行,宁可少弹一次确认框。
Logger.error('$_logTag 处理返回失败,直接退出: $e\n$st');
Get.back();
_deleteControllerAfterPop(tag);
} finally {
_popHandling = false;
} }
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
// 用户从后台通知或其它页面点回来了,之前挂着的"还在后台录音"提示可以撤了。
unawaited(
BackgroundSessionNotifier.cancel(
CallRecordingController.notificationIdFor(_tag),
),
);
final isDarkMode = Theme.of(context).brightness == Brightness.dark; final isDarkMode = Theme.of(context).brightness == Brightness.dark;
final Color bg = isDarkMode ? Colors.black : AppPalette.brand50; final Color bg = isDarkMode ? Colors.black : AppPalette.brand50;
return PopScope( return PopScope(
canPop: false, canPop: false,
onPopInvokedWithResult: (didPop, result) async { onPopInvokedWithResult: (didPop, result) async {
if (didPop) return; if (didPop) return;
await _handlePop(isDarkMode); await _handlePop();
}, },
child: Scaffold( child: Scaffold(
backgroundColor: bg, backgroundColor: bg,
appBar: AppBar( appBar: AppBar(
backgroundColor: bg, backgroundColor: bg,
elevation: 0, elevation: 0,
// 显式走 maybePop,交给上面的 PopScope 统一判断,
// 不直接 Get.back(),否则录音中点返回箭头会绕过确认弹窗。
leading: BackButton(onPressed: () => Navigator.of(context).maybePop()),
title: Text(controller.pageTitle.tr), title: Text(controller.pageTitle.tr),
), ),
body: Column( body: Column(

160
apps/client/lib/modules/devices/views/bes_scan_view.dart

@ -32,9 +32,21 @@ class BesScanView extends StatefulWidget {
} }
class _BesScanViewState extends State<BesScanView> { class _BesScanViewState extends State<BesScanView> {
/// 目前恒玄这条链路只对接 DEEPVOICE 耳机,其它已配对的经典蓝牙设备 /// 展示用的设备名(提示文案、连接成功 snackbar 的兜底)。当前固件的广播名。
///
/// ⚠️ 大小写按设备端实际广播的写;**匹配**不要直接拿它比,走
/// [_acceptedNames]([_findTargets] 里统一转大写)——上一版常量恰好是全大写的
/// `DEEPVOICE`,跟大写后的设备名比才碰巧成立,改成混合大小写就会全部匹配不到。
static const String _targetName = 'Echo-one';
/// 认哪些广播名。恒玄这条链路只对接自家耳机,其它已配对的经典蓝牙设备
/// (车机、音箱、别家耳机)都不该出现在这个页面里,直接按名字写死过滤。 /// (车机、音箱、别家耳机)都不该出现在这个页面里,直接按名字写死过滤。
static const String _targetName = 'DEEPVOICE'; ///
/// 之所以是列表而不是单个值:设备端 2026-08-31 把广播名从 `DEEPVOICE` 改成
/// `Echo-one`,但 Android 的 `getBondedDevices` 给的是**系统缓存的配对名**,
/// 改名前配过对的手机在重新配对之前仍然报旧名,只认新名会让这些机器扫不到。
/// 旧名可在存量设备全部换代后删除。
static const List<String> _acceptedNames = ['Echo-one', 'DEEPVOICE'];
late final BesBluetoothService _service; late final BesBluetoothService _service;
final RxList<Map<String, dynamic>> _devices = <Map<String, dynamic>>[].obs; final RxList<Map<String, dynamic>> _devices = <Map<String, dynamic>>[].obs;
@ -49,8 +61,15 @@ class _BesScanViewState extends State<BesScanView> {
: Get.put(BesBluetoothService()); : Get.put(BesBluetoothService());
// 主动连接失败后耳机才连上手机的场景:原生靠 ACL 广播自动补 RFCOMM, // 主动连接失败后耳机才连上手机的场景:原生靠 ACL 广播自动补 RFCOMM,
// 状态一旦变成「已连接」,这页也要把绑定存下来并跳回设备页。 // 状态一旦变成「已连接」,这页也要把绑定存下来并跳回设备页。
//
// ⚠️ `_connecting` 期间不能开火:_connect 正在按候选列表逐个试,
// 第一个候选一连上就 _finish() 会把用户点的那台挤掉(存盘存成先连上的
// 那一台并直接跳走)。候选循环自己会收尾——
// · 回连发生在循环期间 → _waitConnected 看到 2 就 break,由 _connect 走 _finish;
// · 回连发生在循环之后 → finally 已把 _connecting 置回 false,这里照常开火。
// 两条路都有人收尾,不存在空档。
_statusWorker = ever<int>(_service.deviceStatus, (v) { _statusWorker = ever<int>(_service.deviceStatus, (v) {
if (v == 2 && mounted && !_finished) _finish(); if (v == 2 && mounted && !_finished && !_connecting) _finish();
}); });
WidgetsBinding.instance.addPostFrameCallback((_) => _load()); WidgetsBinding.instance.addPostFrameCallback((_) => _load());
} }
@ -86,12 +105,9 @@ class _BesScanViewState extends State<BesScanView> {
_loading.value = true; _loading.value = true;
try { try {
await _service.initialize(); await _service.initialize();
// 进页面时就已经连上了(冷启动自动回连 / 上个页面连过), // ⚠️ 进页面时已经连上(冷启动自动回连 / 上个页面连过)**不再直接跳回主页**。
// 不用再让用户点一次,直接按连接成功处理 // 这页要展示已配对的耳机并把「当前连着的那台」高亮出来,直接跳走的话
if (_service.deviceStatus.value == 2) { // 用户根本看不到这个区分。想回主页点那一行即可(走 _finish)。
if (!_finished) await _finish();
return;
}
final target = await _findTargets(); final target = await _findTargets();
_devices.assignAll(target); _devices.assignAll(target);
// 只有一台目标耳机时不用再让用户点一次,直接连。 // 只有一台目标耳机时不用再让用户点一次,直接连。
@ -122,7 +138,7 @@ class _BesScanViewState extends State<BesScanView> {
final list = await _service.getBondedDevices(); final list = await _service.getBondedDevices();
final target = list.where((d) { final target = list.where((d) {
final n = (d['name'] as String? ?? '').toUpperCase(); final n = (d['name'] as String? ?? '').toUpperCase();
return n.contains(_targetName); return _acceptedNames.any((t) => n.contains(t.toUpperCase()));
}).toList(); }).toList();
if (target.isNotEmpty || i == attempts - 1) return target; if (target.isNotEmpty || i == attempts - 1) return target;
await Future.delayed(const Duration(milliseconds: 300)); await Future.delayed(const Duration(milliseconds: 300));
@ -153,19 +169,30 @@ class _BesScanViewState extends State<BesScanView> {
} }
setState(() => _connecting = true); setState(() => _connecting = true);
try { try {
// Android 上耳机是 TWS 双地址:系统配对列表给的地址不一定是能跑 SPP 的那个 // 候选 = 用户点的那台(永远第一)+ 列表里的同名设备。
// (实测配对列表是 …C1:C4,SPP 实际走 …BE:F8)。iOS 上 address 是 CBPeripheral //
// UUID,不存在双地址问题,但多试一个也不会有副作用。所以统一按 // 身份由连上之后的 MAC 校验来定(见 BesDeviceAuth 与 _listen 的回填),
// 「原生当前已连接的 → 上次连成功过的 → 列表里的」依次试。 // 不由拨哪个地址来定。所以连接阶段只要「拨通一条链路」即可,
final candidates = <String>[]; // 不需要在地址层面追求精确——同名的另一行既可能是同一只耳机的另一个
try { // BR/EDR 地址(Android TWS 双地址:实测配对列表是 …C1:C4,SPP 走 …BE:F8),
final live = await _service.currentConnectedDevice(); // 也可能是另一台同款耳机,两种都值得试。
final liveId = live?['address'] ?? ''; //
if (liveId.isNotEmpty) candidates.add(liveId); // ⚠️ **不要把 currentConnectedDevice() / lastDeviceAddress 加回来。**
} catch (_) {} // 插件里没有任何扫描/发现路径,地址的唯一来源是 adapter.bondedDevices;
final last = _service.lastDeviceAddress; // 而这两个值都只是把 App 自己拨过的地址回吐(lastDeviceId 只在 connect()
if (last.isNotEmpty && !candidates.contains(last)) candidates.add(last); // 里赋值,lastDeviceAddress 来自 savePairedDevice 存的 connectedDeviceId)。
if (!candidates.contains(id)) candidates.add(id); // 它们永远产生不了一个「新地址」,唯一的实际效果是把**上一台耳机**塞进
// 候选并排在用户的选择前面——换耳机时就会连上旧的那台,或者在旧耳机
// 不在身边时白等一轮超时。
final tappedName = name.trim().toUpperCase();
final candidates = <String>[id];
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);
}
bool ok = false; bool ok = false;
for (final addr in candidates) { for (final addr in candidates) {
@ -192,12 +219,23 @@ class _BesScanViewState extends State<BesScanView> {
} }
} }
/// 等状态流推到「已连接」,超时返回当前状态 /// 等状态流推到「已连接」,超时返回当前状态。
///
/// 进过「连接中」又掉回 0 就提前判失败:原生 `connectBlocking` 失败时会
/// `publishBluetoothStatus(0)`,不必每个候选都干等满超时——遍历同名设备的
/// 总耗时会从「N × timeout」降到接近真实失败时间。
///
/// ⚠️ 仍要保留超时兜底:原生若把这次 connect 丢弃了(见 BluetoothManager.kt
/// 的 isConnecting 门禁),状态会停在 connectToDevice 自己写的 1,等不到 0。
Future<bool> _waitConnected( Future<bool> _waitConnected(
{Duration timeout = const Duration(seconds: 8)}) async { {Duration timeout = const Duration(seconds: 8)}) async {
final end = DateTime.now().add(timeout); final end = DateTime.now().add(timeout);
bool sawConnecting = false;
while (DateTime.now().isBefore(end)) { while (DateTime.now().isBefore(end)) {
if (_service.deviceStatus.value == 2) return true; final s = _service.deviceStatus.value;
if (s == 2) return true;
if (s == 1) sawConnecting = true;
if (sawConnecting && s == 0) return false;
await Future.delayed(const Duration(milliseconds: 300)); await Future.delayed(const Duration(milliseconds: 300));
} }
return _service.deviceStatus.value == 2; return _service.deviceStatus.value == 2;
@ -232,11 +270,22 @@ class _BesScanViewState extends State<BesScanView> {
return const Center(child: CircularProgressIndicator()); return const Center(child: CircularProgressIndicator());
} }
if (_devices.isEmpty) return _empty(isDark); if (_devices.isEmpty) return _empty(isDark);
// ⚠️ 连接态必须在**这里**读出来再传下去:ListView 的 itemBuilder 是
// 延迟调用的,在 _tile 里读 Rx 不会被这个 Obx 收集到,
// 耳机连上/断开时列表不会重绘。
final live = _service.deviceStatus.value == 2;
final connId = _service.connectedDeviceId.value;
final connName = _service.connectedDeviceName.value;
return ListView.separated( return ListView.separated(
padding: EdgeInsets.fromLTRB(16.w, 16.h, 16.w, 24.h), padding: EdgeInsets.fromLTRB(16.w, 16.h, 16.w, 24.h),
itemCount: _devices.length, itemCount: _devices.length,
separatorBuilder: (_, __) => SizedBox(height: 12.h), separatorBuilder: (_, __) => SizedBox(height: 12.h),
itemBuilder: (context, i) => _tile(_devices[i], isDark), itemBuilder: (context, i) => _tile(
_devices[i],
isDark,
connected:
live && _isLiveDevice(_devices[i], connId, connName),
),
); );
}), }),
); );
@ -271,22 +320,52 @@ class _BesScanViewState extends State<BesScanView> {
); );
} }
Widget _tile(Map<String, dynamic> d, bool isDark) { /// 这一行是不是当前正连着的那台。
///
/// ⚠️ 不能只比地址:Android 上耳机是 TWS 双地址,系统配对列表给的地址不一定
/// 是真正跑 SPP 的那个(实测配对列表是 …C1:C4,SPP 走 …BE:F8)。只比地址会让
/// 明明连着的那台显示成「未连接」。地址对不上时退回比设备名——本项目一次只
/// 连一台耳机,重名的风险可以忽略。
bool _isLiveDevice(
Map<String, dynamic> d, String connId, String connName) {
final addr = (d['address'] as String? ?? '').toUpperCase();
if (addr.isNotEmpty && addr == connId.toUpperCase()) return true;
final n = (d['name'] as String? ?? '').trim().toUpperCase();
final cn = connName.trim().toUpperCase();
return n.isNotEmpty && n == cn;
}
/// 一行设备。[connected] 决定这一行是高亮态还是置灰态。
///
/// 置灰的是「配过对但此刻没连上」的存量设备——它们仍然可点(点了就去连),
/// 只是视觉权重压下去,让用户一眼看出哪台是活的。
Widget _tile(Map<String, dynamic> d, bool isDark, {bool connected = false}) {
final name = (d['name'] ?? 'bleUnknownDevice'.tr) as String; final name = (d['name'] ?? 'bleUnknownDevice'.tr) as String;
final addr = (d['address'] ?? '') as String; final addr = (d['address'] ?? '') as String;
const Color accent = AppPalette.accent;
final Color idle = AppPalette.textHint(isDark);
return GestureDetector( return GestureDetector(
behavior: HitTestBehavior.opaque, behavior: HitTestBehavior.opaque,
onTap: () => _connect(d), // 已经连着的那台点了就是「用这台」——存盘并回主页,跟连接成功后同一条路径
onTap: () => connected ? _finish() : _connect(d),
child: Container( child: Container(
padding: EdgeInsets.all(14.w), padding: EdgeInsets.all(14.w),
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppPalette.surface(isDark), color: connected
? accent.withValues(alpha: isDark ? 0.16 : 0.08)
: AppPalette.surface(isDark),
borderRadius: BorderRadius.circular(14.r), borderRadius: BorderRadius.circular(14.r),
border: Border.all(color: AppPalette.border(isDark)), border: Border.all(
color: connected ? accent : AppPalette.border(isDark),
width: connected ? 1.5 : 1,
),
), ),
child: Row( child: Row(
children: [ children: [
Icon(Icons.headphones, size: 26.sp, color: AppPalette.accent), Icon(Icons.headphones,
size: 26.sp, color: connected ? accent : idle),
SizedBox(width: 12.w), SizedBox(width: 12.w),
Expanded( Expanded(
child: Column( child: Column(
@ -295,21 +374,26 @@ class _BesScanViewState extends State<BesScanView> {
Text(name, Text(name,
style: TextStyle( style: TextStyle(
fontSize: 15.sp, fontSize: 15.sp,
fontWeight: FontWeight.w600, fontWeight:
color: AppPalette.textPrimary(isDark))), connected ? FontWeight.w700 : FontWeight.w500,
color: connected
? AppPalette.textPrimary(isDark)
: AppPalette.textSecondary(isDark))),
SizedBox(height: 2.h), SizedBox(height: 2.h),
Text(addr, Text(addr,
style: TextStyle( style: TextStyle(fontSize: 11.sp, color: idle)),
fontSize: 11.sp,
color: AppPalette.textHint(isDark))),
], ],
), ),
), ),
Text('连接', if (connected) ...[
Icon(Icons.check_circle, size: 16.sp, color: accent),
SizedBox(width: 4.w),
],
Text(connected ? 'connected'.tr : 'connect'.tr,
style: TextStyle( style: TextStyle(
fontSize: 13.sp, fontSize: 13.sp,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: AppPalette.accent)), color: connected ? accent : idle)),
], ],
), ),
), ),

17
apps/client/lib/modules/home/controllers/home_controller.dart

@ -85,6 +85,17 @@ class HomeController extends GetxController {
final noviceGuideService = Get.find<NoviceGuideService>(); final noviceGuideService = Get.find<NoviceGuideService>();
/// 按设备名安全地查产品(AppConfig.getproduct 会抛,见调用处注释)。
DBProduct? _findProductByName(String deviceName) {
if (!AppConfig.isInitialized()) return null;
final n = deviceName.trim();
if (n.isEmpty) return null;
for (final p in AppConfig.config.products) {
if (p.devicename.contains(n)) return p;
}
return null;
}
@override @override
void onInit() { void onInit() {
super.onInit(); super.onInit();
@ -171,7 +182,11 @@ class HomeController extends GetxController {
try { try {
if (!_storage.hasData('agent_tts_interrupt')) { if (!_storage.hasData('agent_tts_interrupt')) {
final deviceName = getDeviceName(); final deviceName = getDeviceName();
final DBProduct? product = AppConfig.getproduct(deviceName); // 这里用本地的 _findProductByName 而不是 AppConfig.getproduct():
// 后者只做「产品名包含设备名」单向匹配,本地这份是双向包含 + 兜底,
// 设备名与产品名不完全一致时才匹配得上。
// (getproduct 本身已经不会抛了,2026-09-03 起查不到返回 null。)
final DBProduct? product = _findProductByName(deviceName);
if (deviceName.isNotEmpty && product != null) { if (deviceName.isNotEmpty && product != null) {
isTtsInterruptEnabled.value = !(product.iscontinuouschat ?? true); isTtsInterruptEnabled.value = !(product.iscontinuouschat ?? true);
} else { } else {

22
apps/client/lib/modules/login/views/login_view.dart

@ -68,15 +68,15 @@ class LoginView extends GetView<LoginController> {
Widget _buildAppLogo(bool isDarkMode) { Widget _buildAppLogo(bool isDarkMode) {
return Column( return Column(
children: [ children: [
// 登录页 logo:用 App 自己的图标,替掉原来的「蓝紫渐变 + 耳机 glyph」。
// 与启动页、关于应用、固件页共用 assets/images/app_icon.png:
// 那是张不带圆角的正方形(iOS AppIcon 1024 缩到 512),
// 圆角靠 ClipRRect 切;渐变底去掉,因为图标自带深色不透明底,
// 叠上去只会在圆角处露出一圈脏边。
Container( Container(
width: 80.w, width: 80.w,
height: 80.w, height: 80.w,
decoration: BoxDecoration( decoration: BoxDecoration(
gradient: const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFF0066CC), Color(0xFF5E5CE6)],
),
borderRadius: BorderRadius.circular(24.r), borderRadius: BorderRadius.circular(24.r),
boxShadow: [ boxShadow: [
BoxShadow( BoxShadow(
@ -86,10 +86,14 @@ class LoginView extends GetView<LoginController> {
), ),
], ],
), ),
child: Icon( child: ClipRRect(
Icons.headset, borderRadius: BorderRadius.circular(24.r),
size: 40.sp, child: Image.asset(
color: Colors.white, 'assets/images/app_icon.png',
width: 80.w,
height: 80.w,
fit: BoxFit.cover,
),
), ),
), ),
SizedBox(height: 16.h), SizedBox(height: 16.h),

130
apps/client/lib/modules/main_tab/views/tabs/device_tab.dart

@ -24,6 +24,13 @@ class DeviceTab extends StatelessWidget {
/// 设备图片区高度:不论连没连上都占住,避免连接前后布局跳动 /// 设备图片区高度:不论连没连上都占住,避免连接前后布局跳动
static const double _imageAreaHeight = 300; static const double _imageAreaHeight = 300;
/// 底部信息(左下 MAC / 右下电量)的专属高度。
///
/// 这两块原先都贴在 `bottom: 0`,与居中的耳机主图压在一起、挡住图片下沿。
/// 这里把它们下移到图片区之外:图片仍在上方 [_imageAreaHeight] 内居中
/// (位置和大小都不变),整块区域高度加上这一段。
static const double _bottomInfoInset = 28;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final bool isDarkMode = Theme.of(context).brightness == Brightness.dark; final bool isDarkMode = Theme.of(context).brightness == Brightness.dark;
@ -125,13 +132,20 @@ class DeviceTab extends StatelessWidget {
HomeController controller, HomeController controller,
) { ) {
return SizedBox( return SizedBox(
height: _imageAreaHeight.h, height: (_imageAreaHeight + _bottomInfoInset).h,
child: Stack( child: Stack(
children: [ children: [
Positioned.fill( // 图片只占上方 _imageAreaHeight,底部那段留给 MAC / 电量,
child: Center( // 免得信息压在耳机主图上
child: _buildImage( Positioned(
isDarkMode, connected, holderOn, besOn, controller))), left: 0,
right: 0,
top: 0,
bottom: _bottomInfoInset.h,
child: Center(
child: _buildImage(
isDarkMode, connected, holderOn, besOn, controller)),
),
// 右上:连接状态 // 右上:连接状态
Positioned( Positioned(
right: 0, right: 0,
@ -260,9 +274,15 @@ class DeviceTab extends StatelessWidget {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
_infoLine(Icons.memory, mac, textColor), _infoLine(Icons.memory, mac, textColor),
SizedBox(height: 3.h), // ⚠️ 没有授权码就整行不显示,**不要**退回显示「授权码未知」。
_infoLine(Icons.vpn_key_outlined, // 经典蓝牙耳机(恒玄)这条链路压根没有授权码:绑定时 code 传空,
license.isEmpty ? 'licenseUnknown'.tr : license, textColor), // 服务端是按 MAC 去 license 表反查的(api_binddevice.go),
// 所以 DBUserDivice.License 恒为空串。显示「未知」会让人以为
// 「本该有、只是没读到」,跑去查一个根本不存在的东西。
if (license.isNotEmpty) ...[
SizedBox(height: 3.h),
_infoLine(Icons.vpn_key_outlined, license, textColor),
],
], ],
); );
} }
@ -303,44 +323,74 @@ class DeviceTab extends StatelessWidget {
bool besOn, HomeController controller) { bool besOn, HomeController controller) {
if (!connected) return const SizedBox.shrink(); if (!connected) return const SizedBox.shrink();
return Obx(() { return Obx(() {
// 恒玄耳机:SPP 链路暂时没有电量上报,只展示 MAC // 恒玄耳机:电量随 `BB 0A` 状态帧上报(左/右/仓分开,带充电位)
if (besOn) { if (besOn) {
final Color c = final Color c =
isDarkMode ? Colors.white70 : const Color(0xFF444444); isDarkMode ? Colors.white70 : const Color(0xFF444444);
final mac = BesBluetoothService.to.connectedDeviceId.value; final bes = BesBluetoothService.to;
final ver = BesBluetoothService.to.deviceVersion.value; // ⚠️ 别直接用 connectedDeviceId:那在 iOS 上是 CoreBluetooth 的
final battery = BesBluetoothService.to.batteryLevel.value; // peripheral UUID(每台 iPhone 都不一样),显示出来根本不是 MAC。
if (mac.isEmpty && ver.isEmpty && battery < 0) { // 耳机在 `BB 0A` 属性 7 里自报的地址两端一致,优先用它;
// 它要等一帧状态回包才有值,那之前先拿 connectedDeviceId 顶着
// (Android 上本来就等于最终值,iOS 上会在收到帧后自动刷成真 MAC)。
final reported = bes.deviceMac.value;
final mac = reported.isNotEmpty ? reported : bes.connectedDeviceId.value;
final battery = bes.batteryLevel.value;
final left = bes.batteryLeft.value;
final right = bes.batteryRight.value;
final box = bes.batteryCase.value;
if (mac.isEmpty && battery < 0) {
return const SizedBox.shrink(); return const SizedBox.shrink();
} }
// 耳机主图是实拍照片,直接把文字压在上面看不清,加一层半透明底 // 固件版本不在这里显示——固件升级页(Routes.OTA_UPGRADE)已经有了。
return Container( // 也不套半透明底:直接压在耳机主图上。
padding: EdgeInsets.symmetric(horizontal: 8.w, vertical: 5.h), return Column(
decoration: BoxDecoration( crossAxisAlignment: CrossAxisAlignment.end,
color: (isDarkMode ? Colors.black : Colors.white).withOpacity(0.72), mainAxisSize: MainAxisSize.min,
borderRadius: BorderRadius.circular(8.r), children: [
), if (mac.isNotEmpty) _infoLine(Icons.memory, mac, c),
child: Column( // 电量一行排开,顺序固定 C → L → R。
crossAxisAlignment: CrossAxisAlignment.end, // 每个都可能缺:盒电量只在耳机入盒时上报(属性 3 是可选的),
mainAxisSize: MainAxisSize.min, // 左右耳只有单侧数据时退化成一个不带标的电池。
children: [ if (box >= 0 || left >= 0 || right >= 0 || battery >= 0) ...[
if (mac.isNotEmpty) _infoLine(Icons.memory, mac, c), SizedBox(height: 4.h),
// 版本号旁边跟电量;电量没拿到就不占位 Row(
if (ver.isNotEmpty || battery >= 0) ...[ mainAxisSize: MainAxisSize.min,
SizedBox(height: 4.h), // 用 spacing 而不是手动插 SizedBox:只有 C 一个电池时,
Row( // 尾随的间距会把它整体推离右边缘。
mainAxisSize: MainAxisSize.min, spacing: 8.w,
children: [ children: [
if (ver.isNotEmpty) if (box >= 0)
_infoLine(Icons.memory_outlined, 'v$ver', c), BatteryIndicator(
if (ver.isNotEmpty && battery >= 0) SizedBox(width: 8.w), level: box,
if (battery >= 0) label: 'C',
BatteryIndicator(level: battery, isDarkMode: isDarkMode), isDarkMode: isDarkMode,
], charging: bes.chargingCase.value,
), ),
], if (left >= 0 && right >= 0) ...[
BatteryIndicator(
level: left,
label: 'L',
isDarkMode: isDarkMode,
charging: bes.chargingLeft.value,
),
BatteryIndicator(
level: right,
label: 'R',
isDarkMode: isDarkMode,
charging: bes.chargingRight.value,
),
] else if (battery >= 0)
BatteryIndicator(
level: battery,
isDarkMode: isDarkMode,
charging:
bes.chargingLeft.value || bes.chargingRight.value,
),
],
),
], ],
), ],
); );
} }
// 蓝讯支架是单电池设备,直接用 SDK 上报的电量 // 蓝讯支架是单电池设备,直接用 SDK 上报的电量

70
apps/client/lib/modules/main_tab/views/widgets/module_card.dart

@ -101,17 +101,24 @@ class ModuleCard extends StatelessWidget {
), ),
), ),
), ),
// 未实名时直接在卡片上说明,别让用户点了才知道 // 未实名时直接在卡片上说明,别让用户点了才知道。
if (IdVerifyGuard.required) ...[ // 走 IdVerifyGuard.builder(内部是 Obx):认证通过的那一刻
SizedBox(width: 6.w), // 角标就得消失,写成 `if (IdVerifyGuard.required)` 不会重建。
_tag('idVerifyNotDone'.tr, AppPalette.brand400), IdVerifyGuard.builder((needVerify) {
] else if (module.comingSoon) ...[ if (needVerify) {
SizedBox(width: 6.w), return _gap(
_tag('comingSoonTag'.tr, AppPalette.brand400), _tag('idVerifyNotDone'.tr, AppPalette.brand400));
] else if (module.requiresDevice) ...[ }
SizedBox(width: 6.w), if (module.comingSoon) {
_tag('needDeviceTag'.tr, AppPalette.warning), return _gap(
], _tag('comingSoonTag'.tr, AppPalette.brand400));
}
if (module.requiresDevice) {
return _gap(
_tag('needDeviceTag'.tr, AppPalette.warning));
}
return const SizedBox.shrink();
}),
], ],
), ),
if (showSubtitle && module.subtitle != null) ...[ if (showSubtitle && module.subtitle != null) ...[
@ -137,6 +144,11 @@ class ModuleCard extends StatelessWidget {
); );
} }
/// 角标左侧的间距。原先是 children 里的 SizedBox,改成 Obx 之后整块只能返回
/// 一个 widget,间距只好跟着一起包进来。
Widget _gap(Widget child) =>
Padding(padding: EdgeInsets.only(left: 6.w), child: child);
Widget _tag(String text, Color color) { Widget _tag(String text, Color color) {
return Container( return Container(
padding: EdgeInsets.symmetric(horizontal: 6.w, vertical: 2.h), padding: EdgeInsets.symmetric(horizontal: 6.w, vertical: 2.h),
@ -241,23 +253,27 @@ class ModuleSquareCard extends StatelessWidget {
style: TextStyle(fontSize: 11.sp, color: subColor), style: TextStyle(fontSize: 11.sp, color: subColor),
), ),
], ],
// 未实名:点之前就让用户知道要先认证 // 未实名:点之前就让用户知道要先认证。
if (IdVerifyGuard.required) ...[ // 同上,必须走 builder(Obx) 才能在认证通过时立刻收起来。
SizedBox(height: 6.h), IdVerifyGuard.builder((needVerify) {
Container( if (!needVerify) return const SizedBox.shrink();
padding: EdgeInsets.symmetric( return Padding(
horizontal: 6.w, vertical: 2.h), padding: EdgeInsets.only(top: 6.h),
decoration: BoxDecoration( child: Container(
color: AppPalette.brand400.withOpacity(0.12), padding: EdgeInsets.symmetric(
borderRadius: BorderRadius.circular(4.r), horizontal: 6.w, vertical: 2.h),
), decoration: BoxDecoration(
child: Text( color: AppPalette.brand400.withOpacity(0.12),
'idVerifyNotDone'.tr, borderRadius: BorderRadius.circular(4.r),
style: TextStyle( ),
fontSize: 9.sp, color: AppPalette.brand400), child: Text(
'idVerifyNotDone'.tr,
style: TextStyle(
fontSize: 9.sp, color: AppPalette.brand400),
),
), ),
), );
], }),
], ],
) )
: Row( : Row(

55
apps/client/lib/modules/my_devices/controllers/my_devices_controller.dart

@ -3,6 +3,8 @@ import 'package:get/get.dart';
import '/core/utils/logger.dart'; import '/core/utils/logger.dart';
import '/data/models/user_Info.dart'; import '/data/models/user_Info.dart';
import '/data/services/ble_manager.dart'; import '/data/services/ble_manager.dart';
import '/data/services/bes_bluetooth_service.dart';
import '/data/services/bes_device_auth.dart';
import '/data/services/network/api.dart'; import '/data/services/network/api.dart';
import '/routes/app_routes.dart'; import '/routes/app_routes.dart';
@ -35,6 +37,11 @@ class MyDevicesController extends GetxController {
if (!User.isLoggedIn()) return; if (!User.isLoggedIn()) return;
isLoading.value = true; isLoading.value = true;
try { try {
// 补校当前连着的恒玄耳机(顺带完成绑定登记)。
// 「先连耳机、后登录」是很常见的顺序,那时候连接事件里的登记会因为
// 未登录而跳过;不在这里补一次,这台设备就要等下次重连才会出现在列表里。
// 放在拉取之前,这样这次刷新就能看到它。
await BesDeviceAuth.verifyConnected();
final response = await Api.getdevices(); final response = await Api.getdevices();
if (response != null) { if (response != null) {
final list = response['devices'] as List<dynamic>?; final list = response['devices'] as List<dynamic>?;
@ -63,12 +70,39 @@ class MyDevicesController extends GetxController {
if (response == null) return; if (response == null) return;
User.instance.removeUserDevice(device.id); User.instance.removeUserDevice(device.id);
_refreshFromUser(); // 解绑要把这台在本地留下的痕迹一次清干净,两处缺一不可:
// 1. 确权白名单——不清的话下次连上会命中缓存、跳过 binddevice,
// 这台设备再也回不到列表里;
// 2. 已配对列表 + lastDevice——不清的话 tryAutoConnect 和原生 ACL 广播
// 还会把它自动连回来,用户「解绑了但它还连着」。
// removePairedDevice 内部会顺带断开当前连接。
await BesDeviceAuth.forget(device.devicemac);
// 解绑的是不是当前连着的那台恒玄耳机。
// ⚠️ 判据不能只看 connectedDeviceId:iOS 上那是 CoreBluetooth 的
// peripheral UUID,跟服务端记录里的真 MAC 永远对不上。耳机自报的
// deviceMac 才是能把两边对应起来的值。必须在 removePairedDevice
// **之前**取,那之后连接已经被断开、这两个值都清空了。
bool besWasCurrent = false;
if (device.devicemac.isNotEmpty && if (device.devicemac.isNotEmpty &&
_bleManager.currentDeviceAddress == device.devicemac) { Get.isRegistered<BesBluetoothService>()) {
Logger.i(_tag, '解绑的设备就是当前连接,断开并回到扫描页'); final bes = BesBluetoothService.to;
final String t = device.devicemac.toUpperCase();
besWasCurrent = bes.isConnected &&
(bes.connectedDeviceId.value.toUpperCase() == t ||
bes.deviceMac.value.toUpperCase() == t);
await bes.removePairedDevice(device.devicemac);
}
_refreshFromUser();
final bool jieliWasCurrent = device.devicemac.isNotEmpty &&
_bleManager.currentDeviceAddress == device.devicemac;
if (jieliWasCurrent) {
await _bleManager.disconnect(); await _bleManager.disconnect();
}
// 恒玄那条的断开已由 removePairedDevice 做完,这里只负责离开本页
if (jieliWasCurrent || besWasCurrent) {
Logger.i(_tag, '解绑的设备就是当前连接,已断开,回到主框架');
await Future.delayed(const Duration(milliseconds: 300)); await Future.delayed(const Duration(milliseconds: 300));
// 本页已去掉「添加设备」入口,解绑当前设备后回主框架, // 本页已去掉「添加设备」入口,解绑当前设备后回主框架,
// 让用户从设备页左上角重新走「选择设备 → 扫描」流程 // 让用户从设备页左上角重新走「选择设备 → 扫描」流程
@ -93,10 +127,23 @@ class MyDevicesController extends GetxController {
allDevices.assignAll(flat); allDevices.assignAll(flat);
} }
/// 当前是不是正连着这一台。**只看本地蓝牙状态,不问服务端**——
/// 连接是此刻的链路状态,服务端存的是绑定关系,两回事。
///
/// ⚠️ 原来只查了 [BleManager](杰理链路),而本项目实际用的是恒玄耳机,
/// 于是这里对所有设备恒为 false,列表上永远显示"未连接"。
bool isConnected(UserDevice device) { bool isConnected(UserDevice device) {
if (device.devicemac.isEmpty) return false; if (device.devicemac.isEmpty) return false;
final mac = device.devicemac.toUpperCase();
if (Get.isRegistered<BesBluetoothService>()) {
final bes = BesBluetoothService.to;
if (bes.isConnected &&
bes.connectedDeviceId.value.toUpperCase() == mac) {
return true;
}
}
return _bleManager.isConnected && return _bleManager.isConnected &&
_bleManager.currentDeviceAddress == device.devicemac; _bleManager.currentDeviceAddress?.toUpperCase() == mac;
} }
void goToScan() { void goToScan() {

58
apps/client/lib/modules/my_devices/views/my_devices_view.dart

@ -3,6 +3,7 @@ import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import '/data/models/user_Info.dart'; import '/data/models/user_Info.dart';
import '../../../routes/app_routes.dart';
import '../controllers/my_devices_controller.dart'; import '../controllers/my_devices_controller.dart';
enum _MyDeviceCategory { enum _MyDeviceCategory {
@ -65,13 +66,48 @@ class MyDevicesView extends GetView<MyDevicesController> {
body: Column( body: Column(
children: [ children: [
_buildTopArea(context, isDark, bg), _buildTopArea(context, isDark, bg),
// 只列已绑定设备(服务端拉取)+ 解绑;添加设备统一走设备页左上角入口 // 只列已绑定设备(服务端拉取)+ 解绑
Expanded(child: _buildDevicesList(isDark, topBg)), Expanded(child: _buildDevicesList(isDark, topBg)),
_buildAddDeviceButton(context, isDark),
], ],
), ),
); );
} }
/// 底部「添加设备」:与设备页左上角那个入口同一个去处(`Routes.deviceCategory`)。
/// 加在这里是因为列表为空时用户在本页无路可走,得退回上一级才能找到入口。
Widget _buildAddDeviceButton(BuildContext context, bool isDark) {
return Padding(
padding: EdgeInsets.fromLTRB(
20.w,
8.h,
20.w,
// 手势条/虚拟键区域会盖住按钮,底部安全区要单独加进来
MediaQuery.of(context).padding.bottom + 16.h,
),
child: SizedBox(
width: double.infinity,
height: 50.h,
child: ElevatedButton.icon(
onPressed: () => Get.toNamed(Routes.deviceCategory),
icon: Icon(Icons.add, size: 20.sp),
label: Text(
'addDevice'.tr,
style: TextStyle(fontSize: 16.sp, fontWeight: FontWeight.w600),
),
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF6366F1), // 与本页卡片选中态同色
foregroundColor: Colors.white,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14.r),
),
),
),
),
);
}
Widget _buildTopArea(BuildContext context, bool isDark, Color bg) { Widget _buildTopArea(BuildContext context, bool isDark, Color bg) {
return Container( return Container(
padding: EdgeInsets.only( padding: EdgeInsets.only(
@ -435,7 +471,7 @@ class MyDevicesView extends GetView<MyDevicesController> {
), ),
], ],
), ),
if (device.license.isNotEmpty || device.cmei.isNotEmpty) ...[ if (_hasRealLicense(device) || device.cmei.isNotEmpty) ...[
SizedBox(height: 14.h), SizedBox(height: 14.h),
Container( Container(
padding: EdgeInsets.all(12.w), padding: EdgeInsets.all(12.w),
@ -447,12 +483,12 @@ class MyDevicesView extends GetView<MyDevicesController> {
), ),
child: Column( child: Column(
children: [ children: [
if (device.license.isNotEmpty) if (_hasRealLicense(device))
_buildFieldRow( _buildFieldRow(
label: 'licenseCode'.tr, label: 'licenseCode'.tr,
value: device.license, value: device.license,
isDark: isDark), isDark: isDark),
if (device.license.isNotEmpty && device.cmei.isNotEmpty) if (_hasRealLicense(device) && device.cmei.isNotEmpty)
SizedBox(height: 8.h), SizedBox(height: 8.h),
if (device.cmei.isNotEmpty) if (device.cmei.isNotEmpty)
_buildFieldRow( _buildFieldRow(
@ -466,6 +502,20 @@ class MyDevicesView extends GetView<MyDevicesController> {
); );
} }
/// 这台设备有没有「值得展示」的授权码。
///
/// ⚠️ 光判非空不够:服务端绑定时写的是**从 license 表查到的** code
/// (api_binddevice.go 的 `License: model.Code`,不是客户端传的空串),
/// 而后台「导入 MAC」模式建的授权码,code 就是 MAC 串本身。
/// 于是界面上会出现 MAC 一行、授权码一行、两行值一模一样——看着像 bug。
/// 这种占位值不展示;真授权码(如 71E5E5C3A797C043B5A7)照常展示。
bool _hasRealLicense(UserDevice device) {
final lic = device.license.trim();
if (lic.isEmpty) return false;
String norm(String v) => v.replaceAll(':', '').replaceAll('-', '').toUpperCase();
return norm(lic) != norm(device.devicemac);
}
Widget _buildFieldRow({ Widget _buildFieldRow({
required String label, required String label,
required String value, required String value,

6
apps/client/lib/modules/ota_upgrade/bindings/ota_upgrade_binding.dart

@ -1,9 +1,11 @@
import 'package:get/get.dart'; import 'package:get/get.dart';
import '../controllers/ota_upgrade_controller.dart'; import '../controllers/bes_ota_upgrade_controller.dart';
class OtaUpgradeBinding extends Bindings { class OtaUpgradeBinding extends Bindings {
@override @override
void dependencies() { void dependencies() {
Get.lazyPut<OtaUpgradeController>(() => OtaUpgradeController()); // 本项目只有恒玄耳机,固件升级走 BES 链路。杰理那版 OtaUpgradeController
// 依赖 BleManager/Jielihome,已不再由路由装配。
Get.lazyPut<BesOtaUpgradeController>(() => BesOtaUpgradeController());
} }
} }

153
apps/client/lib/modules/ota_upgrade/controllers/bes_ota_download_service.dart

@ -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');
}
}
}

495
apps/client/lib/modules/ota_upgrade/controllers/bes_ota_upgrade_controller.dart

@ -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();
}
}

98
apps/client/lib/modules/ota_upgrade/controllers/ota_core_contropller.dart

@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:logger/web.dart'; import 'package:logger/web.dart';
import '../../../data/services/ble_manager.dart'; import '../../../data/services/ble_manager.dart';
import '../../../data/services/bes_bluetooth_service.dart';
import '../../../data/services/bluetooth_manager.dart'; // 添加蓝牙管理器导入 import '../../../data/services/bluetooth_manager.dart'; // 添加蓝牙管理器导入
import '../../../data/models/appconfig.dart'; import '../../../data/models/appconfig.dart';
import '../../../data/models/appconfig_model.dart'; import '../../../data/models/appconfig_model.dart';
@ -208,14 +209,11 @@ class OtaCoreService {
// 获取设备类型以确定检查策略 // 获取设备类型以确定检查策略
String deviceName = getDeviceName(); String deviceName = getDeviceName();
DBProduct? product = AppConfig.getproduct(deviceName); final DBProduct? product = findProduct(deviceName);
if (product == null) { if (product == null) {
// 如果根据设备名称找不到产品,尝试使用默认产品名称 _showBatteryCheckFailedSnackbar('otaCannotGetProductInfoWithName'
product = AppConfig.getproduct("DeapSound"); .trParams({'deviceName': deviceName}));
if (product == null) { return false;
_showBatteryCheckFailedSnackbar('otaCannotGetProductInfoWithName'.trParams({'deviceName': deviceName}));
return false;
}
} }
final leftBattery = deviceInfo.leftEarBattery; final leftBattery = deviceInfo.leftEarBattery;
@ -418,6 +416,33 @@ class OtaCoreService {
// ==================== 版本管理功能 ==================== // ==================== 版本管理功能 ====================
/// 按设备名安全地查产品配置。
///
/// 匹配规则比 `AppConfig.getproduct()` 宽:后者只做「产品名包含设备名」这一个
/// 方向,恒玄耳机的广播名与产品表里的名字常常互为子串的另一头,单向匹配会漏。
/// (getproduct 本身已经不会抛了,2026-09-03 起查不到返回 null。)
///
/// 顺序:设备名互相包含 → 唯一的经典蓝牙耳机产品 → null。
/// 老代码里的兜底名 "DeapSound" 是上一代品牌,早已不在产品表里,去掉了。
DBProduct? findProduct(String deviceName) {
if (!AppConfig.isInitialized()) return null;
final products = AppConfig.config.products;
if (products.isEmpty) return null;
final n = deviceName.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;
}
/// 动态获取当前连接的设备名称 /// 动态获取当前连接的设备名称
/// 参考home_controller中的getDeviceName方法实现 /// 参考home_controller中的getDeviceName方法实现
String getDeviceName() { String getDeviceName() {
@ -434,6 +459,14 @@ class OtaCoreService {
return deviceInfo.name; return deviceInfo.name;
} }
// 恒玄耳机不在杰理 RCSP 链路上,上面两个来源都拿不到它。
// 少了这一支,恒玄用户的设备名恒为 unknownEquipment,
// 后面按名字查产品必然落空 → 固件页取不到服务端版本。
if (Get.isRegistered<BesBluetoothService>()) {
final besName = BesBluetoothService.to.connectedDeviceName.value;
if (besName.isNotEmpty) return besName;
}
// 最后从BLE管理器的最后连接设备名称获取 // 最后从BLE管理器的最后连接设备名称获取
// if (_bleManager.currentDeviceName.isNotEmpty) { // if (_bleManager.currentDeviceName.isNotEmpty) {
// return _bleManager.currentDeviceName; // return _bleManager.currentDeviceName;
@ -480,17 +513,13 @@ class OtaCoreService {
String deviceName = getDeviceName(); String deviceName = getDeviceName();
// 获取产品信息以确定设备类型 // 获取产品信息以确定设备类型
DBProduct? product = AppConfig.getproduct(deviceName); final DBProduct? product = findProduct(deviceName);
if (product == null) { if (product == null) {
// 如果根据设备名称找不到产品,尝试使用默认产品名称 throw OtaCoreException(
product = AppConfig.getproduct("DeapSound"); type: OtaCoreErrorType.productInfoUnavailable,
if (product == null) { params: {'deviceName': deviceName},
throw OtaCoreException( debugMessage: 'productInfoUnavailable: $deviceName', //根据设备名称未找到产品信息
type: OtaCoreErrorType.productInfoUnavailable, );
params: {'deviceName': deviceName},
debugMessage: 'productInfoUnavailable: $deviceName', //根据设备名称未找到产品信息
);
}
} }
// 根据设备类型进行不同的版本校验 // 根据设备类型进行不同的版本校验
@ -559,17 +588,15 @@ class OtaCoreService {
// 动态获取当前连接的设备名称 // 动态获取当前连接的设备名称
String deviceName = getDeviceName(); String deviceName = getDeviceName();
DBProduct? product = AppConfig.getproduct(deviceName); final DBProduct? product = findProduct(deviceName);
if (product == null) { if (product == null) {
// 如果根据设备名称找不到产品,尝试使用默认产品名称 throw "无法获取产品信息,设备名称: $deviceName";
product = AppConfig.getproduct("DeapSound");
if (product == null) {
throw "无法获取产品信息,设备名称: $deviceName";
}
} }
product.isforceupdate = product.version?.contains("+"); // ⚠️ 强更以**后台 product.isforceupdate 字段**为准。
// 原来写的是 `product.version?.contains("+")`——拿版本号里有没有 "+"
// 当强更开关,跟后台配置完全脱节(后台勾了拦不住、版本号带 + 的全被强更)。
// 这和 App 版本更新那边踩过的土规则是同一个毛病,别改回去。
return { return {
'latestVersion': product.version?.split('+').first ?? "", 'latestVersion': product.version?.split('+').first ?? "",
'updateDescription': product.updatedescription ?? "", 'updateDescription': product.updatedescription ?? "",
@ -578,16 +605,29 @@ class OtaCoreService {
}; };
} }
/// 把版本串按 `.` 切开,每段只取前导数字(`3+4` → 3,`0-beta` → 0)。
/// 与服务端 comm.CompareVersion 的口径一致。
static 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();
}
/// 比较版本号,判断是否有新版本可用 /// 比较版本号,判断是否有新版本可用
/// [currentVersion] 当前版本号 /// [currentVersion] 当前版本号
/// [latestVersion] 最新版本号 /// [latestVersion] 最新版本号
/// 返回true表示有新版本可用(只支持升级) /// 返回true表示有新版本可用(只支持升级)
bool isNewVersionAvailable(String currentVersion, String latestVersion) { bool isNewVersionAvailable(String currentVersion, String latestVersion) {
try { try {
List<int> currentParts = // ⚠️ 逐段取**前导数字**,不能用 int.parse:固件版本出现过
currentVersion.split('.').map((e) => int.parse(e.trim())).toList(); // `1.0.0-beta` / `1.2.3+4` 这类后缀,int.parse 直接抛,
List<int> latestParts = // 异常被下面的 catch 吞掉后返回 false —— 表现是「明明有新版本却说已是最新」。
latestVersion.split('.').map((e) => int.parse(e.trim())).toList(); List<int> currentParts = _splitVersion(currentVersion);
List<int> latestParts = _splitVersion(latestVersion);
if (currentParts.isEmpty || latestParts.isEmpty) return false;
// 确保两个版本号长度一致,短的用0补齐 // 确保两个版本号长度一致,短的用0补齐
int maxLength = currentParts.length > latestParts.length int maxLength = currentParts.length > latestParts.length

36
apps/client/lib/modules/ota_upgrade/controllers/ota_upgrade_controller.dart

@ -100,16 +100,28 @@ class OtaUpgradeController extends GetxController {
/// 初始化控制器 /// 初始化控制器
/// 设置事件监听并加载版本信息 /// 设置事件监听并加载版本信息
/// 恒玄耳机是否连着。恒玄不在杰理 RCSP 链路上,
/// [OtaCoreService.checkBleConnectionStatus] 查的是 BleManager(杰理),
/// 对恒玄恒为 false。
bool get _besConnected =>
Get.isRegistered<BesBluetoothService>() &&
BesBluetoothService.to.isConnected;
Future<void> _initializeController() async { Future<void> _initializeController() async {
// 首先检查BLE设备连接状态 // ⚠️ 这里原来是「杰理没连上就直接 return」,而本项目用的是恒玄耳机——
if (!_coreService.checkBleConnectionStatus()) { // 于是 _loadVersionInfo() 永远不会被调到,固件页从头到尾**没有任何一次
//isFetchingDeviceInfo.value = false; // 服务端拉取**,看起来就是「接口没通」。实际上服务端版本是随
//errorMessage.value = 'unconnected'.tr; // user_getappconfig 的 products 下发的(product.version /
// updatepackageaddress / updatedescription / isforceupdate),
// 一直都在,只是没人去读。
final bool jieliReady = _coreService.checkBleConnectionStatus();
if (!jieliReady && !_besConnected) {
isBleConnected.value = false; isBleConnected.value = false;
return; return;
} }
_listenToOtaEvents(); isBleConnected.value = true;
// 杰理的 OTA 事件流只有杰理链路才有意义
if (jieliReady) _listenToOtaEvents();
await _loadVersionInfo(); await _loadVersionInfo();
} }
@ -179,6 +191,18 @@ class OtaUpgradeController extends GetxController {
/// 开始更新流程的公共方法 /// 开始更新流程的公共方法
/// 供视图调用的更新入口点 /// 供视图调用的更新入口点
Future<void> startUpdate() async { Future<void> startUpdate() async {
// 恒玄耳机目前**没有固件推送通道**:bluetooth_manager 插件里没有任何
// OTA 实现,下面这条流程整条是杰理 RCSP 的(下载 → RCSP 分包 → 重启配对)。
// 让它跑下去只会在中途失败,用户看到的是一串莫名其妙的错误码。
// 所以这里明确拦一下:版本对比与更新提示照常给,升级动作说清楚做不了。
if (_besConnected && !_coreService.checkBleConnectionStatus()) {
_uiService.showErrorSnackbar(
'tip'.tr,
'otaBesNotSupported'.tr,
backgroundColor: Colors.orange[800],
);
return;
}
//立即给用户状态反馈 //立即给用户状态反馈
showDetailedStatus.value = true; showDetailedStatus.value = true;
detailedStatus.value = 'otaCheckingDeviceStatus'.tr; detailedStatus.value = 'otaCheckingDeviceStatus'.tr;

443
apps/client/lib/modules/ota_upgrade/views/bes_ota_upgrade_view.dart

@ -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();
}
}

25
apps/client/lib/modules/ota_upgrade/views/ota_upgrade_view.dart

@ -290,7 +290,6 @@ class OtaUpgradeView extends GetView<OtaUpgradeController> {
children: [ children: [
// 设备型号信息 // 设备型号信息
_buildDeviceInfoRow( _buildDeviceInfoRow(
icon: Icons.headset,
label: 'deviceModel'.tr, // 设备型号 label: 'deviceModel'.tr, // 设备型号
value: besName.isNotEmpty value: besName.isNotEmpty
? besName ? besName
@ -331,26 +330,24 @@ class OtaUpgradeView extends GetView<OtaUpgradeController> {
} }
// 设备信息行组件 // 设备信息行组件
//
// 行首图标用 App 自己的图标(原来是蓝色 Icons.headset glyph 套一层浅蓝底)。
// app_icon.png 自带深色背景,再套浅蓝底会脏,所以底色一并去掉,
// 直接按原来的整体尺寸(6.w 内边距 + 16.sp 字形 ≈ 28.w)出图。
Widget _buildDeviceInfoRow({ Widget _buildDeviceInfoRow({
required IconData icon,
required String label, required String label,
required String value, required String value,
required bool isDarkMode, required bool isDarkMode,
}) { }) {
return Row( return Row(
children: [ children: [
Container( ClipRRect(
padding: EdgeInsets.all(6.w), borderRadius: BorderRadius.circular(6.r),
decoration: BoxDecoration( child: Image.asset(
color: isDarkMode 'assets/images/app_icon.png',
? Colors.blue.withValues(alpha: 0.1) width: 28.w,
: Colors.blue.withValues(alpha: 0.1), height: 28.w,
borderRadius: BorderRadius.circular(6.r), fit: BoxFit.cover,
),
child: Icon(
icon,
size: 16.sp,
color: isDarkMode ? Colors.blue[300] : Colors.blue[600],
), ),
), ),
SizedBox(width: 12.w), SizedBox(width: 12.w),

11
apps/client/lib/modules/profile/controllers/profile_controller.dart

@ -83,6 +83,13 @@ class ProfileController extends GetxController {
if (Get.isRegistered<IdVerifyService>()) { if (Get.isRegistered<IdVerifyService>()) {
IdVerifyService.to.ensureLoaded(); IdVerifyService.to.ensureLoaded();
} }
// [userInfo] 包的就是 User.instance 这个对象本身,别处直接改它的字段
// (实名核验回写真实性别就是这么改的)不会触发任何 Rx。挂上全局刷新信号,
// 让这类"绕过 controller 的改动"也能立刻反映到「我的」页上。
ever<int>(User.refreshTick, (_) {
userInfo.value = User.instance;
userInfo.refresh();
});
} }
// 加载用户语言偏好设置 // 加载用户语言偏好设置
@ -464,6 +471,10 @@ class ProfileController extends GetxController {
/// ///
/// 刻意不置 hasEdits —— 这不是用户在编辑,是核验结果回写, /// 刻意不置 hasEdits —— 这不是用户在编辑,是核验结果回写,
/// 置了会让用户退出页面时被问「要保存吗」,很奇怪。 /// 置了会让用户退出页面时被问「要保存吗」,很奇怪。
///
/// 注:写入 User.instance + 落本地缓存 + 发全局刷新信号现在统一在
/// [IdVerifyService] 里做(不论从哪个入口认证都生效),这里只兜住
/// 「服务端这次没回性别、但页面上还是旧值」的残余情况。
void applyVerifiedGender(int gender) { void applyVerifiedGender(int gender) {
if (gender <= 0 || gender == userInfo.value.gender) return; if (gender <= 0 || gender == userInfo.value.gender) return;
userInfo.update((user) { userInfo.update((user) {

32
apps/client/lib/modules/settings/views/settings_view.dart

@ -740,26 +740,16 @@ class SettingsView extends GetView<SettingsController> {
children: [ children: [
GestureDetector( GestureDetector(
onTap: controller.handleSecretTap, // 点击事件 onTap: controller.handleSecretTap, // 点击事件
child: Container( // 关于应用的 logo:用 App 自己的图标,替掉原来的
width: 40.w, // 「蓝紫渐变 + 耳机 glyph」。asset 是无圆角正方形,
height: 40.w, // 圆角由 ClipRRect 切(与启动页同一张图、同一套做法)。
decoration: BoxDecoration( child: ClipRRect(
gradient: LinearGradient( borderRadius: BorderRadius.circular(12.r),
colors: [ child: Image.asset(
const Color(0xFF2196F3) 'assets/images/app_icon.png',
.withValues(alpha: isDarkMode ? 0.7 : 1.0), width: 40.w,
const Color(0xFF9C27B0) height: 40.w,
.withValues(alpha: isDarkMode ? 0.7 : 1.0) fit: BoxFit.cover,
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(12.r),
),
child: Icon(
Icons.headphones,
size: 24.w,
color: Colors.white,
), ),
), ),
), ),
@ -844,7 +834,7 @@ class SettingsView extends GetView<SettingsController> {
child: Column( child: Column(
children: [ children: [
Text( Text(
'© 2025 Eaimar Technologies', '© 2026 Eaimar Technologies',
style: TextStyle( style: TextStyle(
fontSize: 12.sp, fontSize: 12.sp,
color: isDarkMode ? Colors.grey[400] : Colors.grey[500], color: isDarkMode ? Colors.grey[400] : Colors.grey[500],

4
apps/client/lib/modules/speech_test/views/speech_test_view.dart

@ -106,7 +106,9 @@ class SpeechTestView extends GetView<SpeechTestController> {
case SpeechServiceType.google: case SpeechServiceType.google:
return 'Google语音'; return 'Google语音';
case SpeechServiceType.alibaba: case SpeechServiceType.alibaba:
return '阿里语音'; return '阿里语音(旧版)';
case SpeechServiceType.alibaba35:
return '阿里语音3.5(声音复刻)';
case SpeechServiceType.volcano: case SpeechServiceType.volcano:
return '豆包语音'; return '豆包语音';
case SpeechServiceType.flutter: case SpeechServiceType.flutter:

21
apps/client/lib/modules/splash/views/splash_view.dart

@ -75,15 +75,14 @@ class SplashView extends GetView<SplashController> {
), ),
); );
}, },
// 启动页 logo:直接用 App 自己的图标,不再用「蓝紫渐变 + 耳机 glyph」。
// assets/images/app_icon.png 是 iOS AppIcon 1024 缩到 512 的副本;
// 它是**不带圆角的正方形**(RGB 无 alpha),圆角靠外面这层 ClipRRect 切,
// 所以半径改这里就行。换 App 图标时记得同步替换这个 asset。
child: Container( child: Container(
width: 96.w, width: 96.w,
height: 96.w, height: 96.w,
decoration: BoxDecoration( decoration: BoxDecoration(
gradient: const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Colors.blue, Colors.purple],
),
borderRadius: BorderRadius.circular(24.r), borderRadius: BorderRadius.circular(24.r),
boxShadow: [ boxShadow: [
BoxShadow( BoxShadow(
@ -94,10 +93,14 @@ class SplashView extends GetView<SplashController> {
), ),
], ],
), ),
child: Icon( child: ClipRRect(
Icons.headset, borderRadius: BorderRadius.circular(24.r),
size: 48.sp, child: Image.asset(
color: Colors.white, 'assets/images/app_icon.png',
width: 96.w,
height: 96.w,
fit: BoxFit.cover,
),
), ),
), ),
); );

6
apps/client/lib/routes/app_pages.dart

@ -68,7 +68,7 @@ import '../modules/agent/views/agent_view.dart';
import '../modules/emai/views/emai_view.dart'; import '../modules/emai/views/emai_view.dart';
import '../modules/emai/bindings/emai_binding.dart'; import '../modules/emai/bindings/emai_binding.dart';
import '../modules/agent/bindings/agent_binding.dart'; import '../modules/agent/bindings/agent_binding.dart';
import '../modules/ota_upgrade/views/ota_upgrade_view.dart'; import '../modules/ota_upgrade/views/bes_ota_upgrade_view.dart';
import '../modules/ota_upgrade/bindings/ota_upgrade_binding.dart'; import '../modules/ota_upgrade/bindings/ota_upgrade_binding.dart';
import '../modules/realtime/bindings/realtime_binding.dart'; import '../modules/realtime/bindings/realtime_binding.dart';
import '../modules/realtime/views/realtime_view.dart'; import '../modules/realtime/views/realtime_view.dart';
@ -151,7 +151,9 @@ abstract class AppPages {
), ),
GetPage( GetPage(
name: Routes.OTA_UPGRADE, name: Routes.OTA_UPGRADE,
page: () => const OtaUpgradeView(), // 本项目只有恒玄耳机:固件页走 BES 链路(bes_ota_manager 插件自管 SPP)。
// 杰理那版 OtaUpgradeView/OtaUpgradeController 留着但不再挂路由。
page: () => const BesOtaUpgradeView(),
binding: OtaUpgradeBinding(), binding: OtaUpgradeBinding(),
transition: Transition.rightToLeft, transition: Transition.rightToLeft,
transitionDuration: Duration(milliseconds: 250), transitionDuration: Duration(milliseconds: 250),

23
apps/client/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureSpeechPlugin.kt

@ -1394,6 +1394,16 @@ class AzureSpeechPlugin : BleService.Callback, FlutterPlugin, ActivityAware,
val alibabaAppKey = call.argument<String>("alibabaAppKey") ?: "" val alibabaAppKey = call.argument<String>("alibabaAppKey") ?: ""
val alibabaAppId = call.argument<String>("alibabaAppId") ?: "" val alibabaAppId = call.argument<String>("alibabaAppId") ?: ""
val alibabaAppURL = call.argument<String>("alibabaAppURL") ?: "" val alibabaAppURL = call.argument<String>("alibabaAppURL") ?: ""
// 阿里 3.5(声音复刻)相关入参。老版本 Dart 不传这几个,
// 取不到就退回 alibabaAppId / 关闭复刻,行为与改动前一致。
val alibabaModel =
call.argument<String>("alibabaModel")?.takeIf { it.isNotBlank() }
?: alibabaAppId
val alibabaEnableVoiceClone =
call.argument<Boolean>("alibabaEnableVoiceClone") ?: false
val alibabaVoiceCloneFrequency =
call.argument<String>("alibabaVoiceCloneFrequency")
?.takeIf { it.isNotBlank() } ?: "once"
if (currentAstProvider == "iflytek" ) { if (currentAstProvider == "iflytek" ) {
Log.d(tag, "initializeIntegrated: iflytek") Log.d(tag, "initializeIntegrated: iflytek")
val azureConfig = IflytekIntegratedSpeechService.AzureConfiguration( val azureConfig = IflytekIntegratedSpeechService.AzureConfiguration(
@ -1573,21 +1583,26 @@ class AzureSpeechPlugin : BleService.Callback, FlutterPlugin, ActivityAware,
} else if (currentAstProvider == "alibaba" ) { } else if (currentAstProvider == "alibaba" ) {
Log.d(tag, "initializeIntegrated: alibaba") Log.d(tag, "initializeIntegrated: alibaba")
Log.d(tag, "initializeIntegrated: alibaba model=$alibabaModel voiceClone=$alibabaEnableVoiceClone")
val bailianConfigA = AliyunBailianE2EHelper.Config( val bailianConfigA = AliyunBailianE2EHelper.Config(
apiKey = alibabaAppKey, apiKey = alibabaAppKey,
appId = alibabaAppId, appId = alibabaModel,
wsUrl = alibabaAppURL, wsUrl = alibabaAppURL,
sourceLanguage = translationLang0, sourceLanguage = translationLang0,
targetLanguage = translationLang1, targetLanguage = translationLang1,
voice = ttsLang1 voice = ttsLang1,
enableVoiceClone = alibabaEnableVoiceClone,
voiceCloneFrequency = alibabaVoiceCloneFrequency
) )
val bailianConfigB = AliyunBailianE2EHelper.Config( val bailianConfigB = AliyunBailianE2EHelper.Config(
apiKey = alibabaAppKey, apiKey = alibabaAppKey,
appId = alibabaAppId, appId = alibabaModel,
wsUrl = alibabaAppURL, wsUrl = alibabaAppURL,
sourceLanguage = translationLang1, sourceLanguage = translationLang1,
targetLanguage = translationLang0, targetLanguage = translationLang0,
voice = ttsLang0 voice = ttsLang0,
enableVoiceClone = alibabaEnableVoiceClone,
voiceCloneFrequency = alibabaVoiceCloneFrequency
) )
val callbackA = AliyunAstCallback( val callbackA = AliyunAstCallback(

Some files were not shown because too many files changed in this diff

Loading…
Cancel
Save