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.
 
 
 
 
 
 

1369 lines
68 KiB

<template>
<div class="space-y-5">
<!-- ========== 页面 Tab ========== -->
<div class="flex items-center justify-between flex-wrap gap-3">
<div class="tabs tabs-boxed bg-slate-100 inline-flex">
<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 v-if="canCreate" class="tab" :class="{ 'tab-active': tab === 'produce' }" @click="switchPageTab('produce')">生成MAC</a>
</div>
<!-- 共享渠道商/产品筛选(MAC查询与生产记录共用)。
品牌商筛选已去掉:生产链路不再涉及品牌商,新批次 brandid 一律为 0,
这个下拉筛出来永远是空的。 -->
<div v-if="tab !== 'produce'" class="flex items-end gap-2 flex-wrap">
<select v-model="filterChannel" class="select select-bordered select-sm" style="min-width:160px;" @change="onChannelChange">
<option value="">全部渠道商</option>
<option v-for="ch in filterChannelOptions" :key="ch.id" :value="ch.id">{{ ch.name }} ({{ ch.id }})</option>
</select>
<select v-model.number="filterProduct" class="select select-bordered select-sm" style="min-width:180px;" @change="onProductChange">
<!-- 产品受限的账号(运营账号绑了 productids)不给「全部产品」这一项:
后端 getauthcodes 只认单个 productid,0 = 不限产品,会把绑定之外的产品也查出来。 -->
<option v-if="!productScoped" :value="0">全部产品</option>
<option v-else :value="0">请选择产品</option>
<option v-for="p in filterProductOptions" :key="p.id" :value="p.id">{{ p.devicename || '' }}</option>
</select>
</div>
</div>
<!-- ========== Tab 1: MAC查询 ========== -->
<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">
<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="form-control">
<label class="label py-0"><span class="label-text text-xs">状态</span></label>
<select v-model="codeStatus" class="select select-bordered select-xs">
<option value="0">全部</option>
<option value="-1">未使用</option>
<option value="1">已激活</option>
<option value="2">已过期</option>
</select>
</div>
<div class="form-control">
<label class="label py-0"><span class="label-text text-xs">排序</span></label>
<select v-model="codeSort" class="select select-bordered select-xs">
<option value="createtime">生产时间</option>
<option value="usedtime">激活时间</option>
</select>
</div>
<div class="form-control">
<label class="label py-0"><span class="label-text text-xs">批次</span></label>
<input v-model="codeBatch" type="text" class="input input-bordered input-xs font-mono" placeholder="批次号" style="width:90px;" />
</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" />
</div>
<div class="form-control">
<label class="label py-0"><span class="label-text text-xs">用户UID</span></label>
<input v-model="codeUid" type="text" class="input input-bordered input-xs" placeholder="UID" />
</div>
<div class="form-control">
<label class="label py-0"><span class="label-text text-xs">每页数量</span></label>
<select v-model="codePageSizeSel" class="select select-bordered select-xs" @change="onPageSizeChange">
<option value="20">20</option>
<option value="50">50</option>
<option value="100">100</option>
<option value="200">200</option>
<option value="500">500</option>
<option value="custom">自定义</option>
</select>
</div>
<div v-show="codePageSizeSel === 'custom'" class="form-control">
<label class="label py-0"><span class="label-text text-xs">自定义</span></label>
<input v-model.number="codePageSizeCustom" type="number" min="1" max="2000" class="input input-bordered input-xs" placeholder="1-2000" style="width:90px;" @change="onPageSizeChange" />
</div>
<button class="btn btn-primary btn-xs text-white" @click="loadDevices(true)">搜索</button>
<button class="btn btn-error btn-xs text-white" :disabled="selectedCount === 0" @click="batchSetDisabled(-1)">批量禁用</button>
<button class="btn btn-warning btn-xs text-white" :disabled="selectedCount === 0" @click="batchSetDisabled(0)">批量恢复</button>
<button class="btn btn-ghost btn-xs" :disabled="!devices.length" @click="exportDevicesCSV">导出</button>
<button class="btn btn-ghost btn-xs" :disabled="!devices.length" @click="openEmailForList">发邮件</button>
</div>
</div>
<div class="overflow-x-auto">
<table ref="devicesTable" class="table table-zebra text-sm">
<thead>
<tr>
<th class="w-8"><input v-model="selectAll" type="checkbox" class="checkbox checkbox-xs" @change="toggleSelectAll" /></th>
<th>MAC地址</th><th>产品</th><th>批次</th><th>用户UID</th><th>激活时间</th><th>状态</th><th>操作</th>
</tr>
</thead>
<tbody>
<tr v-if="!canQueryCodes">
<td colspan="8" class="text-center text-slate-400 py-8">请先选择产品</td>
</tr>
<tr v-else-if="devicesError">
<td colspan="8" class="text-center text-red-400 py-8">查询失败: {{ devicesError }}</td>
</tr>
<tr v-else-if="!devices.length">
<td colspan="8" class="text-center text-slate-400 py-8">暂无数据</td>
</tr>
<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 class="font-mono text-xs font-medium">{{ d.devicemac || '-' }}</td>
<td class="text-xs">{{ nameOfProduct(d.productid || 0) }}</td>
<td class="font-mono text-xs">{{ d.batchno || '' }}</td>
<td class="text-xs">{{ d.uid || '-' }}</td>
<td class="text-xs text-slate-500">{{ d.usedtime ? fmtTime(d.usedtime) : '-' }}</td>
<td>
<span class="inline-flex items-center gap-1.5 text-xs font-medium whitespace-nowrap" :class="codeState(d).text">
<span class="h-1.5 w-1.5 rounded-full shrink-0" :class="codeState(d).dot"></span>{{ codeState(d).label }}
</span>
</td>
<td>
<div class="flex gap-1">
<button v-if="d.disabled !== 0" class="btn btn-ghost btn-xs text-warning" @click="batchSetDisabled(0, [d.code])">恢复</button>
<button v-else class="btn btn-ghost btn-xs text-error" @click="batchSetDisabled(-1, [d.code])">禁用</button>
<button v-if="d.status !== 0" class="btn btn-ghost btn-xs text-orange-500" @click="resetDevice(d)">重置</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<div class="flex items-center justify-between px-5 py-3 border-t border-slate-100">
<span class="text-xs text-slate-500">{{ canQueryCodes ? '共 ' + devTotal + ' 条' : '-' }}</span>
<div class="flex items-center gap-2">
<button class="btn btn-ghost btn-xs" :disabled="devPage <= 0 || !canQueryCodes" @click="changePage(-1)">上一页</button>
<span class="text-xs text-slate-500">{{ pgInfoText }}</span>
<button class="btn btn-ghost btn-xs" :disabled="!canQueryCodes || (devPage + 1) >= totalPages" @click="changePage(1)">下一页</button>
</div>
</div>
</div>
<!-- ========== Tab 2: 生产记录 ========== -->
<div v-show="tab === 'batches'">
<div class="grid grid-cols-3 gap-4 mb-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-2xl font-bold text-slate-800">{{ statBatch }}</div>
</div>
<div class="bg-white rounded-xl shadow-sm border border-slate-100 p-5">
<div class="text-xs text-slate-400 mb-1">生成MAC总数</div>
<div class="text-2xl font-bold text-indigo-600">{{ statDevices }}</div>
</div>
<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-base font-semibold text-slate-700">{{ statLast }}</div>
</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 flex-wrap gap-2">
<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="form-control">
<label class="label py-0"><span class="label-text text-xs">起始时间</span></label>
<input v-model="filterStart" type="date" class="input input-bordered input-xs" />
</div>
<div class="form-control">
<label class="label py-0"><span class="label-text text-xs">结束时间</span></label>
<input v-model="filterEnd" type="date" class="input input-bordered input-xs" />
</div>
<button class="btn btn-primary btn-xs text-white" @click="loadNotes">查询</button>
<span class="text-xs text-slate-400">{{ notesCountText }}</span>
<button class="btn btn-ghost btn-xs" @click="exportNotesCSV">导出CSV</button>
</div>
</div>
<div class="overflow-x-auto" style="max-height:480px;">
<table class="table table-pin-rows text-sm">
<thead>
<tr>
<th>批次号</th>
<th class="w-40">生产时间</th>
<th>渠道商</th>
<th>产品</th>
<th>代工厂</th>
<th class="text-right">分成比例</th>
<th>序号区间</th>
<th>起始MAC</th>
<th>结束MAC</th>
<th class="text-right">数量</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-if="notesLoading">
<td colspan="11" class="text-center text-slate-400 py-8">加载中…</td>
</tr>
<tr v-else-if="notesError">
<td colspan="11" class="text-center text-red-400 py-8">查询失败: {{ notesError }}</td>
</tr>
<tr v-else-if="!notes.length">
<td colspan="11" class="text-center text-slate-400 py-8">暂无数据</td>
</tr>
<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">{{ nameOfChannel(n.channelid) }}</td>
<td class="text-xs">{{ nameOfProduct(n.productid) }}</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>
<!-- 导入的批次没有 MAC 区间(外部 MAC 不连续),显示「-」;明细点「查看MAC」 -->
<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>
<div class="flex gap-1">
<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>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- ========== Tab 3: 生成MAC ========== -->
<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">生成MAC</h3>
<p class="text-sm text-slate-500 leading-relaxed mb-1">
生产流程:填写生产表单 → 系统按产品类型计算最终数量并预分配批次号 →
<b>核对生产方案</b>(产品ID / 批次号 / 数量 / MAC区间 / 赠送)→ 提交服务端生产 → 发送生产通知邮件。
</p>
<ul class="text-xs text-slate-400 list-disc list-inside mb-6 space-y-0.5">
<li>生成的就是 <b>MAC 地址</b>,<b>不再有单独的授权码</b>——设备的凭据就是它自己的 MAC,
MAC 前 2 字节即 PID,产品 / 批次 / 渠道都能从它查回来。</li>
<li>产品配置了「每副份数」时,最终生成数量 = 生产数量 × 每副份数。</li>
<li><b>导入MAC</b>:按上传的 MAC 地址列表逐条建档,用于外部已烧录好 MAC 的批次。</li>
<li><b>分成比例按批次填</b>——同一家渠道商不同批次可以谈不同比例,生产时冻结进批次台账。</li>
</ul>
<button class="btn btn-primary text-white" @click="showCreateForm">+ 开始生产</button>
</div>
<!-- 生产表单弹窗 -->
<dialog ref="createModal" class="modal">
<div class="modal-box max-w-2xl">
<h3 class="font-bold text-lg mb-4">生成MAC</h3>
<form @submit.prevent="submitPreview">
<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="cChannel" class="select select-bordered select-sm w-full" required :disabled="!createChannelOptions.length">
<option value="">{{ createChannelOptions.length ? '请选择渠道商' : '暂无已启用渠道商,请先到【渠道商管理】新增并审核通过' }}</option>
<option v-for="ch in createChannelOptions" :key="ch.id" :value="ch.id">{{ ch.name + ' (' + ch.id + ')' }}</option>
</select>
</div>
<div class="form-control">
<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">
<option :value="0">{{ createProductOptions.length ? '请选择产品' : '暂无产品,请先到【产品管理】创建' }}</option>
<option v-for="p in createProductOptions" :key="p.id" :value="p.id">{{ (p.devicename || '') + ' (' + hexId(p.id) + ')' }}</option>
</select>
</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>;设备首次被绑定=激活时快照到设备行,之后改批次不影响已激活设备。VIP 天数与算力都填 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" />
<span class="text-[11px] text-slate-400 mt-1">激活日起连续计,解绑不暂停</span>
</div>
<div class="form-control">
<label class="label py-1"><span class="label-text text-xs">VIP 等级</span></label>
<select v-model.number="cVipLevel" class="select select-bordered select-sm w-full">
<option :value="1">VIP</option>
<option :value="2">VIP-Pro(预留)</option>
</select>
<span class="text-[11px] text-slate-400 mt-1">仅在赠送天数 &gt; 0 时有意义</span>
</div>
<div class="form-control">
<label class="label py-1"><span class="label-text text-xs">赠送算力</span></label>
<input v-model.number="cComputeGrant" type="number" min="0" max="10000000" class="input input-bordered input-sm w-full" placeholder="0" />
<span class="text-[11px] text-slate-400 mt-1">归设备、随设备流转,用完为止</span>
</div>
</div>
</div>
<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'">生成MAC</a>
<a class="tab tab-sm" :class="{ 'tab-active': createMode === 'import' }" @click="createMode = 'import'">导入MAC</a>
</div>
<!-- 只剩生产数量。原来右边那个「绑定MAC地址」开关已删:MAC 本身就是设备凭据,
经典蓝牙耳机一律生成 MAC,没有「绑不绑」的选择;而且服务端从来没读过这个字段。 -->
<div v-show="createMode === 'production'" class="grid grid-cols-2 gap-4 mb-1">
<div class="form-control">
<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'" />
</div>
</div>
<div v-show="createMode === 'import'">
<div class="form-control mb-2">
<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" />
<p class="text-xs text-slate-400 mt-1">只读第一列,每行一个 MAC 地址</p>
</div>
<div class="form-control mb-1">
<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>
<span class="label-text-alt text-slate-400 mt-1">用于外部已烧录好 MAC 的批次:不按序号推算,直接按这里的地址逐条建档</span>
</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 class="flex flex-wrap gap-x-6 gap-y-1">
<span>每副份数:<b>{{ createProductInfo.codeCount }}</b></span>
<span v-if="createMode === 'production'">最终生成:<b class="text-indigo-600">{{ createFinalTotal }}</b> 个 MAC
<template v-if="createProductInfo.codeCount > 1">({{ cNumber || 0 }} 副 × {{ createProductInfo.codeCount }} 份)</template>
</span>
<span v-else>最终生成:<b class="text-indigo-600">{{ importMacCount }}</b> 条(按导入的 MAC 条数)</span>
</div>
</div>
<div class="form-control mb-4">
<label class="label"><span class="label-text font-medium">备注</span></label>
<input v-model="cRemark" type="text" class="input input-bordered input-sm w-full" placeholder="批次备注(选填)" />
</div>
<div class="modal-action">
<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">
<span v-if="previewLoading" class="loading loading-spinner loading-xs"></span>下一步:核对生产方案
</button>
</div>
</form>
</div>
<form method="dialog" class="modal-backdrop"><button>close</button></form>
</dialog>
<!-- 生产方案核对弹窗(按邮件规格展示) -->
<dialog ref="confirmModal" class="modal">
<div class="modal-box max-w-lg">
<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>
<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>渠道商:{{ preview.channelname }}({{ preview.channelid }})</div>
<div>渠道分成比例:<b class="text-indigo-600">{{ (Number(cSharepct) || 0).toFixed(2) }}%</b></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>产品名称:{{ preview.productname }}</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>序号区间:{{ preview.serialstart }} - {{ preview.serialend }}</div>
<!-- 生产模式:MAC 由 (PID,序号) 算出,首尾就是真实边界,显示区间 -->
<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 class="modal-action">
<button type="button" class="btn btn-ghost btn-sm" @click="closeConfirmModal">返回修改</button>
<button type="button" class="btn btn-primary btn-sm text-white" :disabled="submitting" @click="submitCreate">
<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 flex items-center gap-2">
<svg class="h-5 w-5 text-blue-500" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" /></svg>
发送邮件通知
</h3>
<form @submit.prevent="sendEmail">
<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="email.to" type="email" class="input input-bordered input-sm w-full" required />
</div>
<div class="form-control">
<label class="label"><span class="label-text text-xs">抄送</span></label>
<input v-model="email.cc" 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">主题 *</span></label>
<input v-model="email.subject" type="text" class="input input-bordered input-sm w-full" required />
</div>
<div class="form-control mb-3">
<label class="label"><span class="label-text text-xs">内容 *</span></label>
<textarea v-model="email.content" class="textarea textarea-bordered w-full" rows="10" required></textarea>
</div>
<div class="form-control mb-4">
<label class="label"><span class="label-text text-xs">附件</span></label>
<div class="text-sm text-slate-500">{{ csvFileName ? '📎 ' + csvFileName : '无附件' }}</div>
</div>
<div class="modal-action">
<button type="button" class="btn btn-ghost btn-sm" @click="closeEmailModal">取消</button>
<button type="submit" class="btn btn-primary btn-sm text-white" :disabled="emailSending">
<span v-if="emailSending" class="loading loading-spinner loading-xs"></span>发送邮件
</button>
</div>
</form>
</div>
<form method="dialog" class="modal-backdrop"><button>close</button></form>
</dialog>
<!-- 撤销生产批次确认弹窗(仅超管可触发) -->
<dialog ref="revokeModal" class="modal">
<div class="modal-box max-w-md">
<h3 class="font-bold text-lg mb-3 flex items-center gap-2 text-red-600">
<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="M12 9v2m0 4h.01M4.93 19h14.14a2 2 0 001.74-3L13.74 4a2 2 0 00-3.48 0L3.19 16a2 2 0 001.74 3z" /></svg>
撤销生产批次
</h3>
<div class="bg-red-50 border border-red-200 rounded-lg p-3 text-sm text-red-700 mb-4">
此操作将<strong>删除该批次的全部 MAC</strong>以及对应的生产出货记录,<strong>不可恢复</strong>。<br />
若该批次中已有任意一条被激活/绑定,撤销会被拒绝。
</div>
<div class="text-sm space-y-1 mb-4">
<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">MAC数量:</span><span class="font-medium">{{ revokeCtx ? revokeCtx.devicenum : '' }}</span></div>
</div>
<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>
<input v-model="revokeConfirmInput" type="text" class="input input-bordered input-sm w-full" autocomplete="off" />
</div>
<p class="text-xs text-red-500 min-h-[1em] mb-2">{{ revokeError }}</p>
<div class="modal-action">
<button type="button" class="btn btn-ghost btn-sm" @click="closeRevokeDialog">取消</button>
<button type="button" class="btn btn-error btn-sm text-white" :disabled="!revokeConfirmOk || revokeSubmitting" @click="submitRevoke">
<span v-if="revokeSubmitting" class="loading loading-spinner loading-xs"></span>确认撤销
</button>
</div>
</div>
<form method="dialog" class="modal-backdrop"><button>close</button></form>
</dialog>
</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 { loadChipVendors, chipVendorName } from '~/utils/chipvendors'
definePageMeta({ title: 'MAC生成管理' })
interface Product {
id: number
devicename: string
brandid: number
providerid?: number // 归属方案公司 = BID;CID 再由该方案商的 vendor 推出
oemfactoryid?: number // 默认代工厂(oem_factory.id),生产表单只读带出、批次归属按它落库
devicecodecount?: number
}
interface Channel {
id: string
brandid: number // 0=全局渠道商;非 0 只出现在随品牌商自动创建的「官方渠道」上,生产时批次继承它
name: string
contactmails?: string // 生产通知邮件的收件人回退来源(无品牌归属时用它)
status: number // 0待审核 1启用 2停用 3驳回
}
interface Note {
id: number
ts: number
brandid: number
channelid?: string
productid: number
serialstart?: number | string
serialend?: number | string
startmac?: string
endmac?: string
devicenum?: number
batchno?: number | 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 {
code: string
codetype: number
batchno: string
number?: number
devicemac?: string
status: number
disabled: number
uid?: string
usedtime?: number
createtime?: number
brandid?: number
productid?: number
}
interface CreateResult {
devices?: Array<DeviceRow | string>
brandid?: number
channelid?: string
productid?: number
batchno?: string
startmac?: string
endmac?: string
serialstart?: number | string
serialend?: number | string
number?: number
}
interface PreviewResult {
batchno?: string
brandid: number
brandname?: string
channelid?: string
channelname?: string
productid: number
productname?: string
codetype?: string
codecount: number
total: number
serialstart?: number | string
serialend?: number | string
startmac?: string
endmac?: string
importsample?: string[] // 导入模式的前几条 MAC 样例;生产模式为空
}
interface RevokeCtx {
brandid: number
productid: number
batchno: string
devicenum: number
}
const { webApi } = useApi()
const { success, error, warn } = useToast()
const auth = useAuthStore()
const isSuperAdmin = computed(() => auth.identity === 1)
// 管理员及以上(identity 1=超管, 2=管理员)才可生成 MAC
const canCreate = computed(() => auth.identity > 0 && auth.identity <= 2)
// ========== 页面 Tab ==========
const tab = ref<'codes' | 'batches' | 'produce'>('codes')
function switchPageTab(t: 'codes' | 'batches' | 'produce') {
tab.value = t
}
// ========== 全局状态 ==========
const channels = ref<Channel[]>([]) // 全量渠道商(含未启用),前端按品牌商过滤
// 全量代工厂(含已停用):列表列名要能显示历史批次里已停用的工厂,生产下拉再单独筛 enabled
const factories = ref<OemFactory[]>([])
// 方案公司全量:生产二次确认与通知邮件里的 CID/BID 都从这里推
const providers = ref<Provider[]>([])
const products = ref<Product[]>([]) // 当前账号可访问的产品(已按 productids 过滤)
const allProducts = ref<Product[]>([]) // 全量产品(用于名称查询)
const notes = ref<Note[]>([])
const devices = ref<DeviceRow[]>([])
const devTotal = ref(0)
const devPage = ref(0)
const devPageSize = ref(200)
// ── 共享筛选 ──
const filterChannel = ref('')
// 0 = 全部产品(默认)。产品受限的账号除外,见 productScoped。
const filterProduct = ref(0)
// 账号被 productids 绑死了可见产品(运营账号)。这类账号不给「全部产品」这一项——
// 后端 getauthcodes 的 productid=0 是「不限产品」,传 0 等于绕开这层收敛。
const productScoped = ref(false)
const filterStart = ref('')
const filterEnd = ref('')
// ── 统计 ──
const statBatch = ref<number | string>('-')
const statDevices = ref<number | string>('-')
const statLast = ref('-')
const notesCountText = ref('')
const notesLoading = ref(false)
const notesError = ref('')
// ── MAC 筛选(默认最近生产:全部状态 + 按生产时间倒序) ──
const codeStatus = ref('0')
const codeSort = ref('createtime')
const codeBatch = ref('') // 批次号(batchno 短码)
// 本批次给渠道商的分成比例,表单用百分比(更直观),提交时换算成后端的万分比整数。
// 放在批次而不是渠道商上:同一渠道商不同批次可以谈不同比例。
const cSharepct = ref(0)
// 绑定赠送三项,按批次配:同款产品不同批次(不同渠道/不同活动)送的东西经常不一样。
// 三项都是 0 时服务端会回退到产品档案上的旧配置,所以「全 0」= 不覆盖,而不是「不送」。
const cRewardVip = ref(0)
const cVipLevel = ref(1)
const cComputeGrant = ref(0)
const codeMac = ref('')
const codeUid = ref('')
const codePageSizeSel = ref('200')
const codePageSizeCustom = ref<number | null>(null)
const devicesError = ref('')
// ── 选择 ──
const selectAll = ref(false)
const selectedCodes = ref<string[]>([])
const selectedCount = computed(() => selectedCodes.value.length)
const devicesTable = ref<HTMLElement | null>(null)
// ── 生产表单 / 预览 ──
const createModal = ref<HTMLDialogElement | null>(null)
const confirmModal = ref<HTMLDialogElement | null>(null)
const createMode = ref<'production' | 'import'>('production')
const cChannel = ref('')
const cProduct = ref(0)
const cNumber = ref<number | null>(null)
const cMacaddresses = ref('')
const cRemark = ref('')
const submitting = ref(false)
const previewLoading = ref(false)
const preview = ref<PreviewResult | null>(null)
// ── CSV / 邮件 ──
const csvBlob = ref<Blob | null>(null)
const csvFileName = ref('')
const emailModal = ref<HTMLDialogElement | null>(null)
const emailSending = ref(false)
const email = reactive({ to: '', cc: '', subject: '', content: '' })
// ── 撤销 ──
const revokeModal = ref<HTMLDialogElement | null>(null)
const revokeCtx = ref<RevokeCtx | null>(null)
const revokeConfirmInput = ref('')
const revokeError = ref('')
const revokeSubmitting = ref(false)
const revokeConfirmTarget = computed(() => revokeCtx.value ? revokeCtx.value.batchno : '')
const revokeConfirmOk = computed(() => !!revokeCtx.value && revokeConfirmInput.value.trim() === revokeConfirmTarget.value)
const totalPages = computed(() => Math.max(1, Math.ceil(devTotal.value / devPageSize.value)))
// 能不能发查询:不受限的账号任何时候都能(0=全部产品),受限账号必须先选一个产品。
const canQueryCodes = computed(() => !productScoped.value || !!filterProduct.value)
const pgInfoText = computed(() => canQueryCodes.value ? '第 ' + (devPage.value + 1) + ' / ' + totalPages.value + ' 页' : '-')
// 顶部产品/渠道商下拉:都列全部。原来按所选品牌商收窄,品牌商筛选去掉后这层过滤没了意义——
// 而且产品与渠道商现在的 brandid 基本都是 0,按它筛只会筛出空列表。
const filterProductOptions = computed(() => products.value)
const filterChannelOptions = computed(() => channels.value)
// 生产表单的代工厂/方案公司/芯片都由所选产品带出,只读——同一事实在两处能改就一定会不一致。
// 代工厂取产品档案上的 oemfactoryid,服务端也是这么取的(不收前端传值)。
const cFactory = computed(() => {
const p = allProducts.value.find((x) => x.id === cProduct.value)
return Number(p?.oemfactoryid || 0)
})
// 「名称 (0xBID)」;产品没绑方案商时显示成「未绑定方案」而不是空白,
// 否则运营看不出是没选产品还是产品缺配置。
const providerLabel = computed(() => {
const p = allProducts.value.find((x) => x.id === cProduct.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 p = allProducts.value.find((x) => x.id === cProduct.value)
if (!p?.providerid) return '未绑定方案'
const pv = providers.value.find((x) => x.id === p.providerid)
if (!pv?.vendor) return '方案未选芯片'
return `${chipVendorName(pv.vendor)} (0x${hex4(pv.vendor)})`
})
// 核对页的一行摘要:三项都为 0 时说清楚会回退到产品配置,别让人以为是「不送」
const rewardSummary = computed(() => {
const parts: string[] = []
if (Number(cRewardVip.value) > 0) parts.push(`${Number(cVipLevel.value) === 2 ? 'VIP-Pro' : 'VIP'} ${cRewardVip.value} 天`)
if (Number(cComputeGrant.value) > 0) parts.push(`算力 ${cComputeGrant.value}`)
return parts.length ? parts.join(' + ') : '未配置(按产品档案上的旧配置发放)'
})
// 生产表单渠道商下拉:只列已启用的(待审核/停用/驳回不能用于生产)。
// ⚠️ 不按品牌商过滤:表单已经没有品牌商这一项了,品牌归属反过来由所选渠道商推出
// (品牌官方渠道 → 继承它的品牌商;全局渠道商 → 无品牌归属)。
const createChannelOptions = computed(() => channels.value.filter((ch) => ch.status === 1))
// 生产表单产品下拉:列全部产品,不按品牌商/渠道商过滤。
// 产品已经不带品牌商了(品牌归属改成生产时按批次定,见产品管理表单),
// 再按 brandid 筛会因为产品的 brandid=0 而永远筛出空列表、一个产品都选不到。
const createProductOptions = computed(() => products.value)
// 生产表单所选产品的产量说明:每副产品生产几份设备码(devicecodecount,0/1=单份,2=双份)。
// ⚠️ 这里原先还按 devicetype==1 分「经典蓝牙耳机 / 通用产品」,文案上区分「个 MAC / 个码」——
// 那个区分从来就不成立:生产模式下所有产品一视同仁地生成 MAC,code 就是 MAC
// (见 api_createauthcodes.go)。devicetype 已于 2026-09-08 废弃,这里一并去掉。
const createProductInfo = computed(() => {
const p = allProducts.value.find((x) => x.id === cProduct.value)
const codeCount = p?.devicecodecount && p.devicecodecount > 0 ? p.devicecodecount : 1
return { codeCount }
})
const createFinalTotal = computed(() => (Number(cNumber.value) || 0) * createProductInfo.value.codeCount)
const importMacCount = computed(() => cMacaddresses.value.split(',').map((s) => s.trim()).filter(Boolean).length)
// 三个要烧进固件的标识,口径固定为:
// CID = 芯片公司(产品 → 方案商 → solution_provider.vendor,即 chip_vendor.id)
// BID = 方案公司(产品 → providerid,即 solution_provider.id)
// 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) {
const p = allProducts.value.find((x) => x.id === productId)
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) {
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')
}
// 万分比 → 百分比展示。0 显示成 -,免得整列都是意义不大的 0.00%
function ratePct(rate?: number) {
const n = Number(rate) || 0
return n ? (n / 100).toFixed(2) + '%' : '-'
}
function dateToUnix(s: string, endOfDay: boolean) {
if (!s) return 0
const d = new Date(s + (endOfDay ? 'T23:59:59' : 'T00:00:00'))
return Math.floor(d.getTime() / 1000)
}
function nameOfChannel(id?: string): string {
if (!id) return '未分配'
const ch = channels.value.find((x) => x.id === id)
return ch ? ch.name + ' (' + ch.id + ')' : id
}
function nameOfProduct(id: number): string {
const p = allProducts.value.find((x) => x.id === 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) {
return id ? Number(id).toString(16).toUpperCase() : ''
}
function serialRange(n: Note): string {
const a = n.serialstart != null && n.serialstart !== '' ? String(n.serialstart) : ''
const b = n.serialend != null && n.serialend !== '' ? String(n.serialend) : ''
if (!a && !b) return '-'
return a + ' - ' + b
}
// 记录状态展示(封禁优先;圆点 + 文字的轻量样式)
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.status === 1) return { label: '已激活', dot: 'bg-emerald-500', text: 'text-emerald-600' }
if (d.status === 2) return { label: '已过期', dot: 'bg-amber-500', text: 'text-amber-600' }
return { label: '未使用', dot: 'bg-slate-300', text: 'text-slate-500' }
}
// ========== 初始化 ==========
async function init() {
try {
const [pRes, cRes, facRes, pvRes] = await Promise.all([
webApi<{ products: Product[] }>('getproducts', {}),
// status=-1 取全部状态:列表列名要能显示历史批次里已停用的渠道商,生产下拉再单独筛 status=1
webApi<{ channels: Channel[] }>('getchannels', { status: -1 }),
webApi<{ factories: OemFactory[] }>('getoemfactories', {}),
webApi<{ providers: Provider[] }>('getsolutionproviders', {}),
// 芯片厂商:生产表单要按「方案商.vendor → 芯片名」展示 CID,拿不到只影响那一栏的显示
loadChipVendors(webApi),
])
allProducts.value = pRes?.products ?? []
channels.value = cRes?.channels ?? []
factories.value = facRes?.factories ?? []
providers.value = pvRes?.providers ?? []
} catch (e: any) {
error(e?.message ?? '加载基础数据失败')
allProducts.value = []
channels.value = []
factories.value = []
providers.value = []
}
// 按账号绑定的 productids 过滤(identity 1 超管不限)
const allowProducts = auth.products.map((p) => parseInt(p)).filter((n) => !isNaN(n) && n > 0)
if (auth.identity !== 1 && allowProducts.length > 0) {
products.value = allProducts.value.filter((p) => allowProducts.indexOf(p.id) >= 0)
productScoped.value = true
} else {
products.value = allProducts.value.slice()
productScoped.value = false
}
// 默认「全部产品」(MAC查询看全部设备、生产记录看全部批次)。
// 产品受限的账号没有这一项,退回第一个可见产品。
if (productScoped.value && products.value.length > 0) {
filterProduct.value = products.value[0].id
} else {
filterProduct.value = 0
}
reloadAll()
}
// ========== 筛选联动 ==========
function onChannelChange() {
devPage.value = 0
reloadAll()
}
function onProductChange() {
devPage.value = 0
reloadAll()
}
function onPageSizeChange() {
let size: number
if (codePageSizeSel.value === 'custom') {
size = Number(codePageSizeCustom.value)
if (!size || size < 1) return // 等待用户输入有效值
} else {
size = parseInt(codePageSizeSel.value)
}
if (size > 2000) size = 2000 // 后端单页上限
devPageSize.value = size
loadDevices(true)
}
// ========== 主加载入口 ==========
function reloadAll() {
loadNotes()
loadDevices(true)
}
// ========== 生产批次台账加载(默认最近 10 条;设了时间范围则放开到 500) ==========
async function loadNotes() {
notesLoading.value = true
notesError.value = ''
const hasTimeFilter = !!(filterStart.value || filterEnd.value)
const params: Record<string, unknown> = { limit: hasTimeFilter ? 500 : 10 }
if (filterChannel.value) params.channelid = filterChannel.value
if (filterProduct.value) params.productid = filterProduct.value
const s = dateToUnix(filterStart.value, false)
if (s) params.start = s
const e = dateToUnix(filterEnd.value, true)
if (e) params.end = e
try {
const data = await webApi<{ notes: Note[]; total_batch?: number; total_devices?: number }>('getproductionbatches', params)
notes.value = data?.notes ?? []
statBatch.value = data?.total_batch ?? 0
statDevices.value = data?.total_devices ?? 0
statLast.value = notes.value.length ? fmtTime(notes.value[0].ts) : '-'
notesCountText.value = hasTimeFilter ? '共 ' + notes.value.length + ' 条' : '最近 ' + notes.value.length + ' 条'
} catch (e: any) {
notesError.value = e?.message ?? ''
notes.value = []
statBatch.value = '0'
statDevices.value = '0'
statLast.value = '-'
notesCountText.value = ''
} finally {
notesLoading.value = false
}
}
// ========== 撤销生产批次(仅超管) ==========
function openRevokeDialog(brandid: number, productid: number, batchno: string, devicenum: number) {
revokeCtx.value = { brandid, productid, batchno, devicenum }
revokeConfirmInput.value = ''
revokeError.value = ''
revokeModal.value?.showModal()
}
function closeRevokeDialog() {
revokeModal.value?.close()
revokeCtx.value = null
}
async function submitRevoke() {
if (!revokeCtx.value) return
revokeSubmitting.value = true
revokeError.value = ''
try {
const data = await webApi<{ deleted?: number }>('revokeproductionbatch', {
productid: revokeCtx.value.productid,
batchno: revokeCtx.value.batchno,
})
closeRevokeDialog()
success('已撤销批次,删除 ' + (data?.deleted ?? 0) + ' 条记录')
reloadAll()
} catch (e: any) {
revokeError.value = e?.message ?? '撤销失败'
} finally {
revokeSubmitting.value = false
}
}
// 点击生产记录跳转到 MAC 查询(按批次号过滤)
function viewBatchCodes(pid: number, batchno: string) {
filterProduct.value = pid
codeBatch.value = batchno
codeStatus.value = '0'
devPage.value = 0
tab.value = 'codes'
loadDevices(true)
setTimeout(() => {
devicesTable.value?.scrollIntoView({ behavior: 'smooth', block: 'start' })
}, 100)
}
// ========== MAC 记录加载 ==========
function buildDeviceFilterParams(): Record<string, unknown> {
const params: Record<string, unknown> = {
productid: filterProduct.value,
sortby: codeSort.value,
order: 'desc',
}
const st = parseInt(codeStatus.value)
if (!isNaN(st)) params.status = st
if (filterChannel.value) params.channelid = filterChannel.value
const batch = codeBatch.value.trim()
if (batch) params.batchno = batch
const mac = codeMac.value.trim(); if (mac) params.devicemac = mac
const uid = codeUid.value.trim(); if (uid) params.uid = uid
return params
}
async function loadDevices(resetPage: boolean) {
if (resetPage) devPage.value = 0
selectedCodes.value = []
selectAll.value = false
// productid=0 = 全部产品,后端认这个值;只有产品受限的账号必须先选产品。
if (!canQueryCodes.value) {
devices.value = []
devTotal.value = 0
devicesError.value = ''
return
}
const params = Object.assign(buildDeviceFilterParams(), {
limit: devPageSize.value,
offset: devPage.value * devPageSize.value,
})
try {
const data = await webApi<{ devices: DeviceRow[]; total?: number }>('getauthcodes', params)
devices.value = data?.devices ?? []
devTotal.value = data?.total ?? 0
devicesError.value = ''
} catch (e: any) {
devicesError.value = e?.message ?? ''
devices.value = []
devTotal.value = 0
}
}
function changePage(delta: number) {
devPage.value = Math.max(0, devPage.value + delta)
loadDevices(false)
}
async function resetDevice(d: DeviceRow) {
if (!window.confirm('确定重置该设备授权状态?')) return
try {
// productid 取这一行自己的,不取顶部筛选——「全部产品」视图下 filterProduct 是 0,
// 而服务端 pid=0 会退化成「从 code 反解产品」,导入的 MAC 前两字节是厂商 OUI,
// 反解出来是别人的产品。
await webApi('resetlicensestatus', { licenses: d.code, productid: Number(d.productid || filterProduct.value) || 0 })
success('重置成功')
loadDevices(false)
loadNotes()
} catch (e: any) {
error(e?.message ?? '失败')
}
}
// ========== 批量选择 / 禁用 ==========
function toggleSelectAll() {
if (selectAll.value) {
selectedCodes.value = devices.value.map((d) => d.code || '')
} else {
selectedCodes.value = []
}
}
// disabled: -1 禁用 / 0 恢复正常。codes 省略时取勾选项。
// ⚠️ 后端 disableauthcodes 一次只认一个 productid(合表后批量更新必须显式带产品作用域),
// 而「全部产品」视图下勾选项可能跨产品,所以这里按各行自己的 productid 分组、逐组下发。
async function batchSetDisabled(disabled: number, codes?: string[]) {
const list = codes ?? selectedCodes.value.slice()
if (!list.length) { warn('请先勾选记录'); return }
const action = disabled === -1 ? '禁用' : '恢复'
if (!window.confirm('确定' + action + ' ' + list.length + ' 条记录?' + (disabled === -1 ? '\n禁用后对应设备将无法绑定。' : ''))) return
// code → productid,取自当前页数据;查不到就退回顶部筛选值
const pidOf = new Map<string, number>()
devices.value.forEach((d) => { if (d.code) pidOf.set(d.code, Number(d.productid || 0)) })
const groups = new Map<number, string[]>()
for (const code of list) {
const pid = pidOf.get(code) || Number(filterProduct.value) || 0
if (!pid) { warn('有记录无法确定所属产品,请先在顶部选中具体产品'); return }
const g = groups.get(pid)
if (g) g.push(code)
else groups.set(pid, [code])
}
try {
let affected = 0
for (const [pid, group] of groups) {
const data = await webApi<{ affected?: number }>('disableauthcodes', { productid: pid, codes: group, disabled })
affected += data?.affected ?? 0
}
success(action + '成功(' + affected + ' 条)')
loadDevices(false)
} catch (e: any) {
error(e?.message ?? (action + '失败'))
}
}
// ========== 生产表单 → 预览 → 二次确认 → 生产 ==========
function showCreateForm() {
cChannel.value = ''
cProduct.value = 0
cSharepct.value = 0 // 比例是一批一谈的,沿用上一批的值风险太大
cRewardVip.value = 0 // 赠送同理,且宁可漏配(回退产品配置)也不要多发
cVipLevel.value = 1
cComputeGrant.value = 0
cNumber.value = null
cMacaddresses.value = ''
cRemark.value = ''
createMode.value = 'production'
preview.value = null
createModal.value?.showModal()
}
function closeCreateModal() {
createModal.value?.close()
}
function closeConfirmModal() {
confirmModal.value?.close()
}
function readCSV(ev: Event) {
const input = ev.target as HTMLInputElement
const file = input.files?.[0]
if (!file) return
const reader = new FileReader()
reader.onload = (e) => {
const lines = String(e.target?.result ?? '').split('\n')
const macs: string[] = []
lines.forEach((line) => {
const cols = line.split(',')
if (cols[0] && cols[0].trim()) macs.push(cols[0].trim())
})
cMacaddresses.value = macs.join(', ')
success('读取 ' + macs.length + ' 个 MAC 地址')
}
reader.readAsText(file, 'UTF-8')
}
// 组装生产请求参数(预览与正式生产共用)
function buildCreateParams(): Record<string, unknown> | null {
const productId = Number(cProduct.value)
const channelId = cChannel.value.trim()
if (!channelId) { warn('请选择渠道商'); return null }
if (!productId) { warn('请选择产品'); return null }
// CID/BID 都从产品的方案商推导(BID=方案公司、CID=该方案用的芯片公司),没绑就发不出可用设备码
if (bidOfProduct(productId) === 'N/A') {
warn('该产品未绑定方案,无法确定方案ID(BID),请先到【产品管理】补上归属方案')
return null
}
if (cidOfProduct(productId) === 'N/A') {
warn('该产品的方案未选芯片,无法确定芯片ID(CID),请先到【方案商管理】给它选芯片')
return null
}
// 不传 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.viplevel = Number(cVipLevel.value) === 2 ? 2 : 1
params.computegrant = Math.max(0, Number(cComputeGrant.value) || 0)
if (createMode.value === 'production') {
params.createtype = 1
params.number = Number(cNumber.value)
if (!params.number || (params.number as number) < 1) { warn('请输入有效数量'); return null }
} else {
params.createtype = 2
const macStr = cMacaddresses.value.trim()
if (!macStr) { warn('请输入或上传 MAC 地址'); return null }
params.macaddresses = macStr
}
return params
}
// 第一步:生产预检 → 弹出二次确认(按邮件规格展示生产方案)
async function submitPreview() {
const params = buildCreateParams()
if (!params) return
previewLoading.value = true
try {
const data = await webApi<PreviewResult>('previewauthcodes', params)
preview.value = data ?? null
if (!preview.value) { error('预检失败'); return }
confirmModal.value?.showModal()
} catch (e: any) {
error(e?.message ?? '预检失败')
} finally {
previewLoading.value = false
}
}
// 第二步:二次确认 → 提交服务端生产 → 弹出邮件发送
async function submitCreate() {
const params = buildCreateParams()
if (!params) return
const productId = params.productid as number
if (preview.value?.batchno) params.batchno = preview.value.batchno // 回传预分配批次号
submitting.value = true
try {
const data = await webApi<CreateResult>('createauthcodes', params)
// 结果中的 devices 可能是码字符串数组或对象数组,统一规整为对象
const rawDevices = data?.devices ?? []
const devs: DeviceRow[] = rawDevices.map((x: any) => (
typeof x === 'string'
? ({ code: x, brandid: data?.brandid ?? 0, productid: data?.productid ?? productId } as DeviceRow)
: ({ ...x, brandid: x.brandid ?? data?.brandid ?? 0, productid: x.productid ?? data?.productid ?? productId } as DeviceRow)
))
closeConfirmModal()
closeCreateModal()
if (devs.length > 0) {
success('成功生成 ' + devs.length + ' 条,批次 ' + (data?.batchno || ''))
buildCSV(devs)
// 品牌商由服务端推出,收件人取不到品牌邮箱时回退渠道商邮箱
openEmailDialog(data ?? {}, productId)
} else {
success('创建成功')
tab.value = 'batches'
reloadAll()
}
} catch (e: any) {
error(e?.message ?? '创建失败')
} finally {
submitting.value = false
}
}
// ========== CSV ==========
// 这份 CSV 是发给方案公司/工厂烧录用的,只有 MAC 一列。
// 2026-09-02 起授权码字符串已整体移除,code 就是 MAC,没有第二个可导的标识。
function buildCSV(devs: DeviceRow[]) {
const headers = ['Mac地址']
const rows = devs.map((d) => [d.devicemac || d.code || ''])
const content = [headers.join(',')].concat(rows.map((r) => r.join(','))).join('\n')
csvBlob.value = new Blob([content], { type: 'text/csv;charset=utf-8;' })
const first = devs[0]
csvFileName.value = '设备生产_' + (first.brandid || 'unknown') + '_' + (first.productid || 'unknown') + '_' + new Date().toISOString().split('T')[0] + '.csv'
}
// 导出当前筛选条件下的「全部」记录(分页拉取,而非仅当前页)
async function exportDevicesCSV() {
if (!canQueryCodes.value) { warn('请先选择产品'); return }
try {
const base = buildDeviceFilterParams()
const PAGE = 2000 // 后端单页上限
let all: DeviceRow[] = []
let offset = 0
let total = Infinity
while (all.length < total) {
const params = Object.assign({}, base, { limit: PAGE, offset })
const data = await webApi<{ devices: DeviceRow[]; total?: number }>('getauthcodes', params)
total = data?.total ?? 0
const page = data?.devices ?? []
all = all.concat(page)
if (page.length === 0) break // 防止异常死循环
offset += PAGE
}
if (all.length === 0) { warn('没有数据可导出'); return }
buildCSV(all)
if (csvBlob.value) downloadBlob(csvBlob.value, csvFileName.value)
success('已导出全部 ' + all.length + ' 条')
} catch (e: any) {
error(e?.message ?? '导出失败')
}
}
function exportNotesCSV() {
if (!notes.value.length) { warn('没有数据可导出'); return }
// 与页面列顺序保持一致:批次号在前,不再有品牌商(生产链路已不涉及品牌商)
const headers = ['批次号', '生产时间', '渠道商', '产品', '代工厂', '分成比例', '序号区间', '起始MAC', '结束MAC', '数量']
const rows = notes.value.map((n) => [
n.batchno || '', fmtTime(n.ts), nameOfChannel(n.channelid), nameOfProduct(n.productid),
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 blob = new Blob([content], { type: 'text/csv;charset=utf-8;' })
downloadBlob(blob, '生产记录_' + new Date().toISOString().split('T')[0] + '.csv')
success('导出成功')
}
function downloadBlob(blob: Blob, name: string) {
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = name
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
}
// ========== 邮件 ==========
// 生产通知的收件人:**方案公司**(产品归属的方案商)的联系邮箱优先——货是方案公司在打,
// MAC 清单也是给它烧录用的。方案公司没填邮箱时退到本次所选渠道商,
// 两个都没有就留空让人手填;没有回退的话表单会经常是空的,运营每次都得自己查邮箱。
//
// ⚠️ 2026-09-12 品牌商下线前,中间还有一路「品牌商联系邮箱」。brand 表已删,这一路没了——
// 原先只靠品牌商邮箱收生产通知的场景,现在要么给方案商补邮箱,要么给渠道商补。
function productionMailTo(productId: number): 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, channel?.contactmails]
.map((x) => (x || '').trim())
.find((x) => x !== '')
return (raw || '').split(',').map((e) => e.trim()).filter(Boolean)
}
function openEmailDialog(createResult: CreateResult, productId: number) {
const product = allProducts.value.find((p) => p.id === productId)
const productName = product ? product.devicename : '未知产品(ID:' + productId + ')'
const emails = productionMailTo(productId)
email.to = emails[0] || ''
email.cc = emails.length > 1 ? emails.slice(1).join(',') : ''
const today = new Date().toLocaleDateString()
email.subject = '【生产通知】批次' + (createResult.batchno || '') + '设备生产通知 - ' + today
email.content =
'尊敬的合作伙伴:\n\n' +
'我公司向贵司发送产品生产通知函。\n\n' +
(createResult.startmac
? '设备地址区间为:' + createResult.startmac + '-' + createResult.endmac + '\n'
: '设备地址:见附件 CSV(本批为导入的 MAC,不连续、无区间)\n') +
'序号区间:' + (createResult.serialstart ?? '') + ' - ' + (createResult.serialend ?? '') + '\n' +
'生产批次(BatchNo):' + (createResult.batchno || '') + '\n' +
'芯片ID(CID): 0x' + cidOfProduct(productId) + '\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' +
'产品名称: ' + productName + '\n' +
'生产数量:' + (createResult.number || 0) + '\n\n' +
'请查收附件中的设备详细信息。\n\n' +
'此致\n敬礼!'
emailModal.value?.showModal()
}
function openEmailForList() {
if (devices.value.length === 0) return
buildCSV(devices.value)
const first = devices.value[0]
const product = allProducts.value.find((p) => p.id === first.productid)
const productName = product ? product.devicename : ''
const emails = productionMailTo(first.productid)
email.to = emails[0] || ''
email.cc = emails.length > 1 ? emails.slice(1).join(',') : ''
const today = new Date().toLocaleDateString()
email.subject = '【MAC清单】' + (productName || '设备') + ' - ' + today
email.content =
'尊敬的合作伙伴:\n\n' +
'附件为当前查询的 MAC 清单,共 ' + devices.value.length + ' 条记录。\n' +
(productName ? '产品:' + productName + '\n' : '') +
'\n请查收。\n\n此致\n敬礼!'
emailModal.value?.showModal()
}
function closeEmailModal() {
emailModal.value?.close()
}
async function sendEmail() {
emailSending.value = true
const formData = new FormData()
formData.append('to', email.to)
formData.append('subject', email.subject)
formData.append('content', email.content)
const cc = email.cc.trim()
if (cc) formData.append('cc', cc)
if (csvBlob.value && csvFileName.value) {
const file = new File([csvBlob.value], csvFileName.value, { type: 'text/csv' })
formData.append('file', file, csvFileName.value)
}
try {
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: formData,
})
const data = await resp.json()
if (data.code === 0) {
success('邮件发送成功')
closeEmailModal()
tab.value = 'batches'
reloadAll()
} else {
error('发送失败: ' + (data.msg || '未知错误'))
}
} catch (err: any) {
error('发送失败: ' + (err?.message ?? ''))
} finally {
emailSending.value = false
}
}
onMounted(init)
</script>