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

<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">&larr; 返回</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>