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.
 
 
 
 
 
 

1194 lines
54 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')">授权码查询</a>
<a class="tab" :class="{ 'tab-active': tab === 'batches' }" @click="switchPageTab('batches')">生产记录</a>
<a v-if="canCreate" class="tab" :class="{ 'tab-active': tab === 'produce' }" @click="switchPageTab('produce')">生产授权码</a>
</div>
<!-- 共享品牌/产品筛选(授权码查询与生产记录共用) -->
<div v-if="tab !== 'produce'" class="flex items-end gap-2 flex-wrap">
<select v-model.number="filterBrand" class="select select-bordered select-sm" style="min-width:150px;" @change="onBrandChange">
<option :value="0">全部品牌商</option>
<option v-for="b in brands" :key="b.id" :value="b.id">{{ b.brand || '' }}</option>
</select>
<select v-model="filterChannel" class="select select-bordered select-sm" style="min-width:160px;" @change="onChannelChange">
<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: 授权码查询 ========== -->
<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">授权码记录<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">授权码</span></label>
<input v-model="codeCode" type="text" class="input input-bordered input-xs" placeholder="授权码" />
</div>
<div class="form-control">
<label class="label py-0"><span class="label-text text-xs">MAC</span></label>
<input v-model="codeMac" type="text" class="input input-bordered input-xs" placeholder="MAC" />
</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>授权码</th><th>批次</th><th>MAC地址</th><th>用户UID</th><th>激活时间</th><th>状态</th><th>操作</th>
</tr>
</thead>
<tbody>
<tr v-if="!filterProduct">
<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">{{ d.code || '' }}</td>
<td class="font-mono text-xs">{{ d.batchno || '' }}</td>
<td class="font-mono text-xs">{{ d.devicemac || '-' }}</td>
<td class="text-xs">{{ d.uid || '-' }}</td>
<td class="text-xs 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">生产授权码总数</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">点击记录查看该批次授权码</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 class="w-40">生产时间</th>
<th>品牌商</th>
<th>渠道商</th>
<th>产品</th>
<th>批次号</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="10" class="text-center text-slate-400 py-8">加载中…</td>
</tr>
<tr v-else-if="notesError">
<td colspan="10" class="text-center text-red-400 py-8">查询失败: {{ notesError }}</td>
</tr>
<tr v-else-if="!notes.length">
<td colspan="10" 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="text-xs text-slate-500 whitespace-nowrap">{{ fmtTime(n.ts) }}</td>
<td class="text-xs">{{ nameOfBrand(n.brandid) }}</td>
<td class="text-xs">{{ nameOfChannel(n.channelid) }}</td>
<td class="text-xs">{{ nameOfProduct(n.productid) }}</td>
<td class="font-mono text-xs font-medium text-indigo-600">{{ n.batchno || '' }}</td>
<td class="font-mono text-xs">{{ serialRange(n) }}</td>
<td class="font-mono text-xs">{{ n.startmac || '' }}</td>
<td class="font-mono text-xs">{{ n.endmac || '' }}</td>
<td class="text-right font-medium">{{ n.devicenum || 0 }}</td>
<td>
<div class="flex gap-1">
<button class="btn btn-ghost btn-xs text-indigo-500" @click.stop="viewBatchCodes(n.productid, String(n.batchno || ''))">查看授权码</button>
<button 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: 生产授权码 ========== -->
<div v-show="tab === 'produce'" class="bg-white rounded-xl shadow-sm border border-slate-100 p-8 max-w-3xl">
<h3 class="text-base font-semibold text-slate-800 mb-2">生产授权码</h3>
<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>设备码</b>(每个码绑定 MAC 地址);其余产品生成<b>授权码</b>。</li>
<li>产品配置了「每副份数」时,最终生成数量 = 生产数量 × 每副份数。</li>
<li>导入模式按上传的 MAC 地址列表逐条生成设备码。</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">生产授权码</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.number="cBrand" class="select select-bordered select-sm w-full" required @change="onCreateBrandChange">
<option :value="0">请选择品牌商</option>
<option v-for="b in brands" :key="b.id" :value="b.id">{{ (b.brand || '') + ' (' + hexId(b.id) + ')' }}</option>
</select>
</div>
<div class="form-control">
<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="">{{ cBrand ? (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">{{ cBrand ? '请选择产品' : '请先选择品牌商' }}</option>
<option v-for="p in createProductOptions" :key="p.id" :value="p.id">{{ (p.devicename || '') + ' (' + hexId(p.id) + ')' }}</option>
</select>
</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'">生产</a>
<a class="tab tab-sm" :class="{ 'tab-active': createMode === 'import' }" @click="createMode = 'import'">导入</a>
</div>
<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 class="form-control">
<label class="label"><span class="label-text font-medium">绑定MAC地址</span></label>
<input v-model="cBindmac" type="checkbox" class="toggle toggle-primary" />
</div>
</div>
<div 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">上传 CSV 文件,系统将读取第一列作为 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>
</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 ? '经典蓝牙耳机(设备码,绑定MAC)' : '通用产品(授权码)' }}</b></span>
<span>每副份数:<b>{{ createProductInfo.codeCount }}</b></span>
<span v-if="createMode === 'production'">最终生成:<b class="text-indigo-600">{{ createFinalTotal }}</b> 个{{ createProductInfo.isClassicBT ? '设备码' : '授权码' }}
<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 }}</div>
<div>渠道商:{{ preview.channelname }}({{ preview.channelid }})</div>
<div>服务ID(CID):0x{{ previewCid }}</div>
<div>品牌ID(BID):0x{{ hexId(preview.brandid).padStart(4, '0') }}</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>
<div v-if="preview.startmac">MAC地址区间:{{ preview.startmac }} - {{ preview.endmac }}</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>删除该批次的全部授权码</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 ? nameOfBrand(revokeCtx.brandid) : '' }}</span></div>
<div><span class="text-slate-500">产品:</span><span class="font-medium">{{ revokeCtx ? nameOfProduct(revokeCtx.productid) : '' }}</span></div>
<div><span class="text-slate-500">批次号:</span><span class="font-mono font-medium text-red-600">{{ revokeConfirmTarget }}</span></div>
<div><span class="text-slate-500">授权码数量:</span><span class="font-medium">{{ revokeCtx ? revokeCtx.devicenum : '' }}</span></div>
</div>
<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'
definePageMeta({ title: '生产管理' })
interface Brand {
id: number
brand: string
providerid?: number
contactmails?: string
}
interface Product {
id: number
devicename: string
brandid: number
devicetype?: number
devicecodecount?: number
}
interface Channel {
id: string
brandid: number
name: 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
}
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
}
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=管理员)才可生产授权码
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[]>([]) // 全量渠道商(含未启用),前端按品牌商过滤
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 filterBrand = ref(0)
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('')
// ── 授权码筛选(默认最近生产:全部状态 + 按生产时间倒序) ──
const codeStatus = ref('0')
const codeSort = ref('createtime')
const codeBatch = ref('') // 批次号(batchno 短码)
const codeCode = ref('')
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 cBrand = ref(0)
const cChannel = ref('')
const cProduct = ref(0)
const cBindmac = ref(false)
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 + ' 页' : '-')
// 顶部产品下拉:按所选品牌商过滤
const filterProductOptions = computed(() => {
return filterBrand.value
? products.value.filter((p) => p.brandid === filterBrand.value)
: products.value
})
// 顶部渠道商下拉:按所选品牌商过滤(不限品牌商时列全部)
const filterChannelOptions = computed(() => {
return filterBrand.value
? channels.value.filter((ch) => ch.brandid === filterBrand.value)
: channels.value
})
// 生产表单渠道商下拉:按所选品牌商过滤,且只列已启用的(待审核/停用/驳回不能用于生产)
const createChannelOptions = computed(() => {
return cBrand.value
? channels.value.filter((ch) => ch.brandid === cBrand.value && ch.status === 1)
: []
})
// 生产表单产品下拉:按所选品牌商过滤
const createProductOptions = computed(() => {
return cBrand.value
? products.value.filter((p) => p.brandid === cBrand.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)
const previewCid = computed(() => {
if (!preview.value) return 'N/A'
const b = brands.value.find((x) => x.id === preview.value!.brandid)
return b && b.providerid != null ? Number(b.providerid).toString(16).padStart(4, '0').toUpperCase() : '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')
}
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 nameOfBrand(id: number): string {
const b = brands.value.find((x) => x.id === id)
return b ? b.brand : String(id || '')
}
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 || '')
}
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] = await Promise.all([
webApi<{ brands: Brand[] }>('getbrands', {}),
webApi<{ products: Product[] }>('getproducts', {}),
// status=-1 取全部状态:列表列名要能显示历史批次里已停用的渠道商,生产下拉再单独筛 status=1
webApi<{ channels: Channel[] }>('getchannels', { status: -1 }),
])
brands.value = fRes?.brands ?? []
allProducts.value = pRes?.products ?? []
channels.value = cRes?.channels ?? []
} catch (e: any) {
error(e?.message ?? '加载基础数据失败')
brands.value = []
allProducts.value = []
channels.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 onBrandChange() {
// 切换品牌商后,若当前所选产品/渠道商不在过滤后的列表,则清空
if (filterProduct.value && !filterProductOptions.value.find((p) => p.id === filterProduct.value)) {
filterProduct.value = 0
}
if (filterChannel.value && !filterChannelOptions.value.find((ch) => ch.id === filterChannel.value)) {
filterChannel.value = ''
}
reloadAll()
}
function onChannelChange() {
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 (filterBrand.value) params.brandid = filterBrand.value
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
}
}
// 点击生产记录跳转到授权码查询(按批次号过滤)
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)
}
// ========== 授权码记录加载 ==========
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 (filterBrand.value) params.brandid = filterBrand.value
if (filterChannel.value) params.channelid = filterChannel.value
const batch = codeBatch.value.trim()
if (batch) params.batchno = batch
const code = codeCode.value.trim(); if (code) params.code = code
const mac = codeMac.value.trim(); if (mac) params.devicemac = mac
const 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 {
await webApi('resetlicensestatus', { licenses: license })
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() {
cBrand.value = 0
cChannel.value = ''
cProduct.value = 0
cBindmac.value = false
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 onCreateBrandChange() {
cProduct.value = 0
cChannel.value = ''
}
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 brandId = Number(cBrand.value)
const productId = Number(cProduct.value)
const channelId = cChannel.value.trim()
if (!brandId || !productId) { warn('请选择品牌商和产品'); return null }
if (!channelId) { warn('请选择渠道商'); return null }
const params: Record<string, unknown> = { brandid: brandId, channelid: channelId, productid: productId, remark: cRemark.value.trim() }
if (createMode.value === 'production') {
params.createtype = 1
params.number = Number(cNumber.value)
params.isbindmac = cBindmac.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 brandId = params.brandid as number
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 ?? brandId, productid: data?.productid ?? productId } as DeviceRow)
: ({ ...x, brandid: x.brandid ?? data?.brandid ?? brandId, productid: x.productid ?? data?.productid ?? productId } as DeviceRow)
))
closeConfirmModal()
closeCreateModal()
if (devs.length > 0) {
success('成功生成 ' + devs.length + ' 个授权码,批次 ' + (data?.batchno || ''))
buildCSV(devs)
openEmailDialog(data ?? {}, brandId, productId)
} else {
success('创建成功')
tab.value = 'batches'
reloadAll()
}
} catch (e: any) {
error(e?.message ?? '创建失败')
} finally {
submitting.value = false
}
}
// ========== CSV ==========
function buildCSV(devs: DeviceRow[]) {
const hasMac = devs.some((d) => d.devicemac)
const headers = hasMac ? ['License', 'Mac地址'] : ['License']
const rows = devs.map((d) => hasMac ? [d.code || '', 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) => [
fmtTime(n.ts), nameOfBrand(n.brandid), nameOfChannel(n.channelid), nameOfProduct(n.productid),
n.batchno || '', 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)
}
// ========== 邮件 ==========
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 + ')'
// 归属方案商 id 作为服务ID(CID)
const serviceid = brand && brand.providerid != null ? Number(brand.providerid) : null
let emails: string[] = []
if (brand && brand.contactmails) {
emails = brand.contactmails.split(',').map((e) => e.trim()).filter(Boolean)
}
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.endmac || '') + '\n' +
'序号区间:' + (createResult.serialstart ?? '') + ' - ' + (createResult.serialend ?? '') + '\n' +
'生产批次(BatchNo):' + (createResult.batchno || '') + '\n' +
'服务ID(CID): 0x' + (serviceid != null ? serviceid.toString(16).padStart(4, '0').toUpperCase() : 'N/A') + '\n' +
'品牌ID(BID): 0x' + (brandId != null ? Number(brandId).toString(16).padStart(4, '0').toUpperCase() : 'N/A') + '\n' +
'产品ID(PID): 0x' + (productId != null ? Number(productId).toString(16).padStart(4, '0').toUpperCase() : 'N/A') + '\n' +
'产品名称: ' + productName + '\n' +
'生产数量:' + (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 : ''
let emails: string[] = []
if (brand && brand.contactmails) {
emails = brand.contactmails.split(',').map((e) => e.trim()).filter(Boolean)
}
email.to = emails[0] || ''
email.cc = emails.length > 1 ? emails.slice(1).join(',') : ''
const today = new Date().toLocaleDateString()
email.subject = '【授权码清单】' + (productName || '设备') + ' - ' + today
email.content =
'尊敬的合作伙伴:\n\n' +
'附件为当前查询的授权码清单,共 ' + 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>