You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
982 lines
42 KiB
982 lines
42 KiB
<template>
|
|
<div class="space-y-6">
|
|
<!-- ========== 顶部公共筛选 ========== -->
|
|
<div class="bg-white rounded-xl shadow-sm border border-slate-100 p-4">
|
|
<div class="flex items-end gap-3 flex-wrap">
|
|
<div class="form-control">
|
|
<label class="label py-1"><span class="label-text text-xs">厂家</span></label>
|
|
<select v-model.number="filterFactory" class="select select-bordered select-sm" style="min-width:160px;" @change="onFactoryChange">
|
|
<option :value="0">全部</option>
|
|
<option v-for="f in factories" :key="f.id" :value="f.id">{{ f.factory || '' }}</option>
|
|
</select>
|
|
</div>
|
|
<div class="form-control">
|
|
<label class="label py-1"><span class="label-text text-xs">产品</span></label>
|
|
<select v-model.number="filterProduct" class="select select-bordered select-sm" style="min-width:200px;" @change="onProductChange">
|
|
<option :value="0">请选择产品</option>
|
|
<option v-for="p in filterProductOptions" :key="p.id" :value="p.id">{{ p.devicename || '' }}</option>
|
|
</select>
|
|
</div>
|
|
<div class="form-control">
|
|
<label class="label py-1"><span class="label-text text-xs">起始时间</span></label>
|
|
<input v-model="filterStart" type="date" class="input input-bordered input-sm" />
|
|
</div>
|
|
<div class="form-control">
|
|
<label class="label py-1"><span class="label-text text-xs">结束时间</span></label>
|
|
<input v-model="filterEnd" type="date" class="input input-bordered input-sm" />
|
|
</div>
|
|
<button class="btn btn-primary btn-sm text-white" @click="reloadAll">查询</button>
|
|
<button v-if="canCreate" class="btn btn-success btn-sm text-white" @click="showCreateForm">+ 生成授权码</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ========== 主视图 ========== -->
|
|
<div v-show="view === 'main'">
|
|
<!-- 统计卡 -->
|
|
<div class="grid grid-cols-3 gap-4">
|
|
<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 mt-5">
|
|
<div class="flex items-center justify-between px-5 py-3 border-b border-slate-100">
|
|
<h3 class="text-sm font-semibold text-slate-700">生产记录(按批次)</h3>
|
|
<div class="flex items-center gap-2">
|
|
<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:360px;">
|
|
<table class="table table-pin-rows text-sm">
|
|
<thead>
|
|
<tr>
|
|
<th class="w-40">生产时间</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="8" class="text-center text-slate-400 py-8">加载中…</td>
|
|
</tr>
|
|
<tr v-else-if="notesError">
|
|
<td colspan="8" class="text-center text-red-400 py-8">查询失败: {{ notesError }}</td>
|
|
</tr>
|
|
<tr v-else-if="!notes.length">
|
|
<td colspan="8" class="text-center text-slate-400 py-8">暂无数据</td>
|
|
</tr>
|
|
<tr v-for="(n, i) in notes" v-else :key="i" class="hover">
|
|
<td class="text-xs text-slate-500 whitespace-nowrap">{{ fmtTime(n.ts) }}</td>
|
|
<td class="text-xs">{{ nameOfFactory(n.factoryid) }}</td>
|
|
<td class="text-xs">{{ nameOfProduct(n.productid) }}</td>
|
|
<td class="text-xs">{{ n.probatch || 0 }}</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.devicetnum || 0 }}</td>
|
|
<td>
|
|
<div class="flex gap-1">
|
|
<button class="btn btn-ghost btn-xs text-indigo-500" @click="viewBatchCodes(n.productid, n.probatch)">查看授权码</button>
|
|
<button v-if="isSuperAdmin" class="btn btn-ghost btn-xs text-red-500" @click="openRevokeDialog(n.factoryid, n.productid, n.probatch, n.devicetnum || 0)">撤销</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 授权码记录 -->
|
|
<div class="bg-white rounded-xl shadow-sm border border-slate-100 mt-5">
|
|
<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">授权码记录</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="1">已激活</option>
|
|
<option value="-1">未使用</option>
|
|
<option value="2">已过期</option>
|
|
<option value="0">全部</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="usedtime">激活时间</option>
|
|
<option value="createtime">创建时间</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="number" class="input input-bordered input-xs" 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>批次</th><th>MAC</th>
|
|
<th>状态</th><th>封禁</th><th>用户UID</th><th>激活时间</th><th>操作</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-if="!filterProduct">
|
|
<td colspan="10" class="text-center text-slate-400 py-8">请先选择产品</td>
|
|
</tr>
|
|
<tr v-else-if="devicesError">
|
|
<td colspan="10" class="text-center text-red-400 py-8">查询失败: {{ devicesError }}</td>
|
|
</tr>
|
|
<tr v-else-if="!devices.length">
|
|
<td colspan="10" 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>{{ d.codetype === 0 ? '授权码' : '设备码' }}</td>
|
|
<td>{{ d.probatch }}</td>
|
|
<td class="font-mono text-xs">{{ d.devicemac || '' }}</td>
|
|
<td>
|
|
<span v-if="d.status === 0" class="badge badge-success badge-sm">未使用</span>
|
|
<span v-else-if="d.status === 1" class="badge badge-warning badge-sm">已使用</span>
|
|
<span v-else class="badge badge-error badge-sm">已过期</span>
|
|
</td>
|
|
<td>
|
|
<span v-if="d.disabled !== 0" class="badge badge-error badge-sm">已禁用</span>
|
|
<span v-else class="badge badge-ghost badge-sm">正常</span>
|
|
</td>
|
|
<td class="text-xs">{{ d.uid || '' }}</td>
|
|
<td class="text-xs text-slate-500">{{ d.usedtime ? fmtTime(d.usedtime) : '-' }}</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>
|
|
</div>
|
|
|
|
<!-- ========== 创建授权码视图 ========== -->
|
|
<div v-show="view === 'create'">
|
|
<div class="flex items-center gap-3 mb-5">
|
|
<button class="btn btn-ghost btn-sm" @click="showMain">← 返回</button>
|
|
<h2 class="text-lg font-semibold text-slate-800">生成授权码</h2>
|
|
</div>
|
|
<div class="bg-white rounded-xl shadow-sm border border-slate-100 p-6 max-w-2xl">
|
|
<form @submit.prevent="submitCreate">
|
|
<div class="form-control mb-4">
|
|
<label class="label"><span class="label-text font-medium">厂家 *</span></label>
|
|
<select v-model.number="cFactory" class="select select-bordered w-full" required @change="onCreateFactoryChange">
|
|
<option :value="0">请选择厂家</option>
|
|
<option v-for="f in factories" :key="f.id" :value="f.id">{{ (f.factory || '') + ' (' + hexId(f.id) + ')' }}</option>
|
|
</select>
|
|
</div>
|
|
<div class="form-control mb-4">
|
|
<label class="label"><span class="label-text font-medium">产品 *</span></label>
|
|
<select v-model.number="cProduct" class="select select-bordered w-full" required :disabled="!createProductOptions.length">
|
|
<option :value="0">{{ cFactory ? '请选择产品' : '请先选择厂家' }}</option>
|
|
<option v-for="p in createProductOptions" :key="p.id" :value="p.id">{{ (p.devicename || '') + ' (' + hexId(p.id) + ')' }}</option>
|
|
</select>
|
|
</div>
|
|
<div class="tabs tabs-boxed mb-4 bg-slate-100 inline-flex">
|
|
<a class="tab" :class="{ 'tab-active': activeTab === 'production' }" @click="switchTab('production')">生产</a>
|
|
<a class="tab" :class="{ 'tab-active': activeTab === 'import' }" @click="switchTab('import')">导入</a>
|
|
</div>
|
|
<div v-show="activeTab === 'production'">
|
|
<div class="form-control mb-4">
|
|
<label class="label"><span class="label-text font-medium">绑定MAC地址</span></label>
|
|
<input v-model="cBindmac" type="checkbox" class="toggle toggle-primary" />
|
|
</div>
|
|
<div class="form-control mb-4">
|
|
<label class="label"><span class="label-text font-medium">数量 *</span></label>
|
|
<input v-model.number="cNumber" type="number" class="input input-bordered w-full" min="1" max="10000" placeholder="请输入生产数量" :required="activeTab === 'production'" />
|
|
</div>
|
|
</div>
|
|
<div v-show="activeTab === '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-4">
|
|
<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 class="flex gap-3 mt-2">
|
|
<button type="button" class="btn btn-ghost" @click="showMain">取消</button>
|
|
<button type="submit" class="btn btn-primary text-white" :disabled="submitting">
|
|
<span v-if="submitting" class="loading loading-spinner loading-xs"></span>生成
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 邮件发送弹窗 -->
|
|
<dialog ref="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 ? nameOfFactory(revokeCtx.factoryid) : '' }}</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">{{ revokeCtx ? revokeCtx.probatch : '' }}</span></div>
|
|
<div><span class="text-slate-500">设备码数量:</span><span class="font-medium">{{ revokeCtx ? revokeCtx.devicetnum : '' }}</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">{{ revokeCtx ? revokeCtx.probatch : '' }}</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 Factory {
|
|
id: number
|
|
factory: string
|
|
factoryflag?: string
|
|
contactmails?: string
|
|
}
|
|
interface Product {
|
|
id: number
|
|
devicename: string
|
|
factoryid: number
|
|
}
|
|
interface Note {
|
|
ts: number
|
|
factoryid: number
|
|
productid: number
|
|
probatch: number
|
|
startmac?: string
|
|
endmac?: string
|
|
devicetnum?: number
|
|
}
|
|
interface DeviceRow {
|
|
code: string
|
|
codetype: number
|
|
probatch: number
|
|
devicemac?: string
|
|
status: number
|
|
disabled: number
|
|
uid?: string
|
|
usedtime?: number
|
|
factoryid?: number
|
|
productid?: number
|
|
}
|
|
interface CreateResult {
|
|
devices?: DeviceRow[]
|
|
probatch?: number
|
|
startmac?: string
|
|
endmac?: string
|
|
number?: number
|
|
}
|
|
interface RevokeCtx {
|
|
factoryid: number
|
|
productid: number
|
|
probatch: number
|
|
devicetnum: number
|
|
}
|
|
|
|
const { webApi } = useApi()
|
|
const { success, error, warn } = useToast()
|
|
const auth = useAuthStore()
|
|
|
|
const isSuperAdmin = computed(() => auth.identity === 1)
|
|
// 管理员及以上(identity 1=超管, 2=管理员)才显示"生成授权码"按钮(原版:identity > 2 隐藏)
|
|
const canCreate = computed(() => auth.identity > 0 && auth.identity <= 2)
|
|
|
|
// ========== 全局状态 ==========
|
|
const factories = ref<Factory[]>([])
|
|
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 activeTab = ref<'production' | 'import'>('production')
|
|
|
|
const view = ref<'main' | 'create'>('main')
|
|
|
|
// ── 顶部筛选 ──
|
|
const filterFactory = ref(0)
|
|
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('1')
|
|
const codeSort = ref('usedtime')
|
|
const codeBatch = ref('')
|
|
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 cFactory = ref(0)
|
|
const cProduct = ref(0)
|
|
const cBindmac = ref(false)
|
|
const cNumber = ref<number | null>(null)
|
|
const cMacaddresses = ref('')
|
|
const submitting = ref(false)
|
|
|
|
// ── 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 revokeConfirmOk = computed(() => !!revokeCtx.value && revokeConfirmInput.value.trim() === String(revokeCtx.value.probatch))
|
|
|
|
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 filterFactory.value
|
|
? products.value.filter((p) => p.factoryid === filterFactory.value)
|
|
: products.value
|
|
})
|
|
// 创建表单产品下拉:按所选厂家过滤
|
|
const createProductOptions = computed(() => {
|
|
return cFactory.value
|
|
? products.value.filter((p) => p.factoryid === cFactory.value)
|
|
: []
|
|
})
|
|
|
|
// ========== 工具函数 ==========
|
|
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 nameOfFactory(id: number): string {
|
|
const f = factories.value.find((x) => x.id === id)
|
|
return f ? f.factory : String(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() : ''
|
|
}
|
|
|
|
// ========== 初始化 ==========
|
|
async function init() {
|
|
try {
|
|
const [fRes, pRes] = await Promise.all([
|
|
webApi<{ factorys: Factory[] }>('getfactorys', {}),
|
|
webApi<{ products: Product[] }>('getproducts', {}),
|
|
])
|
|
factories.value = fRes?.factorys ?? []
|
|
allProducts.value = pRes?.products ?? []
|
|
} catch (e: any) {
|
|
error(e?.message ?? '加载基础数据失败')
|
|
factories.value = []
|
|
allProducts.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 showMain() {
|
|
view.value = 'main'
|
|
}
|
|
function showCreateForm() {
|
|
view.value = 'create'
|
|
// 重置创建表单
|
|
cFactory.value = 0
|
|
cProduct.value = 0
|
|
cBindmac.value = false
|
|
cNumber.value = null
|
|
cMacaddresses.value = ''
|
|
switchTab('production')
|
|
}
|
|
|
|
// ========== 筛选联动 ==========
|
|
function onFactoryChange() {
|
|
// 切换厂家后,若当前所选产品不在过滤后的列表,则清空
|
|
if (filterProduct.value && !filterProductOptions.value.find((p) => p.id === filterProduct.value)) {
|
|
filterProduct.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)
|
|
}
|
|
|
|
// ========== 生产记录加载 ==========
|
|
async function loadNotes() {
|
|
notesLoading.value = true
|
|
notesError.value = ''
|
|
const params: Record<string, unknown> = { limit: 500 }
|
|
if (filterFactory.value) params.factoryid = filterFactory.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 }>('getfactorydeliverynotes', 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 = '共 ' + 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(factoryid: number, productid: number, probatch: number, devicetnum: number) {
|
|
revokeCtx.value = { factoryid, productid, probatch, devicetnum }
|
|
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 }>('revokefactorydevics', {
|
|
factoryid: revokeCtx.value.factoryid,
|
|
productid: revokeCtx.value.productid,
|
|
probatch: revokeCtx.value.probatch,
|
|
})
|
|
closeRevokeDialog()
|
|
success('已撤销批次,删除 ' + (data?.deleted ?? 0) + ' 个设备码')
|
|
reloadAll()
|
|
} catch (e: any) {
|
|
revokeError.value = e?.message ?? '撤销失败'
|
|
} finally {
|
|
revokeSubmitting.value = false
|
|
}
|
|
}
|
|
|
|
// 点击批次跳转到授权码区
|
|
function viewBatchCodes(pid: number, probatch: number) {
|
|
filterProduct.value = pid
|
|
codeBatch.value = String(probatch)
|
|
codeStatus.value = '0'
|
|
devPage.value = 0
|
|
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 (filterFactory.value) params.factoryid = filterFactory.value
|
|
const batch = parseInt(codeBatch.value)
|
|
if (batch) params.probatch = 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 }>('getfactorydevics', 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 }>('disablefactorydevics', { productid: filterProduct.value, codes: list, disabled })
|
|
success(action + '成功(' + (data?.affected ?? 0) + ' 条)')
|
|
loadDevices(false)
|
|
} catch (e: any) {
|
|
error(e?.message ?? (action + '失败'))
|
|
}
|
|
}
|
|
|
|
// ========== 创建表单 ==========
|
|
function onCreateFactoryChange() {
|
|
cProduct.value = 0
|
|
}
|
|
|
|
function switchTab(tab: 'production' | 'import') {
|
|
activeTab.value = tab
|
|
}
|
|
|
|
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')
|
|
}
|
|
|
|
async function submitCreate() {
|
|
const factoryId = Number(cFactory.value)
|
|
const productId = Number(cProduct.value)
|
|
if (!factoryId || !productId) { warn('请选择厂家和产品'); return }
|
|
const params: Record<string, unknown> = { factoryid: factoryId, productid: productId }
|
|
if (activeTab.value === 'production') {
|
|
params.createtype = 1
|
|
params.number = Number(cNumber.value)
|
|
params.isbindmac = cBindmac.value
|
|
if (!params.number || (params.number as number) < 1) { warn('请输入有效数量'); return }
|
|
} else {
|
|
params.createtype = 2
|
|
const macStr = cMacaddresses.value.trim()
|
|
if (!macStr) { warn('请输入或上传 MAC 地址'); return }
|
|
params.macaddresses = macStr
|
|
}
|
|
submitting.value = true
|
|
try {
|
|
const data = await webApi<CreateResult>('createfactorydevics', params)
|
|
if (data && data.devices && data.devices.length > 0) {
|
|
success('成功生成 ' + data.devices.length + ' 个授权码')
|
|
buildCSV(data.devices)
|
|
openEmailDialog(data, factoryId, productId)
|
|
} else {
|
|
success('创建成功')
|
|
showMain()
|
|
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.factoryid || '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 }>('getfactorydevics', 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), nameOfFactory(n.factoryid), nameOfProduct(n.productid),
|
|
n.probatch || 0, n.startmac || '', n.endmac || '', n.devicetnum || 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, factoryId: number, productId: number) {
|
|
const factory = factories.value.find((f) => f.id === factoryId)
|
|
const product = allProducts.value.find((p) => p.id === productId)
|
|
const productName = product ? product.devicename : '未知产品(ID:' + productId + ')'
|
|
const factoryFlag = factory ? factory.factoryflag : null
|
|
const serviceid = factoryFlag != null ? parseInt(factoryFlag, 16) : null
|
|
|
|
let emails: string[] = []
|
|
if (factory && factory.contactmails) {
|
|
emails = factory.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.probatch || '') + '设备生产通知 - ' + today
|
|
email.content =
|
|
'尊敬的合作伙伴:\n\n' +
|
|
'我公司向贵司发送产品生产通知函。\n\n' +
|
|
'设备地址区间为:' + (createResult.startmac || '') + '-' + (createResult.endmac || '') + '\n' +
|
|
'生产批次:' + (createResult.probatch || '') + '\n' +
|
|
'服务ID(CID): 0x' + (serviceid != null ? serviceid.toString(16).padStart(4, '0').toUpperCase() : 'N/A') + '\n' +
|
|
'厂家ID(BID): 0x' + (factoryId != null ? Number(factoryId).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 factory = factories.value.find((f) => f.id === first.factoryid)
|
|
const product = allProducts.value.find((p) => p.id === first.productid)
|
|
const productName = product ? product.devicename : ''
|
|
|
|
let emails: string[] = []
|
|
if (factory && factory.contactmails) {
|
|
emails = factory.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()
|
|
showMain()
|
|
reloadAll()
|
|
} else {
|
|
error('发送失败: ' + (data.msg || '未知错误'))
|
|
}
|
|
} catch (err: any) {
|
|
error('发送失败: ' + (err?.message ?? ''))
|
|
} finally {
|
|
emailSending.value = false
|
|
}
|
|
}
|
|
|
|
onMounted(init)
|
|
</script>
|
|
|