You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
945 lines
48 KiB
945 lines
48 KiB
<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>
|
|
|
|
<!-- 筛选:方案商 / 关键字。
|
|
品牌商筛选已去掉:产品建档阶段不再有品牌商(brandid 恒为 0,表单里也早就没这一栏),
|
|
留着只会是个永远筛不出东西的下拉。 -->
|
|
<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="filterProvider" class="select select-bordered select-sm" style="min-width:200px;">
|
|
<option :value="0">全部方案</option>
|
|
<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>
|
|
</optgroup>
|
|
<option :value="-1">未绑定方案商</option>
|
|
</select>
|
|
</div>
|
|
<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>
|
|
<input v-model="keyword" type="text" class="input input-bordered input-sm w-full" placeholder="产品名称 / PID(十六进制)" />
|
|
</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 text-sm">
|
|
<thead>
|
|
<tr>
|
|
<th class="cursor-pointer select-none" @click="toggleSort('id')">PID{{ sortMark('id') }}</th>
|
|
<th>图片</th>
|
|
<th class="cursor-pointer select-none" @click="toggleSort('devicename')">名称{{ sortMark('devicename') }}</th>
|
|
<th class="cursor-pointer select-none" @click="toggleSort('provider')">方案商{{ sortMark('provider') }}</th>
|
|
<th>绑定应用</th>
|
|
<th>版本</th><th>功能</th>
|
|
<th class="cursor-pointer select-none" @click="toggleSort('createtime')">创建时间{{ sortMark('createtime') }}</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="p in viewRows" :key="p.id">
|
|
<td class="font-mono text-sm"><span class="badge badge-primary badge-outline badge-sm">0x{{ hex4(p.id) }}</span></td>
|
|
<td>
|
|
<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">
|
|
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" /></svg>
|
|
</div>
|
|
</td>
|
|
<td>
|
|
<div class="font-medium">{{ p.devicename }}</div>
|
|
<div v-if="p.description" class="text-xs text-slate-400 max-w-xs truncate" :title="p.description">{{ p.description }}</div>
|
|
</td>
|
|
<td>
|
|
<template v-if="p.providerid">
|
|
<span class="badge badge-outline badge-sm">{{ providerLabel(p.providerid) }}</span>
|
|
</template>
|
|
<span v-else class="badge badge-warning badge-sm">未绑定</span>
|
|
</td>
|
|
<td>
|
|
<div v-if="appNamesOf(p).length" class="flex flex-wrap gap-1">
|
|
<span v-for="n in appNamesOf(p)" :key="n" class="badge badge-ghost badge-sm">{{ n }}</span>
|
|
</div>
|
|
<span v-else class="text-slate-300 text-xs">未绑定</span>
|
|
</td>
|
|
<td class="text-sm"><span v-if="p.version">{{ p.version }}</span><span v-else class="text-slate-300">-</span></td>
|
|
<td><span class="text-xs text-slate-500">{{ featureCount(p) }}/14</span></td>
|
|
<td class="text-xs text-slate-500">{{ formatTs(p.createtime) }}</td>
|
|
<td>
|
|
<div class="flex gap-1">
|
|
<button class="btn btn-ghost btn-xs" @click="showForm(p)">编辑</button>
|
|
<button class="btn btn-ghost btn-xs text-indigo-600" @click="showVersions(p)">版本</button>
|
|
<button class="btn btn-ghost btn-xs text-red-500" @click="delProduct(p)">删除</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ── 产品表单 ── -->
|
|
<div v-show="view === 'form'">
|
|
<div class="flex items-center gap-3 mb-5">
|
|
<button class="btn btn-ghost btn-sm" @click="showList()">← 返回列表</button>
|
|
<h2 class="text-lg font-semibold text-slate-800">{{ isEditing ? '编辑产品' : '新增产品' }}</h2>
|
|
</div>
|
|
<div class="bg-white rounded-xl shadow-sm border border-slate-100 p-6 max-w-3xl">
|
|
<form @submit.prevent="saveProduct">
|
|
<h3 class="text-sm font-semibold text-slate-500 mb-3">基本信息</h3>
|
|
<!-- PID:产品主键,手工指定的十六进制编码。建档填错能改,但一旦投产(有批次/已发 MAC)就锁死 -->
|
|
<div class="form-control mb-4">
|
|
<label class="label"><span class="label-text font-medium">PID(十六进制)*</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.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_<PID></span> 与已发出去的 MAC 都按它组织,服务端会拒绝。
|
|
</span>
|
|
<span v-if="!isEditing" class="label-text-alt text-slate-400 mt-1">现有 PID 段从 B001 起,建议接着往后排。</span>
|
|
</div>
|
|
|
|
<!-- 芯片 → 方案商 联动:先定芯片,方案商下拉只列该芯片下的方案。
|
|
芯片本身不存在产品上——它由方案商推导(solution_provider.vendor),
|
|
存一份会在方案商换芯片时变成对不上的旧值,而 CID(芯片公司) 是要烧进固件的。 -->
|
|
<div class="grid grid-cols-2 gap-4 mb-4">
|
|
<div class="form-control">
|
|
<label class="label">
|
|
<span class="label-text font-medium">芯片</span>
|
|
<span class="label-text-alt text-slate-400">用来筛下面的方案</span>
|
|
</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>
|
|
<span v-if="!chipVendors.length" class="label-text-alt text-amber-500 mt-1">还没有芯片厂商,请先到【芯片管理】创建</span>
|
|
</div>
|
|
<div class="form-control">
|
|
<label class="label">
|
|
<span class="label-text font-medium">生产工厂</span>
|
|
<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>
|
|
<span v-if="!factoryOptions.length" class="label-text-alt text-slate-400 mt-1">可先留空,之后到【工厂管理】添加再回来补</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 归属方案:BID 取它的 id,CID 再由它的芯片推出 -->
|
|
<div class="form-control mb-4">
|
|
<label class="label">
|
|
<span class="label-text font-medium">归属方案 *</span>
|
|
<span class="label-text-alt text-slate-400">方案ID(BID)取自这里,芯片ID(CID)由它的芯片推出</span>
|
|
</label>
|
|
<select v-model.number="form.providerid" class="select select-bordered w-full" required>
|
|
<option :value="0" disabled>请选择方案</option>
|
|
<!-- 按芯片分组:同一家方案公司可能有多套方案,选项带 BID 才分得清是哪一套 -->
|
|
<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>
|
|
</optgroup>
|
|
</select>
|
|
<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 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>
|
|
<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">
|
|
<label class="label">
|
|
<span class="label-text font-medium">绑定应用</span>
|
|
<span class="label-text-alt text-slate-400">可多选;品牌商/渠道商账号的「应用」下拉只列出其产品绑定过的应用</span>
|
|
</label>
|
|
<div v-if="!appOptions.length" class="text-xs text-slate-400 border border-dashed border-slate-200 rounded-lg px-3 py-2">
|
|
暂无可选应用,请先到【SaaS 管理 → 应用中心】新建应用
|
|
</div>
|
|
<div v-else class="flex flex-wrap gap-2 border border-slate-200 rounded-lg px-3 py-2">
|
|
<label v-for="n in appOptions" :key="n" class="label cursor-pointer gap-2 py-1">
|
|
<input v-model="form.appnames" type="checkbox" :value="n" class="checkbox checkbox-sm checkbox-primary" />
|
|
<span class="label-text">{{ n }}</span>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 扫描UUID 已于 2026-09-08 整体删除(表单、库列、客户端逻辑一并删):
|
|
设备校验早就改成按 MAC 走,绑定奖励也是按 MAC 判「这台设备有没有被绑过」,
|
|
跟这个 UUID 没关系。服务端仍下发 scanuuid 空串,只为老 App 包不解析报错。 -->
|
|
<div class="form-control mb-4">
|
|
<label class="label"><span class="label-text font-medium">设备码数量(每副)</span></label>
|
|
<select v-model.number="form.devicecodecount" class="select select-bordered w-full max-w-xs">
|
|
<option :value="1">单份 (1个/副)</option>
|
|
<option :value="2">双份 (2个/副)</option>
|
|
</select>
|
|
</div>
|
|
|
|
<!-- 产品图片 -->
|
|
<div class="form-control mb-4">
|
|
<label class="label"><span class="label-text font-medium">产品图片</span></label>
|
|
<div class="flex items-start gap-4">
|
|
<div class="flex-1">
|
|
<input v-model="form.productimage" type="text" class="input input-bordered w-full input-sm mb-2" placeholder="图片URL(可手动输入或上传)" />
|
|
<div class="flex items-center gap-2">
|
|
<input type="file" accept="image/*" class="file-input file-input-bordered file-input-sm flex-1" @change="onImagePick" />
|
|
<button type="button" class="btn btn-outline btn-sm btn-primary" :disabled="imgUploading" @click="uploadImage">
|
|
<span v-if="imgUploading" class="loading loading-spinner loading-xs"></span>上传
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div v-if="form.productimage">
|
|
<img :src="form.productimage" class="w-20 h-20 object-cover rounded-lg border" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
|
|
<div class="flex gap-3">
|
|
<button type="button" class="btn btn-ghost" @click="showList()">取消</button>
|
|
<button type="submit" class="btn btn-primary text-white" :disabled="submitting">
|
|
<span v-if="submitting" class="loading loading-spinner loading-xs"></span>保存
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ── 新增产品二次确认 ── -->
|
|
<dialog ref="confirmModal" class="modal">
|
|
<div class="modal-box w-11/12 max-w-2xl">
|
|
<h3 class="font-bold text-lg mb-4">确认创建产品</h3>
|
|
<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 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">产品(PID)</span>
|
|
<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>
|
|
<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>
|
|
<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>{{ 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>
|
|
<p class="text-xs text-slate-400 pt-1">PID 为上面手工指定的值,会一并写进通知邮件。</p>
|
|
</div>
|
|
|
|
<label class="flex items-center gap-2 cursor-pointer mb-3">
|
|
<input v-model="notify.enabled" type="checkbox" class="checkbox checkbox-sm checkbox-primary" />
|
|
<span class="text-sm font-medium">创建后发送产品通知邮件(芯片ID / 方案ID / 产品ID)</span>
|
|
</label>
|
|
<div v-if="notify.enabled" class="space-y-3">
|
|
<div class="form-control">
|
|
<label class="label py-1"><span class="label-text text-xs">收件人(方案商)*</span></label>
|
|
<input v-model="notify.to" type="email" class="input input-bordered input-sm w-full" placeholder="方案商联系邮箱" />
|
|
</div>
|
|
<div class="form-control">
|
|
<label class="label py-1"><span class="label-text text-xs">抄送</span></label>
|
|
<input v-model="notify.cc" type="text" class="input input-bordered input-sm w-full" placeholder="多个邮箱用逗号分隔" />
|
|
<div v-if="ccCandidates.length" class="flex flex-wrap gap-1 mt-2">
|
|
<button v-for="m in ccCandidates" :key="m.mail" type="button"
|
|
class="badge badge-outline badge-sm hover:badge-primary cursor-pointer" @click="addCc(m.mail)">
|
|
+ {{ m.mail }}<span class="text-slate-400 ml-1">({{ m.from }})</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="modal-action">
|
|
<button type="button" class="btn btn-ghost btn-sm" @click="closeConfirm">返回修改</button>
|
|
<button type="button" class="btn btn-primary btn-sm text-white" :disabled="submitting" @click="doCreate">
|
|
<span v-if="submitting" class="loading loading-spinner loading-xs"></span>确认创建
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<form method="dialog" class="modal-backdrop"><button>close</button></form>
|
|
</dialog>
|
|
|
|
<!-- ── 产品通知邮件 ── -->
|
|
<dialog ref="emailModal" class="modal">
|
|
<div class="modal-box w-11/12 max-w-2xl">
|
|
<h3 class="font-bold text-lg mb-4">发送产品通知邮件</h3>
|
|
<form @submit.prevent="sendNotifyMail">
|
|
<div class="grid grid-cols-2 gap-4 mb-3">
|
|
<div class="form-control">
|
|
<label class="label py-1"><span class="label-text text-xs">收件人 *</span></label>
|
|
<input v-model="notify.to" type="email" class="input input-bordered input-sm w-full" required />
|
|
</div>
|
|
<div class="form-control">
|
|
<label class="label py-1"><span class="label-text text-xs">抄送</span></label>
|
|
<input v-model="notify.cc" type="text" class="input input-bordered input-sm w-full" placeholder="多个邮箱用逗号分隔" />
|
|
</div>
|
|
</div>
|
|
<div class="form-control mb-3">
|
|
<label class="label py-1"><span class="label-text text-xs">主题 *</span></label>
|
|
<input v-model="notify.subject" type="text" class="input input-bordered input-sm w-full" required />
|
|
</div>
|
|
<div class="form-control mb-3">
|
|
<label class="label py-1"><span class="label-text text-xs">内容 *</span></label>
|
|
<textarea v-model="notify.content" class="textarea textarea-bordered w-full font-mono text-xs" rows="12" required></textarea>
|
|
</div>
|
|
<div class="modal-action">
|
|
<button type="button" class="btn btn-ghost btn-sm" @click="skipNotifyMail">暂不发送</button>
|
|
<button type="submit" class="btn btn-primary btn-sm text-white" :disabled="mailSending">
|
|
<span v-if="mailSending" class="loading loading-spinner loading-xs"></span>发送
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
<form method="dialog" class="modal-backdrop"><button>close</button></form>
|
|
</dialog>
|
|
|
|
<!-- ── 版本管理 ── -->
|
|
<div v-show="view === 'version'">
|
|
<div class="flex items-center gap-3 mb-5">
|
|
<button class="btn btn-ghost btn-sm" @click="showList()">← 返回产品列表</button>
|
|
<h2 class="text-lg font-semibold text-slate-800">版本管理 - {{ currentProduct?.devicename || '' }}</h2>
|
|
</div>
|
|
<!-- 当前版本信息 -->
|
|
<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">
|
|
<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">版本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>
|
|
</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 class="bg-white rounded-xl shadow-sm border border-slate-100 p-5 mb-5">
|
|
<h3 class="text-sm font-semibold text-slate-700 mb-3">添加新版本</h3>
|
|
<form @submit.prevent="addVersion">
|
|
<div class="grid grid-cols-2 gap-4 mb-3">
|
|
<div class="form-control"><label class="label"><span class="label-text text-xs">版本号 *</span></label>
|
|
<input v-model="ver.version" type="text" class="input input-bordered input-sm w-full" placeholder="如 1.2.0" /></div>
|
|
<div class="form-control"><label class="label"><span class="label-text text-xs">描述</span></label>
|
|
<input v-model="ver.desc" type="text" class="input input-bordered input-sm w-full" placeholder="版本描述" /></div>
|
|
</div>
|
|
<div class="form-control mb-3">
|
|
<label class="label"><span class="label-text text-xs">OTA 更新包 *</span></label>
|
|
<input type="file" class="file-input file-input-bordered file-input-sm w-full" accept=".bin,.hex,.zip,.gz,.tar,.ota,.ufw,.fot" @change="onOtaPick" />
|
|
</div>
|
|
<div class="flex items-center gap-3">
|
|
<label class="flex items-center gap-2 cursor-pointer"><input v-model="ver.isuse" type="checkbox" class="checkbox checkbox-primary checkbox-sm" /><span class="text-sm">设为当前使用版本</span></label>
|
|
<button type="submit" class="btn btn-primary btn-sm text-white ml-auto" :disabled="!canAddVersion || versionSubmitting">
|
|
<span v-if="versionSubmitting" class="loading loading-spinner loading-xs"></span>添加版本
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
<!-- 版本列表 -->
|
|
<div class="bg-white rounded-xl shadow-sm border border-slate-100">
|
|
<div class="overflow-x-auto">
|
|
<table class="table table-zebra text-sm">
|
|
<thead><tr><th>版本ID</th><th>版本号</th><th>描述</th><th>更新包地址</th><th>操作</th></tr></thead>
|
|
<tbody>
|
|
<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">
|
|
<td>{{ v.id }}</td>
|
|
<td>
|
|
<span class="badge badge-sm" :class="isCurrentVer(v) ? 'badge-primary' : 'badge-outline'">{{ v.version }}</span>
|
|
<span v-if="isCurrentVer(v)" class="badge badge-success badge-xs ml-1">当前</span>
|
|
</td>
|
|
<td class="text-sm">{{ v.description || '' }}</td>
|
|
<td class="text-xs text-slate-400 max-w-xs truncate">{{ v.updatepackageaddress || '' }}</td>
|
|
<td>
|
|
<div class="flex gap-1">
|
|
<span v-if="isCurrentVer(v)" class="text-xs text-slate-400">使用中</span>
|
|
<template v-else>
|
|
<button class="btn btn-ghost btn-xs text-indigo-600" @click="useVersion(v)">设为当前</button>
|
|
<button class="btn btn-ghost btn-xs text-red-500" @click="delVersion(v)">删除</button>
|
|
</template>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</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 { useAuthStore } from '~/stores/auth'
|
|
import { chipVendors, loadChipVendors, chipVendorName } from '~/utils/chipvendors'
|
|
import { useTableSort } from '~/composables/useTableSort'
|
|
|
|
definePageMeta({ title: '产品管理' })
|
|
|
|
interface Product {
|
|
id: number; devicename: string; providerid?: number
|
|
description?: string // 产品说明(特性描述)
|
|
createtime?: number
|
|
oemfactoryid?: number // 代工厂 id(oem_factory.id),0/缺失 = 未指定
|
|
devicecodecount: number; productimage: string
|
|
channel: string; channelextra: string
|
|
appnames?: string // 绑定应用名 CSV(空=未绑定)
|
|
bindrewardvpitime: number; bindrewardtranslate: number; bindrewardmeeting: number
|
|
version?: string; versionid?: number; updatepackageaddress?: string
|
|
updateonmismatch?: boolean // 版本不一致即可升级(开发用,默认关)
|
|
[key: string]: unknown
|
|
}
|
|
interface Version { id: number; version: string; description: string; updatepackageaddress: string; productid: number }
|
|
interface Provider { id: number; name: string; vendor?: number; contactmails?: string }
|
|
|
|
const { webApi, consoleApi, uploadFile } = useApi()
|
|
const { success, error, warn } = useToast()
|
|
const auth = useAuthStore()
|
|
|
|
const view = ref<'list' | 'form' | 'version'>('list')
|
|
const rows = ref<Product[]>([])
|
|
const providers = ref<Provider[]>([])
|
|
// 列表筛选:方案商(-1=未绑定)/ 关键字
|
|
const filterProvider = ref(0)
|
|
const keyword = ref('')
|
|
// 列表默认按产品 ID 升序(后端返回顺序不保证),表头可切换排序字段
|
|
// 可绑定的应用名(应用中心 app_product.name);只有超管/管理员进得来产品页,直接调注册表接口取。
|
|
const appOptions = ref<string[]>([])
|
|
const loading = ref(false)
|
|
const submitting = ref(false)
|
|
const isEditing = ref(false)
|
|
|
|
const form = reactive({
|
|
// id 是当前 PID,idHex 是编辑框里的十六进制值;改 PID 时两者不同,靠 id 做定位、idHex 做新值
|
|
id: 0, idHex: '', devicename: '', description: '', providerid: 0, devicecodecount: 1,
|
|
// chipid 只用于筛下面的方案下拉,不落库——芯片由方案商推导,见模板注释
|
|
chipid: 0, oemfactoryid: 0,
|
|
productimage: '', channel: 'official', appnames: [] as string[],
|
|
})
|
|
|
|
// 代工厂(含停用的:存量产品可能挂在停用工厂上,列表要显示得出名字;下拉另筛)
|
|
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 hex4(id: number) { return id ? Number(id).toString(16).padStart(4, '0').toUpperCase() : 'N/A' }
|
|
function formatTs(ts?: number) {
|
|
if (!ts) return '-'
|
|
const d = new Date(ts * 1000)
|
|
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')
|
|
}
|
|
function mailsOf(s?: string) { return String(s || '').split(',').map((e) => e.trim()).filter(Boolean) }
|
|
// 方案商展示:公司名(芯片分组),认不出就退回 hex id
|
|
function providerLabel(id?: number) {
|
|
if (!id) return '未绑定'
|
|
const pv = providers.value.find((x) => x.id === id)
|
|
return pv ? `${pv.name}(${chipVendorName(pv.vendor)})` : '方案商#' + hexId(id)
|
|
}
|
|
// 方案商下拉/筛选按芯片厂商分组(未分组的归到最后一组)
|
|
const providerGroups = computed(() => {
|
|
// 含已停用的厂商:存量方案商可能挂在停用厂商下,漏了它们会被归进「未分组」那一组
|
|
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[] })
|
|
// 选了芯片就只看该芯片下的方案;选「全部芯片」时不筛
|
|
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]!
|
|
g.items.push(pv)
|
|
}
|
|
return groups.filter((g) => g.items.length)
|
|
})
|
|
|
|
const { toggleSort, sortMark, applySort } = useTableSort<Product>({
|
|
id: (a, b) => Number(a.id) - Number(b.id),
|
|
devicename: (a, b) => String(a.devicename || '').localeCompare(String(b.devicename || ''), 'zh') || Number(a.id) - Number(b.id),
|
|
// 芯片方案商:先按芯片厂商分组,再按方案商 id,同一套方案的产品挨着排
|
|
provider: (a, b) => {
|
|
const pa = providers.value.find((x) => x.id === a.providerid)
|
|
const pbv = providers.value.find((x) => x.id === b.providerid)
|
|
return (Number(pa?.vendor || 0) - Number(pbv?.vendor || 0)) ||
|
|
(Number(a.providerid || 0) - Number(b.providerid || 0)) ||
|
|
(Number(a.id) - Number(b.id))
|
|
},
|
|
// 历史产品没有创建时间(0),相等时退回按 ID,顺序才稳定
|
|
createtime: (a, b) => Number(a.createtime || 0) - Number(b.createtime || 0) || Number(a.id) - Number(b.id),
|
|
}, 'id')
|
|
|
|
const viewRows = computed(() => {
|
|
const kw = keyword.value.trim().toLowerCase()
|
|
const list = rows.value.filter((p) => {
|
|
if (filterProvider.value === -1 && Number(p.providerid || 0) !== 0) return false
|
|
if (filterProvider.value > 0 && Number(p.providerid || 0) !== filterProvider.value) return false
|
|
if (!kw) return true
|
|
return String(p.devicename || '').toLowerCase().includes(kw) || hexId(p.id).toLowerCase().includes(kw)
|
|
})
|
|
return applySort(list)
|
|
})
|
|
function resetFilter() {
|
|
filterProvider.value = 0
|
|
keyword.value = ''
|
|
}
|
|
function appNamesOf(p: Product): string[] {
|
|
return String(p.appnames || '').split(',').map((s) => s.trim()).filter(Boolean)
|
|
}
|
|
// 功能开关已从产品表单移除(改由别处配置),但字段仍在库里、仍下发给客户端,
|
|
// 列表保留一个「已开启 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() {
|
|
loading.value = true
|
|
try {
|
|
await loadProviders()
|
|
const pd = await webApi<{ products: Product[] }>('getproducts', {})
|
|
rows.value = pd?.products ?? []
|
|
} catch (e: any) {
|
|
error(e?.message ?? '获取产品列表失败')
|
|
rows.value = []
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
// 方案公司:产品必须绑一个。BID 取它的 id,CID 取它的 vendor(芯片公司)
|
|
async function loadProviders() {
|
|
try {
|
|
const d = await webApi<{ providers: Provider[] }>('getsolutionproviders', {})
|
|
providers.value = d?.providers ?? []
|
|
} 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 表),与部署无关,只按应用名绑。
|
|
async function loadAppOptions() {
|
|
try {
|
|
const list = await consoleApi<{ name: string; enabled?: boolean }[]>('products/list', {})
|
|
appOptions.value = (list ?? []).map((a) => a.name).filter(Boolean)
|
|
} catch { appOptions.value = [] }
|
|
}
|
|
|
|
function showList() { view.value = 'list'; loadData() }
|
|
|
|
function showForm(p?: Product) {
|
|
loadProviders()
|
|
loadAppOptions()
|
|
loadFactories()
|
|
if (p) {
|
|
isEditing.value = true
|
|
form.id = p.id
|
|
form.idHex = hexId(p.id)
|
|
form.devicename = p.devicename || ''
|
|
form.description = p.description || ''
|
|
form.providerid = p.providerid || 0
|
|
// 芯片不落库,由所选方案商反推,保证展示的芯片永远等于方案商当前归属
|
|
form.chipid = Number(providers.value.find((x) => x.id === p.providerid)?.vendor || 0)
|
|
form.oemfactoryid = p.oemfactoryid || 0
|
|
form.devicecodecount = p.devicecodecount === 2 ? 2 : 1
|
|
form.productimage = p.productimage || ''
|
|
form.appnames = appNamesOf(p)
|
|
} else {
|
|
isEditing.value = false
|
|
form.id = 0
|
|
form.idHex = ''
|
|
form.chipid = 0
|
|
form.oemfactoryid = 0
|
|
form.devicename = ''
|
|
form.description = ''
|
|
form.providerid = 0
|
|
form.devicecodecount = 1
|
|
form.productimage = ''
|
|
form.appnames = []
|
|
}
|
|
view.value = 'form'
|
|
}
|
|
|
|
function buildProductPayload(): Record<string, unknown> {
|
|
// 只下发本表单实际编辑的字段。
|
|
// 品牌商 / 渠道方(channel,channelextra) / 扫描UUID / 绑定奖励 / 功能开关 已从表单移除,故不再下发——
|
|
// 服务端 updateProduct 会把这几项从旧记录原样保留,编辑产品不会把它们清空。
|
|
const product: Record<string, unknown> = {
|
|
devicename: form.devicename,
|
|
description: form.description,
|
|
providerid: Number(form.providerid) || 0,
|
|
devicecodecount: Number(form.devicecodecount) || 1,
|
|
productimage: form.productimage,
|
|
appnames: form.appnames.join(','),
|
|
oemfactoryid: Number(form.oemfactoryid) || 0,
|
|
}
|
|
// PID 新增时也手填:一律从输入框解析(编辑改号时由调用方另传 oldid 定位旧记录)
|
|
product.id = parsePid() ?? 0
|
|
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() {
|
|
if (!form.devicename.trim()) { warn('请输入产品名称'); return }
|
|
if (!checkPid()) return
|
|
if (!form.providerid) { warn('请选择归属方案(方案ID(BID) 取自它)'); return }
|
|
if (!isEditing.value) {
|
|
prepareNotify()
|
|
confirmModal.value?.showModal()
|
|
return
|
|
}
|
|
submitting.value = true
|
|
try {
|
|
const product = buildProductPayload()
|
|
// 带原 PID:服务端拿它定位记录,再决定要不要改主键
|
|
await webApi('updateproduct', { product, oldid: Number(form.id) })
|
|
success('保存成功')
|
|
showList()
|
|
} catch (e: any) {
|
|
error(e?.message ?? '保存失败')
|
|
} finally {
|
|
submitting.value = false
|
|
}
|
|
}
|
|
|
|
// ── 新增二次确认 + 产品通知邮件 ──
|
|
const confirmModal = ref<HTMLDialogElement | null>(null)
|
|
const emailModal = ref<HTMLDialogElement | null>(null)
|
|
const mailSending = ref(false)
|
|
const notify = reactive({ enabled: true, to: '', cc: '', subject: '', content: '' })
|
|
|
|
// 抄送候选:方案商剩下的联系邮箱,点一下加进抄送
|
|
const ccCandidates = computed(() => {
|
|
const used = new Set([...mailsOf(notify.to), ...mailsOf(notify.cc)])
|
|
const out: { mail: string; from: string }[] = []
|
|
const pv = providers.value.find((x) => x.id === form.providerid)
|
|
mailsOf(pv?.contactmails).forEach((m) => { if (!used.has(m) && !out.some((x) => x.mail === m)) out.push({ mail: m, from: '方案商' }) })
|
|
return out
|
|
})
|
|
function addCc(mail: string) {
|
|
const cur = mailsOf(notify.cc)
|
|
if (cur.includes(mail)) return
|
|
cur.push(mail)
|
|
notify.cc = cur.join(',')
|
|
}
|
|
|
|
// 收件人默认方案商第一个联系邮箱,其余进抄送。
|
|
// 原先默认取品牌方邮箱,但品牌商已从产品上移除(brandid 恒为 0),那一路查出来永远是空。
|
|
function prepareNotify() {
|
|
const pv = providers.value.find((x) => x.id === form.providerid)
|
|
const mails = mailsOf(pv?.contactmails)
|
|
notify.enabled = true
|
|
notify.to = mails[0] || ''
|
|
notify.cc = mails.slice(1).join(',')
|
|
notify.subject = ''
|
|
notify.content = ''
|
|
}
|
|
|
|
function closeConfirm() { confirmModal.value?.close() }
|
|
|
|
async function doCreate() {
|
|
if (notify.enabled && !notify.to.trim()) { warn('请填写通知邮件的收件人(方案商邮箱)'); return }
|
|
if (!checkPid()) { closeConfirm(); return }
|
|
submitting.value = true
|
|
try {
|
|
const d = await webApi<{ product: Product }>('addproduct', { product: buildProductPayload() })
|
|
const pid = Number(d?.product?.id || 0)
|
|
success('创建成功')
|
|
closeConfirm()
|
|
if (notify.enabled) {
|
|
buildNotifyMail(pid)
|
|
emailModal.value?.showModal()
|
|
} else {
|
|
showList()
|
|
}
|
|
} catch (e: any) {
|
|
error(e?.message ?? '创建失败')
|
|
} finally {
|
|
submitting.value = false
|
|
}
|
|
}
|
|
|
|
// 邮件正文给客户录入用:芯片ID(CID) / 方案ID(BID) / 产品ID(PID) 三个十六进制标识
|
|
// 口径固定:CID=芯片公司、BID=方案公司、PID=产品。CID 由所选方案商的 vendor 推出,不单独存。
|
|
function buildNotifyMail(pid: number) {
|
|
const pv = providers.value.find((x) => x.id === form.providerid)
|
|
const today = new Date().toLocaleDateString()
|
|
notify.subject = '【产品创建通知】' + form.devicename + ' - ' + today
|
|
notify.content =
|
|
'尊敬的合作伙伴:\n\n' +
|
|
'贵司产品「' + form.devicename + '」已在平台创建完成,相关标识如下,请按此录入生产与开发环境:\n\n' +
|
|
'芯片ID(CID): 0x' + hex4(chipOfForm.value) + ' ' + chipVendorName(chipOfForm.value) + '\n' +
|
|
'方案ID(BID): 0x' + hex4(form.providerid) + ' ' + (pv ? pv.name : '') + '\n' +
|
|
'产品ID(PID): 0x' + hex4(pid) + ' ' + form.devicename + '\n' +
|
|
(form.description ? '产品说明: ' + form.description + '\n' : '') +
|
|
'设备码数量(每副): ' + (form.devicecodecount === 2 ? '2 个' : '1 个') + '\n' +
|
|
'绑定应用: ' + (form.appnames.length ? form.appnames.join('、') : '未绑定') + '\n\n' +
|
|
'后续生产设备码时,服务ID(CID) 即以上方案商 ID。\n\n' +
|
|
'此致\n敬礼!'
|
|
}
|
|
|
|
function skipNotifyMail() {
|
|
emailModal.value?.close()
|
|
showList()
|
|
}
|
|
|
|
// 与生产通知一致走 multipart 的 api_sendsmtpemail(此处无附件)
|
|
async function sendNotifyMail() {
|
|
if (!notify.to.trim()) { warn('请填写收件人'); return }
|
|
mailSending.value = true
|
|
try {
|
|
const fd = new FormData()
|
|
fd.append('to', notify.to.trim())
|
|
fd.append('subject', notify.subject)
|
|
fd.append('content', notify.content)
|
|
const cc = notify.cc.trim()
|
|
if (cc) fd.append('cc', cc)
|
|
const headers: Record<string, string> = {}
|
|
if (auth.token) headers['Authorization'] = auth.token
|
|
if (auth.currentAppId) headers['X-App-Id'] = String(auth.currentAppId)
|
|
const resp = await fetch('/web/api/api_sendsmtpemail', { method: 'POST', headers, body: fd })
|
|
const data = await resp.json()
|
|
if (data.code === 0) {
|
|
success('通知邮件已发送')
|
|
emailModal.value?.close()
|
|
showList()
|
|
} else {
|
|
error('邮件发送失败: ' + (data.msg || '未知错误') + '(产品已创建,可在此重试)')
|
|
}
|
|
} catch (e: any) {
|
|
error('邮件发送失败: ' + (e?.message ?? '') + '(产品已创建,可在此重试)')
|
|
} finally {
|
|
mailSending.value = false
|
|
}
|
|
}
|
|
|
|
function delProduct(p: Product) {
|
|
if (!window.confirm(`确定删除产品 "${p.devicename}" 吗?`)) return
|
|
webApi('delproduct', { id: p.id })
|
|
.then(() => { success('已删除'); loadData() })
|
|
.catch((e: any) => error(e?.message ?? '删除失败'))
|
|
}
|
|
|
|
// ── 图片上传 ──
|
|
const imgFile = ref<File | null>(null)
|
|
const imgUploading = ref(false)
|
|
function onImagePick(e: Event) {
|
|
imgFile.value = (e.target as HTMLInputElement).files?.[0] ?? null
|
|
}
|
|
async function uploadImage() {
|
|
if (!imgFile.value) { warn('请先选择图片'); return }
|
|
imgUploading.value = true
|
|
try {
|
|
const path = form.id ? `product/${form.id}/images/` : 'product/images/'
|
|
const url = await uploadFile(imgFile.value, path)
|
|
form.productimage = url
|
|
success('上传成功')
|
|
} catch (e: any) {
|
|
error(e?.message ?? '上传失败')
|
|
} finally {
|
|
imgUploading.value = false
|
|
}
|
|
}
|
|
|
|
// ── 版本管理 ──
|
|
const currentProduct = ref<Product | null>(null)
|
|
const versions = ref<Version[]>([])
|
|
const versionSubmitting = ref(false)
|
|
const ver = reactive({ version: '', desc: '', isuse: false })
|
|
const otaFile = ref<File | null>(null)
|
|
|
|
const canAddVersion = computed(() => !!ver.version.trim() && !!otaFile.value)
|
|
|
|
function isCurrentVer(v: Version) { return !!currentProduct.value?.versionid && v.id === currentProduct.value.versionid }
|
|
|
|
async function showVersions(p: Product) {
|
|
view.value = 'version'
|
|
currentProduct.value = null
|
|
versions.value = []
|
|
ver.version = ''; ver.desc = ''; ver.isuse = false; otaFile.value = null
|
|
try {
|
|
const d = await webApi<{ product: Product }>('getproduct', { id: p.id })
|
|
currentProduct.value = d?.product ?? p
|
|
} catch { currentProduct.value = p }
|
|
await loadVersions(p.id)
|
|
}
|
|
|
|
async function loadVersions(pid: number) {
|
|
try {
|
|
const d = await webApi<{ versions: Version[] }>('getproductversions', { pid })
|
|
versions.value = d?.versions ?? []
|
|
} catch { versions.value = [] }
|
|
}
|
|
|
|
function onOtaPick(e: Event) {
|
|
otaFile.value = (e.target as HTMLInputElement).files?.[0] ?? null
|
|
}
|
|
|
|
async function switchCurrentVersion(v: Version): Promise<boolean> {
|
|
if (!currentProduct.value) { error('产品信息未加载'); return false }
|
|
const product = { ...currentProduct.value, versionid: v.id }
|
|
try {
|
|
await webApi('updateproduct', { product })
|
|
const d = await webApi<{ product: Product }>('getproduct', { id: product.id })
|
|
currentProduct.value = d?.product ?? product
|
|
return true
|
|
} catch (e: any) {
|
|
error(e?.message ?? '切换失败')
|
|
return false
|
|
}
|
|
}
|
|
|
|
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) {
|
|
if (!window.confirm(`确定将版本 "${v.version}" 设为当前使用版本?`)) return
|
|
switchCurrentVersion(v).then((ok) => {
|
|
if (ok) { success('切换成功'); if (currentProduct.value) loadVersions(currentProduct.value.id) }
|
|
})
|
|
}
|
|
|
|
async function addVersion() {
|
|
const pid = currentProduct.value?.id
|
|
if (!pid) { error('产品ID不存在'); return }
|
|
if (!ver.version.trim()) { warn('请输入版本号'); return }
|
|
if (!otaFile.value) { warn('请选择固件文件'); return }
|
|
versionSubmitting.value = true
|
|
try {
|
|
// 固件包:目录传 product/<产品id>/,后端强制置于 EAIMAR/ 根下 → EAIMAR/product/<id>/<文件名>
|
|
const url = await uploadFile(otaFile.value, `product/${pid}/`)
|
|
const d = await webApi<{ version: Version }>('addproductversion', {
|
|
version: { productid: pid, version: ver.version.trim(), description: ver.desc, updatepackageaddress: url },
|
|
isuse: false,
|
|
})
|
|
success('添加成功')
|
|
if (ver.isuse && d?.version) await switchCurrentVersion(d.version)
|
|
ver.version = ''; ver.desc = ''; ver.isuse = false; otaFile.value = null
|
|
await loadVersions(pid)
|
|
} catch (e: any) {
|
|
error(e?.message ?? '添加失败')
|
|
} finally {
|
|
versionSubmitting.value = false
|
|
}
|
|
}
|
|
|
|
function delVersion(v: Version) {
|
|
if (currentProduct.value?.versionid === v.id) { error('不能删除当前使用中的版本'); return }
|
|
if (!window.confirm('删除此版本?')) return
|
|
webApi('delproductversion', { id: v.id })
|
|
.then(() => { success('已删除'); if (currentProduct.value) loadVersions(currentProduct.value.id) })
|
|
.catch((e: any) => error(e?.message ?? '删除失败'))
|
|
}
|
|
|
|
onMounted(async () => {
|
|
// 芯片厂商已改为后台可维护的表(chip_vendor),方案商分组要按最新的厂商列表来
|
|
await loadChipVendors(webApi)
|
|
await loadData()
|
|
})
|
|
</script>
|
|
|