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.
 
 
 
 
 
 

982 lines
50 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()">&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-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_&lt;PID&gt;</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" />
<select v-if="form.appnames.length > 1" v-model="imageApp" class="select select-bordered select-sm w-36">
<option v-for="n in form.appnames" :key="n" :value="n">{{ n }}</option>
</select>
<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()">&larr; 返回产品列表</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,.upd" @change="onOtaPick" />
</div>
<div v-if="productApps.length > 1" class="form-control mb-3 max-w-xs">
<label class="label"><span class="label-text text-xs">上传到应用 *</span></label>
<select v-model="firmwareApp" class="select select-bordered select-sm w-full">
<option v-for="n in productApps" :key="n" :value="n">{{ n }}</option>
</select>
</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, watch } 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)
imageApp.value = form.appnames[0] || ''
} 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 = []
imageApp.value = ''
}
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)
const imageApp = ref('')
watch(() => form.appnames.slice(), (names) => {
if (!names.includes(imageApp.value)) imageApp.value = names[0] || ''
})
function onImagePick(e: Event) {
imgFile.value = (e.target as HTMLInputElement).files?.[0] ?? null
}
// 上传目录用产品绑定的应用名。只绑一个时不用再选;绑了多个必须点名,否则服务端拒绝。
function pickUploadApp(names: string[], chosen: string): string | null {
const list = names.map((s) => s.trim()).filter(Boolean)
if (!list.length) {
warn('请先绑定应用,再上传')
return null
}
if (list.length === 1) return list[0]
const pick = chosen.trim()
if (!list.includes(pick)) {
warn('该产品绑定了多个应用,请选择要上传到哪个应用')
return null
}
return pick
}
async function uploadImage() {
if (!imgFile.value) { warn('请先选择图片'); return }
const appName = pickUploadApp(form.appnames, imageApp.value)
if (!appName) return
imgUploading.value = true
try {
const path = form.id ? `product/${form.id}/images/` : 'product/images/'
const url = await uploadFile(imgFile.value, path, { appName })
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 firmwareApp = ref('')
const productApps = computed(() => appNamesOf(currentProduct.value ?? { appnames: '' } as Product))
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 }
firmwareApp.value = appNamesOf(currentProduct.value)[0] || ''
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 }
const appName = pickUploadApp(productApps.value, firmwareApp.value)
if (!appName) return
versionSubmitting.value = true
try {
// 目录根是产品绑定的应用名,由服务端按 appnames 写入,不再固定 EAIMAR/
const url = await uploadFile(otaFile.value, `product/${pid}/`, { appName })
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>