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.
1339 lines
66 KiB
1339 lines
66 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">
|
|
<option :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>用户UID</th><th>激活时间</th><th>状态</th><th>操作</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-if="!filterProduct">
|
|
<td colspan="7" class="text-center text-slate-400 py-8">请先选择产品</td>
|
|
</tr>
|
|
<tr v-else-if="devicesError">
|
|
<td colspan="7" class="text-center text-red-400 py-8">查询失败: {{ devicesError }}</td>
|
|
</tr>
|
|
<tr v-else-if="!devices.length">
|
|
<td colspan="7" 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="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.code)">重置</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">{{ filterProduct ? '共 ' + devTotal + ' 条' : '-' }}</span>
|
|
<div class="flex items-center gap-2">
|
|
<button class="btn btn-ghost btn-xs" :disabled="devPage <= 0 || !filterProduct" @click="changePage(-1)">上一页</button>
|
|
<span class="text-xs text-slate-500">{{ pgInfoText }}</span>
|
|
<button class="btn btn-ghost btn-xs" :disabled="!filterProduct || (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>;三项都填 0 则回退到产品档案上的旧配置)
|
|
</div>
|
|
<div class="grid grid-cols-3 gap-4">
|
|
<div class="form-control">
|
|
<label class="label py-1"><span class="label-text text-xs">赠送VIP(天)</span></label>
|
|
<input v-model.number="cRewardVip" type="number" min="0" max="3650" class="input input-bordered input-sm w-full" placeholder="0" />
|
|
</div>
|
|
<div class="form-control">
|
|
<label class="label py-1"><span class="label-text text-xs">赠送翻译时长(分钟)</span></label>
|
|
<input v-model.number="cRewardTranslate" type="number" min="0" max="525600" class="input input-bordered input-sm w-full" placeholder="0" />
|
|
</div>
|
|
<div class="form-control">
|
|
<label class="label py-1"><span class="label-text text-xs">赠送会议时长(分钟)</span></label>
|
|
<input v-model.number="cRewardMeeting" type="number" min="0" max="525600" class="input input-bordered input-sm w-full" placeholder="0" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div 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.isClassicBT ? '经典蓝牙耳机' : '通用产品' }}</b></span>
|
|
<span>每副份数:<b>{{ createProductInfo.codeCount }}</b></span>
|
|
<span v-if="createMode === 'production'">最终生成:<b class="text-indigo-600">{{ createFinalTotal }}</b> 个{{ createProductInfo.isClassicBT ? 'MAC' : '码' }}
|
|
<template v-if="createProductInfo.codeCount > 1">({{ cNumber || 0 }} 副 × {{ createProductInfo.codeCount }} 份)</template>
|
|
</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.brandname || '-' }}<span v-if="!preview.brandid" class="text-xs text-slate-400 ml-2">(无品牌归属,由所选渠道商推出)</span></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 Brand {
|
|
id: number
|
|
brand: string
|
|
contactmails?: string
|
|
}
|
|
interface Product {
|
|
id: number
|
|
devicename: string
|
|
brandid: number
|
|
providerid?: number // 归属方案公司 = BID;CID 再由该方案商的 vendor 推出
|
|
oemfactoryid?: number // 默认代工厂(oem_factory.id),生产表单只读带出、批次归属按它落库
|
|
devicetype?: number
|
|
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 brands = ref<Brand[]>([])
|
|
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('')
|
|
const filterProduct = ref(0)
|
|
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 cRewardTranslate = ref(0)
|
|
const cRewardMeeting = 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)))
|
|
const pgInfoText = computed(() => filterProduct.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(`VIP ${cRewardVip.value} 天`)
|
|
if (Number(cRewardTranslate.value) > 0) parts.push(`翻译 ${cRewardTranslate.value} 分钟`)
|
|
if (Number(cRewardMeeting.value) > 0) parts.push(`会议 ${cRewardMeeting.value} 分钟`)
|
|
return parts.length ? parts.join(' + ') : '未配置(按产品档案上的旧配置发放)'
|
|
})
|
|
|
|
// 生产表单渠道商下拉:只列已启用的(待审核/停用/驳回不能用于生产)。
|
|
// ⚠️ 不按品牌商过滤:表单已经没有品牌商这一项了,品牌归属反过来由所选渠道商推出
|
|
// (品牌官方渠道 → 继承它的品牌商;全局渠道商 → 无品牌归属)。
|
|
const createChannelOptions = computed(() => channels.value.filter((ch) => ch.status === 1))
|
|
// 生产表单产品下拉:列全部产品,不按品牌商/渠道商过滤。
|
|
// 产品已经不带品牌商了(品牌归属改成生产时按批次定,见产品管理表单),
|
|
// 再按 brandid 筛会因为产品的 brandid=0 而永远筛出空列表、一个产品都选不到。
|
|
const createProductOptions = computed(() => products.value)
|
|
// 生产表单所选产品的类型说明(经典蓝牙=1 → 设备码;每副份数 devicecodecount)
|
|
const createProductInfo = computed(() => {
|
|
const p = allProducts.value.find((x) => x.id === cProduct.value)
|
|
const codeCount = p?.devicecodecount && p.devicecodecount > 0 ? p.devicecodecount : 1
|
|
return { isClassicBT: p?.devicetype === 1, 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 [fRes, pRes, cRes, facRes, pvRes] = await Promise.all([
|
|
webApi<{ brands: Brand[] }>('getbrands', {}),
|
|
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),
|
|
])
|
|
brands.value = fRes?.brands ?? []
|
|
allProducts.value = pRes?.products ?? []
|
|
channels.value = cRes?.channels ?? []
|
|
factories.value = facRes?.factories ?? []
|
|
providers.value = pvRes?.providers ?? []
|
|
} catch (e: any) {
|
|
error(e?.message ?? '加载基础数据失败')
|
|
brands.value = []
|
|
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)
|
|
} else {
|
|
products.value = allProducts.value.slice()
|
|
}
|
|
|
|
// 默认选第一个可见产品 → 自动加载该产品下的所有数据
|
|
if (products.value.length > 0) {
|
|
filterProduct.value = products.value[0].id
|
|
}
|
|
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
|
|
if (!filterProduct.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(license: string) {
|
|
if (!window.confirm('确定重置该设备授权状态?')) return
|
|
try {
|
|
// 必须带 productid:设备分表(license_<pid>)按产品切,而 code 就是 MAC,
|
|
// 导入进来的 MAC 前两字节是外部厂商的 OUI,服务端反解出来会是别人的产品。
|
|
await webApi('resetlicensestatus', { licenses: license, productid: Number(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 省略时取勾选项
|
|
async function batchSetDisabled(disabled: number, codes?: string[]) {
|
|
if (!filterProduct.value) { warn('请先选择产品'); return }
|
|
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
|
|
try {
|
|
const data = await webApi<{ affected?: number }>('disableauthcodes', { productid: filterProduct.value, codes: list, disabled })
|
|
success(action + '成功(' + (data?.affected ?? 0) + ' 条)')
|
|
loadDevices(false)
|
|
} catch (e: any) {
|
|
error(e?.message ?? (action + '失败'))
|
|
}
|
|
}
|
|
|
|
// ========== 生产表单 → 预览 → 二次确认 → 生产 ==========
|
|
function showCreateForm() {
|
|
cChannel.value = ''
|
|
cProduct.value = 0
|
|
cSharepct.value = 0 // 比例是一批一谈的,沿用上一批的值风险太大
|
|
cRewardVip.value = 0 // 赠送同理,且宁可漏配(回退产品配置)也不要多发
|
|
cRewardTranslate.value = 0
|
|
cRewardMeeting.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.rewardtranslate = Math.max(0, Number(cRewardTranslate.value) || 0)
|
|
params.rewardmeeting = Math.max(0, Number(cRewardMeeting.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 ?? {}, data?.brandid ?? 0, 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 (!filterProduct.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 清单也是给它烧录用的。方案公司没填邮箱时才依次退到品牌商、本次所选渠道商,
|
|
// 三个都没有就留空让人手填;没有回退的话表单会经常是空的,运营每次都得自己查邮箱。
|
|
function productionMailTo(productId: number, brandMails?: string): string[] {
|
|
const p = allProducts.value.find((x) => x.id === productId)
|
|
const provider = p?.providerid ? providers.value.find((v) => v.id === p.providerid) : undefined
|
|
const channel = channels.value.find((ch) => ch.id === cChannel.value)
|
|
const raw = [provider?.contactmails, brandMails, channel?.contactmails]
|
|
.map((x) => (x || '').trim())
|
|
.find((x) => x !== '')
|
|
return (raw || '').split(',').map((e) => e.trim()).filter(Boolean)
|
|
}
|
|
function openEmailDialog(createResult: CreateResult, brandId: number, productId: number) {
|
|
const brand = brands.value.find((f) => f.id === brandId)
|
|
const product = allProducts.value.find((p) => p.id === productId)
|
|
const productName = product ? product.devicename : '未知产品(ID:' + productId + ')'
|
|
|
|
const emails = productionMailTo(productId, brand?.contactmails)
|
|
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 brand = brands.value.find((f) => f.id === first.brandid)
|
|
const product = allProducts.value.find((p) => p.id === first.productid)
|
|
const productName = product ? product.devicename : ''
|
|
|
|
const emails = productionMailTo(first.productid, brand?.contactmails)
|
|
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>
|
|
|